@kage-core/kage-graph-mcp 3.2.0 → 3.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/check.js ADDED
@@ -0,0 +1,612 @@
1
+ "use strict";
2
+ // kage check — deterministic drift checker for agent-context files.
3
+ //
4
+ // Verifies mechanically-checkable claims in the files agents are told to trust
5
+ // (CLAUDE.md, AGENTS.md, .cursor/rules, README, docs/) against the code they
6
+ // describe. Three buckets, all counts of reproducible checks — never estimates:
7
+ // confirmed claim checked and false (two-sided evidence: doc line + ground truth)
8
+ // verified claim checked and true
9
+ // unverifiable claim of a supported type we could not check (with the reason)
10
+ //
11
+ // Precision-first: a false positive costs more trust than a missed finding, so
12
+ // every extractor errs toward the unverifiable bucket when context suggests the
13
+ // claim might be hypothetical (placeholders, "e.g.", build artifacts).
14
+ //
15
+ // Deliberately independent of the code graph and memory store: `kage check`
16
+ // must run on any repo in seconds and leave nothing behind.
17
+ Object.defineProperty(exports, "__esModule", { value: true });
18
+ exports.driftCheck = driftCheck;
19
+ exports.writeCheckBaseline = writeCheckBaseline;
20
+ exports.formatCheckReport = formatCheckReport;
21
+ exports.checkReportMarkdown = checkReportMarkdown;
22
+ exports.kageCheckWorkflowYaml = kageCheckWorkflowYaml;
23
+ const node_child_process_1 = require("node:child_process");
24
+ const node_fs_1 = require("node:fs");
25
+ const node_path_1 = require("node:path");
26
+ const CHECK_PATH_EXTENSIONS = "ts|tsx|js|jsx|mjs|cjs|json|md|yml|yaml|toml|py|rb|go|rs|java|kt|sh|bash|css|scss|html|sql|proto|graphql|c|h|cpp|hpp|cs|txt";
27
+ const SHELL_FENCES = new Set(["bash", "sh", "shell", "zsh", "console", "terminal"]);
28
+ // Missing paths under build-output dirs are usually "run the built artifact"
29
+ // instructions, not lies about the tree.
30
+ const BUILD_DIR_RE = /^(?:\.\/)?(dist|build|out|output|target|coverage|\.next|node_modules)\//;
31
+ // Planning/spec/design/release/research docs describe intended, past, or
32
+ // investigated states — not assertions about the current tree.
33
+ const ASPIRATIONAL_DOC_RE = /(^|\/)(plans?|proposals?|rfcs?|roadmaps?|archive[sd]?|changelog|specs?|designs?|releases?|research)([/._-]|\.md$)|(plan|design|proposal|spec|research)[^/]*\.md$/i;
34
+ // "read X (or find the checklist if missing)" — the doc hedges its own claim.
35
+ const HEDGED_RE = /if (it'?s )?(missing|present|absent|available)|may not exist|if it exists|when present/i;
36
+ // "ENV_VAR/some/path" cites a location through a variable, not a repo path.
37
+ const ENV_PREFIX_RE = /^[A-Z][A-Z0-9_]{2,}$/;
38
+ // Well-known runtime/user-data roots: absence from the tree proves nothing.
39
+ const RUNTIME_ROOT_RE = /^(appdata|userdata|home|tmp|temp|var|localappdata)$/i;
40
+ // Lines that read as illustration, not assertion. (No trailing \b: "e.g." and
41
+ // "example:" end in non-word chars, where \b before a space can never match.)
42
+ const HYPOTHETICAL_RE = /\b(e\.g\.|for example|for instance|such as|example:|hypothetical|imagine|suppose|would look like|might look like)/i;
43
+ const PLACEHOLDER_SEGMENT_RE = /^(foo|bar|baz|qux|example|sample|my|your|some|new|path|to)([-_.].*)?$/i;
44
+ const CAMEL_PLACEHOLDER_RE = /^(My|Your|Some|Example)[A-Z]/;
45
+ // `make <word>` in prose is usually English ("make sure"); these never count.
46
+ const MAKE_STOPWORDS = new Set(["sure", "it", "a", "an", "the", "this", "that", "your", "any", "all", "changes", "use", "them", "sense", "and", "or"]);
47
+ const CLI_SUB_STOPWORDS = new Set(["help", "version"]);
48
+ function safeRead(path) {
49
+ try {
50
+ const stats = (0, node_fs_1.statSync)(path);
51
+ if (!stats.isFile() || stats.size > 2 * 1024 * 1024)
52
+ return null;
53
+ return (0, node_fs_1.readFileSync)(path, "utf8");
54
+ }
55
+ catch {
56
+ return null;
57
+ }
58
+ }
59
+ function git(projectDir, args) {
60
+ try {
61
+ return (0, node_child_process_1.execFileSync)("git", args, { cwd: projectDir, encoding: "utf8", timeout: 15000, stdio: ["ignore", "pipe", "ignore"] }).trim();
62
+ }
63
+ catch {
64
+ return null;
65
+ }
66
+ }
67
+ function gitIgnored(projectDir, candidate) {
68
+ return git(projectDir, ["check-ignore", "-q", candidate]) !== null;
69
+ }
70
+ // --- discovery -------------------------------------------------------------
71
+ const CONTEXT_FILE_CANDIDATES = [
72
+ "CLAUDE.md", "AGENTS.md", "GEMINI.md", ".cursorrules", ".windsurfrules",
73
+ ".github/copilot-instructions.md", "README.md", "readme.md", "Readme.md", "CONTRIBUTING.md",
74
+ ];
75
+ function discoverContextFiles(projectDir) {
76
+ const files = [];
77
+ const seen = new Set();
78
+ const push = (rel) => {
79
+ const lower = rel.toLowerCase();
80
+ if (seen.has(lower))
81
+ return;
82
+ seen.add(lower);
83
+ files.push(rel);
84
+ };
85
+ for (const candidate of CONTEXT_FILE_CANDIDATES) {
86
+ if ((0, node_fs_1.existsSync)((0, node_path_1.join)(projectDir, candidate)))
87
+ push(candidate);
88
+ }
89
+ const rulesDir = (0, node_path_1.join)(projectDir, ".cursor", "rules");
90
+ try {
91
+ for (const name of (0, node_fs_1.readdirSync)(rulesDir).filter((entry) => /\.(md|mdc)$/.test(entry)).sort().slice(0, 30)) {
92
+ push((0, node_path_1.join)(".cursor", "rules", name));
93
+ }
94
+ }
95
+ catch { /* no cursor rules */ }
96
+ // docs/**/*.md, bounded: depth 3, 200 files total.
97
+ const walk = (relDir, depth) => {
98
+ if (depth > 3 || files.length >= 200)
99
+ return;
100
+ let entries;
101
+ try {
102
+ entries = (0, node_fs_1.readdirSync)((0, node_path_1.join)(projectDir, relDir)).sort();
103
+ }
104
+ catch {
105
+ return;
106
+ }
107
+ for (const entry of entries) {
108
+ if (files.length >= 200)
109
+ return;
110
+ const rel = (0, node_path_1.join)(relDir, entry);
111
+ if (ASPIRATIONAL_DOC_RE.test(rel))
112
+ continue; // plans/RFCs/archives describe futures or pasts
113
+ let stats;
114
+ try {
115
+ stats = (0, node_fs_1.statSync)((0, node_path_1.join)(projectDir, rel));
116
+ }
117
+ catch {
118
+ continue;
119
+ }
120
+ if (stats.isDirectory())
121
+ walk(rel, depth + 1);
122
+ else if (entry.endsWith(".md"))
123
+ push(rel);
124
+ }
125
+ };
126
+ walk("docs", 1);
127
+ return files;
128
+ }
129
+ function collectDocLines(projectDir, docs) {
130
+ const lines = [];
131
+ for (const doc of docs) {
132
+ const text = safeRead((0, node_path_1.join)(projectDir, doc));
133
+ if (!text)
134
+ continue;
135
+ let fence = null;
136
+ text.split(/\r?\n/).forEach((line, index) => {
137
+ const fenceMatch = line.match(/^\s*(?:```|~~~)\s*([A-Za-z0-9_-]*)/);
138
+ if (fenceMatch) {
139
+ fence = fence === null ? (fenceMatch[1] || "text").toLowerCase() : null;
140
+ return;
141
+ }
142
+ lines.push({ doc, line: index + 1, text: line, fence });
143
+ });
144
+ }
145
+ return lines;
146
+ }
147
+ function parseScripts(text) {
148
+ if (!text)
149
+ return { scripts: {}, bin: [], workspaces: false, parsed: false };
150
+ try {
151
+ const parsed = JSON.parse(text);
152
+ const bin = typeof parsed.bin === "string" ? [parsed.name ?? ""].filter(Boolean) : Object.keys(parsed.bin ?? {});
153
+ return { scripts: parsed.scripts ?? {}, bin, workspaces: Boolean(parsed.workspaces), parsed: true };
154
+ }
155
+ catch {
156
+ return { scripts: {}, bin: [], workspaces: false, parsed: false };
157
+ }
158
+ }
159
+ function loadGroundTruth(projectDir) {
160
+ const packageJsonText = safeRead((0, node_path_1.join)(projectDir, "package.json"));
161
+ const root = parseScripts(packageJsonText);
162
+ // Monorepos run scripts from nested packages; a root miss is not a lie if a
163
+ // workspace defines the script. Bounded walk, node_modules excluded.
164
+ const nestedScriptFiles = [];
165
+ const repoFilesRaw = git(projectDir, ["ls-files", "--", "*package.json"]);
166
+ if (repoFilesRaw) {
167
+ for (const rel of repoFilesRaw.split("\n").filter((p) => p && p !== "package.json" && !p.includes("node_modules/")).slice(0, 50)) {
168
+ const nested = parseScripts(safeRead((0, node_path_1.join)(projectDir, rel)));
169
+ if (nested.parsed && Object.keys(nested.scripts).length)
170
+ nestedScriptFiles.push({ path: rel, scripts: nested.scripts });
171
+ }
172
+ }
173
+ const makefileText = safeRead((0, node_path_1.join)(projectDir, "Makefile")) ?? safeRead((0, node_path_1.join)(projectDir, "makefile"));
174
+ let makeTargets = null;
175
+ let makefileHasInclude = false;
176
+ if (makefileText) {
177
+ makeTargets = new Set();
178
+ for (const raw of makefileText.split(/\r?\n/)) {
179
+ if (/^\s*-?include\s/.test(raw))
180
+ makefileHasInclude = true;
181
+ const target = raw.match(/^([A-Za-z0-9_./-]+)\s*:(?!=)/);
182
+ if (target)
183
+ makeTargets.add(target[1]);
184
+ const phony = raw.match(/^\.PHONY\s*:\s*(.+)$/);
185
+ if (phony)
186
+ for (const name of phony[1].split(/\s+/))
187
+ if (name)
188
+ makeTargets.add(name);
189
+ }
190
+ }
191
+ // CLI dispatch strings live in cli/bin/commands-looking files. Cheap bounded
192
+ // walk — no code graph, so `check` can run cold on a foreign repo.
193
+ const cliSourcePaths = [];
194
+ if (root.bin.length) {
195
+ const roots = ["", "src", "lib", "mcp", "packages"];
196
+ const looksCli = (name) => /^(cli|bin|commands?|main)[^/]*\.(ts|js|mjs|cjs|tsx|py|go|rs)$/i.test(name);
197
+ for (const base of roots) {
198
+ let entries;
199
+ try {
200
+ entries = (0, node_fs_1.readdirSync)((0, node_path_1.join)(projectDir, base || "."));
201
+ }
202
+ catch {
203
+ continue;
204
+ }
205
+ for (const entry of entries) {
206
+ const rel = base ? (0, node_path_1.join)(base, entry) : entry;
207
+ try {
208
+ const stats = (0, node_fs_1.statSync)((0, node_path_1.join)(projectDir, rel));
209
+ if (stats.isFile() && looksCli(entry))
210
+ cliSourcePaths.push(rel);
211
+ else if (stats.isDirectory() && /^(cli|bin|commands)$/i.test(entry)) {
212
+ for (const sub of (0, node_fs_1.readdirSync)((0, node_path_1.join)(projectDir, rel)).slice(0, 30)) {
213
+ const subRel = (0, node_path_1.join)(rel, sub);
214
+ try {
215
+ if ((0, node_fs_1.statSync)((0, node_path_1.join)(projectDir, subRel)).isFile())
216
+ cliSourcePaths.push(subRel);
217
+ }
218
+ catch { /* skip */ }
219
+ }
220
+ }
221
+ }
222
+ catch { /* skip */ }
223
+ if (cliSourcePaths.length >= 30)
224
+ break;
225
+ }
226
+ if (cliSourcePaths.length >= 30)
227
+ break;
228
+ }
229
+ }
230
+ const cliSourceText = cliSourcePaths.slice(0, 30).map((rel) => safeRead((0, node_path_1.join)(projectDir, rel)) ?? "").join("\n");
231
+ const lsFiles = git(projectDir, ["ls-files"]);
232
+ return {
233
+ scripts: root.scripts,
234
+ nestedScriptFiles,
235
+ packageJsonPresent: packageJsonText !== null,
236
+ packageJsonParsed: root.parsed,
237
+ binNames: root.bin,
238
+ makeTargets,
239
+ makefileHasInclude,
240
+ cliSourceText,
241
+ cliSourcePaths,
242
+ repoFiles: lsFiles ? lsFiles.split("\n").filter(Boolean) : null,
243
+ };
244
+ }
245
+ // --- extraction + verification ----------------------------------------------
246
+ function pathCandidates(line) {
247
+ const candidates = new Set();
248
+ for (const match of line.matchAll(/`([^`\n]+)`/g)) {
249
+ const token = match[1].trim();
250
+ if (new RegExp(`^(?:\\./)?[\\w.-]+(?:/[\\w.-]+)+\\.(?:${CHECK_PATH_EXTENSIONS})$`).test(token))
251
+ candidates.add(token.replace(/^\.\//, ""));
252
+ // Backticked directory refs ("`src/components/`") are claims too; the
253
+ // trailing slash marks them as deliberate. Single-segment names ("`packets/`")
254
+ // are skipped: they usually describe a subdir of something named earlier in
255
+ // the doc (a tree listing), so root-relative existence would misfire.
256
+ if (/^(?:\.\/)?[\w.-]+(?:\/[\w.-]+)+\/$/.test(token))
257
+ candidates.add(token.replace(/^\.\//, ""));
258
+ }
259
+ for (const match of line.matchAll(new RegExp(`(?:^|[\\s("'\\[])((?:\\./)?[\\w.-]+(?:/[\\w.-]+)+\\.(?:${CHECK_PATH_EXTENSIONS}))(?=$|[\\s)"'\\],.:;])`, "g"))) {
260
+ candidates.add(match[1].replace(/^\.\//, ""));
261
+ }
262
+ // "./foo.json" sneaks past the two-segment requirement because "." matches
263
+ // [\w.-]+ — and root-level single names are usually runtime files passed as
264
+ // arguments. Keep only real multi-segment paths, plus the handful of
265
+ // well-known root docs that are unambiguous.
266
+ const KNOWN_ROOT_DOC_RE = /^(CONTRIBUTING|CHANGELOG|LICENSE|README|SECURITY|CODE_OF_CONDUCT)\.(md|txt)$/i;
267
+ return [...candidates]
268
+ .map((candidate) => candidate.replace(/^\.\//, ""))
269
+ .filter((candidate) => candidate.includes("/") || KNOWN_ROOT_DOC_RE.test(candidate))
270
+ .filter((candidate) => !/[*<>{}$\\]/.test(candidate)
271
+ && !candidate.startsWith("http")
272
+ && !candidate.includes("node_modules")
273
+ && !candidate.startsWith(".agent_memory"));
274
+ }
275
+ function backtickSegments(line) {
276
+ return [...line.matchAll(/`([^`\n]+)`/g)].map((match) => match[1]);
277
+ }
278
+ function driftCheck(projectDir, options = {}) {
279
+ const docs = discoverContextFiles(projectDir);
280
+ const docLines = collectDocLines(projectDir, docs);
281
+ const truth = loadGroundTruth(projectDir);
282
+ const head = git(projectDir, ["rev-parse", "--short", "HEAD"]);
283
+ const confirmed = [];
284
+ const verified = [];
285
+ const unverifiable = [];
286
+ const seen = new Set();
287
+ const once = (key) => {
288
+ if (seen.has(key))
289
+ return false;
290
+ seen.add(key);
291
+ return true;
292
+ };
293
+ for (const entry of docLines) {
294
+ const inShellFence = entry.fence !== null && SHELL_FENCES.has(entry.fence);
295
+ const hypothetical = HYPOTHETICAL_RE.test(entry.text);
296
+ // 1. path-ref: prose only — fenced content is sample output, not claims.
297
+ if (entry.fence === null) {
298
+ for (const candidate of pathCandidates(entry.text)) {
299
+ // "src/x.ts" and "../../src/x.ts" in the same doc are one claim.
300
+ if (!once(`path:${candidate.replace(/^(\.\.\/)+/, "")}`))
301
+ continue;
302
+ const resolves = (0, node_fs_1.existsSync)((0, node_path_1.join)(projectDir, candidate)) || (0, node_fs_1.existsSync)((0, node_path_1.join)(projectDir, (0, node_path_1.dirname)(entry.doc), candidate));
303
+ const claim = { type: "path", doc: entry.doc, line: entry.line, claim: candidate };
304
+ if (resolves) {
305
+ verified.push({ ...claim, evidence: "file exists" });
306
+ continue;
307
+ }
308
+ if (BUILD_DIR_RE.test(candidate)) {
309
+ unverifiable.push({ ...claim, evidence: "build artifact path — existence depends on running a build, not on the tree" });
310
+ continue;
311
+ }
312
+ if (hypothetical || candidate.split("/").some((segment) => PLACEHOLDER_SEGMENT_RE.test(segment) || CAMEL_PLACEHOLDER_RE.test(segment))) {
313
+ unverifiable.push({ ...claim, evidence: "reads as an illustrative/placeholder path, not an assertion" });
314
+ continue;
315
+ }
316
+ if (ENV_PREFIX_RE.test(candidate.split("/")[0]) || RUNTIME_ROOT_RE.test(candidate.split("/")[0])) {
317
+ unverifiable.push({ ...claim, evidence: "path is anchored on an env-var or runtime data root, not the repo tree" });
318
+ continue;
319
+ }
320
+ // git knows which paths are created at runtime: if the path is ignored,
321
+ // its absence from a fresh checkout proves nothing.
322
+ if (gitIgnored(projectDir, candidate)) {
323
+ unverifiable.push({ ...claim, evidence: "path is gitignored — created at runtime, not tracked in the tree" });
324
+ continue;
325
+ }
326
+ if (HEDGED_RE.test(entry.text)) {
327
+ unverifiable.push({ ...claim, evidence: "the doc line itself hedges the claim (\"if missing\"-style)" });
328
+ continue;
329
+ }
330
+ // Docs often cite paths relative to a package dir, not the repo root
331
+ // (and links sometimes carry a wrong ../ depth for a file that exists).
332
+ if (truth.repoFiles) {
333
+ const bare = candidate.replace(/^(\.\.\/)+/, "").replace(/\/$/, "");
334
+ const suffixMatches = truth.repoFiles.filter((file) => file.endsWith(`/${bare}`) || file.includes(`/${bare}/`));
335
+ if (suffixMatches.length) {
336
+ verified.push({ ...claim, evidence: `exists as ${suffixMatches[0]}${suffixMatches.length > 1 ? ` (+${suffixMatches.length - 1} more)` : ""} — cited relative to a package dir` });
337
+ continue;
338
+ }
339
+ }
340
+ // A real namespace with a missing leaf is drift; a wholly absent
341
+ // top-level dir usually means the path lives out of tree (a consumer
342
+ // repo the tool installs into, a sibling repo, or a runtime dir).
343
+ const normalized = candidate.replace(/^(\.\.\/)+/, "");
344
+ const topSegment = normalized.split("/")[0];
345
+ if (normalized.includes("/") && !(0, node_fs_1.existsSync)((0, node_path_1.join)(projectDir, topSegment)) && !(0, node_fs_1.existsSync)((0, node_path_1.join)(projectDir, (0, node_path_1.dirname)(entry.doc), candidate.split("/")[0]))) {
346
+ unverifiable.push({ ...claim, evidence: `top-level \`${topSegment}/\` does not exist in this tree — path likely refers outside the repo (installed location, sibling repo, or runtime dir)` });
347
+ continue;
348
+ }
349
+ let evidence = "file does not exist in the tree";
350
+ const deleted = git(projectDir, ["log", "--diff-filter=D", "--format=%h %cs", "-n", "1", "--", candidate]);
351
+ if (deleted)
352
+ evidence += ` — deleted in ${deleted.split(" ")[0]} (${deleted.split(" ")[1]})`;
353
+ let fixHint;
354
+ if (truth.repoFiles) {
355
+ const base = candidate.split("/").pop() ?? candidate;
356
+ const sameName = truth.repoFiles.filter((file) => file.endsWith(`/${base}`) || file === base);
357
+ if (sameName.length === 1)
358
+ fixHint = `did you mean ${sameName[0]}?`;
359
+ }
360
+ confirmed.push({ ...claim, evidence, ...(fixHint ? { fix_hint: fixHint } : {}) });
361
+ }
362
+ }
363
+ // 2. npm-script-ref: prose and shell fences (a quoted command is a claim).
364
+ if (entry.fence === null || inShellFence) {
365
+ for (const match of entry.text.matchAll(/\b(?:npm|pnpm) run ([A-Za-z0-9:_.-]+)/g)) {
366
+ const script = match[1];
367
+ if (!once(`script:${script}`))
368
+ continue;
369
+ const claim = { type: "npm-script", doc: entry.doc, line: entry.line, claim: `npm run ${script}` };
370
+ if (truth.scripts[script]) {
371
+ verified.push({ ...claim, evidence: `"${script}" defined in package.json` });
372
+ continue;
373
+ }
374
+ const nested = truth.nestedScriptFiles.find((pkg) => pkg.scripts[script]);
375
+ if (nested) {
376
+ verified.push({ ...claim, evidence: `"${script}" defined in ${nested.path}` });
377
+ continue;
378
+ }
379
+ if (!truth.packageJsonPresent && !truth.nestedScriptFiles.length) {
380
+ unverifiable.push({ ...claim, evidence: truth.repoFiles ? "no package.json anywhere in the repo" : "no root package.json and not a git repo — nested packages unknown" });
381
+ continue;
382
+ }
383
+ if (truth.packageJsonPresent && !truth.packageJsonParsed) {
384
+ unverifiable.push({ ...claim, evidence: "package.json could not be parsed" });
385
+ continue;
386
+ }
387
+ // "cd x && npm run y" targets a directory we didn't resolve.
388
+ if (/\bcd\s+\S+/.test(entry.text) || hypothetical) {
389
+ unverifiable.push({ ...claim, evidence: /\bcd\s/.test(entry.text) ? "command runs after a cd — target package not resolved" : "reads as an illustrative command" });
390
+ continue;
391
+ }
392
+ const available = Object.keys(truth.scripts);
393
+ const where = truth.packageJsonPresent ? `package.json${truth.nestedScriptFiles.length ? ` or ${truth.nestedScriptFiles.length} workspace package.json file(s)` : ""}` : `any of ${truth.nestedScriptFiles.length} package.json file(s)`;
394
+ const shown = available.slice(0, 8).join(", ") + (available.length > 8 ? ", …" : "");
395
+ confirmed.push({ ...claim, evidence: `no "${script}" script in ${where}${available.length ? ` (root scripts: ${shown})` : ""}` });
396
+ }
397
+ }
398
+ // 3. make-target-ref: shell fences, or backticked in prose — bare `make X`
399
+ // in prose is usually English ("make sure").
400
+ if (truth.makeTargets) {
401
+ const segments = inShellFence ? [entry.text] : backtickSegments(entry.text);
402
+ for (const segment of segments) {
403
+ for (const match of segment.matchAll(/\bmake ([A-Za-z0-9_./-]+)/g)) {
404
+ const target = match[1];
405
+ if (MAKE_STOPWORDS.has(target.toLowerCase()) || /[$=]/.test(target))
406
+ continue;
407
+ if (!once(`make:${target}`))
408
+ continue;
409
+ const claim = { type: "make-target", doc: entry.doc, line: entry.line, claim: `make ${target}` };
410
+ if (truth.makeTargets.has(target)) {
411
+ verified.push({ ...claim, evidence: "target defined in Makefile" });
412
+ }
413
+ else if (truth.makefileHasInclude) {
414
+ unverifiable.push({ ...claim, evidence: "Makefile uses include; target may be defined in an included file" });
415
+ }
416
+ else if (hypothetical) {
417
+ unverifiable.push({ ...claim, evidence: "reads as an illustrative command" });
418
+ }
419
+ else {
420
+ confirmed.push({ ...claim, evidence: `no "${target}" target in Makefile` });
421
+ }
422
+ }
423
+ }
424
+ }
425
+ // 4. cli-subcommand-ref: backticked `<bin> <sub>` vs dispatch strings.
426
+ if (truth.binNames.length && (entry.fence === null || inShellFence)) {
427
+ const segments = inShellFence ? [entry.text] : backtickSegments(entry.text);
428
+ for (const bin of truth.binNames) {
429
+ for (const segment of segments) {
430
+ for (const match of segment.matchAll(new RegExp(`(?:^|[\\s;&|])${bin} ([a-z][a-z0-9-]+)`, "g"))) {
431
+ const sub = match[1];
432
+ if (CLI_SUB_STOPWORDS.has(sub))
433
+ continue;
434
+ if (!once(`cli:${bin}:${sub}`))
435
+ continue;
436
+ const claim = { type: "cli-subcommand", doc: entry.doc, line: entry.line, claim: `${bin} ${sub}` };
437
+ if (!truth.cliSourceText) {
438
+ unverifiable.push({ ...claim, evidence: "no CLI dispatch source found to check against" });
439
+ }
440
+ else if (truth.cliSourceText.includes(`"${sub}"`) || truth.cliSourceText.includes(`'${sub}'`)) {
441
+ verified.push({ ...claim, evidence: `dispatch string found in CLI source` });
442
+ }
443
+ else if (hypothetical) {
444
+ unverifiable.push({ ...claim, evidence: "reads as an illustrative command" });
445
+ }
446
+ else {
447
+ confirmed.push({ ...claim, evidence: `no "${sub}" dispatch string in CLI source (${truth.cliSourcePaths.slice(0, 3).join(", ")})` });
448
+ }
449
+ }
450
+ }
451
+ }
452
+ }
453
+ }
454
+ // --base mode: only drift attributable to the diff counts. Everything else
455
+ // is real but preexisting — visible in --json, silent in the gate.
456
+ let suppressedPreexisting = 0;
457
+ const base = options.base ?? null;
458
+ if (base) {
459
+ const diffRaw = git(projectDir, ["diff", "--name-status", `${base}...HEAD`]) ?? git(projectDir, ["diff", "--name-status", `${base}..HEAD`]);
460
+ if (diffRaw === null)
461
+ throw new Error(`git diff against base "${base}" failed — is it a valid ref?`);
462
+ const changed = new Set(diffRaw.split("\n").map((row) => row.split("\t").pop() ?? "").filter(Boolean));
463
+ const anyChanged = (predicate) => [...changed].some(predicate);
464
+ for (const finding of confirmed) {
465
+ const attributable = changed.has(finding.doc)
466
+ || (finding.type === "path" && changed.has(finding.claim))
467
+ || (finding.type === "npm-script" && anyChanged((path) => path.endsWith("package.json")))
468
+ || (finding.type === "make-target" && anyChanged((path) => /(^|\/)[Mm]akefile$/.test(path)))
469
+ || (finding.type === "cli-subcommand" && truth.cliSourcePaths.some((path) => changed.has(path)));
470
+ if (!attributable) {
471
+ finding.preexisting = true;
472
+ suppressedPreexisting += 1;
473
+ }
474
+ }
475
+ }
476
+ // Baseline: previously accepted findings don't gate. Keys skip line numbers
477
+ // so docs can move without re-triggering.
478
+ let suppressedBaseline = 0;
479
+ const baseline = readCheckBaseline(projectDir);
480
+ if (baseline) {
481
+ for (const finding of confirmed) {
482
+ if (!finding.preexisting && baseline.has(claimKey(finding))) {
483
+ finding.preexisting = true; // reuse the suppression channel
484
+ suppressedBaseline += 1;
485
+ }
486
+ }
487
+ }
488
+ const active = confirmed.filter((finding) => !finding.preexisting);
489
+ return {
490
+ schema_version: 1,
491
+ project_dir: projectDir,
492
+ checked_at: new Date().toISOString(),
493
+ base,
494
+ head,
495
+ totals: {
496
+ confirmed: active.length,
497
+ verified: verified.length,
498
+ unverifiable: unverifiable.length,
499
+ suppressed_preexisting: suppressedPreexisting,
500
+ suppressed_baseline: suppressedBaseline,
501
+ },
502
+ confirmed,
503
+ verified,
504
+ unverifiable,
505
+ docs_scanned: docs,
506
+ };
507
+ }
508
+ // --- baseline ----------------------------------------------------------------
509
+ function claimKey(claim) {
510
+ return `${claim.type}:${claim.doc}:${claim.claim}`;
511
+ }
512
+ function baselinePath(projectDir) {
513
+ return (0, node_path_1.join)(projectDir, ".agent_memory", "check-baseline.json");
514
+ }
515
+ function readCheckBaseline(projectDir) {
516
+ const text = safeRead(baselinePath(projectDir));
517
+ if (!text)
518
+ return null;
519
+ try {
520
+ const parsed = JSON.parse(text);
521
+ return new Set(parsed.keys ?? []);
522
+ }
523
+ catch {
524
+ return null;
525
+ }
526
+ }
527
+ function writeCheckBaseline(projectDir, report) {
528
+ const keys = report.confirmed.map(claimKey).sort();
529
+ (0, node_fs_1.mkdirSync)((0, node_path_1.join)(projectDir, ".agent_memory"), { recursive: true });
530
+ (0, node_fs_1.writeFileSync)(baselinePath(projectDir), `${JSON.stringify({ schema_version: 1, written_at: new Date().toISOString(), keys }, null, 2)}\n`, "utf8");
531
+ return baselinePath(projectDir);
532
+ }
533
+ // --- formatters ----------------------------------------------------------------
534
+ function formatCheckReport(report) {
535
+ const lines = [];
536
+ const total = report.totals.confirmed + report.totals.verified + report.totals.unverifiable;
537
+ const against = report.base ? `changes since ${report.base}` : (report.head ? `commit ${report.head}` : "working tree");
538
+ lines.push(`kage check — ${total} claims checked against ${against}`);
539
+ if (!report.docs_scanned.length) {
540
+ lines.push("no agent-context or doc files found (looked for CLAUDE.md, AGENTS.md, .cursor/rules, README, docs/).");
541
+ return lines.join("\n");
542
+ }
543
+ const active = report.confirmed.filter((finding) => !finding.preexisting);
544
+ if (active.length) {
545
+ lines.push("");
546
+ for (const finding of active) {
547
+ lines.push(` ${finding.doc}:${finding.line} says \`${finding.claim}\` — reality: ${finding.evidence}`);
548
+ if (finding.fix_hint)
549
+ lines.push(` ${finding.fix_hint}`);
550
+ }
551
+ }
552
+ lines.push("");
553
+ lines.push(`confirmed drift: ${report.totals.confirmed} · verified true: ${report.totals.verified} · unverifiable: ${report.totals.unverifiable}`);
554
+ if (report.totals.suppressed_baseline)
555
+ lines.push(`baseline: ${report.totals.suppressed_baseline} known finding(s) suppressed`);
556
+ if (report.base && report.totals.suppressed_preexisting)
557
+ lines.push(`preexisting: ${report.totals.suppressed_preexisting} finding(s) predate ${report.base} (see --json)`);
558
+ return lines.join("\n");
559
+ }
560
+ function checkReportMarkdown(report) {
561
+ const active = report.confirmed.filter((finding) => !finding.preexisting);
562
+ const lines = [];
563
+ lines.push(`### kage check — agent-context drift`);
564
+ lines.push("");
565
+ if (!active.length) {
566
+ lines.push(`No new drift: ${report.totals.verified} claim(s) verified true, 0 broken${report.base ? ` by changes since \`${report.base}\`` : ""}.`);
567
+ return lines.join("\n");
568
+ }
569
+ lines.push(`This ${report.base ? "diff breaks" : "repo has"} **${active.length}** documented claim(s):`);
570
+ lines.push("");
571
+ lines.push("| file | claim | reality |");
572
+ lines.push("|---|---|---|");
573
+ for (const finding of active) {
574
+ lines.push(`| ${finding.doc}:${finding.line} | \`${finding.claim.replace(/\|/g, "\\|")}\` | ${finding.evidence.replace(/\|/g, "\\|")}${finding.fix_hint ? ` — ${finding.fix_hint}` : ""} |`);
575
+ }
576
+ lines.push("");
577
+ lines.push(`_confirmed ${report.totals.confirmed} · verified ${report.totals.verified} · unverifiable ${report.totals.unverifiable} — every number is a reproducible check, not an estimate._`);
578
+ return lines.join("\n");
579
+ }
580
+ // --- CI template ----------------------------------------------------------------
581
+ function kageCheckWorkflowYaml() {
582
+ return `name: kage check
583
+ on: pull_request
584
+ permissions:
585
+ contents: read
586
+ pull-requests: write
587
+ jobs:
588
+ check:
589
+ runs-on: ubuntu-latest
590
+ steps:
591
+ - uses: actions/checkout@v4
592
+ with:
593
+ fetch-depth: 0
594
+ - uses: actions/setup-node@v4
595
+ with:
596
+ node-version: 20
597
+ - name: kage check
598
+ id: check
599
+ run: |
600
+ set +e
601
+ npx -y @kage-core/kage-graph-mcp check --project . --base "origin/\${GITHUB_BASE_REF}" --md > kage-check.md
602
+ echo "exit=$?" >> "$GITHUB_OUTPUT"
603
+ cat kage-check.md
604
+ - name: comment on drift
605
+ if: steps.check.outputs.exit == '1'
606
+ env:
607
+ GH_TOKEN: \${{ github.token }}
608
+ run: gh pr comment "\${{ github.event.pull_request.number }}" --body-file kage-check.md --edit-last --create-if-none || gh pr comment "\${{ github.event.pull_request.number }}" --body-file kage-check.md
609
+ - name: gate
610
+ run: exit "\${{ steps.check.outputs.exit }}"
611
+ `;
612
+ }