@cosmicdrift/kumiko-guards 0.354.0 → 0.355.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-guards",
3
- "version": "0.354.0",
3
+ "version": "0.355.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": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -28,7 +28,7 @@
28
28
  "kumiko-pre-push": "./src/pre-push.sh"
29
29
  },
30
30
  "dependencies": {
31
- "@cosmicdrift/kumiko-repo-manifest": "0.354.0",
31
+ "@cosmicdrift/kumiko-repo-manifest": "0.355.0",
32
32
  "ts-morph": "^28.0.0"
33
33
  },
34
34
  "publishConfig": {
@@ -24,7 +24,7 @@ import {
24
24
  type RootResolution,
25
25
  resolveRepoRoots,
26
26
  } from "./roots";
27
- import { type RootScan, type ScanSpec, scanFiles, scanRoots } from "./scan-scope";
27
+ import { type RootScan, type ScanOptions, type ScanSpec, scanFiles, scanRoots } from "./scan-scope";
28
28
  import {
29
29
  applySecurityBaseline,
30
30
  loadSecurityBaseline,
@@ -63,6 +63,10 @@ export function isSecurityGuard(guard: Pick<AstGuard, "security">): boolean {
63
63
  return guard.security === true;
64
64
  }
65
65
 
66
+ function scanOptionsFor(guard: Pick<AstGuard, "security">): ScanOptions {
67
+ return { security: isSecurityGuard(guard) };
68
+ }
69
+
66
70
  // Followers call the predicate per import hop; findLocalRepo re-reads
67
71
  // package.json and the manifest each time.
68
72
  const localRepoRootByCwd = new Map<string, string | undefined>();
@@ -126,7 +130,7 @@ export function buildSharedProject(
126
130
  ...new Set(
127
131
  guards.flatMap((guard) => {
128
132
  try {
129
- return scanFiles(guard.scan, roots);
133
+ return scanFiles(guard.scan, roots, scanOptionsFor(guard));
130
134
  } catch {
131
135
  return [];
132
136
  }
@@ -143,7 +147,7 @@ export function filesForGuard(
143
147
  guard: AstGuard,
144
148
  roots: readonly RepoRoot[] = resolveRepoRoots(),
145
149
  ): SourceFile[] {
146
- return scanFiles(guard.scan, roots).map(
150
+ return scanFiles(guard.scan, roots, scanOptionsFor(guard)).map(
147
151
  (path) => project.getSourceFile(path) ?? project.addSourceFileAtPath(path),
148
152
  );
149
153
  }
@@ -366,7 +370,9 @@ export function runGuards(
366
370
  ): RunResult[] {
367
371
  const results: RunResult[] = [];
368
372
  const scan =
369
- deps.scan ?? ((guard: AstGuard, roots: readonly RepoRoot[]) => scanRoots(guard.scan, roots));
373
+ deps.scan ??
374
+ ((guard: AstGuard, roots: readonly RepoRoot[]) =>
375
+ scanRoots(guard.scan, roots, scanOptionsFor(guard)));
370
376
  const roots = deps.roots ?? resolveRepoRoots();
371
377
 
372
378
  for (const guard of guards) {
@@ -505,7 +511,9 @@ export function explainGuards(
505
511
  deps: ExplainGuardsDeps = {},
506
512
  ): string[] {
507
513
  const scan =
508
- deps.scan ?? ((guard: AstGuard, roots: readonly RepoRoot[]) => scanRoots(guard.scan, roots));
514
+ deps.scan ??
515
+ ((guard: AstGuard, roots: readonly RepoRoot[]) =>
516
+ scanRoots(guard.scan, roots, scanOptionsFor(guard)));
509
517
  const { roots } = deps.resolution ?? explainRepoRoots();
510
518
  const lines = [
511
519
  `Repo: ${roots[0]?.root.absPath ?? "— none found (no package.json with kumiko.json/src layout above cwd)"}`,
package/src/_lib/roots.ts CHANGED
@@ -24,6 +24,9 @@ export type { RepoKind } from "@cosmicdrift/kumiko-repo-manifest";
24
24
 
25
25
  import type { RepoKind } from "@cosmicdrift/kumiko-repo-manifest";
26
26
 
27
+ // Worktrees (`.wt/kumiko-framework-*`) keep this package name, so it is stable across checkouts.
28
+ const FRAMEWORK_PACKAGE_NAME = "kumiko-framework";
29
+
27
30
  export type RepoRoot = {
28
31
  /** Repo identifier — the package.json `name`. */
29
32
  readonly name: string;
@@ -150,6 +153,11 @@ function repoAt(dir: string): RepoRoot | undefined {
150
153
  const name = pkg.name;
151
154
  if (typeof name !== "string") return undefined;
152
155
  const loaded = manifestRootAt(dir);
156
+ if (loaded?.manifest.kind === "framework" && name !== FRAMEWORK_PACKAGE_NAME) {
157
+ throw new Error(
158
+ `${join(dir, "kumiko.json")}: kind "framework" is reserved for the ${FRAMEWORK_PACKAGE_NAME} repo (package name is "${name}"); it narrows scans via frameworkWithin and must not be self-declared`,
159
+ );
160
+ }
153
161
  return loaded ? rootFrom(name, dir, loaded) : undefined;
154
162
  }
155
163
 
@@ -124,6 +124,27 @@ function extraHits(root: RepoRoot, globs: readonly string[] | undefined): Hit[]
124
124
  return [...byRepoRel.values()];
125
125
  }
126
126
 
127
+ // Security guards honour only these: a repo manifest must not be able to switch a security scan off for its own files.
128
+ export const SECURITY_GUARD_HONORED_EXCLUDES: readonly string[] = [
129
+ "**/node_modules/**",
130
+ "**/dist/**",
131
+ ];
132
+
133
+ export type ScanOptions = {
134
+ /** Security guards ignore manifest excludes beyond SECURITY_GUARD_HONORED_EXCLUDES. */
135
+ readonly security?: boolean;
136
+ };
137
+
138
+ function effectiveExcludes(
139
+ root: RepoRoot,
140
+ options: ScanOptions | undefined,
141
+ ): readonly string[] | undefined {
142
+ if (options?.security !== true) return root.manifest.excludes;
143
+ return (root.manifest.excludes ?? []).filter((pattern) =>
144
+ SECURITY_GUARD_HONORED_EXCLUDES.includes(pattern),
145
+ );
146
+ }
147
+
127
148
  function afterExcludes(hits: readonly Hit[], excludes: readonly string[] | undefined): Hit[] {
128
149
  if (!excludes || excludes.length === 0) return [...hits];
129
150
  return hits.filter((hit) => !matchesAny(hit.repoRel, excludes));
@@ -144,9 +165,10 @@ function keepForSpec(hit: Hit, root: RepoRoot, spec: ScanSpec): boolean {
144
165
  return true;
145
166
  }
146
167
 
147
- function scanRoot(spec: ScanSpec, root: RepoRoot): RootScan {
148
- const sourceSurfaceHits = afterExcludes(sourceHits(root), root.manifest.excludes);
149
- const testSurfaceHits = () => afterExcludes(testHits(root), root.manifest.excludes);
168
+ function scanRoot(spec: ScanSpec, root: RepoRoot, options?: ScanOptions): RootScan {
169
+ const excludes = effectiveExcludes(root, options);
170
+ const sourceSurfaceHits = afterExcludes(sourceHits(root), excludes);
171
+ const testSurfaceHits = () => afterExcludes(testHits(root), excludes);
150
172
  const scopeHits =
151
173
  spec.scope === "source"
152
174
  ? sourceSurfaceHits
@@ -159,7 +181,7 @@ function scanRoot(spec: ScanSpec, root: RepoRoot): RootScan {
159
181
  seen.add(hit.repoRel);
160
182
  return true;
161
183
  });
162
- const additionalHits = afterExcludes(extraHits(root, spec.extraGlobs), root.manifest.excludes);
184
+ const additionalHits = afterExcludes(extraHits(root, spec.extraGlobs), excludes);
163
185
  const files = [...uniqueScopeHits, ...additionalHits.filter((hit) => !seen.has(hit.repoRel))]
164
186
  .filter((hit) => keepForSpec(hit, root, spec))
165
187
  .map((hit) => join(root.absPath, hit.repoRel))
@@ -172,10 +194,20 @@ export function keepsRootKind(spec: ScanSpec, root: RepoRoot): boolean {
172
194
  return root.kind !== "tooling";
173
195
  }
174
196
 
175
- export function scanRoots(spec: ScanSpec, roots: readonly RepoRoot[]): RootScan[] {
176
- return roots.filter((root) => keepsRootKind(spec, root)).map((root) => scanRoot(spec, root));
197
+ export function scanRoots(
198
+ spec: ScanSpec,
199
+ roots: readonly RepoRoot[],
200
+ options?: ScanOptions,
201
+ ): RootScan[] {
202
+ return roots
203
+ .filter((root) => keepsRootKind(spec, root))
204
+ .map((root) => scanRoot(spec, root, options));
177
205
  }
178
206
 
179
- export function scanFiles(spec: ScanSpec, roots: readonly RepoRoot[]): string[] {
180
- return [...new Set(scanRoots(spec, roots).flatMap((rootScan) => rootScan.files))].sort();
207
+ export function scanFiles(
208
+ spec: ScanSpec,
209
+ roots: readonly RepoRoot[],
210
+ options?: ScanOptions,
211
+ ): string[] {
212
+ return [...new Set(scanRoots(spec, roots, options).flatMap((rootScan) => rootScan.files))].sort();
181
213
  }
package/src/changes.json CHANGED
@@ -1,4 +1,16 @@
1
1
  [
2
+ {
3
+ "version": "0.355.0",
4
+ "type": "breaking",
5
+ "title": "Security guards ignore kumiko.json excludes beyond node_modules and dist; kind framework is reserved for kumiko-framework",
6
+ "migration": "Security guards (No-Direct-Fs, Direct-Entity-Writes, Direct-Fetch, Tenant-Escalation, Admin-API, Access-Denied-Test, Open-To-All-Reason, Escape-Hatch-Declared) now scan files matched by your kumiko.json excludes; fix the findings or add a precise guard allowlist entry. A kumiko.json with kind framework outside the kumiko-framework package now fails root resolution; use library or app. Direct-Entity-Writes now sees table arguments behind casts (`table as T`, parentheses, non-null), so a cast no longer hides a direct write. i18n-Locale-Mount handles conditional spreads, alias depth and circular constants."
7
+ },
8
+ {
9
+ "version": "0.355.0",
10
+ "type": "breaking",
11
+ "title": "test-timeouts guard flags timeout arguments on test(), it() and describe()",
12
+ "migration": "Remove the timeout argument from test/it/describe calls (test(name, fn, 30_000), test(name, fn, { timeout }), also via .skip/.only/.if()/.each()) and fix the cause: poll with waitFor/expect.poll, shrink the data set, share expensive setup in beforeAll. setDefaultTimeout and describe.configure through renamed or namespace bun:test imports are flagged too. A justified remainder gets '// @timeout-exception: #<issue> <reason>' on the line above the call."
13
+ },
2
14
  {
3
15
  "version": "0.354.0",
4
16
  "type": "improvement",
@@ -44,13 +44,7 @@
44
44
  */
45
45
 
46
46
  import * as path from "node:path";
47
- import {
48
- type CallExpression,
49
- type Identifier,
50
- type Node,
51
- type SourceFile,
52
- SyntaxKind,
53
- } from "ts-morph";
47
+ import { type CallExpression, type Identifier, Node, type SourceFile, SyntaxKind } from "ts-morph";
54
48
  import { ALL_REPO_KINDS, type AstGuard, runStandalone, type ScanSpec } from "./_lib/guard-kit";
55
49
 
56
50
  const ROOT = process.cwd();
@@ -94,6 +88,61 @@ const FN_WRITE_HELPERS = new Map<string, "insert" | "update" | "delete">([
94
88
  ["deleteManyBatched", "delete"],
95
89
  ]);
96
90
 
91
+ // Writes that are deliberately outside the event-store executor. Match on file + enclosing function + table so the exception cannot widen silently.
92
+ type DirectWriteException = {
93
+ readonly file: RegExp;
94
+ readonly functionName: string;
95
+ readonly table: string;
96
+ readonly reason: string;
97
+ };
98
+
99
+ const DIRECT_WRITE_EXCEPTIONS: readonly DirectWriteException[] = [
100
+ {
101
+ file: /(^|\/)packages\/bundled-features\/src\/template-resolver\/seeding\.ts$/,
102
+ functionName: "resolveExistingForEventStoreSeed",
103
+ table: "templateResourcesTable",
104
+ reason: "Orphan projection row without events, no event-store verb possible",
105
+ },
106
+ ];
107
+
108
+ // First named container: `function f`, `const f = () =>`, or `run: () =>` in an object literal.
109
+ function enclosingFunctionName(node: Node): string | undefined {
110
+ for (const ancestor of node.getAncestors()) {
111
+ if (Node.isFunctionDeclaration(ancestor)) return ancestor.getName();
112
+ if (Node.isVariableDeclaration(ancestor) || Node.isPropertyAssignment(ancestor)) {
113
+ return ancestor.getName();
114
+ }
115
+ }
116
+ return undefined;
117
+ }
118
+
119
+ function isDirectWriteException(call: CallExpression, table: string): boolean {
120
+ const functionName = enclosingFunctionName(call);
121
+ if (functionName === undefined) return false;
122
+ const file = call.getSourceFile().getFilePath();
123
+ return DIRECT_WRITE_EXCEPTIONS.some(
124
+ (exception) =>
125
+ exception.file.test(file) &&
126
+ exception.functionName === functionName &&
127
+ exception.table === table,
128
+ );
129
+ }
130
+
131
+ // `table as EntityTableMeta`, `(table)`, `table!`, `<T>table`, `table satisfies T` all name the same table.
132
+ export function unwrapTableExpression(node: Node): Node {
133
+ let current = node;
134
+ while (
135
+ Node.isAsExpression(current) ||
136
+ Node.isSatisfiesExpression(current) ||
137
+ Node.isParenthesizedExpression(current) ||
138
+ Node.isNonNullExpression(current) ||
139
+ Node.isTypeAssertion(current)
140
+ ) {
141
+ current = current.getExpression();
142
+ }
143
+ return current;
144
+ }
145
+
97
146
  // Identity of a table declaration: absolute file path + identifier name.
98
147
  // Text-only names collide across samples (currencies-global.invoiceTable vs.
99
148
  // beammycar.invoiceTable would look the same); resolving to the underlying
@@ -118,9 +167,9 @@ export function collectEsTables(files: readonly SourceFile[]): Set<TableId> {
118
167
  for (const call of sf.getDescendantsOfKind(SyntaxKind.CallExpression)) {
119
168
  const expr = call.getExpression();
120
169
  if (expr.getText() !== "createEventStoreExecutor") continue;
121
- const tableArg = call.getArguments()[0];
122
- if (!tableArg || tableArg.getKind() !== SyntaxKind.Identifier) continue;
123
- const id = tableArg.asKindOrThrow(SyntaxKind.Identifier);
170
+ const firstArg = call.getArguments()[0];
171
+ const id = firstArg && unwrapTableExpression(firstArg).asKind(SyntaxKind.Identifier);
172
+ if (!id) continue;
124
173
 
125
174
  // Repo convention: projection-tables follow `<name>Table`. Secondary
126
175
  // filter that catches factory-of-factory pass-throughs even if
@@ -149,9 +198,10 @@ export function collectEntityProjectionTables(files: readonly SourceFile[]): Set
149
198
  const args = call.getArguments();
150
199
  if (!args[0] || args[0].getKind() !== SyntaxKind.StringLiteral) continue;
151
200
 
152
- const entityArg = args[1];
153
- if (entityArg?.getKind() === SyntaxKind.Identifier) {
154
- const did = declIdOf(entityArg.asKindOrThrow(SyntaxKind.Identifier));
201
+ const entityIdentifier =
202
+ args[1] && unwrapTableExpression(args[1]).asKind(SyntaxKind.Identifier);
203
+ if (entityIdentifier) {
204
+ const did = declIdOf(entityIdentifier);
155
205
  if (did) rebuildableEntities.add(did);
156
206
  }
157
207
 
@@ -162,8 +212,9 @@ export function collectEntityProjectionTables(files: readonly SourceFile[]): Set
162
212
  .getProperty("table");
163
213
  if (tableProp?.getKind() === SyntaxKind.PropertyAssignment) {
164
214
  const init = tableProp.asKindOrThrow(SyntaxKind.PropertyAssignment).getInitializer();
165
- if (init?.getKind() === SyntaxKind.Identifier) {
166
- const did = declIdOf(init.asKindOrThrow(SyntaxKind.Identifier));
215
+ const tableIdentifier = init && unwrapTableExpression(init).asKind(SyntaxKind.Identifier);
216
+ if (tableIdentifier) {
217
+ const did = declIdOf(tableIdentifier);
167
218
  if (did) tables.add(did);
168
219
  }
169
220
  }
@@ -177,8 +228,10 @@ export function collectEntityProjectionTables(files: readonly SourceFile[]): Set
177
228
  for (const call of sf.getDescendantsOfKind(SyntaxKind.CallExpression)) {
178
229
  if (call.getExpression().getText() !== "buildEntityTable") continue;
179
230
  const entityArg = call.getArguments()[1];
180
- if (entityArg?.getKind() !== SyntaxKind.Identifier) continue;
181
- const entityDid = declIdOf(entityArg.asKindOrThrow(SyntaxKind.Identifier));
231
+ const entityIdentifier =
232
+ entityArg && unwrapTableExpression(entityArg).asKind(SyntaxKind.Identifier);
233
+ if (!entityIdentifier) continue;
234
+ const entityDid = declIdOf(entityIdentifier);
182
235
  if (!entityDid || !rebuildableEntities.has(entityDid)) continue;
183
236
 
184
237
  const varDecl = call.getFirstAncestorByKind(SyntaxKind.VariableDeclaration);
@@ -253,9 +306,9 @@ function isInsideProjectionApply(callNode: Node): boolean {
253
306
 
254
307
  // Extracts (table identifier, db/receiver expression, op) from a write
255
308
  // call — both forms: the Drizzle method `<recv>.insert(table)` and the
256
- // bun-db function `updateMany(db, table, ...)`. undefined for non-writes or
257
- // when the table arg isn't a plain identifier (not resolvable → skip, never
258
- // a false block).
309
+ // bun-db function `updateMany(db, table, ...)`. Casts around the table arg are
310
+ // unwrapped; undefined for non-writes or when the table arg is still no plain
311
+ // identifier (computed tables are not resolvable → skip, never a false block).
259
312
  function resolveWrite(
260
313
  call: CallExpression,
261
314
  ): { tableArg: Identifier; receiver: Node; op: "insert" | "update" | "delete" } | undefined {
@@ -266,23 +319,17 @@ function resolveWrite(
266
319
  const pa = expr.asKindOrThrow(SyntaxKind.PropertyAccessExpression);
267
320
  const m = pa.getName();
268
321
  if (m !== "insert" && m !== "update" && m !== "delete") return undefined;
269
- if (args[0]?.getKind() !== SyntaxKind.Identifier) return undefined;
270
- return {
271
- tableArg: args[0].asKindOrThrow(SyntaxKind.Identifier),
272
- receiver: pa.getExpression(),
273
- op: m,
274
- };
322
+ const tableArg = args[0] && unwrapTableExpression(args[0]).asKind(SyntaxKind.Identifier);
323
+ if (!tableArg) return undefined;
324
+ return { tableArg, receiver: pa.getExpression(), op: m };
275
325
  }
276
326
 
277
327
  if (expr.getKind() === SyntaxKind.Identifier) {
278
328
  const op = FN_WRITE_HELPERS.get(expr.getText());
279
329
  if (!op) return undefined;
280
- if (!args[0] || args[1]?.getKind() !== SyntaxKind.Identifier) return undefined;
281
- return {
282
- tableArg: args[1].asKindOrThrow(SyntaxKind.Identifier),
283
- receiver: args[0],
284
- op,
285
- };
330
+ const tableArg = args[1] && unwrapTableExpression(args[1]).asKind(SyntaxKind.Identifier);
331
+ if (!args[0] || !tableArg) return undefined;
332
+ return { tableArg, receiver: args[0], op };
286
333
  }
287
334
 
288
335
  return undefined;
@@ -355,9 +402,6 @@ export function scanDirectWrites(
355
402
  const w = resolveWrite(call);
356
403
  if (!w) continue;
357
404
 
358
- const did = declIdOf(w.tableArg);
359
- if (!did || !esTables.has(did)) continue;
360
-
361
405
  // Walk the receiver chain down to its leftmost identifier. For `db.insert`
362
406
  // that's `db`; for `ctx.db.raw` (function-form arg0) that's `ctx`; for
363
407
  // `tx` that's `tx`. Checked against the TX-receiver-Set.
@@ -367,6 +411,10 @@ export function scanDirectWrites(
367
411
  }
368
412
  const receiverName = receiver.getText();
369
413
 
414
+ const did = declIdOf(w.tableArg);
415
+ if (!did || !esTables.has(did)) continue;
416
+ if (isDirectWriteException(call, w.tableArg.getText())) continue;
417
+
370
418
  if (TX_RECEIVER_NAMES.has(receiverName)) {
371
419
  // A tx receiver is only legitimate when the write actually sits
372
420
  // inside a projection-apply callback. A sub-tx pattern like
@@ -99,7 +99,14 @@ function localeDeClientRef(node: Node): boolean {
99
99
  // through the specifier's own name to get there.
100
100
  function resolveDeclaration(identifier: Identifier, depth = 0): Node | undefined {
101
101
  if (depth > 3) return undefined;
102
- const def = identifier.getDefinitionNodes()[0];
102
+ // With a contextual type (e.g. a declared options type) the first definition
103
+ // can be the property signature, not the binding — pick the binding.
104
+ const def = identifier
105
+ .getDefinitionNodes()
106
+ .find(
107
+ (node) =>
108
+ node.isKind(SyntaxKind.VariableDeclaration) || node.isKind(SyntaxKind.ImportSpecifier),
109
+ );
103
110
  if (def === undefined) return undefined;
104
111
  if (def.isKind(SyntaxKind.VariableDeclaration)) return def;
105
112
  if (def.isKind(SyntaxKind.ImportSpecifier)) {
@@ -113,9 +120,14 @@ function resolveDeclaration(identifier: Identifier, depth = 0): Node | undefined
113
120
 
114
121
  // `as const` and `as const satisfies T[]` (both in active use, e.g.
115
122
  // kumiko-studio's run-config.ts) wrap the array literal in an AsExpression
116
- // and/or SatisfiesExpression — unwrap either so callers see the literal underneath.
123
+ // and/or SatisfiesExpression, and `(X as T)` adds a ParenthesizedExpression —
124
+ // unwrap all of them so callers see the literal underneath.
117
125
  function unwrapAsExpression(node: Node): Node {
118
- if (node.isKind(SyntaxKind.AsExpression) || node.isKind(SyntaxKind.SatisfiesExpression)) {
126
+ if (
127
+ node.isKind(SyntaxKind.AsExpression) ||
128
+ node.isKind(SyntaxKind.SatisfiesExpression) ||
129
+ node.isKind(SyntaxKind.ParenthesizedExpression)
130
+ ) {
119
131
  return unwrapAsExpression(node.getExpression());
120
132
  }
121
133
  return node;
@@ -137,37 +149,44 @@ function resolveSameRepoInitializer(identifier: Identifier): Node | undefined {
137
149
  return init === undefined ? undefined : unwrapAsExpression(init);
138
150
  }
139
151
 
152
+ // Bounds every alias/spread chain the guard follows; circular constants
153
+ // (`const a = [...b]; const b = [...a]`) parse fine and must end in a verdict,
154
+ // not a stack overflow.
155
+ const MAX_ALIAS_DEPTH = 5;
156
+
140
157
  // Resolves an array-shaped value node down to its elements, whether it's a
141
158
  // literal right there or an identifier pointing at a same-repo constant
142
159
  // (`const CONST = [...]`, `... as const` included).
143
- function arrayElementsOf(value: Node | undefined): Node[] | undefined {
144
- if (value === undefined) return undefined;
160
+ function arrayElementsOf(value: Node | undefined, depth = 0): Node[] | undefined {
161
+ if (value === undefined || depth > MAX_ALIAS_DEPTH) return undefined;
145
162
  if (value.isKind(SyntaxKind.ArrayLiteralExpression)) return value.getElements();
146
163
  if (value.isKind(SyntaxKind.Identifier)) {
147
- return arrayElementsOf(resolveSameRepoInitializer(value));
164
+ return arrayElementsOf(resolveSameRepoInitializer(value), depth + 1);
148
165
  }
149
166
  return undefined;
150
167
  }
151
168
 
152
- function elementRegistersGerman(el: Node): boolean {
169
+ // Past the depth bound the answer is "no German found" (fail-closed).
170
+ function elementRegistersGerman(el: Node, depth = 0): boolean {
171
+ if (depth > MAX_ALIAS_DEPTH) return false;
153
172
  if (el.isKind(SyntaxKind.SpreadElement)) {
154
173
  const expr = el.getExpression();
155
174
  if (expr.isKind(SyntaxKind.Identifier)) {
156
175
  const init = resolveSameRepoInitializer(expr);
157
- if (init !== undefined) return elementRegistersGerman(init);
176
+ if (init !== undefined) return elementRegistersGerman(init, depth + 1);
158
177
  // Unresolvable or cross-repo identifier spread — fail-open rather than risk a false alarm.
159
178
  // Safe here: clientFeatures is already known to exist as an array, this only concerns
160
179
  // one of its elements — other elements are still checked.
161
180
  return true;
162
181
  }
163
- return elementRegistersGerman(expr);
182
+ return elementRegistersGerman(expr, depth + 1);
164
183
  }
165
184
  if (el.isKind(SyntaxKind.ArrayLiteralExpression)) {
166
- return el.getElements().some(elementRegistersGerman);
185
+ return el.getElements().some((element) => elementRegistersGerman(element, depth + 1));
167
186
  }
168
187
  // localeDeClient().translations (kumiko-studio auth-mount shape)
169
188
  if (el.isKind(SyntaxKind.PropertyAccessExpression)) {
170
- return localeDeClientRef(el) || elementRegistersGerman(el.getExpression());
189
+ return localeDeClientRef(el) || elementRegistersGerman(el.getExpression(), depth + 1);
171
190
  }
172
191
  if (el.isKind(SyntaxKind.CallExpression)) {
173
192
  return localeDeClientRef(el.getExpression());
@@ -198,42 +217,90 @@ function clientFeaturesElements(prop: Node | undefined): Node[] | undefined {
198
217
  return arrayElementsOf(value);
199
218
  }
200
219
 
201
- const MAX_OPTIONS_SPREAD_DEPTH = 5;
202
-
203
- // Determines whether the mount call's options object — a literal right there,
204
- // or an identifier pointing at a same-repo constant (offlot's `APP_OPTIONS`
205
- // shape) — carries a `clientFeatures` property that registers German. Follows
206
- // `{ ...OTHER_OPTIONS, clientFeatures: [...] }` object spreads the same way
207
- // arrayElementsOf follows array spreads, so `clientFeatures` no longer has to
208
- // stay a literal at the call site to satisfy this guard (infra#734).
209
- //
210
- // Fail-closed (not fail-open) on an unresolvable/cross-repo spread source:
211
- // unlike a single array element, a whole unresolved options object could be
212
- // the only place `clientFeatures` lives — silently passing it would make the
213
- // guard blind again, exactly the risk infra#734 called out.
214
- function objectRegistersGermanClientFeatures(value: Node | undefined, depth = 0): boolean {
215
- if (value === undefined || depth > MAX_OPTIONS_SPREAD_DEPTH) return false;
216
- if (value.isKind(SyntaxKind.Identifier)) {
217
- return objectRegistersGermanClientFeatures(resolveSameRepoInitializer(value), depth + 1);
220
+ type ClientFeaturesLookup =
221
+ | { readonly found: false }
222
+ | { readonly found: true; readonly registersGerman: boolean };
223
+
224
+ const CLIENT_FEATURES_ABSENT: ClientFeaturesLookup = { found: false };
225
+ // An options source the guard cannot see into could hold (or override) clientFeatures —
226
+ // unlike a single array element, treating it as "German registered" would blind the guard.
227
+ const CLIENT_FEATURES_UNRESOLVABLE: ClientFeaturesLookup = { found: true, registersGerman: false };
228
+
229
+ // `cond && {…}` and `cond ? {…} : {…}` spread one of their object branches (or nothing).
230
+ function conditionalBranches(node: Node): Node[] | undefined {
231
+ if (node.isKind(SyntaxKind.ConditionalExpression))
232
+ return [node.getWhenTrue(), node.getWhenFalse()];
233
+ if (
234
+ node.isKind(SyntaxKind.BinaryExpression) &&
235
+ node.getOperatorToken().getKind() === SyntaxKind.AmpersandAmpersandToken
236
+ ) {
237
+ return [node.getRight()];
218
238
  }
219
- if (!value.isKind(SyntaxKind.ObjectLiteralExpression)) return false;
220
- const direct = value
221
- .getProperties()
222
- .find(
223
- (p) =>
224
- (p.isKind(SyntaxKind.PropertyAssignment) ||
225
- p.isKind(SyntaxKind.ShorthandPropertyAssignment)) &&
226
- p.getNameNode().getText() === "clientFeatures",
227
- );
228
- if (direct !== undefined)
229
- return clientFeaturesElements(direct)?.some(elementRegistersGerman) ?? false;
230
- return value
231
- .getProperties()
232
- .some(
233
- (p) =>
234
- p.isKind(SyntaxKind.SpreadAssignment) &&
235
- objectRegistersGermanClientFeatures(p.getExpression(), depth + 1),
236
- );
239
+ return undefined;
240
+ }
241
+
242
+ // Spreading these contributes no properties.
243
+ function isNothingLiteral(node: Node): boolean {
244
+ return (
245
+ node.isKind(SyntaxKind.NullKeyword) ||
246
+ node.isKind(SyntaxKind.FalseKeyword) ||
247
+ (node.isKind(SyntaxKind.Identifier) && node.getText() === "undefined")
248
+ );
249
+ }
250
+
251
+ // Finds the clientFeatures property that wins at runtime in the mount call's
252
+ // options — a literal right there, or an identifier pointing at a same-repo
253
+ // constant (offlot's `APP_OPTIONS` shape). Properties are walked last to
254
+ // first, mirroring object-spread override semantics: the last property or
255
+ // spread that sets `clientFeatures` decides. Resolvable spreads without
256
+ // `clientFeatures` are skipped; unresolvable/cross-repo ones are fail-closed.
257
+ function lookupClientFeatures(value: Node | undefined, depth = 0): ClientFeaturesLookup {
258
+ if (value === undefined) return CLIENT_FEATURES_ABSENT;
259
+ if (depth > MAX_ALIAS_DEPTH) return CLIENT_FEATURES_UNRESOLVABLE;
260
+ const unwrapped = unwrapAsExpression(value);
261
+ if (unwrapped.isKind(SyntaxKind.Identifier)) {
262
+ const init = resolveSameRepoInitializer(unwrapped);
263
+ return init === undefined
264
+ ? CLIENT_FEATURES_UNRESOLVABLE
265
+ : lookupClientFeatures(init, depth + 1);
266
+ }
267
+ const branches = conditionalBranches(unwrapped);
268
+ if (branches !== undefined) {
269
+ const found = branches
270
+ .map((branch) => lookupClientFeatures(branch, depth + 1))
271
+ .filter((lookup) => lookup.found);
272
+ if (found.length === 0) return CLIENT_FEATURES_ABSENT;
273
+ // Which branch runs is unknown statically: German must be registered in every branch that sets clientFeatures.
274
+ return {
275
+ found: true,
276
+ registersGerman: found.every((lookup) => lookup.found && lookup.registersGerman),
277
+ };
278
+ }
279
+ if (isNothingLiteral(unwrapped)) return CLIENT_FEATURES_ABSENT;
280
+ if (!unwrapped.isKind(SyntaxKind.ObjectLiteralExpression)) return CLIENT_FEATURES_UNRESOLVABLE;
281
+ for (const prop of [...unwrapped.getProperties()].reverse()) {
282
+ if (
283
+ (prop.isKind(SyntaxKind.PropertyAssignment) ||
284
+ prop.isKind(SyntaxKind.ShorthandPropertyAssignment)) &&
285
+ prop.getNameNode().getText() === "clientFeatures"
286
+ ) {
287
+ return {
288
+ found: true,
289
+ registersGerman:
290
+ clientFeaturesElements(prop)?.some((el) => elementRegistersGerman(el)) ?? false,
291
+ };
292
+ }
293
+ if (prop.isKind(SyntaxKind.SpreadAssignment)) {
294
+ const spread = lookupClientFeatures(prop.getExpression(), depth + 1);
295
+ if (spread.found) return spread;
296
+ }
297
+ }
298
+ return CLIENT_FEATURES_ABSENT;
299
+ }
300
+
301
+ function objectRegistersGermanClientFeatures(value: Node | undefined): boolean {
302
+ const lookup = lookupClientFeatures(value);
303
+ return lookup.found && lookup.registersGerman;
237
304
  }
238
305
 
239
306
  function clientMountViolations(sf: SourceFile): GuardViolation[] {
@@ -258,7 +325,7 @@ function clientMountViolations(sf: SourceFile): GuardViolation[] {
258
325
  const init = attr.getInitializer();
259
326
  const expr = init?.isKind(SyntaxKind.JsxExpression) ? init.getExpression() : undefined;
260
327
  const elements = arrayElementsOf(expr);
261
- const registered = elements?.some(elementRegistersGerman) ?? false;
328
+ const registered = elements?.some((el) => elementRegistersGerman(el)) ?? false;
262
329
  if (!registered) {
263
330
  violations.push({
264
331
  file: sf.getFilePath(),
@@ -64,8 +64,12 @@ const EXCLUDE =
64
64
 
65
65
  const FS_MODULES = new Set(["fs", "node:fs", "fs/promises", "node:fs/promises"]);
66
66
 
67
- type AllowEntry = {
68
- readonly repo: string | "*";
67
+ // Closed union (not `string | "*"`, which collapses to `string`): a typo in an
68
+ // allowlist repo name must fail to compile instead of silently never matching.
69
+ export type AllowlistRepoName = "kumiko-framework" | "kumiko-enterprise";
70
+
71
+ export type AllowEntry = {
72
+ readonly repo: AllowlistRepoName | "*";
69
73
  readonly pattern: RegExp;
70
74
  };
71
75
 
@@ -77,6 +81,10 @@ const ALLOWLIST: readonly AllowEntry[] = [
77
81
  // machine, no request input as a path.
78
82
  { repo: "kumiko-framework", pattern: /^packages\/dev-server\/src\// },
79
83
 
84
+ // Guard runner + repo-manifest loader: read the scanned repo tree at dev/CI time, no request input as path.
85
+ { repo: "kumiko-framework", pattern: /^packages\/guards\/src\// },
86
+ { repo: "kumiko-framework", pattern: /^packages\/repo-manifest\/src\// },
87
+
80
88
  // CLI (@cosmicdrift/kumiko-cli): local dev/CI tooling reading the repo tree, no request input as path.
81
89
  { repo: "kumiko-framework", pattern: /^packages\/cli\/src\// },
82
90
 
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env bun
2
2
  /**
3
- * Guard: tenant / privilege-escalation safety. Four complementary checks.
3
+ * Guard: tenant / privilege-escalation safety. Three complementary checks.
4
4
  *
5
5
  * A) **Role-input write handlers need an escalation test.** Any
6
6
  * `defineWriteHandler` whose Zod schema takes a `role`/`roles` field lets
@@ -12,13 +12,8 @@
12
12
  * because a feature's escalation test legitimately lives in another
13
13
  * feature's __tests__ (e.g. user:update is covered by auth's multi-roles).
14
14
  *
15
- * B) **tenantIdOverride handlers must use crossTenantOverrideDenied.** A
16
- * payload `tenantIdOverride` on a TenantAdmin-reachable handler is the
17
- * cross-tenant escape hatch; the SystemAdmin gate must go through the
18
- * shared framework helper, not an inline `roles.includes("SystemAdmin")`
19
- * check that the next handler forgets.
20
15
  *
21
- * C) **Membership-derived JWT mints must strip reserved roles.** Command-time
16
+ * B) **Membership-derived JWT mints must strip reserved roles.** Command-time
22
17
  * validation rejects reserved roles from a membership, but a projection
23
18
  * rebuild replays stored membership events through the apply path, not the
24
19
  * handler — so a forbidden role can be resurrected into the projection. Any
@@ -27,7 +22,7 @@
27
22
  * `invitationRole`) MUST call `stripForbiddenMembershipRoles`, the
28
23
  * read-time backstop.
29
24
  *
30
- * D) **TenantAdmin-reachable writes on a global user row need a membership
25
+ * C) **TenantAdmin-reachable writes on a global user row need a membership
31
26
  * check.** A `ctx.db.raw` read deliberately bypasses the auto-tenant-
32
27
  * filter because User status is global. Combined with `access.admin`
33
28
  * (which includes the tenant-scoped TenantAdmin) and a `userId` payload,
@@ -36,7 +31,7 @@
36
31
  * isSystemAdminActor + tenantMembershipsTable shape lift-restriction.
37
32
  * write.ts uses. restrict-account.write.ts shipped without it.
38
33
  *
39
- * All four are tripwires: false-negatives (a weak name match) are tolerated, a
34
+ * All three are tripwires: false-negatives (a weak name match) are tolerated, a
40
35
  * false-positive on the clean repo is not. Detection stays conservative.
41
36
  *
42
37
  * Usage:
@@ -128,32 +123,6 @@ export function findUntestedRoleHandlers(files: readonly SourceFile[]): RoleHand
128
123
  });
129
124
  }
130
125
 
131
- type OverrideHandler = { file: string; line: number };
132
-
133
- export function findOverrideHandlersMissingHelper(files: readonly SourceFile[]): OverrideHandler[] {
134
- const out: OverrideHandler[] = [];
135
- for (const sf of files) {
136
- if (TEST_FILE_RE.test(sf.getFilePath())) continue;
137
- for (const pa of sf.getDescendantsOfKind(SyntaxKind.PropertyAssignment)) {
138
- if (pa.getName() !== "tenantIdOverride") continue;
139
- if (pa.getInitializer()?.getText().startsWith("z.") !== true) continue;
140
- // Scope the check to the enclosing defineWriteHandler(...) call, not
141
- // the whole file — a file with two override handlers where only one
142
- // calls crossTenantOverrideDenied must still flag the other.
143
- const handlerCall = pa.getFirstAncestor(
144
- (a) =>
145
- a.getKind() === SyntaxKind.CallExpression &&
146
- a.asKindOrThrow(SyntaxKind.CallExpression).getExpression().getText() ===
147
- "defineWriteHandler",
148
- );
149
- const scopeText = handlerCall?.getText() ?? sf.getFullText();
150
- if (scopeText.includes("crossTenantOverrideDenied")) continue;
151
- out.push({ file: sf.getFilePath(), line: pa.getStartLineNumber() });
152
- }
153
- }
154
- return out;
155
- }
156
-
157
126
  // A session built for a JWT — either the auth-session result shape or a typed
158
127
  // SessionUser literal.
159
128
  const SESSION_MINT = /kind:\s*"auth-session"|:\s*SessionUser\s*=\s*\{/;
@@ -232,7 +201,7 @@ export function findGlobalUserWritesMissingMembershipCheck(
232
201
  // `name` is only used for the violation message, not the security
233
202
  // check itself — a factory-built handler with a non-literal name
234
203
  // expression (`name: enable ? "enable" : "disable"`) must not skip
235
- // Check D just because literalStringOf() can't resolve it.
204
+ // Check C just because literalStringOf() can't resolve it.
236
205
  const name = literalStringOf(obj.getProperty("name"));
237
206
  const schema = obj.getProperty("schema");
238
207
  const accessProp = obj.getProperty("access");
@@ -267,7 +236,7 @@ export const guard: AstGuard = {
267
236
  name: "Tenant-Escalation Guard",
268
237
  scan: SCAN,
269
238
  security: true,
270
- hint: "Role-input handlers need an escalation test (assert a reserved role is rejected); tenantIdOverride handlers must call crossTenantOverrideDenied; membership-derived JWT mints must call stripForbiddenMembershipRoles; TenantAdmin-reachable ctx.db.raw user writes must check the target's membership.",
239
+ hint: "Role-input handlers need an escalation test (assert a reserved role is rejected); membership-derived JWT mints must call stripForbiddenMembershipRoles; TenantAdmin-reachable ctx.db.raw user writes must check the target's membership.",
271
240
  run(files) {
272
241
  const violations: Array<{ file: string; line: number; message: string }> = [];
273
242
 
@@ -279,15 +248,6 @@ export const guard: AstGuard = {
279
248
  });
280
249
  }
281
250
 
282
- for (const o of findOverrideHandlersMissingHelper(files)) {
283
- violations.push({
284
- file: path.relative(ROOT, o.file),
285
- line: o.line,
286
- message:
287
- "handler exposes tenantIdOverride but never calls crossTenantOverrideDenied — route the SystemAdmin gate through the shared helper.",
288
- });
289
- }
290
-
291
251
  for (const m of findMembershipMintsMissingStrip(files)) {
292
252
  violations.push({
293
253
  file: path.relative(ROOT, m.file),
@@ -3,8 +3,11 @@
3
3
  * Guard: tests wait for a condition, not for time. Flags, in test files and
4
4
  * Playwright specs/helpers:
5
5
  * (a) `test.setTimeout(...)`, `test.slow(...)`, `setDefaultTimeout(...)`,
6
- * `*.describe.configure({ timeout })` — raising a timeout hides the
7
- * cause of a slow or flaky test
6
+ * `*.describe.configure({ timeout })`, and a timeout argument on
7
+ * `test`/`it`/`describe` calls (`test(name, fn, 30_000)`,
8
+ * `test(name, fn, { timeout })`, also via `.skip/.only/.if()/.each()()`
9
+ * and renamed/namespace `bun:test` imports) — raising a timeout hides
10
+ * the cause of a slow or flaky test
8
11
  * (b) `<page>.waitForTimeout(...)` — a fixed sleep instead of a condition
9
12
  * (c) sleep loops — a `while`/`do`/`for`/`for…of`/`for…in` whose body calls
10
13
  * `sleep(...)`/`Bun.sleep(...)` or awaits a `new Promise` that only
@@ -70,6 +73,9 @@ const TIMEOUT_RAISING_CALLEES: ReadonlySet<string> = new Set(["test.setTimeout",
70
73
  const SET_TIMEOUT_CALLEE = /(^|\.)setTimeout$/;
71
74
  const SET_DEFAULT_TIMEOUT_CALLEE = /(^|\.)setDefaultTimeout$/;
72
75
  const DESCRIBE_CONFIGURE_CALLEE = /(^|\.)describe\.configure$/;
76
+ const TEST_FRAMEWORK_MODULES: ReadonlySet<string> = new Set(["bun:test", "@playwright/test"]);
77
+ const TEST_DECLARATION_CALLEE =
78
+ /^(test|it|describe)(\.(skip|only|todo|if|each|failing|concurrent|serial|skipIf|runIf|todoIf))*$/;
73
79
 
74
80
  export interface Finding {
75
81
  file: string;
@@ -143,9 +149,58 @@ function hasTimeoutProperty(node: Node | undefined): boolean {
143
149
  );
144
150
  }
145
151
 
146
- function raisedTimeoutReason(call: CallExpression): string | undefined {
152
+ // Maps local names imported from the test framework to their exported name;
153
+ // "" marks a namespace import (`import * as bt`), whose member access is stripped.
154
+ function testFrameworkAliases(sf: SourceFile): ReadonlyMap<string, string> {
155
+ const aliases = new Map<string, string>();
156
+ for (const decl of sf.getImportDeclarations()) {
157
+ if (!TEST_FRAMEWORK_MODULES.has(decl.getModuleSpecifierValue())) continue;
158
+ const namespaceImport = decl.getNamespaceImport();
159
+ if (namespaceImport !== undefined) aliases.set(namespaceImport.getText(), "");
160
+ for (const named of decl.getNamedImports()) {
161
+ const alias = named.getAliasNode();
162
+ if (alias !== undefined) aliases.set(alias.getText(), named.getName());
163
+ }
164
+ }
165
+ return aliases;
166
+ }
167
+
168
+ function canonicalCalleeText(text: string, aliases: ReadonlyMap<string, string>): string {
169
+ const [head = "", ...rest] = text.split(".");
170
+ const imported = aliases.get(head);
171
+ if (imported === undefined) return text;
172
+ return (imported === "" ? rest : [imported, ...rest]).join(".");
173
+ }
174
+
175
+ // `test.each(rows)(…)` and `test.if(cond)(…)` call the result of a call.
176
+ function declarationBaseCallee(callee: Node): Node {
177
+ return Node.isCallExpression(callee) ? declarationBaseCallee(callee.getExpression()) : callee;
178
+ }
179
+
180
+ function isTimeoutArgument(arg: Node): boolean {
181
+ if (Node.isObjectLiteralExpression(arg)) return hasTimeoutProperty(arg);
182
+ return !Node.isArrowFunction(arg) && !Node.isFunctionExpression(arg);
183
+ }
184
+
185
+ function declarationTimeoutReason(
186
+ call: CallExpression,
187
+ aliases: ReadonlyMap<string, string>,
188
+ ): string | undefined {
189
+ const base = canonicalCalleeText(declarationBaseCallee(call.getExpression()).getText(), aliases);
190
+ if (!TEST_DECLARATION_CALLEE.test(base)) return undefined;
191
+ const [, , ...extra] = call.getArguments();
192
+ if (!extra.some(isTimeoutArgument)) return undefined;
193
+ return `${base}(…, timeout) raises the timeout instead of fixing the cause`;
194
+ }
195
+
196
+ function raisedTimeoutReason(
197
+ call: CallExpression,
198
+ aliases: ReadonlyMap<string, string>,
199
+ ): string | undefined {
147
200
  const callee = call.getExpression();
148
- const calleeText = callee.getText();
201
+ const calleeText = canonicalCalleeText(callee.getText(), aliases);
202
+ const declarationReason = declarationTimeoutReason(call, aliases);
203
+ if (declarationReason !== undefined) return declarationReason;
149
204
  if (TIMEOUT_RAISING_CALLEES.has(calleeText) || SET_DEFAULT_TIMEOUT_CALLEE.test(calleeText)) {
150
205
  return `${calleeText}(…) raises the timeout instead of fixing the cause`;
151
206
  }
@@ -178,8 +233,9 @@ export function scanTimeouts(
178
233
  if (reported === undefined) return;
179
234
  findings.push({ file, line: node.getStartLineNumber(), message: reported });
180
235
  };
236
+ const aliases = testFrameworkAliases(sf);
181
237
  for (const call of sf.getDescendantsOfKind(SyntaxKind.CallExpression)) {
182
- const reason = raisedTimeoutReason(call);
238
+ const reason = raisedTimeoutReason(call, aliases);
183
239
  if (reason !== undefined) add(call, reason);
184
240
  }
185
241
  for (const [kind, label] of LOOP_LABELS) {