@yinsen/deveco-cli 1.3.1 → 1.3.3-Test.3

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