@yinsen/deveco-cli 1.3.1 → 1.3.3-Test.2
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
|
+
}
|