/** * Guard: no raw SiteConversion queries without countedConversionWhere * ──────────────────────────────────────────────────────────────────── * Fails the build when a file contains a direct prisma.siteConversion * count/findMany/groupBy call that neither: * (a) imports countedConversionWhere, nor * (b) carries an explicit "// intentionally unfiltered:" comment on * the same or immediately preceding line. * * Admin/debug/backfill endpoints that legitimately need raw rows MUST * add: // intentionally unfiltered: * to suppress the check. * * Run: node scripts/check-raw-siteconversion.mjs */ import { readFileSync, readdirSync, statSync } from "fs"; import { join, relative } from "path"; const ROOT = join(new URL(".", import.meta.url).pathname, ".."); const SRC = join(ROOT, "src"); const RAW_CALL_RE = /prisma\.siteConversion\.(count|findMany|groupBy|aggregate)\s*\(/g; // Lines that are part of a comment block should not be flagged const COMMENT_LINE_RE = /^\s*(\*|\/\/|\/\*)/; const UNFILTERED_COMMENT_RE = /\/\/\s*intentionally unfiltered:/i; const IMPORT_HELPER_RE = /import\s+[^;]*countedConversionWhere[^;]*from/; function walkFiles(dir, results = []) { for (const entry of readdirSync(dir)) { const full = join(dir, entry); const stat = statSync(full); if (stat.isDirectory()) { // Skip node_modules and .next if (entry === "node_modules" || entry === ".next") continue; walkFiles(full, results); } else if (full.endsWith(".ts") || full.endsWith(".tsx")) { results.push(full); } } return results; } const files = walkFiles(SRC); const violations = []; for (const filePath of files) { const content = readFileSync(filePath, "utf8"); const hasHelper = IMPORT_HELPER_RE.test(content); const lines = content.split("\n"); for (let i = 0; i < lines.length; i++) { const line = lines[i]; if (!RAW_CALL_RE.test(line) || COMMENT_LINE_RE.test(line)) { RAW_CALL_RE.lastIndex = 0; continue; } RAW_CALL_RE.lastIndex = 0; // If the file imports countedConversionWhere, it's opted in — trust the author. if (hasHelper) continue; // Check for an intentionally-unfiltered comment on this line or the line above. const prevLine = i > 0 ? lines[i - 1] : ""; if (UNFILTERED_COMMENT_RE.test(line) || UNFILTERED_COMMENT_RE.test(prevLine)) continue; violations.push(` ${relative(ROOT, filePath)}:${i + 1} → ${line.trim().slice(0, 80)}`); } } if (violations.length > 0) { console.error(""); console.error("╔══════════════════════════════════════════════════════════════════════╗"); console.error("║ RAW SITECONVERSION CHECK FAILED ║"); console.error("╠══════════════════════════════════════════════════════════════════════╣"); console.error("║ The following files query SiteConversion without using ║"); console.error("║ countedConversionWhere() or an explicit unfiltered comment. ║"); console.error("║ ║"); console.error("║ User-facing conversion numbers MUST route through the helper so ║"); console.error("║ isDuplicateOf / invalidatedReason / EXCLUDED_CONVERSION_TYPES are ║"); console.error("║ applied consistently. ║"); console.error("║ ║"); console.error("║ Fix options: ║"); console.error("║ 1. import { countedConversionWhere } from ║"); console.error('║ "@/lib/conversions/counted-where" ║'); console.error("║ and use it in the where clause. ║"); console.error("║ 2. Add: // intentionally unfiltered: ║"); console.error("║ on the line before the query for debug/admin/backfill paths. ║"); console.error("╚══════════════════════════════════════════════════════════════════════╝"); console.error(""); violations.forEach((v) => console.error(v)); console.error(""); process.exit(1); } console.log(`[check-raw-siteconversion] OK — ${files.length} files checked, no violations.`); process.exit(0);