@holmes-lab/holmes-kit 0.1.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.
Files changed (107) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/LICENSE +21 -0
  3. package/README.md +102 -0
  4. package/bin/holmes-hook-antigravity.js +31 -0
  5. package/bin/holmes-kit.js +23 -0
  6. package/bin/holmes-mcp.js +34 -0
  7. package/bin/holmes-stop-antigravity.js +29 -0
  8. package/dist/.build-id +1 -0
  9. package/dist/holmes/cli/agents.js +168 -0
  10. package/dist/holmes/cli/doctor.js +625 -0
  11. package/dist/holmes/cli/gitignore-merge.js +84 -0
  12. package/dist/holmes/cli/governed-precondition.js +157 -0
  13. package/dist/holmes/cli/index.js +384 -0
  14. package/dist/holmes/cli/init.js +462 -0
  15. package/dist/holmes/cli/playbook-skills.js +711 -0
  16. package/dist/holmes/cli/roles-readme.js +134 -0
  17. package/dist/holmes/cli/settings-merge.js +122 -0
  18. package/dist/holmes/config/config.js +70 -0
  19. package/dist/holmes/context/bundler.js +114 -0
  20. package/dist/holmes/context/render.js +29 -0
  21. package/dist/holmes/context/tiers.js +110 -0
  22. package/dist/holmes/context/tokens.js +8 -0
  23. package/dist/holmes/cpg/cpg-scanner.js +213 -0
  24. package/dist/holmes/cpg/hash-cache.js +86 -0
  25. package/dist/holmes/cpg/language-parser-walk.js +917 -0
  26. package/dist/holmes/cpg/language-parser-worker.js +81 -0
  27. package/dist/holmes/cpg/language-parser.js +234 -0
  28. package/dist/holmes/cpg/scan-cache.js +108 -0
  29. package/dist/holmes/cpg/source-path.js +44 -0
  30. package/dist/holmes/cpg/test-files.js +84 -0
  31. package/dist/holmes/governance/constitution-debt.js +73 -0
  32. package/dist/holmes/governance/constitution-report.js +25 -0
  33. package/dist/holmes/governance/constitution.js +129 -0
  34. package/dist/holmes/governance/identity.js +30 -0
  35. package/dist/holmes/governance/ledger-lock.js +165 -0
  36. package/dist/holmes/governance/ledger-store.conformance.js +90 -0
  37. package/dist/holmes/governance/ledger-store.js +106 -0
  38. package/dist/holmes/governance/progress-ledger.js +83 -0
  39. package/dist/holmes/governance/provenance-chain.js +365 -0
  40. package/dist/holmes/governance/provenance-ledger.js +0 -0
  41. package/dist/holmes/governance/provenance-schema.js +47 -0
  42. package/dist/holmes/governance/replica-id.js +106 -0
  43. package/dist/holmes/governance/role-policy.js +137 -0
  44. package/dist/holmes/governance/trust-score.js +43 -0
  45. package/dist/holmes/guardrail/anchors.js +31 -0
  46. package/dist/holmes/guardrail/blind-spots.js +38 -0
  47. package/dist/holmes/guardrail/decision-ledger.js +107 -0
  48. package/dist/holmes/guardrail/executable-artifact.js +129 -0
  49. package/dist/holmes/guardrail/governance-history.js +101 -0
  50. package/dist/holmes/guardrail/phase.js +169 -0
  51. package/dist/holmes/guardrail/risk-classifier.js +450 -0
  52. package/dist/holmes/guardrail/risk-gate.js +160 -0
  53. package/dist/holmes/guardrail/risk-types.js +6 -0
  54. package/dist/holmes/guardrail/tspec-state.js +392 -0
  55. package/dist/holmes/guardrail/write-target.js +224 -0
  56. package/dist/holmes/hooks/adapters/antigravity.js +194 -0
  57. package/dist/holmes/hooks/pre-tool-use.js +1262 -0
  58. package/dist/holmes/hooks/stop.js +416 -0
  59. package/dist/holmes/mcp/basis.js +162 -0
  60. package/dist/holmes/mcp/handlers.js +1831 -0
  61. package/dist/holmes/mcp/server.js +71 -0
  62. package/dist/holmes/mcp/stdio-client.js +165 -0
  63. package/dist/holmes/mcp/supervisor.js +178 -0
  64. package/dist/holmes/mcp/tool-schemas.js +394 -0
  65. package/dist/holmes/mcp/validate-args.js +281 -0
  66. package/dist/holmes/messages/registry.js +50 -0
  67. package/dist/holmes/project/baseline.js +210 -0
  68. package/dist/holmes/project/change-source.js +233 -0
  69. package/dist/holmes/project/ignore.js +145 -0
  70. package/dist/holmes/project/root.js +113 -0
  71. package/dist/holmes/reverse/anchor.js +162 -0
  72. package/dist/holmes/reverse/cluster.js +187 -0
  73. package/dist/holmes/reverse/draft.js +151 -0
  74. package/dist/holmes/reverse/dynamic-wiring.js +47 -0
  75. package/dist/holmes/reverse/scan.js +194 -0
  76. package/dist/holmes/reverse/surface.js +154 -0
  77. package/dist/holmes/reverse/test-map.js +263 -0
  78. package/dist/holmes/review/coverage.js +33 -0
  79. package/dist/holmes/review/findings.js +123 -0
  80. package/dist/holmes/review/package.js +40 -0
  81. package/dist/holmes/review/review-targets.js +92 -0
  82. package/dist/holmes/review/scope.js +57 -0
  83. package/dist/holmes/review/test-evidence.js +77 -0
  84. package/dist/holmes/review/test-runner.js +572 -0
  85. package/dist/holmes/rtm/dataflow-taint.js +262 -0
  86. package/dist/holmes/rtm/gap-analyzer.js +27 -0
  87. package/dist/holmes/rtm/git-changes.js +72 -0
  88. package/dist/holmes/rtm/incremental.js +45 -0
  89. package/dist/holmes/rtm/localize.js +100 -0
  90. package/dist/holmes/rtm/rtm-builder.js +191 -0
  91. package/dist/holmes/rtm/rtm-check.js +89 -0
  92. package/dist/holmes/rtm/rtm-graph.js +232 -0
  93. package/dist/holmes/rtm/taint.js +92 -0
  94. package/dist/holmes/rtm/test-scope.js +336 -0
  95. package/dist/holmes/spec/approval-blockers.js +204 -0
  96. package/dist/holmes/spec/breaking-change.js +89 -0
  97. package/dist/holmes/spec/legacy-format.js +87 -0
  98. package/dist/holmes/spec/spec-digest.js +71 -0
  99. package/dist/holmes/spec/spec-parser.js +106 -0
  100. package/dist/holmes/spec/spec-store.conformance.js +118 -0
  101. package/dist/holmes/spec/spec-store.js +331 -0
  102. package/dist/holmes/spec/spec-types.js +177 -0
  103. package/dist/holmes/spec/validator.js +280 -0
  104. package/package.json +76 -0
  105. package/playbooks/adopt/PLAYBOOK.md +125 -0
  106. package/playbooks/author-slice/PLAYBOOK.md +119 -0
  107. package/playbooks/promote-slice/PLAYBOOK.md +134 -0
