@cosmicdrift/kumiko-guards 0.386.0 → 0.388.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.
Files changed (54) hide show
  1. package/LICENSE +21 -57
  2. package/package.json +4 -4
  3. package/src/_lib/agent-docs.ts +12 -7
  4. package/src/_lib/comment-trivia.ts +148 -0
  5. package/src/_lib/guard-kit.ts +2 -2
  6. package/src/_lib/guards-doc.ts +4 -1
  7. package/src/_lib/roots.ts +9 -5
  8. package/src/_lib/run-slots.ts +39 -10
  9. package/src/_lib/scan-scope.ts +1 -1
  10. package/src/_lib/security-audit.ts +15 -8
  11. package/src/_lib/sql-inventory.ts +2 -2
  12. package/src/changes.json +45 -0
  13. package/src/check-as-casts.ts +6 -7
  14. package/src/check-complexity.ts +86 -29
  15. package/src/check-licenses.ts +23 -6
  16. package/src/check-real-provider-isolation.ts +1 -1
  17. package/src/check-runtime-isolation.ts +2 -2
  18. package/src/check-security.ts +2 -1
  19. package/src/check-single-runtime-instance.ts +1 -1
  20. package/src/dependency-license-exceptions.json +0 -24
  21. package/src/guard-admin-api.ts +5 -6
  22. package/src/guard-app-feature-structure.ts +9 -9
  23. package/src/guard-comment-issue-ref-only.ts +223 -0
  24. package/src/guard-comment-lang.ts +10 -141
  25. package/src/guard-direct-entity-writes.ts +43 -31
  26. package/src/guard-direct-fetch.ts +3 -3
  27. package/src/guard-feature-integration-tests.ts +1 -1
  28. package/src/guard-file-size.ts +2 -2
  29. package/src/guard-i18n-locale-mount.ts +4 -4
  30. package/src/guard-i18n-locale-terminology.ts +3 -3
  31. package/src/guard-i18n-ui-strings.ts +5 -5
  32. package/src/guard-import-extensions.ts +34 -9
  33. package/src/guard-no-custom-primitives.ts +2 -4
  34. package/src/guard-no-direct-fs.ts +1 -1
  35. package/src/guard-no-direct-process-env.ts +2 -2
  36. package/src/guard-no-inline-styles.ts +1 -1
  37. package/src/guard-no-raw-hooks.ts +0 -2
  38. package/src/guard-primitives-discipline.ts +1 -1
  39. package/src/guard-public-exports-changelog.ts +8 -8
  40. package/src/guard-raw-classname.ts +1 -1
  41. package/src/guard-raw-interactive-elements.ts +1 -1
  42. package/src/guard-renderer-boundaries.ts +61 -9
  43. package/src/guard-restricted-symbols.ts +2 -3
  44. package/src/guard-tailwind-scan-surface.ts +28 -29
  45. package/src/guard-tenant-escalation.ts +3 -3
  46. package/src/guard-test-template-drift.ts +1 -1
  47. package/src/guard-test-timeouts.ts +12 -4
  48. package/src/guard-thin-wrappers.ts +35 -0
  49. package/src/guard-write-handler-qns.ts +1 -1
  50. package/src/pre-push.sh +22 -6
  51. package/src/repo-manifest/app-ports/static-app-config.ts +15 -11
  52. package/src/run-guards.ts +11 -4
  53. package/src/run-ui-guards.ts +1 -1
  54. package/src/runtime-isolation-classify.ts +3 -2
