Files
AgentLab/scripts/check-copy.mjs
T
ThearchyHelios eddeded2a4 全栈安全加固、性能提速与架构演进:连接池与沙箱止血、双路混合检索、多版本归档及前端分包治理
- 第一批(安全加固与生命周期止血):隔离测试宿主机密钥与 .env 泄漏;修复 SQLite 取消查询挂死、:memory: 连接池报错与 EngineCache 世代竞态;补齐运行与评测删除时的 Checkpointer 线程、Artifact 磁盘文件与无主附件回收;加固三套沙箱路径越界、进程组清理与 Seatbelt 数据目录隔离,封堵 SSRF IPv4-mapped IPv6 元数据绕过与响应体 OOM;修复推理模型多轮 reasoning_content、结构化输出 Token 漏计与缓存折扣计费
- 第二批(数据库/集群/问数提速与首屏分包):补齐核心表索引与 SQLite WAL 调优,列表接口按需 defer 大列,收敛启动扫描并打通跨进程 slot 槽位唤醒;优化跨源全局选表、哈希桶数字绑定、流式 CSV 导入及多方言分析算子;剥离首屏静态引入的 @xyflow/react 与全局样式(首屏主包降至 461.59 kB),补充流式 rAF 攒批、Store 精细订阅与表单无障碍绑定
- 第三批(架构演进与工程自动化闭环):记录库超挂接阈值历史版本自动增量归档至单库多表(解除 SQLite 10 库 ATTACH 硬上限);重构知识库为词面 BM25 与全集合归一化向量矩阵真·双路独立召回并支持游标分批重建;升级多方言感知 SQL 模板词法分析(E'...' / $$...$$ / q'[...]' / # / 嵌套注释)与 Decimal / bool / NaN 参数校验,消除 ledger 层对 api 路由层的反向依赖;拆分 3101 行 CaliberDrafts 页面为 7 个领域子模块,消除检查脚本本机路径硬编码并新增 GitHub Actions CI 门禁与三批回归测试套件
2026-11-13 14:51:36 +08:00

498 lines
26 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.
// 界面文案防回潮:静态扫描前后端会上界面的字符串,不开浏览器、不连后端。
//
// node scripts/check-copy.mjs 扫一遍,命中禁用写法(且不在白名单里)的报出来
// node scripts/check-copy.mjs --list 列出全部命中,含被白名单放过的(调白名单时用)
//
// 守的是 2026-09 文案整改定下的写法(术语表和语体规范见 frontend/src/lib/terms.ts 开头):
// - 「跑」作动词(工作流写「运行」,节点、工具写「执行」)、后端(写「服务端」)、多半、照样、眼下、压根、
// 取不到、没取回来、读不懂、连不上、写坏了(写「无法 / 未能 / 失败」)、
// 令牌 / tok / tokens(写「token」)、快照链接(写「分享链接」)、钉住(写「固定版本」「引用版本」)、智能体(写「Agent」)、
// 供应商(写「模型接入」)、界面上的 Copilot(写「助手」);
// - 句尾语气词(吧、呢、哦……);
// - 句子里裸写的配置键名和枚举值(report_from、require_citation、cells: true……),要写界面上的中文标签;
// - 中文句子里的半角标点、英文引号和「...」(用全角标点、「」和「…」)。
//
// 扫哪些字符串:
// - 前端:frontend/src 下的 .ts / .tsx,不含 __tests__、dev/、.d.ts。用 vite 自带的解析器(rolldown 的
// oxc)读成语法树,取字符串字面量、模板字符串和 JSX 文本,注释天然不在里面。按原文匹配用的字符串
// (includes / startsWith / === / new RegExp 的参数、case 标签、对象的键、import 路径、类型里的字面量、
// console 输出)不算界面文案,跳过。
// - 后端:backend/app 下的 .py,用 Python 的 ast 读,取带中文的字符串和 f-string(docstring、日志、
// 按原文比较和正则匹配用的跳过)。写给模型的提示词不上界面,按常量名、函数名排除(见 MODEL_ONLY)。
//
// 确实该保留的写在 scripts/copy-allow.json:每条写明文件、原文片段、规则和理由。白名单里有一条
// 已经对不上任何命中(原文改掉了、挪走了),也算不通过:白名单只收现在还需要的例外。
import { spawnSync } from 'node:child_process'
import { readdirSync, readFileSync, realpathSync, statSync } from 'node:fs'
import { createRequire } from 'node:module'
import { dirname, join, relative } from 'node:path'
import { fileURLToPath, pathToFileURL } from 'node:url'
import { resolvePython } from './lib/env.mjs'
const HERE = dirname(fileURLToPath(import.meta.url))
const ROOT = dirname(HERE)
const FRONT = join(ROOT, 'frontend/src')
const BACK = join(ROOT, 'backend/app')
const LIST = process.argv.includes('--list')
let failed = 0
const check = (name, cond, detail = '') => {
console.log(` ${cond ? '✓' : '✗'} ${name}${detail ? ` — ${detail}` : ''}`)
if (!cond) failed++
}
// ---------------------------------------------------------------- 规则
const CJK = /[㐀-鿿]/
const H = '[\\u3400-\\u9fff]'
// 句子里不该裸写的配置键名和枚举值(配置界面字段旁边附的键名是单独的字符串,不带中文,不会命中)
const KEYS = ['report_from', 'require_citation', 'on_violation', 'on_uncited', 'on_unsupported', 'on_exhausted',
'allow_numbers', 'caliber_from', 'metrics_from', 'cite_fields', 'upgrade_policy', 'evidence_role', 'max_repairs',
'max_rounds', 'skip_if', 'budget_tokens', 'workflow_version', 'output_schema',
// 应用平台的新配置键(人工节点的超时处理、表单来自、派给谁,能力的无人值守写入):句子里写中文标签
'on_timeout', 'form_from', 'assignee', 'unattended_write']
// 前面是「{」的是 str.format 的占位符({budget_tokens:,}),不算
const KEY_RE = new RegExp(`(?<![A-Za-z0-9_.${'$'}{])(?:${KEYS.join('|')})(?![A-Za-z0-9_])`
+ '|cells\\s*:\\s*true|numbers\\s*:\\s*(?:strict|off|flag)\\b|claims\\s*:\\s*(?:off|judge|require_citation)\\b')
/**
* 每条规则:id(白名单按它对)、说明、test(text, prose) → 命中的片段或 null。
* prose:这段文字带中文,或者是 JSX 文本——只有这样的才按「句子」查英文写法。
*/
const RULES = [
{ id: '跑', what: '「跑」作动词:工作流写「运行」,节点、工具写「执行」', test: (t) => t.match(/.{0,6}跑.{0,6}/)?.[0] },
{ id: '后端', what: '「后端」:写「服务端」,连接类写「无法连接服务」', test: (t) => t.match(/.{0,6}后端.{0,6}/)?.[0] },
{
id: '口语', what: '口语副词和补语:多半、照样、眼下、压根;取不到、没取回来、读不懂、连不上、写坏了',
test: (t) => t.match(/.{0,6}(?:多半|照样|眼下|压根|取不到|没取回来|读不懂|连不上|写坏了).{0,6}/)?.[0],
},
{ id: '令牌', what: '「令牌」:写「token」', test: (t) => t.match(/.{0,6}令牌.{0,6}/)?.[0] },
// 总方案 X10:不记名的只读链接叫「分享链接」,「快照」专指查询快照
{ id: '快照链接', what: '「快照链接」:写「分享链接」(「快照」专指查询快照)', test: (t) => t.match(/.{0,6}快照链接.{0,6}/)?.[0] },
{
id: 'tok', what: '「tok」「tokens」:写「token」(如「2.8k token」)',
test: (t, prose) => (prose
? t.match(/.{0,8}(?<![A-Za-z_])(?:toks?|tokens)(?![A-Za-z_]).{0,4}/)?.[0]
: t.match(/(?:\d|\{x\})\s*(?:toks?|tokens)(?![A-Za-z_])/)?.[0]),
},
{ id: '钉', what: '「钉住」「钉」:写「固定版本」「引用版本」', test: (t) => t.match(/.{0,6}钉.{0,6}/)?.[0] },
{ id: '智能体', what: '「智能体」:写「Agent」', test: (t) => t.match(/.{0,6}智能体.{0,6}/)?.[0] },
{ id: '供应商', what: '「供应商」:写「模型接入」', test: (t) => t.match(/.{0,6}供应商.{0,6}/)?.[0] },
{
id: 'Copilot', what: '界面上的「Copilot」:写「助手」',
test: (t, prose) => (prose ? t.match(/.{0,6}copilot.{0,6}/i)?.[0] : t.match(/.{0,6}Copilot.{0,6}/)?.[0]),
},
{
id: '语气词', what: '句尾语气词(吧、呢、哦、啊、呀、嘛、啦)',
test: (t) => t.match(new RegExp(`.{0,6}${H}[吧呢哦啊呀嘛啦](?=$|[\\s。!?!?,,;;::」』))\\]…—])`))?.[0],
},
{
id: '键名', what: '句子里裸写配置键名或枚举值:写界面上的中文标签(如「报告来自」「单元格引用」)',
test: (t, prose) => (prose && CJK.test(t) ? t.match(KEY_RE)?.[0] : null),
},
{
id: '半角标点', what: '中文句子里的半角标点、英文引号或「...」:用全角标点、「」和「…」',
test: (t, prose) => {
if (!prose) return null
const m = t.match(new RegExp(`.{0,4}(?:${H}[,;!?]|[,;!?]${H}|${H}:(?![/\\d])|:${H}|${H}\\(|\\)${H}`
+ `|${H}"|"${H}|[“”]|\\.\\.\\.).{0,4}`))
return m?.[0] ?? null
},
},
]
/** 一段文字命中了哪些规则 → [{ rule, snippet }] */
function lint(text, prose) {
const out = []
for (const r of RULES) {
const hit = r.test(text, prose)
if (hit) out.push({ rule: r.id, snippet: hit })
}
return out
}
// ---------------------------------------------------------------- 前端:取界面字符串
// pnpm 的依赖是软链:从 vite 的真实路径出发才解析得到它的依赖 rolldown
const require = createRequire(realpathSync(join(ROOT, 'frontend/node_modules/vite/package.json')))
const { parseAst } = await import(pathToFileURL(require.resolve('rolldown/parseAst')).href)
// 这些方法的参数是「拿来匹配的原文」,不是显示给人的
const MATCH_METHODS = new Set(['includes', 'startsWith', 'endsWith', 'indexOf', 'lastIndexOf', 'match', 'matchAll',
'search', 'split', 'test'])
const FIRST_ARG_METHODS = new Set(['replace', 'replaceAll'])
// 不上界面的 JSX 属性
const HIDDEN_ATTR = /^(?:className|class|style|key|id|role|type|href|to|src|htmlFor|name|value|d|viewBox|fill|stroke|data-.+|aria-hidden|aria-controls|aria-labelledby|aria-describedby)$/
function memberName(n) {
if (!n) return ''
if (n.type === 'Identifier') return n.name
if (n.type === 'MemberExpression' && !n.computed) return `${memberName(n.object)}.${n.property.name}`
return ''
}
/** 这个字面量是不是「按原文匹配 / 代码里的键」而不是界面文字 */
function skipLiteral(node, parent, key) {
if (!parent) return false
switch (parent.type) {
case 'ImportDeclaration': case 'ExportNamedDeclaration': case 'ExportAllDeclaration': case 'ImportExpression':
case 'TSLiteralType': case 'TSExternalModuleReference': case 'TSImportType':
return true
case 'Property': case 'PropertyDefinition': case 'MethodDefinition': case 'TSPropertySignature':
return key === 'key' && !parent.computed
case 'BinaryExpression':
return ['===', '!==', '==', '!=', 'in'].includes(parent.operator)
case 'SwitchCase':
return key === 'test'
case 'JSXAttribute':
return HIDDEN_ATTR.test(parent.name?.name ?? '')
case 'NewExpression':
case 'CallExpression': {
if (key !== 'arguments') return false
const callee = parent.callee
if (callee.type === 'Identifier' && callee.name === 'RegExp') return true
if (callee.type === 'MemberExpression' && !callee.computed) {
const m = callee.property.name
if (MATCH_METHODS.has(m)) return true
if (FIRST_ARG_METHODS.has(m) && parent.arguments[0] === node) return true
if (memberName(callee.object) === 'console') return true
}
return false
}
default:
return false
}
}
function lineOf(src) {
const starts = [0]
for (let i = 0; i < src.length; i++) if (src.charCodeAt(i) === 10) starts.push(i + 1)
return (pos) => {
let lo = 0, hi = starts.length - 1
while (lo < hi) {
const mid = (lo + hi + 1) >> 1
if (starts[mid] <= pos) lo = mid; else hi = mid - 1
}
return lo + 1
}
}
/** 一段 TS / TSX 源码里会上界面的字符串 → [{ line, text, prose }] */
export function frontStrings(src, lang = 'tsx') {
const ast = parseAst(src, { lang })
const at = lineOf(src)
const out = []
const visit = (node, parent, key) => {
if (!node || typeof node.type !== 'string') return
if (node.type === 'Literal' && typeof node.value === 'string') {
if (!skipLiteral(node, parent, key)) out.push({ line: at(node.start), text: node.value, prose: CJK.test(node.value) })
return
}
if (node.type === 'TemplateLiteral') {
if (parent?.type !== 'TaggedTemplateExpression' && !skipLiteral(node, parent, key)) {
const text = node.quasis.map((q) => q.value.cooked ?? q.value.raw).join('{x}')
out.push({ line: at(node.start), text, prose: CJK.test(text) })
}
for (const e of node.expressions) visit(e, node, 'expressions')
return
}
if (node.type === 'JSXText') {
const text = node.value.replace(/\s+/g, ' ').trim()
if (text) out.push({ line: at(node.start), text, prose: true })
return
}
for (const k of Object.keys(node)) {
if (k === 'parent') continue
const v = node[k]
if (Array.isArray(v)) for (const c of v) visit(c, node, k)
else if (v && typeof v === 'object' && typeof v.type === 'string') visit(v, node, k)
}
}
visit(ast, null, null)
return out
}
function walk(dir, keep, out = []) {
for (const name of readdirSync(dir)) {
const p = join(dir, name)
if (statSync(p).isDirectory()) walk(p, keep, out)
else if (keep(p)) out.push(p)
}
return out
}
const frontFiles = walk(FRONT, (p) => /\.tsx?$/.test(p) && !/\.d\.ts$/.test(p)
&& !/\/__tests__\//.test(p) && !/\/dev\//.test(p) && !/\.test\.tsx?$/.test(p))
// ---------------------------------------------------------------- 后端:取界面字符串
// 写给模型的提示词:不上界面(backend-surfaced 分片按要求未改)。按赋值的常量名、所在函数名排除。
// 既给人看又交回模型的,给人看的那句单独存在(for_model、_UNKNOWN_ENTITY_FOR_MODEL 是模型那份)
const MODEL_ONLY = {
// 模块级常量:系统提示、输出协议、写作 / 裁判 / 复核规则、交给模型的 JSON Schema、正则片段
names: new Set([
'NODE_REFERENCE', 'ROLE', 'MARKUP_NUDGE', 'JUDGE_RULES', 'JUDGE_ROLE', 'JUDGE_RULE', 'REVIEW_SYSTEM', 'MARKER_RULES',
'GATE_SYSTEM', 'TOOL_MARKUP_NUDGE', '_UNKNOWN_ENTITY_FOR_MODEL', 'NODE_FIELD_FIX', '_FIX_TAIL',
'_STREAM_PROTOCOL', '_RULES_HEAD', '_NO_LOWERING', '_ASK_HUMAN', '_UPGRADE_TASK', 'CELL_RULES', 'ENTITY_RULES',
'EXTRACT_SYSTEM', 'GRAPH_SCHEMA', 'REVIEW_SCHEMA', '_CN_N', 'OMITTED_RULES', 'ABSENCE_RULE',
]),
// 拼提示词的函数:生成 / 修正 / 升级请求、抽取消息、交给抽取模型的输出结构;
// 助手的数据源上下文(api/copilot_context.py 的各段渲染、挑表请求,copilot.py 里挑表用的需求)
funcs: new Set(['_user_message', '_repair_request', '_publish_fix_request', '_upgrade_assist_request',
'_extract_messages', '_from_rows', 'catalog_prompt', 'writer_catalog', '_trimmed_catalog', '_metric_prompt',
'_fields_prompt', '_field_prompt', '_query_brief',
'_pick_messages', '_pick_need', '_publish_fix_need', '_upgrade_need',
'_detail_block', '_selected_block', '_join_lines', '_route_line', '_source_head']),
// 第二个参数是交回模型改写的指令(第一个是给人看的那句):engine/evidence.py 的 _Reason(text, fix)、fail(text, fix)
fixArg: new Set(['_Reason', 'fail']),
names_re: /(?:^|_)(?:SYSTEM|PROMPT|NUDGE)(?:_|$)/,
}
const PY_EXTRACT = String.raw`
import ast, json, os, re, sys
CJK = re.compile(r'[㐀-鿿]')
root = sys.argv[1]
only = sys.argv[2:] # 给了就只读这几个文件(自检用)
out = []
def dotted(n):
if isinstance(n, ast.Name): return n.id
if isinstance(n, ast.Attribute):
d = dotted(n.value)
return (d + '.' if d else '') + n.attr
return ''
def scan(path, rel):
src = open(path, encoding='utf-8').read()
tree = ast.parse(src)
for node in ast.walk(tree):
for child in ast.iter_child_nodes(node):
child._p = node
skip = set()
for node in ast.walk(tree):
# docstring 和当注释用的裸字符串
if isinstance(node, ast.Expr) and isinstance(node.value, (ast.Constant, ast.JoinedStr)):
skip.add(id(node.value))
def ctx(node):
funcs, assign, call, kw, arg, compare, matcher, logger, dictkey = [], None, None, None, None, False, False, False, None
cur, prev = node, None
while hasattr(cur, '_p'):
prev, cur = cur, cur._p
if isinstance(cur, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
funcs.append(cur.name)
elif isinstance(cur, (ast.Assign, ast.AnnAssign, ast.AugAssign)) and assign is None:
targets = cur.targets if isinstance(cur, ast.Assign) else [cur.target]
for t in targets:
name = dotted(t) or (dotted(t.value) if isinstance(t, ast.Subscript) else '')
if name:
assign = name.split('.')[-1]
break
elif isinstance(cur, ast.Compare) and call is None:
compare = True
elif isinstance(cur, ast.Dict) and dictkey is None and prev in cur.values:
k = cur.keys[cur.values.index(prev)]
if isinstance(k, ast.Constant) and isinstance(k.value, str): dictkey = k.value
elif isinstance(cur, ast.keyword) and kw is None and call is None:
kw = cur.arg
elif isinstance(cur, ast.Call) and call is None:
call = dotted(cur.func)
if prev in cur.args: arg = cur.args.index(prev)
last = call.split('.')[-1]
head = call.split('.')[0]
if last in ('startswith', 'endswith', 'find', 'rfind', 'index', 'count', 'split', 'rsplit',
'partition', 'removeprefix', 'removesuffix', 'strip', 'lstrip', 'rstrip') \
or (head == 're' and last in ('compile', 'search', 'match', 'fullmatch', 'sub', 'subn',
'findall', 'finditer', 'split')) \
or (last == 'replace' and cur.args and prev is cur.args[0]):
matcher = True
if head in ('logger', 'log', '_log', 'LOG', 'logging', 'warnings') or last in ('debug', 'exception'):
logger = True
return dict(funcs=funcs[::-1], assign=assign, call=call, kw=kw, arg=arg, compare=compare, matcher=matcher,
logger=logger, dictkey=dictkey)
for node in ast.walk(tree):
if id(node) in skip: continue
if isinstance(node, ast.JoinedStr):
text = ''.join(str(v.value) if isinstance(v, ast.Constant) else '{x}' for v in node.values)
elif isinstance(node, ast.Constant) and isinstance(node.value, str):
if isinstance(getattr(node, '_p', None), (ast.JoinedStr, ast.FormattedValue)): continue
text = node.value
else:
continue
if not CJK.search(text) and 'Copilot' not in text: continue
out.append(dict(file=rel, line=node.lineno, text=text, **ctx(node)))
files = []
if only:
files = [(p, os.path.basename(p)) for p in only]
else:
for d, _, fs in os.walk(root):
for f in sorted(fs):
if f.endswith('.py'):
p = os.path.join(d, f)
files.append((p, os.path.relpath(p, root)))
for p, rel in files:
scan(p, rel)
print(json.dumps(out, ensure_ascii=False))
`
const PYTHON = resolvePython()
function backStrings(root, only = []) {
const r = spawnSync(PYTHON, ['-c', PY_EXTRACT, root, ...only], { encoding: 'utf8', maxBuffer: 256 << 20 })
if (r.status !== 0) throw new Error(`读后端源码失败:${r.stderr || r.error}`)
return JSON.parse(r.stdout)
}
/** 后端字符串是不是会上界面的那种(不是日志、不是匹配用的原文、不是写给模型的提示词) */
function backShown(s) {
if (s.logger || s.matcher || s.compare) return false
if (s.assign && (MODEL_ONLY.names.has(s.assign) || MODEL_ONLY.names_re.test(s.assign))) return false
if (s.call && MODEL_ONLY.fixArg.has(s.call.split('.').pop()) && s.arg === 1) return false
// 接口参数说明只进 OpenAPI 文档,不上界面
if (s.call && /(?:^|\.)(?:Query|Path|Body|Header)$/.test(s.call) && s.kw === 'description') return false
if (s.funcs.some((f) => MODEL_ONLY.funcs.has(f))) return false
if (s.kw === 'fix' || s.kw === 'for_model' || s.dictkey === 'for_model') return false
return true
}
// ---------------------------------------------------------------- 汇总
const hits = []
for (const file of frontFiles) {
const rel = `frontend/src/${relative(FRONT, file)}`
const src = readFileSync(file, 'utf8')
let strings
try {
strings = frontStrings(src, file.endsWith('.tsx') ? 'tsx' : 'ts')
} catch (e) {
check(`读得懂 ${rel}`, false, String(e?.message ?? e).slice(0, 200))
continue
}
for (const s of strings) for (const h of lint(s.text, s.prose)) hits.push({ file: rel, line: s.line, text: s.text, ...h })
}
const backAll = backStrings(BACK)
const backShownList = backAll.filter(backShown)
for (const s of backShownList) {
const where = [s.funcs.join('.'), s.assign && `= ${s.assign}`, s.call && `${s.call}(${s.kw ? `${s.kw}=` : ''})`,
s.dictkey && `{${s.dictkey}}`].filter(Boolean).join(' ')
for (const h of lint(s.text, true)) hits.push({ file: `backend/app/${s.file}`, line: s.line, text: s.text, where, ...h })
}
const ALLOW_PATH = join(HERE, 'copy-allow.json')
const allow = JSON.parse(readFileSync(ALLOW_PATH, 'utf8')).entries
const used = new Set()
const allowed = (h) => allow.findIndex((a, i) => {
const ok = a.file === h.file && (a.rule === h.rule || a.rule === '*') && h.text.includes(a.text)
if (ok) used.add(i)
return ok
}) >= 0
const bad = hits.filter((h) => !allowed(h))
console.log('=== 扫描范围 ===')
check(`前端 ${frontFiles.length} 个文件都读得懂`, frontFiles.length > 60)
check(`后端取到 ${backAll.length} 条带中文的字符串,其中 ${backShownList.length} 条按界面文案查`,
backAll.length > 1000 && backShownList.length > 500)
console.log('\n=== 禁用写法 ===')
for (const r of RULES) {
const mine = bad.filter((h) => h.rule === r.id)
check(`${r.what}:没有`, mine.length === 0,
mine.slice(0, 40).map((h) => `${h.file}:${h.line}「${h.snippet}」`).join(' | ')
+ (mine.length > 40 ? ` | ……另有 ${mine.length - 40} 处` : ''))
}
console.log('\n=== 给模型的改写指令(for_model)不上界面 ===')
// 后端在报告核对的 violations、助手自查的问题里另带 for_model:交回模型用的原话。前端在请求层(api/http.ts)的
// parseBody 里一进来就去掉,别处不该再碰它——原始事件、工件原文这些直接显示 JSON 的地方才不会露出来。
// REST、上传的入口在 http.ts,助手流、运行事件流在 client.ts,两个文件合起来查
{
const transport = ['api/http.ts', 'api/client.ts'].map((f) => join(FRONT, f))
const readers = frontFiles.filter((f) => !transport.includes(f) && readFileSync(f, 'utf8').includes('for_model'))
check('前端只有 api/http.ts、api/client.ts 提到 for_model', readers.length === 0, readers.map((f) => relative(ROOT, f)).join('、'))
const code = transport.map((f) => readFileSync(f, 'utf8')).join('\n')
check('api/http.ts、api/client.ts:响应体一律经 parseBody 解析(不直接 res.json()),REST、上传、助手流、运行事件流四个入口都在',
!/\.json\(\)/.test(code) && (code.match(/parseBody\(/g) ?? []).length >= 5 && /delete[^\n]*MODEL_NOTE/.test(code))
}
console.log('\n=== 白名单 ===')
const bare = allow.filter((a) => !a.file || !a.text || !a.rule || !a.reason || !RULES.some((r) => r.id === a.rule || a.rule === '*'))
check(`${allow.length} 条都写明了文件、原文片段、规则和理由`, bare.length === 0, bare.map((a) => JSON.stringify(a)).join(' | '))
const stale = allow.filter((_, i) => !used.has(i))
check('每条都还对得上一处命中(失效的要删掉)', stale.length === 0,
stale.map((a) => `${a.file}「${a.text}」(${a.rule})`).join(' | '))
if (LIST) {
console.log('\n=== 全部命中(--list)===')
for (const h of hits) {
console.log(` ${allowed(h) ? '·' : '!'} [${h.rule}] ${h.file}:${h.line}「${h.snippet}」${h.where ? ` @ ${h.where}` : ''}`
+ ` ← ${JSON.stringify(h.text).slice(0, 120)}`)
}
}
// ---------------------------------------------------------------- 自检:扫描器自己认得对不对
console.log('\n=== 扫描器自检(拿构造的源码喂一遍)===')
{
const src = [
"// 注释里写接着跑不算",
"/* 块注释:后端 */",
"const a = <button title=\"接着跑\">接着跑</button>",
"const b = '后端没有响应'",
"const c = `已用 ${n} tok`",
"const d = /跑完/.test(x)",
"const e = x.includes('收尾轮仍想调用工具,接着跑')",
"const f = x === '在跑'",
"const g = { '可续跑': 1 }",
"const h = <div className=\"跑\" data-x=\"跑\" aria-label=\"不能再跑\" />",
"const i = '让 Copilot 改'",
"const j = '好的吧。'",
"const k = '按 report_from 取'",
"const l = '提示:请稍后...'",
"const m = '保存并继续运行'",
"import z from './跑.ts'",
"switch (y) { case '跑': break }",
"console.log('后端')",
].join('\n')
const strings = frontStrings(src)
const flagged = (needle, rule) => strings.some((s) => s.text.includes(needle) && lint(s.text, s.prose).some((h) => h.rule === rule))
const seen = (needle) => strings.some((s) => s.text.includes(needle))
check('JSX 文本、JSX 属性里的「接着跑」报出来', strings.filter((s) => s.text === '接着跑').length === 2 && flagged('接着跑', '跑'))
check('普通字符串里的「后端」、模板字符串里的「tok」报出来', flagged('后端没有响应', '后端') && flagged('已用 {x} tok', 'tok'))
check('读屏名(aria-label)按界面文案查', flagged('不能再跑', '跑'))
check('Copilot、句尾语气词、裸键名、半角标点都报出来', flagged('让 Copilot 改', 'Copilot') && flagged('好的吧', '语气词')
&& flagged('按 report_from 取', '键名') && flagged('提示:请稍后', '半角标点'))
check('注释、正则、includes / === 的原文、对象键、import 路径、case 标签、className / data-*、console 都不算',
!seen('注释') && !seen('块注释') && !seen('跑完') && !seen('收尾轮') && !seen('在跑') && !seen('可续跑')
&& !strings.some((s) => s.text === '跑') && !seen('./跑.ts') && !strings.some((s) => s.text === '后端'))
check('合规的文案不误报', !lint('保存并继续运行', true).length && !lint('56.0k token', false).length
&& !lint('已达上限(3 句),这句未裁判', true).length)
check('「快照链接」报出来,「分享链接」「查询快照」不误报', lint('复制快照链接', true).some((h) => h.rule === '快照链接')
&& !lint('复制分享链接', true).length && !lint('打开查询快照', true).length)
const py = [
'from fastapi import HTTPException',
'NODE_REFERENCE = "给模型:接着跑"',
'def f(x):',
' """docstring:后端"""',
' logger.info("后端日志")',
' if "接着跑" in x: pass',
' if x.startswith("跑"): pass',
' raise HTTPException(409, detail="后端还在跑")',
' return {"message": f"已用 {x} 令牌", "for_model": "照样重试"}',
].join('\n')
const tmp = join(process.env.TMPDIR ?? '/tmp', `check-copy-selftest-${process.pid}.py`)
const { writeFileSync, rmSync } = await import('node:fs')
writeFileSync(tmp, py)
try {
const got = backStrings(dirname(tmp), [tmp]).filter(backShown)
const texts = got.map((s) => s.text)
check('后端:HTTPException 的 detail、返回给界面的 message 报出来',
texts.includes('后端还在跑') && texts.includes('已用 {x} 令牌')
&& lint('后端还在跑', true).some((h) => h.rule === '跑') && lint('已用 {x} 令牌', true).some((h) => h.rule === '令牌'))
check('后端:docstring、日志、in / startswith 的原文、写给模型的常量和 for_model 都不算',
!texts.some((t) => /docstring|日志|给模型|照样/.test(t)) && !texts.includes('接着跑') && !texts.includes('跑'))
} finally {
rmSync(tmp, { force: true })
}
}
console.log(failed ? `\n✗ ${failed} 项未通过` : '\n✓ 文案全部通过')
process.exit(failed ? 1 : 0)