@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.
- package/CHANGELOG.md +22 -0
- package/LICENSE +21 -0
- package/README.md +102 -0
- package/bin/holmes-hook-antigravity.js +31 -0
- package/bin/holmes-kit.js +23 -0
- package/bin/holmes-mcp.js +34 -0
- package/bin/holmes-stop-antigravity.js +29 -0
- package/dist/.build-id +1 -0
- package/dist/holmes/cli/agents.js +168 -0
- package/dist/holmes/cli/doctor.js +625 -0
- package/dist/holmes/cli/gitignore-merge.js +84 -0
- package/dist/holmes/cli/governed-precondition.js +157 -0
- package/dist/holmes/cli/index.js +384 -0
- package/dist/holmes/cli/init.js +462 -0
- package/dist/holmes/cli/playbook-skills.js +711 -0
- package/dist/holmes/cli/roles-readme.js +134 -0
- package/dist/holmes/cli/settings-merge.js +122 -0
- package/dist/holmes/config/config.js +70 -0
- package/dist/holmes/context/bundler.js +114 -0
- package/dist/holmes/context/render.js +29 -0
- package/dist/holmes/context/tiers.js +110 -0
- package/dist/holmes/context/tokens.js +8 -0
- package/dist/holmes/cpg/cpg-scanner.js +213 -0
- package/dist/holmes/cpg/hash-cache.js +86 -0
- package/dist/holmes/cpg/language-parser-walk.js +917 -0
- package/dist/holmes/cpg/language-parser-worker.js +81 -0
- package/dist/holmes/cpg/language-parser.js +234 -0
- package/dist/holmes/cpg/scan-cache.js +108 -0
- package/dist/holmes/cpg/source-path.js +44 -0
- package/dist/holmes/cpg/test-files.js +84 -0
- package/dist/holmes/governance/constitution-debt.js +73 -0
- package/dist/holmes/governance/constitution-report.js +25 -0
- package/dist/holmes/governance/constitution.js +129 -0
- package/dist/holmes/governance/identity.js +30 -0
- package/dist/holmes/governance/ledger-lock.js +165 -0
- package/dist/holmes/governance/ledger-store.conformance.js +90 -0
- package/dist/holmes/governance/ledger-store.js +106 -0
- package/dist/holmes/governance/progress-ledger.js +83 -0
- package/dist/holmes/governance/provenance-chain.js +365 -0
- package/dist/holmes/governance/provenance-ledger.js +0 -0
- package/dist/holmes/governance/provenance-schema.js +47 -0
- package/dist/holmes/governance/replica-id.js +106 -0
- package/dist/holmes/governance/role-policy.js +137 -0
- package/dist/holmes/governance/trust-score.js +43 -0
- package/dist/holmes/guardrail/anchors.js +31 -0
- package/dist/holmes/guardrail/blind-spots.js +38 -0
- package/dist/holmes/guardrail/decision-ledger.js +107 -0
- package/dist/holmes/guardrail/executable-artifact.js +129 -0
- package/dist/holmes/guardrail/governance-history.js +101 -0
- package/dist/holmes/guardrail/phase.js +169 -0
- package/dist/holmes/guardrail/risk-classifier.js +450 -0
- package/dist/holmes/guardrail/risk-gate.js +160 -0
- package/dist/holmes/guardrail/risk-types.js +6 -0
- package/dist/holmes/guardrail/tspec-state.js +392 -0
- package/dist/holmes/guardrail/write-target.js +224 -0
- package/dist/holmes/hooks/adapters/antigravity.js +194 -0
- package/dist/holmes/hooks/pre-tool-use.js +1262 -0
- package/dist/holmes/hooks/stop.js +416 -0
- package/dist/holmes/mcp/basis.js +162 -0
- package/dist/holmes/mcp/handlers.js +1831 -0
- package/dist/holmes/mcp/server.js +71 -0
- package/dist/holmes/mcp/stdio-client.js +165 -0
- package/dist/holmes/mcp/supervisor.js +178 -0
- package/dist/holmes/mcp/tool-schemas.js +394 -0
- package/dist/holmes/mcp/validate-args.js +281 -0
- package/dist/holmes/messages/registry.js +50 -0
- package/dist/holmes/project/baseline.js +210 -0
- package/dist/holmes/project/change-source.js +233 -0
- package/dist/holmes/project/ignore.js +145 -0
- package/dist/holmes/project/root.js +113 -0
- package/dist/holmes/reverse/anchor.js +162 -0
- package/dist/holmes/reverse/cluster.js +187 -0
- package/dist/holmes/reverse/draft.js +151 -0
- package/dist/holmes/reverse/dynamic-wiring.js +47 -0
- package/dist/holmes/reverse/scan.js +194 -0
- package/dist/holmes/reverse/surface.js +154 -0
- package/dist/holmes/reverse/test-map.js +263 -0
- package/dist/holmes/review/coverage.js +33 -0
- package/dist/holmes/review/findings.js +123 -0
- package/dist/holmes/review/package.js +40 -0
- package/dist/holmes/review/review-targets.js +92 -0
- package/dist/holmes/review/scope.js +57 -0
- package/dist/holmes/review/test-evidence.js +77 -0
- package/dist/holmes/review/test-runner.js +572 -0
- package/dist/holmes/rtm/dataflow-taint.js +262 -0
- package/dist/holmes/rtm/gap-analyzer.js +27 -0
- package/dist/holmes/rtm/git-changes.js +72 -0
- package/dist/holmes/rtm/incremental.js +45 -0
- package/dist/holmes/rtm/localize.js +100 -0
- package/dist/holmes/rtm/rtm-builder.js +191 -0
- package/dist/holmes/rtm/rtm-check.js +89 -0
- package/dist/holmes/rtm/rtm-graph.js +232 -0
- package/dist/holmes/rtm/taint.js +92 -0
- package/dist/holmes/rtm/test-scope.js +336 -0
- package/dist/holmes/spec/approval-blockers.js +204 -0
- package/dist/holmes/spec/breaking-change.js +89 -0
- package/dist/holmes/spec/legacy-format.js +87 -0
- package/dist/holmes/spec/spec-digest.js +71 -0
- package/dist/holmes/spec/spec-parser.js +106 -0
- package/dist/holmes/spec/spec-store.conformance.js +118 -0
- package/dist/holmes/spec/spec-store.js +331 -0
- package/dist/holmes/spec/spec-types.js +177 -0
- package/dist/holmes/spec/validator.js +280 -0
- package/package.json +76 -0
- package/playbooks/adopt/PLAYBOOK.md +125 -0
- package/playbooks/author-slice/PLAYBOOK.md +119 -0
- 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
|
+
}
|