@codyswann/lisa 3.64.4 → 3.65.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/dist/core/upstream-evidence-manifest.d.ts.map +1 -1
  2. package/dist/core/upstream-evidence-manifest.js +4 -0
  3. package/dist/core/upstream-evidence-manifest.js.map +1 -1
  4. package/package.json +2 -1
  5. package/plugins/lisa/.claude-plugin/plugin.json +1 -1
  6. package/plugins/lisa/.codex-plugin/plugin.json +1 -1
  7. package/plugins/lisa-agy/plugin.json +1 -1
  8. package/plugins/lisa-cdk/.claude-plugin/plugin.json +1 -1
  9. package/plugins/lisa-cdk/.codex-plugin/plugin.json +1 -1
  10. package/plugins/lisa-cdk-agy/plugin.json +1 -1
  11. package/plugins/lisa-cdk-copilot/.claude-plugin/plugin.json +1 -1
  12. package/plugins/lisa-cdk-cursor/.claude-plugin/plugin.json +1 -1
  13. package/plugins/lisa-copilot/.claude-plugin/plugin.json +1 -1
  14. package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
  15. package/plugins/lisa-expo/.claude-plugin/plugin.json +1 -1
  16. package/plugins/lisa-expo/.codex-plugin/plugin.json +1 -1
  17. package/plugins/lisa-expo-agy/plugin.json +1 -1
  18. package/plugins/lisa-expo-copilot/.claude-plugin/plugin.json +1 -1
  19. package/plugins/lisa-expo-cursor/.claude-plugin/plugin.json +1 -1
  20. package/plugins/lisa-harper-fabric/.claude-plugin/plugin.json +1 -1
  21. package/plugins/lisa-harper-fabric/.codex-plugin/plugin.json +1 -1
  22. package/plugins/lisa-harper-fabric-agy/plugin.json +1 -1
  23. package/plugins/lisa-harper-fabric-copilot/.claude-plugin/plugin.json +1 -1
  24. package/plugins/lisa-harper-fabric-cursor/.claude-plugin/plugin.json +1 -1
  25. package/plugins/lisa-nestjs/.claude-plugin/plugin.json +1 -1
  26. package/plugins/lisa-nestjs/.codex-plugin/plugin.json +1 -1
  27. package/plugins/lisa-nestjs-agy/plugin.json +1 -1
  28. package/plugins/lisa-nestjs-copilot/.claude-plugin/plugin.json +1 -1
  29. package/plugins/lisa-nestjs-cursor/.claude-plugin/plugin.json +1 -1
  30. package/plugins/lisa-openclaw/.claude-plugin/plugin.json +1 -1
  31. package/plugins/lisa-openclaw/.codex-plugin/plugin.json +1 -1
  32. package/plugins/lisa-openclaw-agy/plugin.json +1 -1
  33. package/plugins/lisa-openclaw-copilot/.claude-plugin/plugin.json +1 -1
  34. package/plugins/lisa-openclaw-cursor/.claude-plugin/plugin.json +1 -1
  35. package/plugins/lisa-phaser/.claude-plugin/plugin.json +1 -1
  36. package/plugins/lisa-phaser/.codex-plugin/plugin.json +1 -1
  37. package/plugins/lisa-phaser-agy/plugin.json +1 -1
  38. package/plugins/lisa-phaser-copilot/.claude-plugin/plugin.json +1 -1
  39. package/plugins/lisa-phaser-cursor/.claude-plugin/plugin.json +1 -1
  40. package/plugins/lisa-rails/.claude-plugin/plugin.json +1 -1
  41. package/plugins/lisa-rails/.codex-plugin/plugin.json +1 -1
  42. package/plugins/lisa-rails-agy/plugin.json +1 -1
  43. package/plugins/lisa-rails-copilot/.claude-plugin/plugin.json +1 -1
  44. package/plugins/lisa-rails-cursor/.claude-plugin/plugin.json +1 -1
  45. package/plugins/lisa-typescript/.claude-plugin/plugin.json +1 -1
  46. package/plugins/lisa-typescript/.codex-plugin/plugin.json +1 -1
  47. package/plugins/lisa-typescript-agy/plugin.json +1 -1
  48. package/plugins/lisa-typescript-copilot/.claude-plugin/plugin.json +1 -1
  49. package/plugins/lisa-typescript-cursor/.claude-plugin/plugin.json +1 -1
  50. package/plugins/lisa-wiki/.claude-plugin/plugin.json +1 -1
  51. package/plugins/lisa-wiki/.codex-plugin/plugin.json +1 -1
  52. package/plugins/lisa-wiki-agy/plugin.json +1 -1
  53. package/plugins/lisa-wiki-copilot/.claude-plugin/plugin.json +1 -1
  54. package/plugins/lisa-wiki-cursor/.claude-plugin/plugin.json +1 -1
  55. 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
+ }