已开启
Optimize ArkTS linter incremental recheck set to eliminate pseudo-incremental builds #874
Optimize ArkTS linter incremental recheck set to eliminate pseudo-incremental builds #874
已开启
zmw1创建于 12 天前
3 个文件变更+625-33
@@ -0,0 +1,539 @@
1+# ArkTS Linter 伪增量优化方案设计
2+ 
3+| 项 | 内容 |
4+|---|---|
5+| 主题 | 消除 ArkTS Linter 增量构建中的"伪增量"(大范围重查)问题 |
6+| 涉及仓库 | `third_party/typescript`(本仓,核心改动)、`developtools/ace_ets2bundle`(分析工具)、hvigor-ohos-plugin(配置开关,不改码) |
7+| 改动文件 | `src/linter/ArkTSLinter_1_0/LinterRunner.ts``src/linter/ArkTSLinter_1_1/LinterRunner.ts` |
8+| 状态 | 已实现,待评审与测试验证 |
9+ 
10+---
11+ 
12+## 1. 背景
13+ 
14+### 1.1 问题现象
15+ 
16+对某大型工程(单模块根文件 9,344 个,Program 总文件约 43,000)单日构建数据(446 次构建)的分析:
17+ 
18+| 指标 | 数值 |
19+|---|---|
20+| 全量构建 | 142 次 |
21+| 增量构建 | 304 次 |
22+| 伪增量(linter > 30s 的增量) | **87 次(占增量 29%)** |
23+| 伪增量 linter 耗时 | 中位 **218s**,P90 283s,最大 401s |
24+| 真增量 linter 耗时 | 中位 2.7s,P90 8.9s |
25+| 伪增量重查文件数 | 中位 **11,545 / 43,246**,P90 16,790 |
26+| 伪增量消耗的 linter 时间 | **占全部增量 linter 时间的 95%**(16,872s / 17,809s) |
27+| 伪增量消耗的构建时间 | 占全部增量构建时间的 45% |
28+| 理论可挽回 | **约 4.6 小时/天** 机器时间 |
29+ 
30+这些"增量"实际重查了 27%~45% 的文件,接近全量的代价,因此称为伪增量。
31+ 
32+### 1.2 数据特征与排查过程
33+ 
34+重查文件数高度聚簇在固定值:**~11,550(出现 38+ 次)、~19,015、~16,800 等**——即"同一批底层文件的固定依赖锥被反复重查"。87 次中仅 1 次重查数≈总数(真全量失效),71 次落在总量的 25%~50%(部分级联)。
35+ 
36+以下因素经数据比对**排除**(伪增量组与真增量组无统计差异):
37+ 
38+- `ohpm install` 耗时(依赖重装)、构建间隔、距上次全量的位置;
39+- `eventUpdateRootFileNumber``structureIsReused`(程序结构均正常复用);
40+- 小模块同批构建 linter 重查数全为 0(改动集中在大模块内部);
41+- 时间分布集中于工作时段(14–18 点占 51/87),符合开发者修改代码的模式。
42+ 
43+结论:触发因素是**修改了被广泛依赖的文件**,而非环境或调度。
44+ 
45+### 1.3 根因分析(详细)
46+ 
47+#### 1.3.1 前置:一次增量 lint 中到底发生了什么
48+ 
49+`runArkTSLinter`(1.0 与 1.1 结构相同)每次运行分两个阶段:
50+ 
51+1. **类型诊断阶段** `doAllGetDiagnostics()`:对 builder program 调用无参 `getSemanticDiagnostics()`。这并不是"检查所有文件",而是驱动 TS 增量 builder 的 **affected 迭代**`builder.ts:getNextAffectedFile`)——只重算上一次构建以来受影响的文件的语义诊断;
52+2. **lint 阶段** `lintLoop`:遍历 program 全部源文件,**在重查集合内的文件重新 lint;集合外的文件直接复用上次缓存的诊断**`state.arktsLinterDiagnosticsPerFile`,随 `.tsbuildinfo.linter` 持久化)。
53+ 
54+因此 **lint 耗时 ∝ 重查集合大小**,而该集合由 `collectChangedFilesFromProgramState` 在两个 linter 版本中以不同方式计算——这正是问题所在。集合的失效路径分三层:
55+ 
56+| 层级 | 触发条件 | 重查范围 | 数据占比 |
57+|---|---|---|---|
58+| L1 全量 | `arkTSVersion`/`compatibleSdkVersion(Stage)` 变化 | 全部文件 | 极少 |
59+| L2 全量 | 任一变更文件 `affectsGlobalScope` | 全部文件 | 87 次中约 1 次 |
60+| L3 依赖传播 | 变更文件的依赖者集合 | **部分级联** | **71 次,主体** |
61+ 
62+重查集合的决策路径与两版本 L3 实现差异:
63+ 
64+```mermaid
65+flowchart TD
66+ A["collectChangedFilesFromProgramState 计算重查集合"] --> B{"L1 - arkTSVersion / compatibleSdkVersion 变化?"}
67+ B -->|是| F1["返回全部文件 - 全量 lint"]
68+ B -->|否| C{"L2 - 变更文件中存在 affectsGlobalScope?"}
69+ C -->|是| F1
70+ C -->|否| D{"存在 referencedMap?"}
71+ D -->|"否 - module=None"| F2["返回原始变更集"]
72+ D -->|是| E["L3 - 依赖传播"]
73+ E --> E1["ArkTS 1.0 - 无门控整锥 BFS"]
74+ E --> E2["ArkTS 1.1 - 双 checker 重查集合并集"]
75+ F1 --> G["lintLoop - 集合内重新 lint<br/>集合外复用缓存诊断"]
76+ F2 --> G
77+ E1 --> G
78+ E2 --> G
79+ style F1 fill:#ffcccc
80+ style E1 fill:#ffcccc
81+```
82+ 
83+#### 1.3.2 ArkTS 1.0 根因:无签名门控的整锥 BFS
84+ 
85+修复前 `src/linter/ArkTSLinter_1_0/LinterRunner.ts:141-152`
86+ 
87+```ts
88+const seenPaths = new Set<Path>();
89+const queue = arrayFrom(changedFiles.keys()); // 直接变更的文件
90+while (queue.length) {
91+ const path = queue.pop()!;
92+ if (!seenPaths.has(path)) {
93+ seenPaths.add(path);
94+ queue.push(...BuilderState.getReferencedByPaths(state, path)); // 谁引用我 → 全部入队
95+ }
96+}
97+return seenPaths; // ← 整个反向依赖锥 = 重 lint 集合
98+```
99+ 
100+整锥 BFS 的扩散过程(修改内容不影响锥的大小):
101+ 
102+```mermaid
103+flowchart TD
104+ A["编辑 hub.ets - 哪怕只加一个空格"] --> B["changedFilesSet = hub.ets"]
105+ B --> C["BFS 沿 referencedMap 反向遍历<br/>全程无签名比较"]
106+ C --> D1["直接引用者"]
107+ C --> D2["二级引用者"]
108+ C --> D3["更外层引用者"]
109+ D1 --> E["整个传递依赖锥 ~11550 文件"]
110+ D2 --> E
111+ D3 --> E
112+ E --> F["全部重新 lint - 中位 218s<br/>锥大小纯由工程拓扑决定"]
113+ style F fill:#ffcccc
114+```
115+ 
116+逐步推演(设 `hub.ets` 被 1.15 万个文件传递依赖):
117+ 
118+1. 开发者编辑 `hub.ets`(哪怕只加一个空格),内容哈希变化 → `hub.ets` 进入 `changedFilesSet`
119+2. BFS 从 `hub.ets` 出发沿 `referencedMap`(文件 → 引用者)反向遍历:直接引用者、引用者的引用者……直至遍历完整个传递依赖锥,共 1.15 万个文件;
120+3. **全程没有任何"这次修改是否改变了公开 API"的判断**——锥的大小纯由工程拓扑决定,与修改内容无关;
121+4. lintLoop 对这 1.15 万个文件全部重新 lint(中位耗时约 218s),其余文件走缓存。
122+ 
123+数据完全印证:重查数聚簇在固定档位(~11,550 出现 38+ 次、~19,015、~16,800),正是几个中心文件各自的依赖锥大小;每次编辑同一批中心文件,就重查同样的锥。反之,编辑叶子文件(依赖者极少)时锥只有几个文件——这就是真增量组重查数中位数为 3 的来源。聚簇值全天只有微小漂移(11532→11584,来自工程文件增删),与"拓扑决定、内容无关"的推论一致。
124+ 
125+#### 1.3.3 ArkTS 1.1 根因:门控机制本身正常,但叠加了三个保守来源
126+ 
127+1.1 的重查集合 = `program.getTypeChecker().getCheckedSourceFiles() ∪ program.getLinterTypeChecker().getCheckedSourceFiles()`,即**两个类型检查器在本次构建中实际重查的文件**`checker.ts:45539` 在逐文件算完语义诊断后把文件加入 `checkedSourceFiles`)。类型检查的重查由 builder 的 affected 迭代驱动,其门控逻辑(这套门控也是 1.0 修复对齐的目标):
128+ 
129+- `builderState.ts:getFilesAffectedByWithOldState` 对每个变更文件调 `updateShapeSignature`**用声明产物(d.ts 文本 + 声明 emit 诊断,`builder.ts:1369 computeSignatureWithDiagnostics`)的哈希作为"形状签名"**
130+- 形状签名未变 → affected 仅含该文件自身,扩散停止;
131+- 形状签名变了 → `getFilesAffectedByUpdatedShapeWhenModuleEmit``builderState.ts:628`)沿 `referencedMap` 反向 BFS,但**依赖者只有自身的形状签名也变化时才继续向更外层扩散**
132+ 
133+即"空白/注释/实现体修改不改公开 API → 依赖者不重查"在 1.1 的这条主路径上是成立的。问题出在以下三个保守来源:
134+ 
135+**来源一:两套签名方案混用,导致门控频繁误判"形状变了"**(主害)
136+ 
137+`updateShapeSignature``builderState.ts:413`)的签名有两种来源,由第 7 个参数 `useFileVersionAsSignature` 决定:
138+ 
139+- `false` → 走 d.ts emit,签名 = 声明产物哈希(**精确**但需 emit,较贵);
140+- `true` → 跳过 emit,签名 = `sourceFile.version`(文件内容哈希,**便宜**但与 d.ts 哈希不可比)。
141+ 
142+两条调用路径传参不一致:
143+ 
144+| 调用路径 | 传参 | 效果(`useDeclarationFileSignature` 未开启时) |
145+|---|---|---|
146+| affected 迭代主路径 `getFilesAffectedBy*` | 不传,用 state 默认值;增量时 state 默认 = false | 用 **d.ts 哈希** |
147+| 再导出/依赖处理 `handleDtsMayChangeOf``builder.ts:685`) | 显式传 `!host.disableUseFileVersionAsSignature` = **true** | 用**文件版本号** |
148+ 
149+混用的后果逐步推演:
150+ 
151+1. **全量构建**(无旧状态)时 state 默认 `useFileVersionAsSignature=true` → 全部 4.3 万文件的签名 = 各自的版本号(省去 emit);
152+2. 下一次**增量**:BFS 对变更文件 A 计算 **d.ts 哈希**,与存储的**版本号**比较——两种字符串空间,**必然不相等** → 误判"A 形状变了" → 扩散到 A 的全部直接引用者;
153+3. 对每个直接引用者 B 同样算 d.ts 哈希 vs 版本号 → **又必然不等** → B 的依赖者也入队 → **整锥洪水**
154+4. 洪水期间这些文件的签名被改写为 d.ts 哈希,本可就此收敛——但同一构建内 `handleDtsMayChangeOf` 又把再导出图上的部分文件签名**写回版本号**,下一轮增量再次误判;
155+5. 本工程一天 142 次全量构建,每次全量都把签名集体重置回版本号 → 洪水全天反复发生。
156+ 
157+签名混用的自增强循环(这是 1.1 洪水反复发生、而非一次性的原因):
158+ 
159+```mermaid
160+flowchart TD
161+ FULL["全量构建 - 本工程 142 次/天<br/>无旧状态 - 签名 = 文件版本号"] --> S["全部 43000 文件签名 = 内容版本号"]
162+ S --> INC["下一次增量 - BFS 对变更文件 A 计算 d.ts 哈希"]
163+ INC --> CMP{"d.ts 哈希 vs 存储的版本号<br/>两种字符串空间不可比"}
164+ CMP -->|必然不相等| F1["误判 A 形状变化<br/>扩散到全部直接引用者"]
165+ F1 --> CMP2{"对每个引用者 B<br/>d.ts 哈希 vs 版本号"}
166+ CMP2 -->|必然不相等| F2["整锥洪水 ~11550 文件重查"]
167+ F2 --> W["洪水期间签名改写为 d.ts 哈希 - 本可就此收敛"]
168+ W --> X["handleDtsMayChangeOf 又将再导出图上<br/>部分文件签名写回版本号"]
169+ X --> N["下一轮增量再次误判"]
170+ N -->|下一次全量构建| FULL
171+ style F2 fill:#ffcccc
172+```
173+ 
174+该行为继承自原版 TypeScript(已与 typescript@5.5.4 对照:vanilla 在 `handleDtsMayChangeOf` 中同样硬编码传 `true`),并非 OH 引入的退化。但 vanilla 的典型场景(tsc -b、IDE watch)全量重建频率低,矛盾不显性;OH 的 daemon + 高频全量 CI 场景把它放大了。**此来源可通过配置开关 `useDeclarationFileSignature` 统一为 d.ts 签名来消除(方案 L3),代价是全量构建要为每个源文件计算 d.ts 签名。**
175+ 
176+**来源二:再导出级联完全无门控**
177+ 
178+`handleDtsMayChangeOfReferencingExportOfAffectedFile``builder.ts:766`)处理"变更文件的导出被其他模块再导出"的场景(典型:业务模块的 `Index.ets` 聚合导出)。入口有门控(变更文件 ∈ changedFilesSet 且签名确实变了),但内部 `handleDtsMayChangeOfFileAndExportsOfFile` 对再导出图上的**所有引用文件递归执行 `removeSemanticDiagnosticsOf`——直接丢弃缓存诊断、强制重查,不做任何签名比较**。类型正确性上这是保守但安全的选择(再导出面形状变化难以局部判定);lint 侧则意味着:改动任何一个被 `Index.ets` 再导出的文件,所有 `import ... from '该模块'` 的文件都会重 lint。此项同样是原版 TS 行为,本方案不动(见 2.6 决策表),残余量由 L2 日志归档观测。
179+ 
180+**来源三:双 checker 并集**
181+ 
182+1.1 同时迭代非严格 program 与 `builderProgramForLinter`(严格检查),两个 program 的 affected 迭代相互独立,lint 重查集合取**并集**——任一 checker 重查过的文件都会重 lint。该设计保证了 strict 模式诊断的完整性,代价是集合只增不减。
183+ 
184+#### 1.3.4 `affectsGlobalScope` 的宽定义(L2 层的隐蔽性)
185+ 
186+`builderState.ts:581 isFileAffectingGlobalScope` 判定:含全局作用域增强,**或不是模块(没有 import/export 的脚本文件)**,或非纯 ambient module 的声明文件。换言之,修改任何一个"没有导入导出语句的 .ets/.ts/.d.ts"都会触发 linter 直接全量重查。数据中 87 次里仅 1 次重查数≈总数,说明本工程几乎不出现此类编辑,非主因——但插桩保留了该路径的独立 `reason` 以便观测。
187+ 
188+#### 1.3.5 附带发现:`changedFilesSet` 被 drain 导致全量检查静默失效(1.1 隐患)
189+ 
190+affected 迭代每处理完一个变更文件的 affected 批次,就把它从 `changedFilesSet` 中删除(`builder.ts:558`)。诊断迭代结束后该集合基本为空。而修复前 1.1 的 `collectChangedFilesFromProgramState``doAllGetDiagnostics()` **之后**才执行 `new Set(state.changedFilesSet)` 并据此做 version / affectsGlobalScope 检查——多数时候读到空集,两个全量分支被静默跳过。之所以从未表现为缺陷,是因为 affectsGlobalScope 变化时类型检查器自身也会全量重查(`getFilesAffectedByUpdatedShapeWhenModuleEmit` 对 global scope 文件返回全部文件),最终重 lint 范围恰好等价。但这是设计意图失效,且使"为什么全量"不可归因,本次一并修复(快照模式)。
191+ 
192+#### 1.3.6 证据链汇总:每条数据观察对应的机制解释
193+ 
194+| 数据观察 | 机制解释 |
195+|---|---|
196+| 重查数聚簇在固定档位(~11,550 × 38+ 次、~19,015、~16,800) | 依赖锥大小由工程拓扑决定:1.0 BFS 纯拓扑无门控;1.1 混用洪水的锥同样 = 拓扑锥。不同档位对应不同中心文件 |
197+| 87 次中仅 1 次重查数≈总数 | L1/L2 全量路径极少触发 |
198+| 与 ohpm install 耗时、构建间隔、距上次全量的位置均无相关 | 触发因素是"编辑了哪个文件"(拓扑位置),不是环境或调度 |
199+| 集中在工作时段(14–18 点占 51/87) | 开发者编辑行为模式 |
200+| 同批构建中小模块(lint_1)重查数恒为 0 | 改动集中在大模块内部,小模块程序无变化 |
201+| 真增量重查数中位 3、P90 342 | 编辑叶子/低依赖文件,锥极小 |
202+| 伪增量中位 218s ≈ 重查 1.15 万文件的 lint 代价;真增量 2.7s | lint 耗时与重查数线性相关(组内相关系数 0.67) |
203+| 伪增量多为孤立出现(55/87 单次,前后都是真增量) | 单次编辑中心文件即触发一次洪水;无持续性环境异常 |
204+ 
205+#### 1.3.7 版本判别:1.0 还是 1.1 主导?
206+ 
207+工程实际使用哪个 linter 版本由项目 `build-profile.json5``arkTSVersion` 决定,无法从 `report_times.csv` 直接判定。两个假设与数据的相容性:
208+ 
209+- **1.0 假设**:洪水规模纯由拓扑决定 → 聚簇值全天恒定、与修改内容无关——与"聚簇值仅微小漂移"观察高度吻合;
210+- **1.1 假设**:混用洪水在首次发生后签名部分收敛,后续洪水规模应呈递减趋势——数据上未观察到明显递减,相容性略弱(弱证据,不作定论)。
211+ 
212+判别不需要猜测:修复后插桩输出的 `reason` 字段直接标明来源(`ArkTS_1_0:checkerCascade` / `ArkTS_1_1:checkerCascade` / `affectsGlobalScope:*` / `versionOrSdkDiffers`)。**两个版本的修复均已实施**(1.0 机制对齐 + 1.1 快照修复;1.1 的混用洪水另由 L3 配置开关覆盖),无论实际是哪个版本都在治理范围内。
213+ 
214+### 1.4 目标与非目标
215+ 
216+**目标**
217+ 
218+1. ArkTS 1.0 增量 lint 重查集合收敛到"实际被类型检查重查的文件",与 1.1 机制对齐;
219+2. 修复 1.1 的 changedFilesSet drain 隐患;
220+3. 提供可观测性:大级联发生时输出触发原因与变更根文件清单;
221+4. 不改变 `.tsbuildinfo` 缓存格式,不做强制缓存失效。
222+ 
223+**非目标**
224+ 
225+1. 不修改 builder 的签名机制与再导出级联(类型检查正确性范畴,风险收益比不合适);
226+2. 不默认开启 `useDeclarationFileSignature`(见 2.5,涉及全量劣化权衡,留给配置层决策);
227+3. 不处理业务工程的中心文件结构问题(提供数据支撑,由业务仓治理)。
228+ 
229+---
230+ 
231+## 2. 方案设计
232+ 
233+### 2.1 总体架构:四层方案
234+ 
235+| 层 | 内容 | 位置 | 状态 |
236+|---|---|---|---|
237+| L1 机制修复 | 1.0 重查集合改为 checker 重查集合并集;1.1 快照修复 | 本仓 LinterRunner.ts | **本次实现** |
238+| L2 可观测性 | `[LINT-CASCADE]` 级联诊断日志 + 分析脚本 | 本仓 + ace_ets2bundle | **本次实现** |
239+| L3 配置开关 | `ohos.etslinter.useDeclarationFileSignature=true`(统一签名方案,消除混用洪水) | hvigor-config.json5 / CLI | 现有能力,建议 A/B 后启用 |
240+| L4 工程治理 | 拆分中心文件的再导出面 | 业务仓 | 依赖 L2 数据 |
241+ 
242+四层方案的定位与依赖关系:
243+ 
244+```mermaid
245+flowchart TD
246+ subgraph MR["本次 MR - third_party/typescript"]
247+ L1["L1 机制修复<br/>1.0 对齐 1.1 checker 并集<br/>+ drain 快照修复"]
248+ L2["L2 可观测性<br/>LINT-CASCADE 日志<br/>+ analyze_lint_incremental.py"]
249+ end
250+ subgraph CFG["配置与工程侧 - 不改本仓代码"]
251+ L3["L3 配置开关<br/>ohos.etslinter.useDeclarationFileSignature"]
252+ L4["L4 工程治理<br/>拆分中心文件再导出面"]
253+ end
254+ L1 -.->|"消除 1.0 整锥洪水"| GOAL
255+ L3 -.->|"消除 1.1 签名混用洪水"| GOAL
256+ L2 -->|"级联根文件名单"| L4
257+ L4 -.->|"缩小依赖锥"| GOAL
258+ GOAL["伪增量 87 次/天 大幅下降<br/>增量 linter 中位 218s 趋向 3s<br/>找回约 4.6 小时/天"]
259+ style GOAL fill:#ccffcc
260+```
261+ 
262+### 2.2 L1 详细设计:ArkTS 1.0 重查集合重构
263+ 
264+**修复前数据流**(伪代码):
265+ 
266+```
267+runArkTSLinter:
268+ changedFiles = collect(state, arkTSVersion) # ① 在诊断迭代前收集
269+ state.arkTSVersion = arkTSVersion
270+ doAllGetDiagnostics() # ② 之后 drain changedFilesSet
271+ 
272+collect(state, arkTSVersion):
273+ 版本不同 -> 全部文件
274+ globalScope -> 全部文件
275+ 无 referencedMap -> 原始变更集
276+ 否则: BFS(state.changedFilesSet 沿 referencedMap 反向) # ← 无门控整锥扩散
277+```
278+ 
279+**修复后数据流**
280+ 
281+```
282+runArkTSLinter:
283+ snapshot = new Set(state.changedFilesSet) # ① drain 前快照原始变更集
284+ doAllGetDiagnostics() # ② affected 迭代填充 checkedSourceFiles
285+ changedFiles = collect(state, program, snapshot, arkTSVersion) # ③ 诊断后收集
286+ state.arkTSVersion = arkTSVersion # 写回时机不变(collect 先读后写)
287+ 
288+collect(state, program, snapshot, arkTSVersion):
289+ 版本不同 -> 全部文件 # 全量失效分支原样保留
290+ globalScope(以 snapshot 为准) -> 全部文件
291+ 无 referencedMap -> snapshot
292+ 否则:
293+ targetSet = program.getTypeChecker().getCheckedSourceFiles().paths
294+ ∪ program.getLinterTypeChecker().getCheckedSourceFiles().paths
295+ return targetSet # ← 与 1.1 完全同机制
296+```
297+ 
298+修复后 `runArkTSLinter` 的执行时序:
299+ 
300+```mermaid
301+sequenceDiagram
302+ autonumber
303+ participant R as runArkTSLinter
304+ participant ST as builder state (.tsbuildinfo)
305+ participant DG as doAllGetDiagnostics
306+ participant CK as 类型检查器 x2 (regular + linter)
307+ participant LP as lintLoop
308+ 
309+ R->>ST: 快照 changedFilesSet (drain 前)
310+ R->>DG: 驱动 affected 迭代
311+ DG->>CK: 只重查受影响文件 (形状签名门控)
312+ CK-->>CK: 逐文件填充 checkedSourceFiles
313+ R->>CK: collect = 两 checker 重查集合并集
314+ Note over R,CK: 全量失效分支 (版本/globalScope) 以快照判定<br/>命中则直接返回全部文件
315+ R->>ST: 写回 arkTSVersion (collect 先读后写)
316+ R->>LP: 集合内重新 lint / 集合外复用缓存诊断
317+ R->>ST: emitBuildInfo 持久化
318+```
319+ 
320+关键点说明:
321+ 
322+- **收集时机移到 `doAllGetDiagnostics()` 之后**`checkedSourceFiles` 由 checker 在逐文件计算语义诊断时填充(每次 program 创建时为空集,只含本次实际重查文件),必须先迭代 affected;
323+- **快照在 drain 之前**`getNextAffectedFile` 完成每个变更文件的 affected 批次后会从 `changedFilesSet` 删除该项,诊断迭代后该集合不可靠;version/globalScope 检查必须用快照;
324+- **双 checker 并集**`doAllGetDiagnostics` 同时迭代非严格 program 与 `builderProgramForLinter`(见 `ArkTSLinter_1_0/TSDiagnostics.ts`),两个 checker 的重查集都要并入;
325+- **`getLinterTypeChecker()` 懒创建安全**`program.ts:2421``linterTypeChecker || (linterTypeChecker = createTypeChecker(program, true))`),且 ets_loader 始终以 `getBuilderProgram(true)` 创建带 linter 的 program,不会触发意外构建。
326+ 
327+**收敛效果**:affected 迭代中,变更文件本身总是重查;其直接引用者总是重查(验证依赖形状是否影响自身);更远的传递依赖者仅当中间文件的声明形状签名变化时进入。空白字符、注释、函数体实现等不影响公开 API 的修改不再引发整锥重 lint。
328+ 
329+修复前后同一操作(编辑中心文件)的重查范围对比:
330+ 
331+```mermaid
332+flowchart LR
333+ A1["修复前 - 编辑 hub.ets"] --> B1["整锥 11550 文件重 lint"]
334+ B1 --> C1["中位 218s"]
335+ A2["修复后 - 编辑 hub.ets"] --> B2{"公开 API (d.ts 形状) 变化?"}
336+ B2 -->|"否 - 空格/注释/实现体"| C2["仅变更文件 + 直接引用者"]
337+ B2 -->|是| D2["形状变化链上的受影响依赖者"]
338+ C2 --> E2["约 3s"]
339+ D2 --> E2
340+ style C1 fill:#ffcccc
341+ style E2 fill:#ccffcc
342+```
343+ 
344+### 2.3 L1 详细设计:ArkTS 1.1 快照修复
345+ 
346+与 2.2 相同的快照模式:在 `doAllGetDiagnostics()` 之前 `new Set(state.changedFilesSet)`,作为参数传入 `collectChangedFilesFromProgramState`,替换原先函数内部在 drain 之后读取的 `new Set<Path>(state.changedFilesSet)`。checker 并集逻辑不变。
347+ 
348+行为影响方向:version/affectsGlobalScope 全量失效分支从"大概率被 drain 跳过"变为"稳定生效"。这是**恢复设计意图**(该两分支本就要求全量),不是收紧——类型诊断层面二者结果一致(checker 自身会因 global scope 变化重查全部),差异仅在 lint 诊断缓存的复用范围。
349+ 
350+### 2.4 L2 详细设计:级联诊断日志
351+ 
352+两个 LinterRunner 各内置 `logLinterCascade(reason, state, changedFiles, recheckSet)`,在重查集合 > **1000** 时输出单行 JSON:
353+ 
354+```json
355+{"ts":"...","linter":"ArkTS_1_0","reason":"checkerCascade",
356+ "recheckFiles":11550,"totalFiles":43246,"changedRoots":3,
357+ "changedRootsSample":["/path/to/center.ets","..."],
358+ "affectsGlobalScopeRoots":[]}
359+```
360+ 
361+| 设计项 | 取值 | 依据 |
362+|---|---|---|
363+| 触发阈值 | 1000 | 真增量重查数 P90=342、P98 以内 <1000;伪增量最小 ~1,900。落在两类分布的间隔带 |
364+| reason 枚举 | `versionOrSdkDiffers` / `affectsGlobalScope:<file>` / `checkerCascade` | 对应三层失效路径,直接区分根因 |
365+| 输出方式 | `console.log` 单行,try/catch 包裹 | 与 ArkTSLinterTimePrinter 同通道;日志量 ≤ 87 行/天,无性能影响 |
366+| 采样 | 变更根文件前 30 个 | 定位中心文件足够,避免日志膨胀 |
367+ 
368+配套分析工具 `developtools/ace_ets2bundle/analyze_lint_incremental.py`
369+ 
370+```bash
371+python3 analyze_lint_incremental.py report_times.csv # 单日画像 + 聚簇
372+python3 analyze_lint_incremental.py new.csv --baseline old.csv # A/B 净效果
373+python3 analyze_lint_incremental.py --cascade-log build.log # 级联根文件排名
374+```
375+ 
376+### 2.5 L3 说明:配置开关(不改码,建议 A/B 后启用)
377+ 
378+`useDeclarationFileSignature` 使 `handleDtsMayChangeOf` 改用 d.ts 签名(与 BFS 方案一致),消除签名混用引发的重复洪水。链路(已逐步验证):
379+ 
380+```
381+<项目>/hvigor/hvigor-config.json5 → properties["ohos.etslinter.useDeclarationFileSignature"]
382+ → hvigor-ohos-plugin abstract-compile-node.js → projectConfig
383+ → ets_loader ets_checker.ts servicesHost → TS builder host disableUseFileVersionAsSignature
384+```
385+ 
386+权衡:开启后全量构建需为每个源文件计算 d.ts 签名(官方注释:全量劣化,首次增量受益)。本工程全量 142 次/天 vs 伪增量损失 4.6h/天,预期净收益为正,需 A/B 实测。**注意:该开关只作用于 1.1 的 checker 级联路径,对 1.0 原整锥 BFS 无效——这也是 L1 机制修复必须先行/同时进行的原因。**
387+ 
388+### 2.6 关键设计决策
389+ 
390+| 决策 | 理由 |
391+|---|---|
392+| 1.0 对齐 1.1 机制,而非自研"带签名门控的 BFS" | 复用已在生产验证的代码路径与语义;自研需在 linter 内调 `updateShapeSignature`(dts emit),复杂度和风险更高 |
393+| 不修改 `handleDtsMayChangeOfReferencingExportOfAffectedFile` | 再导出级联服务类型检查正确性,收敛它可能漏报类型错误;lint 侧受益已由 L1 达成大部分 |
394+| 不修改 builder 签名机制(含版本签名混用) | 属原版 TS 行为,影响面为全部增量编译;已有 L3 配置开关覆盖,按需启用 |
395+| 重查集合不含 scriptKind 过滤 | `lintLoop` 已有 ETS/TS 过滤逻辑,collect 保持纯集合语义 |
396+| 插桩不设环境变量开关 | 日志量有界(≤级联次数/天),常开保证线上可观测;如需关闭可回退补丁 |
397+ 
398+### 2.7 改动清单
399+ 
400+| 仓库 | 文件 | 改动 |
401+|---|---|---|
402+| third_party/typescript | `src/linter/ArkTSLinter_1_0/LinterRunner.ts` | 快照 + 时序重排 + BFS 替换为 checker 并集 + 插桩 |
403+| third_party/typescript | `src/linter/ArkTSLinter_1_1/LinterRunner.ts` | 快照修复 + 插桩 |
404+| developtools/ace_ets2bundle | `analyze_lint_incremental.py`(新增) | 画像/A/B/级联日志分析 |
405+| command-line-tools(运行时产物,非源码仓) | `sdk/.../typescript/lib/typescript.js` | 与上述等价的补丁(验证用),备份 `.lint-cascade.bak` |
406+ 
407+---
408+ 
409+## 3. 兼容性分析
410+ 
411+### 3.1 lint 结果语义兼容性
412+ 
413+**唯一的语义变化**:1.0 增量构建中,依赖者在其依赖的 d.ts 形状**未变**时不再重 lint(原为必然重 lint 整锥)。
414+ 
415+正确性论证:
416+ 
417+1. lint 规则通过类型检查器消费依赖的**公开 API**;形状签名(d.ts 文本 + 声明产物诊断哈希)稳定 ⟹ 公开 API 不变 ⟹ 依赖者的 lint 结果不变;
418+2. 类型检查器自身遵循同一门控(依赖形状未变时不重查依赖者的类型),lint 与类型检查的覆盖面保持一致——若存在"类型不查但 lint 必须查"的规则,1.1 早已暴露该问题,实际未发生;
419+3. 全量构建行为完全不变(首建无旧状态 → 全部文件进入 changedFilesSet 与 checkedSourceFiles)。
420+ 
421+已知理论风险:若未来新增"读取依赖实现细节(非公开 API)"的 lint 规则,需同步评估本门控。在规则清单现状下无此规则。
422+ 
423+### 3.2 缓存(.tsbuildinfo)兼容性
424+ 
425+- 未新增/删除/修改任何 builder state 字段,`.tsbuildinfo``.tsbuildinfo.linter` 的 schema 不变;
426+- `arktsLinterDiagnosticsPerFile` 的读写语义不变:重查文件写新诊断,未重查文件从旧缓存拷贝;
427+- `state.arkTSVersion` 写回时机不变(collect 读取后赋值),**升级后旧缓存可直接续用,无需 clean**;降级回退同样无缓存迁移。
428+ 
429+### 3.3 接口兼容性
430+ 
431+| 接口 | 影响 |
432+|---|---|
433+| `runArkTSLinter(builderProgram, srcFile?, buildInfoWriteFile?, arkTSVersion?)` | 签名不变,ets_loader/do_arkTS_linter.ts 调用方零改动 |
434+| `collectChangedFilesFromProgramState`(模块内私有函数) | 参数变化,仅本文件调用,无外部引用 |
435+| `Program.getLinterTypeChecker()` / `TypeChecker.getCheckedSourceFiles()` | 只读调用,无修改;均为现有导出 API(1.1 生产在用) |
436+ 
437+### 3.4 场景兼容性矩阵
438+ 
439+| 场景 | 修复前行为 | 修复后行为 | 兼容结论 |
440+|---|---|---|---|
441+| 首次构建 / 无旧状态 | 全量 lint | 全量 lint(checker 全量重查) | 一致 |
442+| 增量 + 修改不影响 API | 整锥重查(1.0)/ checker 级联(1.1) | 仅变更文件 + 直接引用者 + 形状变化链 | **预期优化点** |
443+| 增量 + 修改导出 API | 整锥重查 | 变更文件 + 形状变化传播链(含全部受影响依赖者) | 结果一致,范围收敛 |
444+| affectsGlobalScope 文件变更 | 全量(1.0 稳定触发;1.1 可能被 drain 跳过) | 稳定全量(两版本) | 1.1 恢复设计意图 |
445+| 版本/compatibleSdkVersion 变化 | 全量 | 全量 | 一致 |
446+| 无 referencedMap(module=None) | 返回原始变更集 | 返回快照(等价) | 一致 |
447+| `srcFile` 单文件 lint 模式 | changedFiles 驱动过滤 | 不变(srcFiles 路径独立于 collect) | 一致 |
448+| 文件新增/删除 | 进入 changedFilesSet → 级联 | checker 同样纳入重查;删除文件自然退出 fileInfos | 一致 |
449+| `strictCheckerOnly` / `skipOhModulesLint` / oh_modules 跳过 | lintLoop 内过滤 | 不变(collect 不做过滤) | 一致 |
450+| COMPATIBLE_MODE(告警模式) | 诊断降级为告警 | 不变(do_arkTS_linter 层逻辑) | 一致 |
451+| daemon 多轮增量 | 依赖内存态 state | 同左(不跨进程引入新依赖) | 一致 |
452+ 
453+### 3.5 工具链与日志兼容
454+ 
455+- `[LINT-CASCADE]` 为新增 stdout 单行 JSON,不解析构建日志的外部工具不受影响;hvigor 日志采集按行收纳,无格式冲突;
456+- `report_times.csv` 各列(lint_1/lint_2_checkedFilesNum 等)来源未动,历史数据可与修复后数据直接 A/B。
457+ 
458+### 3.6 风险与回滚
459+ 
460+| 风险 | 等级 | 缓解 |
461+|---|---|---|
462+| 1.0 lint 覆盖面收窄引发漏报争议 | 中 | §3.1 论证 + §4.3 诊断等价性专项测试兜底;与 1.1 生产语义一致 |
463+| 时序重排引入的顺序依赖(collect 依赖诊断迭代完成) | 低 | checkedSourceFiles 为 checker 实例字段,与 1.1 相同生命周期;已验证双分支执行 |
464+| getLinterTypeChecker 意外实例化第二个 checker | 低 | 懒创建一次性成本;ets_loader 流程中本就存在 linter program |
465+| 插桩日志在某些环境不可写 console | 低 | try/catch 吞掉,不影响构建 |
466+ 
467+**回滚**:源码仓 `git checkout src/linter/ArkTSLinter_1_0/LinterRunner.ts src/linter/ArkTSLinter_1_1/LinterRunner.ts`;打包版恢复 `typescript.js.lint-cascade.bak`。无缓存残留,回滚即生效。**灰度建议**:先在单条 CI 作业启用一个构建日,用 §4.4 验收指标确认后再放量。
468+ 
469+---
470+ 
471+## 4. 测试设计
472+ 
473+### 4.1 测试分层
474+ 
475+| 层 | 内容 | 环境 |
476+|---|---|---|
477+| 功能用例(4.2) | 机制正确性:重查集合边界 | fork 仓单测/集成测试(`hereby runtests-parallel` + linter 测试目录) |
478+| 诊断等价性(4.3) | lint 输出无回归:黄金集对比 | 真实工程快照 |
479+| 性能与验收(4.4) | 伪增量收敛 + 无全量劣化 | 大型工程 CI,一天的构建量 |
480+| 已完成的验证(4.5) | 本环境冒烟 | command-line-tools 打包版 |
481+ 
482+### 4.2 功能用例矩阵
483+ 
484+工程骨架:`c.ets``b.ets``a.ets`(链式依赖),另备 `global.ets`(无 import/export 脚本)、`index.ets`(再导出聚合)。
485+ 
486+| 编号 | 场景 | 步骤 | 预期(重查集合) |
487+|---|---|---|---|
488+| T1 | 首次构建 | 全新构建 | 全部文件(等于旧行为) |
489+| T2 | 非 API 修改 | `a.ets` 加空白/注释/函数体实现 | 仅 `a.ets`(旧 1.0 为整锥;旧 1.1 受签名混用影响常为整锥) |
490+| T3 | 导出 API 类型变更 | `a.ets` 导出从 `number``string` | `a.ets` + `b.ets`(+形状变化链上的 `c.ets` 如其声明受影响)——**不得漏掉受影响依赖者** |
491+| T4 | 新增导出符号 | `a.ets` 新增 export | 变更文件 + 引用者按形状变化判定 |
492+| T5 | affectsGlobalScope | 修改 `global.ets` | 全部文件(两版本稳定触发) |
493+| T6 | 版本失效 | 切换 `arkTSVersion` / compatibleSdkVersion | 全部文件 |
494+| T7 | 文件新增/删除 | 加/删 `d.ets`(被引用) | 新文件及其影响链进入重查;删除文件退出诊断缓存且不残留 |
495+| T8 | srcFile 单文件模式 | `runArkTSLinter(bp, srcFile)` | 仅 lint 指定文件 |
496+| T9 | 无 referencedMap | `module: None` 编译 | 返回原始变更快照 |
497+| T10 | lint 诊断缓存往返 | 增量序列后检查 `arktsLinterDiagnosticsPerFile` | 重查文件=新诊断;未重查文件=旧诊断拷贝,无丢失 |
498+| T11 | tsbuildinfo 跨进程 | 构建进程退出重启后增量 | 增量语义不回退为全量(state 持久化兼容) |
499+| T12 | 编译选项组合 | `strictCheckerOnly``skipOhModulesLint`、COMPATIBLE_MODE | lintLoop 过滤行为不变,collect 不干扰 |
500+| T13 | 1.1 路径全量回归 | 对 1.1 工程重复 T1–T11 | 1.1 仅快照修复生效,checker 并集行为与修复前一致 |
501+| T14 | 插桩 | 触发 T3/T5/T6,采集构建日志 | `[LINT-CASCADE]` 行出现,`reason`/`changedRootsSample` 字段正确;<1000 重查时无输出 |
502+ 
503+### 4.3 诊断等价性专项(lint 输出无回归)
504+ 
505+方法:对同一工程快照,分别用修复前后构建产物执行相同的"全量 → 编辑序列(≥50 步:叶子修改 / API 修改 / 全局文件修改 / 增删文件混合)→ 逐步增量"流程,逐步 diff 全量 lint 诊断输出(文件+规则+位置+消息)。
506+ 
507+判定:
508+ 
509+- **必须通过**:每一步的诊断集合与修复前完全一致(T3 类 API 变更步骤中,修复前后均应报出依赖者上由类型触发的相同诊断);
510+- 若出现差异,差异文件必须落在"依赖形状未变却被跳过"的集合内,并按 §3.1 论证定位是规则缺陷还是门控缺陷。
511+ 
512+### 4.4 性能测试与验收指标
513+ 
514+环境:4.3 万文件级真实工程,连续 24h 常规开发节奏构建(约 450 次),工具 `analyze_lint_incremental.py` 产出对比。
515+ 
516+| 指标 | 基线(修复前单日) | 验收线 |
517+|---|---|---|
518+| 伪增量次数(linter>30s 占增量比例) | 87 / 304(29%) | **≤ 10%** |
519+| 增量 linter 耗时中位 | 伪 218s / 真 2.7s | 整体中位 ≤ 15s |
520+| 重查数聚簇(~11,550 档) | 38+ 次/天 | **0 次**(残余级联按 --cascade-log 根因另行归档) |
521+| 全量构建 linter 耗时 | 中位 177s | 劣化 ≤ 5%(本方案理论上不影响全量,超限即回归) |
522+| 全天 linter 总耗时 | 增量 17,809s + 全量 30,853s | 净节省 ≥ 3h/天 |
523+ 
524+### 4.5 已完成的验证记录(本环境,打包版产物)
525+ 
526+| 项 | 结果 |
527+|---|---|
528+| `node --check` 语法校验 | 通过 |
529+| 模块加载 + 双 linter 导出冒烟 | 通过 |
530+| ArkTS 1.0 linter 端到端执行(新旧两条收集分支各触发一次) | 无异常,诊断数符合预期 |
531+| 上游两文件 tsc 类型检查(过滤至本文件) | 0 错误 |
532+| 已知局限:裸语言服务 harness 无法复现 daemon 级结构复用(`structureIsReused` 恒 0),门控收敛效果依赖 §4.4 真实构建验证 | 待 CI 覆盖 |
533+ 
534+### 4.6 遗留与后续
535+ 
536+1. `useDeclarationFileSignature` 默认值评估(L3):依据 §4.4 A/B 数据决定是否在 hvigor 侧调整默认;
537+2.`--cascade-log` 显示 1.1 残余大级联集中于再导出级联,评估 lint 专用门控(上游 TS fork 的 `handleDtsMayChangeOfReferencingExportOfAffectedFile` 增加 lint 范围内的签名短路);
538+3. 业务仓依据级联根文件名单治理中心文件(L4);
539+4. ArkTS 1.0 退出历史版本后,本方案中 1.0 分支可随版本一并清理。
@@ -13,8 +13,8 @@
13 * limitations under the License.13 * limitations under the License.
14 */14 */
15import {15import {
16- ArkTSLinterTimePrinter, arrayFrom, BuilderProgram, BuilderState, Diagnostic, DiagnosticCategory, Map, normalizePath,16+ ArkTSLinterTimePrinter, arrayFrom, BuilderProgram, Diagnostic, DiagnosticCategory, Map, normalizePath,
17- Path, ReusableBuilderProgramState, ScriptKind, Set, SourceFile, TimePhase, WriteFileCallback, 17+ Path, Program, ReusableBuilderProgramState, ScriptKind, Set, SourceFile, TimePhase, WriteFileCallback,
18} from "../_namespaces/ts";18} from "../_namespaces/ts";
19import { 19import {
20 clearTypeChecker, clearTrueSymbolAtLocationCache, TypeScriptLinter, ProblemSeverity, ProblemInfo, setTypeChecker, LinterConfig, TSCCompiledProgram20 clearTypeChecker, clearTrueSymbolAtLocationCache, TypeScriptLinter, ProblemSeverity, ProblemInfo, setTypeChecker, LinterConfig, TSCCompiledProgram
@@ -38,17 +38,13 @@ buildInfoWriteFile?: WriteFileCallback, arkTSVersion?: string): Diagnostic[] {
38 38 
39 LinterConfig.initStatic();39 LinterConfig.initStatic();
40 40 
41- // Retrieve list of changed files from the old program state. This needs
42- // to be done before re-evaluating program diagnostics through the call
43- // 'tscDiagnosticsLinter.doAllGetDiagnostics()' below, as it will update
44- // program state, clearing the changedFiles list.
45 let programState = tsBuilderProgram.getState();41 let programState = tsBuilderProgram.getState();
46 const oldDiagnostics = programState.arktsLinterDiagnosticsPerFile;42 const oldDiagnostics = programState.arktsLinterDiagnosticsPerFile;
47 programState.arktsLinterDiagnosticsPerFile = new Map();43 programState.arktsLinterDiagnosticsPerFile = new Map();
48- const changedFiles = collectChangedFilesFromProgramState(programState, arkTSVersion);44+ // Snapshot the directly changed files before 'tscDiagnosticsLinter.doAllGetDiagnostics()'
49- // Set arkTSVersion info for file .tsbuildinfo.45+ // iterates affected files and drains state.changedFilesSet, so the version and
50- // File .tsbuildinfo.linter dosen't need to set arkTSVersion because it dosen't contain linter diagnostics.46+ // global-scope checks inside collectChangedFilesFromProgramState() stay reliable.
51- programState.arkTSVersion = arkTSVersion;47+ const changedFilesBeforeCheck = new Set<Path>(programState.changedFilesSet);
52 48 
53 const tscDiagnosticsLinter = new TSCCompiledProgram(tsBuilderProgram);49 const tscDiagnosticsLinter = new TSCCompiledProgram(tsBuilderProgram);
54 const program = tscDiagnosticsLinter.getProgram();50 const program = tscDiagnosticsLinter.getProgram();
@@ -58,6 +54,14 @@ buildInfoWriteFile?: WriteFileCallback, arkTSVersion?: string): Diagnostic[] {
58 54 
59 tscDiagnosticsLinter.doAllGetDiagnostics();55 tscDiagnosticsLinter.doAllGetDiagnostics();
60 56 
57+ // Collect the re-lint set after diagnostics iteration: files actually re-checked
58+ // by the type checkers reflect signature-gated dependency propagation, instead of
59+ // the whole reverse-dependency cone of every changed file.
60+ const changedFiles = collectChangedFilesFromProgramState(programState, program, changedFilesBeforeCheck, arkTSVersion);
61+ // Set arkTSVersion info for file .tsbuildinfo.
62+ // File .tsbuildinfo.linter dosen't need to set arkTSVersion because it dosen't contain linter diagnostics.
63+ programState.arkTSVersion = arkTSVersion;
64+ 
61 let srcFiles: SourceFile[] = [];65 let srcFiles: SourceFile[] = [];
62 if (!!srcFile) {66 if (!!srcFile) {
63 srcFiles.push(srcFile);67 srcFiles.push(srcFile);
@@ -116,40 +120,64 @@ function releaseReferences(): void {
116 clearTrueSymbolAtLocationCache();120 clearTrueSymbolAtLocationCache();
117}121}
118 122 
119-function collectChangedFilesFromProgramState(state: ReusableBuilderProgramState, arkTSVersion?: string): Set<Path> {123+// One-line diagnostic for large re-lint cascades. Grep '\[LINT-CASCADE\]' in build logs
120- const changedFiles = new Set<Path>(state.changedFilesSet);124+// to identify which changed files pull thousands of dependents into re-linting.
125+function logLinterCascade(reason: string, state: ReusableBuilderProgramState, changedFiles: Set<Path>, recheckSet: { size: number }): void {
126+ try {
127+ const roots = arrayFrom(changedFiles.keys());
128+ console.log(`[LINT-CASCADE] ${JSON.stringify({
129+ ts: new Date().toISOString(),
130+ linter: 'ArkTS_1_0',
131+ reason,
132+ recheckFiles: recheckSet.size,
133+ totalFiles: state.fileInfos.size,
134+ changedRoots: roots.length,
135+ changedRootsSample: roots.slice(0, 30),
136+ affectsGlobalScopeRoots: roots.filter(path => state.fileInfos.get(path)?.affectsGlobalScope).slice(0, 5)
137+ })}`);
138+ } catch {
139+ // logging must never break the build
140+ }
141+}
121 142 
143+function collectChangedFilesFromProgramState(
144+ state: ReusableBuilderProgramState,
145+ program: Program,
146+ changedFilesBeforeCheck: Set<Path>,
147+ arkTSVersion?: string
148+): Set<Path> {
122 // If old arkTSVersion from last run is not same current arkTSVersion from ets_loader,149 // If old arkTSVersion from last run is not same current arkTSVersion from ets_loader,
123 // then process all files in project.150 // then process all files in project.
124 if (state.arkTSVersion !== arkTSVersion) {151 if (state.arkTSVersion !== arkTSVersion) {
152+ logLinterCascade('versionOrSdkDiffers', state, changedFilesBeforeCheck, state.fileInfos);
125 return new Set<Path>(arrayFrom(state.fileInfos.keys()));153 return new Set<Path>(arrayFrom(state.fileInfos.keys()));
126 }154 }
127 155 
128 // If any source file that affects global scope has been changed,156 // If any source file that affects global scope has been changed,
129 // then process all files in project.157 // then process all files in project.
130- for (const changedFile of arrayFrom(changedFiles.keys())) {158+ for (const changedFile of arrayFrom(changedFilesBeforeCheck.keys())) {
131 const fileInfo = state.fileInfos.get(changedFile);159 const fileInfo = state.fileInfos.get(changedFile);
132 if (fileInfo?.affectsGlobalScope) {160 if (fileInfo?.affectsGlobalScope) {
161+ logLinterCascade(`affectsGlobalScope:${changedFile}`, state, changedFilesBeforeCheck, state.fileInfos);
133 return new Set<Path>(arrayFrom(state.fileInfos.keys()));162 return new Set<Path>(arrayFrom(state.fileInfos.keys()));
134 }163 }
135 }164 }
136 165 
137 if (!state.referencedMap) {166 if (!state.referencedMap) {
138- return changedFiles;167+ return changedFilesBeforeCheck;
139 }168 }
140 169 
141- const seenPaths = new Set<Path>();170+ // Re-lint only what the type checkers actually re-checked. Their affected-file
142- const queue = arrayFrom(changedFiles.keys());171+ // iteration stops where declaration shape signatures are unchanged, while the
143- while (queue.length) {172+ // previous reverse-dependency-cone walk here re-linted the entire transitive
144- const path = queue.pop()!;173+ // cone of any changed file regardless of shape changes.
145- if (!seenPaths.has(path)) {174+ const targetSet = new Set<Path>();
146- seenPaths.add(path);175+ program.getTypeChecker().getCheckedSourceFiles().forEach(x => targetSet.add(x.path));
147- 176+ program.getLinterTypeChecker().getCheckedSourceFiles().forEach(x => targetSet.add(x.path));
148- // Collect all files that import this file177+ if (targetSet.size > 1000) {
149- queue.push(...BuilderState.getReferencedByPaths(state, path));178+ logLinterCascade('checkerCascade', state, changedFilesBeforeCheck, targetSet);
150- }
151 }179 }
152- return seenPaths;180+ return targetSet;
153}181}
154 182 
155/**183/**
@@ -43,13 +43,13 @@ buildInfoWriteFile?: WriteFileCallback, arkTSVersion?: string): Diagnostic[] {
43 43 
44 LinterConfig.initStatic();44 LinterConfig.initStatic();
45 45 
46- // Retrieve list of changed files from the old program state. This needs
47- // to be done before re-evaluating program diagnostics through the call
48- // 'tscDiagnosticsLinter.doAllGetDiagnostics()' below, as it will update
49- // program state, clearing the changedFiles list.
50 let programState = tsBuilderProgram.getState();46 let programState = tsBuilderProgram.getState();
51 const oldDiagnostics = programState.arktsLinterDiagnosticsPerFile;47 const oldDiagnostics = programState.arktsLinterDiagnosticsPerFile;
52 programState.arktsLinterDiagnosticsPerFile = new Map();48 programState.arktsLinterDiagnosticsPerFile = new Map();
49+ // Snapshot the directly changed files before 'tscDiagnosticsLinter.doAllGetDiagnostics()'
50+ // iterates affected files and drains state.changedFilesSet, so the version and
51+ // global-scope checks inside collectChangedFilesFromProgramState() stay reliable.
52+ const changedFilesBeforeCheck = new Set<Path>(programState.changedFilesSet);
53 53 
54 const tscDiagnosticsLinter = new TSCCompiledProgram(tsBuilderProgram);54 const tscDiagnosticsLinter = new TSCCompiledProgram(tsBuilderProgram);
55 const program = tscDiagnosticsLinter.getProgram();55 const program = tscDiagnosticsLinter.getProgram();
@@ -69,6 +69,7 @@ buildInfoWriteFile?: WriteFileCallback, arkTSVersion?: string): Diagnostic[] {
69 const changedFiles = collectChangedFilesFromProgramState(69 const changedFiles = collectChangedFilesFromProgramState(
70 programState,70 programState,
71 program,71 program,
72+ changedFilesBeforeCheck,
72 arkTSVersion,73 arkTSVersion,
73 compilerOptions.compatibleSdkVersion,74 compilerOptions.compatibleSdkVersion,
74 compilerOptions.compatibleSdkVersionStage75 compilerOptions.compatibleSdkVersionStage
@@ -201,15 +202,34 @@ function releaseReferences(): void {
201 LibraryTypeCallDiagnosticChecker.instance.clear();202 LibraryTypeCallDiagnosticChecker.instance.clear();
202}203}
203 204 
205+// One-line diagnostic for large re-lint cascades. Grep '\[LINT-CASCADE\]' in build logs
206+// to identify which changed files pull thousands of dependents into re-linting.
207+function logLinterCascade(reason: string, state: ReusableBuilderProgramState, changedFiles: Set<Path>, recheckSet: { size: number }): void {
208+ try {
209+ const roots = arrayFrom(changedFiles.keys());
210+ console.log(`[LINT-CASCADE] ${JSON.stringify({
211+ ts: new Date().toISOString(),
212+ linter: 'ArkTS_1_1',
213+ reason,
214+ recheckFiles: recheckSet.size,
215+ totalFiles: state.fileInfos.size,
216+ changedRoots: roots.length,
217+ changedRootsSample: roots.slice(0, 30),
218+ affectsGlobalScopeRoots: roots.filter(path => state.fileInfos.get(path)?.affectsGlobalScope).slice(0, 5)
219+ })}`);
220+ } catch {
221+ // logging must never break the build
222+ }
223+}
224+ 
204function collectChangedFilesFromProgramState(225function collectChangedFilesFromProgramState(
205 state: ReusableBuilderProgramState,226 state: ReusableBuilderProgramState,
206 program: Program,227 program: Program,
228+ changedFilesBeforeCheck: Set<Path>,
207 arkTSVersion?: string,229 arkTSVersion?: string,
208 compatibleSdkVersion?: number,230 compatibleSdkVersion?: number,
209 compatibleSdkVersionStage?: string231 compatibleSdkVersionStage?: string
210): Set<Path> {232): Set<Path> {
211-const changedFiles = new Set<Path>(state.changedFilesSet);
212- 
213 // If old arkTSVersion from last run is not same current arkTSVersion from ets_loader,233 // If old arkTSVersion from last run is not same current arkTSVersion from ets_loader,
214 // the process all files in project.234 // the process all files in project.
215 // The compatibleSdkVersion and compatibleSdkVersionStage is the same as arkTSVersion235 // The compatibleSdkVersion and compatibleSdkVersionStage is the same as arkTSVersion
@@ -218,20 +238,22 @@ const changedFiles = new Set<Path>(state.changedFilesSet);
218 state.compatibleSdkVersion !== compatibleSdkVersion ||238 state.compatibleSdkVersion !== compatibleSdkVersion ||
219 state.compatibleSdkVersionStage !== compatibleSdkVersionStage239 state.compatibleSdkVersionStage !== compatibleSdkVersionStage
220 ) {240 ) {
241+ logLinterCascade('versionOrSdkDiffers', state, changedFilesBeforeCheck, state.fileInfos);
221 return new Set<Path>(arrayFrom(state.fileInfos.keys()));242 return new Set<Path>(arrayFrom(state.fileInfos.keys()));
222 }243 }
223 244 
224 // If any source file that affects global scope has been changed,245 // If any source file that affects global scope has been changed,
225 // then process all files in project.246 // then process all files in project.
226- for (const changedFile of arrayFrom(changedFiles.keys())) {247+ for (const changedFile of arrayFrom(changedFilesBeforeCheck.keys())) {
227 const fileInfo = state.fileInfos.get(changedFile);248 const fileInfo = state.fileInfos.get(changedFile);
228 if (fileInfo?.affectsGlobalScope) {249 if (fileInfo?.affectsGlobalScope) {
250+ logLinterCascade(`affectsGlobalScope:${changedFile}`, state, changedFilesBeforeCheck, state.fileInfos);
229 return new Set<Path>(arrayFrom(state.fileInfos.keys()));251 return new Set<Path>(arrayFrom(state.fileInfos.keys()));
230 }252 }
231 }253 }
232 254 
233 if (!state.referencedMap) {255 if (!state.referencedMap) {
234- return changedFiles;256+ return changedFilesBeforeCheck;
235 }257 }
236 258 
237 const changedSourcesForLinter = program.getLinterTypeChecker().getCheckedSourceFiles();259 const changedSourcesForLinter = program.getLinterTypeChecker().getCheckedSourceFiles();
@@ -240,6 +262,9 @@ const changedFiles = new Set<Path>(state.changedFilesSet);
240 const targetSet = new Set<Path>();262 const targetSet = new Set<Path>();
241 changedSourcesForLinter.forEach(x => targetSet.add(x.path));263 changedSourcesForLinter.forEach(x => targetSet.add(x.path));
242 changedSources.forEach(x => targetSet.add(x.path));264 changedSources.forEach(x => targetSet.add(x.path));
265+ if (targetSet.size > 1000) {
266+ logLinterCascade('checkerCascade', state, changedFilesBeforeCheck, targetSet);
267+ }
243 return targetSet;268 return targetSet;
244}269}
245 270