package/LICENSE CHANGED
@@ -1,57 +1,21 @@
1
- Business Source License 1.1
2
-
3
- Parameters
4
-
5
- Licensor: Marc Frost
6
-
7
- Licensed Work: @cosmicdrift/kumiko-guards
8
- The Licensed Work is © 2026 Marc Frost.
9
-
10
- Additional Use Grant:
11
- You may use the Licensed Work in production for any purpose, including
12
- commercially, EXCEPT for the Restricted Use.
13
-
14
- "Restricted Use" is defined as using the Licensed Work to provide a platform
15
- or service to third parties that allows them to host, deploy, or run their
16
- own applications built with the Licensed Work. This includes, but is not
17
- limited to: managed hosting services, software-as-a-service (SaaS) platforms,
18
- platform-as-a-service (PaaS), developer platforms, or any multi-tenant
19
- managed offering of the Licensed Work.
20
-
21
- This restriction does not apply to the Licensor, any entity controlled by,
22
- controlling, or under common control with the Licensor ("Affiliates"), or
23
- contractors acting on their behalf. The Licensor remains free to use the
24
- Licensed Work for any purpose, including for the operation of kumiko.rocks.
25
-
26
- Change Date: 2030-05-05
27
- Change License: Apache License, Version 2.0
28
-
29
-
30
- Terms
31
-
32
- The Licensor hereby grants you the right to copy, modify, create derivative works,
33
- redistribute, and make non-production use of the Licensed Work. The Licensor may
34
- make an Additional Use Grant, above, permitting limited production use.
35
-
36
- Effective on the Change Date, or the fourth anniversary of the first publicly
37
- available distribution of the Licensed Work under this License, whichever comes
38
- first, this License will convert to the Change License.
39
-
40
- This Business Source License governs use of the Licensed Work in all cases, except
41
- as to any use that is explicitly granted in the Additional Use Grant above or
42
- under the Change License after the Change Date.
43
-
44
- If your use of the Licensed Work does not comply with the requirements of this
45
- License, you must cease use of the Licensed Work immediately.
46
-
47
- All copies of the Licensed Work, and all derivative works thereof, must include
48
- this License.
49
-
50
- This License does not grant you any right, title, or interest in any trademark,
51
- logo, or branding of the Licensor, except as required to comply with this License.
52
-
53
- TO THE EXTENT PERMITTED BY APPLICABLE LAW, THE LICENSED WORK IS PROVIDED ON AN
54
- “AS IS” BASIS. LICENSOR DISCLAIMS ALL WARRANTIES, EXPRESS OR IMPLIED, INCLUDING
55
- WITHOUT LIMITATION WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE,
56
- TITLE, AND NON-INFRINGEMENT. IN NO EVENT WILL LICENSOR BE LIABLE FOR ANY DAMAGES
57
- ARISING OUT OF OR RELATED TO THIS LICENSE OR THE USE OF THE LICENSED WORK.
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Marc Frost
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-guards",
3
- "version": "0.386.0",
3
+ "version": "0.388.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
- "license": "BUSL-1.1",
5
+ "license": "MIT",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
7
7
  "repository": {
8
8
  "type": "git",
@@ -51,7 +51,7 @@
51
51
  "LICENSE"
52
52
  ],
