Skip to content

Commit 8ba2cf8

Browse files
committed
feat(scripts): 行数红线测量与棘轮校验工具
口径唯一实现 countLines()(= ReadAllLines/UTF8,含空文件早返回)+ 全树扫描 + 四条不变式(棘轮只减不增 / 棘轮无残留 / 无漏登 / 无幽灵过期)+ --full 数值一致。 ⚠️ 流程说明:本任务的实施者进程被中断(未提交、未写报告),脚本内容与计划 Task 2 逐字一致,控制方补完验证与提交。实测: - 默认校验 exit 1、47 处问题((a)0 (b)0 (c)45 (d)2),现状行 15/15 · 123 · 117 - 变异探针 (a):commands.rs 顶到 608 -> 正确报新增硬限违规;Copy-Item 还原后 git diff 为空 - 变异探针 (b):冻结名单混入 tokens.ts(28) -> 正确报已回落;还原后 diff 为空 - (c)/(d) 在真实数据上自然触发((d) 的幽灵与已回落两个分支都亮)=> 四条非空转 - 脚本 142 行 <= 300 此时 node scripts/line-limits.mjs 仍为红 —— 这是刻意的(Task 3 重建表才让它变绿)。
1 parent fd922ca commit 8ba2cf8

1 file changed

Lines changed: 142 additions & 0 deletions

File tree

