start-command 0.38.0 → 0.39.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/README.md +4 -2
  3. package/bunfig.toml +4 -0
  4. package/eslint.config.mjs +1 -0
  5. package/package.json +4 -4
  6. package/src/lib/args-parser.js +9 -0
  7. package/src/lib/cgroup-memory.js +120 -24
  8. package/src/lib/command-builder.js +4 -0
  9. package/src/lib/container-removal.js +4 -5
  10. package/src/lib/detached-finalize.js +1 -0
  11. package/src/lib/docker-init-option.js +65 -0
  12. package/src/lib/docker-inspect-batch.js +176 -0
  13. package/src/lib/docker-resource-options.js +21 -2
  14. package/src/lib/docker-runtime-args.js +12 -3
  15. package/src/lib/execution-control.js +8 -6
  16. package/src/lib/execution-recovery.js +4 -1
  17. package/src/lib/execution-resume.js +4 -1
  18. package/src/lib/execution-store.js +19 -1
  19. package/src/lib/exit-evidence.js +139 -7
  20. package/src/lib/output-blocks.js +11 -0
  21. package/src/lib/resume-resources.js +2 -2
  22. package/src/lib/screen-isolation.js +72 -42
  23. package/src/lib/status-formatter.js +34 -130
  24. package/src/lib/status-probe.js +149 -0
  25. package/src/lib/store-decode-cache.js +141 -0
  26. package/src/lib/usage.js +3 -0
  27. package/test/cargo-lock-version.mjs +121 -0
  28. package/test/check-rust-coverage.mjs +71 -0
  29. package/test/check-web-archive.mjs +154 -0
  30. package/test/ci-workflow-invariants.js +204 -0
  31. package/test/command-builder-docker-cleanup.js +24 -0
  32. package/test/command-exit-codes.mjs +91 -0
  33. package/test/docker-runtime-options.js +2 -0
  34. package/test/docker-snapshot-safety.js +39 -0
  35. package/test/execution-resume.js +2 -0
  36. package/test/fixtures/fake-npm.sh +64 -0
  37. package/test/fixtures/lychee-report.md +39 -0
  38. package/test/isolation.js +32 -0
  39. package/test/javascript-first-ci.js +69 -0
  40. package/test/links-workflow.mjs +127 -0
  41. package/test/lint-gate.mjs +56 -0
  42. package/test/npm-publish.mjs +181 -0
  43. package/test/recheck-broken-links.mjs +452 -0
  44. package/test/regression-176.js +2 -0
  45. package/test/regression-187.js +2 -0
  46. package/test/regression-191.js +4 -0
  47. package/test/regression-193.js +2 -0
  48. package/test/regression-198.js +2 -0
  49. package/test/regression-207.js +8 -2
  50. package/test/regression-224.js +2 -0
  51. package/test/regression-236.js +250 -0
  52. package/test/regression-238.js +129 -0
  53. package/test/regression-241.js +254 -0
  54. package/test/regression-242.js +241 -0
  55. package/test/regression-243.js +493 -0
  56. package/test/regression-244.js +471 -0
  57. package/test/regression-247.js +140 -0
  58. package/test/release-push-retry.mjs +172 -0
  59. package/test/run-js-tests.mjs +15 -3
  60. package/test/setup/private-snapshot-lock.js +17 -0
  61. package/test/shared-golden.js +29 -2
  62. package/test/transient-links.mjs +139 -0
