@vibe-agent-toolkit/utils 0.2.0-rc.3 → 0.2.0-rc.5

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 (86) hide show
  1. package/README.md +21 -13
  2. package/dist/crawl-timing.d.ts +43 -66
  3. package/dist/crawl-timing.d.ts.map +1 -1
  4. package/dist/crawl-timing.js +35 -79
  5. package/dist/crawl-timing.js.map +1 -1
  6. package/dist/crawl.d.ts +2 -4
  7. package/dist/crawl.d.ts.map +1 -1
  8. package/dist/crawl.js +2 -4
  9. package/dist/crawl.js.map +1 -1
  10. package/dist/fs-utils.d.ts +15 -6
  11. package/dist/fs-utils.d.ts.map +1 -1
  12. package/dist/fs-utils.js +15 -6
  13. package/dist/fs-utils.js.map +1 -1
  14. package/dist/git-tracker.d.ts +32 -1
  15. package/dist/git-tracker.d.ts.map +1 -1
  16. package/dist/git-tracker.js +32 -1
  17. package/dist/git-tracker.js.map +1 -1
  18. package/dist/git.d.ts +11 -4
  19. package/dist/git.d.ts.map +1 -1
  20. package/dist/git.js +11 -4
  21. package/dist/git.js.map +1 -1
  22. package/dist/index.d.ts +19 -20
  23. package/dist/index.d.ts.map +1 -1
  24. package/dist/index.js +19 -41
  25. package/dist/index.js.map +1 -1
  26. package/dist/project.d.ts +7 -5
  27. package/dist/project.d.ts.map +1 -1
  28. package/dist/project.js +7 -5
  29. package/dist/project.js.map +1 -1
  30. package/dist/safe-exec.js +0 -1
  31. package/dist/safe-exec.js.map +1 -1
  32. package/dist/spawn-hardened.js +0 -1
  33. package/dist/spawn-hardened.js.map +1 -1
  34. package/dist/testing.d.ts +73 -3
  35. package/dist/testing.d.ts.map +1 -1
  36. package/dist/testing.js +93 -3
  37. package/dist/testing.js.map +1 -1
  38. package/dist/timing-dump.d.ts +22 -5
  39. package/dist/timing-dump.d.ts.map +1 -1
  40. package/dist/timing-dump.js +89 -23
  41. package/dist/timing-dump.js.map +1 -1
  42. package/eslint/index.cjs +9 -0
  43. package/eslint/index.d.cts +14 -7
  44. package/eslint/rules/no-process-exit-in-phase.cjs +117 -0
  45. package/package.json +5 -7
  46. package/dist/link-auth/build-headers.d.ts +0 -34
  47. package/dist/link-auth/build-headers.d.ts.map +0 -1
  48. package/dist/link-auth/build-headers.js +0 -58
  49. package/dist/link-auth/build-headers.js.map +0 -1
  50. package/dist/link-auth/expand-macro.d.ts +0 -38
  51. package/dist/link-auth/expand-macro.d.ts.map +0 -1
  52. package/dist/link-auth/expand-macro.js +0 -139
  53. package/dist/link-auth/expand-macro.js.map +0 -1
  54. package/dist/link-auth/macros.yaml +0 -50
  55. package/dist/link-auth/resolve-token.d.ts +0 -83
  56. package/dist/link-auth/resolve-token.d.ts.map +0 -1
  57. package/dist/link-auth/resolve-token.js +0 -115
  58. package/dist/link-auth/resolve-token.js.map +0 -1
  59. package/dist/link-auth/resolve.d.ts +0 -102
  60. package/dist/link-auth/resolve.d.ts.map +0 -1
  61. package/dist/link-auth/resolve.js +0 -66
  62. package/dist/link-auth/resolve.js.map +0 -1
  63. package/dist/link-auth/rewrite.d.ts +0 -52
  64. package/dist/link-auth/rewrite.d.ts.map +0 -1
  65. package/dist/link-auth/rewrite.js +0 -102
  66. package/dist/link-auth/rewrite.js.map +0 -1
  67. package/dist/link-auth/select-provider.d.ts +0 -30
  68. package/dist/link-auth/select-provider.d.ts.map +0 -1
  69. package/dist/link-auth/select-provider.js +0 -55
  70. package/dist/link-auth/select-provider.js.map +0 -1
  71. package/dist/link-auth/template.d.ts +0 -40
  72. package/dist/link-auth/template.d.ts.map +0 -1
  73. package/dist/link-auth/template.js +0 -89
  74. package/dist/link-auth/template.js.map +0 -1
  75. package/dist/link-auth/transforms.d.ts +0 -46
  76. package/dist/link-auth/transforms.d.ts.map +0 -1
  77. package/dist/link-auth/transforms.js +0 -52
  78. package/dist/link-auth/transforms.js.map +0 -1
  79. package/dist/template-entry.d.ts +0 -10
  80. package/dist/template-entry.d.ts.map +0 -1
  81. package/dist/template-entry.js +0 -10
  82. package/dist/template-entry.js.map +0 -1
  83. package/dist/template.d.ts +0 -7
  84. package/dist/template.d.ts.map +0 -1
  85. package/dist/template.js +0 -18
  86. package/dist/template.js.map +0 -1
package/dist/testing.d.ts CHANGED
@@ -1,8 +1,78 @@
1
1
  /**
2
- * @vibe-agent-toolkit/utils/testing
2
+ * Fixture primitives shared by suites in more than one package.
3
3
  *
4
- * Isolated temp-directory helpers for test suites. Node-only. Intended for
5
- * a consumer's test code — not for their shipped runtime.
4
+ * Deliberately framework-free: nothing here imports `vitest`, so this module
5
+ * stays a plain function library rather than something that registers hooks as a
6
+ * side effect of being imported. Each suite owns its own `beforeEach`/
7
+ * `afterEach` and calls these from inside them — two lines, which is below
8
+ * anything worth sharing, while the part that is genuinely identical (mkdtemp,
9
+ * write the literal, remove the tree) lives here once.
10
+ *
11
+ * It earned a home in `utils` the ordinary way: two packages needed it, not one
12
+ * package speculating that a second might.
13
+ *
14
+ * ⛔ **Do not import `vitest` here to share the hook wrapper itself.**
15
+ * `subpath-purity.test.ts` pins this entry at an EMPTY third-party set, and that
16
+ * pin is load-bearing: `./testing` is a published subpath, so a test framework
17
+ * reached from it becomes a runtime requirement for every adopter who imports
18
+ * it. {@link replantableCorpus} exists precisely so the per-test wrapper each
19
+ * suite still writes is three lines rather than twelve.
6
20
  */
