@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.
- package/bin/norns.js +146 -6
- package/package.json +9 -3
- package/src/auto-import.js +7 -3
- package/src/config.js +17 -3
- package/src/kernel/absorb.js +279 -0
- package/src/kernel/address.js +228 -0
- package/src/kernel/adopt.js +157 -0
- package/src/kernel/emit-machines.js +87 -0
- package/src/kernel/emit-schema.js +199 -0
- package/src/kernel/emit-units.js +855 -0
- package/src/kernel/emit-wrangler.js +134 -0
- package/src/kernel/expr-compile.js +191 -0
- package/src/kernel/expr.js +290 -0
- package/src/kernel/flow.js +444 -0
- package/src/kernel/generate.js +840 -0
- package/src/kernel/graph.js +222 -0
- package/src/kernel/index.js +78 -0
- package/src/kernel/meta.js +381 -0
- package/src/kernel/migrate.js +134 -0
- package/src/kernel/refine.js +277 -0
- package/src/kernel/trace.js +465 -0
- package/src/kernel/validate.js +92 -0
- package/src/live-client.js +216 -0
- package/src/server/boot.js +83 -4
- package/src/server/cron.js +105 -0
- package/src/server/db.js +66 -1
- package/src/server/endpoint.js +142 -0
- package/src/server/events.js +86 -0
- package/src/server/guard.js +48 -0
- package/src/server/handle/auth.js +54 -0
- package/src/server/index.js +15 -1
- package/src/server/job.js +102 -0
- package/src/server/live.js +134 -0
- package/src/server/machine.js +35 -0
- package/src/server/page.js +7 -3
- package/src/server/room.js +179 -0
- package/src/server/service.js +188 -0
- package/src/server/storage.js +97 -0
|
@@ -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
|
+
}
|