@cosmicdrift/kumiko-guards 0.387.0 → 0.389.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 CHANGED
@@ -77,8 +77,9 @@ workspace it runs a tracked `scripts/check-wt.sh` if present, otherwise the
77
77
  worktree's own `package.json` scripts `typecheck`, `lint`, `test`, and
78
78
  `test:dom` (whichever are declared), directly in the worktree — the parent's
79
79
  `bun check` would otherwise check the main checkout instead. There is no
80
- bypass switch. A `check-wt.sh` that wraps this hook sets `KUMIKO_CHECK_WT=1`
81
- so the hook does not call it again.
80
+ bypass switch. A `check-wt.sh` that wraps this hook (via `exec` or as a child
81
+ process) is recognised by process ancestry, so the hook does not call it
82
+ again; an exported variable cannot skip it.
82
83
 
83
84
  Extension point: a tracked, executable `scripts/pre-push-extra.sh` runs
84
85
  before the main check in every branch (worktree, parent-scoped, and
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-guards",
3
- "version": "0.387.0",
3
+ "version": "0.389.0",
4
4
  "description": "AST-based security guards for Kumiko repos: direct-fs/fetch, tenant escalation, admin-API, escape hatches and related checks, run over a shared ts-morph project.",
5
5
  "license": "MIT",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -51,7 +51,7 @@
51
51
  "LICENSE"
52
52
  ],
53
53
  "peerDependencies": {
54
- "@cosmicdrift/kumiko-framework": "0.387.0"
54
+ "@cosmicdrift/kumiko-framework": "0.389.0"
55
55
  },
56
56
  "peerDependenciesMeta": {
57
57
  "@cosmicdrift/kumiko-framework": {
@@ -59,6 +59,6 @@
59
59
  }
60
60
  },
61
61
  "devDependencies": {
62
- "@cosmicdrift/kumiko-framework": "0.387.0"
62
+ "@cosmicdrift/kumiko-framework": "0.389.0"
63
63
  }
64
64
  }
@@ -336,14 +336,21 @@ function renderExamplesIndex(recipes: readonly Recipe[]): string {
336
336
  );
337
337
  }
338
338
 
