@holmes-lab/holmes-kit 0.2.1 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (132) hide show
  1. package/CHANGELOG.md +55 -0
  2. package/dist/.build-id +1 -1
  3. package/dist/holmes/assoc/assoc-arm.d.ts +94 -0
  4. package/dist/holmes/assoc/assoc-arm.js +187 -0
  5. package/dist/holmes/assoc/explore.d.ts +21 -0
  6. package/dist/holmes/assoc/explore.js +160 -0
  7. package/dist/holmes/assoc/impact-baseline.d.ts +13 -0
  8. package/dist/holmes/assoc/impact-baseline.js +48 -0
  9. package/dist/holmes/assoc/ppr.d.ts +64 -0
  10. package/dist/holmes/assoc/ppr.js +110 -0
  11. package/dist/holmes/cli/agents.d.ts +24 -11
  12. package/dist/holmes/cli/agents.js +93 -21
  13. package/dist/holmes/cli/doctor.d.ts +56 -0
  14. package/dist/holmes/cli/doctor.js +311 -5
  15. package/dist/holmes/cli/index.js +13 -1
  16. package/dist/holmes/cli/semantic-key.d.ts +19 -0
  17. package/dist/holmes/cli/semantic-key.js +93 -0
  18. package/dist/holmes/config/config.d.ts +9 -0
  19. package/dist/holmes/config/config.js +8 -1
  20. package/dist/holmes/cpg/language-capability.d.ts +65 -0
  21. package/dist/holmes/cpg/language-capability.js +145 -0
  22. package/dist/holmes/cpg/language-parser-walk.js +179 -34
  23. package/dist/holmes/cpg/language-parser.d.ts +1 -1
  24. package/dist/holmes/governance/ledger-rechain.d.ts +12 -0
  25. package/dist/holmes/governance/ledger-rechain.js +17 -2
  26. package/dist/holmes/governance/provenance-ledger.js +21 -0
  27. package/dist/holmes/guardrail/blind-spots.js +12 -1
  28. package/dist/holmes/guardrail/impact-gate.d.ts +77 -0
  29. package/dist/holmes/guardrail/impact-gate.js +263 -0
  30. package/dist/holmes/guardrail/write-target.d.ts +38 -1
  31. package/dist/holmes/guardrail/write-target.js +41 -4
  32. package/dist/holmes/hooks/adapters/antigravity.js +12 -1
  33. package/dist/holmes/hooks/corrupt-state-run.d.ts +33 -0
  34. package/dist/holmes/hooks/corrupt-state-run.js +16 -0
  35. package/dist/holmes/hooks/pre-tool-use.d.ts +26 -0
  36. package/dist/holmes/hooks/pre-tool-use.js +203 -11
  37. package/dist/holmes/hooks/stop.d.ts +65 -0
  38. package/dist/holmes/hooks/stop.js +200 -2
  39. package/dist/holmes/mcp/elicit-approval.d.ts +32 -2
  40. package/dist/holmes/mcp/elicit-approval.js +30 -2
  41. package/dist/holmes/mcp/handlers.d.ts +88 -0
  42. package/dist/holmes/mcp/handlers.js +636 -35
  43. package/dist/holmes/mcp/maintenance-analyze.d.ts +435 -0
  44. package/dist/holmes/mcp/maintenance-analyze.js +994 -0
  45. package/dist/holmes/mcp/maintenance-evidence.d.ts +140 -0
  46. package/dist/holmes/mcp/maintenance-evidence.js +253 -0
  47. package/dist/holmes/mcp/server.js +13 -10
  48. package/dist/holmes/mcp/tool-schemas.js +71 -0
  49. package/dist/holmes/project/root.js +3 -1
  50. package/dist/holmes/review/baseline-arm.d.ts +37 -0
  51. package/dist/holmes/review/baseline-arm.js +51 -0
  52. package/dist/holmes/review/captured-stdin-guard.d.ts +8 -0
  53. package/dist/holmes/review/captured-stdin-guard.js +48 -0
  54. package/dist/holmes/review/coherence-verify.d.ts +31 -0
  55. package/dist/holmes/review/coherence-verify.js +144 -0
  56. package/dist/holmes/review/commit-text.d.ts +50 -0
  57. package/dist/holmes/review/commit-text.js +76 -0
  58. package/dist/holmes/review/confidence-calibration.d.ts +39 -0
  59. package/dist/holmes/review/confidence-calibration.js +39 -0
  60. package/dist/holmes/review/content-baseline.d.ts +38 -0
  61. package/dist/holmes/review/content-baseline.js +103 -0
  62. package/dist/holmes/review/content-verify.d.ts +20 -0
  63. package/dist/holmes/review/content-verify.js +73 -0
  64. package/dist/holmes/review/dense-retrieval.d.ts +66 -0
  65. package/dist/holmes/review/dense-retrieval.js +97 -0
  66. package/dist/holmes/review/edge-quality.d.ts +44 -0
  67. package/dist/holmes/review/edge-quality.js +117 -0
  68. package/dist/holmes/review/evaluation-metrics.d.ts +138 -0
  69. package/dist/holmes/review/evaluation-metrics.js +175 -0
  70. package/dist/holmes/review/graph-verifier.d.ts +34 -0
  71. package/dist/holmes/review/graph-verifier.js +62 -0
  72. package/dist/holmes/review/hop-ablation.d.ts +100 -0
  73. package/dist/holmes/review/hop-ablation.js +89 -0
  74. package/dist/holmes/review/manual-baseline.d.ts +209 -0
  75. package/dist/holmes/review/manual-baseline.js +2846 -0
  76. package/dist/holmes/review/oracle-gap.d.ts +32 -0
  77. package/dist/holmes/review/oracle-gap.js +102 -0
  78. package/dist/holmes/review/point-in-time-replay.d.ts +41 -0
  79. package/dist/holmes/review/point-in-time-replay.js +161 -0
  80. package/dist/holmes/review/rank-diagnosis.d.ts +43 -0
  81. package/dist/holmes/review/rank-diagnosis.js +163 -0
  82. package/dist/holmes/review/replay-calibration.d.ts +62 -0
  83. package/dist/holmes/review/replay-calibration.js +83 -0
  84. package/dist/holmes/review/replay-corpus.d.ts +135 -0
  85. package/dist/holmes/review/replay-corpus.js +210 -0
  86. package/dist/holmes/review/run-replay.d.ts +260 -0
  87. package/dist/holmes/review/run-replay.js +729 -0
  88. package/dist/holmes/review/semantic-arm.d.ts +271 -0
  89. package/dist/holmes/review/semantic-arm.js +717 -0
  90. package/dist/holmes/review/semantic-retrieval.d.ts +55 -0
  91. package/dist/holmes/review/semantic-retrieval.js +156 -0
  92. package/dist/holmes/review/spec-layer-stats.d.ts +38 -0
  93. package/dist/holmes/review/spec-layer-stats.js +52 -0
  94. package/dist/holmes/review/temporal-prior.d.ts +33 -0
  95. package/dist/holmes/review/temporal-prior.js +53 -0
  96. package/dist/holmes/review/test-runner.d.ts +15 -0
  97. package/dist/holmes/review/test-runner.js +41 -4
  98. package/dist/holmes/review/test-selection-breadth.d.ts +75 -0
  99. package/dist/holmes/review/test-selection-breadth.js +57 -0
  100. package/dist/holmes/review/traceability-benchmark.d.ts +81 -0
  101. package/dist/holmes/review/traceability-benchmark.js +135 -0
  102. package/dist/holmes/review/union-verify.d.ts +12 -0
  103. package/dist/holmes/review/union-verify.js +70 -0
  104. package/dist/holmes/rtm/graph-store.d.ts +51 -0
  105. package/dist/holmes/rtm/graph-store.js +122 -0
  106. package/dist/holmes/rtm/incremental.d.ts +25 -1
  107. package/dist/holmes/rtm/incremental.js +18 -1
  108. package/dist/holmes/rtm/localize.d.ts +28 -0
  109. package/dist/holmes/rtm/localize.js +272 -14
  110. package/dist/holmes/rtm/rtm-builder.d.ts +118 -3
  111. package/dist/holmes/rtm/rtm-builder.js +265 -28
  112. package/dist/holmes/rtm/rtm-graph.d.ts +117 -2
  113. package/dist/holmes/rtm/rtm-graph.js +194 -31
  114. package/dist/holmes/rtm/taint-benchmark.d.ts +97 -0
  115. package/dist/holmes/rtm/taint-benchmark.js +141 -0
  116. package/dist/holmes/rtm/test-scope.js +24 -1
  117. package/dist/holmes/semantic/credentials.d.ts +15 -0
  118. package/dist/holmes/semantic/credentials.js +134 -0
  119. package/dist/holmes/semantic/embedder.d.ts +44 -0
  120. package/dist/holmes/semantic/embedder.js +185 -0
  121. package/dist/holmes/semantic/hit-rerank.d.ts +4 -0
  122. package/dist/holmes/semantic/hit-rerank.js +38 -0
  123. package/dist/holmes/semantic/tier.d.ts +37 -0
  124. package/dist/holmes/semantic/tier.js +54 -0
  125. package/dist/holmes/semantic/vector-cache.d.ts +11 -0
  126. package/dist/holmes/semantic/vector-cache.js +91 -0
  127. package/dist/holmes/spec/acceptance-quality.d.ts +81 -0
  128. package/dist/holmes/spec/acceptance-quality.js +169 -0
  129. package/dist/holmes/spec/validator.js +33 -1
  130. package/dist/holmes/spec/yaml-scalar.d.ts +1 -0
  131. package/dist/holmes/spec/yaml-scalar.js +43 -0
  132. package/package.json +1 -1
