已开启
Optimize ArkTS linter incremental recheck set to eliminate pseudo-incremental builds #874
zmw1创建于 12 天前
Optimize ArkTS linter incremental recheck set to eliminate pseudo-incremental builds #874
已开启
共 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 | */ |
| 15 | import { | 15 | import { |
| 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"; |
| 19 | import { | 19 | import { |
| 20 | clearTypeChecker, clearTrueSymbolAtLocationCache, TypeScriptLinter, ProblemSeverity, ProblemInfo, setTypeChecker, LinterConfig, TSCCompiledProgram | 20 | 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 file | 177 | + 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.compatibleSdkVersionStage | 75 | 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 | + | ||
| 204 | function collectChangedFilesFromProgramState( | 225 | function 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?: string | 231 | 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 arkTSVersion | 235 | // 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 !== compatibleSdkVersionStage | 239 | 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 | ||