@@ -0,0 +1,71 @@
1
+ import { describe, expect, it } from 'bun:test';
2
+
3
+ import {
4
+ checkCoverage,
5
+ parseTarpaulinCoverage,
6
+ } from '../../scripts/check-rust-coverage.mjs';
7
+
8
+ // Tail of a passing cargo-tarpaulin 0.37.5 run (Rust CI/CD, run 39256972823).
9
+ const PASSING_LOG = `|| src/lib/usage.rs: 0/1
10
+ || src/lib/user_manager.rs: 48/170
11
+ ||
12
+ 73.81% coverage, 6296/8530 lines covered
13
+ `;
14
+
15
+ // Tail of the failing run on main (run 39257928573): a test failed, tarpaulin
16
+ // stopped before its summary, and the old step reported
17
+ // "Coverage % is below the 50% threshold" (issue #234).
18
+ const FAILED_TEST_LOG = `failures:
19
+ reservation_failure_after_cleanup_preserves_restart_state
20
+
21
+ test result: FAILED. 5 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 3.13s
22
+
23
+ Error: "Test failed during run"
24
+ `;
25
+
26
+ describe('check-rust-coverage', () => {
27
+ it('parses the tarpaulin summary line', () => {
28
+ expect(parseTarpaulinCoverage(PASSING_LOG)).toBe(73.81);
29
+ });
30
+
31
+ it('uses the last summary when several are printed', () => {
32
+ const log =
33
+ '10.00% coverage, 1/10 lines covered\r\n60.5% coverage, 6/10 lines covered\r\n';
34
+ expect(parseTarpaulinCoverage(log)).toBe(60.5);
35
+ });
36
+
37
+ it('ignores lines that only mention coverage', () => {
38
+ expect(parseTarpaulinCoverage('Coverage: 73.81%\n')).toBeNull();
39
+ expect(parseTarpaulinCoverage('')).toBeNull();
40
+ expect(parseTarpaulinCoverage(undefined)).toBeNull();
41
+ });
42
+
43
+ it('passes when coverage meets the threshold', () => {
44
+ const result = checkCoverage(PASSING_LOG, 50);
45
+ expect(result.ok).toBe(true);
46
+ expect(result.message).toBe('✅ Coverage 73.81% meets the 50% threshold');
47
+ });
48
+
49
+ it('fails when coverage is below the threshold', () => {
50
+ const result = checkCoverage(PASSING_LOG, 80);
51
+ expect(result.ok).toBe(false);
52
+ expect(result.message).toBe(
53
+ '❌ Coverage 73.81% is below the 80% threshold'
54
+ );
55
+ });
56
+
57
+ it('reports a failed test instead of an empty coverage drop (issue #234)', () => {
58
+ const result = checkCoverage(FAILED_TEST_LOG, 50);
59
+ expect(result.ok).toBe(false);
60
+ expect(result.coverage).toBeNull();
61
+ expect(result.message).toContain('a test failed');
62
+ expect(result.message).not.toContain('below the');
63
+ expect(result.message).not.toContain('Coverage %');
64
+ });
65
+
66
+ it('fails when the log has no summary for another reason', () => {
67
+ const result = checkCoverage('error: could not compile', 50);
68
+ expect(result.ok).toBe(false);
69
+ expect(result.message).toContain('Could not determine coverage');
70
+ });
71
+ });
@@ -0,0 +1,154 @@
1
+ // Regression tests for the lychee report parser used by the Broken Link
2
+ // Checker workflow. A lychee error without an http(s) URL - a missing local
3
+ // file, an unresolvable root-relative link - has no Wayback equivalent. Such
4
+ // errors must still be reported, and must keep the check red.
5
+ import assert from 'node:assert/strict';
6
+ import { describe, it } from 'bun:test';
7
+ import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
8
+ import { tmpdir } from 'node:os';
9
+ import { spawnSync } from 'node:child_process';
10
+ import { dirname, join } from 'node:path';
11
+ import { fileURLToPath } from 'node:url';
12
+
13
+ import {
14
+ extractBrokenLinks,
15
+ extractBrokenUrls,
16
+ extractErrorsSection,
17
+ } from '../../scripts/check-web-archive.mjs';
18
+
19
+ const here = dirname(fileURLToPath(import.meta.url));
20
+ const report = readFileSync(
21
+ join(here, 'fixtures', 'lychee-report.md'),
22
+ 'utf-8'
23
+ );
24
+
25
+ describe('extractErrorsSection', () => {
26
+ it('stops at the next top-level heading', () => {
27
+ const section = extractErrorsSection(report);
28
+ assert.ok(section.includes('Errors in README.md'));
29
+ assert.ok(
30
+ !section.includes('Redirects per input'),
31
+ 'the redirects section must not leak into the errors section'
32
+ );
33
+ });
34
+
35
+ it('falls back to the whole document when the heading is absent', () => {
36
+ const legacy = '# Link report\n\n* [404] https://broken.example/legacy\n';
37
+ assert.equal(extractErrorsSection(legacy), legacy);
38
+ });
39
+ });
40
+
41
+ describe('extractBrokenUrls', () => {
42
+ it('ignores successful redirects outside the errors section', () => {
43
+ const shortReport = `## Errors per input
44
+
45
+ ### Errors in docs/reference.md
46
+
47
+ * [502] <https://broken.example/reference> (at 1:1) | Rejected status code: 502
48
+
49
+ ## Redirects per input
50
+
51
+ ### Redirects in README.md
52
+
53
+ * https://working.example/old --[301]--> https://working.example/current
54
+ `;
55
+
56
+ assert.deepEqual(extractBrokenUrls(shortReport), [
57
+ 'https://broken.example/reference',
58
+ ]);
59
+ });
60
+
61
+ it('parses the full document when the errors section is absent', () => {
62
+ const legacyReport = `# Link report
63
+
64
+ * [404] https://broken.example/legacy
65
+ `;
66
+
67
+ assert.deepEqual(extractBrokenUrls(legacyReport), [
68
+ 'https://broken.example/legacy',
69
+ ]);
70
+ });
71
+
72
+ it('extracts every http error from a full report exactly once', () => {
73
+ assert.deepEqual(extractBrokenUrls(report), [
74
+ 'https://example.com/csharp/',
75
+ 'https://example.com/rust/link_cli/',
76
+ ]);
77
+ });
78
+ });
79
+
80
+ describe('extractBrokenLinks', () => {
81
+ it('reports errors that the Wayback Machine cannot answer', () => {
82
+ const { others } = extractBrokenLinks(report);
83
+ assert.equal(
84
+ others.length,
85
+ 2,
86
+ 'the missing DocFX file and the unresolvable root-relative link must not be silently dropped'
87
+ );
88
+ assert.ok(
89
+ others.some((link) => link.endsWith('Foundation.Data.Doublets.Cli.yml')),
90
+ 'the missing local file must be reported'
91
+ );
92
+ assert.ok(
93
+ others.includes('error:'),
94
+ 'the unresolvable root-relative link must be reported'
95
+ );
96
+ });
97
+
98
+ it('never classifies a non-http error as a URL', () => {
99
+ const localOnly = `## Errors per input
100
+
101
+ ### Errors in docs/index.md
102
+
103
+ * [ERROR] <file:///repo/docs/api/Some.Type.yml> (at 15:12) | File not found. Check if file exists and path is correct
104
+ `;
105
+
106
+ const { urls, others } = extractBrokenLinks(localOnly);
107
+ assert.deepEqual(urls, []);
108
+ assert.deepEqual(others, ['file:///repo/docs/api/Some.Type.yml']);
109
+ });
110
+
111
+ it('matches the error count lychee itself reports', () => {
112
+ const { urls, others } = extractBrokenLinks(report);
113
+ const reported = Number(/🚫 Errors\s*\|\s*(\d+)/.exec(report)[1]);
114
+ assert.equal(urls.length + others.length, reported);
115
+ });
116
+ });
117
+
118
+ describe('check-web-archive.mjs end to end', () => {
119
+ it('fails when the only lychee errors have no http URL to archive', async () => {
120
+ const scriptPath = join(
121
+ here,
122
+ '..',
123
+ '..',
124
+ 'scripts',
125
+ 'check-web-archive.mjs'
126
+ );
127
+ const workDir = mkdtempSync(join(tmpdir(), 'web-archive-'));
128
+ const reportPath = join(workDir, 'out.md');
129
+ const outputPath = join(workDir, 'github-output.txt');
130
+ writeFileSync(
131
+ reportPath,
132
+ `## Errors per input
133
+
134
+ ### Errors in docs/index.md
135
+
136
+ * [ERROR] <file:///repo/docs/api/Some.Type.yml> (at 15:12) | File not found. Check if file exists and path is correct
137
+ `
138
+ );
139
+ writeFileSync(outputPath, '');
140
+
141
+ const result = spawnSync(process.execPath, [scriptPath], {
142
+ encoding: 'utf-8',
143
+ env: {
144
+ ...process.env,
145
+ LYCHEE_OUTPUT: reportPath,
146
+ GITHUB_OUTPUT: outputPath,
147
+ },
148
+ });
149
+
150
+ assert.equal(result.status, 1, result.stdout + result.stderr);
151
+ assert.match(readFileSync(outputPath, 'utf-8'), /all_archived=false/);
152
+ rmSync(workDir, { recursive: true, force: true });
153
+ });
154
+ });
@@ -127,6 +127,51 @@ function inRunBlock(workflow, index) {
127
127
  return false;
128
128
  }
