@codyswann/lisa 4.43.1 → 4.44.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 (69) hide show
  1. package/all/copy-overwrite/scripts/lisa-hooks/worktree-binding-guard.mjs +263 -1
  2. package/all/copy-overwrite/scripts/lisa-work-item.mjs +103 -7
  3. package/dist/configs/vitest/coverage-reports-directory.d.ts +70 -0
  4. package/dist/configs/vitest/coverage-reports-directory.d.ts.map +1 -1
  5. package/dist/configs/vitest/coverage-reports-directory.js +180 -2
  6. package/dist/configs/vitest/coverage-reports-directory.js.map +1 -1
  7. package/dist/core/lisa-owned-hash-ledger.d.ts.map +1 -1
  8. package/dist/core/lisa-owned-hash-ledger.js +20 -0
  9. package/dist/core/lisa-owned-hash-ledger.js.map +1 -1
  10. package/dist/core/nightly-e2e-guard-behavior-certificate.js +2 -2
  11. package/dist/core/upstream-evidence-manifest.d.ts.map +1 -1
  12. package/dist/core/upstream-evidence-manifest.js +10 -3
  13. package/dist/core/upstream-evidence-manifest.js.map +1 -1
  14. package/package.json +7 -6
  15. package/plugins/lisa/.claude-plugin/plugin.json +1 -1
  16. package/plugins/lisa/.codex-plugin/plugin.json +1 -1
  17. package/plugins/lisa/hooks/worktree-binding-guard.mjs +263 -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-copilot/hooks/worktree-binding-guard.mjs +263 -1
  26. package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
  27. package/plugins/lisa-cursor/hooks/worktree-binding-guard.mjs +263 -1
  28. package/plugins/lisa-expo/.claude-plugin/plugin.json +1 -1
  29. package/plugins/lisa-expo/.codex-plugin/plugin.json +1 -1
  30. package/plugins/lisa-expo-agy/plugin.json +1 -1
  31. package/plugins/lisa-expo-copilot/.claude-plugin/plugin.json +1 -1
  32. package/plugins/lisa-expo-cursor/.claude-plugin/plugin.json +1 -1
  33. package/plugins/lisa-harper-fabric/.claude-plugin/plugin.json +1 -1
  34. package/plugins/lisa-harper-fabric/.codex-plugin/plugin.json +1 -1
  35. package/plugins/lisa-harper-fabric-agy/plugin.json +1 -1
  36. package/plugins/lisa-harper-fabric-copilot/.claude-plugin/plugin.json +1 -1
  37. package/plugins/lisa-harper-fabric-cursor/.claude-plugin/plugin.json +1 -1
  38. package/plugins/lisa-nestjs/.claude-plugin/plugin.json +1 -1
  39. package/plugins/lisa-nestjs/.codex-plugin/plugin.json +1 -1
  40. package/plugins/lisa-nestjs-agy/plugin.json +1 -1
  41. package/plugins/lisa-nestjs-copilot/.claude-plugin/plugin.json +1 -1
  42. package/plugins/lisa-nestjs-cursor/.claude-plugin/plugin.json +1 -1
  43. package/plugins/lisa-openclaw/.claude-plugin/plugin.json +1 -1
  44. package/plugins/lisa-openclaw/.codex-plugin/plugin.json +1 -1
  45. package/plugins/lisa-openclaw-agy/plugin.json +1 -1
  46. package/plugins/lisa-openclaw-copilot/.claude-plugin/plugin.json +1 -1
  47. package/plugins/lisa-openclaw-cursor/.claude-plugin/plugin.json +1 -1
  48. package/plugins/lisa-phaser/.claude-plugin/plugin.json +1 -1
  49. package/plugins/lisa-phaser/.codex-plugin/plugin.json +1 -1
  50. package/plugins/lisa-phaser-agy/plugin.json +1 -1
  51. package/plugins/lisa-phaser-copilot/.claude-plugin/plugin.json +1 -1
  52. package/plugins/lisa-phaser-cursor/.claude-plugin/plugin.json +1 -1
  53. package/plugins/lisa-rails/.claude-plugin/plugin.json +1 -1
  54. package/plugins/lisa-rails/.codex-plugin/plugin.json +1 -1
  55. package/plugins/lisa-rails-agy/plugin.json +1 -1
  56. package/plugins/lisa-rails-copilot/.claude-plugin/plugin.json +1 -1
  57. package/plugins/lisa-rails-cursor/.claude-plugin/plugin.json +1 -1
  58. package/plugins/lisa-typescript/.claude-plugin/plugin.json +1 -1
  59. package/plugins/lisa-typescript/.codex-plugin/plugin.json +1 -1
  60. package/plugins/lisa-typescript-agy/plugin.json +1 -1
  61. package/plugins/lisa-typescript-copilot/.claude-plugin/plugin.json +1 -1
  62. package/plugins/lisa-typescript-cursor/.claude-plugin/plugin.json +1 -1
  63. package/plugins/lisa-wiki/.claude-plugin/plugin.json +1 -1
  64. package/plugins/lisa-wiki/.codex-plugin/plugin.json +1 -1
  65. package/plugins/lisa-wiki-agy/plugin.json +1 -1
  66. package/plugins/lisa-wiki-copilot/.claude-plugin/plugin.json +1 -1
  67. package/plugins/lisa-wiki-cursor/.claude-plugin/plugin.json +1 -1
  68. package/plugins/src/base/hooks/worktree-binding-guard.mjs +263 -1
  69. package/scripts/check-generated-artifact-merge-coverage.mjs +301 -0
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-wiki",
3
- "version": "4.43.1",
3
+ "version": "4.44.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"
@@ -47,7 +47,13 @@
47
47
  */
