@holmes-lab/holmes-kit 0.18.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.
Files changed (56) hide show
  1. package/CHANGELOG.md +69 -0
  2. package/README.md +3 -0
  3. package/dist/.build-id +1 -1
  4. package/dist/holmes/cli/release-docs.d.ts +27 -0
  5. package/dist/holmes/cli/release-docs.js +68 -0
  6. package/dist/holmes/cpg/arch-observe.d.ts +15 -0
  7. package/dist/holmes/cpg/arch-observe.js +19 -0
  8. package/dist/holmes/cpg/cpg-scanner.d.ts +10 -36
  9. package/dist/holmes/cpg/cpg-scanner.js +27 -3
  10. package/dist/holmes/cpg/cycle-detect.d.ts +87 -0
  11. package/dist/holmes/cpg/cycle-detect.js +251 -0
  12. package/dist/holmes/cpg/scan-cache.d.ts +1 -1
  13. package/dist/holmes/cpg/scanned-file.d.ts +36 -0
  14. package/dist/holmes/cpg/scanned-file.js +2 -0
  15. package/dist/holmes/governance/constitution.d.ts +20 -0
  16. package/dist/holmes/governance/constitution.js +17 -0
  17. package/dist/holmes/governance/ledger-store.d.ts +9 -0
  18. package/dist/holmes/governance/ledger-store.js +47 -0
  19. package/dist/holmes/governance/provenance-chain.d.ts +16 -1
  20. package/dist/holmes/governance/provenance-chain.js +5 -3
  21. package/dist/holmes/hooks/pre-tool-use.js +3 -1
  22. package/dist/holmes/hooks/stop.d.ts +14 -0
  23. package/dist/holmes/hooks/stop.js +73 -0
  24. package/dist/holmes/mcp/defuse-bound.d.ts +1 -0
  25. package/dist/holmes/mcp/defuse-bound.js +8 -0
  26. package/dist/holmes/mcp/handlers.d.ts +5 -0
  27. package/dist/holmes/mcp/handlers.js +103 -4
  28. package/dist/holmes/mcp/history-admission.d.ts +15 -0
  29. package/dist/holmes/mcp/history-admission.js +37 -0
  30. package/dist/holmes/mcp/maintenance-analyze.d.ts +8 -0
  31. package/dist/holmes/mcp/maintenance-analyze.js +44 -8
  32. package/dist/holmes/review/evaluation-metrics.d.ts +6 -0
  33. package/dist/holmes/review/evaluation-metrics.js +18 -1
  34. package/dist/holmes/review/paired-power.d.ts +14 -0
  35. package/dist/holmes/review/paired-power.js +57 -0
  36. package/dist/holmes/review/replay-corpus.d.ts +11 -0
  37. package/dist/holmes/review/replay-corpus.js +34 -0
  38. package/dist/holmes/review/run-replay.js +60 -4
  39. package/dist/holmes/review/symbol-truth.d.ts +14 -0
  40. package/dist/holmes/review/symbol-truth.js +23 -0
  41. package/dist/holmes/rtm/defuse-symbols.d.ts +17 -0
  42. package/dist/holmes/rtm/defuse-symbols.js +91 -0
  43. package/dist/holmes/rtm/incremental.js +5 -0
  44. package/dist/holmes/rtm/rtm-builder.d.ts +8 -0
  45. package/dist/holmes/rtm/rtm-builder.js +32 -5
  46. package/dist/holmes/rtm/rtm-graph.d.ts +11 -0
  47. package/dist/holmes/rtm/rtm-graph.js +13 -0
  48. package/dist/holmes/spec/legacy-fields.d.ts +2 -0
  49. package/dist/holmes/spec/legacy-fields.js +9 -0
  50. package/dist/holmes/spec/legacy-format.d.ts +1 -1
  51. package/dist/holmes/spec/legacy-format.js +4 -1
  52. package/dist/holmes/spec/spec-parser.js +5 -3
  53. package/package.json +1 -1
  54. package/playbooks/author-slice/PLAYBOOK.md +14 -0
  55. package/playbooks/publish/PLAYBOOK.md +32 -0
  56. package/playbooks/tdd-slice/PLAYBOOK.md +14 -0
