@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.
Files changed (64) hide show
  1. package/CHANGELOG.md +127 -0
  2. package/README.md +6 -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/autonomy.js +1 -0
  16. package/dist/holmes/governance/constitution.d.ts +20 -0
  17. package/dist/holmes/governance/constitution.js +17 -0
  18. package/dist/holmes/governance/ledger-store.d.ts +9 -0
  19. package/dist/holmes/governance/ledger-store.js +47 -0
  20. package/dist/holmes/governance/provenance-chain.d.ts +16 -1
  21. package/dist/holmes/governance/provenance-chain.js +5 -3
  22. package/dist/holmes/hooks/pre-tool-use.js +3 -1
  23. package/dist/holmes/hooks/stop.d.ts +14 -0
  24. package/dist/holmes/hooks/stop.js +73 -0
  25. package/dist/holmes/mcp/defuse-bound.d.ts +1 -0
  26. package/dist/holmes/mcp/defuse-bound.js +8 -0
  27. package/dist/holmes/mcp/handlers.d.ts +23 -0
  28. package/dist/holmes/mcp/handlers.js +173 -6
  29. package/dist/holmes/mcp/history-admission.d.ts +15 -0
  30. package/dist/holmes/mcp/history-admission.js +37 -0
  31. package/dist/holmes/mcp/maintenance-analyze.d.ts +11 -0
  32. package/dist/holmes/mcp/maintenance-analyze.js +64 -8
  33. package/dist/holmes/mcp/spec-id-guard.d.ts +0 -8
  34. package/dist/holmes/mcp/spec-id-guard.js +10 -1
  35. package/dist/holmes/review/evaluation-metrics.d.ts +6 -0
  36. package/dist/holmes/review/evaluation-metrics.js +18 -1
  37. package/dist/holmes/review/paired-power.d.ts +14 -0
  38. package/dist/holmes/review/paired-power.js +57 -0
  39. package/dist/holmes/review/replay-corpus.d.ts +11 -0
  40. package/dist/holmes/review/replay-corpus.js +34 -0
  41. package/dist/holmes/review/run-replay.js +60 -4
  42. package/dist/holmes/review/symbol-truth.d.ts +14 -0
  43. package/dist/holmes/review/symbol-truth.js +23 -0
  44. package/dist/holmes/rtm/decision-context.d.ts +23 -0
  45. package/dist/holmes/rtm/decision-context.js +47 -0
  46. package/dist/holmes/rtm/defuse-symbols.d.ts +17 -0
  47. package/dist/holmes/rtm/defuse-symbols.js +91 -0
  48. package/dist/holmes/rtm/incremental.js +5 -0
  49. package/dist/holmes/rtm/localize.js +4 -2
  50. package/dist/holmes/rtm/rtm-builder.d.ts +8 -0
  51. package/dist/holmes/rtm/rtm-builder.js +34 -5
  52. package/dist/holmes/rtm/rtm-graph.d.ts +11 -0
  53. package/dist/holmes/rtm/rtm-graph.js +13 -0
  54. package/dist/holmes/spec/legacy-fields.d.ts +2 -0
  55. package/dist/holmes/spec/legacy-fields.js +9 -0
  56. package/dist/holmes/spec/legacy-format.d.ts +1 -1
  57. package/dist/holmes/spec/legacy-format.js +4 -1
  58. package/dist/holmes/spec/spec-parser.js +5 -3
  59. package/dist/holmes/spec/spec-types.d.ts +1 -1
  60. package/dist/holmes/spec/spec-types.js +14 -1
  61. package/package.json +1 -1
  62. package/playbooks/author-slice/PLAYBOOK.md +33 -0
  63. package/playbooks/publish/PLAYBOOK.md +32 -0
  64. package/playbooks/tdd-slice/PLAYBOOK.md +19 -0
@@ -1,6 +1,9 @@
1
1
  import { SpecStore } from '../spec/spec-store';
2
+ import { Spec } from '../spec/spec-parser';
2
3
  import { MaintenanceGroundTruth, MaintenanceAnalysis } from './maintenance-analyze';
3
4
  import { Action } from '../guardrail/phase';
