@codyswann/lisa 3.38.3 → 3.38.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/dist/core/upstream-evidence-manifest.d.ts.map +1 -1
  2. package/dist/core/upstream-evidence-manifest.js +3 -0
  3. package/dist/core/upstream-evidence-manifest.js.map +1 -1
  4. package/dist/strategies/package-lisa.js +58 -2
  5. package/dist/strategies/package-lisa.js.map +1 -1
  6. package/package.json +2 -1
  7. package/plugins/lisa/.claude-plugin/plugin.json +1 -1
  8. package/plugins/lisa/.codex-plugin/plugin.json +1 -1
  9. package/plugins/lisa-agy/plugin.json +1 -1
  10. package/plugins/lisa-cdk/.claude-plugin/plugin.json +1 -1
  11. package/plugins/lisa-cdk/.codex-plugin/plugin.json +1 -1
  12. package/plugins/lisa-cdk-agy/plugin.json +1 -1
  13. package/plugins/lisa-cdk-copilot/.claude-plugin/plugin.json +1 -1
  14. package/plugins/lisa-cdk-cursor/.claude-plugin/plugin.json +1 -1
  15. package/plugins/lisa-copilot/.claude-plugin/plugin.json +1 -1
  16. package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
  17. package/plugins/lisa-expo/.claude-plugin/plugin.json +1 -1
  18. package/plugins/lisa-expo/.codex-plugin/plugin.json +1 -1
  19. package/plugins/lisa-expo-agy/plugin.json +1 -1
  20. package/plugins/lisa-expo-copilot/.claude-plugin/plugin.json +1 -1
  21. package/plugins/lisa-expo-cursor/.claude-plugin/plugin.json +1 -1
  22. package/plugins/lisa-harper-fabric/.claude-plugin/plugin.json +1 -1
  23. package/plugins/lisa-harper-fabric/.codex-plugin/plugin.json +1 -1
  24. package/plugins/lisa-harper-fabric-agy/plugin.json +1 -1
  25. package/plugins/lisa-harper-fabric-copilot/.claude-plugin/plugin.json +1 -1
  26. package/plugins/lisa-harper-fabric-cursor/.claude-plugin/plugin.json +1 -1
  27. package/plugins/lisa-nestjs/.claude-plugin/plugin.json +1 -1
  28. package/plugins/lisa-nestjs/.codex-plugin/plugin.json +1 -1
  29. package/plugins/lisa-nestjs-agy/plugin.json +1 -1
  30. package/plugins/lisa-nestjs-copilot/.claude-plugin/plugin.json +1 -1
  31. package/plugins/lisa-nestjs-cursor/.claude-plugin/plugin.json +1 -1
  32. package/plugins/lisa-openclaw/.claude-plugin/plugin.json +1 -1
  33. package/plugins/lisa-openclaw/.codex-plugin/plugin.json +1 -1
  34. package/plugins/lisa-openclaw-agy/plugin.json +1 -1
  35. package/plugins/lisa-openclaw-copilot/.claude-plugin/plugin.json +1 -1
  36. package/plugins/lisa-openclaw-cursor/.claude-plugin/plugin.json +1 -1
  37. package/plugins/lisa-phaser/.claude-plugin/plugin.json +1 -1
  38. package/plugins/lisa-phaser/.codex-plugin/plugin.json +1 -1
  39. package/plugins/lisa-phaser-agy/plugin.json +1 -1
  40. package/plugins/lisa-phaser-copilot/.claude-plugin/plugin.json +1 -1
  41. package/plugins/lisa-phaser-cursor/.claude-plugin/plugin.json +1 -1
  42. package/plugins/lisa-rails/.claude-plugin/plugin.json +1 -1
  43. package/plugins/lisa-rails/.codex-plugin/plugin.json +1 -1
  44. package/plugins/lisa-rails-agy/plugin.json +1 -1
  45. package/plugins/lisa-rails-copilot/.claude-plugin/plugin.json +1 -1
  46. package/plugins/lisa-rails-cursor/.claude-plugin/plugin.json +1 -1
  47. package/plugins/lisa-typescript/.claude-plugin/plugin.json +1 -1
  48. package/plugins/lisa-typescript/.codex-plugin/plugin.json +1 -1
  49. package/plugins/lisa-typescript-agy/plugin.json +1 -1
  50. package/plugins/lisa-typescript-copilot/.claude-plugin/plugin.json +1 -1
  51. package/plugins/lisa-typescript-cursor/.claude-plugin/plugin.json +1 -1
  52. package/plugins/lisa-wiki/.claude-plugin/plugin.json +1 -1
  53. package/plugins/lisa-wiki/.codex-plugin/plugin.json +1 -1
  54. package/plugins/lisa-wiki-agy/plugin.json +1 -1
  55. package/plugins/lisa-wiki-copilot/.claude-plugin/plugin.json +1 -1
  56. package/plugins/lisa-wiki-cursor/.claude-plugin/plugin.json +1 -1
  57. package/scripts/check-delivery-deletion-conflicts.mjs +494 -0
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-wiki",
3
- "version": "3.38.3",
3
+ "version": "3.38.5",
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": "3.38.3",
3
+ "version": "3.38.5",
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": "3.38.3",
3
+ "version": "3.38.5",
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": "3.38.3",
3
+ "version": "3.38.5",
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": "3.38.3",
3
+ "version": "3.38.5",
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"
@@ -0,0 +1,494 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Deterministic gate that no path is both DELIVERED by a copy strategy and
4
+ * DELETED by a `deletions.json` that runs in the same `lisa apply` (#2714).
5
+ *
6
+ * Lisa decides what a consumer's tree should contain from two manifests that
7
+ * are authored independently and were never compared with each other:
8
+ *
9
+ * - delivery lanes — `<stack>/create-only/`, `<stack>/copy-overwrite/`,
10
+ * `<stack>/copy-contents/`, `<stack>/merge/`, `<stack>/tagged-merge/`,
11
+ * `<stack>/package-lisa/`;
12
+ * - deletion manifests — `<stack>/deletions.json`, effective set being
13
+ * `paths` minus `keep`.
14
+ *
15
+ * When one path lands in both, `apply` creates it and then destroys it in the
16
+ * same run — `processConfigurations()` precedes `processDeletions()` in
17
+ * `src/core/lisa.ts`, and the deletion is unconditional. Nothing errors and
18
+ * nothing warns; the operator sees a file that will not stay put.
19
+ *
20
+ * The class is not hypothetical. Scanning every released tag for this exact
21
+ * shape found 9 distinct conflicts spanning 52 tags, among them
22
+ * `all/copy-overwrite/.claude/rules/coding-philosophy.md` against
23
+ * `all/deletions.json` across v1.67.0..v1.76.6 — `all` is active for every
24
+ * project, so that one shipped to everyone for 32 releases. Each was found by
25
+ * hand, later, by someone who noticed a file behaving oddly.
26
+ *
27
+ * ## Which directions are wrong
28
+ *
29
+ * Two stacks can only collide when both are active in one apply, which means
30
+ * one is an ancestor of the other (`all` is the implicit root; `typescript` is
31
+ * the parent of `cdk`, `expo`, `nestjs`, `phaser`, `harper-fabric`,
32
+ * `npm-package`). The direction decides the verdict, and only two of the four
33
+ * are defects:
34
+ *
35
+ * - SELF — one stack ships and deletes the same path. A manifest
36
+ * contradicting itself has no correct reading. Always a finding.
37
+ * - ANCESTOR-DELETES — a less specific stack deletes what a more specific
38
+ * one ships. Deletions run last and unconditionally, so the ancestor
39
+ * destroys a file the child deliberately installed. Always a finding.
40
+ * - DESCENDANT-DELETES — a more specific stack deletes what an ancestor
41
+ * ships. This is the DESIGNED child-overrides-parent override, documented
42
+ * on `loadPendingDeletions()` with the CDK-drops-inherited-jest case as
43
+ * its worked example. Reported, never a finding.
44
+ * - UNRELATED — two stacks with no ancestry relation (siblings such as
45
+ * `expo` and `cdk`). Reported, never a finding. Detection has no mutual
46
+ * exclusion (`detectAll` pushes every matching detector), so these are a
47
+ * genuine latent hazard, but resolving them is a design decision about
48
+ * which test runner wins in a mixed repo rather than an authoring slip.
49
+ * Deliberately out of this gate's scope; see #2714 "Out of scope".
50
+ *
51
+ * ## Directory entries count
52
+ *
53
+ * `deletions.json` may name a directory (`.claude/skills/jira-create`). The
54
+ * runtime pre-pass compares by exact string, so it does NOT suppress delivery
55
+ * of a file nested under a deleted directory — but `processDeletions()` still
56
+ * removes the whole tree afterwards. That is the same create-then-destroy
57
+ * defect wearing a different spelling, so this gate matches nested paths too
58
+ * and labels them `under-dir`.
59
+ *
60
+ * ## Determinism guarantees
61
+ *
62
+ * - zero third-party dependencies (Node built-ins only),
63
+ * - no network access,
64
+ * - no `Date` / `Math.random`,
65
+ * - the file list comes from `git ls-files`, so the gate sees exactly what a
66
+ * release would carry rather than whatever is loose in the working tree.
67
+ *
68
+ * Discovering zero stacks is exit 2, not a clean pass, and a `deletions.json`
69
+ * that will not parse is exit 2 rather than the runtime's silent empty set. A
70
+ * gate that passes because it could not look is the failure mode this file
71
+ * exists to prevent, and reproducing it here would be perverse.
72
+ *
73
+ * CLI:
74
+ * node scripts/check-delivery-deletion-conflicts.mjs [--root <dir>] [--json]
75
+ *
76
+ * Exit codes (mirroring the sibling parity scripts):
77
+ * 0 — no path is delivered and deleted by stacks active in the same apply.
78
+ * 1 — ≥1 SELF or ANCESTOR-DELETES conflict.
79
+ * 2 — operational/usage error: unknown flag, a flag missing its value,
80
+ * `--root` absent or not a git repository, git unavailable, zero stacks
81
+ * discovered, or an unparseable/ill-typed deletions manifest.
82
+ *
83
+ * @module scripts/check-delivery-deletion-conflicts
84
+ */
85
+ import { execFileSync } from "node:child_process";
86
+ import fs from "node:fs";
87
+ import path from "node:path";
88
+ import process from "node:process";
89
+ import { fileURLToPath } from "node:url";
90
+
91
+ import { invokedAsScript } from "./lib/invoked-as-script.mjs";
92
+
93
+ const REPO_ROOT = path.resolve(
94
+ path.dirname(fileURLToPath(import.meta.url)),
95
+ ".."
96
+ );
97
+
98
+ /**
99
+ * Parent of each stack directory. The implicit root `all` is not listed: it is
100
+ * the ancestor of everything and has no parent of its own.
101
+ *
102
+ * Mirrors `PROJECT_TYPE_HIERARCHY` in `src/core/config.ts`. The unit test
103
+ * asserts the two agree, so adding a stack there without adding it here fails
104
+ * a test rather than silently narrowing this gate's reach.
105
+ */
106
+ export const STACK_PARENT = Object.freeze({
107
+ cdk: "typescript",
108
+ expo: "typescript",
109
+ "harper-fabric": "typescript",
110
+ nestjs: "typescript",
111
+ "npm-package": "typescript",
112
+ phaser: "typescript",
113
+ rails: undefined,
114
+ typescript: undefined,
115
+ });
116
+
117
+ /** The implicit root stack, processed for every project regardless of type. */
118
+ export const ROOT_STACK = "all";
119
+
120
+ /**
121
+ * Delivery lanes, i.e. every copy strategy that writes a file at a destination
122
+ * path. Mirrors `COPY_STRATEGIES` in `src/core/config.ts`; the unit test
123
+ * asserts the two agree.
124
+ */
125
+ export const DELIVERY_LANES = Object.freeze([
126
+ "copy-contents",
127
+ "copy-overwrite",
128
+ "create-only",
129
+ "merge",
130
+ "package-lisa",
131
+ "tagged-merge",
132
+ ]);
133
+
134
+ /**
135
+ * `package-lisa` is the one lane whose source filename differs from its
136
+ * destination: `package.lisa.json` governs `package.json`.
137
+ */
138
+ const PACKAGE_LISA_SOURCE = "package.lisa.json";
139
+ const PACKAGE_LISA_DEST = "package.json";
140
+
141
+ /** Max bytes of `git ls-files` output (7k+ tracked paths is ~0.3 MB today). */
142
+ const MAX_GIT_OUTPUT_BYTES = 64 * 1024 * 1024;
143
+
144
+ /**
145
+ * Usage error — thrown for an invalid invocation or an unverifiable state so
146
+ * `main` can distinguish it (exit 2) from a finding (exit 1).
147
+ */
148
+ export class UsageError extends Error {}
149
+
150
+ /**
151
+ * The chain of stacks active alongside `stack` in one apply, most specific
152
+ * first, always ending at the implicit root.
153
+ *
154
+ * @param {string} stack - a stack directory name.
155
+ * @returns {string[]} `stack`, its ancestors, and `all`.
156
+ */
157
+ export function ancestryChain(stack) {
158
+ const chain = [stack];
159
+ let parent = STACK_PARENT[stack];
160
+ while (parent !== undefined) {
161
+ chain.push(parent);
162
+ parent = STACK_PARENT[parent];
163
+ }
164
+ if (stack !== ROOT_STACK) chain.push(ROOT_STACK);
165
+ return chain;
166
+ }
167
+
168
+ /**
169
+ * Classify one delivered/deleted pair by the ancestry relation between the
170
+ * stack that ships the path and the stack that deletes it.
171
+ *
172
+ * @param {string} shipper - stack whose delivery lane carries the path.
173
+ * @param {string} deleter - stack whose deletions.json removes the path.
174
+ * @returns {"self" | "ancestor-deletes" | "descendant-deletes" | "unrelated"}
175
+ * the relation; the first two are findings.
176
+ */
177
+ export function classifyRelation(shipper, deleter) {
178
+ if (shipper === deleter) return "self";
179
+ if (ancestryChain(shipper).includes(deleter)) return "ancestor-deletes";
180
+ if (ancestryChain(deleter).includes(shipper)) return "descendant-deletes";
181
+ return "unrelated";
182
+ }
183
+
184
+ /** The two relations that make a conflict a defect rather than a design. */
185
+ const FORBIDDEN_RELATIONS = Object.freeze(["ancestor-deletes", "self"]);
186
+
187
+ /**
188
+ * The destination path a lane source file governs. Identity for every lane
189
+ * except `package-lisa`, whose `package.lisa.json` governs `package.json`.
190
+ *
191
+ * @param {string} lane - the delivery lane directory name.
192
+ * @param {string} sourceRelative - path relative to the lane directory.
193
+ * @returns {string} the destination path in the consumer's tree.
194
+ */
195
+ export function destinationPath(lane, sourceRelative) {
196
+ return lane === "package-lisa" && sourceRelative === PACKAGE_LISA_SOURCE
197
+ ? PACKAGE_LISA_DEST
198
+ : sourceRelative;
199
+ }
200
+
201
+ /**
202
+ * The set of paths a deletions manifest actually removes: `paths` minus
203
+ * `keep`. Mirrors `pendingDeletionPaths` in `src/core/template-ownership.ts`,
204
+ * except that a manifest this gate cannot read is an error rather than an
205
+ * empty set — see the module remarks.
206
+ *
207
+ * @param {unknown} manifest - the parsed deletions.json value.
208
+ * @param {string} label - the manifest path, for error messages.
209
+ * @returns {Set<string>} paths that will be deleted.
210
+ */
211
+ export function effectiveDeletions(manifest, label) {
212
+ if (manifest === null || typeof manifest !== "object") {
213
+ throw new UsageError(`${label}: expected a JSON object`);
214
+ }
215
+ const paths = manifest.paths;
216
+ if (!Array.isArray(paths) || paths.some(entry => typeof entry !== "string")) {
217
+ throw new UsageError(`${label}: "paths" must be an array of strings`);
218
+ }
219
+ const keep = manifest.keep ?? [];
220
+ if (!Array.isArray(keep) || keep.some(entry => typeof entry !== "string")) {
221
+ throw new UsageError(`${label}: "keep" must be an array of strings`);
222
+ }
223
+ const kept = new Set(keep);
224
+ return new Set(paths.filter(entry => !kept.has(entry)));
225
+ }
226
+
227
+ /**
228
+ * How `deleted` covers `delivered`, if at all.
229
+ *
230
+ * @param {string} delivered - a destination path a lane writes.
231
+ * @param {ReadonlySet<string>} deleted - the effective deletion set.
232
+ * @returns {{ kind: "exact" } | { kind: "under-dir", entry: string } | null}
233
+ * the match, or null when the path survives.
234
+ */
235
+ export function matchDeletion(delivered, deleted) {
236
+ if (deleted.has(delivered)) return { kind: "exact" };
237
+ for (const entry of deleted) {
238
+ if (delivered.startsWith(`${entry}/`)) return { entry, kind: "under-dir" };
239
+ }
240
+ return null;
241
+ }
242
+
243
+ /**
244
+ * List every tracked file in `root`, relative to it. Throws `UsageError` when
245
+ * git is unavailable or `root` is not a repository.
246
+ *
247
+ * @param {string} root - the repository root.
248
+ * @returns {string[]} tracked paths, relative to `root`.
249
+ */
250
+ function listTrackedFiles(root) {
251
+ let stdout;
252
+ try {
253
+ stdout = execFileSync("git", ["-C", root, "ls-files", "-z"], {
254
+ encoding: "utf8",
255
+ maxBuffer: MAX_GIT_OUTPUT_BYTES,
256
+ stdio: ["ignore", "pipe", "ignore"],
257
+ });
258
+ } catch (error) {
259
+ throw new UsageError(
260
+ `could not list tracked files in ${root}: ${error.message}`
261
+ );
262
+ }
263
+ return stdout.split("\0").filter(entry => entry !== "");
264
+ }
265
+
266
+ /**
267
+ * Group tracked files into per-stack delivery maps and deletion manifests.
268
+ *
269
+ * A stack is any top-level directory carrying a delivery lane or a
270
+ * `deletions.json` — discovered, never listed, so a new stack directory
271
+ * inherits this gate with nobody remembering to register it.
272
+ *
273
+ * @param {readonly string[]} tracked - repo-relative tracked paths.
274
+ * @param {string} root - the repository root, for reading manifests.
275
+ * @returns {{ delivered: Map<string, Map<string, string>>, deletions: Map<string, Set<string>>, stacks: Set<string> }}
276
+ * delivery maps keyed stack → destination path → lane, effective deletion
277
+ * sets keyed by stack, and every discovered stack.
278
+ */
279
+ export function collectManifests(tracked, root) {
280
+ const delivered = new Map();
281
+ const deletions = new Map();
282
+ const stacks = new Set();
283
+ for (const file of tracked) {
284
+ const segments = file.split("/");
285
+ if (segments.length < 2) continue;
286
+ const [stack, second, ...rest] = segments;
287
+ if (second === "deletions.json" && rest.length === 0) {
288
+ const parsed = readManifest(path.join(root, file), file);
289
+ deletions.set(stack, effectiveDeletions(parsed, file));
290
+ stacks.add(stack);
291
+ } else if (DELIVERY_LANES.includes(second) && rest.length > 0) {
292
+ if (!delivered.has(stack)) delivered.set(stack, new Map());
293
+ delivered.get(stack).set(destinationPath(second, rest.join("/")), second);
294
+ stacks.add(stack);
295
+ }
296
+ }
297
+ return { deletions, delivered, stacks };
298
+ }
299
+
300
+ /**
301
+ * Read and parse one deletions manifest, turning any failure into a
302
+ * `UsageError` so the gate exits 2 instead of scanning an empty set.
303
+ *
304
+ * @param {string} absolute - absolute path to the manifest.
305
+ * @param {string} label - repo-relative path, for error messages.
306
+ * @returns {unknown} the parsed value.
307
+ */
308
+ function readManifest(absolute, label) {
309
+ let raw;
310
+ try {
311
+ raw = fs.readFileSync(absolute, "utf8");
312
+ } catch (error) {
313
+ throw new UsageError(`${label}: could not read — ${error.message}`);
314
+ }
315
+ try {
316
+ return JSON.parse(raw);
317
+ } catch (error) {
318
+ throw new UsageError(`${label}: could not parse — ${error.message}`);
319
+ }
320
+ }
321
+
322
+ /**
323
+ * Every delivered path covered by some stack's deletion manifest, classified.
324
+ *
325
+ * @param {{ delivered: Map<string, Map<string, string>>, deletions: Map<string, Set<string>> }} manifests
326
+ * the collected manifests.
327
+ * @returns {Array<Record<string, string>>} one row per conflict, sorted for a
328
+ * stable report.
329
+ */
330
+ export function findConflicts(manifests) {
331
+ const rows = [];
332
+ for (const [deleter, deleted] of manifests.deletions) {
333
+ for (const [shipper, lanes] of manifests.delivered) {
334
+ for (const [destination, lane] of lanes) {
335
+ const match = matchDeletion(destination, deleted);
336
+ if (match === null) continue;
337
+ rows.push({
338
+ deleter,
339
+ destination,
340
+ lane,
341
+ match: match.kind === "exact" ? "exact" : `under-dir(${match.entry})`,
342
+ relation: classifyRelation(shipper, deleter),
343
+ shipper,
344
+ });
345
+ }
346
+ }
347
+ }
348
+ return rows.sort((left, right) =>
349
+ `${left.shipper}/${left.lane}/${left.destination}/${left.deleter}`.localeCompare(
350
+ `${right.shipper}/${right.lane}/${right.destination}/${right.deleter}`
351
+ )
352
+ );
353
+ }
354
+
355
+ /**
356
+ * Assemble the machine-readable report.
357
+ *
358
+ * @param {ReadonlyArray<Record<string, string>>} rows - every classified conflict.
359
+ * @param {{ root: string, stacks: number, delivered: number }} opts - resolved
360
+ * options plus scan size.
361
+ * @returns {Record<string, unknown>} the report object.
362
+ */
363
+ export function buildReport(rows, opts) {
364
+ const violations = rows.filter(row =>
365
+ FORBIDDEN_RELATIONS.includes(row.relation)
366
+ );
367
+ return {
368
+ conflicts: rows,
369
+ root: opts.root,
370
+ schemaVersion: 1,
371
+ summary: {
372
+ allowed: rows.length - violations.length,
373
+ delivered: opts.delivered,
374
+ stacks: opts.stacks,
375
+ violations: violations.length,
376
+ },
377
+ violations,
378
+ };
379
+ }
380
+
381
+ /**
382
+ * Render the human-readable report.
383
+ *
384
+ * @param {Record<string, unknown>} report - the report object.
385
+ * @returns {string} the rendered report.
386
+ */
387
+ function humanReport(report) {
388
+ const { summary } = report;
389
+ if (summary.violations === 0) {
390
+ return [
391
+ `✓ ${summary.delivered} delivered path(s) across ${summary.stacks} stack(s):`,
392
+ ` no path is both shipped and deleted by stacks active in the same apply`,
393
+ ` (${summary.allowed} deliberate override(s) reported and allowed)`,
394
+ ].join("\n");
395
+ }
396
+ const lines = report.violations.map(
397
+ row =>
398
+ `✗ ${row.shipper}/${row.lane}/${row.destination}\n` +
399
+ ` deleted by ${row.deleter}/deletions.json [${row.match}] — ${row.relation}`
400
+ );
401
+ return [
402
+ ...lines,
403
+ "",
404
+ `${summary.violations} path(s) are both delivered and deleted in the same apply.`,
405
+ "Deletions run after every delivery lane and are unconditional, so each of",
406
+ "these is created and then destroyed in one run, with no error and no",
407
+ "warning — the operator sees a file that will not stay put.",
408
+ "",
409
+ "Fix by choosing one side: drop the file from the delivery lane, or drop",
410
+ "the entry from deletions.json (or move it into that manifest's `keep`).",
411
+ "",
412
+ "A child stack deleting a path its PARENT ships is the deliberate override",
413
+ "and is not reported here. What is reported is a stack contradicting itself,",
414
+ "or a less specific stack destroying what a more specific one installed.",
415
+ ].join("\n");
416
+ }
417
+
418
+ /**
419
+ * Parse argv into resolved options. Throws `UsageError` on a bad invocation.
420
+ *
421
+ * @param {readonly string[]} argv - arguments (without node/script prefix).
422
+ * @returns {{ root: string, json: boolean }} options.
423
+ */
424
+ export function parseArgs(argv) {
425
+ let root = null;
426
+ let json = false;
427
+ for (let i = 0; i < argv.length; i++) {
428
+ const arg = argv[i];
429
+ if (arg === "--json") {
430
+ json = true;
431
+ } else if (arg === "--root") {
432
+ const next = argv[i + 1];
433
+ if (next === undefined || next.startsWith("--")) {
434
+ throw new UsageError("--root requires a value");
435
+ }
436
+ root = next;
437
+ i += 1;
438
+ } else {
439
+ throw new UsageError(`unknown argument: ${arg}`);
440
+ }
441
+ }
442
+ return { json, root: path.resolve(root ?? REPO_ROOT) };
443
+ }
444
+
445
+ /**
446
+ * Run the gate. Returns the process exit code (does not call `exit`).
447
+ *
448
+ * @param {readonly string[]} argv - arguments (without node/script prefix).
449
+ * @param {{ stdout?: { write(s: string): void }, stderr?: { write(s: string): void } }} [io]
450
+ * injectable streams (defaults to process streams).
451
+ * @returns {number} exit code (0 clean, 1 finding, 2 usage/unverifiable).
452
+ */
453
+ export function main(argv, io = {}) {
454
+ const out = io.stdout ?? process.stdout;
455
+ const err = io.stderr ?? process.stderr;
456
+ let opts;
457
+ let manifests;
458
+ try {
459
+ opts = parseArgs(argv);
460
+ if (!fs.existsSync(opts.root) || !fs.statSync(opts.root).isDirectory()) {
461
+ throw new UsageError(`--root is not a directory: ${opts.root}`);
462
+ }
463
+ manifests = collectManifests(listTrackedFiles(opts.root), opts.root);
464
+ if (manifests.stacks.size === 0) {
465
+ // Finding nothing to check is a broken invocation, not conformance.
466
+ throw new UsageError(
467
+ `no stack directories found under ${opts.root} — expected paths like ` +
468
+ "typescript/create-only/... or all/deletions.json. Refusing to " +
469
+ "report a clean run for a scan that examined nothing."
470
+ );
471
+ }
472
+ } catch (error) {
473
+ err.write(`error: ${error.message}\n`);
474
+ return 2;
475
+ }
476
+
477
+ let delivered = 0;
478
+ for (const lanes of manifests.delivered.values()) delivered += lanes.size;
479
+ const report = buildReport(findConflicts(manifests), {
480
+ delivered,
481
+ root: opts.root,
482
+ stacks: manifests.stacks.size,
483
+ });
484
+ out.write(
485
+ `${opts.json ? JSON.stringify(report, null, 2) : humanReport(report)}\n`
486
+ );
487
+ return report.summary.violations === 0 ? 0 : 1;
488
+ }
489
+
490
+ if (invokedAsScript(import.meta.url)) {
491
+ // exitCode (not process.exit): when stdout is a pipe, writes are async and
492
+ // process.exit() truncates the report mid-flush.
493
+ process.exitCode = main(process.argv.slice(2));
494
+ }