@@ -45,6 +45,8 @@ exports.collectDecisions = collectDecisions;
45
45
  // @implements A-SPEC-277
46
46
  // @implements A-SPEC-269
47
47
  // @implements A-SPEC-267
48
+ // @implements A-SPEC-573.4 — shared with the benchmark so the two cannot drift.
49
+ const defuse_bound_1 = require("./defuse-bound");
48
50
  const fs = __importStar(require("node:fs"));
49
51
  const http = __importStar(require("node:http"));
50
52
  const assoc_arm_1 = require("../assoc/assoc-arm");
@@ -205,6 +207,8 @@ const ledger_timeline_1 = require("../governance/ledger-timeline");
205
207
  const version_conflict_1 = require("../spec/version-conflict");
206
208
  const ledger_store_1 = require("../governance/ledger-store");
207
209
  const provenance_chain_1 = require("../governance/provenance-chain");
210
+ // @implements A-SPEC-574.3 — the caller owns the store and hands the chain the question.
211
+ const ledger_store_2 = require("../governance/ledger-store");
208
212
  const ledger_lock_1 = require("../governance/ledger-lock");
209
213
  const decision_ledger_1 = require("../guardrail/decision-ledger");
210
214
  const cspec_change_1 = require("../guardrail/cspec-change");
@@ -1375,7 +1379,63 @@ function makeRawHandlers(store, opts) {
1375
1379
  return null;
1376
1380
  } });
1377
1381
  const density = anchorDensityFindings(ftt, graph.implementsAnchorCounts());