@@ -0,0 +1,262 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.DATAFLOW_LIMITS = void 0;
4
+ exports.taintDataFlow = taintDataFlow;
5
+ /**
6
+ * What this pass does NOT model. Carried in every result, for the same reason REQ-138 carries
7
+ * `TAINT_LIMITS`: a screening signal that does not state its blind spots is read as a proof.
8
+ */
9
+ exports.DATAFLOW_LIMITS = [
10
+ 'no field sensitivity (obj.a and obj.b are one value)',
11
+ 'no aliasing',
12
+ 'no path sensitivity',
13
+ 'no container/element tracking',
14
+ 'no reflection or dynamic dispatch',
15
+ 'sink matching is by callee NAME with no receiver type — RE.exec(s) is indistinguishable from child_process.exec(s)',
16
+ 'no anonymous-function parameters (arrow/function-expression params are not bound)',
17
+ 'not statement-order sensitive — a variable tainted anywhere in a function is tainted throughout it',
18
+ 'reports reachability of tainted data to a sink, never exploitability',
19
+ ];
20
+ const DEFAULT_MAX_ITERATIONS = 10;
21
+ const DEFAULT_MAX_FINDINGS = 100;
22
+ /** Source patterns are path-shaped fragments of an expression (`req.body` in `req.body.cmd`), so substring is correct here. */
23
+ const matchesText = (text, patterns) => {
24
+ const t = text.toLowerCase();
25
+ return patterns.some((p) => p && t.includes(p.toLowerCase()));
26
+ };
27
+ /** A callee NAME is matched exactly — see `DataFlowTaintConfig.sinks` for the two false positives substring matching produced. */
28
+ const matchesName = (name, patterns) => {
29
+ const n = name.toLowerCase();
30
+ return patterns.some((p) => p && p.toLowerCase() === n);
31
+ };
32
+ /**
33
+ * A FUNCTION's identity is `(file, qualifiedName)`, and a VARIABLE's is that plus its name.
34
+ *
35
+ * The file is not decoration. Measured while writing the ambiguity test: keying on the qualified
36
+ * name alone made two files that each declare `function run(...)` collapse into ONE function, which
37
+ * both defeated the ambiguity refusal below AND let taint leak across files — a variable tainted in
38
+ * `a.ts` marked a same-named variable in `b.ts` as tainted, a finding with no flow behind it.
39
+ */
40
+ const fid = (file, fn) => `${file}|${fn}`;
41
+ const key = (fnId, name) => `${fnId}::${name}`;
42
+ /** Sanitizer KILL. Applied before every other rule: a sanitized expression is clean whatever it reads. */
43
+ const isSanitized = (e, cfg) => e.callees.some((c) => matchesName(c, cfg.sanitizers));
44
+ /**
45
+ * Taint propagation to a fixpoint.
46
+ *
47
+ * Pure: no I/O, no clock, no randomness. Facts are visited in a stable order and findings are
48
+ * deduped and sorted, so the same input always produces byte-identical output.
49
+ */
50
+ function taintDataFlow(factSet, cfg) {
51
+ const maxIterations = cfg.maxIterations ?? DEFAULT_MAX_ITERATIONS;
52
+ const maxFindings = cfg.maxFindings ?? DEFAULT_MAX_FINDINGS;
53
+ // Flatten with file provenance, in a stable order.
54
+ const files = [...factSet].sort((a, b) => (a.file < b.file ? -1 : a.file > b.file ? 1 : 0));
55
+ const defs = files.flatMap(({ file, facts }) => facts.defs.map((d) => ({ ...d, file })));
56
+ const calls = files.flatMap(({ file, facts }) => facts.calls.map((c) => ({ ...c, file })));
57
+ const returns = files.flatMap(({ file, facts }) => facts.returns.map((r) => ({ ...r, file })));
58
+ const params = files.flatMap(({ file, facts }) => facts.params.map((p) => ({ ...p, file })));
59
+ // Callee name -> the functions declaring it. A name declaring MORE THAN ONE function is
60
+ // ambiguous, and rule 4 refuses to bind through it: `addCallEdges` makes the same call, because a
61
+ // wrong binding silently poisons every finding derived from it while a missing one is a disclosed
62
+ // gap. `Class.method` is indexed by its last segment too, so a method call resolves.
63
+ //
64
+ // Built from EVERY fact kind, not just `params`. Measured: indexing only functions that declare a
65
+ // parameter made a zero-argument function unresolvable, so `const y = src()` could never receive
66
+ // `src`'s tainted return — the whole RETURN→CALL-SITE rule was dead for that very common shape.
67
+ const fnsByName = new Map();
68
+ const declare = (file, fn) => {
69
+ if (!fn || fn === '<module>')
70
+ return; // <module> is not callable; indexing it would invite bogus binds
71
+ for (const n of [fn, fn.includes('.') ? fn.slice(fn.lastIndexOf('.') + 1) : null]) {
72
+ if (!n)
73
+ continue;
74
+ if (!fnsByName.has(n))
75
+ fnsByName.set(n, new Set());
76
+ fnsByName.get(n).add(fid(file, fn));
77
+ }
78
+ };
79
+ for (const p of params)
80
+ declare(p.file, p.fn);
81
+ for (const r of returns)
82
+ declare(r.file, r.fn);
83
+ for (const d of defs)
84
+ declare(d.file, d.fn);
85
+ for (const c of calls)
86
+ declare(c.file, c.fn);
87
+ const resolveUnique = (callee) => {
88
+ const s = fnsByName.get(callee);
89
+ return s && s.size === 1 ? [...s][0] : null;
90
+ };
91
+ const paramAt = (fnId, index) => params.find((p) => fid(p.file, p.fn) === fnId && p.index === index);
92
+ // fn -> the functions it calls that are known to return tainted data (rule 5 needs this per round).
93
+ const returnsByFn = new Map();
94
+ for (const r of returns) {
95
+ const id = fid(r.file, r.fn);
96
+ if (!returnsByFn.has(id))
97
+ returnsByFn.set(id, []);
98
+ returnsByFn.get(id).push(r);
99
+ }
100
+ const tainted = new Map();
101
+ const taint = (k, step, from) => {
102
+ if (tainted.has(k))
103
+ return false;
104
+ tainted.set(k, { step, from });
105
+ return true;
106
+ };
107
+ /**
108
+ * The key under which a source-bearing expression is tainted when it is not bound to a variable.
109
+ *
110
+ * A source does not have to be assigned before it is used. `exec(req.body.cmd)` is the most direct
111
+ * injection shape there is, and a rule that only taints DEFS reports nothing for it — measured, on
112
+ * a version of this pass that 27 tests called green. The same hole swallowed `run(req.body.cmd)`
113
+ * and `return process.argv[2]`. So a source occurrence anywhere gets a pseudo-variable, and every
114
+ * rule reaches it through the one `carrier` predicate below.
115
+ */
116
+ const srcKey = (fnId, text) => key(fnId, `<source>${text}`);
117
+ /**
118
+ * An expression carries taint iff it is not sanitized AND either reads a tainted variable of `fn`
119
+ * or is itself a source. Returns the origin key so the flow path can be replayed from it.
120
+ */
121
+ const carrier = (e, fnId) => {
122
+ if (isSanitized(e, cfg))
123
+ return null;
124
+ for (const r of e.refs) {
125
+ const k = key(fnId, r);
126
+ if (tainted.has(k))
127
+ return k;
128
+ }
129
+ const sk = srcKey(fnId, e.text);
130
+ return tainted.has(sk) ? sk : null;
131
+ };
132
+ // SOURCE is not part of the loop: it depends on nothing the loop can change. It covers every place
133
+ // an expression appears — a def's initialiser, a call argument, and a returned value.
134
+ const markSource = (e, fnId, file, line, detail) => {
135
+ // The `isSanitized` half here is DEFENCE IN DEPTH, and deliberately not mutation-discriminated:
136
+ // `carrier` is the real gate, and it rejects the same expression before this entry can be read
137
+ // (the pseudo key is derived from the expression's own text, so only that expression retrieves
138
+ // it). Deleting this line leaves every test green. It stays so that a future change to
139
+ // `carrier`'s lookup cannot silently resurrect sanitized sources — not as a tested behaviour.
140
+ if (isSanitized(e, cfg) || !matchesText(e.text, cfg.sources))
141
+ return;
142
+ const k = srcKey(fnId, e.text);
143
+ taint(k, { rule: 'source', variable: k, file, line, detail }, null);
144
+ };
145
+ for (const d of defs)
146
+ markSource(d.expr, fid(d.file, d.fn), d.file, d.line, `${d.name} = ${d.expr.text}`);
147
+ for (const r of returns)
148
+ markSource(r.expr, fid(r.file, r.fn), r.file, r.line, `return ${r.expr.text}`);
149
+ for (const c of calls) {
150
+ for (let i = 0; i < c.args.length; i++) {
151
+ markSource(c.args[i], fid(c.file, c.fn), c.file, c.line, `${c.callee}(…) argument ${i} = ${c.args[i].text}`);
152
+ }
153
+ }
154
+ // A def whose initialiser IS a source names the variable directly, which reads better in a flow
155
+ // path than the pseudo-variable would. The pseudo entry above still exists and is harmless.
156
+ for (const d of defs) {
157
+ if (isSanitized(d.expr, cfg) || !matchesText(d.expr.text, cfg.sources))
158
+ continue;
159
+ const k = key(fid(d.file, d.fn), d.name);
160
+ taint(k, { rule: 'source', variable: k, file: d.file, line: d.line, detail: `${d.name} = ${d.expr.text}` }, null);
161
+ }
162
+ let converged = false;
163
+ for (let iter = 0; iter < maxIterations; iter++) {
164
+ let changed = false;
165
+ // ASSIGN — a def reading a tainted variable of the same function.
166
+ for (const d of defs) {
167
+ const k = key(fid(d.file, d.fn), d.name);
168
+ if (tainted.has(k))
169
+ continue;
170
+ const from = carrier(d.expr, fid(d.file, d.fn));
171
+ if (from) {
172
+ changed = taint(k, { rule: 'assign', variable: k, file: d.file, line: d.line, detail: `${d.name} = ${d.expr.text}` }, from) || changed;
173
+ }
174
+ }
175
+ // RETURN→CALL-SITE — a def invoking a function whose return expression is tainted.
176
+ for (const d of defs) {
177
+ const k = key(fid(d.file, d.fn), d.name);
178
+ if (tainted.has(k) || isSanitized(d.expr, cfg))
179
+ continue;
180
+ for (const calleeName of d.expr.callees) {
181
+ const target = resolveUnique(calleeName);
182
+ if (!target)
183
+ continue;
184
+ const rs = returnsByFn.get(target) ?? [];
185
+ const tr = rs.find((r) => carrier(r.expr, target));
186
+ if (!tr)
187
+ continue;
188
+ changed = taint(k, { rule: 'return', variable: k, file: d.file, line: d.line, detail: `${d.name} = ${calleeName}(…) returning tainted` }, carrier(tr.expr, target)) || changed;
189
+ break;
190
+ }
191
+ }
192
+ // ARG→PARAM — position binds; an ambiguous callee binds nothing.
193
+ for (const c of calls) {
194
+ for (let i = 0; i < c.args.length; i++) {
195
+ const from = carrier(c.args[i], fid(c.file, c.fn));
196
+ if (!from)
197
+ continue;
198
+ const target = resolveUnique(c.callee);
199
+ if (!target)
200
+ continue;
201
+ const p = paramAt(target, i);
202
+ if (!p)
203
+ continue;
204
+ const k = key(target, p.name);
205
+ changed = taint(k, { rule: 'arg-param', variable: k, file: c.file, line: c.line, detail: `${c.callee}(…) argument ${i} binds ${p.name}` }, from) || changed;
206
+ }
207
+ }
208
+ if (!changed) {
209
+ converged = true;
210
+ break;
211
+ }
212
+ }
213
+ /** Walk a taint fact back to its source, oldest step first. */
214
+ const replay = (k) => {
215
+ const steps = [];
216
+ const seen = new Set();
217
+ let cur = k;
218
+ while (cur && tainted.has(cur) && !seen.has(cur)) {
219
+ seen.add(cur);
220
+ const e = tainted.get(cur);
221
+ steps.unshift(e.step);
222
+ cur = e.from;
223
+ }
224
+ return steps;
225
+ };
226
+ // SINK — reported after the fixpoint, so a finding always reflects the complete taint state.
227
+ const all = [];
228
+ for (const c of calls) {
229
+ if (!matchesName(c.callee, cfg.sinks))
230
+ continue;
231
+ for (let i = 0; i < c.args.length; i++) {
232
+ const from = carrier(c.args[i], fid(c.file, c.fn));
233
+ if (!from)
234
+ continue;
235
+ all.push({
236
+ file: c.file, fn: c.fn, callee: c.callee, line: c.line, argIndex: i, argText: c.args[i].text,
237
+ flow: [...replay(from), {
238
+ rule: 'sink', variable: from, file: c.file, line: c.line,
239
+ detail: `${c.callee}(…) argument ${i} = ${c.args[i].text}`,
240
+ }],
241
+ });
242
+ }
243
+ }
244
+ // `argText` is part of the key, not decoration. Measured without it: `exec(a); exec(b);` written on
245
+ // ONE line collapsed to a single finding and `exec(b)` disappeared — a silent under-report, the
246
+ // failure mode this pass exists to avoid. Two calls that are genuinely identical (`exec(raw)`
247
+ // twice on one line) still collapse, which is the deduplication that was actually wanted.
248
+ const dedupKey = (f) => `${f.file}|${f.fn}|${f.callee}|${String(f.line).padStart(9, '0')}|${f.argIndex}|${f.argText}`;
249
+ const seen = new Set();
250
+ const unique = all.filter((f) => { const k = dedupKey(f); if (seen.has(k))
251
+ return false; seen.add(k); return true; });
252
+ unique.sort((a, b) => (dedupKey(a) < dedupKey(b) ? -1 : dedupKey(a) > dedupKey(b) ? 1 : 0));
253
+ const findings = unique.slice(0, maxFindings);
254
+ return {
255
+ kind: 'data-flow',
256
+ findings,
257
+ tainted: [...tainted.keys()].sort(),
258
+ limits: [...exports.DATAFLOW_LIMITS],
259
+ truncated: unique.length - findings.length,
260
+ converged,
261
+ };
262
+ }
@@ -0,0 +1,27 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.computeConvergenceGaps = computeConvergenceGaps;
4
+ function computeConvergenceGaps(scanned, specs, testAnchors) {
5
+ const approvedAspecs = specs.filter((s) => s.type === 'A-SPEC' && s.status === 'approved').map((s) => s.id);
6
+ const implemented = new Set();
7
+ const unanchoredFiles = [];
8
+ for (const f of scanned) {
9
+ const ids = f.implementsSpecs ?? [];
10
+ if (ids.length === 0)
11
+ unanchoredFiles.push(f.sourcePath);
12
+ for (const id of ids)
13
+ implemented.add(id);
14
+ }
15
+ const unimplementedSpecs = approvedAspecs.filter((id) => !implemented.has(id)).sort();
16
+ const tested = new Set();
17
+ for (const ids of Object.values(testAnchors ?? {}))
18
+ for (const id of ids)
19
+ tested.add(id);
20
+ const untestedSpecs = testAnchors
21
+ ? approvedAspecs.filter((id) => implemented.has(id) && !tested.has(id)).sort()
22
+ : [];
23
+ const gapCount = unimplementedSpecs.length + unanchoredFiles.length + untestedSpecs.length;
24
+ const denom = Math.max(1, approvedAspecs.length + scanned.length);
25
+ const score = Math.max(0, 1 - gapCount / denom);
26
+ return { unimplementedSpecs, unanchoredFiles: [...unanchoredFiles].sort(), untestedSpecs, score };
27
+ }
@@ -0,0 +1,72 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.parseNameStatus = parseNameStatus;
4
+ exports.assertSafeRev = assertSafeRev;
5
+ exports.gitChangedFiles = gitChangedFiles;
6
+ const node_child_process_1 = require("node:child_process");
7
+ // @implements A-SPEC-113.1
8
+ /**
9
+ * Pure parser for `git diff --name-status` output. Paths are already
10
+ * repo-relative POSIX paths as emitted by git — no path normalization here.
11
+ *
12
+ * Recognized status codes (one per tab-separated line):
13
+ * - `A\tpath` -> added
14
+ * - `M\tpath` -> modified
15
+ * - `D\tpath` -> deleted
16
+ * - `R<score>\tfrom\tto` -> renamed { from, to }
17
+ * - `C<score>\tfrom\tto` -> treated as an add of `to` (a copy introduces a
18
+ * new file at `to`; the original at `from` is untouched, so there is no
19
+ * corresponding delete/replace to emit for it).
20
+ *
21
+ * Blank lines are ignored. Unrecognized status codes are ignored rather than
22
+ * throwing, so a git version with an unfamiliar status letter degrades
23
+ * gracefully instead of crashing the whole reindex.
24
+ */
25
+ function parseNameStatus(raw) {
26
+ const changes = { added: [], modified: [], deleted: [], renamed: [] };
27
+ for (const line of raw.split('\n')) {
28
+ if (line.trim() === '')
29
+ continue;
30
+ const cols = line.split('\t');
31
+ const status = cols[0];
32
+ if (status === 'A' && cols[1]) {
33
+ changes.added.push(cols[1]);
34
+ }
35
+ else if (status === 'M' && cols[1]) {
36
+ changes.modified.push(cols[1]);
37
+ }
38
+ else if (status === 'D' && cols[1]) {
39
+ changes.deleted.push(cols[1]);
40
+ }
41
+ else if (/^R\d+$/.test(status) && cols[1] && cols[2]) {
42
+ changes.renamed.push({ from: cols[1], to: cols[2] });
43
+ }
44
+ else if (/^C\d+$/.test(status) && cols[2]) {
45
+ changes.added.push(cols[2]);
46
+ }
47
+ }
48
+ return changes;
49
+ }
50
+ /**
51
+ * Rejects any git rev/ref that begins with `-`. Without this, a value like
52
+ * `--output=/victim/file` passed as `base`/`head` to `git diff` is parsed by
53
+ * git as an OPTION (not a positional ref), letting an attacker make git write
54
+ * or truncate an arbitrary file (audit B1, argument-injection). Defense in
55
+ * depth alongside the `--end-of-options` guard below.
56
+ */
57
+ function assertSafeRev(v) {
58
+ if (/^-/.test(v)) {
59
+ throw new Error(`unsafe git ref (leading dash): ${v}`);
60
+ }
61
+ }
62
+ /**
63
+ * Thin, side-effecting wrapper: invokes real git and hands the raw output to
64
+ * the pure parser above. Kept isolated here so `parseNameStatus` stays
65
+ * testable on a raw string with no process/filesystem dependency.
66
+ */
67
+ function gitChangedFiles(repoRoot, base, head) {
68
+ assertSafeRev(base);
69
+ assertSafeRev(head);
70
+ const raw = (0, node_child_process_1.execFileSync)('git', ['-C', repoRoot, 'diff', '--name-status', '--end-of-options', base, head], { encoding: 'utf8' });
71
+ return parseNameStatus(raw);
72
+ }
@@ -0,0 +1,45 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.applyIncremental = applyIncremental;
4
+ const rtm_builder_1 = require("./rtm-builder");
5
+ /**
6
+ * Reduces every add/modify/delete/rename case to one primitive: replace file
7
+ * F's subgraph. Processing order is deterministic: deleted, renamed, added,
8
+ * modified.
9
+ *
10
+ * - deleted F: graph.removeBySource(F) — no re-add.
11
+ * - renamed {from,to}: graph.removeBySource(from); then `to` is treated as added.
12
+ * - added/modified F: graph.removeBySource(F); scanOne(F); if non-null,
13
+ * buildFileSubgraph(sf, specs, graph).
14
+ *
15
+ * removeBySource runs before any re-add — including for a pure add — so the
16
+ * operation is idempotent: re-applying the same ChangeSet has no net effect
17
+ * beyond the first application.
18
+ *
19
+ * SPEC-file changes (paths under .ax/specs/) are out of scope here: scanOne
20
+ * returns null for non-code files, so only their removeBySource side (a
21
+ * no-op, since SPEC nodes are keyed on the spec's own sourcePath — see
22
+ * buildRtm/rtm-builder) would run. Incremental SPEC-node updates are
23
+ * deferred to a full spec reload for now.
24
+ */
25
+ function applyIncremental(graph, changes, ctx) {
26
+ const replaceFile = (f) => {
27
+ graph.removeBySource(f);
28
+ const sf = ctx.scanOne(f);
29
+ if (sf)
30
+ (0, rtm_builder_1.buildFileSubgraph)(sf, ctx.specs, graph);
31
+ };
32
+ for (const f of changes.deleted) {
33
+ graph.removeBySource(f);
34
+ }
35
+ for (const { from, to } of changes.renamed) {
36
+ graph.removeBySource(from);
37
+ replaceFile(to);
38
+ }
39
+ for (const f of changes.added) {
40
+ replaceFile(f);
41
+ }
42
+ for (const f of changes.modified) {
43
+ replaceFile(f);
44
+ }
45
+ }
@@ -0,0 +1,100 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.significantTerms = significantTerms;
4
+ exports.localizeIssue = localizeIssue;
5
+ const STOP = new Set([
6
+ 'the', 'and', 'for', 'with', 'that', 'this', 'when', 'then', 'from', 'into', 'not', 'but', 'are',
7
+ 'was', 'were', 'have', 'has', 'had', 'does', 'did', 'should', 'would', 'could', 'will', 'can',
8
+ 'error', 'bug', 'issue', 'fix', 'fails', 'failed', 'failing', 'broken', 'wrong', 'incorrect',
9
+ 'file', 'code', 'test', 'tests', 'function', 'method', 'value', 'return', 'returns',
10
+ ]);
11
+ // Compute cap (review D5): terms beyond this bound add noise, not signal, and the per-symbol scan is
12
+ // O(terms × symbols) — a pasted 0.5MB issue must not stall the synchronous MCP handler.
13
+ const MAX_TERMS = 64;
14
+ /**
15
+ * Significant tokens of the text, UNICODE-aware (review D2: the old [^a-z0-9] split dropped every
16
+ * non-ASCII codepoint, so Korean issue text — and this repo's Korean spec titles — tokenized to
17
+ * NOTHING and localization was a silent no-op). Letters/digits of any script are token chars;
18
+ * camelCase/snake are split; Latin tokens need length>=3, non-ASCII (e.g. Korean) length>=2.
19
+ */
20
+ function significantTerms(text) {
21
+ const raw = text
22
+ .replace(/([a-z0-9])([A-Z])/g, '$1 $2') // camelCase -> camel Case
23
+ .toLowerCase()
24
+ .split(/[^\p{L}\p{N}]+/u)
25
+ .filter((t) => {
26
+ const minLen = /[^\x00-\x7f]/.test(t) ? 2 : 3;
27
+ return t.length >= minLen && !STOP.has(t);
28
+ });
29
+ return [...new Set(raw)].slice(0, MAX_TERMS);
30
+ }
31
+ const tokensOf = (s) => new Set(significantTerms(s));
32
+ function localizeIssue(issueText, scanned, specs, topN = 10) {
33
+ const terms = significantTerms(issueText);
34
+ if (terms.length === 0)
35
+ return { terms: [], hits: [], matchedSpecs: [] };
36
+ const termSet = new Set(terms);
37
+ // 1) A-SPECs whose title/section text shares terms with the issue (RTM entry points). Restricted
38
+ // to A-SPEC: files anchor A-SPECs only, so other spec kinds can never pull a file through the
39
+ // hop and reporting them overstated what influenced the ranking (review D7). Threshold (review
40
+ // D6): short issues (<=3 terms) match on a single overlapping term; longer issues need >=2 so
41
+ // one common word doesn't fan out across the whole spec set.
42
+ const matchedSpecs = [];
43
+ for (const s of specs) {
44
+ if (s.type !== 'A-SPEC')
45
+ continue;
46
+ const specText = `${s.title} ${Object.values(s.sections).join(' ')}`;
47
+ const st = tokensOf(specText);
48
+ let overlap = 0;
49
+ for (const t of termSet)
50
+ if (st.has(t))
51
+ overlap++;
52
+ if (overlap >= 2 || (overlap === 1 && termSet.size <= 3))
53
+ matchedSpecs.push(s.id);
54
+ }
55
+ const matchedSpecSet = new Set(matchedSpecs);
56
+ // Vendored/mirrored trees (review D4): an identical copy under reference/vendor must not outrank —
57
+ // or lexically tie-break above — the live source. Their scores are halved and ties break toward
58
+ // non-vendor paths.
59
+ const isVendorPath = (p) => /(^|\/)(reference|vendor|vendors|third_party|third-party|external)\//i.test(p);
60
+ // 2) Score each scanned file: symbol-name matches (strong), path matches (weak),
61
+ // implements-a-matched-spec (graph bonus the lexical layer cannot see).
62
+ const hits = [];
63
+ for (const f of scanned) {
64
+ const why = [];
65
+ let score = 0;
66
+ const matchedSymbols = [];
67
+ for (const sym of f.symbols) {
68
+ const symTokens = tokensOf(sym.qualifiedName);
69
+ const hitTerms = [...termSet].filter((t) => symTokens.has(t));
70
+ if (hitTerms.length > 0) {
71
+ score += 3 * hitTerms.length;
72
+ matchedSymbols.push(sym.qualifiedName);
73
+ why.push(`symbol ${sym.qualifiedName} matches [${hitTerms.join(', ')}]`);
74
+ }
75
+ }
76
+ const pathTokens = tokensOf(f.sourcePath);
77
+ const pathHits = [...termSet].filter((t) => pathTokens.has(t));
78
+ if (pathHits.length > 0) {
79
+ score += pathHits.length;
80
+ why.push(`path matches [${pathHits.join(', ')}]`);
81
+ }
82
+ // Dedupe: a file may carry the same @implements anchor many times (once per code section);
83
+ // each spec must count ONCE toward the bonus (live smoke showed 4x inflation otherwise).
84
+ const viaSpecs = [...new Set(f.implementsSpecs ?? [])].filter((id) => matchedSpecSet.has(id));
85
+ if (viaSpecs.length > 0) {
86
+ score += 4 * viaSpecs.length;
87
+ why.push(`implements matched spec(s) ${viaSpecs.join(', ')}`);
88
+ }
89
+ if (isVendorPath(f.sourcePath) && score > 0) {
90
+ score = score / 2;
91
+ why.push('vendored path — score halved');
92
+ }
93
+ if (score > 0)
94
+ hits.push({ file: f.sourcePath, score, matchedSymbols, viaSpecs, why });
95
+ }
96
+ hits.sort((a, b) => b.score - a.score ||
97
+ Number(isVendorPath(a.file)) - Number(isVendorPath(b.file)) || // ties: live source before vendor
98
+ a.file.localeCompare(b.file)); // then deterministic path order
99
+ return { terms, hits: hits.slice(0, topN), matchedSpecs: matchedSpecs.sort() };
100
+ }