@cosmicdrift/kumiko-guards 0.374.0 → 0.378.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -67,16 +67,18 @@ drops it.
67
67
  A POSIX-sh pre-push hook, wired by copying the package's `hooks/pre-push`
68
68
  shim to `.husky/pre-push`. The shim execs the nearest executable
69
69
  `node_modules/.bin/kumiko-pre-push` from the repo root upwards (so worktrees
70
- without their own `node_modules` resolve via the parent install) and fails
71
- closed with a `bun install` / `PRE_PUSH_SKIP=1` hint when none exists. Inside
70
+ without their own `node_modules` resolve via the parent install, up to and
71
+ including `$HOME`, skipping ancestors owned by another user or writable by
72
+ group/others) and fails closed with a `bun install` hint when none exists. Inside
72
73
  the cosmicdriftgamestudio parent workspace it runs a scoped `bun check` for
73
74
  the pushing repo only; in a standalone clone it runs the repo's own
74
75
  `package.json` `test` script. From a `.wt/<name>` worktree under the parent
75
76
  workspace it runs a tracked `scripts/check-wt.sh` if present, otherwise the
76
77
  worktree's own `package.json` scripts `typecheck`, `lint`, `test`, and
77
78
  `test:dom` (whichever are declared), directly in the worktree — the parent's
78
- `bun check` would otherwise check the main checkout instead. Set
79
- `PRE_PUSH_SKIP=1` to bypass.
79
+ `bun check` would otherwise check the main checkout instead. There is no
80
+ bypass switch. A `check-wt.sh` that wraps this hook sets `KUMIKO_CHECK_WT=1`
81
+ so the hook does not call it again.
80
82
 
81
83
  Extension point: a tracked, executable `scripts/pre-push-extra.sh` runs
82
84
  before the main check in every branch (worktree, parent-scoped, and
package/hooks/pre-push CHANGED
@@ -1,10 +1,4 @@
1
1
  #!/usr/bin/env sh
2
- # PRE_PUSH_SKIP is checked here too so the bypass works even without node_modules.
3
- if [ "${PRE_PUSH_SKIP:-0}" = "1" ]; then
4
- echo "[pre-push] PRE_PUSH_SKIP=1 — hook skipped"
5
- exit 0
6
- fi
7
-
8
2
  REPO_ROOT="$(git rev-parse --show-toplevel)"
9
3
  if [ -z "$REPO_ROOT" ]; then
10
4
  echo "[pre-push] FATAL: git rev-parse --show-toplevel failed — refusing push" >&2
@@ -14,15 +8,35 @@ fi
14
8
  # Walk up so a worktree without its own node_modules resolves via the parent
15
9
  # install, but not past an enclosing repository: a bin above that boundary
16
10
  # belongs to another project. `[ -x ]` also skips a broken (uninstalled) symlink.
11
+ # A bin in an ancestor that is owned by someone else or group/world-writable
12
+ # (/tmp, /Users/Shared) is attacker-plantable and must never be exec'd, and
13
+ # $HOME is the upper trust boundary (inclusive), compared by physical path
14
+ # because git prints REPO_ROOT resolved: a trailing slash or a symlinked home
15
+ # would otherwise never match and the walk would continue to /.
16
+ HOME_BOUND=""
17
+ if [ -n "$HOME" ] && [ -d "$HOME" ]; then
18
+ HOME_BOUND="$(cd "$HOME" 2>/dev/null && pwd -P)"
19
+ fi
17
20
  BIN=""
18
21
  DIR="$REPO_ROOT"
19
22
  while true; do
20
- CANDIDATE="$DIR/node_modules/.bin/kumiko-pre-push"
21
- if [ -x "$CANDIDATE" ]; then
22
- BIN="$CANDIDATE"
23
- break
23
+ TRUSTED=1
24
+ if [ "$DIR" != "$REPO_ROOT" ]; then
25
+ if [ ! -O "$DIR" ] || [ -n "$(find "$DIR" -maxdepth 0 \( -perm -0020 -o -perm -0002 \) -print 2>/dev/null)" ]; then
26
+ TRUSTED=0
27
+ fi
28
+ fi
29
+ if [ "$TRUSTED" = 1 ]; then
30
+ BIN_DIR="$DIR/node_modules/.bin"
31
+ CANDIDATE="$BIN_DIR/kumiko-pre-push"
32
+ # The bin dir itself must be user-owned and not group/world-writable, or a
33
+ # planted entry could replace the real bin.
34
+ if [ -x "$CANDIDATE" ] && [ -O "$BIN_DIR" ] && [ -z "$(find "$BIN_DIR" -maxdepth 0 \( -perm -0020 -o -perm -0002 \) -print 2>/dev/null)" ]; then
35
+ BIN="$CANDIDATE"
36
+ break
37
+ fi
24
38
  fi
25
- if [ "$DIR" = "/" ]; then
39
+ if [ "$DIR" = "/" ] || { [ -n "$HOME_BOUND" ] && [ "$DIR" = "$HOME_BOUND" ]; }; then
26
40
  break
27
41
  fi
28
42
  if [ "$DIR" != "$REPO_ROOT" ] && [ -e "$DIR/.git" ]; then
@@ -33,7 +47,7 @@ done
33
47
 
34
48
  if [ -z "$BIN" ]; then
35
49
  echo "[pre-push] kumiko-pre-push not found in any node_modules/.bin above $REPO_ROOT." >&2
36
- echo "Run \`bun install\`, or set PRE_PUSH_SKIP=1 to bypass this hook." >&2
50
+ echo "Run \`bun install\`." >&2
37
51
  exit 1
38
52
  fi
39
53
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-guards",
3
- "version": "0.374.0",
3
+ "version": "0.378.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>",
@@ -21,6 +21,14 @@
21
21
  ".": {
22
22
  "types": "./src/index.ts",
23
23
  "default": "./src/index.ts"
24
+ },
25
+ "./repo-manifest": {
26
+ "types": "./src/repo-manifest/index.ts",
27
+ "default": "./src/repo-manifest/index.ts"
28
+ },
29
+ "./repo-manifest/app-ports": {
30
+ "types": "./src/repo-manifest/app-ports/index.ts",
31
+ "default": "./src/repo-manifest/app-ports/index.ts"
24
32
  }
25
33
  },