5
+ import { ScannedFile } from '../cpg/cpg-scanner';
6
+ import { type DecisionRecord, type DecisionCitation } from '../rtm/rtm-builder';
4
7
  import { Basis } from './basis';
5
8
  import { Finding } from '../review/findings';
6
9
  import { Approval, Enforcement } from '../guardrail/risk-gate';
@@ -53,6 +56,16 @@ export declare function makeHandlers(store: SpecStore, opts?: ElicitOpts): RawHa
53
56
  }>;
54
57
  };
55
58
  type RawHandlers = ReturnType<typeof makeRawHandlers>;
59
+ /**
60
+ * @implements A-SPEC-293
61
+ * Read `.ax/decisions/*.md` and find who cites them. Citation is the link, chosen by measurement:
62
+ * `governs` resolves 9 of 65 names to symbols while 37 source files and 71 spec files cite an ADR.
63
+ * Failure here is never fatal — a project with no decisions directory simply has no decisions.
64
+ */
65
+ export declare function collectDecisions(root: string, scanned: readonly ScannedFile[], specs: readonly Spec[]): {
66
+ decisions: DecisionRecord[];
67
+ citations: DecisionCitation[];
68
+ };
56
69
  declare function makeRawHandlers(store: SpecStore, opts?: ElicitOpts): {
57
70
  spec_create(a: any): Promise<{
58
71
  ok: boolean;
@@ -207,6 +220,16 @@ declare function makeRawHandlers(store: SpecStore, opts?: ElicitOpts): {
207
220
  ok: boolean;
208
221
  reason: string;
209
222
  } | {
223
+ graphPreview?: {
224
+ impact?: import("../rtm/impact-advisory").ImpactAdvisory;
225
+ density?: import("../rtm/anchor-density").AnchorDensityFinding[];
226
+ cycles?: {
227
+ findings: import("../cpg/cycle-detect").CycleFinding[];
228
+ note: string;
229
+ };
230
+ architecture?: import("../cpg/arch-observe").ArchObservation[];
231
+ graphAsOf?: string;
232
+ } | undefined;
210
233
  id: string;
211
234
  type?: string;
212
235
  status: string;
@@ -37,6 +37,7 @@ exports.HandlerRefusal = void 0;
37
37
  exports.isHandlerRefusal = isHandlerRefusal;
38
38
  exports.unreadableAmong = unreadableAmong;
39
39
  exports.makeHandlers = makeHandlers;
40
+ exports.collectDecisions = collectDecisions;
40
41
  // @implements A-SPEC-293
41
42
  // @implements A-SPEC-292
42
43
  // @implements A-SPEC-290
@@ -44,6 +45,8 @@ exports.makeHandlers = makeHandlers;
44
45
  // @implements A-SPEC-277
45
46
  // @implements A-SPEC-269
46
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");
47
50
  const fs = __importStar(require("node:fs"));
48
51
  const http = __importStar(require("node:http"));
49
52
  const assoc_arm_1 = require("../assoc/assoc-arm");
@@ -204,6 +207,8 @@ const ledger_timeline_1 = require("../governance/ledger-timeline");
204
207
  const version_conflict_1 = require("../spec/version-conflict");
205
208
  const ledger_store_1 = require("../governance/ledger-store");
206
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");
207
212
  const ledger_lock_1 = require("../governance/ledger-lock");
208
213
  const decision_ledger_1 = require("../guardrail/decision-ledger");
209
214
  const cspec_change_1 = require("../guardrail/cspec-change");
@@ -699,8 +704,24 @@ function collectDecisions(root, scanned, specs) {
699
704
  });
700
705
  }
701
706
  }
