@codyswann/lisa 4.4.0 → 4.4.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. package/dist/codex/mcp-installer.d.ts.map +1 -1
  2. package/dist/codex/mcp-installer.js +55 -10
  3. package/dist/codex/mcp-installer.js.map +1 -1
  4. package/dist/core/upstream-evidence-manifest.d.ts.map +1 -1
  5. package/dist/core/upstream-evidence-manifest.js +15 -13
  6. package/dist/core/upstream-evidence-manifest.js.map +1 -1
  7. package/package.json +1 -1
  8. package/plugins/lisa/.claude-plugin/plugin.json +1 -1
  9. package/plugins/lisa/.codex-plugin/plugin.json +1 -1
  10. package/plugins/lisa/.codex-plugin/skills/lisa-secrets-access/scripts/materialize-secrets.mjs +130 -26
  11. package/plugins/lisa/skills/lisa-secrets-access/scripts/materialize-secrets.mjs +130 -26
  12. package/plugins/lisa-agy/plugin.json +1 -1
  13. package/plugins/lisa-agy/skills/lisa-secrets-access/scripts/materialize-secrets.mjs +130 -26
  14. package/plugins/lisa-cdk/.claude-plugin/plugin.json +1 -1
  15. package/plugins/lisa-cdk/.codex-plugin/plugin.json +1 -1
  16. package/plugins/lisa-cdk-agy/plugin.json +1 -1
  17. package/plugins/lisa-cdk-copilot/.claude-plugin/plugin.json +1 -1
  18. package/plugins/lisa-cdk-cursor/.claude-plugin/plugin.json +1 -1
  19. package/plugins/lisa-copilot/.claude-plugin/plugin.json +1 -1
  20. package/plugins/lisa-copilot/skills/lisa-secrets-access/scripts/materialize-secrets.mjs +130 -26
  21. package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
  22. package/plugins/lisa-cursor/skills/lisa-secrets-access/scripts/materialize-secrets.mjs +130 -26
  23. package/plugins/lisa-expo/.claude-plugin/plugin.json +1 -1
  24. package/plugins/lisa-expo/.codex-plugin/plugin.json +1 -1
  25. package/plugins/lisa-expo-agy/plugin.json +1 -1
  26. package/plugins/lisa-expo-copilot/.claude-plugin/plugin.json +1 -1
  27. package/plugins/lisa-expo-cursor/.claude-plugin/plugin.json +1 -1
  28. package/plugins/lisa-harper-fabric/.claude-plugin/plugin.json +1 -1
  29. package/plugins/lisa-harper-fabric/.codex-plugin/plugin.json +1 -1
  30. package/plugins/lisa-harper-fabric-agy/plugin.json +1 -1
  31. package/plugins/lisa-harper-fabric-copilot/.claude-plugin/plugin.json +1 -1
  32. package/plugins/lisa-harper-fabric-cursor/.claude-plugin/plugin.json +1 -1
  33. package/plugins/lisa-nestjs/.claude-plugin/plugin.json +1 -1
  34. package/plugins/lisa-nestjs/.codex-plugin/plugin.json +1 -1
  35. package/plugins/lisa-nestjs-agy/plugin.json +1 -1
  36. package/plugins/lisa-nestjs-copilot/.claude-plugin/plugin.json +1 -1
  37. package/plugins/lisa-nestjs-cursor/.claude-plugin/plugin.json +1 -1
  38. package/plugins/lisa-openclaw/.claude-plugin/plugin.json +1 -1
  39. package/plugins/lisa-openclaw/.codex-plugin/plugin.json +1 -1
  40. package/plugins/lisa-openclaw-agy/plugin.json +1 -1
  41. package/plugins/lisa-openclaw-copilot/.claude-plugin/plugin.json +1 -1
  42. package/plugins/lisa-openclaw-cursor/.claude-plugin/plugin.json +1 -1
  43. package/plugins/lisa-phaser/.claude-plugin/plugin.json +1 -1
  44. package/plugins/lisa-phaser/.codex-plugin/plugin.json +1 -1
  45. package/plugins/lisa-phaser-agy/plugin.json +1 -1
  46. package/plugins/lisa-phaser-copilot/.claude-plugin/plugin.json +1 -1
  47. package/plugins/lisa-phaser-cursor/.claude-plugin/plugin.json +1 -1
  48. package/plugins/lisa-rails/.claude-plugin/plugin.json +1 -1
  49. package/plugins/lisa-rails/.codex-plugin/plugin.json +1 -1
  50. package/plugins/lisa-rails-agy/plugin.json +1 -1
  51. package/plugins/lisa-rails-copilot/.claude-plugin/plugin.json +1 -1
  52. package/plugins/lisa-rails-cursor/.claude-plugin/plugin.json +1 -1
  53. package/plugins/lisa-typescript/.claude-plugin/plugin.json +1 -1
  54. package/plugins/lisa-typescript/.codex-plugin/plugin.json +1 -1
  55. package/plugins/lisa-typescript-agy/plugin.json +1 -1
  56. package/plugins/lisa-typescript-copilot/.claude-plugin/plugin.json +1 -1
  57. package/plugins/lisa-typescript-cursor/.claude-plugin/plugin.json +1 -1
  58. package/plugins/lisa-wiki/.claude-plugin/plugin.json +1 -1
  59. package/plugins/lisa-wiki/.codex-plugin/plugin.json +1 -1
  60. package/plugins/lisa-wiki-agy/plugin.json +1 -1
  61. package/plugins/lisa-wiki-copilot/.claude-plugin/plugin.json +1 -1
  62. package/plugins/lisa-wiki-cursor/.claude-plugin/plugin.json +1 -1
  63. package/plugins/src/base/skills/lisa-secrets-access/scripts/materialize-secrets.mjs +130 -26
  64. package/scripts/check-delivery-deletion-conflicts.mjs +2 -2
  65. package/scripts/check-derived-artifacts.mjs +24 -5
  66. package/scripts/check-orphan-test-processes.mjs +2 -2
  67. package/scripts/check-security-floors.mjs +20 -9
  68. package/scripts/check-template-workflow-refs.mjs +23 -8
  69. package/scripts/check-workflow-package-paths.mjs +12 -8
  70. package/scripts/generate-lisa-owned-hash-ledger.mjs +12 -4
  71. package/scripts/generate-upstream-evidence-manifest.mjs +4 -4
  72. package/scripts/verify-health-agentic-built.mjs +4 -3
  73. package/scripts/verify-health-consumer-built.mjs +3 -2
  74. package/scripts/verify-health-contract-built.mjs +3 -2
  75. package/scripts/verify-health-deterministic-built.mjs +5 -4
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-phaser",
3
- "version": "4.4.0",
3
+ "version": "4.4.2",
4
4
  "description": "Phaser 4 game-development rules for TypeScript projects",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-phaser",
