@codyswann/lisa 3.34.0 → 3.35.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. package/all/copy-overwrite/scripts/lisa-lint-staged-preflight.mjs +399 -0
  2. package/dist/cli/doctor-traceability-gate.d.ts +39 -0
  3. package/dist/cli/doctor-traceability-gate.d.ts.map +1 -0
  4. package/dist/cli/doctor-traceability-gate.js +164 -0
  5. package/dist/cli/doctor-traceability-gate.js.map +1 -0
  6. package/dist/cli/doctor.d.ts.map +1 -1
  7. package/dist/cli/doctor.js +7 -0
  8. package/dist/cli/doctor.js.map +1 -1
  9. package/dist/core/lisa-owned-hash-ledger.d.ts.map +1 -1
  10. package/dist/core/lisa-owned-hash-ledger.js +3 -0
  11. package/dist/core/lisa-owned-hash-ledger.js.map +1 -1
  12. package/dist/core/upstream-evidence-manifest.d.ts.map +1 -1
  13. package/dist/core/upstream-evidence-manifest.js +6 -1
  14. package/dist/core/upstream-evidence-manifest.js.map +1 -1
  15. package/package.json +1 -1
  16. package/plugins/lisa/.claude-plugin/plugin.json +1 -1
  17. package/plugins/lisa/.codex-plugin/plugin.json +1 -1
  18. package/plugins/lisa-agy/plugin.json +1 -1
  19. package/plugins/lisa-cdk/.claude-plugin/plugin.json +1 -1
  20. package/plugins/lisa-cdk/.codex-plugin/plugin.json +1 -1
  21. package/plugins/lisa-cdk-agy/plugin.json +1 -1
  22. package/plugins/lisa-cdk-copilot/.claude-plugin/plugin.json +1 -1
  23. package/plugins/lisa-cdk-cursor/.claude-plugin/plugin.json +1 -1
  24. package/plugins/lisa-copilot/.claude-plugin/plugin.json +1 -1
  25. package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
  26. package/plugins/lisa-expo/.claude-plugin/plugin.json +1 -1
  27. package/plugins/lisa-expo/.codex-plugin/plugin.json +1 -1
  28. package/plugins/lisa-expo-agy/plugin.json +1 -1
  29. package/plugins/lisa-expo-copilot/.claude-plugin/plugin.json +1 -1
  30. package/plugins/lisa-expo-cursor/.claude-plugin/plugin.json +1 -1
  31. package/plugins/lisa-harper-fabric/.claude-plugin/plugin.json +1 -1
  32. package/plugins/lisa-harper-fabric/.codex-plugin/plugin.json +1 -1
  33. package/plugins/lisa-harper-fabric-agy/plugin.json +1 -1
  34. package/plugins/lisa-harper-fabric-copilot/.claude-plugin/plugin.json +1 -1
  35. package/plugins/lisa-harper-fabric-cursor/.claude-plugin/plugin.json +1 -1
  36. package/plugins/lisa-nestjs/.claude-plugin/plugin.json +1 -1
  37. package/plugins/lisa-nestjs/.codex-plugin/plugin.json +1 -1
  38. package/plugins/lisa-nestjs-agy/plugin.json +1 -1
  39. package/plugins/lisa-nestjs-copilot/.claude-plugin/plugin.json +1 -1
  40. package/plugins/lisa-nestjs-cursor/.claude-plugin/plugin.json +1 -1
  41. package/plugins/lisa-openclaw/.claude-plugin/plugin.json +1 -1
  42. package/plugins/lisa-openclaw/.codex-plugin/plugin.json +1 -1
  43. package/plugins/lisa-openclaw-agy/plugin.json +1 -1
  44. package/plugins/lisa-openclaw-copilot/.claude-plugin/plugin.json +1 -1
  45. package/plugins/lisa-openclaw-cursor/.claude-plugin/plugin.json +1 -1
  46. package/plugins/lisa-phaser/.claude-plugin/plugin.json +1 -1
  47. package/plugins/lisa-phaser/.codex-plugin/plugin.json +1 -1
  48. package/plugins/lisa-phaser-agy/plugin.json +1 -1
  49. package/plugins/lisa-phaser-copilot/.claude-plugin/plugin.json +1 -1
  50. package/plugins/lisa-phaser-cursor/.claude-plugin/plugin.json +1 -1
  51. package/plugins/lisa-rails/.claude-plugin/plugin.json +1 -1
  52. package/plugins/lisa-rails/.codex-plugin/plugin.json +1 -1
  53. package/plugins/lisa-rails-agy/plugin.json +1 -1
  54. package/plugins/lisa-rails-copilot/.claude-plugin/plugin.json +1 -1
  55. package/plugins/lisa-rails-cursor/.claude-plugin/plugin.json +1 -1
  56. package/plugins/lisa-typescript/.claude-plugin/plugin.json +1 -1
  57. package/plugins/lisa-typescript/.codex-plugin/plugin.json +1 -1
  58. package/plugins/lisa-typescript-agy/plugin.json +1 -1
  59. package/plugins/lisa-typescript-copilot/.claude-plugin/plugin.json +1 -1
  60. package/plugins/lisa-typescript-cursor/.claude-plugin/plugin.json +1 -1
  61. package/plugins/lisa-wiki/.claude-plugin/plugin.json +1 -1
  62. package/plugins/lisa-wiki/.codex-plugin/plugin.json +1 -1
  63. package/plugins/lisa-wiki-agy/plugin.json +1 -1
  64. package/plugins/lisa-wiki-copilot/.claude-plugin/plugin.json +1 -1
  65. package/plugins/lisa-wiki-cursor/.claude-plugin/plugin.json +1 -1
  66. package/typescript/copy-contents/.husky/pre-commit +64 -0
