@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.
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env bun
2
2
 
3
3
  /**
4
- * `as X` cast audit with a baseline regression report (WARNING, no fail).
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 baseline and
31
- * reports increases — per the project coding standards ("Type Assertions")
32
- * this check is "warning, no fail": it never blocks, not even on
33
- * unknown @cast-boundary reasons or a baseline regression.
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 (KNOWN_BOUNDARY_REASONS as readonly string[]).includes(reason);
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 should have a known reason.
471
- // Unknown reasons are reported (reason drift like "engine-payload" vs
472
- // "enginePayload" would otherwise destroy the audit's value in the long
473
- // run) — extending the whitelist: add an entry to KNOWN_BOUNDARY_REASONS
474
- // above. Warning, no fail (see module header).
475
- function reportUnknownReasons(all: readonly Site[]): void {
476
- const unknownReasons: Array<{ file: string; line: number; reason: string }> = [];
477
- for (const s of all) {
478
- if (s.category !== "legit-boundary") continue;
479
- const r = s.boundaryReason ?? "";
480
- if (r === "" || !isKnownBoundaryReason(r)) {
481
- unknownReasons.push({ file: s.file, line: s.line, reason: r || "<missing>" });
482
- }
483
- }
484
- if (unknownReasons.length > 0) {
485
- console.log(`\n UNKNOWN @cast-boundary REASONS (${unknownReasons.length}):`);
486
- for (const u of unknownReasons) {
487
- console.log(` ${u.file}:${u.line} reason=${u.reason}`);
488
- }
489
- console.log(`\n Known reasons: ${KNOWN_BOUNDARY_REASONS.join(", ")}`);
490
- console.log(" New reason? Add an entry in check-as-casts.ts → KNOWN_BOUNDARY_REASONS.");
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. Report only — warning, no fail.
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
- "\n New casts need a justification. Options:\n" +
649
- " 1. Avoid the cast (TypeGuard, Discriminated Union, better typing at the source)\n" +
650
- " 2. If a legit system boundary: add a `// @cast-boundary <reason>` marker\n" +
651
- " 3. If a cleanup reduction in one file offsets an increase in another: " +
652
- "run `bun packages/guards/src/check-as-casts.ts --write-baseline` after committing",
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
- console.log(
682
- `\n Baseline format drift: expected format=${BASELINE_FORMAT_VERSION}, read format=${String(loaded.format ?? "<missing>")}.`,
683
- );
684
- console.log(" Run `bun packages/guards/src/check-as-casts.ts --write-baseline` once.");
685
- return [];
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
- // WARNING, no fail (coding-standards.md → "Type Assertions"): neither
713
- // unknown @cast-boundary reasons nor a baseline regression block. Only
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
 
@@ -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 && (FUNCTION_KINDS as readonly SyntaxKind[]).includes(node.getKind())) {
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
+ }