702
- catch {
703
- return { decisions: [], citations: [] };
707
+ catch { /* @implements A-SPEC-571.2 — no legacy .ax/decisions dir is not "no decisions": store
708
+ ADRs below are still a source. An unreadable dir degrades to the empty legacy set, not an early
709
+ return that would skip the store population. */
710
+ }
711
+ // @implements A-SPEC-571.2 — store ADRs JOIN the decisions population, so the existing consumers
712
+ // (DECISION nodes, constrained_by citations, supersedes) light up with no new edge kind. A store
713
+ // ADR is the canon: same id in legacy .ax/decisions is replaced (store wins).
714
+ for (const spec of specs) {
715
+ if (spec.type !== 'ADR')
716
+ continue;
717
+ const sup = spec.frontmatter?.supersedes;
718
+ const supersedes = typeof sup === 'string' && sup.trim() && sup.trim() !== 'null' ? sup.trim() : null;
719
+ const idx = decisions.findIndex((d) => d.id === spec.id);
720
+ const rec = { id: spec.id, title: spec.title, status: spec.status, supersedes };
721
+ if (idx >= 0)
722
+ decisions[idx] = rec;
723
+ else
724
+ decisions.push(rec);
704
725
  }
705
726
  const ids = new Set(decisions.map((d) => d.id));
706
727
  // @implements A-SPEC-546.1 — recognise ADR-\d{3,} (4-digit ADRs no longer invisible), via a pure fn.
@@ -1330,7 +1351,117 @@ function makeRawHandlers(store, opts) {
1330
1351
  const unreadable = typeof dir === 'string' ? (0, spec_store_1.unreadableSpecFiles)(dir) : [];
1331
1352
  return { ok: false, reason: (0, spec_store_1.notFoundReason)(a.id, unreadable) };
1332
1353
  }
1333
- return { ok: true, ...(0, approval_status_1.describeApproval)(cur.spec, resolver(all)) };
1354
+ // @implements A-SPEC-572.1
1355
+ // The graph advisory, delivered at DESIGN time. Measured on our own work: A-SPEC-571.1's
1356
+ // sealing advisory named the exact cluster three regressions then landed in — and by then
1357
+ // the design was done. The calculation was never the gap; the delivery time was. So the
1358
+ // read-only "what is blocking this right now" tool also answers "what will this scope leak,
1359
+ // and where is it dense" — using the SAME functions spec_approve calls, so the preview can
1360
+ // never disagree with the seal. Read-only stays read-only: no scan, no build, no ledger
1361
+ // append (a query must not pollute the observation denominator), and every failure degrades
1362
+ // to an absent field on an otherwise identical response.
1363
+ let graphPreview;
1364
+ try {
1365
+ if (cur.spec.type === 'A-SPEC' && a.root) {
1366
+ const dbPath = path.join(a.root, '.ax', 'rtm.sqlite');
1367
+ if (fs.existsSync(dbPath)) {
1368
+ const { declaredImpactGap } = require('../rtm/impact-advisory');
1369
+ const { anchorDensityFindings } = require('../rtm/anchor-density');
1370
+ const { filesToTouch } = require('../spec/compat-impact');
1371
+ const { RtmGraph } = require('../rtm/rtm-graph');
1372
+ const graph = new RtmGraph(dbPath);
1373
+ try {
1374
+ const ftt = filesToTouch(cur.spec);
1375
+ const impact = declaredImpactGap(ftt, graph, (rel) => { try {
1376
+ return fs.readFileSync(path.join(a.root, rel), 'utf8');
1377
+ }
1378
+ catch {
1379
+ return null;
1380
+ } });
1381
+ const density = anchorDensityFindings(ftt, graph.implementsAnchorCounts());
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) {
1439
+ const graphAsOf = (() => { try {
1440
+ return fs.statSync(dbPath).mtime.toISOString();
1441
+ }
1442
+ catch {
1443
+ return undefined;
1444
+ } })();
1445
+ graphPreview = {
1446
+ ...(impact ? { impact } : {}),
1447
+ ...(density.length > 0 ? { density } : {}),
1448
+ ...(cycleFindings.length > 0
1449
+ ? { cycles: { findings: cycleFindings, note: CYCLE_ADVISORY_NOTE } } : {}),
1450
+ ...(arch.length > 0 ? { architecture: arch } : {}),
1451
+ ...(graphAsOf ? { graphAsOf } : {}),
1452
+ };
1453
+ }
1454
+ }
1455
+ finally {
1456
+ graph.close();
1457
+ }
1458
+ }
1459
+ }
1460
+ }
1461
+ catch {
1462
+ graphPreview = undefined;
1463
+ }
1464
+ return { ok: true, ...(0, approval_status_1.describeApproval)(cur.spec, resolver(all)), ...(graphPreview ? { graphPreview } : {}) };
1334
1465
  },