3
- "version": "4.4.0",
3
+ "version": "4.4.2",
4
4
  "description": "Phaser 4 game-development rules for TypeScript projects",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-phaser",
3
- "version": "4.4.0",
3
+ "version": "4.4.2",
4
4
  "description": "Phaser 4 game-development rules for TypeScript projects",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-phaser",
3
- "version": "4.4.0",
3
+ "version": "4.4.2",
4
4
  "description": "Phaser 4 game-development rules for TypeScript projects",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-phaser",
3
- "version": "4.4.0",
3
+ "version": "4.4.2",
4
4
  "description": "Phaser 4 game-development rules for TypeScript projects",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-rails",
3
- "version": "4.4.0",
3
+ "version": "4.4.2",
4
4
  "description": "Ruby on Rails-specific hooks — RuboCop linting/formatting and ast-grep scanning on edit",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-rails",
3
- "version": "4.4.0",
3
+ "version": "4.4.2",
4
4
  "description": "Ruby on Rails-specific skills and hooks for RuboCop and ast-grep scanning on edit.",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-rails",
3
- "version": "4.4.0",
3
+ "version": "4.4.2",
4
4
  "description": "Ruby on Rails-specific hooks — RuboCop linting/formatting and ast-grep scanning on edit",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-rails",
3
- "version": "4.4.0",
3
+ "version": "4.4.2",
4
4
  "description": "Ruby on Rails-specific hooks — RuboCop linting/formatting and ast-grep scanning on edit",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-rails",
