/**
 * CartographerTree — the substrate of the cartographer-conformance project
 * (spec #1, docs/specs/CARTOGRAPHER-DOC-TREE-SCHEMA.md).
 *
 * A hierarchical, semantic doc-tree of the codebase: one node per directory or
 * significant file, each carrying a plain-language `summary` plus a git object-id
 * fingerprint (`codeHash`) captured when the summary was authored. Staleness is
 * DERIVED — never stored — by comparing each node's stored `codeHash` to the
 * code's current git oid, obtained from ONE batched `git ls-tree -r -t HEAD`
 * (plus one `rev-parse` for the root tree). Pure git/filesystem, zero LLM.
 *
 * This module is the schema + storage + staleness + read/update primitives only.
 * Bulk summary authoring (the freshness sweep) is spec #2; this layer just stores
 * and serves. Summaries are LLM-authored and semantically UNVERIFIED here —
 * downstream consumers must re-ground against the code before acting on one.
 */
import fs from 'node:fs';
import path from 'node:path';
import crypto from 'node:crypto';
import { SafeGitExecutor } from './SafeGitExecutor.js';
import { SafeFsExecutor } from './SafeFsExecutor.js';
import { DEFAULT_SKIP_DIRS } from './skipDirs.js';
// Value import is runtime-cycle-free: cartographerDetect imports from this module
// type-ONLY (erased at compile), so its runtime deps are git/fs/summary helpers only.
import { readSnapshot as readDetectSnapshot } from './cartographerDetect.js';
// v2 (fix instar#1069): index entries gained the optional staleSincePass /
// firstSeenAt / authorFailed fields so the off-event-loop detect reads zero node
// files. This bump is a DOCUMENTATION/observability signal only — loadIndex does
// NOT version-gate (a v1 index parses and works unchanged via field coalesce).
const SCHEMA_VERSION = 2;
const DEFAULT_MAX_DEPTH = 12;
const DEFAULT_LEAF_EXTENSIONS = ['.ts', '.js', '.mjs', '.cjs'];
const DEFAULT_EXCLUDE_SUBSTRINGS = ['.test.', '.spec.', '.d.ts'];
const GIT_OP = 'cartographer-tree';
export class CartographerTree {
    projectDir;
    cartoDir;
    nodesDir;
    indexPath;
    maxDepth;
    leafExtensions;
    excludeSubstrings;
    constructor(config) {
        this.projectDir = config.projectDir;
        this.cartoDir = path.join(config.stateDir, 'cartographer');
        this.nodesDir = path.join(this.cartoDir, 'nodes');
        this.indexPath = path.join(this.cartoDir, 'index.json');
        this.maxDepth = config.maxDepth ?? DEFAULT_MAX_DEPTH;
        this.leafExtensions = config.leafExtensions ?? DEFAULT_LEAF_EXTENSIONS;
        this.excludeSubstrings = config.excludeSubstrings ?? DEFAULT_EXCLUDE_SUBSTRINGS;
    }
    // ---- slug + atomic IO -----------------------------------------------------
    /** Collision-free, opaque node-file slug. The index is the authoritative reverse lookup. */
    slug(nodePath) {
        return crypto.createHash('sha256').update(nodePath, 'utf8').digest('hex').slice(0, 40);
    }
    nodeFilePath(nodePath) {
        return path.join(this.nodesDir, `${this.slug(nodePath)}.json`);
    }
    /** Atomic write — temp file + rename (atomic on POSIX) so a reader never sees a torn file. */
    atomicWrite(filePath, contents) {
        const tmp = `${filePath}.${process.pid}.tmp`;
        fs.writeFileSync(tmp, contents);
        fs.renameSync(tmp, filePath);
    }
    ensureDirs() {
        fs.mkdirSync(this.nodesDir, { recursive: true });
    }
    // ---- git plumbing (read-only, batched) ------------------------------------
    git(args) {
        try {
            return SafeGitExecutor.readSync(args, { cwd: this.projectDir, operation: GIT_OP });
        }
        catch {
            // @silent-fallback-ok — absent git/HEAD yields no oids; callers treat as unknown.
            return null;
        }
    }
    /**
     * ONE batched call: every tracked tree (dir) and blob (file) oid in HEAD, keyed
     * by repo-relative path. Plus the root tree oid (key ''). The make-or-break perf
     * primitive — no per-node `git rev-parse`.
     */
    currentOids() {
        const map = new Map();
        const rootTree = this.git(['rev-parse', 'HEAD^{tree}']);
        if (rootTree)
            map.set('', rootTree.trim());
        const out = this.git(['ls-tree', '-r', '-t', '-z', 'HEAD']);
        if (!out)
            return map;
        for (const entry of out.split('\0')) {
            if (!entry)
                continue;
            // format: "<mode> <type> <oid>\t<path>"
            const tab = entry.indexOf('\t');
            if (tab < 0)
                continue;
            const meta = entry.slice(0, tab).split(' ');
            const p = entry.slice(tab + 1);
            const oid = meta[2];
            if (oid && p)
                map.set(p, oid);
        }
        return map;
    }
    /** Single-path current oid (for setSummary capture) — `git rev-parse HEAD:<path>`. */
    currentOid(nodePath) {
        const rev = nodePath === '' ? 'HEAD^{tree}' : `HEAD:${nodePath}`;
        const out = this.git(['rev-parse', rev]);
        return out ? out.trim() : null;
    }
    headShort() {
        const out = this.git(['rev-parse', '--short', 'HEAD']);
        return out ? out.trim() : null;
    }
    // ---- scaffold (structural skeleton) ---------------------------------------
    isLeafFile(name) {
        if (this.excludeSubstrings.some((s) => name.includes(s)))
            return false;
        return this.leafExtensions.some((ext) => name.endsWith(ext));
    }
    /**
     * (Re)build the structural skeleton from a directory walk. Preserves an existing
     * node's authored summary/codeHash/codeRev (so staleness still reflects time of
     * authoring); only structure (kind/children) is refreshed. New nodes start
     * never-authored (codeHash null). Honors skip-set, maxDepth, and a symlink-loop
     * guard (visited real paths).
     */
    // ⚠ O(nodeCount) SYNCHRONOUS directory walk + per-node atomic writes — FORBIDDEN
    // on the server hot path (fix instar#1069). On a real tree this is the ~2-minute
    // freeze the incident observed. Run it OFF any request handler — the boot-path
    // chunked scaffold (AgentServer.start) is the only sanctioned caller; routes must
    // serve indexState:'not-built' instead. The Slice-5 lint forbids calling it from
    // routes.ts and CartographerSweepEngine.ts.
    scaffold() {
        this.ensureDirs();
        const visitedReal = new Set();
        const nodes = new Map();
        const relPosix = (abs) => path.relative(this.projectDir, abs).split(path.sep).join('/');
        const walk = (absDir, depth) => {
            // returns sorted child node paths of absDir
            let real;
            try {
                real = fs.realpathSync(absDir);
            }
            catch {
                // @silent-fallback-ok — an unresolvable path is simply not mapped; the
                // scaffold skips it and continues. No degraded state to report.
                return [];
            }
            if (visitedReal.has(real))
                return [];
            visitedReal.add(real);
            if (depth > this.maxDepth)
                return [];
            let entries;
            try {
                entries = fs.readdirSync(absDir, { withFileTypes: true });
            }
            catch {
                // @silent-fallback-ok — an unreadable directory contributes no child
                // nodes; the scaffold treats it as empty and moves on (read-only map).
                return [];
            }
            const children = [];
            for (const e of entries.sort((a, b) => a.name.localeCompare(b.name))) {
                if (e.isDirectory()) {
                    if (DEFAULT_SKIP_DIRS.has(e.name))
                        continue;
                    const childAbs = path.join(absDir, e.name);
                    const childRel = relPosix(childAbs);
                    const grandChildren = walk(childAbs, depth + 1);
                    this.upsertNode(nodes, childRel, 'dir', grandChildren);
                    children.push(childRel);
                }
                else if (e.isFile() && this.isLeafFile(e.name)) {
                    const childAbs = path.join(absDir, e.name);
                    const childRel = relPosix(childAbs);
                    this.upsertNode(nodes, childRel, 'file', []);
                    children.push(childRel);
                }
            }
            return children;
        };
        const rootChildren = walk(this.projectDir, 0);
        this.upsertNode(nodes, '', 'dir', rootChildren);
        // Prune stale node files (paths that disappeared) and write the surviving set.
        this.rewriteAll(nodes);
        return this.buildIndex(nodes);
    }
    /**
     * fix instar#1069 — the BOOT-PATH chunked scaffold. Same result as scaffold()
     * but the directory walk AND the per-node writes YIELD the event loop every
     * `chunkNodes` operations (via the injected `onYield`, e.g. setImmediate), so a
     * huge tree's structural build never freezes /health (the ~2-minute freeze the
     * incident observed). The ONLY sanctioned caller is AgentServer/boot — never a
     * request handler. P19: `shouldAbort` lets the caller bound total time; on abort
     * the partial state is discarded by NOT writing the index (atomic; a half-built
     * index is never read — routes serve indexState:'not-built' until a full run).
     */
    async scaffoldChunked(opts) {
        this.ensureDirs();
        const chunk = Math.max(1, opts.chunkNodes);
        let ops = 0;
        const tick = async () => {
            ops += 1;
            if (ops % chunk === 0) {
                await opts.onYield();
                if (opts.shouldAbort?.())
                    throw new Error('cartographer-scaffold-aborted');
            }
        };
        const relPosix = (abs) => path.relative(this.projectDir, abs).split(path.sep).join('/');
        // ── Pass 1: iterative pre-order walk collecting structure (yields per chunk). ──
        const visitedReal = new Set();
        const childrenByDir = new Map(); // dir relPath → child relPaths
        const kindByPath = new Map();
        kindByPath.set('', 'dir');
        childrenByDir.set('', []);
        const stack = [{ abs: this.projectDir, rel: '', depth: 0 }];
        while (stack.length > 0) {
            const { abs, rel, depth } = stack.pop();
            let real;
            try {
                real = fs.realpathSync(abs);
            }
            catch {
                continue;
            }
            if (visitedReal.has(real))
                continue;
            visitedReal.add(real);
            if (depth > this.maxDepth)
                continue;
            let entries;
            try {
                entries = fs.readdirSync(abs, { withFileTypes: true });
            }
            catch {
                continue;
            }
            const kids = [];
            for (const e of entries.sort((a, b) => a.name.localeCompare(b.name))) {
                if (e.isDirectory()) {
                    if (DEFAULT_SKIP_DIRS.has(e.name))
                        continue;
                    const childAbs = path.join(abs, e.name);
                    const childRel = relPosix(childAbs);
                    kindByPath.set(childRel, 'dir');
                    childrenByDir.set(childRel, []);
                    kids.push(childRel);
                    stack.push({ abs: childAbs, rel: childRel, depth: depth + 1 });
                }
                else if (e.isFile() && this.isLeafFile(e.name)) {
                    const childRel = relPosix(path.join(abs, e.name));
                    kindByPath.set(childRel, 'file');
                    kids.push(childRel);
                }
                await tick();
            }
            childrenByDir.set(rel, kids);
        }
        // ── Pass 2: write each node file (preserving authored fields), yielding. ──
        const nodes = new Map();
        const keep = new Set();
        for (const [p, kind] of kindByPath) {
            const existing = this.readNodeFile(p);
            const node = {
                path: p, kind, summary: existing?.summary ?? '',
                summaryUpdatedAt: existing?.summaryUpdatedAt ?? null,
                codeHash: existing?.codeHash ?? null, codeRev: existing?.codeRev ?? null,
                children: childrenByDir.get(p) ?? [],
                builtinKind: 'cartographer-node', provenance: existing?.provenance, dirtyAtAuthor: existing?.dirtyAtAuthor,
                firstSeenAt: existing?.firstSeenAt ?? this.nowIso(),
                lastAuthoredBy: existing?.lastAuthoredBy, confidence: existing?.confidence,
                childDigestHash: existing?.childDigestHash, staleSincePass: existing?.staleSincePass,
                consecutiveAuthorFailures: existing?.consecutiveAuthorFailures, authorFailed: existing?.authorFailed,
            };
            nodes.set(p, node);
            keep.add(`${this.slug(p)}.json`);
            this.atomicWrite(this.nodeFilePath(p), JSON.stringify(node, null, 2));
            await tick();
        }
        // Prune node files for paths that disappeared.
        if (fs.existsSync(this.nodesDir)) {
            for (const f of fs.readdirSync(this.nodesDir)) {
                if (f.endsWith('.json') && !keep.has(f)) {
                    try {
                        SafeFsExecutor.safeUnlinkSync(path.join(this.nodesDir, f), { operation: 'cartographer-prune-stale-node' });
                    }
                    catch { /* best-effort prune */ }
                }
                await tick();
            }
        }
        // ── Pass 3: STREAM the index to a tmp file incrementally (bounded per-yield),
        //    then atomic-rename. NOT a single 67MB JSON.stringify — that one synchronous
        //    serialize would block the event loop on a real tree (the boot half of the
        //    #1069 invariant). A partial write lives only in the tmp file and is never
        //    renamed into place, so an aborted run leaves no readable index. ──
        const tmp = `${this.indexPath}.${process.pid}.${crypto.randomBytes(6).toString('hex')}.tmp`;
        const fd = fs.openSync(tmp, 'w');
        try {
            fs.writeSync(fd, `{\n  "schemaVersion": ${SCHEMA_VERSION},\n  "root": "",\n  "generatedAt": ${JSON.stringify(this.nowIso())},\n  "nodes": {`);
            let first = true;
            let buf = '';
            let bufCount = 0;
            for (const node of nodes.values()) {
                const entry = {
                    kind: node.kind,
                    summaryUpdatedAt: node.summaryUpdatedAt,
                    codeHash: node.codeHash,
                    hasChildren: node.children.length > 0,
                    ...(node.staleSincePass ? { staleSincePass: node.staleSincePass } : {}),
                    ...(node.firstSeenAt ? { firstSeenAt: node.firstSeenAt } : {}),
                    ...(node.authorFailed ? { authorFailed: true } : {}),
                };
                buf += `${first ? '\n' : ',\n'}    ${JSON.stringify(node.path)}: ${JSON.stringify(entry)}`;
                first = false;
                bufCount += 1;
                if (bufCount >= chunk) {
                    fs.writeSync(fd, buf);
                    buf = '';
                    bufCount = 0;
                    await opts.onYield();
                    if (opts.shouldAbort?.())
                        throw new Error('cartographer-scaffold-aborted');
                }
            }
            if (buf)
                fs.writeSync(fd, buf);
            fs.writeSync(fd, '\n  }\n}\n');
            fs.fsyncSync(fd);
        }
        finally {
            fs.closeSync(fd);
        }
        fs.renameSync(tmp, this.indexPath);
        return { nodeCount: nodes.size };
    }
    /** Merge structure into a node, preserving any existing authored fields. */
    upsertNode(nodes, nodePath, kind, children) {
        const existing = this.readNodeFile(nodePath);
        nodes.set(nodePath, {
            path: nodePath,
            kind,
            summary: existing?.summary ?? '',
            summaryUpdatedAt: existing?.summaryUpdatedAt ?? null,
            codeHash: existing?.codeHash ?? null,
            codeRev: existing?.codeRev ?? null,
            children,
            builtinKind: 'cartographer-node',
            provenance: existing?.provenance,
            dirtyAtAuthor: existing?.dirtyAtAuthor,
            // Spec #2 fields are preserved across a structure-only rescaffold. A node
            // first seen now anchors its grace clock; existing nodes keep theirs.
            firstSeenAt: existing?.firstSeenAt ?? this.nowIso(),
            lastAuthoredBy: existing?.lastAuthoredBy,
            confidence: existing?.confidence,
            childDigestHash: existing?.childDigestHash,
            staleSincePass: existing?.staleSincePass,
            consecutiveAuthorFailures: existing?.consecutiveAuthorFailures,
            authorFailed: existing?.authorFailed,
        });
    }
    rewriteAll(nodes) {
        // Remove node files for paths no longer present.
        if (fs.existsSync(this.nodesDir)) {
            const keep = new Set();
            for (const p of nodes.keys())
                keep.add(`${this.slug(p)}.json`);
            for (const f of fs.readdirSync(this.nodesDir)) {
                if (f.endsWith('.json') && !keep.has(f)) {
                    try {
                        SafeFsExecutor.safeUnlinkSync(path.join(this.nodesDir, f), {
                            operation: 'cartographer-prune-stale-node',
                        });
                    }
                    catch { /* best-effort prune */ }
                }
            }
        }
        for (const node of nodes.values()) {
            this.atomicWrite(this.nodeFilePath(node.path), JSON.stringify(node, null, 2));
        }
    }
    buildIndex(nodes) {
        const index = {
            schemaVersion: SCHEMA_VERSION,
            root: '',
            generatedAt: this.nowIso(),
            nodes: {},
        };
        for (const node of nodes.values()) {
            index.nodes[node.path] = {
                kind: node.kind,
                summaryUpdatedAt: node.summaryUpdatedAt,
                codeHash: node.codeHash,
                hasChildren: node.children.length > 0,
                // fix instar#1069: mirror the accumulated-history fields onto the index so
                // the off-event-loop detect needs no node-file reads. Omit-when-absent so a
                // never-deferred / never-failed node keeps the entry minimal.
                ...(node.staleSincePass ? { staleSincePass: node.staleSincePass } : {}),
                ...(node.firstSeenAt ? { firstSeenAt: node.firstSeenAt } : {}),
                ...(node.authorFailed ? { authorFailed: true } : {}),
            };
        }
        this.atomicWrite(this.indexPath, JSON.stringify(index, null, 2));
        return index;
    }
    nowIso() {
        // Wall clock is acceptable here (authoring timestamp); not on a resume-critical path.
        return new Date().toISOString();
    }
    // ---- read / update --------------------------------------------------------
    readNodeFile(nodePath) {
        const fp = this.nodeFilePath(nodePath);
        if (!fs.existsSync(fp))
            return null;
        try {
            return JSON.parse(fs.readFileSync(fp, 'utf8'));
        }
        catch {
            // @silent-fallback-ok — a corrupt/partial node file reads as "absent"; the
            // next scaffold/sweep re-authors it. Treating it as null is the recovery.
            return null;
        }
    }
    /**
     * Load + parse the whole index.
     *
     * ⚠ O(67MB) SYNCHRONOUS JSON.parse — FORBIDDEN on the server hot path (fix
     * instar#1069). On a real tree this blocks the event loop for seconds. The
     * off-event-loop detect parses the index inside a worker; routes serve the
     * snapshot. The Slice-5 lint forbids calling it from routes.ts and
     * CartographerSweepEngine.ts. NO version-gate here: a v1 index (without the
     * staleSincePass/firstSeenAt/authorFailed entry fields) parses unchanged.
     */
    loadIndex() {
        if (!fs.existsSync(this.indexPath))
            return null;
        try {
            return JSON.parse(fs.readFileSync(this.indexPath, 'utf8'));
        }
        catch {
            // @silent-fallback-ok — a corrupt index reads as "no index yet"; callers
            // then scaffold() a fresh one. Treating it as null is the recovery path.
            return null;
        }
    }
    getNode(nodePath) {
        return this.readNodeFile(nodePath);
    }
    /**
     * fix instar#1069 — the LINT-CLEAN, BYTE-BOUNDED index loader for the
     * `/cartographer/tree` request path. It stats the file FIRST and refuses
     * ('too-large') above `maxBytes` BEFORE attempting the parse, so a request can
     * never trigger the 67MB sync parse/serialize that froze the event loop. Small
     * trees parse + serve as before. (Distinct method name so the Slice-5 callsite
     * lint — which bans the unbounded `.loadIndex(` — does not flag this.)
     */
    loadIndexBounded(maxBytes) {
        if (!fs.existsSync(this.indexPath))
            return { state: 'not-built' };
        try {
            const size = fs.statSync(this.indexPath).size;
            if (size > maxBytes)
                return { state: 'too-large' };
            const index = JSON.parse(fs.readFileSync(this.indexPath, 'utf8'));
            return { state: 'ok', index, nodeCount: Object.keys(index.nodes).length };
        }
        catch {
            // @silent-fallback-ok — a corrupt/missing index reads as not-built; the route
            // serves indexState:'not-built' and the boot scaffold rebuilds it.
            return { state: 'not-built' };
        }
    }
    getChildren(nodePath) {
        const node = this.readNodeFile(nodePath);
        if (!node)
            return [];
        return node.children
            .map((c) => this.readNodeFile(c))
            .filter((n) => n !== null);
    }
    /**
     * Author/refresh a node's summary, capturing the current code oid so future
     * staleness reflects time-of-authoring. Writes the node file first, then patches
     * the index (so a reader never sees an index entry ahead of its node file).
     */
    setSummary(nodePath, summary, opts = {}) {
        this.ensureDirs();
        const existing = this.readNodeFile(nodePath);
        if (!existing) {
            throw new Error(`CartographerTree.setSummary: no node at path "${nodePath}" — run scaffold() first`);
        }
        const codeHash = opts.codeHash ?? this.currentOid(nodePath);
        const node = {
            ...existing,
            summary,
            summaryUpdatedAt: this.nowIso(),
            codeHash,
            codeRev: opts.codeRev ?? this.headShort(),
            provenance: opts.provenance ?? existing.provenance,
            // Spec #2: a successful author clears the failure/defer state and records
            // who authored + confidence. childDigestHash is set for dir authors.
            lastAuthoredBy: opts.meta?.lastAuthoredBy ?? existing.lastAuthoredBy,
            confidence: opts.meta?.confidence ?? existing.confidence,
            childDigestHash: opts.meta?.childDigestHash !== undefined ? opts.meta.childDigestHash : existing.childDigestHash,
            staleSincePass: 0,
            consecutiveAuthorFailures: 0,
            authorFailed: false,
        };
        this.atomicWrite(this.nodeFilePath(nodePath), JSON.stringify(node, null, 2));
        // fix instar#1069: when deferred, the caller batches the index update off-thread.
        if (opts.deferIndexWrite)
            return node;
        // Patch the index entry (node file already on disk).
        const index = this.loadIndex();
        if (index && index.nodes[nodePath]) {
            index.nodes[nodePath].summaryUpdatedAt = node.summaryUpdatedAt;
            index.nodes[nodePath].codeHash = node.codeHash;
            // A successful author clears the defer/quarantine state on the index entry too.
            index.nodes[nodePath].staleSincePass = 0;
            index.nodes[nodePath].authorFailed = false;
            if (node.firstSeenAt)
                index.nodes[nodePath].firstSeenAt = node.firstSeenAt;
            this.atomicWrite(this.indexPath, JSON.stringify(index, null, 2));
        }
        return node;
    }
    /**
     * Spec #2: patch a node's freshness METADATA without re-authoring its summary.
     * Used by the sweep for failure counters / quarantine / defer-pass bookkeeping,
     * and for a fingerprint-only refresh (the dir re-author amplification guard:
     * bump `codeHash` to the current oid so a comment-only deep edit goes `fresh`
     * with NO LLM call). Never touches `summary` or `summaryUpdatedAt` unless
     * `codeHash` is supplied (a fingerprint refresh) — then the index entry is
     * patched too so staleness derives correctly. No-op (returns null) if absent.
     */
    patchNodeMeta(nodePath, partial, 
    /** fix instar#1069 — when true, write ONLY the node file; the caller batches the index delta off-thread. */
    opts = {}) {
        const existing = this.readNodeFile(nodePath);
        if (!existing)
            return null;
        const codeHashChanged = Object.prototype.hasOwnProperty.call(partial, 'codeHash');
        const node = {
            ...existing,
            codeHash: codeHashChanged ? (partial.codeHash ?? null) : existing.codeHash,
            codeRev: partial.codeRev !== undefined ? partial.codeRev : existing.codeRev,
            childDigestHash: partial.childDigestHash !== undefined ? partial.childDigestHash : existing.childDigestHash,
            lastAuthoredBy: partial.lastAuthoredBy ?? existing.lastAuthoredBy,
            confidence: partial.confidence ?? existing.confidence,
            staleSincePass: partial.staleSincePass ?? existing.staleSincePass,
            consecutiveAuthorFailures: partial.consecutiveAuthorFailures ?? existing.consecutiveAuthorFailures,
            authorFailed: partial.authorFailed ?? existing.authorFailed,
            provenance: partial.provenance ?? existing.provenance,
        };
        this.atomicWrite(this.nodeFilePath(nodePath), JSON.stringify(node, null, 2));
        if (opts.deferIndexWrite)
            return node;
        // The index entry now also mirrors staleSincePass/authorFailed, so a non-deferred
        // patch that changes any index-mirrored field re-syncs the entry in one write.
        const staleChanged = partial.staleSincePass !== undefined;
        const failedChanged = partial.authorFailed !== undefined;
        if (codeHashChanged || staleChanged || failedChanged) {
            const index = this.loadIndex();
            if (index && index.nodes[nodePath]) {
                if (codeHashChanged)
                    index.nodes[nodePath].codeHash = node.codeHash;
                if (staleChanged)
                    index.nodes[nodePath].staleSincePass = node.staleSincePass;
                if (failedChanged)
                    index.nodes[nodePath].authorFailed = node.authorFailed;
                this.atomicWrite(this.indexPath, JSON.stringify(index, null, 2));
            }
        }
        return node;
    }
    /**
     * Spec #2: read the COMMITTED content of a leaf node's covered file, bounded to
     * `maxBytes` (head-truncated). Reads `git show HEAD:<path>` — never the dirty
     * working tree — so an uncommitted edit (possibly containing just-typed secrets)
     * is never read, and a long-lived uncommitted edit never causes re-author churn.
     * Returns null when the path is absent from HEAD or git is unavailable.
     */
    committedContent(nodePath, maxBytes) {
        if (nodePath === '')
            return null; // root is a dir, not a file
        const out = this.git(['show', `HEAD:${nodePath}`]);
        if (out == null)
            return null;
        if (Buffer.byteLength(out, 'utf8') <= maxBytes)
            return { content: out, truncated: false };
        // Head-truncate on a byte budget without splitting a multibyte char.
        const buf = Buffer.from(out, 'utf8').subarray(0, maxBytes);
        return { content: buf.toString('utf8'), truncated: true };
    }
    /** Public: the current short HEAD sha (compare-and-skip / provenance). */
    currentHeadShort() {
        return this.headShort();
    }
    /** Public: the current root tree oid (the CI ratchet short-circuit key). */
    rootTreeOid() {
        return this.currentOid('');
    }
    /** Public: the current git oid for a single node path (compare-and-skip on HEAD). */
    currentNodeOid(nodePath) {
        return this.currentOid(nodePath);
    }
    /**
     * Public: ONE batched current-oid map (path → oid) for the whole HEAD tree. The
     * spec #5 navigator derives per-node `fresh` from a SINGLE read of this map
     * (compared to each node's stored `codeHash`) instead of a per-node staleness
     * call — keeping the "cheap on a wide frontier" guarantee.
     */
    currentOidMap() {
        return this.currentOids();
    }
    // ---- fix instar#1069: off-event-loop detect accessors --------------------
    // The sweep engine builds its worker/sync DetectInput from these instead of
    // calling the O(67MB) methods below on the main thread.
    /** Absolute path to index.json (the worker reads/writes it off-thread). */
    indexFilePath() { return this.indexPath; }
    /** The repo root the detect git plumbing runs against. */
    projectDirPath() { return this.projectDir; }
    /** Absolute path to the per-host snapshot every /cartographer/* read route serves from. */
    snapshotPath() { return path.join(this.cartoDir, 'snapshot.json'); }
    /** Read the per-host snapshot (null when absent/corrupt). Cheap — no index parse. */
    readSnapshot() {
        return readDetectSnapshot(this.snapshotPath());
    }
    /**
     * Derive staleness for the whole tree from ONE batched git read.
     *
     * ⚠ O(nodeCount)/67MB SYNCHRONOUS — FORBIDDEN on the server hot path (fix
     * instar#1069). It loads the full 67MB index and a full git ls-tree on the
     * calling thread; on a real tree that starves the event loop for tens of
     * seconds. Use the off-event-loop detect (cartographerDetect.runDetect) on the
     * sweep path and serve routes from the snapshot. Retained only for small trees,
     * the pure module, and tests; the Slice-5 lint forbids calling it from
     * routes.ts and CartographerSweepEngine.ts.
     */
    staleNodes() {
        const index = this.loadIndex();
        if (!index)
            return [];
        const current = this.currentOids();
        const out = [];
        for (const [nodePath, entry] of Object.entries(index.nodes)) {
            const status = this.deriveStatus(entry.codeHash, current.get(nodePath));
            if (status !== 'fresh') {
                out.push({ path: nodePath, status, reason: this.statusReason(status) });
            }
        }
        return out;
    }
    /** Derive staleness for a single node. */
    computeStaleness(nodePath) {
        const node = this.readNodeFile(nodePath);
        if (!node)
            return 'path-gone';
        return this.deriveStatus(node.codeHash, this.currentOid(nodePath) ?? undefined);
    }
    deriveStatus(storedHash, currentOid) {
        if (storedHash == null)
            return 'never-authored';
        if (currentOid == null)
            return 'path-gone';
        return currentOid === storedHash ? 'fresh' : 'stale';
    }
    statusReason(status) {
        switch (status) {
            case 'never-authored': return 'no summary authored yet';
            case 'path-gone': return 'covered path no longer in HEAD';
            case 'stale': return 'code changed since the summary was authored';
            case 'dirty': return 'uncommitted changes under this path';
            default: return '';
        }
    }
    // ---- observability --------------------------------------------------------
    health() {
        const index = this.loadIndex();
        if (!index) {
            return { nodeCount: 0, authoredCount: 0, neverAuthoredCount: 0, staleCount: 0, generatedAt: null };
        }
        const entries = Object.values(index.nodes);
        const authoredCount = entries.filter((e) => e.codeHash != null).length;
        return {
            nodeCount: entries.length,
            authoredCount,
            neverAuthoredCount: entries.length - authoredCount,
            staleCount: this.staleNodes().filter((s) => s.status === 'stale').length,
            generatedAt: index.generatedAt,
        };
    }
    /**
     * Spec #2 (doc-freshness) richer health: the freshness ratio over AUTHORABLE
     * nodes (excludes `path-gone`), plus the two ABSOLUTE backlog counts the CI
     * ratchet also gates on so a green ratio over a small authored set cannot hide
     * a growing un-authored backlog (Goodhart guard). `freshRatio` is `fresh /
     * authorable` with 1 when there are no authorable nodes. A node is
     * never-authored "past grace" when it has no summary AND was first seen longer
     * ago than `graceMs`.
     */
    // ⚠ O(nodeCount)/67MB SYNCHRONOUS — loads the full index AND reads one node file
    // per authorable node — FORBIDDEN on the server hot path (fix instar#1069). The
    // off-event-loop detect computes this aggregate from the index alone (zero node
    // files) and the /health route serves it from the snapshot. The Slice-5 lint
    // forbids calling it from routes.ts and CartographerSweepEngine.ts.
    freshnessHealth(opts = { graceMs: 0 }) {
        const index = this.loadIndex();
        const empty = {
            nodeCount: 0, authorableCount: 0, freshCount: 0, staleCount: 0,
            neverAuthoredCount: 0, neverAuthoredWithinGrace: 0, neverAuthoredPastGrace: 0,
            authorFailedCount: 0, freshRatio: 1, generatedAt: null,
        };
        if (!index)
            return empty;
        const nowMs = opts.now ?? Date.parse(this.nowIso());
        const current = this.currentOids();
        let authorable = 0, fresh = 0, stale = 0, never = 0, neverWithin = 0, neverPast = 0, authorFailed = 0;
        for (const [nodePath, entry] of Object.entries(index.nodes)) {
            const status = this.deriveStatus(entry.codeHash, current.get(nodePath));
            if (status === 'path-gone')
                continue; // not authorable
            authorable += 1;
            // One node-file read per path: used for both the grace clock AND the
            // orthogonal author-failed quarantine flag (a quarantined node that never
            // successfully authored is still `never-authored`, so the read can't be
            // skipped for that status).
            const node = this.readNodeFile(nodePath);
            if (node?.authorFailed)
                authorFailed += 1;
            if (status === 'fresh')
                fresh += 1;
            else if (status === 'stale')
                stale += 1;
            else if (status === 'never-authored') {
                never += 1;
                const firstSeen = node?.firstSeenAt ? Date.parse(node.firstSeenAt) : nowMs;
                if (Number.isFinite(firstSeen) && nowMs - firstSeen > opts.graceMs)
                    neverPast += 1;
                else
                    neverWithin += 1;
            }
        }
        // The ratio denominator EXCLUDES never-authored-within-grace (a freshly
        // scaffolded node has a grace period to get authored before it counts as debt).
        // denominator = fresh + stale + never-authored-past-grace.
        const ratioDenom = fresh + stale + neverPast;
        return {
            nodeCount: Object.keys(index.nodes).length,
            authorableCount: authorable,
            freshCount: fresh,
            staleCount: stale,
            neverAuthoredCount: never,
            neverAuthoredWithinGrace: neverWithin,
            neverAuthoredPastGrace: neverPast,
            authorFailedCount: authorFailed,
            freshRatio: ratioDenom === 0 ? 1 : fresh / ratioDenom,
            generatedAt: index.generatedAt,
        };
    }
}
//# sourceMappingURL=CartographerTree.js.map