@cosmicdrift/kumiko-guards 0.362.0 → 0.364.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/bun-lock-packages.ts +1 -1
- 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/guard-kit.ts +7 -2
- package/src/_lib/installed-packages.ts +146 -0
- package/src/_lib/npm-registry.ts +168 -0
- package/src/_lib/security-audit.ts +390 -0
- package/src/changes.json +41 -5
- package/src/check-as-casts.ts +93 -47
- package/src/check-complexity.ts +1 -1
- 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 +10 -4
- 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-admin-api.ts +3 -3
- 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-html-escape.ts +2 -2
- package/src/guard-pre-es-patterns.ts +3 -0
- package/src/guard-public-exports-changelog.ts +22 -9
- package/src/guard-raw-sql.ts +1 -1
- package/src/guard-thin-wrappers.ts +11 -5
- package/src/guard-upgrade-state.ts +55 -31
- package/src/index.ts +1 -0
- package/src/pre-push.sh +19 -0
- package/src/run-repo-checks.ts +2 -0
package/src/check-as-casts.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
* `as X` cast audit with a baseline regression
|
|
4
|
+
* `as X` cast audit with a baseline regression gate (fails on regressions).
|
|
5
5
|
*
|
|
6
6
|
* Every `as X` cast is a compiler gag. This check shows all casts
|
|
7
7
|
* in production code, categorized as:
|
|
@@ -27,10 +27,14 @@
|
|
|
27
27
|
* Baseline regression report:
|
|
28
28
|
*
|
|
29
29
|
* `.kumiko-cast-baseline.json` in the repo root pins the expected
|
|
30
|
-
* suspect-cast count per file. The audit compares against the
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
30
|
+
* suspect-cast count per file and target type. The audit compares against the
|
|
31
|
+
* baseline and fails on:
|
|
32
|
+
* • a regression (more suspect casts for a file:target than the baseline,
|
|
33
|
+
* repo-local files only)
|
|
34
|
+
* • an unknown or missing `@cast-boundary` reason in a repo-local file
|
|
35
|
+
* • an unreadable or format-drifted baseline
|
|
36
|
+
* Without a baseline file (e.g. consumer repos) nothing fails: warnings only.
|
|
37
|
+
* Sibling checkouts (`../`) never fail.
|
|
34
38
|
* Reductions (current < baseline) are allowed but do NOT update the baseline
|
|
35
39
|
* automatically — run `--write-baseline` after cleanup commits.
|
|
36
40
|
*
|
|
@@ -227,11 +231,16 @@ export const KNOWN_BOUNDARY_REASONS = [
|
|
|
227
231
|
"projection-table", // implicit entity-projections set `table`
|
|
228
232
|
// UI / Web
|
|
229
233
|
"visual-tree-args", // TargetRef.args is erased to generic args shape
|
|
234
|
+
// Brands / third-party gaps
|
|
235
|
+
"phantom-brand", // TenancyBrand-style phantom marker, or a conditional type keyed on one, with no runtime representation
|
|
236
|
+
"third-party-type-gap", // a library's type does not know a valid runtime value (e.g. Hono status 499)
|
|
237
|
+
"dom-event-target", // event.target narrowed to the Element the DOM guarantees
|
|
238
|
+
"feature-exports", // typeof-checked read-back of a function from untyped feature.exports
|
|
230
239
|
] as const;
|
|
231
240
|
export type BoundaryReason = (typeof KNOWN_BOUNDARY_REASONS)[number];
|
|
232
241
|
|
|
233
242
|
export function isKnownBoundaryReason(reason: string): reason is BoundaryReason {
|
|
234
|
-
return (
|
|
243
|
+
return KNOWN_BOUNDARY_REASONS.some((known) => known === reason);
|
|
235
244
|
}
|
|
236
245
|
|
|
237
246
|
export function hasBoundaryMarker(cast: AsExpression): boolean {
|
|
@@ -338,7 +347,7 @@ export function categorize(cast: AsExpression): Category {
|
|
|
338
347
|
return "suspect-general";
|
|
339
348
|
}
|
|
340
349
|
|
|
341
|
-
function collect(sf: SourceFile): Site[] {
|
|
350
|
+
export function collect(sf: SourceFile): Site[] {
|
|
342
351
|
const file = path.relative(ROOT, sf.getFilePath());
|
|
343
352
|
const sites: Site[] = [];
|
|
344
353
|
for (const cast of sf.getDescendantsOfKind(SyntaxKind.AsExpression)) {
|
|
@@ -361,7 +370,7 @@ function collect(sf: SourceFile): Site[] {
|
|
|
361
370
|
line: cast.getStartLineNumber(),
|
|
362
371
|
category,
|
|
363
372
|
source: cast.getExpression().getText().slice(0, 60),
|
|
364
|
-
target: cast.getTypeNode()?.getText().slice(0, 60) ?? "",
|
|
373
|
+
target: cast.getTypeNode()?.getText().replace(/\s+/g, " ").slice(0, 60) ?? "",
|
|
365
374
|
full: cast.getText().slice(0, 100),
|
|
366
375
|
...(reason ? { boundaryReason: reason } : {}),
|
|
367
376
|
});
|
|
@@ -467,28 +476,34 @@ function reportCasts(all: readonly Site[], scanned: number): void {
|
|
|
467
476
|
);
|
|
468
477
|
}
|
|
469
478
|
|
|
470
|
-
// Reason validation: every legit-boundary cast
|
|
471
|
-
//
|
|
472
|
-
//
|
|
473
|
-
//
|
|
474
|
-
//
|
|
475
|
-
function
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
console.log(
|
|
479
|
+
// Reason validation: every legit-boundary cast needs a known reason (reason
|
|
480
|
+
// drift like "engine-payload" vs "enginePayload" would otherwise destroy the
|
|
481
|
+
// audit's value). Extending the whitelist: add an entry to
|
|
482
|
+
// KNOWN_BOUNDARY_REASONS above. Always printed; only repo-local sites fail,
|
|
483
|
+
// and only once a baseline gates the repo (see reportBaseline).
|
|
484
|
+
function findUnknownReasons(all: readonly Site[]): Site[] {
|
|
485
|
+
return all.filter(
|
|
486
|
+
(s) =>
|
|
487
|
+
s.category === "legit-boundary" &&
|
|
488
|
+
(s.boundaryReason === undefined ||
|
|
489
|
+
s.boundaryReason === "" ||
|
|
490
|
+
!isKnownBoundaryReason(s.boundaryReason)),
|
|
491
|
+
);
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
function reportUnknownReasons(unknown: readonly Site[]): void {
|
|
495
|
+
// skip: every reason is known, nothing to report
|
|
496
|
+
if (unknown.length === 0) return;
|
|
497
|
+
console.log(`\n UNKNOWN @cast-boundary REASONS (${unknown.length}):`);
|
|
498
|
+
for (const u of unknown) {
|
|
499
|
+
console.log(` ${u.file}:${u.line} reason=${u.boundaryReason || "<missing>"}`);
|
|
491
500
|
}
|
|
501
|
+
console.log(`\n Known reasons: ${KNOWN_BOUNDARY_REASONS.join(", ")}`);
|
|
502
|
+
console.log(" New reason? Add an entry in check-as-casts.ts → KNOWN_BOUNDARY_REASONS.");
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
function isSiblingFile(file: string): boolean {
|
|
506
|
+
return file.startsWith("../");
|
|
492
507
|
}
|
|
493
508
|
|
|
494
509
|
const BASELINE_FORMAT_VERSION = 2;
|
|
@@ -557,7 +572,7 @@ function writeBaseline(all: readonly Site[]): void {
|
|
|
557
572
|
|
|
558
573
|
// Per File:target: aktueller Count gegen baseline. Mehr → Regression.
|
|
559
574
|
// A cast swap (cast A removed, cast B with a different target added) is detected
|
|
560
|
-
// this way although the total stays stable.
|
|
575
|
+
// this way although the total stays stable.
|
|
561
576
|
// Merge-conflict markers, truncated writes and hand edits all land here; the
|
|
562
577
|
// file is read once at the boundary so the comparison below can trust its shape.
|
|
563
578
|
function isCountsByFile(value: unknown): value is Record<string, Record<string, number>> {
|
|
@@ -635,6 +650,11 @@ function compareCastsToBaseline(
|
|
|
635
650
|
return { regressions, reduced };
|
|
636
651
|
}
|
|
637
652
|
|
|
653
|
+
const CAST_OPTIONS_HINT =
|
|
654
|
+
"New casts need a justification. Options: (1) avoid the cast (TypeGuard, Discriminated Union, typing at the source); " +
|
|
655
|
+
"(2) a real system boundary: `// @cast-boundary <reason>` with a reason from KNOWN_BOUNDARY_REASONS; " +
|
|
656
|
+
"(3) re-freeze with `bun packages/guards/src/check-as-casts.ts --write-baseline`, only with a justification in the PR.";
|
|
657
|
+
|
|
638
658
|
function printCastRegressions(regressions: readonly CastRegression[]): void {
|
|
639
659
|
console.log(
|
|
640
660
|
`\n REGRESSION: ${regressions.length} (file, target) pair(s) have more suspect casts than the baseline:`,
|
|
@@ -644,13 +664,32 @@ function printCastRegressions(regressions: readonly CastRegression[]): void {
|
|
|
644
664
|
` ${r.file}: as ${r.target} baseline=${r.baseline} current=${r.current} (+${r.current - r.baseline})`,
|
|
645
665
|
);
|
|
646
666
|
}
|
|
647
|
-
console.log(
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
667
|
+
console.log(`\n${CAST_OPTIONS_HINT}`);
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
function regressionViolation(regression: CastRegression, all: readonly Site[]): GuardViolation {
|
|
671
|
+
const firstNew = all
|
|
672
|
+
.filter(
|
|
673
|
+
(s) =>
|
|
674
|
+
s.file === regression.file &&
|
|
675
|
+
s.target === regression.target &&
|
|
676
|
+
s.category.startsWith("suspect-"),
|
|
677
|
+
)
|
|
678
|
+
.sort((a, b) => a.line - b.line)[regression.baseline];
|
|
679
|
+
return {
|
|
680
|
+
file: regression.file,
|
|
681
|
+
line: firstNew?.line ?? 1,
|
|
682
|
+
message: `Suspect cast regression: \`as ${regression.target}\` baseline=${regression.baseline}, current=${regression.current}. ${CAST_OPTIONS_HINT}`,
|
|
683
|
+
};
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
function unknownReasonViolation(site: Site): GuardViolation {
|
|
687
|
+
const reason = site.boundaryReason || "<missing>";
|
|
688
|
+
return {
|
|
689
|
+
file: site.file,
|
|
690
|
+
line: site.line,
|
|
691
|
+
message: `Unknown @cast-boundary reason ${reason}. Use one of: ${KNOWN_BOUNDARY_REASONS.join(", ")} (or add a reason to KNOWN_BOUNDARY_REASONS in check-as-casts.ts).`,
|
|
692
|
+
};
|
|
654
693
|
}
|
|
655
694
|
|
|
656
695
|
export function reportBaseline(
|
|
@@ -678,19 +717,27 @@ export function reportBaseline(
|
|
|
678
717
|
];
|
|
679
718
|
}
|
|
680
719
|
if (loaded.kind === "format-drift") {
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
720
|
+
// A stale baseline must not silently switch the gate off.
|
|
721
|
+
return [
|
|
722
|
+
{
|
|
723
|
+
file: baselineFile,
|
|
724
|
+
line: 1,
|
|
725
|
+
message: `Cast baseline format drift: expected format=${BASELINE_FORMAT_VERSION}, read format=${String(loaded.format ?? "<missing>")}. Regenerate it with \`bun packages/guards/src/check-as-casts.ts --write-baseline\`.`,
|
|
726
|
+
},
|
|
727
|
+
];
|
|
686
728
|
}
|
|
687
729
|
const { baseline } = loaded;
|
|
688
730
|
|
|
689
731
|
const { regressions, reduced } = compareCastsToBaseline(repoLocalSuspects, baseline.perFile);
|
|
690
732
|
if (regressions.length > 0) {
|
|
691
733
|
printCastRegressions(regressions);
|
|
692
|
-
return [];
|
|
693
734
|
}
|
|
735
|
+
const unknownReasons = findUnknownReasons(all).filter((s) => !isSiblingFile(s.file));
|
|
736
|
+
const violations = [
|
|
737
|
+
...regressions.map((r) => regressionViolation(r, all)),
|
|
738
|
+
...unknownReasons.map(unknownReasonViolation),
|
|
739
|
+
];
|
|
740
|
+
if (violations.length > 0) return violations;
|
|
694
741
|
|
|
695
742
|
console.log(`\n ✓ Baseline (${baseline.totalSuspect}) — current ${repoLocalTotal}`);
|
|
696
743
|
if (reduced > 0) {
|
|
@@ -704,14 +751,13 @@ export function reportBaseline(
|
|
|
704
751
|
function analyseCasts(files: readonly SourceFile[], compareBaseline: boolean): GuardOutcome {
|
|
705
752
|
const { all, scanned } = scanCasts(files);
|
|
706
753
|
reportCasts(all, scanned);
|
|
707
|
-
reportUnknownReasons(all);
|
|
754
|
+
reportUnknownReasons(findUnknownReasons(all));
|
|
708
755
|
if (!compareBaseline) {
|
|
709
756
|
console.log("\n Baseline comparison skipped (--no-baseline).");
|
|
710
757
|
return { violations: [] };
|
|
711
758
|
}
|
|
712
|
-
//
|
|
713
|
-
//
|
|
714
|
-
// an unreadable baseline fails.
|
|
759
|
+
// Fails on regressions, repo-local unknown reasons (both only with a valid
|
|
760
|
+
// baseline) and an unreadable or drifted baseline; see the module header.
|
|
715
761
|
return { violations: reportBaseline(all) };
|
|
716
762
|
}
|
|
717
763
|
|
package/src/check-complexity.ts
CHANGED
|
@@ -145,7 +145,7 @@ export function computeComplexity(fn: Node): number {
|
|
|
145
145
|
fn.forEachDescendant((node, traversal) => {
|
|
146
146
|
// skip: decision points inside a nested function-like node belong to its
|
|
147
147
|
// own Hotspot entry
|
|
148
|
-
if (node !== fn && (
|
|
148
|
+
if (node !== fn && FUNCTION_KINDS.some((kind) => kind === node.getKind())) {
|
|
149
149
|
traversal.skip();
|
|
150
150
|
return;
|
|
151
151
|
}
|
|
@@ -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
|
+
}
|