@holmes-lab/holmes-kit 0.2.1 → 0.3.1

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 (132) hide show
  1. package/CHANGELOG.md +55 -0
  2. package/dist/.build-id +1 -1
  3. package/dist/holmes/assoc/assoc-arm.d.ts +94 -0
  4. package/dist/holmes/assoc/assoc-arm.js +187 -0
  5. package/dist/holmes/assoc/explore.d.ts +21 -0
  6. package/dist/holmes/assoc/explore.js +160 -0
  7. package/dist/holmes/assoc/impact-baseline.d.ts +13 -0
  8. package/dist/holmes/assoc/impact-baseline.js +48 -0
  9. package/dist/holmes/assoc/ppr.d.ts +64 -0
  10. package/dist/holmes/assoc/ppr.js +110 -0
  11. package/dist/holmes/cli/agents.d.ts +24 -11
  12. package/dist/holmes/cli/agents.js +93 -21
  13. package/dist/holmes/cli/doctor.d.ts +56 -0
  14. package/dist/holmes/cli/doctor.js +311 -5
  15. package/dist/holmes/cli/index.js +13 -1
  16. package/dist/holmes/cli/semantic-key.d.ts +19 -0
  17. package/dist/holmes/cli/semantic-key.js +93 -0
  18. package/dist/holmes/config/config.d.ts +9 -0
  19. package/dist/holmes/config/config.js +8 -1
  20. package/dist/holmes/cpg/language-capability.d.ts +65 -0
  21. package/dist/holmes/cpg/language-capability.js +145 -0
  22. package/dist/holmes/cpg/language-parser-walk.js +179 -34
  23. package/dist/holmes/cpg/language-parser.d.ts +1 -1
  24. package/dist/holmes/governance/ledger-rechain.d.ts +12 -0
  25. package/dist/holmes/governance/ledger-rechain.js +17 -2
  26. package/dist/holmes/governance/provenance-ledger.js +21 -0
  27. package/dist/holmes/guardrail/blind-spots.js +12 -1
  28. package/dist/holmes/guardrail/impact-gate.d.ts +77 -0
  29. package/dist/holmes/guardrail/impact-gate.js +263 -0
  30. package/dist/holmes/guardrail/write-target.d.ts +38 -1
  31. package/dist/holmes/guardrail/write-target.js +41 -4
  32. package/dist/holmes/hooks/adapters/antigravity.js +12 -1
  33. package/dist/holmes/hooks/corrupt-state-run.d.ts +33 -0
  34. package/dist/holmes/hooks/corrupt-state-run.js +16 -0
  35. package/dist/holmes/hooks/pre-tool-use.d.ts +26 -0
  36. package/dist/holmes/hooks/pre-tool-use.js +203 -11
  37. package/dist/holmes/hooks/stop.d.ts +65 -0
  38. package/dist/holmes/hooks/stop.js +200 -2
  39. package/dist/holmes/mcp/elicit-approval.d.ts +32 -2
  40. package/dist/holmes/mcp/elicit-approval.js +30 -2
  41. package/dist/holmes/mcp/handlers.d.ts +88 -0
  42. package/dist/holmes/mcp/handlers.js +636 -35
  43. package/dist/holmes/mcp/maintenance-analyze.d.ts +435 -0
  44. package/dist/holmes/mcp/maintenance-analyze.js +994 -0
  45. package/dist/holmes/mcp/maintenance-evidence.d.ts +140 -0
  46. package/dist/holmes/mcp/maintenance-evidence.js +253 -0
  47. package/dist/holmes/mcp/server.js +13 -10
  48. package/dist/holmes/mcp/tool-schemas.js +71 -0
  49. package/dist/holmes/project/root.js +3 -1
  50. package/dist/holmes/review/baseline-arm.d.ts +37 -0
  51. package/dist/holmes/review/baseline-arm.js +51 -0
  52. package/dist/holmes/review/captured-stdin-guard.d.ts +8 -0
  53. package/dist/holmes/review/captured-stdin-guard.js +48 -0
  54. package/dist/holmes/review/coherence-verify.d.ts +31 -0
  55. package/dist/holmes/review/coherence-verify.js +144 -0
  56. package/dist/holmes/review/commit-text.d.ts +50 -0
  57. package/dist/holmes/review/commit-text.js +76 -0
  58. package/dist/holmes/review/confidence-calibration.d.ts +39 -0
  59. package/dist/holmes/review/confidence-calibration.js +39 -0
  60. package/dist/holmes/review/content-baseline.d.ts +38 -0
  61. package/dist/holmes/review/content-baseline.js +103 -0
  62. package/dist/holmes/review/content-verify.d.ts +20 -0
  63. package/dist/holmes/review/content-verify.js +73 -0
  64. package/dist/holmes/review/dense-retrieval.d.ts +66 -0
  65. package/dist/holmes/review/dense-retrieval.js +97 -0
  66. package/dist/holmes/review/edge-quality.d.ts +44 -0
  67. package/dist/holmes/review/edge-quality.js +117 -0
  68. package/dist/holmes/review/evaluation-metrics.d.ts +138 -0
  69. package/dist/holmes/review/evaluation-metrics.js +175 -0
  70. package/dist/holmes/review/graph-verifier.d.ts +34 -0
  71. package/dist/holmes/review/graph-verifier.js +62 -0
  72. package/dist/holmes/review/hop-ablation.d.ts +100 -0
  73. package/dist/holmes/review/hop-ablation.js +89 -0
  74. package/dist/holmes/review/manual-baseline.d.ts +209 -0
  75. package/dist/holmes/review/manual-baseline.js +2846 -0
  76. package/dist/holmes/review/oracle-gap.d.ts +32 -0
  77. package/dist/holmes/review/oracle-gap.js +102 -0
  78. package/dist/holmes/review/point-in-time-replay.d.ts +41 -0
  79. package/dist/holmes/review/point-in-time-replay.js +161 -0
  80. package/dist/holmes/review/rank-diagnosis.d.ts +43 -0
  81. package/dist/holmes/review/rank-diagnosis.js +163 -0
  82. package/dist/holmes/review/replay-calibration.d.ts +62 -0
  83. package/dist/holmes/review/replay-calibration.js +83 -0
  84. package/dist/holmes/review/replay-corpus.d.ts +135 -0
  85. package/dist/holmes/review/replay-corpus.js +210 -0
  86. package/dist/holmes/review/run-replay.d.ts +260 -0
  87. package/dist/holmes/review/run-replay.js +729 -0
  88. package/dist/holmes/review/semantic-arm.d.ts +271 -0
  89. package/dist/holmes/review/semantic-arm.js +717 -0
  90. package/dist/holmes/review/semantic-retrieval.d.ts +55 -0
  91. package/dist/holmes/review/semantic-retrieval.js +156 -0
  92. package/dist/holmes/review/spec-layer-stats.d.ts +38 -0
  93. package/dist/holmes/review/spec-layer-stats.js +52 -0
  94. package/dist/holmes/review/temporal-prior.d.ts +33 -0
  95. package/dist/holmes/review/temporal-prior.js +53 -0
  96. package/dist/holmes/review/test-runner.d.ts +15 -0
  97. package/dist/holmes/review/test-runner.js +41 -4
  98. package/dist/holmes/review/test-selection-breadth.d.ts +75 -0
  99. package/dist/holmes/review/test-selection-breadth.js +57 -0
  100. package/dist/holmes/review/traceability-benchmark.d.ts +81 -0
  101. package/dist/holmes/review/traceability-benchmark.js +135 -0
  102. package/dist/holmes/review/union-verify.d.ts +12 -0
  103. package/dist/holmes/review/union-verify.js +70 -0
  104. package/dist/holmes/rtm/graph-store.d.ts +51 -0
  105. package/dist/holmes/rtm/graph-store.js +122 -0
  106. package/dist/holmes/rtm/incremental.d.ts +25 -1
  107. package/dist/holmes/rtm/incremental.js +18 -1
  108. package/dist/holmes/rtm/localize.d.ts +28 -0
  109. package/dist/holmes/rtm/localize.js +272 -14
  110. package/dist/holmes/rtm/rtm-builder.d.ts +118 -3
  111. package/dist/holmes/rtm/rtm-builder.js +265 -28
  112. package/dist/holmes/rtm/rtm-graph.d.ts +117 -2
  113. package/dist/holmes/rtm/rtm-graph.js +194 -31
  114. package/dist/holmes/rtm/taint-benchmark.d.ts +97 -0
  115. package/dist/holmes/rtm/taint-benchmark.js +141 -0
  116. package/dist/holmes/rtm/test-scope.js +24 -1
  117. package/dist/holmes/semantic/credentials.d.ts +15 -0
  118. package/dist/holmes/semantic/credentials.js +134 -0
  119. package/dist/holmes/semantic/embedder.d.ts +44 -0
  120. package/dist/holmes/semantic/embedder.js +185 -0
  121. package/dist/holmes/semantic/hit-rerank.d.ts +4 -0
  122. package/dist/holmes/semantic/hit-rerank.js +38 -0
  123. package/dist/holmes/semantic/tier.d.ts +37 -0
  124. package/dist/holmes/semantic/tier.js +54 -0
  125. package/dist/holmes/semantic/vector-cache.d.ts +11 -0
  126. package/dist/holmes/semantic/vector-cache.js +91 -0
  127. package/dist/holmes/spec/acceptance-quality.d.ts +81 -0
  128. package/dist/holmes/spec/acceptance-quality.js +169 -0
  129. package/dist/holmes/spec/validator.js +33 -1
  130. package/dist/holmes/spec/yaml-scalar.d.ts +1 -0
  131. package/dist/holmes/spec/yaml-scalar.js +43 -0
  132. package/package.json +1 -1
