@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.
- package/CHANGELOG.md +55 -0
- package/dist/.build-id +1 -1
- package/dist/holmes/assoc/assoc-arm.d.ts +94 -0
- package/dist/holmes/assoc/assoc-arm.js +187 -0
- package/dist/holmes/assoc/explore.d.ts +21 -0
- package/dist/holmes/assoc/explore.js +160 -0
- package/dist/holmes/assoc/impact-baseline.d.ts +13 -0
- package/dist/holmes/assoc/impact-baseline.js +48 -0
- package/dist/holmes/assoc/ppr.d.ts +64 -0
- package/dist/holmes/assoc/ppr.js +110 -0
- package/dist/holmes/cli/agents.d.ts +24 -11
- package/dist/holmes/cli/agents.js +93 -21
- package/dist/holmes/cli/doctor.d.ts +56 -0
- package/dist/holmes/cli/doctor.js +311 -5
- package/dist/holmes/cli/index.js +13 -1
- package/dist/holmes/cli/semantic-key.d.ts +19 -0
- package/dist/holmes/cli/semantic-key.js +93 -0
- package/dist/holmes/config/config.d.ts +9 -0
- package/dist/holmes/config/config.js +8 -1
- package/dist/holmes/cpg/language-capability.d.ts +65 -0
- package/dist/holmes/cpg/language-capability.js +145 -0
- package/dist/holmes/cpg/language-parser-walk.js +179 -34
- package/dist/holmes/cpg/language-parser.d.ts +1 -1
- package/dist/holmes/governance/ledger-rechain.d.ts +12 -0
- package/dist/holmes/governance/ledger-rechain.js +17 -2
- package/dist/holmes/governance/provenance-ledger.js +21 -0
- package/dist/holmes/guardrail/blind-spots.js +12 -1
- package/dist/holmes/guardrail/impact-gate.d.ts +77 -0
- package/dist/holmes/guardrail/impact-gate.js +263 -0
- package/dist/holmes/guardrail/write-target.d.ts +38 -1
- package/dist/holmes/guardrail/write-target.js +41 -4
- package/dist/holmes/hooks/adapters/antigravity.js +12 -1
- package/dist/holmes/hooks/corrupt-state-run.d.ts +33 -0
- package/dist/holmes/hooks/corrupt-state-run.js +16 -0
- package/dist/holmes/hooks/pre-tool-use.d.ts +26 -0
- package/dist/holmes/hooks/pre-tool-use.js +203 -11
- package/dist/holmes/hooks/stop.d.ts +65 -0
- package/dist/holmes/hooks/stop.js +200 -2
- package/dist/holmes/mcp/elicit-approval.d.ts +32 -2
- package/dist/holmes/mcp/elicit-approval.js +30 -2
- package/dist/holmes/mcp/handlers.d.ts +88 -0
- package/dist/holmes/mcp/handlers.js +636 -35
- package/dist/holmes/mcp/maintenance-analyze.d.ts +435 -0
- package/dist/holmes/mcp/maintenance-analyze.js +994 -0
- package/dist/holmes/mcp/maintenance-evidence.d.ts +140 -0
- package/dist/holmes/mcp/maintenance-evidence.js +253 -0
- package/dist/holmes/mcp/server.js +13 -10
- package/dist/holmes/mcp/tool-schemas.js +71 -0
- package/dist/holmes/project/root.js +3 -1
- package/dist/holmes/review/baseline-arm.d.ts +37 -0
- package/dist/holmes/review/baseline-arm.js +51 -0
- package/dist/holmes/review/captured-stdin-guard.d.ts +8 -0
- package/dist/holmes/review/captured-stdin-guard.js +48 -0
- package/dist/holmes/review/coherence-verify.d.ts +31 -0
- package/dist/holmes/review/coherence-verify.js +144 -0
- package/dist/holmes/review/commit-text.d.ts +50 -0
- package/dist/holmes/review/commit-text.js +76 -0
- package/dist/holmes/review/confidence-calibration.d.ts +39 -0
- package/dist/holmes/review/confidence-calibration.js +39 -0
- package/dist/holmes/review/content-baseline.d.ts +38 -0
- package/dist/holmes/review/content-baseline.js +103 -0
- package/dist/holmes/review/content-verify.d.ts +20 -0
- package/dist/holmes/review/content-verify.js +73 -0
- package/dist/holmes/review/dense-retrieval.d.ts +66 -0
- package/dist/holmes/review/dense-retrieval.js +97 -0
- package/dist/holmes/review/edge-quality.d.ts +44 -0
- package/dist/holmes/review/edge-quality.js +117 -0
- package/dist/holmes/review/evaluation-metrics.d.ts +138 -0
- package/dist/holmes/review/evaluation-metrics.js +175 -0
- package/dist/holmes/review/graph-verifier.d.ts +34 -0
- package/dist/holmes/review/graph-verifier.js +62 -0
- package/dist/holmes/review/hop-ablation.d.ts +100 -0
- package/dist/holmes/review/hop-ablation.js +89 -0
- package/dist/holmes/review/manual-baseline.d.ts +209 -0
- package/dist/holmes/review/manual-baseline.js +2846 -0
- package/dist/holmes/review/oracle-gap.d.ts +32 -0
- package/dist/holmes/review/oracle-gap.js +102 -0
- package/dist/holmes/review/point-in-time-replay.d.ts +41 -0
- package/dist/holmes/review/point-in-time-replay.js +161 -0
- package/dist/holmes/review/rank-diagnosis.d.ts +43 -0
- package/dist/holmes/review/rank-diagnosis.js +163 -0
- package/dist/holmes/review/replay-calibration.d.ts +62 -0
- package/dist/holmes/review/replay-calibration.js +83 -0
- package/dist/holmes/review/replay-corpus.d.ts +135 -0
- package/dist/holmes/review/replay-corpus.js +210 -0
- package/dist/holmes/review/run-replay.d.ts +260 -0
- package/dist/holmes/review/run-replay.js +729 -0
- package/dist/holmes/review/semantic-arm.d.ts +271 -0
- package/dist/holmes/review/semantic-arm.js +717 -0
- package/dist/holmes/review/semantic-retrieval.d.ts +55 -0
- package/dist/holmes/review/semantic-retrieval.js +156 -0
- package/dist/holmes/review/spec-layer-stats.d.ts +38 -0
- package/dist/holmes/review/spec-layer-stats.js +52 -0
- package/dist/holmes/review/temporal-prior.d.ts +33 -0
- package/dist/holmes/review/temporal-prior.js +53 -0
- package/dist/holmes/review/test-runner.d.ts +15 -0
- package/dist/holmes/review/test-runner.js +41 -4
- package/dist/holmes/review/test-selection-breadth.d.ts +75 -0
- package/dist/holmes/review/test-selection-breadth.js +57 -0
- package/dist/holmes/review/traceability-benchmark.d.ts +81 -0
- package/dist/holmes/review/traceability-benchmark.js +135 -0
- package/dist/holmes/review/union-verify.d.ts +12 -0
- package/dist/holmes/review/union-verify.js +70 -0
- package/dist/holmes/rtm/graph-store.d.ts +51 -0
- package/dist/holmes/rtm/graph-store.js +122 -0
- package/dist/holmes/rtm/incremental.d.ts +25 -1
- package/dist/holmes/rtm/incremental.js +18 -1
- package/dist/holmes/rtm/localize.d.ts +28 -0
- package/dist/holmes/rtm/localize.js +272 -14
- package/dist/holmes/rtm/rtm-builder.d.ts +118 -3
- package/dist/holmes/rtm/rtm-builder.js +265 -28
- package/dist/holmes/rtm/rtm-graph.d.ts +117 -2
- package/dist/holmes/rtm/rtm-graph.js +194 -31
- package/dist/holmes/rtm/taint-benchmark.d.ts +97 -0
- package/dist/holmes/rtm/taint-benchmark.js +141 -0
- package/dist/holmes/rtm/test-scope.js +24 -1
- package/dist/holmes/semantic/credentials.d.ts +15 -0
- package/dist/holmes/semantic/credentials.js +134 -0
- package/dist/holmes/semantic/embedder.d.ts +44 -0
- package/dist/holmes/semantic/embedder.js +185 -0
- package/dist/holmes/semantic/hit-rerank.d.ts +4 -0
- package/dist/holmes/semantic/hit-rerank.js +38 -0
- package/dist/holmes/semantic/tier.d.ts +37 -0
- package/dist/holmes/semantic/tier.js +54 -0
- package/dist/holmes/semantic/vector-cache.d.ts +11 -0
- package/dist/holmes/semantic/vector-cache.js +91 -0
- package/dist/holmes/spec/acceptance-quality.d.ts +81 -0
- package/dist/holmes/spec/acceptance-quality.js +169 -0
- package/dist/holmes/spec/validator.js +33 -1
- package/dist/holmes/spec/yaml-scalar.d.ts +1 -0
- package/dist/holmes/spec/yaml-scalar.js +43 -0
- 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
|
-
|
|
35
|
-
|
|
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
|
-
|
|
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
|
-
|
|
52
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
87
|
-
|
|
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
|
-
|
|
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
|
-
|
|
97
|
-
|
|
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):
|
|
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):
|
|
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;
|