129
129
 
130
+ /** The `needs:` of a job, written inline (`[a, b]`) or as a block list. */
131
+ function parseNeeds(body) {
132
+ const inline = jobKey(body, 'needs');
133
+ if (inline === null) {
134
+ return [];
135
+ }
136
+ if (inline) {
137
+ return inline
138
+ .split(/[,[\]]/)
139
+ .map((entry) => entry.trim())
140
+ .filter(Boolean);
141
+ }
142
+ const block = body.match(/^ {4}needs:\n((?: {6}- .*\n?)+)/m);
143
+ return block
144
+ ? block[1]
145
+ .split('\n')
146
+ .map((line) => line.replace(/^ {6}- /, '').trim())
147
+ .filter(Boolean)
148
+ : [];
149
+ }
150
+
151
+ /** Jobs the stage's pipeline-status gate passes to `--required`. */
152
+ function requiredStageJobs(jobs) {
153
+ const status = jobs.find((job) => job.name === 'pipeline-status');
154
+ const match = status && status.body.match(/--required ([\w,-]+)/);
155
+ return match ? match[1].split(',') : [];
156
+ }
157
+
158
+ /** `workflow_dispatch` inputs as `{ name, body }` blocks. */
159
+ function dispatchInputs(workflow) {
160
+ const section = workflow.match(
161
+ /^ {2}workflow_dispatch:\n {4}inputs:\n((?: {6}.*\n| *\n)+)/m
162
+ );
163
+ if (!section) {
164
+ return [];
165
+ }
166
+ return section[1]
167
+ .split(/^(?= {6}[\w-]+:$)/m)
168
+ .map((block) => {
169
+ const header = block.match(/^ {6}([\w-]+):$/m);
170
+ return header ? { name: header[1], body: block } : null;
171
+ })
172
+ .filter(Boolean);
173
+ }
174
+
130
175
  const workflows = listWorkflows();
