diff --git a/server/scripts/generate-entities.ts b/server/scripts/generate-entities.ts index 3681e177c..2d2cb312e 100644 --- a/server/scripts/generate-entities.ts +++ b/server/scripts/generate-entities.ts @@ -939,6 +939,18 @@ export interface CheckExpressionFixup { * `end_date >= start_date` — or balanced-but-never-closing nested ones) is * left byte-identical: this only fires on the specific over-capture shape, * never on a clean expression. + * + * The walk skips a `(`/`)` that falls inside a single-quoted SQL string + * literal (a doubled `''` is SQLite's escape for a literal quote *inside* + * the literal, so it does not end the string) — an expression like + * `status IN ('confirmed)', 'tentative')` has a literal `)` inside its first + * string value that is not a real closing paren; without this tracking the + * scanner would hit depth 0 at that spurious character and truncate a + * perfectly clean expression (losing its own genuine trailing `)` in the + * process, corrupting otherwise-valid SQL), not just leave RULE12-003's kind + * of case ambiguous the way a false paren-count fires here (Task 8a's own + * carry — "nothing affected today" per 3d's ledger, a latent-bug fix, not an + * active one — no shipped entity's CHECK expression has hit this shape yet). */ export function RULE12_fixGarbledCheckExpressions(metadata: EntityMetadata[]): CheckExpressionFixup[] { const fixups: CheckExpressionFixup[] = []; @@ -948,9 +960,25 @@ export function RULE12_fixGarbledCheckExpressions(metadata: EntityMetadata[]): C const expr = check.expression; let depth = 1; let cut = -1; + let inString = false; for (let i = 0; i < expr.length; i++) { - if (expr[i] === '(') depth++; - else if (expr[i] === ')') { + const ch = expr[i]; + if (inString) { + if (ch === "'") { + if (expr[i + 1] === "'") { + i++; // a doubled '' is an escaped quote inside the literal, not its end + continue; + } + inString = false; + } + continue; + } + if (ch === "'") { + inString = true; + continue; + } + if (ch === '(') depth++; + else if (ch === ')') { depth--; if (depth === 0) { cut = i; diff --git a/server/tests/unit/db/generate-entities.test.ts b/server/tests/unit/db/generate-entities.test.ts index 5755fea2e..f9bf88b84 100644 --- a/server/tests/unit/db/generate-entities.test.ts +++ b/server/tests/unit/db/generate-entities.test.ts @@ -549,6 +549,18 @@ describe('RULE12_fixGarbledCheckExpressions', () => { const fixups = RULE12_fixGarbledCheckExpressions([meta]); expect(fixups).toEqual([]); }); + + it("RULE12-007: a quoted ')' inside a CHECK expression's own string literal is not mistaken for a closing paren — left byte-identical, no fixup", () => { + const meta = fixtureMeta('SchoolHolidayPeriods', 'school_holiday_periods', []); + // The IN-list's first value itself contains a literal ')'. A naive + // char-by-char paren count would hit depth 0 at that in-string + // character and truncate the expression, losing the list's own real + // closing paren — this fixture pins that it does not. + meta.checks = [{ name: 'sh_status_check', expression: "status IN ('confirmed)', 'tentative')" }]; + const fixups = RULE12_fixGarbledCheckExpressions([meta]); + expect(meta.checks[0].expression).toBe("status IN ('confirmed)', 'tentative')"); + expect(fixups).toEqual([]); + }); }); describe('RULE13_pinDeleteRuleDrift', () => {