@@ -0,0 +1,399 @@
1
+ #!/usr/bin/env node
2
+ // This file is managed by Lisa and IS replaced on each `lisa` run.
3
+ // Do not edit directly — durable changes belong upstream in Lisa.
4
+
5
+ /**
6
+ * lisa-lint-staged-preflight — prove every tool lint-staged is about to run can
7
+ * actually be started, before lint-staged gets the chance to swallow the answer.
8
+ *
9
+ * Usage:
10
+ * node scripts/lisa-lint-staged-preflight.mjs --config .lintstagedrc.json
11
+ * node scripts/lisa-lint-staged-preflight.mjs --config=.lintstagedrc.json
12
+ *
13
+ * ## Why this exists
14
+ *
15
+ * `lint-staged` reports **exit 0** when a task's executable cannot be spawned
16
+ * with `ENOEXEC`, while printing `[FAILED] spawn ENOEXEC` to the terminal. The
17
+ * asymmetry is the whole problem, and it is what defeats the intuition that a
18
+ * broken tool would get noticed. Measured with one harness, three tasks,
19
+ * everything else held constant:
20
+ *
21
+ * | task | 16.2.7 | 16.4.0 | 17.3.0 |
22
+ * | ----------------------------------------------- | ------ | ------ | ------ |
23
+ * | script exits 1 (ordinary failure) | 1 | 1 | 1 |
24
+ * | executable absent entirely (`ENOENT`) | 1 | 1 | 1 |
25
+ * | present, `chmod +x`, not executable (`ENOEXEC`) | 1 | **0** | **0** |
26
+ *
27
+ * A *missing* tool is caught. A tool that is present but unrunnable is not.
28
+ *
29
+ * That is precisely the state some published tools leave behind. A package
30
+ * whose real binary is materialized during `postinstall` installs a
31
+ * shebang-less placeholder shim first; the shim is written to fail loudly and
32
+ * explain itself, and it would, because it `exit 1`s. lint-staged spawns
33
+ * without a shell, so no shebang plus no shell means the kernel returns
34
+ * `ENOEXEC` before the shim's own `exit 1` can ever run. Skip `postinstall` —
35
+ * which is a route agents take routinely — and the task runs, reports nothing,
36
+ * and blocks nothing, while the commit proceeds.
37
+ *
38
+ * ## Why hardening the hook could not fix it
39
+ *
40
+ * Worth stating, because it is the obvious first response. The hook already
41
+ * captures lint-staged's status and exits on it. No exit-code check can catch
42
+ * a process that reports `0` for work it did not do. The answer has to arrive
43
+ * *before* the handoff, from something that spawns the tools itself.
44
+ *
45
+ * ## What "invocable" means here, and why it is measured rather than inferred
46
+ *
47
+ * This guard does not read the file's first bytes looking for a shebang or an
48
+ * object-file magic number and reason about what the kernel would do with them.
49
+ * It spawns each executable, shell-less, exactly as lint-staged will, and
50
+ * watches for the `spawn` event. `ENOEXEC`, `ENOENT` and `EACCES` all arrive as
51
+ * an `error` event instead, which is the signal. The probe passes when — and
52
+ * only when — the operating system really started the process.
53
+ *
54
+ * `--version` is the probe argument because every tool Lisa ships in
55
+ * `.lintstagedrc.json` answers it instantly. The exit code is deliberately
56
+ * ignored: a tool that rejects `--version` still *started*, which is the entire
57
+ * question. A tool that hangs is also a pass for the same reason — it started —
58
+ * so the probe kills it and moves on.
59
+ *
60
+ * ## Silence is not the success signal
61
+ *
62
+ * The defect being fixed is a control reporting success for work it no longer
63
+ * performs, so this file refuses to be a second instance of it:
64
+ *
65
+ * - It prints one line per executable it probed and a count. A run that proved
66
+ * nothing cannot look like a run that proved everything.
67
+ * - A config it cannot read, cannot parse, or whose shape it does not
68
+ * understand is a FAILURE, never a quiet pass.
69
+ * - A config that declares no tasks is a FAILURE: lint-staged would prove
70
+ * nothing, and "nothing to check" is exactly the report this guard exists to
71
+ * stop trusting.
72
+ * - Extracting zero executables from a config that has tasks is a FAILURE.
73
+ * - Executable-name extraction that disagrees with lint-staged's own
74
+ * tokenization can only produce a name that fails to spawn, which this guard
75
+ * reports loudly. It has no path to a silent pass.
76
+ * @module scripts/lisa-lint-staged-preflight
77
+ */
78
+ import { spawn } from "node:child_process";
79
+ import { existsSync, readFileSync, realpathSync } from "node:fs";
80
+ import path from "node:path";
81
+ import process from "node:process";
82
+ import { fileURLToPath } from "node:url";
83
+
84
+ /** Default config path, matching what the shipped pre-commit hook passes. */
85
+ const DEFAULT_CONFIG = ".lintstagedrc.json";
86
+
87
+ /** Argument handed to each probed executable. */
88
+ const PROBE_ARGS = Object.freeze(["--version"]);
89
+
90
+ /**
91
+ * How long a probe waits before concluding the process really did start.
92
+ *
93
+ * Only reached by a tool that spawns and then neither exits nor is killed.
94
+ * Since spawning is the whole question, a timeout is a PASS.
95
+ */
96
+ const PROBE_TIMEOUT_MS = 15_000;
97
+
98
+ /**
99
+ * The config path named on the command line.
100
+ *
101
+ * Accepts `--config X` and `--config=X` because the hook and a human invoking
102
+ * this by hand write it both ways.
103
+ * @param {string[]} argv - Arguments after the script name.
104
+ * @returns {string} Config path, relative or absolute.
105
+ */
106
+ export const configPathFrom = argv => {
107
+ const inline = argv.find(arg => arg.startsWith("--config="));
108
+ if (inline !== undefined) return inline.slice("--config=".length);
109
+ const flagAt = argv.indexOf("--config");
110
+ if (flagAt >= 0 && argv[flagAt + 1] !== undefined) return argv[flagAt + 1];
111
+ return DEFAULT_CONFIG;
112
+ };
113
+
114
+ /**
115
+ * The executable a lint-staged task string spawns.
116
+ *
117
+ * lint-staged splits the task with `string-argv` and spawns element zero, so
118
+ * this reproduces that for element zero only: quoted runs are unwrapped, and
119
+ * the token ends at the first unquoted whitespace. A disagreement with
120
+ * `string-argv` on an exotic task string yields a name that will not spawn,
121
+ * which this guard reports — never a name it silently skips.
122
+ * @param {string} command - A lint-staged task string.
123
+ * @returns {string} The executable name, or "" when the task is blank.
124
+ */
125
+ export const executableOf = command => {
126
+ let token = "";
127
+ let quote = "";
128
+ for (const character of command) {
129
+ if (quote !== "") {
130
+ if (character === quote) quote = "";
131
+ else token += character;
132
+ } else if (character === '"' || character === "'") {
133
+ quote = character;
134
+ } else if (/\s/u.test(character)) {
135
+ if (token !== "") break;
136
+ } else {
137
+ token += character;
138
+ }
139
+ }
140
+ return token;
141
+ };
142
+
143
+ /**
144
+ * Every task string a lint-staged JSON config declares.
145
+ * @param {unknown} config - Parsed config contents.
146
+ * @returns {{ tasks: string[] } | { problem: string }} Tasks, or why not.
147
+ */
148
+ export const tasksOf = config => {
149
+ if (config === null || typeof config !== "object" || Array.isArray(config)) {
150
+ return { problem: "the config is not a JSON object of glob → task(s)" };
151
+ }
152
+ const tasks = [];
153
+ for (const [glob, value] of Object.entries(config)) {
154
+ if (typeof value === "string") {
155
+ tasks.push(value);
156
+ } else if (
157
+ Array.isArray(value) &&
158
+ value.every(v => typeof v === "string")
159
+ ) {
160
+ tasks.push(...value);
161
+ } else {
162
+ return {
163
+ problem: `the entry for "${glob}" is neither a task string nor an array of them`,
164
+ };
165
+ }
166
+ }
167
+ return { tasks };
168
+ };
169
+
170
+ /**
171
+ * The environment a probe runs in: PATH augmented the way lint-staged's own
172
+ * spawner augments it, so a locally installed tool resolves identically.
173
+ *
174
+ * `node_modules/.bin` is prepended for the working directory and every ancestor
175
+ * of it, then the directory holding the running Node binary.
176
+ * @param {string} cwd - Directory the probe runs in.
177
+ * @returns {NodeJS.ProcessEnv} Environment for the probe.
178
+ */
179
+ export const probeEnv = cwd => {
180
+ const binDirectories = [];
181
+ let current = cwd;
182
+ let previous = "";
183
+ while (current !== previous) {
184
+ binDirectories.push(path.resolve(current, "node_modules", ".bin"));
185
+ previous = current;
186
+ current = path.dirname(current);
187
+ }
188
+ binDirectories.push(path.dirname(process.execPath));
189
+
190
+ const key =
191
+ Object.keys(process.env).find(name => /^path$/iu.test(name)) ?? "PATH";
192
+ const existing = process.env[key] ?? "";
193
+ return {
194
+ ...process.env,
195
+ [key]: [
196
+ ...binDirectories,
197
+ ...(existing === "" ? [] : existing.split(path.delimiter)),
198
+ ].join(path.delimiter),
199
+ };
200
+ };
201
+
202
+ /**
203
+ * Start one executable and report whether the operating system started it.
204
+ *
205
+ * Shell-less on POSIX, matching how lint-staged spawns and therefore matching
206
+ * the `ENOEXEC` this guard exists to catch. On win32 the shell is used because
207
+ * that is how `.cmd` shims are invoked there; `ENOEXEC` in the sense meant here
208
+ * is a POSIX `execve` outcome and has no win32 counterpart.
209
+ * @param {string} executable - Name or path to start.
210
+ * @param {string} cwd - Directory to start it in.
211
+ * @param {NodeJS.ProcessEnv} env - Environment to start it with.
212
+ * @returns {Promise<{ executable: string, started: boolean, code?: string, detail?: string }>} Probe outcome.
213
+ */
214
+ export const probe = (executable, cwd, env) =>
215
+ new Promise(resolve => {
216
+ let settled = false;
217
+ /**
218
+ * Resolve once, ignoring any later event from the same child.
219
+ * @param {{ executable: string, started: boolean, code?: string, detail?: string }} outcome - What happened.
220
+ * @returns {void}
221
+ */
222
+ const settle = outcome => {
223
+ if (settled) return;
224
+ settled = true;
225
+ resolve(outcome);
226
+ };
227
+
228
+ let child;
229
+ try {
230
+ child = spawn(executable, [...PROBE_ARGS], {
231
+ cwd,
232
+ env,
233
+ stdio: "ignore",
234
+ shell: process.platform === "win32",
235
+ timeout: PROBE_TIMEOUT_MS,
236
+ });
237
+ } catch (error) {
238
+ settle({
239
+ executable,
240
+ started: false,
241
+ code: /** @type {NodeJS.ErrnoException} */ (error).code ?? "FAILED",
242
+ detail: /** @type {Error} */ (error).message,
243
+ });
244
+ return;
245
+ }
246
+
247
+ child.on("error", error => {
248
+ settle({
249
+ executable,
250
+ started: false,
251
+ code: /** @type {NodeJS.ErrnoException} */ (error).code ?? "FAILED",
252
+ detail: error.message,
253
+ });
254
+ });
255
+ child.on("spawn", () => {
256
+ child.kill();
257
+ settle({ executable, started: true });
258
+ });
259
+ });
260
+
261
+ /**
262
+ * Refuse to continue, saying what could not be established.
263
+ * @param {string} reason - Operator-readable reason.
264
+ * @param {string[]} [remedy] - Lines telling the reader what to do.
265
+ * @returns {number} Exit code 1.
266
+ */
267
+ const refuse = (reason, remedy = []) => {
268
+ process.stderr.write(`\n❌ lint-staged preflight: ${reason}\n`);
269
+ for (const line of remedy) process.stderr.write(` ${line}\n`);
270
+ process.stderr.write(
271
+ "\n Nothing was proved about the tools lint-staged would run, so the\n" +
272
+ " commit is blocked rather than allowed to look checked.\n\n"
273
+ );
274
+ return 1;
275
+ };
276
+
277
+ /**
278
+ * Run the preflight.
279
+ * @param {string[]} argv - Arguments after the script name.
280
+ * @param {string} cwd - Directory the probes run in.
281
+ * @returns {Promise<number>} Process exit code.
282
+ */
283
+ export const run = async (argv, cwd) => {
284
+ const configPath = configPathFrom(argv);
285
+ const absolute = path.resolve(cwd, configPath);
286
+ if (!existsSync(absolute)) {
287
+ return refuse(`no config at ${configPath}`, [
288
+ "lint-staged is about to be handed this path and will fail too.",
289
+ "Restore the file, or point the pre-commit hook at the right one.",
290
+ ]);
291
+ }
292
+
293
+ let parsed;
294
+ try {
295
+ parsed = JSON.parse(readFileSync(absolute, "utf8"));
296
+ } catch (error) {
297
+ return refuse(`${configPath} is not valid JSON`, [
298
+ /** @type {Error} */ (error).message,
299
+ ]);
300
+ }
301
+
302
+ const extracted = tasksOf(parsed);
303
+ if ("problem" in extracted) {
304
+ return refuse(`${configPath} has a shape this guard cannot read`, [
305
+ extracted.problem,
306
+ "This guard reads JSON configs only, which is what Lisa ships.",
307
+ ]);
308
+ }
309
+ if (extracted.tasks.length === 0) {
310
+ return refuse(`${configPath} declares no tasks`, [
311
+ "lint-staged would run nothing and report success.",
312
+ "Add the tasks back, or stop running lint-staged from the hook.",
313
+ ]);
314
+ }
315
+
316
+ const executables = [...new Set(extracted.tasks.map(executableOf))].filter(
317
+ name => name !== ""
318
+ );
319
+ if (executables.length === 0) {
320
+ return refuse(
321
+ `no executable name could be read from the ${extracted.tasks.length} task(s) in ${configPath}`
322
+ );
323
+ }
324
+
325
+ process.stdout.write(
326
+ `🔎 lint-staged preflight: probing ${executables.length} tool(s) named by ${configPath}\n`
327
+ );
328
+ const env = probeEnv(cwd);
329
+ const outcomes = await Promise.all(
330
+ executables.map(executable => probe(executable, cwd, env))
331
+ );
332
+
333
+ for (const outcome of outcomes) {
334
+ process.stdout.write(
335
+ outcome.started
336
+ ? ` ✅ ${outcome.executable}\n`
337
+ : ` ❌ ${outcome.executable} — could not be started (${outcome.code})\n`
338
+ );
339
+ }
340
+
341
+ const broken = outcomes.filter(outcome => !outcome.started);
342
+ if (broken.length > 0) {
343
+ return refuse(
344
+ `${broken.length} of ${outcomes.length} tool(s) cannot be started`,
345
+ [
346
+ ...broken.map(
347
+ outcome => `${outcome.executable}: ${outcome.detail ?? outcome.code}`
348
+ ),
349
+ "",
350
+ "lint-staged spawns these without a shell. A tool that is present but",
351
+ "not executable makes it print [FAILED] and still exit 0, so the scan",
352
+ "would run, report nothing, and block nothing.",
353
+ "",
354
+ "Most often this is an install that skipped postinstall scripts, which",
355
+ "leaves a placeholder where the real binary belongs. Reinstall with",
356
+ "scripts enabled (for example `bun install --force`) and try again.",
357
+ ]
358
+ );
359
+ }
360
+
361
+ process.stdout.write(
362
+ ` ${outcomes.length} tool(s) verified runnable; handing off to lint-staged.\n`
363
+ );
364
+ return 0;
365
+ };
366
+
367
+ /**
368
+ * True when `moduleUrl` names the module node was asked to run.
369
+ *
370
+ * Both sides are realpath'd: `import.meta.url` is the real path while `argv[1]`
371
+ * is whatever the caller typed, so through a symlinked checkout, a git
372
+ * worktree, or a `/tmp` path on macOS a raw comparison is false and the body
373
+ * never runs — for a guard, exiting 0 having proved nothing.
374
+ * @param {string} moduleUrl - The caller's own `import.meta.url`.
375
+ * @param {string} [argv1] - Entry path; defaults to `process.argv[1]`.
376
+ * @returns {boolean} Whether the CLI body should run.
377
+ */
378
+ export const invokedAsScript = (moduleUrl, argv1 = process.argv[1]) => {
379
+ if (argv1 === undefined || argv1 === "") return false;
380
+ try {
381
+ return realpathSync(argv1) === realpathSync(fileURLToPath(moduleUrl));
382
+ } catch {
383
+ return false;
384
+ }
385
+ };
386
+
387
+ if (invokedAsScript(import.meta.url)) {
388
+ run(process.argv.slice(2), process.cwd())
389
+ .then(code => {
390
+ process.exitCode = code;
391
+ })
392
+ .catch(error => {
393
+ process.stderr.write(
394
+ `\n❌ lint-staged preflight crashed: ${/** @type {Error} */ (error).message}\n` +
395
+ " Nothing was proved. The commit is blocked.\n\n"
396
+ );
397
+ process.exitCode = 1;
398
+ });
399
+ }
@@ -0,0 +1,39 @@
1
+ import type { DoctorCheck } from "./doctor.js";
2
+ /** The gate id as the registry declares it. */
3
+ export declare const GATE_ID = "traceability";
4
+ /** The moment the registry permits for this gate. */
5
+ export declare const GATE_MOMENT = "pull-request";
6
+ /** What an undeclared project is repaired to. */
7
+ export declare const GATE_LEVEL = "required";
8
+ /** The subset of `.lisa.config.json` this check reads. */
9
+ interface GateConfig {
10
+ readonly gates?: Record<string, unknown>;
11
+ }
12
+ /**
13
+ * Whether a config declares the traceability gate at all.
14
+ *
15
+ * Declared-as-`off` counts as declared. A repair that overwrote an explicit
16
+ * opt-out would make the gate un-declinable, which is worse than the hole it
17
+ * closes: the project could never express a considered exemption, and the next
18
+ * `lisa doctor` would silently undo a deliberate decision.
19
+ * @param config - Parsed `.lisa.config.json`
20
+ * @returns True when the project has already made a decision about this gate.
21
+ */
22
+ export declare function declaresTraceability(config: GateConfig): boolean;
23
+ /**
24
+ * Add the gate at its registry-legal moment, without touching anything else.
25
+ *
26
+ * Returns a new object rather than mutating, so a caller that decides not to
27
+ * write still holds the original.
28
+ * @param config - Parsed `.lisa.config.json`
29
+ * @returns A copy declaring the gate
30
+ */
31
+ export declare function withTraceabilityGate<T extends GateConfig>(config: T): T;
32
+ /**
33
+ * Report — and repair — an undeclared traceability gate.
34
+ * @param targetPath - Project path to inspect and repair
35
+ * @returns The doctor check result
36
+ */
37
+ export declare function checkTraceabilityGate(targetPath: string): Promise<DoctorCheck>;
38
+ export {};
39
+ //# sourceMappingURL=doctor-traceability-gate.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"doctor-traceability-gate.d.ts","sourceRoot":"","sources":["../../src/cli/doctor-traceability-gate.ts"],"names":[],"mappings":"AAqCA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,aAAa,CAAC;AAsB/C,+CAA+C;AAC/C,eAAO,MAAM,OAAO,iBAAiB,CAAC;AAEtC,qDAAqD;AACrD,eAAO,MAAM,WAAW,iBAAiB,CAAC;AAE1C,iDAAiD;AACjD,eAAO,MAAM,UAAU,aAAa,CAAC;AAErC,0DAA0D;AAC1D,UAAU,UAAU;IAClB,QAAQ,CAAC,KAAK,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;CAC1C;AAED;;;;;;;;;GASG;AACH,wBAAgB,oBAAoB,CAAC,MAAM,EAAE,UAAU,GAAG,OAAO,CAEhE;AAED;;;;;;;GAOG;AACH,wBAAgB,oBAAoB,CAAC,CAAC,SAAS,UAAU,EAAE,MAAM,EAAE,CAAC,GAAG,CAAC,CAQvE;AA8BD;;;;GAIG;AACH,wBAAsB,qBAAqB,CACzC,UAAU,EAAE,MAAM,GACjB,OAAO,CAAC,WAAW,CAAC,CAoDtB"}
@@ -0,0 +1,164 @@
1
+ /**
2
+ * Ensure a project actually declares the Work-Item Traceability gate.
3
+ *
4
+ * Every upstream layer already sanctions it — the gate registry declares it at
5
+ * `pull-request`, the TypeScript ruleset template names the context as
6
+ * required, and the promotion ledger grandfathers it. Lisa's own repository
7
+ * enforces it. Measured 2026-08-18, **no consumer did**: `traceability` was
8
+ * absent from all five projects' `.lisa.config.json`.
9
+ *
10
+ * WHAT AN UNDECLARED GATE COSTS, RESTATED AFTER #2680. An earlier draft of this
11
+ * check said an undeclared gate meant "the job never ran at all". That was true
12
+ * when the job's `if:` keyed on `skip_jobs` and ignored the config. Since #2680
13
+ * it is false: the job runs on every pull request, and with the gate undeclared
14
+ * (`configured=false`) the built-in validation step still runs and still reds.
15
+ *
16
+ * The real cost is narrower and easier to miss. `contextsFor` filters on
17
+ * `gate.level === "required"`, so an undeclared gate emits no required status
18
+ * context — the job reds and **merges anyway**. Declaring it is what converts an
19
+ * ignorable red into a blocking one. Do not restore the stronger claim without
20
+ * re-measuring: it was wrong once already, and a repair that overstates what it
21
+ * fixes is the same defect class as a check that reports work it never did.
22
+ *
23
+ * Two independent layers decide whether a missing trailer blocks, and
24
+ * conflating them is why this went unseen:
25
+ *
26
+ * 1. the gate's level in `.lisa.config.json` — does the JOB run and fail?
27
+ * 2. branch-protection required contexts — does a failing job BLOCK a merge?
28
+ *
29
+ * This check repairs layer 1, which is the one that produces any signal at all,
30
+ * and only REPORTS layer 2. Promoting a context is governed by its own control
31
+ * (`check-required-check-promotions.mjs`), and mutating live branch protection
32
+ * belongs to `scripts/lisa-github-rulesets.sh`.
33
+ * @module cli/doctor-traceability-gate
34
+ */
35
+ import { readFile, writeFile } from "node:fs/promises";
36
+ import * as path from "node:path";
37
+ const CHECK_NAME = "Work-Item Traceability declared?";
38
+ /**
39
+ * What this check can and cannot see about layer 2.
40
+ *
41
+ * `lisa doctor` runs offline, so it never observes live branch protection. A
42
+ * declared gate therefore proves the job will FAIL, never that the failure will
43
+ * BLOCK — `contextsFor` only emits the context, and the ruleset has to require
44
+ * it. Saying nothing here would let a green doctor read as "traceability is
45
+ * enforced" when the context may not be required at all, which is the exact
46
+ * conflation #2677 was filed about. So both branches say it, and both name
47
+ * where layer 2 IS observed rather than leaving the reader to find it.
48
+ */
49
+ const LAYER_TWO_NOTE = `Whether a failure BLOCKS a merge is a separate layer that \`lisa doctor\` ` +
50
+ `cannot see: it runs offline and never reads live branch protection. Check ` +
51
+ `it with \`lisa health\` (the \`github.rulesets\` finding names any context ` +
52
+ `that "runs without blocking"), and apply the ruleset with ` +
53
+ `scripts/lisa-github-rulesets.sh if the context is missing.`;
54
+ /** The gate id as the registry declares it. */
55
+ export const GATE_ID = "traceability";
56
+ /** The moment the registry permits for this gate. */
57
+ export const GATE_MOMENT = "pull-request";
58
+ /** What an undeclared project is repaired to. */
59
+ export const GATE_LEVEL = "required";
60
+ /**
61
+ * Whether a config declares the traceability gate at all.
62
+ *
63
+ * Declared-as-`off` counts as declared. A repair that overwrote an explicit
64
+ * opt-out would make the gate un-declinable, which is worse than the hole it
65
+ * closes: the project could never express a considered exemption, and the next
66
+ * `lisa doctor` would silently undo a deliberate decision.
67
+ * @param config - Parsed `.lisa.config.json`
68
+ * @returns True when the project has already made a decision about this gate.
69
+ */
70
+ export function declaresTraceability(config) {
71
+ return Object.hasOwn(config.gates ?? {}, GATE_ID);
72
+ }
73
+ /**
74
+ * Add the gate at its registry-legal moment, without touching anything else.
75
+ *
76
+ * Returns a new object rather than mutating, so a caller that decides not to
77
+ * write still holds the original.
78
+ * @param config - Parsed `.lisa.config.json`
79
+ * @returns A copy declaring the gate
80
+ */
81
+ export function withTraceabilityGate(config) {
82
+ return {
83
+ ...config,
84
+ gates: {
85
+ ...(config.gates ?? {}),
86
+ [GATE_ID]: { [GATE_MOMENT]: GATE_LEVEL },
87
+ },
88
+ };
89
+ }
90
+ /**
91
+ * Read the config file, or `null` when there is none.
92
+ * @param configPath - Absolute path to `.lisa.config.json`
93
+ * @returns File contents, or `null` when absent or unreadable
94
+ */
95
+ async function readConfigText(configPath) {
96
+ try {
97
+ return await readFile(configPath, "utf-8");
98
+ }
99
+ catch {
100
+ return null;
101
+ }
102
+ }
103
+ /**
104
+ * Parse the config, reporting the failure rather than throwing.
105
+ * @param raw - File contents
106
+ * @returns The parsed config, or the parse error's message
107
+ */
108
+ function parseConfig(raw) {
109
+ try {
110
+ return { config: JSON.parse(raw) };
111
+ }
112
+ catch (error) {
113
+ return { error: error instanceof Error ? error.message : String(error) };
114
+ }
115
+ }
116
+ /**
117
+ * Report — and repair — an undeclared traceability gate.
118
+ * @param targetPath - Project path to inspect and repair
119
+ * @returns The doctor check result
120
+ */
121
+ export async function checkTraceabilityGate(targetPath) {
122
+ const configPath = path.join(targetPath, ".lisa.config.json");
123
+ const raw = await readConfigText(configPath);
124
+ if (raw === null) {
125
+ // Not a Lisa project, or not one yet. Nothing to declare and nothing to
126
+ // repair — reporting a gap here would fire on every unrelated directory.
127
+ return {
128
+ name: CHECK_NAME,
129
+ status: "ok",
130
+ detail: "no .lisa.config.json — nothing to declare",
131
+ };
132
+ }
133
+ const parsed = parseConfig(raw);
134
+ if ("error" in parsed) {
135
+ return {
136
+ name: CHECK_NAME,
137
+ status: "warn",
138
+ detail: `.lisa.config.json is not valid JSON, so the gate could not be ` +
139
+ `checked: ${parsed.error}`,
140
+ };
141
+ }
142
+ const config = parsed.config;
143
+ if (declaresTraceability(config)) {
144
+ return {
145
+ name: CHECK_NAME,
146
+ status: "ok",
147
+ detail: `gates.${GATE_ID} is declared — left as the project set it. ${LAYER_TWO_NOTE}`,
148
+ };
149
+ }
150
+ // Two-space indent and a trailing newline: the convention every Lisa-written
151
+ // JSON in a host project uses, so the repair does not show up as a whole-file
152
+ // reformat in the diff that carries it.
153
+ await writeFile(configPath, `${JSON.stringify(withTraceabilityGate(config), null, 2)}\n`, "utf-8");
154
+ return {
155
+ name: CHECK_NAME,
156
+ status: "warn",
157
+ detail: `gates.${GATE_ID} was undeclared, so a missing Work-Item trailer reddened ` +
158
+ `the check and merged anyway — an undeclared gate emits no required ` +
159
+ `status context. ADDED as {"${GATE_MOMENT}": "${GATE_LEVEL}"} — commit ` +
160
+ `it. Declare it as "off" instead if this project is deliberately opting ` +
161
+ `out; that decision is preserved. ${LAYER_TWO_NOTE}`,
162
+ };
163
+ }
164
+ //# sourceMappingURL=doctor-traceability-gate.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"doctor-traceability-gate.js","sourceRoot":"","sources":["../../src/cli/doctor-traceability-gate.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAiCG;AACH,OAAO,EAAE,QAAQ,EAAE,SAAS,EAAE,MAAM,kBAAkB,CAAC;AACvD,OAAO,KAAK,IAAI,MAAM,WAAW,CAAC;AAIlC,MAAM,UAAU,GAAG,kCAAkC,CAAC;AAEtD;;;;;;;;;;GAUG;AACH,MAAM,cAAc,GAClB,4EAA4E;IAC5E,4EAA4E;IAC5E,6EAA6E;IAC7E,4DAA4D;IAC5D,4DAA4D,CAAC;AAE/D,+CAA+C;AAC/C,MAAM,CAAC,MAAM,OAAO,GAAG,cAAc,CAAC;AAEtC,qDAAqD;AACrD,MAAM,CAAC,MAAM,WAAW,GAAG,cAAc,CAAC;AAE1C,iDAAiD;AACjD,MAAM,CAAC,MAAM,UAAU,GAAG,UAAU,CAAC;AAOrC;;;;;;;;;GASG;AACH,MAAM,UAAU,oBAAoB,CAAC,MAAkB;IACrD,OAAO,MAAM,CAAC,MAAM,CAAC,MAAM,CAAC,KAAK,IAAI,EAAE,EAAE,OAAO,CAAC,CAAC;AACpD,CAAC;AAED;;;;;;;GAOG;AACH,MAAM,UAAU,oBAAoB,CAAuB,MAAS;IAClE,OAAO;QACL,GAAG,MAAM;QACT,KAAK,EAAE;YACL,GAAG,CAAC,MAAM,CAAC,KAAK,IAAI,EAAE,CAAC;YACvB,CAAC,OAAO,CAAC,EAAE,EAAE,CAAC,WAAW,CAAC,EAAE,UAAU,EAAE;SACzC;KACF,CAAC;AACJ,CAAC;AAED;;;;GAIG;AACH,KAAK,UAAU,cAAc,CAAC,UAAkB;IAC9C,IAAI,CAAC;QACH,OAAO,MAAM,QAAQ,CAAC,UAAU,EAAE,OAAO,CAAC,CAAC;IAC7C,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,IAAI,CAAC;IACd,CAAC;AACH,CAAC;AAED;;;;GAIG;AACH,SAAS,WAAW,CAClB,GAAW;IAEX,IAAI,CAAC;QACH,OAAO,EAAE,MAAM,EAAE,IAAI,CAAC,KAAK,CAAC,GAAG,CAAe,EAAE,CAAC;IACnD,CAAC;IAAC,OAAO,KAAK,EAAE,CAAC;QACf,OAAO,EAAE,KAAK,EAAE,KAAK,YAAY,KAAK,CAAC,CAAC,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,EAAE,CAAC;IAC3E,CAAC;AACH,CAAC;AAED;;;;GAIG;AACH,MAAM,CAAC,KAAK,UAAU,qBAAqB,CACzC,UAAkB;IAElB,MAAM,UAAU,GAAG,IAAI,CAAC,IAAI,CAAC,UAAU,EAAE,mBAAmB,CAAC,CAAC;IAC9D,MAAM,GAAG,GAAG,MAAM,cAAc,CAAC,UAAU,CAAC,CAAC;IAC7C,IAAI,GAAG,KAAK,IAAI,EAAE,CAAC;QACjB,wEAAwE;QACxE,yEAAyE;QACzE,OAAO;YACL,IAAI,EAAE,UAAU;YAChB,MAAM,EAAE,IAAI;YACZ,MAAM,EAAE,2CAA2C;SACpD,CAAC;IACJ,CAAC;IAED,MAAM,MAAM,GAAG,WAAW,CAAC,GAAG,CAAC,CAAC;IAChC,IAAI,OAAO,IAAI,MAAM,EAAE,CAAC;QACtB,OAAO;YACL,IAAI,EAAE,UAAU;YAChB,MAAM,EAAE,MAAM;YACd,MAAM,EACJ,gEAAgE;gBAChE,YAAY,MAAM,CAAC,KAAK,EAAE;SAC7B,CAAC;IACJ,CAAC;IACD,MAAM,MAAM,GAAG,MAAM,CAAC,MAAM,CAAC;IAE7B,IAAI,oBAAoB,CAAC,MAAM,CAAC,EAAE,CAAC;QACjC,OAAO;YACL,IAAI,EAAE,UAAU;YAChB,MAAM,EAAE,IAAI;YACZ,MAAM,EAAE,SAAS,OAAO,8CAA8C,cAAc,EAAE;SACvF,CAAC;IACJ,CAAC;IAED,6EAA6E;IAC7E,8EAA8E;IAC9E,wCAAwC;IACxC,MAAM,SAAS,CACb,UAAU,EACV,GAAG,IAAI,CAAC,SAAS,CAAC,oBAAoB,CAAC,MAAM,CAAC,EAAE,IAAI,EAAE,CAAC,CAAC,IAAI,EAC5D,OAAO,CACR,CAAC;IAEF,OAAO;QACL,IAAI,EAAE,UAAU;QAChB,MAAM,EAAE,MAAM;QACd,MAAM,EACJ,SAAS,OAAO,2DAA2D;YAC3E,qEAAqE;YACrE,8BAA8B,WAAW,OAAO,UAAU,cAAc;YACxE,yEAAyE;YACzE,oCAAoC,cAAc,EAAE;KACvD,CAAC;AACJ,CAAC"}
@@ -1 +1 @@
1
- {"version":3,"file":"doctor.d.ts","sourceRoot":"","sources":["../../src/cli/doctor.ts"],"names":[],"mappings":"AAMA,OAAO,EAAE,kBAAkB,EAAE,MAAM,qBAAqB,CAAC;AACzD,OAAO,EAAE,mBAAmB,EAAE,MAAM,8BAA8B,CAAC;AAoBnE,OAAO,EAAE,cAAc,EAAE,MAAM,mBAAmB,CAAC;AAEnD,mDAAmD;AACnD,KAAK,YAAY,GAAG,IAAI,GAAG,MAAM,GAAG,MAAM,CAAC;AAO3C,oCAAoC;AACpC,MAAM,WAAW,WAAW;IAC1B,IAAI,EAAE,MAAM,CAAC;IACb,MAAM,EAAE,YAAY,CAAC;IACrB,MAAM,EAAE,MAAM,CAAC;CAChB;AAED,sCAAsC;AACtC,MAAM,WAAW,YAAY;IAC3B,MAAM,EAAE,WAAW,EAAE,CAAC;CACvB;AAED,wCAAwC;AACxC,MAAM,WAAW,aAAa;IAC5B,IAAI,CAAC,EAAE,OAAO,CAAC;IACf,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB;;;;OAIG;IACH,SAAS,CAAC,EAAE,OAAO,CAAC;CACrB;AAED,wCAAwC;AACxC,MAAM,WAAW,kBAAkB;IACjC,SAAS,EAAE,OAAO,KAAK,CAAC;IACxB,cAAc,EAAE,OAAO,cAAc,CAAC;IACtC,WAAW,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;IACpC,KAAK,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK,IAAI,CAAC;IACjC,kBAAkB,EAAE,OAAO,kBAAkB,CAAC;IAC9C,mBAAmB,EAAE,OAAO,mBAAmB,CAAC;CACjD;AAaD;;;;;;;GAOG;AACH,wBAAsB,YAAY,CAChC,IAAI,EAAE,IAAI,CAAC,kBAAkB,EAAE,gBAAgB,CAAC,EAChD,OAAO,EAAE,OAAO,GACf,OAAO,CAAC,WAAW,CAAC,CAyBtB;AA4MD;;;;;;GAMG;AACH,wBAAsB,SAAS,CAC7B,UAAU,EAAE,MAAM,GAAG,SAAS,EAC9B,OAAO,EAAE,aAAa,EACtB,YAAY,GAAE,OAAO,CAAC,kBAAkB,CAAM,GAC7C,OAAO,CAAC,YAAY,CAAC,CAsEvB"}
1
+ {"version":3,"file":"doctor.d.ts","sourceRoot":"","sources":["../../src/cli/doctor.ts"],"names":[],"mappings":"AAMA,OAAO,EAAE,kBAAkB,EAAE,MAAM,qBAAqB,CAAC;AACzD,OAAO,EAAE,mBAAmB,EAAE,MAAM,8BAA8B,CAAC;AAqBnE,OAAO,EAAE,cAAc,EAAE,MAAM,mBAAmB,CAAC;AAEnD,mDAAmD;AACnD,KAAK,YAAY,GAAG,IAAI,GAAG,MAAM,GAAG,MAAM,CAAC;AAO3C,oCAAoC;AACpC,MAAM,WAAW,WAAW;IAC1B,IAAI,EAAE,MAAM,CAAC;IACb,MAAM,EAAE,YAAY,CAAC;IACrB,MAAM,EAAE,MAAM,CAAC;CAChB;AAED,sCAAsC;AACtC,MAAM,WAAW,YAAY;IAC3B,MAAM,EAAE,WAAW,EAAE,CAAC;CACvB;AAED,wCAAwC;AACxC,MAAM,WAAW,aAAa;IAC5B,IAAI,CAAC,EAAE,OAAO,CAAC;IACf,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB;;;;OAIG;IACH,SAAS,CAAC,EAAE,OAAO,CAAC;CACrB;AAED,wCAAwC;AACxC,MAAM,WAAW,kBAAkB;IACjC,SAAS,EAAE,OAAO,KAAK,CAAC;IACxB,cAAc,EAAE,OAAO,cAAc,CAAC;IACtC,WAAW,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;IACpC,KAAK,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK,IAAI,CAAC;IACjC,kBAAkB,EAAE,OAAO,kBAAkB,CAAC;IAC9C,mBAAmB,EAAE,OAAO,mBAAmB,CAAC;CACjD;AAaD;;;;;;;GAOG;AACH,wBAAsB,YAAY,CAChC,IAAI,EAAE,IAAI,CAAC,kBAAkB,EAAE,gBAAgB,CAAC,EAChD,OAAO,EAAE,OAAO,GACf,OAAO,CAAC,WAAW,CAAC,CAyBtB;AA4MD;;;;;;GAMG;AACH,wBAAsB,SAAS,CAC7B,UAAU,EAAE,MAAM,GAAG,SAAS,EAC9B,OAAO,EAAE,aAAa,EACtB,YAAY,GAAE,OAAO,CAAC,kBAAkB,CAAM,GAC7C,OAAO,CAAC,YAAY,CAAC,CA4EvB"}
@@ -19,6 +19,7 @@ import { checkRepositoryReadiness } from "./doctor-readiness.js";
19
19
  import { checkReusableWorkflowRefs } from "./doctor-reusable-workflow-refs.js";
