{"version":3,"file":"audit.d.ts","sourceRoot":"","sources":["../../../src/core/learn/audit.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA+BG;AAKH,wEAAwE;AACxE,MAAM,WAAW,cAAc;IAC9B,uCAAuC;IACvC,IAAI,EAAE,MAAM,CAAC;IACb,2BAA2B;IAC3B,IAAI,EAAE,MAAM,CAAC;IACb,iEAA+D;IAC/D,QAAQ,EAAE,MAAM,CAAC;IACjB,4CAA4C;IAC5C,QAAQ,EAAE,MAAM,CAAC;IACjB,IAAI,EAAE,MAAM,GAAG,QAAQ,CAAC;IACxB,4EAA4E;IAC5E,MAAM,EAAE,MAAM,CAAC;CACf;AAED,0FAA0F;AAC1F,MAAM,WAAW,UAAU;IAC1B,kFAAkF;IAClF,WAAW,EAAE,MAAM,CAAC;IACpB,0EAA0E;IAC1E,OAAO,EAAE,MAAM,CAAC;IAChB,8CAA8C;IAC9C,QAAQ,EAAE,MAAM,CAAC;IACjB,wEAAwE;IACxE,SAAS,EAAE,MAAM,CAAC;IAClB,2EAA2E;IAC3E,cAAc,EAAE,MAAM,CAAC;CACvB;AAED,MAAM,WAAW,WAAW;IAC3B,wDAAwD;IACxD,KAAK,EAAE,KAAK,CAAC;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;IAC/C,wEAAwE;IACxE,YAAY,EAAE,MAAM,EAAE,CAAC;IACvB,0DAA0D;IAC1D,OAAO,EAAE,MAAM,CAAC;IAChB,OAAO,EAAE,UAAU,CAAC;IACpB,KAAK,EAAE,cAAc,EAAE,CAAC;IACxB,kEAAkE;IAClE,KAAK,EAAE,MAAM,EAAE,CAAC;CAChB;AAgDD;;;;;;;GAOG;AACH,wBAAgB,eAAe,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,EAAE,CAqBtD;AA0BD,KAAK,cAAc,GAChB;IAAE,IAAI,EAAE,MAAM,CAAA;CAAE,GAChB;IAAE,IAAI,EAAE,QAAQ,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAE,GAClC;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,MAAM,UAAU,CAAA;CAAE,CAAC;AAE9C;;;;;;GAMG;AACH,wBAAgB,gBAAgB,CAAC,KAAK,EAAE,MAAM,GAAG,cAAc,CAwB9D;AA0BD;;;;;;;;;GASG;AACH,wBAAgB,eAAe,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,CAQnD;AASD,MAAM,WAAW,YAAY;IAC5B,GAAG,EAAE,MAAM,CAAC;IACZ,+DAA+D;IAC/D,KAAK,EAAE,KAAK,CAAC;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,MAAM,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;CAChD;AAED;;;;;;;;;;;GAWG;AACH,wBAAgB,iBAAiB,CAAC,OAAO,EAAE,YAAY,GAAG,WAAW,CA6EpE;AAED,2DAA2D;AAC3D,wBAAgB,WAAW,CAAC,MAAM,EAAE,WAAW,GAAG,MAAM,CAUvD","sourcesContent":["/**\n * The subtractive half of `/learn`: which lines in a context file describe\n * things that no longer exist?\n *\n * The mining pipeline can only ever propose additions. Nothing in it moves the\n * always-loaded token surface down, so a context file accumulates: a rule\n * naming a deleted workflow, a command that was removed, a file that moved two\n * refactors ago. Those lines cost tokens on every request forever and are worse\n * than useless, because the agent believes them.\n *\n * This is deliberately deterministic — no model call, no cache, no state. It\n * reads the context files already in force, pulls out the referents they name\n * in backticks, and asks the filesystem. That makes it instant and free, which\n * is what lets it be the half you run most often.\n *\n * Precision is bought with exclusions rather than cleverness, because a noisy\n * audit is one nobody reads. Three rules do most of the work:\n *\n * - **Only path-like referents with a separator.** A bare `auth.json` could be\n *   anywhere or nowhere; `docs/providers.md` is a claim about this repo.\n * - **Resolve against every package root, not just the repo root.** A monorepo\n *   names `src/cli/args.ts` relative to the package being discussed, and\n *   checking only the repo root reports the entire contributing guide as stale.\n * - **Skip lines that assert absence.** \"these are all gone\", \"e.g.\n *   `bedrock-utils.ts`\", \"create `foo.ts`\" legitimately name files that do not\n *   exist. Deciding this in general is a judgement call; a short vocabulary of\n *   assertive forms catches the cases that occur in practice.\n *\n * What is left is a short list where a wrong entry costs one glance and a right\n * one costs a line of always-loaded context. That asymmetry is the reason the\n * remaining false positives are acceptable and silent misses are not.\n */\n\nimport { type Dirent, existsSync, readdirSync, readFileSync } from \"node:fs\";\nimport { join, resolve, sep } from \"node:path\";\n\n/** One referent that did not resolve, with the line that claimed it. */\nexport interface StaleReference {\n\t/** Context file the claim lives in. */\n\tfile: string;\n\t/** 1-based line number. */\n\tline: number;\n\t/** The line, trimmed — what the reader would delete or fix. */\n\tlineText: string;\n\t/** The referent that could not be found. */\n\treferent: string;\n\tkind: \"path\" | \"script\";\n\t/** Rough token cost of the line, so the value of deleting it is visible. */\n\ttokens: number;\n}\n\n/** Why a referent was not checked. Reported as counts so the audit's reach is visible. */\nexport interface AuditSkips {\n\t/** Contains a placeholder or a glob, e.g. an angle-bracket stand-in or a star. */\n\tplaceholder: number;\n\t/** Home-relative or absolute: a runtime location, not a repo artifact. */\n\truntime: number;\n\t/** A URL, or a git ref like `origin/main`. */\n\texternal: number;\n\t/** No path separator, so the claim is not about a specific location. */\n\tambiguous: number;\n\t/** The line asserts the referent is absent, optional, or to be created. */\n\tassertsAbsence: number;\n}\n\nexport interface AuditReport {\n\t/** Context files audited, with their recurring cost. */\n\tfiles: Array<{ path: string; tokens: number }>;\n\t/** Context files skipped because they live outside the working tree. */\n\tskippedFiles: string[];\n\t/** Referents actually resolved against the filesystem. */\n\tchecked: number;\n\tskipped: AuditSkips;\n\tstale: StaleReference[];\n\t/** Directories referents were resolved against, nearest first. */\n\troots: string[];\n}\n\n/** Directories never descended into when discovering package roots. */\nconst IGNORED_DIRS = new Set([\"node_modules\", \".git\", \"dist\", \"build\", \"coverage\", \"out\", \".next\", \".turbo\"]);\n\n/** How deep to look for package roots. Deep enough for a monorepo, shallow enough to stay instant. */\nconst MAX_ROOT_DEPTH = 3;\n\n/** Extensions that make a token a file reference even without an obvious path shape. */\nconst FILE_EXTENSIONS =\n\t/\\.(md|markdown|ts|tsx|js|jsx|mjs|cjs|json|jsonc|ya?ml|toml|sh|bash|zsh|py|rs|go|lock|txt|css|html)$/i;\n\n/**\n * Forms that legitimately name something absent.\n *\n * Every one of these was a false positive on a real context file before it was\n * excluded. `removed`/`gone` describe a deletion; `e.g.`/`for example` name a\n * pattern rather than a file; `create`/`scaffold` describe an artifact the\n * reader is being told to write.\n *\n * The list is deliberately short. Broad prohibitions (\"never edit X\", \"do not\n * run Y\") name things that exist and are exactly the claims worth checking, so\n * matching on those words would trade the audit's whole purpose for a little\n * precision.\n */\nconst ABSENCE_MARKERS = [\n\t\"removed\",\n\t\"deleted\",\n\t\" gone\",\n\t\"no longer\",\n\t\"used to\",\n\t\"if present\",\n\t\"if it exists\",\n\t\"optional\",\n\t\"e.g.\",\n\t\"for example\",\n\t\"create \",\n\t\"scaffold\",\n];\n\n/** Referents that look like a git ref rather than a path. */\nconst GIT_REF_PREFIXES = [\"origin/\", \"upstream/\", \"refs/\", \"HEAD\"];\n\n/** Rough token estimate, matching the convention used elsewhere for context files. */\nfunction estimateTokens(text: string): number {\n\treturn Math.round(Buffer.byteLength(text, \"utf-8\") / 4);\n}\n\n/**\n * Directories a relative referent may be resolved against.\n *\n * The repo root alone is not enough: a monorepo's contributing notes name\n * `src/cli/args.ts` meaning \"inside the package under discussion\", and resolving\n * that only from the root reports every such line as stale. Every directory\n * holding a `package.json` is therefore a root, nearest-shallowest first.\n */\nexport function resolutionRoots(base: string): string[] {\n\tconst roots: string[] = [resolve(base)];\n\n\tconst walk = (dir: string, depth: number): void => {\n\t\tif (depth > MAX_ROOT_DEPTH) return;\n\t\tlet entries: Dirent[];\n\t\ttry {\n\t\t\tentries = readdirSync(dir, { withFileTypes: true });\n\t\t} catch {\n\t\t\treturn;\n\t\t}\n\t\tfor (const entry of entries) {\n\t\t\tif (!entry.isDirectory() || IGNORED_DIRS.has(entry.name)) continue;\n\t\t\tconst child = join(dir, entry.name);\n\t\t\tif (existsSync(join(child, \"package.json\"))) roots.push(child);\n\t\t\twalk(child, depth + 1);\n\t\t}\n\t};\n\twalk(resolve(base), 1);\n\n\treturn roots;\n}\n\n/** Every backticked span in a line, in order. */\nfunction backtickedSpans(line: string): string[] {\n\tconst out: string[] = [];\n\tconst pattern = /`([^`\\n]+)`/g;\n\tlet match = pattern.exec(line);\n\twhile (match) {\n\t\tif (match[1]) out.push(match[1]);\n\t\tmatch = pattern.exec(line);\n\t}\n\treturn out;\n}\n\n/** True when the line reads as a statement about something absent, optional, or yet to be written. */\nfunction assertsAbsence(line: string): boolean {\n\tconst lower = line.toLowerCase();\n\treturn ABSENCE_MARKERS.some((marker) => lower.includes(marker));\n}\n\n/** The script name a `bun run x` / `npm run x` reference names, if it is one. */\nfunction scriptReference(token: string): string | undefined {\n\tconst match = /^(?:bun|npm|pnpm|yarn)\\s+run\\s+([A-Za-z0-9:_-]+)$/.exec(token.trim());\n\treturn match?.[1];\n}\n\ntype Classification =\n\t| { kind: \"path\" }\n\t| { kind: \"script\"; script: string }\n\t| { kind: \"skip\"; reason: keyof AuditSkips };\n\n/**\n * Decide whether a backticked token is a checkable claim about this repo.\n *\n * Ordering matters: the skip reasons are reported as counts, and a token that\n * matches several should be attributed to the most specific one, so a\n * placeholder is a placeholder rather than \"ambiguous\".\n */\nexport function classifyReferent(token: string): Classification {\n\tconst value = token.trim();\n\tconst script = scriptReference(value);\n\tif (script) return { kind: \"script\", script };\n\n\tif (/[<>*{}$|]/.test(value)) return { kind: \"skip\", reason: \"placeholder\" };\n\tif (/^https?:\\/\\//i.test(value)) return { kind: \"skip\", reason: \"external\" };\n\tif (GIT_REF_PREFIXES.some((prefix) => value.startsWith(prefix))) return { kind: \"skip\", reason: \"external\" };\n\t// A shell command, a prose fragment, or a flag list — not a path.\n\tif (/\\s/.test(value)) return { kind: \"skip\", reason: \"ambiguous\" };\n\tif (value.startsWith(\"~\") || value.startsWith(sep) || /^[A-Za-z]:[\\\\/]/.test(value)) {\n\t\treturn { kind: \"skip\", reason: \"runtime\" };\n\t}\n\tif (value === \".\" || value === \"..\") return { kind: \"skip\", reason: \"ambiguous\" };\n\t// The load-bearing precision rule: without a separator the token names a\n\t// filename that could be anywhere, and \"anywhere\" is not a claim worth\n\t// contradicting. A bare `stream.test.ts` in a monorepo is not stale, it is\n\t// under-specified.\n\tif (!value.includes(\"/\")) return { kind: \"skip\", reason: \"ambiguous\" };\n\t// Past the separator rule, something that is neither a known file type nor an\n\t// obvious directory is more likely prose than a path.\n\tif (!FILE_EXTENSIONS.test(value) && !value.endsWith(\"/\")) return { kind: \"skip\", reason: \"ambiguous\" };\n\n\treturn { kind: \"path\" };\n}\n\n/** True when the referent resolves against any root. Directories count. */\nfunction pathResolves(referent: string, roots: string[]): boolean {\n\tconst relative = referent.replace(/\\/+$/, \"\");\n\tif (!relative) return false;\n\treturn roots.some((root) => existsSync(join(root, relative)));\n}\n\n/** Script names declared by any `package.json` at any resolution root. */\nfunction declaredScripts(roots: string[]): Set<string> {\n\tconst names = new Set<string>();\n\tfor (const root of roots) {\n\t\tconst manifest = join(root, \"package.json\");\n\t\tif (!existsSync(manifest)) continue;\n\t\ttry {\n\t\t\tconst parsed = JSON.parse(readFileSync(manifest, \"utf-8\")) as { scripts?: Record<string, unknown> };\n\t\t\tfor (const name of Object.keys(parsed.scripts ?? {})) names.add(name);\n\t\t} catch {\n\t\t\t// A malformed manifest is not this command's problem; treat it as\n\t\t\t// declaring nothing rather than failing the audit.\n\t\t}\n\t}\n\treturn names;\n}\n\n/**\n * The project a referent is resolved against: the nearest ancestor holding a\n * `.git`, or `cwd` when there is none.\n *\n * Not `cwd` itself. Context files are collected by walking up from `cwd`, so in\n * a monorepo the repo's `AGENTS.md` sits *above* the package you are working\n * in — and running from a package root is the normal case, not the exception.\n * Anchoring on `cwd` meant the file with all the claims in it was declared \"not\n * in this working tree\" and skipped, so the audit passed by checking nothing.\n */\nexport function findProjectRoot(cwd: string): string {\n\tlet dir = resolve(cwd);\n\twhile (true) {\n\t\tif (existsSync(join(dir, \".git\"))) return dir;\n\t\tconst parent = resolve(dir, \"..\");\n\t\tif (parent === dir) return resolve(cwd);\n\t\tdir = parent;\n\t}\n}\n\n/** True when the path is inside the project, so its claims are about this repo. */\nfunction insideTree(path: string, root: string): boolean {\n\tconst base = resolve(root);\n\tconst target = resolve(path);\n\treturn target === base || target.startsWith(base + sep);\n}\n\nexport interface AuditOptions {\n\tcwd: string;\n\t/** Context files in force, as loaded for the system prompt. */\n\tfiles: Array<{ path: string; tokens?: number }>;\n}\n\n/**\n * Check every referent named by the repo-scope context files.\n *\n * Files outside the project are listed but not audited: a rule in\n * `~/.agents/AGENTS.md` naming `src/index.ts` is a claim about whichever repo\n * it was written for, and resolving it here would report another project's\n * rules as broken.\n *\n * File contents are re-read from disk rather than taken from the loader, which\n * truncates oversized files for the prompt — auditing the truncation would\n * silently stop checking exactly the files most likely to have gone stale.\n */\nexport function auditContextFiles(options: AuditOptions): AuditReport {\n\t// Rooted at the project, not at `cwd`: a path in the repo's context file is\n\t// written relative to the repo, and is being read from wherever you happen to\n\t// be working.\n\tconst projectRoot = findProjectRoot(options.cwd);\n\tconst roots = resolutionRoots(projectRoot);\n\tconst scripts = declaredScripts(roots);\n\n\tconst report: AuditReport = {\n\t\tfiles: [],\n\t\tskippedFiles: [],\n\t\tchecked: 0,\n\t\tskipped: { placeholder: 0, runtime: 0, external: 0, ambiguous: 0, assertsAbsence: 0 },\n\t\tstale: [],\n\t\troots,\n\t};\n\n\tfor (const file of options.files) {\n\t\tif (!insideTree(file.path, projectRoot)) {\n\t\t\treport.skippedFiles.push(file.path);\n\t\t\tcontinue;\n\t\t}\n\n\t\tlet content: string;\n\t\ttry {\n\t\t\tcontent = readFileSync(file.path, \"utf-8\");\n\t\t} catch {\n\t\t\treport.skippedFiles.push(file.path);\n\t\t\tcontinue;\n\t\t}\n\t\treport.files.push({ path: file.path, tokens: file.tokens ?? estimateTokens(content) });\n\n\t\tconst lines = content.split(\"\\n\");\n\t\tlet inFence = false;\n\t\tfor (const [index, raw] of lines.entries()) {\n\t\t\t// A fenced block is example code, not a claim about the repo. Its\n\t\t\t// contents are also where most of a context file's plausible-looking\n\t\t\t// paths live, so auditing it is almost pure noise.\n\t\t\tif (raw.trimStart().startsWith(\"```\")) {\n\t\t\t\tinFence = !inFence;\n\t\t\t\tcontinue;\n\t\t\t}\n\t\t\tif (inFence) continue;\n\n\t\t\tconst spans = backtickedSpans(raw);\n\t\t\tif (spans.length === 0) continue;\n\n\t\t\tconst absence = assertsAbsence(raw);\n\t\t\tfor (const span of spans) {\n\t\t\t\tconst classification = classifyReferent(span);\n\t\t\t\tif (classification.kind === \"skip\") {\n\t\t\t\t\treport.skipped[classification.reason]++;\n\t\t\t\t\tcontinue;\n\t\t\t\t}\n\t\t\t\tif (absence) {\n\t\t\t\t\treport.skipped.assertsAbsence++;\n\t\t\t\t\tcontinue;\n\t\t\t\t}\n\n\t\t\t\treport.checked++;\n\t\t\t\tconst resolved =\n\t\t\t\t\tclassification.kind === \"script\" ? scripts.has(classification.script) : pathResolves(span, roots);\n\t\t\t\tif (resolved) continue;\n\n\t\t\t\treport.stale.push({\n\t\t\t\t\tfile: file.path,\n\t\t\t\t\tline: index + 1,\n\t\t\t\t\tlineText: raw.trim(),\n\t\t\t\t\treferent: span,\n\t\t\t\t\tkind: classification.kind,\n\t\t\t\t\ttokens: estimateTokens(raw),\n\t\t\t\t});\n\t\t\t}\n\t\t}\n\t}\n\n\treturn report;\n}\n\n/** Total recurring cost of the lines the audit flagged. */\nexport function staleTokens(report: AuditReport): number {\n\tconst seen = new Set<string>();\n\tlet total = 0;\n\tfor (const item of report.stale) {\n\t\tconst key = `${item.file}:${item.line}`;\n\t\tif (seen.has(key)) continue;\n\t\tseen.add(key);\n\t\ttotal += item.tokens;\n\t}\n\treturn total;\n}\n"]}