@holmes-lab/holmes-kit 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (134) hide show
  1. package/CHANGELOG.md +35 -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 -17
  13. package/dist/holmes/cli/codex-toml.d.ts +26 -0
  14. package/dist/holmes/cli/codex-toml.js +282 -0
  15. package/dist/holmes/cli/doctor.d.ts +56 -0
  16. package/dist/holmes/cli/doctor.js +348 -18
  17. package/dist/holmes/cli/index.js +16 -2
  18. package/dist/holmes/cli/init.js +78 -0
  19. package/dist/holmes/cli/interactive-prompt.js +4 -4
  20. package/dist/holmes/cli/mcp-launcher.d.ts +2 -2
  21. package/dist/holmes/cli/semantic-key.d.ts +19 -0
  22. package/dist/holmes/cli/semantic-key.js +93 -0
  23. package/dist/holmes/config/config.d.ts +9 -0
  24. package/dist/holmes/config/config.js +8 -1
  25. package/dist/holmes/cpg/language-capability.d.ts +65 -0
  26. package/dist/holmes/cpg/language-capability.js +145 -0
  27. package/dist/holmes/cpg/language-parser-walk.js +179 -34
  28. package/dist/holmes/cpg/language-parser.d.ts +1 -1
  29. package/dist/holmes/governance/ledger-rechain.d.ts +12 -0
  30. package/dist/holmes/governance/ledger-rechain.js +17 -2
  31. package/dist/holmes/governance/provenance-ledger.js +21 -0
  32. package/dist/holmes/guardrail/blind-spots.js +12 -1
  33. package/dist/holmes/guardrail/impact-gate.d.ts +77 -0
  34. package/dist/holmes/guardrail/impact-gate.js +263 -0
  35. package/dist/holmes/guardrail/write-target.d.ts +38 -1
  36. package/dist/holmes/guardrail/write-target.js +48 -4
  37. package/dist/holmes/hooks/adapters/antigravity.js +12 -1
  38. package/dist/holmes/hooks/corrupt-state-run.d.ts +33 -0
  39. package/dist/holmes/hooks/corrupt-state-run.js +16 -0
  40. package/dist/holmes/hooks/pre-tool-use.d.ts +26 -0
  41. package/dist/holmes/hooks/pre-tool-use.js +203 -11
  42. package/dist/holmes/hooks/stop.d.ts +65 -0
  43. package/dist/holmes/hooks/stop.js +200 -2
  44. package/dist/holmes/mcp/handlers.d.ts +88 -0
  45. package/dist/holmes/mcp/handlers.js +597 -16
  46. package/dist/holmes/mcp/maintenance-analyze.d.ts +435 -0
  47. package/dist/holmes/mcp/maintenance-analyze.js +994 -0
  48. package/dist/holmes/mcp/maintenance-evidence.d.ts +140 -0
  49. package/dist/holmes/mcp/maintenance-evidence.js +253 -0
  50. package/dist/holmes/mcp/tool-schemas.js +71 -0
  51. package/dist/holmes/project/root.js +3 -1
  52. package/dist/holmes/review/baseline-arm.d.ts +37 -0
  53. package/dist/holmes/review/baseline-arm.js +51 -0
  54. package/dist/holmes/review/captured-stdin-guard.d.ts +8 -0
  55. package/dist/holmes/review/captured-stdin-guard.js +48 -0
  56. package/dist/holmes/review/coherence-verify.d.ts +31 -0
  57. package/dist/holmes/review/coherence-verify.js +144 -0
  58. package/dist/holmes/review/commit-text.d.ts +50 -0
  59. package/dist/holmes/review/commit-text.js +76 -0
  60. package/dist/holmes/review/confidence-calibration.d.ts +39 -0
  61. package/dist/holmes/review/confidence-calibration.js +39 -0
  62. package/dist/holmes/review/content-baseline.d.ts +38 -0
  63. package/dist/holmes/review/content-baseline.js +103 -0
  64. package/dist/holmes/review/content-verify.d.ts +20 -0
  65. package/dist/holmes/review/content-verify.js +73 -0
  66. package/dist/holmes/review/dense-retrieval.d.ts +66 -0
  67. package/dist/holmes/review/dense-retrieval.js +97 -0
  68. package/dist/holmes/review/edge-quality.d.ts +44 -0
  69. package/dist/holmes/review/edge-quality.js +117 -0
  70. package/dist/holmes/review/evaluation-metrics.d.ts +138 -0
  71. package/dist/holmes/review/evaluation-metrics.js +175 -0
  72. package/dist/holmes/review/graph-verifier.d.ts +34 -0
  73. package/dist/holmes/review/graph-verifier.js +62 -0
  74. package/dist/holmes/review/hop-ablation.d.ts +100 -0
  75. package/dist/holmes/review/hop-ablation.js +89 -0
  76. package/dist/holmes/review/manual-baseline.d.ts +209 -0
  77. package/dist/holmes/review/manual-baseline.js +2846 -0
  78. package/dist/holmes/review/oracle-gap.d.ts +32 -0
  79. package/dist/holmes/review/oracle-gap.js +102 -0
  80. package/dist/holmes/review/point-in-time-replay.d.ts +41 -0
  81. package/dist/holmes/review/point-in-time-replay.js +161 -0
  82. package/dist/holmes/review/rank-diagnosis.d.ts +43 -0
  83. package/dist/holmes/review/rank-diagnosis.js +163 -0
  84. package/dist/holmes/review/replay-calibration.d.ts +62 -0
  85. package/dist/holmes/review/replay-calibration.js +83 -0
  86. package/dist/holmes/review/replay-corpus.d.ts +135 -0
  87. package/dist/holmes/review/replay-corpus.js +210 -0
  88. package/dist/holmes/review/run-replay.d.ts +260 -0
  89. package/dist/holmes/review/run-replay.js +729 -0
  90. package/dist/holmes/review/semantic-arm.d.ts +271 -0
  91. package/dist/holmes/review/semantic-arm.js +717 -0
  92. package/dist/holmes/review/semantic-retrieval.d.ts +55 -0
  93. package/dist/holmes/review/semantic-retrieval.js +156 -0
  94. package/dist/holmes/review/spec-layer-stats.d.ts +38 -0
  95. package/dist/holmes/review/spec-layer-stats.js +52 -0
  96. package/dist/holmes/review/temporal-prior.d.ts +33 -0
  97. package/dist/holmes/review/temporal-prior.js +53 -0
  98. package/dist/holmes/review/test-runner.d.ts +15 -0
  99. package/dist/holmes/review/test-runner.js +41 -4
  100. package/dist/holmes/review/test-selection-breadth.d.ts +75 -0
  101. package/dist/holmes/review/test-selection-breadth.js +57 -0
  102. package/dist/holmes/review/traceability-benchmark.d.ts +81 -0
  103. package/dist/holmes/review/traceability-benchmark.js +135 -0
  104. package/dist/holmes/review/union-verify.d.ts +12 -0
  105. package/dist/holmes/review/union-verify.js +70 -0
  106. package/dist/holmes/rtm/graph-store.d.ts +51 -0
  107. package/dist/holmes/rtm/graph-store.js +122 -0
  108. package/dist/holmes/rtm/incremental.d.ts +25 -1
  109. package/dist/holmes/rtm/incremental.js +18 -1
  110. package/dist/holmes/rtm/localize.d.ts +28 -0
  111. package/dist/holmes/rtm/localize.js +272 -14
  112. package/dist/holmes/rtm/rtm-builder.d.ts +118 -3
  113. package/dist/holmes/rtm/rtm-builder.js +265 -28
  114. package/dist/holmes/rtm/rtm-graph.d.ts +117 -2
  115. package/dist/holmes/rtm/rtm-graph.js +194 -31
  116. package/dist/holmes/rtm/taint-benchmark.d.ts +97 -0
  117. package/dist/holmes/rtm/taint-benchmark.js +141 -0
  118. package/dist/holmes/rtm/test-scope.js +24 -1
  119. package/dist/holmes/semantic/credentials.d.ts +15 -0
  120. package/dist/holmes/semantic/credentials.js +134 -0
  121. package/dist/holmes/semantic/embedder.d.ts +44 -0
  122. package/dist/holmes/semantic/embedder.js +185 -0
  123. package/dist/holmes/semantic/hit-rerank.d.ts +4 -0
  124. package/dist/holmes/semantic/hit-rerank.js +38 -0
  125. package/dist/holmes/semantic/tier.d.ts +37 -0
  126. package/dist/holmes/semantic/tier.js +54 -0
  127. package/dist/holmes/semantic/vector-cache.d.ts +11 -0
  128. package/dist/holmes/semantic/vector-cache.js +91 -0
  129. package/dist/holmes/spec/acceptance-quality.d.ts +81 -0
  130. package/dist/holmes/spec/acceptance-quality.js +169 -0
  131. package/dist/holmes/spec/validator.js +33 -1
  132. package/dist/holmes/spec/yaml-scalar.d.ts +1 -0
  133. package/dist/holmes/spec/yaml-scalar.js +43 -0
  134. package/package.json +1 -1
