@codyswann/lisa 2.284.0 → 2.285.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 +19 -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-duplicate-versions.mjs +715 -0
|
@@ -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
|
+
}
|