feat: advance local-first workspace checklist
- add admin access-policy UI and local access control surfaces - add local markdown conflict resolution UI and smoke coverage - add ACP local agent changed-files audit scaffold and read-only write guard - document current P0-P2 checklist progress and verification evidence
This commit is contained in:
@@ -0,0 +1,294 @@
|
||||
#!/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 <path...>",
|
||||
" 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user