diff --git a/scripts/check-kysely-guardrails.mjs b/scripts/check-kysely-guardrails.mjs index da4daae1ffb..52028f4c585 100644 --- a/scripts/check-kysely-guardrails.mjs +++ b/scripts/check-kysely-guardrails.mjs @@ -65,6 +65,9 @@ const rawSqliteAllowPathGroups = { const rawSqliteAllowPathReasons = new Map(); for (const [reason, paths] of Object.entries(rawSqliteAllowPathGroups)) { for (const allowedPath of paths) { + if (rawSqliteAllowPathReasons.has(allowedPath)) { + throw new Error(`Duplicate raw SQLite allowlist path: ${allowedPath}`); + } rawSqliteAllowPathReasons.set(allowedPath, reason); } } @@ -187,16 +190,30 @@ function isLikelySqliteReceiver(expression) { return ts.isPropertyAccessExpression(unwrapped) && getPropertyNameText(unwrapped.name) === "db"; } +function isPersistedRowExpression(expression) { + const unwrapped = unwrapExpression(expression); + if (ts.isPropertyAccessExpression(unwrapped)) { + const owner = unwrapExpression(unwrapped.expression); + return ts.isIdentifier(owner) && /^(?:row|record|entry)$/u.test(owner.text); + } + if (ts.isElementAccessExpression(unwrapped)) { + const owner = unwrapExpression(unwrapped.expression); + return ts.isIdentifier(owner) && /^(?:row|record|entry)$/u.test(owner.text); + } + return false; +} + function isPersistedStringCastType(typeText) { return [ /\bTaskRecord\["(?:runtime|scopeKind|status|deliveryStatus|notifyPolicy|terminalOutcome)"\]/u, /\bTaskFlowRecord\["(?:status|notifyPolicy)"\]/u, /\bTaskFlowSyncMode\b/u, /\bVirtualAgentFsEntryKind\b/u, + /\b[A-Z][A-Za-z0-9]*(?:Status|Kind|Mode|Policy|Runtime|Outcome)\b/u, ].some((pattern) => pattern.test(typeText)); } -function collectKyselyGuardrailViolations(content, relativePath) { +export function collectKyselyGuardrailViolations(content, relativePath) { const sourceFile = ts.createSourceFile(relativePath, content, ts.ScriptTarget.Latest, true); const imports = collectImports(sourceFile); const violations = []; @@ -206,6 +223,7 @@ function collectKyselyGuardrailViolations(content, relativePath) { isSqliteStorePath(relativePath) && (ts.isAsExpression(node) || ts.isTypeAssertionExpression(node)) && isPersistedStringCastType(node.type.getText(sourceFile)) && + isPersistedRowExpression(node.expression) && !hasAllowComment(sourceFile, node, "sqlite-allow-persisted-cast") ) { addViolation( diff --git a/scripts/test-projects.test-support.mjs b/scripts/test-projects.test-support.mjs index 37de11e7a32..fe59c40e2ce 100644 --- a/scripts/test-projects.test-support.mjs +++ b/scripts/test-projects.test-support.mjs @@ -324,6 +324,7 @@ const TOOLING_SOURCE_TEST_TARGETS = new Map([ ["scripts/changed-lanes.mjs", ["test/scripts/changed-lanes.test.ts"]], ["scripts/check-changed.mjs", ["test/scripts/changed-lanes.test.ts"]], ["scripts/check-deadcode-unused-files.mjs", ["test/scripts/check-deadcode-unused-files.test.ts"]], + ["scripts/check-kysely-guardrails.mjs", ["test/scripts/check-kysely-guardrails.test.ts"]], [ "scripts/deadcode-unused-files.allowlist.mjs", ["test/scripts/check-deadcode-unused-files.test.ts"], diff --git a/test/scripts/check-kysely-guardrails.test.ts b/test/scripts/check-kysely-guardrails.test.ts new file mode 100644 index 00000000000..583b9fdf6e5 --- /dev/null +++ b/test/scripts/check-kysely-guardrails.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, it } from "vitest"; +import { collectKyselyGuardrailViolations } from "../../scripts/check-kysely-guardrails.mjs"; + +function messagesFor(content: string, relativePath = "src/example/store.sqlite.ts"): string[] { + return collectKyselyGuardrailViolations(content, relativePath).map( + (violation) => violation.message, + ); +} + +describe("Kysely guardrails", () => { + it("rejects explicit sync-helper row generics for builder queries", () => { + expect( + messagesFor(` + import { executeSqliteQuerySync } from "../infra/kysely-sync.js"; + + executeSqliteQuerySync<{ id: string }>(db, query); + `), + ).toContain("sync helper row generic at call site; let Kysely infer builder result rows"); + }); + + it("rejects persisted row casts to enum-like types in SQLite stores", () => { + expect( + messagesFor(` + type TaskStatus = "running" | "succeeded"; + + function rowToRecord(row: { status: string }) { + return { + status: row.status as TaskStatus, + }; + } + `), + ).toContain( + "persisted SQLite enum-like values must be parsed through closed validators, not cast", + ); + }); + + it("allows explicit local escape hatches for reviewed persisted casts", () => { + expect( + messagesFor(` + type TaskStatus = "running" | "succeeded"; + + function rowToRecord(row: { status: string }) { + return { + status: row.status as TaskStatus, // sqlite-allow-persisted-cast + }; + } + `), + ).toEqual([]); + }); + + it("rejects typed raw SQL outside allowlisted boundaries", () => { + expect( + messagesFor( + ` + import { sql } from "kysely"; + + const count = sql\`COUNT(*)\`; + `, + "src/example/report.ts", + ), + ).toContain("typed raw sql snippet needs a small helper or allowlisted boundary"); + }); + + it("rejects direct raw node:sqlite prepare in new production files", () => { + expect( + messagesFor( + ` + import { requireNodeSqlite } from "../infra/node-sqlite.js"; + + const sqlite = requireNodeSqlite(); + const db = new sqlite.DatabaseSync(":memory:"); + db.prepare("select 1").get(); + `, + "src/example/raw-store.ts", + ), + ).toContain( + "new raw node:sqlite access requires Kysely or an explicit raw SQLite allowlist entry", + ); + }); + + it("keeps ordinary static Kysely reference strings valid", () => { + expect( + messagesFor(` + import { executeSqliteQuerySync, getNodeSqliteKysely } from "../infra/kysely-sync.js"; + + const query = getNodeSqliteKysely<{ task_runs: { task_id: string } }>(db) + .selectFrom("task_runs") + .select(["task_id"]) + .where("task_id", "=", taskId); + executeSqliteQuerySync(db, query); + `), + ).toEqual([]); + }); +});