@codyswann/lisa 4.1.6 → 4.3.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/all/copy-overwrite/scripts/lib/bounded-spawn.mjs +188 -0
  2. package/dist/core/lisa-owned-hash-ledger.d.ts.map +1 -1
  3. package/dist/core/lisa-owned-hash-ledger.js +4 -0
  4. package/dist/core/lisa-owned-hash-ledger.js.map +1 -1
  5. package/dist/core/upstream-evidence-manifest.d.ts.map +1 -1
  6. package/dist/core/upstream-evidence-manifest.js +12 -1
  7. package/dist/core/upstream-evidence-manifest.js.map +1 -1
  8. package/expo/copy-overwrite/scripts/lib/bounded-spawn.mjs +188 -0
  9. package/package.json +2 -1
  10. package/plugins/lisa/.claude-plugin/plugin.json +1 -1
  11. package/plugins/lisa/.codex-plugin/plugin.json +1 -1
  12. package/plugins/lisa-agy/plugin.json +1 -1
  13. package/plugins/lisa-cdk/.claude-plugin/plugin.json +1 -1
  14. package/plugins/lisa-cdk/.codex-plugin/plugin.json +1 -1
  15. package/plugins/lisa-cdk-agy/plugin.json +1 -1
  16. package/plugins/lisa-cdk-copilot/.claude-plugin/plugin.json +1 -1
  17. package/plugins/lisa-cdk-cursor/.claude-plugin/plugin.json +1 -1
  18. package/plugins/lisa-copilot/.claude-plugin/plugin.json +1 -1
  19. package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
  20. package/plugins/lisa-expo/.claude-plugin/plugin.json +1 -1
  21. package/plugins/lisa-expo/.codex-plugin/plugin.json +1 -1
  22. package/plugins/lisa-expo-agy/plugin.json +1 -1
  23. package/plugins/lisa-expo-copilot/.claude-plugin/plugin.json +1 -1
  24. package/plugins/lisa-expo-cursor/.claude-plugin/plugin.json +1 -1
  25. package/plugins/lisa-harper-fabric/.claude-plugin/plugin.json +1 -1
  26. package/plugins/lisa-harper-fabric/.codex-plugin/plugin.json +1 -1
  27. package/plugins/lisa-harper-fabric-agy/plugin.json +1 -1
  28. package/plugins/lisa-harper-fabric-copilot/.claude-plugin/plugin.json +1 -1
  29. package/plugins/lisa-harper-fabric-cursor/.claude-plugin/plugin.json +1 -1
  30. package/plugins/lisa-nestjs/.claude-plugin/plugin.json +1 -1
  31. package/plugins/lisa-nestjs/.codex-plugin/plugin.json +1 -1
  32. package/plugins/lisa-nestjs-agy/plugin.json +1 -1
  33. package/plugins/lisa-nestjs-copilot/.claude-plugin/plugin.json +1 -1
  34. package/plugins/lisa-nestjs-cursor/.claude-plugin/plugin.json +1 -1
  35. package/plugins/lisa-openclaw/.claude-plugin/plugin.json +1 -1
  36. package/plugins/lisa-openclaw/.codex-plugin/plugin.json +1 -1
  37. package/plugins/lisa-openclaw-agy/plugin.json +1 -1
  38. package/plugins/lisa-openclaw-copilot/.claude-plugin/plugin.json +1 -1
  39. package/plugins/lisa-openclaw-cursor/.claude-plugin/plugin.json +1 -1
  40. package/plugins/lisa-phaser/.claude-plugin/plugin.json +1 -1
  41. package/plugins/lisa-phaser/.codex-plugin/plugin.json +1 -1
  42. package/plugins/lisa-phaser-agy/plugin.json +1 -1
  43. package/plugins/lisa-phaser-copilot/.claude-plugin/plugin.json +1 -1
  44. package/plugins/lisa-phaser-cursor/.claude-plugin/plugin.json +1 -1
  45. package/plugins/lisa-rails/.claude-plugin/plugin.json +1 -1
  46. package/plugins/lisa-rails/.codex-plugin/plugin.json +1 -1
  47. package/plugins/lisa-rails-agy/plugin.json +1 -1
  48. package/plugins/lisa-rails-copilot/.claude-plugin/plugin.json +1 -1
  49. package/plugins/lisa-rails-cursor/.claude-plugin/plugin.json +1 -1
  50. package/plugins/lisa-typescript/.claude-plugin/plugin.json +1 -1
  51. package/plugins/lisa-typescript/.codex-plugin/plugin.json +1 -1
  52. package/plugins/lisa-typescript-agy/plugin.json +1 -1
  53. package/plugins/lisa-typescript-copilot/.claude-plugin/plugin.json +1 -1
  54. package/plugins/lisa-typescript-cursor/.claude-plugin/plugin.json +1 -1
  55. package/plugins/lisa-wiki/.claude-plugin/plugin.json +1 -1
  56. package/plugins/lisa-wiki/.codex-plugin/plugin.json +1 -1
  57. package/plugins/lisa-wiki-agy/plugin.json +1 -1
  58. package/plugins/lisa-wiki-copilot/.claude-plugin/plugin.json +1 -1
  59. package/plugins/lisa-wiki-cursor/.claude-plugin/plugin.json +1 -1
  60. package/scripts/build-plugins.sh +28 -0
  61. package/scripts/lib/bounded-spawn.mjs +185 -0
  62. package/typescript/copy-overwrite/scripts/lib/bounded-spawn.mjs +188 -0
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-wiki",
3
- "version": "4.1.6",
3
+ "version": "4.3.1",
4
4
  "description": "LLM Wiki — a distributable, git-native markdown knowledge base for Claude Code and Codex",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-wiki",