53
53
  "peerDependencies": {
54
- "@cosmicdrift/kumiko-framework": "0.386.0"
54
+ "@cosmicdrift/kumiko-framework": "0.388.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.386.0"
62
+ "@cosmicdrift/kumiko-framework": "0.388.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"));
@@ -0,0 +1,148 @@
1
+ import { ts } from "ts-morph";
2
+
3
+ export const COMMENT_KIND_LINE = "line";
4
+ export const COMMENT_KIND_BLOCK = "block";
5
+
6
+ export type CommentTrivia = {
7
+ readonly start: number;
8
+ readonly kind: typeof COMMENT_KIND_LINE | typeof COMMENT_KIND_BLOCK;
9
+ readonly text: string;
10
+ };
11
+
12
+ // Tokens that end an operand without being identifiers or keywords.
13
+ const OPERAND_END_PUNCTUATION_KINDS: ReadonlySet<ts.SyntaxKind> = new Set([
14
+ ts.SyntaxKind.NumericLiteral,
15
+ ts.SyntaxKind.BigIntLiteral,
16
+ ts.SyntaxKind.StringLiteral,
17
+ ts.SyntaxKind.NoSubstitutionTemplateLiteral,
18
+ ts.SyntaxKind.TemplateTail,
19
+ ts.SyntaxKind.RegularExpressionLiteral,
20
+ ts.SyntaxKind.CloseParenToken,
21
+ ts.SyntaxKind.CloseBracketToken,
22
+ ts.SyntaxKind.CloseBraceToken,
23
+ ts.SyntaxKind.PlusPlusToken,
24
+ ts.SyntaxKind.MinusMinusToken,
25
+ ]);
26
+
27
+ // Reserved words after which an expression (so possibly a regex) starts.
28
+ const EXPRESSION_INTRODUCING_KEYWORDS: ReadonlySet<ts.SyntaxKind> = new Set([
29
+ ts.SyntaxKind.ReturnKeyword,
30
+ ts.SyntaxKind.TypeOfKeyword,
31
+ ts.SyntaxKind.InstanceOfKeyword,
32
+ ts.SyntaxKind.InKeyword,
33
+ ts.SyntaxKind.CaseKeyword,
34
+ ts.SyntaxKind.VoidKeyword,
35
+ ts.SyntaxKind.DeleteKeyword,
36
+ ts.SyntaxKind.ThrowKeyword,
37
+ ts.SyntaxKind.NewKeyword,
38
+ ts.SyntaxKind.YieldKeyword,
39
+ ts.SyntaxKind.AwaitKeyword,
40
+ ts.SyntaxKind.ElseKeyword,
41
+ ts.SyntaxKind.DoKeyword,
42
+ ts.SyntaxKind.ExtendsKeyword,
43
+ ts.SyntaxKind.DefaultKeyword,
44
+ ]);
45
+
46
+ function isIdentifierOrKeyword(kind: ts.SyntaxKind): boolean {
47
+ return (
48
+ kind === ts.SyntaxKind.Identifier ||
49
+ (kind >= ts.SyntaxKind.FirstKeyword && kind <= ts.SyntaxKind.LastKeyword)
50
+ );
51
+ }
52
+
53
+ // After an operand end a `/` is division, not the start of a regex literal.
54
+ // Contextual keywords (`from`, `type`, `of`, ...) scan as keyword kinds but are
55
+ // valid member and variable names, so every non-introducing keyword counts. A `!`
56
+ // is the non-null postfix only when the token before it ended an operand.
57
+ function isOperandEnd(kind: ts.SyntaxKind, previousEndedOperand: boolean): boolean {
58
+ if (kind === ts.SyntaxKind.ExclamationToken) return previousEndedOperand;
59
+ if (OPERAND_END_PUNCTUATION_KINDS.has(kind)) return true;
60
+ return isIdentifierOrKeyword(kind) && !EXPRESSION_INTRODUCING_KEYWORDS.has(kind);
61
+ }
62
+
63
+ const TRIVIA_KINDS: ReadonlySet<ts.SyntaxKind> = new Set([
64
+ ts.SyntaxKind.SingleLineCommentTrivia,
65
+ ts.SyntaxKind.MultiLineCommentTrivia,
66
+ ts.SyntaxKind.WhitespaceTrivia,
67
+ ts.SyntaxKind.NewLineTrivia,
68
+ ]);
69
+
70
+ // The raw scanner always yields SlashToken for `/`; without the rescan a regex
71
+ // body like /\{/ would shift braceDepth and end a template early. After an
72
+ // operand the rescan would instead swallow `/ 2; // {` as a regex and hide the comment.
73
+ function rescanSlashAsRegex(
74
+ scanner: ts.Scanner,
75
+ kind: ts.SyntaxKind,
76
+ previousEndedOperand: boolean,
77
+ ): ts.SyntaxKind {
78
+ if (previousEndedOperand) return kind;
79
+ const reScanned = scanner.reScanSlashToken();
80
+ return reScanned === ts.SyntaxKind.RegularExpressionLiteral ? reScanned : kind;
81
+ }
82
+
83
+ function closesTemplateSubstitution(
84
+ templateBraceDepths: readonly number[],
85
+ braceDepth: number,
86
+ ): boolean {
87
+ return (
88
+ templateBraceDepths.length > 0 &&
89
+ braceDepth === templateBraceDepths[templateBraceDepths.length - 1]
90
+ );
91
+ }
92
+
93
+ // Template substitutions can nest braces (object literals, arrow bodies, other
94
+ // templates). The raw scanner tokenizes a `}` as a plain CloseBraceToken and
95
+ // never resumes template mode, so reScanTemplateToken() must run once brace
96
+ // depth returns to the level the substitution opened at.
97
+ export function scanCommentTrivia(fullText: string): CommentTrivia[] {
98
+ const comments: CommentTrivia[] = [];
99
+ const scanner = ts.createScanner(
100
+ ts.ScriptTarget.Latest,
101
+ /* skipTrivia */ false,
102
+ ts.LanguageVariant.Standard,
103
+ fullText,
104
+ );
105
+
106
+ const templateBraceDepths: number[] = [];
107
+ let braceDepth = 0;
108
+ let previousEndedOperand = false;
109
+
110
+ let kind = scanner.scan();
111
+ while (kind !== ts.SyntaxKind.EndOfFileToken) {
112
+ switch (kind) {
113
+ case ts.SyntaxKind.SingleLineCommentTrivia:
114
+ case ts.SyntaxKind.MultiLineCommentTrivia:
115
+ comments.push({
116
+ start: scanner.getTokenStart(),
117
+ kind:
118
+ kind === ts.SyntaxKind.SingleLineCommentTrivia ? COMMENT_KIND_LINE : COMMENT_KIND_BLOCK,
119
+ text: scanner.getTokenText(),
120
+ });
121
+ break;
122
+ case ts.SyntaxKind.SlashToken:
123
+ case ts.SyntaxKind.SlashEqualsToken:
124
+ kind = rescanSlashAsRegex(scanner, kind, previousEndedOperand);
125
+ break;
126
+ case ts.SyntaxKind.TemplateHead:
127
+ case ts.SyntaxKind.TemplateMiddle:
128
+ templateBraceDepths.push(braceDepth);
129
+ break;
130
+ case ts.SyntaxKind.OpenBraceToken:
131
+ braceDepth++;
132
+ break;
133
+ case ts.SyntaxKind.CloseBraceToken:
134
+ if (closesTemplateSubstitution(templateBraceDepths, braceDepth)) {
135
+ templateBraceDepths.pop();
136
+ kind = scanner.reScanTemplateToken(false);
137
+ continue;
138
+ }
139
+ if (braceDepth > 0) braceDepth--;
140
+ break;
141
+ default:
142
+ break;
143
+ }
144
+ if (!TRIVIA_KINDS.has(kind)) previousEndedOperand = isOperandEnd(kind, previousEndedOperand);
145
+ kind = scanner.scan();
146
+ }
147
+ return comments;
148
+ }
@@ -339,7 +339,7 @@ export type RunResult = {
339
339
  readonly scanKinds?: readonly string[];
340
340
  /** Files the scan matched — the runner's own count, basis of the floor. */
341
341
  readonly matchedFiles?: number;
342
- /** Roots whose declared sourceRoots hold no .ts/.tsx at all (D4, infra#427). */
342
+ /** Roots whose declared sourceRoots hold no .ts/.tsx at all. */
343
343
  readonly violatingRoots?: readonly string[];
344
344
  /** Violations excused by the per-repo security baseline — security guards only. */
345
345
  readonly frozenFindings?: number;
@@ -597,7 +597,7 @@ function guardKitVersion(): string {
597
597
 
598
598
  /**
599
599
  * Pure preflight verdict, injectable for tests: which of the two globally
600
- * silent-green cases (infra#2863) applies, if any. Not to be confused with a
600
+ * silent-green cases applies, if any. Not to be confused with a
601
601
  * single guard finding no target repos — that stays a per-guard `skipped`
602
602
  * line in `reportResults`.
603
603
  */
@@ -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,7 +35,7 @@ export type RootScan = {
35
35
  readonly root: RepoRoot;
36
36
  /** Absolute paths handed to the guard, sorted. */
37
37
  readonly files: readonly string[];
38
- /** All .ts/.tsx under the declared sourceRoots minus excludes, before extension/within filters — the D4 floor input. */
38
+ /** All .ts/.tsx under the declared sourceRoots minus excludes, before extension/within filters — the floor input. */
39
39
  readonly sourceSurface: number;
40
40
  };
41
41
 
@@ -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). */
@@ -58,7 +58,7 @@ export type SqlInventoryReport = {
58
58
  };
59
59
  };