‎scripts/line-limits.mjs‎

Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
#!/usr/bin/env node
2+
/**
3+
* @ai-context 行数红线的**唯一**测量与校验工具(AGENTS.md §3.1)。
4+
*
5+
* Why:红线此前无法执行 —— 规范没定口径、登记表是只追加的历史日志(同一文件多行、
6+
* 读者无法判断哪行有效)、且 15 个 >600 违规中有 10 个被登记表用 <600 的数字
7+
* 「认证为合规」。故把「测量」收进一个脚本:写表与查表共用同一套逻辑,口径只有一处实现。
8+
*
9+
* 副作用:`--write` 会重写 docs/standards/line-limit-exemptions.md(人工维护的
10+
* 「豁免理由 / 拆分计划」两列按路径保留);默认模式只读。
11+
* 边界:只扫 app/src 与 app/src-tauri/src 下的 .ts/.tsx/.rs。>600 用**棘轮**而非
12+
* 「立刻为零」—— 拆分是 0-C2/C3 的事,本脚本负责让违规**不再增加**且**可见**。
13+
*
14+
* 用法:node scripts/line-limits.mjs [--write|--full]
15+
*/
16+
import { readdirSync, readFileSync, statSync, writeFileSync, existsSync } from 'node:fs';
17+
import { join, relative, dirname, sep, resolve } from 'node:path';
18+
import { fileURLToPath } from 'node:url';
19+
20+
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
21+
const SCAN_DIRS = ['app/src', 'app/src-tauri/src'];
22+
const SOURCE_EXT = /\.(ts|tsx|rs)$/;
23+
export const TABLE_PATH = 'docs/standards/line-limit-exemptions.md';
24+
export const HARD_LIMIT = 600;
25+
export const SOFT_LIMIT = 300;
26+
27+
/**
28+
* >600 硬限违规的**冻结名单** —— 只允许减少。
29+
* 每完成一个拆分(0-C2 / 0-C3),就从这里删掉对应一行。
30+
*/
31+
export const FROZEN_OVER_LIMIT = [
32+
'app/src-tauri/src/lib.rs',
33+
'app/src-tauri/src/types.rs',
34+
'app/src-tauri/src/live_session_frame.rs',
35+
'app/src-tauri/src/commands_ai_refine.rs',
36+
'app/src/pages/ClassroomPage.tsx',
37+
'app/src-tauri/src/db_goals.rs',
38+
'app/src-tauri/src/commands_goals.rs',
39+
'app/src-tauri/src/ai_refine_task.rs',
40+
'app/src/components/SessionDetailPanel.tsx',
41+
'app/src/components/NoteListView.tsx',
42+
'app/src-tauri/src/note_filter.rs',
43+
'app/src-tauri/src/artifact_templates.rs',
44+
'app/src-tauri/src/video_profile.rs',
45+
'app/src/components/SessionListPanel.tsx',
46+
'app/src/pages/NotesPage.tsx',
47+
];
48+
49+
const toPosix = (p) => p.split(sep).join('/');
50+
51+
/**
52+
* 行数口径的**唯一实现**:全部行数(含空行)。
53+
* 与 [System.IO.File]::ReadAllLines(path, UTF8).Count 等价:末尾换行不额外算一行。
54+
*/
55+
export function countLines(absPath) {
56+
const s = readFileSync(absPath, 'utf8');
57+
if (s === '') return 0;
58+
return s.split('\n').length - (s.endsWith('\n') ? 1 : 0);
59+
}
60+
61+
/** 递归收集源文件,返回 Map<仓库相对路径(正斜杠), 行数> */
62+
export function scanTree() {
63+
const out = new Map();
64+
const walk = (absDir) => {
65+
for (const entry of readdirSync(absDir)) {
66+
const abs = join(absDir, entry);
67+
if (statSync(abs).isDirectory()) walk(abs);
68+
else if (SOURCE_EXT.test(entry)) out.set(toPosix(relative(ROOT, abs)), countLines(abs));
69+
}
70+
};
71+
for (const d of SCAN_DIRS) {
72+
const abs = join(ROOT, d);
73+
if (existsSync(abs)) walk(abs);
74+
}
75+
return out;
76+
}
77+
78+
/** 从登记表抽出条目:| 路径 | 行数 | … | */
79+
export function parseTable() {
80+
const abs = join(ROOT, TABLE_PATH);
81+
if (!existsSync(abs)) return [];
82+
const rows = [];
83+
for (const line of readFileSync(abs, 'utf8').split('\n')) {
84+
const m = /^\|\s*`?([^|`]+?\.(?:rs|tsx?))`?\s*\|\s*(\d+)\s*\|/.exec(line);
85+
if (m) rows.push({ path: m[1].trim(), declared: Number(m[2]) });
86+
}
87+
return rows;
88+
}
89+
90+
function check({ full }) {
91+
const measured = scanTree();
92+
const rows = parseTable();
93+
const registered = new Set(rows.map((r) => r.path));
94+
const problems = [];
95+
96+
// (a) 棘轮只减不增
97+
const frozen = new Set(FROZEN_OVER_LIMIT);
98+
for (const [p, n] of measured) {
99+
if (n > HARD_LIMIT && !frozen.has(p)) problems.push(`(a) 新增 >${HARD_LIMIT} 硬限违规:${p}(${n} 行)→ 必须拆分,不允许登记豁免`);
100+
}
101+
// (b) 棘轮无残留
102+
for (const p of FROZEN_OVER_LIMIT) {
103+
const n = measured.get(p);
104+
if (n === undefined) problems.push(`(b) 冻结名单里的文件已不存在:${p} → 从 FROZEN_OVER_LIMIT 删除该行`);
105+
else if (n <= HARD_LIMIT) problems.push(`(b) 冻结名单里的文件已回到 ${HARD_LIMIT} 以内:${p}(${n} 行)→ 从 FROZEN_OVER_LIMIT 删除该行`);
106+
}
107+
// (c) 无漏登
108+
for (const [p, n] of measured) {
109+
if (n > SOFT_LIMIT && !registered.has(p)) problems.push(`(c) 超过 ${SOFT_LIMIT} 行但未登记:${p}(${n} 行)→ 运行 --write 补登并填写豁免理由`);
110+
}
111+
// (d) 无幽灵/过期条目
112+
for (const r of rows) {
113+
const n = measured.get(r.path);
114+
if (n === undefined) problems.push(`(d) 登记表条目指向不存在的文件:${r.path} → 删除该行`);
115+
else if (n <= SOFT_LIMIT) problems.push(`(d) 登记表条目已回落至 ${SOFT_LIMIT} 行以内:${r.path}(${n} 行)→ 删除该行`);
116+
}
117+
// (e) 数值一致(仅 --full)
118+
if (full) {
119+
for (const r of rows) {
120+
const n = measured.get(r.path);
121+
if (n !== undefined && n !== r.declared) problems.push(`(e) 行数不一致:${r.path} 声明 ${r.declared} / 实测 ${n} → 运行 --write`);
122+
}
123+
}
124+
125+
const over = [...measured.entries()].filter(([, n]) => n > HARD_LIMIT).length;
126+
const band = [...measured.entries()].filter(([, n]) => n > SOFT_LIMIT && n <= HARD_LIMIT).length;
127+
const mode = full ? '--full' : '默认';
128+
if (problems.length) {
129+
console.error(`❌ line-limits(${mode}):${problems.length} 处问题`);
130+
for (const p of problems) console.error(` · ${p}`);
131+
console.error(`\n现状:>${HARD_LIMIT} 硬限 ${over}/${FROZEN_OVER_LIMIT.length}(棘轮)· ${SOFT_LIMIT+1}–${HARD_LIMIT} 档 ${band} · 登记条目 ${rows.length}`);
132+
process.exit(1);
133+
}
134+
console.log(`✅ line-limits(${mode}):>${HARD_LIMIT} 硬限 ${over}(棘轮内)· ${SOFT_LIMIT+1}–${HARD_LIMIT} 档 ${band} · 登记条目 ${rows.length}`);
135+
}
136+
137+
// 主入口判定:**必须**用 fileURLToPath + resolve 比较。
138+
// `import.meta.url` 在 Windows 上是 `file:///D:/.../a%20b.mjs`(三斜杠 + 空格转义成 %20),
139+
// 拼 `file://${process.argv[1]}` 得到的字符串**永远不相等** ⇒ 脚本会静默什么都不做(本计划初稿即有此错)。
140+
if (process.argv[1] && fileURLToPath(import.meta.url) === resolve(process.argv[1])) {
141+
check({ full: process.argv.includes('--full') });
142+
}

0 commit comments

Comments
 (0)