@codyswann/lisa 3.64.4 → 3.65.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.
- package/dist/core/upstream-evidence-manifest.d.ts.map +1 -1
- package/dist/core/upstream-evidence-manifest.js +4 -0
- package/dist/core/upstream-evidence-manifest.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-workflow-package-paths.mjs +477 -0
|
@@ -0,0 +1,477 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Assert that every package path a workflow references exists in the RELEASED
|
|
4
|
+
* package, not merely in this source tree (issue #2960).
|
|
5
|
+
*
|
|
6
|
+
* Consumers reference the reusable workflow at `@main`, so a workflow edit
|
|
7
|
+
* reaches them immediately. Consumers also version-pin `@codyswann/lisa`, so
|
|
8
|
+
* the package on their disk is whatever release they last upgraded to. Every
|
|
9
|
+
* consumer therefore runs TODAY'S workflow against an OLDER package, and any
|
|
10
|
+
* workflow step resolving a path under `node_modules/@codyswann/lisa/` is
|
|
11
|
+
* making a claim about the layout of a *released* package.
|
|
12
|
+
*
|
|
13
|
+
* Nothing verified that claim. #2951 proved the cost: a prover moved between
|
|
14
|
+
* directories 72 minutes after a release, the workflow was updated to the new
|
|
15
|
+
* path in the same commit as the move — correct against the source tree,
|
|
16
|
+
* correct against the *next* release, wrong against every consumer already
|
|
17
|
+
* installed — and the gate then failed closed fleet-wide on clean trees.
|
|
18
|
+
*
|
|
19
|
+
* Lisa's own CI could not have caught it, and that is structural rather than an
|
|
20
|
+
* oversight: **Lisa is the one repository where the host-relative candidate
|
|
21
|
+
* resolves**, so its own run of that step found the prover in its checkout no
|
|
22
|
+
* matter what the package contained. Every test that asked "does this gate pass
|
|
23
|
+
* here?" answered yes throughout. Only a released tarball can answer the
|
|
24
|
+
* question this script asks, which is why it reads one.
|
|
25
|
+
*
|
|
26
|
+
* ## What counts as satisfied
|
|
27
|
+
*
|
|
28
|
+
* Paths are grouped **by workflow step**, and a step passes a release when AT
|
|
29
|
+
* LEAST ONE of its package paths exists there. That is the transition escape
|
|
30
|
+
* hatch, and it is deliberately the only one: a deliberate layout change is
|
|
31
|
+
* expressed by referencing both locations across the move, which is what #2951
|
|
32
|
+
* ended up doing by hand. There is no allowlist and no override flag — an
|
|
33
|
+
* override is a way to record that you know, and this gate wants you to record
|
|
34
|
+
* it in the workflow, where the next reader sees it.
|
|
35
|
+
*
|
|
36
|
+
* HOST-relative candidates in the same step (`scripts/foo.mjs`) do not count
|
|
37
|
+
* and are never extracted. A host-relative fallback is exactly what made #2951
|
|
38
|
+
* invisible here, so it cannot be what rescues a step from this check.
|
|
39
|
+
*
|
|
40
|
+
* ## What this does NOT prove
|
|
41
|
+
*
|
|
42
|
+
* Existence is necessary and NOT sufficient, and this script proves only
|
|
43
|
+
* existence. A file can keep its path and change its contract, or move and take
|
|
44
|
+
* its contract with it: greening the path in #2951 produced a prover that
|
|
45
|
+
* scanned zero tracked files and reported success, which was strictly worse
|
|
46
|
+
* than the red it replaced. Only the PATH is legible from a workflow, so a
|
|
47
|
+
* contract probe needs a declaration this gate does not have and does not
|
|
48
|
+
* invent. **Do not read a pass as "the released artifact still does its job."**
|
|
49
|
+
* Tracked separately rather than implied here.
|
|
50
|
+
*
|
|
51
|
+
* ## Refusing to pass on nothing
|
|
52
|
+
*
|
|
53
|
+
* Discovering zero workflows, or extracting zero package paths, is exit 2 and
|
|
54
|
+
* never a clean pass. So is a registry it could not reach. A gate that passes
|
|
55
|
+
* because it did nothing is the failure this file exists to prevent, and it
|
|
56
|
+
* would be perverse to reproduce it here.
|
|
57
|
+
*
|
|
58
|
+
* CLI:
|
|
59
|
+
* node scripts/check-workflow-package-paths.mjs [--root <dir>]
|
|
60
|
+
* [--releases <n>] [--listing <file>] [--json]
|
|
61
|
+
*
|
|
62
|
+
* With no flags it reads the floor declared in
|
|
63
|
+
* `.github/workflow-package-floor.json`, the latest release, and one midpoint
|
|
64
|
+
* between them. `--releases <n>` reads the most recent n instead, which is an
|
|
65
|
+
* ad-hoc sweep rather than the promise CI enforces.
|
|
66
|
+
*
|
|
67
|
+
* `--listing` supplies release file listings from a JSON file instead of the
|
|
68
|
+
* registry (`{ "<version>": ["package/a", ...] }`), so the unit suite can drive
|
|
69
|
+
* every branch offline. The networked path is what CI runs.
|
|
70
|
+
*
|
|
71
|
+
* Exit codes:
|
|
72
|
+
* 0 — every step's package paths resolve in every release in the window.
|
|
73
|
+
* 1 — a step has no resolvable package path in some release.
|
|
74
|
+
* 2 — operational: unknown flag, a flag missing its value, `--root` absent or
|
|
75
|
+
* not a git repository, git unavailable, zero workflows discovered, zero
|
|
76
|
+
* package paths extracted, or the registry could not be read.
|
|
77
|
+
*
|
|
78
|
+
* @module scripts/check-workflow-package-paths
|
|
79
|
+
*/
|
|
80
|
+
import { execFileSync } from "node:child_process";
|
|
81
|
+
import {
|
|
82
|
+
existsSync,
|
|
83
|
+
mkdirSync,
|
|
84
|
+
readdirSync,
|
|
85
|
+
readFileSync,
|
|
86
|
+
writeFileSync,
|
|
87
|
+
} from "node:fs";
|
|
88
|
+
import { mkdtempSync } from "node:fs";
|
|
89
|
+
import { tmpdir } from "node:os";
|
|
90
|
+
import path from "node:path";
|
|
91
|
+
|
|
92
|
+
import { invokedAsScript } from "./lib/invoked-as-script.mjs";
|
|
93
|
+
|
|
94
|
+
/** The package whose released layout a workflow may make claims about. */
|
|
95
|
+
const PACKAGE_NAME = "@codyswann/lisa";
|
|
96
|
+
|
|
97
|
+
/** How a workflow spells a path inside the installed package. */
|
|
98
|
+
const PACKAGE_PREFIX = `node_modules/${PACKAGE_NAME}/`;
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Where the compatibility floor is declared.
|
|
102
|
+
* @remarks
|
|
103
|
+
* A COUNT of recent releases answers nothing here. Measured 2026-08-23: this
|
|
104
|
+
* package published ~15 releases a day and 365 in thirty days, so "the last 3"
|
|
105
|
+
* spans about five hours. Downloading thirty days of tarballs is ~3.6 GB, so a
|
|
106
|
+
* true window is not affordable either. What is affordable, and what the
|
|
107
|
+
* question actually needs, is a DECLARED floor — the oldest release the
|
|
108
|
+
* workflows promise to keep working against. That is a decision, recorded where
|
|
109
|
+
* the next person can read and argue with it, rather than a number nobody
|
|
110
|
+
* chose.
|
|
111
|
+
*/
|
|
112
|
+
const FLOOR_CONFIG = path.join(".github", "workflow-package-floor.json");
|
|
113
|
+
|
|
114
|
+
/** Every path literal, file or directory, that names something in the package. */
|
|
115
|
+
const PATH_LITERAL = new RegExp(
|
|
116
|
+
`${PACKAGE_PREFIX.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}[A-Za-z0-9_@./-]*`,
|
|
117
|
+
"g"
|
|
118
|
+
);
|
|
119
|
+
|
|
120
|
+
/** A trailing segment that names a file rather than a directory or an ellipsis. */
|
|
121
|
+
const FILE_SEGMENT = /^[A-Za-z0-9_@-]+(?:\.[A-Za-z0-9]+)+$/;
|
|
122
|
+
|
|
123
|
+
/** Exit code for an operational failure — including "I could not look". */
|
|
124
|
+
const EXIT_OPERATIONAL = 2;
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Print usage guidance and exit.
|
|
128
|
+
* @param {string} message - Why the invocation was rejected
|
|
129
|
+
* @returns {never}
|
|
130
|
+
*/
|
|
131
|
+
function usage(message) {
|
|
132
|
+
process.stderr.write(
|
|
133
|
+
`${message}\n\nUsage: node scripts/check-workflow-package-paths.mjs [--root <dir>] [--releases <n>] [--listing <file>] [--json]\n`
|
|
134
|
+
);
|
|
135
|
+
process.exit(EXIT_OPERATIONAL);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Parse argv into options.
|
|
140
|
+
* @param {readonly string[]} argv - Arguments after the script name
|
|
141
|
+
* @returns {{root: string, releases: number, listing: string|null, json: boolean}}
|
|
142
|
+
*/
|
|
143
|
+
function parseArgs(argv) {
|
|
144
|
+
const options = {
|
|
145
|
+
root: process.cwd(),
|
|
146
|
+
releases: null,
|
|
147
|
+
listing: null,
|
|
148
|
+
json: false,
|
|
149
|
+
};
|
|
150
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
151
|
+
const flag = argv[index];
|
|
152
|
+
if (flag === "--json") {
|
|
153
|
+
options.json = true;
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
const value = argv[index + 1];
|
|
157
|
+
if (flag === "--root" || flag === "--releases" || flag === "--listing") {
|
|
158
|
+
if (value === undefined) usage(`${flag} needs a value`);
|
|
159
|
+
index += 1;
|
|
160
|
+
if (flag === "--root") options.root = path.resolve(value);
|
|
161
|
+
if (flag === "--listing") options.listing = path.resolve(value);
|
|
162
|
+
if (flag === "--releases") {
|
|
163
|
+
const parsed = Number.parseInt(value, 10);
|
|
164
|
+
if (!Number.isInteger(parsed) || parsed < 1) {
|
|
165
|
+
usage(`--releases needs a positive integer, got ${value}`);
|
|
166
|
+
}
|
|
167
|
+
options.releases = parsed;
|
|
168
|
+
}
|
|
169
|
+
continue;
|
|
170
|
+
}
|
|
171
|
+
usage(`Unknown flag: ${flag}`);
|
|
172
|
+
}
|
|
173
|
+
return options;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Tracked workflow files, so the scan sees exactly what a release would carry.
|
|
178
|
+
* @param {string} root - Repository root
|
|
179
|
+
* @returns {readonly string[]} Repo-relative workflow paths
|
|
180
|
+
*/
|
|
181
|
+
function trackedWorkflows(root) {
|
|
182
|
+
const output = execFileSync(
|
|
183
|
+
"git",
|
|
184
|
+
["-C", root, "ls-files", ".github/workflows"],
|
|
185
|
+
{ encoding: "utf8" }
|
|
186
|
+
);
|
|
187
|
+
return output
|
|
188
|
+
.split("\n")
|
|
189
|
+
.map(line => line.trim())
|
|
190
|
+
.filter(line => line.endsWith(".yml") || line.endsWith(".yaml"));
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Every package path literal in a chunk of workflow text.
|
|
195
|
+
* @remarks
|
|
196
|
+
* A candidate whose last segment is not a filename and which does not end in
|
|
197
|
+
* `/` is prose — `node_modules/@codyswann/lisa/...` appears in a comment
|
|
198
|
+
* explaining the hazard, and treating that as a claim about the package would
|
|
199
|
+
* make the gate's first act a false alarm.
|
|
200
|
+
* @param {string} text - Workflow source or a step's serialised body
|
|
201
|
+
* @returns {readonly string[]} Deduped package paths, order preserved
|
|
202
|
+
*/
|
|
203
|
+
export function extractPackagePaths(text) {
|
|
204
|
+
const found = (text.match(PATH_LITERAL) ?? [])
|
|
205
|
+
.map(literal => literal.slice(PACKAGE_PREFIX.length))
|
|
206
|
+
.map(rest => rest.replace(/["'\\]+$/, ""))
|
|
207
|
+
.filter(rest => rest.length > 0)
|
|
208
|
+
.filter(rest => {
|
|
209
|
+
if (rest.endsWith("/")) return true;
|
|
210
|
+
const last = rest.slice(rest.lastIndexOf("/") + 1);
|
|
211
|
+
return FILE_SEGMENT.test(last);
|
|
212
|
+
});
|
|
213
|
+
return [...new Set(found)];
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Split a workflow into step-sized chunks of text.
|
|
218
|
+
* @remarks
|
|
219
|
+
* Grouping is by step because that is the unit a transition is expressed in: a
|
|
220
|
+
* `for candidate in "<new>" "<old>"` loop lives inside one step, and the step is
|
|
221
|
+
* satisfied when either resolves. Deliberately textual rather than YAML-aware —
|
|
222
|
+
* a parse that silently dropped a step would under-report, and under-reporting
|
|
223
|
+
* is the failure mode this gate exists to remove. Anything the split fails to
|
|
224
|
+
* attribute is still caught by the whole-file sweep, which is reconciled
|
|
225
|
+
* against the per-step totals.
|
|
226
|
+
* @param {string} source - Workflow file contents
|
|
227
|
+
* @returns {readonly {label: string, text: string}[]} One entry per step
|
|
228
|
+
*/
|
|
229
|
+
export function splitIntoSteps(source) {
|
|
230
|
+
const lines = source.split("\n");
|
|
231
|
+
const starts = lines
|
|
232
|
+
.map((line, index) => ({ line, index }))
|
|
233
|
+
.filter(({ line }) => /^\s{4,}-\s+(name|uses|run|id):/.test(line))
|
|
234
|
+
.map(({ index }) => index);
|
|
235
|
+
if (starts.length === 0) return [{ label: "whole file", text: source }];
|
|
236
|
+
return starts.map((start, position) => {
|
|
237
|
+
const end = starts[position + 1] ?? lines.length;
|
|
238
|
+
const text = lines.slice(start, end).join("\n");
|
|
239
|
+
const named = /^\s*-\s+name:\s*(.+)$/.exec(lines[start]);
|
|
240
|
+
return {
|
|
241
|
+
label: (named?.[1] ?? `line ${start + 1}`)
|
|
242
|
+
.trim()
|
|
243
|
+
.replace(/^["']|["']$/g, ""),
|
|
244
|
+
text,
|
|
245
|
+
};
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Every step in every tracked workflow that references a package path.
|
|
251
|
+
* @param {string} root - Repository root
|
|
252
|
+
* @returns {{groups: readonly object[], workflowCount: number, pathCount: number, unattributed: readonly string[]}}
|
|
253
|
+
*/
|
|
254
|
+
export function collectClaims(root) {
|
|
255
|
+
const workflows = trackedWorkflows(root);
|
|
256
|
+
const groups = [];
|
|
257
|
+
const unattributed = [];
|
|
258
|
+
for (const workflow of workflows) {
|
|
259
|
+
const source = readFileSync(path.join(root, workflow), "utf8");
|
|
260
|
+
const wholeFile = new Set(extractPackagePaths(source));
|
|
261
|
+
if (wholeFile.size === 0) continue;
|
|
262
|
+
const attributed = new Set();
|
|
263
|
+
for (const step of splitIntoSteps(source)) {
|
|
264
|
+
const paths = extractPackagePaths(step.text);
|
|
265
|
+
if (paths.length === 0) continue;
|
|
266
|
+
for (const found of paths) attributed.add(found);
|
|
267
|
+
groups.push({ workflow, step: step.label, paths });
|
|
268
|
+
}
|
|
269
|
+
for (const found of wholeFile) {
|
|
270
|
+
if (!attributed.has(found)) unattributed.push(`${workflow}: ${found}`);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
const pathCount = new Set(groups.flatMap(group => group.paths)).size;
|
|
274
|
+
return {
|
|
275
|
+
groups,
|
|
276
|
+
workflowCount: workflows.length,
|
|
277
|
+
pathCount,
|
|
278
|
+
unattributed,
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* The published stable versions, oldest first.
|
|
284
|
+
* @returns {readonly string[]} Stable versions in publish order
|
|
285
|
+
*/
|
|
286
|
+
function publishedVersions() {
|
|
287
|
+
const versions = JSON.parse(
|
|
288
|
+
execFileSync("npm", ["view", PACKAGE_NAME, "versions", "--json"], {
|
|
289
|
+
encoding: "utf8",
|
|
290
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
291
|
+
maxBuffer: 16 * 1024 * 1024,
|
|
292
|
+
})
|
|
293
|
+
);
|
|
294
|
+
return versions.filter(version => !version.includes("-"));
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* Choose which releases to read.
|
|
299
|
+
* @remarks
|
|
300
|
+
* The floor and the latest release are the two ends of the promise, and the
|
|
301
|
+
* midpoint is one interior sample — cheap insurance against a path that
|
|
302
|
+
* vanished and came back inside the range, which the two ends alone would miss.
|
|
303
|
+
* Three tarballs, cached, rather than the hundreds a real time window would
|
|
304
|
+
* cost.
|
|
305
|
+
* @param {readonly string[]} versions - Stable versions in publish order
|
|
306
|
+
* @param {string} floor - The declared compatibility floor
|
|
307
|
+
* @param {number|null} recent - Override: read the most recent N instead
|
|
308
|
+
* @returns {readonly string[]} Versions to read
|
|
309
|
+
* @throws {Error} When the floor is not a published version
|
|
310
|
+
*/
|
|
311
|
+
function chooseReleases(versions, floor, recent) {
|
|
312
|
+
if (recent !== null) return versions.slice(-recent);
|
|
313
|
+
const start = versions.indexOf(floor);
|
|
314
|
+
if (start === -1) {
|
|
315
|
+
throw new Error(
|
|
316
|
+
`${FLOOR_CONFIG} declares floor ${floor}, which is not a published version of ${PACKAGE_NAME}`
|
|
317
|
+
);
|
|
318
|
+
}
|
|
319
|
+
const range = versions.slice(start);
|
|
320
|
+
const midpoint = range[Math.floor((range.length - 1) / 2)];
|
|
321
|
+
return [...new Set([range[0], midpoint, range[range.length - 1]])];
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* Read the declared compatibility floor.
|
|
326
|
+
* @param {string} root - Repository root
|
|
327
|
+
* @returns {string} The floor version
|
|
328
|
+
* @throws {Error} When the declaration is missing or malformed
|
|
329
|
+
*/
|
|
330
|
+
function readFloor(root) {
|
|
331
|
+
const file = path.join(root, FLOOR_CONFIG);
|
|
332
|
+
if (!existsSync(file)) {
|
|
333
|
+
throw new Error(
|
|
334
|
+
`${FLOOR_CONFIG} is missing. It declares the oldest release these workflows promise to keep working against; without it this check would be guessing, and a guessed floor is how a gate ends up measuring nothing.`
|
|
335
|
+
);
|
|
336
|
+
}
|
|
337
|
+
const declared = JSON.parse(readFileSync(file, "utf8"));
|
|
338
|
+
if (typeof declared.floor !== "string" || declared.floor.length === 0) {
|
|
339
|
+
throw new Error(`${FLOOR_CONFIG} has no string "floor"`);
|
|
340
|
+
}
|
|
341
|
+
return declared.floor;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* Read each chosen release's file listing, memoising downloads.
|
|
346
|
+
* @param {readonly string[]} chosen - Versions to read
|
|
347
|
+
* @param {string} cacheDir - Where downloaded listings are memoised
|
|
348
|
+
* @returns {Record<string, readonly string[]>} Version to tarball entry list
|
|
349
|
+
*/
|
|
350
|
+
function fetchReleaseListings(chosen, cacheDir) {
|
|
351
|
+
mkdirSync(cacheDir, { recursive: true });
|
|
352
|
+
const listings = {};
|
|
353
|
+
for (const version of chosen) {
|
|
354
|
+
const cached = path.join(cacheDir, `${version}.json`);
|
|
355
|
+
if (existsSync(cached)) {
|
|
356
|
+
listings[version] = JSON.parse(readFileSync(cached, "utf8"));
|
|
357
|
+
continue;
|
|
358
|
+
}
|
|
359
|
+
const staging = mkdtempSync(path.join(tmpdir(), "lisa-release-"));
|
|
360
|
+
execFileSync(
|
|
361
|
+
"npm",
|
|
362
|
+
["pack", `${PACKAGE_NAME}@${version}`, "--pack-destination", staging],
|
|
363
|
+
{ stdio: ["ignore", "pipe", "pipe"] }
|
|
364
|
+
);
|
|
365
|
+
const tarball = readdirSync(staging).find(entry => entry.endsWith(".tgz"));
|
|
366
|
+
if (tarball === undefined) {
|
|
367
|
+
throw new Error(`npm pack produced no tarball for ${version}`);
|
|
368
|
+
}
|
|
369
|
+
const entries = execFileSync("tar", ["-tzf", path.join(staging, tarball)], {
|
|
370
|
+
encoding: "utf8",
|
|
371
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
372
|
+
})
|
|
373
|
+
.split("\n")
|
|
374
|
+
.filter(Boolean);
|
|
375
|
+
listings[version] = entries;
|
|
376
|
+
writeFileSync(cached, JSON.stringify(entries));
|
|
377
|
+
}
|
|
378
|
+
return listings;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* Does a release contain this package path?
|
|
383
|
+
* @param {readonly string[]} entries - Tarball entries, all `package/`-prefixed
|
|
384
|
+
* @param {string} candidate - Package-relative path, `/`-suffixed for a directory
|
|
385
|
+
* @returns {boolean} True when the release carries it
|
|
386
|
+
*/
|
|
387
|
+
export function releaseContains(entries, candidate) {
|
|
388
|
+
const target = `package/${candidate}`;
|
|
389
|
+
return candidate.endsWith("/")
|
|
390
|
+
? entries.some(entry => entry.startsWith(target) && entry !== target)
|
|
391
|
+
: entries.some(entry => entry === target || entry === `${target}/`);
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/**
|
|
395
|
+
* Judge every step against every release in the window.
|
|
396
|
+
* @param {readonly object[]} groups - Step claims from {@link collectClaims}
|
|
397
|
+
* @param {Record<string, readonly string[]>} listings - Version to entry list
|
|
398
|
+
* @returns {readonly string[]} One operator-readable line per failure
|
|
399
|
+
*/
|
|
400
|
+
export function findBreakages(groups, listings) {
|
|
401
|
+
return Object.entries(listings).flatMap(([version, entries]) =>
|
|
402
|
+
groups
|
|
403
|
+
.filter(group => !group.paths.some(p => releaseContains(entries, p)))
|
|
404
|
+
.map(
|
|
405
|
+
group =>
|
|
406
|
+
`${version}: ${group.workflow} step "${group.step}" resolves ${group.paths
|
|
407
|
+
.map(p => `${PACKAGE_PREFIX}${p}`)
|
|
408
|
+
.join(
|
|
409
|
+
" or "
|
|
410
|
+
)}, and release ${version} contains none of them. Merging this breaks every consumer still on ${version}.`
|
|
411
|
+
)
|
|
412
|
+
);
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/**
|
|
416
|
+
* Run the check.
|
|
417
|
+
* @returns {void}
|
|
418
|
+
*/
|
|
419
|
+
function main() {
|
|
420
|
+
const options = parseArgs(process.argv.slice(2));
|
|
421
|
+
if (!existsSync(path.join(options.root, ".git"))) {
|
|
422
|
+
usage(`--root is not a git repository: ${options.root}`);
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
const claims = collectClaims(options.root);
|
|
426
|
+
if (claims.workflowCount === 0) {
|
|
427
|
+
usage("No tracked workflows found — a scan of nothing is not a pass");
|
|
428
|
+
}
|
|
429
|
+
if (claims.pathCount === 0) {
|
|
430
|
+
usage(
|
|
431
|
+
`No ${PACKAGE_PREFIX} paths found in ${claims.workflowCount} workflows — a scan that examined nothing is not a pass`
|
|
432
|
+
);
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
let listings;
|
|
436
|
+
try {
|
|
437
|
+
listings = options.listing
|
|
438
|
+
? JSON.parse(readFileSync(options.listing, "utf8"))
|
|
439
|
+
: fetchReleaseListings(
|
|
440
|
+
chooseReleases(
|
|
441
|
+
publishedVersions(),
|
|
442
|
+
readFloor(options.root),
|
|
443
|
+
options.releases
|
|
444
|
+
),
|
|
445
|
+
path.join(options.root, "node_modules", ".cache", "lisa-releases")
|
|
446
|
+
);
|
|
447
|
+
} catch (error) {
|
|
448
|
+
usage(
|
|
449
|
+
`Could not read released package layouts: ${error.message}. Refusing to report a clean scan that never ran.`
|
|
450
|
+
);
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
const breakages = findBreakages(claims.groups, listings);
|
|
454
|
+
const report = {
|
|
455
|
+
workflowsExamined: claims.workflowCount,
|
|
456
|
+
packagePathsExamined: claims.pathCount,
|
|
457
|
+
stepsExamined: claims.groups.length,
|
|
458
|
+
releasesExamined: Object.keys(listings),
|
|
459
|
+
unattributed: claims.unattributed,
|
|
460
|
+
breakages,
|
|
461
|
+
};
|
|
462
|
+
|
|
463
|
+
if (options.json) {
|
|
464
|
+
process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
|
|
465
|
+
} else {
|
|
466
|
+
process.stdout.write(
|
|
467
|
+
`Examined ${report.packagePathsExamined} package path(s) across ${report.stepsExamined} step(s) in ${report.workflowsExamined} workflow(s), against release(s) ${report.releasesExamined.join(", ")}.\n`
|
|
468
|
+
);
|
|
469
|
+
for (const line of breakages) process.stdout.write(` ✗ ${line}\n`);
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
process.exit(breakages.length === 0 ? 0 : 1);
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
if (invokedAsScript(import.meta.url)) {
|
|
476
|
+
main();
|
|
477
|
+
}
|