@holmes-lab/holmes-kit 0.2.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (129) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/dist/.build-id +1 -1
  3. package/dist/holmes/assoc/assoc-arm.d.ts +94 -0
  4. package/dist/holmes/assoc/assoc-arm.js +187 -0
  5. package/dist/holmes/assoc/explore.d.ts +21 -0
  6. package/dist/holmes/assoc/explore.js +160 -0
  7. package/dist/holmes/assoc/impact-baseline.d.ts +13 -0
  8. package/dist/holmes/assoc/impact-baseline.js +48 -0
  9. package/dist/holmes/assoc/ppr.d.ts +64 -0
  10. package/dist/holmes/assoc/ppr.js +110 -0
  11. package/dist/holmes/cli/agents.d.ts +24 -11
  12. package/dist/holmes/cli/agents.js +93 -21
  13. package/dist/holmes/cli/doctor.d.ts +56 -0
  14. package/dist/holmes/cli/doctor.js +311 -5
  15. package/dist/holmes/cli/index.js +13 -1
  16. package/dist/holmes/cli/semantic-key.d.ts +19 -0
  17. package/dist/holmes/cli/semantic-key.js +93 -0
  18. package/dist/holmes/config/config.d.ts +9 -0
  19. package/dist/holmes/config/config.js +8 -1
  20. package/dist/holmes/cpg/language-capability.d.ts +65 -0
  21. package/dist/holmes/cpg/language-capability.js +145 -0
  22. package/dist/holmes/cpg/language-parser-walk.js +179 -34
  23. package/dist/holmes/cpg/language-parser.d.ts +1 -1
  24. package/dist/holmes/governance/ledger-rechain.d.ts +12 -0
  25. package/dist/holmes/governance/ledger-rechain.js +17 -2
  26. package/dist/holmes/governance/provenance-ledger.js +21 -0
  27. package/dist/holmes/guardrail/blind-spots.js +12 -1
  28. package/dist/holmes/guardrail/impact-gate.d.ts +77 -0
  29. package/dist/holmes/guardrail/impact-gate.js +263 -0
  30. package/dist/holmes/guardrail/write-target.d.ts +38 -1
  31. package/dist/holmes/guardrail/write-target.js +41 -4
  32. package/dist/holmes/hooks/adapters/antigravity.js +12 -1
  33. package/dist/holmes/hooks/corrupt-state-run.d.ts +33 -0
  34. package/dist/holmes/hooks/corrupt-state-run.js +16 -0
  35. package/dist/holmes/hooks/pre-tool-use.d.ts +26 -0
  36. package/dist/holmes/hooks/pre-tool-use.js +203 -11
  37. package/dist/holmes/hooks/stop.d.ts +65 -0
  38. package/dist/holmes/hooks/stop.js +200 -2
  39. package/dist/holmes/mcp/handlers.d.ts +88 -0
  40. package/dist/holmes/mcp/handlers.js +597 -16
  41. package/dist/holmes/mcp/maintenance-analyze.d.ts +435 -0
  42. package/dist/holmes/mcp/maintenance-analyze.js +994 -0
  43. package/dist/holmes/mcp/maintenance-evidence.d.ts +140 -0
  44. package/dist/holmes/mcp/maintenance-evidence.js +253 -0
  45. package/dist/holmes/mcp/tool-schemas.js +71 -0
  46. package/dist/holmes/project/root.js +3 -1
  47. package/dist/holmes/review/baseline-arm.d.ts +37 -0
  48. package/dist/holmes/review/baseline-arm.js +51 -0
  49. package/dist/holmes/review/captured-stdin-guard.d.ts +8 -0
  50. package/dist/holmes/review/captured-stdin-guard.js +48 -0
  51. package/dist/holmes/review/coherence-verify.d.ts +31 -0
  52. package/dist/holmes/review/coherence-verify.js +144 -0
  53. package/dist/holmes/review/commit-text.d.ts +50 -0
  54. package/dist/holmes/review/commit-text.js +76 -0
  55. package/dist/holmes/review/confidence-calibration.d.ts +39 -0
  56. package/dist/holmes/review/confidence-calibration.js +39 -0
  57. package/dist/holmes/review/content-baseline.d.ts +38 -0
  58. package/dist/holmes/review/content-baseline.js +103 -0
  59. package/dist/holmes/review/content-verify.d.ts +20 -0
  60. package/dist/holmes/review/content-verify.js +73 -0
  61. package/dist/holmes/review/dense-retrieval.d.ts +66 -0
  62. package/dist/holmes/review/dense-retrieval.js +97 -0
  63. package/dist/holmes/review/edge-quality.d.ts +44 -0
  64. package/dist/holmes/review/edge-quality.js +117 -0
  65. package/dist/holmes/review/evaluation-metrics.d.ts +138 -0
  66. package/dist/holmes/review/evaluation-metrics.js +175 -0
  67. package/dist/holmes/review/graph-verifier.d.ts +34 -0
  68. package/dist/holmes/review/graph-verifier.js +62 -0
  69. package/dist/holmes/review/hop-ablation.d.ts +100 -0
  70. package/dist/holmes/review/hop-ablation.js +89 -0
  71. package/dist/holmes/review/manual-baseline.d.ts +209 -0
  72. package/dist/holmes/review/manual-baseline.js +2846 -0
  73. package/dist/holmes/review/oracle-gap.d.ts +32 -0
  74. package/dist/holmes/review/oracle-gap.js +102 -0
  75. package/dist/holmes/review/point-in-time-replay.d.ts +41 -0
  76. package/dist/holmes/review/point-in-time-replay.js +161 -0
  77. package/dist/holmes/review/rank-diagnosis.d.ts +43 -0
  78. package/dist/holmes/review/rank-diagnosis.js +163 -0
  79. package/dist/holmes/review/replay-calibration.d.ts +62 -0
  80. package/dist/holmes/review/replay-calibration.js +83 -0
  81. package/dist/holmes/review/replay-corpus.d.ts +135 -0
  82. package/dist/holmes/review/replay-corpus.js +210 -0
  83. package/dist/holmes/review/run-replay.d.ts +260 -0
  84. package/dist/holmes/review/run-replay.js +729 -0
  85. package/dist/holmes/review/semantic-arm.d.ts +271 -0
  86. package/dist/holmes/review/semantic-arm.js +717 -0
  87. package/dist/holmes/review/semantic-retrieval.d.ts +55 -0
  88. package/dist/holmes/review/semantic-retrieval.js +156 -0
  89. package/dist/holmes/review/spec-layer-stats.d.ts +38 -0
  90. package/dist/holmes/review/spec-layer-stats.js +52 -0
  91. package/dist/holmes/review/temporal-prior.d.ts +33 -0
  92. package/dist/holmes/review/temporal-prior.js +53 -0
  93. package/dist/holmes/review/test-runner.d.ts +15 -0
  94. package/dist/holmes/review/test-runner.js +41 -4
  95. package/dist/holmes/review/test-selection-breadth.d.ts +75 -0
  96. package/dist/holmes/review/test-selection-breadth.js +57 -0
  97. package/dist/holmes/review/traceability-benchmark.d.ts +81 -0
  98. package/dist/holmes/review/traceability-benchmark.js +135 -0
  99. package/dist/holmes/review/union-verify.d.ts +12 -0
  100. package/dist/holmes/review/union-verify.js +70 -0
  101. package/dist/holmes/rtm/graph-store.d.ts +51 -0
  102. package/dist/holmes/rtm/graph-store.js +122 -0
  103. package/dist/holmes/rtm/incremental.d.ts +25 -1
  104. package/dist/holmes/rtm/incremental.js +18 -1
  105. package/dist/holmes/rtm/localize.d.ts +28 -0
  106. package/dist/holmes/rtm/localize.js +272 -14
  107. package/dist/holmes/rtm/rtm-builder.d.ts +118 -3
  108. package/dist/holmes/rtm/rtm-builder.js +265 -28
  109. package/dist/holmes/rtm/rtm-graph.d.ts +117 -2
  110. package/dist/holmes/rtm/rtm-graph.js +194 -31
  111. package/dist/holmes/rtm/taint-benchmark.d.ts +97 -0
  112. package/dist/holmes/rtm/taint-benchmark.js +141 -0
  113. package/dist/holmes/rtm/test-scope.js +24 -1
  114. package/dist/holmes/semantic/credentials.d.ts +15 -0
  115. package/dist/holmes/semantic/credentials.js +134 -0
  116. package/dist/holmes/semantic/embedder.d.ts +44 -0
  117. package/dist/holmes/semantic/embedder.js +185 -0
  118. package/dist/holmes/semantic/hit-rerank.d.ts +4 -0
  119. package/dist/holmes/semantic/hit-rerank.js +38 -0
  120. package/dist/holmes/semantic/tier.d.ts +37 -0
  121. package/dist/holmes/semantic/tier.js +54 -0
  122. package/dist/holmes/semantic/vector-cache.d.ts +11 -0
  123. package/dist/holmes/semantic/vector-cache.js +91 -0
  124. package/dist/holmes/spec/acceptance-quality.d.ts +81 -0
  125. package/dist/holmes/spec/acceptance-quality.js +169 -0
  126. package/dist/holmes/spec/validator.js +33 -1
  127. package/dist/holmes/spec/yaml-scalar.d.ts +1 -0
  128. package/dist/holmes/spec/yaml-scalar.js +43 -0
  129. package/package.json +1 -1
