#!/usr/bin/env node "use strict"; const { execFileSync } = require("node:child_process"); const fs = require("node:fs"); const path = require("node:path"); const WAIVER = "local-first-allow-convex-main-storage"; const RUNTIME_FORBIDDEN = [ { name: "未标注的 Convex documents 直连", pattern: /documents:(?:createWithParentReference|updateTitle|move|updateContent|getMeta|getContent|purge)\b/, }, { name: "未标注的 Convex media 直连", pattern: /mediaAssets:(?:generateUploadUrl|createWithStorage|getById|refreshUrl|patchById|emptyTrashByWorkspace|purgeById|listByIds)\b/, }, { name: "未标注的 Convex AI session 直连", pattern: /aiSessions:(?:upsertRuntimeRun|appendRuntimeEvent|getRuntimeRun|listRuntimeRuns|listRuntimeEvents|renameRuntimeSession|autoTitleRuntimeSession|deleteRuntimeSession|searchRuntimeSessions)\b/, }, { name: "Convex media URL 主路径", pattern: /\/api\/media\/(?:upload|sign|batch)|assetId=/, }, ]; const DESIGN_DEFAULT_CONVEX = /(?:Convex|convex).*(?:默认|default).*(?:主存储|主数据层|数据真相|页面正文真相|文件树真相|附件|AI 会话|session)/; const DESIGN_NEGATION = /(?:不应|不得|不再|禁止|只作为|降级|控制面|检查|若把|应要求改成|不是|不作为|不依赖|可选|sync replica|cloud source|compat|依赖证据|代码盘点|未完成|部分完成|当前状态)/; const ACTIVE_CONVEX_FORBIDDEN = [ { name: "未标注的 Convex documents 表", pattern: /\bdocuments\s*:\s*defineTable\b/, }, { name: "未标注的 Convex media_assets 表", pattern: /\bmedia_assets\s*:\s*defineTable\b/, }, { name: "未标注的 Convex documents 函数", pattern: /export const (?:createWithParentReference|updateTitle|move|updateContent|getMeta|getContent|purge|listByWorkspace)\b/, }, { name: "未标注的 Convex mediaAssets 函数", pattern: /export const (?:generateUploadUrl|createWithStorage|getById|refreshUrl|patchById|emptyTrashByWorkspace|purgeById|listByIds)\b/, }, ]; function usage() { return [ "用法:", " node scripts/check-local-first-convex-guard.js", " node scripts/check-local-first-convex-guard.js --base origin/main", " node scripts/check-local-first-convex-guard.js --staged", " node scripts/check-local-first-convex-guard.js --files ", " node scripts/check-local-first-convex-guard.js --self-test", "", `如需保留明确的 cloud/compat 直连,请在同一新增行加入 ${WAIVER}。`, ].join("\n"); } function parseArgs(argv) { const result = { base: "HEAD", staged: false, files: [], selfTest: false, }; for (let index = 0; index < argv.length; index += 1) { const arg = argv[index]; if (arg === "--help" || arg === "-h") { console.log(usage()); process.exit(0); } if (arg === "--staged") { result.staged = true; continue; } if (arg === "--self-test") { result.selfTest = true; continue; } if (arg === "--base") { const value = argv[index + 1]; if (!value) throw new Error("--base 需要一个 git ref"); result.base = value; index += 1; continue; } if (arg === "--files") { result.files = argv.slice(index + 1); break; } throw new Error(`未知参数:${arg}\n${usage()}`); } return result; } function isDesignPath(filePath) { return filePath.startsWith("design/") && !filePath.startsWith("design/old/") && filePath.endsWith(".md"); } function isRuntimePath(filePath) { return filePath.startsWith("rust/crates/mnote-web/src/") && filePath.endsWith(".rs"); } function isActiveConvexPath(filePath) { return filePath.startsWith("convex/") && filePath.endsWith(".ts") && !filePath.startsWith("convex/_generated/"); } function isAllowedRuntimeAdapter(filePath) { return ( filePath === "rust/crates/mnote-web/src/transport/convex.rs" || filePath === "rust/crates/mnote-web/src/routes/compat.rs" ); } function normalizePath(filePath) { return filePath.split(path.sep).join("/"); } function readDiffLines(options) { const args = ["diff", "--unified=0", "--no-ext-diff"]; if (options.staged) { args.push("--cached"); } else { args.push(options.base); } args.push("--"); const output = execFileSync("git", args, { encoding: "utf8" }); return output.split(/\r?\n/); } function collectAddedLinesFromDiff(lines) { const added = []; let currentFile = null; let newLine = 0; for (const line of lines) { const fileMatch = line.match(/^\+\+\+ b\/(.+)$/); if (fileMatch) { currentFile = fileMatch[1]; continue; } if (line.startsWith("+++ /dev/null")) { currentFile = null; continue; } const hunkMatch = line.match(/^@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@/); if (hunkMatch) { newLine = Number(hunkMatch[1]); continue; } if (!currentFile || line.startsWith("diff --git") || line.startsWith("index ")) { continue; } if (line.startsWith("+") && !line.startsWith("+++")) { added.push({ filePath: currentFile, lineNumber: newLine, text: line.slice(1) }); newLine += 1; continue; } if (!line.startsWith("-")) { newLine += 1; } } return added; } function collectLinesFromFiles(files) { const rows = []; for (const rawPath of files) { const filePath = normalizePath(rawPath); const text = fs.readFileSync(filePath, "utf8"); text.split(/\r?\n/).forEach((line, index) => { rows.push({ filePath, lineNumber: index + 1, text: line }); }); } return rows; } function checkLine(row) { if (row.text.includes(WAIVER)) { return []; } if (isDesignPath(row.filePath)) { if (DESIGN_DEFAULT_CONVEX.test(row.text) && !DESIGN_NEGATION.test(row.text)) { return [ { ...row, reason: "设计稿新增口径把 Convex 描述成默认主存储;请改成 LocalFS/WorkspaceSource 默认,或明确标注 cloud/control-plane/compat。", }, ]; } return []; } if (!isRuntimePath(row.filePath) || isAllowedRuntimeAdapter(row.filePath)) { if (!isActiveConvexPath(row.filePath)) { return []; } return ACTIVE_CONVEX_FORBIDDEN.filter((entry) => entry.pattern.test(row.text)).map((entry) => ({ ...row, reason: `${entry.name} 出现在 active convex 目录;新增 Convex 代码只能是 auth、membership、share grants、sync state、AI policy、cloud source、compat 或 sync replica,并需用 ${WAIVER} 标注例外。`, })); } return RUNTIME_FORBIDDEN.filter((entry) => entry.pattern.test(row.text)).map((entry) => ({ ...row, reason: `${entry.name} 出现在非 Convex adapter 路径;请先走 WorkspaceSource / Rust kernel / LocalFS executor,或加 ${WAIVER} 并说明原因。`, })); } function runSelfTest() { const rows = [ { filePath: "convex/schema.ts", lineNumber: 1, text: " documents: defineTable({", }, { filePath: "convex/mediaAssets.ts", lineNumber: 1, text: "export const generateUploadUrl = mutation({", }, { filePath: "convex/shareGrants.ts", lineNumber: 1, text: "export const upsertShareGrant = mutation({", }, { filePath: "rust/crates/mnote-web/src/routes/example.rs", lineNumber: 1, text: '"documents:updateContent"', }, { filePath: "rust/crates/mnote-web/src/transport/convex.rs", lineNumber: 1, text: '"documents:updateContent"', }, ]; const violations = rows.flatMap(checkLine); const labels = violations.map((item) => item.filePath); if (!labels.includes("convex/schema.ts")) { throw new Error("self-test 失败:未拦截 active convex documents 表"); } if (!labels.includes("convex/mediaAssets.ts")) { throw new Error("self-test 失败:未拦截 active convex media 函数"); } if (!labels.includes("rust/crates/mnote-web/src/routes/example.rs")) { throw new Error("self-test 失败:未拦截非 adapter Rust Convex 直连"); } if (labels.includes("convex/shareGrants.ts")) { throw new Error("self-test 失败:误拦截控制面 share grants"); } if (labels.includes("rust/crates/mnote-web/src/transport/convex.rs")) { throw new Error("self-test 失败:误拦截 Convex adapter"); } console.log(JSON.stringify({ ok: true, guard: "local-first-convex", selfTest: true }, null, 2)); } function main() { const options = parseArgs(process.argv.slice(2)); if (options.selfTest) { runSelfTest(); return; } const rows = options.files.length > 0 ? collectLinesFromFiles(options.files) : collectAddedLinesFromDiff(readDiffLines(options)); const violations = rows.flatMap(checkLine); if (violations.length > 0) { console.error("local-first Convex guard 发现新增主存储绑定:"); for (const violation of violations) { console.error(`- ${violation.filePath}:${violation.lineNumber} ${violation.reason}`); console.error(` ${violation.text.trim()}`); } process.exit(1); } console.log(JSON.stringify({ ok: true, checkedLines: rows.length, guard: "local-first-convex" }, null, 2)); } if (require.main === module) { try { main(); } catch (error) { console.error(error instanceof Error ? error.message : String(error)); process.exit(1); } }