// storage/status-store/self-healing.ts — SPEC-753: Self-healing status.json
//
// Validates, quarantines, and rebuilds status.json from spec.md frontmatters.
// The real-world corruption pattern (2026-04-24): two writes within 0.4s produced
// a file where a valid JSON document is followed by trailing content from a second
// partial write — i.e. JSON.parse fails at non-zero offset.
import { readFile, writeFile, rename, mkdir, readdir } from 'node:fs/promises';
import { join, dirname } from 'node:path';
import { PLANU_VERSION } from '../../config/version.js';
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function isNodeError(err) {
    return err instanceof Error && 'code' in err;
}
/**
 * Classify the corruption type from raw content + JSON.parse error message.
 * Priority: duplicate-content > truncated > syntax.
 */
function classifyCorruption(raw, parseError) {
    // Detect "trailing content after valid JSON" — different node versions use different messages
    if (parseError.includes('Extra data') ||
        parseError.includes('extra data') ||
        parseError.includes('non-whitespace character after JSON') ||
        parseError.includes('Unexpected non-whitespace')) {
        return 'duplicate-content';
    }
    // Empty or very short file → truncated
    if (raw.trim().length < 10) {
        return 'truncated';
    }
    // "Unexpected end" or "Unterminated" → could be truncated or syntax
    if (parseError.includes('Unexpected end') ||
        parseError.includes('unexpected end') ||
        parseError.includes('Unterminated')) {
        // If file ends abruptly (last char is not } or ]) → truncated
        const trimmed = raw.trim();
        const lastChar = trimmed[trimmed.length - 1];
        if (lastChar !== '}' && lastChar !== ']' && lastChar !== '"') {
            return 'truncated';
        }
        return 'syntax';
    }
    return 'syntax';
}
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
/**
 * Validate a status.json file. Returns `ok: true` when the file is valid JSON
 * with the expected shape. Returns `ok: false` with corruptKind on corruption.
 */
export async function validateStatusJson(filePath) {
    let raw;
    try {
        raw = await readFile(filePath, 'utf-8');
    }
    catch (err) {
        if (isNodeError(err) && err.code === 'ENOENT') {
            return { ok: false, error: 'ENOENT', corruptKind: 'syntax' };
        }
        return {
            ok: false,
            error: `Cannot read file: ${err instanceof Error ? err.message : String(err)}`,
            corruptKind: 'syntax',
        };
    }
    if (raw.trim().length === 0) {
        return { ok: false, error: 'Empty file', corruptKind: 'truncated' };
    }
    let parsed;
    try {
        parsed = JSON.parse(raw);
    }
    catch (err) {
        const msg = err instanceof Error ? err.message : String(err);
        return {
            ok: false,
            error: msg,
            corruptKind: classifyCorruption(raw, msg),
        };
    }
    // Structural check: must be an object with expected keys
    if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
        return { ok: false, error: 'Not a JSON object', corruptKind: 'syntax' };
    }
    return { ok: true };
}
/**
 * Move a corrupt status.json to planu/.broken/status-<ts>.json.
 * Returns the quarantine path and timestamp.
 */
export async function quarantineCorruptStatus(statusPath, projectPath) {
    const ts = new Date().toISOString().replace(/[:.]/g, '-');
    const brokenDir = join(projectPath, 'planu', '.broken');
    const quarantinePath = join(brokenDir, `status-${ts}.json`);
    await mkdir(brokenDir, { recursive: true });
    try {
        await rename(statusPath, quarantinePath);
    }
    catch {
        // Fallback: copy + delete if rename fails (e.g. cross-device)
        const raw = await readFile(statusPath, 'utf-8');
        await writeFile(quarantinePath, raw, 'utf-8');
        await writeFile(statusPath, '', 'utf-8');
    }
    return { quarantinedAt: new Date().toISOString(), quarantinePath };
}
import { isNativeActive, fastScanSpecs } from '../../engine/core-bridge.js';
/**
 * Rebuild a fresh status.json by reading all spec.md frontmatters.
 * Returns the new ProjectStatus object (not written to disk — caller decides).
 */
export async function rebuildStatusFromFrontmatters(params) {
    const { projectPath } = params;
    const byStatus = {};
    const byType = {};
    let totalSpecs = 0;
    if (isNativeActive()) {
        const briefs = fastScanSpecs(projectPath);
        if (briefs) {
            for (const b of briefs) {
                totalSpecs++;
                byStatus[b.status] = (byStatus[b.status] ?? 0) + 1;
                byType[b.specType] = (byType[b.specType] ?? 0) + 1;
            }
            return {
                updatedAt: new Date().toISOString(),
                totalSpecs,
                byStatus,
                byType,
                recentChanges: [],
                version: PLANU_VERSION,
            };
        }
    }
    const specsDir = join(projectPath, 'planu', 'specs');
    const byStatusResult = {};
    const byTypeResult = {};
    let totalSpecsResult = 0;
    let specDirs = [];
    try {
        specDirs = await readdir(specsDir);
    }
    catch {
        // No specs dir — return empty state
    }
    for (const dir of specDirs) {
        const specPath = join(specsDir, dir, 'spec.md');
        try {
            const content = await readFile(specPath, 'utf-8');
            const fm = parseFrontmatter(content);
            if (fm) {
                totalSpecsResult++;
                const status = typeof fm.status === 'string' ? fm.status : 'draft';
                const type = typeof fm.type === 'string' ? fm.type : 'feature';
                byStatusResult[status] = (byStatusResult[status] ?? 0) + 1;
                byTypeResult[type] = (byTypeResult[type] ?? 0) + 1;
            }
        }
        catch {
            // Skip unreadable spec.md files
        }
    }
    return {
        updatedAt: new Date().toISOString(),
        totalSpecs: totalSpecsResult,
        byStatus: byStatusResult,
        byType: byTypeResult,
        recentChanges: [],
        version: PLANU_VERSION,
    };
}
/**
 * Write a fresh status.json atomically.
 * Use this after `rebuildStatusFromFrontmatters` to persist the rebuild.
 */
export async function writeRebuiltStatus(statusPath, status) {
    await mkdir(dirname(statusPath), { recursive: true });
    await writeFile(statusPath, JSON.stringify(status, null, 2), 'utf-8');
}
// ---------------------------------------------------------------------------
// Internal: minimal YAML frontmatter parser
// ---------------------------------------------------------------------------
function parseFrontmatter(content) {
    const match = /^---\r?\n([\s\S]*?)\r?\n---/.exec(content);
    if (!match?.[1]) {
        return null;
    }
    const block = match[1];
    const result = {};
    for (const line of block.split('\n')) {
        const colonIdx = line.indexOf(':');
        if (colonIdx === -1) {
            continue;
        }
        const key = line.slice(0, colonIdx).trim();
        const val = line
            .slice(colonIdx + 1)
            .trim()
            .replace(/^["']|["']$/g, '');
        if (key === 'id') {
            result.id = val;
        }
        else if (key === 'status') {
            result.status = val;
        }
        else if (key === 'type') {
            result.type = val;
        }
        else if (key === 'updated') {
            result.updated = val;
        }
    }
    return result;
}
//# sourceMappingURL=self-healing.js.map