@codyswann/lisa 2.283.0 → 2.285.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 (70) hide show
  1. package/dist/core/upstream-evidence-manifest.d.ts.map +1 -1
  2. package/dist/core/upstream-evidence-manifest.js +34 -2
  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/.codex-plugin/skills/lisa-task-decomposition/SKILL.md +26 -0
  8. package/plugins/lisa/rules/eager/dependency-trust-classes.md +43 -0
  9. package/plugins/lisa/rules/reference/dependency-trust-classes.md +243 -0
  10. package/plugins/lisa/skills/lisa-task-decomposition/SKILL.md +26 -0
  11. package/plugins/lisa-agy/plugin.json +1 -1
  12. package/plugins/lisa-agy/skills/lisa-task-decomposition/SKILL.md +26 -0
  13. package/plugins/lisa-cdk/.claude-plugin/plugin.json +1 -1
  14. package/plugins/lisa-cdk/.codex-plugin/plugin.json +1 -1
  15. package/plugins/lisa-cdk-agy/plugin.json +1 -1
  16. package/plugins/lisa-cdk-copilot/.claude-plugin/plugin.json +1 -1
  17. package/plugins/lisa-cdk-cursor/.claude-plugin/plugin.json +1 -1
  18. package/plugins/lisa-copilot/.claude-plugin/plugin.json +1 -1
  19. package/plugins/lisa-copilot/rules/eager/dependency-trust-classes.md +43 -0
  20. package/plugins/lisa-copilot/rules/reference/dependency-trust-classes.md +243 -0
  21. package/plugins/lisa-copilot/skills/lisa-task-decomposition/SKILL.md +26 -0
  22. package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
  23. package/plugins/lisa-cursor/rules/dependency-trust-classes-reference.mdc +248 -0
  24. package/plugins/lisa-cursor/rules/dependency-trust-classes.mdc +48 -0
  25. package/plugins/lisa-cursor/skills/lisa-task-decomposition/SKILL.md +26 -0
  26. package/plugins/lisa-expo/.claude-plugin/plugin.json +1 -1
  27. package/plugins/lisa-expo/.codex-plugin/plugin.json +1 -1
  28. package/plugins/lisa-expo-agy/plugin.json +1 -1
  29. package/plugins/lisa-expo-copilot/.claude-plugin/plugin.json +1 -1
  30. package/plugins/lisa-expo-cursor/.claude-plugin/plugin.json +1 -1
  31. package/plugins/lisa-harper-fabric/.claude-plugin/plugin.json +1 -1
  32. package/plugins/lisa-harper-fabric/.codex-plugin/plugin.json +1 -1
  33. package/plugins/lisa-harper-fabric-agy/plugin.json +1 -1
  34. package/plugins/lisa-harper-fabric-copilot/.claude-plugin/plugin.json +1 -1
  35. package/plugins/lisa-harper-fabric-cursor/.claude-plugin/plugin.json +1 -1
  36. package/plugins/lisa-nestjs/.claude-plugin/plugin.json +1 -1
  37. package/plugins/lisa-nestjs/.codex-plugin/plugin.json +1 -1
  38. package/plugins/lisa-nestjs-agy/plugin.json +1 -1
  39. package/plugins/lisa-nestjs-copilot/.claude-plugin/plugin.json +1 -1
  40. package/plugins/lisa-nestjs-cursor/.claude-plugin/plugin.json +1 -1
  41. package/plugins/lisa-openclaw/.claude-plugin/plugin.json +1 -1
  42. package/plugins/lisa-openclaw/.codex-plugin/plugin.json +1 -1
  43. package/plugins/lisa-openclaw-agy/plugin.json +1 -1
  44. package/plugins/lisa-openclaw-copilot/.claude-plugin/plugin.json +1 -1
  45. package/plugins/lisa-openclaw-cursor/.claude-plugin/plugin.json +1 -1
  46. package/plugins/lisa-phaser/.claude-plugin/plugin.json +1 -1
  47. package/plugins/lisa-phaser/.codex-plugin/plugin.json +1 -1
  48. package/plugins/lisa-phaser-agy/plugin.json +1 -1
  49. package/plugins/lisa-phaser-copilot/.claude-plugin/plugin.json +1 -1
  50. package/plugins/lisa-phaser-cursor/.claude-plugin/plugin.json +1 -1
  51. package/plugins/lisa-rails/.claude-plugin/plugin.json +1 -1
  52. package/plugins/lisa-rails/.codex-plugin/plugin.json +1 -1
  53. package/plugins/lisa-rails-agy/plugin.json +1 -1
  54. package/plugins/lisa-rails-copilot/.claude-plugin/plugin.json +1 -1
  55. package/plugins/lisa-rails-cursor/.claude-plugin/plugin.json +1 -1
  56. package/plugins/lisa-typescript/.claude-plugin/plugin.json +1 -1
  57. package/plugins/lisa-typescript/.codex-plugin/plugin.json +1 -1
  58. package/plugins/lisa-typescript-agy/plugin.json +1 -1
  59. package/plugins/lisa-typescript-copilot/.claude-plugin/plugin.json +1 -1
  60. package/plugins/lisa-typescript-cursor/.claude-plugin/plugin.json +1 -1
  61. package/plugins/lisa-wiki/.claude-plugin/plugin.json +1 -1
  62. package/plugins/lisa-wiki/.codex-plugin/plugin.json +1 -1
  63. package/plugins/lisa-wiki-agy/plugin.json +1 -1
  64. package/plugins/lisa-wiki-copilot/.claude-plugin/plugin.json +1 -1
  65. package/plugins/lisa-wiki-cursor/.claude-plugin/plugin.json +1 -1
  66. package/plugins/src/base/rules/eager/dependency-trust-classes.md +43 -0
  67. package/plugins/src/base/rules/reference/dependency-trust-classes.md +243 -0
  68. package/plugins/src/base/skills/lisa-task-decomposition/SKILL.md +26 -0
  69. package/scripts/check-duplicate-versions.mjs +715 -0
  70. package/ui/index.html +195 -43