@@ -0,0 +1,66 @@
1
+ import type { Spec } from '../spec/spec-parser';
2
+ /**
3
+ * The eighth attempt: does a dense embedding beat matching the words?
4
+ *
5
+ * Seven mechanisms have been measured against uncited requests and all seven were negative. The last
6
+ * two aimed straight at the matching step — BM25 with morphological normalisation, and query
7
+ * expansion through the graph — and both traded recall for precision or the reverse. The hypothesis
8
+ * left standing is that what failed was not the idea but the offline bag-of-words implementing it: a
9
+ * report saying "uploads are being rejected" and a spec saying "quarantine of malformed payloads"
10
+ * share no token at all, and no amount of stemming makes them share one.
11
+ *
12
+ * ## Why this is optional, and stays optional
13
+ *
14
+ * The model is tens of megabytes and its first use may need a network. Making that a condition of
15
+ * installing Holmes-Kit would tax every user for a feature most will not switch on, so:
16
+ *
17
+ * - the dependency is OPTIONAL — absent by default, installed only when a project opts in;
18
+ * - it is imported dynamically inside a try/catch, so a missing module is a normal state and not a
19
+ * crash;
20
+ * - when it is unavailable the result says `available: false` and the caller falls back to the
21
+ * lexical path. It never returns an empty ranking, because "the model is not here" and "nothing
22
+ * was similar" are different facts and one of them must not be reported as the other.
23
+ */
24
+ export interface DenseAvailability {
25
+ available: boolean;
26
+ /** Why not, when it is not — for a diagnostic that tells the user what to do about it. */
27
+ reason?: 'module-absent' | 'model-unavailable';
28
+ }
29
+ export interface DenseRanked {
30
+ id: string;
31
+ similarity: number;
32
+ }
33
+ export interface DenseIndex {
34
+ availability: DenseAvailability;
35
+ /** null when unavailable — never an empty ranking, which would read as "nothing matched". */
36
+ rank(issueText: string, topK: number): Promise<DenseRanked[] | null>;
37
+ }
38
+ /**
39
+ * @implements A-SPEC-474 — the model becomes a PARAMETER so a discriminating re-measure can swap
40
+ * only that axis. The historical verdicts (0.424 vs 0.699; 6 of 25 lexical-zero cases) were
41
+ * measured with the English-only MiniLM above against a largely KOREAN spec store — a condition
42
+ * flaw the re-measure exists to isolate. The DEFAULT does not move: absent opts, shipped behavior
43
+ * is bit-identical. `pipeline` is injectable for tests only — the optional module stays optional.
44
+ */
45
+ export type PipelineLoader = (task: string, model: string, opts: {
46
+ quantized: boolean;
47
+ }) => Promise<(text: string, opts: {
48
+ pooling: string;
49
+ normalize: boolean;
50
+ }) => Promise<{
51
+ data: Float32Array;
52
+ }>>;
53
+ export interface DenseOptions {
54
+ model?: string;
55
+ pipeline?: PipelineLoader;
56
+ }
57
+ /** Cosine similarity of two equal-length vectors. */
58
+ export declare function cosine(a: readonly number[], b: readonly number[]): number;
59
+ /** The text of a spec as the embedder sees it. Title first: it is the densest statement of subject. */
60
+ export declare function specText(s: Spec): string;
61
+ /**
62
+ * Build an index over the A-SPECs, or report why it could not be built.
63
+ *
64
+ * Never throws: an absent optional dependency is an expected state on most installations.
65
+ */
66
+ export declare function buildDenseIndex(specs: readonly Spec[], opts?: DenseOptions): Promise<DenseIndex>;
@@ -0,0 +1,97 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.cosine = cosine;
37
+ exports.specText = specText;
38
+ exports.buildDenseIndex = buildDenseIndex;
39
+ const MODEL = 'Xenova/all-MiniLM-L6-v2';
40
+ /** Cosine similarity of two equal-length vectors. */
41
+ function cosine(a, b) {
42
+ if (a.length !== b.length || a.length === 0)
43
+ return 0;
44
+ let dot = 0, na = 0, nb = 0;
45
+ for (let i = 0; i < a.length; i++) {
46
+ dot += a[i] * b[i];
47
+ na += a[i] * a[i];
48
+ nb += b[i] * b[i];
49
+ }
50
+ return na === 0 || nb === 0 ? 0 : dot / Math.sqrt(na * nb);
51
+ }
52
+ /** The text of a spec as the embedder sees it. Title first: it is the densest statement of subject. */
53
+ function specText(s) {
54
+ return `${s.title}. ${Object.values(s.sections).join(' ')}`.slice(0, 2000);
55
+ }
56
+ /**
57
+ * Build an index over the A-SPECs, or report why it could not be built.
58
+ *
59
+ * Never throws: an absent optional dependency is an expected state on most installations.
60
+ */
61
+ async function buildDenseIndex(specs, opts = {}) {
62
+ const docs = specs.filter((s) => s.type === 'A-SPEC');
63
+ let embed = null;
64
+ let reason = 'module-absent';
65
+ try {
66
+ // Dynamic, guarded, AND resolved through a variable specifier. A literal `import('...')` is a
67
+ // COMPILE-time dependency: removing the package broke `tsc` even though every runtime path
68
+ // already handled its absence, which is the opposite of optional. Measured by doing it.
69
+ let loader = opts.pipeline;
70
+ if (loader === undefined) {
71
+ const specifier = '@xenova/transformers';
72
+ const mod = await Promise.resolve(`${specifier}`).then(s => __importStar(require(s)));
73
+ loader = mod.pipeline;
74
+ }
75
+ reason = 'model-unavailable';
76
+ const pipe = await loader('feature-extraction', opts.model ?? MODEL, { quantized: true });
77
+ embed = async (text) => Array.from((await pipe(text, { pooling: 'mean', normalize: true })).data);
78
+ }
79
+ catch {
80
+ return { availability: { available: false, reason }, rank: async () => null };
81
+ }
82
+ const vectors = [];
83
+ for (const s of docs)
84
+ vectors.push({ id: s.id, vector: await embed(specText(s)) });
85
+ return {
86
+ availability: { available: true },
87
+ rank: async (issueText, topK) => {
88
+ if (issueText.trim().length === 0 || vectors.length === 0)
89
+ return [];
90
+ const q = await embed(issueText);
91
+ return vectors
92
+ .map((v) => ({ id: v.id, similarity: cosine(q, v.vector) }))
93
+ .sort((a, b) => b.similarity - a.similarity || a.id.localeCompare(b.id))
94
+ .slice(0, topK);
95
+ },
96
+ };
97
+ }
@@ -0,0 +1,44 @@
1
+ import type { ScannedFile } from '../cpg/cpg-scanner';
2
+ /**
3
+ * What a `calls` edge actually is.
4
+ *
5
+ * Five mechanisms that walked the call graph made retrieval WORSE — the single hop cost Top-10 recall
6
+ * 0.770 → 0.547. That has been recorded as the idea failing, and there is a competing explanation
7
+ * that was never tested: the edges may be wrong. Extraction is syntactic, and a call is bound to its
8
+ * target BY NAME. A name defined in two files is ambiguous; a name defined nowhere in the scan is
9
+ * unresolved. If proven calls and name-guesses are mixed into one relation, a walk over them
10
+ * failing is not a fact about walks.
11
+ *
12
+ * The distinction the assessment asks for — CALLS_PROVEN against MAY_CALL against
13
+ * UNRESOLVED_CALLSITE — starts by counting which of the three the corpus actually contains.
14
+ */
15
+ export type EdgeResolution = 'unique' | 'ambiguous' | 'unresolved' | 'self';
16
+ export interface EdgeQuality {
17
+ total: number;
18
+ unique: number;
19
+ ambiguous: number;
20
+ unresolved: number;
21
+ /** Calls landing in the file they came from — resolvable without any cross-file guess. */
22
+ self: number;
23
+ /** null when there are no edges: an unmeasured graph, not a perfect one. */
24
+ uniqueShare: number | null;
25
+ }
26
+ /**
27
+ * Classify each call edge by how its target name resolves across the scan.
28
+ *
29
+ * Both the qualified name and its last segment are indexed, matching how the walk itself binds — a
30
+ * classifier stricter than the consumer would report a quality the consumer never had.
31
+ */
32
+ /**
33
+ * @implements A-SPEC-393
34
+ * Resolve an import specifier to a scanned file.
35
+ *
36
+ * `imports` edges carry the module path only — `../spec/spec-parser`, not which symbols came with
37
+ * it. That is still enough to break a tie: among several files declaring the same name, the one this
38
+ * file actually imports is the one it calls. It is the cheapest form of what the review calls Tier 2
39
+ * symbol resolution, and the part reachable without a compiler.
40
+ */
41
+ export declare function resolveImport(fromFile: string, specifier: string, known: ReadonlySet<string>): string | null;
42
+ export declare function callEdgeQuality(scanned: readonly ScannedFile[], opts?: {
43
+ importAware?: boolean;
44
+ }): EdgeQuality;
@@ -0,0 +1,117 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.resolveImport = resolveImport;
4
+ exports.callEdgeQuality = callEdgeQuality;
5
+ /**
6
+ * Classify each call edge by how its target name resolves across the scan.
7
+ *
8
+ * Both the qualified name and its last segment are indexed, matching how the walk itself binds — a
9
+ * classifier stricter than the consumer would report a quality the consumer never had.
10
+ */
11
+ /**
12
+ * @implements A-SPEC-393
13
+ * Resolve an import specifier to a scanned file.
14
+ *
15
+ * `imports` edges carry the module path only — `../spec/spec-parser`, not which symbols came with
16
+ * it. That is still enough to break a tie: among several files declaring the same name, the one this
17
+ * file actually imports is the one it calls. It is the cheapest form of what the review calls Tier 2
18
+ * symbol resolution, and the part reachable without a compiler.
19
+ */
20
+ function resolveImport(fromFile, specifier, known) {
21
+ const bases = [];
22
+ if (specifier.startsWith('.') && specifier.includes('/')) {
23
+ // JS/TS relative form: resolve against the importing file's directory.
24
+ const dir = fromFile.includes('/') ? fromFile.slice(0, fromFile.lastIndexOf('/')) : '';
25
+ const stack = [];
26
+ for (const part of `${dir}/${specifier}`.split('/')) {
27
+ if (part === '' || part === '.')
28
+ continue;
29
+ if (part === '..') {
30
+ stack.pop();
31
+ continue;
32
+ }
33
+ stack.push(part);
34
+ }
35
+ bases.push(stack.join('/'));
36
+ }
37
+ else if (specifier.includes('.')) {
38
+ // Dotted module form (Python): `src.core.llm`. The scan root may already BE the package root, so
39
+ // the leading segment is tried both ways — measured on the second corpus, where paths are
40
+ // relative to `src/` and every import names `src.` anyway, so without this the resolver found
41
+ // nothing and the 1,534 ambiguous edges there did not move at all.
42
+ const slashed = specifier.split('.').join('/');
43
+ bases.push(slashed);
44
+ const withoutFirst = specifier.split('.').slice(1).join('/');
45
+ if (withoutFirst.length > 0)
46
+ bases.push(withoutFirst);
47
+ }
48
+ else {
49
+ return null; // a bare package name — nothing in this scan
50
+ }
51
+ for (const base of bases) {
52
+ for (const candidate of [`${base}.ts`, `${base}.tsx`, `${base}/index.ts`, `${base}.js`, `${base}.py`, `${base}/__init__.py`]) {
53
+ if (known.has(candidate))
54
+ return candidate;
55
+ }
56
+ }
57
+ return null;
58
+ }
59
+ function callEdgeQuality(scanned, opts = {}) {
60
+ const declaredIn = new Map();
61
+ for (const f of scanned) {
62
+ for (const sym of f.symbols) {
63
+ const bare = sym.qualifiedName.includes('.')
64
+ ? sym.qualifiedName.slice(sym.qualifiedName.lastIndexOf('.') + 1) : sym.qualifiedName;
65
+ for (const key of new Set([sym.qualifiedName, bare])) {
66
+ (declaredIn.get(key) ?? declaredIn.set(key, new Set()).get(key)).add(f.sourcePath);
67
+ }
68
+ }
69
+ }
70
+ const knownPaths = new Set(scanned.map((f) => f.sourcePath));
71
+ let total = 0, unique = 0, ambiguous = 0, unresolved = 0, self = 0;
72
+ for (const f of scanned) {
73
+ // @implements A-SPEC-393 — the modules this file actually imports, resolved to scanned paths.
74
+ const imported = new Set();
75
+ if (opts.importAware) {
76
+ for (const e of f.edges ?? []) {
77
+ if (e.rel !== 'imports')
78
+ continue;
79
+ const target = resolveImport(f.sourcePath, e.to, knownPaths);
80
+ if (target)
81
+ imported.add(target);
82
+ }
83
+ }
84
+ for (const e of f.edges ?? []) {
85
+ if (e.rel !== 'calls')
86
+ continue;
87
+ total++;
88
+ const targets = declaredIn.get(e.to);
89
+ if (!targets || targets.size === 0) {
90
+ unresolved++;
91
+ continue;
92
+ }
93
+ if (targets.size === 1) {
94
+ if (targets.has(f.sourcePath))
95
+ self++;
96
+ else
97
+ unique++;
98
+ continue;
99
+ }
100
+ // Ambiguous by name — but if exactly one candidate is a module this file imports, the call is
101
+ // that one. Binding only when exactly one matches: two imported candidates is still a guess,
102
+ // and a guess dressed as a resolution is worse than a disclosed ambiguity.
103
+ if (opts.importAware) {
104
+ const viaImport = [...targets].filter((t) => imported.has(t));
105
+ if (viaImport.length === 1) {
106
+ if (viaImport[0] === f.sourcePath)
107
+ self++;
108
+ else
109
+ unique++;
110
+ continue;
111
+ }
112
+ }
113
+ ambiguous++;
114
+ }
115
+ }
116
+ return { total, unique, ambiguous, unresolved, self, uniqueShare: total === 0 ? null : unique / total };
117
+ }
@@ -0,0 +1,138 @@
1
+ /**
2
+ * Objective evaluation of a replay corpus (Goal Phase 8).
3
+ *
4
+ * The replay numbers used to exist only as a `console.log` in one test, copied into documentation by
5
+ * hand. That is not a contract: a metric can fall and the suite still passes. It happened during this
6
+ * work — authoring a single slice moved localization recall from 0.5476 to 0.5060 while every test
7
+ * stayed green, and only a human comparing two tables noticed.
8
+ *
9
+ * So the computation lives here, pure and fixture-checked, and the replay test asserts FLOORS taken
10
+ * from a real measurement. Raising a floor pins an improvement and is always welcome; lowering one
11
+ * approves a regression and needs a reason recorded with it.
12
+ *
13
+ * `null` rather than `0` for an unmeasured metric, throughout: zero claims "measured, and bad".
14
+ */
15
+ export declare const TOP_K: readonly [1, 3, 5, 10];
16
+ export interface ReplayOutcome {
17
+ /** Predicted files, best first. */
18
+ ranked: string[];
19
+ /** Files the change actually touched. */
20
+ truthFiles: string[];
21
+ selectedTests: string[];
22
+ truthTests: string[];
23
+ }
24
+ export interface CutMetrics {
25
+ /** Share of cases where at least one truth file appears in the first K. */
26
+ hitRate: number | null;
27
+ recall: number | null;
28
+ precision: number | null;
29
+ }
30
+ export interface EvaluationMetrics {
31
+ cases: number;
32
+ /** Cases that have truth to score against — the denominator recall actually rests on. */
33
+ scorableCases: number;
34
+ topK: Record<number, CutMetrics>;
35
+ testRecall: number | null;
36
+ }
37
+ export declare function evaluationMetrics(outcomes: readonly ReplayOutcome[]): EvaluationMetrics;
38
+ /**
39
+ * One case of change-driven impact: a file is presented as changed, and the question is which OTHER
40
+ * files that change reaches. The ground truth is free — a commit's co-changed files are exactly the
41
+ * answer, recorded by the person who made the change.
42
+ */
43
+ export interface ImpactCase {
44
+ /** The file presented as changed. Excluded from both sides of the score. */
45
+ seed: string;
46
+ /** The other files the commit really touched. */
47
+ expected: string[];
48
+ /** The files the analysis said were impacted. */
49
+ predicted: string[];
50
+ }
51
+ export interface ImpactEvaluation {
52
+ cases: number;
53
+ /** Cases with something to predict. A commit touching ONE file cannot be scored on impact. */
54
+ scorableCases: number;
55
+ recall: number | null;
56
+ precision: number | null;
57
+ }
58
+ /**
59
+ * Impact had no benchmark until this existed, so an impact regression could not fail anything.
60
+ * Measured on the replay corpus: 26 of 155 truth files are reached by impact and none of those 26
61
+ * appear in the ranking — the two paths find different files, and only ranking was ever scored.
62
+ *
63
+ * Recall and precision are both reported because they say different things: widening scope buys
64
+ * recall with precision, and a single number would hide which way a change traded.
65
+ */
66
+ export declare function impactMetrics(cases: readonly ImpactCase[]): ImpactEvaluation;
67
+ /** One case for the ceiling: what the commit co-changed, and what the graph can reach at all. */
68
+ export interface CeilingCase {
69
+ expected: string[];
70
+ /** Files reachable from the seed by ANY relation, in ANY direction, at any depth. */
71
+ reachable: string[];
72
+ }
73
+ export interface CeilingEvaluation {
74
+ scorableCases: number;
75
+ /** Mean share of co-changed files the graph could reach even in principle. */
76
+ ceiling: number | null;
77
+ }
78
+ /**
79
+ * How much of the ground truth is reachable at all.
80
+ *
81
+ * A measured impact recall of 0.093 says nothing on its own: files land in the same commit for
82
+ * reasons the graph has no way to represent, so recall 1.0 was never available. The ceiling supplies
83
+ * the denominator that turns "0.093" into "0.093 of X".
84
+ *
85
+ * Computed OPTIMISTICALLY on purpose — no relation filter, no direction, no depth bound. A narrower
86
+ * definition of reachable would lower the ceiling and flatter the algorithm's achievement rate,
87
+ * which is bending the ruler toward the thing being measured.
88
+ */
89
+ export declare function ceilingMetrics(cases: readonly CeilingCase[]): CeilingEvaluation;
90
+ /**
91
+ * Measured recall as a share of what was reachable. `null` when there is no denominator — dividing
92
+ * by a zero ceiling would report perfect achievement on a corpus where nothing could be found.
93
+ */
94
+ export declare function achievementRate(recall: number | null, ceiling: number | null): number | null;
95
+ export interface BoundsCase {
96
+ /** What the walk refused to expand, and why, as the analysis already records it. */
97
+ stoppedAt: Array<{
98
+ id: string;
99
+ reason: string;
100
+ }>;
101
+ /** The files the commit really co-changed. */
102
+ expected: string[];
103
+ }
104
+ export interface BoundsSummary {
105
+ cases: number;
106
+ hubStops: number;
107
+ depthStops: number;
108
+ /**
109
+ * Cases where a stop landed on a file the commit actually changed. Stops are cheap unless the
110
+ * answer was behind one, so this is the number that decides whether raising a bound would help.
111
+ */
112
+ expectedBehindStops: number;
113
+ }
114
+ /**
115
+ * Aggregate what the traversal bounds cut.
116
+ *
117
+ * `impactBounds.stoppedAt` has always recorded this node by node and nothing had ever read it — the
118
+ * tool was stating its own limits to an empty room. Counting them before changing them is the order
119
+ * that has repeatedly paid here: the capability table found C++'s zero edges, the resolution report
120
+ * found the Rust and Go naming defect, and `callerNotNamed` found the contract gap.
121
+ */
122
+ export declare function boundsImpact(cases: readonly BoundsCase[]): BoundsSummary;
123
+ /**
124
+ * Which axis found each correct file.
125
+ *
126
+ * Measured on the replay corpus: 26 of 155 truth files are reached by impact and none of those
127
+ * appear in the ranking. The axes are complementary, yet each was scored alone — so the set the user
128
+ * actually receives, the union, had never been scored at all, and six tuning verdicts were reached
129
+ * looking at one half.
130
+ *
131
+ * Counted over the TRUTH rather than over the predictions: the question is which axis found the
132
+ * answer, not how much the two happen to agree with each other.
133
+ */
134
+ export declare function overlapOf(ranked: readonly string[], impacted: readonly string[], truth: readonly string[]): {
135
+ rankedOnly: number;
136
+ impactOnly: number;
137
+ both: number;
138
+ };
@@ -0,0 +1,175 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.TOP_K = void 0;
4
+ exports.evaluationMetrics = evaluationMetrics;
5
+ exports.impactMetrics = impactMetrics;
6
+ exports.ceilingMetrics = ceilingMetrics;
7
+ exports.achievementRate = achievementRate;
8
+ exports.boundsImpact = boundsImpact;
9
+ exports.overlapOf = overlapOf;
10
+ // @implements A-SPEC-296
11
+ /**
12
+ * Objective evaluation of a replay corpus (Goal Phase 8).
13
+ *
14
+ * The replay numbers used to exist only as a `console.log` in one test, copied into documentation by
15
+ * hand. That is not a contract: a metric can fall and the suite still passes. It happened during this
16
+ * work — authoring a single slice moved localization recall from 0.5476 to 0.5060 while every test
17
+ * stayed green, and only a human comparing two tables noticed.
18
+ *
19
+ * So the computation lives here, pure and fixture-checked, and the replay test asserts FLOORS taken
20
+ * from a real measurement. Raising a floor pins an improvement and is always welcome; lowering one
21
+ * approves a regression and needs a reason recorded with it.
22
+ *
23
+ * `null` rather than `0` for an unmeasured metric, throughout: zero claims "measured, and bad".
24
+ */
25
+ exports.TOP_K = [1, 3, 5, 10];
26
+ const mean = (xs) => (xs.length === 0 ? null : xs.reduce((s, x) => s + x, 0) / xs.length);
27
+ function evaluationMetrics(outcomes) {
28
+ const scorable = outcomes.filter((o) => o.truthFiles.length > 0);
29
+ const topK = {};
30
+ for (const k of exports.TOP_K) {
31
+ const hits = [];
32
+ const recalls = [];
33
+ const precisions = [];
34
+ for (const o of scorable) {
35
+ const cut = o.ranked.slice(0, k);
36
+ const found = cut.filter((f) => o.truthFiles.includes(f)).length;
37
+ hits.push(found > 0 ? 1 : 0);
38
+ recalls.push(found / o.truthFiles.length);
39
+ precisions.push(cut.length === 0 ? 0 : found / cut.length);
40
+ }
41
+ topK[k] = { hitRate: mean(hits), recall: mean(recalls), precision: mean(precisions) };
42
+ }
43
+ const testScorable = outcomes.filter((o) => o.truthTests.length > 0);
44
+ const testRecall = mean(testScorable.map((o) => o.truthTests.filter((t) => o.selectedTests.includes(t)).length / o.truthTests.length));
45
+ return { cases: outcomes.length, scorableCases: scorable.length, topK, testRecall };
46
+ }
47
+ /**
48
+ * Impact had no benchmark until this existed, so an impact regression could not fail anything.
49
+ * Measured on the replay corpus: 26 of 155 truth files are reached by impact and none of those 26
50
+ * appear in the ranking — the two paths find different files, and only ranking was ever scored.
51
+ *
52
+ * Recall and precision are both reported because they say different things: widening scope buys
53
+ * recall with precision, and a single number would hide which way a change traded.
54
+ */
55
+ function impactMetrics(cases) {
56
+ const recalls = [];
57
+ const precisions = [];
58
+ for (const c of cases) {
59
+ const expected = new Set(c.expected.filter((f) => f !== c.seed));
60
+ if (expected.size === 0)
61
+ continue; // nothing to predict: unmeasured, not zero
62
+ const predicted = new Set(c.predicted.filter((f) => f !== c.seed));
63
+ let hit = 0;
64
+ for (const f of predicted)
65
+ if (expected.has(f))
66
+ hit++;
67
+ recalls.push(hit / expected.size);
68
+ // An empty prediction is not perfect precision. Answering nothing is a failure to answer.
69
+ precisions.push(predicted.size === 0 ? 0 : hit / predicted.size);
70
+ }
71
+ const mean = (xs) => (xs.length === 0 ? null : xs.reduce((a, b) => a + b, 0) / xs.length);
72
+ return {
73
+ cases: cases.length,
74
+ scorableCases: recalls.length,
75
+ recall: mean(recalls),
76
+ precision: mean(precisions),
77
+ };
78
+ }
79
+ /**
80
+ * How much of the ground truth is reachable at all.
81
+ *
82
+ * A measured impact recall of 0.093 says nothing on its own: files land in the same commit for
83
+ * reasons the graph has no way to represent, so recall 1.0 was never available. The ceiling supplies
84
+ * the denominator that turns "0.093" into "0.093 of X".
85
+ *
86
+ * Computed OPTIMISTICALLY on purpose — no relation filter, no direction, no depth bound. A narrower
87
+ * definition of reachable would lower the ceiling and flatter the algorithm's achievement rate,
88
+ * which is bending the ruler toward the thing being measured.
89
+ */
90
+ function ceilingMetrics(cases) {
91
+ const shares = [];
92
+ for (const c of cases) {
93
+ if (c.expected.length === 0)
94
+ continue;
95
+ const reachable = new Set(c.reachable);
96
+ shares.push(c.expected.filter((f) => reachable.has(f)).length / c.expected.length);
97
+ }
98
+ return {
99
+ scorableCases: shares.length,
100
+ ceiling: shares.length === 0 ? null : shares.reduce((a, b) => a + b, 0) / shares.length,
101
+ };
102
+ }
103
+ /**
104
+ * Measured recall as a share of what was reachable. `null` when there is no denominator — dividing
105
+ * by a zero ceiling would report perfect achievement on a corpus where nothing could be found.
106
+ */
107
+ function achievementRate(recall, ceiling) {
108
+ if (recall === null || ceiling === null || ceiling === 0)
109
+ return null;
110
+ return recall / ceiling;
111
+ }
112
+ /**
113
+ * Aggregate what the traversal bounds cut.
114
+ *
115
+ * `impactBounds.stoppedAt` has always recorded this node by node and nothing had ever read it — the
116
+ * tool was stating its own limits to an empty room. Counting them before changing them is the order
117
+ * that has repeatedly paid here: the capability table found C++'s zero edges, the resolution report
118
+ * found the Rust and Go naming defect, and `callerNotNamed` found the contract gap.
119
+ */
120
+ function boundsImpact(cases) {
121
+ let hubStops = 0;
122
+ let depthStops = 0;
123
+ let expectedBehindStops = 0;
124
+ for (const c of cases) {
125
+ const expected = new Set(c.expected);
126
+ const hitFiles = new Set();
127
+ for (const stop of c.stoppedAt) {
128
+ // An unrecognised reason is counted as neither. Folding it into `hub` would turn an unknown
129
+ // into a confident wrong diagnosis.
130
+ if (stop.reason === 'hub')
131
+ hubStops++;
132
+ else if (stop.reason === 'depth')
133
+ depthStops++;
134
+ const at = stop.id.lastIndexOf('@');
135
+ if (at < 0)
136
+ continue;
137
+ const file = stop.id.slice(at + 1);
138
+ if (expected.has(file))
139
+ hitFiles.add(file);
140
+ }
141
+ expectedBehindStops += hitFiles.size;
142
+ }
143
+ return { cases: cases.length, hubStops, depthStops, expectedBehindStops };
144
+ }
145
+ // @implements A-SPEC-312
146
+ /**
147
+ * Which axis found each correct file.
148
+ *
149
+ * Measured on the replay corpus: 26 of 155 truth files are reached by impact and none of those
150
+ * appear in the ranking. The axes are complementary, yet each was scored alone — so the set the user
151
+ * actually receives, the union, had never been scored at all, and six tuning verdicts were reached
152
+ * looking at one half.
153
+ *
154
+ * Counted over the TRUTH rather than over the predictions: the question is which axis found the
155
+ * answer, not how much the two happen to agree with each other.
156
+ */
157
+ function overlapOf(ranked, impacted, truth) {
158
+ const inRanked = new Set(ranked);
159
+ const inImpacted = new Set(impacted);
160
+ let rankedOnly = 0;
161
+ let impactOnly = 0;
162
+ let both = 0;
163
+ for (const file of new Set(truth)) {
164
+ const r = inRanked.has(file);
165
+ const i = inImpacted.has(file);
166
+ if (r && i)
167
+ both++;
168
+ else if (r)
169
+ rankedOnly++;
170
+ else if (i)
171
+ impactOnly++;
172
+ // Found by neither: counted nowhere. It is a miss, and the recall metrics already say so.
173
+ }
174
+ return { rankedOnly, impactOnly, both };
175
+ }
@@ -0,0 +1,34 @@
1
+ import type { ScannedFile } from '../cpg/cpg-scanner';
2
+ /**
3
+ * The graph used to CHECK a candidate rather than to find one.
4
+ *
5
+ * Ten mechanisms have been measured and every one used the graph additively — follow a neighbour,
6
+ * pay a bonus, expand the query. All of them chase recall, all of them were negative, and most of
7
+ * them cost precision. The other half of the argument is the opposite operation: let the lexical
8
+ * layer propose, and let the graph decide which proposals it can corroborate.
9
+ *
10
+ * Corroboration here is deliberately weak — a call edge either way, or a shared spec anchor, between
11
+ * a candidate and the top-ranked file. It is a demotion and never a deletion: this graph is
12
+ * incomplete by construction (its own coverage numbers say so), and removing a file because the
13
+ * graph fails to connect it would turn a gap in the extractor into a wrong answer.
14
+ */
15
+ export interface VerifierOptions {
16
+ /** How much an uncorroborated candidate keeps. 1 is a no-op; 0 would be deletion by another name. */
17
+ demotion: number;
18
+ }
19
+ /** Files reachable from `seed` by a call edge in either direction, or by a shared spec anchor. */
20
+ export declare function corroborated(seed: string, scanned: readonly ScannedFile[]): Set<string>;
21
+ /**
22
+ * Demote candidates the graph cannot connect to the top-ranked one.
23
+ *
24
+ * The top candidate is taken as the anchor of the answer, which is the assumption that makes this
25
+ * cheap and also the one that can sink it: if the first hit is wrong, corroboration is measured
26
+ * against the wrong place. That is why the demotion is partial and the list is never truncated.
27
+ */
28
+ export declare function verifyByGraph(ranked: readonly {
29
+ file: string;
30
+ score: number;
31
+ }[], scanned: readonly ScannedFile[], opts: VerifierOptions): {
32
+ file: string;
33
+ score: number;
34
+ }[];