3
- "version": "4.1.6",
3
+ "version": "4.3.1",
4
4
  "description": "LLM Wiki — a distributable, git-native markdown knowledge base for Claude Code and Codex",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-wiki",
3
- "version": "4.1.6",
3
+ "version": "4.3.1",
4
4
  "description": "LLM Wiki — a distributable, git-native markdown knowledge base for Claude Code and Codex",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -183,6 +183,34 @@ if [ -f "$ROOT_DIR/scripts/lib/invoked-as-script.mjs" ] &&
183
183
  done
184
184
  fi
185
185
 
186
+ # The shared child-start deadline, into the same lanes, for the same reason.
187
+ #
188
+ # A guard that starts a child and never bounds it does not hang loudly — it
189
+ # returns a VERDICT. `spawnSync` hands back empty streams and a null status when
190
+ # it kills a child at its deadline, which is indistinguishable from a program
191
+ # that ran and said no, so `status === 0 ? out : null` reads a busy machine as a
192
+ # clean negative and the guard says "allow" (#2980).
193
+ #
194
+ # Materialized rather than hand-copied on exactly the `invoked-as-script`
195
+ # reasoning above: downstream every lane lands flat in one `scripts/`, so a
196
+ # single `scripts/lib/bounded-spawn.mjs` serves them all and `./lib/...`
197
+ # resolves from each, while inside THIS repo the lanes are separate trees whose
198
+ # unit tests import the lane copies directly. One canonical source, a
199
+ # byte-equality test, and the copies cannot drift.
200
+ #
201
+ # Guarded on the GENERATOR as well as the source, again for the fixture reason:
202
+ # this script runs against isolated fixtures that vendor `scripts/lib/` without
203
+ # the repository's own `scripts/*.mjs`.
204
+ if [ -f "$ROOT_DIR/scripts/lib/bounded-spawn.mjs" ] &&
205
+ [ -f "$ROOT_DIR/scripts/materialize-copy-overwrite.mjs" ]; then
206
+ for spawn_lane in all typescript expo; do
207
+ spawn_lib_dir="$ROOT_DIR/$spawn_lane/copy-overwrite/scripts/lib"
208
+ mkdir -p "$spawn_lib_dir"
209
+ materialize "$ROOT_DIR/scripts/lib/bounded-spawn.mjs" \
210
+ "$spawn_lib_dir/bounded-spawn.mjs"
211
+ done
212
+ fi
213
+
186
214
  # Stack-specific plugins (NO base copy)
