diff --git a/src/gate/test-presence.test.ts b/src/gate/test-presence.test.ts index d917427..40e1c70 100644 --- a/src/gate/test-presence.test.ts +++ b/src/gate/test-presence.test.ts @@ -40,6 +40,46 @@ describe("patchAddsQueryCode", () => { expect(patchAddsQueryCode(addPatch("const total = items.length + 1;"))).toBe(false); }); + test("ignores an import of a component named Select", () => { + // The Site#3650 false positive: `Select } from "…"` completes the raw + // `select … from` shape, so importing a UI component read as a query and + // failed the build on a frontend-only PR. + expect( + patchAddsQueryCode(addPatch('import { Button, Select } from "@query-doctor/ui";')), + ).toBe(false); + }); + + test("ignores a re-export of a component named Select", () => { + // Same shape one keyword over. A barrel file re-exporting the component + // (packages/ui/src/index.tsx in the Site repo) hits it without an import. + expect( + patchAddsQueryCode( + addPatch('export { default as Select } from "./components/select";'), + ), + ).toBe(false); + }); + + test("still detects a query in an exported declaration", () => { + // The recall boundary of the re-export rule: `export` starts this line too, + // but the statement declares a query rather than re-exporting a binding. + expect( + patchAddsQueryCode(addPatch('export const q = sql`SELECT id FROM "users"`;')), + ).toBe(true); + }); + + test("ignores prose spanning a JSX block comment", () => { + // The Site#3615 false positive: a comment explaining a sort control. Line + // stripping missed it twice — the opening line trims to `{/*`, not `/*`, + // and the continuation lines are bare prose — leaving "select stay … away + // from" to complete the raw select shape. + const patch = + "@@ -1,0 +1,3 @@\n" + + "+ {/* Icon, label and select stay one unwrappable unit: once the label is\n" + + "+ hidden the icon is the only thing saying \"sort\", so it must not\n" + + "+ wrap away from the control it labels. */}"; + expect(patchAddsQueryCode(patch)).toBe(false); + }); + test("ignores DDL keywords in a prose string literal", () => { // The Site#3539 false positive: an MCP tool description mentioning the // fix it returns. Prose, not a statement — no ON clause, no column list. @@ -283,6 +323,24 @@ describe("evaluateTestPresence", () => { expect(verdict).toBeNull(); }); + test("credits a spec that runs a .sql script from another directory", () => { + // The Site#3650 false positive: an operational script under `scripts/` whose + // real-DB spec lives under `src/db/` and reads the file back. Different + // directory, so only the stem rule can link them, and it compared + // `backfill-personal-teams.sql` against `backfill-personal-teams`. + const verdict = evaluateTestPresence([ + changed( + "apps/api/scripts/backfill-personal-teams.sql", + "INSERT INTO teams (id, name) SELECT gen_random_uuid(), u.name FROM users u;", + ), + changed( + "apps/api/src/db/backfill-personal-teams.spec.ts", + 'await db.execute(readFileSync("scripts/backfill-personal-teams.sql", "utf8"));', + ), + ]); + expect(verdict).toBeNull(); + }); + test("does not fire on the route-tree regeneration from Site#3614", () => { // The reported false positive: a routeTree.gen.ts regeneration (import // re-ordering) whose `select-plan` route path read as `SELECT ... FROM`. diff --git a/src/gate/test-presence.ts b/src/gate/test-presence.ts index 8544c2c..ca8e948 100644 --- a/src/gate/test-presence.ts +++ b/src/gate/test-presence.ts @@ -138,6 +138,18 @@ function addedLines(patch: string): string { .join("\n"); } +/** + * Drop `/* … *\/` spans before the line rules run. A block comment is only + * recognisable line by line when every line is decorated (`*` prefix, JSDoc + * style); a JSX `{/* … *\/}` opens with `{` and continues in bare prose, so its + * middle lines survived line stripping and "select stay … away from" read as a + * query (Site#3615). An unterminated span — a hunk that opens a comment it + * doesn't close — is dropped to the end, the gate's usual under-fire side. + */ +function stripBlockComments(text: string): string { + return text.replace(/\/\*[\s\S]*?(?:\*\/|$)/g, " "); +} + /** Drop lines that are plainly comments, so prose mentioning SQL keywords doesn't match. */ function stripCommentLines(text: string): string { return text @@ -155,13 +167,36 @@ function stripCommentLines(text: string): string { .join("\n"); } +/** `import … from "…"`. */ +const IMPORT_LINE = /^\s*import\s/; +/** + * `export { … } from "…"` / `export * from "…"` — an import that re-exports. + * Matched by its own shape rather than a bare `export` prefix so a declaration + * like ``export const q = sql`SELECT id FROM "users"` `` is still inspected. + */ +const REEXPORT_LINE = /^\s*export\s+(\*|type\s+\{|\{)[^;]*\bfrom\b/; + +/** + * Drop module-import statements. An import is never a query, but a component + * named `Select` puts `Select } from "…"` in the text, which completes the raw + * `select … from` shape and reddens a frontend-only PR (Site#3650). + */ +function stripImportLines(text: string): string { + return text + .split("\n") + .filter((line) => !IMPORT_LINE.test(line) && !REEXPORT_LINE.test(line)) + .join("\n"); +} + /** True when the diff's *added* lines contain query code. */ export function patchAddsQueryCode( patch: string | undefined, config: TestPresenceConfig = DEFAULT_TEST_PRESENCE_CONFIG, ): boolean { if (!patch) return false; - const added = stripCommentLines(addedLines(patch)); + const added = stripImportLines( + stripCommentLines(stripBlockComments(addedLines(patch))), + ); return matchesAny(added, config.queryCodePatterns); } @@ -203,7 +238,10 @@ function baseStem(path: string): string { return name .replace(/\.(test|spec)\./i, ".") .replace(/\.[cm]?[jt]sx?$/i, "") - .replace(/\.(py|go|rb)$/i, ""); + // `.sql` belongs here for a script whose spec reads and runs the file: + // `scripts/backfill.sql` ↔ `src/db/backfill.spec.ts`. Leaving it on made the + // stem `backfill.sql`, which no test stem can contain (Site#3650). + .replace(/\.(py|go|rb|sql)$/i, ""); } /**