Skip to content

Commit 2208e72

Browse files
committed
fix(scripts): write 入口补规模守卫并保留超硬限人工理由
check() 早有 measured.size===0 守卫,writeTable() 没有:SCAN_DIRS 指向不存在路径时 --write 仍 exit 0、stderr 0 字节、stdout 打印 ✅,而登记表从 179 行塌到 41 行 (138 条与全部人工/自动理由清空)。抽 assertScanSane() 两入口共用:① 扫描域为空 ② 逐目录断言存在性与命中数(只坏一个目录时 (b)/(d) 会刷出上百条'删除该行'误导修法) ③ 覆盖断言:SCAN_DIRS 少写一个目录时①②全过(列出的目录都存在且有命中),但棘轮/ 登记表条目整体落到扫描域外——实测 ['app/src'] 下默认校验刷 10×(b)+102×(d), --write 更会 exit 0 打印 ✅ 并把表从 179 行/138 条砍到 77 行/36 条。③ 是该破坏类的 唯一拦截点,故超出计划给出的禁用片段范围一并补上。writeTable 在 parseReasons/ writeFileSync 之前 exit 1。 超硬限行的说明原为常量 why,导致旧表 15 条人工理由(1993 字)在重建后 0 次出现, 而表头与 docblock 均宣称'两列由人工维护,生成器按路径保留'——自述与行为不符, 且下次 --write 会继续吞掉按表头指引写进去的理由。改为常量前缀 + 追加人工理由, stripOverPrefix 先剥前缀保证 --write 幂等(前缀不叠加)。 Refs: docs/superpowers/plans/2026-09-11-frontend-redesign-batch0c1-line-limit-governance.md Task 3
1 parent c9a7455 commit 2208e72

1 file changed

Lines changed: 55 additions & 8 deletions

File tree

‎scripts/line-limits.mjs‎

