@nodetool-ai/cli-harness 0.0.0-stage → 0.8.9-bootstrap.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/README.md +13 -2
  2. package/dist/.nodetool-build-stamp +1 -0
  3. package/dist/affected/affected.d.ts +48 -0
  4. package/dist/affected/affected.d.ts.map +1 -0
  5. package/dist/affected/affected.js +167 -0
  6. package/dist/affected/affected.js.map +1 -0
  7. package/dist/commands/__tests__/jtbd.test.d.ts +2 -0
  8. package/dist/commands/__tests__/jtbd.test.d.ts.map +1 -0
  9. package/dist/commands/__tests__/jtbd.test.js +120 -0
  10. package/dist/commands/__tests__/jtbd.test.js.map +1 -0
  11. package/dist/commands/affected.d.ts +9 -0
  12. package/dist/commands/affected.d.ts.map +1 -0
  13. package/dist/commands/affected.js +127 -0
  14. package/dist/commands/affected.js.map +1 -0
  15. package/dist/commands/eval.d.ts +152 -0
  16. package/dist/commands/eval.d.ts.map +1 -0
  17. package/dist/commands/eval.js +693 -0
  18. package/dist/commands/eval.js.map +1 -0
  19. package/dist/commands/harness.d.ts +90 -0
  20. package/dist/commands/harness.d.ts.map +1 -0
  21. package/dist/commands/harness.js +883 -0
  22. package/dist/commands/harness.js.map +1 -0
  23. package/dist/commands/jtbd.d.ts +38 -0
  24. package/dist/commands/jtbd.d.ts.map +1 -0
  25. package/dist/commands/jtbd.js +261 -0
  26. package/dist/commands/jtbd.js.map +1 -0
  27. package/dist/commands/reliability.d.ts +14 -0
  28. package/dist/commands/reliability.d.ts.map +1 -0
  29. package/dist/commands/reliability.js +81 -0
  30. package/dist/commands/reliability.js.map +1 -0
  31. package/dist/evals/graph-runner.d.ts +8 -0
  32. package/dist/evals/graph-runner.d.ts.map +1 -0
  33. package/dist/evals/graph-runner.js +136 -0
  34. package/dist/evals/graph-runner.js.map +1 -0
  35. package/dist/harness/capability-coverage.d.ts +165 -0
  36. package/dist/harness/capability-coverage.d.ts.map +1 -0
  37. package/dist/harness/capability-coverage.js +308 -0
  38. package/dist/harness/capability-coverage.js.map +1 -0
  39. package/dist/harness/capability-table.d.ts +17 -0
  40. package/dist/harness/capability-table.d.ts.map +1 -0
  41. package/dist/harness/capability-table.js +3941 -0
  42. package/dist/harness/capability-table.js.map +1 -0
  43. package/dist/harness/changed-files.d.ts +20 -0
  44. package/dist/harness/changed-files.d.ts.map +1 -0
  45. package/dist/harness/changed-files.js +90 -0
  46. package/dist/harness/changed-files.js.map +1 -0
  47. package/dist/harness/declared-capabilities.d.ts +12 -0
  48. package/dist/harness/declared-capabilities.d.ts.map +1 -0
  49. package/dist/harness/declared-capabilities.js +21 -0
  50. package/dist/harness/declared-capabilities.js.map +1 -0
  51. package/dist/harness/registry.d.ts +190 -0
  52. package/dist/harness/registry.d.ts.map +1 -0
  53. package/dist/harness/registry.js +2419 -0
  54. package/dist/harness/registry.js.map +1 -0
  55. package/dist/index.d.ts +22 -0
  56. package/dist/index.d.ts.map +1 -0
  57. package/dist/index.js +33 -0
  58. package/dist/index.js.map +1 -0
  59. 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