26
34
  "bin": {
@@ -28,8 +36,8 @@
28
36
  "kumiko-pre-push": "./src/pre-push.sh"
29
37
  },
30
38
  "dependencies": {
31
- "@cosmicdrift/kumiko-repo-manifest": "0.374.0",
32
- "ts-morph": "^28.0.0"
39
+ "ts-morph": "^28.0.0",
40
+ "zod": "^4.4.3"
33
41
  },
34
42
  "publishConfig": {
35
43
  "registry": "https://registry.npmjs.org",
@@ -41,5 +49,16 @@
41
49
  "hooks",
42
50
  "README.md",
43
51
  "LICENSE"
44
- ]
52
+ ],
53
+ "peerDependencies": {
54
+ "@cosmicdrift/kumiko-framework": ">=0.366.0 <1.0.0"
55
+ },
56
+ "peerDependenciesMeta": {
57
+ "@cosmicdrift/kumiko-framework": {
58
+ "optional": true
59
+ }
60
+ },
61
+ "devDependencies": {
62
+ "@cosmicdrift/kumiko-framework": "0.378.0"
63
+ }
45
64
  }
@@ -14,7 +14,7 @@ import { type ExportedDeclarations, type JSDocableNode, Node, Project, ts } from
14
14
  export const AGENT_DOCS_DIR = "packages/framework/agents";
15
15
 
16
16
  const FEATURE_MANIFEST_REL = "samples/apps/use-all-bundled/feature-manifest.json";
17
- const RENDERER_WEB_INDEX_REL = "packages/renderer-web/src/index.ts";
17
+ const RENDERER_WEB_INDEX_REL = "packages/renderer/src/web/index.ts";
18
18
  const RECIPES_REL = "samples/recipes";
19
19
  const RECIPE_SLUG_RE = /^[a-z0-9-]+$/;
20
20
  const SOURCE_EXTENSIONS = [".ts", ".tsx"];
@@ -286,7 +286,7 @@ function renderWidgets(repoRoot: string): string {
286
286
  const rows = entries.map(([name, description]) => [`\`${name}\``, description]);
287
287
  sections.push(`## ${group}\n\n${table(["Name", "Description"], rows)}`);
288
288
  }
289
- const intro = "Value exports of `@cosmicdrift/kumiko-renderer-web`.";
289
+ const intro = "Value exports of `@cosmicdrift/kumiko-renderer/web`.";
290
290
  return finishFile(RENDERER_WEB_INDEX_REL, `# Widgets\n\n${intro}\n\n${sections.join("\n\n")}`);
291
291
  }
292
292
 
@@ -1,16 +1,20 @@
1
1
  import type { SourceFile } from "ts-morph";
2
2
 
3
- const RENDERER_WEB_MODULE = "@cosmicdrift/kumiko-renderer-web";
3
+ const RENDERER_WEB_MODULES: ReadonlySet<string> = new Set([
4
+ "@cosmicdrift/kumiko-renderer/web",
5
+ "@cosmicdrift/kumiko-renderer-web",
6
+ ]);
4
7
 
5
8
  // Shared by every "raw HTML tag instead of framework primitive" guard
6
9
  // (guard-no-custom-primitives, guard-raw-interactive-elements): proves the
7
10
  // primitive set was reachable in this file — either an import from
