@cosmicdrift/kumiko-guards 0.389.0 → 0.390.1

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.389.0",
3
+ "version": "0.390.1",
4
4
  "description": "AST-based security guards for Kumiko repos: direct-fs/fetch, tenant escalation, admin-API, escape hatches and related checks, run over a shared ts-morph project.",
5
5
  "license": "MIT",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -37,7 +37,7 @@
37
37
  },
38
38
  "dependencies": {
39
39
  "ts-morph": "^28.0.0",
40
- "zod": "^4.4.3"
40
+ "zod": "^4.6.7"
41
41
  },
42
42
  "publishConfig": {
43
43
  "registry": "https://registry.npmjs.org",
@@ -51,7 +51,7 @@
51
51
  "LICENSE"
52
52
  ],
53
53
  "peerDependencies": {
54
- "@cosmicdrift/kumiko-framework": "0.389.0"
54
+ "@cosmicdrift/kumiko-framework": "0.390.1"
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.389.0"
62
+ "@cosmicdrift/kumiko-framework": "0.390.1"
63
63
  }
64
64
  }
@@ -81,7 +81,7 @@ export const RAW_SQL_ALLOWLIST: ReadonlyArray<RegExp> = [
81
81
  /\/bundled-features\/src\/form-draft\/db\/queries\/owned-file-refs\.ts$/,
82
82
  /\/bundled-features\/src\/secrets\/db\/queries\/read\.ts$/,
83
83
  /\/bundled-features\/src\/sessions\/db\/queries\/cleanup\.ts$/,
84
- /\/bundled-features\/src\/user\/db\/queries\/stream-tenant-backfill\.ts$/,
84
+ /\/bundled-features\/src\/shared\/stream-tenant-backfill\.ts$/,
85
85
  /\/packages\/framework\/src\/engine\/steps\/unsafe-projection-/,
86
86
  /(^|\/)samples\/(apps|recipes)\/[^/]+\/src\/db\/queries\//,
87
87
  /\/bin\/commands\//,
@@ -0,0 +1,88 @@
1
+ // Static detection of projection-only writes on event-sourced tables in tests. Without
2
+ // types the guard knows a table is event-sourced only when the file builds it itself
3
+ // (buildEntityTable) or names it by its reserved read_ prefix; imported tables in
4
+ // template SQL stay invisible.
5
+ import type { SourceFile } from "ts-morph";
6
+ import { Node, SyntaxKind } from "ts-morph";
7
+
8
+ export type RawWriteFinding = { readonly line: number; readonly message: string };
9
+
10
+ const EVENTS_TABLE_NAME = "eventsTable";
11
+
12
+ export const SEED_ROW_MESSAGE =
13
+ "seedRow on an event-sourced entity table writes the projection without its event: use seedRowViaEventStore";
14
+
15
+ export const RAW_SQL_MESSAGE =
16
+ "raw DELETE/UPDATE on an event-sourced table in a test: reset with resetTestTables(db, [table, eventsTable]), " +
17
+ "change rows through the real command or updateRowViaEventStore/deleteRowViaEventStore";
18
+
19
+ // kumiko_events UPDATEs are reported by the test-es-fixtures guard already.
20
+ const LITERAL_WRITE_RE =
21
+ /\b(?:DELETE\s+FROM\s+"?(?:read_\w+|kumiko_events)|UPDATE\s+"?read_\w+)\b/i;
22
+ const TEMPLATE_WRITE_RE = /\b(?:DELETE\s+FROM|UPDATE)\s+"?\$\{\s*(\w+)(?:\.tableName)?\s*\}/gi;
23
+
24
+ function entityTableBindings(sourceFile: SourceFile): Set<string> {
25
+ const names = new Set<string>();
26
+ for (const decl of sourceFile.getDescendantsOfKind(SyntaxKind.VariableDeclaration)) {
27
+ const init = decl.getInitializer();
28
+ if (
29
+ init &&
30
+ Node.isCallExpression(init) &&
31
+ init.getExpression().getText() === "buildEntityTable"
32
+ ) {
33
+ names.add(decl.getName());
34
+ }
35
+ }
36
+ return names;
37
+ }
38
+
39
+ function isEventSourcedBinding(name: string, entityTables: ReadonlySet<string>): boolean {
40
+ return name === EVENTS_TABLE_NAME || entityTables.has(name);
41
+ }
42
+
43
+ function writesEventSourcedTable(node: Node, entityTables: ReadonlySet<string>): boolean {
44
+ const text = node.getText();
45
+ if (LITERAL_WRITE_RE.test(text)) return true;
46
+ if (!Node.isTemplateExpression(node)) return false;
47
+ for (const match of text.matchAll(TEMPLATE_WRITE_RE)) {
48
+ if (isEventSourcedBinding(match[1] ?? "", entityTables)) return true;
49
+ }
50
+ return false;
51
+ }
52
+
53
+ export function findRawSqlWrites(sourceFile: SourceFile): RawWriteFinding[] {
54
+ const entityTables = entityTableBindings(sourceFile);
55
+ const findings: RawWriteFinding[] = [];
56
+ for (const kind of [
57
+ SyntaxKind.StringLiteral,
58
+ SyntaxKind.NoSubstitutionTemplateLiteral,
59
+ SyntaxKind.TemplateExpression,
60
+ ]) {
61
+ for (const node of sourceFile.getDescendantsOfKind(kind)) {
62
+ if (writesEventSourcedTable(node, entityTables)) {
63
+ findings.push({ line: node.getStartLineNumber(), message: RAW_SQL_MESSAGE });
64
+ }
65
+ }
66
+ }
67
+ return findings;
68
+ }
69
+
70
+ export function findSeedRowOnEntityTables(
71
+ sourceFile: SourceFile,
72
+ isSeedRowCallee: (callee: Node) => boolean,
73
+ ): RawWriteFinding[] {
74
+ const entityTables = entityTableBindings(sourceFile);
75
+ if (entityTables.size === 0) return [];
76
+ return sourceFile
77
+ .getDescendantsOfKind(SyntaxKind.CallExpression)
78
+ .filter((call) => {
79
+ const table = call.getArguments()[1];
80
+ return (
81
+ isSeedRowCallee(call.getExpression()) &&
82
+ table !== undefined &&
83
+ Node.isIdentifier(table) &&
84
+ entityTables.has(table.getText())
85
+ );
86
+ })
87
+ .map((call) => ({ line: call.getStartLineNumber(), message: SEED_ROW_MESSAGE }));
88
+ }
package/src/changes.json CHANGED
@@ -1,4 +1,54 @@
1
1
  [
2
+ {
3
+ "version": "0.390.0",
4
+ "type": "improvement",
5
+ "title": "Complexity guard covers .tsx with the hard cap of 40",
6
+ "detail": "The Complexity Check scans .tsx files next to .ts (.test.tsx stays excluded), and conditional JSX counts as a branch. A .tsx function above complexity 40 fails in the repo's own files, regardless of baseline or complexity-budget tag.",
7
+ "migration": "Run `kumiko-guards guards --write-baseline --guard=\"Complexity Check\"` once on the bump to freeze the .tsx hotspots between 15 and 40, and split every .tsx function above 40 before the bump; the complexity-budget tag and the baseline do not cover it. Measured on 2026-10-23 (function, complexity / lines): offlot-app ComposerWorkspace 129/597 (campaign-composer-screen.tsx) and ComposerPostText 47/289 (composer-post-drawer.tsx); kumiko-enterprise AgentPanelBody 74/634 (agent-panel-body.tsx); phronexsis WorkbenchDashboardScreen 64/653 (workbench-dashboard-screen.tsx); money-horse CreditCalculatorScreen 60/468 (credit-calculator-screen.tsx); solon CockpitDetail 58/462 (inbox-cockpit-screen.tsx). publicstatus, show-pony, kumiko-studio and kumiko-platform have no .tsx function above 40."
8
+ },
9
+ {
10
+ "version": "0.390.0",
11
+ "type": "improvement",
12
+ "title": "Guard for the runSchemaCli feature list",
13
+ "detail": "The Schema CLI Features guard fails when a consumer's `runSchemaCli` call does not get `features: FEATURES` imported from `kumiko/schema`. `allAppFeatures()` is raw, without the bundled foundation, so the projection rebuild after the migrations failed at deploy with \"Feature X requires feature Y\" (kumiko-studio, 2026-10-22). The message names the fix. Repos without `kumiko/schema.ts` are skipped, and a manifest exclude cannot hide `bin/kumiko.ts`.",
14
+ "migration": "Run `bun kumiko check` once on the bump. Measured on 2026-10-23: only show-pony fails (bin/kumiko.ts composes its own list); import `FEATURES` from `../kumiko/schema` there. publicstatus, offlot-app, solon, money-horse, phronexsis and kumiko-studio are clean."
15
+ },
16
+ {
17
+ "version": "0.390.0",
18
+ "type": "improvement",
19
+ "title": "Guard against projection-only writes in framework tests",
20
+ "detail": "The Test-ES-Row-Writes guard flags updateRows and deleteRows from the testing helpers in framework tests, because they change the projection but not the event log. It only runs in the framework repo; consumer repos are not affected."
21
+ },
22
+ {
23
+ "version": "0.390.0",
24
+ "type": "improvement",
25
+ "title": "Guard against array parameters in .unsafe()",
26
+ "detail": "The Unsafe Array Param guard warns when a `.unsafe(sql, [...])` parameter list holds an array literal or a spread. Bun.SQL binds such an array as \"a,b\" on Linux, so `ANY($n)` fails only in CI and passes on macOS; pass `toPostgresArrayLiteral(values)` with a `$n::text[]` cast. It is a warning and cannot see array-typed variables. Measured on 2026-10-23: no hit in any consumer repo."
27
+ },
28
+ {
29
+ "version": "0.390.0",
30
+ "type": "improvement",
31
+ "title": "test-es-row-writes guard covers seedRow and raw SQL on event-sourced tables",
32
+ "detail": "Besides updateRows/deleteRows, the guard now reports seedRow on an entity table the test file builds with buildEntityTable, and raw DELETE or UPDATE statements on read_ tables, DELETE on kumiko_events and template SQL on eventsTable or such an entity table. Tests that corrupt projections, backdate timestamps or plant legacy rows on purpose are allowlisted per file with a reason. The guard runs in the framework repo only; nothing changes for apps."
33
+ },
34
+ {
35
+ "version": "0.390.0",
36
+ "type": "improvement",
37
+ "title": "New guard rls-session-settings",
38
+ "detail": "Flags SET and SET SESSION on kumiko.* settings and set_config with is_local false in any source or test string, kumiko.* writes outside rls-scope.ts in non-test code, and a set_config in rls-scope.ts without literal true. A session-level setting survives on a pooled connection and leaks one request's scope into the next."
39
+ },
40
+ {
41
+ "version": "0.390.0",
42
+ "type": "improvement",
43
+ "title": "rls-session-settings also scans .sql files",
44
+ "detail": "Consumer migrations are checked statement by statement for session-level SET kumiko.*, set_config with is_local false and any kumiko.* write."
45
+ },
46
+ {
47
+ "version": "0.390.0",
48
+ "type": "improvement",
49
+ "title": "New guard internal-subpath-imports",
50
+ "detail": "Flags kumiko-framework/internal/* imports outside the framework repo, row level security scope helpers from kumiko-testing/helpers in non-test code, and system scope calls without a literal reason."
51
+ },
2
52
  {
3
53
  "version": "0.389.0",
4
54
  "type": "improvement",
@@ -329,7 +329,7 @@ const FILE_DEFAULT_REASONS: ReadonlyArray<{
329
329
  {
330
330
  // Feature-AST extractors cast erased ts-morph parse results to typed feature definitions
331
331
  // (EntityDefinition, RelationDef, NavDef, ConfigKeys, …), so every cast is a schema walk.
332
- pattern: /\/engine\/feature-ast\//,
332
+ pattern: /(^|\/)packages\/feature-ast\/src\/(?!scripts\/|codemod\/|pattern-library\/)/,
333
333
  boundaryReason: "schema-walk",
334
334
  },
335
335
  ];
@@ -362,7 +362,7 @@ export function collect(sf: SourceFile): Site[] {
362
362
  if (isBridgeOuter(cast)) continue;
363
363
  const category = categorize(cast);
364
364
  // TypingLossMarker casts (e.g. `as DbRow`) and FileDefault casts
365
- // (e.g. feature-ast/extractors.ts → "schema-walk") are boundaries by type
365
+ // (e.g. feature-ast/src/extractors/*.ts → "schema-walk") are boundaries by type
366
366
  // definition / convention — synthetic reason so the unknown-reason check
367
367
  // does not fail on them.
368
368
  const reason =
@@ -1,10 +1,11 @@
1
1
  #!/usr/bin/env bun
2
2
  /**
3
- * Complexity check for handler hotspots with a baseline regression guard.
3
+ * Complexity check for .ts and .tsx hotspots with a hard cap and a baseline regression guard.
4
4
  *
5
5
  * Computes cyclomatic complexity per function/method (base 1, +1 per decision
6
6
  * point: if, for, while, case, catch, &&, ||, ??, ternary). Above the
7
- * threshold -> hotspot.
7
+ * threshold -> hotspot. In .tsx, conditional JSX (`cond && <X/>`, ternaries)
8
+ * counts like any other branch.
8
9
  *
9
10
  * Baseline regression guard like guard-comment-lang.ts: `.kumiko-complexity-
10
11
  * baseline.json` in the repo root pins the frozen hotspot count per file.
@@ -52,11 +53,11 @@ const ROOT = process.cwd();
52
53
 
53
54
  const SCAN: ScanSpec = {
54
55
  scope: "source",
55
- extensions: ["ts"],
56
+ extensions: ["ts", "tsx"],
56
57
  frameworkWithin: ["packages/*/src/**"],
57
58
  };
58
59
 
59
- const EXCLUDE = /(__tests__|\.test\.ts$|\.integration\.ts$|\.d\.ts$)/;
60
+ const EXCLUDE = /(__tests__|\.test\.(ts|tsx)$|\.integration\.(ts|tsx)$|\.d\.ts$)/;
60
61
 
61
62
  const COMPLEXITY_THRESHOLD = 15;
62
63
  const MAX_REPORTED = 30;
@@ -115,7 +115,7 @@ export const guard: AstGuard = {
115
115
  scan: SCAN,
116
116
  // App repos are not exempt — appendRaw/appendRawBatch bypasses the pipeline.
117
117
  security: true,
118
- hint: "Admin API (appendRaw/appendRawBatch) bypasses the pipeline — only allowed in samples/*/migration/ or scripts/migrations/. For domain events: ctx.appendEvent / write handler.",
118
+ hint: "Admin API (appendRaw/appendRawBatch) bypasses the pipeline — only allowed in samples/*/migration/ or scripts/migrations/. For domain events: ctx.events.append / write handler.",
119
119
  run(files, roots: readonly RepoRoot[] = resolveRepoRoots()) {
120
120
  const violations: Array<{ file: string; line: number; message: string }> = [];
121
121
 
@@ -20,7 +20,8 @@
20
20
  * (FrameworkReasons.*, <Anything>Reasons.*, TenantErrors.*, etc.).
21
21
  * 2. `failUnprocessable(X, ...)` — same rule.
22
22
  * 3. Object literals containing `reason: "X"` — same rule for the X.
23
- * Skips `openToAll: { reason: "..." }` / `escapeHatch: { reason: "..." }`
23
+ * Skips `openToAll: { reason: "..." }` / `escapeHatch: { reason: "..." }` /
24
+ * `grants: { unsafeRaw: { reason: "..." } }` (every escape-hatch grant)
24
25
  * and `declareEscapeHatch({ reason: "..." })` — those are prose
25
26
  * access-declaration justifications, enforced instead by
26
27
  * the boot validator and the runtime reasonProblem check.
@@ -34,6 +35,7 @@
34
35
  */
35
36
 
36
37
  import * as path from "node:path";
38
+ import { ESCAPE_HATCH_GRANTS } from "@cosmicdrift/kumiko-types/handlers";
37
39
  import { type Node, type SourceFile, SyntaxKind } from "ts-morph";
38
40
  import { type AstGuard, runStandalone, type ScanSpec } from "./_lib/guard-kit";
39
41
 
@@ -58,11 +60,22 @@ const UNPROC_CALL_NAMES = new Set(["UnprocessableError", "failUnprocessable"]);
58
60
 
59
61
  // Property names whose `{ reason: "..." }` is a prose access-declaration
60
62
  // justification, not an error-reason code.
61
- const ACCESS_DECLARATION_NAMES = new Set(["openToAll", "escapeHatch"]);
63
+ // `grants: { <grant>: { reason } }` carries the grant names of EscapeHatchDeclaration; deriving them
64
+ // keeps a new grant from being reported as an error code.
65
+ const ACCESS_DECLARATION_NAMES = new Set<string>([
66
+ "openToAll",
67
+ "escapeHatch",
68
+ "unsafeAllTenants",
69
+ ...ESCAPE_HATCH_GRANTS,
70
+ ]);
62
71
 
63
72
  // Calls whose object-literal argument carries a prose `reason` for an
64
- // access grant / escape hatch.
65
- const ACCESS_DECLARATION_CALLS = new Set(["declareEscapeHatch", "withUnsafeRawGrant"]);
73
+ // access grant / escape hatch / system-mode TenantDb.
74
+ const ACCESS_DECLARATION_CALLS = new Set([
75
+ "declareEscapeHatch",
76
+ "withUnsafeRawGrant",
77
+ "createTenantDb",
78
+ ]);
66
79
 
67
80
  interface Violation {
68
81
  readonly file: string;
@@ -38,14 +38,14 @@ const EXCLUDE = /(__tests__|\.test\.ts$|\.integration\.ts$|\.d\.ts$)/;
38
38
  // call (event-store.ts, event-store-admin.ts, event-store-transfer.ts).
39
39
  // - db/queries/backfill-pii.ts — the one-time pre-KMS plaintext backfill;
40
40
  // rewrites payload/deletes stale snapshots in place, framework-owned.
41
- // - user/db/queries/stream-tenant-backfill.ts — a one-time stream-ownership
41
+ // - shared/stream-tenant-backfill.ts — a one-time stream-ownership
42
42
  // migration that predates this guard; already carries its own
43
43
  // RAW_SQL_ALLOWLIST entry, kept here for the same reason.
44
44
  const ALLOWLIST: readonly RegExp[] = [
45
45
  /^packages\/framework\/src\/event-store\//,
46
46
  /^packages\/framework\/src\/db\/queries\/event-store.*\.ts$/,
47
47
  /^packages\/framework\/src\/db\/queries\/backfill-pii\.ts$/,
48
- /^packages\/bundled-features\/src\/user\/db\/queries\/stream-tenant-backfill\.ts$/,
48
+ /^packages\/bundled-features\/src\/shared\/stream-tenant-backfill\.ts$/,
49
49
  ];
50
50
 
51
51
  const ES_TABLES = "kumiko_events|kumiko_snapshots|kumiko_archived_streams";
@@ -87,7 +87,7 @@ export const guard: AstGuard = {
87
87
  scan: SCAN,
88
88
  hint:
89
89
  "Relative imports name the source file: `./foo.js` -> `./foo.ts`, `./Comp.jsx` -> `./Comp.tsx`. " +
90
- "Run the import-extensions codemod: `bun node_modules/@cosmicdrift/kumiko-framework/src/scripts/codemod/import-extensions.ts . --write`. " +
90
+ "Run the import-extensions codemod: `bun node_modules/@cosmicdrift/kumiko-feature-ast/src/scripts/codemod/import-extensions.ts . --write`. " +
91
91
  "The tsconfig needs `allowImportingTsExtensions` (and `rewriteRelativeImportExtensions` when tsc emits).",
92
92
  run(files) {
93
93
  const violations: Array<{ file: string; line: number; message: string }> = [];
@@ -0,0 +1,279 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * Guard: keeps the row-level-security escape hatches out of consumer code.
4
+ *
5
+ * 1. Outside the framework repo nothing may import `@cosmicdrift/kumiko-framework/internal/*`.
6
+ * 2. `runInRowLevelSecurityScope`, `runInSystemRowLevelSecurityScope` and
7
+ * `tenantRowLevelSecurityScope` from `@cosmicdrift/kumiko-testing/helpers` are test-only.
8
+ * 3. `runInSystemRowLevelSecurityScope` and `systemScopedRunner` take a non-empty string literal
9
+ * reason, so every system-scope entry stays greppable; `SYSTEM_ROW_LEVEL_SECURITY_SCOPE` is
10
+ * framework-internal and only used by an allowlist. An object literal passed to
11
+ * `createTenantDb` with `mode: "system"` carries the same kind of `reason`.
12
+ *
13
+ * Usage:
14
+ * bun guards/guard-internal-subpath-imports.ts
15
+ *
16
+ * Exit 1 on violations, 0 when clean.
17
+ */
18
+
19
+ import { type Node, type SourceFile, SyntaxKind } from "ts-morph";
20
+ import { findRepoRootFor } from "./_lib/baseline-compare";
21
+ import {
22
+ ALL_REPO_KINDS,
23
+ type AstGuard,
24
+ isAllowlisted,
25
+ relFromRepoRoot,
26
+ runStandalone,
27
+ type ScanSpec,
28
+ } from "./_lib/guard-kit";
29
+ import { resolveRepoRoots } from "./_lib/roots";
30
+ import { TEST_FILE_RE } from "./_lib/test-file";
31
+
32
+ const SCAN: ScanSpec = {
33
+ kinds: ALL_REPO_KINDS,
34
+ scope: "source+tests",
35
+ extensions: ["ts", "tsx"],
36
+ frameworkWithin: ["packages/*/src/**", "samples/**"],
37
+ };
38
+
39
+ const INTERNAL_PREFIX = "@cosmicdrift/kumiko-framework/internal/";
40
+ const TESTING_HELPERS = "@cosmicdrift/kumiko-testing/helpers";
41
+
42
+ const TEST_ONLY_SCOPE_SYMBOLS = new Set([
43
+ "runInRowLevelSecurityScope",
44
+ "runInSystemRowLevelSecurityScope",
45
+ "tenantRowLevelSecurityScope",
46
+ ]);
47
+
48
+ // Position of the reason argument per call.
49
+ const REASON_ARG_INDEX: Record<string, number> = {
50
+ runInSystemRowLevelSecurityScope: 0,
51
+ systemScopedRunner: 1,
52
+ };
53
+
54
+ const FIXED_SCOPE_SYMBOL = "SYSTEM_ROW_LEVEL_SECURITY_SCOPE";
55
+
56
+ const FIXED_SCOPE_ALLOWLIST = [
57
+ /^packages\/framework\/src\/db\/row-level-security-scope\.ts$/,
58
+ /^packages\/framework\/src\/pipeline\/dispatch-scope\.ts$/,
59
+ /^packages\/framework\/src\/db\/migrate-runner\.ts$/,
60
+ ];
61
+
62
+ // Exercises the empty-reason rejection, so it passes empty literals on purpose.
63
+ const REASON_RULE_EXEMPT = [
64
+ /^packages\/framework\/src\/db\/__tests__\/row-level-security-scope\.test\.ts$/,
65
+ ];
66
+
67
+ // These forward a reason their own caller already supplied as a literal.
68
+ const FORWARDED_TENANT_DB_REASON_ALLOWLIST = [
69
+ /^packages\/framework\/src\/db\/tenant-db\.ts$/,
70
+ /^packages\/framework\/src\/db\/cross-tenant-reads\.ts$/,
71
+ ];
72
+
73
+ type Finding = { line: number; message: string };
74
+
75
+ function isTestFile(rel: string): boolean {
76
+ return rel.split("/").includes("__tests__") || TEST_FILE_RE.test(rel);
77
+ }
78
+
79
+ function isInternalSpecifier(specifier: string): boolean {
80
+ return specifier.startsWith(INTERNAL_PREFIX);
81
+ }
82
+
83
+ // Literal text of a string argument; for a template with substitutions only its static head.
84
+ function staticSpecifierHead(arg: Node): string | undefined {
85
+ const kind = arg.getKind();
86
+ if (kind === SyntaxKind.StringLiteral || kind === SyntaxKind.NoSubstitutionTemplateLiteral) {
87
+ return arg.getText().slice(1, -1);
88
+ }
89
+ if (kind === SyntaxKind.TemplateExpression) {
90
+ return arg.asKindOrThrow(SyntaxKind.TemplateExpression).getHead().getLiteralText();
91
+ }
92
+ return undefined;
93
+ }
94
+
95
+ function findInternalImports(sf: SourceFile): Finding[] {
96
+ const findings: Finding[] = [];
97
+ const report = (line: number, specifier: string): void => {
98
+ findings.push({
99
+ line,
100
+ message: `[internal-import] "${specifier}" is framework-internal; consumer code must not import it`,
101
+ });
102
+ };
103
+ for (const decl of [...sf.getImportDeclarations(), ...sf.getExportDeclarations()]) {
104
+ const specifier = decl.getModuleSpecifierValue();
105
+ if (specifier !== undefined && isInternalSpecifier(specifier)) {
106
+ report(decl.getStartLineNumber(), specifier);
107
+ }
108
+ }
109
+ for (const call of sf.getDescendantsOfKind(SyntaxKind.CallExpression)) {
110
+ const callee = call.getExpression();
111
+ const isDynamicImport = callee.getKind() === SyntaxKind.ImportKeyword;
112
+ const isRequire = callee.getKind() === SyntaxKind.Identifier && callee.getText() === "require";
113
+ if (!isDynamicImport && !isRequire) continue;
114
+ const arg = call.getArguments()[0];
115
+ if (arg === undefined) continue;
116
+ const specifier = staticSpecifierHead(arg);
117
+ if (specifier !== undefined && isInternalSpecifier(specifier)) {
118
+ report(call.getStartLineNumber(), specifier);
119
+ }
120
+ }
121
+ return findings;
122
+ }
123
+
124
+ function findTestOnlyScopeImports(sf: SourceFile): Finding[] {
125
+ const findings: Finding[] = [];
126
+ const decls = [
127
+ ...sf.getImportDeclarations(),
128
+ ...sf.getExportDeclarations().filter((d) => d.getModuleSpecifier() !== undefined),
129
+ ];
130
+ for (const decl of decls) {
131
+ if (decl.getModuleSpecifierValue() !== TESTING_HELPERS) continue;
132
+ const named =
133
+ "getNamedImports" in decl
134
+ ? decl.getNamedImports().map((n) => n.getName())
135
+ : decl.getNamedExports().map((n) => n.getName());
136
+ for (const name of named) {
137
+ if (!TEST_ONLY_SCOPE_SYMBOLS.has(name)) continue;
138
+ findings.push({
139
+ line: decl.getStartLineNumber(),
140
+ message: `[test-only-scope] ${name} from ${TESTING_HELPERS} is allowed in test files only`,
141
+ });
142
+ }
143
+ }
144
+ const namespaces = new Set<string>();
145
+ for (const decl of sf.getImportDeclarations()) {
146
+ if (decl.getModuleSpecifierValue() !== TESTING_HELPERS) continue;
147
+ const ns = decl.getNamespaceImport();
148
+ if (ns !== undefined) namespaces.add(ns.getText());
149
+ }
150
+ if (namespaces.size > 0) {
151
+ for (const access of sf.getDescendantsOfKind(SyntaxKind.PropertyAccessExpression)) {
152
+ const name = access.getName();
153
+ if (!TEST_ONLY_SCOPE_SYMBOLS.has(name)) continue;
154
+ if (!namespaces.has(access.getExpression().getText())) continue;
155
+ findings.push({
156
+ line: access.getStartLineNumber(),
157
+ message: `[test-only-scope] ${name} from ${TESTING_HELPERS} is allowed in test files only`,
158
+ });
159
+ }
160
+ }
161
+ return findings;
162
+ }
163
+
164
+ // Maps local binding names (aliases included) to the symbol they import.
165
+ function localNamesOf(sf: SourceFile, symbols: ReadonlySet<string>): Map<string, string> {
166
+ const names = new Map<string, string>();
167
+ for (const decl of sf.getImportDeclarations()) {
168
+ for (const named of decl.getNamedImports()) {
169
+ const original = named.getName();
170
+ if (symbols.has(original)) names.set(named.getAliasNode()?.getText() ?? original, original);
171
+ }
172
+ }
173
+ for (const symbol of symbols) {
174
+ if (!names.has(symbol)) names.set(symbol, symbol);
175
+ }
176
+ return names;
177
+ }
178
+
179
+ function isNonEmptyStringLiteral(node: { getKind(): SyntaxKind; getText(): string }): boolean {
180
+ const kind = node.getKind();
181
+ if (kind !== SyntaxKind.StringLiteral && kind !== SyntaxKind.NoSubstitutionTemplateLiteral) {
182
+ return false;
183
+ }
184
+ return node.getText().slice(1, -1).trim() !== "";
185
+ }
186
+
187
+ // `fn(...)` through a local binding (aliases included) or `<namespace>.fn(...)` by property name.
188
+ function reasonSymbolOf(callee: Node, locals: ReadonlyMap<string, string>): string | undefined {
189
+ if (callee.getKind() === SyntaxKind.Identifier) return locals.get(callee.getText());
190
+ if (callee.getKind() === SyntaxKind.PropertyAccessExpression) {
191
+ const name = callee.asKindOrThrow(SyntaxKind.PropertyAccessExpression).getName();
192
+ return Object.hasOwn(REASON_ARG_INDEX, name) ? name : undefined;
193
+ }
194
+ return undefined;
195
+ }
196
+
197
+ function findReasonViolations(sf: SourceFile): Finding[] {
198
+ const findings: Finding[] = [];
199
+ const locals = localNamesOf(sf, new Set(Object.keys(REASON_ARG_INDEX)));
200
+ for (const call of sf.getDescendantsOfKind(SyntaxKind.CallExpression)) {
201
+ const callee = call.getExpression();
202
+ const symbol = reasonSymbolOf(callee, locals);
203
+ if (symbol === undefined) continue;
204
+ const reasonArg = call.getArguments()[REASON_ARG_INDEX[symbol] ?? 0];
205
+ if (reasonArg !== undefined && isNonEmptyStringLiteral(reasonArg)) continue;
206
+ findings.push({
207
+ line: call.getStartLineNumber(),
208
+ message: `[system-scope-reason] ${symbol} needs a non-empty string literal reason as argument ${(REASON_ARG_INDEX[symbol] ?? 0) + 1}`,
209
+ });
210
+ }
211
+ return findings;
212
+ }
213
+
214
+ function isSystemModeProperty(prop: Node): boolean {
215
+ if (prop.getKind() !== SyntaxKind.PropertyAssignment) return false;
216
+ const assignment = prop.asKindOrThrow(SyntaxKind.PropertyAssignment);
217
+ return assignment.getName() === "mode" && assignment.getInitializer()?.getText() === '"system"';
218
+ }
219
+
220
+ function findTenantDbReasonViolations(sf: SourceFile): Finding[] {
221
+ const findings: Finding[] = [];
222
+ for (const call of sf.getDescendantsOfKind(SyntaxKind.CallExpression)) {
223
+ const callee = call.getExpression();
224
+ const calleeName =
225
+ callee.getKind() === SyntaxKind.PropertyAccessExpression
226
+ ? callee.asKindOrThrow(SyntaxKind.PropertyAccessExpression).getName()
227
+ : callee.getText();
228
+ if (calleeName !== "createTenantDb") continue;
229
+ const options = call.getArguments()[0]?.asKind(SyntaxKind.ObjectLiteralExpression);
230
+ if (options === undefined || !options.getProperties().some(isSystemModeProperty)) continue;
231
+ const reason = options.getProperty("reason")?.asKind(SyntaxKind.PropertyAssignment);
232
+ const initializer = reason?.getInitializer();
233
+ if (initializer !== undefined && isNonEmptyStringLiteral(initializer)) continue;
234
+ findings.push({
235
+ line: call.getStartLineNumber(),
236
+ message:
237
+ '[tenant-db-system-reason] createTenantDb({ mode: "system" }) needs a non-empty string literal `reason`',
238
+ });
239
+ }
240
+ return findings;
241
+ }
242
+
243
+ function findFixedScopeUses(sf: SourceFile): Finding[] {
244
+ return sf
245
+ .getDescendantsOfKind(SyntaxKind.Identifier)
246
+ .filter((id) => id.getText() === FIXED_SCOPE_SYMBOL)
247
+ .map((id) => ({
248
+ line: id.getStartLineNumber(),
249
+ message: `[fixed-system-scope] ${FIXED_SCOPE_SYMBOL} carries no call-site reason; use runInSystemRowLevelSecurityScope(reason, fn)`,
250
+ }));
251
+ }
252
+
253
+ export const guard: AstGuard = {
254
+ name: "Internal-Subpath-Imports Guard",
255
+ scan: SCAN,
256
+ hint: "Consumer repos use the public subpaths only; `@cosmicdrift/kumiko-framework/internal/*` is for framework packages. Scope helpers from kumiko-testing/helpers belong in tests. Every system-scope entry, including createTenantDb with mode system, passes a specific string-literal reason (why it needs cross-tenant access). SYSTEM_ROW_LEVEL_SECURITY_SCOPE is limited to row-level-security-scope.ts, dispatch-scope.ts and migrate-runner.ts; extend that allowlist in guard-internal-subpath-imports.ts with a reason.",
257
+ run(files, roots = resolveRepoRoots()) {
258
+ const violations: Array<{ file: string; line: number; message: string }> = [];
259
+
260
+ for (const sf of files) {
261
+ const file = sf.getFilePath();
262
+ const rel = relFromRepoRoot(file, roots);
263
+ const isFrameworkRepo = findRepoRootFor(file, roots)?.kind === "framework";
264
+ const findings: Finding[] = [];
265
+ if (!isFrameworkRepo) findings.push(...findInternalImports(sf));
266
+ if (!isTestFile(rel)) findings.push(...findTestOnlyScopeImports(sf));
267
+ if (!isAllowlisted(rel, REASON_RULE_EXEMPT)) findings.push(...findReasonViolations(sf));
268
+ if (!isAllowlisted(rel, FORWARDED_TENANT_DB_REASON_ALLOWLIST)) {
269
+ findings.push(...findTenantDbReasonViolations(sf));
270
+ }
271
+ if (!isAllowlisted(rel, FIXED_SCOPE_ALLOWLIST)) findings.push(...findFixedScopeUses(sf));
272
+ for (const f of findings) violations.push({ file: rel, ...f });
273
+ }
274
+
275
+ return { violations };
276
+ },
277
+ };
278
+
279
+ if (import.meta.main) runStandalone(guard);
@@ -92,7 +92,7 @@ const ALLOWLIST: readonly AllowEntry[] = [
92
92
  { repo: "kumiko-framework", pattern: /^packages\/framework\/src\/migrations\/kumiko-drift\.ts$/ },
93
93
  { repo: "kumiko-framework", pattern: /^packages\/framework\/src\/schema-cli(\.ts|\/[^/]+\.ts)$/ },
94
94
  // same CLI-tooling pattern as schema-cli.ts — repo-tree/node_modules walking for the upgrade command, not app-level file storage
95
- { repo: "kumiko-framework", pattern: /^packages\/framework\/src\/upgrade-cli\.ts$/ },
95
+ { repo: "kumiko-framework", pattern: /^packages\/framework\/src\/upgrade-cli(-internals)?\.ts$/ },
96
96
  { repo: "kumiko-framework", pattern: /^packages\/framework\/src\/es-ops\/runner\.ts$/ },
97
97
  { repo: "kumiko-framework", pattern: /^packages\/framework\/src\/db\/migrate-generator\.ts$/ },
98
98
  { repo: "kumiko-framework", pattern: /^packages\/framework\/src\/db\/migrate-runner\.ts$/ },
@@ -103,7 +103,7 @@ const ALLOWLIST: readonly AllowEntry[] = [
103
103
  },
104
104
  {
105
105
  repo: "kumiko-framework",
106
- pattern: /^packages\/framework\/src\/engine\/codemod\/pipeline-codemod\.ts$/,
106
+ pattern: /^packages\/feature-ast\/src\/codemod\/pipeline-codemod\.ts$/,
107
107
  },
108
108
 
109
109
  // server-runtime: prod bundle build + static asset delivery from the
@@ -37,7 +37,7 @@ const EXCLUDE = /(__tests__|\.test\.ts$|\.integration\.ts$|\.d\.ts$)/;
37
37
  const RESTRICTED_SYMBOLS = new Set([
38
38
  "getUnscopedAggregateStreamMaxVersion",
39
39
  "getUnscopedAggregateStreamTenant",
40
- // Appends any event type without the feature-ownership check of ctx.appendEvent and without
40
+ // Appends any event type without the feature-ownership check of ctx.events.append and without
41
41
  // an audited unsafeRaw escape hatch, so new callers need an explicit allowlist entry.
42
42
  "appendEventInTenantDb",
43
43
  "getStreamVersionInTenantDb",
@@ -52,7 +52,7 @@ const ALLOWLIST = [
52
52
  /^packages\/framework\/src\/event-store\/index\.ts$/,
53
53
  /^packages\/framework\/src\/event-store\/tenant-db-append\.ts$/,
54
54
  /^packages\/bundled-features\/src\/cap-counter\/cap-reservation\.ts$/,
55
- // Catch-up of a missing terminal extract event in the caller tenant's own stream; JobContext has no appendEvent.
55
+ // Catch-up of a missing terminal extract event in the caller tenant's own stream; JobContext has no events.unsafeAppend.
56
56
  /^packages\/bundled-features\/src\/document-ingest-foundation\/append-missing-extract-event\.ts$/,
57
57
  /^packages\/bundled-features\/src\/user-data-rights-defaults\/hooks\/delivery-attempt\.userdata-hook\.ts$/,
58
58
  /^packages\/framework\/src\/db\/tenant-db\.ts$/,
@@ -130,7 +130,7 @@ function findRestrictedSymbolReferences(sf: SourceFile): Violation[] {
130
130
  export const guard: AstGuard = {
131
131
  name: "Restricted-Symbols Guard",
132
132
  scan: SCAN,
133
- hint: 'getUnscopedAggregateStream{MaxVersion,Tenant} is an existence oracle for foreign tenants — only seed-/system-internal code may reference them. appendEventInTenantDb/getStreamVersionInTenantDb bypass the feature-ownership check of ctx.appendEvent — only framework-internal coordination code may use them. createTenantDataDestroyDb grants direct writes on event-fed tables and belongs to the tenant-destroy stage only. withExecutorManagedWritesForTests is the test-seed seam behind seedRow/deleteRows and must not reach runtime code. New caller needed? Extend the allowlist in guard-restricted-symbols.ts, with a reason. Known gap: `export * from "...event-store"` is not detected (named imports, re-exports, and namespace property access are covered).',
133
+ hint: 'getUnscopedAggregateStream{MaxVersion,Tenant} is an existence oracle for foreign tenants — only seed-/system-internal code may reference them. appendEventInTenantDb/getStreamVersionInTenantDb bypass the feature-ownership check of ctx.events.append — only framework-internal coordination code may use them. createTenantDataDestroyDb grants direct writes on event-fed tables and belongs to the tenant-destroy stage only. withExecutorManagedWritesForTests is the test-seed seam behind seedRow/deleteRows and must not reach runtime code. New caller needed? Extend the allowlist in guard-restricted-symbols.ts, with a reason. Known gap: `export * from "...event-store"` is not detected (named imports, re-exports, and namespace property access are covered).',
134
134
  run(files, roots = resolveRepoRoots()) {
135
135
  const violations: Array<{ file: string; line: number; message: string }> = [];
136
136