Lines changed: 55 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,12 @@ export const FROZEN_OVER_LIMIT = [
4848

4949
const toPosix = (p) => p.split(sep).join('/');
5050

51+
/** 超硬限表格的**生成器前缀**:人工理由追在其后,靠 `stripOverPrefix` 保证 `--write` 幂等 */
52+
const OVER_PREFIX = `超硬限(>${HARD_LIMIT} 行),不允许豁免`;
53+
/** 幂等:读回时先剥掉生成器自己加的前缀,否则每次 --write 都会叠加一层 */
54+
const stripOverPrefix = (t) =>
55+
t.startsWith(OVER_PREFIX) ? t.slice(OVER_PREFIX.length).replace(/^\s*——\s*/, '').trim() : t.trim();
56+
5157
/**
5258
* 行数口径的**唯一实现**:全部行数(含空行)。
5359
* 与 [System.IO.File]::ReadAllLines(path, UTF8).Count 等价:末尾换行不额外算一行。
@@ -114,6 +120,43 @@ function autoReason(absPath) {
114120
return text ? `${text}(自动摘取,待细化)` : '(待补理由:本条目由生成器补登)';
115121
}
116122

123+
/**
124+
* 扫描域规模守卫:`check()` 与 `writeTable()` **两个入口共用**,且必须在做任何事之前调用 ——
125+
* 没有它,扫描域失效时 check 会把 (a)/(c) 静默判绿、write 会把登记表清空还打印 ✅ 并 exit 0。
126+
*/
127+
function assertScanSane(measured) {
128+
if (measured.size === 0) {
129+
console.error(
130+
`❌ line-limits:扫描域为空 —— ${SCAN_DIRS.join(' / ')} 下没有匹配 ${SOURCE_EXT} 的文件。\n` +
131+
` 这几乎总是路径写错或工作目录不对,**不是"没有超限文件"**,更不是"可以安全重写登记表"。`,
132+
);
133+
process.exit(1);
134+
}
135+
// 逐目录断言:只坏一个目录时,(b)/(d) 会刷出上百条"→ 删除该行",把人引向删棘轮/删条目
136+
for (const dir of SCAN_DIRS) {
137+
const abs = join(ROOT, dir);
138+
if (!existsSync(abs)) {
139+
console.error(`❌ line-limits:扫描目录不存在 —— ${dir}(SCAN_DIRS 配置或工作目录有问题)`);
140+
process.exit(1);
141+
}
142+
const hits = [...measured.keys()].filter((p) => p.startsWith(`${dir}/`)).length;
143+
if (hits === 0) {
144+
console.error(`❌ line-limits:扫描目录下没有任何匹配文件 —— ${dir}(期望 .ts/.tsx/.rs,实际 0 个)`);
145+
process.exit(1);
146+
}
147+
}
148+
// 覆盖断言(上面两条的补集):SCAN_DIRS **少写一个目录**时列出的目录都存在且有命中 ⇒ 上面全过,但棘轮/登记表里的文件整体落到扫描域外 ⇒ (b)/(d) 报成"→ 删除该行"、--write 整片删掉仍打印 ✅(实测 ['app/src']:10×(b)+102×(d);--write exit 0,179 → 77 行 / 138 → 36 条)。
149+
const declared = new Set([...FROZEN_OVER_LIMIT, ...parseReasons().keys()]);
150+
const uncovered = [...declared].filter((p) => !SCAN_DIRS.some((d) => p.startsWith(`${d}/`)));
151+
if (uncovered.length) {
152+
console.error(
153+
`❌ line-limits:扫描域覆盖不全 —— 棘轮/登记表有 ${uncovered.length} 条不在 ${SCAN_DIRS.join(' / ')} 下(如 ${uncovered[0]}):\n` +
154+
` SCAN_DIRS 少写目录会把它们误报成"→ 删除该行",--write 更会整片删掉它们仍打印 ✅。`,
155+
);
156+
process.exit(1);
157+
}
158+
}
159+
117160
/** 逐字保留「已拆分 / 登记移除记录」整节 */
118161
function parseHistory() {
119162
const abs = join(ROOT, TABLE_PATH);
@@ -125,6 +168,8 @@ function parseHistory() {
125168

126169
function writeTable() {
127170
const measured = scanTree();
171+
// ⚠️ 必须在 parseReasons()/writeFileSync() **之前**:否则扫描域失效时它会把 138 条清成表头 + 历史节(实测 179 → 41 行)还打印 ✅、exit 0。同类破坏在"SCAN_DIRS 少写一个目录"时同样可达。
172+
assertScanSane(measured);
128173
const reasons = parseReasons();
129174
// 并列时必须按路径断开:`scanTree` 的 Map 迭代序来自 readdirSync,**跨平台不一致**
130175
// (Windows 与 Linux 的顺序可能不同)⇒ 只按行数排会让 `--write` 在不同平台产出不同字节。
@@ -138,6 +183,13 @@ function writeTable() {
138183
return s || fallback;
139184
};
140185
const row = (p, n, why, split) => `| ${p} | ${n} | ${cell(why, '(待补理由)')} | ${cell(split, '若再增长:按职责拆分')} |`;
186+
// 超硬限行的说明=**生成器常量前缀 + 人工理由**。表头 :5 宣称这两列「由人工维护,生成器按路径保留」,
187+
// 只写常量会让按表头指引写进这一列的文字在下一次 `--write` 被整列吞掉(旧表 15 条 / 1993 字即如此丢失);
188+
// 前缀保证"不允许豁免"不可被人工理由改写,人工文字仍完整保留(`stripOverPrefix` 保证幂等)。
189+
const overWhy = (p) => {
190+
const human = stripOverPrefix(reasons.get(p)?.why ?? '');
191+
return human ? `${OVER_PREFIX} —— ${human}` : OVER_PREFIX;
192+
};
141193

142194
const lines = [
143195
'# 单文件行数豁免登记(AGENTS.md §3:单文件 ≤300 行;301–600 行须登记本清单)',
@@ -159,7 +211,7 @@ function writeTable() {
159211
// 按路径保留人工维护的两列,改成 3 列会让这些拆分计划在**下一次重生成时静默丢失**。
160212
'| 文件 | 行数 | 说明 | 拆分计划 |',
161213
'|---|---|---|---|',
162-
...over.map(([p, n]) => row(p, n, `超硬限(>${HARD_LIMIT} 行),不允许豁免`, reasons.get(p)?.split || `**超硬限必须拆**:拆到各文件 ≤${SOFT_LIMIT} 行`)),
214+
...over.map(([p, n]) => row(p, n, overWhy(p), reasons.get(p)?.split || `**超硬限必须拆**:拆到各文件 ≤${SOFT_LIMIT} 行`)),
163215
'',
164216
`## ${SOFT_LIMIT + 1}–${HARD_LIMIT} 档(须登记)`,
165217
'',
@@ -180,13 +232,8 @@ function check({ full }) {
180232
const measured = scanTree();
181233
// 规模自检:没有它,扫描域失效会让 (a)/(c) 静默通过、(d) 反把 117 条登记行报成"指向不存在的文件"。
182234
// 更危险的是未来:FROZEN_OVER_LIMIT 被清空(0-C2/C3 拆完后)时,双失效会给出 exit 0 绿灯。
183-
if (measured.size === 0) {
184-
console.error(
185-
`❌ line-limits:扫描域为空 —— ${SCAN_DIRS.join(' / ')} 下没有匹配 ${SOURCE_EXT} 的文件。\n` +
186-
` 这几乎总是路径写错或工作目录不对,**不是"没有超限文件"**。`,
187-
);
188-
process.exit(1);
189-
}
235+
// 守卫与 `--write` 入口**共用同一实现**,避免两处口径漂移。
236+
assertScanSane(measured);
190237

191238
// 登记表缺失自检:缺了它,(c) 会把每条超限文件逐条报成"未登记",
192239
// 而不提示真实原因是登记表不存在 —— 同样把人引向错误的修法。

0 commit comments

Comments
 (0)