@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.
@@ -0,0 +1,206 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * Guard: row-level-security settings are transaction-local and written in one place.
4
+ *
5
+ * The scope lives in the `kumiko.*` settings that `packages/framework/src/db/queries/rls-scope.ts`
6
+ * sets per statement transaction. A session-level setting survives on a pooled connection and leaks
7
+ * one request's scope into the next, so in every source and test string:
8
+ *
9
+ * 1. `SET kumiko.…` / `SET SESSION kumiko.…` is flagged (use `SET LOCAL`).
10
+ * 2. Every `set_config` passes the literal `true` as is_local.
11
+ * 3. Outside rls-scope.ts, non-test code writes no `kumiko.*` setting at all, and a `set_config`
12
+ * with a dynamic setting name is flagged because it cannot be proven to leave `kumiko.*` alone.
13
+ *
14
+ * `.sql` files (consumer migrations) are checked statement by statement under the same rules.
15
+ *
16
+ * Usage:
17
+ * bun guards/guard-rls-session-settings.ts
18
+ *
19
+ * Exit 1 on violations, 0 when clean.
20
+ */
21
+
22
+ import { existsSync, readFileSync } from "node:fs";
23
+ import { Glob } from "bun";
24
+ import { Node, type SourceFile, SyntaxKind } from "ts-morph";
25
+ import {
26
+ ALL_REPO_KINDS,
27
+ type AstGuard,
28
+ isAllowlisted,
29
+ relFromRepoRoot,
30
+ runStandalone,
31
+ type ScanSpec,
32
+ } from "./_lib/guard-kit";
33
+ import { resolveRepoRoots } from "./_lib/roots";
34
+ import { TEST_FILE_RE } from "./_lib/test-file";
35
+
36
+ const SCAN: ScanSpec = {
37
+ kinds: ALL_REPO_KINDS,
38
+ scope: "source+tests",
39
+ extensions: ["ts", "tsx"],
40
+ frameworkWithin: ["packages/*/src/**", "samples/**"],
41
+ };
42
+
43
+ const SCOPE_WRITER = [/^packages\/framework\/src\/db\/queries\/rls-scope\.ts$/];
44
+
45
+ // The assignment tail separates SQL from prose such as "Set kumiko.deploy.infraRef to an infra tag".
46
+ const ASSIGNMENT = String.raw`"?kumiko\.[\w.]+"?\s*(?:=|TO\s+['"$\d])`;
47
+ const SESSION_SET_RE = new RegExp(String.raw`\bSET\s+(?:SESSION\s+)?${ASSIGNMENT}`, "i");
48
+ const LOCAL_SET_RE = new RegExp(String.raw`\bSET\s+LOCAL\s+${ASSIGNMENT}`, "i");
49
+ const SET_CONFIG_CALL_RE = /\bset_config\s*\(/gi;
50
+
51
+ function isTestFile(rel: string): boolean {
52
+ return rel.split("/").includes("__tests__") || TEST_FILE_RE.test(rel);
53
+ }
54
+
55
+ const QUOTES = "'\"`";
56
+ const OPENERS = "([{";
57
+ const CLOSERS = ")]}";
58
+
59
+ // Top-level comma-separated arguments of the call whose "(" ends right before `from`.
60
+ function callArguments(text: string, from: number): string[] {
61
+ const args: string[] = [];
62
+ let depth = 0;
63
+ let quote: string | undefined;
64
+ let current = "";
65
+ for (let i = from; i < text.length; i++) {
66
+ const c = text.charAt(i);
67
+ if (quote !== undefined) {
68
+ current += c;
69
+ if (c === quote) quote = undefined;
70
+ continue;
71
+ }
72
+ if (QUOTES.includes(c)) quote = c;
73
+ else if (OPENERS.includes(c)) depth++;
74
+ else if (CLOSERS.includes(c)) {
75
+ if (depth === 0) break;
76
+ depth--;
77
+ } else if (c === "," && depth === 0) {
78
+ args.push(current.trim());
79
+ current = "";
80
+ continue;
81
+ }
82
+ current += c;
83
+ }
84
+ args.push(current.trim());
85
+ return args;
86
+ }
87
+
88
+ function setConfigCalls(text: string): string[][] {
89
+ return [...text.matchAll(SET_CONFIG_CALL_RE)].map((m) =>
90
+ callArguments(text, m.index + m[0].length).map((arg) => arg.toLowerCase()),
91
+ );
92
+ }
93
+
94
+ function isKumikoLiteral(arg: string | undefined): boolean {
95
+ return arg !== undefined && /^['"`]kumiko\./.test(arg);
96
+ }
97
+
98
+ function isStringLiteralArgument(arg: string | undefined): boolean {
99
+ return arg !== undefined && /^['"`]/.test(arg);
100
+ }
101
+
102
+ function setConfigMessages(text: string, isScopeWriter: boolean, isTest: boolean): string[] {
103
+ const calls = setConfigCalls(text);
104
+ const messages: string[] = [];
105
+ if (calls.some((args) => args[2] !== "true")) {
106
+ messages.push(
107
+ "set_config must pass the literal true as is_local, anything else can be session-level",
108
+ );
109
+ }
110
+ if (isScopeWriter || isTest) return messages;
111
+ if (calls.some((args) => isKumikoLiteral(args[0]))) {
112
+ messages.push("only rls-scope.ts may write a kumiko.* setting");
113
+ }
114
+ if (calls.some((args) => !isStringLiteralArgument(args[0]))) {
115
+ messages.push(
116
+ "set_config with a dynamic setting name cannot be proven to leave kumiko.* alone",
117
+ );
118
+ }
119
+ return messages;
120
+ }
121
+
122
+ function violationsInText(text: string, rel: string): string[] {
123
+ const isScopeWriter = isAllowlisted(rel, SCOPE_WRITER);
124
+ const isTest = isTestFile(rel);
125
+ const messages: string[] = [];
126
+ if (SESSION_SET_RE.test(text)) {
127
+ messages.push("SET / SET SESSION on a kumiko.* setting is session-level; use SET LOCAL");
128
+ }
129
+ if (!isScopeWriter && !isTest && LOCAL_SET_RE.test(text)) {
130
+ messages.push("only rls-scope.ts may write a kumiko.* setting");
131
+ }
132
+ return [...messages, ...setConfigMessages(text, isScopeWriter, isTest)];
133
+ }
134
+
135
+ function isStringLike(node: Node): boolean {
136
+ return (
137
+ Node.isStringLiteral(node) ||
138
+ Node.isNoSubstitutionTemplateLiteral(node) ||
139
+ Node.isTemplateExpression(node)
140
+ );
141
+ }
142
+
143
+ function findSessionSettings(sf: SourceFile, rel: string) {
144
+ const found: Array<{ line: number; message: string }> = [];
145
+ for (const node of sf.getDescendants()) {
146
+ if (!isStringLike(node)) continue;
147
+ // A template's own spans are visited as separate nodes; its full text is checked once here.
148
+ if (node.getParent()?.getKind() === SyntaxKind.TemplateSpan) continue;
149
+ for (const message of violationsInText(node.getText(), rel)) {
150
+ found.push({ line: node.getStartLineNumber(), message: `[rls-session-setting] ${message}` });
151
+ }
152
+ }
153
+ return found;
154
+ }
155
+
156
+ const SQL_IGNORED_DIRS = /(^|\/)(node_modules|\.wt|\.git|dist)(\/|$)/;
157
+
158
+ // Statements split on ";" with their start line; "--" comment lines are blanked so prose in them never matches.
159
+ export function findSqlSessionSettings(sql: string, rel: string) {
160
+ const found: Array<{ line: number; message: string }> = [];
161
+ const code = sql.replace(/^\s*--.*$/gm, "");
162
+ let line = 1;
163
+ for (const statement of code.split(";")) {
164
+ const startLine =
165
+ line + countNewlines(statement.slice(0, statement.length - statement.trimStart().length));
166
+ for (const message of violationsInText(statement, rel)) {
167
+ found.push({ line: startLine, message: `[rls-session-setting] ${message}` });
168
+ }
169
+ line += countNewlines(statement);
170
+ }
171
+ return found;
172
+ }
173
+
174
+ function countNewlines(text: string): number {
175
+ return text.split("\n").length - 1;
176
+ }
177
+
178
+ function sqlFilesOf(rootDir: string): string[] {
179
+ // skip: in-memory roots of guard tests have no directory
180
+ if (!existsSync(rootDir)) return [];
181
+ return [...new Glob("**/*.sql").scanSync({ cwd: rootDir })].filter(
182
+ (file) => !SQL_IGNORED_DIRS.test(file),
183
+ );
184
+ }
185
+
186
+ export const guard: AstGuard = {
187
+ name: "RLS-Session-Settings Guard",
188
+ scan: SCAN,
189
+ hint: "Row-level-security settings must be transaction-local: use SET LOCAL or pass true as the third set_config argument. Only packages/framework/src/db/queries/rls-scope.ts writes the kumiko.* scope; tests may use SET LOCAL.",
190
+ run(files, roots = resolveRepoRoots()) {
191
+ const violations: Array<{ file: string; line: number; message: string }> = [];
192
+ for (const sf of files) {
193
+ const rel = relFromRepoRoot(sf.getFilePath(), roots);
194
+ for (const f of findSessionSettings(sf, rel)) violations.push({ file: rel, ...f });
195
+ }
196
+ for (const root of roots) {
197
+ for (const file of sqlFilesOf(root.absPath)) {
198
+ const sql = readFileSync(`${root.absPath}/${file}`, "utf8");
199
+ for (const f of findSqlSessionSettings(sql, file)) violations.push({ file, ...f });
200
+ }
201
+ }
202
+ return { violations };
203
+ },
204
+ };
205
+
206
+ if (import.meta.main) runStandalone(guard);
@@ -0,0 +1,142 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * Guard: a consumer's `runSchemaCli(...)` call must pass `features: FEATURES` imported from `kumiko/schema`.
4
+ *
5
+ * `allAppFeatures(config)` is raw (no bundled foundation: config, user, tenant, auth-email-password).
6
+ * Only the managed `kumiko/schema.ts` composes it. With a raw or hand-built list the projection rebuild
7
+ * after the migrations fails at deploy with "Feature X requires feature Y". Repos without
8
+ * `kumiko/schema.ts` and the framework repo are not applicable. `composeEnvSchema` inputs are not checked.
9
+ */
10
+
11
+ import { existsSync, readFileSync } from "node:fs";
12
+ import { join, relative } from "node:path";
13
+ import { Node, Project, type SourceFile, SyntaxKind } from "ts-morph";
14
+ import {
15
+ type GuardViolation,
16
+ type RepoCheck,
17
+ reportResults,
18
+ runRepoChecks,
19
+ } from "./_lib/guard-kit";
20
+ import type { RepoRoot } from "./_lib/roots";
21
+ import { type ScanSpec, scanFiles } from "./_lib/scan-scope";
22
+
23
+ const SCAN: ScanSpec = { scope: "source", extensions: ["ts"], kinds: ["app", "library"] };
24
+
25
+ const SCHEMA_MODULE_REL = "kumiko/schema.ts";
26
+ const SCHEMA_MODULE_SPECIFIER = /(^|\/)kumiko\/schema(\.ts)?$/;
27
+ const SCHEMA_CLI_CALL = "runSchemaCli";
28
+ const OPTIONS_ARG_INDEX = 3;
29
+ const FEATURES_EXPORT = "FEATURES";
30
+ const FOUND_SNIPPET_MAX = 60;
31
+
32
+ const FIX_HINT =
33
+ 'Fix: `import { FEATURES } from "../kumiko/schema";` and pass `features: FEATURES`. ' +
34
+ "allAppFeatures() is raw and a locally composed list drifts from kumiko/schema, which fails the rebuild at deploy.";
35
+
36
+ export type SchemaCliFinding = {
37
+ readonly line: number;
38
+ readonly message: string;
39
+ };
40
+
41
+ function importOf(sf: SourceFile, localName: string): { name: string; module: string } | undefined {
42
+ for (const imp of sf.getImportDeclarations()) {
43
+ for (const spec of imp.getNamedImports()) {
44
+ if ((spec.getAliasNode()?.getText() ?? spec.getName()) === localName) {
45
+ return { name: spec.getName(), module: imp.getModuleSpecifierValue() };
46
+ }
47
+ }
48
+ }
49
+ return undefined;
50
+ }
51
+
52
+ function isSchemaFeaturesImport(sf: SourceFile, localName: string): boolean {
53
+ const imported = importOf(sf, localName);
54
+ return (
55
+ imported !== undefined &&
56
+ imported.name === FEATURES_EXPORT &&
57
+ SCHEMA_MODULE_SPECIFIER.test(imported.module)
58
+ );
59
+ }
60
+
61
+ function snippet(node: Node): string {
62
+ const flat = node.getText().replace(/\s+/g, " ");
63
+ return flat.length > FOUND_SNIPPET_MAX ? `${flat.slice(0, FOUND_SNIPPET_MAX)}...` : flat;
64
+ }
65
+
66
+ function featuresValueOf(options: Node): Node | undefined | "no-literal" {
67
+ if (!Node.isObjectLiteralExpression(options)) return "no-literal";
68
+ const property = options.getProperty("features");
69
+ if (property === undefined) return undefined;
70
+ if (Node.isPropertyAssignment(property)) return property.getInitializer();
71
+ if (Node.isShorthandPropertyAssignment(property)) return property.getNameNode();
72
+ return "no-literal";
73
+ }
74
+
75
+ function isRunSchemaCliCall(sf: SourceFile, callee: Node): boolean {
76
+ if (!Node.isIdentifier(callee)) return false;
77
+ const localName = callee.getText();
78
+ return (importOf(sf, localName)?.name ?? localName) === SCHEMA_CLI_CALL;
79
+ }
80
+
81
+ function problemOf(sf: SourceFile, optionsArg: Node | undefined): string | undefined {
82
+ if (optionsArg === undefined) {
83
+ return `${SCHEMA_CLI_CALL} has no options argument, so the projection rebuild is skipped`;
84
+ }
85
+ const value = featuresValueOf(optionsArg);
86
+ if (value === "no-literal") {
87
+ return `${SCHEMA_CLI_CALL} options are not an object literal with a \`features\` property`;
88
+ }
89
+ if (value === undefined) return `${SCHEMA_CLI_CALL} options have no \`features\` property`;
90
+ if (Node.isIdentifier(value) && isSchemaFeaturesImport(sf, value.getText())) return undefined;
91
+ return `${SCHEMA_CLI_CALL} gets \`features: ${snippet(value)}\`, not ${FEATURES_EXPORT} from kumiko/schema`;
92
+ }
93
+
94
+ export function findSchemaCliFeatureProblems(sf: SourceFile): SchemaCliFinding[] {
95
+ const findings: SchemaCliFinding[] = [];
96
+ for (const call of sf.getDescendantsOfKind(SyntaxKind.CallExpression)) {
97
+ if (!isRunSchemaCliCall(sf, call.getExpression())) continue;
98
+ const problem = problemOf(sf, call.getArguments()[OPTIONS_ARG_INDEX]);
99
+ if (problem !== undefined) {
100
+ findings.push({ line: call.getStartLineNumber(), message: `${problem}. ${FIX_HINT}` });
101
+ }
102
+ }
103
+ return findings;
104
+ }
105
+
106
+ function hasSchemaModule(root: RepoRoot): boolean {
107
+ return existsSync(join(root.absPath, SCHEMA_MODULE_REL));
108
+ }
109
+
110
+ export const check: RepoCheck = {
111
+ name: "Schema CLI Features Guard",
112
+ hint:
113
+ "Pass `features: FEATURES` imported from kumiko/schema to runSchemaCli; allAppFeatures() is raw " +
114
+ "and breaks the projection rebuild at deploy.",
115
+ run(roots) {
116
+ const applicable = roots.filter((root) => root.kind !== "framework" && hasSchemaModule(root));
117
+ if (applicable.length === 0) return { violations: [], matchedFiles: 0, notApplicable: true };
118
+
119
+ // Manifest excludes must not hide bin/kumiko.ts: kumiko-studio and phronexsis exclude it from other guards.
120
+ const paths = scanFiles(SCAN, applicable, { security: true });
121
+ const project = new Project({ skipAddingFilesFromTsConfig: true, useInMemoryFileSystem: true });
122
+ const violations: GuardViolation[] = [];
123
+ for (const absPath of paths) {
124
+ const text = readFileSync(absPath, "utf-8");
125
+ if (!text.includes(SCHEMA_CLI_CALL)) continue;
126
+ const sf = project.createSourceFile(absPath, text, { overwrite: true });
127
+ for (const finding of findSchemaCliFeatureProblems(sf)) {
128
+ violations.push({
129
+ file: relative(process.cwd(), absPath),
130
+ line: finding.line,
131
+ message: finding.message,
132
+ });
133
+ }
134
+ }
135
+ return { violations, matchedFiles: paths.length, notApplicable: false };
136
+ },
137
+ };
138
+
139
+ if (import.meta.main) {
140
+ const failed = reportResults(await runRepoChecks([check]));
141
+ process.exit(failed > 0 ? 1 : 0);
142
+ }
@@ -118,7 +118,7 @@ function classifyEnclosing(_ret: ReturnStatement, fn: Node): SkipSite["enclosing
118
118
  * Policy: a bare `return;` needs one of the following markers right before it
119
119
  * — otherwise it is flagged.
120
120
  *
121
- * (a) Log call: ctx.log?.debug(...), logger.debug(...), context.log?.info(...)
121
+ * (a) Log call: ctx.obs.log.debug(...), logger.debug(...), context.log?.info(...)
122
122
  * (b) Skip comment: // skip: <reason>
123
123
  * (c) Throw escape: preceded by a throw (the return is dead code, should go)
124
124
  *
@@ -0,0 +1,153 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * Guard: framework tests do not write event-sourced rows through the seed bypass.
4
+ * Flags calls to updateRows/deleteRows imported from kumiko-testing/helpers, seedRow on
5
+ * an entity table the file builds itself, and raw DELETE/UPDATE SQL on read_ tables,
6
+ * kumiko_events or such an entity table: they change the projection only, so the event
7
+ * log drifts from it and a rebuild undoes the write.
8
+ *
9
+ * Usage:
10
+ * bun packages/guards/src/guard-test-es-row-writes.ts
11
+ */
12
+ import type { SourceFile } from "ts-morph";
13
+ import { Node, SyntaxKind } from "ts-morph";
14
+ import {
15
+ type AstGuard,
16
+ type GuardViolation,
17
+ relFromRepoRoot,
18
+ runStandalone,
19
+ type ScanSpec,
20
+ } from "./_lib/guard-kit";
21
+ import { type RepoRoot, resolveRepoRoots } from "./_lib/roots";
22
+ import { findRawSqlWrites, findSeedRowOnEntityTables } from "./_lib/test-es-raw-writes";
23
+
24
+ // Framework repo only: the consumer repos still call updateRows/deleteRows in their
25
+ // tests, so rolling this out there needs a migration of those call sites first.
26
+ const SCAN: ScanSpec = { scope: "tests", extensions: ["ts"], kinds: ["framework"] };
27
+
28
+ const ALLOWLIST: readonly RegExp[] = [
29
+ // Home of the helpers.
30
+ /^packages\/testing\/src\//,
31
+ // The guard's own fixtures.
32
+ /^packages\/guards\/src\/__tests__\//,
33
+ // Simulates event-less legacy rows for the backfill.
34
+ /^packages\/bundled-features\/src\/user-data-rights\/__tests__\/read-users-rebuild-survives-lifecycle\.integration\.test\.ts$/,
35
+ // Stands in for the handover move, which itself writes directly.
36
+ /^packages\/bundled-features\/src\/files-tenant-data\/__tests__\/hooks\.integration\.test\.ts$/,
37
+ // Corrupts ciphertext on the projection to exercise the skip path.
38
+ /^packages\/bundled-features\/src\/inbound-mail-foundation\/__tests__\/watch-supervisor\.integration\.test\.ts$/,
39
+ // Primitive test of the TenantDb where-merge.
40
+ /^packages\/framework\/src\/db\/__tests__\/tenant-db-where-merge\.integration\.test\.ts$/,
41
+ // The entity cache is invalidated on the helper path, so only a projection-only write leaves it stale.
42
+ /^packages\/framework\/src\/__tests__\/full-stack\.integration\.test\.ts$/,
43
+ // Corrupt, drift or poison a projection row on purpose to exercise the read, repair or skip path.
44
+ /^packages\/bundled-features\/src\/auth-mfa\/__tests__\/reencrypt-job\.integration\.test\.ts$/,
45
+ /^packages\/bundled-features\/src\/config\/__tests__\/config\.integration\.test\.ts$/,
46
+ /^packages\/bundled-features\/src\/custom-fields\/__tests__\/custom-fields\.integration\.test\.ts$/,
47
+ /^packages\/bundled-features\/src\/custom-fields\/__tests__\/field-access\.integration\.test\.ts$/,
48
+ /^packages\/bundled-features\/src\/inbound-mail-foundation\/__tests__\/inbound-mail-foundation\.integration\.test\.ts$/,
49
+ /^packages\/framework\/src\/db\/__tests__\/event-store-executor\.integration\.test\.ts$/,
50
+ /^packages\/framework\/src\/db\/__tests__\/event-store-executor-write-verbs\.integration\.test\.ts$/,
51
+ /^packages\/framework\/src\/event-store\/__tests__\/backfill-pii\.integration\.test\.ts$/,
52
+ /^packages\/framework\/src\/pipeline\/__tests__\/search-consumer-named-events\.integration\.test\.ts$/,
53
+ /^packages\/framework\/src\/search\/__tests__\/search-pii-derived-index\.integration\.test\.ts$/,
54
+ // Seed an event-less legacy row that the code under test must adopt or upcast.
55
+ /^packages\/framework\/src\/__tests__\/reference-data\.integration\.test\.ts$/,
56
+ /^packages\/framework\/src\/pipeline\/__tests__\/load-aggregate-query\.integration\.test\.ts$/,
57
+ // Backdate timestamps that no command can set, to drive retention and cleanup crons.
58
+ /^packages\/bundled-features\/src\/custom-fields\/__tests__\/retention\.integration\.test\.ts$/,
59
+ /^packages\/bundled-features\/src\/form-draft\/__tests__\/cleanup\.integration\.test\.ts$/,
60
+ /^packages\/bundled-features\/src\/form-draft\/__tests__\/feature-optional-config\.integration\.test\.ts$/,
61
+ /^packages\/server-runtime\/src\/__tests__\/bundled\/tier-engine-resolver\.integration\.test\.ts$/,
62
+ /^samples\/recipes\/public-share-token\/src\/__tests__\/feature\.integration\.test\.ts$/,
63
+ // App user-data delete hooks and probes that stand in for app code deleting its own rows.
64
+ /^packages\/bundled-features\/src\/custom-fields\/__tests__\/user-data-rights\.integration\.test\.ts$/,
65
+ /^packages\/bundled-features\/src\/crypto-shredding\/__tests__\/forget-subject-record\.integration\.test\.ts$/,
66
+ /^packages\/bundled-features\/src\/user-data-rights\/__tests__\/forget-cleanup-search-purge-ordering\.integration\.test\.ts$/,
67
+ // Rebuild scenarios wipe the projection while the events stay.
68
+ /^packages\/bundled-features\/src\/jobs\/__tests__\/projection-rebuild-job\.integration\.test\.ts$/,
69
+ /^packages\/framework\/src\/pipeline\/__tests__\/projection-rebuild\.integration\.test\.ts$/,
70
+ // Deletes rows between pages or batches to simulate concurrent removal for the pager and reindex.
71
+ /^packages\/framework\/src\/db\/__tests__\/event-store-executor-list\.integration\.test\.ts$/,
72
+ /^packages\/framework\/src\/search\/__tests__\/reindex-entity\.integration\.test\.ts$/,
73
+ // Plants projection rows without events because detecting them is the behavior under test.
74
+ /^packages\/framework\/src\/db\/__tests__\/assert-no-unreachable-live-rows\.integration\.test\.ts$/,
75
+ // Primitive db tests of the write lock, inserts, codecs and migrations below the executor.
76
+ /^packages\/framework\/src\/db\/__tests__\/executor-managed-write-lock\.integration\.test\.ts$/,
77
+ /^packages\/framework\/src\/db\/__tests__\/multi-row-insert\.integration\.test\.ts$/,
78
+ /^packages\/framework\/src\/db\/__tests__\/number-field-fractional\.integration\.test\.ts$/,
79
+ /^packages\/framework\/src\/db\/__tests__\/query\/write-brand\.test\.ts$/,
80
+ /^packages\/framework\/src\/db\/__tests__\/schema-migration\.integration\.test\.ts$/,
81
+ /^packages\/framework\/src\/db\/__tests__\/source-shadow-create\.integration\.test\.ts$/,
82
+ // A hook writes a marker row through its TenantDb, which the event-store helpers do not take.
83
+ /^packages\/bundled-features\/src\/feature-toggles\/__tests__\/feature-toggles\.integration\.test\.ts$/,
84
+ ];
85
+
86
+ const BYPASS_NAMES: ReadonlySet<string> = new Set(["updateRows", "deleteRows"]);
87
+ const SEED_ROW_NAMES: ReadonlySet<string> = new Set(["seedRow"]);
88
+ const TESTING_HELPERS_RE =
89
+ /^@cosmicdrift\/kumiko-testing\/helpers$|(^|\/)testing\/src\/helpers(\/|$)/;
90
+ const MESSAGE =
91
+ "updateRows/deleteRows bypass the event store: use the real command, seedUser, " +
92
+ "updateRowViaEventStore/deleteRowViaEventStore, or updateMany/deleteMany on a Writable table";
93
+
94
+ type HelperBindings = {
95
+ readonly wanted: ReadonlySet<string>;
96
+ readonly names: Set<string>;
97
+ readonly namespaces: Set<string>;
98
+ };
99
+
100
+ function helperBindings(sourceFile: SourceFile, wanted: ReadonlySet<string>): HelperBindings {
101
+ const names = new Set<string>();
102
+ const namespaces = new Set<string>();
103
+ for (const decl of sourceFile.getImportDeclarations()) {
104
+ if (!TESTING_HELPERS_RE.test(decl.getModuleSpecifierValue())) continue;
105
+ for (const spec of decl.getNamedImports()) {
106
+ if (wanted.has(spec.getName())) names.add(spec.getAliasNode()?.getText() ?? spec.getName());
107
+ }
108
+ const namespace = decl.getNamespaceImport();
109
+ if (namespace) namespaces.add(namespace.getText());
110
+ }
111
+ return { wanted, names, namespaces };
112
+ }
113
+
114
+ function isHelperCallee(callee: Node, bindings: HelperBindings): boolean {
115
+ if (Node.isIdentifier(callee)) return bindings.names.has(callee.getText());
116
+ if (!Node.isPropertyAccessExpression(callee)) return false;
117
+ const target = callee.getExpression();
118
+ return (
119
+ bindings.wanted.has(callee.getName()) &&
120
+ Node.isIdentifier(target) &&
121
+ bindings.namespaces.has(target.getText())
122
+ );
123
+ }
124
+
125
+ function collect(sourceFile: SourceFile, roots: readonly RepoRoot[]): GuardViolation[] {
126
+ const file = relFromRepoRoot(sourceFile.getFilePath(), roots);
127
+ if (ALLOWLIST.some((re) => re.test(file))) return [];
128
+ const bypass = helperBindings(sourceFile, BYPASS_NAMES);
129
+ const seedRow = helperBindings(sourceFile, SEED_ROW_NAMES);
130
+ const bypassCalls = sourceFile
131
+ .getDescendantsOfKind(SyntaxKind.CallExpression)
132
+ .filter((call) => isHelperCallee(call.getExpression(), bypass))
133
+ .map((call) => ({ line: call.getStartLineNumber(), message: MESSAGE }));
134
+ return [
135
+ ...bypassCalls,
136
+ ...findSeedRowOnEntityTables(sourceFile, (callee) => isHelperCallee(callee, seedRow)),
137
+ ...findRawSqlWrites(sourceFile),
138
+ ].map((finding) => ({ file, ...finding }));
139
+ }
140
+
141
+ export const guard: AstGuard = {
142
+ name: "Test-ES-Row-Writes Guard",
143
+ scan: SCAN,
144
+ hint:
145
+ "Change event-sourced rows through the real command, seedUser({...}), seedRowViaEventStore or " +
146
+ "updateRowViaEventStore/deleteRowViaEventStore, reset them with resetTestTables plus eventsTable; " +
147
+ "use updateMany/deleteMany for non-event-sourced tables.",
148
+ run(files, roots: readonly RepoRoot[] = resolveRepoRoots()) {
149
+ return { violations: files.flatMap((sf) => collect(sf, roots)) };
150
+ },
151
+ };
152
+
153
+ if (import.meta.main) runStandalone(guard);
@@ -0,0 +1,110 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * Warns when a `.unsafe(sql, [...])` parameter list holds a JS array (or a spread).
4
+ *
5
+ * Bun.SQL binds a JS array parameter as "a,b" on Linux, so `ANY($n)` fails with a
6
+ * malformed array literal in CI while the same call passes on macOS. Pass
7
+ * `toPostgresArrayLiteral(values)` (from `@cosmicdrift/kumiko-framework/db`) and cast the
8
+ * placeholder (`$1::text[]`).
9
+ *
10
+ * Heuristic without type info: only array literals and spreads directly inside the
11
+ * parameter-list literal are reported; array-typed variables pass unnoticed. Warning only.
12
+ */
13
+
14
+ import { readFileSync } from "node:fs";
15
+ import { relative } from "node:path";
16
+ import { Node, Project, type SourceFile, SyntaxKind } from "ts-morph";
17
+ import {
18
+ type GuardViolation,
19
+ type RepoCheck,
20
+ reportResults,
21
+ runRepoChecks,
22
+ } from "./_lib/guard-kit";
23
+ import { type ScanSpec, scanFiles } from "./_lib/scan-scope";
24
+
25
+ const SCAN: ScanSpec = { scope: "source+tests", extensions: ["ts", "tsx"] };
26
+
27
+ const UNSAFE_CALL_MARKER = ".unsafe";
28
+ const UNSAFE_PARAMS_ARG_INDEX = 1;
29
+
30
+ const FIX_HINT =
31
+ "pass toPostgresArrayLiteral(values) from @cosmicdrift/kumiko-framework/db and cast the placeholder, e.g. ANY($1::text[])";
32
+
33
+ export type UnsafeArrayParamFinding = {
34
+ readonly line: number;
35
+ readonly message: string;
36
+ };
37
+
38
+ function unwrapExpression(node: Node): Node {
39
+ let current = node;
40
+ while (
41
+ Node.isAsExpression(current) ||
42
+ Node.isSatisfiesExpression(current) ||
43
+ Node.isParenthesizedExpression(current)
44
+ ) {
45
+ current = current.getExpression();
46
+ }
47
+ return current;
48
+ }
49
+
50
+ export function findUnsafeArrayParams(sf: SourceFile): UnsafeArrayParamFinding[] {
51
+ const findings: UnsafeArrayParamFinding[] = [];
52
+ for (const call of sf.getDescendantsOfKind(SyntaxKind.CallExpression)) {
53
+ const callee = call.getExpression();
54
+ if (!Node.isPropertyAccessExpression(callee) || callee.getName() !== "unsafe") continue;
55
+ const params = call.getArguments()[UNSAFE_PARAMS_ARG_INDEX];
56
+ if (params === undefined) continue;
57
+ const paramList = unwrapExpression(params);
58
+ if (!Node.isArrayLiteralExpression(paramList)) continue;
59
+ for (const element of paramList.getElements()) {
60
+ const value = unwrapExpression(element);
61
+ if (Node.isArrayLiteralExpression(value)) {
62
+ findings.push({
63
+ line: element.getStartLineNumber(),
64
+ message: `array literal as an unsafe() parameter binds as "a,b" on Linux; ${FIX_HINT}`,
65
+ });
66
+ } else if (Node.isSpreadElement(element)) {
67
+ findings.push({
68
+ line: element.getStartLineNumber(),
69
+ message: `spread in the unsafe() parameter list; if it feeds ANY($n), ${FIX_HINT}`,
70
+ });
71
+ }
72
+ }
73
+ }
74
+ return findings;
75
+ }
76
+
77
+ export const check: RepoCheck = {
78
+ name: "Unsafe Array Param Guard",
79
+ hint:
80
+ "Bun.SQL binds a JS array parameter as 'a,b' on Linux (CI) but not on macOS. " +
81
+ "Use toPostgresArrayLiteral(values) with a $n::type[] cast in unsafe() calls.",
82
+ run(roots) {
83
+ const paths = scanFiles(SCAN, roots);
84
+ const project = new Project({ skipAddingFilesFromTsConfig: true, useInMemoryFileSystem: true });
85
+ const warnings: GuardViolation[] = [];
86
+ for (const absPath of paths) {
87
+ const text = readFileSync(absPath, "utf-8");
88
+ if (!text.includes(UNSAFE_CALL_MARKER)) continue;
89
+ const sf = project.createSourceFile(absPath, text, { overwrite: true });
90
+ for (const finding of findUnsafeArrayParams(sf)) {
91
+ warnings.push({
92
+ file: relative(process.cwd(), absPath),
93
+ line: finding.line,
94
+ message: finding.message,
95
+ });
96
+ }
97
+ }
98
+ return {
99
+ violations: [],
100
+ warnings: warnings.length > 0 ? warnings : undefined,
101
+ matchedFiles: paths.length,
102
+ notApplicable: false,
103
+ };
104
+ },
105
+ };
106
+
107
+ if (import.meta.main) {
108
+ const failed = reportResults(await runRepoChecks([check]));
109
+ process.exit(failed > 0 ? 1 : 0);
110
+ }
package/src/run-guards.ts CHANGED
@@ -38,16 +38,19 @@ import { guard as i18nKeys } from "./guard-i18n-keys";
38
38
  import { guard as i18nLocaleMount } from "./guard-i18n-locale-mount";
39
39
  import { guard as i18nLocaleTerminology } from "./guard-i18n-locale-terminology";
40
40
  import { guard as importExtensions } from "./guard-import-extensions";
41
+ import { guard as internalSubpathImports } from "./guard-internal-subpath-imports";
41
42
  import { guard as libTestCoverage } from "./guard-lib-test-coverage";
42
43
  import { guard as noDateApi } from "./guard-no-date-api";
43
44
  import { guard as noDirectFs } from "./guard-no-direct-fs";
44
45
  import { guard as noLogicInViews } from "./guard-no-logic-in-views";
45
46
  import { guard as noTemporalPolyfillImport } from "./guard-no-temporal-polyfill-import";
46
47
  import { guard as restrictedSymbols } from "./guard-restricted-symbols";
48
+ import { guard as rlsSessionSettings } from "./guard-rls-session-settings";
47
49
  import { guard as screenConventions } from "./guard-screen-conventions";
48
50
  import { guard as silentSkip } from "./guard-silent-skip";
49
51
  import { guard as tenantEscalation } from "./guard-tenant-escalation";
50
52
  import { guard as testEsFixtures } from "./guard-test-es-fixtures";
53
+ import { guard as testEsRowWrites } from "./guard-test-es-row-writes";
51
54
  import { guard as testTemplateDrift } from "./guard-test-template-drift";
52
55
  import { guard as testTimeouts } from "./guard-test-timeouts";
53
56
  import { guard as unsafeJsonParse } from "./guard-unsafe-json-parse";
@@ -67,6 +70,8 @@ export const GUARDS = [
67
70
  noDateApi,
68
71
  noTemporalPolyfillImport,
69
72
  restrictedSymbols,
73
+ internalSubpathImports,
74
+ rlsSessionSettings,
70
75
  fakeTests,
71
76
  noLogicInViews,
72
77
  errorReasons,
@@ -83,6 +88,7 @@ export const GUARDS = [
83
88
  libTestCoverage,
84
89
  testTimeouts,
85
90
  testEsFixtures,
91
+ testEsRowWrites,
86
92
  testTemplateDrift,
87
93
  fileSize,
88
94
  commentIssueRefOnly,
@@ -15,8 +15,10 @@ import { check as noDirectProcessEnv } from "./guard-no-direct-process-env";
15
15
  import { check as primitivesDiscipline } from "./guard-primitives-discipline";
16
16
  import { check as rawSql } from "./guard-raw-sql";
17
17
  import { check as rendererBoundaries } from "./guard-renderer-boundaries";
18
+ import { check as schemaCliFeatures } from "./guard-schema-cli-features";
18
19
  import { check as testStackDrift } from "./guard-test-stack-drift";
19
20
  import { check as thinWrappers } from "./guard-thin-wrappers";
21
+ import { check as unsafeArrayParam } from "./guard-unsafe-array-param";
20
22
  import { check as upgradeState } from "./guard-upgrade-state";
21
23
 
22
24
  export const REPO_CHECKS = [
@@ -35,6 +37,8 @@ export const REPO_CHECKS = [
35
37
  agentDocsDrift,
36
38
  subpathExports,
37
39
  guardsDocDrift,
40
+ unsafeArrayParam,
41
+ schemaCliFeatures,
38
42
  ];
39
43
 
40
44
  export const REPO_CHECK_FLAGS: readonly string[] = ["--write-baseline"];