1378
- if (impact || density.length > 0) {
1382
+ // @implements A-SPEC-574.2 the computation lives in cycle-detect; this file holds
1383
+ // the wiring only (measured: 85 anchors here against a p90 of 7).
1384
+ const { cycleAdvisory, classifyEdgeByTarget, CYCLE_ADVISORY_NOTE } = require('../cpg/cycle-detect');
1385
+ const readCache = new Map();
1386
+ const readSource = (rel) => {
1387
+ const hit = readCache.get(rel);
1388
+ if (hit !== undefined)
1389
+ return hit;
1390
+ // A read failure degrades to the conservative kind rather than losing the finding.
1391
+ let text = '';
1392
+ try {
1393
+ text = fs.readFileSync(path.join(a.root, rel), 'utf8');
1394
+ }
1395
+ catch {
1396
+ text = '';
1397
+ }
1398
+ readCache.set(rel, text);
1399
+ return text;
1400
+ };
1401
+ const cycleFindings = cycleAdvisory(ftt, graph.importEdges().map((edge) => ({
1402
+ ...edge,
1403
+ kind: classifyEdgeByTarget(readSource(edge.from), edge.from, edge.to),
1404
+ })));
1405
+ // @implements A-SPEC-574.5 — the same import edges the cycle pass already read, plus
1406
+ // the parent-time symbol spans the graph already holds. No new scan, no new parse.
1407
+ const { architectureObservation } = require('../cpg/arch-observe');
1408
+ const { TreeSitterTsParser } = require('../cpg/language-parser');
1409
+ const { langForPath } = require('../cpg/cpg-scanner');
1410
+ // Bounded to the DECLARED files, whose text is read once and used for both numbers.
1411
+ const archParser = new TreeSitterTsParser();
1412
+ const archText = new Map();
1413
+ const readArch = (f) => {
1414
+ if (!archText.has(f)) {
1415
+ try {
1416
+ archText.set(f, fs.readFileSync(path.join(a.root, f), 'utf8'));
1417
+ }
1418
+ catch {
1419
+ archText.set(f, null);
1420
+ }
1421
+ }
1422
+ return archText.get(f) ?? null;
1423
+ };
1424
+ const spans = new Map(ftt.map((f) => {
1425
+ const text = readArch(f);
1426
+ if (text === null)
1427
+ return [f, []];
1428
+ try {
1429
+ return [f, archParser.extractSymbols(text, langForPath(f))
1430
+ .filter((sy) => sy.kind !== 'class')
1431
+ .map((sy) => ({ startLine: sy.startLine, endLine: sy.endLine }))];
1432
+ }
1433
+ catch {
1434
+ return [f, []];
1435
+ }
1436
+ }));
1437
+ const arch = architectureObservation(ftt, spans, graph.importEdges(), (f) => { const t = readArch(f); return t === null ? null : t.split('\n').length; });
1438
+ if (impact || density.length > 0 || cycleFindings.length > 0 || arch.length > 0) {
1379
1439
  const graphAsOf = (() => { try {
1380
1440
  return fs.statSync(dbPath).mtime.toISOString();
1381
1441
  }
@@ -1385,6 +1445,9 @@ function makeRawHandlers(store, opts) {
1385
1445
  graphPreview = {
1386
1446
  ...(impact ? { impact } : {}),
1387
1447
  ...(density.length > 0 ? { density } : {}),
1448
+ ...(cycleFindings.length > 0
1449
+ ? { cycles: { findings: cycleFindings, note: CYCLE_ADVISORY_NOTE } } : {}),
1450
+ ...(arch.length > 0 ? { architecture: arch } : {}),
1388
1451
  ...(graphAsOf ? { graphAsOf } : {}),
1389
1452
  };
1390
1453
  }
@@ -2515,7 +2578,7 @@ function makeRawHandlers(store, opts) {
2515
2578
  commitTextBoost[hit.file] = hit.score / top;
2516
2579
  }
2517
2580
  catch { /* no history, no boost — the ranking falls back to lexical evidence alone */ }
2518
- const analysis = (0, maintenance_analyze_1.analyzeMaintenance)({
2581
+ const analyzeWith = (defUse) => (0, maintenance_analyze_1.analyzeMaintenance)({
2519
2582
  semantic,
2520
2583
  ...common,
2521
2584
  coverage: { ...common.coverage, historyStatus },
@@ -2524,7 +2587,43 @@ function makeRawHandlers(store, opts) {
2524
2587
  commitTextBoost,
2525
2588
  contextBundle,
2526
2589
  groundTruth: a.groundTruth,
2590
+ defUse,
2527
2591
  });
2592
+ // @implements A-SPEC-573.4 — def-use for the TOP CANDIDATES ONLY. Extracting it for the whole
2593
+ // repository costs +88.8% (measured 2026-09-08), well past this slice's budget; the first pass
2594
+ // says which handful of files are worth parsing, and the second pass reads their data flow.
2595
+ // Every step is fail-open: a parse failure, an unsupported language or a missing file leaves
2596
+ // the candidate's symbols exactly as the first pass produced them.
2597
+ const firstPass = analyzeWith();
2598
+ const analysis = (() => {
2599
+ const targets = firstPass.candidates.slice(0, defuse_bound_1.DEFUSE_TOP_FILES).map((c) => c.file);
2600
+ if (targets.length === 0)
2601
+ return firstPass;
2602
+ const defUse = {};
2603
+ try {
2604
+ const { TreeSitterTsParser, hasDataFlowWalk } = require('../cpg/language-parser');
2605
+ const { langForPath } = require('../cpg/cpg-scanner');
2606
+ const parser = new TreeSitterTsParser();
2607
+ for (const file of targets) {
2608
+ try {
2609
+ // The LANGUAGE matters: the first wiring omitted it and parsed Python as TypeScript,
2610
+ // which produced wrong facts and cost the second corpus 0.2376 -> 0.1741 on the
2611
+ // symbol axis. A language with no walk is skipped rather than guessed at.
2612
+ const lang = langForPath(file);
2613
+ if (!hasDataFlowWalk(lang))
2614
+ continue;
2615
+ const facts = parser.extractDataFlow(fs.readFileSync(path.join(root, file), 'utf8'), lang);
2616
+ if (facts !== undefined)
2617
+ defUse[file] = facts;
2618
+ }
2619
+ catch { /* one unreadable or unparseable file must not cost the other nine */ }
2620
+ }
2621
+ }
2622
+ catch {
2623
+ return firstPass;
2624
+ }
2625
+ return Object.keys(defUse).length === 0 ? firstPass : analyzeWith(defUse);
2626
+ })();
2528
2627
  // @implements A-SPEC-268 — persistence is OPT-IN. The tool is advertised read-only, and a
2529
2628
  // regression pins that a cold project gains no `.ax/cpg_cache`; writing evidence by default
2530
2629
  // would break that contract for every caller who only wanted to look.
@@ -3092,7 +3191,7 @@ function makeRawHandlers(store, opts) {
3092
3191
  summary: `consumed single-use approval for: review-resolve ${envLiftedCriticals.join(', ')}`.slice(0, 200),
3093
3192
  inputs: [(0, provenance_chain_1.nonceFingerprint)(String(approval.nonce))], rationale: approval.rationale,
3094
3193
  authorization: (0, provenance_chain_1.authorizationRef)(approval.actor, approval.token),
3095
- });
3194
+ }, { isNonceConsumed: (0, ledger_store_2.nonceConsumedIn)(ledgerFile) });
3096
3195
  if (!won) {
3097
3196
  throw new HandlerRefusal(`review_record: 단일 사용 승인(nonce)이 이미 소비되었습니다 — 재사용은 거부됩니다. 새 승인을 발급받으십시오`);
3098
3197
  }
@@ -3222,7 +3321,7 @@ function makeRawHandlers(store, opts) {
3222
3321
  summary: `consumed single-use approval for: ${coverTarget.kind} ${(0, provenance_chain_1.redactTarget)('command', coverTarget.target)}`.slice(0, 200),
3223
3322
  inputs: [(0, provenance_chain_1.nonceFingerprint)(String(a.approval.nonce))], rationale: a.approval.rationale,
3224
3323
  authorization: (0, provenance_chain_1.authorizationRef)(a.approval.actor, a.approval.token),
3225
- });
3324
+ }, { isNonceConsumed: (0, ledger_store_2.nonceConsumedIn)(ledgerFile) });
3226
3325
  }
