@holmes-lab/holmes-kit 0.18.0 → 0.19.2
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 +149 -0
- package/README.md +3 -0
- package/dist/.build-id +1 -1
- package/dist/holmes/cli/agents.d.ts +22 -0
- package/dist/holmes/cli/agents.js +76 -1
- package/dist/holmes/cli/approve.js +6 -1
- package/dist/holmes/cli/doctor.d.ts +36 -1
- package/dist/holmes/cli/doctor.js +182 -35
- package/dist/holmes/cli/index.js +7 -1
- package/dist/holmes/cli/init.js +12 -0
- package/dist/holmes/cli/native-deps.d.ts +65 -0
- package/dist/holmes/cli/native-deps.js +131 -0
- package/dist/holmes/cli/release-docs.d.ts +27 -0
- package/dist/holmes/cli/release-docs.js +68 -0
- package/dist/holmes/cpg/arch-observe.d.ts +15 -0
- package/dist/holmes/cpg/arch-observe.js +19 -0
- package/dist/holmes/cpg/cpg-scanner.d.ts +10 -36
- package/dist/holmes/cpg/cpg-scanner.js +27 -3
- package/dist/holmes/cpg/cycle-detect.d.ts +87 -0
- package/dist/holmes/cpg/cycle-detect.js +251 -0
- package/dist/holmes/cpg/cycle-observation.d.ts +65 -0
- package/dist/holmes/cpg/cycle-observation.js +146 -0
- package/dist/holmes/cpg/scan-cache.d.ts +1 -1
- package/dist/holmes/cpg/scanned-file.d.ts +36 -0
- package/dist/holmes/cpg/scanned-file.js +2 -0
- package/dist/holmes/governance/approval-queue.d.ts +23 -4
- package/dist/holmes/governance/approval-queue.js +44 -6
- package/dist/holmes/governance/constitution.d.ts +20 -0
- package/dist/holmes/governance/constitution.js +17 -0
- package/dist/holmes/governance/ledger-store.d.ts +9 -0
- package/dist/holmes/governance/ledger-store.js +47 -0
- package/dist/holmes/governance/provenance-chain.d.ts +16 -1
- package/dist/holmes/governance/provenance-chain.js +5 -3
- package/dist/holmes/hooks/pre-tool-use.js +3 -1
- package/dist/holmes/hooks/stop.d.ts +29 -0
- package/dist/holmes/hooks/stop.js +119 -3
- package/dist/holmes/mcp/defuse-bound.d.ts +1 -0
- package/dist/holmes/mcp/defuse-bound.js +8 -0
- package/dist/holmes/mcp/handlers.d.ts +7 -0
- package/dist/holmes/mcp/handlers.js +132 -6
- package/dist/holmes/mcp/history-admission.d.ts +15 -0
- package/dist/holmes/mcp/history-admission.js +37 -0
- package/dist/holmes/mcp/maintenance-analyze.d.ts +45 -0
- package/dist/holmes/mcp/maintenance-analyze.js +117 -9
- package/dist/holmes/mcp/maintenance-evidence.d.ts +41 -0
- package/dist/holmes/mcp/maintenance-evidence.js +71 -4
- package/dist/holmes/project/install-scripts-policy.d.ts +76 -0
- package/dist/holmes/project/install-scripts-policy.js +131 -0
- package/dist/holmes/project/npx-bin.d.ts +6 -0
- package/dist/holmes/project/npx-bin.js +10 -0
- package/dist/holmes/review/evaluation-metrics.d.ts +6 -0
- package/dist/holmes/review/evaluation-metrics.js +18 -1
- package/dist/holmes/review/failed-test-names.d.ts +19 -0
- package/dist/holmes/review/failed-test-names.js +43 -0
- package/dist/holmes/review/paired-power.d.ts +14 -0
- package/dist/holmes/review/paired-power.js +57 -0
- package/dist/holmes/review/replay-corpus.d.ts +11 -0
- package/dist/holmes/review/replay-corpus.js +34 -0
- package/dist/holmes/review/run-replay.d.ts +23 -0
- package/dist/holmes/review/run-replay.js +90 -4
- package/dist/holmes/review/symbol-truth.d.ts +14 -0
- package/dist/holmes/review/symbol-truth.js +23 -0
- package/dist/holmes/review/test-runner.d.ts +27 -0
- package/dist/holmes/review/test-runner.js +59 -3
- package/dist/holmes/rtm/defuse-symbols.d.ts +17 -0
- package/dist/holmes/rtm/defuse-symbols.js +91 -0
- package/dist/holmes/rtm/incremental.js +5 -0
- package/dist/holmes/rtm/rtm-builder.d.ts +8 -0
- package/dist/holmes/rtm/rtm-builder.js +32 -5
- package/dist/holmes/rtm/rtm-graph.d.ts +11 -0
- package/dist/holmes/rtm/rtm-graph.js +13 -0
- package/dist/holmes/spec/legacy-fields.d.ts +2 -0
- package/dist/holmes/spec/legacy-fields.js +9 -0
- package/dist/holmes/spec/legacy-format.d.ts +1 -1
- package/dist/holmes/spec/legacy-format.js +4 -1
- package/dist/holmes/spec/spec-parser.js +5 -3
- package/docs/install-guide.md +54 -5
- package/package.json +4 -1
- package/playbooks/author-slice/PLAYBOOK.md +14 -0
- package/playbooks/publish/PLAYBOOK.md +32 -0
- package/playbooks/tdd-slice/PLAYBOOK.md +14 -0
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// @implements A-SPEC-582.1
|
|
3
|
+
/**
|
|
4
|
+
* The names behind a red release gate.
|
|
5
|
+
*
|
|
6
|
+
* Measured 2026-09-10: `npm publish` refused with "스위트가 붉습니다" and nothing else, and finding
|
|
7
|
+
* out which test had failed cost two more full-suite runs. `test_run` learned to name its failures
|
|
8
|
+
* in REQ-577; the release gate had not. A gate that says "red" without saying what is a gate that
|
|
9
|
+
* makes the next person guess.
|
|
10
|
+
*/
|
|
11
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
12
|
+
exports.FAILED_NAME_CAP = void 0;
|
|
13
|
+
exports.failedTestNames = failedTestNames;
|
|
14
|
+
/** How many names a refusal lists before it counts the rest. */
|
|
15
|
+
exports.FAILED_NAME_CAP = 10;
|
|
16
|
+
/** Jest's summary marks each failure with `● <suite> › <test>`. */
|
|
17
|
+
const SUMMARY_LINE = /^\s*●\s+(.+?)\s*$/;
|
|
18
|
+
/**
|
|
19
|
+
* Pull the failing test names out of a jest run's output.
|
|
20
|
+
*
|
|
21
|
+
* PURE, and forgiving: output it cannot read yields an empty list so the caller keeps its existing
|
|
22
|
+
* refusal rather than replacing a working message with an empty one. Jest prints the summary block
|
|
23
|
+
* twice on some configurations, so names are de-duplicated — the same failure listed twice is one
|
|
24
|
+
* failure.
|
|
25
|
+
*/
|
|
26
|
+
function failedTestNames(output) {
|
|
27
|
+
const seen = [];
|
|
28
|
+
for (const line of String(output ?? '').split('\n')) {
|
|
29
|
+
const m = SUMMARY_LINE.exec(line);
|
|
30
|
+
if (m === null)
|
|
31
|
+
continue;
|
|
32
|
+
const name = m[1];
|
|
33
|
+
// A `●` line that is not a test name (jest uses the bullet for Console blocks too).
|
|
34
|
+
if (name === '' || name === 'Console' || !name.includes('›'))
|
|
35
|
+
continue;
|
|
36
|
+
if (!seen.includes(name))
|
|
37
|
+
seen.push(name);
|
|
38
|
+
}
|
|
39
|
+
if (seen.length <= exports.FAILED_NAME_CAP)
|
|
40
|
+
return seen;
|
|
41
|
+
const rest = seen.length - exports.FAILED_NAME_CAP;
|
|
42
|
+
return [...seen.slice(0, exports.FAILED_NAME_CAP), `… and ${rest} more`];
|
|
43
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/** F1 over the SET of files, which is the edit set a caller actually acts on. */
|
|
2
|
+
export declare function editSetF1(predicted: readonly string[], truth: readonly string[]): number;
|
|
3
|
+
export interface PairedPower {
|
|
4
|
+
n: number;
|
|
5
|
+
meanDiff: number | null;
|
|
6
|
+
/** Sample standard deviation of the paired differences (n-1). Null below two observations. */
|
|
7
|
+
sdDiff: number | null;
|
|
8
|
+
sem: number | null;
|
|
9
|
+
/** Smallest difference detectable at 80% power, two-sided alpha 0.05. */
|
|
10
|
+
mde80: number | null;
|
|
11
|
+
/** True when the degrees of freedom fell outside the table and the normal approximation was used. */
|
|
12
|
+
approx: boolean;
|
|
13
|
+
}
|
|
14
|
+
export declare function pairedPower(diffs: readonly number[]): PairedPower;
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// @implements A-SPEC-573.6
|
|
3
|
+
// Every verdict in REQ-573 — S1 adopted, S2 held at 60, S3 conditional, S4 rejected — was reached
|
|
4
|
+
// without asking whether the observed difference was large enough to be detectable at all. A
|
|
5
|
+
// rejection that a study could never have detected is not evidence of absence, and an adoption of
|
|
6
|
+
// a difference smaller than the noise is not evidence of presence. This computes both, from paired
|
|
7
|
+
// observations, with no dependency and no randomness.
|
|
8
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
9
|
+
exports.editSetF1 = editSetF1;
|
|
10
|
+
exports.pairedPower = pairedPower;
|
|
11
|
+
/** F1 over the SET of files, which is the edit set a caller actually acts on. */
|
|
12
|
+
function editSetF1(predicted, truth) {
|
|
13
|
+
const p = new Set(predicted);
|
|
14
|
+
const t = new Set(truth);
|
|
15
|
+
if (p.size === 0 || t.size === 0)
|
|
16
|
+
return 0;
|
|
17
|
+
let hit = 0;
|
|
18
|
+
for (const f of p)
|
|
19
|
+
if (t.has(f))
|
|
20
|
+
hit++;
|
|
21
|
+
if (hit === 0)
|
|
22
|
+
return 0;
|
|
23
|
+
const precision = hit / p.size;
|
|
24
|
+
const recall = hit / t.size;
|
|
25
|
+
return (2 * precision * recall) / (precision + recall);
|
|
26
|
+
}
|
|
27
|
+
// Two-sided 0.975 and one-sided 0.80 critical values, by degrees of freedom. Small and explicit
|
|
28
|
+
// rather than a dependency: the numbers a verdict rests on should be readable in the file that uses
|
|
29
|
+
// them. Anything past the table is close enough to normal that the approximation is honest — and it
|
|
30
|
+
// is reported as such rather than passed off as exact.
|
|
31
|
+
const T975 = {
|
|
32
|
+
1: 12.706, 2: 4.303, 3: 3.182, 4: 2.776, 5: 2.571, 6: 2.447, 7: 2.365, 8: 2.306, 9: 2.262,
|
|
33
|
+
10: 2.228, 11: 2.201, 12: 2.179, 13: 2.160, 14: 2.145, 15: 2.131, 16: 2.120, 17: 2.110,
|
|
34
|
+
18: 2.101, 19: 2.093, 20: 2.086, 25: 2.060, 30: 2.042, 40: 2.021, 60: 2.000, 120: 1.980,
|
|
35
|
+
};
|
|
36
|
+
const T80 = {
|
|
37
|
+
1: 1.376, 2: 1.061, 3: 0.978, 4: 0.941, 5: 0.920, 6: 0.906, 7: 0.896, 8: 0.889, 9: 0.883,
|
|
38
|
+
10: 0.879, 11: 0.876, 12: 0.873, 13: 0.870, 14: 0.868, 15: 0.866, 16: 0.865, 17: 0.863,
|
|
39
|
+
18: 0.862, 19: 0.861, 20: 0.860, 25: 0.856, 30: 0.854, 40: 0.851, 60: 0.848, 120: 0.845,
|
|
40
|
+
};
|
|
41
|
+
const NORMAL_975 = 1.96;
|
|
42
|
+
const NORMAL_80 = 0.8416;
|
|
43
|
+
function pairedPower(diffs) {
|
|
44
|
+
const n = diffs.length;
|
|
45
|
+
if (n === 0)
|
|
46
|
+
return { n: 0, meanDiff: null, sdDiff: null, sem: null, mde80: null, approx: false };
|
|
47
|
+
const meanDiff = diffs.reduce((s, x) => s + x, 0) / n;
|
|
48
|
+
if (n < 2)
|
|
49
|
+
return { n, meanDiff, sdDiff: null, sem: null, mde80: null, approx: false };
|
|
50
|
+
const variance = diffs.reduce((s, x) => s + (x - meanDiff) ** 2, 0) / (n - 1);
|
|
51
|
+
const sdDiff = Math.sqrt(variance);
|
|
52
|
+
const sem = sdDiff / Math.sqrt(n);
|
|
53
|
+
const df = n - 1;
|
|
54
|
+
const exact = T975[df] !== undefined && T80[df] !== undefined;
|
|
55
|
+
const [tAlpha, tBeta] = exact ? [T975[df], T80[df]] : [NORMAL_975, NORMAL_80];
|
|
56
|
+
return { n, meanDiff, sdDiff, sem, mde80: (tAlpha + tBeta) * sem, approx: !exact };
|
|
57
|
+
}
|
|
@@ -44,6 +44,17 @@ export interface ReplayCase {
|
|
|
44
44
|
/** Test files the commit changed. */
|
|
45
45
|
tests: string[];
|
|
46
46
|
}
|
|
47
|
+
/**
|
|
48
|
+
* The PARENT-side line numbers a commit changed in one file.
|
|
49
|
+
*
|
|
50
|
+
* Parent-side, not head-side, because the symbol ranges these are intersected with come from the
|
|
51
|
+
* parent-time scan — the tree the analysis actually saw. Reading head-side numbers would line up
|
|
52
|
+
* the diff against a file the analyzer never had.
|
|
53
|
+
*
|
|
54
|
+
* Returns an empty list rather than throwing: a file the parent did not have (the commit created
|
|
55
|
+
* it) has no parent lines, and that is an answer, not a failure.
|
|
56
|
+
*/
|
|
57
|
+
export declare function changedParentLines(root: string, commit: string, file: string): number[];
|
|
47
58
|
export declare const HOLMES_CORPUS: ReplayCorpus;
|
|
48
59
|
/**
|
|
49
60
|
* The second corpus. Measured 2026-08-29: 269 source files, 329 commits, 251 eligible cases, 959
|
|
@@ -34,6 +34,7 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
34
34
|
})();
|
|
35
35
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
36
|
exports.GEMINI_CLI_MEASUREMENT = exports.GEMINI_CLI_CORPUS = exports.JARVIS_CORPUS = exports.HOLMES_CORPUS = void 0;
|
|
37
|
+
exports.changedParentLines = changedParentLines;
|
|
37
38
|
exports.casesFor = casesFor;
|
|
38
39
|
exports.thinSpecs = thinSpecs;
|
|
39
40
|
exports.casesAvoidingRecent = casesAvoidingRecent;
|
|
@@ -44,6 +45,39 @@ const path = __importStar(require("node:path"));
|
|
|
44
45
|
const node_child_process_1 = require("node:child_process");
|
|
45
46
|
const maintenance_analyze_1 = require("../mcp/maintenance-analyze");
|
|
46
47
|
const TS_FAMILY = (f) => f.endsWith('.ts') && !f.endsWith('.d.ts');
|
|
48
|
+
// @implements A-SPEC-573.3
|
|
49
|
+
/**
|
|
50
|
+
* The PARENT-side line numbers a commit changed in one file.
|
|
51
|
+
*
|
|
52
|
+
* Parent-side, not head-side, because the symbol ranges these are intersected with come from the
|
|
53
|
+
* parent-time scan — the tree the analysis actually saw. Reading head-side numbers would line up
|
|
54
|
+
* the diff against a file the analyzer never had.
|
|
55
|
+
*
|
|
56
|
+
* Returns an empty list rather than throwing: a file the parent did not have (the commit created
|
|
57
|
+
* it) has no parent lines, and that is an answer, not a failure.
|
|
58
|
+
*/
|
|
59
|
+
function changedParentLines(root, commit, file) {
|
|
60
|
+
let raw;
|
|
61
|
+
try {
|
|
62
|
+
raw = (0, node_child_process_1.execFileSync)('git', ['-C', root, 'show', '--unified=0', '--format=', commit, '--', file], { encoding: 'utf8', maxBuffer: 16 * 1024 * 1024 });
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
return [];
|
|
66
|
+
}
|
|
67
|
+
const lines = [];
|
|
68
|
+
for (const line of raw.split('\n')) {
|
|
69
|
+
// `@@ -12,3 +12,4 @@` — the old-side start and count. A count of 0 marks a pure insertion, which
|
|
70
|
+
// touched no existing line and therefore no existing symbol.
|
|
71
|
+
const m = /^@@ -(\d+)(?:,(\d+))? /.exec(line);
|
|
72
|
+
if (m === null)
|
|
73
|
+
continue;
|
|
74
|
+
const start = Number(m[1]);
|
|
75
|
+
const count = m[2] === undefined ? 1 : Number(m[2]);
|
|
76
|
+
for (let i = 0; i < count; i++)
|
|
77
|
+
lines.push(start + i);
|
|
78
|
+
}
|
|
79
|
+
return lines;
|
|
80
|
+
}
|
|
47
81
|
exports.HOLMES_CORPUS = {
|
|
48
82
|
root: path.resolve(__dirname, '../../..'),
|
|
49
83
|
// @implements A-SPEC-443 — pinned so the floors stay floors. Our own repository moves too; the
|
|
@@ -1,6 +1,23 @@
|
|
|
1
1
|
import { evaluationMetrics, impactMetrics, ceilingMetrics } from './evaluation-metrics';
|
|
2
2
|
import { type ReplayCorpus } from './replay-corpus';
|
|
3
3
|
import { type PprArmConfig } from '../assoc/assoc-arm';
|
|
4
|
+
/** One case, scored by both arms. Measurement only — never read by a pin. */
|
|
5
|
+
export interface PairedRow {
|
|
6
|
+
commit: string;
|
|
7
|
+
uncited: boolean;
|
|
8
|
+
productRecall10: number;
|
|
9
|
+
baselineRecall10: number;
|
|
10
|
+
truth: number;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* @implements A-SPEC-578.7
|
|
14
|
+
* The paired differences on the UNCITED axis, ready for `pairedPower`.
|
|
15
|
+
*
|
|
16
|
+
* Filtering here rather than while measuring is deliberate: observation records every case, and the
|
|
17
|
+
* axis is chosen at analysis time. A harness that only recorded what it currently cares about could
|
|
18
|
+
* never answer a question asked later.
|
|
19
|
+
*/
|
|
20
|
+
export declare function uncitedDiffs(rows: readonly PairedRow[]): number[];
|
|
4
21
|
export interface ReplayResult {
|
|
5
22
|
corpus: string;
|
|
6
23
|
cases: number;
|
|
@@ -231,6 +248,12 @@ export declare function runReplay(corpus: ReplayCorpus, limit: number, opts?: {
|
|
|
231
248
|
/** @implements A-SPEC-567.2 — present only when judgementBundle was asked. */
|
|
232
249
|
judgementBundle?: import('./judgement-bundle').JudgementBundle;
|
|
233
250
|
}) => void;
|
|
251
|
+
/**
|
|
252
|
+
* @implements A-SPEC-578.7 — the power arm: one row per case, both arms scored, so the caller
|
|
253
|
+
* can compute an MDE with `pairedPower`. Same idiom as `caseDump` — a callback, never a change
|
|
254
|
+
* to `ReplayResult`, so a pin cannot move because a measurement was asked for.
|
|
255
|
+
*/
|
|
256
|
+
pairedDump?: (row: PairedRow) => void;
|
|
234
257
|
/** @implements A-SPEC-487 — 2-pass semantic injection for the dump only, never the pins. */
|
|
235
258
|
productSemantic?: {
|
|
236
259
|
embedBatch: (texts: string[], kind: 'query' | 'doc') => Promise<number[][]>;
|
|
@@ -33,6 +33,7 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
33
33
|
};
|
|
34
34
|
})();
|
|
35
35
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.uncitedDiffs = uncitedDiffs;
|
|
36
37
|
exports.runReplay = runReplay;
|
|
37
38
|
exports.semanticCaseRanking = semanticCaseRanking;
|
|
38
39
|
// @implements A-SPEC-346
|
|
@@ -41,11 +42,15 @@ exports.semanticCaseRanking = semanticCaseRanking;
|
|
|
41
42
|
// @implements A-SPEC-349
|
|
42
43
|
// @implements A-SPEC-378
|
|
43
44
|
// @implements A-SPEC-402
|
|
45
|
+
const baseline_arm_1 = require("./baseline-arm");
|
|
44
46
|
const fs = __importStar(require("node:fs"));
|
|
45
47
|
const node_child_process_1 = require("node:child_process");
|
|
46
48
|
const os = __importStar(require("node:os"));
|
|
47
49
|
const path = __importStar(require("node:path"));
|
|
48
50
|
const cpg_scanner_1 = require("../cpg/cpg-scanner");
|
|
51
|
+
const language_parser_1 = require("../cpg/language-parser");
|
|
52
|
+
// @implements A-SPEC-573.4 — same bound as the handler, one constant so the two cannot drift.
|
|
53
|
+
const defuse_bound_1 = require("../mcp/defuse-bound");
|
|
49
54
|
const rtm_builder_1 = require("../rtm/rtm-builder");
|
|
50
55
|
const rtm_graph_1 = require("../rtm/rtm-graph");
|
|
51
56
|
const test_scope_1 = require("../rtm/test-scope");
|
|
@@ -55,6 +60,7 @@ const maintenance_analyze_1 = require("../mcp/maintenance-analyze");
|
|
|
55
60
|
const point_in_time_replay_1 = require("./point-in-time-replay");
|
|
56
61
|
const evaluation_metrics_1 = require("./evaluation-metrics");
|
|
57
62
|
const replay_corpus_1 = require("./replay-corpus");
|
|
63
|
+
const symbol_truth_1 = require("./symbol-truth");
|
|
58
64
|
const semantic_arm_1 = require("./semantic-arm");
|
|
59
65
|
// @implements A-SPEC-479 — pre-emission verification of the union answer (measurement only).
|
|
60
66
|
// @implements A-SPEC-488 — lexPoverty drives the gate simulation ("uncited ∨ lexically poor").
|
|
@@ -75,6 +81,17 @@ const dense_retrieval_1 = require("./dense-retrieval");
|
|
|
75
81
|
const explore_1 = require("../assoc/explore");
|
|
76
82
|
const temporal_prior_1 = require("./temporal-prior");
|
|
77
83
|
const commit_text_1 = require("./commit-text");
|
|
84
|
+
/**
|
|
85
|
+
* @implements A-SPEC-578.7
|
|
86
|
+
* The paired differences on the UNCITED axis, ready for `pairedPower`.
|
|
87
|
+
*
|
|
88
|
+
* Filtering here rather than while measuring is deliberate: observation records every case, and the
|
|
89
|
+
* axis is chosen at analysis time. A harness that only recorded what it currently cares about could
|
|
90
|
+
* never answer a question asked later.
|
|
91
|
+
*/
|
|
92
|
+
function uncitedDiffs(rows) {
|
|
93
|
+
return rows.filter((r) => r.uncited).map((r) => r.productRecall10 - r.baselineRecall10);
|
|
94
|
+
}
|
|
78
95
|
/**
|
|
79
96
|
* Run the point-in-time replay against ANY corpus.
|
|
80
97
|
*
|
|
@@ -212,25 +229,94 @@ async function runReplay(corpus, limit, opts = {}) {
|
|
|
212
229
|
// reported from it understated what a caller actually gets — a benchmark measuring something
|
|
213
230
|
// adjacent to the product is worse than one measuring nothing, because it reads as if it
|
|
214
231
|
// measured the product.
|
|
215
|
-
|
|
232
|
+
// @implements A-SPEC-573.3 — and the boost is handed over UNFILTERED. This benchmark used to
|
|
233
|
+
// narrow it to the scanned set on its own, which is a filter the product did not have: the
|
|
234
|
+
// pipeline being scored was not the pipeline that shipped, so a defect worth 63.3% of the
|
|
235
|
+
// product's emitted slots read as six-decimal NO MOVEMENT here (measured, S1). Admission is
|
|
236
|
+
// the product's business now (A-SPEC-573.1/573.2), and the bench inherits whatever it does.
|
|
216
237
|
const profile = (0, commit_text_1.commitTextProfile)(corpus.root, parent, 400);
|
|
217
|
-
const ct = (0, commit_text_1.rankByCommitText)(c.subject, profile, 300)
|
|
238
|
+
const ct = (0, commit_text_1.rankByCommitText)(c.subject, profile, 300);
|
|
218
239
|
const top = ct[0]?.score ?? 0;
|
|
219
240
|
const commitTextBoost = {};
|
|
220
241
|
if (top > 0)
|
|
221
242
|
for (const h of ct)
|
|
222
243
|
commitTextBoost[h.file] = h.score / top;
|
|
223
|
-
const
|
|
244
|
+
const analyzeWith = (defUse) => (0, maintenance_analyze_1.analyzeMaintenance)({
|
|
224
245
|
request: c.subject, scanned, specs, graph, testAnchors: anchors, history: [], changePrior, commitTextBoost,
|
|
225
246
|
groundTruth: { files: c.files, tests: c.tests },
|
|
226
247
|
basis: { head: parent, loadedBuild: 'b', diskBuild: 'b', specFingerprint: 'fp' },
|
|
227
248
|
coverage: { scannedFiles: scanned.length, skippedFiles: [], unsupportedLanguages: [] },
|
|
249
|
+
defUse,
|
|
228
250
|
});
|
|
251
|
+
// @implements A-SPEC-573.4 — the same two-pass the handler runs, for the same reason the
|
|
252
|
+
// bench stopped filtering the boost on its own: what is measured has to be what ships.
|
|
253
|
+
const firstPass = analyzeWith();
|
|
254
|
+
const result = (() => {
|
|
255
|
+
const targets = firstPass.candidates.slice(0, defuse_bound_1.DEFUSE_TOP_FILES).map((x) => x.file);
|
|
256
|
+
if (targets.length === 0)
|
|
257
|
+
return firstPass;
|
|
258
|
+
const defUse = {};
|
|
259
|
+
const parser = new language_parser_1.TreeSitterTsParser();
|
|
260
|
+
for (const file of targets) {
|
|
261
|
+
try {
|
|
262
|
+
const lang = (0, cpg_scanner_1.langForPath)(file);
|
|
263
|
+
if (!(0, language_parser_1.hasDataFlowWalk)(lang))
|
|
264
|
+
continue;
|
|
265
|
+
const facts = parser.extractDataFlow(fs.readFileSync(path.join(dest, file), 'utf8'), lang);
|
|
266
|
+
if (facts !== undefined)
|
|
267
|
+
defUse[file] = facts;
|
|
268
|
+
}
|
|
269
|
+
catch { /* fail-open, per file */ }
|
|
270
|
+
}
|
|
271
|
+
return Object.keys(defUse).length === 0 ? firstPass : analyzeWith(defUse);
|
|
272
|
+
})();
|
|
229
273
|
const ranked = result.candidates.map((x) => x.file);
|
|
274
|
+
// @implements A-SPEC-578.7 — the power arm. Both recalls come from what already exists:
|
|
275
|
+
// the product's from `ranked` (never recomputed — recomputing is how a benchmark ends up
|
|
276
|
+
// scoring a pipeline the product does not ship, measured once already in A-SPEC-573.3),
|
|
277
|
+
// the baseline's from `rankBaseline`, the no-graph floor built in A-SPEC-356 and never
|
|
278
|
+
// called until now. The `uncited` predicate is the PRODUCT'S — `citationsIn` — so the axis
|
|
279
|
+
// is defined the same way here and there.
|
|
280
|
+
if (opts.pairedDump !== undefined && c.files.length > 0) {
|
|
281
|
+
const truth = new Set(c.files);
|
|
282
|
+
const recallOf = (files) => files.slice(0, 10).filter((f) => truth.has(f)).length / truth.size;
|
|
283
|
+
opts.pairedDump({
|
|
284
|
+
commit: c.commit,
|
|
285
|
+
uncited: (0, localize_1.citationsIn)(c.subject, new Set(specs.map((sp) => sp.id))).cited.length === 0,
|
|
286
|
+
productRecall10: recallOf(ranked),
|
|
287
|
+
baselineRecall10: recallOf((0, baseline_arm_1.rankBaseline)(c.subject, scanned, 10).map((h) => h.file)),
|
|
288
|
+
truth: truth.size,
|
|
289
|
+
});
|
|
290
|
+
}
|
|
230
291
|
// @implements A-SPEC-469 — the union scores what a caller actually RECEIVES as the impact
|
|
231
292
|
// answer, and that surface is now the graded rankedImpact (the closure stays gate-facing).
|
|
232
293
|
const impacted = (result.impacts?.rankedImpact ?? []).map((r) => r.file);
|
|
233
|
-
|
|
294
|
+
// @implements A-SPEC-573.3 — the function axis, derived from what this loop already holds:
|
|
295
|
+
// the commit's parent-side changed lines and the parent-time scan's symbol ranges. A case
|
|
296
|
+
// whose change fell outside every symbol contributes no truth and drops out of that
|
|
297
|
+
// denominator; it is not scored zero.
|
|
298
|
+
const byPathScanned = new Map(scanned.map((f) => [f.sourcePath, f]));
|
|
299
|
+
const truthSymbols = [];
|
|
300
|
+
for (const file of c.files) {
|
|
301
|
+
const sf = byPathScanned.get(file);
|
|
302
|
+
if (sf === undefined)
|
|
303
|
+
continue;
|
|
304
|
+
const ranges = sf.symbols.map((sy) => ({ name: sy.qualifiedName, startLine: sy.startLine, endLine: sy.endLine }));
|
|
305
|
+
for (const name of (0, symbol_truth_1.symbolsTouched)(ranges, (0, replay_corpus_1.changedParentLines)(corpus.root, c.commit, file))) {
|
|
306
|
+
if (!truthSymbols.includes(name))
|
|
307
|
+
truthSymbols.push(name);
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
// The prediction is the symbols the emitted candidates carried, in candidate order — the
|
|
311
|
+
// same list a caller reads, never a separately computed one.
|
|
312
|
+
const rankedSymbols = [];
|
|
313
|
+
for (const cand of result.candidates) {
|
|
314
|
+
for (const sy of cand.symbols)
|
|
315
|
+
if (!rankedSymbols.includes(sy))
|
|
316
|
+
rankedSymbols.push(sy);
|
|
317
|
+
}
|
|
318
|
+
outcomes.push({ ranked, truthFiles: c.files, selectedTests: [...result.relevantTests], truthTests: c.tests,
|
|
319
|
+
truthSymbols, rankedSymbols });
|
|
234
320
|
// @implements A-SPEC-487 — the dump: 2-pass with semantic injected when asked (vectors
|
|
235
321
|
// only for the files the 1-pass surfaced — the hot-path lookup contract holds), 1-pass
|
|
236
322
|
// otherwise. Nothing here feeds outcomes or any pin.
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export interface SymbolRange {
|
|
2
|
+
name: string;
|
|
3
|
+
startLine: number;
|
|
4
|
+
endLine: number;
|
|
5
|
+
}
|
|
6
|
+
/**
|
|
7
|
+
* The symbols a commit's changed lines fall inside, in symbol-list order, deduped.
|
|
8
|
+
*
|
|
9
|
+
* A change that lands outside every symbol (an import line, a top-level constant) yields nothing —
|
|
10
|
+
* and a case with no symbol truth drops OUT of the symbol denominator rather than scoring zero. An
|
|
11
|
+
* unanswerable case is not a failed one, which is the same rule `evaluationMetrics` already applies
|
|
12
|
+
* to files.
|
|
13
|
+
*/
|
|
14
|
+
export declare function symbolsTouched(symbols: readonly SymbolRange[], changedLines: readonly number[]): string[];
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.symbolsTouched = symbolsTouched;
|
|
4
|
+
/**
|
|
5
|
+
* The symbols a commit's changed lines fall inside, in symbol-list order, deduped.
|
|
6
|
+
*
|
|
7
|
+
* A change that lands outside every symbol (an import line, a top-level constant) yields nothing —
|
|
8
|
+
* and a case with no symbol truth drops OUT of the symbol denominator rather than scoring zero. An
|
|
9
|
+
* unanswerable case is not a failed one, which is the same rule `evaluationMetrics` already applies
|
|
10
|
+
* to files.
|
|
11
|
+
*/
|
|
12
|
+
function symbolsTouched(symbols, changedLines) {
|
|
13
|
+
if (symbols.length === 0 || changedLines.length === 0)
|
|
14
|
+
return [];
|
|
15
|
+
const out = [];
|
|
16
|
+
for (const s of symbols) {
|
|
17
|
+
// Both ends inclusive: the signature line and the closing line are part of the symbol.
|
|
18
|
+
if (changedLines.some((line) => line >= s.startLine && line <= s.endLine) && !out.includes(s.name)) {
|
|
19
|
+
out.push(s.name);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
return out;
|
|
23
|
+
}
|
|
@@ -190,6 +190,18 @@ export declare function runGo(files: string[], mode: TestRunPlan['mode'], cwd: s
|
|
|
190
190
|
* gate that ran part of its scope has verified less than it reports.
|
|
191
191
|
*/
|
|
192
192
|
export declare function runTestScope(scope: TestScope, cwd: string): TestRunResult;
|
|
193
|
+
/**
|
|
194
|
+
* The last few lines of a runner's output, bounded by SIZE as well as by line count.
|
|
195
|
+
*
|
|
196
|
+
* A line budget is only a size budget while the lines are short, and jest `--json` breaks that
|
|
197
|
+
* assumption completely: it emits one line. Measured 2026-09-02 — a GREEN `test_run` returned
|
|
198
|
+
* 1,432,092 characters, of which this field was 1,421,327 (99.2%) across "6 lines" whose longest
|
|
199
|
+
* was 1,420,959. The usefulness was inverted: a red run gave six clean lines of stderr summary,
|
|
200
|
+
* and a green run gave the whole document.
|
|
201
|
+
*
|
|
202
|
+
* The END is what survives a cut. A runner's conclusion is always at the bottom.
|
|
203
|
+
*/
|
|
204
|
+
export declare const TAIL_MAX_CHARS = 4000;
|
|
193
205
|
export declare function tailOf(s: string, opts?: number | {
|
|
194
206
|
lines?: number;
|
|
195
207
|
maxChars?: number;
|
|
@@ -204,4 +216,19 @@ export declare function tailOf(s: string, opts?: number | {
|
|
|
204
216
|
* Returns null on anything it cannot read, and the caller falls back to the truncated original:
|
|
205
217
|
* a failed summary must not turn partial information into none.
|
|
206
218
|
*/
|
|
219
|
+
/**
|
|
220
|
+
* What failed, by name — the red counterpart to `summarizeJestJson`.
|
|
221
|
+
*
|
|
222
|
+
* Measured 2026-09-09: a red `test_run` reported "1 failed" three times running and never said
|
|
223
|
+
* which test. The failing file had to be recovered from the outcome ledger's A-SPEC list, because
|
|
224
|
+
* the red path's tail is the tail of a JSON DOCUMENT — bytes, not a name. Counts tell you a run
|
|
225
|
+
* went red; only a name tells you what to look at.
|
|
226
|
+
*
|
|
227
|
+
* The payload is located by the SAME rule `summarizeJestJson` uses (the first `{`), because a suite
|
|
228
|
+
* that logs anything prints it before the JSON — this repository's does — and two rules for one
|
|
229
|
+
* string is how the two quietly disagree.
|
|
230
|
+
*
|
|
231
|
+
* Only the FIRST line of a failure message: one failure, one look. The rest is in the file.
|
|
232
|
+
*/
|
|
233
|
+
export declare function failedTestSummary(stdout: string, limit?: number): string | null;
|
|
207
234
|
export declare function summarizeJestJson(stdout: string): string | null;
|
|
@@ -33,6 +33,7 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
33
33
|
};
|
|
34
34
|
})();
|
|
35
35
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.TAIL_MAX_CHARS = void 0;
|
|
36
37
|
exports.planTestRun = planTestRun;
|
|
37
38
|
exports.ecosystemOf = ecosystemOf;
|
|
38
39
|
exports.parseGoTestJson = parseGoTestJson;
|
|
@@ -49,6 +50,7 @@ exports.runDotnet = runDotnet;
|
|
|
49
50
|
exports.runGo = runGo;
|
|
50
51
|
exports.runTestScope = runTestScope;
|
|
51
52
|
exports.tailOf = tailOf;
|
|
53
|
+
exports.failedTestSummary = failedTestSummary;
|
|
52
54
|
exports.summarizeJestJson = summarizeJestJson;
|
|
53
55
|
// @implements A-SPEC-102.1
|
|
54
56
|
const node_child_process_1 = require("node:child_process");
|
|
@@ -304,7 +306,11 @@ function runJest(files, mode, cwd) {
|
|
|
304
306
|
const err = e;
|
|
305
307
|
// A failing suite still emits --json on stdout, so execution evidence — and the red/green outcome
|
|
306
308
|
// classification (A-SPEC-534.1) — survives a red run.
|
|
307
|
-
|
|
309
|
+
// @implements A-SPEC-577.1 — the names first, then jest's own summary from stderr. Without the
|
|
310
|
+
// first half this tail was the tail of a JSON document: it said how many failed and never which.
|
|
311
|
+
const named = failedTestSummary(err.stdout ?? '');
|
|
312
|
+
const tail = tailOf(`${named === null ? (err.stdout ?? '') : named}\n${err.stderr ?? err.message ?? ''}`, { lines: named === null ? 6 : 40 });
|
|
313
|
+
return { passed: false, tail, executed: parseExecutedCounts(err.stdout ?? '', cwd), outcomes: classifyJestOutcomes(err.stdout ?? '', cwd) };
|
|
308
314
|
}
|
|
309
315
|
}
|
|
310
316
|
/**
|
|
@@ -703,11 +709,11 @@ function runTestScope(scope, cwd) {
|
|
|
703
709
|
*
|
|
704
710
|
* The END is what survives a cut. A runner's conclusion is always at the bottom.
|
|
705
711
|
*/
|
|
706
|
-
|
|
712
|
+
exports.TAIL_MAX_CHARS = 4000;
|
|
707
713
|
function tailOf(s, opts = {}) {
|
|
708
714
|
// The numeric spelling is the one the go adapter uses (`tailOf(err.stderr, 2)`); keeping it means
|
|
709
715
|
// this change cannot silently alter a caller that only ever wanted fewer lines.
|
|
710
|
-
const { lines = 6, maxChars = TAIL_MAX_CHARS } = typeof opts === 'number' ? { lines: opts } : opts;
|
|
716
|
+
const { lines = 6, maxChars = exports.TAIL_MAX_CHARS } = typeof opts === 'number' ? { lines: opts } : opts;
|
|
711
717
|
const picked = s.trim().split('\n').slice(-lines).join('\n');
|
|
712
718
|
if (picked.length <= maxChars)
|
|
713
719
|
return picked;
|
|
@@ -725,6 +731,56 @@ function tailOf(s, opts = {}) {
|
|
|
725
731
|
* Returns null on anything it cannot read, and the caller falls back to the truncated original:
|
|
726
732
|
* a failed summary must not turn partial information into none.
|
|
727
733
|
*/
|
|
734
|
+
// @implements A-SPEC-577.1
|
|
735
|
+
/**
|
|
736
|
+
* What failed, by name — the red counterpart to `summarizeJestJson`.
|
|
737
|
+
*
|
|
738
|
+
* Measured 2026-09-09: a red `test_run` reported "1 failed" three times running and never said
|
|
739
|
+
* which test. The failing file had to be recovered from the outcome ledger's A-SPEC list, because
|
|
740
|
+
* the red path's tail is the tail of a JSON DOCUMENT — bytes, not a name. Counts tell you a run
|
|
741
|
+
* went red; only a name tells you what to look at.
|
|
742
|
+
*
|
|
743
|
+
* The payload is located by the SAME rule `summarizeJestJson` uses (the first `{`), because a suite
|
|
744
|
+
* that logs anything prints it before the JSON — this repository's does — and two rules for one
|
|
745
|
+
* string is how the two quietly disagree.
|
|
746
|
+
*
|
|
747
|
+
* Only the FIRST line of a failure message: one failure, one look. The rest is in the file.
|
|
748
|
+
*/
|
|
749
|
+
function failedTestSummary(stdout, limit = 5) {
|
|
750
|
+
const start = stdout.indexOf('{');
|
|
751
|
+
if (start < 0)
|
|
752
|
+
return null;
|
|
753
|
+
let j;
|
|
754
|
+
try {
|
|
755
|
+
j = JSON.parse(stdout.slice(start));
|
|
756
|
+
}
|
|
757
|
+
catch {
|
|
758
|
+
return null;
|
|
759
|
+
}
|
|
760
|
+
if (j === null || typeof j !== 'object' || !Array.isArray(j.testResults))
|
|
761
|
+
return null;
|
|
762
|
+
const failures = [];
|
|
763
|
+
for (const file of j.testResults) {
|
|
764
|
+
const base = typeof file?.name === 'string' ? file.name.split(/[\\/]/).pop() ?? file.name : '(unknown file)';
|
|
765
|
+
for (const a of Array.isArray(file?.assertionResults) ? file.assertionResults : []) {
|
|
766
|
+
if (a?.status !== 'failed')
|
|
767
|
+
continue;
|
|
768
|
+
const msg = Array.isArray(a.failureMessages) ? a.failureMessages.find((m) => typeof m === 'string' && m.trim() !== '') : undefined;
|
|
769
|
+
failures.push({
|
|
770
|
+
where: `${base} \u203a ${String(a.title ?? '(untitled)')}`,
|
|
771
|
+
first: typeof msg === 'string' ? (msg.trim().split('\n')[0] ?? '') : '',
|
|
772
|
+
});
|
|
773
|
+
}
|
|
774
|
+
}
|
|
775
|
+
if (failures.length === 0)
|
|
776
|
+
return null;
|
|
777
|
+
const shown = failures.slice(0, Math.max(0, limit));
|
|
778
|
+
const lines = shown.flatMap((f) => (f.first === '' ? [f.where] : [f.where, ` ${f.first}`]));
|
|
779
|
+
const rest = failures.length - shown.length;
|
|
780
|
+
if (rest > 0)
|
|
781
|
+
lines.push(` \u2026 \uc678 ${rest}\uac74`);
|
|
782
|
+
return lines.join('\n');
|
|
783
|
+
}
|
|
728
784
|
function summarizeJestJson(stdout) {
|
|
729
785
|
// The payload is not the whole of stdout. A suite that logs anything prints it BEFORE the JSON,
|
|
730
786
|
// and this repository's own suite does: measured, the first live run after this function landed
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { DataFlowFacts } from '../cpg/language-parser';
|
|
2
|
+
/**
|
|
3
|
+
* Order a file's function names by how close they sit, in def-use terms, to what the request says.
|
|
4
|
+
*
|
|
5
|
+
* Three tiers, stable within each so the result is deterministic (the downstream lists are
|
|
6
|
+
* position-indexed): functions the request names directly, functions one def-use step from those,
|
|
7
|
+
* then everything else in its original order. A term matching nothing leaves the order untouched —
|
|
8
|
+
* an empty first tier must not shuffle the list.
|
|
9
|
+
*/
|
|
10
|
+
export declare function rankSymbolsByDefUse(facts: DataFlowFacts, terms: readonly string[], order: readonly string[]): string[];
|
|
11
|
+
/**
|
|
12
|
+
* Append what def-use suggested behind what the lexical layer matched.
|
|
13
|
+
*
|
|
14
|
+
* Never in front: this repository has already measured a re-ranker deleting the lexical answer, so
|
|
15
|
+
* a second signal rides behind the first rather than replacing it.
|
|
16
|
+
*/
|
|
17
|
+
export declare function enrichCandidateSymbols(lexical: readonly string[], suggested: readonly string[]): string[];
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.rankSymbolsByDefUse = rankSymbolsByDefUse;
|
|
4
|
+
exports.enrichCandidateSymbols = enrichCandidateSymbols;
|
|
5
|
+
/** Names a function mentions anywhere the walk recovered them: params, defs, and call arguments. */
|
|
6
|
+
function surfaceOf(facts) {
|
|
7
|
+
const out = new Map();
|
|
8
|
+
const push = (fn, ...words) => {
|
|
9
|
+
const bucket = out.get(fn) ?? out.set(fn, []).get(fn);
|
|
10
|
+
for (const w of words)
|
|
11
|
+
if (w !== '' && !bucket.includes(w))
|
|
12
|
+
bucket.push(w);
|
|
13
|
+
};
|
|
14
|
+
for (const p of facts.params)
|
|
15
|
+
push(p.fn, p.name);
|
|
16
|
+
for (const d of facts.defs)
|
|
17
|
+
push(d.fn, d.name, ...d.expr.refs, ...d.expr.callees);
|
|
18
|
+
for (const c of facts.calls) {
|
|
19
|
+
push(c.fn, c.callee);
|
|
20
|
+
for (const a of c.args)
|
|
21
|
+
push(c.fn, ...a.refs, ...a.callees);
|
|
22
|
+
}
|
|
23
|
+
for (const r of facts.returns)
|
|
24
|
+
push(r.fn, ...r.expr.refs, ...r.expr.callees);
|
|
25
|
+
return out;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Order a file's function names by how close they sit, in def-use terms, to what the request says.
|
|
29
|
+
*
|
|
30
|
+
* Three tiers, stable within each so the result is deterministic (the downstream lists are
|
|
31
|
+
* position-indexed): functions the request names directly, functions one def-use step from those,
|
|
32
|
+
* then everything else in its original order. A term matching nothing leaves the order untouched —
|
|
33
|
+
* an empty first tier must not shuffle the list.
|
|
34
|
+
*/
|
|
35
|
+
function rankSymbolsByDefUse(facts, terms, order) {
|
|
36
|
+
if (order.length === 0)
|
|
37
|
+
return [];
|
|
38
|
+
const lowered = terms.map((t) => t.toLowerCase()).filter((t) => t !== '');
|
|
39
|
+
if (lowered.length === 0)
|
|
40
|
+
return [...order];
|
|
41
|
+
const surface = surfaceOf(facts);
|
|
42
|
+
// A function is "named" when a request term appears in its own name or in any identifier its
|
|
43
|
+
// body touches. Substring, not equality: `config` has to reach `configPath`.
|
|
44
|
+
const names = (fn) => [fn, ...(surface.get(fn) ?? [])];
|
|
45
|
+
const direct = new Set();
|
|
46
|
+
for (const fn of new Set([...order, ...surface.keys()])) {
|
|
47
|
+
if (names(fn).some((n) => lowered.some((t) => n.toLowerCase().includes(t))))
|
|
48
|
+
direct.add(fn);
|
|
49
|
+
}
|
|
50
|
+
if (direct.size === 0)
|
|
51
|
+
return [...order];
|
|
52
|
+
// One def-use step: a function that reads what a direct one defined, or that sits on either end
|
|
53
|
+
// of a call with it. Call adjacency counts BOTH ways — the caller of a named function is as
|
|
54
|
+
// relevant as its callee, and a one-directional rule would silently favour one.
|
|
55
|
+
const definedByDirect = new Set();
|
|
56
|
+
for (const d of facts.defs)
|
|
57
|
+
if (direct.has(d.fn))
|
|
58
|
+
definedByDirect.add(d.name);
|
|
59
|
+
const near = new Set();
|
|
60
|
+
const mark = (fn) => { if (!direct.has(fn))
|
|
61
|
+
near.add(fn); };
|
|
62
|
+
for (const d of facts.defs)
|
|
63
|
+
if (d.expr.refs.some((r) => definedByDirect.has(r)))
|
|
64
|
+
mark(d.fn);
|
|
65
|
+
for (const c of facts.calls) {
|
|
66
|
+
if (c.args.some((a) => a.refs.some((r) => definedByDirect.has(r))))
|
|
67
|
+
mark(c.fn);
|
|
68
|
+
if (direct.has(c.fn))
|
|
69
|
+
mark(c.callee);
|
|
70
|
+
if (direct.has(c.callee))
|
|
71
|
+
mark(c.fn);
|
|
72
|
+
}
|
|
73
|
+
for (const r of facts.returns)
|
|
74
|
+
if (r.expr.refs.some((x) => definedByDirect.has(x)))
|
|
75
|
+
mark(r.fn);
|
|
76
|
+
const tier = (fn) => (direct.has(fn) ? 0 : near.has(fn) ? 1 : 2);
|
|
77
|
+
return [...order].sort((a, b) => tier(a) - tier(b) || order.indexOf(a) - order.indexOf(b));
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Append what def-use suggested behind what the lexical layer matched.
|
|
81
|
+
*
|
|
82
|
+
* Never in front: this repository has already measured a re-ranker deleting the lexical answer, so
|
|
83
|
+
* a second signal rides behind the first rather than replacing it.
|
|
84
|
+
*/
|
|
85
|
+
function enrichCandidateSymbols(lexical, suggested) {
|
|
86
|
+
const out = [...lexical];
|
|
87
|
+
for (const s of suggested)
|
|
88
|
+
if (!out.includes(s))
|
|
89
|
+
out.push(s);
|
|
90
|
+
return out;
|
|
91
|
+
}
|