20
20
  import { checkWorkerEpoch } from "./doctor-worker-epoch.js";
21
21
  import { checkSerializeLegsContract } from "./doctor-serialize-legs-contract.js";
22
+ import { checkTraceabilityGate } from "./doctor-traceability-gate.js";
22
23
  import { checkWorktreeHygiene } from "./doctor-worktree-hygiene.js";
23
24
  import { checkWorktreeWorkAtRisk } from "./doctor-worktree-work-at-risk.js";
24
25
  import { STARTERS } from "./starters.js";
@@ -262,6 +263,12 @@ export async function runDoctor(targetPath, options, dependencies = {}) {
262
263
  await checkApplyFreshness(resolvedTarget),
263
264
  checkYamlRuntime(),
264
265
  await checkProjectConfig(resolvedTarget),
266
+ // Immediately after the config check, because it repairs the same file and
267
+ // an operator reading the output wants both config findings together. It
268
+ // is mutating: an undeclared gate is ADDED rather than merely reported,
269
+ // since an undeclared gate means the job never runs and there is no signal
270
+ // to notice (CodySwannGT/lisa#2677).
271
+ await checkTraceabilityGate(resolvedTarget),
265
272
  await checkKaneProvider(resolvedTarget, deps),
266
273
  await checkSonarProvider(resolvedTarget, deps),
267
274
  await checkLegacyMonitorThresholds(resolvedTarget),