3
- "version": "4.4.0",
3
+ "version": "4.4.2",
4
4
  "description": "Ruby on Rails-specific hooks — RuboCop linting/formatting and ast-grep scanning on edit",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-typescript",
3
- "version": "4.4.0",
3
+ "version": "4.4.2",
4
4
  "description": "TypeScript-specific hooks — Prettier formatting, ESLint linting, ast-grep scanning, and error-suppression blocking on edit",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-typescript",
3
- "version": "4.4.0",
3
+ "version": "4.4.2",
4
4
  "description": "TypeScript-specific hooks for formatting, linting, and ast-grep scanning on edit.",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-typescript",
3
- "version": "4.4.0",
3
+ "version": "4.4.2",
4
4
  "description": "TypeScript-specific hooks — Prettier formatting, ESLint linting, ast-grep scanning, and error-suppression blocking on edit",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-typescript",
3
- "version": "4.4.0",
3
+ "version": "4.4.2",
4
4
  "description": "TypeScript-specific hooks — Prettier formatting, ESLint linting, ast-grep scanning, and error-suppression blocking on edit",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-typescript",
3
- "version": "4.4.0",
3
+ "version": "4.4.2",
4
4
  "description": "TypeScript-specific hooks — Prettier formatting, ESLint linting, ast-grep scanning, and error-suppression blocking on edit",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-wiki",
3
- "version": "4.4.0",
3
+ "version": "4.4.2",
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.4.0",
3
+ "version": "4.4.2",
4
4
  "description": "Distributable LLM Wiki kernel — ingest, query, lint, and maintain a git-native markdown knowledge base across Claude and Codex.",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-wiki",
