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