@human-synthesis/norns 0.0.16 → 0.2.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,465 @@
1
+ /**
2
+ * Trace runner (K-18): execute Action `examples` against a sandboxed
3
+ * in-memory SQLite and report per-case pass/fail with step values.
4
+ *
5
+ * The traced artifact is the *generated* code, not a re-interpretation of
6
+ * the spec: the generated tree is compiled (Civet → JS) into a scratch
7
+ * dir with imports rewritten to resolvable paths, then imported and run.
8
+ *
9
+ * Conventions:
10
+ * - `$name` input values are fixture handles: a row with that id is
11
+ * seeded in the action's entity table; if `name` is one of the
12
+ * entity's states, the row starts in that state. Owner/ref fields
13
+ * are seeded to the trace user so `owner` policies pass.
14
+ * - `expect` keys are checked against the entity row after the run,
15
+ * falling back to the action's return value.
16
+ * - external calls (`container.resolve(...)`) hit `opts.fixtures` when
17
+ * provided, otherwise a recording stub — every call is reported.
18
+ */
19
+
20
+ import { mkdirSync, readFileSync, readdirSync, rmSync, existsSync, writeFileSync } from 'node:fs';
21
+ import { createRequire } from 'node:module';
22
+ import { dirname, join } from 'node:path';
23
+ import { pathToFileURL } from 'node:url';
24
+
25
+ import { actionEntity } from './emit-units.js';
26
+ import { shapeIssues } from '../server/service.js';
27
+ import { normalizeField } from './emit-schema.js';
28
+ import { generateApp } from './generate.js';
29
+ import { loadSpecs } from './validate.js';
30
+
31
+ const require = createRequire(import.meta.url);
32
+
33
+ export const TRACE_USER = { id: 'trace-user', roles: ['admin'] };
34
+
35
+ /* ------------------------------------------------------------------ */
36
+ /* Scratch tree: compile generated Civet to importable JS */
37
+ /* ------------------------------------------------------------------ */
38
+
39
+ const BARE = {
40
+ '@human-synthesis/norns/server': new URL('../server/index.js', import.meta.url).href,
41
+ '@human-synthesis/norns/kernel': new URL('./index.js', import.meta.url).href
42
+ };
43
+
44
+ function resolveBare(spec) {
45
+ if (BARE[spec]) return BARE[spec];
46
+ return import.meta.resolve(spec);
47
+ }
48
+
49
+ function rewriteImports(js, { scratch, appRoot, compileCivet }) {
50
+ return js.replace(/(from\s+)(['"])([^'"]+)\2/g, (whole, from, q, spec) => {
51
+ if (spec.startsWith('.')) return `${from}${q}${spec.replace(/\.c$/, '.js')}${q}`;
52
+ if (spec.startsWith('$lib/')) {
53
+ const target = join(scratch, 'lib', spec.slice('$lib/'.length).replace(/\.c$/, '.js'));
54
+ return `${from}${q}${pathToFileURL(target).href}${q}`;
55
+ }
56
+ if (spec.startsWith('$custom/')) {
57
+ const target = ensureCustom(spec.slice('$custom/'.length), { scratch, appRoot, compileCivet });
58
+ return `${from}${q}${pathToFileURL(target).href}${q}`;
59
+ }
60
+ return `${from}${q}${resolveBare(spec)}${q}`;
61
+ });
62
+ }
63
+
64
+ /** Compile a custom body `src/<rel>` into the scratch tree; missing bodies throw when invoked. */
65
+ function ensureCustom(rel, { scratch, appRoot, compileCivet }) {
66
+ const target = join(scratch, 'custom', rel.replace(/\.c$/, '.js'));
67
+ if (!existsSync(target)) {
68
+ const src = join(appRoot, 'src', rel);
69
+ mkdirSync(dirname(target), { recursive: true });
70
+ if (existsSync(src)) {
71
+ writeFileSync(
72
+ target,
73
+ rewriteImports(compileCivet(readFileSync(src, 'utf-8')), { scratch, appRoot, compileCivet })
74
+ );
75
+ } else {
76
+ writeFileSync(
77
+ target,
78
+ `export default () => { throw new Error(${JSON.stringify(`missing custom body: src/${rel}`)}) }\n`
79
+ );
80
+ }
81
+ }
82
+ return target;
83
+ }
84
+
85
+ /** Compile `lib/**` of the generated tree into `<scratch>/lib` as JS. */
86
+ function buildScratch(genRoot, scratch, appRoot) {
87
+ const { compile } = require('@danielx/civet');
88
+ const compileCivet = (src) => compile(src, { sync: true, js: true });
89
+ const libRoot = join(genRoot, 'lib');
90
+ if (!existsSync(libRoot)) return;
91
+ for (const moduleName of readdirSync(libRoot)) {
92
+ const dir = join(libRoot, moduleName);
93
+ for (const file of readdirSync(dir)) {
94
+ if (!file.endsWith('.c')) continue;
95
+ const js = rewriteImports(compileCivet(readFileSync(join(dir, file), 'utf-8')), {
96
+ scratch,
97
+ appRoot,
98
+ compileCivet
99
+ });
100
+ const out = join(scratch, 'lib', moduleName, file.replace(/\.c$/, '.js'));
101
+ mkdirSync(dirname(out), { recursive: true });
102
+ writeFileSync(out, js);
103
+ }
104
+ }
105
+ }
106
+
107
+ /* ------------------------------------------------------------------ */
108
+ /* Sandbox database */
109
+ /* ------------------------------------------------------------------ */
110
+
111
+ const sqlLit = (v) =>
112
+ typeof v === 'number' ? String(v) : typeof v === 'boolean' ? (v ? '1' : '0') : `'${String(v).replaceAll("'", "''")}'`;
113
+
114
+ async function tableDDL(tables) {
115
+ const { getTableConfig } = await import('drizzle-orm/sqlite-core');
116
+ return tables.map((table) => {
117
+ const cfg = getTableConfig(table);
118
+ const cols = cfg.columns.map((c) => {
119
+ let s = `"${c.name}" ${c.getSQLType()}`;
120
+ if (c.primary) s += ' PRIMARY KEY';
121
+ else if (c.notNull) s += ' NOT NULL';
122
+ if (c.hasDefault && c.default !== undefined) s += ` DEFAULT ${sqlLit(c.default)}`;
123
+ return s;
124
+ });
125
+ return `CREATE TABLE "${cfg.name}" (${cols.join(', ')})`;
126
+ });
127
+ }
128
+
129
+ /** Collect every sqliteTable export across the compiled schema modules. */
130
+ async function loadTables(scratch, specs) {
131
+ const tables = {};
132
+ for (const moduleName of Object.keys(specs.modules)) {
133
+ const file = join(scratch, 'lib', moduleName, 'schema.js');
134
+ if (!existsSync(file)) continue;
135
+ const mod = await import(pathToFileURL(file).href);
136
+ for (const [entity] of Object.entries(specs.modules[moduleName].entities ?? {})) {
137
+ if (mod[entity]) tables[`${moduleName}.${entity}`] = mod[entity];
138
+ }
139
+ }
140
+ return tables;
141
+ }
142
+
143
+ const SEED_VALUES = {
144
+ text: 'trace',
145
+ email: 'trace@example.com',
146
+ url: 'https://example.com',
147
+ file: 'trace.bin',
148
+ int: 0,
149
+ money: 0,
150
+ number: 0,
151
+ bool: false,
152
+ json: {}
153
+ };
154
+
155
+ function seedRow(id, entitySpec) {
156
+ const row = { id };
157
+ const owner = entitySpec.owner;
158
+ for (const [field, def0] of Object.entries(entitySpec.fields ?? {})) {
159
+ const def = normalizeField(def0);
160
+ if (def.optional) continue;
161
+ if (field === owner || def.type === 'ref') row[field] = TRACE_USER.id;
162
+ else if (def.type === 'date' || def.type === 'datetime') row[field] = new Date(0);
163
+ else row[field] = def.default ?? SEED_VALUES[def.type] ?? 'trace';
164
+ }
165
+ const states = Object.keys(entitySpec.status ?? {});
166
+ const name = id.startsWith('$') ? id.slice(1) : null;
167
+ if (name && states.includes(name)) row.status = name;
168
+ return row;
169
+ }
170
+
171
+ /* ------------------------------------------------------------------ */
172
+ /* Runner */
173
+ /* ------------------------------------------------------------------ */
174
+
175
+ function recordingContainer(db, { fixtures = {}, auto = {}, events, calls }) {
176
+ return {
177
+ resolve(name) {
178
+ if (name === 'db') return db;
179
+ if (name === 'events') {
180
+ return {
181
+ emit: async (evt, payload) => {
182
+ events.push({ name: evt, payload });
183
+ }
184
+ };
185
+ }
186
+ if (name === 'jobs') {
187
+ return {
188
+ enqueue: async (address, input) => {
189
+ calls.push({ name: 'jobs.enqueue', args: [address, input] });
190
+ }
191
+ };
192
+ }
193
+ if (name in fixtures) return fixtures[name];
194
+ if (name in auto) {
195
+ return async (...args) => {
196
+ calls.push({ name, args });
197
+ return auto[name];
198
+ };
199
+ }
200
+ return async (...args) => {
201
+ calls.push({ name, args });
202
+ };
203
+ },
204
+ // `has` makes serviceClient's op-level fixture hook fire for every
205
+ // declared service operation — traced flows never touch the network.
206
+ has: (name) => name in fixtures || name in auto
207
+ };
208
+ }
209
+
210
+ /** Service op address → response fabricated from the op's declared output shape. */
211
+ function serviceAutoFixtures(specs) {
212
+ const auto = {};
213
+ for (const [moduleName, moduleSpec] of Object.entries(specs.modules)) {
214
+ for (const [serviceName, svc] of Object.entries(moduleSpec.services ?? {})) {
215
+ for (const [opName, op] of Object.entries(svc.operations ?? {})) {
216
+ const out = {};
217
+ for (const [key, t] of Object.entries(op.output && typeof op.output === 'object' ? op.output : {})) {
218
+ const type = (typeof t === 'string' ? t : (t?.type ?? 'json')).replace(/\?$/, '');
219
+ out[key] = SEED_VALUES[type] ?? 'trace';
220
+ }
221
+ auto[`${moduleName}.Service.${serviceName}.${opName}`] = out;
222
+ }
223
+ }
224
+ }
225
+ return auto;
226
+ }
227
+
228
+ const looseEq = (a, b) => JSON.stringify(a) === JSON.stringify(b);
229
+
230
+ function matchCalls(calls, want) {
231
+ return (Array.isArray(want) ? want : []).every((w) => {
232
+ const name = typeof w === 'string' ? w : w?.name;
233
+ return (calls ?? []).some(
234
+ (c) => c.name === name && (typeof w === 'string' || w.with === undefined || looseEq(c.args[0], w.with))
235
+ );
236
+ });
237
+ }
238
+
239
+ /**
240
+ * `expect` semantics shared by every traced kind: plain keys check the
241
+ * entity row then the return value; `calls` checks recorded external
242
+ * calls (by name, optionally `with` args); `frames` the collected SSE
243
+ * frames; `state`/`broadcasts` the Room script outcome.
244
+ */
245
+ function checkExpect(record) {
246
+ record.pass = Object.entries(record.expect).every(([key, want]) => {
247
+ if (key === 'calls') return matchCalls(record.calls, want);
248
+ if (key === 'frames') return looseEq(record.frames, want);
249
+ if (key === 'state') {
250
+ return Object.entries(want ?? {}).every(([field, v2]) => looseEq(record.state?.[field], v2));
251
+ }
252
+ if (key === 'broadcasts') {
253
+ return (Array.isArray(want) ? want : []).every((n) => (record.broadcasts ?? []).some((b) => b?.type === n));
254
+ }
255
+ return looseEq(record.row?.[key], want) || looseEq(record.result?.[key], want);
256
+ });
257
+ }
258
+
259
+ /**
260
+ * Run every example in the app's specs: Actions against the sandbox DB,
261
+ * plus L3 units (K-25) — Jobs and custom Functions/Endpoints run with the
262
+ * same recording container (Service calls auto-fixtured from their output
263
+ * schemas), Room workers run their script examples headless.
264
+ *
265
+ * @param {string} [dir] specs directory, defaults to `<cwd>/specs`
266
+ * @param {{ out?: string, fixtures?: Record<string, *>, user?: * }} [opts]
267
+ * @returns {Promise<{ version: string, pass: number, fail: number, cases: * }>}
268
+ */
269
+ export async function traceApp(dir, opts = {}) {
270
+ const specs = loadSpecs(dir);
271
+ const appRoot = dirname(specs.dir);
272
+ const genRoot = opts.out ?? join(appRoot, '.norns', 'generated');
273
+ generateApp(dir, { out: opts.out });
274
+
275
+ const scratch = join(appRoot, '.norns', 'cache', 'trace', `${specs.version.slice(0, 8)}-${Date.now()}`);
276
+ const user = opts.user ?? TRACE_USER;
277
+ const cases = [];
278
+
279
+ try {
280
+ buildScratch(genRoot, scratch, appRoot);
281
+ const tables = await loadTables(scratch, specs);
282
+ const ddl = await tableDDL(Object.values(tables));
283
+ const { betterSqlite } = await import('../server/db.js');
284
+ const { sql, eq } = await import('drizzle-orm');
285
+ const { compile } = require('@danielx/civet');
286
+ const ctxc = { scratch, appRoot, compileCivet: (src) => compile(src, { sync: true, js: true }) };
287
+ const auto = serviceAutoFixtures(specs);
288
+
289
+ const runL3 = async (address, index, example, invoke) => {
290
+ const events = [];
291
+ const calls = [];
292
+ const record = { address, index, input: example.input ?? {}, expect: example.expect ?? {}, events, calls };
293
+ try {
294
+ const db = await betterSqlite(':memory:');
295
+ for (const stmt of ddl) await db.run(sql.raw(stmt));
296
+ const container = recordingContainer(db, { fixtures: opts.fixtures, auto, events, calls });
297
+ record.result = await invoke({ input: record.input, container, user, event: null });
298
+ checkExpect(record);
299
+ } catch (e) {
300
+ record.pass = false;
301
+ record.error = e?.body?.message ?? e?.message ?? String(e);
302
+ record.status = e?.status;
303
+ }
304
+ return record;
305
+ };
306
+
307
+ for (const [moduleName, moduleSpec] of Object.entries(specs.modules)) {
308
+ const actionsFile = join(scratch, 'lib', moduleName, 'actions.js');
309
+ const actionNames = Object.entries(moduleSpec.actions ?? {})
310
+ .filter(([, a]) => (a.examples ?? []).length > 0)
311
+ .map(([n]) => n)
312
+ .sort();
313
+ const mod =
314
+ actionNames.length > 0 && existsSync(actionsFile) ? await import(pathToFileURL(actionsFile).href) : null;
315
+
316
+ for (const name of mod ? actionNames : []) {
317
+ const actionSpec = moduleSpec.actions[name];
318
+ const address = `${moduleName}.Action.${name}`;
319
+ const target = actionEntity(moduleName, actionSpec, specs);
320
+
321
+ for (const [index, example] of (actionSpec.examples ?? []).entries()) {
322
+ const events = [];
323
+ const calls = [];
324
+ const record = { address, index, input: example.input ?? {}, expect: example.expect ?? {}, events, calls };
325
+ cases.push(record);
326
+ try {
327
+ const db = await betterSqlite(':memory:');
328
+ for (const stmt of ddl) await db.run(sql.raw(stmt));
329
+
330
+ // seed every `$handle` input value into its entity table
331
+ let seededId = null;
332
+ for (const [key, value] of Object.entries(example.input ?? {})) {
333
+ if (typeof value !== 'string' || !value.startsWith('$')) continue;
334
+ const ref = actionSpec.input?.[key];
335
+ const entity = typeof ref === 'string' ? ref.replace(/\?$/, '').split('.')[0] : target?.entity;
336
+ const entityModule = target && target.entity === entity ? target.module : moduleName;
337
+ const table = tables[`${entityModule}.${entity}`];
338
+ const entitySpec = specs.modules[entityModule]?.entities?.[entity];
339
+ if (!table || !entitySpec) continue;
340
+ await db.insert(table).values(seedRow(value, entitySpec));
341
+ if (ref?.endsWith('.id')) seededId = { table, value };
342
+ }
343
+
344
+ const container = recordingContainer(db, { fixtures: opts.fixtures, auto, events, calls });
345
+ record.result = await mod[name].run({ input: example.input ?? {}, container, user });
346
+
347
+ if (seededId && target) {
348
+ record.row = (
349
+ await db.select().from(seededId.table).where(eq(seededId.table.id, seededId.value)).limit(1)
350
+ )[0];
351
+ }
352
+
353
+ checkExpect(record);
354
+ } catch (e) {
355
+ record.pass = false;
356
+ record.error = e?.body?.message ?? e?.message ?? String(e);
357
+ record.status = e?.status;
358
+ }
359
+ }
360
+ }
361
+
362
+ // Jobs — run the generated (or custom) `run` inline (K-25)
363
+ const jobsFile = join(scratch, 'lib', moduleName, 'jobs.js');
364
+ const jobNames = Object.entries(moduleSpec.jobs ?? {})
365
+ .filter(([, j]) => (j.examples ?? []).length > 0)
366
+ .map(([n]) => n)
367
+ .sort();
368
+ if (jobNames.length > 0 && existsSync(jobsFile)) {
369
+ const jobsMod = await import(pathToFileURL(jobsFile).href);
370
+ for (const name of jobNames) {
371
+ for (const [index, example] of moduleSpec.jobs[name].examples.entries()) {
372
+ cases.push(
373
+ await runL3(`${moduleName}.Job.${name}`, index, example, (ctx) => jobsMod[name].run(ctx))
374
+ );
375
+ }
376
+ }
377
+ }
378
+
379
+ // Functions — custom bodies with contract examples
380
+ for (const name of Object.keys(moduleSpec.functions ?? {}).sort()) {
381
+ const fnSpec = moduleSpec.functions[name];
382
+ if ((fnSpec.examples ?? []).length === 0) continue;
383
+ const target = ensureCustom(`${moduleName}/functions/${name}.c`, ctxc);
384
+ const body = (await import(pathToFileURL(target).href)).default;
385
+ for (const [index, example] of fnSpec.examples.entries()) {
386
+ cases.push(await runL3(`${moduleName}.Function.${name}`, index, example, (ctx) => body(ctx)));
387
+ }
388
+ }
389
+
390
+ // Endpoints — body-level trace with the declared IO contract enforced
391
+ for (const name of Object.keys(moduleSpec.endpoints ?? {}).sort()) {
392
+ const ep = moduleSpec.endpoints[name];
393
+ if ((ep.examples ?? []).length === 0) continue;
394
+ const target = ensureCustom(`${moduleName}/endpoints/${name}.c`, ctxc);
395
+ const body = (await import(pathToFileURL(target).href)).default;
396
+ for (const [index, example] of ep.examples.entries()) {
397
+ const record = await runL3(`${moduleName}.Endpoint.${name}`, index, example, async (ctx) => {
398
+ let result = body(ctx);
399
+ if (ep.stream) {
400
+ if (!result?.[Symbol.asyncIterator]) result = await result;
401
+ const frames = [];
402
+ for await (const frame of result) {
403
+ const issues = shapeIssues(ep.stream.frame ?? {}, frame, 'frame');
404
+ if (issues.length > 0) throw new Error(issues.join('; '));
405
+ frames.push(frame);
406
+ }
407
+ return frames;
408
+ }
409
+ result = await result;
410
+ if (ep.output) {
411
+ const issues = shapeIssues(ep.output, result, 'output');
412
+ if (issues.length > 0) throw new Error(`output contract: ${issues.join('; ')}`);
413
+ }
414
+ return result;
415
+ });
416
+ if (ep.stream && Array.isArray(record.result)) {
417
+ record.frames = record.result;
418
+ checkExpect(record);
419
+ }
420
+ cases.push(record);
421
+ }
422
+ }
423
+
424
+ // Room workers — script examples driven headless against the class
425
+ for (const name of Object.keys(moduleSpec.workers ?? {}).sort()) {
426
+ const w = moduleSpec.workers[name];
427
+ if (w?.room !== true || (w.examples ?? []).length === 0) continue;
428
+ const rel = w.source.startsWith('src/') ? w.source.slice(4) : w.source;
429
+ const target = ensureCustom(rel, ctxc);
430
+ const RoomClass = (await import(pathToFileURL(target).href)).default;
431
+ for (const [index, example] of w.examples.entries()) {
432
+ const record = {
433
+ address: `${moduleName}.Worker.${name}`,
434
+ index,
435
+ script: example.script ?? [],
436
+ expect: example.expect ?? {},
437
+ broadcasts: []
438
+ };
439
+ cases.push(record);
440
+ try {
441
+ const instance = new RoomClass({}, {});
442
+ instance.tickMs = 0;
443
+ instance.broadcast = (message) => {
444
+ record.broadcasts.push(typeof message === 'string' ? JSON.parse(message) : message);
445
+ return 0;
446
+ };
447
+ for (const step of example.script ?? []) {
448
+ await instance.onMessage(JSON.stringify({ type: step.send, ...(step.with ?? {}) }), null);
449
+ }
450
+ record.state = Object.fromEntries(Object.keys(w.state ?? {}).map((f) => [f, instance[f]]));
451
+ checkExpect(record);
452
+ } catch (e) {
453
+ record.pass = false;
454
+ record.error = e?.message ?? String(e);
455
+ }
456
+ }
457
+ }
458
+ }
459
+ } finally {
460
+ rmSync(scratch, { recursive: true, force: true });
461
+ }
462
+
463
+ const pass = cases.filter((c) => c.pass).length;
464
+ return { version: specs.version, pass, fail: cases.length - pass, cases };
465
+ }
@@ -0,0 +1,92 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { resolve } from 'node:path';
3
+
4
+ import { APP_SPEC, readSpecs } from '@human-synthesis/norns-tron/spec';
5
+
6
+ import { indexUnits, listUnits } from './address.js';
7
+ import { APP_SCHEMA, MODULE_SCHEMA, UNIT_SCHEMAS, schemaIssues } from './meta.js';
8
+ import { refineSpecs } from './refine.js';
9
+
10
+ /**
11
+ * @typedef {{ level: 'error' | 'warning', address: string, message: string }} Issue
12
+ * @typedef {{
13
+ * dir: string,
14
+ * app: *,
15
+ * modules: Record<string, *>,
16
+ * hashes: Record<string, string>,
17
+ * version: string
18
+ * }} LoadedSpecs
19
+ */
20
+
21
+ /**
22
+ * Load a `specs/` directory. Throws when the directory is missing —
23
+ * everything past this point can assume specs exist.
24
+ *
25
+ * @param {string} [dir] defaults to `<cwd>/specs`
26
+ * @returns {LoadedSpecs}
27
+ */
28
+ export function loadSpecs(dir = resolve(process.cwd(), 'specs')) {
29
+ const abs = resolve(dir);
30
+ if (!existsSync(abs)) {
31
+ throw new Error(`norns: no specs directory at ${abs} (expected specs/*.tron)`);
32
+ }
33
+ return { dir: abs, ...readSpecs(abs) };
34
+ }
35
+
36
+ /**
37
+ * Validate a specs directory and return every issue found: structural
38
+ * checks, per-kind meta-schemas, uid uniqueness. Cross-unit refinements
39
+ * (K-06) register here as they land.
40
+ *
41
+ * @param {string} [dir]
42
+ * @returns {{ ok: boolean, version: string, modules: string[], issues: Issue[] }}
43
+ */
44
+ export function validateSpecs(dir) {
45
+ const specs = loadSpecs(dir);
46
+ /** @type {Issue[]} */
47
+ const issues = [];
48
+
49
+ if (specs.app === null) {
50
+ issues.push({
51
+ level: 'error',
52
+ address: APP_SPEC,
53
+ message: `missing ${APP_SPEC}.tron — every app needs an app spec`
54
+ });
55
+ }
56
+
57
+ for (const [name, value] of Object.entries(specs.modules)) {
58
+ if (value === null || typeof value !== 'object' || Array.isArray(value)) {
59
+ issues.push({
60
+ level: 'error',
61
+ address: name,
62
+ message: `${name}.tron must be an object, got ${Array.isArray(value) ? 'array' : typeof value}`
63
+ });
64
+ continue;
65
+ }
66
+ if (value.module !== name) {
67
+ issues.push({
68
+ level: 'error',
69
+ address: name,
70
+ message: `module field ${JSON.stringify(value.module)} does not match file name "${name}"`
71
+ });
72
+ }
73
+ issues.push(...schemaIssues(MODULE_SCHEMA, value, name));
74
+ for (const unit of listUnits(name, value)) {
75
+ issues.push(...schemaIssues(UNIT_SCHEMAS[unit.kind], unit.value, unit.address));
76
+ }
77
+ }
78
+
79
+ if (specs.app !== null) {
80
+ issues.push(...schemaIssues(APP_SCHEMA, specs.app, APP_SPEC));
81
+ }
82
+
83
+ issues.push(...indexUnits(specs.modules).issues);
84
+ issues.push(...refineSpecs(specs));
85
+
86
+ return {
87
+ ok: !issues.some((i) => i.level === 'error'),
88
+ version: specs.version,
89
+ modules: Object.keys(specs.modules),
90
+ issues
91
+ };
92
+ }