@oniroproject/core 0.16.0 → 0.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,3875 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ // Adapted for oniro-app from deveco-cli 1.3.10 (commit 99e3725fc2c5,
5
+ // packages/cli/src/resources/arkts-check.cjs), Copyright (c) 2026 Huawei Device
6
+ // Co., Ltd., MIT licence (see LICENSE beside this file). Changes from upstream:
7
+ // the SDK is given with `--sdk <dir>` (an OpenHarmony SDK API directory, the
8
+ // layout DevEco keeps under sdk/default/openharmony) instead of being found by
9
+ // looking for a DevEco Studio install, and the HarmonyOS `hms/ets` API path is
10
+ // given with `--hms <dir>` instead of being assumed beside it. `--fix` also
11
+ // adds `export` for "File '...' is not a module" (an imported file that exports
12
+ // nothing yet), which upstream leaves alone. Everything else is upstream's.
13
+
14
+ const path = require('path');
15
+ const fs = require('fs');
16
+
17
+ // --- Argument parsing ---
18
+
19
+ function parseArgs(argv) {
20
+ // 默认只读检查;--fix 才改写源文件,避免直跑脚本时静默修改代码。
21
+ const args = { project: '', files: [], fix: false, serve: false, sdk: '', hms: '' };
22
+ let i = 2;
23
+ while (i < argv.length) {
24
+ if (argv[i] === '--project' && argv[i + 1]) {
25
+ args.project = path.resolve(argv[++i]);
26
+ } else if (argv[i] === '--sdk' && argv[i + 1]) {
27
+ args.sdk = path.resolve(argv[++i]);
28
+ } else if (argv[i] === '--hms' && argv[i + 1]) {
29
+ args.hms = path.resolve(argv[++i]);
30
+ } else if (argv[i] === '--serve') {
31
+ args.serve = true;
32
+ } else if (argv[i] === '--no-fix') {
33
+ args.fix = false;
34
+ } else if (argv[i] === '--fix') {
35
+ args.fix = true;
36
+ } else if (argv[i] === '--files') {
37
+ i++;
38
+ while (i < argv.length && !argv[i].startsWith('--')) {
39
+ args.files.push(argv[i++]);
40
+ }
41
+ continue;
42
+ } else if (!argv[i].startsWith('--')) {
43
+ args.files.push(argv[i]);
44
+ }
45
+ i++;
46
+ }
47
+ return args;
48
+ }
49
+
50
+ // --- SDK location ---
51
+ //
52
+ // `sdkDir` is the directory holding ets/, previewer/ and toolchains/: an
53
+ // OpenHarmony SDK API directory (<sdk-root>/20) or DevEco's
54
+ // sdk/default/openharmony. `hmsEtsDir` is HarmonyOS's sdk/default/hms/ets, added
55
+ // to the checker's external API paths when the project targets HarmonyOS.
56
+ // Without --sdk, DEVECO_HOME is honoured the way upstream reads it.
57
+ function resolveSdk(args) {
58
+ if (args.sdk) return { sdkDir: args.sdk, hmsEtsDir: args.hms || '' };
59
+ const envHome = (process.env.DEVECO_HOME || '').trim();
60
+ if (envHome && fs.existsSync(envHome)) {
61
+ const hms = path.join(envHome, 'sdk', 'default', 'hms', 'ets');
62
+ return {
63
+ sdkDir: path.join(envHome, 'sdk', 'default', 'openharmony'),
64
+ hmsEtsDir: args.hms || (fs.existsSync(hms) ? hms : ''),
65
+ };
66
+ }
67
+ return { sdkDir: '', hmsEtsDir: '' };
68
+ }
69
+
70
+ function findEtsLoader(sdkDir) {
71
+ const candidates = [
72
+ path.join(sdkDir, 'ets', 'build-tools', 'ets-loader'),
73
+ ];
74
+ for (const c of candidates) {
75
+ if (fs.existsSync(path.join(c, 'lib', 'ets_checker.js'))) return c;
76
+ }
77
+ return null;
78
+ }
79
+
80
+ // --- Collect .ets files from project ---
81
+
82
+ function collectEtsFiles(projectPath) {
83
+ const results = [];
84
+ const srcDir = path.join(projectPath, 'entry', 'src', 'main', 'ets');
85
+ if (!fs.existsSync(srcDir)) return results;
86
+
87
+ function walk(dir) {
88
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
89
+ const full = path.join(dir, entry.name);
90
+ if (entry.isDirectory()) {
91
+ if (entry.name === 'node_modules' || entry.name === 'oh_modules' || entry.name === 'build') continue;
92
+ walk(full);
93
+ } else if (entry.name.endsWith('.ets') && !entry.name.endsWith('.d.ets')) {
94
+ results.push(full);
95
+ }
96
+ }
97
+ }
98
+ walk(srcDir);
99
+ return results;
100
+ }
101
+
102
+ // Every .ets under any module's src/main/ets, not just `entry` (collectEtsFiles
103
+ // above is entry-only because it feeds the checker's default file list). Used by
104
+ // cross-file checks that need to see declarations the caller did not pass in.
105
+ // Cached per project path: a single run re-checks the same project up to twice
106
+ // (once more after auto-fix), and auto-fix never adds or removes source files.
107
+ const projectEtsFileCache = new Map();
108
+
109
+ function collectProjectEtsFiles(projectPath) {
110
+ const cached = projectEtsFileCache.get(projectPath);
111
+ if (cached) return cached;
112
+
113
+ const results = [];
114
+ function walk(dir) {
115
+ let entries;
116
+ try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
117
+ for (const entry of entries) {
118
+ const full = path.join(dir, entry.name);
119
+ if (entry.isDirectory()) {
120
+ if (entry.name === 'node_modules' || entry.name === 'oh_modules' || entry.name === 'build') continue;
121
+ if (entry.name.startsWith('.')) continue;
122
+ walk(full);
123
+ } else if (entry.name.endsWith('.ets') && !entry.name.endsWith('.d.ets')) {
124
+ results.push(full);
125
+ }
126
+ }
127
+ }
128
+
129
+ // 模块可嵌套于分组目录(如 commons/lib_foundation、components/address_management),
130
+ // 故递归查找任意深度的 src/main/ets,而不是只枚举一层 {mod}/src/main/ets。
131
+ function findModuleEtsDirs(dir, depth) {
132
+ if (depth > 6) return;
133
+ let entries;
134
+ try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
135
+ for (const entry of entries) {
136
+ if (!entry.isDirectory()) continue;
137
+ if (entry.name === 'node_modules' || entry.name === 'oh_modules' || entry.name === 'build') continue;
138
+ if (entry.name.startsWith('.')) continue;
139
+ const full = path.join(dir, entry.name);
140
+ if (entry.name === 'src') {
141
+ const etsDir = path.join(full, 'main', 'ets');
142
+ if (fs.existsSync(etsDir)) {
143
+ walk(etsDir);
144
+ continue;
145
+ }
146
+ }
147
+ findModuleEtsDirs(full, depth + 1);
148
+ }
149
+ }
150
+ findModuleEtsDirs(projectPath, 0);
151
+
152
+ projectEtsFileCache.set(projectPath, results);
153
+ return results;
154
+ }
155
+
156
+ // `files` plus every other project .ets, de-duplicated by absolute path. Keeps
157
+ // the caller's exact paths (which may be absolute or already-resolved) first so
158
+ // per-file caches keyed on them still hit.
159
+ function unionProjectFiles(files, projectPath) {
160
+ const seen = new Set(files.map((f) => path.resolve(f)));
161
+ const merged = [...files];
162
+ for (const f of collectProjectEtsFiles(projectPath)) {
163
+ const abs = path.resolve(f);
164
+ if (seen.has(abs)) continue;
165
+ seen.add(abs);
166
+ merged.push(f);
167
+ }
168
+ return merged;
169
+ }
170
+
171
+ // --- Per-file diagnostic cache (warm --serve process only) ---
172
+ //
173
+ // A file's checker diagnostics depend on its own content plus the content it
174
+ // imports, so caching on the file's own hash alone would go stale the moment a
175
+ // dependency changed. Each entry is therefore keyed on a *closure* hash: this
176
+ // file's content plus every relatively-imported file reachable from it.
177
+ //
178
+ // Two things sit outside any closure and would silently go stale, so they are
179
+ // folded into a process-wide epoch that clears the whole cache when it changes:
180
+ // installed dependencies (`ohpm install` mid-session) and ambient `.d.ets`
181
+ // declarations, which affect files with no import edge to them.
182
+ const fileDiagCache = new Map(); // abs -> { closureHash, diagnostics }
183
+ let cacheEpoch = '';
184
+
185
+ function hashText(text) {
186
+ return require('crypto').createHash('sha1').update(text).digest('hex');
187
+ }
188
+
189
+ function fileHash(abs) {
190
+ try {
191
+ return hashText(fs.readFileSync(abs, 'utf-8'));
192
+ } catch {
193
+ return 'missing';
194
+ }
195
+ }
196
+
197
+ function computeEpoch(projectPath, sdkDir) {
198
+ const parts = [sdkDir];
199
+ for (const rel of ['oh-package.json5', 'oh-package-lock.json5', 'build-profile.json5']) {
200
+ parts.push(rel, fileHash(path.join(projectPath, rel)));
201
+ }
202
+ for (const abs of collectAmbientDeclarations(projectPath)) {
203
+ parts.push(path.relative(projectPath, abs), fileHash(abs));
204
+ }
205
+ return hashText(parts.join('\n'));
206
+ }
207
+
208
+ // Ambient `.d.ets` files declare types with no import edge, so any file's
209
+ // diagnostics can depend on them. Collected project-wide, excluding deps.
210
+ function collectAmbientDeclarations(projectPath) {
211
+ const results = [];
212
+ function walk(dir) {
213
+ let entries;
214
+ try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
215
+ for (const entry of entries) {
216
+ const full = path.join(dir, entry.name);
217
+ if (entry.isDirectory()) {
218
+ if (entry.name === 'node_modules' || entry.name === 'oh_modules' || entry.name === 'build') continue;
219
+ if (entry.name.startsWith('.')) continue;
220
+ walk(full);
221
+ continue;
222
+ }
223
+ if (entry.name.endsWith('.d.ets') || entry.name.endsWith('.d.ts')) results.push(full);
224
+ }
225
+ }
226
+ walk(projectPath);
227
+ return results;
228
+ }
229
+
230
+ // Resolve a relative specifier for closure walking. Wider than
231
+ // `resolveModuleFile` (which is .ets-only, for the import fixer): a relative
232
+ // `.ts` or `.d.ets` dependency changing must also invalidate the importer.
233
+ function resolveClosureDependency(importerAbs, spec) {
234
+ if (!spec.startsWith('.')) return null;
235
+ const base = path.resolve(path.dirname(importerAbs), spec);
236
+ const candidates = /\.(ets|ts)$/.test(base)
237
+ ? [base]
238
+ : [base + '.ets', base + '.ts', base + '.d.ets', base + '.d.ts',
239
+ path.join(base, 'index.ets'), path.join(base, 'index.ts')];
240
+ for (const c of candidates) {
241
+ try { if (fs.statSync(c).isFile()) return c; } catch { /* not present */ }
242
+ }
243
+ return null;
244
+ }
245
+
246
+ const IMPORT_SPEC_RE = /(?:^|\n)\s*(?:import|export)\b[^;\n]*?from\s*['"]([^'"]+)['"]/g;
247
+
248
+ // Hash of `abs` plus every relatively-imported file transitively reachable from
249
+ // it. Cycles terminate via `seen`; unresolvable and bare specifiers are ignored
250
+ // (bare package imports are covered by the epoch instead).
251
+ function closureHash(abs) {
252
+ const seen = new Set();
253
+ const parts = [];
254
+ const stack = [abs];
255
+ while (stack.length > 0) {
256
+ const current = stack.pop();
257
+ if (seen.has(current)) continue;
258
+ seen.add(current);
259
+ let content;
260
+ try { content = fs.readFileSync(current, 'utf-8'); } catch { parts.push(current + ':missing'); continue; }
261
+ parts.push(current + ':' + hashText(content));
262
+ IMPORT_SPEC_RE.lastIndex = 0;
263
+ for (;;) {
264
+ const m = IMPORT_SPEC_RE.exec(content);
265
+ if (!m) break;
266
+ const dep = resolveClosureDependency(current, m[1]);
267
+ if (dep && !seen.has(dep)) stack.push(dep);
268
+ }
269
+ }
270
+ return hashText(parts.sort().join('\n'));
271
+ }
272
+
273
+ // --- Diagnostic output capture ---
274
+
275
+ function parseDiagnosticLine(line) {
276
+ const errorMatch = line.match(/ArkTS:(ERROR|WARN)\s+File:\s+(.+?):(\d+):(\d+)/);
277
+ if (errorMatch) {
278
+ return { severity: errorMatch[1].toLowerCase(), file: errorMatch[2], line: parseInt(errorMatch[3]), column: parseInt(errorMatch[4]) };
279
+ }
280
+ return null;
281
+ }
282
+
283
+ function parseMessageLine(line) {
284
+ const trimmed = line.trim();
285
+ const ruleMatch = trimmed.match(/^(.+?)\s*\(([a-z][\w-]+)\)\s*$/);
286
+ if (ruleMatch) {
287
+ return { message: ruleMatch[1].trim(), rule: ruleMatch[2] };
288
+ }
289
+ return { message: trimmed, rule: '' };
290
+ }
291
+
292
+ // --- Project-level validation (A-class checks) ---
293
+
294
+ function loadSystemResourceNames(sdkDir) {
295
+ const candidates = [
296
+ path.join(sdkDir, 'previewer', 'common', 'resources', 'entry', 'resources.txt'),
297
+ ];
298
+ let resFile = '';
299
+ for (const c of candidates) {
300
+ if (fs.existsSync(c)) { resFile = c; break; }
301
+ }
302
+ if (!resFile) return null;
303
+
304
+ const names = new Set();
305
+ const content = fs.readFileSync(resFile, 'utf-8');
306
+ const linePattern = /^id:\d+,\s*'[^']*'\s+'([^']+)'/;
307
+ for (const line of content.split('\n')) {
308
+ const m = line.match(linePattern);
309
+ if (m) names.add(m[1]);
310
+ }
311
+ return names;
312
+ }
313
+
314
+ function validateSystemResources(files, sdkDir, projectPath) {
315
+ const validNames = loadSystemResourceNames(sdkDir);
316
+ if (!validNames) return [];
317
+
318
+ const diagnostics = [];
319
+ const refPattern = /\$r\(\s*['"]sys\.(media|symbol)\.([^'"]+)['"]\s*\)/g;
320
+
321
+ for (const filePath of files) {
322
+ if (!fs.existsSync(filePath)) continue;
323
+ const content = fs.readFileSync(filePath, 'utf-8');
324
+ const fileLines = content.split('\n');
325
+ for (let i = 0; i < fileLines.length; i++) {
326
+ let match;
327
+ refPattern.lastIndex = 0;
328
+ while ((match = refPattern.exec(fileLines[i])) !== null) {
329
+ const resName = match[2];
330
+ if (!validNames.has(resName)) {
331
+ diagnostics.push({
332
+ file: path.relative(projectPath, filePath),
333
+ line: i + 1,
334
+ column: match.index + 1,
335
+ severity: 'error',
336
+ message: `Unknown resource name '${resName}'. No matching sys.${match[1]} resource found in SDK.`,
337
+ rule: 'resource-name-check',
338
+ });
339
+ }
340
+ }
341
+ }
342
+ }
343
+ return diagnostics;
344
+ }
345
+
346
+ // --- app.* resource references ($r('app.media.foo'), $r('app.string.bar')) ---
347
+ //
348
+ // validateSystemResources above covers only `sys.*` (SDK-provided) names. The
349
+ // project's OWN resources are just as easy to get wrong and hvigor rejects them
350
+ // with the same 10903329 "Unknown resource name" error: 39 of 93 build errors in
351
+ // one bootstrap round were a missing app.media.* image. Resolution is a pure
352
+ // existence question — a media file on disk, or a named entry in an element JSON
353
+ // — so this needs no type information and cannot disagree with the compiler the
354
+ // way a heuristic would.
355
+ //
356
+ // Qualifier directories (base, dark, zh_CN, ...) are merged into one namespace:
357
+ // a name defined under ANY qualifier satisfies a reference, matching how the
358
+ // resource compiler resolves at runtime. Only `media` and element JSON kinds are
359
+ // indexed; `$r('app.color.x')` etc. resolve through element/color.json, whose
360
+ // top-level key ("color") is singularized from the JSON's array key ("colors"
361
+ // is not used — element files key on the singular already, e.g. {"color": [...]}).
362
+ const APP_RESOURCE_REF_RE = /\$r\(\s*['"]app\.([a-z]+)\.([A-Za-z0-9_]+)['"]\s*\)/g;
363
+
364
+ // Raw-file resource kinds referenced with $rawfile()/other syntax, not $r('app.*'),
365
+ // so an unknown-name check does not apply to them here.
366
+ const APP_RESOURCE_INDEXED_KINDS = new Set(['media', 'color', 'string', 'float', 'integer', 'boolean', 'intarray', 'strarray', 'pattern', 'plural', 'profile', 'symbol']);
367
+
368
+ function loadAppResourceNames(projectPath) {
369
+ const names = new Set();
370
+ let moduleDirs;
371
+ try {
372
+ moduleDirs = fs.readdirSync(projectPath, { withFileTypes: true }).filter((d) => d.isDirectory());
373
+ } catch {
374
+ return names;
375
+ }
376
+
377
+ for (const mod of moduleDirs) {
378
+ if (mod.name === 'node_modules' || mod.name === 'oh_modules' || mod.name.startsWith('.')) continue;
379
+ const resourcesDir = path.join(projectPath, mod.name, 'src', 'main', 'resources');
380
+ let qualifiers;
381
+ try {
382
+ qualifiers = fs.readdirSync(resourcesDir, { withFileTypes: true }).filter((d) => d.isDirectory());
383
+ } catch {
384
+ continue;
385
+ }
386
+
387
+ for (const qualifier of qualifiers) {
388
+ const qualifierDir = path.join(resourcesDir, qualifier.name);
389
+
390
+ // media/profile: the file's basename (minus extension) is the resource name.
391
+ for (const kind of ['media', 'profile']) {
392
+ const kindDir = path.join(qualifierDir, kind);
393
+ let files;
394
+ try { files = fs.readdirSync(kindDir); } catch { continue; }
395
+ for (const file of files) {
396
+ names.add(`${kind}.${file.replace(/\.[^.]+$/, '')}`);
397
+ }
398
+ }
399
+
400
+ // element/*.json: {"color": [{"name": "primary", ...}], ...}
401
+ const elementDir = path.join(qualifierDir, 'element');
402
+ let elementFiles;
403
+ try { elementFiles = fs.readdirSync(elementDir); } catch { continue; }
404
+ for (const file of elementFiles) {
405
+ if (!file.endsWith('.json')) continue;
406
+ let parsed;
407
+ try {
408
+ parsed = JSON.parse(fs.readFileSync(path.join(elementDir, file), 'utf-8'));
409
+ } catch {
410
+ // A malformed element file is its own build error; skipping it here
411
+ // only means we cannot vouch for names it would have defined, and the
412
+ // unknown-name diagnostics below are suppressed for that kind.
413
+ continue;
414
+ }
415
+ for (const [kind, entries] of Object.entries(parsed)) {
416
+ if (!Array.isArray(entries)) continue;
417
+ for (const entry of entries) {
418
+ if (entry && typeof entry.name === 'string') names.add(`${kind}.${entry.name}`);
419
+ }
420
+ }
421
+ }
422
+ }
423
+ }
424
+ return names;
425
+ }
426
+
427
+ function validateAppResources(files, projectPath) {
428
+ const validNames = loadAppResourceNames(projectPath);
429
+ // No resources directory at all (or unreadable): stay silent rather than
430
+ // reporting every reference as unknown.
431
+ if (validNames.size === 0) return [];
432
+
433
+ // Only vouch for kinds we actually managed to index; if a project defines no
434
+ // colors at all, a color reference is far more likely to mean our index missed
435
+ // the file than that the reference is wrong.
436
+ const indexedKinds = new Set();
437
+ for (const name of validNames) indexedKinds.add(name.slice(0, name.indexOf('.')));
438
+
439
+ const diagnostics = [];
440
+ for (const filePath of files) {
441
+ let content;
442
+ try { content = fs.readFileSync(filePath, 'utf-8'); } catch { continue; }
443
+ const relFile = path.relative(projectPath, filePath);
444
+ const fileLines = content.split('\n');
445
+ for (let i = 0; i < fileLines.length; i++) {
446
+ let match;
447
+ APP_RESOURCE_REF_RE.lastIndex = 0;
448
+ while ((match = APP_RESOURCE_REF_RE.exec(fileLines[i])) !== null) {
449
+ const kind = match[1];
450
+ const resName = match[2];
451
+ if (!APP_RESOURCE_INDEXED_KINDS.has(kind)) continue;
452
+ if (!indexedKinds.has(kind)) continue;
453
+ if (validNames.has(`${kind}.${resName}`)) continue;
454
+ diagnostics.push({
455
+ file: relFile,
456
+ line: i + 1,
457
+ column: match.index + 1,
458
+ severity: 'error',
459
+ message: `Unknown resource name '${resName}'. No matching app.${kind} resource is defined under any module's resources/*/${kind === 'media' || kind === 'profile' ? kind : 'element'} directory.`,
460
+ rule: 'app-resource-name-check',
461
+ });
462
+ }
463
+ }
464
+ }
465
+ return diagnostics;
466
+ }
467
+
468
+ function validateRouterPages(projectPath) {
469
+ const candidates = [
470
+ path.join(projectPath, 'entry', 'src', 'main', 'resources', 'base', 'profile', 'main_pages.json'),
471
+ path.join(projectPath, 'src', 'main', 'resources', 'base', 'profile', 'main_pages.json'),
472
+ ];
473
+ let mainPagesPath = '';
474
+ for (const c of candidates) {
475
+ if (fs.existsSync(c)) { mainPagesPath = c; break; }
476
+ }
477
+ if (!mainPagesPath) return [];
478
+
479
+ let config;
480
+ try {
481
+ config = JSON.parse(fs.readFileSync(mainPagesPath, 'utf-8'));
482
+ } catch { return []; }
483
+
484
+ const pages = config.src || [];
485
+ const diagnostics = [];
486
+ const etsBase = path.join(projectPath, 'entry', 'src', 'main', 'ets');
487
+
488
+ for (let i = 0; i < pages.length; i++) {
489
+ const pagePath = pages[i];
490
+ const etsFile = path.join(etsBase, pagePath + '.ets');
491
+ if (!fs.existsSync(etsFile)) {
492
+ diagnostics.push({
493
+ file: path.relative(projectPath, mainPagesPath),
494
+ line: i + 2,
495
+ column: 1,
496
+ severity: 'error',
497
+ message: `Page '${pagePath}.ets' does not exist. Registered in main_pages.json but file not found at entry/src/main/ets/${pagePath}.ets`,
498
+ rule: 'page-file-exists',
499
+ });
500
+ continue;
501
+ }
502
+ const entryDiag = validatePageEntryCount(etsFile, projectPath, pagePath);
503
+ if (entryDiag) diagnostics.push(entryDiag);
504
+ }
505
+ return diagnostics;
506
+ }
507
+
508
+ // --- NavDestination route map (route_map.json / router_map.json) schema ---
509
+ //
510
+ // A module's dynamic-route profile (referenced from module.json5 as
511
+ // `$profile:route_map`, conventionally named route_map.json — some generated
512
+ // projects instead write router_map.json, which hvigor also accepts) is
513
+ // validated by hvigor against a strict JSON schema at the `ProcessRouterMap`
514
+ // build step: `routerMap[]` entries allow only `name`/`pageSourceFile`/
515
+ // `buildFunction`/`data`/`customData`, and `name`/`pageSourceFile`/
516
+ // `buildFunction` are required (10005/11003/additionalProperties errors,
517
+ // observed as build-time-only ajv validation failures). The standalone
518
+ // checker never reads this file, so a malformed entry (wrong key name, e.g.
519
+ // `pageSource`/`builderFunction` instead of `pageSourceFile`/`buildFunction`,
520
+ // or a missing required key) passes arkts_check clean and only surfaces once
521
+ // hvigor runs. This is a pure JSON/schema check — no ArkTS parsing involved.
522
+ const ROUTE_MAP_ALLOWED_KEYS = new Set(['name', 'pageSourceFile', 'buildFunction', 'data', 'customData']);
523
+ const ROUTE_MAP_REQUIRED_KEYS = ['name', 'pageSourceFile', 'buildFunction'];
524
+
525
+ function validateRouteMapProfile(projectPath) {
526
+ const candidates = [
527
+ path.join(projectPath, 'entry', 'src', 'main', 'resources', 'base', 'profile', 'route_map.json'),
528
+ path.join(projectPath, 'entry', 'src', 'main', 'resources', 'base', 'profile', 'router_map.json'),
529
+ path.join(projectPath, 'src', 'main', 'resources', 'base', 'profile', 'route_map.json'),
530
+ path.join(projectPath, 'src', 'main', 'resources', 'base', 'profile', 'router_map.json'),
531
+ ];
532
+ let routeMapPath = '';
533
+ for (const c of candidates) {
534
+ if (fs.existsSync(c)) { routeMapPath = c; break; }
535
+ }
536
+ if (!routeMapPath) return [];
537
+
538
+ const relFile = path.relative(projectPath, routeMapPath);
539
+ let text;
540
+ try { text = fs.readFileSync(routeMapPath, 'utf-8'); } catch { return []; }
541
+
542
+ let config;
543
+ try { config = JSON.parse(text); } catch (e) {
544
+ return [{
545
+ file: relFile,
546
+ line: 1,
547
+ column: 1,
548
+ severity: 'error',
549
+ rule: 'route-map-invalid-json',
550
+ message: `Failed to parse ${path.basename(routeMapPath)} as JSON: ${e.message}`,
551
+ }];
552
+ }
553
+
554
+ const entries = Array.isArray(config.routerMap) ? config.routerMap : null;
555
+ if (!entries) return [];
556
+
557
+ // Locate each `routerMap[i]` object's opening line by counting `{` occurrences
558
+ // in document order — cheap and accurate enough for diagnostics on a
559
+ // hand-authored profile file (no nested objects appear inside an entry).
560
+ const lines = text.split('\n');
561
+ const entryLineIdx = [];
562
+ for (let i = 0; i < lines.length; i++) {
563
+ if (/^\s*\{/.test(lines[i])) entryLineIdx.push(i);
564
+ }
565
+
566
+ const diagnostics = [];
567
+ for (let i = 0; i < entries.length; i++) {
568
+ const entry = entries[i];
569
+ const line = (entryLineIdx[i] !== undefined ? entryLineIdx[i] : 0) + 1;
570
+ if (!entry || typeof entry !== 'object') continue;
571
+
572
+ for (const key of Object.keys(entry)) {
573
+ if (!ROUTE_MAP_ALLOWED_KEYS.has(key)) {
574
+ diagnostics.push({
575
+ file: relFile,
576
+ line,
577
+ column: 1,
578
+ severity: 'error',
579
+ rule: 'route-map-unknown-key',
580
+ message: `routerMap[${i}] has unknown property '${key}'. Allowed properties are: ${[...ROUTE_MAP_ALLOWED_KEYS].join(', ')}.`,
581
+ });
582
+ }
583
+ }
584
+ for (const req of ROUTE_MAP_REQUIRED_KEYS) {
585
+ if (!(req in entry)) {
586
+ diagnostics.push({
587
+ file: relFile,
588
+ line,
589
+ column: 1,
590
+ severity: 'error',
591
+ rule: 'route-map-missing-key',
592
+ message: `routerMap[${i}] is missing required property '${req}'.`,
593
+ });
594
+ }
595
+ }
596
+
597
+ diagnostics.push(...validateRouteMapBuildFunction(entry, i, line, relFile, routeMapPath, projectPath));
598
+ }
599
+ return diagnostics;
600
+ }
601
+
602
+ // 10904336: `buildFunction` must name an exported @Builder in the entry's
603
+ // `pageSourceFile`. Declaring the route is the easy half and the compiler only
604
+ // objects at packaging time, so the gap is wide -- one project shipped two routes
605
+ // whose builders were never written and lost a whole build cycle to it.
606
+ //
607
+ // hvigor collapses three distinct causes into one "does not exist": absent,
608
+ // present but not exported, present but missing the decorator. Each wants a
609
+ // different edit, so they are separated here.
610
+ //
611
+ // Silent when the page file itself is missing: `validateRouterPages` already
612
+ // reports that as `page-file-exists`, and a second diagnostic for the same root
613
+ // cause would just be noise.
614
+ function validateRouteMapBuildFunction(entry, index, line, relFile, routeMapPath, projectPath) {
615
+ const fn = entry.buildFunction;
616
+ const pageSource = entry.pageSourceFile;
617
+ if (typeof fn !== 'string' || !fn || typeof pageSource !== 'string' || !pageSource) {
618
+ return [];
619
+ }
620
+
621
+ // `pageSourceFile` is MODULE-relative (`src/main/ets/pages/X.ets`), not
622
+ // project-relative: observed in both a hand-authored profile and hvigor's own
623
+ // generated copy. The profile sits at <module>/src/main/resources/base/profile,
624
+ // so the module root is five levels up from that directory. Deriving it from
625
+ // the profile's own path keeps this correct for a nested `entry/` module and
626
+ // for a project whose single module IS the root.
627
+ const moduleRoot = path.resolve(path.dirname(routeMapPath), '..', '..', '..', '..', '..');
628
+ const pageAbs = path.resolve(moduleRoot, pageSource);
629
+ let pageText;
630
+ try { pageText = fs.readFileSync(pageAbs, 'utf-8'); } catch { return []; }
631
+
632
+ const escaped = escapeRegExp(fn);
633
+ // `@Builder` inline before the name, or on any preceding line -- the decorator
634
+ // chain may carry `export` between them.
635
+ const declared = new RegExp(`\\bfunction\\s+${escaped}\\b|@Builder[\\s\\S]{0,120}?\\b${escaped}\\s*\\(`).test(pageText);
636
+ const exported = new RegExp(`\\bexport\\b[\\s\\S]{0,80}?\\b${escaped}\\b`).test(pageText);
637
+ const decorated = new RegExp(`@Builder[\\s\\S]{0,120}?\\b${escaped}\\s*\\(`).test(pageText);
638
+
639
+ if (declared && exported && decorated) {
640
+ return [];
641
+ }
642
+
643
+ const remedy = !declared
644
+ ? `Define it in '${pageSource}' as an exported '@Builder' function.`
645
+ : !decorated
646
+ ? `'${fn}' exists in '${pageSource}' but carries no '@Builder' decorator. Add '@Builder' above it.`
647
+ : `'${fn}' exists in '${pageSource}' but is not exported. Add 'export' to its declaration.`;
648
+
649
+ return [{
650
+ file: relFile,
651
+ line,
652
+ column: 1,
653
+ severity: 'error',
654
+ rule: 'route-map-build-function-missing',
655
+ message: `The buildFunction '${fn}' configured in the routerMap json file does not exist. ${remedy}`,
656
+ }];
657
+ }
658
+
659
+ // 11211104: inside a qualifier directory (`base`, `dark`, `en_US`, ...) the
660
+ // resource compiler accepts only these three subdirectories. Anything else fails
661
+ // the build at CompileResource, before a single line of ArkTS is compiled.
662
+ const QUALIFIER_RESOURCE_DIRS = new Set(['element', 'media', 'profile']);
663
+
664
+ // Directories that live at the `resources/` top level, as siblings of the
665
+ // qualifier directories, NOT inside them. `resources/rawfile` is right;
666
+ // `resources/base/rawfile` is the mistake this rule catches -- one project put it
667
+ // there and lost its first build to `Invalid resource directory name 'rawfile'`,
668
+ // which no static check reported because nothing looked at directory layout.
669
+ const TOP_LEVEL_RESOURCE_DIRS = new Set(['rawfile', 'resfile']);
670
+
671
+ function validateResourceDirNames(projectPath) {
672
+ const diagnostics = [];
673
+ const roots = [
674
+ path.join(projectPath, 'entry', 'src', 'main', 'resources'),
675
+ path.join(projectPath, 'src', 'main', 'resources'),
676
+ ];
677
+
678
+ for (const root of roots) {
679
+ let qualifiers;
680
+ try { qualifiers = fs.readdirSync(root, { withFileTypes: true }); } catch { continue; }
681
+
682
+ for (const qualifier of qualifiers) {
683
+ if (!qualifier.isDirectory()) continue;
684
+ // A top-level entry is either a qualifier directory or rawfile/resfile;
685
+ // both are legal here, and only the former has constrained children.
686
+ if (TOP_LEVEL_RESOURCE_DIRS.has(qualifier.name)) continue;
687
+
688
+ const qualifierPath = path.join(root, qualifier.name);
689
+ let children;
690
+ try { children = fs.readdirSync(qualifierPath, { withFileTypes: true }); } catch { continue; }
691
+
692
+ for (const child of children) {
693
+ if (!child.isDirectory()) continue;
694
+ if (QUALIFIER_RESOURCE_DIRS.has(child.name)) continue;
695
+
696
+ const relDir = path.relative(projectPath, path.join(qualifierPath, child.name));
697
+ const misplaced = TOP_LEVEL_RESOURCE_DIRS.has(child.name);
698
+ const remedy = misplaced
699
+ ? `'${child.name}' belongs at the resources root, as a sibling of '${qualifier.name}'. Move it to '${path.relative(projectPath, path.join(root, child.name))}'.`
700
+ : `Valid values: ${[...QUALIFIER_RESOURCE_DIRS].map((d) => `"${d}"`).join(', ')}. Move its contents into one of those, or delete it.`;
701
+
702
+ diagnostics.push({
703
+ // Reported against the directory itself: there is no file to point at,
704
+ // and this is the path the build's own message names.
705
+ file: relDir,
706
+ line: 1,
707
+ column: 1,
708
+ severity: 'error',
709
+ rule: 'resource-dir-name',
710
+ message: `Invalid resource directory name '${child.name}'. ${remedy}`,
711
+ });
712
+ }
713
+ }
714
+ }
715
+ return diagnostics;
716
+ }
717
+
718
+ // A page registered in main_pages.json (or build-profile.json5) must contain
719
+ // exactly one top-level `@Entry`-decorated struct in its .ets file; hvigor
720
+ // fails the whole build (10905402) if it finds zero or more than one. The
721
+ // standalone linter never cross-references main_pages.json against decorator
722
+ // counts, so this is a real gap between "arkts_check says 0 errors" and a
723
+ // build that still fails. Counts only top-level (unindented) `@Entry` lines —
724
+ // nested/commented occurrences inside strings or block comments are out of
725
+ // scope for this lightweight heuristic.
726
+ function validatePageEntryCount(etsFile, projectPath, pagePath) {
727
+ let content;
728
+ try { content = fs.readFileSync(etsFile, 'utf-8'); } catch { return null; }
729
+ const entryLines = [];
730
+ const lines = content.split('\n');
731
+ for (let i = 0; i < lines.length; i++) {
732
+ if (/^\s*@Entry\b/.test(lines[i])) entryLines.push(i + 1);
733
+ }
734
+ const relFile = path.relative(projectPath, etsFile);
735
+ if (entryLines.length === 0) {
736
+ return {
737
+ file: relFile,
738
+ line: 1,
739
+ column: 1,
740
+ severity: 'error',
741
+ message: `Page '${pagePath}.ets' is registered in main_pages.json but has no '@Entry' decorator. A page file must have exactly one '@Entry' decorator.`,
742
+ rule: 'page-entry-count',
743
+ };
744
+ }
745
+ if (entryLines.length > 1) {
746
+ return {
747
+ file: relFile,
748
+ line: entryLines[1],
749
+ column: 1,
750
+ severity: 'error',
751
+ message: `Page '${pagePath}.ets' has ${entryLines.length} '@Entry' decorators (lines ${entryLines.join(', ')}). A page file must have exactly one '@Entry' decorator.`,
752
+ rule: 'page-entry-count',
753
+ };
754
+ }
755
+ return null;
756
+ }
757
+
758
+ // --- @Component vs @ComponentV2 member-decorator consistency ---
759
+ //
760
+ // ArkUI's V1 state decorators (@State/@Prop/@Link/@Provide/@Consume/@ObjectLink/
761
+ // @StorageLink/@StorageProp/@LocalStorageLink/@LocalStorageProp/@BuilderParam)
762
+ // only work inside a struct decorated with `@Component`; the V2 decorators
763
+ // (@Local/@Param/@Once/@Event/@Provider/@Consumer) only work inside a struct
764
+ // decorated with `@ComponentV2`. Both are syntactically valid decorators on
765
+ // their own, so the standalone linter's per-token parsing accepts either set
766
+ // on any struct — it validates decorator SYNTAX, not which component model the
767
+ // enclosing struct belongs to. hvigor's ArkTS linter rejects the mismatch at
768
+ // build time (10905338/10905339). This check is single-file only: it just
769
+ // needs the struct's own decorator plus its members' decorators, no cross-file
770
+ // type resolution (unlike the separate "@State property type is @ObservedV2"
771
+ // check, which is out of scope here).
772
+ // Mirrors hvigor's COMPONENT_MEMBER_DECORATOR_V1 (ets-loader/lib/constant_define.js),
773
+ // which is what validateStructDecorator tests for 10905339. '@BuilderParam' is
774
+ // deliberately NOT here even though it is a V1 decorator: @ComponentV2 structs
775
+ // support it too (ets-loader/lib/process_struct_componentV2.js has its own
776
+ // processBuilderParamProperty and a dedicated 10905107 diagnostic for it), so
777
+ // listing it made the legal `@ComponentV2 struct { @BuilderParam content: () => void }`
778
+ // a hard error. '@Watch' is likewise excluded -- hvigor allows it in both versions.
779
+ const V1_ONLY_MEMBER_DECORATORS = new Set([
780
+ 'State', 'Prop', 'Link', 'Provide', 'Consume', 'ObjectLink',
781
+ 'StorageLink', 'StorageProp', 'LocalStorageLink', 'LocalStorageProp',
782
+ ]);
783
+ const V2_ONLY_MEMBER_DECORATORS = new Set([
784
+ 'Local', 'Param', 'Once', 'Event', 'Provider', 'Consumer',
785
+ ]);
786
+
787
+ // `[@Decorators] [export [default]] struct Name {` — captures indent (1),
788
+ // inline decorator text (2), and the struct name (3). Shared by every check
789
+ // that needs to locate struct declarations and their decorators.
790
+ const STRUCT_DECL_RE = /^(\s*)((?:@\w+(?:\([^)]*\))?\s*)*)(?:export\s+(?:default\s+)?)?struct\s+(\w+)\b/;
791
+
792
+ // Collect `{lineIdx, name, inline}` for every struct declared in `lines`, in
793
+ // document order. Struct bodies are delimited by the next struct declaration
794
+ // (or EOF), matching the line-range convention the decorator checks already use.
795
+ function collectStructs(lines) {
796
+ const structs = [];
797
+ for (let i = 0; i < lines.length; i++) {
798
+ const m = STRUCT_DECL_RE.exec(lines[i]);
799
+ if (m) structs.push({ lineIdx: i, name: m[3], inline: m[2] });
800
+ }
801
+ return structs;
802
+ }
803
+
804
+ // Decorator names attached to the `struct Name {` declaration at `lines[structLineIdx]`:
805
+ // any decorators inline on that same line (`inlineDecoratorText`, e.g. `@Entry
806
+ // @ComponentV2 struct Foo {`) plus decorator-only lines walking backward
807
+ // (mirrors the decorator-chain walk in buildExportEdit).
808
+ function collectStructDecorators(lines, structLineIdx, inlineDecoratorText) {
809
+ const names = new Set();
810
+ const inlineRe = /@(\w+)/g;
811
+ let m;
812
+ while ((m = inlineRe.exec(inlineDecoratorText)) !== null) names.add(m[1]);
813
+ let i = structLineIdx - 1;
814
+ while (i >= 0 && /^\s*@\w+(?:\([^)]*\))?\s*$/.test(lines[i])) {
815
+ const dm = /@(\w+)/.exec(lines[i]);
816
+ if (dm) names.add(dm[1]);
817
+ i--;
818
+ }
819
+ return names;
820
+ }
821
+
822
+ // Single-file check: for every `@Component`/`@ComponentV2` struct, scan the
823
+ // lines between it and the next struct declaration (or EOF) for member-level
824
+ // decorators that belong to the OTHER component model. Line-range scanning
825
+ // (not brace matching) is a deliberate simplification — nested inner
826
+ // class/struct member decorators inside a component struct's body are rare in
827
+ // ArkTS page/component files and this stays a lightweight heuristic, not a
828
+ // full parser.
829
+ function validateComponentDecoratorConsistency(filePath, projectPath) {
830
+ let content;
831
+ try { content = fs.readFileSync(filePath, 'utf-8'); } catch { return []; }
832
+ const lines = content.split('\n');
833
+ // `export struct Foo {` is the overwhelmingly common form in generated ArkTS
834
+ // (roughly 2/3 of struct declarations across observed bootstrap output) — an
835
+ // earlier version of this regex only matched a bare `struct Foo {`, silently
836
+ // skipping every exported struct and defeating this check for most files.
837
+ const structs = collectStructs(lines);
838
+ if (structs.length === 0) return [];
839
+
840
+ const diagnostics = [];
841
+ const relFile = path.relative(projectPath, filePath);
842
+
843
+ for (let s = 0; s < structs.length; s++) {
844
+ const { lineIdx, name, inline } = structs[s];
845
+ const decorators = collectStructDecorators(lines, lineIdx, inline);
846
+ const isV2 = decorators.has('ComponentV2');
847
+ const isV1 = decorators.has('Component');
848
+ if (!isV1 && !isV2) continue; // not an ArkUI component struct (e.g. plain data struct)
849
+
850
+ const bodyStart = lineIdx + 1;
851
+ const bodyEnd = s + 1 < structs.length ? structs[s + 1].lineIdx : lines.length;
852
+ const forbidden = isV2 ? V1_ONLY_MEMBER_DECORATORS : V2_ONLY_MEMBER_DECORATORS;
853
+ const allowedIn = isV2 ? '@Component' : '@ComponentV2';
854
+ const actualVersion = isV2 ? '@ComponentV2' : '@Component';
855
+
856
+ for (let i = bodyStart; i < bodyEnd; i++) {
857
+ const dm = /^\s*@(\w+)\b/.exec(lines[i]);
858
+ if (!dm || !forbidden.has(dm[1])) continue;
859
+ diagnostics.push({
860
+ file: relFile,
861
+ line: i + 1,
862
+ column: 1,
863
+ severity: 'error',
864
+ rule: 'component-decorator-version-mismatch',
865
+ message: `The '@${dm[1]}' decorator can only be used in a 'struct' decorated with '${allowedIn}', but struct '${name}' is decorated with '${actualVersion}'.`,
866
+ });
867
+ }
868
+ }
869
+ return diagnostics;
870
+ }
871
+
872
+ function validateComponentDecorators(files, projectPath) {
873
+ const diagnostics = [];
874
+ for (const filePath of files) {
875
+ if (!fs.existsSync(filePath)) continue;
876
+ diagnostics.push(...validateComponentDecoratorConsistency(filePath, projectPath));
877
+ }
878
+ return diagnostics;
879
+ }
880
+
881
+ // --- @State/@Prop/@Provide/@Consume property type vs @ObservedV2 class ---
882
+ //
883
+ // V1 state decorators (@State/@Prop/@Provide/@Consume) bind their property
884
+ // through a Proxy that assumes the property's own class is either a plain
885
+ // value or V1-@Observed; a type decorated with @ObservedV2 (a V2-only "this
886
+ // class tracks its own @Trace fields" decorator) breaks that assumption and
887
+ // hvigor's ArkTS linter rejects it at build time (10905348). Unlike
888
+ // `component-decorator-version-mismatch` above (single-file: a member
889
+ // decorator vs. its OWN struct's decorator), this needs real cross-file
890
+ // resolution — the property's decorator lives in the component file, but the
891
+ // referenced type's @ObservedV2 decorator often lives in a different model
892
+ // file entirely. Implemented as two passes over the whole project file set:
893
+ // 1) index every top-level class decorated with @ObservedV2, by name
894
+ // 2) flag every @State/@Prop/@Provide/@Consume property whose simple type
895
+ // annotation matches a name in that index
896
+ // Deliberately name-keyed (no module/import resolution): two same-named
897
+ // classes in different files where only one is @ObservedV2 would be a false
898
+ // negative, but that pattern does not occur in generated ArkTS and a real
899
+ // symbol table is out of scope for this lightweight heuristic.
900
+ const V1_TYPE_SENSITIVE_DECORATORS = new Set(['State', 'Prop', 'Provide', 'Consume']);
901
+
902
+ const CLASS_DECL_RE = /^(\s*)((?:@\w+(?:\([^)]*\))?\s*)*)(?:export\s+(?:default\s+)?)?class\s+(\w+)\b/;
903
+
904
+ // Only a simple (bare identifier) type annotation is matched — `T[]`,
905
+ // `Array<T>`, `T | U`, `Map<K, V>` etc. are left alone. Every real build
906
+ // failure observed for this rule was a bare class reference (`@State vm:
907
+ // ViewModel = ...`); collection/union-typed state is a different, much rarer
908
+ // shape and guessing at it risks false positives for no observed payoff.
909
+ const STATE_PROPERTY_TYPE_RE = /^\s*@(State|Prop|Provide|Consume)\b(?:\([^)]*\))?\s+\w+\s*:\s*([A-Za-z_$][\w$]*)\b(?!\s*[<[.])/;
910
+
911
+ // SDK classes that hvigor treats as @ObservedV2 but that never appear as a
912
+ // declaration in project source, so collectObservedV2ClassNames' file scan can
913
+ // never see them. EMPTY ON PURPOSE, and adding a name is almost certainly wrong:
914
+ // hvigor's 10905348 check (ets-loader/lib/validate_ui_syntax.js,
915
+ // validatePropertyInStruct -> validatePropertyType -> parsePropertyType) reads
916
+ // the resolved type's own `symbol.valueDeclaration` decorators through the TS
917
+ // checker, and no class in the ArkUI component declarations carries @ObservedV2
918
+ // -- `ets/component/*.d.ts` mentions it only as the
919
+ // `declare const ObservedV2: ClassDecorator` definition. The SDK classes that do
920
+ // carry it live in `@ohos.arkui.advanced.{ArcSlider,DialogV2,SubHeaderV2,
921
+ // ProgressButtonV2,SegmentButtonV2,ToolBarV2}.d.ets` and are never used as
922
+ // @State/@Prop property types. 'NavPathStack' was seeded here from a
923
+ // misattributed bootstrap failure -- `declare class NavPathStack` in
924
+ // `ets/component/navigation.d.ts` carries no decorators at all -- and it made the
925
+ // legal `@Prop pathStack: NavPathStack = new NavPathStack()` a hard error.
926
+ const SDK_OBSERVED_V2_CLASSES = new Set();
927
+
928
+ // The @ObservedV2 class index must be built from the WHOLE project, not just
929
+ // the checked `files`: the decorated class lives in a model file while the
930
+ // offending @State property lives in a page/component file, and the agent
931
+ // typically batches a check over one of those groups but not both (observed in
932
+ // bootstrap task-009: a 23-file check covering every model and zero pages, so
933
+ // the cross-file pair never co-occurred and 5 real 10905348 errors reached
934
+ // hvigor). Scanning every project .ets costs ~20 ms (recursive walk + read of
935
+ // ~80 KB), which is noise next to runChecker's multi-second tsc pass, and can
936
+ // only ever ADD declarations to the index — the reported diagnostics are still
937
+ // keyed to properties found in `files`, so widening this cannot introduce a
938
+ // false positive on a file the caller did not ask about.
939
+ function collectObservedV2ClassNames(files, projectPath) {
940
+ const names = new Set(SDK_OBSERVED_V2_CLASSES);
941
+ const declarationFiles = projectPath ? unionProjectFiles(files, projectPath) : files;
942
+ for (const filePath of declarationFiles) {
943
+ let content;
944
+ try { content = fs.readFileSync(filePath, 'utf-8'); } catch { continue; }
945
+ const lines = content.split('\n');
946
+ for (let i = 0; i < lines.length; i++) {
947
+ const m = CLASS_DECL_RE.exec(lines[i]);
948
+ if (!m) continue;
949
+ const decorators = collectStructDecorators(lines, i, m[2]);
950
+ if (decorators.has('ObservedV2')) names.add(m[3]);
951
+ }
952
+ }
953
+ return names;
954
+ }
955
+
956
+ function validateObservedV2PropertyTypes(files, projectPath) {
957
+ const observedV2Classes = collectObservedV2ClassNames(files, projectPath);
958
+ if (observedV2Classes.size === 0) return [];
959
+
960
+ const diagnostics = [];
961
+ for (const filePath of files) {
962
+ let content;
963
+ try { content = fs.readFileSync(filePath, 'utf-8'); } catch { continue; }
964
+ const lines = content.split('\n');
965
+ const relFile = path.relative(projectPath, filePath);
966
+ for (let i = 0; i < lines.length; i++) {
967
+ const m = STATE_PROPERTY_TYPE_RE.exec(lines[i]);
968
+ if (!m) continue;
969
+ const decoratorName = m[1];
970
+ const typeName = m[2];
971
+ if (!observedV2Classes.has(typeName)) continue;
972
+ diagnostics.push({
973
+ file: relFile,
974
+ line: i + 1,
975
+ column: 1,
976
+ severity: 'error',
977
+ rule: 'observed-v2-state-property-type',
978
+ message: `The type of the '@${decoratorName}' property can not be a class decorated with '@ObservedV2'. '${typeName}' is decorated with '@ObservedV2'; use a plain class here, or move this member to a '@ComponentV2' struct using '@Local'/'@Param' instead of '@${decoratorName}'.`,
979
+ });
980
+ }
981
+ }
982
+ return diagnostics;
983
+ }
984
+
985
+ // --- ArkUI component-model rules (10905xxx) not covered by the ArkTS linter ---
986
+ //
987
+ // Everything below validates the ArkUI COMPONENT MODEL, which only hvigor's
988
+ // CompileArkTS task checks. etsStandaloneChecker (what runChecker runs) validates
989
+ // ArkTS syntax and types, so these violations pass the static check cleanly and
990
+ // only surface as a failed build — 25 of 25 compiler errors across one 16-case
991
+ // bootstrap round were of this kind, and raising arkts_check frequency cannot
992
+ // help because the checker never looks at these rules.
993
+
994
+ // In a '@ComponentV2' struct, a member with NO decorator is a "regular" property
995
+ // and ArkUI forbids the call site from initializing it (10905324). The common
996
+ // instance is a callback (`onBack`, `onCardClick`) declared bare and then passed
997
+ // as `Child({ onBack: () => {...} })`; the fix is to decorate it '@Param'.
998
+ //
999
+ // V1 '@Component' structs are DELIBERATELY EXCLUDED: there, passing an
1000
+ // undecorated member from the parent is the ordinary way to hand a component its
1001
+ // callbacks, and it compiles cleanly (verified against bootstrap projects that
1002
+ // build successfully with exactly this shape, e.g. `onPause: () => void = () =>
1003
+ // {}` in a '@Component' struct initialized by its caller). Flagging those was a
1004
+ // false positive on 13 call sites across 3 passing projects.
1005
+ //
1006
+ // Detection needs the component's declaration (which members are undecorated)
1007
+ // and its call sites, which usually live in different files — so the member index
1008
+ // is built over the whole project, while diagnostics are only reported for the
1009
+ // caller files the tool was asked to check.
1010
+ const REGULAR_PROPERTY_DECL_RE = /^\s*(?:private\s+|protected\s+|public\s+|readonly\s+)*([A-Za-z_$][\w$]*)\s*(?:\?|!)?\s*:\s*[^=;]+(?:=|$|;)/;
1011
+
1012
+ // Members that are methods/builders, not data properties: never "regular
1013
+ // properties" in the 10905324 sense.
1014
+ const NON_PROPERTY_MEMBER_RE = /^\s*(?:private\s+|protected\s+|public\s+|static\s+|async\s+)*(?:build|aboutToAppear|aboutToDisappear|onPageShow|onPageHide|onBackPress|onDidBuild|pageTransition)\s*\(/;
1015
+
1016
+ // A '@Local' member is the V2 struct's OWN state: ArkUI forbids the parent from
1017
+ // specifying it at the call site too (same "cannot be initialized here" family as
1018
+ // 10905324, reported by hvigor as 10905208/10905209). It is indexed alongside the
1019
+ // undecorated members because both are answered by the same call-site walk; only
1020
+ // the wording of the fix differs ('@Local' -> '@Param', undecorated -> add '@Param').
1021
+ const LOCAL_MEMBER_DECL_RE = /^\s*@Local\b(?:\([^)]*\))?\s+(?:private\s+|protected\s+|public\s+|readonly\s+)*([A-Za-z_$][\w$]*)\s*(?:\?|!)?\s*[:=]/;
1022
+
1023
+ // Net '{' minus '}' on a line, ignoring braces inside string literals, template
1024
+ // literals, block comments and after a line comment. Used to tell a struct's
1025
+ // DIRECT members (brace depth 1) from everything nested inside a method or
1026
+ // build() body.
1027
+ //
1028
+ // `state` carries quote/comment context ACROSS lines and MUST be threaded through
1029
+ // a whole file scan. Without it every line restarts as "not in a string", so a
1030
+ // multi-line template literal leaks its contents into the brace count: a stray
1031
+ // '}' inside the literal's text pushes depth below the struct body and the
1032
+ // following lines get mistaken for member declarations. That is exactly how
1033
+ // bootstrap task-012 produced an unfixable 'regular-property-init' — a nested
1034
+ // call-site argument (`viewModel: this.viewModel`) was indexed as an undecorated
1035
+ // member of the very struct that declared it '@Param', so no edit to the
1036
+ // declaration could clear the diagnostic and the component had to be deleted.
1037
+ function countBraceDelta(line, state) {
1038
+ const st = state || { quote: null, inBlockComment: false };
1039
+ let delta = 0;
1040
+ for (let i = 0; i < line.length; i++) {
1041
+ const ch = line[i];
1042
+ if (st.inBlockComment) {
1043
+ if (ch === '*' && line[i + 1] === '/') { st.inBlockComment = false; i++; }
1044
+ continue;
1045
+ }
1046
+ if (st.quote) {
1047
+ if (ch === '\\') { i++; continue; }
1048
+ // Only a template literal spans lines; an unterminated '/" is a lexical
1049
+ // error, so resetting at EOL keeps one bad line from corrupting the rest.
1050
+ if (ch === st.quote) st.quote = null;
1051
+ continue;
1052
+ }
1053
+ if (ch === '/' && line[i + 1] === '*') { st.inBlockComment = true; i++; continue; }
1054
+ if (ch === '/' && line[i + 1] === '/') break;
1055
+ if (ch === '"' || ch === "'" || ch === '`') { st.quote = ch; continue; }
1056
+ if (ch === '{') delta++;
1057
+ else if (ch === '}') delta--;
1058
+ }
1059
+ if (st.quote === '"' || st.quote === "'") st.quote = null;
1060
+ return delta;
1061
+ }
1062
+
1063
+ // End line (exclusive) of the struct body opening at `lines[lineIdx]`, found by
1064
+ // brace matching rather than by "wherever the next struct declaration starts".
1065
+ // The line-range approximation over-runs in two ways that both cause false
1066
+ // positives: the LAST struct in a file swallows every trailing helper/export, and
1067
+ // any struct whose successor's declaration line is not matched absorbs that
1068
+ // successor's whole body -- including its call sites.
1069
+ function findStructBodyEnd(lines, lineIdx, state) {
1070
+ let depth = countBraceDelta(lines[lineIdx], state);
1071
+ if (depth <= 0) return lineIdx + 1;
1072
+ for (let i = lineIdx + 1; i < lines.length; i++) {
1073
+ depth += countBraceDelta(lines[i], state);
1074
+ if (depth <= 0) return i + 1;
1075
+ }
1076
+ return lines.length;
1077
+ }
1078
+
1079
+ // componentName -> Map(member name -> 'regular' | 'local'), one entry per struct
1080
+ // DECLARATION rather than per name. Two different files may each declare a
1081
+ // '@ComponentV2 struct Card'; merging them by bare name lets one file's
1082
+ // undecorated member flag the other file's correctly-'@Param'-decorated one. The
1083
+ // call-site walk resolves a name to candidate declarations and only reports a key
1084
+ // that every candidate agrees is undecorated, so an ambiguous name cannot produce
1085
+ // a false positive.
1086
+ function collectComponentRegularProperties(files) {
1087
+ const index = new Map();
1088
+ const add = (name, members) => {
1089
+ if (members.size === 0) return;
1090
+ const list = index.get(name);
1091
+ if (list) list.push(members);
1092
+ else index.set(name, [members]);
1093
+ };
1094
+ for (const filePath of files) {
1095
+ let content;
1096
+ try { content = fs.readFileSync(filePath, 'utf-8'); } catch { continue; }
1097
+ const lines = content.split('\n');
1098
+ const structs = collectStructs(lines);
1099
+ for (let s = 0; s < structs.length; s++) {
1100
+ const { lineIdx, name, inline } = structs[s];
1101
+ const decorators = collectStructDecorators(lines, lineIdx, inline);
1102
+ // V2 only — see the note above on why '@Component' is excluded.
1103
+ if (!decorators.has('ComponentV2')) continue;
1104
+
1105
+ // Brace-matched, and clamped to the next struct declaration so that a
1106
+ // miscounted body cannot run past a sibling and absorb its call sites.
1107
+ const nextDecl = s + 1 < structs.length ? structs[s + 1].lineIdx : lines.length;
1108
+ const matched = findStructBodyEnd(lines, lineIdx, { quote: null, inBlockComment: false });
1109
+ const bodyEnd = Math.min(matched, nextDecl);
1110
+ const members = new Map();
1111
+ // Brace depth relative to the struct body: 1 == a direct member of the
1112
+ // struct, >1 == inside a method/build()/@Builder body or a nested literal.
1113
+ // Only depth 1 is scanned. Without this, a call site the component itself
1114
+ // writes inside build() -- `Child({ themeColor: this.themeColor, })` --
1115
+ // matches REGULAR_PROPERTY_DECL_RE and gets indexed as an undecorated
1116
+ // member of the ENCLOSING struct, so every caller passing that same
1117
+ // property name is then flagged. That was 8 false positives on one
1118
+ // bootstrap project whose theme color threads down four component levels,
1119
+ // all of them on properties correctly declared '@Param'.
1120
+ const state = { quote: null, inBlockComment: false };
1121
+ let depth = countBraceDelta(lines[lineIdx], state);
1122
+ for (let i = lineIdx + 1; i < bodyEnd; i++) {
1123
+ const line = lines[i];
1124
+ // Whether THIS line sits at member level is decided before its own braces
1125
+ // are applied: a method declaration is a member-level line, but what
1126
+ // follows it is not.
1127
+ const atMemberLevel = depth === 1;
1128
+ const insideText = state.quote !== null || state.inBlockComment;
1129
+ depth += countBraceDelta(line, state);
1130
+ // A line that STARTS inside a template literal or block comment is prose,
1131
+ // never a declaration.
1132
+ if (!atMemberLevel || insideText) continue;
1133
+
1134
+ // Skip a decorated member (and anything that is not a property decl).
1135
+ if (/^\s*@\w+/.test(line)) {
1136
+ const local = LOCAL_MEMBER_DECL_RE.exec(line);
1137
+ if (local) members.set(local[1], 'local');
1138
+ // Decorators on their own lines decorate the NEXT non-decorator line;
1139
+ // consume the whole chain. `@Param` and `@Require` are routinely stacked
1140
+ // one per line, and consuming only a single line left the real member
1141
+ // declaration to fall through to REGULAR_PROPERTY_DECL_RE below — which
1142
+ // indexed a correctly-decorated '@Param' as undecorated.
1143
+ if (/^\s*@\w+(?:\([^)]*\))?\s*$/.test(line)) {
1144
+ let j = i;
1145
+ let sawLocal = /^\s*@Local\s*$/.test(line);
1146
+ while (j + 1 < bodyEnd && /^\s*@\w+(?:\([^)]*\))?\s*$/.test(lines[j + 1])) {
1147
+ j++;
1148
+ if (/^\s*@Local\s*$/.test(lines[j])) sawLocal = true;
1149
+ depth += countBraceDelta(lines[j], state);
1150
+ }
1151
+ if (j + 1 < bodyEnd) {
1152
+ j++;
1153
+ const next = /^\s*(?:private\s+|protected\s+|public\s+|readonly\s+)*([A-Za-z_$][\w$]*)\s*(?:\?|!)?\s*[:=]/.exec(lines[j]);
1154
+ // The decorated member is accounted for either way: recorded as
1155
+ // '@Local', or simply consumed so it is not read as undecorated.
1156
+ if (next && sawLocal) members.set(next[1], 'local');
1157
+ // The consumed line's braces still count (`@Builder\nfoo() {`).
1158
+ depth += countBraceDelta(lines[j], state);
1159
+ }
1160
+ i = j;
1161
+ }
1162
+ continue;
1163
+ }
1164
+ if (NON_PROPERTY_MEMBER_RE.test(line)) continue;
1165
+ // A method declaration: `name(args) {` / `name(): T {`
1166
+ if (/^\s*(?:private\s+|protected\s+|public\s+|static\s+|async\s+)*[A-Za-z_$][\w$]*\s*\([^)]*\)\s*(?::[^{]+)?\{/.test(line)) continue;
1167
+ const m = REGULAR_PROPERTY_DECL_RE.exec(line);
1168
+ if (m) members.set(m[1], 'regular');
1169
+ }
1170
+ add(name, members);
1171
+ }
1172
+ }
1173
+ return index;
1174
+ }
1175
+
1176
+ // Independent confirmation that `member` really is undecorated in every
1177
+ // declaration of `componentName`: scan each declaring struct's text for a state
1178
+ // decorator attached to that member, inline (`@Param x: T`) or on the lines above
1179
+ // it. Deliberately NOT brace-depth aware — it answers "does a decorator for this
1180
+ // name appear anywhere in this struct's text", which is the safe direction to err
1181
+ // in: a stray match suppresses a diagnostic, it can never invent one.
1182
+ const STATE_MEMBER_DECORATORS = /@(Param|Local|Once|Require|Event|Provider|Consumer|State|Prop|Link|ObjectLink|Provide|Consume|StorageLink|StorageProp|LocalStorageLink|LocalStorageProp|BuilderParam|Builder)\b/;
1183
+
1184
+ function declaresDecoratedMember(declarationFiles, componentName, member) {
1185
+ const memberRe = new RegExp(`^\\s*(?:@\\w+(?:\\([^)]*\\))?\\s+)*(?:private\\s+|protected\\s+|public\\s+|readonly\\s+)*${member}\\s*(?:\\?|!)?\\s*[:=]`);
1186
+ for (const filePath of declarationFiles) {
1187
+ let content;
1188
+ try { content = fs.readFileSync(filePath, 'utf-8'); } catch { continue; }
1189
+ if (!content.includes(componentName) || !content.includes(member)) continue;
1190
+ const lines = content.split('\n');
1191
+ const structs = collectStructs(lines);
1192
+ for (let s = 0; s < structs.length; s++) {
1193
+ if (structs[s].name !== componentName) continue;
1194
+ const nextDecl = s + 1 < structs.length ? structs[s + 1].lineIdx : lines.length;
1195
+ const bodyEnd = Math.min(
1196
+ findStructBodyEnd(lines, structs[s].lineIdx, { quote: null, inBlockComment: false }),
1197
+ nextDecl,
1198
+ );
1199
+ for (let i = structs[s].lineIdx + 1; i < bodyEnd; i++) {
1200
+ if (!memberRe.test(lines[i])) continue;
1201
+ // Inline decorators on the declaration line itself.
1202
+ if (STATE_MEMBER_DECORATORS.test(lines[i])) return true;
1203
+ // Or a chain of decorator-only lines directly above it.
1204
+ for (let j = i - 1; j >= 0 && /^\s*@\w+(?:\([^)]*\))?\s*$/.test(lines[j]); j--) {
1205
+ if (STATE_MEMBER_DECORATORS.test(lines[j])) return true;
1206
+ }
1207
+ }
1208
+ }
1209
+ }
1210
+ return false;
1211
+ }
1212
+
1213
+ function validateRegularPropertyInit(files, projectPath) {
1214
+ const declarationFiles = projectPath ? unionProjectFiles(files, projectPath) : files;
1215
+ const index = collectComponentRegularProperties(declarationFiles);
1216
+ if (index.size === 0) return [];
1217
+
1218
+ const diagnostics = [];
1219
+ for (const filePath of files) {
1220
+ let content;
1221
+ try { content = fs.readFileSync(filePath, 'utf-8'); } catch { continue; }
1222
+ const lines = content.split('\n');
1223
+ const relFile = path.relative(projectPath, filePath);
1224
+
1225
+ for (let i = 0; i < lines.length; i++) {
1226
+ // Call site: `ComponentName({` — the multi-line object-literal form. The
1227
+ // single-line form `Child({ a: 1 })` is matched by the same regex.
1228
+ const call = /(?:^|[^\w.$])([A-Z][\w$]*)\s*\(\s*\{/.exec(lines[i]);
1229
+ if (!call) continue;
1230
+ const componentName = call[1];
1231
+ const candidates = index.get(componentName);
1232
+ if (!candidates) continue;
1233
+ // A name may resolve to several declarations across the project. Report only
1234
+ // what EVERY candidate agrees on, and prefer the stricter 'local' wording
1235
+ // only when it is unanimous too.
1236
+ const members = new Map();
1237
+ for (const [key, kind] of candidates[0]) {
1238
+ if (candidates.every((c) => c.get(key) === kind)) members.set(key, kind);
1239
+ }
1240
+ if (members.size === 0) continue;
1241
+
1242
+ // Walk the argument object until its braces balance, flagging keys that
1243
+ // name a regular property. Keys are matched against the literal's own text
1244
+ // span (not anchored to line start) so both the multi-line form and the
1245
+ // single-line `Child({ cb: () => {} })` form are covered.
1246
+ let depth = 0;
1247
+ let started = false;
1248
+ for (let j = i; j < lines.length; j++) {
1249
+ // On the call's own line, skip past `Component(` so the component name is
1250
+ // not mistaken for a key; later lines are scanned whole.
1251
+ const offset = j === i ? call.index + call[0].length : 0;
1252
+ const text = lines[j].slice(offset);
1253
+
1254
+ // The call regex already consumed the literal's opening `{`, so on the
1255
+ // call's own line we start INSIDE the object at depth 1.
1256
+ if (j === i) { depth = 1; started = true; }
1257
+
1258
+ // Only keys at the literal's TOP level (depth 1) are its own properties;
1259
+ // a nested object's keys belong to that object.
1260
+ const keyRe = /(?:^|[{,])\s*([A-Za-z_$][\w$]*)\s*:/g;
1261
+ const topLevelKeys = new Set();
1262
+ let km;
1263
+ while ((km = keyRe.exec(text)) !== null) {
1264
+ // Count braces up to the KEY IDENTIFIER, not to the match start: the
1265
+ // regex consumes its own '{' delimiter, so measuring to km.index leaves
1266
+ // that brace uncounted and a nested literal's keys read as depth 1.
1267
+ // `Child({ cfg: { tag: 'x' } })` then flagged 'tag' as Child's own
1268
+ // property.
1269
+ const idOffset = km.index + km[0].indexOf(km[1]);
1270
+ let d = depth;
1271
+ for (let k = 0; k < idOffset; k++) {
1272
+ if (text[k] === '{') d++;
1273
+ else if (text[k] === '}') d--;
1274
+ }
1275
+ if (d === 1) topLevelKeys.add(km[1]);
1276
+ }
1277
+
1278
+ let local = depth;
1279
+ for (const ch of text) {
1280
+ if (ch === '{') { local++; started = true; }
1281
+ else if (ch === '}') local--;
1282
+ }
1283
+ depth = local;
1284
+
1285
+ for (const key of topLevelKeys) {
1286
+ const kind = members.get(key);
1287
+ if (!kind) continue;
1288
+ // Last-resort guard: the index is heuristic, so confirm against the raw
1289
+ // declaration text before accusing a property of being undecorated. If
1290
+ // ANY declaration of this component carries a state decorator on this
1291
+ // member, the index entry is wrong and reporting it would produce a
1292
+ // diagnostic the model cannot fix by writing correct code.
1293
+ if (kind === 'regular' && declaresDecoratedMember(declarationFiles, componentName, key)) continue;
1294
+ diagnostics.push({
1295
+ file: relFile,
1296
+ line: j + 1,
1297
+ column: 1,
1298
+ severity: 'error',
1299
+ rule: kind === 'local' ? 'local-property-init' : 'regular-property-init',
1300
+ message:
1301
+ kind === 'local'
1302
+ ? `The '@Local' property '${key}' in the custom component '${componentName}' cannot be initialized here (forbidden to specify). '@Local' is component-private state; change '${key}' to '@Param' in '${componentName}' if the parent must supply it, or drop it from this call site.`
1303
+ : `The 'regular' property '${key}' in the custom component '${componentName}' cannot be initialized here (forbidden to specify). '${key}' is declared without a decorator in the '@ComponentV2' struct '${componentName}'; decorate it with '@Param' so the parent can pass it.`,
1304
+ });
1305
+ }
1306
+
1307
+ if (started && depth <= 0) break;
1308
+ }
1309
+ }
1310
+ }
1311
+ return diagnostics;
1312
+ }
1313
+
1314
+ // An @Entry component's build() must contain exactly one root node, and that
1315
+ // node must be a container (10905210). Two adjacent top-level components, or a
1316
+ // single non-container like Image, both fail the build. Counting top-level
1317
+ // statements inside build() is a brace-depth question, not a typing one.
1318
+ //
1319
+ // hvigor does NOT curate a container list: component_map.js sorts every entry of
1320
+ // ets-loader/components/*.json by its own `atomic` flag into AUTOMIC_COMPONENT vs
1321
+ // BUILDIN_CONTAINER_COMPONENT, and checkContainer() tests the latter. So this set
1322
+ // is that derivation -- every non-atomic component in the SDK's component
1323
+ // descriptors (API 20) -- not a hand-picked subset. Hand-picking is what made
1324
+ // `Text() { ... }` as an @Entry root a false error: Text is non-atomic (it takes
1325
+ // Span/ImageSpan children) and hvigor accepts it as the root, as do Button,
1326
+ // Checkbox, Menu, Select, Toggle and ~40 others the curated list omitted. Four
1327
+ // names it wrongly INCLUDED (AlphabetIndexer, ContentSlot, NodeContainer,
1328
+ // RemoteWindow) are atomic and are now correctly absent. Regenerate from the SDK
1329
+ // rather than editing by hand; XComponent is container-capable only with an
1330
+ // object-literal argument, a distinction this line-based check cannot make and
1331
+ // deliberately resolves in the permissive direction.
1332
+ const CONTAINER_COMPONENTS = new Set([
1333
+ 'ArcList', 'ArcListItem', 'ArcScrollBar', 'ArcSwiper', 'Badge', 'Button', 'Calendar', 'Canvas',
1334
+ 'Checkbox', 'CheckboxGroup', 'ColorPicker', 'ColorPickerDialog', 'Column', 'ColumnSplit',
1335
+ 'ContainerSpan', 'Counter', 'DataPanel', 'DatePicker', 'EffectComponent', 'Flex', 'FlowItem',
1336
+ 'FolderStack', 'FormLink', 'Gauge', 'Grid', 'GridCol', 'GridContainer', 'GridItem', 'GridRow',
1337
+ 'Hyperlink', 'IsolatedComponent', 'LazyVGridLayout', 'List', 'ListItem', 'ListItemGroup', 'Menu',
1338
+ 'MenuItem', 'MenuItemGroup', 'NavDestination', 'NavRouter', 'Navigation', 'Navigator', 'Option',
1339
+ 'Panel', 'Piece', 'PluginComponent', 'QRCode', 'Rating', 'Refresh', 'RelativeContainer',
1340
+ 'Repeat', 'RootScene', 'Row', 'RowSplit', 'Screen', 'Scroll', 'ScrollBar', 'Section', 'Select',
1341
+ 'Shape', 'Sheet', 'SideBarContainer', 'Stack', 'Stepper', 'StepperItem', 'Swiper', 'TabContent',
1342
+ 'Tabs', 'Text', 'TextClock', 'TextPicker', 'TextTimer', 'TimePicker', 'Toggle', 'ToolBarItem',
1343
+ 'WaterFlow', 'WindowScene', 'WithTheme', 'XComponent', 'XComponentNode',
1344
+ ]);
1345
+
1346
+ // The API surface the checker type-checks against, read from the project's own
1347
+ // build-profile.json5 instead of assumed.
1348
+ //
1349
+ // runChecker used to hardcode `sdkInfo: '5.0.0'`, `compatibleSdkVersion: 12` and
1350
+ // `runtimeOS: 'OpenHarmony'`. Every project in an observed 26-task run declared
1351
+ // `6.1.1(24)` and `HarmonyOS`, so the checker was resolving a different API
1352
+ // surface than the build -- which is exactly the shape of a check that passes on
1353
+ // code the build then rejects for a missing member. Whether it explains any
1354
+ // specific miss is unverified (that needs a DevEco SDK to test), but passing the
1355
+ // project's real values removes the discrepancy either way.
1356
+ //
1357
+ // `compatibleSdkVersion` comes in two forms: a bare API number (`12`) and the
1358
+ // versioned form (`"6.1.1(24)"`), where the parenthesised number is the API
1359
+ // level. When the field is absent or unrecognised, falls back to the SDK's own
1360
+ // `oh-uni-package.json` `apiVersion` — the API level the installed SDK actually
1361
+ // targets — instead of a hardcoded 12 that produces false-positive version
1362
+ // warnings.
1363
+ const DEFAULT_SDK_INFO = '5.0.0';
1364
+ const DEFAULT_RUNTIME_OS = 'OpenHarmony';
1365
+
1366
+ function parseCompatibleSdkVersion(raw) {
1367
+ if (typeof raw === 'number' && Number.isFinite(raw)) {
1368
+ return { apiLevel: raw, sdkInfo: undefined };
1369
+ }
1370
+ if (typeof raw !== 'string') {
1371
+ return { apiLevel: undefined, sdkInfo: undefined };
1372
+ }
1373
+ // `"6.1.1(24)"` -> API 24, SDK 6.1.1.
1374
+ const versioned = /^\s*(\d+(?:\.\d+)*)\s*\(\s*(\d+)\s*\)\s*$/.exec(raw);
1375
+ if (versioned) {
1376
+ return { apiLevel: Number(versioned[2]), sdkInfo: versioned[1] };
1377
+ }
1378
+ // A bare number in string form (`"12"`).
1379
+ const bare = /^\s*(\d+)\s*$/.exec(raw);
1380
+ if (bare) {
1381
+ return { apiLevel: Number(bare[1]), sdkInfo: undefined };
1382
+ }
1383
+ return { apiLevel: undefined, sdkInfo: undefined };
1384
+ }
1385
+
1386
+ // Reads the `apiVersion` field from the bundled OpenHarmony SDK's
1387
+ // `oh-uni-package.json`. This is the API level the installed SDK targets, and
1388
+ // is the correct fallback when a project's build-profile.json5 doesn't declare
1389
+ // `compatibleSdkVersion`.
1390
+ function readSdkApiLevel(sdkDir) {
1391
+ if (!sdkDir) return undefined;
1392
+ const candidates = [
1393
+ path.join(sdkDir, 'ets', 'oh-uni-package.json'),
1394
+ ];
1395
+ for (const p of candidates) {
1396
+ try {
1397
+ const content = JSON.parse(fs.readFileSync(p, 'utf-8'));
1398
+ if (content && content.apiVersion !== undefined) {
1399
+ return Number(content.apiVersion);
1400
+ }
1401
+ } catch { /* try next candidate */ }
1402
+ }
1403
+ return undefined;
1404
+ }
1405
+
1406
+ function readProjectSdkConfig(projectPath, sdkDir) {
1407
+ const profilePath = path.join(projectPath, 'build-profile.json5');
1408
+ let parsed = null;
1409
+ try {
1410
+ parsed = parseJson5Loose(fs.readFileSync(profilePath, 'utf-8'));
1411
+ } catch {
1412
+ parsed = null;
1413
+ }
1414
+
1415
+ const products = parsed && parsed.app && Array.isArray(parsed.app.products) ? parsed.app.products : [];
1416
+ // `default` is the product hvigor builds unless told otherwise, and it is the
1417
+ // one whose SDK the check should match; fall back to the first declared.
1418
+ const product = products.find((p) => p && p.name === 'default') || products[0] || null;
1419
+
1420
+ const { apiLevel, sdkInfo } = parseCompatibleSdkVersion(product ? product.compatibleSdkVersion : undefined);
1421
+ const runtimeOS = product && typeof product.runtimeOS === 'string' && product.runtimeOS
1422
+ ? product.runtimeOS
1423
+ : DEFAULT_RUNTIME_OS;
1424
+
1425
+ // When the project profile doesn't declare compatibleSdkVersion (or the value
1426
+ // can't be parsed), fall back to the installed SDK's apiVersion. This avoids
1427
+ // false-positive "supported since SDK version X" warnings that arise when the
1428
+ // checker defaults to a low hardcoded API level.
1429
+ let resolvedApiLevel = apiLevel;
1430
+ if (resolvedApiLevel === undefined) {
1431
+ resolvedApiLevel = readSdkApiLevel(sdkDir);
1432
+ }
1433
+
1434
+ return {
1435
+ sdkInfo: sdkInfo || DEFAULT_SDK_INFO,
1436
+ compatibleSdkVersion: resolvedApiLevel,
1437
+ runtimeOS,
1438
+ };
1439
+ }
1440
+
1441
+ // 10905227: a custom struct may not take the name of a built-in component. The
1442
+ // name resolves to the SDK component, so the struct is unreachable and every use
1443
+ // type-checks against the wrong thing -- which is why the build usually reports a
1444
+ // second, more confusing error alongside it (a `ColorPicker` struct also drew
1445
+ // `Cannot find name 'ColorPickerAttribute'`, and that one disappeared on rename).
1446
+ //
1447
+ // hvigor tests INNER_COMPONENT_NAMES, which component_map.js fills with EVERY key
1448
+ // of ets-loader/components/*.json regardless of `atomic`. Together with
1449
+ // CONTAINER_COMPONENTS (the non-atomic half) this set -- the atomic half -- is that
1450
+ // full list, derived rather than curated. The previous hand-picked version both
1451
+ // missed real collisions and invented five that do not exist: Chip, ChipGroup,
1452
+ // CalendarPickerDialog, MovingPhotoView and SecurityUIExtensionComponent are not
1453
+ // built-in components at all but ordinary exports from `@ohos.arkui.advanced.*` /
1454
+ // `@ohos.multimedia.movingphotoview`, so a struct may legally take those names and
1455
+ // flagging them blocked a name the compiler accepts. Regenerate from the SDK
1456
+ // component descriptors rather than editing by hand.
1457
+ const BUILTIN_LEAF_COMPONENTS = new Set([
1458
+ 'AbilityComponent', 'AlphabetIndexer', 'Animator', 'ArcAlphabetIndexer', 'Blank',
1459
+ 'CalendarPicker', 'Camera', 'Circle', 'Component3D', 'ContentSlot', 'Divider', 'DotMatrix',
1460
+ 'Ellipse', 'EmbeddedComponent', 'FormComponent', 'FrictionMotion', 'GeometryView', 'Image',
1461
+ 'ImageAnimator', 'ImageSpan', 'IndicatorComponent', 'Line', 'LoadingProgress', 'LocationButton',
1462
+ 'Marquee', 'MediaCachedImage', 'NodeContainer', 'PageTransitionEnter', 'PageTransitionExit',
1463
+ 'Particle', 'PasteButton', 'Path', 'PatternLock', 'Polygon', 'Polyline', 'Progress', 'Radio',
1464
+ 'Rect', 'RemoteWindow', 'RichEditor', 'RichText', 'SaveButton', 'ScrollMotion', 'Search',
1465
+ 'Slider', 'Span', 'SpringMotion', 'SpringProp', 'SymbolGlyph', 'SymbolSpan', 'TextArea',
1466
+ 'TextInput', 'UIExtensionComponent', 'Video', 'Web',
1467
+ ]);
1468
+
1469
+ function isBuiltinComponentName(name) {
1470
+ return CONTAINER_COMPONENTS.has(name) || BUILTIN_LEAF_COMPONENTS.has(name);
1471
+ }
1472
+
1473
+ function validateStructNameCollisions(files, projectPath) {
1474
+ const diagnostics = [];
1475
+ for (const filePath of files) {
1476
+ let content;
1477
+ try { content = fs.readFileSync(filePath, 'utf-8'); } catch { continue; }
1478
+ const lines = content.split('\n');
1479
+ const relFile = path.relative(projectPath, filePath);
1480
+
1481
+ for (const struct of collectStructs(lines)) {
1482
+ if (!isBuiltinComponentName(struct.name)) continue;
1483
+ // hvigor reports a 0-based column here, unlike its 1-based lines. Matching
1484
+ // it matters: the intended workflow is to diff a check against a build and
1485
+ // see the same coordinates, and an off-by-one reads as a different finding.
1486
+ const column = lines[struct.lineIdx].indexOf(struct.name);
1487
+ diagnostics.push({
1488
+ file: relFile,
1489
+ line: struct.lineIdx + 1,
1490
+ column,
1491
+ severity: 'error',
1492
+ rule: 'struct-name-builtin-collision',
1493
+ message: `The struct '${struct.name}' cannot have the same name as the built-in component '${struct.name}'. Rename the struct (for example '${struct.name}View' or a name describing its role) and update every use, including any '${struct.name}Attribute' reference.`,
1494
+ });
1495
+ }
1496
+ }
1497
+ return diagnostics;
1498
+ }
1499
+
1500
+ function validateEntryBuildRootNode(files, projectPath) {
1501
+ const diagnostics = [];
1502
+ for (const filePath of files) {
1503
+ let content;
1504
+ try { content = fs.readFileSync(filePath, 'utf-8'); } catch { continue; }
1505
+ const lines = content.split('\n');
1506
+ const relFile = path.relative(projectPath, filePath);
1507
+ const structs = collectStructs(lines);
1508
+
1509
+ for (let s = 0; s < structs.length; s++) {
1510
+ const { lineIdx, name, inline } = structs[s];
1511
+ const decorators = collectStructDecorators(lines, lineIdx, inline);
1512
+ if (!decorators.has('Entry')) continue;
1513
+
1514
+ const bodyEnd = s + 1 < structs.length ? structs[s + 1].lineIdx : lines.length;
1515
+ // Locate this struct's own build() (not a nested @Builder method).
1516
+ let buildLine = -1;
1517
+ for (let i = lineIdx + 1; i < bodyEnd; i++) {
1518
+ if (/^\s*build\s*\(\s*\)\s*\{/.test(lines[i])) { buildLine = i; break; }
1519
+ }
1520
+ if (buildLine < 0) continue;
1521
+
1522
+ // Walk build()'s body, collecting the component names that appear at brace
1523
+ // depth 1 (its immediate children).
1524
+ let depth = 1;
1525
+ const roots = [];
1526
+ for (let i = buildLine + 1; i < bodyEnd && depth > 0; i++) {
1527
+ const trimmed = lines[i].trim();
1528
+ if (depth === 1 && trimmed && !trimmed.startsWith('//') && !trimmed.startsWith('.') && !trimmed.startsWith('}')) {
1529
+ const comp = /^([A-Z][\w$]*)\s*[({]/.exec(trimmed);
1530
+ if (comp) roots.push({ name: comp[1], line: i + 1 });
1531
+ }
1532
+ for (const ch of lines[i]) {
1533
+ if (ch === '{') depth++;
1534
+ else if (ch === '}') depth--;
1535
+ }
1536
+ }
1537
+
1538
+ if (roots.length === 0) continue; // empty/unparsed build() — not ours to judge
1539
+ if (roots.length > 1) {
1540
+ diagnostics.push({
1541
+ file: relFile,
1542
+ line: roots[1].line,
1543
+ column: 1,
1544
+ severity: 'error',
1545
+ rule: 'entry-build-root-node',
1546
+ message: `In an '@Entry' decorated component, the 'build' method can have only one root node, which must be a container component. Struct '${name}' has ${roots.length} root nodes (${roots.map((r) => r.name).join(', ')}); wrap them in a single container such as Column or Stack.`,
1547
+ });
1548
+ } else if (!CONTAINER_COMPONENTS.has(roots[0].name)) {
1549
+ diagnostics.push({
1550
+ file: relFile,
1551
+ line: roots[0].line,
1552
+ column: 1,
1553
+ severity: 'error',
1554
+ rule: 'entry-build-root-node',
1555
+ message: `In an '@Entry' decorated component, the 'build' method can have only one root node, which must be a container component. Struct '${name}' has a single root '${roots[0].name}', which is not a container; wrap it in a container such as Column or Stack.`,
1556
+ });
1557
+ }
1558
+ }
1559
+ }
1560
+ return diagnostics;
1561
+ }
1562
+
1563
+ // 10905209: inside a `build()` or `@Builder` body, only UI component syntax may
1564
+ // appear. A local `const`/`let`/`var` there is ordinary, type-correct ArkTS — the
1565
+ // standalone checker accepts it happily — but hvigor's UI transform rejects it,
1566
+ // so the whole build fails on code that passed arkts_check cleanly.
1567
+ //
1568
+ // Only two unambiguous shapes are flagged, and only at a UI statement position:
1569
+ // a local declaration, and a loop/switch statement. ArkUI's UI syntax admits
1570
+ // `if`/`else` and `ForEach`/`LazyForEach` — never `for`/`while`/`do`/`switch` —
1571
+ // so those are safe to reject; other expression statements are left alone
1572
+ // because a bare component call looks the same to a line-based check.
1573
+ //
1574
+ // Statements inside a nested JS scope (an `.onClick(() => { const x = ... })`
1575
+ // handler, a `.key(item => item.id)` generator) are legal and must not be
1576
+ // flagged, so the walk keeps a scope stack: a brace opened by a line carrying
1577
+ // `=>` or `function` starts a JS scope, any other brace continues the UI scope.
1578
+ //
1579
+ // The exception is the item-builder callback of a list renderer. `ForEach`,
1580
+ // `LazyForEach` and `Repeat().each()/.template()` take an arrow whose body is
1581
+ // still UI-syntax-only — hvigor rejects a `let`/`for` there with 10905209 just
1582
+ // as it does directly inside `build()`. Treating that arrow as a JS scope is
1583
+ // what let `let club: ClubInfo = item as ClubInfo` inside a `ForEach` reach the
1584
+ // build across several observed projects, so those callbacks keep the UI scope.
1585
+ const BUILDER_LOCAL_DECL_RE = /^\s*(const|let|var)\s+[A-Za-z_$[{]/;
1586
+ const BUILDER_NON_UI_STATEMENT_RE = /^\s*(for|while|do|switch)\s*[({]/;
1587
+
1588
+ // A line that opens the item-builder callback of a list renderer. The arrow and
1589
+ // the brace it opens must both sit on this line: `ForEach(list, (item: T) => {`.
1590
+ // A key generator (`(item: T) => item.id`) opens no brace and so never matches,
1591
+ // and one written as a block (`.key((s: Song): string => { return s.id })`) is
1592
+ // not an item builder — hence matching on the renderer call, not on `=>` alone.
1593
+ const UI_ITEM_BUILDER_RE = /(?:\b(?:ForEach|LazyForEach)\s*\(|\.\s*(?:each|template)\s*\()/;
1594
+
1595
+ // A `build()` / `@Builder`-decorated function or method header, i.e. the lines
1596
+ // whose body is UI-syntax-only.
1597
+ function collectBuilderBodies(lines) {
1598
+ const bodies = [];
1599
+ for (let i = 0; i < lines.length; i++) {
1600
+ if (/^\s*build\s*\(\s*\)\s*\{/.test(lines[i])) {
1601
+ bodies.push({ lineIdx: i, kind: 'build' });
1602
+ continue;
1603
+ }
1604
+ // `@Builder` inline (`@Builder foo() {`) or on the preceding line.
1605
+ const inlineBuilder = /^\s*@Builder\b/.test(lines[i]);
1606
+ const prevBuilder = i > 0 && /^\s*@Builder\s*$/.test(lines[i - 1]);
1607
+ if (!inlineBuilder && !prevBuilder) continue;
1608
+ // The header may be the same line as an inline `@Builder`, or the line after
1609
+ // a standalone one. Both the free-function and struct-method forms count.
1610
+ const header = /\)\s*(?::[^{]+)?\{\s*$/.test(lines[i]) ? i : -1;
1611
+ if (header >= 0) bodies.push({ lineIdx: header, kind: 'builder' });
1612
+ }
1613
+ return bodies;
1614
+ }
1615
+
1616
+ function validateBuilderBodyStatements(files, projectPath) {
1617
+ const diagnostics = [];
1618
+ for (const filePath of files) {
1619
+ let content;
1620
+ try { content = fs.readFileSync(filePath, 'utf-8'); } catch { continue; }
1621
+ const lines = content.split('\n');
1622
+ const relFile = path.relative(projectPath, filePath);
1623
+
1624
+ for (const body of collectBuilderBodies(lines)) {
1625
+ // 'ui' for the body's own scope; each nested brace pushes 'ui' or 'js'.
1626
+ const scopes = ['ui'];
1627
+ for (let i = body.lineIdx + 1; i < lines.length && scopes.length > 0; i++) {
1628
+ const line = lines[i];
1629
+ const trimmed = line.trim();
1630
+ if (scopes[scopes.length - 1] === 'ui' && !trimmed.startsWith('//')) {
1631
+ const where = body.kind === 'build' ? 'build()' : '@Builder';
1632
+ const isDecl = BUILDER_LOCAL_DECL_RE.test(line);
1633
+ const isLoop = BUILDER_NON_UI_STATEMENT_RE.test(line);
1634
+ if (isDecl || isLoop) {
1635
+ diagnostics.push({
1636
+ file: relFile,
1637
+ line: i + 1,
1638
+ column: 1,
1639
+ severity: 'error',
1640
+ rule: 'builder-body-ui-only',
1641
+ message: isDecl
1642
+ ? `Only UI component syntax can be written here. A local variable declaration is not allowed directly inside a '${where}' body; compute the value in a regular method or getter (or a private field) and reference it here instead.`
1643
+ : `Only UI component syntax can be written here. A '${trimmed.split(/[\s({]/)[0]}' statement is not allowed directly inside a '${where}' body; use 'ForEach'/'LazyForEach' to render a list, or move the loop into a regular method that returns the data.`,
1644
+ });
1645
+ }
1646
+ }
1647
+ // A brace opened on a line carrying `=>`/`function` starts a JS scope
1648
+ // where ordinary statements are legal -- unless the arrow is the item
1649
+ // builder of a list renderer, whose body stays UI-syntax-only.
1650
+ //
1651
+ // Known limitation: a single line opening both an item builder and an
1652
+ // event callback (`ForEach(l, (i: T) => { Button().onClick(() => {`)
1653
+ // gets one verdict for both braces, and UI wins. That direction risks a
1654
+ // false positive on the handler rather than missing the item builder;
1655
+ // splitting it needs an expression-level parse, not a line scan.
1656
+ const js = /=>|(?:^|[^\w.$])function\b/.test(line) && !UI_ITEM_BUILDER_RE.test(line);
1657
+ for (const ch of line) {
1658
+ if (ch === '{') scopes.push(js ? 'js' : scopes[scopes.length - 1]);
1659
+ else if (ch === '}') scopes.pop();
1660
+ }
1661
+ }
1662
+ }
1663
+ }
1664
+ return diagnostics;
1665
+ }
1666
+
1667
+ // Single-file V1/V2 member-decorator rules that the ArkTS linter accepts but
1668
+ // hvigor rejects. Each is a local, syntactic judgement about one member line.
1669
+ // --- Navigation runtime correctness ---
1670
+ // These three shapes all compile cleanly and all fail only at runtime, as a
1671
+ // white screen or a wrong page. The checker cannot see them because each is a
1672
+ // legal call; what is wrong is the semantics.
1673
+
1674
+ // `.navDestination(builder)` is a property method, not an event subscription:
1675
+ // calling it more than once on the same Navigation replaces the registration
1676
+ // rather than adding to it, so only the LAST builder is ever used and every
1677
+ // pushPath renders that one page regardless of its name.
1678
+ function validateNavDestinationRegistration(files, projectPath) {
1679
+ const diagnostics = [];
1680
+ for (const filePath of files) {
1681
+ let content;
1682
+ try { content = fs.readFileSync(filePath, 'utf-8'); } catch { continue; }
1683
+ const lines = content.split('\n');
1684
+ const relFile = path.relative(projectPath, filePath);
1685
+
1686
+ // Attribute chains attach to the Navigation() they follow, so grouping by
1687
+ // the nearest preceding Navigation() keeps two separate Navigations in one
1688
+ // file from being merged into a false positive.
1689
+ let navLine = -1;
1690
+ const hits = [];
1691
+ const flush = () => {
1692
+ if (hits.length > 1) {
1693
+ diagnostics.push({
1694
+ file: relFile,
1695
+ line: hits[1].line,
1696
+ column: 1,
1697
+ severity: 'error',
1698
+ rule: 'nav-destination-single-builder',
1699
+ message:
1700
+ `'.navDestination' is a property method, so chaining it ${hits.length} times does not register ${hits.length} routes — ` +
1701
+ `each call replaces the previous one and only the last builder ('${hits[hits.length - 1].builder}') is ever used, ` +
1702
+ `making every pushPath open that page. Call '.navDestination' once with a single @Builder that dispatches on the ` +
1703
+ `route name: '@Builder routeMap(name: string, param: object) { if (name === \'A\') { ... } else if (name === \'B\') { ... } }'.`,
1704
+ });
1705
+ }
1706
+ hits.length = 0;
1707
+ };
1708
+
1709
+ for (let i = 0; i < lines.length; i++) {
1710
+ const line = lines[i];
1711
+ if (line.trim().startsWith('//')) continue;
1712
+ if (/\bNavigation\s*\(/.test(line)) {
1713
+ flush();
1714
+ navLine = i;
1715
+ }
1716
+ const m = /^\s*\.navDestination\s*\(\s*(?:this\s*\.\s*)?([A-Za-z_$][\w$]*)/.exec(line);
1717
+ if (m && navLine >= 0) hits.push({ line: i + 1, builder: m[1] });
1718
+ }
1719
+ flush();
1720
+ }
1721
+ return diagnostics;
1722
+ }
1723
+
1724
+ // `.hideNavBar(true)` hides the whole navigation bar area — title bar, TOOLBAR
1725
+ // AND the Navigation's own content child. When the home page is written inside
1726
+ // `Navigation { ... }`, that blanks the launch screen. `.hideTitleBar(true)` is
1727
+ // what "remove the system title" means.
1728
+ function validateHideNavBarUsage(files, projectPath) {
1729
+ const diagnostics = [];
1730
+ for (const filePath of files) {
1731
+ let content;
1732
+ try { content = fs.readFileSync(filePath, 'utf-8'); } catch { continue; }
1733
+ const lines = content.split('\n');
1734
+ const relFile = path.relative(projectPath, filePath);
1735
+
1736
+ for (let i = 0; i < lines.length; i++) {
1737
+ if (lines[i].trim().startsWith('//')) continue;
1738
+ if (!/^\s*\.hideNavBar\s*\(\s*true\s*\)/.test(lines[i])) continue;
1739
+
1740
+ // Only a Navigation that actually has a content child loses something.
1741
+ // Find the Navigation this chain belongs to and check whether its body
1742
+ // holds a component rather than being empty.
1743
+ let navIdx = -1;
1744
+ for (let j = i; j >= 0; j--) {
1745
+ if (/\bNavigation\s*\(/.test(lines[j])) { navIdx = j; break; }
1746
+ }
1747
+ if (navIdx < 0) continue;
1748
+ if (!/\{\s*$/.test(lines[navIdx])) continue;
1749
+
1750
+ const state = { quote: null, inBlockComment: false };
1751
+ const bodyEnd = findStructBodyEnd(lines, navIdx, state);
1752
+ let hasChild = false;
1753
+ for (let j = navIdx + 1; j < bodyEnd && j < lines.length; j++) {
1754
+ const trimmed = lines[j].trim();
1755
+ if (!trimmed || trimmed.startsWith('//') || trimmed.startsWith('.') || trimmed.startsWith('}')) continue;
1756
+ if (/^([A-Z][\w$]*)\s*[({]/.test(trimmed)) { hasChild = true; break; }
1757
+ }
1758
+ if (!hasChild) continue;
1759
+
1760
+ diagnostics.push({
1761
+ file: relFile,
1762
+ line: i + 1,
1763
+ column: 1,
1764
+ severity: 'error',
1765
+ rule: 'hide-nav-bar-hides-content',
1766
+ message:
1767
+ `'.hideNavBar(true)' hides the entire navigation bar — title bar, tool bar AND the content written inside ` +
1768
+ `'Navigation { ... }' — so this page renders blank at runtime even though it compiles. ` +
1769
+ `Use '.hideTitleBar(true)' to remove only the system title, and delete '.hideNavBar(true)'.`,
1770
+ });
1771
+ }
1772
+ }
1773
+ return diagnostics;
1774
+ }
1775
+
1776
+ // A route builder branch must produce a `NavDestination` root. Pushing to a
1777
+ // branch that renders a bare business component leaves the destination with no
1778
+ // mountable root, which shows as a white screen after pushPath.
1779
+ //
1780
+ // There are two correct shapes and both must pass: the builder wraps its
1781
+ // branches itself, OR the target page uses NavDestination as its own build()
1782
+ // root. Only a branch that satisfies neither is reported, so the component is
1783
+ // resolved across the project before judging it.
1784
+ function collectNavDestinationRootComponents(files, projectPath) {
1785
+ const names = new Set();
1786
+ const declarationFiles = projectPath ? unionProjectFiles(files, projectPath) : files;
1787
+ for (const filePath of declarationFiles) {
1788
+ let content;
1789
+ try { content = fs.readFileSync(filePath, 'utf-8'); } catch { continue; }
1790
+ const lines = content.split('\n');
1791
+ const structs = collectStructs(lines);
1792
+ for (const { lineIdx, name } of structs) {
1793
+ const state = { quote: null, inBlockComment: false };
1794
+ const bodyEnd = findStructBodyEnd(lines, lineIdx, state);
1795
+ for (let i = lineIdx + 1; i < bodyEnd && i < lines.length; i++) {
1796
+ if (!/^\s*build\s*\(\s*\)\s*\{/.test(lines[i])) continue;
1797
+ // First component named in build() is its root node.
1798
+ for (let j = i + 1; j < bodyEnd && j < lines.length; j++) {
1799
+ const trimmed = lines[j].trim();
1800
+ if (!trimmed || trimmed.startsWith('//') || trimmed.startsWith('.')) continue;
1801
+ const comp = /^([A-Z][\w$]*)\s*[({]/.exec(trimmed);
1802
+ if (comp) {
1803
+ if (comp[1] === 'NavDestination') names.add(name);
1804
+ break;
1805
+ }
1806
+ break;
1807
+ }
1808
+ break;
1809
+ }
1810
+ }
1811
+ }
1812
+ return names;
1813
+ }
1814
+
1815
+ function validateNavDestinationRoot(files, projectPath) {
1816
+ const diagnostics = [];
1817
+ const wrappedPages = collectNavDestinationRootComponents(files, projectPath);
1818
+ // Components the project itself declares. A branch rendering anything else
1819
+ // (a library page, an unresolvable import) cannot be judged: its root node is
1820
+ // not in the sources we can read, so staying silent is the only safe choice.
1821
+ const localComponents = new Set();
1822
+ const declarationFiles = projectPath ? unionProjectFiles(files, projectPath) : files;
1823
+ for (const declFile of declarationFiles) {
1824
+ let declContent;
1825
+ try { declContent = fs.readFileSync(declFile, 'utf-8'); } catch { continue; }
1826
+ for (const { name } of collectStructs(declContent.split('\n'))) localComponents.add(name);
1827
+ }
1828
+
1829
+ for (const filePath of files) {
1830
+ let content;
1831
+ try { content = fs.readFileSync(filePath, 'utf-8'); } catch { continue; }
1832
+ const lines = content.split('\n');
1833
+ const relFile = path.relative(projectPath, filePath);
1834
+
1835
+ // Only builders actually registered as a route map are subject to this.
1836
+ const registered = new Set();
1837
+ for (const line of lines) {
1838
+ const m = /\.navDestination\s*\(\s*(?:this\s*\.\s*)?([A-Za-z_$][\w$]*)/.exec(line);
1839
+ if (m) registered.add(m[1]);
1840
+ }
1841
+ if (registered.size === 0) continue;
1842
+
1843
+ for (let i = 0; i < lines.length; i++) {
1844
+ const header = /^\s*(?:@Builder\s+)?([A-Za-z_$][\w$]*)\s*\([^)]*\)\s*(?::[^{]+)?\{\s*$/.exec(lines[i]);
1845
+ if (!header || !registered.has(header[1])) continue;
1846
+ const isBuilder = /^\s*@Builder\b/.test(lines[i]) || (i > 0 && /^\s*@Builder\s*$/.test(lines[i - 1]));
1847
+ if (!isBuilder) continue;
1848
+
1849
+ const state = { quote: null, inBlockComment: false };
1850
+ const bodyEnd = findStructBodyEnd(lines, i, state);
1851
+ let mentionsND = false;
1852
+ const branches = [];
1853
+ for (let j = i + 1; j < bodyEnd && j < lines.length; j++) {
1854
+ const trimmed = lines[j].trim();
1855
+ if (!trimmed || trimmed.startsWith('//')) continue;
1856
+ if (/\bNavDestination\s*\(/.test(trimmed)) { mentionsND = true; break; }
1857
+ // Match both `Page(...)` and `Container() {` so a container opening a
1858
+ // block is recognized as the root rather than skipped, which would let
1859
+ // one of its children be reported instead.
1860
+ const comp = /^([A-Z][\w$]*)\s*[({]/.exec(trimmed);
1861
+ if (!comp) continue;
1862
+ const name = comp[1];
1863
+ // A container root means this branch builds its own subtree — that is a
1864
+ // missing NavDestination, but the container IS the root, so stop here
1865
+ // rather than descending into its children.
1866
+ if (CONTAINER_COMPONENTS.has(name)) { branches.push({ line: j + 1, name }); break; }
1867
+ if (wrappedPages.has(name)) continue;
1868
+ if (!localComponents.has(name)) continue;
1869
+ branches.push({ line: j + 1, name });
1870
+ }
1871
+ if (mentionsND || branches.length === 0) continue;
1872
+
1873
+ const target = branches[0];
1874
+ const isContainer = CONTAINER_COMPONENTS.has(target.name);
1875
+ diagnostics.push({
1876
+ file: relFile,
1877
+ line: target.line,
1878
+ column: 1,
1879
+ severity: 'error',
1880
+ rule: 'nav-destination-root-node',
1881
+ message:
1882
+ `Route builder '${header[1]}' renders '${target.name}' without a 'NavDestination' root, so pushing this route ` +
1883
+ `shows a white screen at runtime even though it compiles. ` +
1884
+ (isContainer
1885
+ ? `Wrap this branch's content as 'NavDestination() { ${target.name}() { ... } }'.`
1886
+ : `Wrap each branch as 'NavDestination() { ${target.name}(...) }', or make 'NavDestination' the root node of ` +
1887
+ `that page's own build().`),
1888
+ });
1889
+ }
1890
+ }
1891
+ return diagnostics;
1892
+ }
1893
+
1894
+ // V1 `AppStorage` cannot store a V2 (`@ObservedV2`) class instance: the call
1895
+ // throws at startup, so the app crashes on launch. V2 state belongs in
1896
+ // `AppStorageV2.connect` / `PersistenceV2.connect`.
1897
+ function validateAppStorageV2Mixing(files, projectPath) {
1898
+ // Only a class the project itself decorates '@ObservedV2' crashes here, which is
1899
+ // exactly what this index holds while SDK_OBSERVED_V2_CLASSES stays empty. If a
1900
+ // name is ever seeded there, re-check whether it also crashes AppStorage before
1901
+ // letting it reach this rule.
1902
+ const observedV2Classes = collectObservedV2ClassNames(files, projectPath);
1903
+ if (observedV2Classes.size === 0) return [];
1904
+
1905
+ const diagnostics = [];
1906
+ for (const filePath of files) {
1907
+ let content;
1908
+ try { content = fs.readFileSync(filePath, 'utf-8'); } catch { continue; }
1909
+ const lines = content.split('\n');
1910
+ const relFile = path.relative(projectPath, filePath);
1911
+
1912
+ // Local `name: Type` declarations, so a bare `this.viewModel` argument can
1913
+ // be resolved back to its @ObservedV2 class.
1914
+ const declaredTypes = new Map();
1915
+ for (const line of lines) {
1916
+ const decl = /^\s*(?:@\w+(?:\([^)]*\))?\s*)*(?:private\s+|readonly\s+|public\s+)*([A-Za-z_$][\w$]*)\s*(?:\?|!)?\s*:\s*([A-Za-z_$][\w$]*)\s*=/.exec(line);
1917
+ if (decl) declaredTypes.set(decl[1], decl[2]);
1918
+ }
1919
+
1920
+ for (let i = 0; i < lines.length; i++) {
1921
+ if (lines[i].trim().startsWith('//')) continue;
1922
+ const call = /AppStorage\s*\.\s*setOrCreate\s*(?:<\s*([A-Za-z_$][\w$]*)\s*>)?\s*\(\s*([^,]+),\s*([^),]+)/.exec(lines[i]);
1923
+ if (!call) continue;
1924
+
1925
+ const explicit = call[1];
1926
+ const argExpr = call[3].trim();
1927
+ const argName = /^(?:this\s*\.\s*)?([A-Za-z_$][\w$]*)$/.exec(argExpr);
1928
+ const newed = /^new\s+([A-Za-z_$][\w$]*)/.exec(argExpr);
1929
+ const resolved =
1930
+ (explicit && observedV2Classes.has(explicit) && explicit) ||
1931
+ (newed && observedV2Classes.has(newed[1]) && newed[1]) ||
1932
+ (argName && observedV2Classes.has(declaredTypes.get(argName[1])) && declaredTypes.get(argName[1]));
1933
+ if (!resolved) continue;
1934
+
1935
+ diagnostics.push({
1936
+ file: relFile,
1937
+ line: i + 1,
1938
+ column: 1,
1939
+ severity: 'error',
1940
+ rule: 'appstorage-observedv2-mixing',
1941
+ message:
1942
+ `'AppStorage.setOrCreate' cannot store '${resolved}', which is decorated '@ObservedV2': V1 AppStorage and V2 ` +
1943
+ `observation cannot be mixed and this throws at startup, crashing the app on launch. ` +
1944
+ `Use 'AppStorageV2.connect(${resolved}, 'key', () => new ${resolved}())' (or 'PersistenceV2.connect' to persist).`,
1945
+ });
1946
+ }
1947
+ }
1948
+ return diagnostics;
1949
+ }
1950
+
1951
+ function validateV2MemberDecoratorRules(files, projectPath) {
1952
+ const diagnostics = [];
1953
+ for (const filePath of files) {
1954
+ let content;
1955
+ try { content = fs.readFileSync(filePath, 'utf-8'); } catch { continue; }
1956
+ const lines = content.split('\n');
1957
+ const relFile = path.relative(projectPath, filePath);
1958
+
1959
+ for (let i = 0; i < lines.length; i++) {
1960
+ // 10905363: a V1 @Prop/@Link/@State cannot hold a function-typed value.
1961
+ const v1Fn = /^\s*@(Prop|Link|State)\b(?:\([^)]*\))?\s+([A-Za-z_$][\w$]*)\s*(?:\?|!)?\s*:\s*(\([^)]*\)\s*=>|Function\b)/.exec(lines[i]);
1962
+ if (v1Fn) {
1963
+ diagnostics.push({
1964
+ file: relFile,
1965
+ line: i + 1,
1966
+ column: 1,
1967
+ severity: 'error',
1968
+ rule: 'v1-decorator-function-type',
1969
+ message: `The V1 decorator '@${v1Fn[1]}' cannot be applied to a Function-type variable '${v1Fn[2]}'. Use '@Param' in a '@ComponentV2' struct, or declare it as a plain callback member and pass it via a builder/arrow property instead.`,
1970
+ });
1971
+ continue;
1972
+ }
1973
+
1974
+ // 10905327: @Param without a default value must also carry @Require.
1975
+ const param = /^\s*@Param\b(?:\([^)]*\))?\s+([A-Za-z_$][\w$]*)\s*(?:\?)?\s*:\s*([^=]+)$/.exec(lines[i].replace(/\/\/.*$/, '').trimEnd());
1976
+ if (param && !/\?\s*:/.test(lines[i])) {
1977
+ // @Require may sit inline before @Param or on the preceding line.
1978
+ const inlineRequire = /@Require\b/.test(lines[i]);
1979
+ const prevRequire = i > 0 && /^\s*@Require\s*$/.test(lines[i - 1]);
1980
+ if (!inlineRequire && !prevRequire) {
1981
+ diagnostics.push({
1982
+ file: relFile,
1983
+ line: i + 1,
1984
+ column: 1,
1985
+ severity: 'error',
1986
+ rule: 'param-requires-require',
1987
+ message: `When a variable decorated with '@Param' is not assigned a default value, it must also be decorated with '@Require'. Add '@Require' to '${param[1]}', or give it a default value.`,
1988
+ });
1989
+ }
1990
+ }
1991
+ }
1992
+ }
1993
+ return diagnostics;
1994
+ }
1995
+
1996
+ // 10905307: @ObjectLink's type must be a class decorated with @Observed/@ObservedV2.
1997
+ //
1998
+ // NOT WIRED INTO computeProjectDiagnostics. The naive form of this rule ("type
1999
+ // is a project class without @Observed -> error") fires on code that hvigor
2000
+ // accepts: a bootstrap project that builds successfully has `@ObjectLink task:
2001
+ // HealthTask` where HealthTask carries no @Observed at all. Whatever makes that
2002
+ // legal (inherited decoration, a V2 container path, or a laxer check than the
2003
+ // error message implies) is not captured here, and one real failure is not worth
2004
+ // a false positive on passing code. Kept — exported for tests — so the next
2005
+ // attempt starts from the known-insufficient version rather than from scratch.
2006
+ const OBJECT_LINK_TYPE_RE = /^\s*@ObjectLink\b(?:\([^)]*\))?\s+([A-Za-z_$][\w$]*)\s*(?:\?|!)?\s*:\s*([A-Za-z_$][\w$]*)\b(?!\s*[<[.])/;
2007
+
2008
+ function collectObservedClassNames(files) {
2009
+ const names = new Set();
2010
+ for (const filePath of files) {
2011
+ let content;
2012
+ try { content = fs.readFileSync(filePath, 'utf-8'); } catch { continue; }
2013
+ const lines = content.split('\n');
2014
+ for (let i = 0; i < lines.length; i++) {
2015
+ const m = CLASS_DECL_RE.exec(lines[i]);
2016
+ if (!m) continue;
2017
+ const decorators = collectStructDecorators(lines, i, m[2]);
2018
+ if (decorators.has('Observed') || decorators.has('ObservedV2')) names.add(m[3]);
2019
+ }
2020
+ }
2021
+ return names;
2022
+ }
2023
+
2024
+ function validateObjectLinkTypes(files, projectPath) {
2025
+ const declarationFiles = projectPath ? unionProjectFiles(files, projectPath) : files;
2026
+ const observed = collectObservedClassNames(declarationFiles);
2027
+ // Class declarations we can see at all — only judge types declared in-project,
2028
+ // so an SDK or third-party type is never guessed at.
2029
+ const declared = new Set();
2030
+ for (const filePath of declarationFiles) {
2031
+ let content;
2032
+ try { content = fs.readFileSync(filePath, 'utf-8'); } catch { continue; }
2033
+ for (const line of content.split('\n')) {
2034
+ const m = CLASS_DECL_RE.exec(line);
2035
+ if (m) declared.add(m[3]);
2036
+ }
2037
+ }
2038
+
2039
+ const diagnostics = [];
2040
+ for (const filePath of files) {
2041
+ let content;
2042
+ try { content = fs.readFileSync(filePath, 'utf-8'); } catch { continue; }
2043
+ const lines = content.split('\n');
2044
+ const relFile = path.relative(projectPath, filePath);
2045
+ for (let i = 0; i < lines.length; i++) {
2046
+ const m = OBJECT_LINK_TYPE_RE.exec(lines[i]);
2047
+ if (!m) continue;
2048
+ const typeName = m[2];
2049
+ if (!declared.has(typeName)) continue; // not a project class -> out of scope
2050
+ if (observed.has(typeName)) continue;
2051
+ diagnostics.push({
2052
+ file: relFile,
2053
+ line: i + 1,
2054
+ column: 1,
2055
+ severity: 'error',
2056
+ rule: 'object-link-observed-type',
2057
+ message: `'@ObjectLink' cannot be used with this type. Apply it only to classes decorated by '@Observed' or '@ObservedV2'. '${typeName}' has neither; add '@Observed' to the class, or use a different state decorator for '${m[1]}'.`,
2058
+ });
2059
+ }
2060
+ }
2061
+ return diagnostics;
2062
+ }
2063
+
2064
+ function validateModelVersion(projectPath) {
2065
+ const hvigorPath = path.join(projectPath, 'hvigor', 'hvigor-config.json5');
2066
+ const ohPkgPath = path.join(projectPath, 'oh-package.json5');
2067
+ if (!fs.existsSync(hvigorPath) || !fs.existsSync(ohPkgPath)) return [];
2068
+
2069
+ const extractVersion = (file) => {
2070
+ const content = fs.readFileSync(file, 'utf-8');
2071
+ const m = content.match(/["']?modelVersion["']?\s*:\s*["']([^"']+)["']/);
2072
+ return m ? m[1] : null;
2073
+ };
2074
+
2075
+ const hvigorVer = extractVersion(hvigorPath);
2076
+ const ohPkgVer = extractVersion(ohPkgPath);
2077
+
2078
+ if (hvigorVer && ohPkgVer && hvigorVer !== ohPkgVer) {
2079
+ return [{
2080
+ file: 'hvigor/hvigor-config.json5',
2081
+ line: 1,
2082
+ column: 1,
2083
+ severity: 'error',
2084
+ message: `modelVersion mismatch: hvigor-config.json5 has '${hvigorVer}' but oh-package.json5 has '${ohPkgVer}'. They must be consistent.`,
2085
+ rule: 'model-version-consistency',
2086
+ }];
2087
+ }
2088
+ return [];
2089
+ }
2090
+
2091
+ // --- user_grant permission config validation ---
2092
+ //
2093
+ // Permissions are split into `system_grant` (auto-granted at install) and
2094
+ // `user_grant` (runtime dialog). For every `user_grant` permission declared in
2095
+ // module.json5's `requestPermissions`, hvigor REQUIRES a `reason` that is a
2096
+ // `$string:` resource reference; a missing/literal `reason` fails the build.
2097
+ // `usedScene` is optional since API 9, so we only warn when it is absent.
2098
+ // The linter never reads module.json5's permission block, so we add it here.
2099
+
2100
+ // Parse a JSON5-ish document (comments + trailing commas) into an object, or
2101
+ // null on failure. Comment stripping is string-literal aware so a `//` inside a
2102
+ // value is preserved. Deliberately minimal — enough for module.json5 configs.
2103
+ function parseJson5Loose(text) {
2104
+ try {
2105
+ let out = '';
2106
+ let inStr = false;
2107
+ let quote = '';
2108
+ for (let i = 0; i < text.length; i++) {
2109
+ const c = text[i];
2110
+ const next = text[i + 1];
2111
+ if (inStr) {
2112
+ out += c;
2113
+ if (c === '\\') { out += next; i++; continue; }
2114
+ if (c === quote) inStr = false;
2115
+ continue;
2116
+ }
2117
+ if (c === '"' || c === "'") { inStr = true; quote = c; out += c; continue; }
2118
+ if (c === '/' && next === '/') { while (i < text.length && text[i] !== '\n') i++; out += '\n'; continue; }
2119
+ if (c === '/' && next === '*') { i += 2; while (i < text.length && !(text[i] === '*' && text[i + 1] === '/')) i++; i++; continue; }
2120
+ out += c;
2121
+ }
2122
+ // Single-quoted strings -> double-quoted; drop trailing commas.
2123
+ out = out.replace(/'((?:[^'\\]|\\.)*)'/g, (_m, inner) => '"' + inner.replace(/"/g, '\\"') + '"');
2124
+ out = out.replace(/,(\s*[}\]])/g, '$1');
2125
+ return JSON.parse(out);
2126
+ } catch {
2127
+ return null;
2128
+ }
2129
+ }
2130
+
2131
+ // Set of `$string:` keys defined in a string.json document, or null if the
2132
+ // document is missing/unparseable (in which case key-existence isn't checked).
2133
+ function loadStringResourceKeys(stringJsonText) {
2134
+ if (stringJsonText == null) return null;
2135
+ const parsed = parseJson5Loose(stringJsonText);
2136
+ if (!parsed || !Array.isArray(parsed.string)) return null;
2137
+ const keys = new Set();
2138
+ for (const entry of parsed.string) {
2139
+ if (entry && typeof entry.name === 'string') keys.add(entry.name);
2140
+ }
2141
+ return keys;
2142
+ }
2143
+
2144
+ // Pure core: given the raw module.json5 text, the user_grant name set, and
2145
+ // (optionally) the string.json keys, return diagnostics. `relFile` is used only
2146
+ // for the diagnostic's file field. Line numbers anchor to the permission's
2147
+ // `"name"` line in the raw text (fallback: line 1).
2148
+ function validatePermissionsConfig(moduleJson5Text, userGrantSet, stringKeys, relFile) {
2149
+ const parsed = parseJson5Loose(moduleJson5Text);
2150
+ const perms = parsed && parsed.module && Array.isArray(parsed.module.requestPermissions)
2151
+ ? parsed.module.requestPermissions
2152
+ : null;
2153
+ if (!perms) return [];
2154
+
2155
+ const lineOf = (permName) => {
2156
+ const idx = moduleJson5Text.indexOf(permName);
2157
+ if (idx < 0) return 1;
2158
+ return moduleJson5Text.slice(0, idx).split('\n').length;
2159
+ };
2160
+
2161
+ const diags = [];
2162
+ for (const p of perms) {
2163
+ if (!p || typeof p.name !== 'string' || !userGrantSet.has(p.name)) continue;
2164
+ const line = lineOf(p.name);
2165
+ const base = { file: relFile, line, column: 1 };
2166
+
2167
+ if (p.reason === undefined || p.reason === null || p.reason === '') {
2168
+ diags.push({ ...base, severity: 'error', rule: 'permission-reason-required',
2169
+ message: `user_grant permission '${p.name}' is missing 'reason'. It must be a $string: resource reference or the build will fail.` });
2170
+ } else if (typeof p.reason !== 'string' || !p.reason.startsWith('$string:')) {
2171
+ diags.push({ ...base, severity: 'error', rule: 'permission-reason-required',
2172
+ message: `user_grant permission '${p.name}' has an invalid 'reason' (${JSON.stringify(p.reason)}). It must be a $string: resource reference.` });
2173
+ } else if (stringKeys) {
2174
+ const key = p.reason.slice('$string:'.length);
2175
+ if (!stringKeys.has(key)) {
2176
+ diags.push({ ...base, severity: 'error', rule: 'permission-reason-resource',
2177
+ message: `user_grant permission '${p.name}' references '${p.reason}' but string resource '${key}' is not defined in string.json.` });
2178
+ }
2179
+ }
2180
+
2181
+ const scene = p.usedScene;
2182
+ const sceneEmpty = scene == null ||
2183
+ (typeof scene === 'object' && (!Array.isArray(scene.abilities) || scene.abilities.length === 0) && scene.when === undefined);
2184
+ if (sceneEmpty) {
2185
+ diags.push({ ...base, severity: 'warning', rule: 'permission-usedscene-recommended',
2186
+ message: `user_grant permission '${p.name}' has no 'usedScene'. Declaring abilities/when is recommended for store review.` });
2187
+ }
2188
+ }
2189
+ return diags;
2190
+ }
2191
+
2192
+ // Load the SDK's permission definitions from PermissionDefinitions.json, split
2193
+ // into the user_grant subset (existing reason/usedScene checks) and the full
2194
+ // name set across every grantMode (system_grant + user_grant), used by the
2195
+ // "unknown permission name" check below. Returns { userGrant, all } or null if
2196
+ // the SDK file cannot be found/parsed.
2197
+ function loadSdkPermissionSets(sdkDir) {
2198
+ const candidates = [
2199
+ path.join(sdkDir, 'toolchains', 'lib', 'PermissionDefinitions.json'),
2200
+ ];
2201
+ let file = '';
2202
+ for (const c of candidates) {
2203
+ if (fs.existsSync(c)) { file = c; break; }
2204
+ }
2205
+ if (!file) return null;
2206
+ try {
2207
+ const parsed = JSON.parse(fs.readFileSync(file, 'utf-8'));
2208
+ const list = Array.isArray(parsed.definePermissions) ? parsed.definePermissions : [];
2209
+ const userGrant = new Set();
2210
+ const all = new Set();
2211
+ for (const d of list) {
2212
+ if (!d || typeof d.name !== 'string') continue;
2213
+ all.add(d.name);
2214
+ if (d.grantMode === 'user_grant') userGrant.add(d.name);
2215
+ }
2216
+ return all.size > 0 ? { userGrant, all } : null;
2217
+ } catch {
2218
+ return null;
2219
+ }
2220
+ }
2221
+
2222
+ // Names declared in module.json5's own `definePermissions` block (custom
2223
+ // permissions this module defines for OTHER apps to request) — these are
2224
+ // valid `requestPermissions` targets too, on top of the SDK-predefined set.
2225
+ function loadCustomPermissionNames(parsedModuleJson5) {
2226
+ const list = parsedModuleJson5 && parsedModuleJson5.module && Array.isArray(parsedModuleJson5.module.definePermissions)
2227
+ ? parsedModuleJson5.module.definePermissions
2228
+ : [];
2229
+ const set = new Set();
2230
+ for (const d of list) {
2231
+ if (d && typeof d.name === 'string') set.add(d.name);
2232
+ }
2233
+ return set;
2234
+ }
2235
+
2236
+ // hvigor rejects `requestPermissions` entries whose `name` is not one of the
2237
+ // SDK-predefined permissions (any grantMode) or a custom name this module
2238
+ // itself declares under `definePermissions` (00303221 Configuration Error).
2239
+ // `validatePermissionsConfig` above only checks reason/usedScene for names
2240
+ // that ARE in the user_grant set — a name that doesn't exist at all (e.g. a
2241
+ // hallucinated `ohos.permission.NOTIFICATION` instead of the real
2242
+ // `ohos.permission.ACCESS_NOTIFICATION_POLICY`) silently skips that check and
2243
+ // only surfaces once hvigor's PreBuild step runs. This closes that gap.
2244
+ function validatePermissionNamesExist(moduleJson5Text, allPermissionNames, relFile) {
2245
+ const parsed = parseJson5Loose(moduleJson5Text);
2246
+ const perms = parsed && parsed.module && Array.isArray(parsed.module.requestPermissions)
2247
+ ? parsed.module.requestPermissions
2248
+ : null;
2249
+ if (!perms) return [];
2250
+
2251
+ const customNames = loadCustomPermissionNames(parsed);
2252
+ const lineOf = (permName) => {
2253
+ const idx = moduleJson5Text.indexOf(permName);
2254
+ if (idx < 0) return 1;
2255
+ return moduleJson5Text.slice(0, idx).split('\n').length;
2256
+ };
2257
+
2258
+ const diags = [];
2259
+ for (const p of perms) {
2260
+ if (!p || typeof p.name !== 'string') continue;
2261
+ if (allPermissionNames.has(p.name) || customNames.has(p.name)) continue;
2262
+ diags.push({
2263
+ file: relFile,
2264
+ line: lineOf(p.name),
2265
+ column: 1,
2266
+ severity: 'error',
2267
+ rule: 'permission-name-exists',
2268
+ message: `Unknown permission '${p.name}'. It is not defined in the SDK's PermissionDefinitions.json and not declared under this module's own 'definePermissions'. The build will fail with a Configuration Error.`,
2269
+ });
2270
+ }
2271
+ return diags;
2272
+ }
2273
+
2274
+ // Project-level entry: locate module.json5 + string.json, run the pure checkers.
2275
+ function validatePermissions(projectPath, sdkDir) {
2276
+ const sdkSets = loadSdkPermissionSets(sdkDir);
2277
+ if (!sdkSets) return [];
2278
+
2279
+ const moduleCandidates = [
2280
+ path.join(projectPath, 'entry', 'src', 'main', 'module.json5'),
2281
+ path.join(projectPath, 'src', 'main', 'module.json5'),
2282
+ path.join(projectPath, 'entry', 'module.json5'),
2283
+ ];
2284
+ let modulePath = '';
2285
+ for (const c of moduleCandidates) {
2286
+ if (fs.existsSync(c)) { modulePath = c; break; }
2287
+ }
2288
+ if (!modulePath) return [];
2289
+
2290
+ let moduleText;
2291
+ try { moduleText = fs.readFileSync(modulePath, 'utf-8'); } catch { return []; }
2292
+
2293
+ const stringPath = path.join(path.dirname(modulePath), 'resources', 'base', 'element', 'string.json');
2294
+ let stringText = null;
2295
+ try { if (fs.existsSync(stringPath)) stringText = fs.readFileSync(stringPath, 'utf-8'); } catch { /* keep null */ }
2296
+
2297
+ const stringKeys = loadStringResourceKeys(stringText);
2298
+ const relFile = path.relative(projectPath, modulePath);
2299
+ return [
2300
+ ...validatePermissionNamesExist(moduleText, sdkSets.all, relFile),
2301
+ ...validatePermissionsConfig(moduleText, sdkSets.userGrant, stringKeys, relFile),
2302
+ ];
2303
+ }
2304
+
2305
+ // --- ArkUI binding-syntax false-positive whitelist ---
2306
+ //
2307
+ // The standalone type checker (etsStandaloneChecker) does not expand ArkUI
2308
+ // syntax sugar, so it treats binding-syntax tokens as ordinary identifiers and
2309
+ // wrongly reports `Cannot find name '$...'`. Two legal forms trigger this:
2310
+ // A) `$$this.x` two-way binding (e.g. bindSheet($$this.foo), Refresh({ refreshing: $$this.bar }))
2311
+ // B) `$varName` @Link / @Builder argument passing (e.g. Child({ items: $items }))
2312
+ // devecocli build (the real compiler) accepts both. We silently drop these
2313
+ // false positives here. Rule B only fires when `varName` is actually declared
2314
+ // as a state-decorated field in the same source file, so genuinely-undefined
2315
+ // `$foo` references are still reported.
2316
+
2317
+ const STATE_DECORATORS = [
2318
+ 'State', 'Link', 'Prop', 'ObjectLink', 'Local', 'Param', 'Provide', 'Consume',
2319
+ 'StorageLink', 'StorageProp', 'LocalStorageLink', 'LocalStorageProp',
2320
+ ];
2321
+
2322
+ // State-field decorator matcher. Compiled once at module load (the pattern is
2323
+ // built from the constant STATE_DECORATORS) instead of on every call. It carries
2324
+ // the global flag, so reset lastIndex before each reuse.
2325
+ const STATE_FIELD_RE = new RegExp(
2326
+ '@(?:' + STATE_DECORATORS.join('|') + ')(?:\\([^)]*\\))?\\s+([A-Za-z_$][\\w$]*)',
2327
+ 'g',
2328
+ );
2329
+
2330
+ // Cache: absolute source path -> Set of state-decorated field names declared in it.
2331
+ const stateFieldCache = new Map();
2332
+
2333
+ function getStateFields(absPath) {
2334
+ if (stateFieldCache.has(absPath)) return stateFieldCache.get(absPath);
2335
+ const fields = new Set();
2336
+ try {
2337
+ const content = fs.readFileSync(absPath, 'utf-8');
2338
+ STATE_FIELD_RE.lastIndex = 0;
2339
+ let m;
2340
+ while ((m = STATE_FIELD_RE.exec(content)) !== null) {
2341
+ fields.add(m[1]);
2342
+ }
2343
+ } catch {
2344
+ // unreadable file -> empty set (conservative: rule B won't match)
2345
+ }
2346
+ stateFieldCache.set(absPath, fields);
2347
+ return fields;
2348
+ }
2349
+
2350
+ // Returns true if the diagnostic is a binding-syntax false positive that should
2351
+ // be dropped. Any parse/IO issue returns false (keep the diagnostic).
2352
+ function isBindingSyntaxFalsePositive(diag, projectPath) {
2353
+ try {
2354
+ const m = /^Cannot find name '(\$[^']+)'/.exec(diag.message || '');
2355
+ if (!m) return false;
2356
+ const name = m[1];
2357
+
2358
+ // Rule A: `$$...` is always ArkUI two-way binding sugar, never a plain identifier.
2359
+ if (name.startsWith('$$')) return true;
2360
+
2361
+ // Rule B: `$word` -> confirm `word` is a state-decorated field in the same file.
2362
+ const bare = /^\$([A-Za-z_][\w$]*)$/.exec(name);
2363
+ if (!bare) return false;
2364
+ const fieldName = bare[1];
2365
+
2366
+ const absPath = path.isAbsolute(diag.file)
2367
+ ? diag.file
2368
+ : path.resolve(projectPath, diag.file);
2369
+ return getStateFields(absPath).has(fieldName);
2370
+ } catch {
2371
+ return false;
2372
+ }
2373
+ }
2374
+
2375
+ // --- Auto-fix (heuristic, deterministic) ---
2376
+ //
2377
+ // We only rewrite text the compiler has already computed for us (its
2378
+ // `Did you mean 'X'?` suggestions and fixed structural rules), then re-run the
2379
+ // checker to confirm the diagnostic disappeared. A fix that introduces a NEW
2380
+ // error on the same file is reverted. This keeps accuracy first: a wrong guess
2381
+ // self-corrects instead of silently corrupting the source.
2382
+
2383
+ // Suggestions that are generic Object/prototype members. The compiler offers
2384
+ // these when the real bug is a type mismatch (e.g. `string.value` -> `valueOf`),
2385
+ // so applying them compiles but is semantically wrong. Never auto-apply.
2386
+ const GENERIC_SUGGESTION_BLACKLIST = new Set([
2387
+ 'valueOf', 'toString', 'toLocaleString', 'length', 'constructor',
2388
+ 'hasOwnProperty', 'isPrototypeOf', 'propertyIsEnumerable', 'name',
2389
+ 'call', 'apply', 'bind',
2390
+ ]);
2391
+
2392
+ function computeLineStarts(text) {
2393
+ const starts = [0];
2394
+ for (let i = 0; i < text.length; i++) {
2395
+ if (text[i] === '\n') starts.push(i + 1);
2396
+ }
2397
+ return starts;
2398
+ }
2399
+
2400
+ function offsetOf(lineStarts, line, col) {
2401
+ const base = lineStarts[line - 1];
2402
+ if (base === undefined) return -1;
2403
+ return base + (col - 1);
2404
+ }
2405
+
2406
+ // Extract the named-binding list from a "has no default export. Did you mean to
2407
+ // use 'import { X } from "..."' instead?" diagnostic, or null.
2408
+ //
2409
+ // The compiler resolves the module and names the exact export(s) that a default
2410
+ // import should have been, so the binding text is authored by the compiler, not
2411
+ // guessed here — the same trust model as extractRename. Only the BINDING is
2412
+ // taken: the suggestion's specifier is the absolute resolved path
2413
+ // ("E:/.../ets/model/ScanFile"), so splicing the whole statement in would
2414
+ // replace a clean relative specifier with an absolute one. The diagnostic's
2415
+ // column points at the default-binding identifier, which is all we rewrite.
2416
+ //
2417
+ // A diagnostic with no suggestion means the compiler found no candidate export;
2418
+ // those are left for the model (the module may legitimately export nothing).
2419
+ function extractDefaultImportBinding(message) {
2420
+ const m = /Did you mean to use 'import (\{[^}]*\}) from "[^"]*"' instead\?/.exec(message || '');
2421
+ if (!m) return null;
2422
+ const binding = m[1].trim();
2423
+ // Guard the shape we are about to write: `{ Ident }` or `{ A, B }`, nothing
2424
+ // with aliases/nesting we have not seen the compiler emit here.
2425
+ const inner = binding.slice(1, -1).trim();
2426
+ if (!inner) return null;
2427
+ if (!/^[A-Za-z_$][\w$]*(?:\s*,\s*[A-Za-z_$][\w$]*)*$/.test(inner)) return null;
2428
+ return `{ ${inner.split(/\s*,\s*/).join(', ')} }`;
2429
+ }
2430
+
2431
+ // Extract {bad, good} from a rename-style diagnostic, or null. `good` is
2432
+ // dropped if it is a blacklisted generic member.
2433
+ function extractRename(message) {
2434
+ const good = /Did you mean '([^']+)'\?/.exec(message || '');
2435
+ if (!good) return null;
2436
+ if (GENERIC_SUGGESTION_BLACKLIST.has(good[1])) return null;
2437
+ const bad =
2438
+ /Cannot find name '([^']+)'/.exec(message) ||
2439
+ /Property '([^']+)' does not exist/.exec(message) ||
2440
+ /has no exported member(?: named)? '([^']+)'/.exec(message);
2441
+ if (!bad) return null;
2442
+ return { bad: bad[1], good: good[1] };
2443
+ }
2444
+
2445
+ // Build a list of {start, end, text, diag} edits for one file from its
2446
+ // diagnostics. Structural whole-file rewrites (import hoist) are returned as a
2447
+ // single edit spanning the region they change. Returns [] if nothing applies.
2448
+ function buildFileEdits(content, diags, fixCtx) {
2449
+ const lineStarts = computeLineStarts(content);
2450
+ const edits = [];
2451
+ let importHoistDiags = null;
2452
+ let versionMismatchDiags = null;
2453
+
2454
+ for (const d of diags) {
2455
+ // Tier 1.1: `Did you mean 'X'?` token rename.
2456
+ const rename = extractRename(d.message);
2457
+ if (rename) {
2458
+ const start = offsetOf(lineStarts, d.line, d.column);
2459
+ if (start >= 0 && content.startsWith(rename.bad, start)) {
2460
+ edits.push({ start, end: start + rename.bad.length, text: rename.good, diag: d });
2461
+ }
2462
+ continue;
2463
+ }
2464
+
2465
+ // Tier 1.2: default import of a module that has only named exports ->
2466
+ // rewrite the default binding as the compiler-named named binding.
2467
+ const defaultBinding = extractDefaultImportBinding(d.message);
2468
+ if (defaultBinding) {
2469
+ const start = offsetOf(lineStarts, d.line, d.column);
2470
+ if (start >= 0) {
2471
+ // The diagnostic column sits on the default-binding identifier; replace
2472
+ // exactly that token so the original (relative) specifier survives.
2473
+ const ident = /^[A-Za-z_$][\w$]*/.exec(content.slice(start));
2474
+ if (ident) {
2475
+ edits.push({ start, end: start + ident[0].length, text: defaultBinding, diag: d });
2476
+ }
2477
+ }
2478
+ continue;
2479
+ }
2480
+
2481
+ // Tier 1.3: async function must return Promise<T>.
2482
+ const promise = /Did you mean to write '(Promise<[^']*>)'/.exec(d.message || '');
2483
+ if (promise) {
2484
+ const start = offsetOf(lineStarts, d.line, d.column);
2485
+ // The annotated return type begins at the diagnostic column; replace the
2486
+ // type token (identifier + optional generic args) up to the following
2487
+ // '{' , '=>' , newline, or ')'.
2488
+ if (start >= 0) {
2489
+ const rest = content.slice(start);
2490
+ const m = /^[A-Za-z_$][\w$.]*(?:<[^{)=\n]*>)?/.exec(rest);
2491
+ if (m) edits.push({ start, end: start + m[0].length, text: promise[1], diag: d });
2492
+ }
2493
+ continue;
2494
+ }
2495
+
2496
+ // Tier 1.4: import path must not end with '.ets'.
2497
+ if (/An import path cannot end with a '\.ets' extension/.test(d.message || '')) {
2498
+ const lineStart = lineStarts[d.line - 1];
2499
+ if (lineStart !== undefined) {
2500
+ const lineEnd = content.indexOf('\n', lineStart);
2501
+ const line = content.slice(lineStart, lineEnd < 0 ? content.length : lineEnd);
2502
+ const idx = line.indexOf('.ets');
2503
+ if (idx >= 0) {
2504
+ const start = lineStart + idx;
2505
+ edits.push({ start, end: start + 4, text: '', diag: d });
2506
+ }
2507
+ }
2508
+ continue;
2509
+ }
2510
+
2511
+ // Tier 1.5: V1/V2 component decorator mismatch -> flip the STRUCT's
2512
+ // decorator (not the members the diagnostics point at). Collect; the edits
2513
+ // are built once below since several member diagnostics map to one struct.
2514
+ if (/component-decorator-version-mismatch/.test(d.rule || '')) {
2515
+ (versionMismatchDiags = versionMismatchDiags || []).push(d);
2516
+ continue;
2517
+ }
2518
+
2519
+ // Tier 1.6: misplaced imports -> hoist. Collect; handled once below.
2520
+ if (/arkts-no-misplaced-imports/.test(d.rule || '') ||
2521
+ /"import" statements after other statements/.test(d.message || '')) {
2522
+ (importHoistDiags = importHoistDiags || []).push(d);
2523
+ continue;
2524
+ }
2525
+
2526
+ // Tier 1.6: `Cannot find module '<relative>'` where the specifier has the
2527
+ // wrong relative depth (e.g. `../../model/X` should be `../model/X`).
2528
+ // Resolve the basename against the project index; rewrite ONLY on a unique
2529
+ // match (0 or >1 => bail, left to the model). @kit/@ohos/bare specifiers are
2530
+ // out of scope. Needs fixCtx.{index, abs}; skipped when unavailable.
2531
+ if (fixCtx && fixCtx.index && fixCtx.abs && /Cannot find module '([^']+)'/.test(d.message || '')) {
2532
+ const spec = /Cannot find module '([^']+)'/.exec(d.message)[1];
2533
+ if (spec.startsWith('.')) {
2534
+ const base = path.basename(spec).replace(/\.ets$/, '');
2535
+ const candidates = fixCtx.index.get(base);
2536
+ if (candidates && candidates.length === 1 &&
2537
+ path.resolve(candidates[0]) !== path.resolve(fixCtx.abs)) {
2538
+ const newSpec = toRelativeSpecifier(path.dirname(fixCtx.abs), candidates[0]);
2539
+ if (newSpec !== spec) {
2540
+ const lineStart = lineStarts[d.line - 1];
2541
+ if (lineStart !== undefined) {
2542
+ const lineEnd = content.indexOf('\n', lineStart);
2543
+ const line = content.slice(lineStart, lineEnd < 0 ? content.length : lineEnd);
2544
+ const q = new RegExp(`(['"])${escapeRegExp(spec)}\\1`);
2545
+ const sm = line.match(q);
2546
+ if (sm) {
2547
+ const idx = line.indexOf(sm[0]);
2548
+ edits.push({
2549
+ start: lineStart + idx,
2550
+ end: lineStart + idx + sm[0].length,
2551
+ text: `${sm[1]}${newSpec}${sm[1]}`,
2552
+ diag: d,
2553
+ });
2554
+ }
2555
+ }
2556
+ }
2557
+ }
2558
+ }
2559
+ continue;
2560
+ }
2561
+ }
2562
+
2563
+ if (versionMismatchDiags) {
2564
+ edits.push(...buildComponentVersionEdits(content, versionMismatchDiags));
2565
+ }
2566
+
2567
+ if (importHoistDiags) {
2568
+ const hoist = buildImportHoist(content, importHoistDiags);
2569
+ if (hoist) edits.push(hoist);
2570
+ }
2571
+
2572
+ return edits;
2573
+ }
2574
+
2575
+ // Flip a struct's `@Component` <-> `@ComponentV2` decorator when EVERY state
2576
+ // member decorator it carries belongs to the other component model.
2577
+ //
2578
+ // `component-decorator-version-mismatch` diagnostics point at the offending
2579
+ // MEMBER lines, but the correct single-token fix is on the struct declaration:
2580
+ // a page written with five `@Local` members under `@Component` wants
2581
+ // `@ComponentV2`, not five member rewrites. The direction is unambiguous —
2582
+ // the diagnostic names both the required and the actual struct decorator, and
2583
+ // across the observed corpus every struct's mismatches agreed on one target.
2584
+ //
2585
+ // The gate is what makes this safe: flipping affects every member of the struct,
2586
+ // so if the struct ALSO carries a decorator from the version it currently
2587
+ // declares (a `@State` next to a `@Local`), the author's intent is genuinely
2588
+ // ambiguous and both directions break something. Those are skipped and left to
2589
+ // the model. Re-parsing the file here rather than inferring membership from the
2590
+ // diagnostic list keeps the gate honest about what the struct actually contains.
2591
+ function buildComponentVersionEdits(content, diags) {
2592
+ const structNames = new Set();
2593
+ for (const d of diags) {
2594
+ const m = /but struct '(\w+)' is decorated with '@(\w+)'/.exec(d.message || '');
2595
+ if (m) structNames.add(m[1]);
2596
+ }
2597
+ if (structNames.size === 0) return [];
2598
+
2599
+ const lines = content.split('\n');
2600
+ const structs = collectStructs(lines);
2601
+
2602
+ const lineStarts = computeLineStarts(content);
2603
+ const edits = [];
2604
+
2605
+ for (let s = 0; s < structs.length; s++) {
2606
+ const { lineIdx, name, inline } = structs[s];
2607
+ if (!structNames.has(name)) continue;
2608
+
2609
+ const decorators = collectStructDecorators(lines, lineIdx, inline);
2610
+ const isV2 = decorators.has('ComponentV2');
2611
+ const isV1 = decorators.has('Component');
2612
+ // A struct carrying both is already malformed; not ours to guess at.
2613
+ if (isV1 === isV2) continue;
2614
+
2615
+ const bodyStart = lineIdx + 1;
2616
+ const bodyEnd = s + 1 < structs.length ? structs[s + 1].lineIdx : lines.length;
2617
+ let sameVersionMembers = 0;
2618
+ let otherVersionMembers = 0;
2619
+ for (let i = bodyStart; i < bodyEnd; i++) {
2620
+ const dm = /^\s*@(\w+)\b/.exec(lines[i]);
2621
+ if (!dm) continue;
2622
+ const ownSet = isV2 ? V2_ONLY_MEMBER_DECORATORS : V1_ONLY_MEMBER_DECORATORS;
2623
+ const otherSet = isV2 ? V1_ONLY_MEMBER_DECORATORS : V2_ONLY_MEMBER_DECORATORS;
2624
+ if (ownSet.has(dm[1])) sameVersionMembers++;
2625
+ else if (otherSet.has(dm[1])) otherVersionMembers++;
2626
+ }
2627
+ // Mixed members, or nothing to move toward: leave it alone.
2628
+ if (sameVersionMembers > 0 || otherVersionMembers === 0) continue;
2629
+
2630
+ // Rewrite the struct's own decorator token, wherever it sits: inline on the
2631
+ // `struct` line or on one of the decorator-only lines above it.
2632
+ const from = isV2 ? 'ComponentV2' : 'Component';
2633
+ const to = isV2 ? 'Component' : 'ComponentV2';
2634
+ // Walk the struct line, then the decorator-only lines above it (the same
2635
+ // range collectStructDecorators considers), and stop at the first exact
2636
+ // `@Component`/`@ComponentV2` token.
2637
+ let targetLine = -1;
2638
+ let col = -1;
2639
+ for (let i = lineIdx; i >= 0; i--) {
2640
+ if (i < lineIdx && !/^\s*@\w+(?:\([^)]*\))?\s*$/.test(lines[i])) break;
2641
+ const idx = lines[i].indexOf('@' + from);
2642
+ // `@Component` is a prefix of `@ComponentV2`: require a non-word char after.
2643
+ if (idx >= 0 && !/^\w/.test(lines[i].slice(idx + 1 + from.length))) {
2644
+ targetLine = i;
2645
+ col = idx;
2646
+ break;
2647
+ }
2648
+ }
2649
+ if (targetLine < 0) continue;
2650
+
2651
+ const start = lineStarts[targetLine] + col;
2652
+ edits.push({
2653
+ start,
2654
+ end: start + 1 + from.length,
2655
+ text: '@' + to,
2656
+ diag: diags.filter((d) => new RegExp(`but struct '${name}' is decorated`).test(d.message || '')),
2657
+ });
2658
+ }
2659
+
2660
+ return edits;
2661
+ }
2662
+
2663
+ // Move every top-level `import ...` line to the top of the file (after any
2664
+ // leading comment/copyright block), preserving original order. Returns a single
2665
+ // edit rewriting the whole file, or null if nothing to move.
2666
+ function buildImportHoist(content, diags) {
2667
+ const lines = content.split('\n');
2668
+ const importLineIdx = [];
2669
+ for (let i = 0; i < lines.length; i++) {
2670
+ if (/^\s*import\s.+from\s+['"].+['"];?\s*$/.test(lines[i]) ||
2671
+ /^\s*import\s+['"].+['"];?\s*$/.test(lines[i])) {
2672
+ importLineIdx.push(i);
2673
+ }
2674
+ }
2675
+ if (importLineIdx.length === 0) return null;
2676
+
2677
+ // Insertion point = number of leading comment/blank lines. Hoisted imports
2678
+ // go right after that block (below any copyright header). Imports are never
2679
+ // in this leading region since the scan breaks at the first import.
2680
+ const importSet = new Set(importLineIdx);
2681
+ let insertAt = 0;
2682
+ let inBlock = false;
2683
+ for (let i = 0; i < lines.length; i++) {
2684
+ const t = lines[i].trim();
2685
+ if (inBlock) { if (t.includes('*/')) inBlock = false; insertAt = i + 1; continue; }
2686
+ if (t === '') { insertAt = i + 1; continue; }
2687
+ if (t.startsWith('//')) { insertAt = i + 1; continue; }
2688
+ if (t.startsWith('/*')) { inBlock = !t.includes('*/'); insertAt = i + 1; continue; }
2689
+ break;
2690
+ }
2691
+
2692
+ // Already contiguous starting right after the leading block -> nothing to do.
2693
+ const alreadyAtTop = importLineIdx.every((idx, k) => idx === insertAt + k);
2694
+ if (alreadyAtTop) return null;
2695
+
2696
+ // Strip leading indentation (hoisted imports sit at column 0) and trailing
2697
+ // spaces/tabs, but preserve a trailing '\r' so CRLF files don't end up with
2698
+ // mixed line endings (the untouched lines below keep their '\r').
2699
+ const hoisted = importLineIdx.map((i) => lines[i].replace(/^[ \t]+/, '').replace(/[ \t]+$/, ''));
2700
+ const remaining = [];
2701
+ for (let i = 0; i < lines.length; i++) {
2702
+ if (importSet.has(i)) continue;
2703
+ remaining.push(lines[i]);
2704
+ }
2705
+ remaining.splice(insertAt, 0, ...hoisted);
2706
+ return { start: 0, end: content.length, text: remaining.join('\n'), diag: diags, wholeFile: true };
2707
+ }
2708
+
2709
+ // Extract {module, name} from a "declares locally but not exported" diagnostic,
2710
+ // or null. This diagnostic appears on the *importing* file; the fix (adding
2711
+ // `export`) must land in the module that owns the declaration.
2712
+ // Module '"../view/PollListPage"' declares 'PollListPage' locally, but it is not exported.
2713
+ function extractMissingExport(message) {
2714
+ const m = /Module '"([^"]+)"' declares '([^']+)' locally, but it is not exported\./.exec(message || '');
2715
+ if (!m) return null;
2716
+ return { module: m[1], name: m[2] };
2717
+ }
2718
+
2719
+ // The absolute path from a "File '...' is not a module" diagnostic (TS2306), or
2720
+ // null. It appears on the importer when the imported file exports nothing at
2721
+ // all, so the names to export are the ones the importer asks for.
2722
+ // File '/p/entry/src/main/ets/components/Card.ets' is not a module.
2723
+ function extractNotAModule(message) {
2724
+ const m = /^File '([^']+)' is not a module\./.exec(message || '');
2725
+ return m ? m[1] : null;
2726
+ }
2727
+
2728
+ // The names `importerSource` imports with `import { A, B as C } from '<spec>'`
2729
+ // from the file `declAbs` (C's original name, A and B). Default and namespace
2730
+ // imports name nothing that `export` on a declaration would provide.
2731
+ function namedImportsFrom(importerSource, importerAbs, declAbs) {
2732
+ const names = [];
2733
+ const re = /import\s+(?:type\s+)?\{([^}]*)\}\s*from\s*['"]([^'"]+)['"]/g;
2734
+ let m;
2735
+ while ((m = re.exec(importerSource)) !== null) {
2736
+ const target = resolveModuleFile(importerAbs, m[2]);
2737
+ if (!target || path.resolve(target) !== path.resolve(declAbs)) continue;
2738
+ for (const part of m[1].split(',')) {
2739
+ const name = part.trim().replace(/^type\s+/, '').split(/\s+as\s+/)[0].trim();
2740
+ if (/^[A-Za-z_$][\w$]*$/.test(name)) names.push(name);
2741
+ }
2742
+ }
2743
+ return names;
2744
+ }
2745
+
2746
+ // Given the source of the module that owns `name`, return an edit that prefixes
2747
+ // its top-level declaration with `export `, or null if the declaration can't be
2748
+ // located unambiguously. Handles class/struct/interface/enum/function/const/
2749
+ // let/var/type and `@Component`-decorated structs (export goes before the
2750
+ // decorator chain). Never touches a declaration that is already exported.
2751
+ function buildExportEdit(content, name) {
2752
+ const lineStarts = computeLineStarts(content);
2753
+ const declRe = new RegExp(
2754
+ `^(\\s*)(?:@\\w+(?:\\([^)]*\\))?\\s*)*` +
2755
+ `(class|struct|interface|enum|function|const|let|var|type)\\s+${escapeRegExp(name)}\\b`,
2756
+ );
2757
+ for (let i = 0; i < lineStarts.length; i++) {
2758
+ const start = lineStarts[i];
2759
+ const end = i + 1 < lineStarts.length ? lineStarts[i + 1] : content.length;
2760
+ const line = content.slice(start, end);
2761
+ if (!declRe.test(line)) continue;
2762
+ // Walk back over a decorator chain (lines like `@Component` / `@Entry`).
2763
+ let declLineIdx = i;
2764
+ while (declLineIdx > 0 && /^\s*@\w+(?:\([^)]*\))?\s*$/.test(
2765
+ content.slice(lineStarts[declLineIdx - 1], lineStarts[declLineIdx]),
2766
+ )) declLineIdx--;
2767
+ const declStart = lineStarts[declLineIdx];
2768
+ if (/^\s*export\b/.test(content.slice(declStart))) return null; // already exported
2769
+ // Insert 'export ' right before the declaration keyword on the keyword's line.
2770
+ // In ArkTS, `export` must precede the keyword (struct/class/...), not the decorator:
2771
+ // @Component
2772
+ // export struct MyComponent ← correct
2773
+ // not:
2774
+ // export @Component ← "Declaration or statement expected"
2775
+ // struct MyComponent
2776
+ const prefixMatch = line.match(/^\s*(?:@\w+(?:\([^)]*\))?\s*)*/);
2777
+ const keywordOffset = prefixMatch ? prefixMatch[0].length : 0;
2778
+ const insertAt = start + keywordOffset;
2779
+ return { start: insertAt, end: insertAt, text: 'export ', name };
2780
+ }
2781
+ return null;
2782
+ }
2783
+
2784
+ function escapeRegExp(s) {
2785
+ return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
2786
+ }
2787
+
2788
+ // Resolve a relative module specifier (from `importerAbs`) to an on-disk .ets
2789
+ // file. Tries `spec.ets`, `spec/index.ets`, and a bare `spec` that already has
2790
+ // an extension. Returns an absolute path or null. Only relative specifiers
2791
+ // (./ or ../) are resolved; bare package imports are left to the compiler.
2792
+ function resolveModuleFile(importerAbs, spec) {
2793
+ if (!spec.startsWith('.')) return null;
2794
+ const base = path.resolve(path.dirname(importerAbs), spec);
2795
+ const candidates = /\.ets$/.test(base)
2796
+ ? [base]
2797
+ : [base + '.ets', path.join(base, 'index.ets')];
2798
+ for (const c of candidates) {
2799
+ try { if (fs.statSync(c).isFile()) return c; } catch { /* not present */ }
2800
+ }
2801
+ return null;
2802
+ }
2803
+
2804
+ // Build a basename -> [absPath] index over the project's .ets files so the
2805
+ // relative-import fixer (Tier 1.6) can resolve a broken specifier to a real
2806
+ // file. Built once per autofix pass and passed in via fixCtx.
2807
+ function buildModuleIndex(project) {
2808
+ const index = new Map();
2809
+ for (const abs of collectEtsFiles(project)) {
2810
+ const base = path.basename(abs, '.ets');
2811
+ if (!index.has(base)) index.set(base, []);
2812
+ index.get(base).push(abs);
2813
+ }
2814
+ return index;
2815
+ }
2816
+
2817
+ // Normalize an absolute target into a relative ESM specifier for `fromDir`:
2818
+ // forward slashes, no `.ets` extension, `./` prefix when not already `../`.
2819
+ function toRelativeSpecifier(fromDir, targetAbs) {
2820
+ let rel = path.relative(fromDir, targetAbs).replace(/\\/g, '/');
2821
+ rel = rel.replace(/\.ets$/, '');
2822
+ if (!rel.startsWith('.')) rel = './' + rel;
2823
+ return rel;
2824
+ }
2825
+
2826
+ // Apply edits to content. Edits are applied in descending start order so
2827
+ // offsets stay valid; overlapping edits are skipped (kept for a later pass).
2828
+ // Returns { output, applied: [diag...] }.
2829
+ function applyEdits(content, edits) {
2830
+ const sorted = edits.slice().sort((a, b) => b.start - a.start || b.end - a.end);
2831
+ let output = content;
2832
+ let lastStart = Infinity;
2833
+ const applied = [];
2834
+ for (const e of sorted) {
2835
+ if (e.end > lastStart) continue; // overlaps a later-in-file edit already applied
2836
+ output = output.slice(0, e.start) + e.text + output.slice(e.end);
2837
+ lastStart = e.start;
2838
+ if (Array.isArray(e.diag)) applied.push(...e.diag);
2839
+ else applied.push(e.diag);
2840
+ }
2841
+ return { output, applied };
2842
+ }
2843
+
2844
+ // Stable identity for a diagnostic, used to diff before/after re-check.
2845
+ function diagKey(d) {
2846
+ return `${d.file}|${d.line}|${d.column}|${d.message}`;
2847
+ }
2848
+
2849
+ // Order by file, then position, then rule/message. Plain code-unit comparison
2850
+ // rather than localeCompare so the result does not depend on the host locale.
2851
+ function compareStr(a, b) {
2852
+ return a < b ? -1 : a > b ? 1 : 0;
2853
+ }
2854
+
2855
+ function sortDiagnostics(diags) {
2856
+ return diags.slice().sort((a, b) =>
2857
+ compareStr(String(a.file || ''), String(b.file || '')) ||
2858
+ (a.line || 0) - (b.line || 0) ||
2859
+ (a.column || 0) - (b.column || 0) ||
2860
+ compareStr(String(a.rule || ''), String(b.rule || '')) ||
2861
+ compareStr(String(a.message || ''), String(b.message || '')));
2862
+ }
2863
+
2864
+ // --- Checker run (reusable so we can re-check after applying fixes) ---
2865
+
2866
+ // hvigor's getResolveModules() returns, per compiled module, four entries:
2867
+ // [modulePath/src/main, <module>/build/<product>/generated/profile/<target>,
2868
+ // modulePath/oh_modules, projectTopDir/oh_modules]. The oh_modules pair feeds
2869
+ // rollup resolution, which the SDK checker does not use (it resolves packages
2870
+ // itself), so only the first two shapes are reproduced here. What must be
2871
+ // exact is the generated profile dir: hvigor compiles exactly ONE path per
2872
+ // module — the selected product's — never a glob over <module>/build/*, so a
2873
+ // multi-product project must not leak another product's BuildProfile into the
2874
+ // check, and a nested module (srcPath ./features/sub) must resolve its own.
2875
+
2876
+ // Target hvigor compiles for `productName`: the declared target whose
2877
+ // applyToProducts includes the product; otherwise 'default' (hvigor's own
2878
+ // fallback — DefaultTargetConst.DEFAULT_TARGET).
2879
+ function pickTargetNameForProduct(targets, productName) {
2880
+ if (Array.isArray(targets)) {
2881
+ for (const t of targets) {
2882
+ if (t && Array.isArray(t.applyToProducts) && t.applyToProducts.includes(productName) &&
2883
+ typeof t.name === 'string' && t.name) {
2884
+ return t.name;
2885
+ }
2886
+ }
2887
+ }
2888
+ return 'default';
2889
+ }
2890
+
2891
+ // modules[].srcPath from build-profile.json5 is the authoritative module list
2892
+ // hvigor compiles. Filesystem walks miss nested modules and pick up unrelated
2893
+ // dirs; the declared list does neither. The product choice mirrors
2894
+ // readProjectSdkConfig so the check compiles against the same product the SDK
2895
+ // config was resolved from.
2896
+ function readProjectModules(projectPath) {
2897
+ let parsed = null;
2898
+ try {
2899
+ parsed = parseJson5Loose(fs.readFileSync(path.join(projectPath, 'build-profile.json5'), 'utf-8'));
2900
+ } catch { /* missing/unparseable profile: no declared modules */ }
2901
+ const products = parsed && parsed.app && Array.isArray(parsed.app.products) ? parsed.app.products : [];
2902
+ const product = products.find((p) => p && p.name === 'default') || products[0] || null;
2903
+ const productName = product && typeof product.name === 'string' && product.name ? product.name : 'default';
2904
+ const rawModules = parsed && Array.isArray(parsed.modules) ? parsed.modules : [];
2905
+ const modules = [];
2906
+ for (const m of rawModules) {
2907
+ if (!m || typeof m.name !== 'string' || !m.name) continue;
2908
+ const srcPath = typeof m.srcPath === 'string' && m.srcPath ? m.srcPath : './' + m.name;
2909
+ modules.push({ name: m.name, srcPath, target: pickTargetNameForProduct(m.targets, productName) });
2910
+ }
2911
+ return { productName, modules };
2912
+ }
2913
+
2914
+ // Exported for tests: the remediation hint when BuildProfile cannot resolve
2915
+ // because nothing has been built yet (issue #614), or null when build output
2916
+ // exists (an unresolved BuildProfile then means something else).
2917
+ function buildProfileNotBuiltHint(projectPath) {
2918
+ const { productName, modules } = readProjectModules(projectPath);
2919
+ if (modules.length === 0) return null;
2920
+ const built = modules.some((m) =>
2921
+ fs.existsSync(path.resolve(projectPath, m.srcPath, 'build', productName, 'generated', 'profile')));
2922
+ if (built) return null;
2923
+ return `No module has build output for product '${productName}' yet, so generated modules such as BuildProfile do not exist. ` +
2924
+ `Run a build first (oniro-app build), then re-run the check.`;
2925
+ }
2926
+
2927
+ const resolveModulePathsCache = new Map();
2928
+
2929
+ function collectResolveModulePaths(projectPath) {
2930
+ const cached = resolveModulePathsCache.get(projectPath);
2931
+ if (cached) return cached;
2932
+
2933
+ const roots = [];
2934
+ const seen = new Set();
2935
+ const push = (p) => {
2936
+ try {
2937
+ const r = path.resolve(p);
2938
+ if (!seen.has(r) && fs.existsSync(r)) {
2939
+ seen.add(r);
2940
+ roots.push(r);
2941
+ }
2942
+ } catch { /* ignore */ }
2943
+ };
2944
+
2945
+ const { productName, modules } = readProjectModules(projectPath);
2946
+ if (modules.length > 0) {
2947
+ for (const m of modules) {
2948
+ const modulePath = path.resolve(projectPath, m.srcPath);
2949
+ push(path.join(modulePath, 'src', 'main'));
2950
+ // Exactly hvigor's buildGeneratedProfilePath shape. A missing dir (module
2951
+ // not built for this product yet) is simply not added — runCheck then
2952
+ // attaches buildProfileNotBuiltHint to the resulting diagnostic.
2953
+ push(path.join(modulePath, 'build', productName, 'generated', 'profile', m.target));
2954
+ }
2955
+ } else {
2956
+ // No declared modules (minimal or unparseable profile): fall back to
2957
+ // discovering modules on disk so a plain layout still resolves.
2958
+ findModuleSrcMainOnDisk(projectPath, push);
2959
+ }
2960
+
2961
+ resolveModulePathsCache.set(projectPath, roots);
2962
+ return roots;
2963
+ }
2964
+
2965
+ // Legacy fallback for collectResolveModulePaths when build-profile.json5
2966
+ // declares no modules: walk the tree for src/main dirs and generated profile
2967
+ // dirs. Nested-module discovery needs the depth recursion; declared-srcPath
2968
+ // mode does not.
2969
+ function findModuleSrcMainOnDisk(projectPath, push) {
2970
+ function findModuleSrcMain(dir, depth) {
2971
+ if (depth > 3 || !fs.existsSync(dir)) return;
2972
+ const srcMain = path.join(dir, 'src', 'main');
2973
+ if (fs.existsSync(srcMain)) push(srcMain);
2974
+ let items;
2975
+ try { items = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
2976
+ for (const it of items) {
2977
+ if (it.isDirectory() && !['node_modules', 'oh_modules', 'build', '.git', '.hvigor', '.cache'].includes(it.name)) {
2978
+ findModuleSrcMain(path.join(dir, it.name), depth + 1);
2979
+ }
2980
+ }
2981
+ }
2982
+ findModuleSrcMain(projectPath, 0);
2983
+
2984
+ function walkBuildGenerated(dir) {
2985
+ if (!fs.existsSync(dir)) return;
2986
+ let moduleDirs;
2987
+ try { moduleDirs = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
2988
+ for (const mod of moduleDirs) {
2989
+ if (!mod.isDirectory()) continue;
2990
+ const buildDir = path.join(dir, mod.name, 'build');
2991
+ if (!fs.existsSync(buildDir)) continue;
2992
+ let buildEntries;
2993
+ try { buildEntries = fs.readdirSync(buildDir, { withFileTypes: true }); } catch { continue; }
2994
+ for (const b of buildEntries) {
2995
+ if (!b.isDirectory()) continue;
2996
+ const profileDir = path.join(buildDir, b.name, 'generated', 'profile');
2997
+ if (!fs.existsSync(profileDir)) continue;
2998
+ let targets;
2999
+ try { targets = fs.readdirSync(profileDir, { withFileTypes: true }); } catch { continue; }
3000
+ for (const t of targets) {
3001
+ if (t.isDirectory()) push(path.join(profileDir, t.name));
3002
+ }
3003
+ }
3004
+ }
3005
+ }
3006
+ walkBuildGenerated(projectPath);
3007
+ }
3008
+
3009
+ // Project-level diagnostics read only immutable config/SDK files (module.json5,
3010
+ // main_pages.json, hvigor-config.json5, PermissionDefinitions.json, string.json,
3011
+ // resources/*/{media,profile,element}) plus a scan of `files` and — for the
3012
+ // cross-file @ObservedV2 index — every other project .ets. main() computes them
3013
+ // ONCE and carries them on env; runChecker just re-injects the array instead of
3014
+ // re-reading and re-parsing the SDK's (large) PermissionDefinitions.json on
3015
+ // every pass.
3016
+ //
3017
+ // Most fix tiers cannot invalidate this: they rewrite import specifiers, export
3018
+ // keywords and type annotations, never config/resource files, `$r('app.*')`
3019
+ // references, or an @ObservedV2 class declaration. The component-version fix
3020
+ // (Tier 1.5) IS an exception — it rewrites the very @Component/@ComponentV2
3021
+ // decorator these checks read — so applyAutoFixes recomputes this after applying
3022
+ // edits rather than trusting the cache.
3023
+ function computeProjectDiagnostics(files, env, hasExplicitFiles) {
3024
+ const { sdkDir, projectPath } = env;
3025
+ const fileScoped = [
3026
+ ...validateSystemResources(files, sdkDir, projectPath),
3027
+ ...validateAppResources(files, projectPath),
3028
+ ...validateComponentDecorators(files, projectPath),
3029
+ ...validateObservedV2PropertyTypes(files, projectPath),
3030
+ ...validateRegularPropertyInit(files, projectPath),
3031
+ ...validateStructNameCollisions(files, projectPath),
3032
+ ...validateEntryBuildRootNode(files, projectPath),
3033
+ ...validateBuilderBodyStatements(files, projectPath),
3034
+ ...validateV2MemberDecoratorRules(files, projectPath),
3035
+ ...validateNavDestinationRegistration(files, projectPath),
3036
+ ...validateHideNavBarUsage(files, projectPath),
3037
+ ...validateNavDestinationRoot(files, projectPath),
3038
+ ...validateAppStorageV2Mixing(files, projectPath),
3039
+ ];
3040
+ // 项目级校验器(不依赖 files 参数)仅在检查整个项目时运行,
3041
+ // 指定具体文件时跳过,避免因被检查文件不在列表中而误报。
3042
+ if (hasExplicitFiles) return fileScoped;
3043
+ return [
3044
+ ...fileScoped,
3045
+ ...validateRouterPages(projectPath),
3046
+ ...validateRouteMapProfile(projectPath),
3047
+ ...validateResourceDirNames(projectPath),
3048
+ ...validateModelVersion(projectPath),
3049
+ ...validatePermissions(projectPath, sdkDir),
3050
+ ];
3051
+ }
3052
+
3053
+ // Runs etsStandaloneChecker over `files` and returns the filtered diagnostics
3054
+ // (relative paths, binding/FA false positives removed). `env` carries the
3055
+ // resolved paths so we don't re-detect the SDK on every call.
3056
+ function cleanupCheckerCache(projectPath) {
3057
+ const cachePath = path.join(projectPath, '.cache', 'arkts-check');
3058
+ try { fs.rmSync(cachePath, { recursive: true, force: true }); } catch { /* best-effort */ }
3059
+ // `.cache` 可能被其它工具使用,仅在为空时移除。
3060
+ try {
3061
+ const parent = path.dirname(cachePath);
3062
+ if (fs.readdirSync(parent).length === 0) fs.rmdirSync(parent);
3063
+ } catch { /* best-effort */ }
3064
+ }
3065
+
3066
+ function runChecker(files, env) {
3067
+ const { sdkDir, etsLoaderPath, projectPath, aceModuleJsonPath } = env;
3068
+ // Carried on env when runCheck computed it (one profile read per request);
3069
+ // read here so runChecker stays usable standalone, as in tests.
3070
+ const sdkConfig = env.sdkConfig || readProjectSdkConfig(projectPath, env.sdkDir);
3071
+
3072
+ const fileMap = {};
3073
+ files.forEach((f, i) => { fileMap[`file_${i}`] = f; });
3074
+
3075
+ const captured = [];
3076
+ let checkerFailure;
3077
+ const origLog = console.log;
3078
+ const origError = console.error;
3079
+ const origWarn = console.warn;
3080
+ const capture = (...a) => { captured.push(a.map(String).join(' ')); };
3081
+ console.log = capture;
3082
+ console.error = capture;
3083
+ console.warn = capture;
3084
+
3085
+ const hmsSdkEts = env.hmsEtsDir || '';
3086
+ if (hmsSdkEts && fs.existsSync(hmsSdkEts) && !(process.env.externalApiPaths || '').includes(hmsSdkEts)) {
3087
+ const existing = process.env.externalApiPaths || '';
3088
+ process.env.externalApiPaths = existing ? existing + path.delimiter + hmsSdkEts : hmsSdkEts;
3089
+ }
3090
+
3091
+ try {
3092
+ const etsChecker = require(path.join(etsLoaderPath, 'lib', 'ets_checker.js'));
3093
+ const mainModule = require(path.join(etsLoaderPath, 'main.js'));
3094
+
3095
+ Object.assign(mainModule.partialUpdateConfig, {
3096
+ executeArkTSLinter: true,
3097
+ standardArkTSLinter: true,
3098
+ });
3099
+
3100
+ // SDK 6.1.1+ added an API whitelist validator that reads
3101
+ // `main.projectConfig.globalModulePaths` (api/, arkts/, kits/) and calls
3102
+ // `.some` on it unguarded. In a full build, init_config.js copies it there
3103
+ // from the module-level `main.globalModulePaths` that main.js populates on
3104
+ // load; the standalone checker never runs init_config, so the field is
3105
+ // undefined and every check dies with "Cannot read properties of undefined".
3106
+ // Bridge the two here. Older SDKs lack the validator entirely and ignore it.
3107
+ if (Array.isArray(mainModule.globalModulePaths) && !mainModule.projectConfig.globalModulePaths) {
3108
+ mainModule.projectConfig.globalModulePaths = mainModule.globalModulePaths;
3109
+ }
3110
+
3111
+ // The checker's `checkSinceValue` and `checkFileHasAvailableByFileName`
3112
+ // (local functions in api_check_utils.js) gate the @since version
3113
+ // comparison on `sourceFilePath.startsWith(projectRootPath)`. SDK .d.ts
3114
+ // files live under the SDK ets directory, so projectRootPath must point
3115
+ // there for the path check to pass and the version comparison to run.
3116
+ // Set it directly on the module's projectConfig — NOT in the projectConfig
3117
+ // object below — so `Object.assign` in `initEtsStandaloneCheckerConfig`
3118
+ // does not overwrite it.
3119
+ mainModule.projectConfig.projectRootPath = path.join(sdkDir, 'ets');
3120
+
3121
+ // Standalone checker must mirror the rollup ets-checker path: only
3122
+ // with compileTool='rollup' does setCompilerOptions map
3123
+ // resolveModulePaths into compilerOptions.paths ...
3124
+ process.env.compileTool = 'rollup';
3125
+ process.env.compileMode = 'moduleJson';
3126
+
3127
+ const projectConfig = {
3128
+ projectPath,
3129
+ modulePath: projectPath,
3130
+ cachePath: path.join(projectPath, '.cache', 'arkts-check'),
3131
+ aceModuleJsonPath,
3132
+ compileMode: 'esmodule',
3133
+ etsLoaderPath,
3134
+ packageManagerType: 'ohpm',
3135
+ packageDir: 'oh_modules',
3136
+ // From the project's build-profile.json5, not assumed: checking against a
3137
+ // different API surface than the build compiles against is how a check
3138
+ // passes on a member the build then rejects.
3139
+ runtimeOS: sdkConfig.runtimeOS,
3140
+ sdkInfo: sdkConfig.sdkInfo,
3141
+ compatibleSdkVersion: sdkConfig.compatibleSdkVersion,
3142
+ // SinceJSDocChecker reads originCompatibleSdkVersion first, falling back
3143
+ // to compatibleSdkVersion. partialUpdateController sets minAPIVersion.
3144
+ // Set all three to the same resolved API level so every code path in the
3145
+ // checker sees a consistent version.
3146
+ compileSdkVersion: sdkConfig.compatibleSdkVersion,
3147
+ minAPIVersion: sdkConfig.compatibleSdkVersion,
3148
+ originCompatibleSdkVersion: sdkConfig.compatibleSdkVersion,
3149
+ bundleType: '',
3150
+ compilerTypes: [],
3151
+ resolveModulePaths: collectResolveModulePaths(projectPath),
3152
+ };
3153
+
3154
+ // The checker persists .tsbuildinfo/.tsbuildinfo.linter under cachePath.
3155
+ // Stale cache from a prior run with different parameters causes the
3156
+ // checker to reuse old diagnostics — the @since version checks silently
3157
+ // never re-run. Delete the cache directory before each run.
3158
+ try { fs.rmSync(projectConfig.cachePath, { recursive: true, force: true }); } catch { /* best-effort */ }
3159
+ fs.mkdirSync(projectConfig.cachePath, { recursive: true });
3160
+
3161
+ const logger = { debug: capture, info: capture, warn: capture, error: capture };
3162
+ etsChecker.etsStandaloneChecker(fileMap, logger, projectConfig);
3163
+ } catch (e) {
3164
+ // The checker threw, so it type-checked nothing (or stopped partway) and the
3165
+ // diagnostics below describe an incomplete run. Recording this in `captured`
3166
+ // is not enough: the parse loop only keeps lines matching `ArkTS:ERROR File:
3167
+ // path:line:col`, so this line is dropped and the run reports as clean --
3168
+ // a crash and a genuinely error-free project become indistinguishable.
3169
+ // Rethrow instead. In the daemon, handleRequest turns it into an `error`
3170
+ // result the tool surfaces; as a one-shot CLI it exits non-zero with the
3171
+ // message on stderr, which runOneShot raises. Both are loud.
3172
+ checkerFailure = e;
3173
+ } finally {
3174
+ console.log = origLog;
3175
+ console.error = origError;
3176
+ console.warn = origWarn;
3177
+ // 缓存只在单次检查期间有效(下次运行前会重建),结束后清掉,
3178
+ // 避免在用户工程根残留 .cache/arkts-check 目录。
3179
+ cleanupCheckerCache(projectPath);
3180
+ }
3181
+
3182
+ // Thrown after console is restored so the message is not swallowed by capture.
3183
+ if (checkerFailure) {
3184
+ // The stack, not just the message: the failures worth diagnosing here come
3185
+ // from inside the SDK's own loader reading a projectConfig field this script
3186
+ // does not supply, and a bare "Cannot read properties of undefined" names
3187
+ // neither the field nor the loader module. The top frames do.
3188
+ const detail = checkerFailure.stack || checkerFailure.message;
3189
+ throw new Error(
3190
+ `arkts_check could not complete: the ArkTS checker failed over ${files.length} file(s). ` +
3191
+ `No type-check result is available, so this is NOT a clean result. ` +
3192
+ `Underlying error: ${detail}`,
3193
+ );
3194
+ }
3195
+
3196
+ const diagnostics = [];
3197
+ let current = null;
3198
+ const lines = captured.flatMap(entry => entry.split('\n'));
3199
+
3200
+ for (const line of lines) {
3201
+ const clean = line.replace(/\x1b\[\d+m/g, '').replace(/\[(\d+)m/g, '');
3202
+ const loc = parseDiagnosticLine(clean);
3203
+ if (loc) { current = loc; continue; }
3204
+ if (current && clean.trim() && !clean.includes('ArkTS:') && !clean.includes('For details about')) {
3205
+ const { message, rule } = parseMessageLine(clean);
3206
+ diagnostics.push({
3207
+ file: path.relative(projectPath, current.file),
3208
+ line: current.line,
3209
+ column: current.column,
3210
+ severity: current.severity === 'error' ? 'error' : 'warning',
3211
+ message,
3212
+ rule,
3213
+ });
3214
+ current = null;
3215
+ }
3216
+ }
3217
+
3218
+ // Reuse the project-level diagnostics computed once in main(); fall back to
3219
+ // computing them here so runChecker stays usable standalone (e.g. in tests).
3220
+ const projectDiagnostics = env.skipProjectDiagnostics
3221
+ ? []
3222
+ : (env.projectDiagnostics || computeProjectDiagnostics(files, env, env.hasExplicitFiles));
3223
+ diagnostics.push(...projectDiagnostics);
3224
+
3225
+ return dropFalsePositives(diagnostics, projectPath, aceModuleJsonPath);
3226
+ }
3227
+
3228
+ // The checker type-checks the whole program, so SDK `.d.ets` files are pulled in
3229
+ // transitively. Its parser does not understand the SDK's own ArkTS annotation
3230
+ // syntax (`@interface Retention` in @arkts.lang.d.ets), so it reports parse
3231
+ // errors against SDK sources that the real hvigor build never reports. Neither
3232
+ // user code nor fixable, so drop them.
3233
+ function isOutsideProject(relPath) {
3234
+ // Across drive letters (project on E:, SDK on C:) path.relative returns an
3235
+ // absolute path instead of a `..` prefix, so both checks are needed.
3236
+ return relPath.startsWith('..') || path.isAbsolute(relPath);
3237
+ }
3238
+
3239
+ // Declaration files are never AI-authored source, so their diagnostics are not
3240
+ // actionable even when they sit inside the project (e.g. under oh_modules).
3241
+ function isDeclarationFile(relPath) {
3242
+ return relPath.endsWith('.d.ets') || relPath.endsWith('.d.ts');
3243
+ }
3244
+
3245
+ // Out-of-project and declaration-file noise, FA-mode noise on a Stage project,
3246
+ // and ArkUI `$`/`$$` binding sugar the checker reads as unknown identifiers.
3247
+ // Shared so the cached path filters identically.
3248
+ function dropFalsePositives(diagnostics, projectPath, aceModuleJsonPath) {
3249
+ const isStageProject = aceModuleJsonPath !== '';
3250
+ return diagnostics.filter(d => {
3251
+ if (isOutsideProject(d.file)) return false;
3252
+ if (isDeclarationFile(d.file)) return false;
3253
+ if (isStageProject && d.message.includes('the current Mode is FA')) return false;
3254
+ if (isBindingSyntaxFalsePositive(d, projectPath)) return false;
3255
+ return true;
3256
+ });
3257
+ }
3258
+
3259
+ // Checker diagnostics for `files`, reusing cached results for files whose
3260
+ // dependency closure is unchanged and running the checker only over the rest.
3261
+ // Project-level diagnostics are excluded — they are computed once per request in
3262
+ // runCheck and are not per-file, so they must not enter the per-file cache.
3263
+ //
3264
+ // Worth roughly 25ms per skipped file. That is small next to the ~2s fixed cost
3265
+ // of a warm check, but it is what makes re-sending an unchanged 47-file list
3266
+ // cheap, which is the pattern the prompt asks the model to follow.
3267
+ function cachedCheckerDiagnostics(files, env) {
3268
+ const epoch = computeEpoch(env.projectPath, env.sdkDir);
3269
+ if (epoch !== cacheEpoch) {
3270
+ fileDiagCache.clear();
3271
+ cacheEpoch = epoch;
3272
+ }
3273
+
3274
+ const hashes = new Map(files.map((f) => [f, closureHash(f)]));
3275
+ const stale = files.filter((f) => fileDiagCache.get(f)?.closureHash !== hashes.get(f));
3276
+
3277
+ if (stale.length > 0) {
3278
+ const fresh = runChecker(stale, { ...env, skipProjectDiagnostics: true });
3279
+ // Seed an empty entry for every re-checked file so a clean file is a future
3280
+ // hit rather than a permanent miss.
3281
+ for (const f of stale) fileDiagCache.set(f, { closureHash: hashes.get(f), diagnostics: [] });
3282
+ for (const d of fresh) {
3283
+ const abs = path.isAbsolute(d.file) ? d.file : path.resolve(env.projectPath, d.file);
3284
+ // The checker can attribute a diagnostic to a file outside `stale` (for
3285
+ // example a dependency); those are kept in the result but not cached, since
3286
+ // this pass did not check that file in full.
3287
+ const entry = fileDiagCache.get(abs);
3288
+ if (entry && entry.closureHash === hashes.get(abs)) entry.diagnostics.push(d);
3289
+ }
3290
+ return [...fresh, ...files.filter((f) => !stale.includes(f)).flatMap((f) => fileDiagCache.get(f).diagnostics)];
3291
+ }
3292
+
3293
+ return files.flatMap((f) => fileDiagCache.get(f).diagnostics);
3294
+ }
3295
+
3296
+ // Drop cache entries for files the auto-fixer rewrote. Their closure hash has
3297
+ // changed anyway, so this is belt-and-braces against a hash collision or a
3298
+ // same-content rewrite, and keeps the cache from growing stale entries.
3299
+ function invalidateFileDiagCache(absPaths) {
3300
+ for (const abs of absPaths) fileDiagCache.delete(abs);
3301
+ }
3302
+
3303
+ // Merge a partial (incremental) re-check back into a whole-project view.
3304
+ // `recheckResult` holds the accurate diagnostics for the files in `recheckSet`
3305
+ // (absolute paths); every other file keeps its `firstPass` diagnostics. Project
3306
+ // diagnostics (identity-compared against `projectDiags`) already live in
3307
+ // `recheckResult`, so they are dropped from the carried-over first-pass entries
3308
+ // to avoid duplication. `absOf` maps a diagnostic's (relative) file to absolute.
3309
+ function mergeIncrementalDiagnostics(firstPass, recheckResult, recheckSet, projectDiags, absOf) {
3310
+ const projectSet = new Set(projectDiags);
3311
+ const carriedOver = firstPass.filter((d) => !projectSet.has(d) && !recheckSet.has(absOf(d.file)));
3312
+ return [...recheckResult, ...carriedOver];
3313
+ }
3314
+
3315
+ // Produce the accurate diagnostics for the re-checked subset AFTER a regression
3316
+ // revert, without another checker run: reverted files return to their first-pass
3317
+ // diagnostics, kept files keep their `after` diagnostics, and project diagnostics
3318
+ // appear exactly once. Only valid when no reverted file is a cross-file
3319
+ // propagation source (an export-fix decl file) — the caller checks that first.
3320
+ // `staleProjectDiags` are first-pass project diagnostic objects superseded by a
3321
+ // recompute; they are filtered out like `projectDiags` but not re-emitted.
3322
+ function spliceRevertedDiagnostics(firstPass, after, reverted, projectDiags, absOf, staleProjectDiags = []) {
3323
+ const projectSet = new Set([...projectDiags, ...staleProjectDiags]);
3324
+ const kept = after.filter((d) => !projectSet.has(d) && !reverted.has(absOf(d.file)));
3325
+ const restored = firstPass.filter((d) => !projectSet.has(d) && reverted.has(absOf(d.file)));
3326
+ return [...kept, ...restored, ...projectDiags];
3327
+ }
3328
+
3329
+ // Restore first-pass diagnostics that the re-check pass silently dropped.
3330
+ //
3331
+ // applyAutoFixes seeds recheckSet with every originally-checked file (so an
3332
+ // indirectly-fixed importer cannot carry a stale error), which means
3333
+ // mergeIncrementalDiagnostics carries nothing over and the re-check becomes the
3334
+ // sole source of truth for the whole project. That is only sound if the
3335
+ // re-check reproduces everything the first pass found -- and a checker run is
3336
+ // not guaranteed to, so a diagnostic can vanish with no fix behind it. The
3337
+ // regression guard does not catch this: it only fires when a file's error count
3338
+ // goes UP, and a vanished error makes the count go DOWN, which reads as success.
3339
+ //
3340
+ // A file the fixer never wrote cannot have been fixed, so any of its first-pass
3341
+ // diagnostics missing from the merged view is under-reporting and comes back.
3342
+ // Files the fixer did write are left alone: one edit legitimately clears
3343
+ // diagnostics it was not aimed at (fixing an import path resolves every
3344
+ // `Cannot find name` that depended on it), so restoring those would report
3345
+ // errors the source no longer has. Reverted files count as untouched -- their
3346
+ // content is back to the original, so their original diagnostics still hold.
3347
+ function restoreDroppedDiagnostics(firstPass, merged, writtenFiles, absOf) {
3348
+ const present = new Set(merged.map(diagKey));
3349
+ const dropped = firstPass.filter((d) => !present.has(diagKey(d)) && !writtenFiles.has(absOf(d.file)));
3350
+ return dropped.length > 0 ? [...merged, ...dropped] : merged;
3351
+ }
3352
+
3353
+ // Put every fixer edit back. Used when the verification re-check throws: the
3354
+ // edits are already on disk but nothing has confirmed them, and leaving them
3355
+ // there would hand back a rewritten tree with no diagnostics to judge it by.
3356
+ // Files whose `original` has been released were already verified and kept, so
3357
+ // they are skipped; re-reverting an already-reverted file is a no-op.
3358
+ function revertAllTouched(touched) {
3359
+ for (const t of touched) {
3360
+ if (t.original === null || t.original === undefined) continue;
3361
+ try {
3362
+ fs.writeFileSync(t.abs, t.original, 'utf-8');
3363
+ } catch {
3364
+ // Best effort: a file we cannot restore is worse than one we can, but
3365
+ // failing the whole revert over it would strand the rest.
3366
+ }
3367
+ }
3368
+ }
3369
+
3370
+ // Attempt heuristic fixes over `diagnostics`. Edits are applied per file, then
3371
+ // only the affected files (edited files + the importers of any cross-file
3372
+ // `export` fix) are re-checked — untouched files keep their first-pass results.
3373
+ // A diagnostic is reported fixed only if it disappeared AND the file's error
3374
+ // count did not rise; otherwise the file is reverted. Returns
3375
+ // { fixed: [...], diagnostics: [...] } (post-fix, whole-project diagnostics).
3376
+ function applyAutoFixes(diagnostics, env) {
3377
+ const { projectPath } = env;
3378
+ const absOf = (rel) => path.isAbsolute(rel) ? rel : path.resolve(projectPath, rel);
3379
+
3380
+ // Group fixable diagnostics by absolute file path.
3381
+ const byFile = new Map();
3382
+ for (const d of diagnostics) {
3383
+ if (d.severity !== 'error') continue;
3384
+ const abs = absOf(d.file);
3385
+ if (!byFile.has(abs)) byFile.set(abs, []);
3386
+ byFile.get(abs).push(d);
3387
+ }
3388
+
3389
+ // Basename index for the Tier 1.6 relative-import fixer. Built once and reused
3390
+ // for every file; only used when a "Cannot find module" diagnostic appears.
3391
+ const moduleIndex = buildModuleIndex(projectPath);
3392
+
3393
+ const touched = []; // { abs, original, appliedKeys:Set }
3394
+ for (const [abs, diags] of byFile) {
3395
+ let content;
3396
+ try { content = fs.readFileSync(abs, 'utf-8'); } catch { continue; }
3397
+ const edits = buildFileEdits(content, diags, { abs, index: moduleIndex, project: projectPath });
3398
+ if (edits.length === 0) continue;
3399
+ const { output, applied } = applyEdits(content, edits);
3400
+ if (output === content || applied.length === 0) continue;
3401
+ fs.writeFileSync(abs, output, 'utf-8');
3402
+ touched.push({ abs, original: content, appliedKeys: new Set(applied.map(diagKey)) });
3403
+ }
3404
+
3405
+ // Cross-file channel: "declares 'X' locally, but it is not exported" appears on
3406
+ // the importing file, but the fix lands in the module that owns the decl. Group
3407
+ // by declaring file so one file gets a single edit even if imported many times.
3408
+ const exportByDecl = new Map(); // declAbs -> { edits:[{name}], keys:Set<diagKey> }
3409
+ const exportImporters = new Set(); // importer abs paths that reported a missing export
3410
+ for (const d of diagnostics) {
3411
+ if (d.severity !== 'error') continue;
3412
+ const importerAbs = absOf(d.file);
3413
+ let declAbs = null;
3414
+ let names = [];
3415
+ const info = extractMissingExport(d.message);
3416
+ if (info) {
3417
+ declAbs = resolveModuleFile(importerAbs, info.module);
3418
+ names = [info.name];
3419
+ } else {
3420
+ const notModule = extractNotAModule(d.message);
3421
+ if (!notModule) continue;
3422
+ declAbs = path.isAbsolute(notModule) ? notModule : path.resolve(projectPath, notModule);
3423
+ try {
3424
+ names = namedImportsFrom(fs.readFileSync(importerAbs, 'utf-8'), importerAbs, declAbs);
3425
+ } catch { continue; }
3426
+ if (names.length === 0) continue;
3427
+ }
3428
+ // Skip if unresolved, already edited, or the decl file resolves OUTSIDE the
3429
+ // project root — never write beyond the project we were asked to check.
3430
+ if (!declAbs || (touched && touched.some(t => t.abs === declAbs))) continue;
3431
+ const projRoot = path.resolve(projectPath);
3432
+ const declResolved = path.resolve(declAbs);
3433
+ if (declResolved !== projRoot && !declResolved.startsWith(projRoot + path.sep)) continue;
3434
+ exportImporters.add(importerAbs);
3435
+ if (!exportByDecl.has(declAbs)) exportByDecl.set(declAbs, { names: new Map(), keys: new Set() });
3436
+ const bucket = exportByDecl.get(declAbs);
3437
+ for (const name of names) bucket.names.set(name, true);
3438
+ bucket.keys.add(diagKey(d));
3439
+ }
3440
+ const exportDeclAbs = new Set(); // decl files edited by the export fix (cross-file propagation sources)
3441
+ for (const [declAbs, bucket] of exportByDecl) {
3442
+ let content;
3443
+ try { content = fs.readFileSync(declAbs, 'utf-8'); } catch { continue; }
3444
+ const edits = [];
3445
+ for (const name of bucket.names.keys()) {
3446
+ const e = buildExportEdit(content, name);
3447
+ if (e) edits.push(e);
3448
+ }
3449
+ if (edits.length === 0) continue;
3450
+ const { output } = applyEdits(content, edits);
3451
+ if (output === content) continue;
3452
+ fs.writeFileSync(declAbs, output, 'utf-8');
3453
+ touched.push({ abs: declAbs, original: content, appliedKeys: bucket.keys });
3454
+ exportDeclAbs.add(declAbs);
3455
+ }
3456
+
3457
+ if (!touched || touched.length === 0) return { fixed: [], diagnostics };
3458
+
3459
+ // The auto-fixer rewrote these files, so any cached diagnostics for them (and
3460
+ // for importers whose missing-export error it targeted) describe the old text.
3461
+ // Their closure hashes have changed too, but dropping the entries keeps the
3462
+ // cache from carrying results this pass is about to supersede.
3463
+ invalidateFileDiagCache([...(touched || []).map((t) => t.abs), ...exportImporters]);
3464
+
3465
+ // Incremental re-check: a fix only changes diagnostics in the file it edited,
3466
+ // plus — for the cross-file `export` fix — the importer files that referenced
3467
+ // the symbol (their "not exported" error must be re-evaluated). Re-checking
3468
+ // that subset is enough to verify the fixes and detect regressions; every
3469
+ // other file keeps its first-pass diagnostics. This relies on a file's
3470
+ // diagnostics depending only on its own content plus its on-disk dependencies,
3471
+ // not on whether sibling files are in the checker's file set.
3472
+ //
3473
+ // Seed with ALL originally-checked files so mergeIncrementalDiagnostics never
3474
+ // carries over a stale error from a file that was indirectly fixed (e.g. a
3475
+ // missing-export error in an untouched importer whose symbol now exists).
3476
+ const recheckSet = new Set(env.files || []);
3477
+ for (const t of touched) recheckSet.add(t.abs);
3478
+ for (const imp of exportImporters) recheckSet.add(imp);
3479
+ const recheckFiles = [...recheckSet];
3480
+
3481
+ for (const t of touched) stateFieldCache.delete(t.abs);
3482
+
3483
+ // The component-version fix (Tier 1.5) rewrites a struct's @Component /
3484
+ // @ComponentV2 decorator, which is exactly the input to the source-derived
3485
+ // project-level checks (component-decorator-version-mismatch and the
3486
+ // @ObservedV2 property-type rule). So unlike every earlier fix tier, these
3487
+ // cached diagnostics CAN go stale after an edit: recompute them before the
3488
+ // re-check, or a successful flip still reports its original mismatch and the
3489
+ // fix is never credited.
3490
+ // `diagnostics` still holds the first-pass project diagnostic objects. The merge
3491
+ // and splice below drop project entries by identity, so they need both the old
3492
+ // and the recomputed objects; with only the new ones, every first-pass project
3493
+ // diagnostic on a non-.ets file (module.json5, route_map.json, resource dirs)
3494
+ // survives as "carried over" and is reported twice.
3495
+ const firstPassProjectDiags = env.projectDiagnostics || [];
3496
+ if (touched && touched.length > 0) {
3497
+ env.projectDiagnostics = computeProjectDiagnostics(env.files || [], env, env.hasExplicitFiles);
3498
+ }
3499
+
3500
+ // The ets checker persists incremental build info (.tsbuildinfo) under
3501
+ // `.cache/arkts-check`. After auto-fix rewrites a file on disk, the cached
3502
+ // build info still describes the pre-edit module graph — so the recheck
3503
+ // would read stale module resolution and re-report the original error
3504
+ // (e.g. "declares 'X' locally, but it is not exported" persists even after
3505
+ // `export` was added). Delete the cache directory so the checker rebuilds
3506
+ // from the current file contents.
3507
+ const cacheDir = path.join(projectPath, '.cache', 'arkts-check');
3508
+ try { fs.rmSync(cacheDir, { recursive: true, force: true }); } catch { /* best-effort */ }
3509
+ try { fs.mkdirSync(cacheDir, { recursive: true }); } catch { /* best-effort */ }
3510
+
3511
+ // The ets checker and its TypeScript compiler dependency hold in-memory
3512
+ // module resolution state from the first pass. Clearing the require cache
3513
+ // for all non-built-in modules forces a fresh load so the recheck reads
3514
+ // the edited files rather than the cached module graph.
3515
+ for (const modPath of Object.keys(require.cache)) {
3516
+ if (!modPath.includes('\\node\\') && !modPath.startsWith('node:')) {
3517
+ delete require.cache[modPath];
3518
+ }
3519
+ }
3520
+
3521
+ // runChecker throws when the ArkTS checker fails. The edits are on disk by
3522
+ // now, so roll them back before the error propagates -- an unverified rewrite
3523
+ // is not something to leave behind on a failed check.
3524
+ let after;
3525
+ try {
3526
+ after = runChecker(recheckFiles, env);
3527
+ } catch (e) {
3528
+ revertAllTouched(touched);
3529
+ throw new Error(`${e.message} (auto-fix edits were rolled back)`);
3530
+ }
3531
+
3532
+ const projectDiags = env.projectDiagnostics || [];
3533
+ const allProjectDiags = [...firstPassProjectDiags, ...projectDiags];
3534
+ // Per-file error counts before/after to detect regressions.
3535
+ const errCount = (list, abs) => list.filter(d => d.severity === 'error' && absOf(d.file) === abs).length;
3536
+
3537
+ const reverted = new Set();
3538
+ for (const t of touched) {
3539
+ if (errCount(after, t.abs) > errCount(diagnostics, t.abs)) {
3540
+ fs.writeFileSync(t.abs, t.original, 'utf-8');
3541
+ reverted.add(t.abs);
3542
+ }
3543
+ }
3544
+
3545
+ // `original` is only needed for the revert above; drop the kept files' copies
3546
+ // so we don't hold every touched file's full source through the rest of the pass.
3547
+ for (const t of touched) {
3548
+ if (!reverted.has(t.abs)) t.original = null;
3549
+ }
3550
+
3551
+ // Accurate diagnostics for the re-checked subset after any reverts.
3552
+ let recheckResult;
3553
+ if (reverted.size === 0) {
3554
+ recheckResult = after;
3555
+ } else if ([...reverted].some(abs => exportDeclAbs.has(abs))) {
3556
+ // A reverted export fix re-breaks its importers, which `after` still shows as
3557
+ // fixed; splicing would be wrong, so re-run the (still incremental) checker.
3558
+ for (const t of (touched || [])) stateFieldCache.delete(t.abs);
3559
+ try {
3560
+ recheckResult = runChecker(recheckFiles, env);
3561
+ } catch (e) {
3562
+ // Kept files released their `original` above, so only the already-reverted
3563
+ // ones are restorable here. That is the right scope anyway: the first
3564
+ // re-check confirmed each kept edit did not regress its own file, so those
3565
+ // stay. Only the verdict on the reverted files' importers is missing.
3566
+ revertAllTouched(touched);
3567
+ throw new Error(`${e.message} (reverted-file edits were rolled back; kept edits remain)`);
3568
+ }
3569
+ } else {
3570
+ // No cross-file propagation among reverted files: splice instead of re-running.
3571
+ recheckResult = spliceRevertedDiagnostics(diagnostics, after, reverted, projectDiags, absOf, firstPassProjectDiags);
3572
+ }
3573
+
3574
+ // Only files still holding a fixer edit may lose diagnostics; a reverted file
3575
+ // is back to its original content, so it is treated as untouched.
3576
+ // Importers targeted by the cross-file `export` fix are also included: their
3577
+ // "not exported" error was cleared indirectly by the edit to the declaring
3578
+ // module, even though the importer file itself was never written.
3579
+ const writtenFiles = new Set([
3580
+ ...(touched || []).filter((t) => !reverted.has(t.abs)).map((t) => t.abs),
3581
+ ...exportImporters,
3582
+ ]);
3583
+ const merged = mergeIncrementalDiagnostics(diagnostics, recheckResult, recheckSet, allProjectDiags, absOf);
3584
+ const finalDiagnostics = restoreDroppedDiagnostics(diagnostics, merged, writtenFiles, absOf);
3585
+
3586
+ // Built from the guarded view: a restored diagnostic must not read as fixed.
3587
+ const finalKeys = new Set(finalDiagnostics.map(diagKey));
3588
+
3589
+ const fixed = [];
3590
+ for (const t of (touched || [])) {
3591
+ if (reverted.has(t.abs)) continue;
3592
+ for (const d of diagnostics) {
3593
+ if (t.appliedKeys.has(diagKey(d)) && !finalKeys.has(diagKey(d))) fixed.push(d);
3594
+ }
3595
+ }
3596
+
3597
+ // Files we edited (and kept) that were NOT in the caller's original file list
3598
+ // — these come from the cross-file `export` fix landing in a declaring module.
3599
+ // Surface them so the caller knows the diff includes files it didn't ask to check.
3600
+ const requested = new Set(env.files || []);
3601
+ const alsoModified = (touched || [])
3602
+ .filter(t => !reverted.has(t.abs) && !requested.has(t.abs))
3603
+ .map(t => path.relative(projectPath, t.abs));
3604
+
3605
+ return { fixed, diagnostics: finalDiagnostics, alsoModified };
3606
+ }
3607
+
3608
+ // One check pass over `args` ({ project, files, fix }). Returns the result object
3609
+ // the CLI serializes to stdout; never calls process.exit, so `--serve` can reuse
3610
+ // it request after request in one warm process.
3611
+ function runCheck(args) {
3612
+ if (!args.project) {
3613
+ return { success: false, error: 'Missing --project argument', errors: [], summary: { errorCount: 0, warnCount: 0 } };
3614
+ }
3615
+
3616
+ if (!fs.existsSync(args.project)) {
3617
+ return { success: false, error: `Project path not found: ${args.project}`, errors: [], summary: { errorCount: 0, warnCount: 0 } };
3618
+ }
3619
+
3620
+ // --project 必须是目录,不是文件
3621
+ const projStat = fs.statSync(args.project);
3622
+ if (projStat.isFile()) {
3623
+ return {
3624
+ success: false,
3625
+ error: `--project must be a project root directory, not a file: ${args.project}`,
3626
+ errors: [],
3627
+ summary: { errorCount: 0, warnCount: 0 },
3628
+ };
3629
+ }
3630
+
3631
+ const { sdkDir, hmsEtsDir } = resolveSdk(args);
3632
+ if (!sdkDir) {
3633
+ return { success: false, error: 'No SDK given. Pass --sdk <dir> (an OpenHarmony SDK API directory such as <sdk-root>/20, or DevEco\'s sdk/default/openharmony), or set DEVECO_HOME.', errors: [], summary: { errorCount: 0, warnCount: 0 } };
3634
+ }
3635
+
3636
+ const etsLoaderPath = findEtsLoader(sdkDir);
3637
+ if (!etsLoaderPath) {
3638
+ return { success: false, error: `Cannot find ets/build-tools/ets-loader/lib/ets_checker.js in the SDK at: ${sdkDir}`, errors: [], summary: { errorCount: 0, warnCount: 0 } };
3639
+ }
3640
+ let files = args.files.map(f => path.isAbsolute(f) ? f : path.resolve(args.project, f));
3641
+ // Filter out directories and non-.ets files from the explicit file list.
3642
+ // When the CLI passes `.` (cwd) as a positional arg, it resolves to a
3643
+ // directory path that would crash fs.readFileSync in validateSystemResources.
3644
+ if (files.length > 0) {
3645
+ files = files.filter(f => fs.existsSync(f) && fs.statSync(f).isFile() && f.endsWith('.ets') && !f.endsWith('.d.ets'));
3646
+ }
3647
+ // 用户是否显式指定了有效的 .ets 文件(用于跳过项目级校验器)
3648
+ const hasExplicitFiles = files.length > 0;
3649
+ if (files.length === 0) {
3650
+ // 全模块收集:entry-only 版本在多模块工程(products/features/commons 等,
3651
+ // 无 entry 模块)下返回空集,导致静默 0 文件空跑。
3652
+ files = collectProjectEtsFiles(args.project);
3653
+ }
3654
+
3655
+ if (files.length === 0) {
3656
+ return { success: true, errors: [], summary: { errorCount: 0, warnCount: 0 } };
3657
+ }
3658
+
3659
+ const moduleJsonCandidates = [
3660
+ path.join(args.project, 'entry', 'src', 'main', 'module.json5'),
3661
+ path.join(args.project, 'src', 'main', 'module.json5'),
3662
+ path.join(args.project, 'entry', 'module.json5'),
3663
+ ];
3664
+ let aceModuleJsonPath = '';
3665
+ for (const candidate of moduleJsonCandidates) {
3666
+ if (fs.existsSync(candidate)) {
3667
+ aceModuleJsonPath = candidate;
3668
+ break;
3669
+ }
3670
+ }
3671
+
3672
+ // Read once per request rather than per runChecker call: auto-fix never edits
3673
+ // build-profile.json5, so its SDK values cannot change mid-request.
3674
+ const sdkConfig = readProjectSdkConfig(args.project, sdkDir);
3675
+
3676
+ const env = { sdkDir, hmsEtsDir, etsLoaderPath, projectPath: args.project, aceModuleJsonPath, files, sdkConfig, hasExplicitFiles };
3677
+
3678
+ // Compute project-level diagnostics once and carry them on env: auto-fix never
3679
+ // edits the config files they read, so the (potentially large) SDK JSON reads
3680
+ // and JSON.parse are done a single time regardless of how many re-checks run.
3681
+ env.projectDiagnostics = computeProjectDiagnostics(files, env, hasExplicitFiles);
3682
+
3683
+ // The per-file cache only pays off across requests, so it is limited to the
3684
+ // warm --serve process; a one-shot CLI run would just hash files for nothing.
3685
+ const checkerDiags = args.cache ? cachedCheckerDiagnostics(files, env) : undefined;
3686
+ let filtered = checkerDiags
3687
+ ? [...checkerDiags, ...dropFalsePositives(env.projectDiagnostics, args.project, aceModuleJsonPath)]
3688
+ : runChecker(files, env);
3689
+ // How many of these came from the ArkTS checker rather than the source-derived
3690
+ // project rules. The two layers fail independently: across an observed 26-task
3691
+ // run, several sessions produced project-rule diagnostics normally while the
3692
+ // checker returned nothing at all, and their builds then failed on the type
3693
+ // errors it should have caught. The count is what makes that distinguishable
3694
+ // from a project that is genuinely clean, so it is reported, not inferred.
3695
+ const projectDiagSet = new Set(env.projectDiagnostics);
3696
+ let checkerDiagCount = filtered.filter((d) => !projectDiagSet.has(d)).length;
3697
+ let fixed = [];
3698
+ let alsoModified = [];
3699
+
3700
+ if (args.fix && filtered.some(d => d.severity === 'error')) {
3701
+ const res = applyAutoFixes(filtered, env);
3702
+ fixed = res.fixed;
3703
+ filtered = res.diagnostics;
3704
+ alsoModified = res.alsoModified || [];
3705
+ // applyAutoFixes may recompute env.projectDiagnostics into a fresh array, so
3706
+ // the identity set above no longer matches; rebuild before recounting.
3707
+ //
3708
+ // Keep the higher of the two counts. The question this feeds is whether the
3709
+ // checker produced anything at all, not how much is still unfixed -- and a
3710
+ // run whose checker diagnostics were all auto-fixed would otherwise report
3711
+ // zero and read as a checker that never spoke. One observed session had that
3712
+ // exact shape: three checker diagnostics, all fixed on the first pass.
3713
+ const afterProjectSet = new Set(env.projectDiagnostics || []);
3714
+ checkerDiagCount = Math.max(checkerDiagCount, filtered.filter((d) => !afterProjectSet.has(d)).length);
3715
+ }
3716
+
3717
+ // The checker, the project rules and the post-fix merge each emit in their own
3718
+ // order (file walk order is filesystem-dependent), so sort once here to make
3719
+ // the output stable across platforms and runs.
3720
+ filtered = sortDiagnostics(filtered);
3721
+
3722
+ // Issue #614: on a project with no build output yet, "Cannot find module
3723
+ // 'BuildProfile'" is a build-order problem, not a code problem — say so
3724
+ // instead of leaving the raw diagnostic to mislead.
3725
+ const bpHint = buildProfileNotBuiltHint(args.project);
3726
+ if (bpHint) {
3727
+ for (const d of filtered) {
3728
+ if (d.severity === 'error' && /Cannot find module 'BuildProfile'/.test(d.message || '')) {
3729
+ d.message = `${d.message.replace(/\.$/, '')}. ${bpHint}`;
3730
+ }
3731
+ }
3732
+ }
3733
+
3734
+ const errorCount = filtered.filter(d => d.severity === 'error').length;
3735
+ const warnCount = filtered.filter(d => d.severity === 'warning').length;
3736
+
3737
+ return {
3738
+ success: errorCount === 0,
3739
+ errors: filtered,
3740
+ fixed,
3741
+ alsoModified,
3742
+ summary: { errorCount, warnCount, fixedCount: fixed.length, checkerDiagCount, fileCount: files.length },
3743
+ };
3744
+ }
3745
+
3746
+ // Newline-delimited JSON server. One warm process handles many checks, which is
3747
+ // where the speedup lives: a cold run pays ~0.3s node boot + ~1.7s to require
3748
+ // ets_checker + ~4.2s to build the SDK type graph, and only ~24ms per extra
3749
+ // file. Re-entering `runCheck` in an already-warm process costs ~2s instead of
3750
+ // ~6s. Protocol: one `{"ready":true}` line on startup, then one response line
3751
+ // per request line, tagged with the request's `id`.
3752
+ function serve(args) {
3753
+ process.stdout.write(JSON.stringify({ ready: true, pid: process.pid }) + '\n');
3754
+
3755
+ let buffer = '';
3756
+ process.stdin.setEncoding('utf-8');
3757
+ process.stdin.on('data', (chunk) => {
3758
+ buffer += chunk;
3759
+ for (;;) {
3760
+ const nl = buffer.indexOf('\n');
3761
+ if (nl === -1) break;
3762
+ const line = buffer.slice(0, nl).trim();
3763
+ buffer = buffer.slice(nl + 1);
3764
+ if (!line) continue;
3765
+ handleRequest(line, args);
3766
+ }
3767
+ });
3768
+ process.stdin.on('end', () => { process.exitCode = 0; });
3769
+ }
3770
+
3771
+ function handleRequest(line, args) {
3772
+ let req;
3773
+ try {
3774
+ req = JSON.parse(line);
3775
+ } catch (e) {
3776
+ process.stdout.write(JSON.stringify({ id: null, error: `Malformed request: ${e.message}` }) + '\n');
3777
+ return;
3778
+ }
3779
+
3780
+ if (req.shutdown) {
3781
+ process.stdout.write(JSON.stringify({ id: req.id, shutdown: true }) + '\n');
3782
+ process.exit(0);
3783
+ }
3784
+
3785
+ // Every module-level cache keyed on paths or file contents must be dropped:
3786
+ // between two requests the model has edited files, added new ones, or switched
3787
+ // projects, and a stale entry would report diagnostics for content no longer
3788
+ // on disk. Both caches exist only to avoid re-reading within a single pass.
3789
+ // `fileDiagCache` is deliberately NOT cleared here — surviving across requests
3790
+ // is its entire purpose. It self-invalidates per file via closure hashes and
3791
+ // wholesale via the epoch.
3792
+ projectEtsFileCache.clear();
3793
+ stateFieldCache.clear();
3794
+ resolveModulePathsCache.clear();
3795
+
3796
+ const result = (() => {
3797
+ try {
3798
+ return runCheck({
3799
+ project: req.project ? path.resolve(req.project) : '',
3800
+ files: Array.isArray(req.files) ? req.files : [],
3801
+ fix: req.fix !== false,
3802
+ cache: true,
3803
+ sdk: req.sdk ? path.resolve(req.sdk) : args.sdk,
3804
+ hms: req.hms ? path.resolve(req.hms) : args.hms,
3805
+ });
3806
+ } catch (e) {
3807
+ return { success: false, error: `Internal error: ${e && e.message}`, errors: [], summary: { errorCount: 0, warnCount: 0 } };
3808
+ }
3809
+ })();
3810
+
3811
+ process.stdout.write(JSON.stringify({ id: req.id, result }) + '\n');
3812
+
3813
+ // Each `etsStandaloneChecker` call rebuilds the program and leaves the previous
3814
+ // one (~125MB of AST and type objects) unreferenced but uncollected, so an
3815
+ // untouched daemon climbs past 650MB. Collecting here — after the response is
3816
+ // written, off the caller's wait path — holds the steady state near 230MB.
3817
+ // Requires --expose-gc; without it V8 still reclaims under pressure, just later.
3818
+ if (global.gc) global.gc();
3819
+ }
3820
+
3821
+ function main() {
3822
+ const args = parseArgs(process.argv);
3823
+ if (args.serve) {
3824
+ serve(args);
3825
+ return;
3826
+ }
3827
+ const result = runCheck(args);
3828
+ process.stdout.write(JSON.stringify(result, null, 2));
3829
+ // 用 exitCode + 自然退出而非 process.exit:POSIX 上 stdout 指向 pipe 时写入
3830
+ // 是异步的,process.exit 可能截断未 flush 的大体积 JSON 结果。
3831
+ process.exitCode = result.error || (result.summary && result.summary.errorCount > 0) ? 1 : 0;
3832
+ }
3833
+
3834
+ // Exported for unit testing without a DevEco SDK.
3835
+ module.exports = {
3836
+ isBindingSyntaxFalsePositive, getStateFields, stateFieldCache,
3837
+ extractRename, buildFileEdits, buildImportHoist, applyEdits,
3838
+ extractDefaultImportBinding, buildComponentVersionEdits,
3839
+ computeLineStarts, offsetOf, GENERIC_SUGGESTION_BLACKLIST,
3840
+ extractMissingExport, extractNotAModule, namedImportsFrom, buildExportEdit, resolveModuleFile,
3841
+ buildModuleIndex, toRelativeSpecifier,
3842
+ parseJson5Loose, loadStringResourceKeys, validatePermissionsConfig,
3843
+ mergeIncrementalDiagnostics, spliceRevertedDiagnostics, restoreDroppedDiagnostics, revertAllTouched,
3844
+ validatePageEntryCount, validatePermissionNamesExist, loadCustomPermissionNames,
3845
+ validateComponentDecoratorConsistency, collectStructDecorators,
3846
+ V1_ONLY_MEMBER_DECORATORS, V2_ONLY_MEMBER_DECORATORS,
3847
+ validateObservedV2PropertyTypes, collectObservedV2ClassNames, V1_TYPE_SENSITIVE_DECORATORS,
3848
+ validateAppResources, loadAppResourceNames, APP_RESOURCE_INDEXED_KINDS,
3849
+ collectProjectEtsFiles, unionProjectFiles, projectEtsFileCache,
3850
+ STRUCT_DECL_RE, collectStructs,
3851
+ validateRegularPropertyInit, collectComponentRegularProperties,
3852
+ validateEntryBuildRootNode, CONTAINER_COMPONENTS,
3853
+ validateStructNameCollisions, isBuiltinComponentName, BUILTIN_LEAF_COMPONENTS,
3854
+ validateBuilderBodyStatements, collectBuilderBodies, BUILDER_LOCAL_DECL_RE,
3855
+ validateV2MemberDecoratorRules,
3856
+ validateNavDestinationRegistration, validateHideNavBarUsage,
3857
+ validateNavDestinationRoot, validateAppStorageV2Mixing,
3858
+ validateObjectLinkTypes, collectObservedClassNames,
3859
+ validateRouteMapProfile, ROUTE_MAP_ALLOWED_KEYS, ROUTE_MAP_REQUIRED_KEYS,
3860
+ validateRouteMapBuildFunction,
3861
+ validateResourceDirNames, QUALIFIER_RESOURCE_DIRS, TOP_LEVEL_RESOURCE_DIRS,
3862
+ parseArgs, runCheck, resolveSdk,
3863
+ readProjectSdkConfig, parseCompatibleSdkVersion, readSdkApiLevel,
3864
+ DEFAULT_SDK_INFO, DEFAULT_RUNTIME_OS,
3865
+ collectResolveModulePaths, readProjectModules, pickTargetNameForProduct,
3866
+ buildProfileNotBuiltHint, resolveModulePathsCache,
3867
+ closureHash, computeEpoch, resolveClosureDependency, collectAmbientDeclarations,
3868
+ fileDiagCache, invalidateFileDiagCache, dropFalsePositives, hashText,
3869
+ isOutsideProject, isDeclarationFile,
3870
+ };
3871
+
3872
+ // Run as a CLI only when invoked directly (node arkts-check.cjs ...), not when required by tests.
3873
+ if (require.main === module) {
3874
+ main();
3875
+ }