Files
mnote/scripts/check-local-first-convex-guard.js
T
lix-2026 47e224d419 chore: align sqlite control plane architecture
- replace default Convex control-plane wording with Rust SQLite control-plane across architecture, AGENTS, Reasonix, and design docs

- retire root Convex functions source and deploy script into recycle while keeping explicit cloud/compat/sync-replica boundaries

- add control-plane migration guard/docs and keep CodeGraph refreshed after the SQLite control-plane cutover
2026-05-22 17:45:22 +08:00

409 lines
14 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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 MIGRATION_PERIOD_WAIVER = "sqlite-migration-waiver";
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/,
},
];
const RETIRED_ROOT_CONVEX_PATHS = [
{
name: "根 convex schema",
pattern: /^convex\/schema\.ts$/,
},
{
name: "根 Convex Auth 函数",
pattern: /^convex\/auth(?:\.config)?\.ts$/,
},
{
name: "根 Convex 用户函数",
pattern: /^convex\/users\.ts$/,
},
{
name: "根 Convex AI session 函数",
pattern: /^convex\/aiSessions\.ts$/,
},
];
// ─── SQLite 迁移期 forbid ───────────────────────────────────
// 在 SQLite 控制面迁移阶段(Phase 1-5),新增 Convex 控制面代码
// users/workspaces/sessions/policies/audit/outbox/share 等)
// 应标记 sqlite-migration-waiver,否则提示应迁移到 SQLite。
const MIGRATION_PERIOD_FORBIDDEN = [
{
name: "未标注迁移例外的 Convex 控制面 users 代码",
pattern: /(?:[Uu]sers?\s*[:=.]|[a-z]+[A-Z][a-z]*[Uu]ser\b|auth_identities|auth_sessions).*(?:defineTable|mutation|query|schema)/,
scope: "convex",
},
{
name: "未标注迁移例外的 Convex 控制面 workspace 代码",
pattern: /(?:[Ww]orkspaces?\s*[:=]|[Ww]orkspace[Mm]embers\b|[Ww]orkspace[Mm]ember\b).*(?:defineTable|mutation|query|schema)/,
scope: "convex",
},
{
name: "未标注迁移例外的 Convex 控制面 grants/access 代码",
pattern: /\b(?:directory_grants|directoryGrants|access_policy|accessPolicy)\b/,
scope: "convex",
},
{
name: "未标注迁移例外的 Convex 控制面 share 代码",
pattern: /\b(?:share_links|shareLinks)\b/,
scope: "convex",
},
{
name: "未标注迁移例外的 Convex 控制面 sync/audit/outbox 代码",
pattern: /\b(?:sync_state|syncState|audit_log|auditLog|outbox_events|outboxEvents)\b/,
scope: "convex",
},
{
name: "未标注迁移例外的 Convex 控制面 ai_policies 代码",
pattern: /\b(?:ai_policies|aiPolicies|ai_policy|aiPolicy)\b/,
scope: "convex",
},
{
name: "未标注迁移例外的 Convex 控制面 legacy_id_map 代码",
pattern: /\b(?:legacy_id_map|legacyIdMap)\b/,
scope: "convex",
},
];
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 <path...>",
" node scripts/check-local-first-convex-guard.js --self-test",
"",
`如需保留明确的 cloud/compat 直连,请在同一新增行加入 ${WAIVER}。`,
"根 convex/ functions 源码已退役;如确需新增显式 cloud/compat Convex functions,应先放到明确的辅助目录并更新 guard。",
`SQLite 迁移期中新增 Convex 控制面代码(users/workspaces/grants/share 等)需加 ${MIGRATION_PERIOD_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 isRetiredRootConvexPath(filePath) {
return RETIRED_ROOT_CONVEX_PATHS.some((entry) => entry.pattern.test(filePath));
}
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 isMigrationWaived(row) {
return row.text.includes(WAIVER) || row.text.includes(MIGRATION_PERIOD_WAIVER);
}
function checkLine(row) {
if (isMigrationWaived(row)) {
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 (isRetiredRootConvexPath(row.filePath)) {
return [
{
...row,
reason: `${RETIRED_ROOT_CONVEX_PATHS.find((entry) => entry.pattern.test(row.filePath)).name} 已随根 convex/ 退役;不要重新新增为默认控制面,请迁到 Rust SQLite control-plane 或明确的 cloud/compat 辅助目录。`,
},
];
}
if (!isRuntimePath(row.filePath) || isAllowedRuntimeAdapter(row.filePath)) {
if (!isActiveConvexPath(row.filePath)) {
return [];
}
const violations = 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} 标注例外。`,
}));
// 在迁移期内,新增控制面 Convex 代码也应标记例外
if (violations.length === 0) {
const migrationHit = MIGRATION_PERIOD_FORBIDDEN.filter((entry) => entry.pattern.test(row.text)).map((entry) => ({
...row,
reason: `${entry.name} — SQLite 迁移期内新增 Convex 控制面代码应标记 ${MIGRATION_PERIOD_WAIVER} 迁移例外,或将逻辑迁移到 Rust control-plane SQLite 实现。`,
}));
violations.push(...migrationHit);
}
return violations;
}
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/aiSessions.ts",
lineNumber: 1,
text: "export const upsertRuntimeRun = mutation({",
},
{
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"',
},
{
filePath: "convex/users.ts",
lineNumber: 1,
text: " users: defineTable({",
},
{
filePath: "convex/workspaces_migration.ts",
lineNumber: 1,
text: " workspaces: defineTable({",
},
];
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/aiSessions.ts")) {
throw new Error("self-test 失败:未拦截退役根 convex AI session 函数");
}
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");
}
if (!labels.includes("convex/users.ts")) {
throw new Error("self-test 失败:未拦截迁移期新增 Convex 控制面 users 代码");
}
if (!labels.includes("convex/workspaces_migration.ts")) {
throw new Error("self-test 失败:未拦截迁移期新增 Convex 控制面 workspaces 代码");
}
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);
}
}