3227
3326
  catch {
3228
3327
  won = false;
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Narrow history-derived candidates to files that could be source at all.
3
+ *
4
+ * The predicate is the SCANNER's, not the replay corpus's truth predicate — scoring against a
5
+ * filter copied from the metric would be gaming it. Deliberately NOT membership in the scanned
6
+ * set: a file a commit CREATES does not exist in the parent-time scan, and admitting exactly such
7
+ * files is A-SPEC-388's boundary contract. Every offender measured above is excluded by the
8
+ * extension test alone (.jsonl / .json / .md), so the stronger predicate would have cost that
9
+ * contract and bought nothing.
10
+ *
11
+ * Order is preserved because the downstream RRF lists are position-indexed.
12
+ */
13
+ export declare function admitHistoryFiles(historyFiles: readonly string[]): string[];
14
+ /** The lexical path's predicate (localize.ts), restated so both channels demote the same trees. */
15
+ export declare const isVendorPath: (p: string) => boolean;
@@ -0,0 +1,37 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.isVendorPath = void 0;
4
+ exports.admitHistoryFiles = admitHistoryFiles;
5
+ // @implements A-SPEC-573.1
6
+ // The commit-prose channel (A-SPEC-388) lets files the lexical layer never scored ENTER the
7
+ // candidate pool — that is where its measured gain comes from (Top-10 recall 0.393 -> 0.601). But
8
+ // its keys come from git history, so it also admits files that CANNOT be the answer: this
9
+ // repository's ledger JSONL, the last-green baseline, CHANGELOG.md. Measured 2026-09-08 over 12
10
+ // real commit-subject requests: 76 of 120 emitted candidate slots (63.3%) went to such files, and
11
+ // one took the emission head. The channel is not the problem; its POPULATION is.
12
+ const cpg_scanner_1 = require("../cpg/cpg-scanner");
13
+ /**
14
+ * Narrow history-derived candidates to files that could be source at all.
15
+ *
16
+ * The predicate is the SCANNER's, not the replay corpus's truth predicate — scoring against a
17
+ * filter copied from the metric would be gaming it. Deliberately NOT membership in the scanned
18
+ * set: a file a commit CREATES does not exist in the parent-time scan, and admitting exactly such
19
+ * files is A-SPEC-388's boundary contract. Every offender measured above is excluded by the
20
+ * extension test alone (.jsonl / .json / .md), so the stronger predicate would have cost that
21
+ * contract and bought nothing.
22
+ *
23
+ * Order is preserved because the downstream RRF lists are position-indexed.
24
+ */
25
+ function admitHistoryFiles(historyFiles) {
26
+ const admitted = historyFiles.filter((file) => !file.endsWith('.d.ts') && cpg_scanner_1.SCANNABLE_EXTENSIONS.some((ext) => file.endsWith(ext)));
27
+ // @implements A-SPEC-573.2 — the lexical path halves a vendored file's score AND orders vendored
28
+ // last (localize.ts). Entering at score 0, this channel bypassed both: measured 2026-09-08, the
29
+ // only two non-source candidates the extension test let through were vendored files and BOTH held
30
+ // the emission head. Demote, never drop — vendored code is the answer in some projects, which is
31
+ // why the lexical path orders rather than filters. Stable within each group: the downstream RRF
32
+ // lists are position-indexed.
33
+ return [...admitted.filter((f) => !(0, exports.isVendorPath)(f)), ...admitted.filter(exports.isVendorPath)];
34
+ }
35
+ /** The lexical path's predicate (localize.ts), restated so both channels demote the same trees. */
36
+ const isVendorPath = (p) => /(^|\/)(reference|vendor|vendors|third_party|third-party|external)\//i.test(p);
37
+ exports.isVendorPath = isVendorPath;
@@ -117,6 +117,14 @@ export interface MaintenanceAnalysisInput {
117
117
  * that is the whole result.
118
118
  */
119
119
  commitTextBoost?: Record<string, number>;
120
+ /**
121
+ * @implements A-SPEC-573.4
122
+ * Def-use facts for a BOUNDED set of files — the caller extracts them, because this core does no
123
+ * I/O. Extracting them for the whole repository costs +88.8% (measured 2026-09-08, 2572ms ->
124
+ * 4857ms), which is why the callers pass the top candidates only. Absent means "not extracted",
125
+ * and the symbols then read exactly as they did before this existed.
126
+ */
127
+ defUse?: Record<string, import('../cpg/language-parser').DataFlowFacts>;
120
128
  contextBundle?: ContextBundle | null;
121
129
  /**
122
130
  * @implements A-SPEC-290
@@ -34,6 +34,8 @@ const decision_context_1 = require("../rtm/decision-context");
34
34
  const taint_1 = require("../rtm/taint");
35
35
  const test_evidence_1 = require("../review/test-evidence");
36
36
  const language_capability_1 = require("../cpg/language-capability");
37
+ const history_admission_1 = require("./history-admission");
38
+ const defuse_symbols_1 = require("../rtm/defuse-symbols");
37
39
  // @implements A-SPEC-409 — the SHAPE gained a field (`rerankPool`), so a consumer can detect it.
38
40
  // @implements A-SPEC-405 A-SPEC-407 — the EXTRACTOR changed too: the same request now returns a
39
41
  // differently ordered candidate list (keep-head rank fusion, then the graph-hop list), so two
@@ -181,10 +183,12 @@ function analyzeMaintenance(input) {
181
183
  // Files the commit prose names but the lexical layer never scored have to be able to ENTER, or the
182
184
  // fusion can only reorder what lexical already found — and reaching what it missed is where the
183
185
  // measured gain came from (Top-10 recall 0.393 -> 0.601 on the second corpus).
186
+ // @implements A-SPEC-573.1 — but only files that could be source at all. The keys come from git
187
+ // history, so without this the pool also admits ledger JSONL and build artifacts that cannot be
188
+ // the answer (measured: 63.3% of emitted slots). The lexical hits are untouched, and a file the
189
+ // scan has never seen still enters — that is the boundary contract directly above.
184
190
  const seeded = hasBoost
185
- ? [...localization.hits, ...Object.keys(boost)
186
- .filter((file) => !localization.hits.some((h) => h.file === file))
187
- .map((file) => ({ file, score: 0, matchedSymbols: [], viaSpecs: [], why: ['reached through commit history'] }))]
191
+ ? [...localization.hits, ...(0, history_admission_1.admitHistoryFiles)(Object.keys(boost).filter((file) => !localization.hits.some((h) => h.file === file))).map((file) => ({ file, score: 0, matchedSymbols: [], viaSpecs: [], why: ['reached through commit history'] }))]
188
192
  : localization.hits;
189
193
  const reranked = !hasPrior && !hasBoost
190
194
  ? localization.hits
@@ -202,7 +206,13 @@ function analyzeMaintenance(input) {
202
206
  // than prose spread evenly. Kept, with the cost named rather than defended.
203
207
  + (hasBoost ? COMMIT_TEXT_WEIGHT * (boost[hit.file] ?? 0) * Math.max(1, hit.score) : 0),
204
208
  }))
205
- .sort((a, b) => b.score - a.score || a.file.localeCompare(b.file));
209
+ // @implements A-SPEC-573.2 — vendored last, exactly as the lexical path orders it
210
+ // (localize.ts). A history-seeded file scores 0 lexically, so the boost term alone can carry
211
+ // it to 2.0 and hand it the head — measured, a vendored file took the head in 2 of 12 real
212
+ // requests. This is the ordering rule that path already applies; the channel was bypassing
213
+ // it. Demotion, not exclusion: vendored code is the answer in some projects.
214
+ .sort((a, b) => Number((0, history_admission_1.isVendorPath)(a.file)) - Number((0, history_admission_1.isVendorPath)(b.file))
215
+ || b.score - a.score || a.file.localeCompare(b.file));
206
216
  // @implements A-SPEC-405
207
217
  // S-405 decomposed the recall loss measured in S-404: on the eight blind cases 18 of 18 truth
208
218
  // files were already in this pool and 7 sat at ranks 12-93, so widening retrieval could not have
@@ -294,7 +304,10 @@ function analyzeMaintenance(input) {
294
304
  ;
295
305
  const lists = [
296
306
  desc((f) => byFile.get(f).score),
297
- hasBoost ? desc((f) => boost[f] ?? 0) : [],
307
+ // @implements A-SPEC-573.2 vendored last inside this list. Ordering `seeded` was not enough:
308
+ // measured, the head is decided HERE, and a vendored file with a high boost took it twice in
309
+ // twelve requests. The boost VALUES are untouched, so the lexical scoring above is unchanged.
310
+ hasBoost ? desc((f) => boost[f] ?? 0).sort((a, b) => Number((0, history_admission_1.isVendorPath)(a)) - Number((0, history_admission_1.isVendorPath)(b))) : [],
298
311
  hasPrior ? desc((f) => (prior[f] ?? 1) - 1) : [],
299
312
  cited.length > 0 ? [] : hops,
300
313
  ];
@@ -345,10 +358,23 @@ function analyzeMaintenance(input) {
345
358
  // @implements A-SPEC-486 — the S-502/503 calibration point for gemini cosines. Display only:
346
359
  // nothing in this module compares against it to drop or reorder anything.
347
360
  const SEM_VERIFY_TAU = 0.65;
361
+ // @implements A-SPEC-573.4 — def-use rides BEHIND the lexical match, never in front of it: the
362
+ // symbols the request actually named keep the front, and the data-flow neighbours are appended.
363
+ const defUseTerms = input.defUse === undefined ? [] : (0, localize_1.significantTerms)(input.request);
364
+ const symbolsByFile = new Map(scanned.map((f) => [f.sourcePath, f.symbols.map((sy) => sy.qualifiedName)]));
365
+ const symbolsOf = (file, matched) => {
366
+ const facts = input.defUse?.[file];
367
+ if (facts === undefined)
368
+ return matched;
369
+ const inFile = symbolsByFile.get(file) ?? [];
370
+ if (inFile.length === 0)
371
+ return matched;
372
+ return (0, defuse_symbols_1.enrichCandidateSymbols)(matched, (0, defuse_symbols_1.rankSymbolsByDefUse)(facts, defUseTerms, inFile));
373
+ };
348
374
  const shape = (hit) => ({
349
375
  file: hit.file,
350
376
  score: hit.score,
351
- symbols: sortedUnique(hit.matchedSymbols),
377
+ symbols: symbolsOf(hit.file, sortedUnique(hit.matchedSymbols)),
352
378
  evidence: hit.why,
353
379
  });
354
380
  // @implements A-SPEC-428
@@ -376,10 +402,15 @@ function analyzeMaintenance(input) {
376
402
  const rescuedFiles = new Set(rescued.map((h) => h.file));
377
403
  const rerankPool = [...rescued, ...exposedTail.filter((h) => !rescuedFiles.has(h.file))]
378
404
  .slice(0, RERANK_POOL_N).map(shape);
405
+ const lexicalSymbolsOf = new Map(ordered.slice(0, LOCALIZATION_TOP_N).map((hit) => [hit.file, sortedUnique(hit.matchedSymbols)]));
379
406
  const candidates = ordered.slice(0, LOCALIZATION_TOP_N).map((hit) => ({
380
407
  file: hit.file,
381
408
  score: hit.score,
382
- symbols: sortedUnique(hit.matchedSymbols),
409
+ // @implements A-SPEC-573.4 — the emitted list, which is what a caller reads and what the
410
+ // benchmark scores on the symbol axis. `shape` above serves the rerank pool only; wiring the
411
+ // enrichment there and not here would have enriched a surface nobody grades (caught by the
412
+ // symbol axis reporting NO movement — the instrument earned its keep on its first use).
413
+ symbols: symbolsOf(hit.file, sortedUnique(hit.matchedSymbols)),
383
414
  evidence: [...hit.why],
384
415
  }));
385
416
  // @implements A-SPEC-572.3
@@ -460,7 +491,12 @@ function analyzeMaintenance(input) {
460
491
  .flatMap((f) => f.symbols.map((sym) => sym.qualifiedName));
461
492
  // A file you edited is not unaffected by your edit, so these are direct impact and not merely a
462
493
  // traversal starting point.
463
- const direct = sortedUnique([...candidates.flatMap((candidate) => candidate.symbols), ...changedSymbols]);
494
+ // @implements A-SPEC-573.4 the impact axis seeds from what the LEXICAL layer matched, not from
495
+ // the def-use symbols appended for emission. Measured: seeding the enriched list moved the union
496
+ // axis on both corpora (precision 0.1667->0.2075 / 0.1476->0.1689, recall 0.8755->0.8698 /
497
+ // 0.8302->0.7996) — a different axis than this slice targets, and axis transfer is exactly what
498
+ // this repository's record forbids. The emitted symbols stay enriched; the seeds do not.
499
+ const direct = sortedUnique([...lexicalSymbolsOf.values()].flat().concat(changedSymbols));
464
500
  const transitiveIds = new Set();
465
501
  const maxCallDepth = 3;
466
502
  const hubInDegree = 12;
@@ -20,6 +20,10 @@ export interface ReplayOutcome {
20
20
  truthFiles: string[];
21
21
  selectedTests: string[];
22
22
  truthTests: string[];
23
+ /** Symbols the change actually touched (parent-time ranges ∩ changed lines). */
24
+ truthSymbols?: string[];
25
+ /** Predicted symbols, best first — the qualified names the emitted candidates carried. */
26
+ rankedSymbols?: string[];
23
27
  }
24
28
  export interface CutMetrics {
25
29
  /** Share of cases where at least one truth file appears in the first K. */
@@ -33,6 +37,8 @@ export interface EvaluationMetrics {
33
37
  scorableCases: number;
34
38
  topK: Record<number, CutMetrics>;
35
39
  testRecall: number | null;
40
+ symbolTopK: Record<number, CutMetrics>;
41
+ symbolCases: number;
36
42
  }
37
43
  export declare function evaluationMetrics(outcomes: readonly ReplayOutcome[]): EvaluationMetrics;
38
44
  /**
@@ -42,7 +42,24 @@ function evaluationMetrics(outcomes) {
42
42
  }
43
43
  const testScorable = outcomes.filter((o) => o.truthTests.length > 0);
44
44
  const testRecall = mean(testScorable.map((o) => o.truthTests.filter((t) => o.selectedTests.includes(t)).length / o.truthTests.length));
45
- return { cases: outcomes.length, scorableCases: scorable.length, topK, testRecall };
45
+ // @implements A-SPEC-573.3
46
+ const symbolScorable = outcomes.filter((o) => (o.truthSymbols?.length ?? 0) > 0);
47
+ const symbolTopK = {};
48
+ for (const k of exports.TOP_K) {
49
+ const hits = [];
50
+ const recalls = [];
51
+ const precisions = [];
52
+ for (const o of symbolScorable) {
53
+ const cut = (o.rankedSymbols ?? []).slice(0, k);
54
+ const found = cut.filter((s2) => o.truthSymbols.includes(s2)).length;
55
+ hits.push(found > 0 ? 1 : 0);
56
+ recalls.push(found / o.truthSymbols.length);
57
+ precisions.push(cut.length === 0 ? 0 : found / cut.length);
58
+ }
59
+ symbolTopK[k] = { hitRate: mean(hits), recall: mean(recalls), precision: mean(precisions) };
60
+ }
61
+ return { cases: outcomes.length, scorableCases: scorable.length, topK, testRecall,
62
+ symbolTopK, symbolCases: symbolScorable.length };
46
63
  }
47
64
  /**
48
65
  * Impact had no benchmark until this existed, so an impact regression could not fail anything.
@@ -0,0 +1,14 @@
1
+ /** F1 over the SET of files, which is the edit set a caller actually acts on. */
2
+ export declare function editSetF1(predicted: readonly string[], truth: readonly string[]): number;
3
+ export interface PairedPower {
4
+ n: number;
5
+ meanDiff: number | null;
6
+ /** Sample standard deviation of the paired differences (n-1). Null below two observations. */
7
+ sdDiff: number | null;
8
+ sem: number | null;
9
+ /** Smallest difference detectable at 80% power, two-sided alpha 0.05. */
10
+ mde80: number | null;
11
+ /** True when the degrees of freedom fell outside the table and the normal approximation was used. */
12
+ approx: boolean;
13
+ }
14
+ export declare function pairedPower(diffs: readonly number[]): PairedPower;
@@ -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
- const known = new Set(scanned.map((f) => f.sourcePath));
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).filter((h) => known.has(h.file));
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 result = (0, maintenance_analyze_1.analyzeMaintenance)({
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
- outcomes.push({ ranked, truthFiles: c.files, selectedTests: [...result.relevantTests], truthTests: c.tests });
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.