1010 * ★ 牙齿(评审 M-1 的结清):③ 用「迁移面(0) ∪ 残留面(1)」钉死冻结表每一格 + `TOTAL == RESIDUAL.length`
1111 * 独立校验和 ⇒「总数 + 某一残留格同时抬高」不再自洽。
1212 *
13- * ★ 扫描口径(与 `tmp/scan-callsites.mjs` / T1 的 loading 类**同源**)
14- * ① 域 = `app/src/**` 的 `.ts`/`.tsx` 减 `*.test.ts(x)` 减 `ui/primitives/**` 减 `ui/icons/**`;
15- * ② 先剥注释(抹等长空白保行号);字符串/模板只跳过不抹内容 —— 判据读的正是可见文案;
16- * ③ 行口径 `/加载[^"'`\n]{0,8}(中|…)/`,同行多次命中算 1 行(与 T1 的 18 行读数可比);
17- * **唯一例外**:文案由原语的 `label` 承载时**不计**(`label="…"` / `label={…}` —— 那是迁移的
18- * 终点形态,不是手写占位)。冻结时域内 `label=` 载体 0 处 ⇒ 与 T1 读数逐字可比;跨行 label 会
19- * 被误计,实测本仓 0 例(迁移面 5 处 label 全在同行),已由 ① 的仪器对照钉住。
13+ * ★ 仪器已析出成 `./loadingScan`(收口评审 I-3:本文件贴 300 行硬限,加锚必须先析出)——
14+ * 域 / 剥注释 / 行口径 / 五事实一律在那里,本文件只留**断言**。
2015 *
21- * 副作用:只读磁盘(递归遍历 `app/src`),不修改任何文件。边界:只管可见加载文案(不含布尔门控本身、
22- * 不含 `busy` 状态机、不含已声明的 4 处例外);基线随迁移**只降不升**,余量去向见 `loadingBaseline.ts` 。
16+ * 副作用:只读磁盘(`loadingScan` 模块加载时递归遍历 `app/src` 一次 ),不修改任何文件。边界:只管可见
17+ * 加载文案(不含布尔门控本身、 不含 `busy` 状态机、不含已声明的 4 处例外);基线随迁移**只降不升**。
2318 */
24- import { existsSync , readdirSync , readFileSync , statSync } from "node:fs" ;
25- import { dirname , join , relative , sep } from "node:path" ;
19+ import { existsSync } from "node:fs" ;
20+ import { dirname , join } from "node:path" ;
2621import { fileURLToPath } from "node:url" ;
2722import { describe , expect , it } from "vitest" ;
2823import {
24+ FROZEN_LOADING_RESIDUAL_COUNT ,
2925 FROZEN_LOADING_TEXT_BY_FILE ,
3026 FROZEN_LOADING_TEXT_TOTAL ,
27+ FROZEN_MIGRATED_FILES_COUNT ,
3128 MIGRATED_FILES ,
3229 RESIDUAL ,
3330 type ResidualKind ,
3431} from "./loadingBaseline" ;
32+ import { HITTING , RE_LOADING_TEXT , SCAN , TOTAL , isHandwritten , isIcons , isPrim , isTest , stripComments } from "./loadingScan" ;
3533
3634const HERE = dirname ( fileURLToPath ( import . meta. url ) ) ;
3735/** `app/src` —— 基线的键就是相对这个目录的正斜杠路径。 */
3836const SRC = join ( HERE , ".." , ".." ) ;
39-
40- /** 可见加载文案(与 T1 的 loading 类同源;`g` 只用于统计,逐行判断用 `test`)。 */
41- const RE_LOADING_TEXT = / 加 载 [ ^ " ' ` \n ] { 0 , 8 } ( 中 | … ) / ;
42- /** 原语 `label` 载体:`label="…"` / `label={'…'}` / `label={…}`(该行不计入手写占位)。 */
43- const RE_PRIMITIVE_LABEL = / \b l a b e l \s * = (?: \{ | [ " ' ` ] ) / ;
44- /** 原语用法:`<Loading …` / `<Skeleton …`(`</Loading>` 不算、深导入不算 —— 只认标签)。 */
45- const RE_PRIMITIVE_USE = / < (?: L o a d i n g | S k e l e t o n ) [ \s / > ] / ;
46- const RE_SKELETON_USE = / < S k e l e t o n [ \s / > ] / ;
47- const RE_LOADING_USE = / < L o a d i n g [ \s / > ] / ;
48- /** `busy` 承载:`busy={loading}` / `busy` 简写。 */
49- const RE_BUSY = / \b b u s y (?: = \{ | [ > \s ] ) / ;
5037const KINDS : readonly ResidualKind [ ] = [ "exception" , "button-busy" , "backlog" ] ;
51-
52- /**
53- * 剥注释:**与 `tmp/scan-callsites.mjs` 的 `stripComments()` 同一状态机**(重写一份而非 import ——
54- * 那个文件在 gitignored 的 `tmp/` 下,测试不能依赖它存在)。抹等长空白保行号,字面量只跳过不抹内容。
55- */
56- function stripComments ( src : string ) : string {
57- const out = src . split ( "" ) ;
58- const blank = ( a : number , b : number ) : void => {
59- for ( let i = a ; i < b ; i ++ ) if ( out [ i ] !== "\n" ) out [ i ] = " " ;
60- } ;
61- const isRegexStart = ( k : number ) : boolean => {
62- let j = k - 1 ;
63- while ( j >= 0 && / \s / . test ( src . charAt ( j ) ) ) j -- ;
64- return j < 0 || "(,=:[!&|?{};+-*%~^<>" . includes ( src . charAt ( j ) ) ;
65- } ;
66- const n = src . length ;
67- let i = 0 ;
68- while ( i < n ) {
69- const c = src . charAt ( i ) ;
70- if ( c === "/" && src . charAt ( i + 1 ) === "/" ) {
71- const end = src . indexOf ( "\n" , i ) ;
72- blank ( i , end < 0 ? n : end ) ;
73- i = end < 0 ? n : end ;
74- continue ;
75- }
76- if ( c === "/" && src . charAt ( i + 1 ) === "*" ) {
77- const end = src . indexOf ( "*/" , i + 2 ) ;
78- blank ( i , end < 0 ? n : end + 2 ) ;
79- i = end < 0 ? n : end + 2 ;
80- continue ;
81- }
82- if ( c === '"' || c === "'" ) {
83- let j = i + 1 ;
84- while ( j < n && src . charAt ( j ) !== c ) {
85- if ( src . charAt ( j ) === "\\" ) j ++ ;
86- if ( src . charAt ( j ) === "\n" ) break ;
87- j ++ ;
88- }
89- i = j + 1 ;
90- continue ;
91- }
92- if ( c === "`" ) {
93- let j = i + 1 ;
94- while ( j < n && src . charAt ( j ) !== "`" ) {
95- if ( src . charAt ( j ) === "\\" ) j ++ ;
96- j ++ ;
97- }
98- i = j + 1 ;
99- continue ;
100- }
101- if ( c === "/" && isRegexStart ( i ) ) {
102- let j = i + 1 ;
103- let cls = false ;
104- let ok = false ;
105- while ( j < n ) {
106- const d = src . charAt ( j ) ;
107- if ( d === "\\" ) { j += 2 ; continue ; }
108- if ( d === "\n" ) break ;
109- if ( d === "[" ) cls = true ;
110- else if ( d === "]" ) cls = false ;
111- else if ( d === "/" && ! cls ) { ok = true ; break ; }
112- j ++ ;
113- }
114- if ( ok ) { blank ( i + 1 , j ) ; i = j + 1 ; continue ; }
115- }
116- i ++ ;
117- }
118- return out . join ( "" ) ;
119- }
120-
121- function walk ( dir : string , out : string [ ] = [ ] ) : string [ ] {
122- for ( const name of readdirSync ( dir ) ) {
123- const p = join ( dir , name ) ;
124- if ( statSync ( p ) . isDirectory ( ) ) walk ( p , out ) ;
125- else if ( / \. ( t s | t s x ) $ / . test ( name ) ) out . push ( p ) ;
126- }
127- return out ;
128- }
129-
130- const isTest = ( f : string ) : boolean => / \. t e s t \. ( t s | t s x ) $ / . test ( f ) ;
131- const isPrim = ( f : string ) : boolean => f . includes ( `${ sep } ui${ sep } primitives${ sep } ` ) ;
132- const isIcons = ( f : string ) : boolean => f . includes ( `${ sep } ui${ sep } icons${ sep } ` ) ;
133-
134- interface FileFacts {
135- /** 剥注释后文本里可见加载文案的**行数** */
136- hits : number ;
137- hasPrimitive : boolean ;
138- hasSkeleton : boolean ;
139- hasLoading : boolean ;
140- hasBusy : boolean ;
141- }
142-
143- /** 单行的"手写占位"判定(仪器自证与扫描共用同一实现,防"两套口径") */
144- function isHandwritten ( line : string ) : boolean {
145- return RE_LOADING_TEXT . test ( line ) && ! RE_PRIMITIVE_LABEL . test ( line ) ;
146- }
147-
148- /** 扫单个文件的五个事实(供扫描与仪器自证共用) */
149- function factsOf ( abs : string ) : FileFacts {
150- const stripped = stripComments ( readFileSync ( abs , "utf8" ) ) ;
151- const lines = stripped . split ( "\n" ) ;
152- return {
153- hits : lines . filter ( isHandwritten ) . length ,
154- hasPrimitive : RE_PRIMITIVE_USE . test ( stripped ) ,
155- hasSkeleton : RE_SKELETON_USE . test ( stripped ) ,
156- hasLoading : RE_LOADING_USE . test ( stripped ) ,
157- hasBusy : RE_BUSY . test ( stripped ) ,
158- } ;
159- }
160-
161- function scan ( ) : Map < string , FileFacts > {
162- const map = new Map < string , FileFacts > ( ) ;
163- for ( const f of walk ( SRC ) ) {
164- if ( isTest ( f ) || isPrim ( f ) || isIcons ( f ) ) continue ;
165- map . set ( relative ( SRC , f ) . split ( sep ) . join ( "/" ) , factsOf ( f ) ) ;
166- }
167- return map ;
168- }
169-
170- const SCAN = scan ( ) ;
171- const HITTING = [ ...SCAN . entries ( ) ] . filter ( ( [ , v ] ) => v . hits > 0 ) ;
172- const TOTAL = HITTING . reduce ( ( n , [ , v ] ) => n + v . hits , 0 ) ;
17338const DECLARED = new Set ( RESIDUAL . map ( ( r ) => r . file ) ) ;
17439const FROZEN_HOT = new Set ( Object . entries ( FROZEN_LOADING_TEXT_BY_FILE ) . filter ( ( [ , n ] ) => n > 0 ) . map ( ( [ k ] ) => k ) ) ;
17540
@@ -181,13 +46,21 @@ describe("加载态棘轮(B11):基线随迁移只降不升(冻结 18 →
18146 // 阴性①:文案由**原语的 label** 承载 ⇒ 不算手写占位(迁移的终点形态)
18247 expect ( isHandwritten ( 'const a = <Loading label="加载中…" />;' ) , "原语 label 被误算成手写占位" ) . toBe ( false ) ;
18348 expect ( isHandwritten ( 'const a = <Loading label={status || "加载预览中…"} />;' ) , "表达式 label 被误算" ) . toBe ( false ) ;
49+ // 阴性①b(**判定收紧的夹具**,收口评审 I-2):`aria-label=` 不是原语的 `label` ⇒ 仍算手写占位
50+ // (`\b` 在 `-` 后成立,原写法会把它当载体 ⇒ 一条 `aria-label` 就能让棘轮对手写文案失明)
51+ expect ( isHandwritten ( 'const a = <div aria-label="加载中…">加载中…</div>;' ) , "`aria-label=` 被误当成原语 label" ) . toBe ( true ) ;
18452 // 阴性②:不是加载态的"加载"不算(错误文案 / 加载完成 / 超窗)
18553 expect ( isHandwritten ( "setErr(`加载失败: ${e}`)" ) , "错误文案被误算成加载态" ) . toBe ( false ) ;
18654 expect ( isHandwritten ( "const done = 已加载;" ) ) . toBe ( false ) ;
18755 expect ( isHandwritten ( "加载一二三四五六七八九十中" ) , "超窗文案被误算" ) . toBe ( false ) ;
18856 // 剥注释:注释里的加载文案不算命中(仓内多处注释含"加载中")
18957 expect ( isHandwritten ( stripComments ( "// 加载中…\n" ) ) , "行注释里的文案被当成了代码" ) . toBe ( false ) ;
19058 expect ( isHandwritten ( stripComments ( "/* 加载中… */\n" ) ) , "块注释里的文案被当成了代码" ) . toBe ( false ) ;
59+ // ★ 剥注释器**判定收紧**的回归夹具(收口评审 I-A1):闭合标签 `</span>` 的 `/` 曾被 `regexStart`
60+ // 判成正则起点 ⇒ 夹在两个闭合标签之间的内容被抹掉(本文件原来逐字重写的那份状态机同样中招)。
61+ // 正侧:闭合标签**之后**的加载文案必须还在;反侧:正则体**含**该文案时必须被抹掉(只剩第二行)。
62+ expect ( RE_LOADING_TEXT . test ( stripComments ( '<span>a</span>加载中…<i /></span>\n' ) ) , "闭合标签后面的加载文案被剥注释器抹掉了" ) . toBe ( true ) ;
63+ expect ( RE_LOADING_TEXT . test ( stripComments ( 'const r = x < /加载中…/.test(y);\nconst s = "ok";\n' ) ) , "真正的正则体没有跳过(`<` 后带空白)" ) . toBe ( false ) ;
19164 // 域过滤双侧自证:包含域样本(**余量文件** —— 不在任何并行单元的切片里,读数稳定)+
19265 // 排除域样本(原语/测试文件确实不在扫描面里,且该样本文件真实存在 ⇒ 自证不空转)
19366 expect ( HITTING . length , "扫描面里一个命中都没有 ⇒ 下面所有计数判据都会空真" ) . toBeGreaterThan ( 0 ) ;
@@ -229,6 +102,15 @@ describe("加载态棘轮(B11):基线随迁移只降不升(冻结 18 →
229102 for ( const r of RESIDUAL ) expected [ r . file ] = 1 ;
230103 expect ( FROZEN_LOADING_TEXT_BY_FILE , "冻结表 ≠ 迁移面(0) ∪ 残留面(1):多余键 / 漏键 / 某格上限被手改" ) . toEqual ( expected ) ;
231104 expect ( FROZEN_LOADING_TEXT_TOTAL , "冻结总数 ≠ 残留条目数(残留面每条恰 1 处 ⇒ 两者必须相等)" ) . toBe ( RESIDUAL . length ) ;
105+ // ★ **迁移面只许增 / 残留面只许减**(收口评审 I-3):上面那条「整表钉死」只挡单点篡改 ——
106+ // 把文件从迁移面**挪进**残留面时 `expected` 会跟着变(实测 `m2b` 全绿)⇒ 要靠这两条独立锚。
107+ expect ( MIGRATED_FILES . length , "迁移面被缩小(有人把文件挪进残留面来放行新写的加载文案)" )
108+ . toBeGreaterThanOrEqual ( FROZEN_MIGRATED_FILES_COUNT ) ;
109+ expect ( RESIDUAL . length , "残留条目变多(自助豁免:新写文案 + 自登记一条 backlog 即可通过)" )
110+ . toBeLessThanOrEqual ( FROZEN_LOADING_RESIDUAL_COUNT ) ;
111+ // 锚不许是僵尸:两条锚各自与实测贴住的方向自证(迁移面非空 ∧ 残留面非空 ⇒ 上面两条不空真)
112+ expect ( FROZEN_MIGRATED_FILES_COUNT , "迁移面锚 ≤ 0 ⇒ 上一条空真" ) . toBeGreaterThan ( 0 ) ;
113+ expect ( FROZEN_LOADING_RESIDUAL_COUNT , "残留面锚 ≤ 0 ⇒ 残留面被清空(或锚失效)" ) . toBeGreaterThan ( 0 ) ;
232114 } ) ;
233115
234116 it ( "④ 残留声明自洽:每条带合法分类与非空理由,且声明键集 == 冻结表里值 > 0 的键集" , ( ) => {
0 commit comments