@@ -0,0 +1,715 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Manifest-authoritative duplicate-version check (issue #1888).
4
+ *
5
+ * Context: Lisa has seen fleet failures where a version pin drifts between the
6
+ * canonical dependency manifest (`package.json` / `package.lisa.json`) and a
7
+ * copy of that same literal living in a workflow, a governed script, or a
8
+ * template. `scripts/update-node-version.ts` exists only because the Node
9
+ * version is duplicated across a dozen workflow files: bumping the manifest is
10
+ * not enough, so a bulk rewriter hand-syncs the copies. Every such copy is a
11
+ * second edit site that a routine bump can silently miss.
12
+ *
13
+ * This detector makes the manifest authoritative. It parses the canonical
14
+ * manifests, then scans governed inputs (workflows, scripts, templates,
15
+ * fixtures) for version literals that are pinned for a package or engine the
16
+ * manifest already pins — those places should PARSE the manifest instead of
17
+ * copying the pin.
18
+ *
19
+ * ## Bounded false positives (explicit acceptance criterion)
20
+ *
21
+ * A false "duplicate" erodes the check, so detection is deliberately
22
+ * conservative and under-reports rather than over-fires:
23
+ *
24
+ * - Non-policy surfaces are never scanned: lockfiles (`bun.lock`,
25
+ * `package-lock.json`, `yarn.lock`, `pnpm-lock.yaml`), prose/markdown,
26
+ * the `.lisa/` ledgers, `node_modules`, generated output (`dist/`,
27
+ * `coverage/`), and the upstream-evidence manifest (which stores versions
28
+ * and hashes by design).
29
+ * - Manifests themselves are never findings — they are the source of truth.
30
+ * - Only two provably-actionable shapes are flagged:
31
+ * 1. an install pin (`npm i -g pkg@1.2.3`, `bunx pkg@1.2.3`, ...) whose
32
+ * package name the manifest also pins, and
33
+ * 2. a toolchain pin (`node-version:`/`bun-version:` and their
34
+ * underscore spellings) whose tool the manifest pins under `engines`.
35
+ * Both require a FULL `x.y.z` literal; loose ranges (`22.x`, `lts/*`) are
36
+ * not literal duplicates and are skipped.
37
+ * - A package name absent from the manifest is never flagged: an unmanaged
38
+ * tool version is not manifest drift.
39
+ * - Comment lines are prose, not active pins.
40
+ * - A SELF-reference — an install literal for the canonical manifest's own
41
+ * `name` — is never flagged. That literal is a published-artifact version
42
+ * FLOOR ("the released CLI must be at least X for this gate to mean
43
+ * anything"), a different knob from the dependency range the project
44
+ * dogfoods, so "read it from the manifest" would DOWNGRADE the gate.
45
+ *
46
+ * Findings are reported whether the literal AGREES with the manifest
47
+ * (`duplicate`) or has already diverged (`drifted`). Identity, not equality,
48
+ * is what makes it a duplicate — that is exactly why changing a manifest pin
49
+ * keeps the copy reported instead of hiding it.
50
+ *
51
+ * ## Documented exceptions
52
+ *
53
+ * An intentional duplicate (e.g. mid-migration) is recorded honestly with an
54
+ * inline marker on the offending line or the line above it:
55
+ *
56
+ * # lisa-allow-duplicate-version: pinned during CI migration (#1888)
57
+ * run: npm i -g @ast-grep/cli@0.40.4
58
+ *
59
+ * The marker requires BOTH a non-empty reason and a ticket reference (`#1888`,
60
+ * `LISA-42`, or a URL) so the exception is tracked and auditable rather than a
61
+ * silent mute; a ticketless marker is rejected and the duplicate is reported.
62
+ * Every allowed exception is printed in the report, not merely counted.
63
+ *
64
+ * ## Remediation rule
65
+ *
66
+ * Update the manifest + lockfile ONLY, and make the governed input read the
67
+ * value from the manifest. If the duplicate must exist during a migration,
68
+ * add the inline marker above with a tracked ticket.
69
+ *
70
+ * ## Rollout mode
71
+ *
72
+ * Default is ADVISORY (report, exit 0) because Lisa itself is not yet clean.
73
+ * `--strict` turns findings into a non-zero exit and is what a cleaned-up
74
+ * surface (or a fixture-scoped test) uses.
75
+ *
76
+ * CLI:
77
+ * node scripts/check-duplicate-versions.mjs [--root <dir>] [--scan <dir>]...
78
+ * [--strict] [--json] [--help]
79
+ *
80
+ * Exit codes:
81
+ * 0 — advisory mode (always), or strict mode with no findings.
82
+ * 1 — strict mode with at least one unallowed finding.
83
+ * 2 — operational/usage error: unknown flag, flag missing its value, a
84
+ * `--root` that isn't a directory, an explicitly passed `--scan` path
85
+ * that is missing or isn't a directory, or no manifest found. An absent
86
+ * BUILT-IN default scan root (a repo with no `rails/`) is not an error;
87
+ * it is skipped and named in the report.
88
+ *
89
+ * @module scripts/check-duplicate-versions
90
+ */
91
+ import fs from "node:fs";
92
+ import path from "node:path";
93
+ import process from "node:process";
94
+ import { fileURLToPath } from "node:url";
95
+
96
+ const REPO_ROOT = path.resolve(
97
+ path.dirname(fileURLToPath(import.meta.url)),
98
+ ".."
99
+ );
100
+
101
+ /** Schema version of the emitted JSON report. */
102
+ export const SCHEMA_VERSION = 1;
103
+
104
+ /** Manifest filenames parsed as canonical sources of truth. */
105
+ export const MANIFEST_FILES = ["package.json", "package.lisa.json"];
106
+
107
+ /** Directories scanned when no `--scan` is supplied (relative to root). */
108
+ export const DEFAULT_SCAN_DIRS = [
109
+ ".github/workflows",
110
+ "scripts",
111
+ "all",
112
+ "typescript",
113
+ "rails",
114
+ "expo",
115
+ "cdk",
116
+ "nestjs",
117
+ ];
118
+
119
+ /** Directory names never scanned — generated, vendored, or non-policy. */
120
+ export const SKIPPED_DIRECTORIES = new Set([
121
+ ".git",
122
+ ".lisa",
123
+ "node_modules",
124
+ "dist",
125
+ "build",
126
+ "coverage",
127
+ "docs",
128
+ "wiki",
129
+ "transcripts",
130
+ "evidence",
131
+ ]);
132
+
133
+ /** File basenames never scanned — lockfiles and generated manifests. */
134
+ export const SKIPPED_FILES = new Set([
135
+ "bun.lock",
136
+ "bun.lockb",
137
+ "package-lock.json",
138
+ "yarn.lock",
139
+ "pnpm-lock.yaml",
140
+ "upstream-evidence-manifest.json",
141
+ ]);
142
+
143
+ /** Extensions of governed inputs. Prose (`.md`) and data are excluded. */
144
+ export const GOVERNED_EXTENSIONS = new Set([
145
+ ".yml",
146
+ ".yaml",
147
+ ".sh",
148
+ ".bash",
149
+ ".mjs",
150
+ ".cjs",
151
+ ".js",
152
+ ".ts",
153
+ ".mts",
154
+ ]);
155
+
156
+ /** Inline marker that records an intentional, documented duplicate. */
157
+ export const EXCEPTION_MARKER = "lisa-allow-duplicate-version:";
158
+
159
+ /**
160
+ * A tracked ticket an exception must cite: a GitHub-style issue (`#1888`), a
161
+ * tracker key (`LISA-42`), or a URL. Without one the "exception" is untracked
162
+ * and would never be cleaned up.
163
+ */
164
+ const TICKET_REFERENCE_PATTERN = /#\d+|\b[A-Z][A-Z\d]+-\d+\b|https?:\/\/\S+/u;
165
+
166
+ /** Package-manager tokens that make a `name@version` literal an install pin. */
167
+ const INSTALL_COMMAND_PATTERN =
168
+ /\b(?:npm|npx|bun|bunx|pnpm|pnpx|yarn|corepack)\b/u;
169
+
170
+ /** `name@x.y.z` occurrences, including scoped names. */
171
+ const NAME_AT_VERSION_PATTERN =
172
+ /(@[a-z0-9][\w.-]*\/[\w.-]+|[a-z0-9][\w.-]*)@(\d+\.\d+\.\d+[\w.+-]*)/giu;
173
+
174
+ /** `node-version: '22.21.1'` / `bun_version: "1.3.8"` toolchain pins. */
175
+ const TOOLCHAIN_PIN_PATTERN =
176
+ /\b(node|bun)[-_]version\s*:\s*['"]?(\d+\.\d+\.\d+[\w.+-]*)['"]?/giu;
177
+
178
+ /** Leading tokens that make a line a comment rather than an active pin. */
179
+ const COMMENT_PREFIXES = ["#", "//", "*", "/*", "<!--"];
180
+
181
+ /** Manifest sections holding package pins, in both plain and Lisa shapes. */
182
+ const DEPENDENCY_SECTIONS = [
183
+ "dependencies",
184
+ "devDependencies",
185
+ "peerDependencies",
186
+ "optionalDependencies",
187
+ ];
188
+
189
+ /** Lisa `package.lisa.json` governance wrappers around real manifest keys. */
190
+ const GOVERNANCE_SECTIONS = ["force", "defaults", "merge"];
191
+
192
+ /**
193
+ * Usage error — thrown by `parseArgs` (and manifest resolution) so `main` can
194
+ * distinguish an invalid invocation (exit 2) from a drift result (exit 1).
195
+ */
196
+ export class UsageError extends Error {}
197
+
198
+ /**
199
+ * Strip a semver range operator so a manifest pin can be compared to a bare
200
+ * literal. `^0.40.4` and `0.40.4` are the same pin for duplication purposes.
201
+ *
202
+ * @param {string} value - raw manifest version value.
203
+ * @returns {string} the value without a leading range operator.
204
+ */
205
+ export function normalizeVersion(value) {
206
+ return String(value)
207
+ .replace(/^[\^~>=<\s]+/u, "")
208
+ .trim();
209
+ }
210
+
211
+ /**
212
+ * Collect package and engine pins from one parsed manifest object, handling
213
+ * both a plain `package.json` and Lisa's `package.lisa.json`, whose real keys
214
+ * are nested under `force`/`defaults`/`merge`.
215
+ *
216
+ * @param {Record<string, unknown>} manifest - parsed manifest JSON.
217
+ * @param {string} label - manifest filename used in remediation text.
218
+ * @returns {{ packages: Record<string, {version: string, field: string}>, engines: Record<string, {version: string, field: string}> }}
219
+ * the pins this manifest declares.
220
+ */
221
+ export function collectManifestPins(manifest, label) {
222
+ const packages = {};
223
+ const engines = {};
224
+ const scopes = [{ node: manifest, prefix: "" }];
225
+ for (const section of GOVERNANCE_SECTIONS) {
226
+ const node = manifest?.[section];
227
+ if (node && typeof node === "object") {
228
+ scopes.push({ node, prefix: `${section}.` });
229
+ }
230
+ }
231
+ for (const { node, prefix } of scopes) {
232
+ for (const section of DEPENDENCY_SECTIONS) {
233
+ const entries = node[section];
234
+ if (!entries || typeof entries !== "object") continue;
235
+ for (const [name, version] of Object.entries(entries)) {
236
+ if (typeof version !== "string" || packages[name]) continue;
237
+ // A project referencing its OWN published artifact is stating a
238
+ // minimum version FLOOR ("the released CLI must be at least X for this
239
+ // gate to mean anything"), which is a different knob from the
240
+ // dependency range it dogfoods. Telling an operator to read the floor
241
+ // from the dependency range would DOWNGRADE the gate, so a
242
+ // self-reference is never a governed pin.
243
+ if (name === manifest?.name) continue;
244
+ packages[name] = {
245
+ version: normalizeVersion(version),
246
+ field: `${label} ${prefix}${section}.${name}`,
247
+ };
248
+ }
249
+ }
250
+ const engineEntries = node.engines;
251
+ if (!engineEntries || typeof engineEntries !== "object") continue;
252
+ for (const [tool, version] of Object.entries(engineEntries)) {
253
+ if (typeof version !== "string" || engines[tool]) continue;
254
+ if (!/^\d+\.\d+\.\d+/u.test(version)) continue;
255
+ engines[tool] = {
256
+ version: normalizeVersion(version),
257
+ field: `${label} ${prefix}engines.${tool}`,
258
+ };
259
+ }
260
+ }
261
+ return { packages, engines };
262
+ }
263
+
264
+ /**
265
+ * Merge the pins of every manifest present at `root`. Earlier manifests win so
266
+ * `package.json` (the resolved truth) takes precedence over the template.
267
+ *
268
+ * @param {string} root - directory holding the canonical manifests.
269
+ * @returns {{ packages: Record<string, {version: string, field: string}>, engines: Record<string, {version: string, field: string}>, sources: string[] }}
270
+ * merged pins plus the manifest filenames that contributed.
271
+ * @throws {UsageError} when no manifest exists at `root`.
272
+ */
273
+ export function loadManifestPins(root) {
274
+ const packages = {};
275
+ const engines = {};
276
+ const sources = [];
277
+ const selfNames = new Set();
278
+ for (const file of MANIFEST_FILES) {
279
+ const full = path.join(root, file);
280
+ if (!fs.existsSync(full)) continue;
281
+ const parsed = JSON.parse(fs.readFileSync(full, "utf8"));
282
+ if (typeof parsed?.name === "string") selfNames.add(parsed.name);
283
+ const pins = collectManifestPins(parsed, file);
284
+ Object.assign(packages, { ...pins.packages, ...packages });
285
+ Object.assign(engines, { ...pins.engines, ...engines });
286
+ sources.push(file);
287
+ }
288
+ // A name declared by ANY canonical manifest is a self-reference, even when a
289
+ // sibling manifest is what pins it as a dependency.
290
+ for (const name of selfNames) delete packages[name];
291
+ if (sources.length === 0) {
292
+ throw new UsageError(
293
+ `no canonical manifest (${MANIFEST_FILES.join(" or ")}) found in ${root}`
294
+ );
295
+ }
296
+ return { packages, engines, sources };
297
+ }
298
+
299
+ /**
300
+ * True iff a file is a governed input worth scanning. Lockfiles, prose, and
301
+ * generated or vendored trees are non-policy surfaces and never scanned —
302
+ * this is the bounded-false-positive guarantee.
303
+ *
304
+ * @param {string} relativePath - path relative to the scan root.
305
+ * @returns {boolean} whether the file should be scanned.
306
+ */
307
+ export function isGovernedFile(relativePath) {
308
+ const segments = relativePath.split(path.sep);
309
+ if (segments.some(segment => SKIPPED_DIRECTORIES.has(segment))) return false;
310
+ const base = segments.at(-1) ?? "";
311
+ if (SKIPPED_FILES.has(base)) return false;
312
+ if (MANIFEST_FILES.includes(base)) return false;
313
+ return GOVERNED_EXTENSIONS.has(path.extname(base));
314
+ }
315
+
316
+ /**
317
+ * True iff a line is a comment. A version literal inside a comment — a usage
318
+ * example in a script's docblock, a commented-out workflow step — is prose,
319
+ * not an active policy pin, so it is never a duplicate. Part of the
320
+ * bounded-false-positive guarantee.
321
+ *
322
+ * @param {string} line - the raw line.
323
+ * @returns {boolean} whether the line is a comment.
324
+ */
325
+ export function isCommentLine(line) {
326
+ const trimmed = line.trimStart();
327
+ return COMMENT_PREFIXES.some(prefix => trimmed.startsWith(prefix));
328
+ }
329
+
330
+ /**
331
+ * Whether a finding on `lineIndex` carries a documented inline exception —
332
+ * on its own line or the line directly above it, with a non-empty reason AND a
333
+ * ticket reference. The ticket is mandatory: an exception nobody tracks is a
334
+ * silent mute, not the honest record the marker is supposed to be.
335
+ *
336
+ * @param {string[]} lines - all lines of the file.
337
+ * @param {number} lineIndex - zero-based index of the finding's line.
338
+ * @returns {string | null} the recorded reason, or null when unmarked or
339
+ * missing a ticket reference.
340
+ */
341
+ export function findExceptionReason(lines, lineIndex) {
342
+ for (const index of [lineIndex, lineIndex - 1]) {
343
+ const line = lines[index];
344
+ if (typeof line !== "string" || !line.includes(EXCEPTION_MARKER)) continue;
345
+ const reason = line
346
+ .slice(line.indexOf(EXCEPTION_MARKER) + EXCEPTION_MARKER.length)
347
+ .trim();
348
+ if (reason.length > 0 && TICKET_REFERENCE_PATTERN.test(reason)) {
349
+ return reason;
350
+ }
351
+ }
352
+ return null;
353
+ }
354
+
355
+ /**
356
+ * Human-readable remediation for one finding.
357
+ *
358
+ * @param {string} subject - the duplicated package or engine name.
359
+ * @param {string} field - the manifest field that already owns the pin.
360
+ * @returns {string} remediation guidance.
361
+ */
362
+ export function remediationFor(subject, field) {
363
+ return (
364
+ `Read the ${subject} version from ${field} instead of hardcoding it; ` +
365
+ `a version bump must touch the manifest + lockfile only. If this ` +
366
+ `duplicate is intentional during a migration, record it with an inline ` +
367
+ `\`${EXCEPTION_MARKER} <reason> (<ticket>)\` marker and track the cleanup ` +
368
+ `in a ticket.`
369
+ );
370
+ }
371
+
372
+ /**
373
+ * Classify one duplicated literal against its manifest pin.
374
+ *
375
+ * @param {string} literal - the version literal found in the governed input.
376
+ * @param {string} pinned - the manifest's normalized pin.
377
+ * @param {string | null} exception - documented exception reason, if any.
378
+ * @returns {"allowed" | "duplicate" | "drifted"} the finding status.
379
+ */
380
+ export function classifyStatus(literal, pinned, exception) {
381
+ if (exception !== null) return "allowed";
382
+ return literal === pinned ? "duplicate" : "drifted";
383
+ }
384
+
385
+ /**
386
+ * Scan a single governed line for install pins of manifest-known packages.
387
+ *
388
+ * @param {string} line - the line's text.
389
+ * @param {Record<string, {version: string, field: string}>} packages - manifest package pins.
390
+ * @returns {{ package: string, version: string, manifestVersion: string, manifestField: string, source: "install-pin" }[]}
391
+ * raw (unclassified) matches on this line.
392
+ */
393
+ export function matchInstallPins(line, packages) {
394
+ if (!INSTALL_COMMAND_PATTERN.test(line)) return [];
395
+ const matches = [];
396
+ for (const match of line.matchAll(NAME_AT_VERSION_PATTERN)) {
397
+ const [, name, version] = match;
398
+ const pin = packages[name];
399
+ if (!pin) continue;
400
+ matches.push({
401
+ package: name,
402
+ version,
403
+ manifestVersion: pin.version,
404
+ manifestField: pin.field,
405
+ source: "install-pin",
406
+ });
407
+ }
408
+ return matches;
409
+ }
410
+
411
+ /**
412
+ * Scan a single governed line for toolchain pins of manifest-known engines.
413
+ *
414
+ * @param {string} line - the line's text.
415
+ * @param {Record<string, {version: string, field: string}>} engines - manifest engine pins.
416
+ * @returns {{ package: string, version: string, manifestVersion: string, manifestField: string, source: "toolchain-pin" }[]}
417
+ * raw (unclassified) matches on this line.
418
+ */
419
+ export function matchToolchainPins(line, engines) {
420
+ const matches = [];
421
+ for (const match of line.matchAll(TOOLCHAIN_PIN_PATTERN)) {
422
+ const [, tool, version] = match;
423
+ const pin = engines[tool.toLowerCase()];
424
+ if (!pin) continue;
425
+ matches.push({
426
+ package: tool.toLowerCase(),
427
+ version,
428
+ manifestVersion: pin.version,
429
+ manifestField: pin.field,
430
+ source: "toolchain-pin",
431
+ });
432
+ }
433
+ return matches;
434
+ }
435
+
436
+ /**
437
+ * Scan one governed file's contents and produce classified findings.
438
+ *
439
+ * @param {string} contents - full file text.
440
+ * @param {string} relativePath - path reported in findings.
441
+ * @param {{ packages: Record<string, {version: string, field: string}>, engines: Record<string, {version: string, field: string}> }} pins - manifest pins.
442
+ * @returns {{ file: string, line: number, package: string, version: string, manifestVersion: string, manifestField: string, source: string, status: string, exception: string | null, remediation: string }[]}
443
+ * findings, in file order.
444
+ */
445
+ export function scanContents(contents, relativePath, pins) {
446
+ const lines = contents.split("\n");
447
+ const findings = [];
448
+ lines.forEach((line, index) => {
449
+ if (isCommentLine(line)) return;
450
+ const raw = [
451
+ ...matchInstallPins(line, pins.packages),
452
+ ...matchToolchainPins(line, pins.engines),
453
+ ];
454
+ if (raw.length === 0) return;
455
+ const exception = findExceptionReason(lines, index);
456
+ for (const match of raw) {
457
+ findings.push({
458
+ ...match,
459
+ file: relativePath,
460
+ line: index + 1,
461
+ status: classifyStatus(match.version, match.manifestVersion, exception),
462
+ exception,
463
+ remediation: remediationFor(match.package, match.manifestField),
464
+ });
465
+ }
466
+ });
467
+ return findings;
468
+ }
469
+
470
+ /**
471
+ * Recursively list governed files under a directory.
472
+ *
473
+ * @param {string} directory - absolute directory to walk.
474
+ * @param {string} root - absolute scan root, for relative paths.
475
+ * @returns {string[]} absolute paths of governed files.
476
+ */
477
+ export function listGovernedFiles(directory, root) {
478
+ const found = [];
479
+ const entries = fs.readdirSync(directory, { withFileTypes: true });
480
+ for (const entry of entries) {
481
+ const full = path.join(directory, entry.name);
482
+ if (entry.isDirectory()) {
483
+ if (SKIPPED_DIRECTORIES.has(entry.name)) continue;
484
+ found.push(...listGovernedFiles(full, root));
485
+ continue;
486
+ }
487
+ if (entry.isFile() && isGovernedFile(path.relative(root, full))) {
488
+ found.push(full);
489
+ }
490
+ }
491
+ return found;
492
+ }
493
+
494
+ /**
495
+ * Build the full report for a root and its scan directories.
496
+ *
497
+ * @param {{ root: string, scan: string[], strict: boolean, scanIsExplicit?: boolean }} options - resolved CLI options.
498
+ * @returns {{ schemaVersion: number, mode: string, root: string, manifests: string[], scanned: string[], skippedDefaults: string[], summary: { files: number, duplicate: number, drifted: number, allowed: number }, findings: object[] }}
499
+ * the machine-readable report.
500
+ * @throws {UsageError} when an explicitly requested scan directory is missing
501
+ * or is not a directory, or when no manifest exists.
502
+ */
503
+ export function buildReport({ root, scan, strict, scanIsExplicit = false }) {
504
+ const pins = loadManifestPins(root);
505
+ const findings = [];
506
+ const skippedDefaults = [];
507
+ let files = 0;
508
+ for (const relative of scan) {
509
+ const directory = path.join(root, relative);
510
+ if (!fs.existsSync(directory)) {
511
+ // A directory the caller explicitly asked for and that does not exist is
512
+ // an error: silently skipping it would leave the check reporting "no
513
+ // duplicates found" while it scanned nothing at all. An absent BUILT-IN
514
+ // default (a repo with no `rails/`) is normal — skip it, but say so.
515
+ if (scanIsExplicit) {
516
+ throw new UsageError(`--scan directory does not exist: ${relative}`);
517
+ }
518
+ skippedDefaults.push(relative);
519
+ continue;
520
+ }
521
+ if (!fs.statSync(directory).isDirectory()) {
522
+ throw new UsageError(`--scan target is not a directory: ${relative}`);
523
+ }
524
+ for (const file of listGovernedFiles(directory, root)) {
525
+ files += 1;
526
+ findings.push(
527
+ ...scanContents(
528
+ fs.readFileSync(file, "utf8"),
529
+ path.relative(root, file),
530
+ pins
531
+ )
532
+ );
533
+ }
534
+ }
535
+ const count = status => findings.filter(f => f.status === status).length;
536
+ return {
537
+ schemaVersion: SCHEMA_VERSION,
538
+ mode: strict ? "strict" : "advisory",
539
+ root,
540
+ manifests: pins.sources,
541
+ scanned: scan,
542
+ skippedDefaults,
543
+ summary: {
544
+ files,
545
+ duplicate: count("duplicate"),
546
+ drifted: count("drifted"),
547
+ allowed: count("allowed"),
548
+ },
549
+ findings,
550
+ };
551
+ }
552
+
553
+ /**
554
+ * Parse CLI arguments.
555
+ *
556
+ * @param {string[]} argv - arguments after the script path.
557
+ * @returns {{ root: string, scan: string[], scanIsExplicit: boolean, strict: boolean, json: boolean, help: boolean }} resolved options.
558
+ * @throws {UsageError} on an unknown flag or a flag missing its value.
559
+ */
560
+ export function parseArgs(argv) {
561
+ const options = {
562
+ root: REPO_ROOT,
563
+ scan: [],
564
+ scanIsExplicit: false,
565
+ strict: false,
566
+ json: false,
567
+ help: false,
568
+ };
569
+ for (let index = 0; index < argv.length; index += 1) {
570
+ const argument = argv[index];
571
+ if (argument === "--strict") options.strict = true;
572
+ else if (argument === "--json") options.json = true;
573
+ else if (argument === "--help" || argument === "-h") options.help = true;
574
+ else if (argument === "--root" || argument === "--scan") {
575
+ const value = argv[index + 1];
576
+ if (value === undefined || value.startsWith("--")) {
577
+ throw new UsageError(`${argument} requires a directory value`);
578
+ }
579
+ index += 1;
580
+ if (argument === "--root") options.root = path.resolve(value);
581
+ else options.scan.push(value);
582
+ } else {
583
+ throw new UsageError(`unknown argument: ${argument}`);
584
+ }
585
+ }
586
+ if (
587
+ !fs.existsSync(options.root) ||
588
+ !fs.statSync(options.root).isDirectory()
589
+ ) {
590
+ throw new UsageError(`--root is not a directory: ${options.root}`);
591
+ }
592
+ options.scanIsExplicit = options.scan.length > 0;
593
+ if (!options.scanIsExplicit) options.scan = [...DEFAULT_SCAN_DIRS];
594
+ return options;
595
+ }
596
+
597
+ /** Usage text, which also carries the remediation rule. */
598
+ export const HELP_TEXT = `check:duplicate-versions — the canonical manifest is authoritative.
599
+
600
+ Usage: node scripts/check-duplicate-versions.mjs [options]
601
+
602
+ --root <dir> Directory holding the canonical manifests (default: repo root).
603
+ --scan <dir> Governed directory to scan, relative to --root (repeatable).
604
+ --strict Exit non-zero on findings (default: advisory, exit 0).
605
+ --json Emit the machine-readable report on stdout.
606
+ -h, --help Show this help.
607
+
608
+ What it flags: a version literal pinned in a governed workflow, script,
609
+ template, or fixture for a package or engine the canonical manifest already
610
+ pins — a second edit site a routine bump can miss.
611
+
612
+ What it never flags: lockfiles, prose/markdown, comments, .lisa ledgers,
613
+ node_modules, generated output, the manifests themselves, loose ranges (22.x),
614
+ any package the manifest does not pin, and a self-reference to the manifest's
615
+ own name (that is a published-artifact version FLOOR, not a mirrored range).
616
+
617
+ Remediation rule: update the MANIFEST + LOCKFILE only, and make the governed
618
+ input read the value from the manifest. If the duplicate must exist during a
619
+ migration, record it inline with
620
+ ${EXCEPTION_MARKER} <reason> (<ticket>)
621
+ on the offending line or the line above it. A ticket reference (#123, KEY-123,
622
+ or a URL) is REQUIRED — a ticketless marker is rejected — and every allowed
623
+ exception is listed in the report so no mute is invisible.`;
624
+
625
+ /**
626
+ * Render the human-readable report.
627
+ *
628
+ * @param {ReturnType<typeof buildReport>} report - the report to print.
629
+ * @returns {string} formatted text.
630
+ */
631
+ export function formatReport(report) {
632
+ const violations = report.findings.filter(f => f.status !== "allowed");
633
+ const lines = [
634
+ `check:duplicate-versions (${report.mode} mode)`,
635
+ ` manifests: ${report.manifests.join(", ")}`,
636
+ ` scanned: ${report.summary.files} governed files`,
637
+ ` findings: ${report.summary.duplicate} duplicate, ${report.summary.drifted} drifted, ${report.summary.allowed} allowed`,
638
+ ];
639
+ for (const finding of violations) {
640
+ lines.push(
641
+ "",
642
+ ` ${finding.file}:${finding.line} [${finding.status}] ${finding.package}@${finding.version} (manifest: ${finding.manifestVersion})`,
643
+ ` ${finding.remediation}`
644
+ );
645
+ }
646
+ // Every mute is printed, not just counted: an exception nobody can see in
647
+ // the report is indistinguishable from a duplicate nobody noticed.
648
+ const allowed = report.findings.filter(f => f.status === "allowed");
649
+ if (allowed.length > 0) {
650
+ lines.push("", ` Allowed exceptions (${allowed.length}):`);
651
+ for (const finding of allowed) {
652
+ lines.push(
653
+ ` ${finding.file}:${finding.line} ${finding.package}@${finding.version} — ${finding.exception}`
654
+ );
655
+ }
656
+ }
657
+ if (report.skippedDefaults?.length > 0) {
658
+ lines.push(
659
+ "",
660
+ ` Default scan roots skipped (absent): ${report.skippedDefaults.join(", ")}`
661
+ );
662
+ }
663
+ if (violations.length === 0) {
664
+ lines.push("", " No manifest-authoritative duplicates found.");
665
+ } else if (report.mode === "advisory") {
666
+ lines.push(
667
+ "",
668
+ " ADVISORY: reported, not enforced. Lisa still carries pre-existing",
669
+ " duplicates; clean them up (or record exceptions) before switching",
670
+ " this check to --strict."
671
+ );
672
+ }
673
+ return lines.join("\n");
674
+ }
675
+
676
+ /**
677
+ * CLI entry point.
678
+ *
679
+ * @returns {void}
680
+ */
681
+ function main() {
682
+ // `process.exitCode` rather than `process.exit()` throughout: exiting
683
+ // eagerly truncates a piped stdout write, which would corrupt the `--json`
684
+ // report exactly when there is something to report.
685
+ let options;
686
+ try {
687
+ options = parseArgs(process.argv.slice(2));
688
+ } catch (error) {
689
+ console.error(`check:duplicate-versions: ${error.message}`);
690
+ console.error(HELP_TEXT);
691
+ process.exitCode = 2;
692
+ return;
693
+ }
694
+ if (options.help) {
695
+ console.log(HELP_TEXT);
696
+ return;
697
+ }
698
+ let report;
699
+ try {
700
+ report = buildReport(options);
701
+ } catch (error) {
702
+ console.error(`check:duplicate-versions: ${error.message}`);
703
+ process.exitCode = 2;
704
+ return;
705
+ }
706
+ console.log(
707
+ options.json ? JSON.stringify(report, null, 2) : formatReport(report)
708
+ );
709
+ const violations = report.findings.filter(f => f.status !== "allowed").length;
710
+ if (options.strict && violations > 0) process.exitCode = 1;
711
+ }
712
+
713
+ if (process.argv[1] === fileURLToPath(import.meta.url)) {
714
+ main();
715
+ }