@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
@@ -1,7 +1,46 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.citationsIn = citationsIn;
3
4
  exports.significantTerms = significantTerms;
5
+ exports.bigramUnspaced = bigramUnspaced;
4
6
  exports.localizeIssue = localizeIssue;
7
+ /**
8
+ * @implements A-SPEC-273
9
+ * Explicit spec references inside request text: `A-SPEC-262.1`, `REQ-267`, `T-SPEC-121.2`, and this
10
+ * repository's `S-<n>` slice shorthand (which names the whole REQ->T-SPEC chain at that number).
11
+ * The prefix is REQUIRED — a bare `262` in "262 files were rewritten" is a number, not a citation.
12
+ */
13
+ const CITATION = /\b(?:(A-SPEC|H-SPEC|T-SPEC|C-SPEC|REQ)-(\d+(?:\.\d+)?)|S-(\d+(?:\.\d+)?))\b/gi;
14
+ const CITABLE_KINDS = ['REQ', 'H-SPEC', 'A-SPEC', 'T-SPEC', 'C-SPEC'];
15
+ /**
16
+ * @implements A-SPEC-273
17
+ * How much a citation is worth, normalized by the file's anchor count exactly as the lexical spec
18
+ * bonus is (A-SPEC-270). Chosen by measurement on the 12-commit replay corpus, not by taste: see
19
+ * the sweep recorded in docs/holmes-kit-activation.md.
20
+ */
21
+ const CITATION_BONUS = 20;
22
+ // @implements A-SPEC-349
23
+ // Exported so the Phase C measurement arm shares this exact path rather than reimplementing it.
24
+ // A second citation extractor would drift from this one, and the drift would show up in the replay
25
+ // as a difference between RANKERS — the one thing the arm exists to measure.
26
+ function citationsIn(text, known) {
27
+ const cited = new Set();
28
+ const unknown = new Set();
29
+ for (const m of text.matchAll(CITATION)) {
30
+ if (m[1]) {
31
+ const id = `${m[1].toUpperCase()}-${m[2]}`;
32
+ (known.has(id) ? cited : unknown).add(id);
33
+ continue;
34
+ }
35
+ // `S-<n>` names a slice, not one document: expand to every kind at that number that EXISTS.
36
+ const resolved = CITABLE_KINDS.map((kind) => `${kind}-${m[3]}`).filter((id) => known.has(id));
37
+ if (resolved.length > 0)
38
+ resolved.forEach((id) => cited.add(id));
39
+ else
40
+ unknown.add(`S-${m[3]}`);
41
+ }
42
+ return { cited: [...cited].sort(), unknown: [...unknown].sort() };
43
+ }
5
44
  const STOP = new Set([
6
45
  'the', 'and', 'for', 'with', 'that', 'this', 'when', 'then', 'from', 'into', 'not', 'but', 'are',
7
46
  'was', 'were', 'have', 'has', 'had', 'does', 'did', 'should', 'would', 'could', 'will', 'can',
@@ -22,18 +61,113 @@ function significantTerms(text) {
22
61
  .replace(/([a-z0-9])([A-Z])/g, '$1 $2') // camelCase -> camel Case
23
62
  .toLowerCase()
24
63
  .split(/[^\p{L}\p{N}]+/u)
64
+ .flatMap(bigramUnspaced)
25
65
  .filter((t) => {
26
66
  const minLen = /[^\x00-\x7f]/.test(t) ? 2 : 3;
27
67
  return t.length >= minLen && !STOP.has(t);
28
68
  });
29
69
  return [...new Set(raw)].slice(0, MAX_TERMS);
30
70
  }
71
+ /**
72
+ * @implements A-SPEC-400
73
+ * Character bigrams for scripts that do not space their words.
74
+ *
75
+ * Measured before this existed: every script that separates words with spaces tokenized correctly —
76
+ * Korean 4 tokens, German 7, Spanish 6, Russian 6, Arabic 6, French 5 — while Japanese and Chinese
77
+ * produced ONE token, the entire sentence, because splitting on non-letters found nothing to split.
78
+ * A single token matches nothing, so those requests reached no code at all. That is a missing
79
+ * capability for two major languages rather than a ranking shortfall.
80
+ *
81
+ * Bigrams are the standard answer for unsegmented scripts and need no dictionary: `隔離処理` yields
82
+ * `隔離`, `離処`, `処理`, one of which will overlap a spec that discusses the same subject.
83
+ *
84
+ * Scoped by SCRIPT, not by "is it non-ASCII". Korean spaces its words and already tokenized into
85
+ * meaningful units; bigramming it would shred those into fragments and make six working languages pay
86
+ * for two broken ones. Han, Hiragana and Katakana only.
87
+ */
88
+ function bigramUnspaced(token) {
89
+ if (token.length < 2 || !UNSPACED_SCRIPT.test(token))
90
+ return [token];
91
+ const out = [];
92
+ for (let i = 0; i + 1 < token.length; i++)
93
+ out.push(token.slice(i, i + 2));
94
+ return out;
95
+ }
96
+ /** Han, Hiragana, Katakana — the scripts in these corpora that carry no word spacing. */
97
+ const UNSPACED_SCRIPT = /[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}]/u;
31
98
  const tokensOf = (s) => new Set(significantTerms(s));
99
+ /**
100
+ * @implements A-SPEC-350
101
+ * How much one matching term is worth, given how common it is in THIS corpus.
102
+ *
103
+ * Every term used to be worth the same, so a match on `spec` — a token in nearly every file of this
104
+ * repository — counted exactly as heavily as a match on a name that occurs once. No global stop-list
105
+ * can fix that: the ubiquitous vocabulary is a property of the corpus, not of the language.
106
+ *
107
+ * Rescaled to mean 1, so the lexical term keeps its magnitude relative to the spec-anchor (+4) and
108
+ * citation (+20) bonuses and only its DISTRIBUTION across terms changes. An unseen term weighs 1:
109
+ * a word absent from the corpus is not evidence against a file, and mapping it to 0 would delete a
110
+ * request's most distinctive vocabulary — the words most likely to name something new.
111
+ *
112
+ * @implements A-SPEC-361
113
+ * Memoised on the scanned array's IDENTITY. Measured at 365 files / 2907 symbols: this pass is 5.0ms
114
+ * of a 9.0ms localize call — 56% — and grows linearly with the corpus (4x corpus, 3.2x time). The
115
+ * absolute cost is small and the worry that prompted the measurement was not borne out, but over
116
+ * half a call is worth not repeating when a caller localizes several requests against one scan.
117
+ *
118
+ * A WeakMap keyed on the array is chosen over a keyed cache precisely because it CANNOT go stale: a
119
+ * fresh scan produces a fresh array, so a changed corpus can never hit a previous entry, and there
120
+ * is no invalidation step for someone to forget. Stale weights would silently reorder results, which
121
+ * is the failure mode this repository would find hardest to notice.
122
+ */
123
+ const WEIGHT_CACHE = new WeakMap();
124
+ function termWeights(scanned) {
125
+ const cached = WEIGHT_CACHE.get(scanned);
126
+ if (cached)
127
+ return cached;
128
+ const computed = computeTermWeights(scanned);
129
+ WEIGHT_CACHE.set(scanned, computed);
130
+ return computed;
131
+ }
132
+ function computeTermWeights(scanned) {
133
+ const df = new Map();
134
+ for (const f of scanned) {
135
+ const seen = new Set(tokensOf(f.sourcePath));
136
+ for (const sym of f.symbols)
137
+ for (const t of tokensOf(sym.qualifiedName))
138
+ seen.add(t);
139
+ for (const t of seen)
140
+ df.set(t, (df.get(t) ?? 0) + 1);
141
+ }
142
+ const n = scanned.length;
143
+ const raw = new Map();
144
+ for (const [t, d] of df)
145
+ raw.set(t, Math.max(Math.log((n + 1) / (d + 1)), 1e-6));
146
+ const vals = [...raw.values()];
147
+ if (vals.length === 0)
148
+ return raw;
149
+ const mean = vals.reduce((a, b) => a + b, 0) / vals.length;
150
+ if (mean <= 0)
151
+ return new Map([...raw.keys()].map((t) => [t, 1]));
152
+ const out = new Map();
153
+ for (const [t, v] of raw)
154
+ out.set(t, v / mean);
155
+ return out;
156
+ }
32
157
  function localizeIssue(issueText, scanned, specs, topN = 10) {
33
158
  const terms = significantTerms(issueText);
34
- if (terms.length === 0)
35
- return { terms: [], hits: [], matchedSpecs: [] };
159
+ // @implements A-SPEC-273 — citations are recovered BEFORE the lexical gate: `significantTerms`
160
+ // shreds `S-262.1` into the bare token `262`, so a request that names its spec outright can never
161
+ // be recovered lexically, however many terms it has.
162
+ const citations = citationsIn(issueText, new Set(specs.map((s) => s.id)));
163
+ const citedSet = new Set(citations.cited);
164
+ if (terms.length === 0 && citations.cited.length === 0) {
165
+ return { terms: [], hits: [], matchedSpecs: [], unknownCitations: citations.unknown };
166
+ }
36
167
  const termSet = new Set(terms);
168
+ // @implements A-SPEC-350
169
+ const weights = termWeights(scanned);
170
+ const weightOf = (t) => weights.get(t) ?? 1;
37
171
  // 1) A-SPECs whose title/section text shares terms with the issue (RTM entry points). Restricted
38
172
  // to A-SPEC: files anchor A-SPECs only, so other spec kinds can never pull a file through the
39
173
  // hop and reporting them overstated what influenced the ranking (review D7). Threshold (review
@@ -45,13 +179,50 @@ function localizeIssue(issueText, scanned, specs, topN = 10) {
45
179
  continue;
46
180
  const specText = `${s.title} ${Object.values(s.sections).join(' ')}`;
47
181
  const st = tokensOf(specText);
48
- let overlap = 0;
182
+ // @implements A-SPEC-353
183
+ // Weighed, not counted. This is the entry point into the knowledge graph, and an admitted spec
184
+ // pays its bonus to EVERY file anchoring it — so a spec opened by two ubiquitous words promotes
185
+ // a whole group of unrelated files at once. Threshold 1 means "one term of average rarity, or
186
+ // several common ones". Measured over both corpora at thresholds 0.5/1/2/3: 1 was the only
187
+ // setting that improved BOTH (this repository Top-1 0.317 -> 0.333, Top-10 recall 0.754 ->
188
+ // 0.770; jarvis 0.333 -> 0.367 and 0.662 -> 0.684).
189
+ let mass = 0;
49
190
  for (const t of termSet)
50
191
  if (st.has(t))
51
- overlap++;
52
- if (overlap >= 2 || (overlap === 1 && termSet.size <= 3))
192
+ mass += weightOf(t);
193
+ // The short-request escape hatch is unchanged: a three-word request cannot reach a threshold
194
+ // built for prose, and removing it here would change two things at once.
195
+ if (mass >= 1 || (mass > 0 && termSet.size <= 3))
53
196
  matchedSpecs.push(s.id);
54
197
  }
198
+ // A cited spec is matched by fiat: the request named it, which is stronger evidence than any
199
+ // amount of word overlap.
200
+ // @implements A-SPEC-373
201
+ // A request that names no spec, yet matches a crowd of them, has identified nothing — it was
202
+ // written in words the corpus uses everywhere. Measured over 60 point-in-time cases: uncited, this
203
+ // ranker scored Top-10 recall 0.275 against 0.357 for matching the request's words against paths
204
+ // and symbols alone, and precision 0.067 against 0.122. That is the condition every incoming bug
205
+ // report is in.
206
+ //
207
+ // 26.6 A-SPECs match lexically per case here. A hub file anchoring many of them collects
208
+ // 4·n/sqrt(n) = 4·sqrt(n), which GROWS with the crowd — twelve matches outrank any lexical hit.
209
+ // With a citation present its +20 dominates and the crowd is corroborated rather than speculative
210
+ // (dropping it there cost 0.770 → 0.743), so the cap applies only when nothing was cited. A SMALL
211
+ // uncited crowd is still evidence: the second corpus matches 0.87 specs per case and gains from
212
+ // them, and capping it there threw away its only lead.
213
+ // @implements A-SPEC-399
214
+ // The cap is gone, and the reason is that A-SPEC-399 removed what it was protecting against.
215
+ // It existed because a crowd of lexically-matched specs ADMITTED a crowd of files and buried the
216
+ // lexical ranking. Now a spec cannot admit anything while the request has lexical purchase, so the
217
+ // crowd can only reorder files that earned their place — and measured, keeping the cap alongside
218
+ // the admission rule cost Top-1 (0.267 -> 0.222, below the 0.244 baseline) while the arm without
219
+ // it held 0.267 at the same recovered precision.
220
+ //
221
+ // The cap remains correct for what it was measured on and is superseded rather than reverted: its
222
+ // job is now done by a narrower rule that does not also discard the reordering.
223
+ for (const id of citations.cited)
224
+ if (!matchedSpecs.includes(id))
225
+ matchedSpecs.push(id);
55
226
  const matchedSpecSet = new Set(matchedSpecs);
56
227
  // Vendored/mirrored trees (review D4): an identical copy under reference/vendor must not outrank —
57
228
  // or lexically tie-break above — the live source. Their scores are halved and ties break toward
@@ -59,6 +230,24 @@ function localizeIssue(issueText, scanned, specs, topN = 10) {
59
230
  const isVendorPath = (p) => /(^|\/)(reference|vendor|vendors|third_party|third-party|external)\//i.test(p);
60
231
  // 2) Score each scanned file: symbol-name matches (strong), path matches (weak),
61
232
  // implements-a-matched-spec (graph bonus the lexical layer cannot see).
233
+ // @implements A-SPEC-399
234
+ // Does the request get ANY lexical purchase on this corpus? A commit subject shares words with
235
+ // symbol names and paths; a request written in the project's documentation language — Korean specs
236
+ // against English symbols here — shares none at all. The difference is visible from the query, and
237
+ // it is exactly the difference between the two cases that pulled A-SPEC-398 apart.
238
+ const anyLexicalPurchase = scanned.some((f) => {
239
+ for (const sym of f.symbols) {
240
+ const st = tokensOf(sym.qualifiedName);
241
+ for (const t of termSet)
242
+ if (st.has(t))
243
+ return true;
244
+ }
245
+ const pt = tokensOf(f.sourcePath);
246
+ for (const t of termSet)
247
+ if (pt.has(t))
248
+ return true;
249
+ return false;
250
+ });
62
251
  const hits = [];
63
252
  for (const f of scanned) {
64
253
  const why = [];
@@ -68,7 +257,12 @@ function localizeIssue(issueText, scanned, specs, topN = 10) {
68
257
  const symTokens = tokensOf(sym.qualifiedName);
69
258
  const hitTerms = [...termSet].filter((t) => symTokens.has(t));
70
259
  if (hitTerms.length > 0) {
71
- score += 3 * hitTerms.length;
260
+ // @implements A-SPEC-350 weighted by rarity; `hitTerms.length` was the flat version.
261
+ // @implements A-SPEC-430 — saturation was TRIED here and reverted; see review notes. The
262
+ // flat sum is kept because saturating it cost this repository's clean-slice Top-5 recall
263
+ // (0.509 -> 0.493) while buying nothing on the corpus it was designed for.
264
+ for (const t of hitTerms)
265
+ score += 3 * weightOf(t);
72
266
  matchedSymbols.push(sym.qualifiedName);
73
267
  why.push(`symbol ${sym.qualifiedName} matches [${hitTerms.join(', ')}]`);
74
268
  }
@@ -76,25 +270,89 @@ function localizeIssue(issueText, scanned, specs, topN = 10) {
76
270
  const pathTokens = tokensOf(f.sourcePath);
77
271
  const pathHits = [...termSet].filter((t) => pathTokens.has(t));
78
272
  if (pathHits.length > 0) {
79
- score += pathHits.length;
273
+ // @implements A-SPEC-350
274
+ for (const t of pathHits)
275
+ score += weightOf(t);
80
276
  why.push(`path matches [${pathHits.join(', ')}]`);
81
277
  }
82
278
  // Dedupe: a file may carry the same @implements anchor many times (once per code section);
83
279
  // each spec must count ONCE toward the bonus (live smoke showed 4x inflation otherwise).
84
- const viaSpecs = [...new Set(f.implementsSpecs ?? [])].filter((id) => matchedSpecSet.has(id));
280
+ // @implements A-SPEC-366
281
+ // RESOLVED anchors only. The denominator used to count every `@implements` marker written in the
282
+ // file, including ones naming specs that have been retired, renamed or deleted. Such a marker
283
+ // supplies no evidence and was taxing the evidence its neighbours do supply: measured, three dead
284
+ // markers alongside one live one halved the live one's bonus from 4 to 2.
285
+ //
286
+ // The dilution A-SPEC-270 introduced is untouched — a file implementing many LIVE specs is still
287
+ // weaker evidence per spec. This removes only the tax paid to specs that no longer exist.
288
+ const knownSpecIds = new Set(specs.map((spec) => spec.id));
289
+ const anchors = [...new Set(f.implementsSpecs ?? [])].filter((id) => knownSpecIds.has(id));
290
+ const viaSpecs = anchors.filter((id) => matchedSpecSet.has(id));
85
291
  if (viaSpecs.length > 0) {
86
- score += 4 * viaSpecs.length;
87
- why.push(`implements matched spec(s) ${viaSpecs.join(', ')}`);
292
+ // @implements A-SPEC-270
293
+ // Normalized by how many anchors the file carries. The raw `4 × matched` grew without bound
294
+ // as anchors accumulated, and anchors only ever accumulate: a file implementing 113 specs
295
+ // (handlers.ts, measured 2026-08-28) will brush SOME spec for almost any request, so each
296
+ // individual match there is far weaker evidence than a lone anchor that matched exactly.
297
+ // Left unnormalized, every slice that touches a hub file made that file a stronger attractor
298
+ // for unrelated requests — a precision decay produced by the governance process itself
299
+ // (authoring one slice moved replay localization recall 0.5476 -> 0.5060, no logic changed).
300
+ //
301
+ // sqrt, not the raw fraction: a lone matching anchor keeps its original +4 exactly, while a
302
+ // hub is diluted rather than erased — it must still be FINDABLE when it really is the target.
303
+ const specificity = 4 * viaSpecs.length / Math.sqrt(anchors.length);
304
+ score += Math.round(specificity * 100) / 100;
305
+ why.push(`implements matched spec(s) ${viaSpecs.join(', ')} (${viaSpecs.length}/${anchors.length} anchors)`);
306
+ }
307
+ const citedAnchors = anchors.filter((id) => citedSet.has(id));
308
+ if (citedAnchors.length > 0) {
309
+ // Normalized by anchor count for the same reason the lexical spec bonus is (A-SPEC-270):
310
+ // otherwise a hub file would collect this bonus for every citation anyone ever writes.
311
+ score += Math.round(CITATION_BONUS * citedAnchors.length / Math.sqrt(anchors.length) * 100) / 100;
312
+ why.push(`request cites spec(s) ${citedAnchors.join(', ')}`);
88
313
  }
89
314
  if (isVendorPath(f.sourcePath) && score > 0) {
90
315
  score = score / 2;
91
316
  why.push('vendored path — score halved');
92
317
  }
93
- if (score > 0)
318
+ // @implements A-SPEC-398
319
+ // NOT admitted-only, and the reason is a measurement colliding with a contract.
320
+ //
321
+ // Restricting admission to files with lexical evidence recovered every point of precision the
322
+ // spec layer costs (0.104 -> 0.156) while keeping its Top-1 gain — measured on both corpora, on
323
+ // clean history. It also broke six approved tests, and one of them names the reason: this
324
+ // repository's specs are written in Korean and its symbols in English, so a Korean request
325
+ // matches specs and shares NO term with any file name. Admission via the spec is the only path
326
+ // it has, and withholding it makes such a request find nothing at all.
327
+ //
328
+ // The measurement and the contract are both right about their own query. Commit subjects overlap
329
+ // symbol names; a report written in the project's documentation language does not. Adopting on
330
+ // the first would silently disable the second, so the arm stays measured and unadopted, and what
331
+ // @implements A-SPEC-399
332
+ // The query-type test A-SPEC-398 said this needed. When the request has lexical purchase
333
+ // somewhere in the corpus, a spec may REORDER candidates but not admit them — measured to recover
334
+ // precision 0.104 -> 0.156 and Top-10 recall 0.651 -> 0.668 while keeping Top-1. When the request
335
+ // has none — the Korean-spec, English-symbol case — spec admission is its only path, and
336
+ // withholding it makes such a request find nothing at all.
337
+ //
338
+ // A cited spec admits regardless: citation is the strongest signal here (Top-10 recall 0.770
339
+ // against 0.275), and paying for precision with it would break what works to repair what does not.
340
+ const lexicalEvidence = matchedSymbols.length > 0 || pathHits.length > 0;
341
+ const admitted = !anyLexicalPurchase || lexicalEvidence || citedAnchors.length > 0;
342
+ if (score > 0 && admitted)
94
343
  hits.push({ file: f.sourcePath, score, matchedSymbols, viaSpecs, why });
95
344
  }
96
- hits.sort((a, b) => b.score - a.score ||
97
- Number(isVendorPath(a.file)) - Number(isVendorPath(b.file)) || // ties: live source before vendor
345
+ // @implements A-SPEC-272
346
+ // Vendored-ness is the FIRST key, not a tie-break. Halving the score was the old demotion and it
347
+ // is not enough where most of the tree is vendored: measured 2026-08-28 on this repository, 212 of
348
+ // 351 scanned files sit under `reference/`, and 27% of the predicted set (22% of the primary tier)
349
+ // was third-party code — every one a guaranteed false positive, since a change to THIS project
350
+ // never edits vendored sources. Ordering by vendored-ness first means a vendored file appears only
351
+ // once the live candidates are exhausted: demoted, never erased, so it is still findable on a
352
+ // project where the vendored code really is the answer. The score halving stays, so the relative
353
+ // order WITHIN each group is unchanged.
354
+ hits.sort((a, b) => Number(isVendorPath(a.file)) - Number(isVendorPath(b.file)) || // live source before vendored
355
+ b.score - a.score || // then by evidence strength
98
356
  a.file.localeCompare(b.file)); // then deterministic path order
99
- return { terms, hits: hits.slice(0, topN), matchedSpecs: matchedSpecs.sort() };
357
+ return { terms, hits: hits.slice(0, topN), matchedSpecs: matchedSpecs.sort(), unknownCitations: citations.unknown };
100
358
  }
@@ -1,9 +1,55 @@
1
1
  import { Spec } from '../spec/spec-parser';
2
2
  import { RtmGraph } from './rtm-graph';
3
3
  import { ScannedFile } from '../cpg/cpg-scanner';
4
+ /**
5
+ * @implements A-SPEC-292
6
+ * What resolution did with every extracted `calls`/`inherits` relation. The graph discards on
7
+ * purpose — precision over recall — but nothing counted the discards, so an impact answer read as
8
+ * if the graph were complete.
9
+ *
10
+ * `unknownTarget` and `ambiguous` are DIFFERENT facts and are kept apart: an unknown target is
11
+ * almost always external (a library or runtime call this repository cannot see), while an ambiguous
12
+ * one names code that IS here and could not be picked. Only the second is a real miss.
13
+ *
14
+ * The buckets are produced BY the resolution loop, never by a second copy of the rule — this
15
+ * codebase has repeatedly paid for the same judgement written twice.
16
+ */
17
+ export interface ResolutionReport {
18
+ resolved: number;
19
+ /** Callee defined in several files: precision-over-recall refused to guess. A real miss. */
20
+ ambiguous: number;
21
+ /** Callee defined nowhere in the scan — usually an external package or runtime builtin. */
22
+ unknownTarget: number;
23
+ /** Caller name the symbol walk never emitted (see A-SPEC-291). */
24
+ callerNotNamed: number;
25
+ selfReference: number;
26
+ /** `<module>`-scoped relations, which are not symbol-to-symbol. */
27
+ moduleScoped: number;
28
+ }
4
29
  export interface BuildRtmOptions {
5
30
  /** Resolve a spec id to its source file path (e.g. its .md file) for provenance tagging. */
6
31
  specSourcePath?: (id: string) => string | undefined;
32
+ /**
33
+ * @implements A-SPEC-281
34
+ * The observation context every fact in this build shares: which commit it was read at, which
35
+ * extractor read it, and WHEN — supplied by the caller, never read from a clock in here, so the
36
+ * same inputs keep producing the same graph. Per-fact fields (`sourceLocation`, `confidence`,
37
+ * `derivation`) are decided by the builder, because only it knows whether a fact was declared or
38
+ * inferred.
39
+ */
40
+ provenance?: {
41
+ sourceCommit?: string;
42
+ extractorVersion?: string;
43
+ observedAt?: string;
44
+ validUntil?: string;
45
+ };
46
+ /**
47
+ * @implements A-SPEC-284
48
+ * Content digest of a scanned file, supplied by the caller. The builder stays pure — it never
49
+ * opens a file to compute one — so an absent digest records `null`, the honest mark for "not
50
+ * recorded", rather than a value nobody measured.
51
+ */
52
+ fileDigest?: (sourcePath: string) => string | undefined;
7
53
  }
8
54
  /**
9
55
  * Adds one scanned file's CODE nodes and `implements` edges to the graph,
@@ -11,8 +57,10 @@ export interface BuildRtmOptions {
11
57
  * can later drop exactly this file's contribution (used by the incremental
12
58
  * orchestrator — see Task 4).
13
59
  */
14
- export declare function buildFileSubgraph(f: ScannedFile, specs: Spec[], graph: RtmGraph): void;
15
- export declare function buildRtm(specs: Spec[], scanned: ScannedFile[], graph: RtmGraph, opts?: BuildRtmOptions): void;
60
+ export declare function buildFileSubgraph(f: ScannedFile, specs: Spec[], graph: RtmGraph, opts?: BuildRtmOptions): void;
61
+ export declare function buildRtm(specs: Spec[], scanned: ScannedFile[], graph: RtmGraph, opts?: BuildRtmOptions): {
62
+ resolution: ResolutionReport;
63
+ };
16
64
  /**
17
65
  * Resolve each file's recovered call relations to graph edges.
18
66
  *
@@ -33,7 +81,7 @@ export declare function buildRtm(specs: Spec[], scanned: ScannedFile[], graph: R
33
81
  * exact (src,dst,rel) triple — so the first-writer-wins hazard documented on RtmGraph cannot arise
34
82
  * for call edges, and `removeBySource(caller)` drops exactly them.
35
83
  */
36
- export declare function addCallEdges(scanned: ScannedFile[], graph: RtmGraph): void;
84
+ export declare function addCallEdges(scanned: ScannedFile[], graph: RtmGraph, opts?: BuildRtmOptions): ResolutionReport;
37
85
  /**
38
86
  * Bounds on call-edge propagation (REQ-127). Defaults are constants, not laws — a project can tune
39
87
  * them, and `explain` reports what each bound actually did.
@@ -99,3 +147,70 @@ export interface ImpactExplanation {
99
147
  */
100
148
  export declare function impactedBy(graph: RtmGraph, changedSymbolQNames: string[], opts?: ImpactOptions): string[];
101
149
  export declare function explainImpact(graph: RtmGraph, changedSymbolQNames: string[], opts?: ImpactOptions): ImpactExplanation;
150
+ /** @implements A-SPEC-284 — one commit as git reports it. */
151
+ export interface CommitRecord {
152
+ commit: string;
153
+ date: string;
154
+ subject: string;
155
+ files: readonly string[];
156
+ }
157
+ /**
158
+ * @implements A-SPEC-284
159
+ * The graph's first temporal layer: `COMMIT:<sha>` nodes and `changed_by` edges to the code they
160
+ * touched.
161
+ *
162
+ * Kept as a SEPARATE ingestion rather than folded into the per-file subgraph. Commit history is not
163
+ * a product of scanning a file, and entangling it with the incremental path would put the
164
+ * incremental/full-rebuild convergence A-SPEC-280 proved at risk for no benefit — the code layer
165
+ * must come out identical whether or not history was ever ingested.
166
+ *
167
+ * An edge is emitted ONLY when the changed file is present in the scan. Precision over recall, the
168
+ * same rule call resolution follows: an edge pointing at a node that does not exist poisons every
169
+ * traversal that walks it, while a missing edge merely leaves a gap. Commits touching files nobody
170
+ * scanned are still recorded as nodes — they happened.
171
+ */
172
+ export declare function addCommitHistory(history: readonly CommitRecord[], scanned: readonly ScannedFile[], graph: RtmGraph, opts?: BuildRtmOptions): void;
173
+ /**
174
+ * @implements A-SPEC-289
175
+ * FILE nodes and resolved `imports` edges.
176
+ *
177
+ * Import relations were extracted and then dropped, because their endpoints are a file and a module
178
+ * path while the graph knew only symbols and specs — measured on this repository, 1,892 extracted
179
+ * and 0 in the graph. `File` is a node kind Goal Phase 3 names, so it becomes one.
180
+ *
181
+ * ONLY relative specifiers are resolved, and only to a file the scan actually contains. A bare
182
+ * specifier (`node:fs`, `js-yaml`) is an external package with no node to point at, and inventing
183
+ * one would be a guess — the same precision-over-recall rule call resolution follows. Measured: 441
184
+ * of 1,892 specifiers (23%) resolve inside the repository, and the rest failing to resolve is a
185
+ * fact about the imports, not a defect in the resolver.
186
+ */
187
+ export declare function addImportEdges(scanned: readonly ScannedFile[], graph: RtmGraph, opts?: BuildRtmOptions): void;
188
+ /** @implements A-SPEC-293 — one architecture decision as its record states it. */
189
+ export interface DecisionRecord {
190
+ id: string;
191
+ title: string;
192
+ status: string;
193
+ /** The decision this one replaces, as declared in its frontmatter, or null. */
194
+ supersedes: string | null;
195
+ }
196
+ /** @implements A-SPEC-293 — "this file/spec cites that ADR", read from the text that cites it. */
197
+ export interface DecisionCitation {
198
+ /** `FILE:<repo-relative path>` or `SPEC:<id>`. */
199
+ from: string;
200
+ adr: string;
201
+ }
202
+ /**
203
+ * @implements A-SPEC-293
204
+ * DECISION nodes and the `constrained_by` / `supersedes` edges around them.
205
+ *
206
+ * Citation is the link, chosen by measurement rather than by taste: on this repository `governs`
207
+ * resolves only 9 of its 65 names to symbols (the rest are free-form design concepts), while 37
208
+ * source files and 71 spec files cite an ADR outright — and a citation sits exactly where the
209
+ * decision actually bears on the work.
210
+ *
211
+ * A citation naming a decision that does not exist creates neither node nor edge: an edge to a node
212
+ * nobody can open is a guess, the same rule import and call resolution follow. `supersedes` is
213
+ * declared in frontmatter and linked only when both decisions exist; this repository currently has
214
+ * none, and the edge kind is supported anyway rather than waiting for the first one.
215
+ */
216
+ export declare function addDecisionEdges(decisions: readonly DecisionRecord[], citations: readonly DecisionCitation[], graph: RtmGraph, opts?: BuildRtmOptions): void;