@human-synthesis/norns 0.1.0 → 0.2.1
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/package.json +1 -1
- package/src/kernel/address.js +4 -0
- package/src/kernel/emit-units.js +283 -7
- package/src/kernel/emit-wrangler.js +22 -2
- package/src/kernel/expr-compile.js +4 -1
- package/src/kernel/flow.js +444 -0
- package/src/kernel/generate.js +500 -23
- package/src/kernel/index.js +9 -2
- package/src/kernel/meta.js +145 -1
- package/src/kernel/refine.js +78 -0
- package/src/kernel/trace.js +215 -27
- package/src/live-client.js +144 -0
- package/src/server/boot.js +22 -0
- package/src/server/db.js +5 -1
- package/src/server/endpoint.js +142 -0
- package/src/server/index.js +3 -0
- package/src/server/job.js +102 -0
- package/src/server/room.js +17 -0
- package/src/server/service.js +188 -0
|
@@ -0,0 +1,444 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Flow-graph emitter (K-24, D17) — generated metadata, not analysis on
|
|
3
|
+
* demand. Each runnable unit gets a pipeline tree from input to output:
|
|
4
|
+
* stages derived from spec are exact (`src: 'spec' | 'generated'`); call
|
|
5
|
+
* edges inside custom bodies come from a line-level indexer and carry a
|
|
6
|
+
* `confidence` tag (`static` for address literals, `heuristic` for dynamic
|
|
7
|
+
* dispatch) — the graph never pretends.
|
|
8
|
+
*
|
|
9
|
+
* Persisted per module under `.norns/cache/flow/<module>.json`, keyed by
|
|
10
|
+
* spec hash + body-file hashes so unchanged modules are not re-derived.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { createHash } from 'node:crypto';
|
|
14
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
15
|
+
import { dirname, join } from 'node:path';
|
|
16
|
+
|
|
17
|
+
import { listUnits } from './address.js';
|
|
18
|
+
import { buildGraph } from './graph.js';
|
|
19
|
+
import { loadSpecs } from './validate.js';
|
|
20
|
+
|
|
21
|
+
const ADDRESS_LITERAL_RE =
|
|
22
|
+
/(['"`])([a-z][A-Za-z0-9_]*\.(?:Entity|Query|Action|Policy|Page|Trigger|Component|Machine|Remote|Service|Job|Endpoint|Function|Worker|Route)\.[A-Za-z0-9_]+(?:\.[A-Za-z0-9_]+)?)\1/g;
|
|
23
|
+
|
|
24
|
+
const SYMBOL_RES = [
|
|
25
|
+
/^\s*(?:export\s+)?(?:default\s+)?(?:async\s+)?function\s*\*?\s*([A-Za-z_$][\w$]*)/,
|
|
26
|
+
/^(?:export\s+)?([A-Za-z_$][\w$]*)\s*:?=\s*(?:async\b|\(|function\b)/,
|
|
27
|
+
/^\s*(?:async\s+)?\*?\s*([A-Za-z_$][\w$]*)\s*\([^)]*\)\s*(?:{|=>|$)/
|
|
28
|
+
];
|
|
29
|
+
|
|
30
|
+
/** Nearest enclosing symbol name for a 0-based line, scanning upward. */
|
|
31
|
+
function symbolAt(lines, index) {
|
|
32
|
+
for (let i = index; i >= 0; i--) {
|
|
33
|
+
if (/^\s*export\s+default\b/.test(lines[i]) && !/class/.test(lines[i])) return 'default';
|
|
34
|
+
for (const re of SYMBOL_RES) {
|
|
35
|
+
const name = lines[i].match(re)?.[1];
|
|
36
|
+
if (name && !['if', 'for', 'while', 'switch', 'catch', 'return'].includes(name)) {
|
|
37
|
+
return name;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return 'default';
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Line-level call indexer for a custom body. Address literals are static
|
|
46
|
+
* edges; `resolve(` with a non-literal argument is a heuristic edge —
|
|
47
|
+
* dynamic dispatch the indexer cannot pin down.
|
|
48
|
+
*
|
|
49
|
+
* @param {string} text
|
|
50
|
+
* @returns {{ to: string, at: string, line: number, confidence: 'static' | 'heuristic' }[]}
|
|
51
|
+
*/
|
|
52
|
+
export function indexBody(text) {
|
|
53
|
+
const calls = [];
|
|
54
|
+
const lines = text.split('\n');
|
|
55
|
+
lines.forEach((line, i) => {
|
|
56
|
+
for (const match of line.matchAll(ADDRESS_LITERAL_RE)) {
|
|
57
|
+
calls.push({
|
|
58
|
+
to: match[2],
|
|
59
|
+
at: `#${symbolAt(lines, i)}`,
|
|
60
|
+
line: i + 1,
|
|
61
|
+
confidence: 'static'
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
if (/\bresolve\s*\((?!\s*['"`])/.test(line)) {
|
|
65
|
+
calls.push({
|
|
66
|
+
to: '(dynamic)',
|
|
67
|
+
at: `#${symbolAt(lines, i)}`,
|
|
68
|
+
line: i + 1,
|
|
69
|
+
confidence: 'heuristic'
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
});
|
|
73
|
+
return calls;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const sha = (text) => createHash('sha256').update(text).digest('hex').slice(0, 16);
|
|
77
|
+
|
|
78
|
+
function bodyStage(rel, appRoot, bodies) {
|
|
79
|
+
const stage = { kind: 'body', src: rel };
|
|
80
|
+
const file = appRoot ? join(appRoot, rel) : null;
|
|
81
|
+
if (file && existsSync(file)) {
|
|
82
|
+
const text = readFileSync(file, 'utf-8');
|
|
83
|
+
if (bodies) bodies[rel] = sha(text);
|
|
84
|
+
const calls = indexBody(text);
|
|
85
|
+
if (calls.length > 0) stage.calls = calls;
|
|
86
|
+
stage.confidence = calls.some((c) => c.confidence === 'heuristic') ? 'heuristic' : 'static';
|
|
87
|
+
} else {
|
|
88
|
+
stage.confidence = 'static';
|
|
89
|
+
if (bodies && rel) bodies[rel] = null;
|
|
90
|
+
}
|
|
91
|
+
return stage;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function describeStep(step) {
|
|
95
|
+
if (step === null || typeof step !== 'object') return String(step);
|
|
96
|
+
if (typeof step.call === 'string') return `call ${step.call}`;
|
|
97
|
+
if (typeof step.enqueue === 'string') return `enqueue ${step.enqueue}`;
|
|
98
|
+
if (typeof step.emit === 'string') return `emit ${step.emit}`;
|
|
99
|
+
if (step.set && typeof step.set === 'object') {
|
|
100
|
+
const { entity, ...fields } = step.set;
|
|
101
|
+
return `set ${entity ?? ''}.${Object.keys(fields).join(',')}`;
|
|
102
|
+
}
|
|
103
|
+
if (step.create && typeof step.create === 'object') return `create ${step.create.entity ?? ''}`;
|
|
104
|
+
return Object.keys(step).join('+');
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function stepStages(steps) {
|
|
108
|
+
const entries = (Array.isArray(steps) ? steps : []).map(describeStep);
|
|
109
|
+
if (entries.length === 0) return [];
|
|
110
|
+
const stages = [{ kind: 'steps', entries, src: 'spec' }];
|
|
111
|
+
const events = entries.filter((e) => e.startsWith('emit ')).map((e) => e.slice(5));
|
|
112
|
+
if (events.length > 0) stages.push({ kind: 'emit', events, src: 'spec' });
|
|
113
|
+
return stages;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function guardsOf(graph, address) {
|
|
117
|
+
const direct = (graph.inbound.get(address) ?? [])
|
|
118
|
+
.filter((e) => e.type === 'guards')
|
|
119
|
+
.map((e) => e.from);
|
|
120
|
+
const writes = (graph.outbound.get(address) ?? []).filter((e) => e.type === 'writes');
|
|
121
|
+
const viaEntities = writes.flatMap((edge) =>
|
|
122
|
+
(graph.inbound.get(edge.to) ?? []).filter((e) => e.type === 'guards').map((e) => e.from)
|
|
123
|
+
);
|
|
124
|
+
return [...new Set([...direct, ...viaEntities])];
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Pipeline tree for one unit, or null for kinds without runtime flow.
|
|
129
|
+
*
|
|
130
|
+
* @returns {{ unit: string, stages: * } | null}
|
|
131
|
+
*/
|
|
132
|
+
export function unitFlow(unit, { moduleName, graph, appRoot, bodies } = {}) {
|
|
133
|
+
const { address, kind, name, value } = unit;
|
|
134
|
+
const v = value && typeof value === 'object' ? value : {};
|
|
135
|
+
const stages = [];
|
|
136
|
+
switch (kind) {
|
|
137
|
+
case 'Endpoint': {
|
|
138
|
+
stages.push({ kind: 'transport', route: v.route, method: v.method ?? 'POST', src: 'spec' });
|
|
139
|
+
stages.push({ kind: 'auth', mode: v.auth?.mode ?? v.auth, src: 'generated' });
|
|
140
|
+
if (v.input) stages.push({ kind: 'validate', schema: 'input', src: 'generated' });
|
|
141
|
+
stages.push(bodyStage(`src/${moduleName}/endpoints/${name}.c`, appRoot, bodies));
|
|
142
|
+
if (v.stream) {
|
|
143
|
+
stages.push({ kind: 'stream', frame: Object.keys(v.stream.frame ?? {}), src: 'generated' });
|
|
144
|
+
} else {
|
|
145
|
+
stages.push({ kind: 'respond', schema: v.output ? 'output' : null, src: 'generated' });
|
|
146
|
+
}
|
|
147
|
+
break;
|
|
148
|
+
}
|
|
149
|
+
case 'Action': {
|
|
150
|
+
stages.push({ kind: 'transport', mode: v.transport ?? 'form', src: 'spec' });
|
|
151
|
+
const guards = guardsOf(graph, address);
|
|
152
|
+
if (guards.length > 0) stages.push({ kind: 'policy', guards, src: 'generated' });
|
|
153
|
+
if (v.input) stages.push({ kind: 'validate', schema: 'input', src: 'generated' });
|
|
154
|
+
if (v.requires) stages.push({ kind: 'machine', requires: v.requires, src: 'spec' });
|
|
155
|
+
if (v.impl === 'custom') {
|
|
156
|
+
stages.push(bodyStage(`src/${moduleName}/actions/${name}.c`, appRoot, bodies));
|
|
157
|
+
}
|
|
158
|
+
stages.push(...stepStages(v.steps));
|
|
159
|
+
if (Array.isArray(v.emits) && v.emits.length > 0) {
|
|
160
|
+
stages.push({ kind: 'emit', events: v.emits, src: 'spec' });
|
|
161
|
+
}
|
|
162
|
+
if (Array.isArray(v.refresh) && v.refresh.length > 0) {
|
|
163
|
+
stages.push({ kind: 'refresh', queries: v.refresh, src: 'spec' });
|
|
164
|
+
}
|
|
165
|
+
stages.push({ kind: 'respond', src: 'generated' });
|
|
166
|
+
break;
|
|
167
|
+
}
|
|
168
|
+
case 'Job': {
|
|
169
|
+
const consume = { kind: 'consume', src: 'spec' };
|
|
170
|
+
if (v.retry) consume.retry = v.retry;
|
|
171
|
+
if (v.dlq) consume.dlq = v.dlq;
|
|
172
|
+
if (v.concurrency !== undefined) consume.concurrency = v.concurrency;
|
|
173
|
+
stages.push(consume);
|
|
174
|
+
if (v.input) stages.push({ kind: 'validate', schema: 'input', src: 'generated' });
|
|
175
|
+
if (v.impl === 'custom') {
|
|
176
|
+
stages.push(bodyStage(`src/${moduleName}/jobs/${name}.c`, appRoot, bodies));
|
|
177
|
+
}
|
|
178
|
+
stages.push(...stepStages(v.steps));
|
|
179
|
+
stages.push({ kind: 'ack', src: 'generated' });
|
|
180
|
+
break;
|
|
181
|
+
}
|
|
182
|
+
case 'Function': {
|
|
183
|
+
if (v.input) stages.push({ kind: 'validate', schema: 'input', src: 'generated' });
|
|
184
|
+
stages.push(bodyStage(`src/${moduleName}/functions/${name}.c`, appRoot, bodies));
|
|
185
|
+
stages.push({ kind: 'respond', src: 'generated' });
|
|
186
|
+
break;
|
|
187
|
+
}
|
|
188
|
+
case 'Worker': {
|
|
189
|
+
if (v.room !== true) return null;
|
|
190
|
+
stages.push({ kind: 'transport', proto: 'ws', src: 'generated' });
|
|
191
|
+
stages.push({ kind: 'auth', mode: v.auth?.mode ?? v.auth, src: 'spec' });
|
|
192
|
+
const messages = v.messages ?? {};
|
|
193
|
+
const inbound = Object.keys(messages);
|
|
194
|
+
const outbound = Object.entries(messages)
|
|
195
|
+
.filter(([, m]) => m && typeof m === 'object' && m.out)
|
|
196
|
+
.map(([n]) => n);
|
|
197
|
+
if (inbound.length > 0) stages.push({ kind: 'messages', in: inbound, src: 'spec' });
|
|
198
|
+
if (typeof v.source === 'string') {
|
|
199
|
+
stages.push(bodyStage(v.source.replace(/^\.\//, ''), appRoot, bodies));
|
|
200
|
+
}
|
|
201
|
+
if (outbound.length > 0) stages.push({ kind: 'broadcast', out: outbound, src: 'spec' });
|
|
202
|
+
break;
|
|
203
|
+
}
|
|
204
|
+
case 'Query': {
|
|
205
|
+
const read = { kind: 'read', from: v.from, src: 'spec' };
|
|
206
|
+
if (v.where) read.where = v.where;
|
|
207
|
+
if (v.limit !== undefined) read.limit = v.limit;
|
|
208
|
+
if (v.groupBy) read.groupBy = v.groupBy;
|
|
209
|
+
stages.push(read);
|
|
210
|
+
stages.push({ kind: 'respond', live: v.live === true, src: 'generated' });
|
|
211
|
+
break;
|
|
212
|
+
}
|
|
213
|
+
case 'Trigger': {
|
|
214
|
+
stages.push({ kind: 'on', event: name, src: 'spec' });
|
|
215
|
+
const action = typeof value === 'string' ? value : v.action;
|
|
216
|
+
if (action) stages.push({ kind: 'call', to: action, src: 'spec' });
|
|
217
|
+
break;
|
|
218
|
+
}
|
|
219
|
+
default:
|
|
220
|
+
return null;
|
|
221
|
+
}
|
|
222
|
+
return { unit: address, stages };
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Flow nodes for every runnable unit in one module.
|
|
227
|
+
*
|
|
228
|
+
* @returns {{ units: Record<string, *>, bodies: Record<string, string | null> }}
|
|
229
|
+
*/
|
|
230
|
+
export function buildModuleFlow(moduleName, moduleSpec, { graph, appRoot } = {}) {
|
|
231
|
+
const units = {};
|
|
232
|
+
const bodies = {};
|
|
233
|
+
for (const unit of listUnits(moduleName, moduleSpec)) {
|
|
234
|
+
const node = unitFlow(unit, { moduleName, graph, appRoot, bodies });
|
|
235
|
+
if (node) units[node.unit] = node;
|
|
236
|
+
}
|
|
237
|
+
return { units, bodies };
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const flowFile = (appRoot, moduleName) =>
|
|
241
|
+
join(appRoot, '.norns', 'cache', 'flow', `${moduleName}.json`);
|
|
242
|
+
|
|
243
|
+
function readFlowFile(file) {
|
|
244
|
+
try {
|
|
245
|
+
return JSON.parse(readFileSync(file, 'utf-8'));
|
|
246
|
+
} catch {
|
|
247
|
+
return null;
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/** A cached module entry is fresh when spec hash and every body hash still match. */
|
|
252
|
+
function isFresh(entry, specHash, appRoot) {
|
|
253
|
+
if (!entry || entry.specHash !== specHash) return false;
|
|
254
|
+
for (const [rel, recorded] of Object.entries(entry.bodies ?? {})) {
|
|
255
|
+
const file = join(appRoot, rel);
|
|
256
|
+
const current = existsSync(file) ? sha(readFileSync(file, 'utf-8')) : null;
|
|
257
|
+
if (current !== recorded) return false;
|
|
258
|
+
}
|
|
259
|
+
return true;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Emit `.norns/cache/flow/<module>.json` for every module — incremental:
|
|
264
|
+
* modules whose spec hash and body hashes are unchanged are skipped.
|
|
265
|
+
*
|
|
266
|
+
* @param {{ dir: string, modules: Record<string, *>, hashes: Record<string, string>, version: string }} specs
|
|
267
|
+
* @param {{ force?: boolean }} [opts]
|
|
268
|
+
* @returns {{ written: string[], skipped: string[] }}
|
|
269
|
+
*/
|
|
270
|
+
export function emitFlow(specs, opts = {}) {
|
|
271
|
+
const appRoot = dirname(specs.dir);
|
|
272
|
+
const graph = buildGraph(specs.modules);
|
|
273
|
+
const written = [];
|
|
274
|
+
const skipped = [];
|
|
275
|
+
for (const [moduleName, moduleSpec] of Object.entries(specs.modules)) {
|
|
276
|
+
const file = flowFile(appRoot, moduleName);
|
|
277
|
+
const specHash = specs.hashes?.[moduleName];
|
|
278
|
+
if (!opts.force && isFresh(readFlowFile(file), specHash, appRoot)) {
|
|
279
|
+
skipped.push(moduleName);
|
|
280
|
+
continue;
|
|
281
|
+
}
|
|
282
|
+
const { units, bodies } = buildModuleFlow(moduleName, moduleSpec, { graph, appRoot });
|
|
283
|
+
const entry = { module: moduleName, specHash, bodies, units };
|
|
284
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
285
|
+
writeFileSync(file, JSON.stringify(entry, null, '\t') + '\n');
|
|
286
|
+
written.push(moduleName);
|
|
287
|
+
}
|
|
288
|
+
return { written, skipped };
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function listOf(value) {
|
|
292
|
+
return Array.isArray(value) ? value : [];
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function addedRemoved(before, after) {
|
|
296
|
+
const b = new Set(before);
|
|
297
|
+
const a = new Set(after);
|
|
298
|
+
return {
|
|
299
|
+
added: [...a].filter((x) => !b.has(x)),
|
|
300
|
+
removed: [...b].filter((x) => !a.has(x))
|
|
301
|
+
};
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/** Detail-level deltas for one stage kind present on both sides. */
|
|
305
|
+
function stageDelta(kind, b, a) {
|
|
306
|
+
const out = [];
|
|
307
|
+
switch (kind) {
|
|
308
|
+
case 'auth':
|
|
309
|
+
if (b.mode !== a.mode) out.push(`auth ${b.mode ?? 'none'} → ${a.mode ?? 'none'}`);
|
|
310
|
+
break;
|
|
311
|
+
case 'transport':
|
|
312
|
+
if (b.route !== a.route) out.push(`route ${b.route} → ${a.route}`);
|
|
313
|
+
if (b.method !== a.method) out.push(`method ${b.method} → ${a.method}`);
|
|
314
|
+
if (b.mode !== a.mode) out.push(`transport ${b.mode} → ${a.mode}`);
|
|
315
|
+
break;
|
|
316
|
+
case 'policy': {
|
|
317
|
+
const { added, removed } = addedRemoved(listOf(b.guards), listOf(a.guards));
|
|
318
|
+
out.push(...added.map((g) => `+guard ${g}`), ...removed.map((g) => `-guard ${g}`));
|
|
319
|
+
break;
|
|
320
|
+
}
|
|
321
|
+
case 'machine':
|
|
322
|
+
if (b.requires !== a.requires) out.push(`requires \`${b.requires}\` → \`${a.requires}\``);
|
|
323
|
+
break;
|
|
324
|
+
case 'steps': {
|
|
325
|
+
const { added, removed } = addedRemoved(listOf(b.entries), listOf(a.entries));
|
|
326
|
+
out.push(...added.map((e) => `steps +${e}`), ...removed.map((e) => `steps -${e}`));
|
|
327
|
+
break;
|
|
328
|
+
}
|
|
329
|
+
case 'emit': {
|
|
330
|
+
const { added, removed } = addedRemoved(listOf(b.events), listOf(a.events));
|
|
331
|
+
out.push(
|
|
332
|
+
...added.map((e) => `now emits ${e}`),
|
|
333
|
+
...removed.map((e) => `no longer emits ${e}`)
|
|
334
|
+
);
|
|
335
|
+
break;
|
|
336
|
+
}
|
|
337
|
+
case 'refresh': {
|
|
338
|
+
const { added, removed } = addedRemoved(listOf(b.queries), listOf(a.queries));
|
|
339
|
+
out.push(
|
|
340
|
+
...added.map((q) => `refresh now touches ${q}`),
|
|
341
|
+
...removed.map((q) => `refresh no longer touches ${q}`)
|
|
342
|
+
);
|
|
343
|
+
break;
|
|
344
|
+
}
|
|
345
|
+
case 'body': {
|
|
346
|
+
const callsOf = (s) => listOf(s.calls).map((c) => c.to);
|
|
347
|
+
const { added, removed } = addedRemoved(callsOf(b), callsOf(a));
|
|
348
|
+
out.push(
|
|
349
|
+
...added.map((c) => `body now calls ${c}`),
|
|
350
|
+
...removed.map((c) => `body no longer calls ${c}`)
|
|
351
|
+
);
|
|
352
|
+
break;
|
|
353
|
+
}
|
|
354
|
+
case 'stream': {
|
|
355
|
+
const { added, removed } = addedRemoved(listOf(b.frame), listOf(a.frame));
|
|
356
|
+
out.push(
|
|
357
|
+
...added.map((f) => `stream frame +${f}`),
|
|
358
|
+
...removed.map((f) => `stream frame -${f}`)
|
|
359
|
+
);
|
|
360
|
+
break;
|
|
361
|
+
}
|
|
362
|
+
case 'respond':
|
|
363
|
+
if ((b.schema ?? null) !== (a.schema ?? null)) {
|
|
364
|
+
out.push(`respond schema ${b.schema ?? 'none'} → ${a.schema ?? 'none'}`);
|
|
365
|
+
}
|
|
366
|
+
break;
|
|
367
|
+
case 'consume':
|
|
368
|
+
if (JSON.stringify(b.retry) !== JSON.stringify(a.retry)) {
|
|
369
|
+
out.push(`retry ${JSON.stringify(b.retry)} → ${JSON.stringify(a.retry)}`);
|
|
370
|
+
}
|
|
371
|
+
if ((b.dlq ?? null) !== (a.dlq ?? null)) out.push(`dlq ${b.dlq ?? 'none'} → ${a.dlq ?? 'none'}`);
|
|
372
|
+
break;
|
|
373
|
+
default:
|
|
374
|
+
if (JSON.stringify(b) !== JSON.stringify(a)) out.push(`${kind} stage changed`);
|
|
375
|
+
}
|
|
376
|
+
return out;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
function unitDelta(before, after) {
|
|
380
|
+
const deltas = [];
|
|
381
|
+
const bKinds = before.stages.map((s) => s.kind);
|
|
382
|
+
const aKinds = after.stages.map((s) => s.kind);
|
|
383
|
+
for (const [i, stage] of after.stages.entries()) {
|
|
384
|
+
if (!bKinds.includes(stage.kind)) {
|
|
385
|
+
const next = after.stages[i + 1];
|
|
386
|
+
deltas.push(`+${stage.kind} stage${next ? ` before ${next.kind}` : ''}`);
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
for (const kind of bKinds) {
|
|
390
|
+
if (!aKinds.includes(kind)) deltas.push(`-${kind} stage`);
|
|
391
|
+
}
|
|
392
|
+
for (const stage of after.stages) {
|
|
393
|
+
const prior = before.stages.find((s) => s.kind === stage.kind);
|
|
394
|
+
if (prior) deltas.push(...stageDelta(stage.kind, prior, stage));
|
|
395
|
+
}
|
|
396
|
+
return deltas;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
/**
|
|
400
|
+
* Semantic flow diff (K-26): old units vs new units → compact human
|
|
401
|
+
* strings for the apply response. Empty array means no behavioral change.
|
|
402
|
+
*
|
|
403
|
+
* @param {Record<string, *>} beforeUnits
|
|
404
|
+
* @param {Record<string, *>} afterUnits
|
|
405
|
+
* @returns {string[]}
|
|
406
|
+
*/
|
|
407
|
+
export function flowDelta(beforeUnits, afterUnits) {
|
|
408
|
+
const deltas = [];
|
|
409
|
+
const addresses = new Set([
|
|
410
|
+
...Object.keys(beforeUnits ?? {}),
|
|
411
|
+
...Object.keys(afterUnits ?? {})
|
|
412
|
+
]);
|
|
413
|
+
for (const address of [...addresses].sort()) {
|
|
414
|
+
const before = beforeUnits?.[address];
|
|
415
|
+
const after = afterUnits?.[address];
|
|
416
|
+
if (!before && after) {
|
|
417
|
+
deltas.push(`${address}: new flow (${after.stages.map((s) => s.kind).join(' → ')})`);
|
|
418
|
+
} else if (before && !after) {
|
|
419
|
+
deltas.push(`${address}: flow removed`);
|
|
420
|
+
} else if (before && after) {
|
|
421
|
+
deltas.push(...unitDelta(before, after).map((d) => `${address}: ${d}`));
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
return deltas;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
/**
|
|
428
|
+
* Build (or load) the whole app's flow graph: `{ version, units }` keyed by
|
|
429
|
+
* unit address. Always derives fresh — callers wanting the cached files
|
|
430
|
+
* read `.norns/cache/flow/` directly.
|
|
431
|
+
*
|
|
432
|
+
* @param {string} [dir] specs directory
|
|
433
|
+
* @returns {{ version: string, units: Record<string, *> }}
|
|
434
|
+
*/
|
|
435
|
+
export function flowApp(dir) {
|
|
436
|
+
const specs = loadSpecs(dir);
|
|
437
|
+
const appRoot = dirname(specs.dir);
|
|
438
|
+
const graph = buildGraph(specs.modules);
|
|
439
|
+
const units = {};
|
|
440
|
+
for (const [moduleName, moduleSpec] of Object.entries(specs.modules)) {
|
|
441
|
+
Object.assign(units, buildModuleFlow(moduleName, moduleSpec, { graph, appRoot }).units);
|
|
442
|
+
}
|
|
443
|
+
return { version: specs.version, units };
|
|
444
|
+
}
|