60
60
 
61
- /** Paths where `.unsafe()` / `asRawClient()` are permitted (Phase 5 guard). */
61
+ /** Paths where `.unsafe()` / `asRawClient()` are permitted. */
62
62
  export const RAW_SQL_ALLOWLIST: ReadonlyArray<RegExp> = [
63
63
  // Layout-agnostic: framework packages/... AND flat app src/... AND enterprise packages/<pkg>/...
64
64
  /(^|\/)(packages\/[^/]+\/)?src\/db\/queries\//,
@@ -107,7 +107,7 @@ const PATTERNS: ReadonlyArray<{
107
107
  { kind: "execute", re: /\.execute\s*\(/ },
108
108
  ];
109
109
 
110
- /** Only these kinds fail CI; delete_from/execute stay inventory-only (infra#610). */
110
+ /** Only these kinds fail CI; delete_from/execute stay inventory-only. */
111
111
  export const BLOCKING_SQL_KINDS: ReadonlyArray<SqlInventoryKind> = ["unsafe", "asRawClient"];
112
112
 
113
113
  const TS_GLOB = new Bun.Glob("**/*.{ts,tsx}");
package/src/changes.json CHANGED
@@ -1,4 +1,49 @@
1
1
  [
2
+ {
3
+ "version": "0.388.0",
4
+ "type": "improvement",
5
+ "title": "Complexity hard cap 40, thin-wrapper guard skips typed table slots",
6
+ "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.",
7
+ "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\"."
8
+ },
9
+ {
10
+ "version": "0.388.0",
11
+ "type": "improvement",
12
+ "title": "Complexity hard cap, --all, file-size threshold 1200",
13
+ "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.",
14
+ "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."
15
+ },
16
+ {
17
+ "version": "0.388.0",
18
+ "type": "fix",
19
+ "title": "Harden guards infrastructure (pre-push scan range, slot dir, root walk)",
20
+ "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."
21
+ },
22
+ {
23
+ "version": "0.388.0",
24
+ "type": "fix",
25
+ "title": "Guards report fewer false results",
26
+ "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."
27
+ },
28
+ {
29
+ "version": "0.388.0",
30
+ "type": "fix",
31
+ "title": "Dependency exceptions, timeout and boundary guards no longer miss or invent findings",
32
+ "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."
33
+ },
34
+ {
35
+ "version": "0.388.0",
36
+ "type": "fix",
37
+ "title": "Guards --explain uses the injectable project builder",
38
+ "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."
39
+ },
40
+ {
41
+ "version": "0.387.0",
42
+ "type": "improvement",
43
+ "title": "Comment Issue-Ref guard",
44
+ "detail": "The comment-issue-ref-only guard flags comments that consist only of an issue or PR reference or a planning label (`#1234`, `fw#1234`, `Phase 3`, `S2.H1`) with no explanatory word besides it. Consecutive own-line `//` comments count as one paragraph, so a reference at the end of an explaining paragraph passes. Hits are frozen per file in `.kumiko-comment-issue-ref-baseline.json` and may not grow; without the baseline file the guard only warns. Deliberate exceptions take `// kumiko-lint-ignore comment-issue-ref-only <reason>`.",
45
+ "migration": "Optional. Freeze existing hits with `kumiko-guards guards --write-baseline --guard=comment-issue-ref-only`."
46
+ },
2
47
  {
3
48
  "version": "0.385.0",
4
49
  "type": "improvement",
@@ -117,7 +117,7 @@ export function isConstAssertion(cast: AsExpression): boolean {
117
117
  // optional ParenthesizedExpression in between (depending on parenthesisation):
118
118
  // `x as unknown as Y` → AsExpr(target=Y) → AsExpr(target=unknown) → x
119
119
  // `(x as unknown) as Y` → AsExpr(target=Y) → ParenExpr → AsExpr(target=unknown) → x
120
- // Wir gehen durch Parens transparent durch.
120
+ // Traverse parentheses transparently.
121
121
  function unwrapParens(node: Node | undefined): Node | undefined {
122
122
  while (node?.getKind() === SyntaxKind.ParenthesizedExpression) {
123
123
  node = node.asKindOrThrow(SyntaxKind.ParenthesizedExpression).getExpression();
@@ -277,7 +277,7 @@ function restOfLine(fullText: string, from: number): string {
277
277
  export function extractBoundaryReason(cast: AsExpression): string | null {
278
278
  const fullText = cast.getSourceFile().getFullText();
279
279
 
280
- // (1) Leading comments des enclosing statement
280
+ // (1) Leading comments of the enclosing statement
281
281
  const stmt =
282
282
  cast.getFirstAncestorByKind(SyntaxKind.VariableStatement) ??
283
283
  cast.getFirstAncestorByKind(SyntaxKind.ExpressionStatement) ??
@@ -288,7 +288,7 @@ export function extractBoundaryReason(cast: AsExpression): string | null {
288
288
  if (reason !== null) return reason;
289
289
  }
290
290
 
291
- // (2) Cast-Range selbst (multi-line casts, inline comments innerhalb)
291
+ // (2) Cast range itself (multi-line casts, inline comments inside)
292
292
  const inlineReason = boundaryReasonIn(fullText.slice(cast.getStart(), cast.getEnd()));
293
293
  if (inlineReason !== null) return inlineReason;
294
294
 
@@ -327,9 +327,8 @@ const FILE_DEFAULT_REASONS: ReadonlyArray<{
327
327
  boundaryReason: BoundaryReason;
328
328
  }> = [
329
329
  {
330
- // feature-AST extractors: alle Casts gehen vom erased ts-morph-Parse-
331
- // Result zu typed feature-Definitionen (EntityDefinition, RelationDef,
332
- // NavDef, ConfigKeys, …). Per Konstruktion ein schema-walk.
330
+ // Feature-AST extractors cast erased ts-morph parse results to typed feature definitions
331
+ // (EntityDefinition, RelationDef, NavDef, ConfigKeys, …), so every cast is a schema walk.
333
332
  pattern: /\/engine\/feature-ast\//,
334
333
  boundaryReason: "schema-walk",
335
334
  },
@@ -605,7 +604,7 @@ function writeBaseline(all: readonly Site[]): void {
605
604
  legitRatchet.write(legitCountsByPackage(all));
606
605
  }
607
606
 
608
- // Per File:target: aktueller Count gegen baseline. Mehr → Regression.
607
+ // Per file:target: current count against the baseline. More → regression.
609
608
  // A cast swap (cast A removed, cast B with a different target added) is detected
610
609
  // this way although the total stays stable.
611
610
  // Merge-conflict markers, truncated writes and hand edits all land here; the