@@ -0,0 +1,717 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.fileTokens = fileTokens;
4
+ exports.corpusIdf = corpusIdf;
5
+ exports.rankIdf = rankIdf;
6
+ exports.normalisedIdf = normalisedIdf;
7
+ exports.rankWeighted = rankWeighted;
8
+ exports.rankExpanded = rankExpanded;
9
+ exports.matchedSpecsWeighted = matchedSpecsWeighted;
10
+ exports.rankBreadthDamped = rankBreadthDamped;
11
+ exports.anchorCoverage = anchorCoverage;
12
+ exports.rankCoverageAware = rankCoverageAware;
13
+ exports.rankCitationConditioned = rankCitationConditioned;
14
+ exports.rankCapped = rankCapped;
15
+ exports.rankUncitedCapped = rankUncitedCapped;
16
+ exports.rankWithCallHop = rankWithCallHop;
17
+ exports.ablate = ablate;
18
+ exports.rankSpecLiftLimited = rankSpecLiftLimited;
19
+ exports.rankWithBonusScale = rankWithBonusScale;
20
+ exports.rankSpecReorderOnly = rankSpecReorderOnly;
21
+ const localize_1 = require("../rtm/localize");
22
+ const edge_quality_1 = require("./edge-quality");
23
+ /**
24
+ * An alternative ranker, scored beside the product's — never in place of it.
25
+ *
26
+ * Goal Phase C asks for the semantic layer as an ARM: added to graph-off / current / enhanced and
27
+ * scored on the same corpus, adopted only if the measurement moves. Nothing here is wired into
28
+ * `localizeIssue`; if the numbers below do not beat the current arm, the honest outcome is a
29
+ * recorded negative result, which this repository already has several of.
30
+ *
31
+ * The mechanism is the oldest fix for the failure mode the current scorer has: every term is worth
32
+ * the same. `significantTerms` drops stop-words, but a corpus has its OWN ubiquitous vocabulary that
33
+ * no global stop-list knows — in this repository `spec`, `holmes` and `test` appear nearly
34
+ * everywhere, and matching one of them is close to no evidence at all. Precision, not recall, is
35
+ * what that costs, and precision is the axis Phase C targets.
36
+ */
37
+ const tokensOf = (s) => new Set((0, localize_1.significantTerms)(s));
38
+ /** Tokens of a file as the ranker sees it: its symbol names and its path. */
39
+ function fileTokens(f) {
40
+ const out = tokensOf(f.sourcePath);
41
+ for (const sym of f.symbols)
42
+ for (const t of tokensOf(sym.qualifiedName))
43
+ out.add(t);
44
+ return out;
45
+ }
46
+ /**
47
+ * Inverse document frequency over the scanned corpus.
48
+ *
49
+ * Smoothed (`(N+1)/(df+1)`) and floored at zero: a term present in every file must be WEAK evidence,
50
+ * never negative evidence. Unsmoothed IDF would make it exactly 0 and let a file that matches only
51
+ * ubiquitous terms tie with a file that matches nothing, which reads as "no opinion" when the truth
52
+ * is "a little opinion".
53
+ */
54
+ function corpusIdf(scanned) {
55
+ const df = new Map();
56
+ for (const f of scanned)
57
+ for (const t of fileTokens(f))
58
+ df.set(t, (df.get(t) ?? 0) + 1);
59
+ const n = scanned.length;
60
+ const idf = new Map();
61
+ for (const [t, d] of df)
62
+ idf.set(t, Math.max(Math.log((n + 1) / (d + 1)), 1e-6));
63
+ return idf;
64
+ }
65
+ /**
66
+ * Rank files by IDF-weighted term overlap.
67
+ *
68
+ * Symbol matches stay worth 3× a path match, as in the product's scorer — changing two things at
69
+ * once would leave the measurement unable to say which one moved. The only difference from the
70
+ * current arm is that each match is multiplied by how rare its term is in this corpus.
71
+ */
72
+ function rankIdf(issueText, scanned, topN) {
73
+ const terms = new Set((0, localize_1.significantTerms)(issueText));
74
+ if (terms.size === 0 || scanned.length === 0)
75
+ return [];
76
+ const idf = corpusIdf(scanned);
77
+ const hits = [];
78
+ for (const f of scanned) {
79
+ let score = 0;
80
+ for (const sym of f.symbols) {
81
+ const st = tokensOf(sym.qualifiedName);
82
+ for (const t of terms)
83
+ if (st.has(t))
84
+ score += 3 * (idf.get(t) ?? 0);
85
+ }
86
+ const pt = tokensOf(f.sourcePath);
87
+ for (const t of terms)
88
+ if (pt.has(t))
89
+ score += idf.get(t) ?? 0;
90
+ if (score > 0)
91
+ hits.push({ file: f.sourcePath, score });
92
+ }
93
+ return hits.sort((a, b) => b.score - a.score || a.file.localeCompare(b.file)).slice(0, topN);
94
+ }
95
+ /**
96
+ * IDF, rescaled so the MEAN weight over the corpus vocabulary is 1.
97
+ *
98
+ * Without this the arm would not be a one-variable change. Raw IDF values sit well below 1 on a
99
+ * corpus of a few hundred files, so every lexical contribution shrinks at once while the spec-anchor
100
+ * bonus (+4/sqrt(anchors)) and the citation bonus (+20/sqrt(anchors)) keep their size — the arm would
101
+ * then be measuring how much the graph bonuses dominate, not how much term rarity helps. Rescaling
102
+ * keeps the lexical term's total magnitude comparable and changes only its DISTRIBUTION across
103
+ * terms, which is the hypothesis under test.
104
+ *
105
+ * An unseen term weighs exactly 1: a word absent from the corpus is not evidence against a file, and
106
+ * mapping it to 0 would silently delete the request's most distinctive vocabulary.
107
+ */
108
+ function normalisedIdf(scanned) {
109
+ const idf = corpusIdf(scanned);
110
+ const vals = [...idf.values()];
111
+ if (vals.length === 0)
112
+ return idf;
113
+ const mean = vals.reduce((a, b) => a + b, 0) / vals.length;
114
+ if (mean <= 0)
115
+ return new Map([...idf.keys()].map((t) => [t, 1]));
116
+ const out = new Map();
117
+ for (const [t, v] of idf)
118
+ out.set(t, v / mean);
119
+ return out;
120
+ }
121
+ const CITATION_BONUS = 20;
122
+ const isVendorPath = (p) => /(^|\/)(reference|vendor|vendors|third_party|third-party|external)\//i.test(p);
123
+ /**
124
+ * The product's scorer with the term weight lifted out as a parameter.
125
+ *
126
+ * This mirrors `localizeIssue` deliberately and the mirror is load-bearing, so a test pins it: with
127
+ * `() => 1` this function must return the product's ranking and its scores EXACTLY. If someone
128
+ * changes the product's scoring and not this one, that test goes red rather than the replay quietly
129
+ * reporting a difference between rankers that is really a difference between vintages.
130
+ */
131
+ function rankWeighted(issueText, scanned, specs, topN, weight,
132
+ /**
133
+ * @implements A-SPEC-352
134
+ * Weighted spec gate. Absent = the product's `overlap >= 2` count.
135
+ *
136
+ * Passed in here rather than reimplemented by the caller for a reason paid for three times: the
137
+ * first IDF arm hand-rolled a scorer and silently dropped the spec and citation bonuses; a probe
138
+ * of this very gate hand-rolled one again and dropped the citation path, whose +20 bonus is the
139
+ * largest single gain this repository has measured. Both produced confident, worthless numbers.
140
+ * Every arm goes through this function so the features it does NOT vary cannot go missing.
141
+ */
142
+ gateThreshold) {
143
+ const terms = (0, localize_1.significantTerms)(issueText);
144
+ const citations = (0, localize_1.citationsIn)(issueText, new Set(specs.map((s) => s.id)));
145
+ const citedSet = new Set(citations.cited);
146
+ if (terms.length === 0 && citations.cited.length === 0)
147
+ return [];
148
+ const termSet = new Set(terms);
149
+ const matchedSpecs = gateThreshold === undefined
150
+ ? (() => {
151
+ const out = [];
152
+ for (const s of specs) {
153
+ if (s.type !== 'A-SPEC')
154
+ continue;
155
+ const st = tokensOf(`${s.title} ${Object.values(s.sections).join(' ')}`);
156
+ let overlap = 0;
157
+ for (const t of termSet)
158
+ if (st.has(t))
159
+ overlap++;
160
+ if (overlap >= 2 || (overlap === 1 && termSet.size <= 3))
161
+ out.push(s.id);
162
+ }
163
+ return out;
164
+ })()
165
+ : matchedSpecsWeighted(issueText, specs, weight, gateThreshold);
166
+ for (const id of citations.cited)
167
+ if (!matchedSpecs.includes(id))
168
+ matchedSpecs.push(id);
169
+ const matchedSpecSet = new Set(matchedSpecs);
170
+ const hits = [];
171
+ for (const f of scanned) {
172
+ let score = 0;
173
+ for (const sym of f.symbols) {
174
+ const st = tokensOf(sym.qualifiedName);
175
+ const hit = [...termSet].filter((t) => st.has(t));
176
+ for (const t of hit)
177
+ score += 3 * weight(t);
178
+ }
179
+ const pt = tokensOf(f.sourcePath);
180
+ for (const t of termSet)
181
+ if (pt.has(t))
182
+ score += weight(t);
183
+ const anchors = [...new Set(f.implementsSpecs ?? [])];
184
+ const viaSpecs = anchors.filter((id) => matchedSpecSet.has(id));
185
+ if (viaSpecs.length > 0)
186
+ score += Math.round(4 * viaSpecs.length / Math.sqrt(anchors.length) * 100) / 100;
187
+ const citedAnchors = anchors.filter((id) => citedSet.has(id));
188
+ if (citedAnchors.length > 0) {
189
+ score += Math.round(CITATION_BONUS * citedAnchors.length / Math.sqrt(anchors.length) * 100) / 100;
190
+ }
191
+ if (isVendorPath(f.sourcePath) && score > 0)
192
+ score = score / 2;
193
+ if (score > 0)
194
+ hits.push({ file: f.sourcePath, score });
195
+ }
196
+ hits.sort((a, b) => Number(isVendorPath(a.file)) - Number(isVendorPath(b.file)) ||
197
+ b.score - a.score ||
198
+ a.file.localeCompare(b.file));
199
+ return hits.slice(0, topN);
200
+ }
201
+ /**
202
+ * The spec graph, undirected.
203
+ *
204
+ * `dependsOn` points from a child to its parent, so the semantically interesting neighbour — another
205
+ * A-SPEC under the same H-SPEC — is TWO hops away: up to the parent and back down. Walking the
206
+ * edges in one direction only would make siblings unreachable and the expansion would look useless
207
+ * for a reason that is about the walk, not about the idea.
208
+ */
209
+ function specAdjacency(specs) {
210
+ const known = new Set(specs.map((s) => s.id));
211
+ const adj = new Map();
212
+ const link = (a, b) => {
213
+ (adj.get(a) ?? adj.set(a, []).get(a)).push(b);
214
+ (adj.get(b) ?? adj.set(b, []).get(b)).push(a);
215
+ };
216
+ for (const s of specs)
217
+ for (const p of s.dependsOn ?? [])
218
+ if (known.has(p))
219
+ link(s.id, p);
220
+ return adj;
221
+ }
222
+ /**
223
+ * Rank with GraphRAG-style expansion over the spec graph.
224
+ *
225
+ * The gap this addresses is the one graph retrieval exists for: the answer does not contain the
226
+ * request's words, but it is the neighbour of something that does. The lexical layer scores a file
227
+ * only if the request's terms reach the file or a spec it anchors — one hop. A sibling requirement,
228
+ * written in different words for the same feature, is invisible however closely it is related.
229
+ *
230
+ * Deliberately weaker than a hit at every hop: the bonus is the matched-spec bonus times `decay^k`.
231
+ * A neighbour is evidence, not an answer, and if decay ever let it outrank a direct match the
232
+ * retrieval would be trading the answer for its neighbourhood. A test pins that ordering.
233
+ *
234
+ * Everything else is `rankWeighted` unchanged — the one-variable rule from A-SPEC-349 applies here
235
+ * too, or a win could not be attributed to expansion rather than to weighting.
236
+ */
237
+ function rankExpanded(issueText, scanned, specs, topN, weight, opts) {
238
+ const base = rankWeighted(issueText, scanned, specs, scanned.length, weight);
239
+ if (opts.hops <= 0 || opts.decay <= 0)
240
+ return base.slice(0, topN);
241
+ // Which specs the request matched — recomputed exactly as `rankWeighted` does, so the two agree.
242
+ const terms = new Set((0, localize_1.significantTerms)(issueText));
243
+ const citations = (0, localize_1.citationsIn)(issueText, new Set(specs.map((s) => s.id)));
244
+ const matched = new Set(citations.cited);
245
+ for (const s of specs) {
246
+ if (s.type !== 'A-SPEC')
247
+ continue;
248
+ const st = tokensOf(`${s.title} ${Object.values(s.sections).join(' ')}`);
249
+ let overlap = 0;
250
+ for (const t of terms)
251
+ if (st.has(t))
252
+ overlap++;
253
+ if (overlap >= 2 || (overlap === 1 && terms.size <= 3))
254
+ matched.add(s.id);
255
+ }
256
+ if (matched.size === 0)
257
+ return base.slice(0, topN);
258
+ const adj = specAdjacency(specs);
259
+ const hopOf = new Map([...matched].map((id) => [id, 0]));
260
+ let wave = [...matched];
261
+ for (let k = 1; k <= opts.hops && wave.length > 0; k++) {
262
+ const next = [];
263
+ for (const id of wave)
264
+ for (const n of adj.get(id) ?? []) {
265
+ if (hopOf.has(n))
266
+ continue; // first arrival wins: the shortest path is the strongest claim
267
+ hopOf.set(n, k);
268
+ next.push(n);
269
+ }
270
+ wave = next;
271
+ }
272
+ const score = new Map(base.map((h) => [h.file, h.score]));
273
+ for (const f of scanned) {
274
+ const anchors = [...new Set(f.implementsSpecs ?? [])];
275
+ if (anchors.length === 0)
276
+ continue; // expansion reaches files THROUGH specs; anchor or nothing
277
+ let bonus = 0;
278
+ for (const id of anchors) {
279
+ const k = hopOf.get(id);
280
+ if (k === undefined || k === 0)
281
+ continue; // hop 0 is already in the base score
282
+ bonus += 4 * Math.pow(opts.decay, k) / Math.sqrt(anchors.length);
283
+ }
284
+ if (bonus > 0)
285
+ score.set(f.sourcePath, (score.get(f.sourcePath) ?? 0) + Math.round(bonus * 100) / 100);
286
+ }
287
+ return [...score.entries()]
288
+ .filter(([, v]) => v > 0)
289
+ .map(([file, s]) => ({ file, score: s }))
290
+ .sort((a, b) => Number(isVendorPath(a.file)) - Number(isVendorPath(b.file)) ||
291
+ b.score - a.score ||
292
+ a.file.localeCompare(b.file))
293
+ .slice(0, topN);
294
+ }
295
+ /**
296
+ * Which A-SPECs the request is taken to have matched — the entry point into the knowledge graph.
297
+ *
298
+ * The product's rule counts overlapping terms: two shared words admit a spec, one does not (unless
299
+ * the request is very short). That treats a shared `handler` exactly like a shared `quarantine`, and
300
+ * it costs more here than it does in file scoring: an admitted spec sprays its bonus over EVERY file
301
+ * anchoring it, so one wrongly-opened gate promotes a whole group of unrelated files at once.
302
+ *
303
+ * Weighing the overlap instead of counting it means a request has to bring either several ordinary
304
+ * words or one genuinely distinctive one. With `weight = () => 1` and `threshold = 2` this is the
305
+ * product's original rule exactly, which is what makes any measured difference attributable.
306
+ *
307
+ * A-SPEC only: files anchor A-SPECs, so admitting any other kind can never pull a file through the
308
+ * hop and would only overstate what influenced the ranking.
309
+ */
310
+ function matchedSpecsWeighted(issueText, specs, weight, threshold) {
311
+ const terms = new Set((0, localize_1.significantTerms)(issueText));
312
+ const out = [];
313
+ for (const s of specs) {
314
+ if (s.type !== 'A-SPEC')
315
+ continue;
316
+ const st = tokensOf(`${s.title} ${Object.values(s.sections).join(' ')}`);
317
+ let mass = 0;
318
+ for (const t of terms)
319
+ if (st.has(t))
320
+ mass += weight(t);
321
+ // The short-request escape hatch survives: a three-word request cannot reach a threshold built
322
+ // for prose, and dropping it would change two things at once.
323
+ if (mass >= threshold || (mass > 0 && terms.size <= 3))
324
+ out.push(s.id);
325
+ }
326
+ return out;
327
+ }
328
+ /**
329
+ * The spec bonus, damped by how much code the spec covers.
330
+ *
331
+ * The existing normalisation divides by how many anchors a FILE carries (A-SPEC-270) — it answers
332
+ * "how much of this file's identity is this spec?". It never asks the other question: "how much of
333
+ * this spec's territory is this file?". A spec spanning twenty files says little about any one of
334
+ * them; a spec covering one says a great deal, and today they pay the same +4.
335
+ *
336
+ * That asymmetry is invisible in a repository with hundreds of narrow specs and decisive in one with
337
+ * a few broad ones — which is exactly where the measured precision loss appeared (A-SPEC-362: on a
338
+ * 54-spec corpus the product loses Top-5 recall and Top-10 precision to plain word matching, and the
339
+ * weighting-only arm is indistinguishable from that baseline).
340
+ *
341
+ * sqrt, matching the existing normalisation's shape: a one-file spec is untaxed, a twenty-file spec
342
+ * is damped by ~4.5x rather than erased. Damping to zero would delete the very hop that carries all
343
+ * of this harness's measured advantage.
344
+ */
345
+ function rankBreadthDamped(issueText, scanned, specs, topN, weight, gateThreshold) {
346
+ const filesPerSpec = new Map();
347
+ for (const f of scanned) {
348
+ for (const id of new Set(f.implementsSpecs ?? []))
349
+ filesPerSpec.set(id, (filesPerSpec.get(id) ?? 0) + 1);
350
+ }
351
+ const base = rankWeighted(issueText, scanned, specs, scanned.length, weight, gateThreshold);
352
+ const byFile = new Map(scanned.map((f) => [f.sourcePath, f]));
353
+ const adjusted = base.map((hit) => {
354
+ const f = byFile.get(hit.file);
355
+ const anchors = [...new Set(f?.implementsSpecs ?? [])];
356
+ if (anchors.length === 0)
357
+ return hit;
358
+ // Recover the spec component and re-pay it damped, leaving the lexical part untouched: the arm
359
+ // must differ from the product in this one term only.
360
+ let credited = 0, damped = 0;
361
+ for (const id of anchors) {
362
+ const breadth = filesPerSpec.get(id) ?? 1;
363
+ const share = 4 / Math.sqrt(anchors.length);
364
+ credited += share;
365
+ damped += share / Math.sqrt(breadth);
366
+ }
367
+ // `credited` overstates when the gate admitted only some anchors, so scale by what the base
368
+ // actually paid rather than subtracting a number it may never have added.
369
+ const paid = Math.min(credited, hit.score);
370
+ return { file: hit.file, score: hit.score - paid + paid * (credited === 0 ? 1 : damped / credited) };
371
+ });
372
+ return adjusted
373
+ .filter((h) => h.score > 0)
374
+ .sort((a, b) => Number(isVendorPath(a.file)) - Number(isVendorPath(b.file)) ||
375
+ b.score - a.score ||
376
+ a.file.localeCompare(b.file))
377
+ .slice(0, topN);
378
+ }
379
+ /**
380
+ * How much of the corpus carries an `@implements` anchor at all.
381
+ *
382
+ * The spec layer's evidence is only representative when a decent share of the tree is governed. If
383
+ * five files in a thousand carry anchors, matching one of their specs says something about those
384
+ * five and nothing about the other nine hundred and ninety-five — yet the bonus is paid at full
385
+ * strength and displaces lexically correct candidates.
386
+ */
387
+ function anchorCoverage(scanned, specs = []) {
388
+ if (scanned.length === 0)
389
+ return 0;
390
+ // RESOLVED anchors, not merely present ones. The first version of this counted files carrying any
391
+ // `@implements` marker and returned exactly 1.000 on both corpora — a metric that separated
392
+ // nothing, and the two arms it fed were byte-identical at every spec density. Thinning the spec
393
+ // store does not remove the markers in the code; it strands them. A marker pointing at a spec that
394
+ // is not in the store is precisely the sparse-layer condition, so it must count as UNCOVERED.
395
+ const known = new Set(specs.map((s) => s.id));
396
+ if (known.size === 0)
397
+ return 0;
398
+ return scanned.filter((f) => (f.implementsSpecs ?? []).some((id) => known.has(id))).length / scanned.length;
399
+ }
400
+ /**
401
+ * The spec bonus scaled by how much of the tree the spec layer actually covers.
402
+ *
403
+ * Measured on one repository with only the spec count varied (A-SPEC-364): 585 specs gave Top-10
404
+ * recall 0.770, but 146 gave 0.253 and 59 gave 0.264 — BELOW the 0.305 of no spec layer at all. A
405
+ * partly populated spec store is worse than an empty one, and every project that adopts Holmes
406
+ * passes through that valley on its way to the peak. jarvis, at 54 specs, is sitting in it.
407
+ *
408
+ * Scaling by coverage makes the layer fade in rather than switch on: at zero coverage the ranking is
409
+ * exactly the lexical arm (the floor a project starts from), and at full coverage the bonus is
410
+ * unchanged (the peak this repository measures). No threshold, because a threshold would create a
411
+ * cliff where authoring one more spec changes every ranking at once.
412
+ */
413
+ function rankCoverageAware(issueText, scanned, specs, topN, weight, gateThreshold) {
414
+ const coverage = anchorCoverage(scanned, specs);
415
+ const withSpecs = rankWeighted(issueText, scanned, specs, scanned.length, weight, gateThreshold);
416
+ if (coverage >= 1)
417
+ return withSpecs.slice(0, topN);
418
+ const withoutSpecs = new Map(rankWeighted(issueText, scanned, [], scanned.length, weight, gateThreshold).map((h) => [h.file, h.score]));
419
+ // Interpolate between the two arms rather than rescaling the bonus in place: the spec layer also
420
+ // decides WHICH specs matched and which files were admitted, and a bonus-only adjustment would
421
+ // leave those effects at full strength while claiming to have damped them.
422
+ const merged = new Map();
423
+ for (const h of withSpecs) {
424
+ const lexical = withoutSpecs.get(h.file) ?? 0;
425
+ merged.set(h.file, lexical + (h.score - lexical) * coverage);
426
+ }
427
+ for (const [file, score] of withoutSpecs)
428
+ if (!merged.has(file))
429
+ merged.set(file, score);
430
+ return [...merged.entries()]
431
+ .filter(([, v]) => v > 0)
432
+ .map(([file, score]) => ({ file, score }))
433
+ .sort((a, b) => Number(isVendorPath(a.file)) - Number(isVendorPath(b.file)) ||
434
+ b.score - a.score ||
435
+ a.file.localeCompare(b.file))
436
+ .slice(0, topN);
437
+ }
438
+ /**
439
+ * The gate, chosen by whether the request names a spec at all.
440
+ *
441
+ * No single threshold serves both conditions. Measured over 60 point-in-time cases (A-SPEC-369):
442
+ *
443
+ * threshold 1 — cited Top-10 recall 0.770, uncited 0.275 (a no-graph baseline gets 0.357)
444
+ * threshold 4 — cited 0.743 and Top-1 0.333 → 0.283, uncited 0.349
445
+ *
446
+ * Under a citation, the named spec locates the request in the corpus, and the specs that merely
447
+ * share vocabulary with it are corroborated rather than speculative — the permissive gate pays. With
448
+ * no citation, those same matches are twenty-six unanchored guesses per case whose bonuses bury the
449
+ * lexical signal, and the strict gate keeps them out.
450
+ *
451
+ * The condition is read from the request itself, and only a citation the store can RESOLVE counts:
452
+ * a stray `A-SPEC-999` in a bug report must not unlock the permissive path.
453
+ */
454
+ function rankCitationConditioned(issueText, scanned, specs, topN, weight, citedThreshold, uncitedThreshold) {
455
+ const cited = (0, localize_1.citationsIn)(issueText, new Set(specs.map((s) => s.id))).cited.length > 0;
456
+ return rankWeighted(issueText, scanned, specs, topN, weight, cited ? citedThreshold : uncitedThreshold);
457
+ }
458
+ /**
459
+ * Drop the lexically-matched specs when too many of them match.
460
+ *
461
+ * Conditioning on whether the request cited a spec repaired this repository and cost the second
462
+ * corpus its lead, because that corpus cites nothing and would always take the strict path. Both
463
+ * measurements point at the same underlying quantity: HOW MANY specs the request matched. jarvis
464
+ * matches 0.87 per case, this repository 26.6 (A-SPEC-367).
465
+ *
466
+ * One matching spec is a claim about where the request belongs. Twenty-six matching specs is a fact
467
+ * about the request's vocabulary — it was written in words the corpus uses everywhere — and their
468
+ * bonuses, spread over a hundred anchored files, bury the lexical ranking that would otherwise work.
469
+ *
470
+ * A citation is never capped: it names something rather than resembling it, and it is the one signal
471
+ * in this scorer measured to carry the localization result.
472
+ */
473
+ function rankCapped(issueText, scanned, specs, topN, weight, gateThreshold, maxMatchedSpecs) {
474
+ const known = new Set(specs.map((s) => s.id));
475
+ const cited = new Set((0, localize_1.citationsIn)(issueText, known).cited);
476
+ const lexical = matchedSpecsWeighted(issueText, specs, weight, gateThreshold)
477
+ .filter((id) => !cited.has(id));
478
+ if (lexical.length <= maxMatchedSpecs)
479
+ return rankWeighted(issueText, scanned, specs, topN, weight, gateThreshold);
480
+ // Over the cap: keep only the specs the request NAMED. Passing just those reproduces the citation
481
+ // path exactly, with no lexical spec matching left to admit anything else.
482
+ const citedSpecs = specs.filter((s) => cited.has(s.id));
483
+ return rankWeighted(issueText, scanned, citedSpecs, topN, weight, gateThreshold);
484
+ }
485
+ /**
486
+ * The cap, applied only to requests that named nothing.
487
+ *
488
+ * The two halves the measurements each found: a citation corroborates the specs that merely share
489
+ * vocabulary with the request, so with one present they help (dropping them cost Top-10 recall
490
+ * 0.770 → 0.743). Without one, a large number of matches is a fact about the request's wording
491
+ * rather than evidence, and their bonuses bury the lexical ranking (0.275 against a 0.357 baseline).
492
+ * A SMALL number of matches with no citation is still meaningful — that is the second corpus, at
493
+ * 0.87 matches per case, and capping it there threw away the modest lead it had.
494
+ *
495
+ * So: exempt cited requests, and cap only the uncited ones that matched more specs than a request
496
+ * can plausibly be about.
497
+ */
498
+ function rankUncitedCapped(issueText, scanned, specs, topN, weight, gateThreshold, maxMatchedSpecs) {
499
+ const cited = (0, localize_1.citationsIn)(issueText, new Set(specs.map((s) => s.id))).cited.length > 0;
500
+ if (cited)
501
+ return rankWeighted(issueText, scanned, specs, topN, weight, gateThreshold);
502
+ return rankCapped(issueText, scanned, specs, topN, weight, gateThreshold, maxMatchedSpecs);
503
+ }
504
+ /**
505
+ * One `calls` hop out from the lexical hits, added at a discount.
506
+ *
507
+ * With no citation the spec layer is off and the ranking is exactly what grepping the request's
508
+ * words would produce. The call graph is the only structure left that could beat it, and A-SPEC-318
509
+ * measured the opportunity: 13 missed files one `calls` hop away, all 58 hop edges being calls, none
510
+ * imports or inherits. That was measured under cited requests, where the spec layer had already
511
+ * found most of the answer — uncited, the lexical hits are the only seed, so the hop has more to add.
512
+ *
513
+ * Half the seed's score, and never more: a callee is evidence about the seed's neighbourhood, not
514
+ * about itself. If a hop could outrank a direct hit, the ranking would be trading the answer for its
515
+ * surroundings, which is the failure the spec-graph expansion arm was rejected for.
516
+ */
517
+ function rankWithCallHop(issueText, scanned, specs, topN, weight, gateThreshold, maxMatchedSpecs,
518
+ /** @implements A-SPEC-394 — use import edges to bind names the global map calls ambiguous. */
519
+ importAware = false) {
520
+ const base = rankUncitedCapped(issueText, scanned, specs, scanned.length, weight, gateThreshold, maxMatchedSpecs);
521
+ if (base.length === 0)
522
+ return [];
523
+ // Symbol -> the file declaring it. A name declared in two files is ambiguous and is not followed:
524
+ // a wrong hop is a confident false positive, a missing one is a disclosed gap.
525
+ const declaredIn = new Map();
526
+ for (const f of scanned) {
527
+ for (const sym of f.symbols) {
528
+ const bare = sym.qualifiedName.includes('.')
529
+ ? sym.qualifiedName.slice(sym.qualifiedName.lastIndexOf('.') + 1) : sym.qualifiedName;
530
+ for (const key of new Set([sym.qualifiedName, bare])) {
531
+ declaredIn.set(key, declaredIn.has(key) && declaredIn.get(key) !== f.sourcePath ? null : f.sourcePath);
532
+ }
533
+ }
534
+ }
535
+ // @implements A-SPEC-394
536
+ // Per-file disambiguation from imports (A-SPEC-393). The global map above discards a name declared
537
+ // twice; here a file that imports exactly one of the declaring modules binds to that one. This is
538
+ // the SAME hop as before on a BETTER edge set — a re-test of a rejected mechanism under a changed
539
+ // premise, not a new mechanism.
540
+ const declaredEverywhere = new Map();
541
+ for (const f of scanned) {
542
+ for (const sym of f.symbols) {
543
+ const bare = sym.qualifiedName.includes('.')
544
+ ? sym.qualifiedName.slice(sym.qualifiedName.lastIndexOf('.') + 1) : sym.qualifiedName;
545
+ for (const key of new Set([sym.qualifiedName, bare])) {
546
+ (declaredEverywhere.get(key) ?? declaredEverywhere.set(key, new Set()).get(key)).add(f.sourcePath);
547
+ }
548
+ }
549
+ }
550
+ const knownPaths = new Set(scanned.map((f) => f.sourcePath));
551
+ const importsOf = new Map();
552
+ if (importAware) {
553
+ for (const f of scanned) {
554
+ const set = new Set();
555
+ for (const e of f.edges ?? []) {
556
+ if (e.rel !== 'imports')
557
+ continue;
558
+ const target = (0, edge_quality_1.resolveImport)(f.sourcePath, e.to, knownPaths);
559
+ if (target)
560
+ set.add(target);
561
+ }
562
+ importsOf.set(f.sourcePath, set);
563
+ }
564
+ }
565
+ const resolveFrom = (fromFile, name) => {
566
+ const direct = declaredIn.get(name);
567
+ if (direct)
568
+ return direct;
569
+ if (!importAware)
570
+ return null;
571
+ const all = declaredEverywhere.get(name);
572
+ if (!all || all.size < 2)
573
+ return null;
574
+ const imported = [...all].filter((t) => importsOf.get(fromFile)?.has(t));
575
+ return imported.length === 1 ? imported[0] : null;
576
+ };
577
+ const score = new Map(base.map((h) => [h.file, h.score]));
578
+ const byPath = new Map(scanned.map((f) => [f.sourcePath, f]));
579
+ for (const hit of base) {
580
+ const f = byPath.get(hit.file);
581
+ for (const e of f?.edges ?? []) {
582
+ if (e.rel !== 'calls')
583
+ continue;
584
+ const target = resolveFrom(hit.file, e.to);
585
+ if (!target || target === hit.file)
586
+ continue;
587
+ const bonus = hit.score / 2;
588
+ if ((score.get(target) ?? 0) < bonus)
589
+ score.set(target, bonus);
590
+ }
591
+ }
592
+ return [...score.entries()]
593
+ .filter(([, v]) => v > 0)
594
+ .map(([file, s]) => ({ file, score: s }))
595
+ .sort((a, b) => Number(isVendorPath(a.file)) - Number(isVendorPath(b.file)) ||
596
+ b.score - a.score ||
597
+ a.file.localeCompare(b.file))
598
+ .slice(0, topN);
599
+ }
600
+ function ablate(issueText, scanned, specs, topN, opts) {
601
+ const weights = opts.idf ? normalisedIdf(scanned) : null;
602
+ const weight = (t) => (weights ? (weights.get(t) ?? 1) : 1);
603
+ const hits = rankWeighted(issueText, scanned, opts.specBonus ? specs : [], scanned.length, weight, 1);
604
+ const adjusted = opts.vendorDemotion
605
+ ? hits
606
+ // The demotion is applied inside `rankWeighted`; undoing it is a doubling, which restores the
607
+ // pre-demotion score exactly because the demotion is a halving.
608
+ : hits.map((h) => ({ file: h.file, score: isVendorPath(h.file) ? h.score * 2 : h.score }));
609
+ return [...adjusted]
610
+ .sort((a, b) => (opts.vendorDemotion ? Number(isVendorPath(a.file)) - Number(isVendorPath(b.file)) : 0) ||
611
+ b.score - a.score ||
612
+ a.file.localeCompare(b.file))
613
+ .slice(0, topN);
614
+ }
615
+ /**
616
+ * @implements A-SPEC-396
617
+ * Limit how many files each matched spec may lift.
618
+ *
619
+ * The ablation isolated the cost: turning the spec bonus off restores the baseline exactly, so that
620
+ * layer buys Top-1 (0.244 → 0.267 here, 0.400 → 0.433 on jarvis) and sells precision (0.156 → 0.104).
621
+ *
622
+ * Three earlier attempts all changed WHICH specs are admitted — breadth damping, coverage scaling,
623
+ * a cap on matched specs — and all three were rejected. This changes something else: once a spec is
624
+ * admitted, every file anchoring it rises together, and that simultaneity is the shape of the
625
+ * precision loss. Ranking a spec's anchored files by their own lexical evidence and lifting only the
626
+ * strongest few keeps the top of the list while leaving the tail alone.
627
+ *
628
+ * The cost is explicit: a file with no lexical evidence can no longer be lifted by its spec alone,
629
+ * so the layer loses the files it uniquely contributed. Whether that trade is positive is the
630
+ * measurement, not the assumption.
631
+ */
632
+ function rankSpecLiftLimited(issueText, scanned, specs, topN, weight, gateThreshold, liftPerSpec) {
633
+ const withSpecs = rankWeighted(issueText, scanned, specs, scanned.length, weight, gateThreshold);
634
+ const lexicalOnly = new Map(rankWeighted(issueText, scanned, [], scanned.length, weight, gateThreshold).map((h) => [h.file, h.score]));
635
+ const known = new Set(specs.map((s) => s.id));
636
+ const cited = new Set((0, localize_1.citationsIn)(issueText, known).cited);
637
+ const admitted = new Set([...cited, ...matchedSpecsWeighted(issueText, specs, weight, gateThreshold)]);
638
+ // For each admitted spec, keep only its strongest anchored files by LEXICAL score. Ties break on
639
+ // path so the choice is reproducible rather than dependent on scan order.
640
+ const allowed = new Set();
641
+ for (const id of admitted) {
642
+ const anchored = scanned
643
+ .filter((f) => (f.implementsSpecs ?? []).includes(id))
644
+ .map((f) => ({ file: f.sourcePath, lex: lexicalOnly.get(f.sourcePath) ?? 0 }))
645
+ .sort((a, b) => b.lex - a.lex || a.file.localeCompare(b.file))
646
+ .slice(0, Math.max(0, liftPerSpec));
647
+ for (const a of anchored)
648
+ allowed.add(a.file);
649
+ }
650
+ return withSpecs
651
+ .map((h) => ({ file: h.file, score: allowed.has(h.file) ? h.score : (lexicalOnly.get(h.file) ?? 0) }))
652
+ .filter((h) => h.score > 0)
653
+ .sort((a, b) => Number(isVendorPath(a.file)) - Number(isVendorPath(b.file)) ||
654
+ b.score - a.score ||
655
+ a.file.localeCompare(b.file))
656
+ .slice(0, topN);
657
+ }
658
+ /**
659
+ * @implements A-SPEC-397
660
+ * The spec bonus with its magnitude as a parameter.
661
+ *
662
+ * Four arms failed to recover the 0.052 of precision the layer costs, and each changed a different
663
+ * thing: which specs are admitted (breadth damping, coverage scaling, a cap on matches) and how far
664
+ * an admitted one reaches (the lift limit). Magnitude was never touched.
665
+ *
666
+ * The default is 4 per anchor against a lexical symbol hit worth 3 and a path hit worth 1, so a
667
+ * single matched spec outweighs a symbol match — a file whose only evidence is one anchor can
668
+ * outrank a file whose name actually contains the request's words. That is a plausible source of the
669
+ * tail pollution and it is the last untried one.
670
+ *
671
+ * Reducing it should cost Top-1 as it recovers precision, so both are reported: a setting that fixes
672
+ * one by destroying the other is not a fix.
673
+ */
674
+ function rankWithBonusScale(issueText, scanned, specs, topN, weight, gateThreshold, bonusScale) {
675
+ const withSpecs = rankWeighted(issueText, scanned, specs, scanned.length, weight, gateThreshold);
676
+ if (bonusScale === 1)
677
+ return withSpecs.slice(0, topN);
678
+ const lexical = new Map(rankWeighted(issueText, scanned, [], scanned.length, weight, gateThreshold).map((h) => [h.file, h.score]));
679
+ // Scale only the spec-derived part: the difference between the two arms IS the bonus, so this
680
+ // needs no assumption about how the bonus was computed and cannot drift from it.
681
+ return withSpecs
682
+ .map((h) => {
683
+ const lex = lexical.get(h.file) ?? 0;
684
+ return { file: h.file, score: lex + (h.score - lex) * bonusScale };
685
+ })
686
+ .filter((h) => h.score > 0)
687
+ .sort((a, b) => Number(isVendorPath(a.file)) - Number(isVendorPath(b.file)) ||
688
+ b.score - a.score ||
689
+ a.file.localeCompare(b.file))
690
+ .slice(0, topN);
691
+ }
692
+ /**
693
+ * @implements A-SPEC-398
694
+ * The spec bonus may REORDER, but may not ADMIT.
695
+ *
696
+ * Five arms failed to recover the 0.052 of precision the spec layer costs, and counting the lists
697
+ * showed why none of them could: the baseline returns 7.09 files per case and the product returns
698
+ * 9.27, filling all ten slots in 36 of 45 cases against the baseline's 16. The layer is not ranking
699
+ * the same set differently — it is **adding about two files per case**, and those additions are
700
+ * mostly wrong. Every arm so far reordered or rescaled; none declined to add.
701
+ *
702
+ * So: a file with lexical evidence keeps its bonus and can move up. A file whose only evidence is an
703
+ * anchor does not enter the list at all.
704
+ *
705
+ * The cost is the one thing the spec layer uniquely did — reaching a file the request's words never
706
+ * touch. That is a real loss and the measurement has to weigh it, not assume it away.
707
+ */
708
+ function rankSpecReorderOnly(issueText, scanned, specs, topN, weight, gateThreshold) {
709
+ const withSpecs = rankWeighted(issueText, scanned, specs, scanned.length, weight, gateThreshold);
710
+ const lexical = new Map(rankWeighted(issueText, scanned, [], scanned.length, weight, gateThreshold).map((h) => [h.file, h.score]));
711
+ return withSpecs
712
+ .filter((h) => (lexical.get(h.file) ?? 0) > 0) // admitted by words, reordered by specs
713
+ .sort((a, b) => Number(isVendorPath(a.file)) - Number(isVendorPath(b.file)) ||
714
+ b.score - a.score ||
715
+ a.file.localeCompare(b.file))
716
+ .slice(0, topN);
717
+ }