@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 +3 -2
- package/package.json +3 -3
- package/src/_lib/agent-docs.ts +12 -7
- package/src/_lib/guards-doc.ts +4 -1
- package/src/_lib/roots.ts +9 -5
- package/src/_lib/run-slots.ts +39 -10
- package/src/_lib/security-audit.ts +15 -8
- package/src/changes.json +50 -0
- package/src/check-complexity.ts +86 -27
- package/src/check-licenses.ts +23 -6
- package/src/check-security.ts +2 -1
- package/src/dependency-license-exceptions.json +0 -24
- package/src/guard-app-feature-structure.ts +9 -7
- package/src/guard-comment-issue-ref-only.ts +4 -2
- package/src/guard-comment-lang.ts +6 -6
- package/src/guard-direct-entity-writes.ts +43 -31
- package/src/guard-file-size.ts +2 -2
- package/src/guard-import-extensions.ts +34 -9
- package/src/guard-no-date-api.ts +2 -2
- package/src/guard-no-direct-fs.ts +7 -1
- package/src/guard-public-exports-changelog.ts +8 -8
- package/src/guard-renderer-boundaries.ts +61 -9
- package/src/guard-restricted-symbols.ts +2 -0
- package/src/guard-test-es-fixtures.ts +83 -0
- package/src/guard-test-timeouts.ts +11 -3
- package/src/guard-thin-wrappers.ts +35 -0
- package/src/pre-push.sh +37 -9
- package/src/repo-manifest/app-ports/static-app-config.ts +15 -11
- package/src/run-guards.ts +11 -4
- package/src/runtime-isolation-classify.ts +2 -1
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
|
|
81
|
-
so the hook does not call it
|
|
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.
|
|
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.
|
|
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.
|
|
62
|
+
"@cosmicdrift/kumiko-framework": "0.389.0"
|
|
63
63
|
}
|
|
64
64
|
}
|
package/src/_lib/agent-docs.ts
CHANGED
|
@@ -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
|
-
|
|
346
|
-
|
|
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(
|
|
366
|
-
const content = readFileSync(join(recipe.dir,
|
|
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 `###
|
|
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"));
|
package/src/_lib/guards-doc.ts
CHANGED
|
@@ -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(
|
|
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
|
-
/**
|
|
187
|
-
|
|
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;
|
package/src/_lib/run-slots.ts
CHANGED
|
@@ -1,11 +1,20 @@
|
|
|
1
|
-
import {
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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",
|
package/src/check-complexity.ts
CHANGED
|
@@ -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
|
|
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
|
-
):
|
|
167
|
+
): FunctionMetric[] {
|
|
159
168
|
const file = relFromRepoRoot(sf.getFilePath(), roots);
|
|
160
|
-
const
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
236
|
+
metrics.push(...collectFunctionMetrics(sf, roots));
|
|
210
237
|
}
|
|
211
|
-
hotspots.sort((a, b) => b.complexity - a.complexity);
|
|
212
|
-
|
|
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(
|
|
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,
|
|
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
|
|
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 {
|
|
265
|
-
|
|
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
|
-
|
|
356
|
+
const { violations } = analyse(filesForGuard(project, guard), resolveRepoRoots(), false, {
|
|
357
|
+
reportLimit,
|
|
358
|
+
});
|
|
359
|
+
process.exit(violations.length > 0 ? 1 : 0);
|
|
304
360
|
}
|
|
305
|
-
runStandalone(
|
|
361
|
+
runStandalone({
|
|
362
|
+
...guard,
|
|
363
|
+
run: (files, roots = resolveRepoRoots()) => analyse(files, roots, true, { reportLimit }),
|
|
364
|
+
});
|
|
306
365
|
}
|
package/src/check-licenses.ts
CHANGED
|
@@ -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
|
-
|
|
115
|
-
|
|
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
|
);
|
package/src/check-security.ts
CHANGED
|
@@ -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
|
-
|
|
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;
|