339
+ const RECIPE_SKIPPED_DIRS = new Set(["node_modules", "screenshots", "dist"]);
340
+ const GENERATED_FILE = /\.generated\./;
341
+
339
342
  function listSourceFiles(dir: string, prefix = ""): string[] {
340
343
  if (!existsSync(dir)) return [];
341
344
  const found: string[] = [];
342
345
  for (const entry of readdirSync(dir, { withFileTypes: true })) {
343
346
  const rel = prefix === "" ? entry.name : `${prefix}/${entry.name}`;
344
347
  if (entry.isDirectory()) {
345
- found.push(...listSourceFiles(join(dir, entry.name), rel));
346
- } else if (SOURCE_EXTENSIONS.some((extension) => entry.name.endsWith(extension))) {
348
+ if (!RECIPE_SKIPPED_DIRS.has(entry.name))
349
+ found.push(...listSourceFiles(join(dir, entry.name), rel));
350
+ } else if (
351
+ SOURCE_EXTENSIONS.some((extension) => entry.name.endsWith(extension)) &&
352
+ !GENERATED_FILE.test(entry.name)
353
+ ) {
347
354
  found.push(rel);
348
355
  }
349
356
  }
@@ -362,13 +369,11 @@ function renderRecipe(recipe: Recipe): string {
362
369
  if (existsSync(readmePath)) {
363
370
  parts.push(`## README\n\n${readFileSync(readmePath, "utf-8").replace(/\r\n/g, "\n").trim()}`);
364
371
  }
365
- const sourceBlocks = listSourceFiles(join(recipe.dir, "src")).map((rel) => {
366
- const content = readFileSync(join(recipe.dir, "src", rel), "utf-8")
367
- .replace(/\r\n/g, "\n")
368
- .trimEnd();
372
+ const sourceBlocks = listSourceFiles(recipe.dir).map((rel) => {
373
+ const content = readFileSync(join(recipe.dir, rel), "utf-8").replace(/\r\n/g, "\n").trimEnd();
369
374
  const fence = fenceFor(content);
370
375
  const language = rel.endsWith(".tsx") ? "tsx" : "ts";
371
- return `### src/${rel}\n\n${fence}${language}\n${content}\n${fence}`;
376
+ return `### ${rel}\n\n${fence}${language}\n${content}\n${fence}`;
372
377
  });
373
378
  if (sourceBlocks.length > 0) parts.push(`## Source files\n\n${sourceBlocks.join("\n\n")}`);
374
379
  return finishFile(`${RECIPES_REL}/${recipe.slug}`, parts.join("\n\n"));
@@ -34,10 +34,13 @@ function headerComment(source: string): string {
34
34
  return body.join("\n");
35
35
  }
36
36
 
37
+ // Abbreviations that end in a period but not the sentence.
38
+ const SENTENCE_END_RE = /(?<!\be\.g|\bi\.e|\bresp|\bz\. B|\bvs|\betc)\.(\s|$)/;
39
+
37
40
  function purposeOf(source: string): string {
38
41
  const paragraph = headerComment(source).split(/\n\s*\n/)[0] ?? "";
39
42
  const flat = paragraph.replace(/\s+/g, " ").trim();
40
- const sentenceEnd = flat.search(/\.(\s|$)/);
43
+ const sentenceEnd = flat.search(SENTENCE_END_RE);
41
44
  const sentence = sentenceEnd === -1 ? flat : flat.slice(0, sentenceEnd + 1);
42
45
  return sentence.replace(/\|/g, "\\|") || "-";
43
46
  }
package/src/_lib/roots.ts CHANGED
@@ -183,14 +183,18 @@ function isDerivedAppFallback(root: RepoRoot): boolean {
183
183
  return root.manifestSource === "derived" && root.kind === "app";
184
184
  }
185
185
 
186
- /** The repo of the nearest `kumiko.json` at or above `start`, which is the app root in a git tree that holds several. */
187
- function nearestManifestRepo(start: string): RepoRoot | undefined {
186
+ /**
187
+ * The repo of the nearest `kumiko.json` at or above `start`, which is the app root in a git tree
188
+ * that holds several. The walk stops at the git toplevel, so a foreign `kumiko.json` further up
189
+ * (workspace parent, home dir) never captures a repo that has none of its own.
190
+ */
191
+ function nearestManifestRepo(start: string, stopAt: string | undefined): RepoRoot | undefined {
188
192
  for (let curr = start; ; curr = dirname(curr)) {
189
193
  if (existsSync(join(curr, REPO_MANIFEST_FILE))) {
190
194
  const repo = repoAt(curr);
191
195
  if (repo) return repo;
192
196
  }
193
- if (curr === dirname(curr)) return undefined;
197
+ if (curr === stopAt || curr === dirname(curr)) return undefined;
194
198
  }
195
199
  }
196
200
 
@@ -210,9 +214,9 @@ function nearestManifestRepo(start: string): RepoRoot | undefined {
210
214
  */
211
215
  export function findLocalRepo(cwd: string = process.cwd()): RepoRoot | undefined {
212
216
  const start = resolve(cwd);
213
- const fromManifest = nearestManifestRepo(start);
214
- if (fromManifest) return fromManifest;
215
217
  const toplevel = gitToplevelOf(start);
218
+ const fromManifest = nearestManifestRepo(start, toplevel);
219
+ if (fromManifest) return fromManifest;
216
220
  const fromGit = toplevel !== undefined ? repoAt(toplevel) : undefined;
217
221
  if (fromGit) return fromGit;
218
222
  let fallback: RepoRoot | undefined;
@@ -1,11 +1,20 @@
1
- import { mkdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
1
+ import {
2
+ lstatSync,
3
+ mkdirSync,
4
+ readFileSync,
5
+ renameSync,
6
+ rmSync,
7
+ statSync,
8
+ writeFileSync,
9
+ } from "node:fs";
2
10
  import { join } from "node:path";
3
11
 
4
12
  const DEFAULT_SLOTS = 2;
5
13
  const POLL_INTERVAL_MS = 1000;
6
14
  const PIDLESS_ORPHAN_AGE_MS = 60_000;
7
15
  // Upper bound against pid reuse after SIGKILL/OOM: a reused pid would otherwise hold the slot forever.
8
- const MAX_SLOT_AGE_MS = 15 * 60_000;
16
+ // The slot mtime is never refreshed, so this must exceed the longest legitimate guard run.
17
+ const MAX_SLOT_AGE_MS = 2 * 60 * 60_000;
9
18
  const PID_FILE = "pid";
10
19
 
11
20
  type Environment = Readonly<Record<string, string | undefined>>;
@@ -31,13 +40,19 @@ export function resolveGuardSlotCount(environment: Environment): number {
31
40
  return Number(raw);
32
41
  }
33
42
 
43
+ function errorCode(error: unknown): string {
44
+ return error instanceof Error && "code" in error && typeof error.code === "string"
45
+ ? error.code
46
+ : "unknown";
47
+ }
48
+
34
49
  function defaultIsPidAlive(pid: number): boolean {
35
50
  try {
36
51
  process.kill(pid, 0);
37
52
  return true;
38
53
  } catch (error) {
39
54
  // EPERM means the process exists but belongs to another user.
40
- return error instanceof Error && "code" in error && error.code === "EPERM";
55
+ return errorCode(error) === "EPERM";
41
56
  }
42
57
  }
43
58
 
@@ -73,12 +88,6 @@ function isOrphanedSlot(
73
88
  return ageMs > PIDLESS_ORPHAN_AGE_MS;
74
89
  }
75
90
 
76
- function errorCode(error: unknown): string {
77
- return error instanceof Error && "code" in error && typeof error.code === "string"
78
- ? error.code
79
- : "unknown";
80
- }
81
-
82
91
  function tryClaimSlot(slotPath: string, pid: number): boolean {
83
92
  try {
84
93
  mkdirSync(slotPath);
@@ -148,6 +157,25 @@ function defaultSlotsDir(): string {
148
157
  return uid === undefined ? "/tmp/kumiko-guard-slots" : `/tmp/kumiko-guard-slots-${uid}`;
149
158
  }
150
159
 
160
+ // The default dir lives in a shared /tmp: another local user could pre-create it (or swap in a
161
+ // symlink) to hold slots forever or redirect the pid write.
162
+ function assertPrivateOwnDirectory(dir: string): void {
163
+ const uid = process.getuid?.();
164
+ // skip: no uid to compare against on this platform.
165
+ if (uid === undefined) return;
166
+ const stats = lstatSync(dir);
167
+ if (
168
+ stats.isSymbolicLink() ||
169
+ !stats.isDirectory() ||
170
+ stats.uid !== uid ||
171
+ (stats.mode & 0o077) !== 0
172
+ ) {
173
+ throw Object.assign(new Error(`${dir} is not a private directory owned by the current user`), {
174
+ code: "EUNSAFE",
175
+ });
176
+ }
177
+ }
178
+
151
179
  export function acquireGuardRunSlot(options: GuardRunSlotOptions = {}): () => void {
152
180
  const slotCount = resolveGuardSlotCount(options.environment ?? process.env);
153
181
  if (slotCount === 0) return () => {};
@@ -164,7 +192,8 @@ export function acquireGuardRunSlot(options: GuardRunSlotOptions = {}): () => vo
164
192
  for (;;) {
165
193
  let slotPath: string | undefined;
166
194
  try {
167
- mkdirSync(slotsDir, { recursive: true });
195
+ mkdirSync(slotsDir, { recursive: true, mode: 0o700 });
196
+ assertPrivateOwnDirectory(slotsDir);
168
197
  slotPath = claimFreeSlot(slotsDir, slotCount, pid, isPidAlive, now);
169
198
  } catch (error) {
170
199
  // A throttle must never block the push: fail open.
@@ -35,6 +35,7 @@ export type SecurityEvaluation = {
35
35
  readonly blocking: AuditAdvisory[];
36
36
  readonly warnings: AuditAdvisory[];
37
37
  readonly suppressed: AuditAdvisory[];
38
+ readonly usedExceptions: SecurityException[];
38
39
  readonly counts: Record<Severity, number>;
39
40
  };
40
41
 
@@ -90,12 +91,20 @@ function matchesAdvisory(exceptionAdvisory: string, advisoryId: string, url: str
90
91
  function findSecurityException(
91
92
  advisory: AuditAdvisory,
92
93
  exceptions: readonly SecurityException[],
94
+ nowMs: number,
93
95
  ): SecurityException | undefined {
94
- return exceptions.find(
96
+ const matching = exceptions.filter(
95
97
  (ex) =>
96
98
  matchesAdvisory(ex.advisory, advisory.advisoryId, advisory.url) &&
97
99
  (ex.package === "*" || matchesPackageName(ex.package, advisory.package)),
98
100
  );
101
+ // An expired earlier entry (e.g. bundled) must not shadow a valid later one (e.g. repo-level).
102
+ return matching.find((ex) => isSecurityExceptionActive(ex, nowMs)) ?? matching[0];
103
+ }
104
+
105
+ // isExceptionValid() treats a missing expiry as "forever valid", which is wrong for security: such an exception would suppress a critical permanently.
106
+ function isSecurityExceptionActive(exception: SecurityException, nowMs: number): boolean {
107
+ return Boolean(exception.expires) && isExceptionValid(exception.expires, nowMs).valid;
99
108
  }
100
109
 
101
110
  export function evaluateSecurity(
@@ -107,18 +116,16 @@ export function evaluateSecurity(
107
116
  const blocking: AuditAdvisory[] = [];
108
117
  const warnings: AuditAdvisory[] = [];
109
118
  const suppressed: AuditAdvisory[] = [];
119
+ const usedExceptions = new Set<SecurityException>();
110
120
 
111
121
  for (const advisory of advisories) {
112
122
  counts[advisory.severity] += 1;
113
- const exception = findSecurityException(advisory, exceptions);
114
- // isExceptionValid() treats a missing expiry as "forever valid", which is wrong for security: such an exception would suppress a critical permanently.
115
- const covered =
116
- exception !== undefined &&
117
- Boolean(exception.expires) &&
118
- isExceptionValid(exception.expires, nowMs).valid;
123
+ const exception = findSecurityException(advisory, exceptions, nowMs);
124
+ const covered = exception !== undefined && isSecurityExceptionActive(exception, nowMs);
119
125
 
120
126
  if (covered) {
121
127
  suppressed.push(advisory);
128
+ usedExceptions.add(exception);
122
129
  } else if (FAIL_SEVERITIES.has(advisory.severity)) {
123
130
  blocking.push(advisory);
124
131
  } else if (advisory.severity === "moderate") {
@@ -126,7 +133,7 @@ export function evaluateSecurity(
126
133
  }
127
134
  }
128
135
 
129
- return { blocking, warnings, suppressed, counts };
136
+ return { blocking, warnings, suppressed, usedExceptions: [...usedExceptions], counts };
130
137
  }
131
138
 
132
139
  /** True when `bytes` starts with the gzip magic header (1f 8b). */
package/src/changes.json CHANGED
@@ -1,4 +1,54 @@
1
1
  [
2
+ {
3
+ "version": "0.389.0",
4
+ "type": "improvement",
5
+ "title": "Guard against hand-made event and membership SQL in tests",
6
+ "detail": "Flags INSERT INTO / UPDATE kumiko_events and CREATE TABLE read_tenant_memberships in test files. Seed through append() or runSeedWritesAt() and unsafeCreateEntityTable with seedRow instead. Perf bulk seeds and the ordering tests that hold a transaction open are allowlisted."
7
+ },
8
+ {
9
+ "version": "0.389.0",
10
+ "type": "fix",
11
+ "title": "pre-push re-entry detection no longer an env switch",
12
+ "detail": "Before delegating to a tracked scripts/check-wt.sh the hook records its own PID in KUMIKO_CHECK_WT_OWNER and treats a run as re-entry only when that PID is an ancestor of the current process, whether check-wt.sh execs or spawns the hook. KUMIKO_CHECK_WT=1 is ignored, so it cannot skip check-wt.sh; wrappers that still set it need no change."
13
+ },
14
+ {
15
+ "version": "0.388.0",
16
+ "type": "improvement",
17
+ "title": "Complexity hard cap 40, thin-wrapper guard skips typed table slots",
18
+ "detail": "A function above complexity 40 in the repo's own files fails the complexity guard, independent of the complexity baseline and of the complexity-budget tag, also with --no-baseline. The thin-wrapper guard skips a function that is a value of an object literal with a declared type or a satisfies check, inline or referenced by name, because the table fixes its signature.",
19
+ "migration": "Split every function above complexity 40 before the bump; the complexity-budget tag and the baseline do not cover it. `bun check-complexity.ts --all --no-baseline` lists them under \"Above hard cap\"."
20
+ },
21
+ {
22
+ "version": "0.388.0",
23
+ "type": "improvement",
24
+ "title": "Complexity hard cap, --all, file-size threshold 1200",
25
+ "detail": "Functions with complexity above 60 are a violation in the repo's own files, independent of the complexity baseline and of the complexity-budget tag, also with --no-baseline. The report lists them in their own section. `bun check-complexity.ts --all` prints all hotspots in the standalone run, --write-baseline and --no-baseline. The file-size guard now freezes files above 1200 lines instead of 2000.",
26
+ "migration": "Consumer repos without a complexity baseline get a hard error for functions above complexity 60; split them. File size stays a warning there without a baseline; with one, files between 1200 and 2000 lines count as new hits until `--write-baseline` is run."
27
+ },
28
+ {
29
+ "version": "0.388.0",
30
+ "type": "fix",
31
+ "title": "Harden guards infrastructure (pre-push scan range, slot dir, root walk)",
32
+ "detail": "The pre-push secret scan covers every pushed ref instead of HEAD. The guard slot directory is created 0700 and ignored when not private; slot max age is 2 h. findLocalRepo no longer picks up a kumiko.json above the git root. Runtime-isolation keeps web/dev/ files as client."
33
+ },
34
+ {
35
+ "version": "0.388.0",
36
+ "type": "fix",
37
+ "title": "Guards report fewer false results",
38
+ "detail": "The import-extensions guard now also checks import types, mock.module and template-literal specifiers. The comment-issue-ref-only guard no longer treats file names like round2.ts as plan labels. The direct-entity-writes guard recognises r.entity registrations with a constant or shorthand name, reports direct writes on package catalog tables even when the scope has no own ES tables, keeps receiver text on one line and names the installed kumiko-guards bin in its catalog rewrite hint. The app-feature-structure guard no longer tells dev/ and scripts/ files to move under web/, and the bundled license exceptions drop the stale first-party and create-kumiko-app entries."
39
+ },
40
+ {
41
+ "version": "0.388.0",
42
+ "type": "fix",
43
+ "title": "Dependency exceptions, timeout and boundary guards no longer miss or invent findings",
44
+ "detail": "A renewed license or security exception now wins over an expired earlier one, license exceptions must match the package license and a real semver range, and only exceptions that suppressed an advisory block on near expiry. The test-timeouts guard also flags union-typed and untyped timeout arguments, the renderer-boundaries guard covers headless and live imports, import-extensions ignores real .js files without a .ts sibling, and the generated agent examples now include bin, e2e, seeds and scripts files."
45
+ },
46
+ {
47
+ "version": "0.388.0",
48
+ "type": "fix",
49
+ "title": "Guards --explain uses the injectable project builder",
50
+ "detail": "runGuardsCli builds the explain project through the same buildProject dependency as the check run, which lets tests assert that --explain takes no guard run slot without parsing the whole repository."
51
+ },
2
52
  {
3
53
  "version": "0.387.0",
4
54
  "type": "improvement",
@@ -21,12 +21,14 @@
21
21
  *
22
22
  * Opt-out: `// kumiko-lint-ignore complexity-budget <reason>` on the function
23
23
  * line, the line above, or (for JSDoc-documented functions) the line above
24
- * the JSDoc block.
24
+ * the JSDoc block. The opt-out does NOT hold above HARD_COMPLEXITY_CAP: such a
25
+ * function in the repo's own files always fails, regardless of baseline or tag.
25
26
  *
26
27
  * Usage:
27
28
  * bun guards/check-complexity.ts # compare against baseline
28
29
  * bun guards/check-complexity.ts --write-baseline # rewrite baseline
29
30
  * bun guards/check-complexity.ts --no-baseline # skip comparison
31
+ * bun guards/check-complexity.ts --all # list every hotspot, not the first 30
30
32
  */
31
33
 
32
34
  import * as path from "node:path";
@@ -58,6 +60,7 @@ const EXCLUDE = /(__tests__|\.test\.ts$|\.integration\.ts$|\.d\.ts$)/;
58
60
 
59
61
  const COMPLEXITY_THRESHOLD = 15;
60
62
  const MAX_REPORTED = 30;
63
+ export const HARD_COMPLEXITY_CAP = 40;
61
64
  const BUDGET_TAG = "kumiko-lint-ignore complexity-budget";
62
65
 
63
66
  const FUNCTION_KINDS = [
@@ -152,26 +155,49 @@ export function computeComplexity(fn: Node): number {
152
155
  return complexity;
153
156
  }
154
157
 
155
- export function collectHotspots(
158
+ export interface FunctionMetric extends Hotspot {
159
+ budgetTagged: boolean;
160
+ }
161
+
162
+ // Single walk per file: complexity is computed once and feeds both the
163
+ // hotspot list and the hard cap.
164
+ export function collectFunctionMetrics(
156
165
  sf: SourceFile,
157
166
  roots: readonly RepoRoot[] = resolveRepoRoots(),
158
- ): Hotspot[] {
167
+ ): FunctionMetric[] {
159
168
  const file = relFromRepoRoot(sf.getFilePath(), roots);
160
- const hotspots: Hotspot[] = [];
169
+ const metrics: FunctionMetric[] = [];
161
170
  for (const kind of FUNCTION_KINDS) {
162
171
  for (const fn of sf.getDescendantsOfKind(kind)) {
163
172
  const complexity = computeComplexity(fn);
164
173
  if (complexity < COMPLEXITY_THRESHOLD) continue;
165
- if (hasBudgetTag(fn)) continue;
166
- hotspots.push({
174
+ metrics.push({
167
175
  file,
168
176
  line: fn.getStartLineNumber(),
169
177
  name: functionName(fn),
170
178
  complexity,
179
+ budgetTagged: hasBudgetTag(fn),
171
180
  });
172
181
  }
173
182
  }
174
- return hotspots;
183
+ return metrics;
184
+ }
185
+
186
+ export function hotspotsOf(metrics: readonly FunctionMetric[]): Hotspot[] {
187
+ return metrics
188
+ .filter((m) => !m.budgetTagged)
189
+ .map(({ file, line, name, complexity }) => ({ file, line, name, complexity }));
190
+ }
191
+
192
+ export function capBreachesOf(metrics: readonly FunctionMetric[]): FunctionMetric[] {
193
+ return metrics.filter((m) => m.complexity > HARD_COMPLEXITY_CAP && isLocalFinding(m));
194
+ }
195
+
196
+ export function collectHotspots(
197
+ sf: SourceFile,
198
+ roots: readonly RepoRoot[] = resolveRepoRoots(),
199
+ ): Hotspot[] {
200
+ return hotspotsOf(collectFunctionMetrics(sf, roots));
175
201
  }
176
202
 
177
203
  const BASELINE_FILE = ".kumiko-complexity-baseline.json";
@@ -199,26 +225,37 @@ function scan(
199
225
  roots: readonly RepoRoot[] = resolveRepoRoots(),
200
226
  ): {
201
227
  hotspots: Hotspot[];
228
+ capBreaches: FunctionMetric[];
202
229
  scanned: number;
203
230
  } {
204
- const hotspots: Hotspot[] = [];
231
+ const metrics: FunctionMetric[] = [];
205
232
  let scanned = 0;
206
233
  for (const sf of files) {
207
234
  if (EXCLUDE.test(sf.getFilePath())) continue;
208
235
  scanned++;
209
- hotspots.push(...collectHotspots(sf, roots));
236
+ metrics.push(...collectFunctionMetrics(sf, roots));
210
237
  }
211
- hotspots.sort((a, b) => b.complexity - a.complexity);
212
- return { hotspots, scanned };
238
+ const hotspots = hotspotsOf(metrics).sort((a, b) => b.complexity - a.complexity);
239
+ const capBreaches = capBreachesOf(metrics).sort((a, b) => b.complexity - a.complexity);
240
+ return { hotspots, capBreaches, scanned };
213
241
  }
214
242
 
215
- function report(hotspots: readonly Hotspot[], scanned: number): void {
243
+ function report(
244
+ hotspots: readonly Hotspot[],
245
+ scanned: number,
246
+ capBreaches: readonly FunctionMetric[],
247
+ reportLimit: number,
248
+ ): void {
216
249
  console.log(`Complexity Check: ${scanned} files checked.`);
217
250
  console.log(` Hotspots (complexity >= ${COMPLEXITY_THRESHOLD}): ${hotspots.length}`);
251
+ console.log(` Above hard cap (complexity > ${HARD_COMPLEXITY_CAP}): ${capBreaches.length}`);
252
+ for (const b of capBreaches) {
253
+ console.log(` CAP ${b.file}:${b.line} ${b.name}() complexity=${b.complexity}`);
254
+ }
218
255
  if (hotspots.length === 0) {
219
256
  console.log(" Nothing to report.");
220
257
  } else {
221
- const shown = hotspots.slice(0, MAX_REPORTED);
258
+ const shown = hotspots.slice(0, reportLimit);
222
259
  for (const h of shown) {
223
260
  console.log(` ${h.file}:${h.line} ${h.name}() complexity=${h.complexity}`);
224
261
  }
@@ -231,12 +268,25 @@ function report(hotspots: readonly Hotspot[], scanned: number): void {
231
268
  }
232
269
  }
233
270
 
271
+ type Ratchet = ReturnType<typeof baselineRatchet>;
272
+
234
273
  const complexityBaseline = baselineRatchet({
235
274
  file: path.join(ROOT, BASELINE_FILE),
236
275
  formatVersion: BASELINE_FORMAT_VERSION,
237
276
  unit: "Complexity-Hotspot(s)",
238
277
  });
239
278
 
279
+ function capViolations(capBreaches: readonly FunctionMetric[]): GuardViolation[] {
280
+ return capBreaches.map((b) => ({
281
+ file: b.file,
282
+ line: b.line,
283
+ neverFrozen: true,
284
+ message:
285
+ `${b.name}() has complexity ${b.complexity}, above the hard cap of ${HARD_COMPLEXITY_CAP}. ` +
286
+ `A "${BUDGET_TAG}" tag and the baseline do not apply above the cap — split the function.`,
287
+ }));
288
+ }
289
+
240
290
  // `scan` sorts by complexity descending, so the first hit for a file is its
241
291
  // worst function — the one the violation should point at, not whichever
242
292
  // hotspot happens to come first in the file.
@@ -244,9 +294,9 @@ export function resolveHotspotLine(hotspots: readonly Hotspot[], file: string):
244
294
  return hotspots.find((h) => h.file === file)?.line ?? 1;
245
295
  }
246
296
 
247
- function checkBaseline(scanned: readonly Hotspot[]): GuardViolation[] {
297
+ function checkBaseline(scanned: readonly Hotspot[], ratchet: Ratchet): GuardViolation[] {
248
298
  const hotspots = localHotspots(scanned);
249
- return complexityBaseline.check(
299
+ return ratchet.check(
250
300
  countHotspotsByFile(hotspots),
251
301
  `Split the function, or allow it with "// ${BUDGET_TAG} <reason>".`,
252
302
  {
@@ -256,18 +306,21 @@ function checkBaseline(scanned: readonly Hotspot[]): GuardViolation[] {
256
306
  );
257
307
  }
258
308
 
259
- function analyse(
309
+ export function analyse(
260
310
  files: readonly SourceFile[],
261
311
  roots: readonly RepoRoot[],
262
312
  compareBaseline: boolean,
313
+ options: { ratchet?: Ratchet; reportLimit?: number } = {},
263
314
  ): GuardOutcome {
264
- const { hotspots, scanned } = scan(files, roots);
265
- report(hotspots, scanned);
315
+ const { ratchet = complexityBaseline, reportLimit = MAX_REPORTED } = options;
316
+ const { hotspots, capBreaches, scanned } = scan(files, roots);
317
+ report(hotspots, scanned, capBreaches, reportLimit);
318
+ const hardViolations = capViolations(capBreaches);
266
319
  if (!compareBaseline) {
267
320
  console.log(" Baseline comparison skipped (--no-baseline).");
268
- return { violations: [] };
321
+ return { violations: hardViolations };
269
322
  }
270
- return { violations: checkBaseline(hotspots) };
323
+ return { violations: [...hardViolations, ...checkBaseline(hotspots, ratchet)] };
271
324
  }
272
325
 
273
326
  export const guard: AstGuard = {
@@ -279,8 +332,8 @@ export const guard: AstGuard = {
279
332
  hint: 'after a deliberate change: `kumiko-guards guards --write-baseline --guard="Complexity Check"`',
280
333
  run: (files, roots = resolveRepoRoots()) => analyse(files, roots, true),
281
334
  writeBaseline: (files) => {
282
- const { hotspots, scanned } = scan(files);
283
- report(hotspots, scanned);
335
+ const { hotspots, capBreaches, scanned } = scan(files);
336
+ report(hotspots, scanned, capBreaches, MAX_REPORTED);
284
337
  complexityBaseline.write(countHotspotsByFile(localHotspots(hotspots)));
285
338
  },
286
339
  };
@@ -290,17 +343,23 @@ export const guard: AstGuard = {
290
343
  // silently rewrite the baseline.
291
344
  if (import.meta.main) {
292
345
  const args = process.argv.slice(2);
346
+ const reportLimit = args.includes("--all") ? Number.POSITIVE_INFINITY : MAX_REPORTED;
293
347
  if (args.includes("--write-baseline")) {
294
348
  const project = buildSharedProject([guard]);
295
- const { hotspots, scanned } = scan(filesForGuard(project, guard));
296
- report(hotspots, scanned);
349
+ const { hotspots, capBreaches, scanned } = scan(filesForGuard(project, guard));
350
+ report(hotspots, scanned, capBreaches, reportLimit);
297
351
  complexityBaseline.write(countHotspotsByFile(localHotspots(hotspots)));
298
352
  process.exit(0);
299
353
  }
300
354
  if (args.includes("--no-baseline")) {
301
355
  const project = buildSharedProject([guard]);
302
- analyse(filesForGuard(project, guard), resolveRepoRoots(), false);
303
- process.exit(0);
356
+ const { violations } = analyse(filesForGuard(project, guard), resolveRepoRoots(), false, {
357
+ reportLimit,
358
+ });
359
+ process.exit(violations.length > 0 ? 1 : 0);
304
360
  }
305
- runStandalone(guard);
361
+ runStandalone({
362
+ ...guard,
363
+ run: (files, roots = resolveRepoRoots()) => analyse(files, roots, true, { reportLimit }),
364
+ });
306
365
  }
@@ -107,14 +107,30 @@ export function loadLicenseExceptions(
107
107
  });
108
108
  }
109
109
 
110
+ function normalizeLicenseId(license: string): string {
111
+ return license.replace(/[()]/g, "").trim().toUpperCase();
112
+ }
113
+
114
+ function exceptionMatchesPackage(e: LicenseException, pkg: InstalledPackage): boolean {
115
+ if (!matchesPackageName(e.package, pkg.name)) return false;
116
+ // A package without a license field has nothing to contradict the recorded license.
117
+ if (pkg.license !== null && normalizeLicenseId(e.license) !== normalizeLicenseId(pkg.license)) {
118
+ return false;
119
+ }
120
+ return (
121
+ e.version === "*" || e.version === pkg.version || Bun.semver.satisfies(pkg.version, e.version)
122
+ );
123
+ }
124
+
125
+ // A renewed repo exception must win over an expired bundled one for the same package, so prefer a
126
+ // still-valid match and only fall back to an expired one for the "expired" hint.
110
127
  function findException(
111
128
  pkg: InstalledPackage,
112
129
  exceptions: readonly LicenseException[],
130
+ nowMs: number,
113
131
  ): 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
- });
132
+ const matching = exceptions.filter((e) => exceptionMatchesPackage(e, pkg));
133
+ return matching.find((e) => isExceptionValid(e.expires, nowMs).valid) ?? matching[0];
118
134
  }
119
135
 
120
136
  function splitExpression(expr: string, operator: "OR" | "AND"): string[] {
@@ -132,9 +148,10 @@ function matchesDenylist(license: string): boolean {
132
148
  export function judgeLicense(
133
149
  pkg: InstalledPackage,
134
150
  exceptions: readonly LicenseException[],
151
+ nowMs: number = Date.now(),
135
152
  ): Verdict {
136
153
  const license = pkg.license;
137
- const exception = findException(pkg, exceptions);
154
+ const exception = findException(pkg, exceptions, nowMs);
138
155
  if (!license) return { kind: "unknown", raw: null, exception };
139
156
 
140
157
  const andParts = splitExpression(license, "AND");
@@ -181,7 +198,7 @@ export function evaluateLicenses(
181
198
  exceptions: readonly LicenseException[],
182
199
  nowMs: number = Date.now(),
183
200
  ): LicenseEvaluation {
184
- const judged = packages.map((pkg) => ({ pkg, verdict: judgeLicense(pkg, exceptions) }));
201
+ const judged = packages.map((pkg) => ({ pkg, verdict: judgeLicense(pkg, exceptions, nowMs) }));
185
202
  const blocked = judged.filter(
186
203
  ({ verdict }) => verdict.kind !== "ok" && !hasValidException(verdict, nowMs),
187
204
  );
@@ -157,7 +157,8 @@ export async function runSecurityCheck(options: {
157
157
  const advisories: AuditAdvisory[] = parseAuditReport(raw);
158
158
 
159
159
  const evaluation = evaluateSecurity(advisories, exceptions, nowMs);
160
- const expiringUrgent = findExpiringSoon(exceptions, EXPIRY_BLOCK_DAYS, nowMs);
160
+ // Only exceptions that suppressed an advisory in this run block; unused bundled ones must not break consumer CI.
161
+ const expiringUrgent = findExpiringSoon(evaluation.usedExceptions, EXPIRY_BLOCK_DAYS, nowMs);
161
162
  printReport(evaluation, exceptions, expiringUrgent, nowMs);
162
163
 
163
164
  if (options.reportOnly) return 0;