@@ -0,0 +1,55 @@
1
+ import type { Spec } from '../spec/spec-parser';
2
+ /**
3
+ * One token, reduced to the form two spellings of the same word share.
4
+ *
5
+ * Suffix stripping is conservative and applied once — an aggressive stemmer merges words that are
6
+ * genuinely different (`parse`/`parsing` should merge, `general`/`generate` must not), and a wrong
7
+ * merge is a false positive nobody can see in the output.
8
+ */
9
+ export declare function normaliseTerm(term: string): string;
10
+ export declare const normalisedTerms: (text: string) => string[];
11
+ export interface RankedSpec {
12
+ id: string;
13
+ score: number;
14
+ }
15
+ /**
16
+ * BM25 over A-SPEC documents.
17
+ *
18
+ * `k1` bounds how much repetition can add and `b` controls the length correction — the standard
19
+ * 1.2/0.75, not tuned here, because tuning two constants on sixty cases fits noise and this arm has
20
+ * to answer a yes-or-no question about the idea rather than squeeze the last point out of it.
21
+ *
22
+ * A-SPEC only, as everywhere else: files anchor A-SPECs, so no other kind can pull a file through.
23
+ */
24
+ export declare function retrieveSpecs(issueText: string, specs: readonly Spec[], topK: number): RankedSpec[];
25
+ /**
26
+ * @implements A-SPEC-380
27
+ * The query, expanded with the vocabulary of the specs that the first search's best files anchor.
28
+ *
29
+ * Six mechanisms have been measured against uncited requests and none of them used the graph
30
+ * ACTIVELY: every one added a graph-derived score to a single retrieval pass. This is the other
31
+ * structure — retrieve, traverse, expand, retrieve again — which information retrieval calls
32
+ * pseudo-relevance feedback, and it is the only principled way a graph can close the gap between the
33
+ * words a reporter writes and the names the code uses. A bug report says "uploads are being rejected";
34
+ * the spec those files anchor says "quarantine of malformed payloads"; the code is named
35
+ * `sanitizeIngest`. No amount of matching the request against the code bridges that. Going through
36
+ * the spec does.
37
+ *
38
+ * Expansion terms are weighted BELOW the original query, and the reason is the failure mode: if the
39
+ * first pass ranked the wrong file, its spec's vocabulary is the wrong vocabulary, and an
40
+ * over-weighted expansion turns one mistake into a whole ranking of them.
41
+ */
42
+ export interface ExpandedQuery {
43
+ /** Original request terms, normalised. */
44
+ base: string[];
45
+ /** Terms contributed by the specs reached through the top files, with their weight. */
46
+ expansion: Map<string, number>;
47
+ }
48
+ export declare function expandThroughGraph(issueText: string, topFiles: readonly {
49
+ file: string;
50
+ anchors: readonly string[];
51
+ }[], specs: readonly Spec[], opts: {
52
+ seedFiles: number;
53
+ termsPerSpec: number;
54
+ weight: number;
55
+ }): ExpandedQuery;
@@ -0,0 +1,156 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.normalisedTerms = void 0;
4
+ exports.normaliseTerm = normaliseTerm;
5
+ exports.retrieveSpecs = retrieveSpecs;
6
+ exports.expandThroughGraph = expandThroughGraph;
7
+ const localize_1 = require("../rtm/localize");
8
+ /**
9
+ * Ranked retrieval of specs for a request, instead of counting exact token matches.
10
+ *
11
+ * Five mechanisms have been measured against uncited requests and all five were negative — and all
12
+ * five were about TRAVERSAL: which neighbours to follow, how much bonus to pay, when to stop. The
13
+ * step none of them touched is the one that decides whether the request and a spec are about the
14
+ * same thing at all, and that step is still exact string equality over a bag of words.
15
+ *
16
+ * So `quarantine` and `quarantining` are different subjects, `config` and `cfg` are different
17
+ * subjects, and a two-line spec is compared to a request on the same footing as a forty-line one.
18
+ * That is string matching wearing the word "semantic".
19
+ *
20
+ * What replaces it here is the strongest thing available with no model and no network: BM25 with
21
+ * morphological normalisation. It is not embeddings, and calling it semantic search would overstate
22
+ * it — but it does handle inflection, the abbreviations this corpus actually uses, and document
23
+ * length, none of which the previous rule could.
24
+ */
25
+ /**
26
+ * Abbreviations that genuinely occur in these corpora, checked rather than imagined.
27
+ *
28
+ * Deliberately short: every entry is a claim that two different words mean the same thing, and a
29
+ * wrong one silently merges unrelated subjects. Anything not observed here stays out.
30
+ */
31
+ const ABBREVIATIONS = new Map(Object.entries({
32
+ cfg: 'config', config: 'config', configuration: 'config',
33
+ auth: 'auth', authentication: 'auth', authorization: 'auth',
34
+ msg: 'message', message: 'message',
35
+ req: 'request', request: 'request', requirement: 'requirement',
36
+ res: 'response', resp: 'response', response: 'response',
37
+ err: 'error', error: 'error',
38
+ init: 'init', initialize: 'init', initialise: 'init', initialization: 'init',
39
+ impl: 'impl', implement: 'impl', implementation: 'impl', implements: 'impl',
40
+ spec: 'spec', specification: 'spec',
41
+ doc: 'doc', docs: 'doc', documentation: 'doc',
42
+ repo: 'repo', repository: 'repo',
43
+ db: 'db', database: 'db',
44
+ }));
45
+ /**
46
+ * One token, reduced to the form two spellings of the same word share.
47
+ *
48
+ * Suffix stripping is conservative and applied once — an aggressive stemmer merges words that are
49
+ * genuinely different (`parse`/`parsing` should merge, `general`/`generate` must not), and a wrong
50
+ * merge is a false positive nobody can see in the output.
51
+ */
52
+ function normaliseTerm(term) {
53
+ const abbreviated = ABBREVIATIONS.get(term);
54
+ if (abbreviated)
55
+ return abbreviated;
56
+ let t = term;
57
+ // `ers` is deliberately absent: it would take `handlers` to `handl` while `handler` stays whole,
58
+ // splitting a word from its own plural — the opposite of what normalisation is for. Plain `s`
59
+ // handles that pair correctly.
60
+ for (const [suffix, min] of [['ings', 6], ['ing', 5], ['ies', 5], ['ed', 4], ['es', 4], ['s', 4]]) {
61
+ if (t.length >= min && t.endsWith(suffix)) {
62
+ t = t.slice(0, -suffix.length);
63
+ break;
64
+ }
65
+ }
66
+ if (t.endsWith('i'))
67
+ t = `${t.slice(0, -1)}y`; // `ies` -> `i` -> `y`
68
+ // `quarantine` and `quarantining` must land on the same stem: stripping `ing` leaves `quarantin`
69
+ // while the base form keeps its silent `e`, so the `e` goes too. Length-guarded, or `file` and
70
+ // `code` would be mangled into stems that collide with unrelated short words.
71
+ if (t.length >= 5 && t.endsWith('e'))
72
+ t = t.slice(0, -1);
73
+ return ABBREVIATIONS.get(t) ?? t;
74
+ }
75
+ const normalisedTerms = (text) => (0, localize_1.significantTerms)(text).map(normaliseTerm);
76
+ exports.normalisedTerms = normalisedTerms;
77
+ /**
78
+ * BM25 over A-SPEC documents.
79
+ *
80
+ * `k1` bounds how much repetition can add and `b` controls the length correction — the standard
81
+ * 1.2/0.75, not tuned here, because tuning two constants on sixty cases fits noise and this arm has
82
+ * to answer a yes-or-no question about the idea rather than squeeze the last point out of it.
83
+ *
84
+ * A-SPEC only, as everywhere else: files anchor A-SPECs, so no other kind can pull a file through.
85
+ */
86
+ function retrieveSpecs(issueText, specs, topK) {
87
+ const query = [...new Set((0, exports.normalisedTerms)(issueText))];
88
+ if (query.length === 0)
89
+ return [];
90
+ const docs = specs
91
+ .filter((s) => s.type === 'A-SPEC')
92
+ .map((s) => ({ id: s.id, terms: (0, exports.normalisedTerms)(`${s.title} ${Object.values(s.sections).join(' ')}`) }));
93
+ if (docs.length === 0)
94
+ return [];
95
+ const avgLen = docs.reduce((sum, d) => sum + d.terms.length, 0) / docs.length;
96
+ const df = new Map();
97
+ for (const d of docs)
98
+ for (const t of new Set(d.terms))
99
+ df.set(t, (df.get(t) ?? 0) + 1);
100
+ const k1 = 1.2, b = 0.75;
101
+ const scored = [];
102
+ for (const d of docs) {
103
+ const freq = new Map();
104
+ for (const t of d.terms)
105
+ freq.set(t, (freq.get(t) ?? 0) + 1);
106
+ let score = 0;
107
+ for (const q of query) {
108
+ const f = freq.get(q) ?? 0;
109
+ if (f === 0)
110
+ continue;
111
+ const n = df.get(q) ?? 0;
112
+ // Standard BM25 IDF, floored: a term in every document contributes nothing rather than
113
+ // subtracting, which would make a common word count AGAINST a spec that contains it.
114
+ const idf = Math.max(Math.log(1 + (docs.length - n + 0.5) / (n + 0.5)), 0);
115
+ score += idf * (f * (k1 + 1)) / (f + k1 * (1 - b + b * (d.terms.length / (avgLen || 1))));
116
+ }
117
+ if (score > 0)
118
+ scored.push({ id: d.id, score });
119
+ }
120
+ return scored.sort((a, b2) => b2.score - a.score || a.id.localeCompare(b2.id)).slice(0, topK);
121
+ }
122
+ function expandThroughGraph(issueText, topFiles, specs, opts) {
123
+ const base = [...new Set((0, exports.normalisedTerms)(issueText))];
124
+ const expansion = new Map();
125
+ if (opts.weight <= 0 || opts.seedFiles <= 0)
126
+ return { base, expansion };
127
+ const byId = new Map(specs.filter((s) => s.type === 'A-SPEC').map((s) => [s.id, s]));
128
+ const reached = new Set();
129
+ for (const f of topFiles.slice(0, opts.seedFiles))
130
+ for (const id of f.anchors)
131
+ if (byId.has(id))
132
+ reached.add(id);
133
+ if (reached.size === 0)
134
+ return { base, expansion };
135
+ // Rank the spec's own vocabulary by how distinctive it is within the reached set, so expansion
136
+ // contributes the words that make those specs different rather than the words every spec uses.
137
+ const df = new Map();
138
+ const perSpec = [];
139
+ for (const id of reached) {
140
+ const s = byId.get(id);
141
+ const terms = [...new Set((0, exports.normalisedTerms)(`${s.title} ${Object.values(s.sections).join(' ')}`))];
142
+ perSpec.push(terms);
143
+ for (const t of terms)
144
+ df.set(t, (df.get(t) ?? 0) + 1);
145
+ }
146
+ const baseSet = new Set(base);
147
+ for (const terms of perSpec) {
148
+ const ranked = terms
149
+ .filter((t) => !baseSet.has(t))
150
+ .sort((a, b) => (df.get(a) ?? 0) - (df.get(b) ?? 0) || a.localeCompare(b))
151
+ .slice(0, opts.termsPerSpec);
152
+ for (const t of ranked)
153
+ expansion.set(t, Math.max(expansion.get(t) ?? 0, opts.weight));
154
+ }
155
+ return { base, expansion };
156
+ }
@@ -0,0 +1,38 @@
1
+ import type { ScannedFile } from '../cpg/cpg-scanner';
2
+ import type { Spec } from '../spec/spec-parser';
3
+ import type { LocalizationReport } from '../rtm/localize';
4
+ /**
5
+ * What the spec layer is actually doing on a corpus.
6
+ *
7
+ * All of the harness's measured advantage lives in this layer (A-SPEC-362), and on the second corpus
8
+ * it delivers nothing — near-parity with plain word matching, ahead on two axes and behind on two.
9
+ * The density explanation was tested and retracted: that corpus resolves an anchor for every scanned
10
+ * file. So the layer is present, connected, and not paying, and the next honest step is to count what
11
+ * it does rather than to guess again.
12
+ *
13
+ * Every field is a count or a share, deliberately: a single "spec layer health" score would hide
14
+ * which of these differs, and it is the difference between corpora that carries the answer.
15
+ */
16
+ export interface SpecLayerStats {
17
+ cases: number;
18
+ /** A-SPECs the request was taken to match, per case. */
19
+ matchedSpecs: number;
20
+ /** Files that received a spec bonus, per case. */
21
+ filesViaSpec: number;
22
+ /** Of those, the share that the commit actually changed — the layer's own precision. */
23
+ viaSpecPrecision: number | null;
24
+ /** Truth files reached ONLY through a spec — what the layer uniquely contributes. */
25
+ truthOnlyViaSpec: number;
26
+ /** Files anchoring each spec, and specs anchored by each file: the shape of the mapping. */
27
+ filesPerSpec: number | null;
28
+ specsPerFile: number | null;
29
+ /** Share of cases whose request text cited a spec outright — the strongest signal available. */
30
+ citingCases: number | null;
31
+ }
32
+ export interface CaseObservation {
33
+ report: LocalizationReport;
34
+ scanned: readonly ScannedFile[];
35
+ specs: readonly Spec[];
36
+ truthFiles: readonly string[];
37
+ }
38
+ export declare function specLayerStats(observations: readonly CaseObservation[]): SpecLayerStats;
@@ -0,0 +1,52 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.specLayerStats = specLayerStats;
4
+ function specLayerStats(observations) {
5
+ if (observations.length === 0) {
6
+ return { cases: 0, matchedSpecs: 0, filesViaSpec: 0, viaSpecPrecision: null,
7
+ truthOnlyViaSpec: 0, filesPerSpec: null, specsPerFile: null, citingCases: null };
8
+ }
9
+ let matched = 0, viaFiles = 0, viaHits = 0, onlyVia = 0, citing = 0;
10
+ let fps = 0, spf = 0, shapeSamples = 0;
11
+ for (const o of observations) {
12
+ matched += o.report.matchedSpecs.length;
13
+ const truth = new Set(o.truthFiles);
14
+ const via = o.report.hits.filter((h) => h.viaSpecs.length > 0);
15
+ viaFiles += via.length;
16
+ viaHits += via.filter((h) => truth.has(h.file)).length;
17
+ // Reached only through a spec: no symbol matched and no path term did, so the lexical layer
18
+ // could not have produced it at any weighting.
19
+ onlyVia += via.filter((h) => truth.has(h.file) && h.matchedSymbols.length === 0).length;
20
+ if (o.report.hits.some((h) => h.why.some((w) => w.startsWith('request cites'))))
21
+ citing++;
22
+ const known = new Set(o.specs.map((s) => s.id));
23
+ const perSpec = new Map();
24
+ let anchorsTotal = 0, anchoredFiles = 0;
25
+ for (const f of o.scanned) {
26
+ const anchors = [...new Set(f.implementsSpecs ?? [])].filter((id) => known.has(id));
27
+ if (anchors.length === 0)
28
+ continue;
29
+ anchoredFiles++;
30
+ anchorsTotal += anchors.length;
31
+ for (const id of anchors)
32
+ perSpec.set(id, (perSpec.get(id) ?? 0) + 1);
33
+ }
34
+ if (anchoredFiles > 0) {
35
+ fps += [...perSpec.values()].reduce((a, b) => a + b, 0) / Math.max(1, perSpec.size);
36
+ spf += anchorsTotal / anchoredFiles;
37
+ shapeSamples++;
38
+ }
39
+ }
40
+ const n = observations.length;
41
+ return {
42
+ cases: n,
43
+ matchedSpecs: matched / n,
44
+ filesViaSpec: viaFiles / n,
45
+ // null, not zero: if the layer scored no file at all there is no precision to report.
46
+ viaSpecPrecision: viaFiles === 0 ? null : viaHits / viaFiles,
47
+ truthOnlyViaSpec: onlyVia / n,
48
+ filesPerSpec: shapeSamples === 0 ? null : fps / shapeSamples,
49
+ specsPerFile: shapeSamples === 0 ? null : spf / shapeSamples,
50
+ citingCases: citing / n,
51
+ };
52
+ }
@@ -0,0 +1,33 @@
1
+ /**
2
+ * How recently and how often each file changed, as of a point in time.
3
+ *
4
+ * Every graph mechanism tried against uncited requests has been a spec-graph mechanism, and all five
5
+ * were negative. The Goal's Phase 3 names a second structure that has never been used for retrieval
6
+ * at all: the temporal graph — Commit nodes and `changed_by` edges. A file that changed recently and
7
+ * often is more likely to change again, which is one of the oldest findings in fault localization,
8
+ * and unlike everything tried so far it does not depend on the request naming anything.
9
+ *
10
+ * POINT-IN-TIME is the whole discipline here. Reading the answer commit, or anything after it, is
11
+ * reading the answer: the file the commit touched will look "recently changed" because it is about
12
+ * to be. The window ends at the parent, exactly as the rest of the replay does.
13
+ */
14
+ export interface TemporalPrior {
15
+ /** Commits, within the window, that touched each file. */
16
+ frequency: Map<string, number>;
17
+ /** How many commits back the file was last touched; absent when it never was. */
18
+ recency: Map<string, number>;
19
+ /** Commits actually examined — 0 means the prior is UNMEASURED, not that nothing changed. */
20
+ commits: number;
21
+ }
22
+ export declare function temporalPrior(root: string, before: string, limit?: number): TemporalPrior;
23
+ /**
24
+ * The multiplier a file's history earns.
25
+ *
26
+ * Multiplicative rather than additive: the prior says which of several plausible files is likelier,
27
+ * and it must not promote a file the request has no lexical connection to at all. A file with no
28
+ * history in the window scores 1 — unknown is not evidence against.
29
+ *
30
+ * `halfLife` is in commits. Recency decays geometrically; frequency enters through a log so that a
31
+ * file touched fifty times is not fifty times likelier than one touched once.
32
+ */
33
+ export declare function priorFor(prior: TemporalPrior, file: string, halfLife: number, freqWeight: number): number;
@@ -0,0 +1,53 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.temporalPrior = temporalPrior;
4
+ exports.priorFor = priorFor;
5
+ // @implements A-SPEC-377
6
+ const node_child_process_1 = require("node:child_process");
7
+ const maintenance_analyze_1 = require("../mcp/maintenance-analyze");
8
+ function temporalPrior(root, before, limit = 200) {
9
+ let raw;
10
+ try {
11
+ raw = (0, node_child_process_1.execFileSync)('git', ['-C', root, 'log', '--no-merges', `-${limit}`, '--format=%H', '--name-only', before], { encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 });
12
+ }
13
+ catch {
14
+ // A repository or revision this machine does not have is unmeasured, not empty.
15
+ return { frequency: new Map(), recency: new Map(), commits: 0 };
16
+ }
17
+ const frequency = new Map();
18
+ const recency = new Map();
19
+ let index = -1;
20
+ for (const line of raw.split('\n')) {
21
+ const t = line.trim();
22
+ if (t.length === 0)
23
+ continue;
24
+ if (/^[0-9a-f]{40}$/.test(t)) {
25
+ index++;
26
+ continue;
27
+ }
28
+ const file = (0, maintenance_analyze_1.unquoteGitPath)(t);
29
+ frequency.set(file, (frequency.get(file) ?? 0) + 1);
30
+ if (!recency.has(file))
31
+ recency.set(file, index); // first sighting is the most recent commit
32
+ }
33
+ return { frequency, recency, commits: index + 1 };
34
+ }
35
+ /**
36
+ * The multiplier a file's history earns.
37
+ *
38
+ * Multiplicative rather than additive: the prior says which of several plausible files is likelier,
39
+ * and it must not promote a file the request has no lexical connection to at all. A file with no
40
+ * history in the window scores 1 — unknown is not evidence against.
41
+ *
42
+ * `halfLife` is in commits. Recency decays geometrically; frequency enters through a log so that a
43
+ * file touched fifty times is not fifty times likelier than one touched once.
44
+ */
45
+ function priorFor(prior, file, halfLife, freqWeight) {
46
+ if (prior.commits === 0)
47
+ return 1;
48
+ const age = prior.recency.get(file);
49
+ const freq = prior.frequency.get(file) ?? 0;
50
+ const recencyTerm = age === undefined ? 0 : Math.pow(0.5, age / halfLife);
51
+ const freqTerm = Math.log1p(freq) / Math.log1p(10);
52
+ return 1 + recencyTerm + freqWeight * freqTerm;
53
+ }
@@ -60,6 +60,21 @@ export declare function parseExecutedCounts(stdout: string, cwd: string): Record
60
60
  * pending/skipped assertions are excluded. Errors and failures DO count: they executed.
