@nodetool-ai/cli-harness 0.0.0-stage → 0.8.9-beta.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/README.md +13 -2
- package/dist/.nodetool-build-stamp +1 -0
- package/dist/affected/affected.d.ts +48 -0
- package/dist/affected/affected.d.ts.map +1 -0
- package/dist/affected/affected.js +167 -0
- package/dist/affected/affected.js.map +1 -0
- package/dist/commands/__tests__/jtbd.test.d.ts +2 -0
- package/dist/commands/__tests__/jtbd.test.d.ts.map +1 -0
- package/dist/commands/__tests__/jtbd.test.js +120 -0
- package/dist/commands/__tests__/jtbd.test.js.map +1 -0
- package/dist/commands/affected.d.ts +9 -0
- package/dist/commands/affected.d.ts.map +1 -0
- package/dist/commands/affected.js +127 -0
- package/dist/commands/affected.js.map +1 -0
- package/dist/commands/eval.d.ts +152 -0
- package/dist/commands/eval.d.ts.map +1 -0
- package/dist/commands/eval.js +693 -0
- package/dist/commands/eval.js.map +1 -0
- package/dist/commands/harness.d.ts +90 -0
- package/dist/commands/harness.d.ts.map +1 -0
- package/dist/commands/harness.js +883 -0
- package/dist/commands/harness.js.map +1 -0
- package/dist/commands/jtbd.d.ts +38 -0
- package/dist/commands/jtbd.d.ts.map +1 -0
- package/dist/commands/jtbd.js +261 -0
- package/dist/commands/jtbd.js.map +1 -0
- package/dist/commands/reliability.d.ts +14 -0
- package/dist/commands/reliability.d.ts.map +1 -0
- package/dist/commands/reliability.js +81 -0
- package/dist/commands/reliability.js.map +1 -0
- package/dist/evals/graph-runner.d.ts +8 -0
- package/dist/evals/graph-runner.d.ts.map +1 -0
- package/dist/evals/graph-runner.js +136 -0
- package/dist/evals/graph-runner.js.map +1 -0
- package/dist/harness/capability-coverage.d.ts +165 -0
- package/dist/harness/capability-coverage.d.ts.map +1 -0
- package/dist/harness/capability-coverage.js +308 -0
- package/dist/harness/capability-coverage.js.map +1 -0
- package/dist/harness/capability-table.d.ts +17 -0
- package/dist/harness/capability-table.d.ts.map +1 -0
- package/dist/harness/capability-table.js +3941 -0
- package/dist/harness/capability-table.js.map +1 -0
- package/dist/harness/changed-files.d.ts +20 -0
- package/dist/harness/changed-files.d.ts.map +1 -0
- package/dist/harness/changed-files.js +90 -0
- package/dist/harness/changed-files.js.map +1 -0
- package/dist/harness/declared-capabilities.d.ts +12 -0
- package/dist/harness/declared-capabilities.d.ts.map +1 -0
- package/dist/harness/declared-capabilities.js +21 -0
- package/dist/harness/declared-capabilities.js.map +1 -0
- package/dist/harness/registry.d.ts +190 -0
- package/dist/harness/registry.d.ts.map +1 -0
- package/dist/harness/registry.js +2419 -0
- package/dist/harness/registry.js.map +1 -0
- package/dist/index.d.ts +22 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +33 -0
- package/dist/index.js.map +1 -0
- package/package.json +63 -4
|
@@ -0,0 +1,883 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { dirname, join, resolve } from "node:path";
|
|
3
|
+
import { printCommandError } from "@nodetool-ai/pack-manager/command-errors";
|
|
4
|
+
import { HARNESSES, SURFACES, auditHarnessCoverage, isUnclaimedPath, planGate } from "../harness/registry.js";
|
|
5
|
+
import { computeAffected, readWorkspacePackages } from "../affected/affected.js";
|
|
6
|
+
import { auditCapabilityCoverage, planCapabilityMappingGate, resolveGateBaseRef, readGateCapabilityTable } from "../harness/capability-coverage.js";
|
|
7
|
+
import { CAPABILITY_COVERAGE } from "../harness/capability-table.js";
|
|
8
|
+
import { readChangedFiles, isGateRelevantCodeFile, isGlobalGateFile } from "../harness/changed-files.js";
|
|
9
|
+
/** Sentinel exit code for a selfcheck the gate had to kill on timeout. */
|
|
10
|
+
const TIMEOUT_EXIT_CODE = 124;
|
|
11
|
+
/**
|
|
12
|
+
* How much a selfcheck may print before the gate stops buffering it.
|
|
13
|
+
*
|
|
14
|
+
* `--json` captures each selfcheck's output, and `spawnSync`'s default cap is
|
|
15
|
+
* 1 MB: past that Node kills the child and leaves a signal behind, which the
|
|
16
|
+
* gate read as a timeout. A jest leg covering a whole component tree prints
|
|
17
|
+
* more than that in warnings alone, so a passing suite was reported as
|
|
18
|
+
* "exceeded 900s" after three minutes.
|
|
19
|
+
*/
|
|
20
|
+
const SELFCHECK_MAX_BUFFER = 256 * 1024 * 1024;
|
|
21
|
+
/** Where the coverage table lives, as git sees it. */
|
|
22
|
+
const CAPABILITY_TABLE_PATH = "packages/cli-harness/src/harness/capability-table.ts";
|
|
23
|
+
function harnessRequiresCheckout(command) {
|
|
24
|
+
return !/^(?:nodetool(?:-chat)?\b|echo .*\| nodetool-chat\b)/.test(command) ||
|
|
25
|
+
command.startsWith("nodetool affected");
|
|
26
|
+
}
|
|
27
|
+
function harnessRepositoryRoot() {
|
|
28
|
+
let directory = resolve(process.cwd());
|
|
29
|
+
for (;;) {
|
|
30
|
+
if (existsSync(join(directory, ".git")) &&
|
|
31
|
+
existsSync(join(directory, "packages/cli-harness/src/harness/registry.ts"))) {
|
|
32
|
+
return directory;
|
|
33
|
+
}
|
|
34
|
+
const parent = dirname(directory);
|
|
35
|
+
if (parent === directory) {
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
directory = parent;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
export function registerHarnessCommands(program) {
|
|
42
|
+
const harness = program
|
|
43
|
+
.command("harness")
|
|
44
|
+
.description("Harness-first engineering: inventory and coverage audit");
|
|
45
|
+
harness
|
|
46
|
+
.command("list")
|
|
47
|
+
.description("List CLI commands and repository checks, marking checks that require a NodeTool checkout")
|
|
48
|
+
.option("--json", "Print the registry as JSON")
|
|
49
|
+
.action((opts) => {
|
|
50
|
+
if (opts.json) {
|
|
51
|
+
console.log(JSON.stringify(HARNESSES.map((harness) => ({
|
|
52
|
+
...harness,
|
|
53
|
+
requiresCheckout: harnessRequiresCheckout(harness.command)
|
|
54
|
+
})), null, 2));
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
console.log(`\n${HARNESSES.length} harnesses:\n`);
|
|
58
|
+
for (const h of HARNESSES) {
|
|
59
|
+
const caps = h.capabilities.length
|
|
60
|
+
? ` [${h.capabilities.join(", ")}]`
|
|
61
|
+
: "";
|
|
62
|
+
const tool = h.agentTool ? ` (agent tool: ${h.agentTool})` : "";
|
|
63
|
+
console.log(` ${h.id.padEnd(18)} ${h.kind.padEnd(9)} ${h.title}`);
|
|
64
|
+
const availability = harnessRequiresCheckout(h.command) ? " [NodeTool checkout required]" : " [installed CLI]";
|
|
65
|
+
console.log(` ${"".padEnd(18)} ${h.command}${availability}${caps}${tool}`);
|
|
66
|
+
console.log("");
|
|
67
|
+
}
|
|
68
|
+
console.log("Docs: https://github.com/nodetool-ai/nodetool/blob/main/docs/HARNESS_FIRST.md\n");
|
|
69
|
+
});
|
|
70
|
+
harness
|
|
71
|
+
.command("audit")
|
|
72
|
+
.description("Audit surface coverage: every surface needs a harness or a documented gap")
|
|
73
|
+
.option("--json", "Print the audit result as JSON")
|
|
74
|
+
.option("--strict", "Exit non-zero while any surface gap remains")
|
|
75
|
+
.action((opts) => {
|
|
76
|
+
const result = auditHarnessCoverage();
|
|
77
|
+
if (opts.json) {
|
|
78
|
+
console.log(JSON.stringify(result, null, 2));
|
|
79
|
+
}
|
|
80
|
+
else {
|
|
81
|
+
console.log(`\nSurface coverage: ${result.coveredCount}/${SURFACES.length} covered, ${result.gapCount} gap(s)\n`);
|
|
82
|
+
for (const s of result.surfaces) {
|
|
83
|
+
if (s.covered) {
|
|
84
|
+
console.log(` ok ${s.id.padEnd(20)} ${s.harnesses.join(", ")}`);
|
|
85
|
+
}
|
|
86
|
+
else {
|
|
87
|
+
console.log(` GAP ${s.id.padEnd(20)} ${s.title}`);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
const gaps = result.surfaces.filter((s) => !s.covered);
|
|
91
|
+
if (gaps.length > 0) {
|
|
92
|
+
console.log("\nDocumented gaps:");
|
|
93
|
+
for (const s of gaps)
|
|
94
|
+
console.log(`\n ${s.id}: ${s.gap}`);
|
|
95
|
+
}
|
|
96
|
+
if (result.undocumentedGaps.length > 0) {
|
|
97
|
+
console.log(`\nUNDOCUMENTED gaps (no harness, no gap note): ${result.undocumentedGaps.join(", ")}`);
|
|
98
|
+
}
|
|
99
|
+
if (result.unknownHarnessRefs.length > 0) {
|
|
100
|
+
console.log(`\nUnknown harness references: ${result.unknownHarnessRefs.join(", ")}`);
|
|
101
|
+
}
|
|
102
|
+
if (result.orphanHarnesses.length > 0) {
|
|
103
|
+
console.log(`\nHarnesses no surface claims: ${result.orphanHarnesses.join(", ")}`);
|
|
104
|
+
}
|
|
105
|
+
console.log("");
|
|
106
|
+
}
|
|
107
|
+
const broken = result.undocumentedGaps.length > 0 ||
|
|
108
|
+
result.unknownHarnessRefs.length > 0;
|
|
109
|
+
if (broken || (opts.strict && result.gapCount > 0)) {
|
|
110
|
+
process.exit(1);
|
|
111
|
+
}
|
|
112
|
+
});
|
|
113
|
+
harness
|
|
114
|
+
.command("capabilities")
|
|
115
|
+
.description("Audit capability coverage: every capability needs a suite, an eval case, or a gap note")
|
|
116
|
+
.option("--json", "Print the audit result as JSON")
|
|
117
|
+
.option("--strict", "Exit non-zero while any capability gap remains")
|
|
118
|
+
.action(async (opts) => {
|
|
119
|
+
// Lazy: declared-capabilities imports the built agents package.
|
|
120
|
+
const { declaredCapabilities } = await import("../harness/declared-capabilities.js");
|
|
121
|
+
const result = auditCapabilityCoverage(declaredCapabilities(), CAPABILITY_COVERAGE, HARNESSES);
|
|
122
|
+
if (opts.json) {
|
|
123
|
+
console.log(JSON.stringify(result, null, 2));
|
|
124
|
+
}
|
|
125
|
+
else {
|
|
126
|
+
console.log(`\nCapability coverage: ${result.coveredCount}/${result.rows.length} covered, ${result.gapCount} gap(s)\n`);
|
|
127
|
+
for (const row of result.rows) {
|
|
128
|
+
if (!row.covered) {
|
|
129
|
+
console.log(` GAP ${row.name.padEnd(32)} ${row.module}`);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
const gaps = result.rows.filter((r) => !r.covered);
|
|
133
|
+
if (gaps.length > 0) {
|
|
134
|
+
console.log("\nDocumented gaps:");
|
|
135
|
+
for (const row of gaps)
|
|
136
|
+
console.log(`\n ${row.name}: ${row.gap}`);
|
|
137
|
+
}
|
|
138
|
+
for (const [label, list] of [
|
|
139
|
+
["Capabilities with no entry", result.unmapped],
|
|
140
|
+
["Entries naming no capability", result.stale],
|
|
141
|
+
["Entries with no coverage and no gap note", result.undocumentedGaps],
|
|
142
|
+
["Entries naming an unknown selfcheck", result.unknownSelfchecks],
|
|
143
|
+
["Entries claiming a selfcheck with no suite", result.selfchecksWithoutSuites],
|
|
144
|
+
["Contract drift", result.contractDrift],
|
|
145
|
+
["Module mismatches", result.moduleMismatches],
|
|
146
|
+
["Duplicate entries", result.duplicates]
|
|
147
|
+
]) {
|
|
148
|
+
if (list.length > 0)
|
|
149
|
+
console.log(`\n${label}: ${list.join(", ")}`);
|
|
150
|
+
}
|
|
151
|
+
console.log("");
|
|
152
|
+
}
|
|
153
|
+
const broken = result.unmapped.length > 0 ||
|
|
154
|
+
result.stale.length > 0 ||
|
|
155
|
+
result.undocumentedGaps.length > 0 ||
|
|
156
|
+
result.unknownSelfchecks.length > 0 ||
|
|
157
|
+
result.selfchecksWithoutSuites.length > 0 ||
|
|
158
|
+
result.contractDrift.length > 0 ||
|
|
159
|
+
result.moduleMismatches.length > 0 ||
|
|
160
|
+
result.duplicates.length > 0;
|
|
161
|
+
if (broken || (opts.strict && result.gapCount > 0)) {
|
|
162
|
+
process.exit(1);
|
|
163
|
+
}
|
|
164
|
+
});
|
|
165
|
+
harness
|
|
166
|
+
.command("gate [files...]")
|
|
167
|
+
.description("Map a diff to touched surfaces and run their harnesses' selfchecks")
|
|
168
|
+
.option("--base <ref>", "Diff against a git ref (e.g. main) instead of the working tree")
|
|
169
|
+
.option("--all", "Ignore the diff and run every selfcheck")
|
|
170
|
+
.option("--expensive", "Include expensive selfchecks (bundle staging etc.)")
|
|
171
|
+
.option("--include-suites", "Also run selfchecks that only re-run suites `npm run test:affected` runs")
|
|
172
|
+
.option("--deps", "Also touch surfaces inside workspaces that depend on a changed one")
|
|
173
|
+
.option("--dry-run", "Print the plan without running anything")
|
|
174
|
+
.option("--json", "Print the plan (and results, unless --dry-run) as JSON")
|
|
175
|
+
.option("--strict", "Exit non-zero when the diff touches a surface only a gap note covers")
|
|
176
|
+
.option("--timeout <seconds>", "Kill a selfcheck that runs longer than this many seconds (default 900)", "900")
|
|
177
|
+
.option("--jobs <n>", "Run up to n cheap selfchecks at once (default 1, serial)", "1")
|
|
178
|
+
.action(async (files, opts) => {
|
|
179
|
+
const repoRoot = harnessRepositoryRoot();
|
|
180
|
+
if (!repoRoot) {
|
|
181
|
+
process.exit(printCommandError(new Error("harness gate requires a NodeTool Git checkout with repository test scripts. Run it from the checkout root. For a local document, use nodetool validate, jsscript test, app debug, or timeline debug."), opts.json));
|
|
182
|
+
}
|
|
183
|
+
// An unusable --timeout falls back to the default, and so does the
|
|
184
|
+
// number the TIMEOUT line prints: it said "exceeded NaNs" before.
|
|
185
|
+
const requestedTimeout = Number(opts.timeout ?? "900");
|
|
186
|
+
const timeoutSeconds = Number.isFinite(requestedTimeout) && requestedTimeout > 0
|
|
187
|
+
? requestedTimeout
|
|
188
|
+
: 900;
|
|
189
|
+
const timeoutMs = timeoutSeconds * 1000;
|
|
190
|
+
const jobsRequested = Number(opts.jobs ?? "1");
|
|
191
|
+
const jobs = Number.isInteger(jobsRequested) && jobsRequested > 0
|
|
192
|
+
? jobsRequested
|
|
193
|
+
: 1;
|
|
194
|
+
let changedFiles = files;
|
|
195
|
+
if (changedFiles.length === 0 && !opts.all) {
|
|
196
|
+
changedFiles = readChangedFiles(repoRoot, opts.base);
|
|
197
|
+
}
|
|
198
|
+
const mappingViolations = opts.all
|
|
199
|
+
? []
|
|
200
|
+
: await capabilityMappingViolations(repoRoot, opts.base ?? "HEAD", changedFiles);
|
|
201
|
+
if (!opts.json && mappingViolations.length > 0) {
|
|
202
|
+
console.log("\nCapability mapping violations (harness capabilities):");
|
|
203
|
+
for (const v of mappingViolations)
|
|
204
|
+
console.log(` ${v}`);
|
|
205
|
+
}
|
|
206
|
+
const plan = opts.all
|
|
207
|
+
? {
|
|
208
|
+
changedFiles: [],
|
|
209
|
+
globalFiles: [],
|
|
210
|
+
surfaces: [],
|
|
211
|
+
checks: HARNESSES.filter((h) => h.selfcheck).map((h) => ({
|
|
212
|
+
harnessId: h.id,
|
|
213
|
+
command: h.selfcheck.command,
|
|
214
|
+
cost: h.selfcheck.cost,
|
|
215
|
+
suiteOnly: h.selfcheck.suiteOnly ?? false,
|
|
216
|
+
surfaces: []
|
|
217
|
+
})),
|
|
218
|
+
manual: [],
|
|
219
|
+
uncoveredSurfaces: [],
|
|
220
|
+
unmappedFiles: []
|
|
221
|
+
}
|
|
222
|
+
: planGate(changedFiles, undefined, undefined, opts.deps ? readWorkspacePackages(repoRoot) : undefined);
|
|
223
|
+
const { toRun, skippedExpensive, skippedSuites, prunedSuites } = selectChecks(plan.checks, {
|
|
224
|
+
expensive: opts.expensive,
|
|
225
|
+
includeSuites: opts.includeSuites,
|
|
226
|
+
globalFiles: plan.globalFiles,
|
|
227
|
+
changedFiles: plan.changedFiles,
|
|
228
|
+
workspaces: readWorkspacePackages(repoRoot)
|
|
229
|
+
});
|
|
230
|
+
// A directory recorded in UNCLAIMED_PATHS has already been judged: no
|
|
231
|
+
// harness reaches it, and the entry says why. `auditPathClaims` honors
|
|
232
|
+
// that; the gate did not, so the first diff to touch such a directory
|
|
233
|
+
// failed with no way to satisfy it short of inventing a surface.
|
|
234
|
+
const unmappedCodeFiles = plan.unmappedFiles
|
|
235
|
+
.filter(isGateRelevantCodeFile)
|
|
236
|
+
.filter((f) => !isUnclaimedPath(f));
|
|
237
|
+
if (!opts.json) {
|
|
238
|
+
printGatePlan(plan, toRun.length, skippedExpensive.length, skippedSuites, opts.all);
|
|
239
|
+
if (prunedSuites.length > 0) {
|
|
240
|
+
console.log("\nSteps left out because `npm run test:affected` runs their whole suite:");
|
|
241
|
+
for (const p of prunedSuites) {
|
|
242
|
+
for (const c of p.commands)
|
|
243
|
+
console.log(` ${p.harnessId}: ${c}`);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
if (unmappedCodeFiles.length > 0) {
|
|
247
|
+
console.log("\nCode files no surface claims (the gate fails on these):");
|
|
248
|
+
for (const f of unmappedCodeFiles)
|
|
249
|
+
console.log(` ${f}`);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
const skippedSuiteIds = skippedSuites.map((c) => c.harnessId);
|
|
253
|
+
if (opts.dryRun) {
|
|
254
|
+
if (opts.json) {
|
|
255
|
+
console.log(JSON.stringify({
|
|
256
|
+
plan,
|
|
257
|
+
skippedSuites: skippedSuiteIds,
|
|
258
|
+
prunedSuites,
|
|
259
|
+
mappingViolations,
|
|
260
|
+
unmappedCodeFiles
|
|
261
|
+
}, null, 2));
|
|
262
|
+
}
|
|
263
|
+
if (mappingViolations.length > 0 ||
|
|
264
|
+
unmappedCodeFiles.length > 0 ||
|
|
265
|
+
(opts.strict && plan.uncoveredSurfaces.length > 0)) {
|
|
266
|
+
process.exit(1);
|
|
267
|
+
}
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
const results = await executeChecks(toRun, {
|
|
271
|
+
repoRoot,
|
|
272
|
+
json: opts.json === true,
|
|
273
|
+
timeoutMs,
|
|
274
|
+
timeoutSeconds,
|
|
275
|
+
jobs
|
|
276
|
+
});
|
|
277
|
+
const failed = results.filter((r) => !r.ok);
|
|
278
|
+
if (opts.json) {
|
|
279
|
+
console.log(JSON.stringify({
|
|
280
|
+
plan,
|
|
281
|
+
results,
|
|
282
|
+
skippedSuites: skippedSuiteIds,
|
|
283
|
+
prunedSuites,
|
|
284
|
+
mappingViolations,
|
|
285
|
+
unmappedCodeFiles
|
|
286
|
+
}, null, 2));
|
|
287
|
+
}
|
|
288
|
+
else if (toRun.length > 0) {
|
|
289
|
+
console.log(`\nGate: ${results.length - failed.length}/${results.length} selfchecks passed`);
|
|
290
|
+
for (const r of failed) {
|
|
291
|
+
const label = r.timedOut
|
|
292
|
+
? "TIMEOUT"
|
|
293
|
+
: r.killed
|
|
294
|
+
? "KILLED"
|
|
295
|
+
: "FAIL";
|
|
296
|
+
console.log(` ${label} ${r.harnessId} (exit ${r.exitCode}): ${r.command}`);
|
|
297
|
+
}
|
|
298
|
+
console.log("");
|
|
299
|
+
}
|
|
300
|
+
if (failed.length > 0 ||
|
|
301
|
+
mappingViolations.length > 0 ||
|
|
302
|
+
unmappedCodeFiles.length > 0 ||
|
|
303
|
+
(opts.strict && plan.uncoveredSurfaces.length > 0)) {
|
|
304
|
+
process.exit(1);
|
|
305
|
+
}
|
|
306
|
+
});
|
|
307
|
+
}
|
|
308
|
+
/**
|
|
309
|
+
* Split a plan's checks into what runs now and what is deliberately left out.
|
|
310
|
+
* A suite-only check is skipped (and named) rather than silently dropped:
|
|
311
|
+
* `npm run test:affected` runs the same suites for the same diff, so running
|
|
312
|
+
* them here again is pure repetition unless `--include-suites` asks for it.
|
|
313
|
+
*
|
|
314
|
+
* The same holds for one `npm run test --workspace=<pkg>` step of a chained
|
|
315
|
+
* selfcheck when `test:affected` runs that package's whole suite for this
|
|
316
|
+
* diff: every backend package `computeAffected` returns for `changedFiles`
|
|
317
|
+
* (the CI `test-packages-*` shards run the same set), or every workspace on a
|
|
318
|
+
* global diff. Such a step is pruned from the command and listed in
|
|
319
|
+
* `prunedSuites`, and a check left with no step is skipped. Web and electron
|
|
320
|
+
* steps are kept outside a global diff, because `test:affected` may run only
|
|
321
|
+
* the tests related to their own changed files.
|
|
322
|
+
*/
|
|
323
|
+
export function selectChecks(checks, opts) {
|
|
324
|
+
const covered = opts.includeSuites
|
|
325
|
+
? new Set()
|
|
326
|
+
: coveredWorkspaces(opts);
|
|
327
|
+
const skippedSuites = [];
|
|
328
|
+
const prunedSuites = [];
|
|
329
|
+
const rest = [];
|
|
330
|
+
for (const c of checks) {
|
|
331
|
+
if (!opts.includeSuites && c.suiteOnly) {
|
|
332
|
+
skippedSuites.push(c);
|
|
333
|
+
continue;
|
|
334
|
+
}
|
|
335
|
+
const pruned = pruneCoveredSuites(c.command, covered);
|
|
336
|
+
if (pruned.command === null) {
|
|
337
|
+
skippedSuites.push(c);
|
|
338
|
+
}
|
|
339
|
+
else if (pruned.removed.length > 0) {
|
|
340
|
+
prunedSuites.push({ harnessId: c.harnessId, commands: pruned.removed });
|
|
341
|
+
rest.push({ ...c, command: pruned.command });
|
|
342
|
+
}
|
|
343
|
+
else {
|
|
344
|
+
rest.push(c);
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
return {
|
|
348
|
+
toRun: rest.filter((c) => opts.expensive || c.cost === "cheap"),
|
|
349
|
+
skippedExpensive: rest.filter((c) => !opts.expensive && c.cost === "expensive"),
|
|
350
|
+
skippedSuites,
|
|
351
|
+
prunedSuites
|
|
352
|
+
};
|
|
353
|
+
}
|
|
354
|
+
/**
|
|
355
|
+
* Workspaces, by name and directory, whose whole test suite `npm run
|
|
356
|
+
* test:affected` runs for this diff. A global gate file selects the full
|
|
357
|
+
* pass, so every package plus web and electron. Otherwise the backend
|
|
358
|
+
* packages `computeAffected` returns: `test:affected` runs exactly those
|
|
359
|
+
* through turbo, and `scripts/ci-plan.mjs` starts the shard of each of them.
|
|
360
|
+
*/
|
|
361
|
+
function coveredWorkspaces(opts) {
|
|
362
|
+
const isBackend = (p) => /^(packages|reliability)\/[^/]+$/.test(p.dir);
|
|
363
|
+
const workspaces = opts.workspaces ?? [];
|
|
364
|
+
if (opts.globalFiles?.some(isGlobalGateFile)) {
|
|
365
|
+
return new Set(workspaces
|
|
366
|
+
.filter((p) => isBackend(p) || p.dir === "web" || p.dir === "electron")
|
|
367
|
+
.flatMap((p) => [p.name, p.dir]));
|
|
368
|
+
}
|
|
369
|
+
if (!opts.changedFiles?.length)
|
|
370
|
+
return new Set();
|
|
371
|
+
const affected = new Set(computeAffected(opts.changedFiles, workspaces).affected);
|
|
372
|
+
return new Set(workspaces
|
|
373
|
+
.filter((p) => isBackend(p) && affected.has(p.name))
|
|
374
|
+
.flatMap((p) => [p.name, p.dir]));
|
|
375
|
+
}
|
|
376
|
+
/**
|
|
377
|
+
* Remove the `npm run test --workspace=…` steps whose workspaces are all
|
|
378
|
+
* covered from a `&&` chain. Returns `command: null` when no step is left,
|
|
379
|
+
* and the command unchanged when it is outside the restricted grammar below.
|
|
380
|
+
*/
|
|
381
|
+
function pruneCoveredSuites(command, covered) {
|
|
382
|
+
const steps = covered.size > 0 ? splitAndChain(command) : null;
|
|
383
|
+
if (!steps)
|
|
384
|
+
return { command, removed: [] };
|
|
385
|
+
const removed = steps.filter((s) => isCoveredTestStep(s.tokens, covered));
|
|
386
|
+
if (removed.length === 0)
|
|
387
|
+
return { command, removed: [] };
|
|
388
|
+
const kept = steps.filter((s) => !removed.includes(s));
|
|
389
|
+
return {
|
|
390
|
+
command: kept.length > 0 ? kept.map((s) => s.text).join(" && ") : null,
|
|
391
|
+
removed: removed.map((s) => s.text)
|
|
392
|
+
};
|
|
393
|
+
}
|
|
394
|
+
/**
|
|
395
|
+
* Split a command into its `&&` steps, each with its source text and words.
|
|
396
|
+
* Quotes may protect filter expressions such as 'one|two'. Shell expansion,
|
|
397
|
+
* redirection and every operator other than `&&` reject the whole command.
|
|
398
|
+
*/
|
|
399
|
+
function splitAndChain(command) {
|
|
400
|
+
if (/[$`\\\n\r;<>%!^]/.test(command)) {
|
|
401
|
+
return null;
|
|
402
|
+
}
|
|
403
|
+
const steps = [];
|
|
404
|
+
let segment = [];
|
|
405
|
+
let segmentStart = 0;
|
|
406
|
+
let token = "";
|
|
407
|
+
let quote = "";
|
|
408
|
+
const flush = () => {
|
|
409
|
+
if (token) {
|
|
410
|
+
segment.push(token);
|
|
411
|
+
}
|
|
412
|
+
token = "";
|
|
413
|
+
};
|
|
414
|
+
const close = (end) => {
|
|
415
|
+
flush();
|
|
416
|
+
if (segment.length === 0)
|
|
417
|
+
return false;
|
|
418
|
+
steps.push({ text: command.slice(segmentStart, end).trim(), tokens: segment });
|
|
419
|
+
segment = [];
|
|
420
|
+
return true;
|
|
421
|
+
};
|
|
422
|
+
for (let i = 0; i < command.length; i++) {
|
|
423
|
+
const char = command.charAt(i);
|
|
424
|
+
if (quote) {
|
|
425
|
+
if (char === quote) {
|
|
426
|
+
quote = "";
|
|
427
|
+
}
|
|
428
|
+
else {
|
|
429
|
+
token += char;
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
else if (char === "'" || char === '"') {
|
|
433
|
+
quote = char;
|
|
434
|
+
}
|
|
435
|
+
else if (/\s/.test(char)) {
|
|
436
|
+
flush();
|
|
437
|
+
}
|
|
438
|
+
else if (char === "&" && command[i + 1] === "&") {
|
|
439
|
+
if (!close(i))
|
|
440
|
+
return null;
|
|
441
|
+
i++;
|
|
442
|
+
segmentStart = i + 1;
|
|
443
|
+
}
|
|
444
|
+
else if (";&|<>()*?{}[]#".includes(char)) {
|
|
445
|
+
return null;
|
|
446
|
+
}
|
|
447
|
+
else {
|
|
448
|
+
token += char;
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
if (quote || !close(command.length)) {
|
|
452
|
+
return null;
|
|
453
|
+
}
|
|
454
|
+
return steps;
|
|
455
|
+
}
|
|
456
|
+
/** `npm [run] test --workspace=<covered>… [-- <file filters>]`. */
|
|
457
|
+
function isCoveredTestStep(tokens, covered) {
|
|
458
|
+
let i = 0;
|
|
459
|
+
if (tokens[i++] !== "npm") {
|
|
460
|
+
return false;
|
|
461
|
+
}
|
|
462
|
+
if (tokens[i] === "run") {
|
|
463
|
+
i++;
|
|
464
|
+
}
|
|
465
|
+
if (tokens[i++] !== "test") {
|
|
466
|
+
return false;
|
|
467
|
+
}
|
|
468
|
+
let count = 0;
|
|
469
|
+
while (tokens[i]?.startsWith("--workspace=")) {
|
|
470
|
+
const workspace = tokens[i];
|
|
471
|
+
if (!workspace || !covered.has(workspace.slice("--workspace=".length))) {
|
|
472
|
+
return false;
|
|
473
|
+
}
|
|
474
|
+
i++;
|
|
475
|
+
count++;
|
|
476
|
+
}
|
|
477
|
+
if (count === 0) {
|
|
478
|
+
return false;
|
|
479
|
+
}
|
|
480
|
+
if (i === tokens.length) {
|
|
481
|
+
return true;
|
|
482
|
+
}
|
|
483
|
+
if (tokens[i++] !== "--") {
|
|
484
|
+
return false;
|
|
485
|
+
}
|
|
486
|
+
return tokens.slice(i).every((arg) => !arg.startsWith("-") ||
|
|
487
|
+
arg === "--runInBand" || /^(--testPathPattern|--testNamePattern)=.+/.test(arg));
|
|
488
|
+
}
|
|
489
|
+
/** Cap on what a failing selfcheck prints into `--json` mode's stderr. */
|
|
490
|
+
const FAILURE_OUTPUT_CHARS = 20_000;
|
|
491
|
+
/**
|
|
492
|
+
* Selfchecks decide their own module resolution: strip the `nodetool-dev`
|
|
493
|
+
* conditions this CLI may be running under (set by `npm run dev:nodetool`), or
|
|
494
|
+
* dist-mode scripts like reliability:ring0 resolve packages to src/ and fail.
|
|
495
|
+
*/
|
|
496
|
+
function selfcheckEnv() {
|
|
497
|
+
const nodeOptions = (process.env["NODE_OPTIONS"] ?? "")
|
|
498
|
+
.replace(/--conditions[= ]nodetool-dev/g, "")
|
|
499
|
+
.trim();
|
|
500
|
+
return {
|
|
501
|
+
...process.env,
|
|
502
|
+
...(nodeOptions ? { NODE_OPTIONS: nodeOptions } : { NODE_OPTIONS: "" })
|
|
503
|
+
};
|
|
504
|
+
}
|
|
505
|
+
/** Kill switches for every selfcheck process group still running. */
|
|
506
|
+
const liveGroups = new Set();
|
|
507
|
+
function killLiveGroups() {
|
|
508
|
+
for (const kill of liveGroups)
|
|
509
|
+
kill();
|
|
510
|
+
}
|
|
511
|
+
function interruptGate() {
|
|
512
|
+
killLiveGroups();
|
|
513
|
+
process.exit(130);
|
|
514
|
+
}
|
|
515
|
+
function terminateGate() {
|
|
516
|
+
killLiveGroups();
|
|
517
|
+
process.exit(143);
|
|
518
|
+
}
|
|
519
|
+
/**
|
|
520
|
+
* Tie a selfcheck's process group to the gate's own lifetime and return the
|
|
521
|
+
* release to call once it has settled. Each check leads its own group
|
|
522
|
+
* (`detached`) so a timeout can kill everything its shell started, which also
|
|
523
|
+
* keeps a terminal Ctrl-C from reaching it: a gate that exited or was
|
|
524
|
+
* interrupted left the check running. The handlers live only while a group
|
|
525
|
+
* does, and are shared, so `--jobs` past ten adds no listener per check.
|
|
526
|
+
*/
|
|
527
|
+
function trackGroup(killGroup) {
|
|
528
|
+
if (liveGroups.size === 0) {
|
|
529
|
+
process.on("exit", killLiveGroups);
|
|
530
|
+
process.on("SIGINT", interruptGate);
|
|
531
|
+
process.on("SIGTERM", terminateGate);
|
|
532
|
+
}
|
|
533
|
+
liveGroups.add(killGroup);
|
|
534
|
+
return () => {
|
|
535
|
+
if (!liveGroups.delete(killGroup) || liveGroups.size > 0)
|
|
536
|
+
return;
|
|
537
|
+
process.removeListener("exit", killLiveGroups);
|
|
538
|
+
process.removeListener("SIGINT", interruptGate);
|
|
539
|
+
process.removeListener("SIGTERM", terminateGate);
|
|
540
|
+
};
|
|
541
|
+
}
|
|
542
|
+
/** Run one serial selfcheck, retaining live output and bounded JSON capture. */
|
|
543
|
+
async function runCheckSerial(check, o) {
|
|
544
|
+
const { spawn } = await import("node:child_process");
|
|
545
|
+
return new Promise((resolveOutcome) => {
|
|
546
|
+
const stdout = [];
|
|
547
|
+
const stderr = [];
|
|
548
|
+
let stdoutBytes = 0;
|
|
549
|
+
let stderrBytes = 0;
|
|
550
|
+
let errorCode;
|
|
551
|
+
const child = spawn(check.command, {
|
|
552
|
+
cwd: o.repoRoot,
|
|
553
|
+
shell: true,
|
|
554
|
+
stdio: o.json ? ["ignore", "pipe", "pipe"] : "inherit",
|
|
555
|
+
detached: true,
|
|
556
|
+
env: selfcheckEnv()
|
|
557
|
+
});
|
|
558
|
+
let groupKilled = false;
|
|
559
|
+
const killGroup = () => {
|
|
560
|
+
if (groupKilled) {
|
|
561
|
+
return;
|
|
562
|
+
}
|
|
563
|
+
groupKilled = true;
|
|
564
|
+
try {
|
|
565
|
+
if (child.pid !== undefined) {
|
|
566
|
+
process.kill(-child.pid, "SIGKILL");
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
catch {
|
|
570
|
+
child.kill("SIGKILL");
|
|
571
|
+
}
|
|
572
|
+
};
|
|
573
|
+
const release = trackGroup(killGroup);
|
|
574
|
+
const kill = (reason) => {
|
|
575
|
+
if (errorCode !== undefined) {
|
|
576
|
+
return;
|
|
577
|
+
}
|
|
578
|
+
errorCode = reason;
|
|
579
|
+
killGroup();
|
|
580
|
+
};
|
|
581
|
+
const timer = setTimeout(() => kill("ETIMEDOUT"), o.timeoutMs);
|
|
582
|
+
const capture = (chunks, chunk, bytes) => {
|
|
583
|
+
if (errorCode !== undefined) {
|
|
584
|
+
return bytes;
|
|
585
|
+
}
|
|
586
|
+
const remaining = SELFCHECK_MAX_BUFFER - bytes;
|
|
587
|
+
if (remaining > 0) {
|
|
588
|
+
chunks.push(chunk.subarray(0, remaining));
|
|
589
|
+
}
|
|
590
|
+
if (chunk.length > remaining) {
|
|
591
|
+
kill("ENOBUFS");
|
|
592
|
+
}
|
|
593
|
+
return bytes + Math.min(chunk.length, remaining);
|
|
594
|
+
};
|
|
595
|
+
child.stdout?.on("data", (chunk) => {
|
|
596
|
+
stdoutBytes = capture(stdout, chunk, stdoutBytes);
|
|
597
|
+
});
|
|
598
|
+
child.stderr?.on("data", (chunk) => {
|
|
599
|
+
stderrBytes = capture(stderr, chunk, stderrBytes);
|
|
600
|
+
});
|
|
601
|
+
child.on("error", (error) => {
|
|
602
|
+
errorCode ??= error.code ?? "UNKNOWN";
|
|
603
|
+
});
|
|
604
|
+
child.on("close", (status, signal) => {
|
|
605
|
+
clearTimeout(timer);
|
|
606
|
+
release();
|
|
607
|
+
resolveOutcome({
|
|
608
|
+
status,
|
|
609
|
+
signal,
|
|
610
|
+
errorCode,
|
|
611
|
+
hasError: errorCode !== undefined,
|
|
612
|
+
stdout: Buffer.concat(stdout, stdoutBytes).toString("utf8"),
|
|
613
|
+
stderr: Buffer.concat(stderr, stderrBytes).toString("utf8")
|
|
614
|
+
});
|
|
615
|
+
});
|
|
616
|
+
});
|
|
617
|
+
}
|
|
618
|
+
/**
|
|
619
|
+
* Run one selfcheck without blocking, buffering its output. A stream keeps only
|
|
620
|
+
* its tail past the cap instead of killing the child, which is what a full
|
|
621
|
+
* pipe did to `spawnSync` (see SELFCHECK_MAX_BUFFER). The child leads its own
|
|
622
|
+
* process group so the timeout's SIGKILL reaches what the shell started, and
|
|
623
|
+
* the result settles at the kill rather than waiting on pipes a grandchild
|
|
624
|
+
* still holds.
|
|
625
|
+
*/
|
|
626
|
+
async function runCheckAsync(check, o) {
|
|
627
|
+
const { spawn } = await import("node:child_process");
|
|
628
|
+
const cap = o.json ? FAILURE_OUTPUT_CHARS : SELFCHECK_MAX_BUFFER;
|
|
629
|
+
return new Promise((resolveOutcome) => {
|
|
630
|
+
let stdout = "";
|
|
631
|
+
let stderr = "";
|
|
632
|
+
let timedOut = false;
|
|
633
|
+
let spawnError;
|
|
634
|
+
let settled = false;
|
|
635
|
+
const child = spawn(check.command, {
|
|
636
|
+
cwd: o.repoRoot,
|
|
637
|
+
shell: true,
|
|
638
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
639
|
+
detached: true,
|
|
640
|
+
env: selfcheckEnv()
|
|
641
|
+
});
|
|
642
|
+
let groupKilled = false;
|
|
643
|
+
const killGroup = () => {
|
|
644
|
+
if (groupKilled)
|
|
645
|
+
return;
|
|
646
|
+
groupKilled = true;
|
|
647
|
+
try {
|
|
648
|
+
if (child.pid !== undefined)
|
|
649
|
+
process.kill(-child.pid, "SIGKILL");
|
|
650
|
+
}
|
|
651
|
+
catch {
|
|
652
|
+
child.kill("SIGKILL");
|
|
653
|
+
}
|
|
654
|
+
};
|
|
655
|
+
const release = trackGroup(killGroup);
|
|
656
|
+
const settle = (status, signal) => {
|
|
657
|
+
if (settled)
|
|
658
|
+
return;
|
|
659
|
+
settled = true;
|
|
660
|
+
clearTimeout(timer);
|
|
661
|
+
release();
|
|
662
|
+
resolveOutcome({
|
|
663
|
+
status,
|
|
664
|
+
signal,
|
|
665
|
+
errorCode: timedOut ? "ETIMEDOUT" : spawnError?.code,
|
|
666
|
+
hasError: timedOut || spawnError !== undefined,
|
|
667
|
+
stdout,
|
|
668
|
+
stderr
|
|
669
|
+
});
|
|
670
|
+
};
|
|
671
|
+
const timer = setTimeout(() => {
|
|
672
|
+
timedOut = true;
|
|
673
|
+
killGroup();
|
|
674
|
+
settle(null, "SIGKILL");
|
|
675
|
+
}, o.timeoutMs);
|
|
676
|
+
child.stdout?.setEncoding("utf8");
|
|
677
|
+
child.stderr?.setEncoding("utf8");
|
|
678
|
+
child.stdout?.on("data", (d) => {
|
|
679
|
+
stdout += d;
|
|
680
|
+
if (stdout.length > cap)
|
|
681
|
+
stdout = stdout.slice(-cap);
|
|
682
|
+
});
|
|
683
|
+
child.stderr?.on("data", (d) => {
|
|
684
|
+
stderr += d;
|
|
685
|
+
if (stderr.length > cap)
|
|
686
|
+
stderr = stderr.slice(-cap);
|
|
687
|
+
});
|
|
688
|
+
child.on("error", (e) => {
|
|
689
|
+
spawnError = e;
|
|
690
|
+
settle(null, null);
|
|
691
|
+
});
|
|
692
|
+
child.on("close", (code, signal) => settle(code, signal));
|
|
693
|
+
});
|
|
694
|
+
}
|
|
695
|
+
/**
|
|
696
|
+
* Turn what a selfcheck did into a result and print its report. `emit` receives
|
|
697
|
+
* the text so a parallel run can print it as one block.
|
|
698
|
+
*
|
|
699
|
+
* Fails closed on a kill: a timeout or any other signal leaves `status` null,
|
|
700
|
+
* which counts as a failure, never as the "no exit code, assume ok" case. Only
|
|
701
|
+
* ETIMEDOUT is reported as a timeout, though — every other signal used to be
|
|
702
|
+
* too, and a check killed for some other reason then claimed to have run for
|
|
703
|
+
* the full `--timeout` when it had run for three minutes.
|
|
704
|
+
*/
|
|
705
|
+
function interpretOutcome(check, raw, o, durationMs) {
|
|
706
|
+
const timedOut = raw.errorCode === "ETIMEDOUT";
|
|
707
|
+
const killed = raw.signal != null || raw.hasError;
|
|
708
|
+
const exitCode = timedOut ? TIMEOUT_EXIT_CODE : (raw.status ?? 1);
|
|
709
|
+
let stderrText = "";
|
|
710
|
+
let stdoutText = "";
|
|
711
|
+
if (o.json && (timedOut || killed || exitCode !== 0)) {
|
|
712
|
+
const output = `${raw.stdout}\n${raw.stderr}`.slice(-FAILURE_OUTPUT_CHARS);
|
|
713
|
+
stderrText = `\n${check.harnessId} failed (exit ${exitCode}):\n${output}\n`;
|
|
714
|
+
}
|
|
715
|
+
if (!o.json && timedOut) {
|
|
716
|
+
stdoutText = `\nTIMEOUT ${check.harnessId} exceeded ${o.timeoutSeconds}s: ${check.command}\n`;
|
|
717
|
+
}
|
|
718
|
+
else if (!o.json && killed) {
|
|
719
|
+
stdoutText = `\nKILLED ${check.harnessId} (${raw.signal ?? raw.errorCode}): ${check.command}\n`;
|
|
720
|
+
}
|
|
721
|
+
return {
|
|
722
|
+
result: {
|
|
723
|
+
harnessId: check.harnessId,
|
|
724
|
+
command: check.command,
|
|
725
|
+
ok: !timedOut && !killed && exitCode === 0,
|
|
726
|
+
exitCode,
|
|
727
|
+
timedOut,
|
|
728
|
+
killed,
|
|
729
|
+
durationMs
|
|
730
|
+
},
|
|
731
|
+
stdoutText,
|
|
732
|
+
stderrText
|
|
733
|
+
};
|
|
734
|
+
}
|
|
735
|
+
/**
|
|
736
|
+
* Run the selected selfchecks and return their results in plan order.
|
|
737
|
+
*
|
|
738
|
+
* With `jobs` of 1 every check runs serially in plan order with live output.
|
|
739
|
+
* With more, the cheap checks run `jobs` at a time with buffered output
|
|
740
|
+
* printed per check as a block, then the expensive ones run serially: they
|
|
741
|
+
* stage bundles and bind ports, so they cannot share a machine with each other
|
|
742
|
+
* or with the cheap pool.
|
|
743
|
+
*/
|
|
744
|
+
export async function executeChecks(checks, o) {
|
|
745
|
+
const results = new Array(checks.length);
|
|
746
|
+
const runSerial = async (index) => {
|
|
747
|
+
const check = checks[index];
|
|
748
|
+
process.stderr.write(`START ${check.harnessId}\n`);
|
|
749
|
+
const started = performance.now();
|
|
750
|
+
if (!o.json)
|
|
751
|
+
console.log(`\n── ${check.harnessId}: ${check.command}\n`);
|
|
752
|
+
const { result, stdoutText, stderrText } = interpretOutcome(check, await runCheckSerial(check, o), o, Math.round(performance.now() - started));
|
|
753
|
+
if (stderrText)
|
|
754
|
+
process.stderr.write(stderrText);
|
|
755
|
+
if (stdoutText)
|
|
756
|
+
process.stdout.write(stdoutText);
|
|
757
|
+
results[index] = result;
|
|
758
|
+
process.stderr.write(`${result.ok ? "PASS" : "FAIL"} ${check.harnessId} (${result.durationMs}ms)\n`);
|
|
759
|
+
};
|
|
760
|
+
if (o.jobs <= 1) {
|
|
761
|
+
for (let i = 0; i < checks.length; i++)
|
|
762
|
+
await runSerial(i);
|
|
763
|
+
return results;
|
|
764
|
+
}
|
|
765
|
+
const cheap = [];
|
|
766
|
+
const expensive = [];
|
|
767
|
+
checks.forEach((c, i) => (c.cost === "cheap" ? cheap : expensive).push(i));
|
|
768
|
+
let next = 0;
|
|
769
|
+
const worker = async () => {
|
|
770
|
+
while (next < cheap.length) {
|
|
771
|
+
const index = cheap[next++];
|
|
772
|
+
const check = checks[index];
|
|
773
|
+
process.stderr.write(`START ${check.harnessId}\n`);
|
|
774
|
+
const started = performance.now();
|
|
775
|
+
const raw = await runCheckAsync(check, o);
|
|
776
|
+
const { result, stdoutText, stderrText } = interpretOutcome(check, raw, o, Math.round(performance.now() - started));
|
|
777
|
+
// One block per check, written without an await in between, so output
|
|
778
|
+
// from checks that finish together does not interleave.
|
|
779
|
+
if (!o.json) {
|
|
780
|
+
process.stdout.write(`\n── ${check.harnessId}: ${check.command}\n\n${raw.stdout}`);
|
|
781
|
+
if (raw.stderr)
|
|
782
|
+
process.stderr.write(raw.stderr);
|
|
783
|
+
}
|
|
784
|
+
if (stderrText)
|
|
785
|
+
process.stderr.write(stderrText);
|
|
786
|
+
if (stdoutText)
|
|
787
|
+
process.stdout.write(stdoutText);
|
|
788
|
+
results[index] = result;
|
|
789
|
+
process.stderr.write(`${result.ok ? "PASS" : "FAIL"} ${check.harnessId} (${result.durationMs}ms)\n`);
|
|
790
|
+
}
|
|
791
|
+
};
|
|
792
|
+
await Promise.all(Array.from({ length: Math.min(o.jobs, cheap.length) }, () => worker()));
|
|
793
|
+
for (const index of expensive)
|
|
794
|
+
await runSerial(index);
|
|
795
|
+
return results;
|
|
796
|
+
}
|
|
797
|
+
export function printGatePlan(plan, runCount, skippedExpensive, skippedSuites, all) {
|
|
798
|
+
const skipNote = [
|
|
799
|
+
skippedExpensive > 0
|
|
800
|
+
? `${skippedExpensive} expensive skipped — pass --expensive`
|
|
801
|
+
: "",
|
|
802
|
+
skippedSuites.length > 0
|
|
803
|
+
? `${skippedSuites.length} suite-only skipped — pass --include-suites`
|
|
804
|
+
: ""
|
|
805
|
+
].filter(Boolean);
|
|
806
|
+
const suffix = skipNote.length > 0 ? ` (${skipNote.join("; ")})` : "";
|
|
807
|
+
const printSkippedSuites = () => {
|
|
808
|
+
if (skippedSuites.length === 0)
|
|
809
|
+
return;
|
|
810
|
+
console.log("\nCovered by `npm run test:affected` (selfcheck only re-runs those suites):");
|
|
811
|
+
for (const c of skippedSuites)
|
|
812
|
+
console.log(` ${c.harnessId}`);
|
|
813
|
+
};
|
|
814
|
+
if (all) {
|
|
815
|
+
console.log(`\nRunning all ${runCount} selfcheck(s)${suffix}`);
|
|
816
|
+
printSkippedSuites();
|
|
817
|
+
return;
|
|
818
|
+
}
|
|
819
|
+
if (plan.changedFiles.length === 0) {
|
|
820
|
+
console.log("\nNo changed files — nothing to gate.");
|
|
821
|
+
return;
|
|
822
|
+
}
|
|
823
|
+
if (plan.globalFiles.length > 0) {
|
|
824
|
+
console.log(`\n${plan.globalFiles.length} global file(s) force every selfcheck:`);
|
|
825
|
+
for (const f of plan.globalFiles.slice(0, 8))
|
|
826
|
+
console.log(` ${f}`);
|
|
827
|
+
if (plan.globalFiles.length > 8) {
|
|
828
|
+
console.log(` …and ${plan.globalFiles.length - 8} more`);
|
|
829
|
+
}
|
|
830
|
+
}
|
|
831
|
+
console.log(`\n${plan.changedFiles.length} changed file(s) touch ${plan.surfaces.length} surface(s):`);
|
|
832
|
+
for (const s of plan.surfaces) {
|
|
833
|
+
const via = s.viaDependency ? " (via dependency)" : "";
|
|
834
|
+
console.log(` ${s.id.padEnd(20)} (${s.files.length} file(s))${via}`);
|
|
835
|
+
}
|
|
836
|
+
if (plan.unmappedFiles.length > 0) {
|
|
837
|
+
console.log(`\n${plan.unmappedFiles.length} file(s) outside any surface:`);
|
|
838
|
+
for (const f of plan.unmappedFiles.slice(0, 8))
|
|
839
|
+
console.log(` ${f}`);
|
|
840
|
+
if (plan.unmappedFiles.length > 8) {
|
|
841
|
+
console.log(` …and ${plan.unmappedFiles.length - 8} more`);
|
|
842
|
+
}
|
|
843
|
+
}
|
|
844
|
+
if (plan.uncoveredSurfaces.length > 0) {
|
|
845
|
+
console.log(`\nTouched surfaces with NO harness (documented gaps): ${plan.uncoveredSurfaces.join(", ")}`);
|
|
846
|
+
}
|
|
847
|
+
if (plan.manual.length > 0) {
|
|
848
|
+
console.log("\nManual harnesses (need a target/key — run yourself):");
|
|
849
|
+
for (const m of plan.manual) {
|
|
850
|
+
console.log(` ${m.harnessId.padEnd(18)} ${m.command}`);
|
|
851
|
+
}
|
|
852
|
+
}
|
|
853
|
+
printSkippedSuites();
|
|
854
|
+
console.log(`\n${runCount} selfcheck(s) to run${suffix}`);
|
|
855
|
+
}
|
|
856
|
+
/**
|
|
857
|
+
* A capability whose declared contract moved without its coverage mapping
|
|
858
|
+
* moving with it. Adding a capability, or changing what one promises, means
|
|
859
|
+
* saying which eval case or suite covers the new contract — a refactor that
|
|
860
|
+
* leaves the contract alone says nothing and runs the mapped checks as usual.
|
|
861
|
+
*/
|
|
862
|
+
async function capabilityMappingViolations(repoRoot, baseRef, changedFiles) {
|
|
863
|
+
const { execFileSync } = await import("node:child_process");
|
|
864
|
+
const { readFileSync } = await import("node:fs");
|
|
865
|
+
const { join } = await import("node:path");
|
|
866
|
+
// argv, never a shell line: `--base` is a git ref, and ref names may carry
|
|
867
|
+
// `;`, `$(…)` and backticks.
|
|
868
|
+
const git = (args) => execFileSync("git", args, {
|
|
869
|
+
cwd: repoRoot,
|
|
870
|
+
encoding: "utf8",
|
|
871
|
+
stdio: ["ignore", "pipe", "ignore"]
|
|
872
|
+
});
|
|
873
|
+
let working;
|
|
874
|
+
try {
|
|
875
|
+
working = readFileSync(join(repoRoot, CAPABILITY_TABLE_PATH), "utf8");
|
|
876
|
+
}
|
|
877
|
+
catch {
|
|
878
|
+
// Running from a package without the source tree; nothing to compare.
|
|
879
|
+
return [];
|
|
880
|
+
}
|
|
881
|
+
return planCapabilityMappingGate(readGateCapabilityTable(resolveGateBaseRef(baseRef, git), git), working, changedFiles).violations.map((v) => v.detail);
|
|
882
|
+
}
|
|
883
|
+
//# sourceMappingURL=harness.js.map
|