@cosmicdrift/kumiko-guards 0.363.0 → 0.365.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.
- package/README.md +28 -0
- package/package.json +2 -2
- package/src/_lib/agent-docs.ts +425 -0
- package/src/_lib/dependency-cli.ts +27 -0
- package/src/_lib/dependency-exceptions.ts +135 -0
- package/src/_lib/direct-dependencies.ts +80 -0
- package/src/_lib/installed-packages.ts +146 -0
- package/src/_lib/npm-registry.ts +168 -0
- package/src/_lib/push-root.ts +5 -3
- package/src/_lib/security-audit.ts +390 -0
- package/src/_lib/security-baseline.ts +14 -3
- package/src/changes.json +47 -5
- package/src/check-licenses.ts +288 -0
- package/src/check-outdated.ts +234 -0
- package/src/check-runtime-isolation.ts +3 -1
- package/src/check-security.ts +182 -0
- package/src/check-single-runtime-instance.ts +1 -1
- package/src/cli.ts +27 -10
- package/src/dependency-license-exceptions.json +77 -0
- package/src/dependency-security-exceptions.json +4 -0
- package/src/guard-agent-docs-drift.ts +137 -0
- package/src/guard-comment-lang.ts +76 -40
- package/src/guard-direct-entity-writes.ts +3 -0
- package/src/guard-lib-test-coverage.ts +1 -1
- package/src/guard-no-direct-fs.ts +0 -1
- package/src/guard-pre-es-patterns.ts +3 -0
- package/src/guard-public-exports-changelog.ts +159 -24
- package/src/guard-test-timeouts.ts +5 -1
- package/src/guard-thin-wrappers.ts +9 -2
- package/src/guard-upgrade-state.ts +40 -11
- package/src/index.ts +1 -0
- package/src/pre-push.sh +20 -0
- package/src/run-repo-checks.ts +2 -0
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
// Licence check over the installed third-party packages of ONE repo.
|
|
2
|
+
//
|
|
3
|
+
// Single-repo by design: the scanned repo is the one `cwd` sits in (the multi-repo
|
|
4
|
+
// workspace scan of the private infra guard is gone). Installed packages come from
|
|
5
|
+
// node_modules of the repo, its workspace members and every ancestor directory.
|
|
6
|
+
// Exceptions merge the bundled dependency-license-exceptions.json with an optional file of
|
|
7
|
+
// the same name in the repo root and in every ancestor whose node_modules was scanned.
|
|
8
|
+
import { parseDependencyCli } from "./_lib/dependency-cli";
|
|
9
|
+
import {
|
|
10
|
+
findExpiringSoon,
|
|
11
|
+
isExceptionValid,
|
|
12
|
+
loadExceptionEntries,
|
|
13
|
+
matchesPackageName,
|
|
14
|
+
} from "./_lib/dependency-exceptions";
|
|
15
|
+
import {
|
|
16
|
+
collectInstalledPackages,
|
|
17
|
+
type InstalledPackage,
|
|
18
|
+
installationRoots,
|
|
19
|
+
} from "./_lib/installed-packages";
|
|
20
|
+
|
|
21
|
+
export const LICENSES_FLAGS: readonly string[] = ["--report"];
|
|
22
|
+
|
|
23
|
+
const ALLOWLIST = new Set([
|
|
24
|
+
"MIT",
|
|
25
|
+
"MIT-0",
|
|
26
|
+
"ISC",
|
|
27
|
+
"Apache-2.0",
|
|
28
|
+
"BSD-2-Clause",
|
|
29
|
+
"BSD-3-Clause",
|
|
30
|
+
"BSD-3-Clause-Clear",
|
|
31
|
+
"0BSD",
|
|
32
|
+
"Unlicense",
|
|
33
|
+
"CC0-1.0",
|
|
34
|
+
"BlueOak-1.0.0",
|
|
35
|
+
"Python-2.0",
|
|
36
|
+
"PostgreSQL",
|
|
37
|
+
"Zlib",
|
|
38
|
+
"WTFPL",
|
|
39
|
+
"Artistic-2.0",
|
|
40
|
+
]);
|
|
41
|
+
|
|
42
|
+
const GRAY_ZONE = new Set([
|
|
43
|
+
"LGPL-2.0",
|
|
44
|
+
"LGPL-2.1",
|
|
45
|
+
"LGPL-2.1-only",
|
|
46
|
+
"LGPL-2.1-or-later",
|
|
47
|
+
"LGPL-3.0",
|
|
48
|
+
"LGPL-3.0-only",
|
|
49
|
+
"LGPL-3.0-or-later",
|
|
50
|
+
"MPL-1.0",
|
|
51
|
+
"MPL-1.1",
|
|
52
|
+
"MPL-2.0",
|
|
53
|
+
"CDDL-1.0",
|
|
54
|
+
"CDDL-1.1",
|
|
55
|
+
"EPL-1.0",
|
|
56
|
+
"EPL-2.0",
|
|
57
|
+
]);
|
|
58
|
+
|
|
59
|
+
const DENYLIST_PATTERNS: RegExp[] = [
|
|
60
|
+
/^GPL-\d/,
|
|
61
|
+
/^AGPL-\d/,
|
|
62
|
+
/^SSPL-/,
|
|
63
|
+
/^BUSL-/,
|
|
64
|
+
/Commons[ -]Clause/i,
|
|
65
|
+
/^UNLICENSED$/i,
|
|
66
|
+
];
|
|
67
|
+
|
|
68
|
+
export const LICENSE_EXCEPTIONS_FILE = "dependency-license-exceptions.json";
|
|
69
|
+
|
|
70
|
+
export type LicenseException = {
|
|
71
|
+
readonly package: string;
|
|
72
|
+
readonly version: string;
|
|
73
|
+
readonly license: string;
|
|
74
|
+
readonly reason: string;
|
|
75
|
+
readonly reviewedBy: string;
|
|
76
|
+
readonly expires: string;
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
type Verdict =
|
|
80
|
+
| { readonly kind: "ok"; readonly license: string }
|
|
81
|
+
| { readonly kind: "gray"; readonly license: string; readonly exception?: LicenseException }
|
|
82
|
+
| { readonly kind: "deny"; readonly license: string; readonly exception?: LicenseException }
|
|
83
|
+
| {
|
|
84
|
+
readonly kind: "unknown";
|
|
85
|
+
readonly raw: string | null;
|
|
86
|
+
readonly exception?: LicenseException;
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
type Judged = { readonly pkg: InstalledPackage; readonly verdict: Verdict };
|
|
90
|
+
|
|
91
|
+
export function loadLicenseExceptions(
|
|
92
|
+
roots: readonly string[],
|
|
93
|
+
bundledDir?: string,
|
|
94
|
+
): LicenseException[] {
|
|
95
|
+
return loadExceptionEntries({
|
|
96
|
+
filename: LICENSE_EXCEPTIONS_FILE,
|
|
97
|
+
roots,
|
|
98
|
+
bundledDir,
|
|
99
|
+
parse: (field) => ({
|
|
100
|
+
package: field("package"),
|
|
101
|
+
version: field("version"),
|
|
102
|
+
license: field("license"),
|
|
103
|
+
reason: field("reason"),
|
|
104
|
+
reviewedBy: field("reviewedBy"),
|
|
105
|
+
expires: field("expires"),
|
|
106
|
+
}),
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function findException(
|
|
111
|
+
pkg: InstalledPackage,
|
|
112
|
+
exceptions: readonly LicenseException[],
|
|
113
|
+
): LicenseException | undefined {
|
|
114
|
+
return exceptions.find((e) => {
|
|
115
|
+
if (!matchesPackageName(e.package, pkg.name)) return false;
|
|
116
|
+
return e.version === "*" || e.version === pkg.version || /^[\^~]/.test(e.version);
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function splitExpression(expr: string, operator: "OR" | "AND"): string[] {
|
|
121
|
+
const stripped = expr.replace(/[()]/g, "").trim();
|
|
122
|
+
return stripped
|
|
123
|
+
.split(new RegExp(`\\s+${operator}\\s+`, "i"))
|
|
124
|
+
.map((p) => p.trim())
|
|
125
|
+
.filter(Boolean);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function matchesDenylist(license: string): boolean {
|
|
129
|
+
return DENYLIST_PATTERNS.some((p) => p.test(license));
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export function judgeLicense(
|
|
133
|
+
pkg: InstalledPackage,
|
|
134
|
+
exceptions: readonly LicenseException[],
|
|
135
|
+
): Verdict {
|
|
136
|
+
const license = pkg.license;
|
|
137
|
+
const exception = findException(pkg, exceptions);
|
|
138
|
+
if (!license) return { kind: "unknown", raw: null, exception };
|
|
139
|
+
|
|
140
|
+
const andParts = splitExpression(license, "AND");
|
|
141
|
+
if (andParts.length > 1) {
|
|
142
|
+
if (andParts.every((p) => ALLOWLIST.has(p))) return { kind: "ok", license };
|
|
143
|
+
if (andParts.some(matchesDenylist)) return { kind: "deny", license, exception };
|
|
144
|
+
if (andParts.every((p) => ALLOWLIST.has(p) || GRAY_ZONE.has(p))) {
|
|
145
|
+
return { kind: "gray", license, exception };
|
|
146
|
+
}
|
|
147
|
+
return { kind: "unknown", raw: license, exception };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const orParts = splitExpression(license, "OR");
|
|
151
|
+
if (orParts.length > 1) {
|
|
152
|
+
if (orParts.some((p) => ALLOWLIST.has(p))) return { kind: "ok", license };
|
|
153
|
+
if (orParts.some((p) => GRAY_ZONE.has(p))) return { kind: "gray", license, exception };
|
|
154
|
+
if (orParts.every(matchesDenylist)) return { kind: "deny", license, exception };
|
|
155
|
+
return { kind: "unknown", raw: license, exception };
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const clean = license.replace(/[()]/g, "").trim();
|
|
159
|
+
if (ALLOWLIST.has(clean)) return { kind: "ok", license: clean };
|
|
160
|
+
if (GRAY_ZONE.has(clean)) return { kind: "gray", license: clean, exception };
|
|
161
|
+
if (matchesDenylist(clean)) return { kind: "deny", license: clean, exception };
|
|
162
|
+
return { kind: "unknown", raw: clean, exception };
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function hasValidException(verdict: Verdict, nowMs: number): boolean {
|
|
166
|
+
return (
|
|
167
|
+
"exception" in verdict &&
|
|
168
|
+
verdict.exception !== undefined &&
|
|
169
|
+
isExceptionValid(verdict.exception.expires, nowMs).valid
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export type LicenseEvaluation = {
|
|
174
|
+
readonly judged: readonly Judged[];
|
|
175
|
+
readonly blocked: readonly Judged[];
|
|
176
|
+
readonly coveredGray: readonly Judged[];
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
export function evaluateLicenses(
|
|
180
|
+
packages: readonly InstalledPackage[],
|
|
181
|
+
exceptions: readonly LicenseException[],
|
|
182
|
+
nowMs: number = Date.now(),
|
|
183
|
+
): LicenseEvaluation {
|
|
184
|
+
const judged = packages.map((pkg) => ({ pkg, verdict: judgeLicense(pkg, exceptions) }));
|
|
185
|
+
const blocked = judged.filter(
|
|
186
|
+
({ verdict }) => verdict.kind !== "ok" && !hasValidException(verdict, nowMs),
|
|
187
|
+
);
|
|
188
|
+
const coveredGray = judged.filter(
|
|
189
|
+
({ verdict }) => verdict.kind === "gray" && hasValidException(verdict, nowMs),
|
|
190
|
+
);
|
|
191
|
+
return { judged, blocked, coveredGray };
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function verdictLicense(verdict: Verdict): string {
|
|
195
|
+
return verdict.kind === "unknown" ? String(verdict.raw) : verdict.license;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function describeBlocked({ pkg, verdict }: Judged, nowMs: number): string {
|
|
199
|
+
const kind =
|
|
200
|
+
verdict.kind === "deny"
|
|
201
|
+
? "DENY"
|
|
202
|
+
: verdict.kind === "unknown"
|
|
203
|
+
? "UNKNOWN"
|
|
204
|
+
: "GRAY (no valid exception)";
|
|
205
|
+
const exception = "exception" in verdict ? verdict.exception : undefined;
|
|
206
|
+
const expiredSuffix =
|
|
207
|
+
exception && isExceptionValid(exception.expires, nowMs).expired
|
|
208
|
+
? ` (exception expired ${exception.expires})`
|
|
209
|
+
: "";
|
|
210
|
+
return ` [${kind}] ${pkg.name}@${pkg.version} license=${verdictLicense(verdict)}${expiredSuffix}`;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function describeCoveredGray({ pkg, verdict }: Judged): string {
|
|
214
|
+
const exception = "exception" in verdict ? verdict.exception : undefined;
|
|
215
|
+
return ` [GRAY/ok] ${pkg.name}@${pkg.version} license=${verdictLicense(verdict)} expires=${exception?.expires ?? "?"}`;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function printReport(evaluation: LicenseEvaluation, nowMs: number): void {
|
|
219
|
+
const counts = { ok: 0, gray: 0, deny: 0, unknown: 0 };
|
|
220
|
+
for (const { verdict } of evaluation.judged) counts[verdict.kind]++;
|
|
221
|
+
console.log(`License check: ${evaluation.judged.length} packages scanned.`);
|
|
222
|
+
console.log(
|
|
223
|
+
` ok: ${counts.ok} gray: ${counts.gray} deny: ${counts.deny} unknown: ${counts.unknown}`,
|
|
224
|
+
);
|
|
225
|
+
|
|
226
|
+
if (evaluation.blocked.length > 0) {
|
|
227
|
+
console.error("\n Problematic licenses:\n");
|
|
228
|
+
for (const judged of evaluation.blocked) console.error(describeBlocked(judged, nowMs));
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
if (evaluation.coveredGray.length > 0) {
|
|
232
|
+
console.log("\n Gray zone (covered by exception):");
|
|
233
|
+
for (const judged of evaluation.coveredGray) console.log(describeCoveredGray(judged));
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const used = evaluation.judged.flatMap(({ verdict }) =>
|
|
237
|
+
"exception" in verdict && verdict.exception ? [verdict.exception] : [],
|
|
238
|
+
);
|
|
239
|
+
const expiring = findExpiringSoon(used, 30, nowMs);
|
|
240
|
+
if (expiring.length > 0) {
|
|
241
|
+
console.log("\n WARN: exceptions expire within 30 days — re-review:");
|
|
242
|
+
for (const exception of expiring) {
|
|
243
|
+
console.log(` ${exception.package} expires=${exception.expires}`);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
export function runLicenseCheck(options: {
|
|
249
|
+
readonly repoRoot: string;
|
|
250
|
+
readonly reportOnly: boolean;
|
|
251
|
+
readonly packages?: readonly InstalledPackage[];
|
|
252
|
+
readonly exceptions?: readonly LicenseException[];
|
|
253
|
+
readonly nowMs?: number;
|
|
254
|
+
}): number {
|
|
255
|
+
const nowMs = options.nowMs ?? Date.now();
|
|
256
|
+
const packages = options.packages ?? collectInstalledPackages(options.repoRoot);
|
|
257
|
+
if (packages.length === 0) {
|
|
258
|
+
console.error(
|
|
259
|
+
` licenses: nothing scanned — no installed packages found for ${options.repoRoot} (run \`bun install\`).`,
|
|
260
|
+
);
|
|
261
|
+
return 1;
|
|
262
|
+
}
|
|
263
|
+
const exceptions =
|
|
264
|
+
options.exceptions ?? loadLicenseExceptions(installationRoots(options.repoRoot));
|
|
265
|
+
const evaluation = evaluateLicenses(packages, exceptions, nowMs);
|
|
266
|
+
printReport(evaluation, nowMs);
|
|
267
|
+
if (options.reportOnly) return 0;
|
|
268
|
+
|
|
269
|
+
if (evaluation.blocked.length > 0) {
|
|
270
|
+
console.error(
|
|
271
|
+
`\n BLOCKED: ${evaluation.blocked.length} package(s) with a problematic license.`,
|
|
272
|
+
);
|
|
273
|
+
console.error(
|
|
274
|
+
` Remove the dependency or add an exception to ${LICENSE_EXCEPTIONS_FILE} in the repo root.\n`,
|
|
275
|
+
);
|
|
276
|
+
return 1;
|
|
277
|
+
}
|
|
278
|
+
if (evaluation.coveredGray.length > 0) {
|
|
279
|
+
console.log(`\n ${evaluation.coveredGray.length} gray-zone package(s) covered by exceptions.`);
|
|
280
|
+
}
|
|
281
|
+
return 0;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
export function runLicensesCli(argv: readonly string[], cwd: string = process.cwd()): number {
|
|
285
|
+
const invocation = parseDependencyCli("licenses", argv, cwd);
|
|
286
|
+
if (!invocation.ok) return invocation.exitCode;
|
|
287
|
+
return runLicenseCheck({ repoRoot: invocation.repoRoot, reportOnly: invocation.reportOnly });
|
|
288
|
+
}
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
// Deprecated-dependency check over ONE repo.
|
|
2
|
+
//
|
|
3
|
+
// Default: fails on any installed direct dependency whose npm registry metadata marks the
|
|
4
|
+
// installed version `deprecated`. General outdated listing is Renovate's job; `--report`
|
|
5
|
+
// adds deprecated transitive packages and a major-lag warning list (never blocking).
|
|
6
|
+
// Single-repo by design: declared dependencies are the scanned repo's root and workspace
|
|
7
|
+
// members; installed packages come from node_modules of the repo, its members and ancestors.
|
|
8
|
+
import { parseDependencyCli } from "./_lib/dependency-cli";
|
|
9
|
+
import {
|
|
10
|
+
collectDeclaredSpecsByName,
|
|
11
|
+
collectDirectDependencies,
|
|
12
|
+
collectRootOverrides,
|
|
13
|
+
} from "./_lib/direct-dependencies";
|
|
14
|
+
import { collectInstalledPackages, type InstalledPackage } from "./_lib/installed-packages";
|
|
15
|
+
import {
|
|
16
|
+
createBunInfoClient,
|
|
17
|
+
findMajorLagWarning,
|
|
18
|
+
mapWithConcurrency,
|
|
19
|
+
memoizeRegistryClient,
|
|
20
|
+
type RegistryClient,
|
|
21
|
+
} from "./_lib/npm-registry";
|
|
22
|
+
|
|
23
|
+
const REGISTRY_CONCURRENCY = 10;
|
|
24
|
+
|
|
25
|
+
type NameAndVersion = { readonly name: string; readonly version: string };
|
|
26
|
+
|
|
27
|
+
export type DeprecatedPackage = NameAndVersion & { readonly message: string };
|
|
28
|
+
|
|
29
|
+
export type UnresolvedPackage = NameAndVersion & { readonly reason: string };
|
|
30
|
+
|
|
31
|
+
export type DeprecationScan = {
|
|
32
|
+
readonly deprecated: DeprecatedPackage[];
|
|
33
|
+
readonly unresolved: UnresolvedPackage[];
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
export async function findDeprecatedPackages(
|
|
37
|
+
packages: readonly NameAndVersion[],
|
|
38
|
+
registry: RegistryClient,
|
|
39
|
+
): Promise<DeprecationScan> {
|
|
40
|
+
const unique = [...new Map(packages.map((pkg) => [`${pkg.name}@${pkg.version}`, pkg])).values()];
|
|
41
|
+
const lookups = await mapWithConcurrency(unique, REGISTRY_CONCURRENCY, async (pkg) => ({
|
|
42
|
+
pkg,
|
|
43
|
+
lookup: await registry.deprecation(pkg.name, pkg.version),
|
|
44
|
+
}));
|
|
45
|
+
const deprecated: DeprecatedPackage[] = [];
|
|
46
|
+
const unresolved: UnresolvedPackage[] = [];
|
|
47
|
+
for (const { pkg, lookup } of lookups) {
|
|
48
|
+
if (lookup.kind === "deprecated") deprecated.push({ ...pkg, message: lookup.message });
|
|
49
|
+
else if (lookup.kind === "unknown") unresolved.push({ ...pkg, reason: lookup.reason });
|
|
50
|
+
}
|
|
51
|
+
return { deprecated, unresolved };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function printUnresolved(
|
|
55
|
+
scope: string,
|
|
56
|
+
unresolved: readonly UnresolvedPackage[],
|
|
57
|
+
total: number,
|
|
58
|
+
): void {
|
|
59
|
+
console.error(
|
|
60
|
+
`\n WARNING: deprecation status UNKNOWN for ${unresolved.length} of ${total} ${scope} — the registry did not answer, so these were NOT checked (re-run once the registry is reachable):`,
|
|
61
|
+
);
|
|
62
|
+
for (const pkg of unresolved.slice(0, 10)) {
|
|
63
|
+
console.error(` ${pkg.name}@${pkg.version} — ${pkg.reason}`);
|
|
64
|
+
}
|
|
65
|
+
if (unresolved.length > 10) console.error(` … and ${unresolved.length - 10} more`);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// A spec that is not a plain semver range (workspace:, catalog:, a git URL, "latest") has nothing to compare against; matching it by name alone can only over-report, never hide a deprecated package.
|
|
69
|
+
function isComparableRange(spec: string): boolean {
|
|
70
|
+
return /^[\d~^><=]/.test(spec.trim());
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function isDeclaredInstall(installedVersion: string, declaredSpec: string): boolean {
|
|
74
|
+
if (!isComparableRange(declaredSpec)) return true;
|
|
75
|
+
return Bun.semver.satisfies(installedVersion, declaredSpec);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function selectDirectInstalledPackages<T extends NameAndVersion>(
|
|
79
|
+
installed: readonly T[],
|
|
80
|
+
declaredSpecs: ReadonlyMap<string, readonly string[]>,
|
|
81
|
+
overrides: ReadonlyMap<string, string> = new Map(),
|
|
82
|
+
): T[] {
|
|
83
|
+
const byName = new Map<string, T[]>();
|
|
84
|
+
for (const pkg of installed) {
|
|
85
|
+
if (!declaredSpecs.has(pkg.name)) continue;
|
|
86
|
+
const group = byName.get(pkg.name);
|
|
87
|
+
if (group) group.push(pkg);
|
|
88
|
+
else byName.set(pkg.name, [pkg]);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const keep = new Set<T>();
|
|
92
|
+
for (const [name, group] of byName) {
|
|
93
|
+
const specs = declaredSpecs.get(name) ?? [];
|
|
94
|
+
const matching = group.filter((pkg) =>
|
|
95
|
+
specs.some((spec) => isDeclaredInstall(pkg.version, spec)),
|
|
96
|
+
);
|
|
97
|
+
if (matching.length > 0) {
|
|
98
|
+
for (const pkg of matching) keep.add(pkg);
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
// A root `overrides` block can pin a direct dependency outside its declared range; keep the instances matching the pin, or all of them (fail-safe) if there is no pin or nothing satisfies it.
|
|
102
|
+
const override = overrides.get(name);
|
|
103
|
+
const pinned =
|
|
104
|
+
override === undefined ? [] : group.filter((pkg) => isDeclaredInstall(pkg.version, override));
|
|
105
|
+
for (const pkg of pinned.length > 0 ? pinned : group) keep.add(pkg);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return installed.filter((pkg) => keep.has(pkg));
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async function printMajorLagReport(
|
|
112
|
+
repoRoot: string,
|
|
113
|
+
registry: RegistryClient,
|
|
114
|
+
nowMs: number,
|
|
115
|
+
): Promise<void> {
|
|
116
|
+
const direct = collectDirectDependencies(repoRoot);
|
|
117
|
+
console.log(`\n Major-lag report (${direct.length} direct dependencies):`);
|
|
118
|
+
|
|
119
|
+
const warnings = await mapWithConcurrency(direct, REGISTRY_CONCURRENCY, async (dep) => {
|
|
120
|
+
const release = await registry.latestRelease(dep.name);
|
|
121
|
+
if (!release) return null;
|
|
122
|
+
const current = dep.version.replace(/^[\^~>=<]+/, "");
|
|
123
|
+
return findMajorLagWarning(dep.name, current, release, 12, nowMs);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
const hits = warnings.filter((w) => w !== null);
|
|
127
|
+
if (hits.length === 0) {
|
|
128
|
+
console.log(" No direct dependencies with major lag >12 months.");
|
|
129
|
+
} else {
|
|
130
|
+
console.log("\n WARN: major version lag >12 months (not blocking):");
|
|
131
|
+
for (const hit of hits) {
|
|
132
|
+
console.log(
|
|
133
|
+
` ${hit.name} ${hit.current} → latest ${hit.latest} (since ${hit.latestPublished})`,
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Prints the direct-dependency verdict; returns true when it must fail the run. */
|
|
140
|
+
function reportDirectScan(scan: DeprecationScan, directChecked: number): boolean {
|
|
141
|
+
if (scan.deprecated.length > 0) {
|
|
142
|
+
console.error("\n DEPRECATED direct dependencies (blocking):\n");
|
|
143
|
+
for (const dep of scan.deprecated) {
|
|
144
|
+
console.error(` ${dep.name}@${dep.version} — ${dep.message}`);
|
|
145
|
+
}
|
|
146
|
+
console.error(
|
|
147
|
+
"\n Replace each deprecated package or upgrade to a version that is not deprecated.",
|
|
148
|
+
);
|
|
149
|
+
} else if (scan.unresolved.length === 0) {
|
|
150
|
+
console.log(" No deprecated direct dependencies found.");
|
|
151
|
+
}
|
|
152
|
+
if (scan.unresolved.length > 0) {
|
|
153
|
+
printUnresolved("direct dependencies", scan.unresolved, directChecked);
|
|
154
|
+
}
|
|
155
|
+
// Partial registry gaps stay a warning (npm answers 503 under load); a run that checked nothing must not pass.
|
|
156
|
+
const nothingChecked = directChecked > 0 && scan.unresolved.length === directChecked;
|
|
157
|
+
if (nothingChecked) {
|
|
158
|
+
console.error(
|
|
159
|
+
" outdated: nothing checked — no direct dependency got a registry answer. Re-run once the registry is reachable.",
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
return scan.deprecated.length > 0 || nothingChecked;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
async function printTransitiveReport(
|
|
166
|
+
installed: readonly InstalledPackage[],
|
|
167
|
+
directScan: DeprecationScan,
|
|
168
|
+
registry: RegistryClient,
|
|
169
|
+
): Promise<void> {
|
|
170
|
+
const allScan = await findDeprecatedPackages(installed, registry);
|
|
171
|
+
if (allScan.unresolved.length > directScan.unresolved.length) {
|
|
172
|
+
printUnresolved("installed packages", allScan.unresolved, countUnique(installed));
|
|
173
|
+
}
|
|
174
|
+
const transitive = allScan.deprecated.filter(
|
|
175
|
+
(dep) => !directScan.deprecated.some((d) => d.name === dep.name && d.version === dep.version),
|
|
176
|
+
);
|
|
177
|
+
if (transitive.length > 0) {
|
|
178
|
+
console.log(`\n WARN: ${transitive.length} deprecated transitive package(s) (Renovate):`);
|
|
179
|
+
for (const dep of transitive.slice(0, 15)) {
|
|
180
|
+
console.log(` ${dep.name}@${dep.version} — ${dep.message}`);
|
|
181
|
+
}
|
|
182
|
+
if (transitive.length > 15) console.log(` … and ${transitive.length - 15} more`);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
export async function runOutdatedCheck(options: {
|
|
187
|
+
readonly repoRoot: string;
|
|
188
|
+
readonly reportMode: boolean;
|
|
189
|
+
readonly installed?: readonly InstalledPackage[];
|
|
190
|
+
readonly registry?: RegistryClient;
|
|
191
|
+
readonly nowMs?: number;
|
|
192
|
+
}): Promise<number> {
|
|
193
|
+
const installed = options.installed ?? collectInstalledPackages(options.repoRoot);
|
|
194
|
+
if (installed.length === 0) {
|
|
195
|
+
console.error(
|
|
196
|
+
` outdated: nothing scanned — no installed packages found for ${options.repoRoot} (run \`bun install\`).`,
|
|
197
|
+
);
|
|
198
|
+
return 1;
|
|
199
|
+
}
|
|
200
|
+
const registry = memoizeRegistryClient(options.registry ?? createBunInfoClient(options.repoRoot));
|
|
201
|
+
const directInstalled = selectDirectInstalledPackages(
|
|
202
|
+
installed,
|
|
203
|
+
collectDeclaredSpecsByName(options.repoRoot),
|
|
204
|
+
collectRootOverrides(options.repoRoot),
|
|
205
|
+
);
|
|
206
|
+
|
|
207
|
+
console.log(
|
|
208
|
+
`Outdated check: ${directInstalled.length} direct dependencies (${installed.length} installed total).`,
|
|
209
|
+
);
|
|
210
|
+
|
|
211
|
+
const directScan = await findDeprecatedPackages(directInstalled, registry);
|
|
212
|
+
const blocking = reportDirectScan(directScan, countUnique(directInstalled));
|
|
213
|
+
|
|
214
|
+
if (options.reportMode) {
|
|
215
|
+
await printTransitiveReport(installed, directScan, registry);
|
|
216
|
+
await printMajorLagReport(options.repoRoot, registry, options.nowMs ?? Date.now());
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
return blocking ? 1 : 0;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function countUnique(packages: readonly NameAndVersion[]): number {
|
|
223
|
+
return new Set(packages.map((pkg) => `${pkg.name}@${pkg.version}`)).size;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
export async function runOutdatedCli(
|
|
227
|
+
argv: readonly string[],
|
|
228
|
+
cwd: string = process.cwd(),
|
|
229
|
+
): Promise<number> {
|
|
230
|
+
const invocation = parseDependencyCli("outdated", argv, cwd);
|
|
231
|
+
if (!invocation.ok) return invocation.exitCode;
|
|
232
|
+
// Unlike licenses/security, `--report` only adds sections: a deprecated direct dependency still exits 1.
|
|
233
|
+
return runOutdatedCheck({ repoRoot: invocation.repoRoot, reportMode: invocation.reportOnly });
|
|
234
|
+
}
|
|
@@ -221,7 +221,9 @@ if (import.meta.main) {
|
|
|
221
221
|
if (args.includes("--write-baseline")) {
|
|
222
222
|
const root = resolveRepoRoots()[0];
|
|
223
223
|
if (!root) {
|
|
224
|
-
console.error(
|
|
224
|
+
console.error(
|
|
225
|
+
"No repo root resolved — nothing to baseline. Run it from inside a repo checkout.",
|
|
226
|
+
);
|
|
225
227
|
process.exit(1);
|
|
226
228
|
}
|
|
227
229
|
const { allViolations } = scanRoot(root);
|