@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.
@@ -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("No repo root resolved — nothing to baseline.");
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);
@@ -0,0 +1,182 @@
1
+ // Security check: `bun audit` over the lockfile of ONE repo.
2
+ //
3
+ // Single-repo by design: audits the bun.lock in the scanned repo's root. A repo without its
4
+ // own bun.lock falls back to the nearest ancestor's (a parent workspace), announced loudly.
5
+ // Critical/high advisories block unless covered by a short-lived exception; so does any
6
+ // exception expiring within EXPIRY_BLOCK_DAYS. Exceptions merge the bundled
7
+ // dependency-security-exceptions.json with an optional file of the same name in the repo root
8
+ // (and in the ancestor holding the lockfile, when that is not the repo root).
9
+ import { existsSync } from "node:fs";
10
+ import { dirname, join } from "node:path";
11
+ import { parseDependencyCli } from "./_lib/dependency-cli";
12
+ import { expiryEndMs, findExpiringSoon, loadExceptionEntries } from "./_lib/dependency-exceptions";
13
+ import {
14
+ type AuditAdvisory,
15
+ evaluateSecurity,
16
+ parseAuditReport,
17
+ type RawAuditReport,
18
+ runBunAudit,
19
+ type SecurityEvaluation,
20
+ type SecurityException,
21
+ } from "./_lib/security-audit";
22
+
23
+ export const SECURITY_EXCEPTIONS_FILE = "dependency-security-exceptions.json";
24
+
25
+ // Block a PR while there is still runway to act, not on the day the advisory re-blocks;
26
+ // the 30-day console warning is invisible in green CI logs.
27
+ export const EXPIRY_BLOCK_DAYS = 14;
28
+
29
+ export function loadSecurityExceptions(
30
+ roots: readonly string[],
31
+ bundledDir?: string,
32
+ ): SecurityException[] {
33
+ return loadExceptionEntries({
34
+ filename: SECURITY_EXCEPTIONS_FILE,
35
+ roots,
36
+ bundledDir,
37
+ parse: (field) => ({
38
+ advisory: field("advisory"),
39
+ package: field("package"),
40
+ reason: field("reason"),
41
+ reviewedBy: field("reviewedBy"),
42
+ expires: field("expires"),
43
+ }),
44
+ }).filter((exception) => {
45
+ const hasDate = expiryEndMs(exception.expires) !== undefined;
46
+ if (!hasDate) {
47
+ console.warn(
48
+ ` WARN: security exception ${exception.advisory} dropped, expires "${exception.expires}" is not a YYYY-MM-DD date. Set expires to a date like 2026-12-31.`,
49
+ );
50
+ }
51
+ return hasDate;
52
+ });
53
+ }
54
+
55
+ /** Directory whose bun.lock `bun audit` runs against: the repo root, else the nearest ancestor with one. */
56
+ export function findLockfileDir(repoRoot: string): string | undefined {
57
+ for (let dir = repoRoot; ; dir = dirname(dir)) {
58
+ if (existsSync(join(dir, "bun.lock"))) return dir;
59
+ if (dirname(dir) === dir) return undefined;
60
+ }
61
+ }
62
+
63
+ export function shouldBlock(
64
+ evaluation: Pick<SecurityEvaluation, "blocking">,
65
+ expiringUrgent: readonly SecurityException[],
66
+ ): boolean {
67
+ return evaluation.blocking.length > 0 || expiringUrgent.length > 0;
68
+ }
69
+
70
+ function printReport(
71
+ evaluation: SecurityEvaluation,
72
+ exceptions: readonly SecurityException[],
73
+ expiringUrgent: readonly SecurityException[],
74
+ nowMs: number,
75
+ ): void {
76
+ const { counts, blocking, warnings, suppressed } = evaluation;
77
+ const total = counts.critical + counts.high + counts.moderate + counts.low + counts.info;
78
+ console.log(
79
+ ` ${total} advisories — critical: ${counts.critical} high: ${counts.high} moderate: ${counts.moderate} low: ${counts.low}`,
80
+ );
81
+
82
+ if (blocking.length > 0) {
83
+ console.error("\n BLOCKING vulnerabilities:\n");
84
+ for (const adv of blocking) {
85
+ console.error(
86
+ ` [${adv.severity.toUpperCase()}] ${adv.package} ${adv.advisoryId} ${adv.title}`,
87
+ );
88
+ }
89
+ }
90
+
91
+ if (warnings.length > 0) {
92
+ console.log("\n WARN (moderate, not blocking):");
93
+ for (const adv of warnings) {
94
+ console.log(` [MODERATE] ${adv.package} ${adv.advisoryId} ${adv.title}`);
95
+ }
96
+ }
97
+
98
+ if (suppressed.length > 0) {
99
+ console.log(`\n Suppressed via exception (${suppressed.length}):`);
100
+ for (const adv of suppressed.slice(0, 10)) {
101
+ console.log(` [${adv.severity}] ${adv.package} ${adv.advisoryId}`);
102
+ }
103
+ if (suppressed.length > 10) console.log(` … and ${suppressed.length - 10} more`);
104
+ }
105
+
106
+ if (expiringUrgent.length > 0) {
107
+ console.error(
108
+ `\n BLOCKING: security exceptions expire within ${EXPIRY_BLOCK_DAYS} days — bump the dependency or re-review the exception now:`,
109
+ );
110
+ for (const ex of expiringUrgent) {
111
+ console.error(` ${ex.package} ${ex.advisory} expires=${ex.expires}`);
112
+ }
113
+ }
114
+
115
+ const urgentKeys = new Set(expiringUrgent.map((ex) => `${ex.package}|${ex.advisory}`));
116
+ const expiringSoon = findExpiringSoon(exceptions, 30, nowMs).filter(
117
+ (ex) => !urgentKeys.has(`${ex.package}|${ex.advisory}`),
118
+ );
119
+ if (expiringSoon.length > 0) {
120
+ console.log("\n WARN: security exceptions expire within 30 days — re-review:");
121
+ for (const ex of expiringSoon) {
122
+ console.log(` ${ex.package} ${ex.advisory} expires=${ex.expires}`);
123
+ }
124
+ }
125
+
126
+ if (shouldBlock(evaluation, expiringUrgent)) {
127
+ console.error(
128
+ `\n Fix dependencies or add short-lived exceptions in ${SECURITY_EXCEPTIONS_FILE} in the repo root\n`,
129
+ );
130
+ }
131
+ }
132
+
133
+ export async function runSecurityCheck(options: {
134
+ readonly repoRoot: string;
135
+ readonly reportOnly: boolean;
136
+ readonly runAudit?: (lockfileDir: string) => Promise<RawAuditReport>;
137
+ readonly exceptions?: readonly SecurityException[];
138
+ readonly nowMs?: number;
139
+ }): Promise<number> {
140
+ const nowMs = options.nowMs ?? Date.now();
141
+ const lockfileDir = findLockfileDir(options.repoRoot);
142
+ if (lockfileDir === undefined) {
143
+ console.error(
144
+ ` security: no bun.lock found in ${options.repoRoot} or any ancestor — nothing to audit. Run bun install to create bun.lock.`,
145
+ );
146
+ return 1;
147
+ }
148
+ if (lockfileDir !== options.repoRoot) {
149
+ console.warn(
150
+ ` WARN: ${options.repoRoot} has no bun.lock — auditing ${lockfileDir}/bun.lock instead (it may cover more than this repo).`,
151
+ );
152
+ }
153
+
154
+ const exceptions = options.exceptions ?? loadSecurityExceptions([options.repoRoot, lockfileDir]);
155
+ console.log(`Security audit: scanning ${lockfileDir} …`);
156
+ const raw = await (options.runAudit ?? runBunAudit)(lockfileDir);
157
+ const advisories: AuditAdvisory[] = parseAuditReport(raw);
158
+
159
+ const evaluation = evaluateSecurity(advisories, exceptions, nowMs);
160
+ const expiringUrgent = findExpiringSoon(exceptions, EXPIRY_BLOCK_DAYS, nowMs);
161
+ printReport(evaluation, exceptions, expiringUrgent, nowMs);
162
+
163
+ if (options.reportOnly) return 0;
164
+ return shouldBlock(evaluation, expiringUrgent) ? 1 : 0;
165
+ }
166
+
167
+ export async function runSecurityCli(
168
+ argv: readonly string[],
169
+ cwd: string = process.cwd(),
170
+ ): Promise<number> {
171
+ const invocation = parseDependencyCli("security", argv, cwd);
172
+ if (!invocation.ok) return invocation.exitCode;
173
+ try {
174
+ return await runSecurityCheck({
175
+ repoRoot: invocation.repoRoot,
176
+ reportOnly: invocation.reportOnly,
177
+ });
178
+ } catch (err) {
179
+ console.error("Security check failed:", err);
180
+ return 1;
181
+ }
182
+ }
@@ -37,9 +37,15 @@ function readRuntimeMarker(rootAbsPath: string, packageName: string): string | u
37
37
  const pkgPath = join(dir, "node_modules", packageName, "package.json");
