@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.
- package/dist/core/upstream-evidence-manifest.d.ts.map +1 -1
- package/dist/core/upstream-evidence-manifest.js +3 -0
- package/dist/core/upstream-evidence-manifest.js.map +1 -1
- package/dist/strategies/package-lisa.js +58 -2
- package/dist/strategies/package-lisa.js.map +1 -1
- package/package.json +2 -1
- package/plugins/lisa/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-agy/plugin.json +1 -1
- package/plugins/lisa-cdk/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-cdk/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-cdk-agy/plugin.json +1 -1
- package/plugins/lisa-cdk-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-cdk-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-expo/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-expo/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-expo-agy/plugin.json +1 -1
- package/plugins/lisa-expo-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-expo-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric-agy/plugin.json +1 -1
- package/plugins/lisa-harper-fabric-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs-agy/plugin.json +1 -1
- package/plugins/lisa-nestjs-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw-agy/plugin.json +1 -1
- package/plugins/lisa-openclaw-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-phaser/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-phaser/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-phaser-agy/plugin.json +1 -1
- package/plugins/lisa-phaser-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-phaser-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-rails/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-rails/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-rails-agy/plugin.json +1 -1
- package/plugins/lisa-rails-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-rails-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript-agy/plugin.json +1 -1
- package/plugins/lisa-typescript-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki-agy/plugin.json +1 -1
- package/plugins/lisa-wiki-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki-cursor/.claude-plugin/plugin.json +1 -1
- package/scripts/check-delivery-deletion-conflicts.mjs +494 -0
|
@@ -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
|
+
}
|