1335
1466
  /**
1336
1467
  * @implements A-SPEC-538.3
@@ -2447,7 +2578,7 @@ function makeRawHandlers(store, opts) {
2447
2578
  commitTextBoost[hit.file] = hit.score / top;
2448
2579
  }
2449
2580
  catch { /* no history, no boost — the ranking falls back to lexical evidence alone */ }
2450
- const analysis = (0, maintenance_analyze_1.analyzeMaintenance)({
2581
+ const analyzeWith = (defUse) => (0, maintenance_analyze_1.analyzeMaintenance)({
2451
2582
  semantic,
2452
2583
  ...common,
2453
2584
  coverage: { ...common.coverage, historyStatus },
@@ -2456,7 +2587,43 @@ function makeRawHandlers(store, opts) {
2456
2587
  commitTextBoost,
2457
2588
  contextBundle,
2458
2589
  groundTruth: a.groundTruth,
2590
+ defUse,
2459
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
+ })();
2460
2627
  // @implements A-SPEC-268 — persistence is OPT-IN. The tool is advertised read-only, and a
2461
2628
  // regression pins that a cold project gains no `.ax/cpg_cache`; writing evidence by default
2462
2629
  // would break that contract for every caller who only wanted to look.
@@ -3024,7 +3191,7 @@ function makeRawHandlers(store, opts) {
3024
3191
  summary: `consumed single-use approval for: review-resolve ${envLiftedCriticals.join(', ')}`.slice(0, 200),
3025
3192
  inputs: [(0, provenance_chain_1.nonceFingerprint)(String(approval.nonce))], rationale: approval.rationale,
3026
3193
  authorization: (0, provenance_chain_1.authorizationRef)(approval.actor, approval.token),
3027
- });
3194
+ }, { isNonceConsumed: (0, ledger_store_2.nonceConsumedIn)(ledgerFile) });
3028
3195
  if (!won) {
3029
3196
  throw new HandlerRefusal(`review_record: 단일 사용 승인(nonce)이 이미 소비되었습니다 — 재사용은 거부됩니다. 새 승인을 발급받으십시오`);
3030
3197
  }
@@ -3154,7 +3321,7 @@ function makeRawHandlers(store, opts) {
3154
3321
  summary: `consumed single-use approval for: ${coverTarget.kind} ${(0, provenance_chain_1.redactTarget)('command', coverTarget.target)}`.slice(0, 200),
3155
3322
  inputs: [(0, provenance_chain_1.nonceFingerprint)(String(a.approval.nonce))], rationale: a.approval.rationale,
3156
3323
  authorization: (0, provenance_chain_1.authorizationRef)(a.approval.actor, a.approval.token),
3157
- });
3324
+ }, { isNonceConsumed: (0, ledger_store_2.nonceConsumedIn)(ledgerFile) });
3158
3325
  }
