@hmharness/domain-harmony 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,414 @@
1
+ /**
2
+ * @hmharness/domain-harmony - project scaffold
3
+ * Creates a minimal buildable HarmonyOS stage-model project (ArkTS entry +
4
+ * single page + resources) tuned to the installed SDK via HM_SDK_VERSION
5
+ * (default "6.1.1(24)"). Icons are generated PNGs (zlib + hand-rolled CRC -
6
+ * no image dependency in the kernel's zero-dep spirit). The scaffold is
7
+ * deliberately minimal: DevEco remains the full-featured authoring tool;
8
+ * this exists so the agent can bootstrap a project and drive it through
9
+ * build/install/launch without leaving the loop.
10
+ */
11
+ import { deflateSync } from 'node:zlib';
12
+ import { mkdir, writeFile } from 'node:fs/promises';
13
+ import { basename, join, resolve } from 'node:path';
14
+ import { parseSdkVersion } from "./apimatrix.js";
15
+ /* ------------------------------------------------------------------ */
16
+ /* Minimal PNG encoder (solid RGBA color, power-of-two sizes) */
17
+ /* ------------------------------------------------------------------ */
18
+ const CRC_TABLE = (() => {
19
+ const t = new Uint32Array(256);
20
+ for (let n = 0; n < 256; n++) {
21
+ let c = n;
22
+ for (let k = 0; k < 8; k++)
23
+ c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
24
+ t[n] = c >>> 0;
25
+ }
26
+ return t;
27
+ })();
28
+ function crc32(buf) {
29
+ let c = 0xffffffff;
30
+ for (let i = 0; i < buf.length; i++)
31
+ c = CRC_TABLE[(c ^ buf[i]) & 0xff] ^ (c >>> 8);
32
+ return (c ^ 0xffffffff) >>> 0;
33
+ }
34
+ function chunk(type, data) {
35
+ const len = Buffer.alloc(4);
36
+ len.writeUInt32BE(data.length, 0);
37
+ const body = Buffer.concat([Buffer.from(type, 'ascii'), data]);
38
+ const crc = Buffer.alloc(4);
39
+ crc.writeUInt32BE(crc32(body), 0);
40
+ return Buffer.concat([len, body, crc]);
41
+ }
42
+ /** Solid-color RGBA PNG (used for app/start icons - content is cosmetic). */
43
+ export function solidPng(size, rgba) {
44
+ const ihdr = Buffer.alloc(13);
45
+ ihdr.writeUInt32BE(size, 0);
46
+ ihdr.writeUInt32BE(size, 4);
47
+ ihdr[8] = 8; // bit depth
48
+ ihdr[9] = 6; // color type RGBA
49
+ const row = Buffer.alloc(1 + size * 4);
50
+ for (let x = 0; x < size; x++) {
51
+ row[1 + x * 4] = rgba[0];
52
+ row[2 + x * 4] = rgba[1];
53
+ row[3 + x * 4] = rgba[2];
54
+ row[4 + x * 4] = rgba[3];
55
+ }
56
+ const raw = Buffer.concat(Array(size).fill(row));
57
+ return Buffer.concat([
58
+ Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
59
+ chunk('IHDR', ihdr),
60
+ chunk('IDAT', deflateSync(raw)),
61
+ chunk('IEND', Buffer.alloc(0)),
62
+ ]);
63
+ }
64
+ /* ------------------------------------------------------------------ */
65
+ /* Scaffold */
66
+ /* ------------------------------------------------------------------ */
67
+ export function sdkVersion() {
68
+ const raw = process.env.HM_SDK_VERSION ?? '6.1.1(24)';
69
+ try {
70
+ // reject junk early with a pointed message instead of letting hvigor
71
+ // fail cryptically later (the apimatrix knows both version shapes)
72
+ parseSdkVersion(raw);
73
+ }
74
+ catch (err) {
75
+ throw new Error(`HM_SDK_VERSION="${raw}" is not a valid SDK version (${String(err).slice(0, 90)}). Expected "6.1.1(24)" or "26.0.0" style.`);
76
+ }
77
+ return raw;
78
+ }
79
+ function sanitizeIdent(s) {
80
+ return s.toLowerCase().replace(/[^a-z0-9]/g, '').slice(0, 40) || 'app';
81
+ }
82
+ const IDENT = /^[A-Z][A-Za-z0-9]*$/;
83
+ export async function scaffoldProject(dir, opts = {}) {
84
+ const root = resolve(dir);
85
+ const name = opts.name ?? basename(root);
86
+ const bundleId = opts.bundleId ?? `com.example.${sanitizeIdent(name)}`;
87
+ const sdk = sdkVersion();
88
+ const pages = ['Index', ...(opts.pages ?? []).map((p) => String(p).trim()).filter((p) => IDENT.test(p))];
89
+ const modules = (opts.modules ?? [])
90
+ .map((m) => ({ name: sanitizeIdent(m.name).replace(/-/g, ''), type: m.type === 'har' ? 'har' : 'feature' }))
91
+ .filter((m) => m.name.length > 0 && m.name !== 'entry');
92
+ const harNames = modules.filter((m) => m.type === 'har').map((m) => m.name);
93
+ const W = async (rel, content) => {
94
+ const p = join(root, rel);
95
+ await mkdir(join(p, '..'), { recursive: true });
96
+ await writeFile(p, content, typeof content === 'string' ? 'utf8' : undefined);
97
+ return 1;
98
+ };
99
+ let files = 0;
100
+ files += await W('build-profile.json5', [
101
+ '{',
102
+ ' "app": {',
103
+ ' "signingConfigs": [],',
104
+ ' "products": [',
105
+ ' {',
106
+ ' "name": "default",',
107
+ ` "compatibleSdkVersion": "${sdk}",`,
108
+ ' "runtimeOS": "HarmonyOS"',
109
+ ' }',
110
+ ' ],',
111
+ ' "buildModeSet": [',
112
+ ' { "name": "debug" },',
113
+ ' { "name": "release" }',
114
+ ' ]',
115
+ ' },',
116
+ ' "modules": [',
117
+ ' {',
118
+ ' "name": "entry",',
119
+ ' "srcPath": "./entry",',
120
+ ' "targets": [',
121
+ ' {',
122
+ ' "name": "default",',
123
+ ' "applyToProducts": [ "default" ]',
124
+ ' }',
125
+ ' ]',
126
+ ' }',
127
+ ...modules.map((m) => [
128
+ ' ,{',
129
+ ` "name": "${m.name}",`,
130
+ ` "srcPath": "./${m.name}",`,
131
+ ' "targets": [',
132
+ ' {',
133
+ ' "name": "default",',
134
+ ' "applyToProducts": [ "default" ]',
135
+ ' }',
136
+ ' ]',
137
+ ' }',
138
+ ].join('\n')),
139
+ ' ]',
140
+ '}',
141
+ '',
142
+ ].join('\n'));
143
+ files += await W('hvigor/hvigor-config.json5', ['{', ' "modelVersion": "5.0.0",', ' "dependencies": {}', '}', ''].join('\n'));
144
+ files += await W('hvigorfile.ts', [
145
+ "import { appTasks } from '@ohos/hvigor-ohos-plugin';",
146
+ '',
147
+ 'export default {',
148
+ ' system: appTasks,',
149
+ ' plugins: []',
150
+ '}',
151
+ '',
152
+ ].join('\n'));
153
+ files += await W('entry/hvigorfile.ts', [
154
+ "import { hapTasks } from '@ohos/hvigor-ohos-plugin';",
155
+ '',
156
+ 'export default {',
157
+ ' system: hapTasks,',
158
+ ' plugins: []',
159
+ '}',
160
+ '',
161
+ ].join('\n'));
162
+ files += await W('oh-package.json5', [`{`, ` "modelVersion": "5.0.0",`, ` "name": "${sanitizeIdent(name)}",`, ' "version": "1.0.0",', ' "description": "scaffolded by hmharness",', ' "dependencies": {}', '}', ''].join('\n'));
163
+ files += await W('.gitignore', ['/oh_modules/', '/build/', '/.hvigor/', '/.clangd/', '/.clang-format/', '/.clang-tidy/', '/local.properties', ''].join('\n'));
164
+ files += await W('AppScope/app.json5', [
165
+ '{',
166
+ ' "app": {',
167
+ ` "bundleName": "${bundleId}",`,
168
+ ' "vendor": "hmharness",',
169
+ ' "versionCode": 1000000,',
170
+ ' "versionName": "1.0.0",',
171
+ ' "icon": "$media:app_icon",',
172
+ ' "label": "$string:app_name"',
173
+ ' }',
174
+ '}',
175
+ '',
176
+ ].join('\n'));
177
+ files += await W('AppScope/resources/base/element/string.json', [
178
+ '{',
179
+ ' "string": [',
180
+ ` { "name": "app_name", "value": "${name.replace(/"/g, '')}" }`,
181
+ ' ]',
182
+ '}',
183
+ '',
184
+ ].join('\n'));
185
+ files += await W('AppScope/resources/base/media/app_icon.png', solidPng(96, [0x1f, 0x6f, 0xeb, 0xff]));
186
+ files += await W('entry/oh-package.json5', [
187
+ '{',
188
+ ' "name": "entry",',
189
+ ' "version": "1.0.0",',
190
+ ' "description": "entry module",',
191
+ harNames.length > 0
192
+ ? ' "dependencies": {\n' + harNames.map((h) => ` "${h}": "file:../${h}"`).join(',\n') + '\n }'
193
+ : ' "dependencies": {}',
194
+ '}',
195
+ '',
196
+ ].join('\n'));
197
+ files += await W('entry/build-profile.json5', [
198
+ '{',
199
+ ' "apiType": "stageMode",',
200
+ ' "buildOption": {},',
201
+ ' "buildOptionSet": [',
202
+ ' {',
203
+ ' "name": "release",',
204
+ ' "arkOptions": {',
205
+ ' "obfuscation": {',
206
+ ' "ruleOptions": {',
207
+ ' "enable": false,',
208
+ ' "files": [ "./obfuscation-rules.txt" ]',
209
+ ' }',
210
+ ' }',
211
+ ' }',
212
+ ' }',
213
+ ' ],',
214
+ ' "targets": [',
215
+ ' { "name": "default" }',
216
+ ' ]',
217
+ '}',
218
+ '',
219
+ ].join('\n'));
220
+ files += await W('entry/obfuscation-rules.txt', [
221
+ '# Define project specific obfuscation rules here.',
222
+ '# (obfuscation disabled in build-profile; file kept for structure parity)',
223
+ '',
224
+ ].join('\n'));
225
+ files += await W('entry/src/main/module.json5', [
226
+ '{',
227
+ ' "module": {',
228
+ ' "name": "entry",',
229
+ ' "type": "entry",',
230
+ ' "description": "$string:module_desc",',
231
+ ' "mainElement": "EntryAbility",',
232
+ ' "deviceTypes": [ "phone", "tablet", "2in1" ],',
233
+ ' "deliveryWithInstall": true,',
234
+ ' "installationFree": false,',
235
+ ' "pages": "$profile:main_pages",',
236
+ ' "abilities": [',
237
+ ' {',
238
+ ' "name": "EntryAbility",',
239
+ ' "srcEntry": "./ets/entryability/EntryAbility.ets",',
240
+ ' "exported": true,',
241
+ ' "description": "$string:EntryAbility_desc",',
242
+ ' "startWindowIcon": "$media:startIcon",',
243
+ ' "startWindowBackground": "$color:start_window_background",',
244
+ ' "label": "$string:EntryAbility_label"',
245
+ ' }',
246
+ ' ]',
247
+ ' }',
248
+ '}',
249
+ '',
250
+ ].join('\n'));
251
+ files += await W('entry/src/main/ets/entryability/EntryAbility.ets', [
252
+ "import { AbilityConstant, UIAbility, Want } from '@kit.AbilityKit';",
253
+ "import { hilog } from '@kit.PerformanceAnalysisKit';",
254
+ "import { window } from '@kit.ArkUI';",
255
+ '',
256
+ 'export default class EntryAbility extends UIAbility {',
257
+ ' onCreate(want: Want, launchParam: AbilityConstant.LaunchParam): void {',
258
+ " hilog.info(0x0000, 'hmh', '%{public}s', 'EntryAbility onCreate');",
259
+ ' }',
260
+ '',
261
+ ' onDestroy(): void {}',
262
+ '',
263
+ ' onWindowStageCreate(windowStage: window.WindowStage): void {',
264
+ " windowStage.loadContent('pages/Index', (err) => {",
265
+ ' if (err.code) {',
266
+ " hilog.error(0x0000, 'hmh', 'Failed to load content: %{public}s', JSON.stringify(err));",
267
+ ' return;',
268
+ ' }',
269
+ ' });',
270
+ ' }',
271
+ '',
272
+ ' onWindowStageDestroy(): void {}',
273
+ ' onForeground(): void {}',
274
+ ' onBackground(): void {}',
275
+ '}',
276
+ '',
277
+ ].join('\n'));
278
+ files += await W('entry/src/main/ets/pages/Index.ets', [
279
+ '@Entry',
280
+ '@Component',
281
+ 'struct Index {',
282
+ " @State message: string = 'Hello HarmonyOS';",
283
+ '',
284
+ ' build() {',
285
+ ' Column() {',
286
+ ' Text(this.message)',
287
+ ' .fontSize(40)',
288
+ ' .fontWeight(FontWeight.Bold)',
289
+ ' }',
290
+ " .width('100%')",
291
+ " .height('100%')",
292
+ ' .justifyContent(FlexAlign.Center)',
293
+ ' }',
294
+ '}',
295
+ '',
296
+ ].join('\n'));
297
+ files += await W('entry/src/main/resources/base/element/string.json', [
298
+ '{',
299
+ ' "string": [',
300
+ ' { "name": "module_desc", "value": "entry module" },',
301
+ ` { "name": "EntryAbility_desc", "value": "${name.replace(/"/g, '')}" },`,
302
+ ` { "name": "EntryAbility_label", "value": "${name.replace(/"/g, '')}" }`,
303
+ ' ]',
304
+ '}',
305
+ '',
306
+ ].join('\n'));
307
+ files += await W('entry/src/main/resources/base/element/color.json', [
308
+ '{',
309
+ ' "color": [',
310
+ ' { "name": "start_window_background", "value": "#FFFFFF" }',
311
+ ' ]',
312
+ '}',
313
+ '',
314
+ ].join('\n'));
315
+ files += await W('entry/src/main/resources/base/media/startIcon.png', solidPng(96, [0x00, 0x00, 0x00, 0xff]));
316
+ files += await W('entry/src/main/resources/base/profile/main_pages.json', ['{', ' "src": [', ...pages.map((p) => ` "pages/${p}"${p === pages[pages.length - 1] ? '' : ','}`), ' ]', '}', ''].join('\n'));
317
+ // extra pages beyond Index
318
+ for (const p of pages.slice(1)) {
319
+ files += await W(`entry/src/main/ets/pages/${p}.ets`, [
320
+ '@Entry',
321
+ '@Component',
322
+ `struct ${p} {`,
323
+ ' build() {',
324
+ ' Column() {',
325
+ ` Text('${p}')`,
326
+ ' .fontSize(32)',
327
+ ' .fontWeight(FontWeight.Bold)',
328
+ ' }',
329
+ " .width('100%')",
330
+ " .height('100%')",
331
+ ' .justifyContent(FlexAlign.Center)',
332
+ ' }',
333
+ '}',
334
+ '',
335
+ ].join('\n'));
336
+ }
337
+ // extra modules (feature HAPs / har libraries)
338
+ for (const m of modules) {
339
+ const task = m.type === 'har' ? 'harTasks' : 'hapTasks';
340
+ files += await W(`${m.name}/hvigorfile.ts`, [
341
+ `import { ${task} } from '@ohos/hvigor-ohos-plugin';`,
342
+ '',
343
+ 'export default {',
344
+ ` system: ${task},`,
345
+ ' plugins: []',
346
+ '}',
347
+ '',
348
+ ].join('\n'));
349
+ files += await W(`${m.name}/oh-package.json5`, ['{', ` "name": "${m.name}",`, ' "version": "1.0.0",', ` "description": "${m.type} module",`, ' "main": "Index.ets",', ' "dependencies": {}', '}', ''].join('\n'));
350
+ files += await W(`${m.name}/build-profile.json5`, ['{', ' "apiType": "stageMode",', ' "buildOption": {},', ' "targets": [', ' { "name": "default" }', ' ]', '}', ''].join('\n'));
351
+ files += await W(`${m.name}/src/main/module.json5`, [
352
+ '{',
353
+ ' "module": {',
354
+ ` "name": "${m.name}",`,
355
+ ` "type": "${m.type}",`,
356
+ ` "description": "$string:module_desc",`,
357
+ ' "deviceTypes": [ "phone", "tablet", "2in1" ],',
358
+ ' "deliveryWithInstall": true',
359
+ ' }',
360
+ '}',
361
+ '',
362
+ ].join('\n'));
363
+ if (m.type === 'har') {
364
+ files += await W(`${m.name}/src/main/ets/Index.ets`, [`export const ${m.name.toUpperCase()}_VERSION = '1.0.0';`, '', `export function ${m.name}Hello(): string {`, ` return 'hello from ${m.name}';`, '}', ''].join('\n'));
365
+ }
366
+ else {
367
+ files += await W(`${m.name}/src/main/ets/${m.name}Api.ets`, [`export const ${m.name.toUpperCase()}_LOADED = true;`, ''].join('\n'));
368
+ }
369
+ files += await W(`${m.name}/src/main/resources/base/element/string.json`, ['{', ' "string": [', ' { "name": "module_desc", "value": "' + m.type + ' module ' + m.name + '" }', ' ]', '}', ''].join('\n'));
370
+ }
371
+ return { root, bundleId, files };
372
+ }
373
+ export const harmonyProjectCreate = {
374
+ name: 'harmony_project_create',
375
+ description: 'Scaffold a buildable HarmonyOS project (stage model, ArkTS) - fully parametric, no DevEco IDE needed. Options: extra pages (PascalCase, e.g. ["Login","Home"] - each gets a routable page file and main_pages registration) and extra modules (e.g. [{"name":"profile","type":"feature"}] HAP-in-app, or [{"name":"uikit","type":"har"}] shared library wired into entry dependencies). Any project shape in one call, then harmony_build. Requires approval (writes files).',
376
+ parameters: {
377
+ type: 'object',
378
+ properties: {
379
+ path: { type: 'string', description: 'directory to create the project in (created if missing)' },
380
+ name: { type: 'string', description: 'display name (default: directory basename)' },
381
+ bundle_id: { type: 'string', description: 'bundle name (default: com.example.<name>)' },
382
+ pages: { type: 'array', items: { type: 'string' }, description: 'extra page names beyond Index, PascalCase' },
383
+ modules: {
384
+ type: 'array',
385
+ description: 'extra modules; default type is feature',
386
+ items: {
387
+ type: 'object',
388
+ properties: {
389
+ name: { type: 'string', description: 'module name (letters/digits)' },
390
+ type: { type: 'string', description: '"feature" (in-app HAP) or "har" (shared library)' },
391
+ },
392
+ required: ['name'],
393
+ },
394
+ },
395
+ },
396
+ required: ['path'],
397
+ },
398
+ needsApproval: () => true,
399
+ async execute(args, ctx) {
400
+ const dir = resolve(ctx.cwd, String(args.path ?? '.'));
401
+ try {
402
+ const r = await scaffoldProject(dir, {
403
+ name: typeof args.name === 'string' ? args.name : undefined,
404
+ bundleId: typeof args.bundle_id === 'string' ? args.bundle_id : undefined,
405
+ pages: Array.isArray(args.pages) ? args.pages : undefined,
406
+ modules: Array.isArray(args.modules) ? args.modules : undefined,
407
+ });
408
+ return { output: `created project at ${r.root} (bundle ${r.bundleId}, ${r.files} files, SDK ${sdkVersion()}). Next: harmony_build.` };
409
+ }
410
+ catch (err) {
411
+ return { output: String(err), isError: true };
412
+ }
413
+ },
414
+ };
@@ -0,0 +1,22 @@
1
+ import type { Tool } from '@hmharness/kernel';
2
+ /** Parse near-JSON5: strips // and /* comments and trailing commas outside
3
+ * strings, converts single-quoted strings to double, then JSON.parse.
4
+ * Not a full JSON5 parser - sufficient for the config shapes DevEco
5
+ * actually emits. */
6
+ export declare function parseJson5(text: string): unknown;
7
+ export interface SchemaIssue {
8
+ file: string;
9
+ field: string;
10
+ problem: string;
11
+ }
12
+ export declare function validateModuleJson5(obj: unknown, file: string): SchemaIssue[];
13
+ export declare function validateBuildProfile(obj: unknown, file: string, kind: 'root' | 'module'): SchemaIssue[];
14
+ export interface SchemaReport {
15
+ checked: number;
16
+ issues: SchemaIssue[];
17
+ }
18
+ /** Validate all module.json5 + build-profile.json5 files under a project
19
+ * root (scans each module dir's src/main/module.json5 and
20
+ * build-profile.json5 plus the root build-profile). Read-only. */
21
+ export declare function checkProjectSchemas(root: string): Promise<SchemaReport>;
22
+ export declare const harmonySchemaCheck: Tool;
package/dist/schema.js ADDED
@@ -0,0 +1,215 @@
1
+ /**
2
+ * @hmharness/domain-harmony - schema (config validation)
3
+ * Structural validation for the three project config files hvigor reads:
4
+ * <module>/src/main/module.json5 - the module manifest (name/type/abilities...)
5
+ * <module>/build-profile.json5 - module build config (apiType/targets...)
6
+ * build-profile.json5 (root) - app products + compatibleSdkVersion
7
+ *
8
+ * Why: when a scaffold edit, agent patch, or hand edit breaks one of these,
9
+ * hvigor fails deep in the build with cryptic messages. Validating structure
10
+ * BEFORE the build turns a 3-minute toolchain failure into a millisecond
11
+ * "module.json5: module.type must be one of entry/feature/har/shared" that
12
+ * names the exact field. Deliberately structural (field presence, types,
13
+ * enums) - semantic build rules stay with hvigor; this is a lint, not a
14
+ * reimplementation.
15
+ *
16
+ * JSON5 tolerance: these files officially allow comments and trailing
17
+ * commas; the parser strips both leniently (line-safe for strings).
18
+ */
19
+ import { readFile } from 'node:fs/promises';
20
+ import { join } from 'node:path';
21
+ /* ---------------- lenient JSON5 parse ---------------- */
22
+ /** Parse near-JSON5: strips // and /* comments and trailing commas outside
23
+ * strings, converts single-quoted strings to double, then JSON.parse.
24
+ * Not a full JSON5 parser - sufficient for the config shapes DevEco
25
+ * actually emits. */
26
+ export function parseJson5(text) {
27
+ let out = '';
28
+ let inStr = null;
29
+ for (let i = 0; i < text.length; i++) {
30
+ const ch = text[i];
31
+ const prev = text[i - 1];
32
+ if (inStr) {
33
+ // close-quote: convert ' -> " (escape-aware); everything else passes
34
+ if (ch === inStr && prev !== '\\') {
35
+ inStr = null;
36
+ out += '"';
37
+ continue;
38
+ }
39
+ // a raw double quote inside a single-quoted string must be escaped
40
+ if (ch === '"' && inStr === "'" && prev !== '\\') {
41
+ out += '\\"';
42
+ continue;
43
+ }
44
+ out += ch;
45
+ continue;
46
+ }
47
+ if (ch === '"' || ch === "'") {
48
+ inStr = ch;
49
+ out += '"';
50
+ continue;
51
+ }
52
+ if (ch === '/' && text[i + 1] === '/') {
53
+ while (i < text.length && text[i] !== '\n')
54
+ i++;
55
+ out += '\n';
56
+ continue;
57
+ }
58
+ if (ch === '/' && text[i + 1] === '*') {
59
+ i += 2;
60
+ while (i < text.length && !(text[i] === '*' && text[i + 1] === '/'))
61
+ i++;
62
+ i++;
63
+ continue;
64
+ }
65
+ out += ch;
66
+ }
67
+ // trailing commas outside strings
68
+ out = out.replace(/,\s*([}\]])/g, '$1');
69
+ return JSON.parse(out);
70
+ }
71
+ const MODULE_TYPES = new Set(['entry', 'feature', 'har', 'shared']);
72
+ export function validateModuleJson5(obj, file) {
73
+ const issues = [];
74
+ const m = obj?.module;
75
+ if (!m || typeof m !== 'object')
76
+ return [{ file, field: 'module', problem: 'missing "module" object' }];
77
+ if (typeof m.name !== 'string' || !m.name)
78
+ issues.push({ file, field: 'module.name', problem: 'missing or empty' });
79
+ if (typeof m.type !== 'string' || !MODULE_TYPES.has(m.type)) {
80
+ issues.push({ file, field: 'module.type', problem: `must be one of ${[...MODULE_TYPES].join('/')}, got ${JSON.stringify(m.type)}` });
81
+ }
82
+ if (m.type === 'entry') {
83
+ if (typeof m.mainElement !== 'string' || !m.mainElement)
84
+ issues.push({ file, field: 'module.mainElement', problem: 'entry modules must name a mainElement (the launch ability)' });
85
+ if (!Array.isArray(m.deviceTypes) || m.deviceTypes.length === 0)
86
+ issues.push({ file, field: 'module.deviceTypes', problem: 'entry modules need a non-empty deviceTypes array' });
87
+ }
88
+ if (m.pages !== undefined && typeof m.pages !== 'string')
89
+ issues.push({ file, field: 'module.pages', problem: 'must be a $profile reference string' });
90
+ if (m.abilities !== undefined && !Array.isArray(m.abilities))
91
+ issues.push({ file, field: 'module.abilities', problem: 'must be an array' });
92
+ return issues;
93
+ }
94
+ export function validateBuildProfile(obj, file, kind) {
95
+ const issues = [];
96
+ const o = obj;
97
+ if (!o || typeof o !== 'object')
98
+ return [{ file, field: '(root)', problem: 'not a JSON object' }];
99
+ if (kind === 'root') {
100
+ const app = o.app;
101
+ if (!app)
102
+ return [{ file, field: 'app', problem: 'root build-profile needs an "app" section' }];
103
+ const products = app.products;
104
+ if (!Array.isArray(products) || products.length === 0)
105
+ issues.push({ file, field: 'app.products', problem: 'needs at least one product' });
106
+ for (const [i, p] of (Array.isArray(products) ? products : []).entries()) {
107
+ const prod = p;
108
+ if (typeof prod.compatibleSdkVersion !== 'string' || !prod.compatibleSdkVersion) {
109
+ issues.push({ file, field: `app.products[${i}].compatibleSdkVersion`, problem: 'missing (e.g. "6.1.1(24)" or "5.0.5(17)")' });
110
+ }
111
+ }
112
+ if (!Array.isArray(o.modules) || o.modules.length === 0)
113
+ issues.push({ file, field: 'modules', problem: 'root build-profile needs a non-empty modules array' });
114
+ }
115
+ else {
116
+ if (o.apiType !== undefined && o.apiType !== 'stageMode')
117
+ issues.push({ file, field: 'apiType', problem: `expected "stageMode", got ${JSON.stringify(o.apiType)}` });
118
+ if (!Array.isArray(o.targets) || o.targets.length === 0)
119
+ issues.push({ file, field: 'targets', problem: 'module build-profile needs a non-empty targets array' });
120
+ }
121
+ return issues;
122
+ }
123
+ /** Validate all module.json5 + build-profile.json5 files under a project
124
+ * root (scans each module dir's src/main/module.json5 and
125
+ * build-profile.json5 plus the root build-profile). Read-only. */
126
+ export async function checkProjectSchemas(root) {
127
+ const { readdir } = await import('node:fs/promises');
128
+ const issues = [];
129
+ let checked = 0;
130
+ const rootProfile = join(root, 'build-profile.json5');
131
+ if (await exists(rootProfile)) {
132
+ checked++;
133
+ try {
134
+ const obj = parseJson5(await readFile(rootProfile, 'utf8'));
135
+ issues.push(...validateBuildProfile(obj, rootProfile, 'root'));
136
+ }
137
+ catch (err) {
138
+ issues.push({ file: rootProfile, field: '(parse)', problem: `unparseable JSON5: ${String(err).slice(0, 90)}` });
139
+ }
140
+ }
141
+ let dirs = [];
142
+ try {
143
+ dirs = (await readdir(root, { withFileTypes: true })).filter((d) => d.isDirectory() && !d.name.startsWith('.')).map((d) => join(root, d.name));
144
+ }
145
+ catch { /* no root */ }
146
+ for (const dir of dirs) {
147
+ // a module dir is one WITH a src/main/module.json5 or a build-profile;
148
+ // everything else (AppScope, hvigor, .git...) is skipped silently -
149
+ // a missing file is not a schema issue
150
+ const mp = join(dir, 'src', 'main', 'module.json5');
151
+ if (await exists(mp)) {
152
+ checked++;
153
+ try {
154
+ const obj = parseJson5(await readFile(mp, 'utf8'));
155
+ issues.push(...validateModuleJson5(obj, mp));
156
+ }
157
+ catch (err) {
158
+ issues.push({ file: mp, field: '(parse)', problem: `unparseable JSON5: ${String(err).slice(0, 90)}` });
159
+ }
160
+ }
161
+ const bp = join(dir, 'build-profile.json5');
162
+ if (await exists(bp)) {
163
+ checked++;
164
+ try {
165
+ const obj = parseJson5(await readFile(bp, 'utf8'));
166
+ issues.push(...validateBuildProfile(obj, bp, 'module'));
167
+ }
168
+ catch (err) {
169
+ issues.push({ file: bp, field: '(parse)', problem: `unparseable JSON5: ${String(err).slice(0, 90)}` });
170
+ }
171
+ }
172
+ }
173
+ return { checked, issues };
174
+ }
175
+ async function exists(p) {
176
+ try {
177
+ await (await import('node:fs/promises')).stat(p);
178
+ return true;
179
+ }
180
+ catch {
181
+ return false;
182
+ }
183
+ }
184
+ /* ---------------- tool registration ---------------- */
185
+ export const harmonySchemaCheck = {
186
+ name: 'harmony_schema_check',
187
+ description: 'Validate a HarmonyOS project\'s config structure (module.json5 / build-profile.json5) BEFORE building - field presence, enums, product SDK versions. Turns a cryptic 3-minute hvigor failure into a millisecond message naming the exact broken field. Read-only. Run after scaffolding edits, patches, or hand edits; fix what it lists, then harmony_build.',
188
+ parameters: {
189
+ type: 'object',
190
+ properties: {
191
+ path: { type: 'string', description: 'project root (default: cwd)' },
192
+ },
193
+ required: [],
194
+ },
195
+ needsApproval: () => false,
196
+ async execute(args, ctx) {
197
+ const { resolve } = await import('node:path');
198
+ const root = resolve(ctx.cwd, String(args.path ?? '.'));
199
+ let report;
200
+ try {
201
+ report = await checkProjectSchemas(root);
202
+ }
203
+ catch (err) {
204
+ return { output: `schema check failed: ${String(err).slice(0, 160)}`, isError: true };
205
+ }
206
+ if (report.checked === 0) {
207
+ return { output: `no config files found under ${root} - is this a HarmonyOS project root?`, isError: true };
208
+ }
209
+ if (report.issues.length === 0) {
210
+ return { output: `OK: ${report.checked} config file(s) valid (structure + enums). Safe to harmony_build.` };
211
+ }
212
+ const lines = report.issues.map((i) => `- ${i.file} :: ${i.field} - ${i.problem}`);
213
+ return { output: `${report.issues.length} issue(s) in ${report.checked} file(s):\n${lines.join('\n')}\n\nFix these before harmony_build - each names the exact field.`, isError: true };
214
+ },
215
+ };