@@ -48,6 +48,12 @@ export const FROZEN_OVER_LIMIT = [
4848
4949const 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/** 逐字保留「已拆分 / 登记移除记录」整节 */
118161function parseHistory ( ) {
119162 const abs = join ( ROOT , TABLE_PATH ) ;
@@ -125,6 +168,8 @@ function parseHistory() {
125168
126169function 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