61
61
  */
62
62
  export declare function parseJUnitXmlCounts(xml: string, ext?: string): Record<string, number>;
63
+ /**
64
+ * @implements A-SPEC-466 — W-4. The TARGET repo's own jest entry, as its package declares it.
65
+ *
66
+ * Windows npm/npx are `.cmd` shims: execFileSync('npx') is ENOENT and naming the .cmd is EINVAL
67
+ * (Node's batch-file mitigation), so `test_run` could never produce ART-4 execution evidence there
68
+ * — measured by the Windows review with a suite that passes standalone. Running the resolved JS
69
+ * entry with THIS node binary (process.execPath) removes the shim from the path entirely.
70
+ *
71
+ * `require.resolve('jest/bin/jest.js')` is blocked by jest's `exports` (measured:
72
+ * ERR_PACKAGE_PATH_NOT_EXPORTED), so the package.json is resolved instead and its own `bin`
73
+ * declaration is read — the package names its entry, this code does not guess it. Any failure
74
+ * returns null and the caller keeps the npx spelling: POSIX behavior is unchanged (no .cmd problem
75
+ * there), and a repo with no local jest could never yield meaningful suite evidence anyway.
76
+ */
77
+ export declare function jestEntry(cwd: string): string | null;
63
78
  /**
64
79
  * @implements A-SPEC-137.2
65
80
  * Executed-test count from `cargo test` text output. cargo prints one `test <name> ... ok|FAILED|
@@ -38,6 +38,7 @@ exports.ecosystemOf = ecosystemOf;
38
38
  exports.parseGoTestJson = parseGoTestJson;
39
39
  exports.parseExecutedCounts = parseExecutedCounts;
40
40
  exports.parseJUnitXmlCounts = parseJUnitXmlCounts;
41
+ exports.jestEntry = jestEntry;
41
42
  exports.parseCargoTest = parseCargoTest;
42
43
  exports.runCargo = runCargo;
43
44
  exports.runGradle = runGradle;
@@ -75,7 +76,11 @@ function ecosystemOf(file) {
75
76
  // @implements A-SPEC-137.2 — Rust test files, matching the scanner's isTestFile convention:
76
77
  // tests/<x>.rs (integration), <x>_test(s).rs, <x>_unittest.rs, test_<x>.rs. A plain src/x.rs may
77
78
  // carry inline #[cfg(test)] unit tests but is a production source, not a selected test file.
78
- if (/(^|\/)(tests\/[^/]*|[^/]*_(tests?|unittest)|test_[^/]*)\.rs$/i.test(file))
79
+ // @implements A-SPEC-466 — both separators. The Windows review (W-4 section) measured
80
+ // ecosystemOf('tests\it.rs') = null: this was the only ecosystem whose pattern names a DIRECTORY,
81
+ // and the directory separator was spelled '/' only. java/dotnet were unaffected — their `[^/]*`
82
+ // happens to accept a backslash — so only this line changes.
83
+ if (/(^|[\\/])(tests[\\/][^\\/]*|[^\\/]*_(tests?|unittest)|test_[^\\/]*)\.rs$/i.test(file))
79
84
  return 'rust';
80
85
  // @implements A-SPEC-137.3 — JUnit/xUnit naming (case-sensitive `Test` so Manifest.java is not a
81
86
  // test): Java `…Test(s|Case|Cases).java`, C# `…Test(s).cs`.
@@ -181,6 +186,34 @@ function parseJUnitXmlCounts(xml, ext = 'py') {
181
186
  }
182
187
  return out;
183
188
  }
189
+ /**
190
+ * @implements A-SPEC-466 — W-4. The TARGET repo's own jest entry, as its package declares it.
191
+ *
192
+ * Windows npm/npx are `.cmd` shims: execFileSync('npx') is ENOENT and naming the .cmd is EINVAL
193
+ * (Node's batch-file mitigation), so `test_run` could never produce ART-4 execution evidence there
194
+ * — measured by the Windows review with a suite that passes standalone. Running the resolved JS
195
+ * entry with THIS node binary (process.execPath) removes the shim from the path entirely.
196
+ *
197
+ * `require.resolve('jest/bin/jest.js')` is blocked by jest's `exports` (measured:
198
+ * ERR_PACKAGE_PATH_NOT_EXPORTED), so the package.json is resolved instead and its own `bin`
199
+ * declaration is read — the package names its entry, this code does not guess it. Any failure
200
+ * returns null and the caller keeps the npx spelling: POSIX behavior is unchanged (no .cmd problem
201
+ * there), and a repo with no local jest could never yield meaningful suite evidence anyway.
202
+ */
203
+ function jestEntry(cwd) {
204
+ try {
205
+ const pkgPath = require.resolve('jest/package.json', { paths: [cwd] });
206
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
207
+ const bin = typeof pkg.bin === 'string' ? pkg.bin : pkg.bin?.jest;
208
+ if (!bin)
209
+ return null;
210
+ const entry = path.join(path.dirname(pkgPath), bin);
211
+ return fs.existsSync(entry) ? entry : null;
212
+ }
213
+ catch {
214
+ return null;
215
+ }
216
+ }
184
217
  /** Run the jest half of a plan. */