38
38
  if (existsSync(pkgPath)) {
39
39
  try {
40
- const marker = (
41
- JSON.parse(readFileSync(pkgPath, "utf8")) as { kumiko?: { runtime?: unknown } }
42
- ).kumiko?.runtime; // @cast-boundary package-json
40
+ const manifest: unknown = JSON.parse(readFileSync(pkgPath, "utf8"));
41
+ const kumikoBlock =
42
+ typeof manifest === "object" && manifest !== null && "kumiko" in manifest
43
+ ? manifest.kumiko
44
+ : undefined;
45
+ const marker =
46
+ typeof kumikoBlock === "object" && kumikoBlock !== null && "runtime" in kumikoBlock
47
+ ? kumikoBlock.runtime
48
+ : undefined;
43
49
  return typeof marker === "string" && ALL_RUNTIMES.has(marker) ? marker : undefined;
44
50
  } catch {
45
51
  return undefined;
@@ -103,7 +109,7 @@ export const check: RepoCheck = {
103
109
  violations.push({
104
110
  file: "bun.lock",
105
111
  line: 1,
106
- message: `bun.lock could not be parsed (${root.name}): ${lock.detail}`,
112
+ message: `bun.lock could not be parsed (${root.name}): ${lock.detail}. Regenerate bun.lock with bun install.`,
107
113
  });
108
114
  continue;
109
115
  }
package/src/cli.ts CHANGED
@@ -4,7 +4,11 @@
4
4
  // runners already use in their own `if (import.meta.main)` blocks — including
5
5
  // flags, which each runner's own *Cli function validates and applies so this
6
6
  // bin and the direct `bun run-*.ts` invocation can never drift apart.
7
+ import { DEPENDENCY_CHECK_FLAGS } from "./_lib/dependency-cli";
7
8
  import { buildGuardKitInventory, cliFlagsError } from "./_lib/guard-kit";
9
+ import { runLicensesCli } from "./check-licenses";
10
+ import { runOutdatedCli } from "./check-outdated";
11
+ import { runSecurityCli } from "./check-security";
8
12
  import { COMMENT_LANG_FLAGS, runCommentLangCli } from "./guard-comment-lang";
9
13
  import {
10
14
  PUBLIC_EXPORTS_CHANGELOG_FLAGS,
@@ -20,6 +24,9 @@ const SUBCOMMANDS = [
20
24
  "checks",
21
25
  "comment-lang",
22
26
  "public-exports-changelog",
27
+ "licenses",
28
+ "security",
29
+ "outdated",
23
30
  "list",
24
31
  ] as const;
25
32
  type Subcommand = (typeof SUBCOMMANDS)[number];
@@ -30,16 +37,19 @@ const SUBCOMMAND_FLAGS: Record<Subcommand, readonly string[]> = {
30
37
  checks: REPO_CHECK_FLAGS,
31
38
  "comment-lang": [...COMMENT_LANG_FLAGS, "--base=<ref>"],
32
39
  "public-exports-changelog": PUBLIC_EXPORTS_CHANGELOG_FLAGS,
40
+ licenses: DEPENDENCY_CHECK_FLAGS,
41
+ security: DEPENDENCY_CHECK_FLAGS,
42
+ outdated: DEPENDENCY_CHECK_FLAGS,
33
43
  list: [],
34
44
  };
35
45
 
36
46
  function isSubcommand(value: string): value is Subcommand {
37
- return (SUBCOMMANDS as readonly string[]).includes(value);
47
+ return SUBCOMMANDS.some((subcommand) => subcommand === value);
38
48
  }
39
49
 
40
50
  function printHelp(): void {
41
51
  console.log(
42
- "Usage: kumiko-guards [guards|ui|checks|comment-lang|public-exports-changelog|list] [flags]",
52
+ "Usage: kumiko-guards [guards|ui|checks|comment-lang|public-exports-changelog|licenses|security|outdated|list] [flags]",
43
53
  );
44
54
  console.log();
45
55
  for (const sub of SUBCOMMANDS) {
@@ -91,6 +101,17 @@ function printInventory(flags: readonly string[]): void {
91
101
  console.log(JSON.stringify(inventory));
92
102
  }
93
103
 
104
+ const STANDALONE_SUBCOMMANDS: Record<
105
+ string,
106
+ (flags: readonly string[]) => number | Promise<number>
107
+ > = {
108
+ "comment-lang": runCommentLangCli,
109
+ "public-exports-changelog": runPublicExportsChangelogCli,
110
+ licenses: (flags) => runLicensesCli(flags),
111
+ security: (flags) => runSecurityCli(flags),
112
+ outdated: (flags) => runOutdatedCli(flags),
113
+ };
114
+
94
115
  async function main(): Promise<void> {
95
116
  const { subcommand, flags } = parseInvocation(process.argv);
96
117
 
@@ -101,14 +122,10 @@ async function main(): Promise<void> {
101
122
  }
102
123
 
103
124
  // Not part of the no-subcommand run: comment-lang needs a baseline or a git base ref,
104
- // public-exports-changelog only applies to the kumiko-framework repo.
105
- if (subcommand === "comment-lang") {
106
- process.exit(runCommentLangCli(flags));
107
- }
108
-
109
- if (subcommand === "public-exports-changelog") {
110
- process.exit(await runPublicExportsChangelogCli(flags));
111
- }
125
+ // public-exports-changelog only applies to the kumiko-framework repo, and
126
+ // licenses/security/outdated call the npm registry or `bun audit` (network).
127
+ const standalone = subcommand === undefined ? undefined : STANDALONE_SUBCOMMANDS[subcommand];
128
+ if (standalone) process.exit(await standalone(flags));
112
129
 
113
130
  let failed = 0;
114
131
  if (subcommand === undefined || subcommand === "guards") {
@@ -0,0 +1,77 @@
1
+ {
2
+ "$schema": "Exceptions for check-licenses.ts. Each exception needs a reason, a reviewer and an expiry date.",
3
+ "exceptions": [
4
+ {
5
+ "package": "lightningcss",
6
+ "version": "*",
7
+ "license": "MPL-2.0",
8
+ "reason": "Transitive Dependency via Expo/Metro CSS-Tooling. MPL-2.0 ist file-level Copyleft — betrifft nur Modifikationen an lightningcss selbst, nicht unseren Code. Nur als Dependency genutzt, nicht modifiziert.",
9
+ "reviewedBy": "Marc, 2026-04-15",
10
+ "expires": "2027-04-15"
11
+ },
12
+ {
13
+ "package": "lightningcss-*",
14
+ "version": "*",
15
+ "license": "MPL-2.0",
16
+ "reason": "Platform-Binaries zu lightningcss (darwin-arm64/darwin-x64/linux-x64-gnu/linux-x64-musl/linux-arm64-gnu/linux-arm64-musl/linux-arm-gnueabihf/android-arm64/freebsd-x64/win32-x64-msvc/win32-arm64-msvc). Wildcard-Match — neue Plattform-Binary kommt automatisch durch ohne weiteren Eintrag.",
17
+ "reviewedBy": "Marc, 2026-04-26",
18
+ "expires": "2027-04-26"
19
+ },
20
+ {
21
+ "package": "spdx-exceptions",
22
+ "version": "*",
23
+ "license": "CC-BY-3.0",
24
+ "reason": "Transitive Dep via SPDX-License-Tooling selbst. CC-BY-3.0 deckt nur die SPDX-Exception-Daten (eine Liste von License-IDs), keine Code-Verteilung — wir nutzen das Package nur zur Laufzeit für Dep-Scanning, kein Re-Distribute.",
25
+ "reviewedBy": "Marc, 2026-05-01",
26
+ "expires": "2027-05-01"
27
+ },
28
+ {
29
+ "package": "@logdna/tail-file",
30
+ "version": "*",
31
+ "license": "SEE LICENSE IN LICENSE",
32
+ "reason": "License-File ist im Package selbst (LICENSE), npm-metadata zeigt nur den Pointer. Apache-2.0 ist die echte Lizenz (siehe node_modules/@logdna/tail-file/LICENSE). Transitive Dep.",
33
+ "reviewedBy": "Marc, 2026-05-01",
34
+ "expires": "2027-05-01"
35
+ },
36
+ {
37
+ "package": "spawndamnit",
38
+ "version": "*",
39
+ "license": "SEE LICENSE IN LICENSE",
40
+ "reason": "Transitive Dep via @changesets/cli. License-File im Package: MIT (Copyright James Kyle, Mitautor von Changesets). npm-metadata zeigt nur den Pointer. Pattern wie @logdna/tail-file.",
41
+ "reviewedBy": "Marc, 2026-05-06",
42
+ "expires": "2027-05-06"
43
+ },
44
+ {
45
+ "package": "@cosmicdriftgamestudio/*",
46
+ "version": "*",
47
+ "license": "UNLICENSED",
48
+ "reason": "Eigene private CDGS-Workspace-Packages (kumiko-guards, später kumiko-ai-*, kumiko-designer etc.). UNLICENSED = proprietary, kein Public-License-Risk weil intern in der Org bleibt (GH-Packages access:restricted).",
49
+ "reviewedBy": "Marc, 2026-05-06",
50
+ "expires": "2030-05-06"
51
+ },
52
+ {
53
+ "package": "@cosmicdrift/*",
54
+ "version": "*",
55
+ "license": "BUSL-1.1",
56
+ "reason": "Eigene Public-Workspace-Packages (kumiko-framework + 6 weitere). Wir sind der Licensor — BUSL-1.1 ist unser eigener Lizenz-Choice für die public NPM-Pakete. Der License-Check denied BUSL-1.1 als generic-Pattern (für Third-Party-Deps korrekt), aber für eigene Packages ist's der designierte Lizenz.",
57
+ "reviewedBy": "Marc, 2026-05-06",
58
+ "expires": "2030-05-06"
59
+ },
60
+ {
61
+ "package": "create-kumiko-app",
62
+ "version": "*",
63
+ "license": "BUSL-1.1",
64
+ "reason": "Unscoped Workspace-Paket — `bun create kumiko-app` resolved zu `bunx create-kumiko-app`, daher KEIN @cosmicdrift/-Scope möglich. Analog zur @cosmicdrift/*-Exception unser eigener Lizenz-Choice; eigene Eintrag weil Wildcard-Pattern unscoped Pakete nicht matched.",
65
+ "reviewedBy": "Marc, 2026-06-21",
66
+ "expires": "2030-06-21"
67
+ },
68
+ {
69
+ "package": "khroma",
70
+ "version": "*",
71
+ "license": "MIT",
72
+ "reason": "Transitive dep via mermaid (docs). npm metadata reports license=null; package LICENSE is MIT.",
73
+ "reviewedBy": "Marc, 2026-07-06",
74
+ "expires": "2027-07-06"
75
+ }
76
+ ]
77
+ }
@@ -0,0 +1,4 @@
1
+ {
2
+ "$schema": "Exceptions file for check-security.ts. Each exception needs reason, reviewer, and expiry (max ~30 days).",
3
+ "exceptions": []
4
+ }
@@ -20,7 +20,7 @@
20
20
  */
21
21
 
22
22
  import * as path from "node:path";
23
- import { type CallExpression, type Identifier, type SourceFile, SyntaxKind } from "ts-morph";
23
+ import { type CallExpression, type SourceFile, SyntaxKind } from "ts-morph";
24
24
  import { findRepoRootFor } from "./_lib/baseline-compare";
25
25
  import { ALL_REPO_KINDS, type AstGuard, runStandalone, type ScanSpec } from "./_lib/guard-kit";
26
26
  import { type RepoRoot, resolveRepoRoots } from "./_lib/roots";
@@ -84,8 +84,8 @@ function isAllowed(relativePath: string): boolean {
84
84
  // references to the known function names.
85
85
  function getCalleeName(call: CallExpression): string | null {
86
86
  const expr = call.getExpression();
87
- if (expr.getKind() === SyntaxKind.Identifier) {
88
- return (expr as Identifier).getText();
87
+ if (expr.isKind(SyntaxKind.Identifier)) {
88
+ return expr.getText();
89
89
  }
90
90
  if (expr.getKind() === SyntaxKind.PropertyAccessExpression) {
91
91
  const name = expr.asKindOrThrow(SyntaxKind.PropertyAccessExpression).getName();