7
21
  export * from './test-helpers.js';
22
+ /** A planted fixture tree and the means to remove it. */
23
+ export interface TempCorpus {
24
+ /** Absolute path to the tree's root. */
25
+ root: string;
26
+ /** Remove the tree. Safe to call when it is already gone. */
27
+ cleanup: () => void;
28
+ }
29
+ /**
30
+ * Write a literal corpus into a fresh temp directory.
31
+ *
32
+ * 🪤 Fixture files must NOT be byte-identical to each other when the suite is
33
+ * about content-addressed behaviour: VAT's blobs are keyed on their bytes plus
34
+ * the parser kind, so two files with the same content collapse into ONE blob and
35
+ * assertions then describe whichever path sorted first. Give each fixture a
36
+ * distinguishing marker line.
37
+ *
38
+ * ⚠️ The root is minted per call, so a suite that plants per test must call this
39
+ * per test — a root captured once and reused across tests survives its own
40
+ * `cleanup`.
41
+ *
42
+ * @param prefix - `mkdtemp` prefix, so a leaked directory names its own suite
43
+ * @param corpus - Fixture name to file content; written verbatim as UTF-8.
44
+ * Names may include forward-slash subpaths only if their parents already exist
45
+ * @returns The tree's root and its teardown
46
+ */
47
+ export declare function createTempCorpus(prefix: string, corpus: Readonly<Record<string, string>>): TempCorpus;
48
+ /** A corpus that can be planted and cleared repeatedly — one tree per test. */
49
+ export interface ReplantableCorpus {
50
+ /** Plant a fresh tree. Drive from the suite's `beforeEach`. */
51
+ plant: () => void;
52
+ /** Remove the current tree, if any. Drive from the suite's `afterEach`. */
53
+ clear: () => void;
54
+ /** The root minted by the most recent {@link plant}. */
55
+ root: () => string;
56
+ }
57
+ /**
58
+ * Hold a per-test corpus root, so a suite's hook wrapper is three lines.
59
+ *
60
+ * ⚠️ **The root comes back through a GETTER, never as a value.** A per-test
61
+ * fixture is reminted for every test, so a root captured at registration time is
62
+ * `undefined` in the first test and stale in every one after it. That mistake is
63
+ * the reason this holder exists rather than each suite keeping its own `let`.
64
+ *
65
+ * ⭐ Framework-free on purpose: it takes no hooks and registers none, so the
66
+ * suite still owns its own `beforeEach`/`afterEach` and this module keeps the
67
+ * empty third-party set its purity pin asserts. See the module docstring.
68
+ *
69
+ * 🪤 `root()` before the first `plant()` THROWS by name. The shape it replaced
70
+ * read an uninitialised `let` and raised `Cannot read properties of undefined`
71
+ * from inside the fixture, which names neither the suite nor the missing hook.
72
+ *
73
+ * @param prefix - `mkdtemp` prefix, so a leaked directory names its own suite
74
+ * @param corpus - Fixture name to file content, as {@link createTempCorpus} takes it
75
+ * @returns Plant/clear/root, to be driven from the caller's own hooks
76
+ */
77
+ export declare function replantableCorpus(prefix: string, corpus: Readonly<Record<string, string>>): ReplantableCorpus;
8
78
  //# sourceMappingURL=testing.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"testing.d.ts","sourceRoot":"","sources":["../src/testing.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,cAAc,mBAAmB,CAAC"}
