@human-synthesis/norns 0.0.16 → 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,397 @@
1
+ /**
2
+ * Generator pipeline (K-09): load → validate → refuse → plan → emit.
3
+ *
4
+ * Incremental by module hash: a cache under `.norns/cache/generate.json`
5
+ * records the per-module spec hash of the last successful run; only
6
+ * changed modules are re-emitted. Emitters (K-10..K-12: schema, queries,
7
+ * actions, pages, routes) register in EMITTERS — each returns files
8
+ * relative to `.norns/generated/`.
9
+ *
10
+ * The refusal engine turns unsafe-but-shapely specs into structured
11
+ * errors `{ address, path, code, message, fix? }` — the safe path is the
12
+ * only path.
13
+ */
14
+
15
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
16
+ import { createRequire } from 'node:module';
17
+ import { dirname, join, resolve } from 'node:path';
18
+
19
+ import * as v from 'valibot';
20
+
21
+ import { isAddress, listUnits, parseAddress } from './address.js';
22
+ import { schemaEmitter } from './emit-schema.js';
23
+ import {
24
+ actionsEmitter,
25
+ pagesEmitter,
26
+ policiesEmitter,
27
+ queriesEmitter,
28
+ remotesEmitter,
29
+ triggersEmitter
30
+ } from './emit-units.js';
31
+ import { machinesEmitter } from './emit-machines.js';
32
+ import { wranglerFile } from './emit-wrangler.js';
33
+ import { buildGraph } from './graph.js';
34
+ import { loadSpecs, validateSpecs } from './validate.js';
35
+
36
+ /** @typedef {{ address: string, path?: string, code: string, message: string, fix?: string }} Refusal */
37
+
38
+ /**
39
+ * Generator-specific refusals, beyond what `validate` rejects.
40
+ *
41
+ * @param {{ modules: Record<string, *> }} specs
42
+ * @param {{ contracts?: Record<string, *> }} [opts] palette props contracts
43
+ * (valibot schemas keyed by component tag, normally loaded from
44
+ * `@human-synthesis/norns-ui/contracts` in the app's node_modules)
45
+ * @returns {Refusal[]}
46
+ */
47
+ export function checkGenerate(specs, opts = {}) {
48
+ /** @type {Refusal[]} */
49
+ const refusals = [];
50
+ const graph = buildGraph(specs.modules);
51
+ if (opts.contracts) refusals.push(...checkBindings(specs, opts.contracts));
52
+
53
+ for (const [moduleName, spec] of Object.entries(specs.modules)) {
54
+ for (const unit of listUnits(moduleName, spec)) {
55
+ if (unit.kind === 'Action') {
56
+ const writes = (graph.outbound.get(unit.address) ?? []).filter(
57
+ (e) => e.type === 'writes'
58
+ );
59
+ for (const edge of writes) {
60
+ const guarded = (graph.inbound.get(edge.to) ?? []).some((e) => e.type === 'guards');
61
+ if (!guarded) {
62
+ refusals.push({
63
+ address: unit.address,
64
+ path: `${unit.address}.steps`,
65
+ code: 'UNGUARDED_ACTION',
66
+ message: `action writes ${edge.to} but no Policy guards that entity`,
67
+ fix: `add policies.${edge.to.split('.').pop()} with read/write rules to module "${moduleName}"`
68
+ });
69
+ }
70
+ }
71
+ }
72
+ if (unit.kind === 'Action') {
73
+ const a = unit.value;
74
+ if (a && typeof a === 'object' && a.transport === 'remote') {
75
+ refusals.push({
76
+ address: unit.address,
77
+ path: `${unit.address}.transport`,
78
+ code: 'UNSPIKED_TRANSPORT',
79
+ message: '`transport: remote` is not generated yet (spike pending) — only `form` actions are emitted',
80
+ fix: 'use `transport: form` (default) or drop the field'
81
+ });
82
+ }
83
+ }
84
+ if (unit.kind === 'Query') {
85
+ const q = unit.value;
86
+ if (q && typeof q === 'object' && !q.live && !q.groupBy && q.limit === undefined) {
87
+ refusals.push({
88
+ address: unit.address,
89
+ path: `${unit.address}.limit`,
90
+ code: 'UNPAGINATED_QUERY',
91
+ message: 'query has no limit and is neither live nor grouped — unbounded reads are refused',
92
+ fix: 'add `limit` (or mark the query `live` / add `groupBy`)'
93
+ });
94
+ }
95
+ }
96
+ }
97
+ }
98
+ return refusals;
99
+ }
100
+
101
+ /**
102
+ * Validate page `components:` entries against palette props contracts
103
+ * (U-02). Each entry is normalized the way the pages emitter binds it: the
104
+ * first key names the component; its value becomes the `data` prop when it
105
+ * is a Query address and the `action` prop when it is an Action address.
106
+ * Tags without a contract are left alone — they may be custom components.
107
+ *
108
+ * @param {{ modules: Record<string, *> }} specs
109
+ * @param {Record<string, *>} contracts valibot schema per component tag
110
+ * @returns {Refusal[]}
111
+ */
112
+ export function checkBindings(specs, contracts) {
113
+ /** @type {Refusal[]} */
114
+ const refusals = [];
115
+ for (const [moduleName, moduleSpec] of Object.entries(specs.modules)) {
116
+ for (const [pageName, page] of Object.entries(moduleSpec.pages ?? {})) {
117
+ (page.components ?? []).forEach((entry, i) => {
118
+ const keys = Object.keys(entry);
119
+ if (keys.length === 0) return;
120
+ const [first, ...rest] = keys;
121
+ const tag = first[0].toUpperCase() + first.slice(1);
122
+ const contract = contracts[tag];
123
+ if (!contract) return;
124
+
125
+ const primary = entry[first];
126
+ const parsed =
127
+ typeof primary === 'string' && isAddress(primary) ? parseAddress(primary) : null;
128
+ const props = {};
129
+ if (parsed) props[parsed.kind === 'Action' ? 'action' : 'data'] = primary;
130
+ for (const key of rest) props[key] = entry[key];
131
+
132
+ const result = v.safeParse(contract, props);
133
+ if (result.success) return;
134
+ const issue = result.issues[0];
135
+ const at = issue.path?.map((p) => p.key).join('.');
136
+ refusals.push({
137
+ address: `${moduleName}.Page.${pageName}`,
138
+ path: `${moduleName}.Page.${pageName}.components[${i}]${at ? `.${at}` : ''}`,
139
+ code: 'INVALID_BINDING',
140
+ message: `<${tag}> binding rejected${at ? ` at \`${at}\`` : ''}: ${issue.message}`,
141
+ fix: `match the ${tag} props contract exported by @human-synthesis/norns-ui/contracts`
142
+ });
143
+ });
144
+ }
145
+ }
146
+ return refusals;
147
+ }
148
+
149
+ /**
150
+ * Emitters (K-10..K-12). Each: { name, emit(ctx) } where ctx =
151
+ * { moduleName, moduleSpec, specs, graph } and the return value is a list
152
+ * of { path, text } relative to the generated root.
153
+ * @type {{ name: string, emit: (ctx: *) => { path: string, text: string }[] }[]}
154
+ */
155
+ export const EMITTERS = [
156
+ schemaEmitter,
157
+ policiesEmitter,
158
+ machinesEmitter,
159
+ queriesEmitter,
160
+ actionsEmitter,
161
+ triggersEmitter,
162
+ pagesEmitter,
163
+ remotesEmitter
164
+ ];
165
+
166
+ const require = createRequire(import.meta.url);
167
+
168
+ const FILE_KINDS = {
169
+ 'schema.c': 'Entity',
170
+ 'queries.c': 'Query',
171
+ 'actions.c': 'Action',
172
+ 'machines.c': 'Action',
173
+ 'policies.c': 'Policy',
174
+ 'triggers.c': 'Trigger'
175
+ };
176
+
177
+ /** `lib/orders/actions.c` + an error near line N → `orders.Action.<unit>`. */
178
+ function selfCheckAddress(file, message) {
179
+ const lib = file.path.match(/^lib\/([^/]+)\/([^/]+)$/);
180
+ const kind = lib ? FILE_KINDS[lib[2]] : file.path.startsWith('routes/') ? 'Page' : null;
181
+ if (!kind) return file.path;
182
+ const line = Number(message.match(/:(\d+):\d+/)?.[1] ?? NaN);
183
+ const lines = file.text.split('\n');
184
+ for (let i = Math.min(line, lines.length) - 1; i >= 0; i--) {
185
+ const unit = lines[i]?.match(/^export (\w+) :=/)?.[1];
186
+ if (unit && lib) return `${lib[1]}.${kind}.${unit}`;
187
+ }
188
+ return lib ? `${lib[1]}.${kind}.*` : file.path;
189
+ }
190
+
191
+ const scriptOf = (pug) => pug.match(/<script>\n([\s\S]*?)<\/script>/)?.[1] ?? null;
192
+
193
+ /**
194
+ * Self-check (K-14): every emitted `.c` file and `.n` script block must
195
+ * compile through Civet before anything is written — a generator bug can
196
+ * never leave a broken tree behind. (svelte-check across the assembled
197
+ * app runs in the app's own check pipeline, not here.)
198
+ *
199
+ * @param {{ path: string, text: string }[]} files
200
+ * @returns {Refusal[]}
201
+ */
202
+ export function selfCheck(files) {
203
+ const { compile } = require('@danielx/civet');
204
+ const refusals = [];
205
+ for (const file of files) {
206
+ const src = file.path.endsWith('.c') ? file.text : file.path.endsWith('.n') ? scriptOf(file.text) : null;
207
+ if (src === null) continue;
208
+ try {
209
+ compile(src, { sync: true, js: true });
210
+ } catch (e) {
211
+ const message = String(e.message ?? e).split('\n')[0];
212
+ refusals.push({
213
+ address: selfCheckAddress(file, message),
214
+ path: file.path,
215
+ code: 'SELFCHECK_FAILED',
216
+ message: `emitted file does not compile: ${message}`
217
+ });
218
+ }
219
+ }
220
+ return refusals;
221
+ }
222
+
223
+ export class GenerateError extends Error {
224
+ /** @param {Refusal[]} refusals */
225
+ constructor(refusals) {
226
+ const lines = refusals.map(
227
+ (r) => ` [${r.code}] ${r.address}: ${r.message}${r.fix ? `\n fix: ${r.fix}` : ''}`
228
+ );
229
+ super(`norns generate: refused\n${lines.join('\n')}`);
230
+ this.name = 'GenerateError';
231
+ this.refusals = refusals;
232
+ }
233
+ }
234
+
235
+ /**
236
+ * Load palette props contracts from the app's own dependency tree. Apps
237
+ * that don't use norns-ui simply skip binding validation.
238
+ *
239
+ * @param {string} appRoot
240
+ * @returns {Record<string, *> | null}
241
+ */
242
+ function loadContracts(appRoot) {
243
+ try {
244
+ const appRequire = createRequire(join(appRoot, 'package.json'));
245
+ return appRequire('@human-synthesis/norns-ui/contracts').contracts ?? null;
246
+ } catch {
247
+ return null;
248
+ }
249
+ }
250
+
251
+ /** Any `live: true` query anywhere means the app serves `/_norns/live`. */
252
+ function hasLiveQueries(specs) {
253
+ for (const mod of Object.values(specs.modules)) {
254
+ for (const query of Object.values(mod.queries ?? {})) {
255
+ if (query?.live === true) return true;
256
+ }
257
+ }
258
+ return false;
259
+ }
260
+
261
+ /**
262
+ * App-level SSE endpoint streaming live-query refresh signals (R-11).
263
+ *
264
+ * @returns {{ path: string, text: string }}
265
+ */
266
+ export function liveRouteFile() {
267
+ return {
268
+ path: 'routes/_norns/live/+server.c',
269
+ text: [
270
+ '// GENERATED by `norns generate` — do not edit.',
271
+ '',
272
+ `import { liveHandler } from '@human-synthesis/norns/server'`,
273
+ '',
274
+ `export GET := liveHandler`,
275
+ ''
276
+ ].join('\n')
277
+ };
278
+ }
279
+
280
+ /**
281
+ * Root layout for the generated route tree (app-level, like wrangler.json).
282
+ * Plain `.svelte` — no Pug/Civet — so it stays outside the vetted-subset
283
+ * surface. Imports the app's global stylesheet when `src/app.css` exists.
284
+ *
285
+ * @param {boolean} hasAppCss
286
+ * @returns {{ path: string, text: string }}
287
+ */
288
+ export function layoutFile(hasAppCss) {
289
+ return {
290
+ path: 'routes/+layout.svelte',
291
+ text: [
292
+ '<!-- GENERATED by `norns generate` — do not edit. -->',
293
+ '<script>',
294
+ ...(hasAppCss ? ["\timport '$custom/app.css';"] : []),
295
+ '\tlet { children } = $props();',
296
+ '</script>',
297
+ '',
298
+ '{@render children()}',
299
+ ''
300
+ ].join('\n')
301
+ };
302
+ }
303
+
304
+ function readCache(file) {
305
+ try {
306
+ return JSON.parse(readFileSync(file, 'utf-8'));
307
+ } catch {
308
+ return { moduleHashes: {} };
309
+ }
310
+ }
311
+
312
+ /**
313
+ * Generate an app from its specs into `.norns/generated/`.
314
+ *
315
+ * @param {string} [dir] specs directory, defaults to `<cwd>/specs`
316
+ * @param {{ out?: string, force?: boolean, contracts?: Record<string, *> }} [opts]
317
+ * @returns {{ version: string, written: string[], skipped: string[], refusals: [] }}
318
+ */
319
+ export function generateApp(dir, opts = {}) {
320
+ const specs = loadSpecs(dir);
321
+ const appRoot = dirname(specs.dir);
322
+ const outRoot = resolve(opts.out ?? join(appRoot, '.norns', 'generated'));
323
+ const cacheFile = join(appRoot, '.norns', 'cache', 'generate.json');
324
+
325
+ const validation = validateSpecs(specs.dir);
326
+ if (!validation.ok) {
327
+ throw new GenerateError(
328
+ validation.issues
329
+ .filter((i) => i.level === 'error')
330
+ .map((i) => ({ address: i.address, code: 'INVALID_SPEC', message: i.message }))
331
+ );
332
+ }
333
+ const contracts = opts.contracts ?? loadContracts(appRoot);
334
+ const refusals = checkGenerate(specs, contracts ? { contracts } : {});
335
+ if (refusals.length > 0) throw new GenerateError(refusals);
336
+
337
+ const cache = opts.force ? { moduleHashes: {} } : readCache(cacheFile);
338
+ const graph = buildGraph(specs.modules);
339
+ const written = [];
340
+ const skipped = [];
341
+ const pending = [];
342
+
343
+ for (const [moduleName, moduleSpec] of Object.entries(specs.modules)) {
344
+ if (cache.moduleHashes[moduleName] === specs.hashes[moduleName]) {
345
+ skipped.push(moduleName);
346
+ continue;
347
+ }
348
+ for (const emitter of EMITTERS) {
349
+ pending.push(...emitter.emit({ moduleName, moduleSpec, specs, graph }));
350
+ }
351
+ cache.moduleHashes[moduleName] = specs.hashes[moduleName];
352
+ }
353
+
354
+ // App-level: the wrangler config derives from all modules, so refresh it
355
+ // whenever anything re-emitted (or it's missing entirely).
356
+ if (pending.length > 0 || !existsSync(join(outRoot, 'wrangler.json'))) {
357
+ pending.push(wranglerFile(specs));
358
+ }
359
+
360
+ // App-level: the live SSE route exists iff any query is live.
361
+ if (
362
+ hasLiveQueries(specs) &&
363
+ (pending.length > 0 || !existsSync(join(outRoot, 'routes', '_norns', 'live', '+server.c')))
364
+ ) {
365
+ pending.push(liveRouteFile());
366
+ }
367
+
368
+ // App-level: a root layout so generated routes render inside a shell.
369
+ if (pending.length > 0 || !existsSync(join(outRoot, 'routes', '+layout.svelte'))) {
370
+ pending.push(layoutFile(existsSync(join(appRoot, 'src', 'app.css'))));
371
+ }
372
+
373
+ const failures = selfCheck(pending);
374
+ if (failures.length > 0) throw new GenerateError(failures);
375
+
376
+ for (const file of pending) {
377
+ const full = join(outRoot, file.path);
378
+ mkdirSync(dirname(full), { recursive: true });
379
+ writeFileSync(full, file.text, 'utf-8');
380
+ written.push(file.path);
381
+ }
382
+ for (const name of Object.keys(cache.moduleHashes)) {
383
+ if (!(name in specs.modules)) delete cache.moduleHashes[name];
384
+ }
385
+
386
+ const manifest = {
387
+ version: specs.version,
388
+ modules: specs.hashes,
389
+ generatedAt: new Date().toISOString()
390
+ };
391
+ mkdirSync(outRoot, { recursive: true });
392
+ writeFileSync(join(outRoot, 'manifest.json'), JSON.stringify(manifest, null, '\t') + '\n');
393
+ mkdirSync(dirname(cacheFile), { recursive: true });
394
+ writeFileSync(cacheFile, JSON.stringify(cache, null, '\t') + '\n');
395
+
396
+ return { version: specs.version, written, skipped, refusals: [] };
397
+ }
@@ -0,0 +1,222 @@
1
+ /**
2
+ * Spec edge graph (K-07) — derived, never stored (PLAN §4.3).
3
+ *
4
+ * Nodes are unit addresses plus `event:<name>` nodes for the emit/on
5
+ * namespace. Edges are derived per module, so incremental invalidation is
6
+ * a per-module cache keyed by the module's spec hash. Code edges (custom
7
+ * body imports via civet-bridge) are appended by the MCP indexer later —
8
+ * this module covers the spec-derived edges.
9
+ *
10
+ * Edge types: reads · writes · binds · guards · emits · on · calls · refreshes
11
+ */
12
+
13
+ import { formatAddress, isAddress, listUnits } from './address.js';
14
+
15
+ /** @typedef {{ from: string, to: string, type: string }} Edge */
16
+
17
+ const eventNode = (name) => `event:${name}`;
18
+
19
+ /** Resolve a written ref to a full address: bare names bind in-module. */
20
+ function refAddress(moduleName, kind, ref) {
21
+ if (typeof ref !== 'string' || ref === '') return null;
22
+ if (isAddress(ref)) return ref;
23
+ return formatAddress({ module: moduleName, kind, name: ref.split('.')[0] });
24
+ }
25
+
26
+ /**
27
+ * Derive all edges that originate from one module's units.
28
+ *
29
+ * @param {string} moduleName
30
+ * @param {*} moduleSpec
31
+ * @returns {Edge[]}
32
+ */
33
+ export function moduleEdges(moduleName, moduleSpec) {
34
+ /** @type {Edge[]} */
35
+ const edges = [];
36
+ const add = (from, to, type) => {
37
+ if (to) edges.push({ from, to, type });
38
+ };
39
+
40
+ for (const unit of listUnits(moduleName, moduleSpec)) {
41
+ const { address: at, value, kind, name } = unit;
42
+ switch (kind) {
43
+ case 'Query':
44
+ add(at, refAddress(moduleName, 'Entity', value?.from), 'reads');
45
+ break;
46
+ case 'Action': {
47
+ for (const step of Array.isArray(value?.steps) ? value.steps : []) {
48
+ if (step === null || typeof step !== 'object') continue;
49
+ for (const [stepKind, stepValue] of Object.entries(step)) {
50
+ if (stepKind === 'emit' && typeof stepValue === 'string') {
51
+ add(at, eventNode(stepValue), 'emits');
52
+ } else if (typeof stepValue?.entity === 'string') {
53
+ add(at, refAddress(moduleName, 'Entity', stepValue.entity), 'writes');
54
+ } else if (stepKind === 'call' && typeof stepValue === 'string') {
55
+ add(at, refAddress(moduleName, 'Function', stepValue), 'calls');
56
+ }
57
+ }
58
+ }
59
+ for (const ev of Array.isArray(value?.emits) ? value.emits : []) {
60
+ if (typeof ev === 'string') add(at, eventNode(ev), 'emits');
61
+ }
62
+ for (const q of Array.isArray(value?.refresh) ? value.refresh : []) {
63
+ add(at, refAddress(moduleName, 'Query', q), 'refreshes');
64
+ }
65
+ break;
66
+ }
67
+ case 'Page': {
68
+ for (const comp of Array.isArray(value?.components) ? value.components : []) {
69
+ if (comp === null || typeof comp !== 'object') continue;
70
+ for (const bound of Object.values(comp)) {
71
+ if (typeof bound === 'string' && isAddress(bound)) add(at, bound, 'binds');
72
+ }
73
+ }
74
+ break;
75
+ }
76
+ case 'Policy': {
77
+ add(at, formatAddress({ module: moduleName, kind: 'Entity', name }), 'guards');
78
+ for (const actionName of Object.keys(value?.run ?? {})) {
79
+ add(at, refAddress(moduleName, 'Action', actionName), 'guards');
80
+ }
81
+ break;
82
+ }
83
+ case 'Trigger': {
84
+ add(eventNode(name), at, 'on');
85
+ const action = typeof value === 'string' ? value : value?.action;
86
+ add(at, refAddress(moduleName, 'Action', action), 'calls');
87
+ break;
88
+ }
89
+ case 'Component': {
90
+ for (const target of Object.values(value?.events ?? {})) {
91
+ add(at, refAddress(moduleName, 'Action', target), 'calls');
92
+ }
93
+ break;
94
+ }
95
+ }
96
+ }
97
+ return edges;
98
+ }
99
+
100
+ /**
101
+ * @typedef {{
102
+ * edges: Edge[],
103
+ * outbound: Map<string, Edge[]>,
104
+ * inbound: Map<string, Edge[]>
105
+ * }} Graph
106
+ */
107
+
108
+ /** @param {Edge[]} edges @returns {Graph} */
109
+ function assemble(edges) {
110
+ const outbound = new Map();
111
+ const inbound = new Map();
112
+ for (const edge of edges) {
113
+ if (!outbound.has(edge.from)) outbound.set(edge.from, []);
114
+ outbound.get(edge.from).push(edge);
115
+ if (!inbound.has(edge.to)) inbound.set(edge.to, []);
116
+ inbound.get(edge.to).push(edge);
117
+ }
118
+ return { edges, outbound, inbound };
119
+ }
120
+
121
+ /**
122
+ * Build the full graph from scratch.
123
+ *
124
+ * @param {Record<string, *>} modules
125
+ * @returns {Graph}
126
+ */
127
+ export function buildGraph(modules) {
128
+ const edges = [];
129
+ for (const [name, spec] of Object.entries(modules)) edges.push(...moduleEdges(name, spec));
130
+ return assemble(edges);
131
+ }
132
+
133
+ /** @returns {{ hashes: Record<string, string>, edges: Record<string, Edge[]> }} */
134
+ export function createGraphCache() {
135
+ return { hashes: {}, edges: {} };
136
+ }
137
+
138
+ /**
139
+ * Incrementally update the graph: only modules whose hash changed are
140
+ * re-derived; removed modules drop out. Mutates and returns the cache.
141
+ *
142
+ * @param {{ hashes: Record<string, string>, edges: Record<string, Edge[]> }} cache
143
+ * @param {Record<string, *>} modules
144
+ * @param {Record<string, string>} hashes per-module spec hashes (e.g. from readSpecs)
145
+ * @returns {{ graph: Graph, changed: string[] }}
146
+ */
147
+ export function updateGraph(cache, modules, hashes) {
148
+ const changed = [];
149
+ for (const name of Object.keys(cache.edges)) {
150
+ if (!(name in modules)) {
151
+ delete cache.edges[name];
152
+ delete cache.hashes[name];
153
+ changed.push(name);
154
+ }
155
+ }
156
+ for (const [name, spec] of Object.entries(modules)) {
157
+ if (cache.hashes[name] !== hashes[name]) {
158
+ cache.edges[name] = moduleEdges(name, spec);
159
+ cache.hashes[name] = hashes[name];
160
+ changed.push(name);
161
+ }
162
+ }
163
+ return { graph: assemble(Object.values(cache.edges).flat()), changed };
164
+ }
165
+
166
+ /**
167
+ * Addresses reachable within `depth` hops of `address`, in either
168
+ * direction — the unit's working context.
169
+ *
170
+ * @param {Graph} graph
171
+ * @param {string} address
172
+ * @param {number} [depth]
173
+ * @returns {Set<string>}
174
+ */
175
+ export function neighborhood(graph, address, depth = 1) {
176
+ const seen = new Set([address]);
177
+ let frontier = [address];
178
+ for (let d = 0; d < depth && frontier.length > 0; d++) {
179
+ const next = [];
180
+ for (const node of frontier) {
181
+ for (const edge of graph.outbound.get(node) ?? []) {
182
+ if (!seen.has(edge.to)) {
183
+ seen.add(edge.to);
184
+ next.push(edge.to);
185
+ }
186
+ }
187
+ for (const edge of graph.inbound.get(node) ?? []) {
188
+ if (!seen.has(edge.from)) {
189
+ seen.add(edge.from);
190
+ next.push(edge.from);
191
+ }
192
+ }
193
+ }
194
+ frontier = next;
195
+ }
196
+ return seen;
197
+ }
198
+
199
+ /**
200
+ * Everything that (transitively) depends on `address` — the units to
201
+ * re-check or regenerate when it changes. Follows inbound edges only;
202
+ * excludes the address itself.
203
+ *
204
+ * @param {Graph} graph
205
+ * @param {string} address
206
+ * @returns {Set<string>}
207
+ */
208
+ export function impact(graph, address) {
209
+ const seen = new Set();
210
+ const stack = [address];
211
+ while (stack.length > 0) {
212
+ const node = stack.pop();
213
+ for (const edge of graph.inbound.get(node) ?? []) {
214
+ if (!seen.has(edge.from)) {
215
+ seen.add(edge.from);
216
+ stack.push(edge.from);
217
+ }
218
+ }
219
+ }
220
+ seen.delete(address);
221
+ return seen;
222
+ }
@@ -0,0 +1,71 @@
1
+ /**
2
+ * Norns kernel — the spec-canonical engine (`@human-synthesis/norns/kernel`).
3
+ *
4
+ * Owns the pipeline: load `specs/*.tron` → validate → generate code into
5
+ * `.norns/generated/`. Kept as a subpath of the norns package (not its own
6
+ * package) but with no imports from the rest of `src/`, so it stays
7
+ * extractable if it ever needs its own release cadence.
8
+ */
9
+
10
+ export {
11
+ KINDS,
12
+ KIND_KEYS,
13
+ KEY_KINDS,
14
+ formatAddress,
15
+ parseAddress,
16
+ isAddress,
17
+ newUid,
18
+ listUnits,
19
+ ensureUids,
20
+ indexUnits,
21
+ resolvePath
22
+ } from './address.js';
23
+ export { CMP_OPS, parseExpr, printExpr, isExpr } from './expr.js';
24
+ export { evalExpr, compileGuard, compileWhere } from './expr-compile.js';
25
+ export {
26
+ FIELD_TYPES,
27
+ DIALECTS,
28
+ UNIT_SCHEMAS,
29
+ MODULE_SCHEMA,
30
+ APP_SCHEMA,
31
+ schemaIssues
32
+ } from './meta.js';
33
+ export {
34
+ moduleEdges,
35
+ buildGraph,
36
+ createGraphCache,
37
+ updateGraph,
38
+ neighborhood,
39
+ impact
40
+ } from './graph.js';
41
+ export { refineSpecs } from './refine.js';
42
+ export {
43
+ CUSTOM_RATIO_THRESHOLD,
44
+ customUnits,
45
+ customRatio,
46
+ customBodyPath,
47
+ absorbUnit,
48
+ absorbApp
49
+ } from './absorb.js';
50
+ export { inferKind, inferCapabilities, inferAuth, adoptUnit, adoptFiles } from './adopt.js';
51
+ export { loadSpecs, validateSpecs } from './validate.js';
52
+ export { generateApp, checkGenerate, checkBindings, layoutFile, liveRouteFile, selfCheck, GenerateError, EMITTERS } from './generate.js';
53
+ export { wranglerConfig, wranglerFile } from './emit-wrangler.js';
54
+ export { emitModuleMachines, machinesEmitter } from './emit-machines.js';
55
+ export { migrateApp } from './migrate.js';
56
+ export { traceApp, TRACE_USER } from './trace.js';
57
+ export { emitModuleSchema, schemaEmitter } from './emit-schema.js';
58
+ export {
59
+ emitModulePolicies,
60
+ emitModuleQueries,
61
+ emitModuleActions,
62
+ emitModuleTriggers,
63
+ emitModulePages,
64
+ emitModuleRemotes,
65
+ policiesEmitter,
66
+ queriesEmitter,
67
+ actionsEmitter,
68
+ triggersEmitter,
69
+ pagesEmitter,
70
+ remotesEmitter
71
+ } from './emit-units.js';