131
176
 
132
177
  describe('CI workflow invariants', () => {
@@ -449,6 +494,38 @@ describe('CI workflow invariants', () => {
449
494
  );
450
495
  });
451
496
 
497
+ it('fails a pull request whose Cargo.lock is out of date', () => {
498
+ // A release bump that rewrote only Cargo.toml left Cargo.lock behind and
499
+ // every later cargo run silently rewrote it (issue #234).
500
+ const lint = parseJobs(readWorkflow('rust.yml')).find(
501
+ (job) => job.name === 'lint'
502
+ );
503
+ assert.ok(lint, 'rust.yml has no lint job');
504
+ assert.match(lint.body, /cargo metadata --locked/);
505
+ });
506
+
507
+ it('reports a failed Rust test as a failed test, not low coverage', () => {
508
+ // Without pipefail a failing test reached the threshold check as an empty
509
+ // value: "Coverage % is below the 50% threshold" (issue #234).
510
+ const coverage = parseJobs(readWorkflow('rust.yml')).find(
511
+ (job) => job.name === 'coverage'
512
+ );
513
+ assert.ok(coverage, 'rust.yml has no coverage job');
514
+ assert.match(coverage.body, /set -o pipefail/);
515
+ assert.match(coverage.body, /scripts\/check-rust-coverage\.mjs/);
516
+ assert.doesNotMatch(
517
+ coverage.body,
518
+ /grep -oP/,
519
+ 'parse tarpaulin output through the tested helper'
520
+ );
521
+ assert.ok(
522
+ coverage.body.includes(
523
+ 'cargo-tarpaulin-bin-${{ env.TARPAULIN_VERSION }}'
524
+ ),
525
+ 'an unversioned tarpaulin cache key never picks up a new release'
526
+ );
527
+ });
528
+
452
529
  it('parses coverage through the tested helper, not an inline grep', () => {
453
530
  const js = readWorkflow('js.yml');
454
531
  assert.doesNotMatch(
@@ -463,4 +540,131 @@ describe('CI workflow invariants', () => {
463
540
  'an unparsable coverage report must fail, not skip'
464
541
  );
465
542
  });
543
+ it('releases only after every check its stage requires has passed', () => {
544
+ // Rust Auto Release needed only [lint, test, build] and started in run
545
+ // 39257928573 while the tarpaulin test run was failing (issue #234).
546
+ for (const name of ['js.yml', 'rust.yml']) {
547
+ const jobs = parseJobs(readWorkflow(name));
548
+ const required = requiredStageJobs(jobs);
549
+ assert.ok(required.length > 0, `${name}: no --required list`);
550
+ const releases = jobs.filter(
551
+ (job) => WRITER_JOBS.has(job.name) && job.name !== 'changeset-pr'
552
+ );
553
+ assert.ok(releases.length > 0, `${name}: no release jobs`);
554
+ for (const job of releases) {
555
+ const needs = parseNeeds(job.body);
556
+ for (const check of required) {
557
+ assert.ok(
558
+ needs.includes(check),
559
+ `${name}: ${job.name} does not wait for ${check}`
560
+ );
561
+ }
562
+ // The job-level `needs.*` also sees skipped ancestors (changeset-check
563
+ // and changelog are skipped on push), so a wildcard check skipped
564
+ // every main release; only named results are reliable (issue #240).
565
+ assert.ok(
566
+ !job.body.includes('needs.*'),
567
+ `${name}: ${job.name} checks needs.* instead of named results`
568
+ );
569
+ for (const check of needs) {
570
+ assert.ok(
571
+ job.body.includes(`needs.${check}.result == 'success'`),
572
+ `${name}: ${job.name} may start without a successful ${check}`
573
+ );
574
+ }
575
+ }
576
+ }
577
+ });
578
+
579
+ it('fails a main run whose release was skipped although its needs succeeded', () => {
580
+ // Nothing was published for days while every run looked green (#240).
581
+ for (const [name, release] of [
582
+ ['js.yml', 'release'],
583
+ ['rust.yml', 'auto-release'],
584
+ ]) {
585
+ const jobs = parseJobs(readWorkflow(name));
586
+ const job = jobs.find((entry) => entry.name === release);
587
+ const gate = jobs.find((entry) => entry.name === 'pipeline-status');
588
+ assert.ok(
589
+ gate.body.includes(
590
+ `--release '${release}:${parseNeeds(job.body).join(',')}'`
591
+ ),
592
+ `${name}: pipeline-status does not check that ${release} ran`
593
+ );
594
+ }
595
+ });
596
+
597
+ it('gives every required dispatch input a default', () => {
598
+ // `gh workflow run` cannot fill a form; a required input without a
599
+ // default makes the dispatch API answer HTTP 422 (issue #234).
600
+ for (const name of workflows) {
601
+ for (const input of dispatchInputs(readWorkflow(name))) {
602
+ if (/^ {8}required: true$/m.test(input.body)) {
603
+ assert.match(
604
+ input.body,
605
+ /^ {8}default: /m,
606
+ `${name}: dispatch input "${input.name}" has no default`
607
+ );
608
+ }
609
+ }
610
+ }
611
+ });
612
+ it('opens release pull requests with a token that triggers their checks', () => {
613
+ // A pull request opened with the built-in GITHUB_TOKEN starts no
614
+ // pull_request workflows, so the changeset PR never ran its checks
615
+ // (issue #234; template issue #192).
616
+ for (const name of workflows) {
617
+ for (const { name: key, body } of parseJobs(readWorkflow(name))) {
618
+ if (!body.includes('peter-evans/create-pull-request@')) {
619
+ continue;
620
+ }
621
+ assert.ok(
622
+ !/^ {10}token: \$\{\{ secrets\.GITHUB_TOKEN \}\}/m.test(body),
623
+ `${name}: ${key} opens its pull request with GITHUB_TOKEN`
624
+ );
625
+ assert.ok(
626
+ /^ {10}token: \$\{\{ secrets\.RELEASE_PR_TOKEN \}\}/m.test(body) &&
627
+ body.includes('::error::RELEASE_PR_TOKEN is required'),
628
+ `${name}: ${key} must use RELEASE_PR_TOKEN and fail loudly without it`
629
+ );
630
+ }
631
+ }
632
+ });
633
+
634
+ it('re-runs a path-filtered workflow when a script it runs changes', () => {
635
+ // workflows.yml ran only on `.github/**`, so a broken
636
+ // scripts/install-actionlint.sh merged without the linter running.
637
+ for (const name of workflows) {
638
+ const workflow = readWorkflow(name);
639
+ const filters = [
640
+ ...workflow.matchAll(/^ {4}paths:\n((?: {6}- .*\n)+)/gm),
641
+ ].map((match) =>
642
+ match[1]
643
+ .split('\n')
644
+ .map((line) =>
645
+ line
646
+ .replace(/^ {6}- /, '')
647
+ .replace(/'/g, '')
648
+ .trim()
649
+ )
650
+ .filter(Boolean)
651
+ );
652
+ if (filters.length === 0) {
653
+ continue;
654
+ }
655
+ const scripts = new Set(
656
+ [...workflow.matchAll(/\bscripts\/[\w.-]+\.(?:sh|mjs|js)\b/g)].map(
657
+ (match) => match[0]
658
+ )
659
+ );
660
+ for (const paths of filters) {
661
+ for (const script of scripts) {
662
+ assert.ok(
663
+ paths.includes(script) || paths.includes('scripts/**'),
664
+ `${name}: ${script} changes do not trigger the workflow`
665
+ );
666
+ }
667
+ }
668
+ }
669
+ });
466
670
  });
@@ -32,4 +32,28 @@ describe('command-builder docker cleanup forwarding', () => {
32
32
  assert.match(command, /--isolated "tmux"/);
33
33
  assert.doesNotMatch(command, /--keep-container/);
34
34
  });
35
+
36
+ it('forwards --init only to a remaining docker level (issue #244)', () => {
37
+ const nested = buildNextLevelCommand(
38
+ {
39
+ isolatedStack: ['screen', 'docker'],
40
+ imageStack: [null, 'alpine'],
41
+ init: true,
42
+ },
43
+ 'npm test'
44
+ );
45
+ assert.match(nested, /--isolated "docker"/);
46
+ assert.match(nested, / --init(?: |$)/);
47
+
48
+ const consumed = buildNextLevelCommand(
49
+ {
50
+ isolatedStack: ['docker', 'tmux'],
51
+ imageStack: ['alpine', null],
52
+ init: true,
53
+ },
54
+ 'npm test'
55
+ );
56
+ assert.match(consumed, /--isolated "tmux"/);
57
+ assert.doesNotMatch(consumed, /--init/);
58
+ });
35
59
  });