3159
3326
  catch {
3160
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;
@@ -3,6 +3,7 @@ import type { Spec } from '../spec/spec-parser';
3
3
  import type { RtmGraph } from '../rtm/rtm-graph';
4
4
  import type { ContextBundle } from '../context/bundler';
5
5
  import { type TestScope } from '../rtm/test-scope';
6
+ import { type DecisionContextEntry } from '../rtm/decision-context';
6
7
  import type { ResolutionReport } from '../rtm/rtm-builder';
7
8
  import { type TestEvidence } from '../review/test-evidence';
8
9
  import { type LanguageGap } from '../cpg/language-capability';
@@ -116,6 +117,14 @@ export interface MaintenanceAnalysisInput {
116
117
  * that is the whole result.
117
118
  */
118
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>;
119
128
  contextBundle?: ContextBundle | null;
120
129
  /**
121
130
  * @implements A-SPEC-290
@@ -212,6 +221,8 @@ export interface MaintenanceAnalysis {
212
221
  score: number;
213
222
  symbols: string[];
214
223
  evidence: string[];
224
+ /** @implements A-SPEC-572.3 — decisions constraining this candidate; information only. */
225
+ decisionContext?: DecisionContextEntry[];
215
226
  }>;
216
227
  /**
217
228
  * @implements A-SPEC-494 — the semantic ALTERNATES: top-3 cached-vector cosines among files
@@ -26,9 +26,16 @@ const scope_1 = require("../review/scope");
26
26
  // the measured numbers transfer to the shipped surface.
27
27
  const assoc_arm_1 = require("../assoc/assoc-arm");
28
28
  const acceptance_quality_1 = require("../spec/acceptance-quality");
29
+ // @implements A-SPEC-572.3 — the decision lookup lives in its own pure module: this file
30
+ // already carries 35 anchors (p90 is 7), and the design-time density advisory said so BEFORE
31
+ // this slice fixed its Files-to-Touch. Adding logic here would have made the file denser; the
32
+ // call site is one line, the logic is next door.
33
+ const decision_context_1 = require("../rtm/decision-context");
29
34
  const taint_1 = require("../rtm/taint");
30
35
  const test_evidence_1 = require("../review/test-evidence");
31
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");
32
39
  // @implements A-SPEC-409 — the SHAPE gained a field (`rerankPool`), so a consumer can detect it.
33
40
  // @implements A-SPEC-405 A-SPEC-407 — the EXTRACTOR changed too: the same request now returns a
34
41
  // differently ordered candidate list (keep-head rank fusion, then the graph-hop list), so two
@@ -176,10 +183,12 @@ function analyzeMaintenance(input) {
176
183
  // Files the commit prose names but the lexical layer never scored have to be able to ENTER, or the
177
184
  // fusion can only reorder what lexical already found — and reaching what it missed is where the
178
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.
179
190
  const seeded = hasBoost
180
- ? [...localization.hits, ...Object.keys(boost)
181
- .filter((file) => !localization.hits.some((h) => h.file === file))
182
- .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'] }))]
183
192
  : localization.hits;
184
193
  const reranked = !hasPrior && !hasBoost
185
194
  ? localization.hits
@@ -197,7 +206,13 @@ function analyzeMaintenance(input) {
197
206
  // than prose spread evenly. Kept, with the cost named rather than defended.
198
207
  + (hasBoost ? COMMIT_TEXT_WEIGHT * (boost[hit.file] ?? 0) * Math.max(1, hit.score) : 0),
199
208
  }))
200
- .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));
201
216
  // @implements A-SPEC-405
202
217
  // S-405 decomposed the recall loss measured in S-404: on the eight blind cases 18 of 18 truth
203
218
  // files were already in this pool and 7 sat at ranks 12-93, so widening retrieval could not have
@@ -289,7 +304,10 @@ function analyzeMaintenance(input) {
289
304
  ;
290
305
  const lists = [
291
306
  desc((f) => byFile.get(f).score),
292
- 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))) : [],
293
311
  hasPrior ? desc((f) => (prior[f] ?? 1) - 1) : [],
294
312
  cited.length > 0 ? [] : hops,
295
313
  ];
@@ -340,10 +358,23 @@ function analyzeMaintenance(input) {
340
358
  // @implements A-SPEC-486 — the S-502/503 calibration point for gemini cosines. Display only:
341
359
  // nothing in this module compares against it to drop or reorder anything.
342
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
+ };
343
374
  const shape = (hit) => ({
344
375
  file: hit.file,
345
376
  score: hit.score,
346
- symbols: sortedUnique(hit.matchedSymbols),
377
+ symbols: symbolsOf(hit.file, sortedUnique(hit.matchedSymbols)),
347
378
  evidence: hit.why,
348
379
  });
349
380
  // @implements A-SPEC-428
@@ -371,12 +402,32 @@ function analyzeMaintenance(input) {
371
402
  const rescuedFiles = new Set(rescued.map((h) => h.file));
372
403
  const rerankPool = [...rescued, ...exposedTail.filter((h) => !rescuedFiles.has(h.file))]
373
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)]));
374
406
  const candidates = ordered.slice(0, LOCALIZATION_TOP_N).map((hit) => ({
375
407
  file: hit.file,
376
408
  score: hit.score,
377
- 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)),
378
414
  evidence: [...hit.why],
379
415
  }));
416
+ // @implements A-SPEC-572.3
417
+ // "What broke" then "why is it this way" — the order a person diagnoses in. The decisions are
418
+ // already in the graph (REQ-571 put store ADRs there; A-SPEC-293 builds `constrained_by` from
419
+ // citations), so this is a lookup, not a new mechanism. Information only: it rides beside a
420
+ // candidate and never enters the score or the ordering above.
421
+ {
422
+ const anchorsOf = new Map(scanned.map((f) => [f.sourcePath, f.implementsSpecs ?? []]));
423
+ const ctx = (0, decision_context_1.decisionContextFor)(candidates.map((c) => c.file), (file) => anchorsOf.get(file) ?? [], input.graph);
424
+ for (const candidate of candidates) {
425
+ const entries = ctx.get(candidate.file);
426
+ if (entries && entries.length > 0) {
427
+ candidate.decisionContext = entries;
428
+ }
429
+ }
430
+ }
380
431
  // @implements A-SPEC-478 — the uncited semantic head rerank, exactly the arm S-495 measured:
381
432
  // same math (cosine over cached doc vectors, pool FIXED), same gate (the request cited no
382
433
  // spec). The gate is load-bearing both ways — uncited corpora gained +26%/+93% at the head,
@@ -440,7 +491,12 @@ function analyzeMaintenance(input) {
440
491
  .flatMap((f) => f.symbols.map((sym) => sym.qualifiedName));
441
492
  // A file you edited is not unaffected by your edit, so these are direct impact and not merely a
442
493
  // traversal starting point.
443
- 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));
444
500
  const transitiveIds = new Set();
445
501
  const maxCallDepth = 3;
446
502
  const hubInDegree = 12;
@@ -15,12 +15,4 @@ export type IdVerdict = {
15
15
  reason: string;
16
16
  nextAvailable: number;
17
17
  };
18
- /**
19
- * 새 id 의 base 가 코퍼스 max base 를 한 칸 넘게 뛰면 거부한다.
20
- *
21
- * - 빈 코퍼스, 또는 base 를 못 뽑는 id → 통과(비교 대상이 없거나, 형태 검증은 별도 소관이라 한 결함에
22
- * 두 이름을 주지 않는다).
23
- * - base ≤ maxBase(갭 메우기·체인 완성) 또는 base == maxBase+1(새 체인) → 통과.
24
- * - base > maxBase+1(leap) → 거부, 다음 가용 번호를 문면과 필드에 댄다.
25
- */
26
18
  export declare function sequentialIdVerdict(newId: string, existingIds: string[]): IdVerdict;
@@ -30,11 +30,20 @@ function specIdBase(id) {
30
30
  * - base ≤ maxBase(갭 메우기·체인 완성) 또는 base == maxBase+1(새 체인) → 통과.
31
31
  * - base > maxBase+1(leap) → 거부, 다음 가용 번호를 문면과 필드에 댄다.
32
32
  */
33
+ /** The id's number SPACE. @implements A-SPEC-571.1 — ADR keeps its own sequence so a decision
34
+ * (jarvis's ADR-0001) neither blocks nor is blocked by the functional chain's max. Everything
35
+ * else shares one space, exactly as before. */
36
+ function idSpaceOf(id) {
37
+ return /^ADR-/.test(id.trim()) ? 'ADR' : 'functional';
38
+ }
33
39
  function sequentialIdVerdict(newId, existingIds) {
34
40
  const base = specIdBase(newId);
35
41
  if (base === null)
36
42
  return { ok: true };
37
- const bases = (existingIds ?? []).map(specIdBase).filter((n) => n !== null);
43
+ // @implements A-SPEC-571.1 compare only within the same number space.
44
+ const space = idSpaceOf(newId);
45
+ const bases = (existingIds ?? []).filter((id) => idSpaceOf(id) === space)
46
+ .map(specIdBase).filter((n) => n !== null);
38
47
  if (bases.length === 0)
39
48
  return { ok: true };
40
49
  const maxBase = Math.max(...bases);
@@ -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;