test: cover kysely guardrails

This commit is contained in:
Peter Steinberger
2026-05-09 04:26:50 +01:00
parent 0b7d7549be
commit 7afb9ce7eb
3 changed files with 114 additions and 1 deletions

View File

@@ -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(

View File

@@ -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"],

View File

@@ -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<number>\`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([]);
});
});