187
215
  STACKS=(typescript expo nestjs cdk harper-fabric phaser rails)
188
216
  for stack in "${STACKS[@]}"; do
@@ -0,0 +1,185 @@
1
+ /**
2
+ * bounded-spawn — the one implementation of "start a child, and refuse to
3
+ * return a verdict from one that never finished", shared by every guarded
4
+ * `.mjs` entry point Lisa ships that starts a synchronous child.
5
+ *
6
+ * @remarks
7
+ * ## The defect this exists to remove
8
+ *
9
+ * A test with a hung child fails a gate. **A guard with a hung child returns a
10
+ * verdict.** That is the whole reason this module is separate from the test
11
+ * tree's equivalent: out here the consequence of a child that never answered
12
+ * is a check that says "allow".
13
+ *
14
+ * The mechanism is one line, and it is measured rather than reasoned. These are
15
+ * the three outcomes of a synchronous child start, on this platform:
16
+ *
17
+ * | outcome | `.code` | `.status` | `.signal` | streams |
18
+ * | --- | --- | --- | --- | --- |
19
+ * | killed at its deadline | `"ETIMEDOUT"` | `null` | `"SIGKILL"` | **empty** |
20
+ * | ran and exited non-zero | *(absent)* | `3` | `null` | real |
21
+ * | binary not found | `"ENOENT"` | `null` | `null` | empty |
22
+ *
23
+ * Now read the shape that appears all over this repository:
24
+ *
25
+ * ```js
26
+ * return result.status === 0 ? result.stdout : null;
27
+ * ```
28
+ *
29
+ * `status === 0` is false for a killed child **and** for a program that ran and
30
+ * said no, so the two are indistinguishable and both become `null`. The caller
31
+ * treats `null` as "the answer is no" and proceeds. **A busy machine therefore
32
+ * produces the same value a clean answer does**, and nothing anywhere says the
33
+ * word "time".
34
+ *
35
+ * That is why the fail-open spelling was never a careless choice. It is
36
+ * indistinguishable from correct handling unless you already know the kill case
37
+ * exists — which is precisely the argument for putting the discrimination in
38
+ * one shared place instead of asking 80 call sites to remember it.
39
+ *
40
+ * ## Why this THROWS
41
+ *
42
+ * Returning a distinguishable value would leave every call site free to ignore
43
+ * it, and the call sites that most need to check are the ones that already
44
+ * decided a falsy result means "no". Throwing inverts the default: a call site
45
+ * that does nothing gets fail-closed behaviour, and only a call site that
46
+ * *deliberately* catches can proceed.
47
+ *
48
+ * Measured on the tree this was written for: of 80 synchronous child starts in
49
+ * shipped scripts, **51 propagate** — a throw escapes and fails the process, so
50
+ * those are fixed by this module alone — and **29 sit inside a `try` with a
51
+ * `catch`**, where a throw would be swallowed. Those 29 are not a conversion
52
+ * job; each needs a human answer to "what should happen when this child is
53
+ * killed?", and the answer is written at the call site with
54
+ * {@link isChildTimeout}.
55
+ *
56
+ * ## Why the discriminator is `error.code`, not a custom error class
57
+ *
58
+ * A class cannot cross the boundary. Plugin payloads have no `./lib/` to import
59
+ * from — the accommodation `threshold-ratchet.mjs` and `preflight-secrets.mjs`
60
+ * already make — so a shared class would be unavailable in exactly the tree
61
+ * with the most call sites. `ETIMEDOUT` is set by Node itself on all three
62
+ * forms (`spawnSync` puts it on `result.error`; `execFileSync` and `execSync`
63
+ * throw an error carrying it), so a payload that cannot import this module
64
+ * writes the identical check inline and is stating the same **platform fact**
65
+ * rather than duplicating a Lisa convention.
66
+ *
67
+ * `ENOENT` is deliberately NOT treated as a timeout. A missing binary is a real
68
+ * answer about the environment, it is reported the same way on every run, and
69
+ * conflating it with a kill would hide a broken install behind a retryable-
70
+ * looking error.
71
+ * @module scripts/lib/bounded-spawn
72
+ */
73
+
74
+ import { execFileSync, spawnSync } from "node:child_process";
75
+
76
+ /**
77
+ * Deadline a child gets when the caller states none, in milliseconds.
78
+ *
79
+ * Deliberately generous. This is not a performance budget — it is the point
80
+ * past which a child is presumed hung, and the cost of setting it too low is a
81
+ * guard that fails on a slow machine while the cost of setting it too high is
82
+ * only a slower failure. `git` resolved through `PATH` on macOS goes through
83
+ * Apple's `xcrun` shim, which has been measured at over 20 seconds under load
84
+ * against 11ms for a real binary (CodySwannGT/lisa#2887).
85
+ *
86
+ * **30s is not padding, and tightening it is not a safe optimisation.** A
87
+ * deadline below the shim's measured worst case would make this module's own
88
+ * timeout the dominant failure mode — the tool would start manufacturing the
89
+ * failures it exists to detect, and every one of them would look exactly like
90
+ * the defect it was built to catch. If a number here ever needs changing, the
91
+ * thing to change first is `git` being resolved through `PATH` at all.
92
+ */
93
+ export const DEFAULT_CHILD_BUDGET_MS = 30_000;
94
+
95
+ /**
96
+ * Whether this error is a child that was killed at its deadline.
97
+ *
98
+ * The one question a `catch` around a bounded child start has to ask. A `catch`
99
+ * that does not ask it treats "the box was busy" as "the command said no",
100
+ * which is the fail-open this module exists to remove — so a catch block
101
+ * enclosing a bounded start must either re-raise on this or say in a comment
102
+ * why continuing is correct.
103
+ *
104
+ * Accepts `unknown` because that is what a `catch` binding is, and reads the
105
+ * property defensively for the same reason: a thrown non-object is legal
106
+ * JavaScript and must answer `false` rather than crash the guard that was
107
+ * trying to be careful.
108
+ * @param {unknown} error A caught value, or `result.error` from `spawnSync`.
109
+ * @returns {boolean} True when the child was killed at its deadline.
110
+ */
111
+ export function isChildTimeout(error) {
112
+ return (
113
+ typeof error === "object" &&
114
+ error !== null &&
115
+ /** @type {{ code?: unknown }} */ (error).code === "ETIMEDOUT"
116
+ );
117
+ }
118
+
119
+ /**
120
+ * Re-raise a caught value when it is a killed child, otherwise do nothing.
121
+ *
122
+ * The one-line form for the 29 call sites whose `catch` predates this module.
123
+ * Written as a helper rather than left to each site because the correct spelling
124
+ * is `throw error` — NOT wrapping, NOT `throw new Error(...)` — and a wrapped
125
+ * error loses the `code` the next frame up needs to make the same decision.
126
+ * @param {unknown} error The caught value.
127
+ * @returns {void}
128
+ * @throws {unknown} The original value, when it is a killed child.
129
+ */
130
+ export function rethrowIfChildTimeout(error) {
131
+ if (isChildTimeout(error)) throw error;
132
+ }
133
+
134
+ /**
135
+ * Options a bounded start always applies, whatever the caller asked for.
136
+ *
137
+ * `killSignal` is `SIGKILL` rather than the default `SIGTERM` deliberately: the
138
+ * hang this module exists for is a process that is not servicing signals, and a
139
+ * `SIGTERM` a child ignores turns the deadline into a suggestion.
140
+ * @param {object} options The caller's options.
141
+ * @returns {object} The caller's options with the deadline enforced.
142
+ */
143
+ function bounded(options) {
144
+ return {
145
+ ...options,
146
+ killSignal: "SIGKILL",
147
+ timeout: options.timeout ?? DEFAULT_CHILD_BUDGET_MS,
148
+ };
149
+ }
150
+
151
+ /**
152
+ * `spawnSync` with a deadline, refusing to return a killed child's result.
153
+ *
154
+ * `spawnSync` does not throw on a timeout — it returns a result whose streams
155
+ * are EMPTY and whose `status` is `null`, which is why the fail-open shape in
156
+ * the module docblock cannot see it. This throws instead, so a caller that does
157
+ * nothing inherits fail-closed behaviour.
158
+ * @param {string} command The executable.
159
+ * @param {readonly string[]} args Arguments.
160
+ * @param {object} [options] `spawnSync` options; `timeout` overrides the default.
161
+ * @returns {import("node:child_process").SpawnSyncReturns<string>} The result.
162
+ * @throws {Error} When the child was killed at its deadline.
163
+ */
164
+ export function boundedSpawnSync(command, args = [], options = {}) {
165
+ const result = spawnSync(command, [...args], bounded(options));
166
+ if (isChildTimeout(result.error)) throw result.error;
167
+ return result;
168
+ }
169
+
170
+ /**
171
+ * `execFileSync` with a deadline.
172
+ *
173
+ * `execFileSync` already throws on a timeout, so this changes the FAILURE MODE
174
+ * rather than adding one: without a `timeout` there is no deadline to reach and
175
+ * the call blocks for as long as the child lives. The throw it produces carries
176
+ * `code: "ETIMEDOUT"`, which is what {@link isChildTimeout} reads.
177
+ * @param {string} command The executable.
178
+ * @param {readonly string[]} args Arguments.
179
+ * @param {object} [options] `execFileSync` options; `timeout` overrides the default.
180
+ * @returns {string|Buffer} Whatever the child wrote to stdout.
181
+ * @throws {Error} When the child was killed, or exited non-zero.
182
+ */
183
+ export function boundedExecFileSync(command, args = [], options = {}) {
184
+ return execFileSync(command, [...args], bounded(options));
185
+ }
@@ -0,0 +1,188 @@
1
+ // This file is managed by Lisa and IS replaced on each `lisa` run.
2
+ // Do not edit directly — durable changes belong upstream in Lisa.
3
+
4
+ /**
5
+ * bounded-spawn — the one implementation of "start a child, and refuse to
6
+ * return a verdict from one that never finished", shared by every guarded
7
+ * `.mjs` entry point Lisa ships that starts a synchronous child.
8
+ *
9
+ * @remarks
10
+ * ## The defect this exists to remove
11
+ *
12
+ * A test with a hung child fails a gate. **A guard with a hung child returns a
13
+ * verdict.** That is the whole reason this module is separate from the test
14
+ * tree's equivalent: out here the consequence of a child that never answered
15
+ * is a check that says "allow".
16
+ *
17
+ * The mechanism is one line, and it is measured rather than reasoned. These are
18
+ * the three outcomes of a synchronous child start, on this platform:
19
+ *
20
+ * | outcome | `.code` | `.status` | `.signal` | streams |
21
+ * | --- | --- | --- | --- | --- |
22
+ * | killed at its deadline | `"ETIMEDOUT"` | `null` | `"SIGKILL"` | **empty** |
23
+ * | ran and exited non-zero | *(absent)* | `3` | `null` | real |
24
+ * | binary not found | `"ENOENT"` | `null` | `null` | empty |
25
+ *
26
+ * Now read the shape that appears all over this repository:
27
+ *
28
+ * ```js
29
+ * return result.status === 0 ? result.stdout : null;
30
+ * ```
31
+ *
32
+ * `status === 0` is false for a killed child **and** for a program that ran and
33
+ * said no, so the two are indistinguishable and both become `null`. The caller
34
+ * treats `null` as "the answer is no" and proceeds. **A busy machine therefore
35
+ * produces the same value a clean answer does**, and nothing anywhere says the
36
+ * word "time".
37
+ *
38
+ * That is why the fail-open spelling was never a careless choice. It is
39
+ * indistinguishable from correct handling unless you already know the kill case
40
+ * exists — which is precisely the argument for putting the discrimination in
41
+ * one shared place instead of asking 80 call sites to remember it.
42
+ *
43
+ * ## Why this THROWS
44
+ *
45
+ * Returning a distinguishable value would leave every call site free to ignore
46
+ * it, and the call sites that most need to check are the ones that already
47
+ * decided a falsy result means "no". Throwing inverts the default: a call site
48
+ * that does nothing gets fail-closed behaviour, and only a call site that
49
+ * *deliberately* catches can proceed.
50
+ *
51
+ * Measured on the tree this was written for: of 80 synchronous child starts in
52
+ * shipped scripts, **51 propagate** — a throw escapes and fails the process, so
53
+ * those are fixed by this module alone — and **29 sit inside a `try` with a
54
+ * `catch`**, where a throw would be swallowed. Those 29 are not a conversion
55
+ * job; each needs a human answer to "what should happen when this child is
56
+ * killed?", and the answer is written at the call site with
57
+ * {@link isChildTimeout}.
58
+ *
59
+ * ## Why the discriminator is `error.code`, not a custom error class
60
+ *
61
+ * A class cannot cross the boundary. Plugin payloads have no `./lib/` to import
62
+ * from — the accommodation `threshold-ratchet.mjs` and `preflight-secrets.mjs`
63
+ * already make — so a shared class would be unavailable in exactly the tree
64
+ * with the most call sites. `ETIMEDOUT` is set by Node itself on all three
65
+ * forms (`spawnSync` puts it on `result.error`; `execFileSync` and `execSync`
66
+ * throw an error carrying it), so a payload that cannot import this module
67
+ * writes the identical check inline and is stating the same **platform fact**
68
+ * rather than duplicating a Lisa convention.
69
+ *
70
+ * `ENOENT` is deliberately NOT treated as a timeout. A missing binary is a real
71
+ * answer about the environment, it is reported the same way on every run, and
72
+ * conflating it with a kill would hide a broken install behind a retryable-
73
+ * looking error.
74
+ * @module scripts/lib/bounded-spawn
75
+ */
76
+
77
+ import { execFileSync, spawnSync } from "node:child_process";
78
+
79
+ /**
80
+ * Deadline a child gets when the caller states none, in milliseconds.
81
+ *
82
+ * Deliberately generous. This is not a performance budget — it is the point
83
+ * past which a child is presumed hung, and the cost of setting it too low is a
84
+ * guard that fails on a slow machine while the cost of setting it too high is
85
+ * only a slower failure. `git` resolved through `PATH` on macOS goes through
86
+ * Apple's `xcrun` shim, which has been measured at over 20 seconds under load
87
+ * against 11ms for a real binary (CodySwannGT/lisa#2887).
88
+ *
89
+ * **30s is not padding, and tightening it is not a safe optimisation.** A
90
+ * deadline below the shim's measured worst case would make this module's own
91
+ * timeout the dominant failure mode — the tool would start manufacturing the
92
+ * failures it exists to detect, and every one of them would look exactly like
93
+ * the defect it was built to catch. If a number here ever needs changing, the
94
+ * thing to change first is `git` being resolved through `PATH` at all.
95
+ */
96
+ export const DEFAULT_CHILD_BUDGET_MS = 30_000;
97
+
98
+ /**
99
+ * Whether this error is a child that was killed at its deadline.
100
+ *
101
+ * The one question a `catch` around a bounded child start has to ask. A `catch`
102
+ * that does not ask it treats "the box was busy" as "the command said no",
103
+ * which is the fail-open this module exists to remove — so a catch block
104
+ * enclosing a bounded start must either re-raise on this or say in a comment
105
+ * why continuing is correct.
106
+ *
107
+ * Accepts `unknown` because that is what a `catch` binding is, and reads the
108
+ * property defensively for the same reason: a thrown non-object is legal
109
+ * JavaScript and must answer `false` rather than crash the guard that was
110
+ * trying to be careful.
111
+ * @param {unknown} error A caught value, or `result.error` from `spawnSync`.
112
+ * @returns {boolean} True when the child was killed at its deadline.
113
+ */
114
+ export function isChildTimeout(error) {
115
+ return (
116
+ typeof error === "object" &&
117
+ error !== null &&
118
+ /** @type {{ code?: unknown }} */ (error).code === "ETIMEDOUT"
119
+ );
120
+ }
121
+
122
+ /**
123
+ * Re-raise a caught value when it is a killed child, otherwise do nothing.
124
+ *
125
+ * The one-line form for the 29 call sites whose `catch` predates this module.
126
+ * Written as a helper rather than left to each site because the correct spelling
127
+ * is `throw error` — NOT wrapping, NOT `throw new Error(...)` — and a wrapped
128
+ * error loses the `code` the next frame up needs to make the same decision.
129
+ * @param {unknown} error The caught value.
130
+ * @returns {void}
131
+ * @throws {unknown} The original value, when it is a killed child.
132
+ */
133
+ export function rethrowIfChildTimeout(error) {
134
+ if (isChildTimeout(error)) throw error;
135
+ }
136
+
137
+ /**
138
+ * Options a bounded start always applies, whatever the caller asked for.
139
+ *
140
+ * `killSignal` is `SIGKILL` rather than the default `SIGTERM` deliberately: the
141
+ * hang this module exists for is a process that is not servicing signals, and a
142
+ * `SIGTERM` a child ignores turns the deadline into a suggestion.
143
+ * @param {object} options The caller's options.
144
+ * @returns {object} The caller's options with the deadline enforced.
145
+ */
146
+ function bounded(options) {
147
+ return {
148
+ ...options,
149
+ killSignal: "SIGKILL",
150
+ timeout: options.timeout ?? DEFAULT_CHILD_BUDGET_MS,
151
+ };
152
+ }
153
+
154
+ /**
155
+ * `spawnSync` with a deadline, refusing to return a killed child's result.
156
+ *
157
+ * `spawnSync` does not throw on a timeout — it returns a result whose streams
158
+ * are EMPTY and whose `status` is `null`, which is why the fail-open shape in
159
+ * the module docblock cannot see it. This throws instead, so a caller that does
160
+ * nothing inherits fail-closed behaviour.
161
+ * @param {string} command The executable.
162
+ * @param {readonly string[]} args Arguments.
163
+ * @param {object} [options] `spawnSync` options; `timeout` overrides the default.
164
+ * @returns {import("node:child_process").SpawnSyncReturns<string>} The result.
165
+ * @throws {Error} When the child was killed at its deadline.
166
+ */
167
+ export function boundedSpawnSync(command, args = [], options = {}) {
168
+ const result = spawnSync(command, [...args], bounded(options));
169
+ if (isChildTimeout(result.error)) throw result.error;
170
+ return result;
171
+ }
172
+
173
+ /**
174
+ * `execFileSync` with a deadline.
175
+ *
176
+ * `execFileSync` already throws on a timeout, so this changes the FAILURE MODE
177
+ * rather than adding one: without a `timeout` there is no deadline to reach and
178
+ * the call blocks for as long as the child lives. The throw it produces carries
179
+ * `code: "ETIMEDOUT"`, which is what {@link isChildTimeout} reads.
180
+ * @param {string} command The executable.
181
+ * @param {readonly string[]} args Arguments.
182
+ * @param {object} [options] `execFileSync` options; `timeout` overrides the default.
183
+ * @returns {string|Buffer} Whatever the child wrote to stdout.
184
+ * @throws {Error} When the child was killed, or exited non-zero.
185
+ */
186
+ export function boundedExecFileSync(command, args = [], options = {}) {
187
+ return execFileSync(command, [...args], bounded(options));
188
+ }