@@ -0,0 +1,91 @@
1
+ import { describe, expect, it } from 'bun:test';
2
+ import { readdirSync, readFileSync } from 'fs';
3
+ import { dirname, join } from 'path';
4
+ import { fileURLToPath } from 'url';
5
+
6
+ // command-stream's `$` resolves with a non-zero `code` instead of throwing, so
7
+ // a bare `await $`...`` - or one inside try/catch - silently ignores a failed
8
+ // command. That made a rejected `npm publish` print "✅ Published" and a failed
9
+ // GitHub API call print "✅ Formatted release notes" (issue #234). Every call in
10
+ // scripts/ must either go through checked() or read the result's `.code`.
11
+
12
+ const scriptsDir = join(
13
+ dirname(fileURLToPath(import.meta.url)),
14
+ '../../scripts'
15
+ );
16
+
17
+ /**
18
+ * Find `$` template calls whose exit code nobody looks at.
19
+ * @param {string} source
20
+ * @returns {string[]} offending lines, prefixed with their line number
21
+ */
22
+ export function findUncheckedCommands(source) {
23
+ const lines = source.split('\n');
24
+ const offenders = [];
25
+ lines.forEach((line, index) => {
26
+ const trimmed = line.trim();
27
+ if (
28
+ !trimmed.includes('$`') ||
29
+ trimmed.startsWith('*') ||
30
+ trimmed.startsWith('//')
31
+ ) {
32
+ return;
33
+ }
34
+ const previous = index > 0 ? lines[index - 1] : '';
35
+ if (/\bchecked\(/.test(line) || /\bchecked\(\s*$/.test(previous)) {
36
+ return;
37
+ }
38
+ const assigned = `${previous}\n${line}`.match(
39
+ /(?:const|let)\s+(\w+)\s*=\s*(?:await\s+)?$|(?:const|let)\s+(\w+)\s*=\s*(?:await\s+)?\$`/m
40
+ );
41
+ const name = assigned && (assigned[1] || assigned[2]);
42
+ const rest = lines.slice(index + 1).join('\n');
43
+ if (name && new RegExp(`\\b${name}\\.code\\b`).test(rest)) {
44
+ return;
45
+ }
46
+ offenders.push(`${index + 1}: ${trimmed}`);
47
+ });
48
+ return offenders;
49
+ }
50
+
51
+ describe('command exit codes in release scripts', () => {
52
+ it('flags a bare call and a try/catch-wrapped call', () => {
53
+ const source = [
54
+ 'try {',
55
+ ' await $`npm run changeset:publish`;',
56
+ '} catch {}',
57
+ 'const r = await $`gh api x`.run({ capture: true });',
58
+ 'console.log(r.stdout);',
59
+ ].join('\n');
60
+ expect(findUncheckedCommands(source)).toEqual([
61
+ '2: await $`npm run changeset:publish`;',
62
+ '4: const r = await $`gh api x`.run({ capture: true });',
63
+ ]);
64
+ });
65
+
66
+ it('accepts checked() and an inspected .code', () => {
67
+ const source = [
68
+ 'await checked($`git push`, "git push");',
69
+ 'await checked(',
70
+ ' $`git fetch`,',
71
+ ' "git fetch"',
72
+ ');',
73
+ 'const ancestry =',
74
+ ' await $`git merge-base --is-ancestor a b`.run({ capture: true });',
75
+ 'if (ancestry.code !== 0) throw new Error("x");',
76
+ '// await $`ignored in a comment`',
77
+ ].join('\n');
78
+ expect(findUncheckedCommands(source)).toEqual([]);
79
+ });
80
+
81
+ it('every command in scripts/*.mjs has its exit code checked', () => {
82
+ const offenders = readdirSync(scriptsDir)
83
+ .filter((file) => file.endsWith('.mjs'))
84
+ .flatMap((file) =>
85
+ findUncheckedCommands(readFileSync(join(scriptsDir, file), 'utf8')).map(
86
+ (offender) => `scripts/${file}:${offender}`
87
+ )
88
+ );
89
+ expect(offenders).toEqual([]);
90
+ });
91
+ });
@@ -303,6 +303,7 @@ describe('buildDockerRuntimeMetadata', () => {
303
303
  labels: null,
304
304
  commandHandoff: null,
305
305
  privileged: null,
306
+ init: null,
306
307
  network: null,
307
308
  networks: null,
308
309
  networkAliases: null,
@@ -332,6 +333,7 @@ describe('buildDockerRuntimeMetadata', () => {
332
333
  mounts: ['type=bind,src=/h,dst=/c'],
333
334
  env: ['FOO=bar'],
334
335
  privileged: true,
336
+ init: null,
335
337
  network: 'hive-formal-ai',
336
338
  networks: ['hive-formal-ai', 'public-egress'],
337
339
  networkAliases: ['formal-ai', 'checker'],
@@ -1,3 +1,5 @@
1
+ // Own snapshot lock even without the bunfig.toml preload (issue #234).
2
+ require('./setup/private-snapshot-lock');
1
3
  const { test } = require('node:test');
2
4
  const assert = require('node:assert/strict');
3
5
  const fs = require('node:fs');
@@ -104,3 +106,40 @@ test('snapshot lock is separate from store locks and excludes concurrent process
104
106
  fs.rmSync(dir, { recursive: true, force: true });
105
107
  }
106
108
  });
109
+
110
+ test('tests never share the host-wide snapshot lock (issue #234)', () => {
111
+ // bunfig.toml preloads test/setup/private-snapshot-lock.js. Without it a
112
+ // real `start --resume` holding the default lock failed every resume test.
113
+ const lockPath = process.env.START_SNAPSHOT_LOCK;
114
+ assert.ok(lockPath, 'START_SNAPSHOT_LOCK is not set by the test preload');
115
+ assert.notEqual(
116
+ lockPath,
117
+ path.join(
118
+ process.platform === 'win32' ? os.tmpdir() : '/tmp',
119
+ 'start-command-docker-snapshot.lock'
120
+ )
121
+ );
122
+ const lock = acquireSnapshotLock({ holder: 'issue-234' });
123
+ try {
124
+ assert.ok(fs.existsSync(lockPath));
125
+ } finally {
126
+ lock.release();
127
+ }
128
+ });
129
+
130
+ test('every snapshot test requires its private lock itself (issue #234)', () => {
131
+ // `bun test ./js/test/x.js` from the repository root skips js/bunfig.toml,
132
+ // so the preload alone left 18 tests on the host-wide lock.
133
+ const dir = __dirname;
134
+ const offenders = fs
135
+ .readdirSync(dir)
136
+ .filter((name) => name.endsWith('.js'))
137
+ .filter((name) => {
138
+ const source = fs.readFileSync(path.join(dir, name), 'utf8');
139
+ return (
140
+ /\b(resumeExecution|acquireSnapshotLock)\(/.test(source) &&
141
+ !source.includes("require('./setup/private-snapshot-lock')")
142
+ );
143
+ });
144
+ assert.deepEqual(offenders, []);
145
+ });
@@ -2,6 +2,8 @@
2
2
  * Tests for --resume and --resume-all (issue #162)
3
3
  */
4
4
 
5
+ // Own snapshot lock even without the bunfig.toml preload (issue #234).
6
+ require('./setup/private-snapshot-lock');
5
7
  const { test, describe } = require('node:test');
6
8
  const assert = require('node:assert');
7
9
  const {