1
+ {"version":3,"file":"testing.d.ts","sourceRoot":"","sources":["../src/testing.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;GAmBG;AAUH,cAAc,mBAAmB,CAAC;AAElC,yDAAyD;AACzD,MAAM,WAAW,UAAU;IACzB,wCAAwC;IACxC,IAAI,EAAE,MAAM,CAAC;IACb,6DAA6D;IAC7D,OAAO,EAAE,MAAM,IAAI,CAAC;CACrB;AAED;;;;;;;;;;;;;;;;;GAiBG;AACH,wBAAgB,gBAAgB,CAC9B,MAAM,EAAE,MAAM,EACd,MAAM,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,GACvC,UAAU,CAOZ;AAED,+EAA+E;AAC/E,MAAM,WAAW,iBAAiB;IAChC,+DAA+D;IAC/D,KAAK,EAAE,MAAM,IAAI,CAAC;IAClB,2EAA2E;IAC3E,KAAK,EAAE,MAAM,IAAI,CAAC;IAClB,wDAAwD;IACxD,IAAI,EAAE,MAAM,MAAM,CAAC;CACpB;AAED;;;;;;;;;;;;;;;;;;;GAmBG;AACH,wBAAgB,iBAAiB,CAC/B,MAAM,EAAE,MAAM,EACd,MAAM,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,GACvC,iBAAiB,CAyBnB"}
package/dist/testing.js CHANGED
@@ -1,8 +1,98 @@
1
1
  /**
2
- * @vibe-agent-toolkit/utils/testing
2
+ * Fixture primitives shared by suites in more than one package.
3
3
  *
4
- * Isolated temp-directory helpers for test suites. Node-only. Intended for
5
- * a consumer's test code — not for their shipped runtime.
4
+ * Deliberately framework-free: nothing here imports `vitest`, so this module
5
+ * stays a plain function library rather than something that registers hooks as a
6
+ * side effect of being imported. Each suite owns its own `beforeEach`/
7
+ * `afterEach` and calls these from inside them — two lines, which is below
8
+ * anything worth sharing, while the part that is genuinely identical (mkdtemp,
9
+ * write the literal, remove the tree) lives here once.
10
+ *
11
+ * It earned a home in `utils` the ordinary way: two packages needed it, not one
12
+ * package speculating that a second might.
13
+ *
14
+ * ⛔ **Do not import `vitest` here to share the hook wrapper itself.**
15
+ * `subpath-purity.test.ts` pins this entry at an EMPTY third-party set, and that
16
+ * pin is load-bearing: `./testing` is a published subpath, so a test framework
17
+ * reached from it becomes a runtime requirement for every adopter who imports
18
+ * it. {@link replantableCorpus} exists precisely so the per-test wrapper each
19
+ * suite still writes is three lines rather than twelve.
6
20
  */
21
+ import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
22
+ import { normalizedTmpdir, safePath } from './path-utils.js';
23
+ // The rest of the testing surface. ⚠️ This re-export is the ENTIRETY of what
24
+ // `./testing` used to be, and dropping it silently removed `detachGitEnv`,
25
+ // `setupSyncTempDirSuite` and every sibling from a published subpath. A module
26
+ // that is both a definition site and a barrel loses the barrel first.
7
27
  export * from './test-helpers.js';
28
+ /**
29
+ * Write a literal corpus into a fresh temp directory.
30
+ *
31
+ * 🪤 Fixture files must NOT be byte-identical to each other when the suite is
32
+ * about content-addressed behaviour: VAT's blobs are keyed on their bytes plus
33
+ * the parser kind, so two files with the same content collapse into ONE blob and
34
+ * assertions then describe whichever path sorted first. Give each fixture a
35
+ * distinguishing marker line.
36
+ *
37
+ * ⚠️ The root is minted per call, so a suite that plants per test must call this
38
+ * per test — a root captured once and reused across tests survives its own
39
+ * `cleanup`.
40
+ *
41
+ * @param prefix - `mkdtemp` prefix, so a leaked directory names its own suite
42
+ * @param corpus - Fixture name to file content; written verbatim as UTF-8.
43
+ * Names may include forward-slash subpaths only if their parents already exist
44
+ * @returns The tree's root and its teardown
45
+ */
46
+ export function createTempCorpus(prefix, corpus) {
47
+ const root = mkdtempSync(safePath.join(normalizedTmpdir(), prefix));
48
+ for (const [name, content] of Object.entries(corpus)) {
49
+ // eslint-disable-next-line security/detect-non-literal-fs-filename -- a fixture name from the caller's own literal corpus, under this call's fresh mkdtemp root
50
+ writeFileSync(safePath.join(root, name), content, 'utf8');
51
+ }
52
+ return { root, cleanup: () => rmSync(root, { recursive: true, force: true }) };
53
+ }
54
+ /**
55
+ * Hold a per-test corpus root, so a suite's hook wrapper is three lines.
56
+ *
57
+ * ⚠️ **The root comes back through a GETTER, never as a value.** A per-test
58
+ * fixture is reminted for every test, so a root captured at registration time is
59
+ * `undefined` in the first test and stale in every one after it. That mistake is
60
+ * the reason this holder exists rather than each suite keeping its own `let`.
61
+ *
62
+ * ⭐ Framework-free on purpose: it takes no hooks and registers none, so the
63
+ * suite still owns its own `beforeEach`/`afterEach` and this module keeps the
64
+ * empty third-party set its purity pin asserts. See the module docstring.
65
+ *
66
+ * 🪤 `root()` before the first `plant()` THROWS by name. The shape it replaced
67
+ * read an uninitialised `let` and raised `Cannot read properties of undefined`
68
+ * from inside the fixture, which names neither the suite nor the missing hook.
69
+ *
70
+ * @param prefix - `mkdtemp` prefix, so a leaked directory names its own suite
71
+ * @param corpus - Fixture name to file content, as {@link createTempCorpus} takes it
72
+ * @returns Plant/clear/root, to be driven from the caller's own hooks
73
+ */
74
+ export function replantableCorpus(prefix, corpus) {
75
+ let planted;
76
+ return {
77
+ plant: () => {
78
+ // 🪤 Removes any tree still standing FIRST. Nested `describe` blocks each
79
+ // get their own `beforeEach` and vitest runs outer-then-inner before a
80
+ // single `afterEach`, so a double plant is ordinary rather than exotic.
81
+ // Overwriting the handle without this would leak the first tree for the
82
+ // process's lifetime, silently and only on the suites that nest.
83
+ planted?.cleanup();
84
+ planted = createTempCorpus(prefix, corpus);
85
+ },
86
+ clear: () => {
87
+ planted?.cleanup();
88
+ planted = undefined;
89
+ },
90
+ root: () => {
91
+ if (planted === undefined) {
92
+ throw new Error(`replantableCorpus('${prefix}'): root() before plant() — the suite is missing its beforeEach`);
93
+ }
94
+ return planted.root;
95
+ },
96
+ };
97
+ }
8
98
  //# sourceMappingURL=testing.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"testing.js","sourceRoot":"","sources":["../src/testing.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,cAAc,mBAAmB,CAAC"}
1
+ {"version":3,"file":"testing.js","sourceRoot":"","sources":["../src/testing.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;GAmBG;AAEH,OAAO,EAAE,WAAW,EAAE,MAAM,EAAE,aAAa,EAAE,MAAM,SAAS,CAAC;AAE7D,OAAO,EAAE,gBAAgB,EAAE,QAAQ,EAAE,MAAM,iBAAiB,CAAC;AAE7D,6EAA6E;AAC7E,2EAA2E;AAC3E,+EAA+E;AAC/E,sEAAsE;AACtE,cAAc,mBAAmB,CAAC;AAUlC;;;;;;;;;;;;;;;;;GAiBG;AACH,MAAM,UAAU,gBAAgB,CAC9B,MAAc,EACd,MAAwC;IAExC,MAAM,IAAI,GAAG,WAAW,CAAC,QAAQ,CAAC,IAAI,CAAC,gBAAgB,EAAE,EAAE,MAAM,CAAC,CAAC,CAAC;IACpE,KAAK,MAAM,CAAC,IAAI,EAAE,OAAO,CAAC,IAAI,MAAM,CAAC,OAAO,CAAC,MAAM,CAAC,EAAE,CAAC;QACrD,gKAAgK;QAChK,aAAa,CAAC,QAAQ,CAAC,IAAI,CAAC,IAAI,EAAE,IAAI,CAAC,EAAE,OAAO,EAAE,MAAM,CAAC,CAAC;IAC5D,CAAC;IACD,OAAO,EAAE,IAAI,EAAE,OAAO,EAAE,GAAG,EAAE,CAAC,MAAM,CAAC,IAAI,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,KAAK,EAAE,IAAI,EAAE,CAAC,EAAE,CAAC;AACjF,CAAC;AAYD;;;;;;;;;;;;;;;;;;;GAmBG;AACH,MAAM,UAAU,iBAAiB,CAC/B,MAAc,EACd,MAAwC;IAExC,IAAI,OAA+B,CAAC;IACpC,OAAO;QACL,KAAK,EAAE,GAAG,EAAE;YACV,0EAA0E;YAC1E,uEAAuE;YACvE,wEAAwE;YACxE,wEAAwE;YACxE,iEAAiE;YACjE,OAAO,EAAE,OAAO,EAAE,CAAC;YACnB,OAAO,GAAG,gBAAgB,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;QAC7C,CAAC;QACD,KAAK,EAAE,GAAG,EAAE;YACV,OAAO,EAAE,OAAO,EAAE,CAAC;YACnB,OAAO,GAAG,SAAS,CAAC;QACtB,CAAC;QACD,IAAI,EAAE,GAAG,EAAE;YACT,IAAI,OAAO,KAAK,SAAS,EAAE,CAAC;gBAC1B,MAAM,IAAI,KAAK,CACb,sBAAsB,MAAM,iEAAiE,CAC9F,CAAC;YACJ,CAAC;YACD,OAAO,OAAO,CAAC,IAAI,CAAC;QACtB,CAAC;KACF,CAAC;AACJ,CAAC"}
@@ -18,9 +18,14 @@
18
18
  * 2. A dump failure is written to **stderr and never thrown**. These run from
19
19
  * an `exit` listener, where a throw changes the process's exit behaviour, and
20
20
  * they must never touch stdout, which carries vat's report.
21
- * 3. A pid can file more than one dump. `vat validate` spawns the vat binary
22
- * once per phase and pids are reused, so `<stem>-<pid>.json` genuinely
23
- * collides; the name gains a counter rather than overwriting.
21
+ * 3. A pid can file more than one dump, and the name is CLAIMED rather than
22
+ * checked. `vat validate` spawns the vat binary once per phase and pids are
23
+ * reused, so `<stem>-<pid>.json` genuinely collides; worse, worker threads
24
+ * SHARE their parent's pid, so a thread pool's whole cohort competes for one
25
+ * `<stem>-<pid>` sequence at the same instant. The name gains a counter
26
+ * rather than overwriting, and the counter is settled by
27
+ * {@link EXCLUSIVE_CREATE} so the OS — not a check-then-write gap — decides
28
+ * who won it.
24
29
  * 4. The process's own wall and CPU time is read ONCE, at dump time. It is a
25
30
  * lifetime figure for the process and never a duration of the measured work;
26
31
  * its value is the RATIO, which tells a reader whether the wall-timed
@@ -59,6 +64,16 @@ export interface TimingProcess {
59
64
  /** System CPU consumed by the process, across all its threads. */
60
65
  cpuSystemMs: number;
61
66
  }
67
+ /**
68
+ * Ceiling on the pid-collision search. A directory holding this many dumps for
69
+ * one pid is a runaway, not a collision, and giving up on a reported line is a
70
+ * better outcome than spinning.
71
+ *
72
+ * Exhausting it does NOT fall back to overwriting the last slot. That was the
73
+ * old behaviour, and it destroyed a dump for the same reason the check-then-
74
+ * write gap did — silently, and precisely when the directory was busiest.
75
+ */
76
+ export declare const MAX_DUMP_COLLISIONS = 1000;
62
77
  /**
63
78
  * Reduce a raw env value to a directory or `null`.
64
79
  *
@@ -107,13 +122,15 @@ export declare function readTimingProcess(): TimingProcess;
107
122
  * Write one seam's dump, if the seam is on.
108
123
  *
109
124
  * The body is built lazily, inside this call, so a disabled seam never pays to
110
- * snapshot accumulators nobody will read.
125
+ * snapshot accumulators nobody will read — and a `build` that throws is reported
126
+ * like any other failure, because this runs from an `exit` listener where a
127
+ * throw would change the process's exit behaviour.
111
128
  *
112
129
  * @param noun - What the seam is called, for any failure line
113
130
  * @param directory - Where to write, or `null` when the seam is off
114
131
  * @param basename - Basename stem for the file
115
132
  * @param build - Produces the dump body
116
- * @returns The path written, or `null` when the seam is off or the write failed
133
+ * @returns The path written, or `null` when the seam is off or nothing was written
117
134
  */
118
135
  export declare function writeTimingDump(noun: string, directory: string | null, basename: string, build: () => unknown): string | null;
119
136
  //# sourceMappingURL=timing-dump.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"timing-dump.d.ts","sourceRoot":"","sources":["../src/timing-dump.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2CG;AAOH;;;;;;;;GAQG;AACH,MAAM,WAAW,aAAa;IAC5B,6CAA6C;IAC7C,MAAM,EAAE,MAAM,CAAC;IACf,gEAAgE;IAChE,SAAS,EAAE,MAAM,CAAC;IAClB,kEAAkE;IAClE,WAAW,EAAE,MAAM,CAAC;CACrB;AAeD;;;;;;;;;GASG;AACH,wBAAgB,wBAAwB,CAAC,GAAG,EAAE,MAAM,GAAG,SAAS,GAAG,MAAM,GAAG,IAAI,CAE/E;AAED;;;;;;;;;GASG;AACH,wBAAgB,uBAAuB,CAAC,IAAI,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,KAAK,EAAE,OAAO,GAAG,IAAI,CAG1F;AAED;;;;;;;;;GASG;AACH,wBAAgB,qBAAqB,CAAC,IAAI,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,GAAG,IAAI,CAM3E;AAED;;;;;;;;;GASG;AACH,wBAAgB,iBAAiB,IAAI,aAAa,CAOjD;AAuBD;;;;;;;;;;;GAWG;AACH,wBAAgB,eAAe,CAC7B,IAAI,EAAE,MAAM,EACZ,SAAS,EAAE,MAAM,GAAG,IAAI,EACxB,QAAQ,EAAE,MAAM,EAChB,KAAK,EAAE,MAAM,OAAO,GACnB,MAAM,GAAG,IAAI,CAYf"}
1
+ {"version":3,"file":"timing-dump.d.ts","sourceRoot":"","sources":["../src/timing-dump.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAgDG;AAOH;;;;;;;;GAQG;AACH,MAAM,WAAW,aAAa;IAC5B,6CAA6C;IAC7C,MAAM,EAAE,MAAM,CAAC;IACf,gEAAgE;IAChE,SAAS,EAAE,MAAM,CAAC;IAClB,kEAAkE;IAClE,WAAW,EAAE,MAAM,CAAC;CACrB;AAQD;;;;;;;;GAQG;AACH,eAAO,MAAM,mBAAmB,OAAO,CAAC;AA6BxC;;;;;;;;;GASG;AACH,wBAAgB,wBAAwB,CAAC,GAAG,EAAE,MAAM,GAAG,SAAS,GAAG,MAAM,GAAG,IAAI,CAE/E;AAED;;;;;;;;;GASG;AACH,wBAAgB,uBAAuB,CAAC,IAAI,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,KAAK,EAAE,OAAO,GAAG,IAAI,CAG1F;AAED;;;;;;;;;GASG;AACH,wBAAgB,qBAAqB,CAAC,IAAI,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,GAAG,IAAI,CAM3E;AAED;;;;;;;;;GASG;AACH,wBAAgB,iBAAiB,IAAI,aAAa,CAOjD;AA0DD;;;;;;;;;;;;;GAaG;AACH,wBAAgB,eAAe,CAC7B,IAAI,EAAE,MAAM,EACZ,SAAS,EAAE,MAAM,GAAG,IAAI,EACxB,QAAQ,EAAE,MAAM,EAChB,KAAK,EAAE,MAAM,OAAO,GACnB,MAAM,GAAG,IAAI,CAoBf"}
@@ -18,9 +18,14 @@
18
18
  * 2. A dump failure is written to **stderr and never thrown**. These run from
19
19
  * an `exit` listener, where a throw changes the process's exit behaviour, and
20
20
  * they must never touch stdout, which carries vat's report.
21
- * 3. A pid can file more than one dump. `vat validate` spawns the vat binary
22
- * once per phase and pids are reused, so `<stem>-<pid>.json` genuinely
23
- * collides; the name gains a counter rather than overwriting.
21
+ * 3. A pid can file more than one dump, and the name is CLAIMED rather than
22
+ * checked. `vat validate` spawns the vat binary once per phase and pids are
23
+ * reused, so `<stem>-<pid>.json` genuinely collides; worse, worker threads
24
+ * SHARE their parent's pid, so a thread pool's whole cohort competes for one
25
+ * `<stem>-<pid>` sequence at the same instant. The name gains a counter
26
+ * rather than overwriting, and the counter is settled by
27
+ * {@link EXCLUSIVE_CREATE} so the OS — not a check-then-write gap — decides
28
+ * who won it.
24
29
  * 4. The process's own wall and CPU time is read ONCE, at dump time. It is a
25
30
  * lifetime figure for the process and never a duration of the measured work;
26
31
  * its value is the RATIO, which tells a reader whether the wall-timed
@@ -42,7 +47,7 @@
42
47
  * thing a reader has to be able to trust identically. What each seam keeps for
43
48
  * itself is its accumulator shape, its dump body and the noun it is called by.
44
49
  */
45
- import { existsSync, writeFileSync } from 'node:fs';
50
+ import { writeFileSync } from 'node:fs';
46
51
  import { safePath } from './path-core.js';
47
52
  import { mkdirSyncReal } from './path-utils.js';
48
53
  /** `process.cpuUsage()` reports microseconds; a dump reports milliseconds. */
@@ -51,10 +56,27 @@ const MICROSECONDS_PER_MS = 1000;
51
56
  const MS_PER_SECOND = 1000;
52
57
  /**
53
58
  * Ceiling on the pid-collision search. A directory holding this many dumps for
54
- * one pid is a runaway, not a collision; overwriting the last slot is a better
55
- * outcome than spinning.
59
+ * one pid is a runaway, not a collision, and giving up on a reported line is a
60
+ * better outcome than spinning.
61
+ *
62
+ * Exhausting it does NOT fall back to overwriting the last slot. That was the
63
+ * old behaviour, and it destroyed a dump for the same reason the check-then-
64
+ * write gap did — silently, and precisely when the directory was busiest.
56
65
  */
57
- const MAX_DUMP_COLLISIONS = 1000;
66
+ export const MAX_DUMP_COLLISIONS = 1000;
67
+ /**
68
+ * The `writeFileSync` flag that makes claiming a name atomic.
69
+ *
70
+ * `wx` is create-exclusive: the OS fails the call with `EEXIST` when the path
71
+ * already exists, so exactly one caller wins each name no matter how many are
72
+ * racing for it. Asking `existsSync` first and writing second is two operations
73
+ * with a gap in between, and threads sharing a pid land in that gap routinely —
74
+ * a measured 8-worker parse pool lost 6 of 9 dumps to it, reporting 44 of 172
75
+ * documents as if that were the whole run.
76
+ */
77
+ const EXCLUSIVE_CREATE = 'wx';
78
+ /** How the failure line names the ceiling case, so it reads unlike an OS error. */
79
+ const CEILING_DETAIL = `every name up to the ${String(MAX_DUMP_COLLISIONS)}-collision ceiling was already claimed`;
58
80
  /**
59
81
  * Reduce a raw env value to a directory or `null`.
60
82
  *
@@ -119,46 +141,90 @@ export function readTimingProcess() {
119
141
  };
120
142
  }
121
143
  /**
122
- * Pick a dump path that does not already exist.
144
+ * The nth candidate name in one pid's dump sequence.
145
+ *
146
+ * @param directory - Directory dumps are written to
147
+ * @param stem - Basename and pid, already joined
148
+ * @param collision - 0 for the unsuffixed name, then the collision counter
149
+ * @returns The candidate path
150
+ */
151
+ function timingDumpCandidate(directory, stem, collision) {
152
+ const suffix = collision === 0 ? '' : `-${String(collision)}`;
153
+ return safePath.join(directory, `${stem}${suffix}.json`);
154
+ }
155
+ /**
156
+ * Whether a caught write failure means somebody else already holds the name.
157
+ *
158
+ * @param error - Whatever `writeFileSync` threw
159
+ * @returns `true` only for `EEXIST`, which is a lost race and not a fault
160
+ */
161
+ function isNameAlreadyTaken(error) {
162
+ return error instanceof Error && 'code' in error && error.code === 'EEXIST';
163
+ }
164
+ /**
165
+ * Claim a name by creating it, and fill it in the same operation.
166
+ *
167
+ * Choosing the name and writing it CANNOT be two steps: whatever separates them
168
+ * is a window in which another writer takes the name that was just declared
169
+ * free, and the loser's dump disappears with no error raised anywhere. So each
170
+ * candidate is written create-exclusively ({@link EXCLUSIVE_CREATE}) and an
171
+ * `EEXIST` — the OS saying somebody else got there — advances to the next
172
+ * counter rather than overwriting.
123
173
  *
124
174
  * @param directory - Directory dumps are written to
125
175
  * @param basename - Basename stem; the pid and any collision counter follow
126
- * @returns An unused path, or the last candidate tried
176
+ * @param contents - The already-serialized dump body
177
+ * @returns Which name was claimed, or why none could be
127
178
  */
128
- function nextTimingDumpPath(directory, basename) {
179
+ function claimTimingDump(directory, basename, contents) {
129
180
  const stem = `${basename}-${String(process.pid)}`;
130
- let candidate = safePath.join(directory, `${stem}.json`);
131
- for (let collision = 1;
132
- // eslint-disable-next-line security/detect-non-literal-fs-filename -- operator-supplied diagnostic directory from a VAT_*_TIMING variable
133
- collision <= MAX_DUMP_COLLISIONS && existsSync(candidate); collision += 1) {
134
- candidate = safePath.join(directory, `${stem}-${String(collision)}.json`);
181
+ for (let collision = 0; collision <= MAX_DUMP_COLLISIONS; collision += 1) {
182
+ const candidate = timingDumpCandidate(directory, stem, collision);
183
+ try {
184
+ // eslint-disable-next-line security/detect-non-literal-fs-filename -- operator-supplied diagnostic directory from a VAT_*_TIMING variable
185
+ writeFileSync(candidate, contents, { encoding: 'utf-8', flag: EXCLUSIVE_CREATE });
186
+ return { outcome: 'written', path: candidate };
187
+ }
188
+ catch (error) {
189
+ if (!isNameAlreadyTaken(error))
190
+ return { outcome: 'failed', path: candidate, error };
191
+ }
135
192
  }
136
- return candidate;
193
+ return {
194
+ outcome: 'exhausted',
195
+ path: timingDumpCandidate(directory, stem, MAX_DUMP_COLLISIONS),
196
+ };
137
197
  }
138
198
  /**
139
199
  * Write one seam's dump, if the seam is on.
140
200
  *
141
201
  * The body is built lazily, inside this call, so a disabled seam never pays to
142
- * snapshot accumulators nobody will read.
202
+ * snapshot accumulators nobody will read — and a `build` that throws is reported
203
+ * like any other failure, because this runs from an `exit` listener where a
204
+ * throw would change the process's exit behaviour.
143
205
  *
144
206
  * @param noun - What the seam is called, for any failure line
145
207
  * @param directory - Where to write, or `null` when the seam is off
146
208
  * @param basename - Basename stem for the file
147
209
  * @param build - Produces the dump body
148
- * @returns The path written, or `null` when the seam is off or the write failed
210
+ * @returns The path written, or `null` when the seam is off or nothing was written
149
211
  */
150
212
  export function writeTimingDump(noun, directory, basename, build) {
151
213
  if (directory === null)
152
214
  return null;
153
- const target = nextTimingDumpPath(directory, basename);
215
+ let claim;
154
216
  try {
155
- // eslint-disable-next-line security/detect-non-literal-fs-filename -- operator-supplied diagnostic directory from a VAT_*_TIMING variable
156
- writeFileSync(target, `${JSON.stringify(build(), null, 2)}\n`, 'utf-8');
217
+ claim = claimTimingDump(directory, basename, `${JSON.stringify(build(), null, 2)}\n`);
157
218
  }
158
219
  catch (error) {
159
- reportTimingDumpFailure(noun, target, error);
220
+ // Only `build()` and its serialization can reach here; every filesystem
221
+ // failure is already an outcome rather than a throw.
222
+ reportTimingDumpFailure(noun, directory, error);
160
223
  return null;
161
224
  }
162
- return target;
225
+ if (claim.outcome === 'written')
226
+ return claim.path;
227
+ reportTimingDumpFailure(noun, claim.path, claim.outcome === 'exhausted' ? CEILING_DETAIL : claim.error);
228
+ return null;
163
229
  }
164
230
  //# sourceMappingURL=timing-dump.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"timing-dump.js","sourceRoot":"","sources":["../src/timing-dump.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"timing-dump.js","sourceRoot":"","sources":["../src/timing-dump.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAgDG;AAEH,OAAO,EAAE,aAAa,EAAE,MAAM,SAAS,CAAC;AAExC,OAAO,EAAE,QAAQ,EAAE,MAAM,gBAAgB,CAAC;AAC1C,OAAO,EAAE,aAAa,EAAE,MAAM,iBAAiB,CAAC;AAoBhD,8EAA8E;AAC9E,MAAM,mBAAmB,GAAG,IAAI,CAAC;AAEjC,uEAAuE;AACvE,MAAM,aAAa,GAAG,IAAI,CAAC;AAE3B;;;;;;;;GAQG;AACH,MAAM,CAAC,MAAM,mBAAmB,GAAG,IAAI,CAAC;AAExC;;;;;;;;;GASG;AACH,MAAM,gBAAgB,GAAG,IAAI,CAAC;AAE9B,mFAAmF;AACnF,MAAM,cAAc,GAAG,wBAAwB,MAAM,CAAC,mBAAmB,CAAC,wCAAwC,CAAC;AAcnH;;;;;;;;;GASG;AACH,MAAM,UAAU,wBAAwB,CAAC,GAAuB;IAC9D,OAAO,GAAG,KAAK,SAAS,IAAI,GAAG,KAAK,EAAE,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,GAAG,CAAC;AACtD,CAAC;AAED;;;;;;;;;GASG;AACH,MAAM,UAAU,uBAAuB,CAAC,IAAY,EAAE,MAAc,EAAE,KAAc;IAClF,MAAM,MAAM,GAAG,KAAK,YAAY,KAAK,CAAC,CAAC,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC;IACtE,OAAO,CAAC,MAAM,CAAC,KAAK,CAAC,QAAQ,IAAI,oBAAoB,MAAM,KAAK,MAAM,IAAI,CAAC,CAAC;AAC9E,CAAC;AAED;;;;;;;;;GASG;AACH,MAAM,UAAU,qBAAqB,CAAC,IAAY,EAAE,SAAiB;IACnE,IAAI,CAAC;QACH,aAAa,CAAC,SAAS,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,CAAC,CAAC;IAChD,CAAC;IAAC,OAAO,KAAK,EAAE,CAAC;QACf,uBAAuB,CAAC,IAAI,EAAE,SAAS,EAAE,KAAK,CAAC,CAAC;IAClD,CAAC;AACH,CAAC;AAED;;;;;;;;;GASG;AACH,MAAM,UAAU,iBAAiB;IAC/B,MAAM,GAAG,GAAG,OAAO,CAAC,QAAQ,EAAE,CAAC;IAC/B,OAAO;QACL,MAAM,EAAE,OAAO,CAAC,MAAM,EAAE,GAAG,aAAa;QACxC,SAAS,EAAE,GAAG,CAAC,IAAI,GAAG,mBAAmB;QACzC,WAAW,EAAE,GAAG,CAAC,MAAM,GAAG,mBAAmB;KAC9C,CAAC;AACJ,CAAC;AAED;;;;;;;GAOG;AACH,SAAS,mBAAmB,CAAC,SAAiB,EAAE,IAAY,EAAE,SAAiB;IAC7E,MAAM,MAAM,GAAG,SAAS,KAAK,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,IAAI,MAAM,CAAC,SAAS,CAAC,EAAE,CAAC;IAC9D,OAAO,QAAQ,CAAC,IAAI,CAAC,SAAS,EAAE,GAAG,IAAI,GAAG,MAAM,OAAO,CAAC,CAAC;AAC3D,CAAC;AAED;;;;;GAKG;AACH,SAAS,kBAAkB,CAAC,KAAc;IACxC,OAAO,KAAK,YAAY,KAAK,IAAI,MAAM,IAAI,KAAK,IAAI,KAAK,CAAC,IAAI,KAAK,QAAQ,CAAC;AAC9E,CAAC;AAED;;;;;;;;;;;;;;GAcG;AACH,SAAS,eAAe,CAAC,SAAiB,EAAE,QAAgB,EAAE,QAAgB;IAC5E,MAAM,IAAI,GAAG,GAAG,QAAQ,IAAI,MAAM,CAAC,OAAO,CAAC,GAAG,CAAC,EAAE,CAAC;IAClD,KAAK,IAAI,SAAS,GAAG,CAAC,EAAE,SAAS,IAAI,mBAAmB,EAAE,SAAS,IAAI,CAAC,EAAE,CAAC;QACzE,MAAM,SAAS,GAAG,mBAAmB,CAAC,SAAS,EAAE,IAAI,EAAE,SAAS,CAAC,CAAC;QAClE,IAAI,CAAC;YACH,0IAA0I;YAC1I,aAAa,CAAC,SAAS,EAAE,QAAQ,EAAE,EAAE,QAAQ,EAAE,OAAO,EAAE,IAAI,EAAE,gBAAgB,EAAE,CAAC,CAAC;YAClF,OAAO,EAAE,OAAO,EAAE,SAAS,EAAE,IAAI,EAAE,SAAS,EAAE,CAAC;QACjD,CAAC;QAAC,OAAO,KAAK,EAAE,CAAC;YACf,IAAI,CAAC,kBAAkB,CAAC,KAAK,CAAC;gBAAE,OAAO,EAAE,OAAO,EAAE,QAAQ,EAAE,IAAI,EAAE,SAAS,EAAE,KAAK,EAAE,CAAC;QACvF,CAAC;IACH,CAAC;IACD,OAAO;QACL,OAAO,EAAE,WAAW;QACpB,IAAI,EAAE,mBAAmB,CAAC,SAAS,EAAE,IAAI,EAAE,mBAAmB,CAAC;KAChE,CAAC;AACJ,CAAC;AAED;;;;;;;;;;;;;GAaG;AACH,MAAM,UAAU,eAAe,CAC7B,IAAY,EACZ,SAAwB,EACxB,QAAgB,EAChB,KAAoB;IAEpB,IAAI,SAAS,KAAK,IAAI;QAAE,OAAO,IAAI,CAAC;IAEpC,IAAI,KAAsB,CAAC;IAC3B,IAAI,CAAC;QACH,KAAK,GAAG,eAAe,CAAC,SAAS,EAAE,QAAQ,EAAE,GAAG,IAAI,CAAC,SAAS,CAAC,KAAK,EAAE,EAAE,IAAI,EAAE,CAAC,CAAC,IAAI,CAAC,CAAC;IACxF,CAAC;IAAC,OAAO,KAAK,EAAE,CAAC;QACf,wEAAwE;QACxE,qDAAqD;QACrD,uBAAuB,CAAC,IAAI,EAAE,SAAS,EAAE,KAAK,CAAC,CAAC;QAChD,OAAO,IAAI,CAAC;IACd,CAAC;IAED,IAAI,KAAK,CAAC,OAAO,KAAK,SAAS;QAAE,OAAO,KAAK,CAAC,IAAI,CAAC;IACnD,uBAAuB,CACrB,IAAI,EACJ,KAAK,CAAC,IAAI,EACV,KAAK,CAAC,OAAO,KAAK,WAAW,CAAC,CAAC,CAAC,cAAc,CAAC,CAAC,CAAC,KAAK,CAAC,KAAK,CAC7D,CAAC;IACF,OAAO,IAAI,CAAC;AACd,CAAC"}
package/eslint/index.cjs CHANGED
@@ -54,6 +54,7 @@ const rules = {
54
54
  'no-self-package-import': require('./rules/no-self-package-import.cjs'),
55
55
  'require-justified-skip': require('./rules/require-justified-skip.cjs'),
56
56
  'no-bare-symlink-in-tests': require('./rules/no-bare-symlink-in-tests.cjs'),
57
+ 'no-process-exit-in-phase': require('./rules/no-process-exit-in-phase.cjs'),
57
58
  };
58
59
 
59
60
  /**
@@ -136,6 +137,14 @@ const RECOMMENDED_EXCLUDE = new Set([
136
137
  // ride in `recommended`; that is a public-API change and has not been made.
137
138
  // VAT enables the whole rule explicitly, scoped to its own conventions.
138
139
  'no-bare-symlink-in-tests',
140
+ // Excluded because it keys on a NAMING CONVENTION that is VAT's, not a
141
+ // portable fact — the same reason `no-unsafe-root-join` is excluded above. The
142
+ // hazard it guards is real and general (an in-process orchestrator whose step
143
+ // calls `process.exit()` silently skips every later step), but the marker is
144
+ // the `…Phase` suffix, and an adopter with an unrelated `computeRenderPhase()`
145
+ // that legitimately exits would get a finding they cannot act on. It ships in
146
+ // `rules` and VAT enables it explicitly, scoped to its own orchestrators.
147
+ 'no-process-exit-in-phase',
139
148
  ]);
140
149
 
141
150
  /**
@@ -47,13 +47,20 @@ declare namespace plugin {
47
47
  rules: Record<string, RuleModule>;
48
48
  configs: {
49
49
  /**
50
- * The cross-platform safety core: 18 of the 22 rules, 15 `error` / 3 `warn`.
51
- * Four are excluded, for three reasons: `no-test-scoped-functions` and
52
- * `require-justified-skip` are positions on test style rather than
53
- * portability facts; `no-unsafe-root-join` keys on naming rather than taint;
54
- * and `no-raw-text-decode` names a decoding seam that only exists in the
55
- * consuming repo. All four still ship in `rules` and are enabled by naming
56
- * them.
50
+ * The cross-platform safety core: 18 of the 25 rules, 15 `error` / 3 `warn`.
51
+ *
52
+ * Seven are excluded, for four reasons. `no-test-scoped-functions`,
53
+ * `require-justified-skip` and `no-bare-symlink-in-tests` are positions on
54
+ * TEST STYLE rather than portability facts. `no-unsafe-root-join` and
55
+ * `no-process-exit-in-phase` key on NAMING rather than on the property they
56
+ * care about (taint, and an orchestrated call site). `no-raw-text-decode`
57
+ * names a decoding SEAM that only exists in the consuming repo. And
58
+ * `no-self-package-import` REQUIRES an option this config cannot supply.
59
+ *
60
+ * All seven still ship in `rules` and are enabled by naming them — which is
61
+ * what this repo's own `eslint.config.js` does. The count above is asserted
62
+ * by `packages/utils/test/eslint/rules.test.ts`, so it cannot drift
63
+ * unnoticed the way it did when this comment said "four".
57
64
  */
58
65
  recommended: FlatConfig;
59
66
  };
@@ -0,0 +1,117 @@
1
+ /**
2
+ * ESLint Rule: no-process-exit-in-phase
3
+ *
4
+ * Forbid `process.exit()` inside a phase entry point — a function whose name
5
+ * ends in `Phase`.
6
+ *
7
+ * ## Why this rule exists
8
+ *
9
+ * `vat validate`, `vat verify` and `vat build` used to run each phase as a
10
+ * SEPARATE CHILD PROCESS. A `process.exit()` inside a phase was then perfectly
11
+ * safe: it ended that child, the parent read the exit code, and the run carried
12
+ * on to the next phase. Nothing about the code said so — the safety came
13
+ * entirely from the process boundary.
14
+ *
15
+ * That boundary is gone. Phases now run in the orchestrator's own process, so a
16
+ * surviving `process.exit()` ends THE WHOLE RUN: every later phase is silently
17
+ * skipped, the parent's aggregation never happens, and the process exits 0-or-1
18
+ * having done half the work with nothing in the document to say so. It is the
19
+ * worst shape of failure this codebase has — a confident, well-formed, wrong
20
+ * answer — and it cannot be caught by a type, because `process.exit()`
21
+ * typechecks anywhere.
22
+ *
23
+ * A phase must RETURN `{ document, exitCode }` and let its caller decide whether
24
+ * to print and exit (a command-line run) or to fold the result into the run (an
25
+ * orchestrated one).
26
+ *
27
+ * ## Why the name is the marker
28
+ *
29
+ * The `…Phase` suffix is the convention every phase entry point already follows
30
+ * (`runResourcesValidatePhase`, `runSkillsBuildPhase`, …). Keying the rule to it
31
+ * means a new phase is protected the moment it is named like one, with no list
32
+ * to keep in sync — a list is the thing that goes stale silently, which is the
33
+ * defect class this rule exists to prevent in the first place.
34
+ *
35
+ * The thin Commander wrappers that CALL these functions keep their
36
+ * `process.exit()`: deciding how the process ends is exactly their job, and they
37
+ * are not named `…Phase`.
38
+ */
39
+
40
+ /** Does this call expression read as `process.exit(...)`? */
41
+ function isProcessExitCall(node) {
42
+ const { callee } = node;
43
+ return (
44
+ callee.type === 'MemberExpression' &&
45
+ !callee.computed &&
46
+ callee.object.type === 'Identifier' &&
47
+ callee.object.name === 'process' &&
48
+ callee.property.type === 'Identifier' &&
49
+ callee.property.name === 'exit'
50
+ );
51
+ }
52
+
53
+ /**
54
+ * The declared name of a function-ish node, however it was declared.
55
+ *
56
+ * Covers the three spellings a phase entry point can legitimately take: a
57
+ * function declaration, a `const x = function () {}`, and a `const x = () => {}`.
58
+ * A rule that only understood declarations would be silently inert against an
59
+ * arrow const — the same blind spot `no-test-scoped-functions` documents.
60
+ */
61
+ function functionName(node) {
62
+ if (node.id && node.id.type === 'Identifier') return node.id.name;
63
+
64
+ const { parent } = node;
65
+ if (parent && parent.type === 'VariableDeclarator' && parent.id.type === 'Identifier') {
66
+ return parent.id.name;
67
+ }
68
+ if (parent && parent.type === 'Property' && parent.key.type === 'Identifier') {
69
+ return parent.key.name;
70
+ }
71
+ return undefined;
72
+ }
73
+
74
+ const FUNCTION_TYPES = new Set([
75
+ 'FunctionDeclaration',
76
+ 'FunctionExpression',
77
+ 'ArrowFunctionExpression',
78
+ ]);
79
+
80
+ /** The nearest enclosing function named like a phase entry point, if any. */
81
+ function enclosingPhaseName(node) {
82
+ for (let current = node.parent; current; current = current.parent) {
83
+ if (!FUNCTION_TYPES.has(current.type)) continue;
84
+ const name = functionName(current);
85
+ if (name !== undefined && name.endsWith('Phase')) return name;
86
+ }
87
+ return undefined;
88
+ }
89
+
90
+ module.exports = {
91
+ meta: {
92
+ type: 'problem',
93
+ docs: {
94
+ description:
95
+ 'Forbid process.exit() inside a phase entry point, where it would end the whole orchestrated run',
96
+ category: 'Agentic Code Safety',
97
+ recommended: true,
98
+ },
99
+ fixable: null,
100
+ schema: [],
101
+ messages: {
102
+ exitInPhase:
103
+ "'{{name}}' is a phase entry point, so process.exit() here ends the ENTIRE run — every later phase is skipped and the orchestrator never aggregates. Return { document, exitCode } instead and let the caller decide how the process ends.",
104
+ },
105
+ },
106
+
107
+ create(context) {
108
+ return {
109
+ CallExpression(node) {
110
+ if (!isProcessExitCall(node)) return;
111
+ const name = enclosingPhaseName(node);
112
+ if (name === undefined) return;
113
+ context.report({ node, messageId: 'exitInPhase', data: { name } });
114
+ },
115
+ };
116
+ },
117
+ };