@holmes-lab/holmes-kit 0.17.0 → 0.19.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 +127 -0
- package/README.md +6 -0
- package/dist/.build-id +1 -1
- package/dist/holmes/cli/release-docs.d.ts +27 -0
- package/dist/holmes/cli/release-docs.js +68 -0
- package/dist/holmes/cpg/arch-observe.d.ts +15 -0
- package/dist/holmes/cpg/arch-observe.js +19 -0
- package/dist/holmes/cpg/cpg-scanner.d.ts +10 -36
- package/dist/holmes/cpg/cpg-scanner.js +27 -3
- package/dist/holmes/cpg/cycle-detect.d.ts +87 -0
- package/dist/holmes/cpg/cycle-detect.js +251 -0
- package/dist/holmes/cpg/scan-cache.d.ts +1 -1
- package/dist/holmes/cpg/scanned-file.d.ts +36 -0
- package/dist/holmes/cpg/scanned-file.js +2 -0
- package/dist/holmes/governance/autonomy.js +1 -0
- package/dist/holmes/governance/constitution.d.ts +20 -0
- package/dist/holmes/governance/constitution.js +17 -0
- package/dist/holmes/governance/ledger-store.d.ts +9 -0
- package/dist/holmes/governance/ledger-store.js +47 -0
- package/dist/holmes/governance/provenance-chain.d.ts +16 -1
- package/dist/holmes/governance/provenance-chain.js +5 -3
- package/dist/holmes/hooks/pre-tool-use.js +3 -1
- package/dist/holmes/hooks/stop.d.ts +14 -0
- package/dist/holmes/hooks/stop.js +73 -0
- package/dist/holmes/mcp/defuse-bound.d.ts +1 -0
- package/dist/holmes/mcp/defuse-bound.js +8 -0
- package/dist/holmes/mcp/handlers.d.ts +23 -0
- package/dist/holmes/mcp/handlers.js +173 -6
- package/dist/holmes/mcp/history-admission.d.ts +15 -0
- package/dist/holmes/mcp/history-admission.js +37 -0
- package/dist/holmes/mcp/maintenance-analyze.d.ts +11 -0
- package/dist/holmes/mcp/maintenance-analyze.js +64 -8
- package/dist/holmes/mcp/spec-id-guard.d.ts +0 -8
- package/dist/holmes/mcp/spec-id-guard.js +10 -1
- package/dist/holmes/review/evaluation-metrics.d.ts +6 -0
- package/dist/holmes/review/evaluation-metrics.js +18 -1
- package/dist/holmes/review/paired-power.d.ts +14 -0
- package/dist/holmes/review/paired-power.js +57 -0
- package/dist/holmes/review/replay-corpus.d.ts +11 -0
- package/dist/holmes/review/replay-corpus.js +34 -0
- package/dist/holmes/review/run-replay.js +60 -4
- package/dist/holmes/review/symbol-truth.d.ts +14 -0
- package/dist/holmes/review/symbol-truth.js +23 -0
- package/dist/holmes/rtm/decision-context.d.ts +23 -0
- package/dist/holmes/rtm/decision-context.js +47 -0
- package/dist/holmes/rtm/defuse-symbols.d.ts +17 -0
- package/dist/holmes/rtm/defuse-symbols.js +91 -0
- package/dist/holmes/rtm/incremental.js +5 -0
- package/dist/holmes/rtm/localize.js +4 -2
- package/dist/holmes/rtm/rtm-builder.d.ts +8 -0
- package/dist/holmes/rtm/rtm-builder.js +34 -5
- package/dist/holmes/rtm/rtm-graph.d.ts +11 -0
- package/dist/holmes/rtm/rtm-graph.js +13 -0
- package/dist/holmes/spec/legacy-fields.d.ts +2 -0
- package/dist/holmes/spec/legacy-fields.js +9 -0
- package/dist/holmes/spec/legacy-format.d.ts +1 -1
- package/dist/holmes/spec/legacy-format.js +4 -1
- package/dist/holmes/spec/spec-parser.js +5 -3
- package/dist/holmes/spec/spec-types.d.ts +1 -1
- package/dist/holmes/spec/spec-types.js +14 -1
- package/package.json +1 -1
- package/playbooks/author-slice/PLAYBOOK.md +33 -0
- package/playbooks/publish/PLAYBOOK.md +32 -0
- package/playbooks/tdd-slice/PLAYBOOK.md +19 -0
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// @implements A-SPEC-573.6
|
|
3
|
+
// Every verdict in REQ-573 — S1 adopted, S2 held at 60, S3 conditional, S4 rejected — was reached
|
|
4
|
+
// without asking whether the observed difference was large enough to be detectable at all. A
|
|
5
|
+
// rejection that a study could never have detected is not evidence of absence, and an adoption of
|
|
6
|
+
// a difference smaller than the noise is not evidence of presence. This computes both, from paired
|
|
7
|
+
// observations, with no dependency and no randomness.
|
|
8
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
9
|
+
exports.editSetF1 = editSetF1;
|
|
10
|
+
exports.pairedPower = pairedPower;
|
|
11
|
+
/** F1 over the SET of files, which is the edit set a caller actually acts on. */
|
|
12
|
+
function editSetF1(predicted, truth) {
|
|
13
|
+
const p = new Set(predicted);
|
|
14
|
+
const t = new Set(truth);
|
|
15
|
+
if (p.size === 0 || t.size === 0)
|
|
16
|
+
return 0;
|
|
17
|
+
let hit = 0;
|
|
18
|
+
for (const f of p)
|
|
19
|
+
if (t.has(f))
|
|
20
|
+
hit++;
|
|
21
|
+
if (hit === 0)
|
|
22
|
+
return 0;
|
|
23
|
+
const precision = hit / p.size;
|
|
24
|
+
const recall = hit / t.size;
|
|
25
|
+
return (2 * precision * recall) / (precision + recall);
|
|
26
|
+
}
|
|
27
|
+
// Two-sided 0.975 and one-sided 0.80 critical values, by degrees of freedom. Small and explicit
|
|
28
|
+
// rather than a dependency: the numbers a verdict rests on should be readable in the file that uses
|
|
29
|
+
// them. Anything past the table is close enough to normal that the approximation is honest — and it
|
|
30
|
+
// is reported as such rather than passed off as exact.
|
|
31
|
+
const T975 = {
|
|
32
|
+
1: 12.706, 2: 4.303, 3: 3.182, 4: 2.776, 5: 2.571, 6: 2.447, 7: 2.365, 8: 2.306, 9: 2.262,
|
|
33
|
+
10: 2.228, 11: 2.201, 12: 2.179, 13: 2.160, 14: 2.145, 15: 2.131, 16: 2.120, 17: 2.110,
|
|
34
|
+
18: 2.101, 19: 2.093, 20: 2.086, 25: 2.060, 30: 2.042, 40: 2.021, 60: 2.000, 120: 1.980,
|
|
35
|
+
};
|
|
36
|
+
const T80 = {
|
|
37
|
+
1: 1.376, 2: 1.061, 3: 0.978, 4: 0.941, 5: 0.920, 6: 0.906, 7: 0.896, 8: 0.889, 9: 0.883,
|
|
38
|
+
10: 0.879, 11: 0.876, 12: 0.873, 13: 0.870, 14: 0.868, 15: 0.866, 16: 0.865, 17: 0.863,
|
|
39
|
+
18: 0.862, 19: 0.861, 20: 0.860, 25: 0.856, 30: 0.854, 40: 0.851, 60: 0.848, 120: 0.845,
|
|
40
|
+
};
|
|
41
|
+
const NORMAL_975 = 1.96;
|
|
42
|
+
const NORMAL_80 = 0.8416;
|
|
43
|
+
function pairedPower(diffs) {
|
|
44
|
+
const n = diffs.length;
|
|
45
|
+
if (n === 0)
|
|
46
|
+
return { n: 0, meanDiff: null, sdDiff: null, sem: null, mde80: null, approx: false };
|
|
47
|
+
const meanDiff = diffs.reduce((s, x) => s + x, 0) / n;
|
|
48
|
+
if (n < 2)
|
|
49
|
+
return { n, meanDiff, sdDiff: null, sem: null, mde80: null, approx: false };
|
|
50
|
+
const variance = diffs.reduce((s, x) => s + (x - meanDiff) ** 2, 0) / (n - 1);
|
|
51
|
+
const sdDiff = Math.sqrt(variance);
|
|
52
|
+
const sem = sdDiff / Math.sqrt(n);
|
|
53
|
+
const df = n - 1;
|
|
54
|
+
const exact = T975[df] !== undefined && T80[df] !== undefined;
|
|
55
|
+
const [tAlpha, tBeta] = exact ? [T975[df], T80[df]] : [NORMAL_975, NORMAL_80];
|
|
56
|
+
return { n, meanDiff, sdDiff, sem, mde80: (tAlpha + tBeta) * sem, approx: !exact };
|
|
57
|
+
}
|
|
@@ -44,6 +44,17 @@ export interface ReplayCase {
|
|
|
44
44
|
/** Test files the commit changed. */
|
|
45
45
|
tests: string[];
|
|
46
46
|
}
|
|
47
|
+
/**
|
|
48
|
+
* The PARENT-side line numbers a commit changed in one file.
|
|
49
|
+
*
|
|
50
|
+
* Parent-side, not head-side, because the symbol ranges these are intersected with come from the
|
|
51
|
+
* parent-time scan — the tree the analysis actually saw. Reading head-side numbers would line up
|
|
52
|
+
* the diff against a file the analyzer never had.
|
|
53
|
+
*
|
|
54
|
+
* Returns an empty list rather than throwing: a file the parent did not have (the commit created
|
|
55
|
+
* it) has no parent lines, and that is an answer, not a failure.
|
|
56
|
+
*/
|
|
57
|
+
export declare function changedParentLines(root: string, commit: string, file: string): number[];
|
|
47
58
|
export declare const HOLMES_CORPUS: ReplayCorpus;
|
|
48
59
|
/**
|
|
49
60
|
* The second corpus. Measured 2026-08-29: 269 source files, 329 commits, 251 eligible cases, 959
|
|
@@ -34,6 +34,7 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
34
34
|
})();
|
|
35
35
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
36
|
exports.GEMINI_CLI_MEASUREMENT = exports.GEMINI_CLI_CORPUS = exports.JARVIS_CORPUS = exports.HOLMES_CORPUS = void 0;
|
|
37
|
+
exports.changedParentLines = changedParentLines;
|
|
37
38
|
exports.casesFor = casesFor;
|
|
38
39
|
exports.thinSpecs = thinSpecs;
|
|
39
40
|
exports.casesAvoidingRecent = casesAvoidingRecent;
|
|
@@ -44,6 +45,39 @@ const path = __importStar(require("node:path"));
|
|
|
44
45
|
const node_child_process_1 = require("node:child_process");
|
|
45
46
|
const maintenance_analyze_1 = require("../mcp/maintenance-analyze");
|
|
46
47
|
const TS_FAMILY = (f) => f.endsWith('.ts') && !f.endsWith('.d.ts');
|
|
48
|
+
// @implements A-SPEC-573.3
|
|
49
|
+
/**
|
|
50
|
+
* The PARENT-side line numbers a commit changed in one file.
|
|
51
|
+
*
|
|
52
|
+
* Parent-side, not head-side, because the symbol ranges these are intersected with come from the
|
|
53
|
+
* parent-time scan — the tree the analysis actually saw. Reading head-side numbers would line up
|
|
54
|
+
* the diff against a file the analyzer never had.
|
|
55
|
+
*
|
|
56
|
+
* Returns an empty list rather than throwing: a file the parent did not have (the commit created
|
|
57
|
+
* it) has no parent lines, and that is an answer, not a failure.
|
|
58
|
+
*/
|
|
59
|
+
function changedParentLines(root, commit, file) {
|
|
60
|
+
let raw;
|
|
61
|
+
try {
|
|
62
|
+
raw = (0, node_child_process_1.execFileSync)('git', ['-C', root, 'show', '--unified=0', '--format=', commit, '--', file], { encoding: 'utf8', maxBuffer: 16 * 1024 * 1024 });
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
return [];
|
|
66
|
+
}
|
|
67
|
+
const lines = [];
|
|
68
|
+
for (const line of raw.split('\n')) {
|
|
69
|
+
// `@@ -12,3 +12,4 @@` — the old-side start and count. A count of 0 marks a pure insertion, which
|
|
70
|
+
// touched no existing line and therefore no existing symbol.
|
|
71
|
+
const m = /^@@ -(\d+)(?:,(\d+))? /.exec(line);
|
|
72
|
+
if (m === null)
|
|
73
|
+
continue;
|
|
74
|
+
const start = Number(m[1]);
|
|
75
|
+
const count = m[2] === undefined ? 1 : Number(m[2]);
|
|
76
|
+
for (let i = 0; i < count; i++)
|
|
77
|
+
lines.push(start + i);
|
|
78
|
+
}
|
|
79
|
+
return lines;
|
|
80
|
+
}
|
|
47
81
|
exports.HOLMES_CORPUS = {
|
|
48
82
|
root: path.resolve(__dirname, '../../..'),
|
|
49
83
|
// @implements A-SPEC-443 — pinned so the floors stay floors. Our own repository moves too; the
|
|
@@ -46,6 +46,9 @@ const node_child_process_1 = require("node:child_process");
|
|
|
46
46
|
const os = __importStar(require("node:os"));
|
|
47
47
|
const path = __importStar(require("node:path"));
|
|
48
48
|
const cpg_scanner_1 = require("../cpg/cpg-scanner");
|
|
49
|
+
const language_parser_1 = require("../cpg/language-parser");
|
|
50
|
+
// @implements A-SPEC-573.4 — same bound as the handler, one constant so the two cannot drift.
|
|
51
|
+
const defuse_bound_1 = require("../mcp/defuse-bound");
|
|
49
52
|
const rtm_builder_1 = require("../rtm/rtm-builder");
|
|
50
53
|
const rtm_graph_1 = require("../rtm/rtm-graph");
|
|
51
54
|
const test_scope_1 = require("../rtm/test-scope");
|
|
@@ -55,6 +58,7 @@ const maintenance_analyze_1 = require("../mcp/maintenance-analyze");
|
|
|
55
58
|
const point_in_time_replay_1 = require("./point-in-time-replay");
|
|
56
59
|
const evaluation_metrics_1 = require("./evaluation-metrics");
|
|
57
60
|
const replay_corpus_1 = require("./replay-corpus");
|
|
61
|
+
const symbol_truth_1 = require("./symbol-truth");
|
|
58
62
|
const semantic_arm_1 = require("./semantic-arm");
|
|
59
63
|
// @implements A-SPEC-479 — pre-emission verification of the union answer (measurement only).
|
|
60
64
|
// @implements A-SPEC-488 — lexPoverty drives the gate simulation ("uncited ∨ lexically poor").
|
|
@@ -212,25 +216,77 @@ async function runReplay(corpus, limit, opts = {}) {
|
|
|
212
216
|
// reported from it understated what a caller actually gets — a benchmark measuring something
|
|
213
217
|
// adjacent to the product is worse than one measuring nothing, because it reads as if it
|
|
214
218
|
// measured the product.
|
|
215
|
-
|
|
219
|
+
// @implements A-SPEC-573.3 — and the boost is handed over UNFILTERED. This benchmark used to
|
|
220
|
+
// narrow it to the scanned set on its own, which is a filter the product did not have: the
|
|
221
|
+
// pipeline being scored was not the pipeline that shipped, so a defect worth 63.3% of the
|
|
222
|
+
// product's emitted slots read as six-decimal NO MOVEMENT here (measured, S1). Admission is
|
|
223
|
+
// the product's business now (A-SPEC-573.1/573.2), and the bench inherits whatever it does.
|
|
216
224
|
const profile = (0, commit_text_1.commitTextProfile)(corpus.root, parent, 400);
|
|
217
|
-
const ct = (0, commit_text_1.rankByCommitText)(c.subject, profile, 300)
|
|
225
|
+
const ct = (0, commit_text_1.rankByCommitText)(c.subject, profile, 300);
|
|
218
226
|
const top = ct[0]?.score ?? 0;
|
|
219
227
|
const commitTextBoost = {};
|
|
220
228
|
if (top > 0)
|
|
221
229
|
for (const h of ct)
|
|
222
230
|
commitTextBoost[h.file] = h.score / top;
|
|
223
|
-
const
|
|
231
|
+
const analyzeWith = (defUse) => (0, maintenance_analyze_1.analyzeMaintenance)({
|
|
224
232
|
request: c.subject, scanned, specs, graph, testAnchors: anchors, history: [], changePrior, commitTextBoost,
|
|
225
233
|
groundTruth: { files: c.files, tests: c.tests },
|
|
226
234
|
basis: { head: parent, loadedBuild: 'b', diskBuild: 'b', specFingerprint: 'fp' },
|
|
227
235
|
coverage: { scannedFiles: scanned.length, skippedFiles: [], unsupportedLanguages: [] },
|
|
236
|
+
defUse,
|
|
228
237
|
});
|
|
238
|
+
// @implements A-SPEC-573.4 — the same two-pass the handler runs, for the same reason the
|
|
239
|
+
// bench stopped filtering the boost on its own: what is measured has to be what ships.
|
|
240
|
+
const firstPass = analyzeWith();
|
|
241
|
+
const result = (() => {
|
|
242
|
+
const targets = firstPass.candidates.slice(0, defuse_bound_1.DEFUSE_TOP_FILES).map((x) => x.file);
|
|
243
|
+
if (targets.length === 0)
|
|
244
|
+
return firstPass;
|
|
245
|
+
const defUse = {};
|
|
246
|
+
const parser = new language_parser_1.TreeSitterTsParser();
|
|
247
|
+
for (const file of targets) {
|
|
248
|
+
try {
|
|
249
|
+
const lang = (0, cpg_scanner_1.langForPath)(file);
|
|
250
|
+
if (!(0, language_parser_1.hasDataFlowWalk)(lang))
|
|
251
|
+
continue;
|
|
252
|
+
const facts = parser.extractDataFlow(fs.readFileSync(path.join(dest, file), 'utf8'), lang);
|
|
253
|
+
if (facts !== undefined)
|
|
254
|
+
defUse[file] = facts;
|
|
255
|
+
}
|
|
256
|
+
catch { /* fail-open, per file */ }
|
|
257
|
+
}
|
|
258
|
+
return Object.keys(defUse).length === 0 ? firstPass : analyzeWith(defUse);
|
|
259
|
+
})();
|
|
229
260
|
const ranked = result.candidates.map((x) => x.file);
|
|
230
261
|
// @implements A-SPEC-469 — the union scores what a caller actually RECEIVES as the impact
|
|
231
262
|
// answer, and that surface is now the graded rankedImpact (the closure stays gate-facing).
|
|
232
263
|
const impacted = (result.impacts?.rankedImpact ?? []).map((r) => r.file);
|
|
233
|
-
|
|
264
|
+
// @implements A-SPEC-573.3 — the function axis, derived from what this loop already holds:
|
|
265
|
+
// the commit's parent-side changed lines and the parent-time scan's symbol ranges. A case
|
|
266
|
+
// whose change fell outside every symbol contributes no truth and drops out of that
|
|
267
|
+
// denominator; it is not scored zero.
|
|
268
|
+
const byPathScanned = new Map(scanned.map((f) => [f.sourcePath, f]));
|
|
269
|
+
const truthSymbols = [];
|
|
270
|
+
for (const file of c.files) {
|
|
271
|
+
const sf = byPathScanned.get(file);
|
|
272
|
+
if (sf === undefined)
|
|
273
|
+
continue;
|
|
274
|
+
const ranges = sf.symbols.map((sy) => ({ name: sy.qualifiedName, startLine: sy.startLine, endLine: sy.endLine }));
|
|
275
|
+
for (const name of (0, symbol_truth_1.symbolsTouched)(ranges, (0, replay_corpus_1.changedParentLines)(corpus.root, c.commit, file))) {
|
|
276
|
+
if (!truthSymbols.includes(name))
|
|
277
|
+
truthSymbols.push(name);
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
// The prediction is the symbols the emitted candidates carried, in candidate order — the
|
|
281
|
+
// same list a caller reads, never a separately computed one.
|
|
282
|
+
const rankedSymbols = [];
|
|
283
|
+
for (const cand of result.candidates) {
|
|
284
|
+
for (const sy of cand.symbols)
|
|
285
|
+
if (!rankedSymbols.includes(sy))
|
|
286
|
+
rankedSymbols.push(sy);
|
|
287
|
+
}
|
|
288
|
+
outcomes.push({ ranked, truthFiles: c.files, selectedTests: [...result.relevantTests], truthTests: c.tests,
|
|
289
|
+
truthSymbols, rankedSymbols });
|
|
234
290
|
// @implements A-SPEC-487 — the dump: 2-pass with semantic injected when asked (vectors
|
|
235
291
|
// only for the files the 1-pass surfaced — the hot-path lookup contract holds), 1-pass
|
|
236
292
|
// otherwise. Nothing here feeds outcomes or any pin.
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export interface SymbolRange {
|
|
2
|
+
name: string;
|
|
3
|
+
startLine: number;
|
|
4
|
+
endLine: number;
|
|
5
|
+
}
|
|
6
|
+
/**
|
|
7
|
+
* The symbols a commit's changed lines fall inside, in symbol-list order, deduped.
|
|
8
|
+
*
|
|
9
|
+
* A change that lands outside every symbol (an import line, a top-level constant) yields nothing —
|
|
10
|
+
* and a case with no symbol truth drops OUT of the symbol denominator rather than scoring zero. An
|
|
11
|
+
* unanswerable case is not a failed one, which is the same rule `evaluationMetrics` already applies
|
|
12
|
+
* to files.
|
|
13
|
+
*/
|
|
14
|
+
export declare function symbolsTouched(symbols: readonly SymbolRange[], changedLines: readonly number[]): string[];
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.symbolsTouched = symbolsTouched;
|
|
4
|
+
/**
|
|
5
|
+
* The symbols a commit's changed lines fall inside, in symbol-list order, deduped.
|
|
6
|
+
*
|
|
7
|
+
* A change that lands outside every symbol (an import line, a top-level constant) yields nothing —
|
|
8
|
+
* and a case with no symbol truth drops OUT of the symbol denominator rather than scoring zero. An
|
|
9
|
+
* unanswerable case is not a failed one, which is the same rule `evaluationMetrics` already applies
|
|
10
|
+
* to files.
|
|
11
|
+
*/
|
|
12
|
+
function symbolsTouched(symbols, changedLines) {
|
|
13
|
+
if (symbols.length === 0 || changedLines.length === 0)
|
|
14
|
+
return [];
|
|
15
|
+
const out = [];
|
|
16
|
+
for (const s of symbols) {
|
|
17
|
+
// Both ends inclusive: the signature line and the closing line are part of the symbol.
|
|
18
|
+
if (changedLines.some((line) => line >= s.startLine && line <= s.endLine) && !out.includes(s.name)) {
|
|
19
|
+
out.push(s.name);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
return out;
|
|
23
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The DECISION context of a candidate — "why is this the way it is?".
|
|
3
|
+
*
|
|
4
|
+
* A person diagnosing a defect asks two questions in order: what broke (the candidates), and then
|
|
5
|
+
* why this code was left this way (the decision). The graph already answers the second — REQ-571
|
|
6
|
+
* put store ADRs into the decision population and A-SPEC-293 already builds `constrained_by` from
|
|
7
|
+
* citations — so this is a lookup, not a new mechanism: no new edge kind, no new tool.
|
|
8
|
+
*
|
|
9
|
+
* OBSERVATION ONLY. The context rides beside a candidate; nothing here enters the ranking, the
|
|
10
|
+
* score, or any gate (the c6 rule — prose informs, it never adjudicates).
|
|
11
|
+
*/
|
|
12
|
+
export interface DecisionContextEntry {
|
|
13
|
+
adr: string;
|
|
14
|
+
/** The decision's own sentence, or null when the store carries no summary for it (id stays). */
|
|
15
|
+
decision: string | null;
|
|
16
|
+
}
|
|
17
|
+
export interface DecisionGraphLike {
|
|
18
|
+
decisionsConstraining(nodeIds: readonly string[]): string[];
|
|
19
|
+
summaryOf(id: string): string | null;
|
|
20
|
+
}
|
|
21
|
+
/** How many decisions ride beside one candidate. A prose constant — never a verdict input. */
|
|
22
|
+
export declare const MAX_DECISIONS = 3;
|
|
23
|
+
export declare function decisionContextFor(files: readonly string[], specIdsOf: (file: string) => readonly string[], graph: DecisionGraphLike): Map<string, DecisionContextEntry[]>;
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// @implements A-SPEC-572.3
|
|
3
|
+
/**
|
|
4
|
+
* The DECISION context of a candidate — "why is this the way it is?".
|
|
5
|
+
*
|
|
6
|
+
* A person diagnosing a defect asks two questions in order: what broke (the candidates), and then
|
|
7
|
+
* why this code was left this way (the decision). The graph already answers the second — REQ-571
|
|
8
|
+
* put store ADRs into the decision population and A-SPEC-293 already builds `constrained_by` from
|
|
9
|
+
* citations — so this is a lookup, not a new mechanism: no new edge kind, no new tool.
|
|
10
|
+
*
|
|
11
|
+
* OBSERVATION ONLY. The context rides beside a candidate; nothing here enters the ranking, the
|
|
12
|
+
* score, or any gate (the c6 rule — prose informs, it never adjudicates).
|
|
13
|
+
*/
|
|
14
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
15
|
+
exports.MAX_DECISIONS = void 0;
|
|
16
|
+
exports.decisionContextFor = decisionContextFor;
|
|
17
|
+
/** How many decisions ride beside one candidate. A prose constant — never a verdict input. */
|
|
18
|
+
exports.MAX_DECISIONS = 3;
|
|
19
|
+
function decisionContextFor(files, specIdsOf, graph) {
|
|
20
|
+
const out = new Map();
|
|
21
|
+
for (const file of files) {
|
|
22
|
+
let adrs = [];
|
|
23
|
+
try {
|
|
24
|
+
// The file itself AND the specs it anchors: a person chasing "why" follows both, and the
|
|
25
|
+
// constraint is often recorded one level up, on the spec rather than on the file.
|
|
26
|
+
adrs = graph.decisionsConstraining([
|
|
27
|
+
`FILE:${file}`,
|
|
28
|
+
...specIdsOf(file).map((id) => `SPEC:${id}`),
|
|
29
|
+
]);
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
continue;
|
|
33
|
+
} // an unreadable graph yields no context, never a throw
|
|
34
|
+
const shown = [...new Set(adrs)].sort().slice(0, exports.MAX_DECISIONS);
|
|
35
|
+
if (shown.length === 0)
|
|
36
|
+
continue; // no entry rather than an invented empty list
|
|
37
|
+
out.set(file, shown.map((adr) => {
|
|
38
|
+
let decision = null;
|
|
39
|
+
try {
|
|
40
|
+
decision = graph.summaryOf(`SPEC:${adr}`);
|
|
41
|
+
}
|
|
42
|
+
catch { /* id stays, prose does not */ }
|
|
43
|
+
return { adr, decision };
|
|
44
|
+
}));
|
|
45
|
+
}
|
|
46
|
+
return out;
|
|
47
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { DataFlowFacts } from '../cpg/language-parser';
|
|
2
|
+
/**
|
|
3
|
+
* Order a file's function names by how close they sit, in def-use terms, to what the request says.
|
|
4
|
+
*
|
|
5
|
+
* Three tiers, stable within each so the result is deterministic (the downstream lists are
|
|
6
|
+
* position-indexed): functions the request names directly, functions one def-use step from those,
|
|
7
|
+
* then everything else in its original order. A term matching nothing leaves the order untouched —
|
|
8
|
+
* an empty first tier must not shuffle the list.
|
|
9
|
+
*/
|
|
10
|
+
export declare function rankSymbolsByDefUse(facts: DataFlowFacts, terms: readonly string[], order: readonly string[]): string[];
|
|
11
|
+
/**
|
|
12
|
+
* Append what def-use suggested behind what the lexical layer matched.
|
|
13
|
+
*
|
|
14
|
+
* Never in front: this repository has already measured a re-ranker deleting the lexical answer, so
|
|
15
|
+
* a second signal rides behind the first rather than replacing it.
|
|
16
|
+
*/
|
|
17
|
+
export declare function enrichCandidateSymbols(lexical: readonly string[], suggested: readonly string[]): string[];
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.rankSymbolsByDefUse = rankSymbolsByDefUse;
|
|
4
|
+
exports.enrichCandidateSymbols = enrichCandidateSymbols;
|
|
5
|
+
/** Names a function mentions anywhere the walk recovered them: params, defs, and call arguments. */
|
|
6
|
+
function surfaceOf(facts) {
|
|
7
|
+
const out = new Map();
|
|
8
|
+
const push = (fn, ...words) => {
|
|
9
|
+
const bucket = out.get(fn) ?? out.set(fn, []).get(fn);
|
|
10
|
+
for (const w of words)
|
|
11
|
+
if (w !== '' && !bucket.includes(w))
|
|
12
|
+
bucket.push(w);
|
|
13
|
+
};
|
|
14
|
+
for (const p of facts.params)
|
|
15
|
+
push(p.fn, p.name);
|
|
16
|
+
for (const d of facts.defs)
|
|
17
|
+
push(d.fn, d.name, ...d.expr.refs, ...d.expr.callees);
|
|
18
|
+
for (const c of facts.calls) {
|
|
19
|
+
push(c.fn, c.callee);
|
|
20
|
+
for (const a of c.args)
|
|
21
|
+
push(c.fn, ...a.refs, ...a.callees);
|
|
22
|
+
}
|
|
23
|
+
for (const r of facts.returns)
|
|
24
|
+
push(r.fn, ...r.expr.refs, ...r.expr.callees);
|
|
25
|
+
return out;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Order a file's function names by how close they sit, in def-use terms, to what the request says.
|
|
29
|
+
*
|
|
30
|
+
* Three tiers, stable within each so the result is deterministic (the downstream lists are
|
|
31
|
+
* position-indexed): functions the request names directly, functions one def-use step from those,
|
|
32
|
+
* then everything else in its original order. A term matching nothing leaves the order untouched —
|
|
33
|
+
* an empty first tier must not shuffle the list.
|
|
34
|
+
*/
|
|
35
|
+
function rankSymbolsByDefUse(facts, terms, order) {
|
|
36
|
+
if (order.length === 0)
|
|
37
|
+
return [];
|
|
38
|
+
const lowered = terms.map((t) => t.toLowerCase()).filter((t) => t !== '');
|
|
39
|
+
if (lowered.length === 0)
|
|
40
|
+
return [...order];
|
|
41
|
+
const surface = surfaceOf(facts);
|
|
42
|
+
// A function is "named" when a request term appears in its own name or in any identifier its
|
|
43
|
+
// body touches. Substring, not equality: `config` has to reach `configPath`.
|
|
44
|
+
const names = (fn) => [fn, ...(surface.get(fn) ?? [])];
|
|
45
|
+
const direct = new Set();
|
|
46
|
+
for (const fn of new Set([...order, ...surface.keys()])) {
|
|
47
|
+
if (names(fn).some((n) => lowered.some((t) => n.toLowerCase().includes(t))))
|
|
48
|
+
direct.add(fn);
|
|
49
|
+
}
|
|
50
|
+
if (direct.size === 0)
|
|
51
|
+
return [...order];
|
|
52
|
+
// One def-use step: a function that reads what a direct one defined, or that sits on either end
|
|
53
|
+
// of a call with it. Call adjacency counts BOTH ways — the caller of a named function is as
|
|
54
|
+
// relevant as its callee, and a one-directional rule would silently favour one.
|
|
55
|
+
const definedByDirect = new Set();
|
|
56
|
+
for (const d of facts.defs)
|
|
57
|
+
if (direct.has(d.fn))
|
|
58
|
+
definedByDirect.add(d.name);
|
|
59
|
+
const near = new Set();
|
|
60
|
+
const mark = (fn) => { if (!direct.has(fn))
|
|
61
|
+
near.add(fn); };
|
|
62
|
+
for (const d of facts.defs)
|
|
63
|
+
if (d.expr.refs.some((r) => definedByDirect.has(r)))
|
|
64
|
+
mark(d.fn);
|
|
65
|
+
for (const c of facts.calls) {
|
|
66
|
+
if (c.args.some((a) => a.refs.some((r) => definedByDirect.has(r))))
|
|
67
|
+
mark(c.fn);
|
|
68
|
+
if (direct.has(c.fn))
|
|
69
|
+
mark(c.callee);
|
|
70
|
+
if (direct.has(c.callee))
|
|
71
|
+
mark(c.fn);
|
|
72
|
+
}
|
|
73
|
+
for (const r of facts.returns)
|
|
74
|
+
if (r.expr.refs.some((x) => definedByDirect.has(x)))
|
|
75
|
+
mark(r.fn);
|
|
76
|
+
const tier = (fn) => (direct.has(fn) ? 0 : near.has(fn) ? 1 : 2);
|
|
77
|
+
return [...order].sort((a, b) => tier(a) - tier(b) || order.indexOf(a) - order.indexOf(b));
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Append what def-use suggested behind what the lexical layer matched.
|
|
81
|
+
*
|
|
82
|
+
* Never in front: this repository has already measured a re-ranker deleting the lexical answer, so
|
|
83
|
+
* a second signal rides behind the first rather than replacing it.
|
|
84
|
+
*/
|
|
85
|
+
function enrichCandidateSymbols(lexical, suggested) {
|
|
86
|
+
const out = [...lexical];
|
|
87
|
+
for (const s of suggested)
|
|
88
|
+
if (!out.includes(s))
|
|
89
|
+
out.push(s);
|
|
90
|
+
return out;
|
|
91
|
+
}
|
|
@@ -56,7 +56,12 @@ function applyIncremental(graph, changes, ctx) {
|
|
|
56
56
|
}
|
|
57
57
|
if (!ctx.allScanned)
|
|
58
58
|
return { callEdgesResolved: false };
|
|
59
|
+
// @implements A-SPEC-573.5 — `calls_ambiguous` is rebuilt by the same pass, so it must be cleared
|
|
60
|
+
// by the same pass. Missing it left a stale candidate edge behind when an ambiguity RESOLVED
|
|
61
|
+
// (the second definer was deleted), and incremental stopped converging with a full rebuild —
|
|
62
|
+
// caught by A-SPEC-280's convergence property, which is exactly what it is for.
|
|
59
63
|
graph.removeEdgesByRel('calls');
|
|
64
|
+
graph.removeEdgesByRel('calls_ambiguous');
|
|
60
65
|
(0, rtm_builder_1.addCallEdges)(ctx.allScanned(), graph, ctx.buildOptions);
|
|
61
66
|
return { callEdgesResolved: true };
|
|
62
67
|
}
|
|
@@ -10,8 +10,10 @@ exports.localizeIssue = localizeIssue;
|
|
|
10
10
|
* repository's `S-<n>` slice shorthand (which names the whole REQ->T-SPEC chain at that number).
|
|
11
11
|
* The prefix is REQUIRED — a bare `262` in "262 files were rewritten" is a number, not a citation.
|
|
12
12
|
*/
|
|
13
|
-
|
|
14
|
-
|
|
13
|
+
// @implements A-SPEC-571.2 — ADR is citable like any governed id (recognition only; the match
|
|
14
|
+
// SCORING population stays A-SPEC-limited below, so an ADR never admits or reorders a file).
|
|
15
|
+
const CITATION = /\b(?:(A-SPEC|H-SPEC|T-SPEC|C-SPEC|ADR|REQ)-(\d+(?:\.\d+)?)|S-(\d+(?:\.\d+)?))\b/gi;
|
|
16
|
+
const CITABLE_KINDS = ['REQ', 'H-SPEC', 'A-SPEC', 'T-SPEC', 'C-SPEC', 'ADR'];
|
|
15
17
|
/**
|
|
16
18
|
* @implements A-SPEC-273
|
|
17
19
|
* How much a citation is worth, normalized by the file's anchor count exactly as the lexical spec
|
|
@@ -16,6 +16,14 @@ import { ScannedFile } from '../cpg/cpg-scanner';
|
|
|
16
16
|
*/
|
|
17
17
|
export interface ResolutionReport {
|
|
18
18
|
resolved: number;
|
|
19
|
+
/**
|
|
20
|
+
* @implements A-SPEC-573.5
|
|
21
|
+
* Candidate edges kept for a call whose name is defined in more than one place. Census on this
|
|
22
|
+
* repository (2026-09-08): 102 qualified-name and 590 last-segment collisions were being dropped
|
|
23
|
+
* against 3,067 resolved edges — 22.6% of the call graph, at a mean fan-out of 2.29. They ride on
|
|
24
|
+
* the `calls_ambiguous` relation, which every certainty-requiring reader filters out by name.
|
|
25
|
+
*/
|
|
26
|
+
ambiguousEdges: number;
|
|
19
27
|
/** Callee defined in several files: precision-over-recall refused to guess. A real miss. */
|
|
20
28
|
ambiguous: number;
|
|
21
29
|
/** Callee defined nowhere in the scan — usually an external package or runtime builtin. */
|
|
@@ -73,6 +73,8 @@ const INTENT_SECTION = {
|
|
|
73
73
|
'REQ': 'Problem / Need',
|
|
74
74
|
'H-SPEC': 'Intent',
|
|
75
75
|
'A-SPEC': 'Objective',
|
|
76
|
+
// @implements A-SPEC-571.2 — a decision's intent IS its Decision line.
|
|
77
|
+
'ADR': 'Decision',
|
|
76
78
|
};
|
|
77
79
|
/** Longest intent sentence carried into the graph — a bound, applied deterministically. */
|
|
78
80
|
const SUMMARY_SENTENCE_CAP = 200;
|
|
@@ -137,7 +139,7 @@ function buildRtm(specs, scanned, graph, opts) {
|
|
|
137
139
|
// on a 56k-insert graph (216 ms -> 173 ms with a transaction; 88 -> 52 ms with cached statements).
|
|
138
140
|
// It also makes a failed build atomic — no half-graph that could be mistaken for a complete one.
|
|
139
141
|
let resolution = {
|
|
140
|
-
resolved: 0, ambiguous: 0, unknownTarget: 0, callerNotNamed: 0, selfReference: 0, moduleScoped: 0,
|
|
142
|
+
resolved: 0, ambiguousEdges: 0, ambiguous: 0, unknownTarget: 0, callerNotNamed: 0, selfReference: 0, moduleScoped: 0,
|
|
141
143
|
};
|
|
142
144
|
graph.transaction(() => {
|
|
143
145
|
// Add spec nodes and dependencies
|
|
@@ -183,7 +185,7 @@ function buildRtm(specs, scanned, graph, opts) {
|
|
|
183
185
|
*/
|
|
184
186
|
function addCallEdges(scanned, graph, opts) {
|
|
185
187
|
const report = {
|
|
186
|
-
resolved: 0, ambiguous: 0, unknownTarget: 0, callerNotNamed: 0, selfReference: 0, moduleScoped: 0,
|
|
188
|
+
resolved: 0, ambiguousEdges: 0, ambiguous: 0, unknownTarget: 0, callerNotNamed: 0, selfReference: 0, moduleScoped: 0,
|
|
187
189
|
};
|
|
188
190
|
// @implements A-SPEC-300
|
|
189
191
|
// Indexes are built PER LANGUAGE FAMILY. Resolution used to look across the whole scan, so a Java
|
|
@@ -245,11 +247,37 @@ function addCallEdges(scanned, graph, opts) {
|
|
|
245
247
|
}
|
|
246
248
|
const family = familyOf(f.sourcePath);
|
|
247
249
|
const table = definedIn.get(family);
|
|
250
|
+
// @implements A-SPEC-573.5 — an ambiguous target is a CANDIDATE SET, not a dead end. Both
|
|
251
|
+
// shapes of ambiguity keep every candidate on `calls_ambiguous`; `calls` still means "one
|
|
252
|
+
// place, certain", so nothing that reads certainty changes. Emitting nothing was buying
|
|
253
|
+
// precision at the cost of 22.6% of the call graph.
|
|
254
|
+
const ambiguousCandidates = (qn, paths) => {
|
|
255
|
+
for (const p of paths) {
|
|
256
|
+
const to = `CODE:${qn}@${p}`;
|
|
257
|
+
if (to === fromId)
|
|
258
|
+
continue; // self-recursion carries no impact, ambiguous or not
|
|
259
|
+
graph.addEdge(fromId, to, 'calls_ambiguous', f.sourcePath, fact(opts, `${f.sourcePath}`, 'name-resolution', null));
|
|
260
|
+
report.ambiguousEdges++;
|
|
261
|
+
}
|
|
262
|
+
};
|
|
248
263
|
let target = null;
|
|
249
264
|
if (table?.has(e.to))
|
|
250
265
|
target = { qn: e.to, paths: table.get(e.to) };
|
|
251
|
-
else
|
|
252
|
-
|
|
266
|
+
else {
|
|
267
|
+
const byLast = byLastSegment.get(family);
|
|
268
|
+
const hit = byLast?.get(e.to);
|
|
269
|
+
if (hit === null) {
|
|
270
|
+
// The last segment is owned by several qualified names — keep every owner as a candidate.
|
|
271
|
+
for (const [qn, paths] of table ?? []) {
|
|
272
|
+
const last = qn.includes('.') ? qn.slice(qn.lastIndexOf('.') + 1) : qn;
|
|
273
|
+
if (last === e.to)
|
|
274
|
+
ambiguousCandidates(qn, paths);
|
|
275
|
+
}
|
|
276
|
+
report.unknownTarget++;
|
|
277
|
+
continue;
|
|
278
|
+
}
|
|
279
|
+
target = hit ?? null;
|
|
280
|
+
}
|
|
253
281
|
if (!target) {
|
|
254
282
|
report.unknownTarget++;
|
|
255
283
|
continue;
|
|
@@ -257,9 +285,10 @@ function addCallEdges(scanned, graph, opts) {
|
|
|
257
285
|
const sameFile = target.paths.includes(f.sourcePath);
|
|
258
286
|
const path = sameFile ? f.sourcePath : (target.paths.length === 1 ? target.paths[0] : null);
|
|
259
287
|
if (!path) {
|
|
288
|
+
ambiguousCandidates(target.qn, target.paths);
|
|
260
289
|
report.ambiguous++;
|
|
261
290
|
continue;
|
|
262
|
-
}
|
|
291
|
+
}
|
|
263
292
|
const toId = `CODE:${target.qn}@${path}`;
|
|
264
293
|
if (toId === fromId) {
|
|
265
294
|
report.selfReference++;
|
|
@@ -136,6 +136,17 @@ export declare class RtmGraph {
|
|
|
136
136
|
*/
|
|
137
137
|
maxCalleeInDegree?: number;
|
|
138
138
|
}): string[];
|
|
139
|
+
/**
|
|
140
|
+
* Every resolved FILE->FILE `imports` edge, as repo-relative path pairs.
|
|
141
|
+
*
|
|
142
|
+
* Exists because cycle detection needs the whole import graph at once, and the alternative —
|
|
143
|
+
* parsing `dumpCanonical()` — builds a multi-megabyte string on every call to answer a question
|
|
144
|
+
* one query answers.
|
|
145
|
+
*/
|
|
146
|
+
importEdges(): Array<{
|
|
147
|
+
from: string;
|
|
148
|
+
to: string;
|
|
149
|
+
}>;
|
|
139
150
|
/**
|
|
140
151
|
* @implements A-SPEC-289
|
|
141
152
|
* Repo-relative paths of the files that import this one, via resolved FILE->FILE `imports` edges.
|
|
@@ -224,6 +224,19 @@ class RtmGraph {
|
|
|
224
224
|
: this.callees(id).filter((callee) => this.callerCount(callee) <= limit);
|
|
225
225
|
return [...new Set([...this.impactSourcesOf(id), ...downstream])].sort();
|
|
226
226
|
}
|
|
227
|
+
// @implements A-SPEC-574.2
|
|
228
|
+
/**
|
|
229
|
+
* Every resolved FILE->FILE `imports` edge, as repo-relative path pairs.
|
|
230
|
+
*
|
|
231
|
+
* Exists because cycle detection needs the whole import graph at once, and the alternative —
|
|
232
|
+
* parsing `dumpCanonical()` — builds a multi-megabyte string on every call to answer a question
|
|
233
|
+
* one query answers.
|
|
234
|
+
*/
|
|
235
|
+
importEdges() {
|
|
236
|
+
return this.db.prepare("SELECT DISTINCT src, dst FROM edges WHERE rel='imports' ORDER BY src ASC, dst ASC")
|
|
237
|
+
.all()
|
|
238
|
+
.map((r) => ({ from: r.src.replace(/^FILE:/, ''), to: r.dst.replace(/^FILE:/, '') }));
|
|
239
|
+
}
|
|
227
240
|
/**
|
|
228
241
|
* @implements A-SPEC-289
|
|
229
242
|
* Repo-relative paths of the files that import this one, via resolved FILE->FILE `imports` edges.
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.LEGACY_DEPENDS_FIELD = void 0;
|
|
4
|
+
// @implements A-SPEC-574.3
|
|
5
|
+
// One constant, in a module neither side owns. `spec-parser` needed the field name and `legacy-format`
|
|
6
|
+
// needed the parser's `Spec` type, so each imported the other — a cycle held up by a single string.
|
|
7
|
+
// Shared things belong to a third module; that is the whole rule.
|
|
8
|
+
/** Frontmatter key that carries a pre-migration `depends_on` value. */
|
|
9
|
+
exports.LEGACY_DEPENDS_FIELD = 'legacy_depends_on';
|
|
@@ -48,7 +48,7 @@ export declare const LEGACY_STATUS_FIELD = "legacy_status";
|
|
|
48
48
|
* computation — asserting it anyway is the fabrication this repository keeps removing. Verbatim
|
|
49
49
|
* preservation leaves the judgement to a person who can actually make it.
|
|
50
50
|
*/
|
|
51
|
-
export
|
|
51
|
+
export { LEGACY_DEPENDS_FIELD } from './legacy-fields';
|
|
52
52
|
/**
|
|
53
53
|
* Classify a document by FORMAT, not by validity.
|
|
54
54
|
*
|
|
@@ -21,7 +21,10 @@ exports.LEGACY_STATUS_FIELD = 'legacy_status';
|
|
|
21
21
|
* computation — asserting it anyway is the fabrication this repository keeps removing. Verbatim
|
|
22
22
|
* preservation leaves the judgement to a person who can actually make it.
|
|
23
23
|
*/
|
|
24
|
-
|
|
24
|
+
// @implements A-SPEC-574.3 — moved to `legacy-fields` and re-exported, so existing importers
|
|
25
|
+
// keep working while the cycle it created is gone.
|
|
26
|
+
var legacy_fields_1 = require("./legacy-fields");
|
|
27
|
+
Object.defineProperty(exports, "LEGACY_DEPENDS_FIELD", { enumerable: true, get: function () { return legacy_fields_1.LEGACY_DEPENDS_FIELD; } });
|
|
25
28
|
/**
|
|
26
29
|
* Classify a document by FORMAT, not by validity.
|
|
27
30
|
*
|