3
- "version": "4.4.0",
3
+ "version": "4.4.2",
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.4.0",
3
+ "version": "4.4.2",
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.4.0",
3
+ "version": "4.4.2",
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"
@@ -68,11 +68,114 @@ function writeAtomic(destination, contents) {
68
68
  * @param {object} [cfg] Resolved configuration.
69
69
  * @returns {{count: number, dir: string}} What was written, and where.
70
70
  */
71
+ /**
72
+ * Marker version this build writes. Bumping it is SAFE, which is the point.
73
+ *
74
+ * The recognisers below match the FAMILY — the frozen identifier plus any
75
+ * version — rather than one literal string, so a block written by an older
76
+ * build is still found and replaced in place. Before that, the reader looked
77
+ * for the exact text it was about to write, so the first rename made it
78
+ * conclude there was no block here and append a second one.
79
+ *
80
+ * That failure is additive and silent, and this module is the worst place in
81
+ * Lisa for it: it writes into `~/.aws` files and a shell profile, outside any
82
+ * repository, where no apply, diff, or review ever revisits the result. An
83
+ * orphaned block in a shell profile is STILL SOURCED, and profiles apply
84
+ * assignments in order — so whichever block comes last wins, and an operator
85
+ * can end up running under credentials from a block Lisa believes it no longer
86
+ * manages. That is credential selection going wrong with no review step.
87
+ *
88
+ * Everywhere else in the codebase the mitigation is "widen the recogniser and
89
+ * accept the old shape until an apply normalises the file". There is no such
90
+ * operation here: the old population never drains and cannot be counted, so
91
+ * the recogniser must accept every past version permanently.
92
+ *
93
+ * Following `core/apply-receipt`, which treats a `schema_version` it does not
94
+ * recognise as NO RECEIPT rather than one it half-understands: on an
95
+ * unrecognised marker, fail toward redoing the work, not toward assuming it is
96
+ * done. Here that means replacing every family member found, so a file already
97
+ * carrying orphans from a past rename is repaired on the next run rather than
98
+ * accumulating one more.
99
+ */
100
+ const MARKER_VERSION = "v2";
101
+
102
+ /**
103
+ * The frozen half of each marker. Changing one of these DOES orphan blocks —
104
+ * that is the whole contract, and it is why the version lives beside it.
105
+ *
106
+ * This does not make renaming impossible, it makes it deliberate: an innocuous
107
+ * text edit no longer orphans a block, and the one edit that still would is the
108
+ * one these comments forbid.
109
+ */
110
+ const PROFILE_FAMILY = "lisa secrets (managed";
111
+
112
+ /** The frozen family identifier for an `~/.aws` managed region. */
113
+ const MANAGED_FAMILY = "managed by lisa-secrets-access";
114
+
115
+ /**
116
+ * Build the start/end recognisers for one marker family.
117
+ *
118
+ * Matches the family followed by anything up to the closing delimiter, so
119
+ * `(managed)` — every block in the field today — and `(managed v2)` are both
120
+ * found by the same reader.
121
+ * @param {string} family Frozen family identifier.
122
+ * @returns {{start: RegExp, end: RegExp}} Global recognisers for the family.
123
+ */
124
+ function familyRecognisers(family) {
125
+ const quoted = family.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
126
+ return {
127
+ start: new RegExp(`# >>> ${quoted}[^\\n]*>>>`, "g"),
128
+ end: new RegExp(`# <<< ${quoted}[^\\n]*<<<`, "g"),
129
+ };
130
+ }
131
+
132
+ /**
133
+ * Index of the first family match at or after `from`, and its length.
134
+ * @param {string} text Haystack.
135
+ * @param {RegExp} recogniser Global family recogniser.
136
+ * @param {number} from Index to search from.
137
+ * @returns {{index: number, length: number}} `index` is -1 when absent.
138
+ */
139
+ function findFamily(text, recogniser, from = 0) {
140
+ recogniser.lastIndex = from;
141
+ const match = recogniser.exec(text);
142
+ return match === null
143
+ ? { index: -1, length: 0 }
144
+ : { index: match.index, length: match[0].length };
145
+ }
146
+
147
+ /**
148
+ * Remove every managed block of one family, leaving the rest of the file.
149
+ *
150
+ * Every block, not just the first: a file that already carries orphans from a
151
+ * rename that happened before this fix must come out with exactly one block,
152
+ * or the fix would leave the damage it exists to prevent.
153
+ * @param {string} text File contents.
154
+ * @param {string} family Frozen family identifier.
155
+ * @returns {string} The file with every family block removed.
156
+ */
157
+ function stripFamilyBlocks(text, family) {
158
+ const { start, end } = familyRecognisers(family);
159
+ let out = text;
160
+ for (;;) {
161
+ const opened = findFamily(out, start);
162
+ if (opened.index === -1) return out;
163
+ const closed = findFamily(out, end, opened.index + opened.length);
164
+ // A truncated block (opened, never closed) would otherwise swallow the rest
165
+ // of the file on every subsequent write.
166
+ const after =
167
+ closed.index === -1
168
+ ? ""
169
+ : out.slice(closed.index + closed.length).replace(/^\n/, "");
170
+ out = `${out.slice(0, opened.index)}${after}`;
171
+ }
172
+ }
173
+
71
174
  /** Marks the block this owns, so it is replaced rather than appended twice. */
72
- const PROFILE_MARKER = "# >>> lisa secrets (managed) >>>";
175
+ const PROFILE_MARKER = `# >>> ${PROFILE_FAMILY} ${MARKER_VERSION}) >>>`;
73
176
 
74
177
  /** Closes the managed block. */
75
- const PROFILE_END = "# <<< lisa secrets (managed) <<<";
178
+ const PROFILE_END = `# <<< ${PROFILE_FAMILY} ${MARKER_VERSION}) <<<`;
76
179
 
77
180
  /**
78
181
  * Identifies an `~/.aws` file as one this wrote, and may therefore replace.
@@ -81,10 +184,10 @@ const PROFILE_END = "# <<< lisa secrets (managed) <<<";
81
184
  * consumer while still being the thing that distinguishes "our file, refresh
82
185
  * it" from "someone else's file, leave it alone".
83
186
  */
84
- const MANAGED_MARKER = "# >>> managed by lisa-secrets-access >>>";
187
+ const MANAGED_MARKER = `# >>> ${MANAGED_FAMILY} ${MARKER_VERSION} >>>`;
85
188
 
86
189
  /** Closes the managed region of an `~/.aws` file. */
87
- const MANAGED_END = "# <<< managed by lisa-secrets-access <<<";
190
+ const MANAGED_END = `# <<< ${MANAGED_FAMILY} ${MARKER_VERSION} <<<`;
88
191
 
89
192
  /**
90
193
  * Replace this module's delimited region in a file, preserving everything else.
@@ -99,22 +202,21 @@ const MANAGED_END = "# <<< managed by lisa-secrets-access <<<";
99
202
  */
100
203
  export function upsertManagedBlock(current, body) {
101
204
  const block = `${MANAGED_MARKER}\n${body.trimEnd()}\n${MANAGED_END}`;
102
- const start = current.indexOf(MANAGED_MARKER);
205
+ const opened = findFamily(current, familyRecognisers(MANAGED_FAMILY).start);
103
206
 
104
- if (start === -1) {
207
+ if (opened.index === -1) {
105
208
  const prefix =
106
209
  current && !current.endsWith("\n") ? `${current}\n` : current;
107
210
  return `${prefix}${prefix ? "\n" : ""}${block}\n`;
108
211
  }
109
212
 
110
- const endAt = current.indexOf(MANAGED_END, start);
111
- // A truncated block (marker opened, never closed) would otherwise swallow the
112
- // rest of the file on every subsequent write.
113
- const after =
114
- endAt === -1
115
- ? ""
116
- : current.slice(endAt + MANAGED_END.length).replace(/^\n/, "");
117
- return `${current.slice(0, start)}${block}\n${after}`;
213
+ // Everything this module owns is stripped first, so a file already carrying
214
+ // orphans from a rename that predates the family recogniser comes out with
215
+ // exactly one block rather than one more.
216
+ const withoutOurs = stripFamilyBlocks(current, MANAGED_FAMILY);
217
+ const head = withoutOurs.slice(0, opened.index);
218
+ const tail = withoutOurs.slice(opened.index);
219
+ return `${head}${block}\n${tail}`;
118
220
  }
119
221
 
120
222
  /**
@@ -173,13 +275,18 @@ export function installProfileSourcing(valuesFile, options = {}) {
173
275
 
174
276
  // Replace an existing managed block rather than appending another: this
175
277
  // runs on every session, and an appended-forever profile is its own bug.
176
- const start = current.indexOf(PROFILE_MARKER);
278
+ const opened = findFamily(current, familyRecognisers(PROFILE_FAMILY).start);
279
+ // Stripping EVERY family block before writing one is what makes an already
280
+ // orphaned profile self-heal. It matters more here than anywhere else in
281
+ // Lisa: an orphaned block in a shell profile is still sourced, and the last
282
+ // assignment wins, so a stale block silently selects the wrong credentials.
283
+ const withoutOurs = stripFamilyBlocks(current, PROFILE_FAMILY);
177
284
  const next =
178
- start === -1
285
+ opened.index === -1
179
286
  ? `${current}${current.endsWith("\n") || !current ? "" : "\n"}\n${block}\n`
180
- : `${current.slice(0, start)}${block}${current.slice(
181
- current.indexOf(PROFILE_END, start) + PROFILE_END.length
182
- )}`;
287
+ : `${withoutOurs.slice(0, opened.index)}${block}\n${withoutOurs
288
+ .slice(opened.index)
289
+ .replace(/^\n/, "")}`;
183
290
 
184
291
  if (next !== current) {
185
292
  write(file, next, { mode: 0o600 });
@@ -214,13 +321,10 @@ export function collidingProfiles(dir, names, io = {}) {
214
321
  if (!exists(file)) return [];
215
322
 
216
323
  const text = String(read(file, "utf8"));
217
- const start = text.indexOf(MANAGED_MARKER);
218
- const endAt = start === -1 ? -1 : text.indexOf(MANAGED_END, start);
219
- const outside =
220
- start === -1
221
- ? text
222
- : text.slice(0, start) +
223
- (endAt === -1 ? "" : text.slice(endAt + MANAGED_END.length));
324
+ // Every family block, not just the current version's. An orphan left by an
325
+ // older marker is still OUR previous output, so counting its profiles as a
326
+ // host collision would refuse to write the very names we wrote last time.
327
+ const outside = stripFamilyBlocks(text, MANAGED_FAMILY);
224
328
 
225
329
  return names.filter(name =>
226
330
  new RegExp(
@@ -82,12 +82,12 @@
82
82
  *
83
83
  * @module scripts/check-delivery-deletion-conflicts
84
84
  */
85
- import { execFileSync } from "node:child_process";
86
85
  import fs from "node:fs";
87
86
  import path from "node:path";
88
87
  import process from "node:process";
89
88
  import { fileURLToPath } from "node:url";
90
89
 
90
+ import { boundedExecFileSync } from "./lib/bounded-spawn.mjs";
91
91
  import { invokedAsScript } from "./lib/invoked-as-script.mjs";
92
92
 
93
93
  const REPO_ROOT = path.resolve(
@@ -250,7 +250,7 @@ export function matchDeletion(delivered, deleted) {
250
250
  function listTrackedFiles(root) {
251
251
  let stdout;
252
252
  try {
253
- stdout = execFileSync("git", ["-C", root, "ls-files", "-z"], {
253
+ stdout = boundedExecFileSync("git", ["-C", root, "ls-files", "-z"], {
254
254
  encoding: "utf8",
255
255
  maxBuffer: MAX_GIT_OUTPUT_BYTES,
256
256
  stdio: ["ignore", "pipe", "ignore"],
@@ -44,10 +44,10 @@
44
44
  * on a commit that owed nothing, which is the safe direction to be wrong in.
45
45
  * @module scripts/check-derived-artifacts
46
46
  */
47
- import { execFileSync } from "node:child_process";
48
47
  import path from "node:path";
49
48
  import process from "node:process";
50
49
 
50
+ import { boundedExecFileSync, isChildTimeout } from "./lib/bounded-spawn.mjs";
51
51
  import { invokedAsScript } from "./lib/invoked-as-script.mjs";
52
52
 
53
53
  const repoRoot = path.resolve(import.meta.dirname, "..");
@@ -118,18 +118,26 @@ export function requiresManifestCheck(stagedPaths) {
118
118
  * Renames report their destination (`-M` plus `--diff-filter=R`), and deletions
119
119
  * are included because removing a template is also a way to move what the
120
120
  * artifacts describe.
121
- * @returns {string[]} Staged paths, empty when git cannot be consulted
121
+ *
122
+ * A KILLED child re-raises rather than returning empty. An empty list means
123
+ * "nothing staged touches the artifacts", which makes this guard SKIP — so a
124
+ * busy machine would let through the commit that moves a template without
125
+ * regenerating what describes it. "I could not ask" is not "there is nothing
126
+ * to check", and only one of those is safe to act on.
127
+ * @returns {string[]} Staged paths, empty when git said there were none
128
+ * @throws {Error} When the child was killed at its deadline
122
129
  */
123
130
  export function stagedPaths() {
124
131
  try {
125
- return execFileSync(
132
+ return boundedExecFileSync(
126
133
  "git",
127
134
  ["diff", "--cached", "--name-only", "--diff-filter=ACMRD"],
128
135
  { cwd: repoRoot, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }
129
136
  )
130
137
  .split("\n")
131
138
  .filter(Boolean);
132
- } catch {
139
+ } catch (error) {
140
+ if (isChildTimeout(error)) throw error;
133
141
  return [];
134
142
  }
135
143
  }
@@ -172,13 +180,24 @@ export function readableFailure(raw) {
172
180
  */
173
181
  function runCheck(script) {
174
182
  try {
175
- execFileSync(process.execPath, [script, "--check"], {
183
+ boundedExecFileSync(process.execPath, [script, "--check"], {
176
184
  cwd: repoRoot,
177
185
  encoding: "utf8",
178
186
  stdio: ["ignore", "pipe", "pipe"],
179
187
  });
180
188
  return { ok: true, output: "" };
181
189
  } catch (error) {
190
+ // Already fail-closed — `ok: false` fails the guard either way — so the
191
+ // catch stays. What it must NOT do is stay silent about a kill: a killed
192
+ // child hands back EMPTY streams, so a timeout would otherwise render as a
193
+ // generator that failed with no diagnostic, which reads as a content
194
+ // problem and sends the operator to the wrong place entirely.
195
+ if (isChildTimeout(error)) {
196
+ return {
197
+ ok: false,
198
+ output: `${script} was killed before it finished. This is a timeout, not a check failure — nothing about the artifact was measured.`,
199
+ };
200
+ }
182
201
  const stderr = error?.stderr ?? "";
183
202
  const stdout = error?.stdout ?? "";
184
203
  return { ok: false, output: readableFailure(`${stdout}${stderr}`) };
@@ -44,8 +44,8 @@
44
44
  *
45
45
  * @module scripts/check-orphan-test-processes
46
46
  */
47
- import { execFileSync } from "node:child_process";
48
47
  import process from "node:process";
48
+ import { boundedExecFileSync } from "./lib/bounded-spawn.mjs";
49
49
  import { invokedAsScript } from "./lib/invoked-as-script.mjs";
50
50
 
51
51
  /**
@@ -168,7 +168,7 @@ export function selectOrphans(rows, minAgeSeconds) {
168
168
  */
169
169
  function readProcessTable() {
170
170
  // eslint-disable-next-line sonarjs/no-os-command-from-path -- fixed executable and argv
171
- return execFileSync("ps", ["-eo", "pid,ppid,etime,command"], {
171
+ return boundedExecFileSync("ps", ["-eo", "pid,ppid,etime,command"], {
172
172
  encoding: "utf8",
173
173
  maxBuffer: 32 * 1024 * 1024,
174
174
  });
@@ -23,10 +23,11 @@
23
23
  * @module scripts/check-security-floors
24
24
  */
25
25
 
26
- import { execFileSync } from "node:child_process";
27
26
  import { existsSync, readFileSync } from "node:fs";
28
27
  import { globSync } from "node:fs";
29
28
 
29
+ import { boundedExecFileSync } from "./lib/bounded-spawn.mjs";
30
+
30
31
  /**
31
32
  * Fixed absolute git locations, tried in order rather than a bare command name
32
33
  * so a writeable directory early on `PATH` cannot decide which binary runs.
@@ -129,18 +130,28 @@ const NOT_A_MANIFEST = [
129
130
  /**
130
131
  * Every manifest git tracks, so the glob can be checked against reality.
131
132
  *
132
- * Returns null when the question cannot be asked — outside a checkout, or with
133
- * no git. A null is reported as "not reconciled", never folded into "nothing
134
- * missing": the whole defect being fixed here is a scan that narrowed silently.
133
+ * Returns null when the question cannot be asked — outside a checkout, with no
134
+ * git, or with the child killed at its deadline. A null is reported as "not
135
+ * reconciled", never folded into "nothing missing": the whole defect being
136
+ * fixed here is a scan that narrowed silently.
137
+ *
138
+ * The swallow is therefore KEPT for a timeout rather than kept by inheritance.
139
+ * A kill is one more way the question cannot be asked, and this function
140
+ * already routes "cannot ask" to the safe answer — so re-raising would trade a
141
+ * correct report for a crash and gain nothing.
135
142
  * @returns {string[]|null} Tracked manifest paths, or null when unknowable.
136
143
  */
137
144
  function trackedManifests() {
138
145
  try {
139
- return execFileSync(GIT_BIN, ["ls-files", "-z", "*package.lisa.json"], {
140
- encoding: "utf8",
141
- maxBuffer: 8 * 1024 * 1024,
142
- stdio: ["ignore", "pipe", "ignore"],
143
- })
146
+ return boundedExecFileSync(
147
+ GIT_BIN,
148
+ ["ls-files", "-z", "*package.lisa.json"],
149
+ {
150
+ encoding: "utf8",
151
+ maxBuffer: 8 * 1024 * 1024,
152
+ stdio: ["ignore", "pipe", "ignore"],
153
+ }
154
+ )
144
155
  .split("\0")
145
156
  .filter(Boolean)
146
157
  .filter(
@@ -57,12 +57,12 @@
57
57
  *
58
58
  * @module scripts/check-template-workflow-refs
59
59
  */
60
- import { execFileSync } from "node:child_process";
61
60
  import fs from "node:fs";
62
61
  import path from "node:path";
63
62
  import process from "node:process";
64
63
  import { fileURLToPath } from "node:url";
65
64
 
65
+ import { boundedExecFileSync, isChildTimeout } from "./lib/bounded-spawn.mjs";
66
66
  import { invokedAsScript } from "./lib/invoked-as-script.mjs";
67
67
 
68
68
  const REPO_ROOT = path.resolve(
@@ -124,7 +124,7 @@ export function findWorkflowRefs(content) {
124
124
  function listTrackedFiles(root) {
125
125
  let stdout;
126
126
  try {
127
- stdout = execFileSync("git", ["-C", root, "ls-files", "-z"], {
127
+ stdout = boundedExecFileSync("git", ["-C", root, "ls-files", "-z"], {
128
128
  encoding: "utf8",
129
129
  maxBuffer: MAX_GIT_OUTPUT_BYTES,
130
130
  stdio: ["ignore", "pipe", "ignore"],
@@ -140,13 +140,19 @@ function listTrackedFiles(root) {
140
140
  /**
141
141
  * Whether `ref` names an object present in this clone.
142
142
  *
143
+ * A KILLED child re-raises. `false` here does not mean "unknown", it means
144
+ * "this reference is broken" — a verdict the caller reports. So a busy machine
145
+ * would manufacture a failure about a reference that is perfectly fine, and a
146
+ * guard that invents failures is one people learn to re-run rather than read.
147
+ * That is the sibling harm to failing open, and it is just as corrosive.
143
148
  * @param {string} root - the repository root.
144
149
  * @param {string} ref - a tag, branch, or SHA.
145
150
  * @returns {boolean} whether the ref resolves locally.
151
+ * @throws {Error} When the child was killed at its deadline
146
152
  */
147
153
  function refExists(root, ref) {
148
154
  try {
149
- execFileSync(
155
+ boundedExecFileSync(
150
156
  "git",
151
157
  ["-C", root, "rev-parse", "--verify", `${ref}^{commit}`],
152
158
  {
@@ -154,7 +160,8 @@ function refExists(root, ref) {
154
160
  }
155
161
  );
156
162
  return true;
157
- } catch {
163
+ } catch (error) {
164
+ if (isChildTimeout(error)) throw error;
158
165
  return false;
159
166
  }
160
167
  }
@@ -162,18 +169,26 @@ function refExists(root, ref) {
162
169
  /**
163
170
  * Whether `target` exists in the tree named by `ref`.
164
171
  *
172
+ * A KILLED child re-raises, for the reason given on `refExists` above: `false`
173
+ * is a verdict of "absent at that ref", not an admission of ignorance.
165
174
  * @param {string} root - the repository root.
166
175
  * @param {string} ref - a tag, branch, or SHA known to resolve.
167
176
  * @param {string} target - a repo-relative path.
168
177
  * @returns {boolean} whether the path exists at that ref.
178
+ * @throws {Error} When the child was killed at its deadline
169
179
  */
170
180
  function pathExistsAtRef(root, ref, target) {
171
181
  try {
172
- execFileSync("git", ["-C", root, "cat-file", "-e", `${ref}:${target}`], {
173
- stdio: ["ignore", "ignore", "ignore"],
174
- });
182
+ boundedExecFileSync(
183
+ "git",
184
+ ["-C", root, "cat-file", "-e", `${ref}:${target}`],
185
+ {
186
+ stdio: ["ignore", "ignore", "ignore"],
187
+ }
188
+ );
175
189
  return true;
176
- } catch {
190
+ } catch (error) {
191
+ if (isChildTimeout(error)) throw error;
177
192
  return false;
178
193
  }
179
194
  }