48
48
 
49
49
  import { execFileSync } from "node:child_process";
50
- import { mkdirSync, readFileSync, realpathSync, writeFileSync } from "node:fs";
50
+ import {
51
+ mkdirSync,
52
+ readFileSync,
53
+ realpathSync,
54
+ statSync,
55
+ writeFileSync,
56
+ } from "node:fs";
51
57
  import { homedir } from "node:os";
52
58
  import { dirname, isAbsolute, join, resolve } from "node:path";
53
59
 
@@ -148,6 +154,258 @@ function mainCheckout(cwd) {
148
154
  return null;
149
155
  }
150
156
 
157
+ /**
158
+ * How much of an executed script this guard will read, in bytes.
159
+ *
160
+ * A generated fixture can be megabytes, and a guard that reads all of it on
161
+ * every Bash call is a guard someone switches off.
162
+ */
163
+ const SCRIPT_READ_LIMIT = 256 * 1024;
164
+
165
+ /**
166
+ * Programs that take their operands as DATA and never execute them.
167
+ *
168
+ * The same split `block-direct-issue-create.sh` makes, and for the same reason
169
+ * it had to: a guard that opens every file a command names, then judges the
170
+ * COMMAND by that FILE's contents, refuses ordinary inspection. Reading a
171
+ * script that visits another worktree is not visiting it.
172
+ *
173
+ * The list is an exemption, not an allowlist. Anything unrecognised is
174
+ * FOLLOWED, because an interpreter roster fails open on the runner nobody
175
+ * enumerated.
176
+ */
177
+ const READ_ONLY_PROGRAMS = new Set([
178
+ "awk",
179
+ "basename",
180
+ "cat",
181
+ "cksum",
182
+ "cmp",
183
+ "comm",
184
+ "cut",
185
+ "diff",
186
+ "dirname",
187
+ "du",
188
+ "file",
189
+ "find",
190
+ "grep",
191
+ "egrep",
192
+ "fgrep",
193
+ "head",
194
+ "less",
195
+ "ls",
196
+ "md5",
197
+ "md5sum",
198
+ "more",
199
+ "nl",
200
+ "od",
201
+ "realpath",
202
+ "rg",
203
+ "sed",
204
+ "sha1sum",
205
+ "sha256sum",
206
+ "sort",
207
+ "stat",
208
+ "strings",
209
+ "tail",
210
+ "tee",
211
+ "tr",
212
+ "uniq",
213
+ "wc",
214
+ "xxd",
215
+ ]);
216
+
217
+ /** Interpreters whose first non-option operand is the file they run. */
218
+ const EXECUTING_INTERPRETERS = new Set([
219
+ "bash",
220
+ "dash",
221
+ "ksh",
222
+ "node",
223
+ "perl",
224
+ "python",
225
+ "python3",
226
+ "ruby",
227
+ "sh",
228
+ "zsh",
229
+ "bun",
230
+ "deno",
231
+ "tsx",
232
+ ]);
233
+
234
+ /** Shell builtins that run a file in the CURRENT shell. */
235
+ const SOURCE_BUILTINS = new Set([".", "source"]);
236
+
237
+ /** Where one command ends and the next begins. */
238
+ const COMMAND_SEPARATORS = /\|\||&&|[;|&\n]/u;
239
+
240
+ /**
241
+ * Split a command into tokens, honouring simple quoting.
242
+ *
243
+ * Deliberately simple, and the guard fails OPEN when it is not enough — see
244
+ * {@link scriptReachingForeignTree}. A tokeniser that guessed would be worse
245
+ * than one that says it does not know.
246
+ */
247
+ function tokenise(segment) {
248
+ const tokens = segment.match(/"[^"]*"|'[^']*'|[^\s]+/gu) ?? [];
249
+ return tokens.map(token => token.replace(/^["']|["']$/gu, ""));
250
+ }
251
+
252
+ /**
253
+ * The path this command EXECUTES, or null.
254
+ *
255
+ * The command-position question. A path anywhere else is an argument, and an
256
+ * argument is data.
257
+ */
258
+ function executedPath(segment) {
259
+ const tokens = tokenise(segment);
260
+ if (tokens.length === 0) return null;
261
+ const [head, ...rest] = tokens;
262
+ const program = head.split("/").pop() ?? "";
263
+ if (READ_ONLY_PROGRAMS.has(program)) return null;
264
+ if (SOURCE_BUILTINS.has(program)) return rest[0] ?? null;
265
+ if (!EXECUTING_INTERPRETERS.has(program)) {
266
+ // A command word that is itself a file runs by its shebang.
267
+ return head.includes("/") ? head : null;
268
+ }
269
+ // A known interpreter: the first operand that is not an option.
270
+ const operand = rest.find(token => !token.startsWith("-"));
271
+ return operand ?? null;
272
+ }
273
+
274
+ /**
275
+ * A worktree root other than `bound` that this text reaches into, or null.
276
+ *
277
+ * ## Why this does not enumerate `cd`
278
+ *
279
+ * The redirect spellings — `cd`, `git -C`, `--work-tree`, `--git-dir`, a
280
+ * subshell, `env -C`, a variable holding the path — are an open set, and
281
+ * enumerating them is the failure this arm exists to end
282
+ * (CodySwannGT/lisa#3927 states it for the sibling guard). So the question is
283
+ * inverted the same way the filing guard inverted its own: not *which syntax
284
+ * moves*, which is unbounded, but *which tokens name a directory in another
285
+ * worktree*, which is bounded by the file.
286
+ *
287
+ * A path only has to RESOLVE. How it arrived does not matter, which is what
288
+ * makes an unenumerated spelling reach the same verdict as `cd`.
289
+ *
290
+ * ## Why comments are stripped first
291
+ *
292
+ * A script that merely MENTIONS a sibling worktree — a usage line, a comment,
293
+ * a log message — would otherwise be refused for describing the thing rather
294
+ * than doing it. Prose about a subject is the likeliest text to contain the
295
+ * subject's shapes, and the population that writes it is the people
296
+ * documenting the guard. A parity classifier elsewhere in this repository was
297
+ * fooled by exactly that and was caught only by a known-answer control.
298
+ */
299
+ function foreignTreeIn(text, bound, cwd) {
300
+ const code = text
301
+ .split("\n")
302
+ .map(line => line.replace(/^\s*#.*$/u, ""))
303
+ .join("\n");
304
+ for (const raw of code.match(/[^\s'";|&()<>]+/gu) ?? []) {
305
+ const token = raw.replace(/^["']|["']$/gu, "");
306
+ if (!token.includes("/")) continue;
307
+ const candidate = isAbsolute(token) ? token : join(cwd, token);
308
+ let root;
309
+ try {
310
+ if (!statSync(candidate).isDirectory()) continue;
311
+ root = worktreeRoot(realpathSync(candidate));
312
+ } catch {
313
+ continue;
314
+ }
315
+ if (root && root !== bound) return root;
316
+ }
317
+ return null;
318
+ }
319
+
320
+ /**
321
+ * The foreign worktree an executed script reaches, or null.
322
+ *
323
+ * ## Why this arm exists at all
324
+ *
325
+ * The check above compares `payload.cwd` against the bound root, and that is
326
+ * the right comparison — but **`payload.cwd` is sampled before the child
327
+ * process runs**. A script that changes directory internally moves AFTER the
328
+ * guard has measured. The guard is not wrong about the target; it is right
329
+ * about a target that stops being the target one instruction later.
330
+ *
331
+ * A measurement taken before execution cannot bind what execution does
332
+ * (CodySwannGT/lisa#3924). So the only thing left is to read what is about to
333
+ * run.
334
+ *
335
+ * ## What this arm does NOT cover, stated rather than implied
336
+ *
337
+ * An INLINE redirect — `cd B && git status`, `git -C B status` — is not
338
+ * refused here. Those are already refused by the runtime's own worktree
339
+ * isolation, and duplicating a control is how two controls drift into
340
+ * disagreeing. The scripted form is the one neither covers, and it is the only
341
+ * cell this arm fills.
342
+ *
343
+ * ## A relative script path is resolved against `payload.cwd`, and that is a
344
+ * KNOWN limitation shared with `parity-safety-net.sh`
345
+ *
346
+ * CodySwannGT/lisa#3933, filed against that guard, is the same defect: a
347
+ * relative token is resolved against the hook process's working directory, not
348
+ * against the directory the command will have once it runs its own leading
349
+ * `cd`. So `cd <other tree> && bash scripts/foo.sh` reads THIS tree's copy of
350
+ * `scripts/foo.sh` and reports on it with full confidence.
351
+ *
352
+ * It matters here for the same reason it matters there: on a machine running
353
+ * many worktrees of one repository, every tree holds its own copy of every
354
+ * script at the same relative path, and they differ by whatever each lane is
355
+ * doing. A confident scan of the wrong copy is wrong in both directions, and
356
+ * the silent one — the copy that will run reaches out, the copy that was read
357
+ * does not — is the direction this arm exists to prevent.
358
+ *
359
+ * NOT fixed here on purpose. #3933 is assigned and its options (honour a
360
+ * leading `cd`, or fail closed when the effective cwd is uncertain) belong in
361
+ * one place rather than two: a second implementation of the same resolution is
362
+ * how two guards drift into disagreeing about which file a command runs.
363
+ * When that lands, this call site should adopt whatever it produces.
364
+ *
365
+ * The exposure here is narrower than there. This arm is reached only after the
366
+ * session has been confirmed to be in its bound worktree, so the common case
367
+ * is a relative path resolving inside the tree the session is actually in.
368
+ *
369
+ * Fails OPEN and says so, like every other undecidable case in this file.
370
+ */
371
+ function scriptReachingForeignTree(payload, bound) {
372
+ const command = payload.tool_input?.command;
373
+ if (typeof command !== "string" || !command) return null;
374
+ for (const segment of command.split(COMMAND_SEPARATORS)) {
375
+ const named = executedPath(segment);
376
+ if (!named) continue;
377
+ const path = isAbsolute(named) ? named : join(payload.cwd, named);
378
+ let text;
379
+ try {
380
+ if (statSync(path).size > SCRIPT_READ_LIMIT) {
381
+ say(`${path} is too large to inspect; NOT enforcing on it`);
382
+ continue;
383
+ }
384
+ text = readFileSync(path, "utf8");
385
+ } catch {
386
+ // Not a readable file: nothing was executed that this can read.
387
+ continue;
388
+ }
389
+ const foreign = foreignTreeIn(text, bound, payload.cwd);
390
+ if (foreign) return { script: path, foreign };
391
+ }
392
+ return null;
393
+ }
394
+
395
+ function reachesOut(script, foreign, bound) {
396
+ return refuse([
397
+ "worktree-binding-guard: this command runs a script that reaches into",
398
+ "another worktree.",
399
+ ` script: ${script}`,
400
+ ` reaches: ${foreign}`,
401
+ ` bound to: ${bound}`,
402
+ "The directory change lives inside the file, so it happens after this",
403
+ "guard has measured where the session is. Run the work from the tree it",
404
+ "belongs to, or acknowledge the move:",
405
+ acceptanceLine(foreign),
406
+ ]);
407
+ }
408
+
151
409
  function stateFile(sessionId) {
152
410
  const home = process.env.LISA_STATE_HOME || join(homedir(), ".lisa");
153
411
  return join(home, "worktree-binding", `${sessionId}.json`);
@@ -328,6 +586,10 @@ function evaluate(payload) {
328
586
  return 0;
329
587
  }
330
588
  if (state.boundRoot !== observed) return displaced(state.boundRoot, observed);
589
+ // Last, and only once the session is provably where it should be: a script
590
+ // that moves somewhere else after this check has already run.
591
+ const reach = scriptReachingForeignTree(payload, observed);
592
+ if (reach) return reachesOut(reach.script, reach.foreign, observed);
331
593
  return 0;
332
594
  }
333
595
 
@@ -0,0 +1,301 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Prove every generated artifact is either merge-driver covered or declared
4
+ * unsupported for a reason that is still true (issue CodySwannGT/lisa#3932).
5
+ *
6
+ * ## The gap this closes
7
+ *
8
+ * Lisa ships a merge driver for its checked-in generated artifacts, and
9
+ * `.gitattributes` maps two of them to it. It maps **two of four**. Nothing
10
+ * said so: an artifact joins the unregistered set by being written, and the
11
+ * only way to notice was to enumerate `.gitattributes` by hand and compare it
12
+ * against a list of artifacts that exists nowhere.
13
+ *
14
+ * ## Why the obvious repair is wrong, measured
15
+ *
16
+ * "Register the other two" looks free and is not. Run the driver's own parser
17
+ * over all four:
18
+ *
19
+ * ```
20
+ * src/core/upstream-evidence-manifest.ts parse=OK roundtrip=EXACT
21
+ * src/core/lisa-owned-hash-ledger.ts parse=OK roundtrip=EXACT
22
+ * scripts/two-channel-couplings.json PARSE-FAIL: mixed indentation
23
+ * src/core/nightly-e2e-guard-behavior-certificate.ts PARSE-FAIL: mixed indentation
24
+ * ```
25
+ *
26
+ * The parser handles a **flat** one-level `"key": scalar` map, which is what
27
+ * the manifest and the ledger are. The other two are **nested** —
28
+ * `two-channel-couplings.json` carries `ratified` / `inspected` / `counts`
29
+ * sub-objects, and the certificate nests a `contractVersion` /
30
+ * `packageVersions` / `provenances` record under each digest. Mapping them
31
+ * would make the driver run, fail to parse, exit 1 and leave the conflict
32
+ * exactly where it was — a control announcing coverage it does not have.
33
+ *
34
+ * So the unregistered set is a **capability boundary**, not an oversight, and
35
+ * this guard's job is to hold that distinction rather than to erase it.
36
+ *
37
+ * ## The tripwire, which is the part worth having
38
+ *
39
+ * An artifact declared `unsupported` is asserted to STILL fail the parser. The
40
+ * day somebody teaches the driver nested shapes, this guard goes red and names
41
+ * the artifact that can now be registered. A declaration that only recorded
42
+ * "unsupported" would quietly stay wrong forever — the failure mode of every
43
+ * comment that was true when written.
44
+ *
45
+ * CLI:
46
+ * node scripts/check-generated-artifact-merge-coverage.mjs [--root <dir>]
47
+ *
48
+ * Exit codes:
49
+ * 0 — every artifact's declared merge status matches reality.
50
+ * 1 — a declaration is wrong, an artifact is undeclared, or a mapping is
51
+ * missing or unsupported.
52
+ * 2 — operational error: `--root` missing its value, or a file unreadable.
53
+ *
54
+ * @module scripts/check-generated-artifact-merge-coverage
55
+ */
56
+ import { readFileSync } from "node:fs";
57
+ import path from "node:path";
58
+ import process from "node:process";
59
+
60
+ import { invokedAsScript } from "./lib/invoked-as-script.mjs";
61
+ import { parseArtifact, renderArtifact } from "./merge-generated-artifact.mjs";
62
+
63
+ /** The driver name `.gitattributes` maps an artifact to. */
64
+ export const DRIVER = "lisa-generated-artifact";
65
+
66
+ /** The npm script that runs every generated-artifact check in one go. */
67
+ export const AGGREGATE_CHECK = "check:artifacts";
68
+
69
+ /** Merge dispositions an artifact may declare. */
70
+ export const DISPOSITION = Object.freeze({
71
+ DRIVER: "driver",
72
+ UNSUPPORTED: "unsupported",
73
+ });
74
+
75
+ /**
76
+ * Every checked-in generated artifact, with the npm check that regenerates it
77
+ * and the merge disposition it claims.
78
+ *
79
+ * The `check` field is not decoration: {@link undeclaredChecks} reads it so a
80
+ * fifth artifact cannot join `check:artifacts` without also appearing here.
81
+ */
82
+ export const GENERATED_ARTIFACTS = Object.freeze([
83
+ Object.freeze({
84
+ path: "src/core/upstream-evidence-manifest.ts",
85
+ check: "check:upstream-evidence-manifest",
86
+ disposition: DISPOSITION.DRIVER,
87
+ reason: "",
88
+ }),
89
+ Object.freeze({
90
+ path: "src/core/lisa-owned-hash-ledger.ts",
91
+ check: "check:lisa-owned-hash-ledger",
92
+ disposition: DISPOSITION.DRIVER,
93
+ reason: "",
94
+ }),
95
+ Object.freeze({
96
+ path: "scripts/two-channel-couplings.json",
97
+ check: "check:two-channel-couplings",
98
+ disposition: DISPOSITION.UNSUPPORTED,
99
+ reason:
100
+ "nested: `ratified` / `inspected` / `counts` sub-objects, which the driver's flat one-level parser rejects",
101
+ }),
102
+ Object.freeze({
103
+ path: "src/core/nightly-e2e-guard-behavior-certificate.ts",
104
+ check: "check:nightly-guard-certificate",
105
+ disposition: DISPOSITION.UNSUPPORTED,
106
+ reason:
107
+ "nested: a contractVersion / packageVersions / provenances record under each digest, which the driver's flat one-level parser rejects",
108
+ }),
109
+ ]);
110
+
111
+ /**
112
+ * Checks named by `check:artifacts` that own no generated artifact.
113
+ *
114
+ * `check:deletion-basis` reads the hand-authored `deletions.json` files. There
115
+ * is no generator behind it and therefore nothing for a merge driver to
116
+ * reconstruct, so it is out of this guard's subject rather than undeclared.
117
+ */
118
+ export const NON_ARTIFACT_CHECKS = Object.freeze([
119
+ "check:deletion-basis",
120
+ // This guard itself. It runs inside `check:artifacts` and generates nothing;
121
+ // it was the first thing its own undeclared-check arm caught, which is the
122
+ // arm working rather than a special case being carved out for it.
123
+ "check:merge-coverage",
124
+ ]);
125
+
126
+ /**
127
+ * Whether `.gitattributes` maps a path to the generated-artifact driver.
128
+ * @param {string} attributes - Contents of `.gitattributes`
129
+ * @param {string} artifactPath - Repository-relative artifact path
130
+ * @returns {boolean} True when a line maps exactly that path to the driver.
131
+ */
132
+ export function mapsToDriver(attributes, artifactPath) {
133
+ return attributes
134
+ .split("\n")
135
+ .map(line => line.trim())
136
+ .includes(`${artifactPath} merge=${DRIVER}`);
137
+ }
138
+
139
+ /**
140
+ * Whether the driver can actually structure an artifact.
141
+ *
142
+ * Three conditions, and the third is the one that took measuring:
143
+ *
144
+ * 1. it parses;
145
+ * 2. re-emitting the parse reproduces the bytes, because the driver writes
146
+ * back what it read and a lossy parse would corrupt the file;
147
+ * 3. **it yields at least one structured entry.** The parser passes
148
+ * unrecognised regions through verbatim as opaque text, so conditions 1 and
149
+ * 2 alone are nearly unfalsifiable — measured, a hash ledger with every
150
+ * leading indent stripped still parsed and still round-tripped exactly,
151
+ * while structuring **0** entries. A mapping over a file the driver
152
+ * structures nothing in is inert: it would merge no entry pointwise and
153
+ * fall back to picking a side.
154
+ *
155
+ * ## What this deliberately does NOT claim
156
+ *
157
+ * It is a floor, not a proof of fidelity. Measured, reindenting a single entry
158
+ * left the count unchanged at 148, and appending a stray line *raised* it to
159
+ * 149 by structuring the stray as an entry. So this catches total structural
160
+ * collapse and an unparseable shape; it does not catch an artifact the parser
161
+ * structures slightly wrong. Saying so here rather than letting a future
162
+ * reader infer a stronger guarantee from a passing check.
163
+ * @param {string} text - The artifact's contents
164
+ * @returns {{ok: boolean, reason: string, entries: number}} Verdict, why not, and the entry count.
165
+ */
166
+ export function driverCanStructure(text) {
167
+ const parsed = parseArtifact(text);
168
+ if (!parsed.ok) return { ok: false, reason: parsed.reason, entries: 0 };
169
+ if (renderArtifact(parsed.chunks) !== text)
170
+ return {
171
+ ok: false,
172
+ reason: "parsed, but re-emitting it changed the bytes",
173
+ entries: 0,
174
+ };
175
+ const entries = parsed.chunks.reduce(
176
+ (total, chunk) =>
177
+ total + (chunk.entries instanceof Map ? chunk.entries.size : 0),
178
+ 0
179
+ );
180
+ return entries > 0
181
+ ? { ok: true, reason: "", entries }
182
+ : {
183
+ ok: false,
184
+ reason: "parsed and round-tripped, but structured ZERO entries",
185
+ entries: 0,
186
+ };
187
+ }
188
+
189
+ /**
190
+ * Checks wired into `check:artifacts` that no artifact declares.
191
+ * @param {string} packageJson - Contents of `package.json`
192
+ * @returns {string[]} Check names present in the script and undeclared here.
193
+ */
194
+ export function undeclaredChecks(packageJson) {
195
+ const script = JSON.parse(packageJson).scripts?.[AGGREGATE_CHECK] ?? "";
196
+ const known = new Set([
197
+ // The aggregate names itself in its own failure message, so it appears in
198
+ // its own body. Excluding it here rather than in the regex keeps the regex
199
+ // a plain "find every check" and puts the reason next to the exclusion.
200
+ AGGREGATE_CHECK,
201
+ ...GENERATED_ARTIFACTS.map(entry => entry.check),
202
+ ...NON_ARTIFACT_CHECKS,
203
+ ]);
204
+ const referenced = [...script.matchAll(/\bcheck:[a-z0-9-]+/gu)].map(
205
+ match => match[0]
206
+ );
207
+ return [...new Set(referenced)].filter(name => !known.has(name));
208
+ }
209
+
210
+ /**
211
+ * Judge one artifact against its declaration.
212
+ * @param {object} entry - One {@link GENERATED_ARTIFACTS} record
213
+ * @param {string} attributes - Contents of `.gitattributes`
214
+ * @param {string} text - The artifact's contents
215
+ * @returns {string[]} Human-readable violations; empty when the declaration holds.
216
+ */
217
+ export function judgeArtifact(entry, attributes, text) {
218
+ const mapped = mapsToDriver(attributes, entry.path);
219
+ const { ok, reason } = driverCanStructure(text);
220
+ const wantsDriver = entry.disposition === DISPOSITION.DRIVER;
221
+
222
+ if (wantsDriver && !mapped)
223
+ return [
224
+ `${entry.path}: declared \`driver\` but .gitattributes does not map it. Add:\n ${entry.path} merge=${DRIVER}`,
225
+ ];
226
+ if (wantsDriver && !ok)
227
+ return [
228
+ `${entry.path}: declared \`driver\` and mapped, but the driver cannot structure it (${reason}). The mapping is inert — git falls back to a text merge and says nothing about the driver having done nothing.`,
229
+ ];
230
+ if (!wantsDriver && mapped)
231
+ return [
232
+ `${entry.path}: declared \`unsupported\` yet .gitattributes maps it to ${DRIVER}. The driver will run, fail, and leave the conflict while appearing to have handled it.`,
233
+ ];
234
+ if (!wantsDriver && ok)
235
+ return [
236
+ `${entry.path}: declared \`unsupported\` — reason recorded as "${entry.reason}" — but the driver can now structure it. The boundary moved: map it in .gitattributes and change its disposition to \`driver\`.`,
237
+ ];
238
+ return [];
239
+ }
240
+
241
+ /**
242
+ * Read one repository-relative file.
243
+ * @param {string} root - Repository root
244
+ * @param {string} relative - Repository-relative path
245
+ * @returns {string} File contents.
246
+ */
247
+ function read(root, relative) {
248
+ return readFileSync(path.join(root, relative), "utf8");
249
+ }
250
+
251
+ /**
252
+ * Run the whole check against a checkout.
253
+ * @param {string} root - Repository root
254
+ * @returns {{violations: string[], checked: number}} What failed, and how many artifacts were read.
255
+ */
256
+ export function inspectGeneratedArtifacts(root) {
257
+ const attributes = read(root, ".gitattributes");
258
+ const undeclared = undeclaredChecks(read(root, "package.json")).map(
259
+ name =>
260
+ `${name} runs in \`check:artifacts\` but no entry in GENERATED_ARTIFACTS declares it. A generated artifact that nothing declares cannot be told from one that needs no merge driver — add it, or list it in NON_ARTIFACT_CHECKS with the reason it owns no artifact.`
261
+ );
262
+ const perArtifact = GENERATED_ARTIFACTS.flatMap(entry =>
263
+ judgeArtifact(entry, attributes, read(root, entry.path))
264
+ );
265
+ return {
266
+ violations: [...undeclared, ...perArtifact],
267
+ checked: GENERATED_ARTIFACTS.length,
268
+ };
269
+ }
270
+
271
+ /**
272
+ * CLI entry point.
273
+ * @param {string[]} argv - Arguments after the script name
274
+ * @returns {number} Process exit code.
275
+ */
276
+ export function main(argv) {
277
+ const flag = argv.indexOf("--root");
278
+ if (flag !== -1 && argv[flag + 1] === undefined) {
279
+ console.error("--root requires a directory");
280
+ return 2;
281
+ }
282
+ const root = flag === -1 ? process.cwd() : argv[flag + 1];
283
+
284
+ const { violations, checked } = inspectGeneratedArtifacts(root);
285
+ if (violations.length === 0) {
286
+ const driven = GENERATED_ARTIFACTS.filter(
287
+ entry => entry.disposition === DISPOSITION.DRIVER
288
+ ).length;
289
+ console.log(
290
+ `generated-artifact merge coverage: ${String(checked)} artifact(s) declared, ${String(driven)} merge-driver covered, ${String(checked - driven)} declared unsupported and still unparseable by the driver.`
291
+ );
292
+ return 0;
293
+ }
294
+ const detail = violations.map(line => ` - ${line}`).join("\n");
295
+ console.error(`generated-artifact merge coverage FAILED:\n${detail}`);
296
+ return 1;
297
+ }
298
+
299
+ if (invokedAsScript(import.meta.url)) {
300
+ process.exit(main(process.argv.slice(2)));
301
+ }