@@ -0,0 +1,994 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.PRIMARY_TIER_WIDTH = exports.EVIDENCE_DIMENSIONS = exports.MAINTENANCE_ANALYSIS_EXTRACTOR = exports.MAINTENANCE_ANALYSIS_SCHEMA = void 0;
4
+ exports.unquoteGitPath = unquoteGitPath;
5
+ exports.analyzeMaintenance = analyzeMaintenance;
6
+ // @implements A-SPEC-295
7
+ // @implements A-SPEC-294
8
+ // @implements A-SPEC-293
9
+ // @implements A-SPEC-292
10
+ // @implements A-SPEC-290
11
+ // @implements A-SPEC-289
12
+ // @implements A-SPEC-288
13
+ // @implements A-SPEC-286
14
+ // @implements A-SPEC-285
15
+ // @implements A-SPEC-279
16
+ // @implements A-SPEC-274
17
+ // @implements A-SPEC-271
18
+ // @implements A-SPEC-269
19
+ // @implements A-SPEC-267
20
+ const crypto_1 = require("crypto");
21
+ const localize_1 = require("../rtm/localize");
22
+ const dense_retrieval_1 = require("../review/dense-retrieval");
23
+ const test_scope_1 = require("../rtm/test-scope");
24
+ const scope_1 = require("../review/scope");
25
+ // @implements A-SPEC-469 — the SAME code path the S-484 measurement ran; identity is the reason
26
+ // the measured numbers transfer to the shipped surface.
27
+ const assoc_arm_1 = require("../assoc/assoc-arm");
28
+ const acceptance_quality_1 = require("../spec/acceptance-quality");
29
+ const taint_1 = require("../rtm/taint");
30
+ const test_evidence_1 = require("../review/test-evidence");
31
+ const language_capability_1 = require("../cpg/language-capability");
32
+ // @implements A-SPEC-409 — the SHAPE gained a field (`rerankPool`), so a consumer can detect it.
33
+ // @implements A-SPEC-405 A-SPEC-407 — the EXTRACTOR changed too: the same request now returns a
34
+ // differently ordered candidate list (keep-head rank fusion, then the graph-hop list), so two
35
+ // artifacts are only comparable when this string matches.
36
+ exports.MAINTENANCE_ANALYSIS_SCHEMA = 'maintenance-analysis/1.13';
37
+ exports.MAINTENANCE_ANALYSIS_EXTRACTOR = 'holmes-graph-fusion/1.13';
38
+ /**
39
+ * The evidence kinds an ablation arm can actually produce. Completeness is measured by asking each
40
+ * arm which of these it FILLED — the first cut hardcoded 1 / 2 / 2+n, so "enhanced beats current"
41
+ * was an identity of the code rather than a result of a measurement.
42
+ */
43
+ exports.EVIDENCE_DIMENSIONS = [
44
+ 'candidates', 'specs', 'directImpact', 'transitiveImpact', 'tests', 'history', 'versionedBasis',
45
+ ];
46
+ /**
47
+ * @implements A-SPEC-271
48
+ * How many ranked candidates form the `primary` tier. A fixed rank cut, not a score threshold:
49
+ * measured over the 12-commit replay corpus at matched set sizes, a `score >= ratio * top` cut was
50
+ * never better than the plain rank cut on both axes at once (top-3 beat ratio>=0.5 on recall AND
51
+ * precision), and tuning a threshold on 12 samples would be fitting noise.
52
+ */
53
+ exports.PRIMARY_TIER_WIDTH = 3;
54
+ function sortedUnique(values) {
55
+ return [...new Set(values)].sort((a, b) => a.localeCompare(b));
56
+ }
57
+ function codeQualifiedName(codeId) {
58
+ const body = codeId.startsWith('CODE:') ? codeId.slice(5) : codeId;
59
+ const delimiter = body.indexOf('@');
60
+ return delimiter === -1 ? body : body.slice(0, delimiter);
61
+ }
62
+ /**
63
+ * Recall/precision against a ground-truth set. An ABSENT or EMPTY truth set yields `null`, never 0:
64
+ * "we did not measure" and "we measured zero" are different claims, and collapsing them would let an
65
+ * unmeasured arm look like a failed one (or, worse, let a vacuous 1.0 stand as a score).
66
+ */
67
+ function scoreAgainst(predicted, truth) {
68
+ if (!truth || truth.length === 0)
69
+ return { recall: null, precision: null };
70
+ const expected = new Set(truth);
71
+ const actual = new Set(predicted);
72
+ let hits = 0;
73
+ for (const value of actual)
74
+ if (expected.has(value))
75
+ hits++;
76
+ return { recall: hits / expected.size, precision: actual.size === 0 ? 0 : hits / actual.size };
77
+ }
78
+ function arm(files, symbols, tests, dimensions, truth, primary, impacted, importedBy) {
79
+ const localization = scoreAgainst(files, truth?.files);
80
+ const impact = scoreAgainst(symbols, truth?.symbols);
81
+ const test = scoreAgainst(tests, truth?.tests);
82
+ const filled = exports.EVIDENCE_DIMENSIONS.filter((dimension) => dimensions.includes(dimension));
83
+ return {
84
+ localizationCandidates: new Set(files).size,
85
+ localizationRecall: localization.recall,
86
+ localizationPrecision: localization.precision,
87
+ impactRecall: impact.recall,
88
+ impactPrecision: impact.precision,
89
+ testRecall: test.recall,
90
+ testPrecision: test.precision,
91
+ evidenceDimensions: [...filled],
92
+ evidenceCompleteness: filled.length / exports.EVIDENCE_DIMENSIONS.length,
93
+ // An arm without tiers has no primary claim to score — null, not a borrowed flat number.
94
+ primaryRecall: primary ? scoreAgainst(primary, truth?.files).recall : null,
95
+ primaryPrecision: primary ? scoreAgainst(primary, truth?.files).precision : null,
96
+ impactedFilesRecall: impacted ? scoreAgainst(impacted, truth?.files).recall : null,
97
+ impactedFilesPrecision: impacted ? scoreAgainst(impacted, truth?.files).precision : null,
98
+ importedByRecall: importedBy ? scoreAgainst(importedBy, truth?.files).recall : null,
99
+ importedByPrecision: importedBy ? scoreAgainst(importedBy, truth?.files).precision : null,
100
+ };
101
+ }
102
+ /**
103
+ * Decode a pathname as git prints it in `--name-only`/`--name-status` output. With the default
104
+ * `core.quotepath=true`, any non-ASCII or control byte makes git wrap the name in double quotes and
105
+ * emit C-style escapes (`"src/\354\227\260.ts"`). Comparing that raw form against the scanner's
106
+ * real relative path never matches, which silently emptied the historical rationale while the
107
+ * provenance still claimed the history was `available`.
108
+ */
109
+ function unquoteGitPath(line) {
110
+ if (line.length < 2 || !line.startsWith('"') || !line.endsWith('"'))
111
+ return line;
112
+ const body = line.slice(1, -1);
113
+ const bytes = [];
114
+ for (let i = 0; i < body.length; i++) {
115
+ if (body[i] !== '\\') {
116
+ bytes.push(...Buffer.from(body[i], 'utf8'));
117
+ continue;
118
+ }
119
+ const next = body[++i];
120
+ if (next === undefined)
121
+ break;
122
+ const simple = { a: 7, b: 8, f: 12, n: 10, r: 13, t: 9, v: 11, '\\': 92, '"': 34 };
123
+ if (next in simple) {
124
+ bytes.push(simple[next]);
125
+ continue;
126
+ }
127
+ if (next >= '0' && next <= '7') {
128
+ const octal = next + (body[i + 1] ?? '') + (body[i + 2] ?? '');
129
+ const digits = octal.match(/^[0-7]{1,3}/)[0];
130
+ i += digits.length - 1;
131
+ bytes.push(parseInt(digits, 8));
132
+ continue;
133
+ }
134
+ bytes.push(...Buffer.from(next, 'utf8'));
135
+ }
136
+ return Buffer.from(bytes).toString('utf8');
137
+ }
138
+ function canonical(value) {
139
+ if (Array.isArray(value))
140
+ return `[${value.map(canonical).join(',')}]`;
141
+ if (value && typeof value === 'object') {
142
+ const record = value;
143
+ return `{${Object.keys(record).sort().map((key) => `${JSON.stringify(key)}:${canonical(record[key])}`).join(',')}}`;
144
+ }
145
+ return JSON.stringify(value);
146
+ }
147
+ /**
148
+ * Fuse lexical localization, RTM ancestry, call-graph blast radius, test anchors, and history into a
149
+ * single immutable evidence contract. This core intentionally performs no I/O, so handlers can
150
+ * reproduce and compare an analysis from the exact same captured inputs.
151
+ */
152
+ function analyzeMaintenance(input) {
153
+ const scanned = [...input.scanned];
154
+ const specs = [...input.specs];
155
+ // @implements A-SPEC-378 — a wide pool so the re-rank has something to promote; the cut to ten
156
+ // happens after the prior is applied.
157
+ const LOCALIZATION_TOP_N = 10;
158
+ // @implements A-SPEC-388 — swept over 0.5/1/2/4 on the clean slice; 2 was the smallest value at
159
+ // the plateau on both corpora, and a larger one bought nothing.
160
+ const COMMIT_TEXT_WEIGHT = 2;
161
+ // @implements A-SPEC-409 A-SPEC-411 A-SPEC-440 — the narrow path asked for exactly ten, which left
162
+ // no eleventh to hand the caller. Widened to sixty, which is `LOCALIZATION_TOP_N + RERANK_POOL_N`:
163
+ // the window can only be as wide as the list it is cut from, and a narrow-path caller was silently
164
+ // getting a shorter pool than a caller who supplied history. The first ten are the same ten in the
165
+ // same order — this is one ranked list read further down.
166
+ const localization = (0, localize_1.localizeIssue)(input.request, scanned, specs, input.changePrior === undefined && input.commitTextBoost === undefined ? LOCALIZATION_TOP_N * 6 : 500);
167
+ // @implements A-SPEC-378
168
+ // Re-ranked over a WIDE pool, then cut to ten. Re-ranking the top ten alone would leave the gain
169
+ // unrealised: the files recency promotes are usually ranked 11th to 50th lexically, which is
170
+ // exactly why the lexical layer misses them.
171
+ const prior = input.changePrior;
172
+ const boost = input.commitTextBoost;
173
+ const hasPrior = prior !== undefined && Object.keys(prior).length > 0;
174
+ const hasBoost = boost !== undefined && Object.keys(boost).length > 0;
175
+ // @implements A-SPEC-388
176
+ // Files the commit prose names but the lexical layer never scored have to be able to ENTER, or the
177
+ // fusion can only reorder what lexical already found — and reaching what it missed is where the
178
+ // measured gain came from (Top-10 recall 0.393 -> 0.601 on the second corpus).
179
+ 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'] }))]
183
+ : localization.hits;
184
+ const reranked = !hasPrior && !hasBoost
185
+ ? localization.hits
186
+ : [...seeded]
187
+ .map((hit) => ({
188
+ ...hit,
189
+ score: (hit.score * (hasPrior ? (prior[hit.file] ?? 1) : 1))
190
+ // @implements A-SPEC-431
191
+ // Scaling this by the CASE (median positive lexical score) instead of the file's own
192
+ // score was implemented, measured on all three corpora, and reverted: Top-5 recall fell
193
+ // 0.509 -> 0.476 here and 0.528 -> 0.494 on the second corpus, with pool coverage
194
+ // unchanged everywhere. The multiplication looks wrong — it lets a large file grow by
195
+ // the product of two signals, which is how `config/config.ts` reached 135.43 — but it is
196
+ // doing work: prose reinforcing a file that ALREADY has lexical evidence ranks better
197
+ // than prose spread evenly. Kept, with the cost named rather than defended.
198
+ + (hasBoost ? COMMIT_TEXT_WEIGHT * (boost[hit.file] ?? 0) * Math.max(1, hit.score) : 0),
199
+ }))
200
+ .sort((a, b) => b.score - a.score || a.file.localeCompare(b.file));
201
+ // @implements A-SPEC-405
202
+ // S-405 decomposed the recall loss measured in S-404: on the eight blind cases 18 of 18 truth
203
+ // files were already in this pool and 7 sat at ranks 12-93, so widening retrieval could not have
204
+ // helped — only the ordering could. Twelve of the eighteen scored ZERO lexically; they were
205
+ // reached through commit prose alone, and the product formula above multiplies a [0,1] boost
206
+ // against an unbounded lexical score, which leaves them permanently under anything the lexical
207
+ // layer did score.
208
+ //
209
+ // Rank fusion is scale-free and fixes exactly that. Measured on the 30-case clean slice of both
210
+ // corpora, plain fusion also COST Top-1 (jarvis 0.533 -> 0.367) because it discards the margin by
211
+ // which a strong lexical winner won, and no weighting recovered it (best 0.433 across w=2/3/4/6).
212
+ // So the score-ranked head is kept verbatim and only the tail is fused: Top-1 is preserved by
213
+ // construction rather than by luck.
214
+ // holmes Top-5 recall 0.382 -> 0.493, Top-10 0.604 -> 0.633, Top-10 precision 0.117 -> 0.127
215
+ // jarvis Top-5 recall 0.459 -> 0.488, Top-10 0.612 -> 0.612, Top-10 precision 0.145 -> 0.135
216
+ // The jarvis precision dip is real and is recorded rather than smoothed over.
217
+ const RRF_K = 60;
218
+ const fuseTail = () => {
219
+ if (!hasPrior && !hasBoost)
220
+ return reranked;
221
+ const byFile = new Map(seeded.map((h) => [h.file, h]));
222
+ const desc = (pick) => [...byFile.keys()].filter((f) => pick(f) > 0)
223
+ .sort((a, b) => pick(b) - pick(a) || a.localeCompare(b));
224
+ // @implements A-SPEC-407
225
+ // The knowledge graph as a ranking list. S-406 measured that 9 of the 12 answers the lexical
226
+ // layer scored at zero sit one hop from a lexically strong file (8 by call edge, 1 by shared
227
+ // spec) — the graph held them all along and nothing consulted it.
228
+ //
229
+ // It both reorders and admits, and the distinction matters enough to state plainly: a file the
230
+ // hop reaches enters the fusion even when no other layer scored it. The first implementation
231
+ // restricted it to files that were already candidates, which is NOT what the arm measured, and
232
+ // it reproduced none of the gain. What keeps this from repeating the rejected retrieval attempts
233
+ // is not a rule against admission but the seed count.
234
+ //
235
+ // FIVE seeds, measured rather than chosen: at 20 both corpora get worse (holmes Top-5 recall
236
+ // 0.554 -> 0.482, jarvis 0.517 -> 0.506). A wide seed set is what turned this axis into noise
237
+ // before, and the narrowness is the mechanism, not a tuning detail.
238
+ const HOP_SEEDS = 5;
239
+ // @implements A-SPEC-407
240
+ // Query-conditioned, exactly as spec admission is (A-SPEC-399), and for a measured reason. On
241
+ // the newest-60 slice of this repository, where 88.3% of requests name their spec outright, the
242
+ // hop list made things WORSE across the board (union precision 0.172 -> 0.134). When the author
243
+ // has already said which spec this is, the RTM answers exactly and a graph fan-out can only
244
+ // dilute that answer. The gain measured on the clean slice came from requests with no citation
245
+ // at all — which is what a real bug report looks like — so that is where the hop is consulted.
246
+ const cited = (0, localize_1.citationsIn)(input.request, new Set(specs.map((sp) => sp.id))).cited;
247
+ const hopList = () => {
248
+ const byPath = new Map(scanned.map((f) => [f.sourcePath, f]));
249
+ const touched = new Map();
250
+ const fileOfNode = (nodeId) => {
251
+ const at = nodeId.lastIndexOf('@');
252
+ return at < 0 ? null : nodeId.slice(at + 1);
253
+ };
254
+ for (const seed of desc((f) => byFile.get(f).score).slice(0, HOP_SEEDS)) {
255
+ const file = byPath.get(seed);
256
+ if (file === undefined)
257
+ continue;
258
+ const reached = new Set();
259
+ for (const sym of file.symbols) {
260
+ for (const nodeId of input.graph.codeNodeIds(sym.qualifiedName)) {
261
+ for (const n of [...input.graph.callees(nodeId), ...input.graph.callersOf(nodeId)]) {
262
+ const f = fileOfNode(n);
263
+ if (f !== null)
264
+ reached.add(f);
265
+ }
266
+ }
267
+ }
268
+ for (const importer of input.graph.importersOf(seed))
269
+ reached.add(String(importer).replace(/^FILE:/, ''));
270
+ for (const n of input.graph.neighborsWithRel(`FILE:${seed}`)) {
271
+ if (n.rel === 'imports')
272
+ reached.add(n.id.replace(/^FILE:/, ''));
273
+ }
274
+ for (const f of reached) {
275
+ if (f !== seed && byPath.has(f))
276
+ touched.set(f, (touched.get(f) ?? 0) + 1);
277
+ }
278
+ }
279
+ return [...touched.entries()]
280
+ .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
281
+ .map(([file]) => file);
282
+ };
283
+ const hops = cited.length > 0 ? [] : hopList();
284
+ for (const file of hops) {
285
+ if (!byFile.has(file)) {
286
+ byFile.set(file, { file, score: 0, matchedSymbols: [], viaSpecs: [], why: ['reached through the knowledge graph'] });
287
+ }
288
+ }
289
+ ;
290
+ const lists = [
291
+ desc((f) => byFile.get(f).score),
292
+ hasBoost ? desc((f) => boost[f] ?? 0) : [],
293
+ hasPrior ? desc((f) => (prior[f] ?? 1) - 1) : [],
294
+ cited.length > 0 ? [] : hops,
295
+ ];
296
+ const fused = new Map();
297
+ for (const list of lists) {
298
+ list.forEach((file, i) => fused.set(file, (fused.get(file) ?? 0) + 1 / (RRF_K + i + 1)));
299
+ }
300
+ const head = reranked[0];
301
+ if (head === undefined)
302
+ return reranked;
303
+ const tail = [...fused.entries()]
304
+ .filter(([file]) => file !== head.file)
305
+ .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
306
+ .filter(([file]) => byFile.has(file))
307
+ .map(([file]) => byFile.get(file));
308
+ return [head, ...tail];
309
+ };
310
+ const ordered = fuseTail();
311
+ // @implements A-SPEC-411
312
+ // Widened from ten to thirty, so the caller sees forty rather than twenty. S-410 left exactly one
313
+ // axis behind a person with grep — Top-10 recall 0.764 to 0.821 — and decomposing that gap over
314
+ // all sixteen cases found it to be FOUR truth files: one I saw and skipped, one the pool never
315
+ // held, and TWO at ranks 33 and 34, below the width the caller was given.
316
+ //
317
+ // The width curve on those same sixteen: 0.651 at ten, 0.829 at twenty, 0.829 at thirty (flat, so
318
+ // thirty buys nothing), 0.885 at forty, flat thereafter. Forty is the next step, and 0.885 is
319
+ // above the 0.821 the human method actually reached.
320
+ //
321
+ // The cost is real and is not paid by this repository: forty annotated items occupy the CALLER's
322
+ // context. What it buys is two truth files in sixteen cases, and only if the caller reranks well.
323
+ // @implements A-SPEC-440
324
+ // Fifty, so the caller sees sixty. A-SPEC-411 set this from a curve over sixteen cases and landed
325
+ // on forty; with a third corpus and the clean slices re-measured, sixty is where the gain actually
326
+ // arrives. Truth inside the exposed window, measured through this path:
327
+ //
328
+ // width 40 holmes 48/65 jarvis 52/70 gemini 14/19
329
+ // width 60 holmes 49/65 jarvis 59/70 gemini 14/19
330
+ // width 80 holmes 50/65 jarvis 59/70 gemini 14/19
331
+ //
332
+ // Jarvis's whole gain is in by sixty; eighty buys one more file on this repository for double the
333
+ // caller's context, so sixty is the knee rather than the maximum. No corpus is worse at sixty than
334
+ // at forty — the first width or signal change in this line of work that clears A-SPEC-425's bar
335
+ // instead of trading one corpus against another.
336
+ //
337
+ // Top-5 and Top-10 recall are identical at all three widths: `candidates` is untouched, and the
338
+ // pinned floors read that field.
339
+ const RERANK_POOL_N = 50;
340
+ // @implements A-SPEC-486 — the S-502/503 calibration point for gemini cosines. Display only:
341
+ // nothing in this module compares against it to drop or reorder anything.
342
+ const SEM_VERIFY_TAU = 0.65;
343
+ const shape = (hit) => ({
344
+ file: hit.file,
345
+ score: hit.score,
346
+ symbols: sortedUnique(hit.matchedSymbols),
347
+ evidence: hit.why,
348
+ });
349
+ // @implements A-SPEC-428
350
+ // The fusion can delete a correct answer outright. Measured on a third party's repository,
351
+ // leak-free at the parent commit: `localizeIssue` ranked `packages/core/src/tools/ask-user.ts`
352
+ // FIRST for "properly format markdown in the AskUser tool", and the forty candidates this function
353
+ // exposed did not contain it. Over eight blind cases, seven answers were in the lexical top ten
354
+ // and FIVE of those were absent from the exposed forty — ranks 1, 2, 4, 5 and 8.
355
+ //
356
+ // A-SPEC-405's kept head does not cover this: it preserves the head of the score ordering, and
357
+ // that score has already been multiplied by change-recency and added to commit-prose boost, so
358
+ // the file the lexical layer was surest about is not the file that survives.
359
+ //
360
+ // The guarantee is on the WINDOW, not the ranking. `candidates` keeps exactly the ten it had —
361
+ // the pinned replay floors read that field and are unchanged by construction — and the reserved
362
+ // slots come out of the pool, which is material for the caller rather than a verdict.
363
+ const LEXICAL_GUARANTEE = 10;
364
+ const exposedTail = ordered.slice(LOCALIZATION_TOP_N);
365
+ const inCandidates = new Set(ordered.slice(0, LOCALIZATION_TOP_N).map((h) => h.file));
366
+ const wouldShow = new Set(exposedTail.slice(0, RERANK_POOL_N).map((h) => h.file));
367
+ const rescued = localization.hits.slice(0, LEXICAL_GUARANTEE)
368
+ .filter((h) => !inCandidates.has(h.file) && !wouldShow.has(h.file))
369
+ .map((h) => exposedTail.find((o) => o.file === h.file))
370
+ .filter((h) => h !== undefined);
371
+ const rescuedFiles = new Set(rescued.map((h) => h.file));
372
+ const rerankPool = [...rescued, ...exposedTail.filter((h) => !rescuedFiles.has(h.file))]
373
+ .slice(0, RERANK_POOL_N).map(shape);
374
+ const candidates = ordered.slice(0, LOCALIZATION_TOP_N).map((hit) => ({
375
+ file: hit.file,
376
+ score: hit.score,
377
+ symbols: sortedUnique(hit.matchedSymbols),
378
+ evidence: [...hit.why],
379
+ }));
380
+ // @implements A-SPEC-478 — the uncited semantic head rerank, exactly the arm S-495 measured:
381
+ // same math (cosine over cached doc vectors, pool FIXED), same gate (the request cited no
382
+ // spec). The gate is load-bearing both ways — uncited corpora gained +26%/+93% at the head,
383
+ // and the citation-rich corpus LOST 61% when reranked, so a citation anywhere disables this.
384
+ // Only candidates with a CACHED vector move (stable order otherwise); any failure is a no-op.
385
+ {
386
+ const sem = input.semantic;
387
+ const citedForSem = (0, localize_1.citationsIn)(input.request, new Set(specs.map((sp) => sp.id))).cited;
388
+ if (sem !== undefined && sem.queryVector !== null && citedForSem.length === 0 && candidates.length > 1) {
389
+ const docTextOf = new Map(scanned.map((f) => [f.sourcePath,
390
+ (f.sourcePath + ' ' + f.symbols.map((sy) => sy.qualifiedName).join(' ')).slice(0, 2000)]));
391
+ const sim = new Map();
392
+ let covered = 0;
393
+ for (const c of candidates) {
394
+ const dt = docTextOf.get(c.file);
395
+ const dv = dt !== undefined ? sem.cachedDocVector(dt) : null;
396
+ if (dv !== null) {
397
+ sim.set(c.file, (0, dense_retrieval_1.cosine)(sem.queryVector, dv));
398
+ covered++;
399
+ }
400
+ }
401
+ if (covered > 0) {
402
+ candidates.sort((a, b) => (sim.get(b.file) ?? -Infinity) === (sim.get(a.file) ?? -Infinity)
403
+ ? 0 : ((sim.get(b.file) ?? -Infinity) - (sim.get(a.file) ?? -Infinity)));
404
+ for (const c of candidates) {
405
+ if (sim.has(c.file))
406
+ c.evidence.push(`semantic-rerank(${sem.label}, coverage ${covered}/${candidates.length})`);
407
+ }
408
+ }
409
+ }
410
+ }
411
+ // @implements A-SPEC-486 — sem-verify EVIDENCE, never a filter. The τ0.65 precision multiple
412
+ // (~×1.7) replicated in three independent windows (S-502/503), but its recall cost is
413
+ // window-dependent, so the harness attaches the verdict and the CONSUMER selects. Cloud tier
414
+ // only: the threshold is a gemini-cosine calibration; local bge-m3 cosines are quasi-binary
415
+ // (S-497) and a grade on them would mislead. Sets, orders and scores never move here.
416
+ {
417
+ const sem = input.semantic;
418
+ if (sem !== undefined && sem.queryVector !== null && sem.label.startsWith('cloud:')) {
419
+ const docTextOf = new Map(scanned.map((f) => [f.sourcePath,
420
+ (f.sourcePath + ' ' + f.symbols.map((sy) => sy.qualifiedName).join(' ')).slice(0, 2000)]));
421
+ for (const c of candidates) {
422
+ const dt = docTextOf.get(c.file);
423
+ const dv = dt !== undefined ? sem.cachedDocVector(dt) : null;
424
+ if (dv === null)
425
+ continue;
426
+ const cos = (0, dense_retrieval_1.cosine)(sem.queryVector, dv);
427
+ c.evidence.push(`sem-verify(${sem.label}, cos=${cos.toFixed(2)}, τ${SEM_VERIFY_TAU} ${cos >= SEM_VERIFY_TAU ? 'pass' : 'fail'})`);
428
+ }
429
+ }
430
+ }
431
+ // @implements A-SPEC-307
432
+ // Impact must start from what CHANGED, not only from what the request text happened to match.
433
+ // Until this slice `changedFiles` fed the architecture check alone, so asking "what does changing
434
+ // this file affect" was answered from the request instead of from the change. Measured consequence
435
+ // (A-SPEC-306): the walk reached 11% of what the graph could reach. Additive on purpose — callers
436
+ // that pass a request and no changed files keep the previous behaviour exactly.
437
+ const changedSymbols = input.changedFiles === undefined
438
+ ? []
439
+ : scanned.filter((f) => input.changedFiles.includes(f.sourcePath))
440
+ .flatMap((f) => f.symbols.map((sym) => sym.qualifiedName));
441
+ // A file you edited is not unaffected by your edit, so these are direct impact and not merely a
442
+ // traversal starting point.
443
+ const direct = sortedUnique([...candidates.flatMap((candidate) => candidate.symbols), ...changedSymbols]);
444
+ const transitiveIds = new Set();
445
+ const maxCallDepth = 3;
446
+ const hubInDegree = 12;
447
+ const stoppedAt = [];
448
+ const seeds = direct.flatMap((qualifiedName) => input.graph.codeNodeIds(qualifiedName));
449
+ const seen = new Set(seeds);
450
+ const candidateSpecs = new Set(localization.matchedSpecs);
451
+ for (const qualifiedName of direct) {
452
+ for (const id of input.graph.codeNodeIds(qualifiedName)) {
453
+ for (const specId of input.graph.specsOf(id))
454
+ candidateSpecs.add(specId.replace(/^SPEC:/, ''));
455
+ }
456
+ }
457
+ // Snapshot of what a graph WITHOUT caller-closure could reach. The "current" ablation arm is
458
+ // computed from this, not from the enhanced result, so the comparison is not self-referential.
459
+ const directSpecIds = sortedUnique(candidateSpecs);
460
+ let frontier = seeds;
461
+ for (let depth = 1; depth <= maxCallDepth && frontier.length > 0; depth++) {
462
+ const next = [];
463
+ for (const id of frontier) {
464
+ const inDegree = input.graph.callerCount(id);
465
+ if (depth > 1 && inDegree > hubInDegree) {
466
+ stoppedAt.push({ id, reason: 'hub', inDegree });
467
+ continue;
468
+ }
469
+ // @implements A-SPEC-309
470
+ // Still UPSTREAM only, and now for a measured reason rather than an oversight. Adding the
471
+ // downstream half (Goal Phase 5 names it, and `impactNeighborsOf` implements it) was tried on
472
+ // the 36-case corpus:
473
+ //
474
+ // recall 0.093 -> 0.199 (2.1x better)
475
+ // precision 0.064 -> 0.0093 (6.8x worse — one predicted file in 107 is right)
476
+ // depth stops 39 -> 3646, hub stops 1 -> 110
477
+ //
478
+ // Direction IS the lever — recall more than doubled where seeds, ground truth and bounds had
479
+ // each moved nothing. But operating principle 12 puts precision first, and a set where 1 in
480
+ // 107 is right cannot drive test scope. Reverted as a trade, not as a failure: the next
481
+ // attempt has to pair the direction with a cost control, and `expectedBehindStops` rising from
482
+ // 3 to 50 says the bounds finally matter once the walk goes both ways.
483
+ // @implements A-SPEC-313
484
+ // Upstream only — and now judged on the ruler that measures the PRODUCT, not a part of it.
485
+ //
486
+ // A-SPEC-309..311 rejected downstream on impact-only precision. A-SPEC-312 showed that number
487
+ // describes a component: what the caller receives is candidates UNION impacts. So the best
488
+ // downstream variant (one hop, utilities damped) was re-measured on the union:
489
+ //
490
+ // union recall union precision impact-only truths
491
+ // upstream only 0.874 0.155 26
492
+ // + damped one hop 0.894 0.0790 30
493
+ //
494
+ // Four more true files out of 155, for HALF the product's precision. The criterion was written
495
+ // into REQ-313 before the run, and it says no. The earlier verdicts were reached with the
496
+ // wrong ruler and still landed on the right answer — worth knowing, and worth having checked
497
+ // rather than assumed.
498
+ for (const caller of input.graph.impactSourcesOf(id)) {
499
+ if (seen.has(caller))
500
+ continue;
501
+ seen.add(caller);
502
+ transitiveIds.add(caller);
503
+ next.push(caller);
504
+ }
505
+ }
506
+ frontier = next;
507
+ }
508
+ for (const id of frontier)
509
+ stoppedAt.push({ id, reason: 'depth' });
510
+ const transitive = sortedUnique([...transitiveIds].map(codeQualifiedName).filter((qn) => !direct.includes(qn)));
511
+ for (const qualifiedName of transitive) {
512
+ for (const id of input.graph.codeNodeIds(qualifiedName)) {
513
+ for (const specId of input.graph.specsOf(id))
514
+ candidateSpecs.add(specId.replace(/^SPEC:/, ''));
515
+ }
516
+ }
517
+ const specIds = sortedUnique(candidateSpecs);
518
+ const fileOfSymbol = new Map();
519
+ for (const file of scanned) {
520
+ for (const symbol of file.symbols) {
521
+ if (!fileOfSymbol.has(symbol.qualifiedName))
522
+ fileOfSymbol.set(symbol.qualifiedName, file.sourcePath);
523
+ }
524
+ }
525
+ const rankedFiles = candidates.map((candidate) => candidate.file);
526
+ const primaryTier = rankedFiles.slice(0, exports.PRIMARY_TIER_WIDTH);
527
+ const secondaryTier = rankedFiles.slice(exports.PRIMARY_TIER_WIDTH);
528
+ const reachableTier = sortedUnique(transitive
529
+ .map((qualifiedName) => fileOfSymbol.get(qualifiedName))
530
+ .filter((file) => !!file && !rankedFiles.includes(file)));
531
+ // @implements A-SPEC-469 — the graded surface, seeded like the S-484 measurement was: from the
532
+ // CHANGED files' symbols when a change is named (the impact question), from every direct symbol
533
+ // otherwise (the request-only question). The whole changed set is excluded from the prediction.
534
+ const rankedImpactSeedSymbols = (input.changedFiles?.length ?? 0) > 0 ? changedSymbols : direct;
535
+ const rankedImpactSeeds = new Map(rankedImpactSeedSymbols
536
+ .flatMap((qualifiedName) => input.graph.codeNodeIds(qualifiedName))
537
+ .map((id) => [id, 1]));
538
+ const rankedImpact = (0, assoc_arm_1.pprImpactRanked)((0, assoc_arm_1.graphViewOf)(input.graph.dumpCanonical()), rankedImpactSeeds, new Set(input.changedFiles ?? []), assoc_arm_1.RANKED_IMPACT_K, assoc_arm_1.RANKED_IMPACT_CONFIG);
539
+ // @implements A-SPEC-486 — the same additive verdict on the graded impact surface: a cached
540
+ // cosine per row, no row added or removed, score untouched.
541
+ {
542
+ const sem = input.semantic;
543
+ if (sem !== undefined && sem.queryVector !== null && sem.label.startsWith('cloud:')) {
544
+ const docTextOf = new Map(scanned.map((f) => [f.sourcePath,
545
+ (f.sourcePath + ' ' + f.symbols.map((sy) => sy.qualifiedName).join(' ')).slice(0, 2000)]));
546
+ for (const row of rankedImpact) {
547
+ const dt = docTextOf.get(row.file);
548
+ const dv = dt !== undefined ? sem.cachedDocVector(dt) : null;
549
+ if (dv !== null)
550
+ row.semCos = Math.round((0, dense_retrieval_1.cosine)(sem.queryVector, dv) * 1e4) / 1e4;
551
+ }
552
+ }
553
+ }
554
+ // @implements A-SPEC-494 — the alternates: cached-lookup cosines over every scanned file the
555
+ // emission missed, top-3. Pure addition — no set, order or score above moves because of this.
556
+ let semanticAlternates;
557
+ {
558
+ const sem = input.semantic;
559
+ if (sem !== undefined && sem.queryVector !== null && sem.label.startsWith('cloud:')) {
560
+ const emitted = new Set([...candidates.map((c) => c.file), ...rankedImpact.map((r) => r.file)]);
561
+ const scoredAlt = [];
562
+ for (const f of scanned) {
563
+ if (emitted.has(f.sourcePath))
564
+ continue;
565
+ const dt = (f.sourcePath + ' ' + f.symbols.map((sy) => sy.qualifiedName).join(' ')).slice(0, 2000);
566
+ const dv = sem.cachedDocVector(dt);
567
+ if (dv === null)
568
+ continue;
569
+ scoredAlt.push({ file: f.sourcePath, cos: Math.round((0, dense_retrieval_1.cosine)(sem.queryVector, dv) * 1e4) / 1e4 });
570
+ }
571
+ scoredAlt.sort((a, b) => b.cos - a.cos);
572
+ if (scoredAlt.length > 0)
573
+ semanticAlternates = scoredAlt.slice(0, 3);
574
+ }
575
+ }
576
+ // @implements A-SPEC-274 — the prediction is the RANKED set. The closure's files are reported on
577
+ // the impact axis instead of being folded in here.
578
+ const localizedFiles = sortedUnique(rankedFiles);
579
+ // @implements A-SPEC-289 — who depends on the predicted files, via resolved FILE imports.
580
+ const importedByFiles = sortedUnique(localizedFiles.flatMap((file) => input.graph.importersOf(file)).filter((file) => !localizedFiles.includes(file)));
581
+ // @implements A-SPEC-290 — observation is only evidence about the tree it was taken on.
582
+ const runtimeRecord = input.runtimeEvidence;
583
+ const runtimeStatus = !runtimeRecord ? 'absent' : (0, test_evidence_1.isFresh)(runtimeRecord, input.basis.head) ? 'fresh' : 'stale';
584
+ // @implements A-SPEC-285 — what the languages in THIS scan could not contribute.
585
+ const languageCoverage = (0, language_capability_1.capabilityGapsFor)(scanned.map((f) => f.sourcePath));
586
+ // @implements A-SPEC-279 — security axis. Reachability, NOT a vulnerability proof: the limits ride
587
+ // along so nobody reads "reaches" as "exploitable". Filtered to paths that touch a predicted file,
588
+ // because a taint pair elsewhere in the tree is true but not about this change.
589
+ const localizedSet = new Set(localizedFiles);
590
+ const fileOfNode = (id) => {
591
+ const at = id.lastIndexOf('@');
592
+ return at === -1 ? '' : id.slice(at + 1);
593
+ };
594
+ const securityAssessed = scanned.length > 0;
595
+ const securityPaths = securityAssessed
596
+ ? (0, taint_1.taintReachability)(input.graph, taint_1.DEFAULT_TAINT_CONFIG).pairs
597
+ .map((pair) => ({
598
+ source: pair.source,
599
+ sink: pair.sink,
600
+ files: sortedUnique(pair.path.map(fileOfNode).filter(Boolean)),
601
+ }))
602
+ .filter((pair) => pair.files.some((file) => localizedSet.has(file)))
603
+ .sort((a, b) => a.source.localeCompare(b.source) || a.sink.localeCompare(b.sink))
604
+ : [];
605
+ // @implements A-SPEC-279 — compatibility axis. `none` is a declaration that there is nothing to
606
+ // carry, so it is not collected; a missing field means the spec never declared, which is why the
607
+ // axis reports what it FOUND rather than asserting the change is compatible.
608
+ // @implements A-SPEC-295 — of the candidate requirements, which cannot define "satisfied".
609
+ const candidateReqs = new Set(specIds.filter((id) => id.startsWith('REQ-')));
610
+ const unactionableCriteria = (0, acceptance_quality_1.unactionableRequirements)(specs.filter((spec) => candidateReqs.has(spec.id)));
611
+ // @implements A-SPEC-294 — the intent recorded on the candidate requirements' own REQs.
612
+ const knownDecisions = new Set(input.graph.decisionsConstraining([]));
613
+ const intent = [];
614
+ for (const id of specIds.filter((i) => i.startsWith('REQ-'))) {
615
+ const raw = specs.find((spec) => spec.id === id)?.frontmatter?.source;
616
+ for (const entry of Array.isArray(raw) ? raw : []) {
617
+ if (!entry || typeof entry !== 'object')
618
+ continue; // malformed rows are skipped, never crashed on
619
+ const e = entry;
620
+ const kind = typeof e.kind === 'string' ? e.kind : null;
621
+ if (!kind)
622
+ continue;
623
+ const ref = typeof e.ref === 'string' ? e.ref : null;
624
+ intent.push({
625
+ req: id,
626
+ kind,
627
+ ref,
628
+ retrieved: typeof e.retrieved === 'string' ? e.retrieved : null,
629
+ note: typeof e.note === 'string' ? e.note : null,
630
+ // An `adr` source names the decision the requirement came from. Linked only when that
631
+ // decision exists — a reference nobody can open is not a link.
632
+ decision: kind === 'adr' && ref && /^ADR-\d{3}$/.test(ref) ? ref : null,
633
+ });
634
+ }
635
+ }
636
+ // @implements A-SPEC-293 — decisions cited by what this change touches.
637
+ const constrainingDecisions = input.graph.decisionsConstraining([
638
+ ...localizedFiles.map((f) => `FILE:${f}`),
639
+ ...specIds.map((id) => `SPEC:${id}`),
640
+ ]);
641
+ const compatibilityAssessed = specs.length > 0;
642
+ const breakingChanges = compatibilityAssessed
643
+ ? specIds
644
+ .map((id) => specs.find((spec) => spec.id === id))
645
+ .filter((spec) => !!spec && spec.type === 'A-SPEC')
646
+ .map((spec) => ({ spec: spec.id, raw: String(spec.frontmatter?.breaking_change ?? '') }))
647
+ .filter((entry) => entry.raw.trim() !== '')
648
+ .map((entry) => ({ spec: entry.spec, kind: entry.raw.split(':')[0].trim().toLowerCase() }))
649
+ .filter((entry) => entry.kind !== 'none' && entry.kind !== '')
650
+ .sort((a, b) => a.spec.localeCompare(b.spec))
651
+ : [];
652
+ const candidateAspecs = new Set(specIds.filter((id) => id.startsWith('A-SPEC-')));
653
+ const relevantTestSpecs = sortedUnique(specs
654
+ .filter((spec) => spec.type === 'T-SPEC' && spec.dependsOn.some((id) => candidateAspecs.has(id)))
655
+ .map((spec) => spec.id));
656
+ const relevantTests = sortedUnique(Object.entries(input.testAnchors)
657
+ .filter(([, anchors]) => anchors.some((id) => candidateAspecs.has(id)))
658
+ .map(([file]) => file));
659
+ const testCoverageGaps = [...candidateAspecs]
660
+ .filter((aspec) => !relevantTestSpecs.some((id) => specs.find((spec) => spec.id === id)?.dependsOn.includes(aspec)));
661
+ // A bounded walk that stopped early has NOT proven the absence of further callers. Treating that
662
+ // silence as "no impact" is the one failure mode this analysis exists to prevent, so the
663
+ // truncation travels into the test scope as an unresolved axis (which forces a full run) instead
664
+ // of quietly leaving the tier at `unit`. Measured on this repository 2026-08-28: 6 `depth` stops
665
+ // coexisted with tier `unit`, empty coverage gaps, and no `unknowns` line at all.
666
+ const truncationReasons = sortedUnique(stoppedAt.map((stop) => stop.reason));
667
+ const truncationMarkers = sortedUnique(stoppedAt.map((stop) => `impact-truncated:${stop.reason}:${stop.id}`));
668
+ const unresolvedCoverage = [
669
+ ...input.coverage.skippedFiles,
670
+ ...input.coverage.unsupportedLanguages.map((language) => `unsupported:${language}`),
671
+ ...truncationMarkers,
672
+ ];
673
+ // @implements A-SPEC-269 — the same two signals `test_run`/`review_scope` already feed in. Without
674
+ // them a maintenance analysis could hand back a NARROWER tier than the runner would compute for
675
+ // the identical impact set, which is the one direction this codebase treats as unacceptable.
676
+ const obligationGaps = (0, scope_1.obligationGapsFor)(specs, specIds);
677
+ const architectureAssessed = input.changedFiles !== undefined;
678
+ const architectureTouched = architectureAssessed ? (0, test_scope_1.architectureGoverningFiles)([...input.changedFiles]) : [];
679
+ const testScope = (0, test_scope_1.computeTestScope)(specIds, input.testAnchors, specs, testCoverageGaps, undefined, { unresolvedFiles: unresolvedCoverage, architectureTouched, obligationGaps });
680
+ const candidateFiles = new Set(candidates.map((candidate) => candidate.file));
681
+ const history = input.history
682
+ .filter((record) => record.files.some((file) => candidateFiles.has(file)))
683
+ .map((record) => ({ ...record, files: [...record.files].sort() }))
684
+ .sort((a, b) => b.date.localeCompare(a.date) || a.commit.localeCompare(b.commit));
685
+ const skippedFiles = sortedUnique(input.coverage.skippedFiles);
686
+ const unsupportedLanguages = sortedUnique(input.coverage.unsupportedLanguages);
687
+ const coverageGaps = skippedFiles.length > 0 || unsupportedLanguages.length > 0
688
+ || input.coverage.scannedFiles === 0 || truncationMarkers.length > 0;
689
+ const stale = input.basis.loadedBuild !== input.basis.diskBuild;
690
+ const unknowns = [];
691
+ if (input.coverage.scannedFiles === 0)
692
+ unknowns.push('No source files were represented in the analyzed graph.');
693
+ if (skippedFiles.length > 0)
694
+ unknowns.push(`Skipped files are outside the evidence basis: ${skippedFiles.join(', ')}`);
695
+ if (unsupportedLanguages.length > 0)
696
+ unknowns.push(`Unsupported languages are outside the evidence basis: ${unsupportedLanguages.join(', ')}`);
697
+ if (truncationMarkers.length > 0) {
698
+ unknowns.push(`Impact closure was truncated (${truncationReasons.join(', ')}) at ${stoppedAt.length} node(s): callers beyond the bound were never explored, so the transitive impact is a lower bound, not a complete set. Stopped at ${stoppedAt.map((stop) => stop.id).join(', ')}.`);
699
+ }
700
+ // @implements A-SPEC-286 — two different facts, two different sentences. Emitting the first
701
+ // template unconditionally produced "Files in .py contributed no relation" once Python's only
702
+ // remaining gap became the extracted-but-unresolved kind.
703
+ for (const gap of languageCoverage) {
704
+ if (gap.missing.length > 0) {
705
+ unknowns.push(`Files in ${gap.ext} contributed no ${gap.missing.join(' or ')} relation: that extractor does not recover ${gap.missing.length > 1 ? 'those kinds' : 'that kind'}, so impact through them is invisible rather than absent.`);
706
+ }
707
+ if (gap.extractedButUnresolved.length > 0) {
708
+ unknowns.push(`Files in ${gap.ext} had ${gap.extractedButUnresolved.join(' and ')} recovered from the source but never resolved into a graph edge, so nothing that queries the graph can traverse ${gap.extractedButUnresolved.length > 1 ? 'them' : 'it'}.`);
709
+ }
710
+ }
711
+ // @implements A-SPEC-292 — two different facts, two different sentences. An unknown target is
712
+ // almost always external and NOT a miss; an ambiguous one names code that is right here.
713
+ const discarded = input.resolution;
714
+ if (discarded && discarded.ambiguous > 0) {
715
+ unknowns.push(`${discarded.ambiguous} call/inherit relation(s) were left unlinked as ambiguous because the target name is defined in several files: that code IS in this repository and the graph declined to guess which copy, so impact through those relations is missing rather than absent.`);
716
+ }
717
+ if (discarded && discarded.unknownTarget > 0) {
718
+ unknowns.push(`${discarded.unknownTarget} call/inherit relation(s) name a target outside the scan — external packages and runtime builtins. They are unresolvable by design, not evidence of a gap in this repository's own graph.`);
719
+ }
720
+ for (const row of unactionableCriteria) {
721
+ unknowns.push(row.quality === 'absent'
722
+ ? `${row.req} states no Success Criteria, so whether this change satisfies it cannot be checked — only that it compiles and its tests pass.`
723
+ : `${row.req}'s Success Criteria say only that the implementation was completed and tested, which is not a definition of satisfied: whether this change meets the requirement cannot be checked against it.`);
724
+ }
725
+ if (constrainingDecisions.length > 0) {
726
+ unknowns.push(`This change is constrained by architecture decision(s) ${constrainingDecisions.join(', ')}: read them before altering the behaviour they fixed — they are what must not break, not merely what is touched.`);
727
+ }
728
+ if (runtimeStatus !== 'fresh') {
729
+ // Name the category the reader actually sees in the result (`runtimeObserved`), so the sentence
730
+ // and the field cannot be mistaken for different subjects.
731
+ unknowns.push(runtimeStatus === 'stale'
732
+ ? `The runtime observation on record was produced at ${runtimeRecord.head}, not at ${input.basis.head}: it is not evidence about this tree, so test relevance rests on anchors alone.`
733
+ : 'No runtime observation exists, so test relevance rests on anchors alone — no test is known to have actually executed the impacted contracts.');
734
+ }
735
+ if (!securityAssessed) {
736
+ unknowns.push('The security axis was not assessed: there was no scanned code to walk, so no data-flow reachability was checked.');
737
+ }
738
+ if (!compatibilityAssessed) {
739
+ unknowns.push('The compatibility axis was not assessed: no specifications were available, so no breaking-change declaration could be read.');
740
+ }
741
+ if (!architectureAssessed) {
742
+ // Assessed-and-empty and never-assessed are different claims. This axis has no input to read
743
+ // without a diff, so it is disclosed rather than escalated: unlike a truncated closure (where we
744
+ // looked and stopped early), here there was nothing to look at.
745
+ unknowns.push('Architecture-governing documents were not assessed: no changed-file basis was supplied. Pass changedFiles (e.g. a base..head diff) to cover the .ax/decisions and C-SPEC axis.');
746
+ }
747
+ if (input.coverage.historyStatus === 'unavailable')
748
+ unknowns.push('Git history was unavailable; historical rationale is incomplete.');
749
+ if (stale)
750
+ unknowns.push('The loaded MCP build differs from the disk build; restart is required for a current tool basis.');
751
+ for (const aspec of candidateAspecs) {
752
+ if (!relevantTestSpecs.some((id) => specs.find((spec) => spec.id === id)?.dependsOn.includes(aspec))) {
753
+ unknowns.push(`No T-SPEC was found for impacted contract ${aspec}.`);
754
+ }
755
+ }
756
+ const implementedMatchedSpecs = new Set(localization.hits.flatMap((hit) => hit.viaSpecs));
757
+ const contradictions = localization.matchedSpecs
758
+ .filter((id) => !implementedMatchedSpecs.has(id))
759
+ .map((id) => `Request text matches ${id}, but no localized code candidate implements it.`);
760
+ // @implements A-SPEC-358 — surfaced for the reader rather than silently folded into the verdict.
761
+ for (const line of contradictions)
762
+ unknowns.push(line);
763
+ // @implements A-SPEC-358
764
+ // `contradictions` no longer forces ambiguity, and the reason is a measurement rather than a
765
+ // preference. Over 60 point-in-time cases `confidence` took exactly ONE value (0.25) while the
766
+ // analysis delivered 0.770 recall; counted over 20 of them, this trigger fired in 20 — and
767
+ // `candidateSpecs` was non-empty in all 20, so it alone was pinning every case to `ambiguous`.
768
+ //
769
+ // Look at what the trigger means: an A-SPEC whose text shares words with the request, whose
770
+ // anchored files did not make the candidate list. In a corpus of several hundred specs such a
771
+ // spec always exists. So the more specs a project wrote, the less its analysis could ever commit
772
+ // — governance disabling the tool that reads it. The signal is real but far too weak to be a
773
+ // verdict, so it stays in `contradictions` and in `unknowns` where a reader can weigh it.
774
+ //
775
+ // The genuinely-incomplete cases still force ambiguity: an unscanned file, an unsupported
776
+ // language, a truncated closure, an empty scan, a stale build. This narrows what counts as
777
+ // incomplete; it does not remove the caution.
778
+ const classification = coverageGaps || stale
779
+ ? 'ambiguous'
780
+ : specIds.length > 0
781
+ ? 'existing-requirement-change'
782
+ : candidates.length === 0
783
+ ? 'ambiguous'
784
+ : 'new-requirement';
785
+ // @implements A-SPEC-360
786
+ // NOT a probability, and the disclaimer is measured rather than modest. This number summarizes
787
+ // which KINDS of evidence were present, never how good any of them was — a candidate matching one
788
+ // ubiquitous word and one matching three distinctive ones contribute identically. Correlated
789
+ // against delivered localization accuracy over 60 point-in-time cases: **-0.062** here and -0.016
790
+ // on a second corpus, with the confident cases marginally LESS accurate than the ambiguous ones.
791
+ //
792
+ // Eight other computed signals were tested as replacements (top score, first-to-second margin and
793
+ // its ratio, matched specs, candidate count, relevant tests, scanned files, and this field). None
794
+ // agreed even in SIGN across the two corpora, so there is nothing here to rebuild it on. The value
795
+ // is left as it is deliberately: changing it would silently alter an existing contract with no
796
+ // measurement showing the new one is better.
797
+ const confidence = classification === 'existing-requirement-change'
798
+ ? Math.min(1, 0.55 + (direct.length > 0 ? 0.15 : 0) + (relevantTests.length > 0 ? 0.15 : 0) + (history.length > 0 ? 0.1 : 0))
799
+ : classification === 'new-requirement' ? 0.65 : 0.25;
800
+ // @implements A-SPEC-375
801
+ // Ranked by the localization that just ran, so the suggestion inherits whatever evidence the
802
+ // lexical layer found rather than re-deriving a second, possibly disagreeing, opinion.
803
+ // Shared with the scorer rather than re-detected: a second citation reader would drift, and the
804
+ // drift would show up as the tool suggesting a citation to a request that already made one.
805
+ const requestCitations = (0, localize_1.citationsIn)(input.request, new Set(specs.map((spec) => spec.id)));
806
+ const citationCandidates = requestCitations.cited.length > 0
807
+ ? []
808
+ : localization.hits.flatMap((hit) => hit.viaSpecs)
809
+ .concat(localization.matchedSpecs)
810
+ .filter((id, i, all) => all.indexOf(id) === i)
811
+ .slice(0, 3);
812
+ if (citationCandidates.length > 0) {
813
+ unknowns.push(`The request cites no specification. Measured over 60 point-in-time cases, a request that names `
814
+ + `its A-SPEC reaches Top-10 localization recall 0.770 where one that does not reaches 0.349 — `
815
+ + `level with plain word matching. Consider re-asking while citing one of: `
816
+ + `${citationCandidates.join(', ')}.`);
817
+ }
818
+ // @implements A-SPEC-390
819
+ const declaredIn = new Map();
820
+ for (const f of scanned) {
821
+ for (const sym of f.symbols) {
822
+ const bare = sym.qualifiedName.includes('.')
823
+ ? sym.qualifiedName.slice(sym.qualifiedName.lastIndexOf('.') + 1) : sym.qualifiedName;
824
+ for (const key of new Set([sym.qualifiedName, bare])) {
825
+ (declaredIn.get(key) ?? declaredIn.set(key, new Set()).get(key)).add(f.sourcePath);
826
+ }
827
+ }
828
+ }
829
+ let edgeTotal = 0, edgeInternal = 0, edgeAmbiguous = 0, edgeOutOfScan = 0, edgeWithin = 0;
830
+ for (const f of scanned) {
831
+ for (const e of f.edges ?? []) {
832
+ if (e.rel !== 'calls')
833
+ continue;
834
+ edgeTotal++;
835
+ const targets = declaredIn.get(e.to);
836
+ if (!targets || targets.size === 0) {
837
+ edgeOutOfScan++;
838
+ continue;
839
+ }
840
+ if (targets.size > 1) {
841
+ edgeAmbiguous++;
842
+ continue;
843
+ }
844
+ if (targets.has(f.sourcePath))
845
+ edgeWithin++;
846
+ else
847
+ edgeInternal++;
848
+ }
849
+ }
850
+ const callEdges = {
851
+ total: edgeTotal,
852
+ internalResolved: edgeInternal,
853
+ ambiguous: edgeAmbiguous,
854
+ outOfScan: edgeOutOfScan,
855
+ withinFile: edgeWithin,
856
+ internalResolvedShare: edgeTotal === 0 ? null : edgeInternal / edgeTotal,
857
+ };
858
+ if (edgeTotal > 0 && edgeInternal / edgeTotal < 0.5) {
859
+ unknowns.push(`Call graph density is narrower than the edge count suggests: ${edgeInternal} of ${edgeTotal} `
860
+ + `call edges bind to exactly one other scanned file; ${edgeOutOfScan} name targets outside the `
861
+ + `scan (libraries and builtins) and ${edgeAmbiguous} are ambiguous. Impact traversal uses only `
862
+ + `the resolved ones.`);
863
+ }
864
+ const evidence = {
865
+ schemaVersion: exports.MAINTENANCE_ANALYSIS_SCHEMA,
866
+ extractorVersion: exports.MAINTENANCE_ANALYSIS_EXTRACTOR,
867
+ classification,
868
+ confidence,
869
+ citationCandidates,
870
+ callEdges,
871
+ candidates,
872
+ ...(semanticAlternates !== undefined ? { semanticAlternates } : {}),
873
+ rerankPool,
874
+ candidateSpecs: specIds,
875
+ impacts: {
876
+ files: localizedFiles,
877
+ tiers: { primary: primaryTier, secondary: secondaryTier },
878
+ impactedFiles: reachableTier,
879
+ rankedImpact,
880
+ importedByFiles,
881
+ unresolvedRelations: input.resolution ?? null,
882
+ runtimeObserved: {
883
+ status: runtimeStatus,
884
+ specs: runtimeStatus === 'fresh'
885
+ ? specIds.filter((id) => (runtimeRecord.executedByAspec[id] ?? 0) > 0)
886
+ : [],
887
+ },
888
+ direct, transitive, contract: specIds, test: relevantTests,
889
+ },
890
+ impactBounds: {
891
+ maxCallDepth,
892
+ hubInDegree,
893
+ stoppedAt: stoppedAt.sort((a, b) => a.id.localeCompare(b.id) || a.reason.localeCompare(b.reason)),
894
+ ...(seeds.reduce((max, id) => Math.max(max, input.graph.callerCount(id)), 0) > hubInDegree
895
+ ? { seedIsHub: { callers: seeds.reduce((max, id) => Math.max(max, input.graph.callerCount(id)), 0), threshold: hubInDegree } }
896
+ : {}),
897
+ },
898
+ constraints: {
899
+ security: { assessed: securityAssessed, limits: [...taint_1.TAINT_LIMITS], paths: securityPaths },
900
+ compatibility: { assessed: compatibilityAssessed, breakingChanges },
901
+ decisions: constrainingDecisions,
902
+ },
903
+ languageCoverage,
904
+ unactionableCriteria,
905
+ intent,
906
+ testScope,
907
+ relevantTests,
908
+ relevantTestSpecs,
909
+ historicalRationale: history,
910
+ contextBundle: input.contextBundle ?? null,
911
+ assumptions: ['Static graph evidence is advisory and must be validated by the selected tests.'],
912
+ contradictions,
913
+ unknowns,
914
+ coverage: { scannedFiles: input.coverage.scannedFiles, skippedFiles, unsupportedLanguages, historyStatus: input.coverage.historyStatus },
915
+ graphBasis: { ...input.basis, stale },
916
+ ablation: buildAblation({
917
+ request: input.request,
918
+ scanned,
919
+ testAnchors: input.testAnchors,
920
+ candidates,
921
+ direct,
922
+ transitive,
923
+ directSpecIds,
924
+ localizedFiles,
925
+ primaryTier,
926
+ impactedTier: reachableTier,
927
+ importedByTier: importedByFiles,
928
+ relevantTests,
929
+ historyCount: history.length,
930
+ fileOfSymbol,
931
+ groundTruth: input.groundTruth,
932
+ }),
933
+ };
934
+ return {
935
+ ...evidence,
936
+ evidenceDigest: `sha256:${(0, crypto_1.createHash)('sha256').update(canonical(evidence)).digest('hex')}`,
937
+ };
938
+ }
939
+ /**
940
+ * Three arms computed from the SAME raw inputs by three different amounts of graph:
941
+ *
942
+ * graphOff — lexical only: no spec linkage, no call edges, no anchors. What a plain grep gets.
943
+ * current — localization + spec ancestry of the directly matched symbols + their anchored tests.
944
+ * enhanced — adds the bounded caller closure (and the files/specs/tests it alone reaches),
945
+ * history, and the versioned coverage/build basis.
946
+ *
947
+ * Each arm's numbers come from its OWN prediction scored against externally supplied ground truth.
948
+ * No arm is scored against another arm's output.
949
+ */
950
+ function buildAblation(a) {
951
+ const truth = a.groundTruth;
952
+ // --- graph-off: term overlap against raw scanned symbols and paths, ranked, top-10 like the
953
+ // localizer's own cut-off so the arms are compared at the same budget.
954
+ const terms = new Set((0, localize_1.significantTerms)(a.request));
955
+ const lexical = a.scanned.map((file) => {
956
+ const matched = file.symbols
957
+ .filter((symbol) => (0, localize_1.significantTerms)(symbol.qualifiedName).some((term) => terms.has(term)))
958
+ .map((symbol) => symbol.qualifiedName);
959
+ const pathHit = (0, localize_1.significantTerms)(file.sourcePath).some((term) => terms.has(term));
960
+ return { file: file.sourcePath, matched, score: matched.length + (pathHit ? 1 : 0) };
961
+ }).filter((entry) => entry.score > 0)
962
+ .sort((x, y) => y.score - x.score || x.file.localeCompare(y.file))
963
+ .slice(0, 10);
964
+ const graphOff = arm(lexical.map((entry) => entry.file), sortedUnique(lexical.flatMap((entry) => entry.matched)), [], [
965
+ ...(lexical.length > 0 ? ['candidates'] : []),
966
+ ...(lexical.some((entry) => entry.matched.length > 0) ? ['directImpact'] : []),
967
+ ], truth);
968
+ // --- current: spec-aware localization, but no caller closure and no versioned basis.
969
+ const currentAspecs = new Set(a.directSpecIds.filter((id) => id.startsWith('A-SPEC-')));
970
+ const currentTests = sortedUnique(Object.entries(a.testAnchors)
971
+ .filter(([, anchors]) => anchors.some((id) => currentAspecs.has(id)))
972
+ .map(([file]) => file));
973
+ const current = arm(sortedUnique(a.candidates.map((candidate) => candidate.file)), [...a.direct], currentTests, [
974
+ ...(a.candidates.length > 0 ? ['candidates'] : []),
975
+ ...(a.directSpecIds.length > 0 ? ['specs'] : []),
976
+ ...(a.direct.length > 0 ? ['directImpact'] : []),
977
+ ...(currentTests.length > 0 ? ['tests'] : []),
978
+ ], truth);
979
+ // --- enhanced: the caller closure also LOCALIZES — a file reachable only through a call edge is a
980
+ // modification candidate the lexical and no-closure arms structurally cannot name.
981
+ const enhancedFiles = a.localizedFiles;
982
+ const enhanced = arm(enhancedFiles, sortedUnique([...a.direct, ...a.transitive]), [...a.relevantTests], [
983
+ ...(enhancedFiles.length > 0 ? ['candidates'] : []),
984
+ ...(a.directSpecIds.length > 0 ? ['specs'] : []),
985
+ ...(a.direct.length > 0 ? ['directImpact'] : []),
986
+ ...(a.transitive.length > 0 ? ['transitiveImpact'] : []),
987
+ ...(a.relevantTests.length > 0 ? ['tests'] : []),
988
+ ...(a.historyCount > 0 ? ['history'] : []),
989
+ // The versioned coverage/build/digest basis is emitted unconditionally — including when it
990
+ // reports zero coverage, which is itself the evidence that matters.
991
+ 'versionedBasis',
992
+ ], truth, a.primaryTier, a.impactedTier, a.importedByTier);
993
+ return { graphOff, current, enhanced };
994
+ }