8
- // @cosmicdrift/kumiko-renderer-web (any symbol), or a `usePrimitives` named
9
- // import, which lives in @cosmicdrift/kumiko-renderer (headless layer), not
10
- // only in -web (see kumiko-enterprise/packages/ai-agent/src/web/turn-cards.tsx).
11
+ // @cosmicdrift/kumiko-renderer/web (any symbol; the deprecated
12
+ // @cosmicdrift/kumiko-renderer-web shim counts too), or a `usePrimitives`
13
+ // named import, which lives in the shared layer of @cosmicdrift/kumiko-renderer,
14
+ // not only in /web (see kumiko-enterprise/packages/ai-agent/src/web/turn-cards.tsx).
11
15
  export function hasPrimitivesAccess(sf: SourceFile): boolean {
12
16
  for (const imp of sf.getImportDeclarations()) {
13
- if (imp.getModuleSpecifierValue() === RENDERER_WEB_MODULE) return true;
17
+ if (RENDERER_WEB_MODULES.has(imp.getModuleSpecifierValue())) return true;
14
18
  if (imp.getNamedImports().some((named) => named.getName() === "usePrimitives")) {
15
19
  return true;
16
20
  }
package/src/_lib/roots.ts CHANGED
@@ -17,12 +17,12 @@ import {
17
17
  type RepoManifest,
18
18
  RepoManifestError,
19
19
  type RepoManifestSource,
20
- } from "@cosmicdrift/kumiko-repo-manifest";
20
+ } from "../repo-manifest/index.ts";
21
21
  import { gitEnv } from "./git-env";
22
22
 
23
- export type { RepoKind } from "@cosmicdrift/kumiko-repo-manifest";
23
+ export type { RepoKind } from "../repo-manifest/index.ts";
24
24
 
25
- import type { RepoKind } from "@cosmicdrift/kumiko-repo-manifest";
25
+ import type { RepoKind } from "../repo-manifest/index.ts";
26
26
 
27
27
  // Worktrees (`.wt/kumiko-framework-*`) keep this package name, so it is stable across checkouts.
28
28
  const FRAMEWORK_PACKAGE_NAME = "kumiko-framework";
@@ -1,7 +1,7 @@
1
1
  import { realpathSync } from "node:fs";
2
2
  import { join, sep } from "node:path";
3
- import { type RepoKind, repoKindSchema } from "@cosmicdrift/kumiko-repo-manifest";
4
3
  import { Glob } from "bun";
4
+ import { type RepoKind, repoKindSchema } from "../repo-manifest/index.ts";
5
5
  import type { RepoRoot } from "./roots";
6
6
 
7
7
  // Security-shaped guards name this explicitly: the default skips "tooling" roots, and a one-word manifest kind must not switch them off.
package/src/changes.json CHANGED
@@ -1,4 +1,66 @@
1
1
  [
2
+ {
3
+ "version": "0.378.0",
4
+ "type": "fix",
5
+ "title": "Direct-entity-writes guard catches imported event-store tables",
6
+ "detail": "Table identifiers are resolved through import aliases to their definition, so direct insert/update/delete on an event-store table imported from another file of the scanned repo is no longer missed. Findings now show the full receiver (ctx.db). Existing consumers can get new findings that were previously hidden."
7
+ },
8
+ {
9
+ "version": "0.378.0",
10
+ "type": "improvement",
11
+ "title": "Faster, leaner guard run",
12
+ "detail": "Four AST guards skip files that cannot contain their anchor names before walking every node, and Direct-Entity-Writes only resolves symbols for table arguments named like an event-sourced table. Findings are identical."
13
+ },
14
+ {
15
+ "version": "0.378.0",
16
+ "type": "fix",
17
+ "title": "Pre-push shim skips untrusted ancestors and stops at $HOME",
18
+ "detail": "The shim walked up to / and could exec a kumiko-pre-push planted in /tmp or another shared directory. The walk now ends at $HOME and skips ancestors that another user owns or that group or others can write to."
19
+ },
20
+ {
21
+ "version": "0.378.0",
22
+ "type": "fix",
23
+ "title": "Pre-push shim also vets node_modules/.bin",
24
+ "detail": "The shim now requires node_modules/.bin itself to be owned by the user and not group or world writable before executing kumiko-pre-push from it, in the guards hook and the dev-server husky template."
25
+ },
26
+ {
27
+ "version": "0.377.0",
28
+ "type": "improvement",
29
+ "title": "guard-unsafe-append-event",
30
+ "detail": "Flags ctx.unsafeAppendEvent outside migrations/, upcasters/ and tests. Existing call sites are frozen per file in .kumiko-unsafe-append-event-baseline.json and the count may only shrink; repos without the file get a warning only."
31
+ },
32
+ {
33
+ "version": "0.377.0",
34
+ "type": "improvement",
35
+ "title": "repo-manifest moved into guards",
36
+ "detail": "The kumiko.json schema and loader are exported from @cosmicdrift/kumiko-guards/repo-manifest, the app port reader from @cosmicdrift/kumiko-guards/repo-manifest/app-ports. @cosmicdrift/kumiko-repo-manifest now only re-exports them and is removed after the consumer pass of wave 4. @cosmicdrift/kumiko-framework is an optional peer for the app-ports subpath.",
37
+ "migration": "Run bun kumiko-upgrade --apply, or bun kumiko codemod package-merge . --write. It rewrites imports of @cosmicdrift/kumiko-repo-manifest (and its /app-ports subpath) to @cosmicdrift/kumiko-guards/repo-manifest and replaces the dependency in package.json with kumiko-guards.",
38
+ "codemod": "scripts/codemod/package-merge.ts"
39
+ },
40
+ {
41
+ "version": "0.377.0",
42
+ "type": "improvement",
43
+ "title": "renderer-boundaries guard scans headless and live for imports only",
44
+ "detail": "The headless and live subtrees of kumiko-renderer must not import react-dom, jsdom or renderer-web; the symbol and JSX rules of the shared layer no longer apply to them. The section-fields-raw scope follows the moved headless sources."
45
+ },
46
+ {
47
+ "version": "0.377.0",
48
+ "type": "improvement",
49
+ "title": "renderer-boundaries guard leaves the web subtree unscanned",
50
+ "detail": "renderer/src/web may use the DOM and every web dependency; the shared renderer root and headless/live must not import it. The tailwind-scan-surface guard follows the new stylesheet location through package exports and shim imports."
51
+ },
52
+ {
53
+ "version": "0.377.0",
54
+ "type": "improvement",
55
+ "title": "comment-lang touched mode pairs renames",
56
+ "detail": "The touched mode diffs a moved file against its old path, so moving a file no longer counts every German comment in it as new."
57
+ },
58
+ {
59
+ "version": "0.376.0",
60
+ "type": "improvement",
61
+ "title": "The pre-push bypass is gone",
62
+ "detail": "PRE_PUSH_SKIP no longer skips the hook, neither in the shipped hook nor in kumiko-pre-push. KUMIKO_CHECK_WT=1 only stops the hook from re-entering a wrapping scripts/check-wt.sh. The i18n-keys guard treats keys defined by installed @cosmicdrift packages as known."
63
+ },
2
64
  {
3
65
  "version": "0.371.0",
4
66
  "type": "improvement",
@@ -2,7 +2,7 @@
2
2
  /**
3
3
  * Guard: the generated agent docs under `packages/framework/agents/` must
4
4
  * match what `bun run gen:agent-docs` renders from the current sources
5
- * (feature manifest, renderer-web exports, sample recipes), and every
5
+ * (feature manifest, renderer web exports, sample recipes), and every
6
6
  * `agents/<file>.md` path the hand-written `packages/framework/AGENTS.md`
7
7
  * points at must exist in that generated set.
8
8
  *
@@ -41,6 +41,8 @@ const REGISTRAR_TYPE_MARKER = "FeatureRegistrar";
41
41
  const FEATURE_FACTORY_NAME = "defineFeature";
42
42
  const REGISTRAR_RECEIVER_NAMES: ReadonlySet<string> = new Set(["r", "registrar"]);
43
43
  const EXCLUDED_PATH = /(\/__tests__\/fixtures\/|\.d\.ts$)/;
44
+ // Cheap text superset of what positionalCallMethod can match; skips the AST walk for most files.
45
+ const REGISTRAR_METHOD_ACCESS = new RegExp(`\\.\\s*(?:${REGISTRAR_METHODS.join("|")})\\b`);
44
46
  const UPGRADE_HINT = "run `bun kumiko-upgrade --apply` (codemod object-form)";
45
47
 
46
48
  type ReceiverResolution = "registrar" | "other" | "unresolved";
@@ -145,39 +147,51 @@ function positionalCallMethod(call: CallExpression): RegistrarMethod | undefined
145
147
 
146
148
  export type ObjectFormFinding = { file: string; line: number; message: string };
147
149
 
150
+ function positionalCallFindings(sf: SourceFile, file: string): ObjectFormFinding[] {
151
+ if (!REGISTRAR_METHOD_ACCESS.test(sf.getFullText())) return [];
152
+ const out: ObjectFormFinding[] = [];
153
+ for (const call of sf.getDescendantsOfKind(SyntaxKind.CallExpression)) {
154
+ const method = positionalCallMethod(call);
155
+ if (method === undefined) continue;
156
+ out.push({
157
+ file,
158
+ line: call.getStartLineNumber(),
159
+ message: `positional r.${method}(…) removed in 0.366 — ${UPGRADE_HINT}`,
160
+ });
161
+ }
162
+ return out;
163
+ }
164
+
165
+ function crudFindings(sf: SourceFile, file: string): ObjectFormFinding[] {
166
+ // Every crud local name is CRUD_FUNCTION_NAME itself or an import alias of it.
167
+ if (!sf.getFullText().includes(CRUD_FUNCTION_NAME)) return [];
168
+ const crudNames = crudLocalNames(sf);
169
+ const out: ObjectFormFinding[] = [];
170
+ for (const id of sf.getDescendantsOfKind(SyntaxKind.Identifier)) {
171
+ if (!crudNames.has(id.getText())) continue;
172
+ const parent = id.getParent();
173
+ const isCall = Node.isCallExpression(parent) && parent.getExpression() === id;
174
+ const isImportOrExport =
175
+ Node.isImportSpecifier(parent) ||
176
+ Node.isExportSpecifier(parent) ||
177
+ id.getFirstAncestorByKind(SyntaxKind.ImportDeclaration) !== undefined;
178
+ if (!isCall && !isImportOrExport) continue;
179
+ if (Node.isImportSpecifier(parent) && parent.getAliasNode() === id) continue;
180
+ out.push({
181
+ file,
182
+ line: id.getStartLineNumber(),
183
+ message: `registerEntityCrud removed in 0.366 — use r.crud(…); ${UPGRADE_HINT}`,
184
+ });
185
+ }
186
+ return out;
187
+ }
188
+
148
189
  export function findPositionalRegistrarCalls(files: readonly SourceFile[]): ObjectFormFinding[] {
149
190
  const out: ObjectFormFinding[] = [];
150
191
  for (const sf of files) {
151
192
  if (EXCLUDED_PATH.test(sf.getFilePath())) continue;
152
193
  const file = relativeToCwd(sf.getFilePath());
153
- const crudNames = crudLocalNames(sf);
154
-
155
- for (const call of sf.getDescendantsOfKind(SyntaxKind.CallExpression)) {
156
- const method = positionalCallMethod(call);
157
- if (method === undefined) continue;
158
- out.push({
159
- file,
160
- line: call.getStartLineNumber(),
161
- message: `positional r.${method}(…) removed in 0.366 — ${UPGRADE_HINT}`,
162
- });
163
- }
164
-
165
- for (const id of sf.getDescendantsOfKind(SyntaxKind.Identifier)) {
166
- if (!crudNames.has(id.getText())) continue;
167
- const parent = id.getParent();
168
- const isCall = Node.isCallExpression(parent) && parent.getExpression() === id;
169
- const isImportOrExport =
170
- Node.isImportSpecifier(parent) ||
171
- Node.isExportSpecifier(parent) ||
172
- id.getFirstAncestorByKind(SyntaxKind.ImportDeclaration) !== undefined;
173
- if (!isCall && !isImportOrExport) continue;
174
- if (Node.isImportSpecifier(parent) && parent.getAliasNode() === id) continue;
175
- out.push({
176
- file,
177
- line: id.getStartLineNumber(),
178
- message: `registerEntityCrud removed in 0.366 — use r.crud(…); ${UPGRADE_HINT}`,
179
- });
180
- }
194
+ out.push(...positionalCallFindings(sf, file), ...crudFindings(sf, file));
181
195
  }
182
196
  return out;
183
197
  }
@@ -266,6 +266,23 @@ function gitOrFail(args: readonly string[], cwd: string, message: string): strin
266
266
 
267
267
  const REWRITE_HINT = `Translate new German comments to English (why, not what), or if deliberate:\n // ${IGNORE_TAG} <reason>`;
268
268
 
269
+ // A path-limited diff cannot pair a rename, so every comment of a moved file would read as added.
270
+ function readRenamedFrom(gitToplevel: string, mergeBase: string): Map<string, readonly string[]> {
271
+ const renamedFrom = new Map<string, readonly string[]>();
272
+ const fields = gitOrFail(
273
+ ["diff", "--name-status", "-z", "-M", "--diff-filter=R", mergeBase, "HEAD"],
274
+ gitToplevel,
275
+ "Touched mode: git diff --name-status failed. Check the git error below.",
276
+ )
277
+ .split("\0")
278
+ .filter(Boolean);
279
+ for (let i = 0; i + 2 < fields.length; i += 3) {
280
+ const [, oldPath, newPath] = fields.slice(i, i + 3);
281
+ if (oldPath !== undefined && newPath !== undefined) renamedFrom.set(newPath, [oldPath]);
282
+ }
283
+ return renamedFrom;
284
+ }
285
+
269
286
  function runTouchedMode(
270
287
  argv: readonly string[],
271
288
  root: string,
@@ -304,6 +321,8 @@ function runTouchedMode(
304
321
  .split("\0")
305
322
  .filter(Boolean);
306
323
 
324
+ const renamedFrom = readRenamedFrom(gitToplevel, mergeBase);
325
+
307
326
  // The commits being pushed are checked, not the working tree: contents come from
308
327
  // HEAD so line numbers match the `mergeBase..HEAD` diff. Scan scope is keyed
309
328
  // relative to root, git output relative to the toplevel.
@@ -321,7 +340,17 @@ function runTouchedMode(
321
340
  if (candidates.length === 0) continue;
322
341
  const addedLines = parseAddedLineRanges(
323
342
  gitOrFail(
324
- ["--literal-pathspecs", "diff", "--unified=0", mergeBase, "HEAD", "--", file],
343
+ [
344
+ "--literal-pathspecs",
345
+ "diff",
346
+ "-M",
347
+ "--unified=0",
348
+ mergeBase,
349
+ "HEAD",
350
+ "--",
351
+ file,
352
+ ...(renamedFrom.get(file) ?? []),
353
+ ],
325
354
  gitToplevel,
326
355
  `Touched mode: git diff --unified=0 failed for ${file}. Check the git error below.`,
327
356
  ),
@@ -156,21 +156,26 @@ export function unwrapTableExpression(node: Node): Node {
156
156
  // declaration disambiguates them.
157
157
  type TableId = string; // "${filePath}::${name}"
158
158
 
159
- function declIdOf(id: import("ts-morph").Identifier): TableId | undefined {
159
+ function declIdOf(id: Identifier): TableId | undefined {
160
160
  const symbol = id.getSymbol();
161
161
  if (!symbol) return undefined;
162
- const decls = symbol.getDeclarations();
162
+ // An imported identifier's own declaration is the ImportSpecifier; follow the alias
163
+ // chain (barrels, renames) so the ID matches the one built at the definition.
164
+ const resolved = symbol.isAlias() ? (symbol.getAliasedSymbol() ?? symbol) : symbol;
165
+ const decls = resolved.getDeclarations();
163
166
  if (decls.length === 0) return undefined;
164
167
  // Param-only symbols are factory pass-throughs — skip.
165
168
  if (decls.every((d) => d.getKind() === SyntaxKind.Parameter)) return undefined;
166
169
  const first = decls[0];
167
170
  if (!first) return undefined;
168
- return `${first.getSourceFile().getFilePath()}::${id.getText()}`;
171
+ const name = Node.hasName(first) ? first.getName() : id.getText();
172
+ return `${first.getSourceFile().getFilePath()}::${name}`;
169
173
  }
170
174
 
171
175
  export function collectEsTables(files: readonly SourceFile[]): Set<TableId> {
172
176
  const tables = new Set<TableId>();
173
177
  for (const sf of files) {
178
+ if (!sf.getFullText().includes("createEventStoreExecutor")) continue;
174
179
  for (const call of sf.getDescendantsOfKind(SyntaxKind.CallExpression)) {
175
180
  const expr = call.getExpression();
176
181
  if (expr.getText() !== "createEventStoreExecutor") continue;
@@ -251,6 +256,8 @@ function rebuildableEntityTableDeclId(
251
256
  return declIdOf(nameNode.asKindOrThrow(SyntaxKind.Identifier));
252
257
  }
253
258
 
259
+ const ENTITY_METHOD_ACCESS = /\.\s*entity\b/;
260
+
254
261
  // Tables registered via `r.entity({ name, ...entityDef })` as a rebuildable implicit
255
262
  // projection, linked entity→table via the shared entity-def symbol (see header), not by name string.
256
263
  export function collectEntityProjectionTables(files: readonly SourceFile[]): Set<TableId> {
@@ -259,6 +266,7 @@ export function collectEntityProjectionTables(files: readonly SourceFile[]): Set
259
266
 
260
267
  // Pass 1: entity-def decls that r.entity makes rebuildable (+ {table} override).
261
268
  for (const sf of files) {
269
+ if (!ENTITY_METHOD_ACCESS.test(sf.getFullText())) continue;
262
270
  for (const call of sf.getDescendantsOfKind(SyntaxKind.CallExpression)) {
263
271
  const registration = entityRegistrationOf(call);
264
272
  if (!registration) continue;
@@ -270,6 +278,7 @@ export function collectEntityProjectionTables(files: readonly SourceFile[]): Set
270
278
  // Pass 2: `const xTable = buildEntityTable(<name>, <entityDef>)` where entityDef
271
279
  // is in the rebuildable set → xTable is a rebuildable entity table.
272
280
  for (const sf of files) {
281
+ if (!sf.getFullText().includes("buildEntityTable")) continue;
273
282
  for (const call of sf.getDescendantsOfKind(SyntaxKind.CallExpression)) {
274
283
  const did = rebuildableEntityTableDeclId(call, rebuildableEntities);
275
284
  if (did) tables.add(did);
@@ -427,14 +436,37 @@ function hasAnyTableWriteOrDeclaration(files: readonly SourceFile[]): boolean {
427
436
  return false;
428
437
  }
429
438
 
439
+ // A TableId ends in `::<identifier text>`, so a table argument whose text is no ES table name
440
+ // can never match; filtering on the name avoids a symbol lookup (TS program build) per call.
441
+ function tableNamesOf(tableIds: ReadonlySet<TableId>): Set<string> {
442
+ return new Set([...tableIds].map((tableId) => tableId.slice(tableId.lastIndexOf("::") + 2)));
443
+ }
444
+
445
+ // Local names an import can give a table in this file (`import { a as b }` -> b). Syntactic,
446
+ // so a rename or barrel re-export never hides an ES table from the name prefilter.
447
+ function importedBindingNames(sf: SourceFile): Set<string> {
448
+ const names = new Set<string>();
449
+ for (const declaration of sf.getImportDeclarations()) {
450
+ const defaultName = declaration.getDefaultImport()?.getText();
451
+ if (defaultName) names.add(defaultName);
452
+ for (const specifier of declaration.getNamedImports()) {
453
+ names.add(specifier.getAliasNode()?.getText() ?? specifier.getName());
454
+ }
455
+ }
456
+ return names;
457
+ }
458
+
430
459
  export function scanDirectWrites(
431
460
  sf: SourceFile,
432
461
  esTables: ReadonlySet<TableId>,
433
462
  ): Omit<Violation, "file">[] {
434
463
  const out: Omit<Violation, "file">[] = [];
464
+ // Superset of every name a write may use for an ES table: definition names plus import bindings.
465
+ const candidateNames = new Set([...tableNamesOf(esTables), ...importedBindingNames(sf)]);
435
466
  for (const call of sf.getDescendantsOfKind(SyntaxKind.CallExpression)) {
436
467
  const w = resolveWrite(call);
437
468
  if (!w) continue;
469
+ if (!candidateNames.has(w.tableArg.getText())) continue;
438
470
 
439
471
  // Walk the receiver chain down to its leftmost identifier. For `db.insert`
440
472
  // that's `db`; for `ctx.db.raw` (function-form arg0) that's `ctx`; for
@@ -457,7 +489,7 @@ export function scanDirectWrites(
457
489
  if (isInsideProjectionApply(call)) continue;
458
490
  out.push({
459
491
  line: call.getStartLineNumber(),
460
- receiver: receiverName,
492
+ receiver: w.receiver.getText(),
461
493
  op: w.op,
462
494
  table: w.tableArg.getText(),
463
495
  snippet: call.getText().slice(0, 120),
@@ -468,7 +500,7 @@ export function scanDirectWrites(
468
500
 
469
501
  out.push({
470
502
  line: call.getStartLineNumber(),
471
- receiver: receiverName,
503
+ receiver: w.receiver.getText(),
472
504
  op: w.op,
473
505
  table: w.tableArg.getText(),
474
506
  snippet: call.getText().slice(0, 120),
@@ -46,8 +46,8 @@ const EXCLUDE =
46
46
  // Prefixed by RepoRoot.name (not kind): kind "app" is shared across
47
47
  // unregistered repos (infra#560).
48
48
 
49
- // packages/http/src/egress.ts IS the egress() implementation.
50
- const ALLOWLIST = [/^kumiko-framework\/packages\/http\/src\/egress\.ts$/];
49
+ // packages/framework/src/http/egress.ts IS the egress() implementation.
50
+ const ALLOWLIST = [/^kumiko-framework\/packages\/framework\/src\/http\/egress\.ts$/];
51
51
  const ALLOW_MARKER = "guard-allow: same-origin fetch";
52
52
 
53
53
  interface Violation {
@@ -128,6 +128,7 @@ function scannableFiles(files: readonly SourceFile[]): SourceFile[] {
128
128
  export function systemScopeDirs(files: readonly SourceFile[]): string[] {
129
129
  const dirs: string[] = [];
130
130
  for (const sf of scannableFiles(files)) {
131
+ if (!sf.getFullText().includes("systemScope")) continue;
131
132
  for (const call of sf.getDescendantsOfKind(SyntaxKind.CallExpression)) {
132
133
  const expr = call.getExpression();
133
134
  if (
@@ -189,6 +190,10 @@ function isTenantDbExpression(node: Node): boolean {
189
190
  return (node.getSymbol()?.getDeclarations() ?? []).some(isTenantDbDeclaration);
190
191
  }
191
192
 
193
+ // Text prefilters: a node walk per rule per file is the guard's dominant cost, and
194
+ // a rule's anchor name must appear verbatim in any file it can fire on.
195
+ const RAW_ACCESS = /\.\s*raw\b/;
196
+
192
197
  function findRawFindings(
193
198
  sf: SourceFile,
194
199
  root: string,
@@ -196,6 +201,7 @@ function findRawFindings(
196
201
  ): EscapeHatchFinding[] {
197
202
  if (isInSystemScope(sf.getFilePath(), systemDirs)) return [];
198
203
  const out: EscapeHatchFinding[] = [];
204
+ if (!RAW_ACCESS.test(sf.getFullText())) return out;
199
205
  for (const pa of sf.getDescendantsOfKind(SyntaxKind.PropertyAccessExpression)) {
200
206
  if (pa.getName() !== "raw") continue;
201
207
  if (!isTenantDbExpression(pa.getExpression())) continue;
@@ -217,6 +223,7 @@ function findUnsafeRawFindings(
217
223
  ): EscapeHatchFinding[] {
218
224
  if (isInSystemScope(sf.getFilePath(), systemDirs)) return [];
219
225
  const out: EscapeHatchFinding[] = [];
226
+ if (!sf.getFullText().includes("unsafeRaw")) return out;
220
227
  for (const call of sf.getDescendantsOfKind(SyntaxKind.CallExpression)) {
221
228
  const expr = call.getExpression();
222
229
  if (!expr.isKind(SyntaxKind.PropertyAccessExpression)) continue;
@@ -531,6 +538,8 @@ function findSystemIdentityFindings(
531
538
  systemDirs: readonly string[],
532
539
  ): EscapeHatchFinding[] {
533
540
  const out: EscapeHatchFinding[] = [];
541
+ const text = sf.getFullText();
542
+ if (!text.includes("queryAs") && !text.includes("writeAs")) return out;
534
543
  for (const call of sf.getDescendantsOfKind(SyntaxKind.CallExpression)) {
535
544
  const expr = call.getExpression();
536
545
  if (!expr.isKind(SyntaxKind.PropertyAccessExpression)) continue;
@@ -562,6 +571,7 @@ function findUnsafeAllTenantsFindings(
562
571
  systemDirs: readonly string[],
563
572
  ): EscapeHatchFinding[] {
564
573
  const out: EscapeHatchFinding[] = [];
574
+ if (!sf.getFullText().includes("unsafeAllTenants")) return out;
565
575
  for (const prop of sf.getDescendantsOfKind(SyntaxKind.PropertyAssignment)) {
566
576
  const nameNode = prop.getNameNode();
567
577
  if (!nameNode.isKind(SyntaxKind.Identifier) || nameNode.getText() !== "unsafeAllTenants") {
@@ -609,6 +619,8 @@ const GENERIC_REASON_METHODS = new Set(["acknowledgeCrossTenant", "unsafeRaw"]);
609
619
 
610
620
  function findGenericReasonMethodCalls(sf: SourceFile, root: string): GenericReasonFinding[] {
611
621
  const out: GenericReasonFinding[] = [];
622
+ const text = sf.getFullText();
623
+ if (![...GENERIC_REASON_METHODS].some((method) => text.includes(method))) return out;
612
624
  for (const call of sf.getDescendantsOfKind(SyntaxKind.CallExpression)) {
613
625
  const expr = call.getExpression();
614
626
  if (!expr.isKind(SyntaxKind.PropertyAccessExpression)) continue;
@@ -634,6 +646,7 @@ function findGenericReasonDeclareEscapeHatchCalls(
634
646
  root: string,
635
647
  ): GenericReasonFinding[] {
636
648
  const out: GenericReasonFinding[] = [];
649
+ if (!sf.getFullText().includes("declareEscapeHatch")) return out;
637
650
  for (const call of sf.getDescendantsOfKind(SyntaxKind.CallExpression)) {
638
651
  const callee = call.getExpression();
639
652
  if (!callee.isKind(SyntaxKind.Identifier) || callee.getText() !== "declareEscapeHatch") {
@@ -662,6 +675,7 @@ function findGenericReasonObjectProperty(
662
675
  propertyName: string,
663
676
  ): GenericReasonFinding[] {
664
677
  const out: GenericReasonFinding[] = [];
678
+ if (!sf.getFullText().includes(propertyName)) return out;
665
679
  for (const pa of sf.getDescendantsOfKind(SyntaxKind.PropertyAssignment)) {
666
680
  if (pa.getName() !== propertyName) continue;
667
681
  const init = pa.getInitializer();
@@ -3,7 +3,7 @@
3
3
  * Guard: HTML-building template literals must escape every interpolation.
4
4
  *
5
5
  * XSS protection in this codebase is a callsite convention
6
- * (escapeHtml/escapeHtmlAttr from kumiko-headless) — this guard makes the
6
+ * (escapeHtml/escapeHtmlAttr from kumiko-renderer/headless) — this guard makes the
7
7
  * convention enforceable. A template literal counts as HTML-building when
8
8
  * its static text contains a known HTML/MJML/SVG tag. Every interpolation
9
9
  * inside it must then be recognizably safe:
@@ -409,7 +409,7 @@ function scanFile(sf: SourceFile): UnsafeSite[] {
409
409
  export const guard: AstGuard = {
410
410
  name: "HTML-Escape Guard",
411
411
  scan: SCAN,
412
- hint: "Escape interpolation in an HTML template literal: escapeHtml()/escapeHtmlAttr() from @cosmicdrift/kumiko-headless. Mark pre-rendered HTML with a `*Html` name; type static copy tables `as const`; deliberate exception with `// html-ok: <why>`.",
412
+ hint: "Escape interpolation in an HTML template literal: escapeHtml()/escapeHtmlAttr() from @cosmicdrift/kumiko-renderer/headless. Mark pre-rendered HTML with a `*Html` name; type static copy tables `as const`; deliberate exception with `// html-ok: <why>`.",
413
413
  run(files) {
414
414
  const violations: Array<{ file: string; line: number; message: string }> = [];
415
415
  for (const sf of files) {