@holmes-lab/holmes-kit 0.19.0 → 0.19.3
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 +120 -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 +51 -1
- package/dist/holmes/cli/doctor.js +211 -36
- 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/cpg/cycle-observation.d.ts +65 -0
- package/dist/holmes/cpg/cycle-observation.js +146 -0
- package/dist/holmes/governance/approval-queue.d.ts +23 -4
- package/dist/holmes/governance/approval-queue.js +44 -6
- package/dist/holmes/hooks/stop.d.ts +15 -0
- package/dist/holmes/hooks/stop.js +46 -3
- package/dist/holmes/mcp/handlers.d.ts +2 -0
- package/dist/holmes/mcp/handlers.js +29 -2
- package/dist/holmes/mcp/maintenance-analyze.d.ts +37 -0
- package/dist/holmes/mcp/maintenance-analyze.js +73 -1
- 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/failed-test-names.d.ts +19 -0
- package/dist/holmes/review/failed-test-names.js +43 -0
- package/dist/holmes/review/run-replay.d.ts +23 -0
- package/dist/holmes/review/run-replay.js +30 -0
- package/dist/holmes/review/test-runner.d.ts +27 -0
- package/dist/holmes/review/test-runner.js +59 -3
- package/docs/install-guide.md +54 -5
- package/package.json +4 -1
|
@@ -39,6 +39,8 @@ exports.artifactFrom = artifactFrom;
|
|
|
39
39
|
exports.writeArtifact = writeArtifact;
|
|
40
40
|
exports.readArtifacts = readArtifacts;
|
|
41
41
|
exports.recordOutcome = recordOutcome;
|
|
42
|
+
exports.openArtifacts = openArtifacts;
|
|
43
|
+
exports.closeOpenArtifacts = closeOpenArtifacts;
|
|
42
44
|
exports.computeCalibration = computeCalibration;
|
|
43
45
|
// @implements A-SPEC-271
|
|
44
46
|
// @implements A-SPEC-268
|
|
@@ -170,6 +172,17 @@ function readArtifacts(dir) {
|
|
|
170
172
|
/**
|
|
171
173
|
* Attach what actually happened to a prediction that was actually made. An unknown digest is
|
|
172
174
|
* refused: inventing the prediction alongside the outcome would let the record score itself.
|
|
175
|
+
*
|
|
176
|
+
* @implements A-SPEC-578.6
|
|
177
|
+
* MERGES into any outcome already there. It used to REPLACE, and the defect was found by following
|
|
178
|
+
* this repository's own instruction: A-SPEC-578.4 made `test_run` attach the 12 files a slice
|
|
179
|
+
* actually changed, AGENTS.md then said "call maintenance_outcome yourself only to add a judged
|
|
180
|
+
* classification" — and that call left `actualFiles: []`. The documented workflow destroyed the
|
|
181
|
+
* data it existed to complete.
|
|
182
|
+
*
|
|
183
|
+
* Omission and erasure are DIFFERENT ACTS: a field left out keeps its value, a field given as an
|
|
184
|
+
* empty array is cleared. Without the second, a wrong record could never be corrected; without the
|
|
185
|
+
* first, completing a record destroys it.
|
|
173
186
|
*/
|
|
174
187
|
function recordOutcome(dir, digest, outcome, recordedAt) {
|
|
175
188
|
const file = path.join(dir, digestFilename(digest));
|
|
@@ -177,19 +190,73 @@ function recordOutcome(dir, digest, outcome, recordedAt) {
|
|
|
177
190
|
throw new Error(`Refusing to record an outcome for an unknown analysis digest: ${digest}`);
|
|
178
191
|
}
|
|
179
192
|
const artifact = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
193
|
+
const prev = artifact.outcome;
|
|
194
|
+
const merged = (given, before) => given === undefined ? (before ?? []) : sortedUnique(given);
|
|
195
|
+
const classification = outcome.actualClassification ?? prev?.actualClassification;
|
|
180
196
|
const updated = {
|
|
181
197
|
...artifact,
|
|
182
198
|
outcome: {
|
|
183
199
|
recordedAt,
|
|
184
|
-
actualFiles:
|
|
185
|
-
actualSymbols:
|
|
186
|
-
actualTests:
|
|
187
|
-
...(
|
|
200
|
+
actualFiles: merged(outcome.actualFiles, prev?.actualFiles),
|
|
201
|
+
actualSymbols: merged(outcome.actualSymbols, prev?.actualSymbols),
|
|
202
|
+
actualTests: merged(outcome.actualTests, prev?.actualTests),
|
|
203
|
+
...(classification ? { actualClassification: classification } : {}),
|
|
188
204
|
},
|
|
189
205
|
};
|
|
190
206
|
writeAtomic(file, `${JSON.stringify(updated, null, 2)}\n`);
|
|
191
207
|
return updated;
|
|
192
208
|
}
|
|
209
|
+
// @implements A-SPEC-578.4
|
|
210
|
+
/**
|
|
211
|
+
* Predictions still waiting to be scored — the loop's missing second half.
|
|
212
|
+
*
|
|
213
|
+
* `since` bounds the window: an analysis recorded before the previous evidence run belonged to a
|
|
214
|
+
* previous slice, and attributing today's changes to it would be contamination rather than
|
|
215
|
+
* measurement. The boundary is EXCLUSIVE (`> since`) because the previous run already saw that
|
|
216
|
+
* instant.
|
|
217
|
+
*/
|
|
218
|
+
function openArtifacts(dir, since) {
|
|
219
|
+
const { artifacts } = readArtifacts(dir);
|
|
220
|
+
return artifacts.filter((a) => {
|
|
221
|
+
if (a.outcome !== undefined)
|
|
222
|
+
return false;
|
|
223
|
+
if (since === undefined)
|
|
224
|
+
return true;
|
|
225
|
+
const t = Date.parse(a.recordedAt);
|
|
226
|
+
const s = Date.parse(since);
|
|
227
|
+
return !Number.isFinite(t) || !Number.isFinite(s) ? true : t > s;
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
/**
|
|
231
|
+
* @implements A-SPEC-578.4
|
|
232
|
+
* Attach what a slice ACTUALLY touched to the predictions it made.
|
|
233
|
+
*
|
|
234
|
+
* Measured 2026-09-09: `maintenance_calibration` had reported `n:0, artifacts:0` since the loop was
|
|
235
|
+
* built, because `maintenance_analyze({persist:true})` is instructed (AGENTS.md step 3) while
|
|
236
|
+
* `maintenance_outcome` is instructed nowhere at all. The write path was healthy the whole time —
|
|
237
|
+
* one call produced an artifact immediately. What was missing was the call.
|
|
238
|
+
*
|
|
239
|
+
* TWO REFUSALS make this a measurement rather than a self-graded exam:
|
|
240
|
+
* - `actualClassification` is never filled in. That is a judgement, and a loop that supplies its
|
|
241
|
+
* own judgement scores itself; the bins stay `insufficient-data` and only the objective half —
|
|
242
|
+
* file-level false positives and negatives — accrues.
|
|
243
|
+
* - An empty change set closes nothing. Attributing "nothing changed" would turn every prediction
|
|
244
|
+
* into a false positive: a measurement of nothing, recorded as a failure of everything.
|
|
245
|
+
*/
|
|
246
|
+
function closeOpenArtifacts(dir, actual, recordedAt, since) {
|
|
247
|
+
const files = Array.isArray(actual?.files) ? actual.files : [];
|
|
248
|
+
if (files.length === 0)
|
|
249
|
+
return { closed: [] };
|
|
250
|
+
const closed = [];
|
|
251
|
+
for (const a of openArtifacts(dir, since)) {
|
|
252
|
+
try {
|
|
253
|
+
recordOutcome(dir, a.digest, { actualFiles: files }, recordedAt);
|
|
254
|
+
closed.push(a.digest);
|
|
255
|
+
}
|
|
256
|
+
catch { /* an artifact we cannot write is not a reason to abandon the rest */ }
|
|
257
|
+
}
|
|
258
|
+
return { closed };
|
|
259
|
+
}
|
|
193
260
|
const BIN_EDGES = [0, 0.2, 0.4, 0.6, 0.8, 1];
|
|
194
261
|
/**
|
|
195
262
|
* Reliability bins plus a Brier score. `minSamples` is the honesty knob: below it a bin reports
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @implements A-SPEC-579
|
|
3
|
+
* npm 12 blocks dependency install scripts unless the ROOT package.json's `allowScripts` covers
|
|
4
|
+
* them ("Install commands silently skip lifecycle scripts for any dependency that does not have a
|
|
5
|
+
* matching entry" — npm docs, cli/v12/commands/npm-install-scripts). Measured 2026-09-09 on
|
|
6
|
+
* Windows (npm 12.0.1, Node 24.19.0): `npm ci` succeeded, nine install scripts were skipped, and
|
|
7
|
+
* better-sqlite3 had no binary — while the eight tree-sitter packages still loaded, because their
|
|
8
|
+
* `node-gyp-build` runtime loader finds `prebuilds/<platform>-<arch>` without the script ever
|
|
9
|
+
* running. So the minimum approval this repository needs is ONE package, pinned to the lockfile
|
|
10
|
+
* version so a dependency bump forces a fresh review.
|
|
11
|
+
*
|
|
12
|
+
* This module is the pure re-statement of that rule: no I/O, no process, no platform — the same
|
|
13
|
+
* verdict on every OS, shared by the test that pins package.json and by doctor (A-SPEC-580).
|
|
14
|
+
*/
|
|
15
|
+
/** Dependencies whose install script must actually RUN for the module to load. */
|
|
16
|
+
export declare const NATIVE_INSTALL_SCRIPT_DEPS: readonly ["better-sqlite3"];
|
|
17
|
+
/** Native dependencies that ship prebuilds and load with the script blocked (measured). */
|
|
18
|
+
export declare const PREBUILT_NATIVE_DEPS: readonly ["tree-sitter", "tree-sitter-typescript", "tree-sitter-python", "tree-sitter-c-sharp", "tree-sitter-java", "tree-sitter-go", "tree-sitter-rust", "tree-sitter-cpp"];
|
|
19
|
+
export type AllowScripts = Record<string, boolean>;
|
|
20
|
+
export type Coverage = 'approved-pinned' | 'approved-unpinned' | 'denied' | 'uncovered';
|
|
21
|
+
export interface LockLike {
|
|
22
|
+
packages?: Record<string, {
|
|
23
|
+
version?: string;
|
|
24
|
+
hasInstallScript?: boolean;
|
|
25
|
+
}>;
|
|
26
|
+
}
|
|
27
|
+
export type PolicyProblem = {
|
|
28
|
+
kind: 'reapproval-required';
|
|
29
|
+
name: string;
|
|
30
|
+
lockVersion: string;
|
|
31
|
+
approved: string[];
|
|
32
|
+
fix: string;
|
|
33
|
+
} | {
|
|
34
|
+
kind: 'unpinned';
|
|
35
|
+
name: string;
|
|
36
|
+
fix: string;
|
|
37
|
+
} | {
|
|
38
|
+
kind: 'denied';
|
|
39
|
+
name: string;
|
|
40
|
+
fix: string;
|
|
41
|
+
} | {
|
|
42
|
+
kind: 'beyond-minimum';
|
|
43
|
+
key: string;
|
|
44
|
+
fix: string;
|
|
45
|
+
} | {
|
|
46
|
+
kind: 'missing-from-lock';
|
|
47
|
+
name: string;
|
|
48
|
+
};
|
|
49
|
+
/** The exact command npm 12 documents for a pinned approval. */
|
|
50
|
+
export declare function approveCommand(name: string, version: string, npmBin?: string): string;
|
|
51
|
+
/**
|
|
52
|
+
* How `allowScripts` covers one package at one version, under npm 12's rules: a pinned `true`
|
|
53
|
+
* covers that version only, a bare `true` covers every version, and a `false` on either key is
|
|
54
|
+
* a denial that survives any approval (npm: deny "survives `approve --all`").
|
|
55
|
+
*/
|
|
56
|
+
export declare function allowScriptsCoverage(allow: AllowScripts | undefined, name: string, version: string): Coverage;
|
|
57
|
+
/**
|
|
58
|
+
* What `npm install-scripts ls` would list: every lock entry with an install script that
|
|
59
|
+
* `allowScripts` does not approve. The root entry (`""`) is the project itself, never a dependency.
|
|
60
|
+
*/
|
|
61
|
+
export declare function blockedInstallScripts(lock: LockLike, allow: AllowScripts | undefined): {
|
|
62
|
+
name: string;
|
|
63
|
+
version: string;
|
|
64
|
+
}[];
|
|
65
|
+
/**
|
|
66
|
+
* This repository's policy, judged: every script-requiring dependency approved AND pinned to the
|
|
67
|
+
* lock version, and nothing else approved. Pure; never throws.
|
|
68
|
+
*/
|
|
69
|
+
export declare function policyVerdict(pkg: {
|
|
70
|
+
allowScripts?: AllowScripts;
|
|
71
|
+
}, lock: LockLike, opts?: {
|
|
72
|
+
npmBin?: string;
|
|
73
|
+
}): {
|
|
74
|
+
ok: boolean;
|
|
75
|
+
problems: PolicyProblem[];
|
|
76
|
+
};
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// @implements A-SPEC-579
|
|
3
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
4
|
+
exports.PREBUILT_NATIVE_DEPS = exports.NATIVE_INSTALL_SCRIPT_DEPS = void 0;
|
|
5
|
+
exports.approveCommand = approveCommand;
|
|
6
|
+
exports.allowScriptsCoverage = allowScriptsCoverage;
|
|
7
|
+
exports.blockedInstallScripts = blockedInstallScripts;
|
|
8
|
+
exports.policyVerdict = policyVerdict;
|
|
9
|
+
/**
|
|
10
|
+
* @implements A-SPEC-579
|
|
11
|
+
* npm 12 blocks dependency install scripts unless the ROOT package.json's `allowScripts` covers
|
|
12
|
+
* them ("Install commands silently skip lifecycle scripts for any dependency that does not have a
|
|
13
|
+
* matching entry" — npm docs, cli/v12/commands/npm-install-scripts). Measured 2026-09-09 on
|
|
14
|
+
* Windows (npm 12.0.1, Node 24.19.0): `npm ci` succeeded, nine install scripts were skipped, and
|
|
15
|
+
* better-sqlite3 had no binary — while the eight tree-sitter packages still loaded, because their
|
|
16
|
+
* `node-gyp-build` runtime loader finds `prebuilds/<platform>-<arch>` without the script ever
|
|
17
|
+
* running. So the minimum approval this repository needs is ONE package, pinned to the lockfile
|
|
18
|
+
* version so a dependency bump forces a fresh review.
|
|
19
|
+
*
|
|
20
|
+
* This module is the pure re-statement of that rule: no I/O, no process, no platform — the same
|
|
21
|
+
* verdict on every OS, shared by the test that pins package.json and by doctor (A-SPEC-580).
|
|
22
|
+
*/
|
|
23
|
+
/** Dependencies whose install script must actually RUN for the module to load. */
|
|
24
|
+
exports.NATIVE_INSTALL_SCRIPT_DEPS = ['better-sqlite3'];
|
|
25
|
+
/** Native dependencies that ship prebuilds and load with the script blocked (measured). */
|
|
26
|
+
exports.PREBUILT_NATIVE_DEPS = [
|
|
27
|
+
'tree-sitter', 'tree-sitter-typescript', 'tree-sitter-python', 'tree-sitter-c-sharp',
|
|
28
|
+
'tree-sitter-java', 'tree-sitter-go', 'tree-sitter-rust', 'tree-sitter-cpp',
|
|
29
|
+
];
|
|
30
|
+
/** The exact command npm 12 documents for a pinned approval. */
|
|
31
|
+
function approveCommand(name, version, npmBin = 'npm') {
|
|
32
|
+
return `${npmBin} install-scripts approve ${name}@${version}`;
|
|
33
|
+
}
|
|
34
|
+
/** Split an allowScripts key into its package name and optional pinned version (scopes kept). */
|
|
35
|
+
function splitKey(key) {
|
|
36
|
+
const at = key.lastIndexOf('@');
|
|
37
|
+
if (at <= 0)
|
|
38
|
+
return { name: key }; // '@scope/pkg' or 'pkg'
|
|
39
|
+
return { name: key.slice(0, at), version: key.slice(at + 1) };
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* How `allowScripts` covers one package at one version, under npm 12's rules: a pinned `true`
|
|
43
|
+
* covers that version only, a bare `true` covers every version, and a `false` on either key is
|
|
44
|
+
* a denial that survives any approval (npm: deny "survives `approve --all`").
|
|
45
|
+
*/
|
|
46
|
+
function allowScriptsCoverage(allow, name, version) {
|
|
47
|
+
if (!allow)
|
|
48
|
+
return 'uncovered';
|
|
49
|
+
const pinned = allow[`${name}@${version}`];
|
|
50
|
+
const bare = allow[name];
|
|
51
|
+
if (pinned === false || bare === false)
|
|
52
|
+
return 'denied';
|
|
53
|
+
if (pinned === true)
|
|
54
|
+
return 'approved-pinned';
|
|
55
|
+
if (bare === true)
|
|
56
|
+
return 'approved-unpinned';
|
|
57
|
+
return 'uncovered';
|
|
58
|
+
}
|
|
59
|
+
/** The package name of a lockfile `packages` key: the segment after its LAST `node_modules/`. */
|
|
60
|
+
function lockEntryName(key) {
|
|
61
|
+
const idx = key.lastIndexOf('node_modules/');
|
|
62
|
+
if (idx < 0)
|
|
63
|
+
return null;
|
|
64
|
+
const name = key.slice(idx + 'node_modules/'.length);
|
|
65
|
+
return name === '' ? null : name;
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* What `npm install-scripts ls` would list: every lock entry with an install script that
|
|
69
|
+
* `allowScripts` does not approve. The root entry (`""`) is the project itself, never a dependency.
|
|
70
|
+
*/
|
|
71
|
+
function blockedInstallScripts(lock, allow) {
|
|
72
|
+
const out = [];
|
|
73
|
+
for (const [key, entry] of Object.entries(lock.packages ?? {})) {
|
|
74
|
+
if (!entry?.hasInstallScript)
|
|
75
|
+
continue;
|
|
76
|
+
const name = lockEntryName(key);
|
|
77
|
+
if (name === null)
|
|
78
|
+
continue;
|
|
79
|
+
const version = entry.version ?? '';
|
|
80
|
+
const cov = allowScriptsCoverage(allow, name, version);
|
|
81
|
+
if (cov !== 'approved-pinned' && cov !== 'approved-unpinned')
|
|
82
|
+
out.push({ name, version });
|
|
83
|
+
}
|
|
84
|
+
return out;
|
|
85
|
+
}
|
|
86
|
+
/** The top-level lock version of a dependency (`node_modules/<name>`), if present. */
|
|
87
|
+
function lockVersionOf(lock, name) {
|
|
88
|
+
return lock.packages?.[`node_modules/${name}`]?.version;
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* This repository's policy, judged: every script-requiring dependency approved AND pinned to the
|
|
92
|
+
* lock version, and nothing else approved. Pure; never throws.
|
|
93
|
+
*/
|
|
94
|
+
function policyVerdict(pkg, lock, opts) {
|
|
95
|
+
const npmBin = opts?.npmBin ?? 'npm';
|
|
96
|
+
const allow = pkg.allowScripts;
|
|
97
|
+
const problems = [];
|
|
98
|
+
for (const name of exports.NATIVE_INSTALL_SCRIPT_DEPS) {
|
|
99
|
+
const lockVersion = lockVersionOf(lock, name);
|
|
100
|
+
if (lockVersion === undefined) {
|
|
101
|
+
problems.push({ kind: 'missing-from-lock', name });
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
const fix = approveCommand(name, lockVersion, npmBin);
|
|
105
|
+
switch (allowScriptsCoverage(allow, name, lockVersion)) {
|
|
106
|
+
case 'approved-pinned': break;
|
|
107
|
+
case 'approved-unpinned':
|
|
108
|
+
problems.push({ kind: 'unpinned', name, fix });
|
|
109
|
+
break;
|
|
110
|
+
case 'denied':
|
|
111
|
+
problems.push({ kind: 'denied', name, fix });
|
|
112
|
+
break;
|
|
113
|
+
case 'uncovered': {
|
|
114
|
+
const approved = Object.entries(allow ?? {})
|
|
115
|
+
.filter(([k, v]) => v === true && splitKey(k).name === name && splitKey(k).version !== undefined)
|
|
116
|
+
.map(([k]) => splitKey(k).version);
|
|
117
|
+
problems.push({ kind: 'reapproval-required', name, lockVersion, approved, fix });
|
|
118
|
+
break;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
const minimum = new Set(exports.NATIVE_INSTALL_SCRIPT_DEPS);
|
|
123
|
+
for (const [key, value] of Object.entries(allow ?? {})) {
|
|
124
|
+
if (value !== true)
|
|
125
|
+
continue; // a denial is never "beyond" the minimum
|
|
126
|
+
if (!minimum.has(splitKey(key).name)) {
|
|
127
|
+
problems.push({ kind: 'beyond-minimum', key, fix: `Remove "${key}" from allowScripts in package.json — it loads from its shipped prebuilds without an install script.` });
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
return { ok: problems.length === 0, problems };
|
|
131
|
+
}
|
|
@@ -8,3 +8,9 @@
|
|
|
8
8
|
* platform must never flip a POSIX hint to a Windows-only form.
|
|
9
9
|
*/
|
|
10
10
|
export declare function npxBin(platform?: string): string;
|
|
11
|
+
/**
|
|
12
|
+
* @implements A-SPEC-580
|
|
13
|
+
* The npm twin: the recovery commands doctor emits (`npm install-scripts approve`, `npm rebuild`)
|
|
14
|
+
* hit the same `.ps1` shim on Windows (measured 2026-09-09: npm.ps1 blocked, npm.cmd runs).
|
|
15
|
+
*/
|
|
16
|
+
export declare function npmBin(platform?: string): string;
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
// @implements A-SPEC-542.1
|
|
3
|
+
// @implements A-SPEC-580
|
|
3
4
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
4
5
|
exports.npxBin = npxBin;
|
|
6
|
+
exports.npmBin = npmBin;
|
|
5
7
|
/**
|
|
6
8
|
* @implements A-SPEC-542.1
|
|
7
9
|
* The npx binary an OPERATOR can actually run on the platform this process runs on — which is the
|
|
@@ -14,3 +16,11 @@ exports.npxBin = npxBin;
|
|
|
14
16
|
function npxBin(platform = process.platform) {
|
|
15
17
|
return platform === 'win32' ? 'npx.cmd' : 'npx';
|
|
16
18
|
}
|
|
19
|
+
/**
|
|
20
|
+
* @implements A-SPEC-580
|
|
21
|
+
* The npm twin: the recovery commands doctor emits (`npm install-scripts approve`, `npm rebuild`)
|
|
22
|
+
* hit the same `.ps1` shim on Windows (measured 2026-09-09: npm.ps1 blocked, npm.cmd runs).
|
|
23
|
+
*/
|
|
24
|
+
function npmBin(platform = process.platform) {
|
|
25
|
+
return platform === 'win32' ? 'npm.cmd' : 'npm';
|
|
26
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The names behind a red release gate.
|
|
3
|
+
*
|
|
4
|
+
* Measured 2026-09-10: `npm publish` refused with "스위트가 붉습니다" and nothing else, and finding
|
|
5
|
+
* out which test had failed cost two more full-suite runs. `test_run` learned to name its failures
|
|
6
|
+
* in REQ-577; the release gate had not. A gate that says "red" without saying what is a gate that
|
|
7
|
+
* makes the next person guess.
|
|
8
|
+
*/
|
|
9
|
+
/** How many names a refusal lists before it counts the rest. */
|
|
10
|
+
export declare const FAILED_NAME_CAP = 10;
|
|
11
|
+
/**
|
|
12
|
+
* Pull the failing test names out of a jest run's output.
|
|
13
|
+
*
|
|
14
|
+
* PURE, and forgiving: output it cannot read yields an empty list so the caller keeps its existing
|
|
15
|
+
* refusal rather than replacing a working message with an empty one. Jest prints the summary block
|
|
16
|
+
* twice on some configurations, so names are de-duplicated — the same failure listed twice is one
|
|
17
|
+
* failure.
|
|
18
|
+
*/
|
|
19
|
+
export declare function failedTestNames(output: string): string[];
|
|
@@ -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
|
+
}
|
|
@@ -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,6 +42,7 @@ 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"));
|
|
@@ -79,6 +81,17 @@ const dense_retrieval_1 = require("./dense-retrieval");
|
|
|
79
81
|
const explore_1 = require("../assoc/explore");
|
|
80
82
|
const temporal_prior_1 = require("./temporal-prior");
|
|
81
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
|
+
}
|
|
82
95
|
/**
|
|
83
96
|
* Run the point-in-time replay against ANY corpus.
|
|
84
97
|
*
|
|
@@ -258,6 +271,23 @@ async function runReplay(corpus, limit, opts = {}) {
|
|
|
258
271
|
return Object.keys(defUse).length === 0 ? firstPass : analyzeWith(defUse);
|
|
259
272
|
})();
|
|
260
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
|
+
}
|
|
261
291
|
// @implements A-SPEC-469 — the union scores what a caller actually RECEIVES as the impact
|
|
262
292
|
// answer, and that surface is now the graded rankedImpact (the closure stays gate-facing).
|
|
263
293
|
const impacted = (result.impacts?.rankedImpact ?? []).map((r) => r.file);
|
|
@@ -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
|