185
218
  function runJest(files, mode, cwd) {
186
219
  // --runTestsByPath treats args as EXACT files (not testPathPattern REGEXes, which would over/mis-match
@@ -190,11 +223,15 @@ function runJest(files, mode, cwd) {
190
223
  // nothing has verified nothing. `full` mode runs the whole suite.
191
224
  const safeFiles = files.map((p) => (p.startsWith('-') ? `./${p}` : p));
192
225
  // --json yields per-file assertion results = EXECUTION evidence (see parseExecutedCounts).
226
+ // @implements A-SPEC-466 — resolved local jest via process.execPath when the target repo has one
227
+ // (structurally no .cmd shim to ENOENT on); the npx spelling only as the fallback.
228
+ const entry = jestEntry(cwd);
229
+ const [cmd, ...pre] = entry !== null ? [process.execPath, entry] : ['npx', 'jest'];
193
230
  const args = mode === 'scoped'
194
- ? ['jest', '--silent', '--json', '--runTestsByPath', '--', ...safeFiles]
195
- : ['jest', '--silent', '--json'];
231
+ ? [...pre, '--silent', '--json', '--runTestsByPath', '--', ...safeFiles]
232
+ : [...pre, '--silent', '--json'];
196
233
  try {
197
- const out = (0, node_child_process_1.execFileSync)('npx', args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
234
+ const out = (0, node_child_process_1.execFileSync)(cmd, args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
198
235
  return { passed: true, tail: tailOf(out), executed: parseExecutedCounts(out, cwd) };
199
236
  }
200
237
  catch (e) {
@@ -0,0 +1,75 @@
1
+ /**
2
+ * What the harness's test-selection recall is actually made of.
3
+ *
4
+ * The replay reports `testRecall` — 0.883 on this repository, 0.867 on the second — and this
5
+ * document has quoted those numbers repeatedly. What was never measured is HOW they are reached.
6
+ *
7
+ * They are reached by selecting most of the suite. A commit changes 2.2 test files on average here
8
+ * and 1.2 there; the harness names 131.0 and 89.9, which is 63.6% and 81.3% of every anchored test.
9
+ * Precision is 0.015 and 0.012. A recall figure computed over a selection that size is not evidence
10
+ * of discrimination — "run almost everything" scores well on recall by construction, and the whole
11
+ * value of intelligent test selection is running FEWER tests without losing the signal.
12
+ *
13
+ * For contrast, the crudest rule a person would use — run the test whose filename contains the
14
+ * changed file's stem — names 6.6 and 2.6 tests and still catches 49 of 67 and 14 of 36 answers.
15
+ * Lower recall, twenty to thirty times fewer tests.
16
+ *
17
+ * The selection is NOT narrowed here. Narrowing costs recall, where the right trade sits needs more
18
+ * measurement than this slice has, and tightening a gate on a hunch is a mistake this repository has
19
+ * already had to undo more than once. What this slice fixes is the reading: `testRecall` quoted
20
+ * alone reads as discrimination, and it is not.
21
+ */
22
+ export interface TestSelectionSlice {
23
+ corpus: string;
24
+ cases: number;
25
+ /** Tests the harness named, averaged over cases. */
26
+ avgSelected: number;
27
+ /** Anchored tests that existed to choose from. */
28
+ avgUniverse: number;
29
+ /** Tests the commit actually changed. */
30
+ avgTruth: number;
31
+ recall: number;
32
+ precision: number;
33
+ /** The filename-stem rule, for contrast. */
34
+ naiveAvgSelected: number;
35
+ naiveHits: number;
36
+ truthTotal: number;
37
+ }
38
+ /** Measured 2026-08-29 through analyzeMaintenance on the newest 30 cases of each corpus. */
39
+ export declare const TEST_SELECTION: readonly TestSelectionSlice[];
40
+ /** How much of the available suite the selection covers. Near 1 means recall is uninformative. */
41
+ export declare function selectionShare(s: TestSelectionSlice): number;
42
+ /**
43
+ * Recall bought per test selected — the figure that separates discrimination from breadth. Selecting
44
+ * the whole suite drives this to its floor while `recall` reads 1.0.
45
+ */
46
+ export declare function recallPerSelectedTest(s: TestSelectionSlice): number;
47
+ /**
48
+ * How far the selection could be narrowed — asked because A-SPEC-424 left it open.
49
+ *
50
+ * The harness's own selection, reordered by how well each test's filename matches the stems of the
51
+ * ranked candidate files, then cut at K. On this repository that ordering works: twenty tests
52
+ * instead of a hundred and thirty-one keep 89% of the recall, forty keep 94%. On the second corpus
53
+ * the same ordering does almost nothing until eighty.
54
+ *
55
+ * So the headroom is real and the cut is not safe. Introducing K=40 would cost 0.05 of recall here
56
+ * and 0.53 there. Recorded as both facts at once, because either one alone is misleading.
57
+ */
58
+ export interface NarrowingCurve {
59
+ corpus: string;
60
+ avgSelected: number;
61
+ /** recall at each K, and `full` when nothing is cut. */
62
+ at: ReadonlyArray<{
63
+ k: number;
64
+ recall: number;
65
+ }>;
66
+ full: number;
67
+ }
68
+ /** Measured 2026-08-29, newest 30 cases per corpus, through analyzeMaintenance. */
69
+ export declare const NARROWING_CURVE: readonly NarrowingCurve[];
70
+ export declare function recallAt(curve: NarrowingCurve, k: number): number;
71
+ /**
72
+ * Whether cutting at K keeps most of the recall on EVERY corpus measured. One corpus agreeing is
73
+ * not evidence — that is the shape of mistake this repository keeps having to undo.
74
+ */
75
+ export declare function narrowingIsSafe(curves: readonly NarrowingCurve[], k: number, keep?: number): boolean;
@@ -0,0 +1,57 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.NARROWING_CURVE = exports.TEST_SELECTION = void 0;
4
+ exports.selectionShare = selectionShare;
5
+ exports.recallPerSelectedTest = recallPerSelectedTest;
6
+ exports.recallAt = recallAt;
7
+ exports.narrowingIsSafe = narrowingIsSafe;
8
+ /** Measured 2026-08-29 through analyzeMaintenance on the newest 30 cases of each corpus. */
9
+ exports.TEST_SELECTION = [
10
+ {
11
+ corpus: 'holmes newest-30', cases: 30,
12
+ avgSelected: 131.0, avgUniverse: 206.1, avgTruth: 2.2,
13
+ recall: 0.883, precision: 0.015,
14
+ naiveAvgSelected: 6.6, naiveHits: 49, truthTotal: 67,
15
+ },
16
+ {
17
+ corpus: 'jarvis newest-30', cases: 30,
18
+ avgSelected: 89.9, avgUniverse: 110.6, avgTruth: 1.2,
19
+ recall: 0.867, precision: 0.012,
20
+ naiveAvgSelected: 2.6, naiveHits: 14, truthTotal: 36,
21
+ },
22
+ ];
23
+ /** How much of the available suite the selection covers. Near 1 means recall is uninformative. */
24
+ function selectionShare(s) {
25
+ return s.avgUniverse === 0 ? 0 : s.avgSelected / s.avgUniverse;
26
+ }
27
+ /**
28
+ * Recall bought per test selected — the figure that separates discrimination from breadth. Selecting
29
+ * the whole suite drives this to its floor while `recall` reads 1.0.
30
+ */
31
+ function recallPerSelectedTest(s) {
32
+ return s.avgSelected === 0 ? 0 : s.recall / s.avgSelected;
33
+ }
34
+ /** Measured 2026-08-29, newest 30 cases per corpus, through analyzeMaintenance. */
35
+ exports.NARROWING_CURVE = [
36
+ {
37
+ corpus: 'holmes newest-30', avgSelected: 131.0, full: 0.883,
38
+ at: [{ k: 1, recall: 0.318 }, { k: 3, recall: 0.434 }, { k: 5, recall: 0.451 },
39
+ { k: 10, recall: 0.668 }, { k: 20, recall: 0.789 }, { k: 40, recall: 0.833 }, { k: 80, recall: 0.883 }],
40
+ },
41
+ {
42
+ corpus: 'jarvis newest-30', avgSelected: 89.9, full: 0.867,
43
+ at: [{ k: 1, recall: 0.067 }, { k: 3, recall: 0.150 }, { k: 5, recall: 0.150 },
44
+ { k: 10, recall: 0.283 }, { k: 20, recall: 0.283 }, { k: 40, recall: 0.333 }, { k: 80, recall: 0.767 }],
45
+ },
46
+ ];
47
+ function recallAt(curve, k) {
48
+ const point = curve.at.find((p) => p.k === k);
49
+ return point?.recall ?? curve.full;
50
+ }
51
+ /**
52
+ * Whether cutting at K keeps most of the recall on EVERY corpus measured. One corpus agreeing is
53
+ * not evidence — that is the shape of mistake this repository keeps having to undo.
54
+ */
55
+ function narrowingIsSafe(curves, k, keep = 0.9) {
56
+ return curves.every((c) => recallAt(c, k) / c.full >= keep);
57
+ }