@nwire/scan 0.12.0 → 0.13.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,56 @@
1
+ /**
2
+ * The manifest's GRAPH MODEL — the definitive internal model as a typed
3
+ * node + edge graph: the digital twin of the system. Derived from the static
4
+ * `AstExtract` (shapes, positions, intent, event-causation edges) joined with
5
+ * the runtime `Topology` (plugins, capabilities-with-kinds, stages, handlers,
6
+ * bindings, hooks, triggers).
7
+ *
8
+ * Every unit of behaviour is a node carrying its kind, `file:line:column`, and
9
+ * declared intent (description / policy / slo / persona / journeyStep). Edges
10
+ * are typed and directional, so traversing them yields the joined
11
+ * `source → handler → effect → sink` paths a consumer (Studio / docs / the gate)
12
+ * renders — instead of stitching flat lists by name.
13
+ */
14
+ import type { InvariantEntry, SourceLocationEntry } from "./scan.js";
15
+ import type { AstExtract } from "./ast-extract.js";
16
+ import type { Topology } from "./topology.js";
17
+ /** Edge relationship types — the wiring vocabulary. */
18
+ export type EdgeType = "triggers" | "emits" | "delivers" | "transitions" | "dispatches" | "reads" | "calls" | "provides" | "contributes" | "mounts";
19
+ export interface GraphIntent {
20
+ readonly description?: string;
21
+ readonly policy?: unknown;
22
+ readonly slo?: unknown;
23
+ readonly persona?: string;
24
+ readonly journeyStep?: string;
25
+ readonly capability?: string;
26
+ readonly tags?: readonly string[];
27
+ /** Business-rule invariants surfaced from `validate()` predicates. */
28
+ readonly invariants?: readonly InvariantEntry[];
29
+ readonly public?: boolean;
30
+ }
31
+ export interface GraphNode {
32
+ /** Stable id: `${kind}:${name}` — edges reference these. */
33
+ readonly id: string;
34
+ readonly kind: string;
35
+ readonly name: string;
36
+ readonly source?: SourceLocationEntry;
37
+ readonly intent?: GraphIntent;
38
+ /** Kind-specific extras (states, schedule, emits, ctxKeys, …). */
39
+ readonly data?: Record<string, unknown>;
40
+ }
41
+ export interface GraphEdge {
42
+ readonly from: string;
43
+ readonly to: string;
44
+ readonly type: EdgeType;
45
+ /** For `transitions`: the event that drives the state change. */
46
+ readonly on?: string;
47
+ }
48
+ export interface ManifestModel {
49
+ readonly nodes: readonly GraphNode[];
50
+ readonly edges: readonly GraphEdge[];
51
+ }
52
+ /**
53
+ * Build the node + edge graph from the static extract and (optionally) the
54
+ * runtime topology. Pure — no IO, no boot; the topology was already captured.
55
+ */
56
+ export declare function buildGraph(ast: AstExtract, topology?: Topology): ManifestModel;
package/dist/graph.js ADDED
@@ -0,0 +1,325 @@
1
+ /**
2
+ * The manifest's GRAPH MODEL — the definitive internal model as a typed
3
+ * node + edge graph: the digital twin of the system. Derived from the static
4
+ * `AstExtract` (shapes, positions, intent, event-causation edges) joined with
5
+ * the runtime `Topology` (plugins, capabilities-with-kinds, stages, handlers,
6
+ * bindings, hooks, triggers).
7
+ *
8
+ * Every unit of behaviour is a node carrying its kind, `file:line:column`, and
9
+ * declared intent (description / policy / slo / persona / journeyStep). Edges
10
+ * are typed and directional, so traversing them yields the joined
11
+ * `source → handler → effect → sink` paths a consumer (Studio / docs / the gate)
12
+ * renders — instead of stitching flat lists by name.
13
+ */
14
+ const nid = (kind, name) => `${kind}:${name}`;
15
+ const hasIntent = (i) => i.description !== undefined ||
16
+ i.policy !== undefined ||
17
+ i.slo !== undefined ||
18
+ i.persona !== undefined ||
19
+ i.journeyStep !== undefined ||
20
+ i.capability !== undefined ||
21
+ (i.tags?.length ?? 0) > 0 ||
22
+ (i.invariants?.length ?? 0) > 0 ||
23
+ i.public !== undefined;
24
+ /**
25
+ * Build the node + edge graph from the static extract and (optionally) the
26
+ * runtime topology. Pure — no IO, no boot; the topology was already captured.
27
+ */
28
+ export function buildGraph(ast, topology) {
29
+ const nodes = [];
30
+ const edges = [];
31
+ const seen = new Set();
32
+ const add = (n) => {
33
+ if (seen.has(n.id))
34
+ return;
35
+ seen.add(n.id);
36
+ const intent = n.intent && hasIntent(n.intent) ? n.intent : undefined;
37
+ nodes.push(intent ? { ...n, intent } : { ...n, intent: undefined });
38
+ };
39
+ const seenEdge = new Set();
40
+ const edge = (from, to, type, on) => {
41
+ const key = `${from}|${to}|${type}|${on ?? ""}`;
42
+ if (seenEdge.has(key))
43
+ return;
44
+ seenEdge.add(key);
45
+ edges.push(on ? { from, to, type, on } : { from, to, type });
46
+ };
47
+ // ── Domain nodes (static AST) ──────────────────────────────────────
48
+ for (const e of ast.events)
49
+ add({
50
+ id: nid("event", e.name),
51
+ kind: "event",
52
+ name: e.name,
53
+ source: e.source,
54
+ intent: { description: e.description, public: e.public },
55
+ data: { version: e.version, audience: e.audience },
56
+ });
57
+ for (const a of ast.actions)
58
+ add({
59
+ id: nid("action", a.name),
60
+ kind: "action",
61
+ name: a.name,
62
+ source: a.source,
63
+ intent: {
64
+ description: a.description,
65
+ policy: a.policy,
66
+ slo: a.slo,
67
+ persona: a.persona,
68
+ journeyStep: a.journeyStep,
69
+ capability: a.capability,
70
+ tags: a.tags,
71
+ invariants: a.invariants,
72
+ public: a.public,
73
+ },
74
+ data: { emits: a.emits, retry: a.retry, hasInlineHandler: a.hasInlineHandler },
75
+ });
76
+ for (const q of ast.queries)
77
+ add({
78
+ id: nid("query", q.name),
79
+ kind: "query",
80
+ name: q.name,
81
+ source: q.source,
82
+ intent: { public: q.public },
83
+ data: { projection: q.projection },
84
+ });
85
+ // Shared state-machine emit: state nodes + `transitions` edges (on event).
86
+ // `from: "*"` (always-active) is rooted at the machine node itself.
87
+ const emitStates = (machineKind, machineName, declaredStates, transitions) => {
88
+ const stateKind = `${machineKind}State`;
89
+ const stateId = (s) => nid(stateKind, `${machineName}/${s}`);
90
+ const machineId = nid(machineKind, machineName);
91
+ const names = new Set(declaredStates);
92
+ for (const t of transitions ?? []) {
93
+ if (t.from !== "*")
94
+ names.add(t.from);
95
+ names.add(t.to);
96
+ }
97
+ for (const s of names)
98
+ add({ id: stateId(s), kind: stateKind, name: s, data: { [machineKind]: machineName } });
99
+ for (const t of transitions ?? [])
100
+ edge(t.from === "*" ? machineId : stateId(t.from), stateId(t.to), "transitions", t.on);
101
+ };
102
+ for (const ac of ast.actors) {
103
+ add({
104
+ id: nid("actor", ac.name),
105
+ kind: "actor",
106
+ name: ac.name,
107
+ source: ac.source,
108
+ intent: { invariants: ac.invariants },
109
+ data: { states: ac.states },
110
+ });
111
+ emitStates("actor", ac.name, ac.states, ac.transitions);
112
+ }
113
+ for (const w of ast.workflows) {
114
+ add({
115
+ id: nid("workflow", w.name),
116
+ kind: "workflow",
117
+ name: w.name,
118
+ source: w.source,
119
+ intent: { description: w.description, public: w.public },
120
+ data: { subscribesTo: w.subscribesTo, dispatches: w.dispatches },
121
+ });
122
+ emitStates("workflow", w.name, [], w.transitions);
123
+ for (const c of w.calls ?? [])
124
+ edge(nid("workflow", w.name), nid("externalCall", c), "calls");
125
+ }
126
+ for (const p of ast.projections)
127
+ add({
128
+ id: nid("projection", p.name),
129
+ kind: "projection",
130
+ name: p.name,
131
+ source: p.source,
132
+ intent: { description: p.description },
133
+ data: { listens: p.listens },
134
+ });
135
+ // Extra static kinds — present once their extractors land; guarded so the
136
+ // graph picks them up automatically without coupling to extractor order.
137
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
138
+ const x = ast;
139
+ for (const c of x.commands ?? [])
140
+ add({ id: nid("command", c.name), kind: "command", name: c.name, source: c.source });
141
+ for (const c of x.crons ?? [])
142
+ add({
143
+ id: nid("cron", c.name),
144
+ kind: "cron",
145
+ name: c.name,
146
+ source: c.source,
147
+ data: { schedule: c.schedule },
148
+ });
149
+ for (const c of x.externalCalls ?? [])
150
+ add({ id: nid("externalCall", c.name), kind: "externalCall", name: c.name, source: c.source });
151
+ for (const c of x.inboundWebhooks ?? [])
152
+ add({
153
+ id: nid("inboundWebhook", c.name),
154
+ kind: "inboundWebhook",
155
+ name: c.name,
156
+ source: c.source,
157
+ });
158
+ for (const c of x.outboxes ?? [])
159
+ add({ id: nid("outbox", c.name), kind: "outbox", name: c.name, source: c.source });
160
+ for (const c of x.inboxes ?? [])
161
+ add({ id: nid("inbox", c.name), kind: "inbox", name: c.name, source: c.source });
162
+ for (const c of x.resources ?? [])
163
+ add({ id: nid("resource", c.name), kind: "resource", name: c.name, source: c.source });
164
+ for (const c of x.errors ?? [])
165
+ add({ id: nid("error", c.code), kind: "error", name: c.code, source: c.source });
166
+ for (const c of x.schemas ?? [])
167
+ add({
168
+ id: nid("schema", c.name),
169
+ kind: "schema",
170
+ name: c.name,
171
+ source: c.source,
172
+ data: { states: c.states, key: c.key },
173
+ });
174
+ // ── Domain edges ───────────────────────────────────────────────────
175
+ // `emits` from each action's declared events (the static graph carries
176
+ // folds/subscribes/dispatches, but the emit list lives on the action entry).
177
+ for (const a of ast.actions) {
178
+ for (const ev of a.emits)
179
+ edge(nid("action", a.name), nid("event", ev), "emits");
180
+ for (const c of a.calls ?? [])
181
+ edge(nid("action", a.name), nid("externalCall", c), "calls");
182
+ }
183
+ for (const ed of ast.graph.events) {
184
+ switch (ed.via) {
185
+ case "emits":
186
+ edge(nid("action", ed.from), nid("event", ed.to), "emits");
187
+ break;
188
+ case "folds":
189
+ edge(nid("event", ed.from), nid("projection", ed.to), "delivers");
190
+ break;
191
+ case "subscribes":
192
+ edge(nid("event", ed.from), nid("workflow", ed.to), "delivers");
193
+ break;
194
+ case "dispatches":
195
+ edge(nid("workflow", ed.from), nid("action", ed.to), "dispatches");
196
+ break;
197
+ default:
198
+ break;
199
+ }
200
+ }
201
+ for (const q of ast.queries)
202
+ if (q.projection)
203
+ edge(nid("query", q.name), nid("projection", q.projection), "reads");
204
+ // ── Topology layer (runtime wiring) ────────────────────────────────
205
+ if (topology) {
206
+ for (const pl of topology.plugins)
207
+ add({ id: nid("plugin", pl.name), kind: "plugin", name: pl.name });
208
+ for (const cap of topology.capabilities)
209
+ add({
210
+ id: nid("capability", cap.name),
211
+ kind: "capability",
212
+ name: cap.name,
213
+ data: { kinds: cap.kinds, ctxKeys: cap.ctxKeys },
214
+ });
215
+ for (const s of topology.sourceStages)
216
+ add({
217
+ id: nid("sourceStage", s.name),
218
+ kind: "sourceStage",
219
+ name: s.name,
220
+ data: { position: s.position, stageKind: s.kind },
221
+ });
222
+ for (const s of topology.sinkStages)
223
+ add({
224
+ id: nid("sinkStage", s.name),
225
+ kind: "sinkStage",
226
+ name: s.name,
227
+ data: { position: s.position, stageKind: s.kind },
228
+ });
229
+ for (const b of topology.bindings)
230
+ add({ id: nid("binding", b.name), kind: "binding", name: b.name, data: { diKind: b.kind } });
231
+ for (const h of topology.hooks)
232
+ add({
233
+ id: nid("hook", h.name),
234
+ kind: "hook",
235
+ name: h.name,
236
+ source: h.source,
237
+ data: { chain: h.chain, listeners: h.listeners },
238
+ });
239
+ // Registered handlers — plain handlers / listeners that aren't already a
240
+ // domain node (action/query ids merge with the domain nodes above).
241
+ for (const h of topology.handlers)
242
+ add({ id: nid(h.kind, h.name), kind: h.kind, name: h.name });
243
+ // kind pseudo-nodes + capability→kind `provides` edges (the ctx-per-kind map).
244
+ const allKinds = new Set(Object.keys(topology.ctxByKind));
245
+ for (const k of allKinds)
246
+ add({ id: nid("kind", k), kind: "kind", name: k, data: { ctxKeys: topology.ctxByKind[k] } });
247
+ for (const cap of topology.capabilities) {
248
+ const applies = cap.kinds ?? [...allKinds]; // undefined = universal
249
+ for (const k of applies)
250
+ edge(nid("capability", cap.name), nid("kind", k), "provides");
251
+ }
252
+ // app nodes (the BC) + richer route nodes; `mounts` app → route.
253
+ // env/config are scanned statically from the app's source tree (project-
254
+ // scoped, one app per manifest) and surfaced on the app node's data.
255
+ for (const ap of topology.apps)
256
+ add({
257
+ id: nid("app", ap.name),
258
+ kind: "app",
259
+ name: ap.name,
260
+ intent: { description: ap.description },
261
+ data: {
262
+ plugins: ap.plugins,
263
+ ...(ast.env.length ? { env: ast.env } : {}),
264
+ ...(ast.config.length ? { config: ast.config } : {}),
265
+ },
266
+ });
267
+ for (const r of topology.routes) {
268
+ const label = `${r.method} ${r.path}`;
269
+ add({
270
+ id: nid("route", label),
271
+ kind: "route",
272
+ name: label,
273
+ data: { method: r.method, path: r.path },
274
+ });
275
+ for (const ap of topology.apps)
276
+ edge(nid("app", ap.name), nid("route", label), "mounts");
277
+ }
278
+ // triggers: source (route/binding) → the handler it dispatches. The route id
279
+ // matches the richer route nodes above (same `"METHOD /path"` label).
280
+ for (const t of topology.triggers) {
281
+ const routeId = nid("route", t.binding);
282
+ add({ id: routeId, kind: "route", name: t.binding });
283
+ if (t.handler) {
284
+ const hk = topology.handlers.find((h) => h.name === t.handler)?.kind ?? "handler";
285
+ edge(routeId, nid(hk, t.handler), "triggers");
286
+ }
287
+ }
288
+ // `contributes`: plugin → the binding/capability/stage/handler/hook it
289
+ // added, attributed at boot by the app lifecycle (diffing the runtime +
290
+ // hook registry around each plugin's phase). Each contributed name maps to
291
+ // its already-added graph node by kind; edges to a node we didn't add are
292
+ // skipped (the node may be a static-only kind the topology doesn't carry).
293
+ for (const c of topology.contributions ?? []) {
294
+ const pluginId = nid("plugin", c.plugin);
295
+ const contribute = (kind, name) => {
296
+ const targetId = nid(kind, name);
297
+ if (seen.has(targetId))
298
+ edge(pluginId, targetId, "contributes");
299
+ };
300
+ for (const b of c.bindings)
301
+ contribute("binding", b);
302
+ for (const cap of c.capabilities)
303
+ contribute("capability", cap);
304
+ for (const s of c.sourceStages)
305
+ contribute("sourceStage", s);
306
+ for (const s of c.sinkStages)
307
+ contribute("sinkStage", s);
308
+ for (const h of c.handlers) {
309
+ // A handler node id uses its registered kind, not "handler".
310
+ const hk = topology.handlers.find((x) => x.name === h)?.kind ?? "handler";
311
+ contribute(hk, h);
312
+ }
313
+ for (const h of c.hooks)
314
+ contribute("hook", h);
315
+ }
316
+ // The →sink leg: a public event drains to the terminal outbound stage(s),
317
+ // completing the joined source → handler → effect → sink path.
318
+ const terminalSinks = topology.sinkStages.filter((s) => s.position === "terminal");
319
+ for (const e of ast.events)
320
+ if (e.public)
321
+ for (const s of terminalSinks)
322
+ edge(nid("event", e.name), nid("sinkStage", s.name), "delivers");
323
+ }
324
+ return { nodes, edges };
325
+ }
@@ -0,0 +1,67 @@
1
+ /**
2
+ * The build-time manifest — the static graph, written without booting an app.
3
+ *
4
+ * "Scan the graph, not the runtime." `buildManifest(root)` walks the source
5
+ * tree with the AST extractor (`extractFromFiles`) and produces a `Manifest`:
6
+ * every event / action / query / actor / workflow / projection with its
7
+ * `file:line:column`, the event-graph edges, and an `unanalyzable` list for
8
+ * anything not statically resolvable (never silently dropped). `writeManifest`
9
+ * serializes it to `.nwire/manifest.json`; `readManifest` loads it back — so
10
+ * Studio / please / trust / doc-gen can read the graph without a running app.
11
+ *
12
+ * equivalence harness proves this static extract matches it. Later units retire
13
+ * the runtime path and point consumers here.
14
+ */
15
+ import { type AstExtract } from "./ast-extract.js";
16
+ import { type Topology } from "./topology.js";
17
+ import { type ManifestModel } from "./graph.js";
18
+ /** Bumped when the manifest shape changes, so readers can detect a stale file. */
19
+ export declare const MANIFEST_VERSION: 3;
20
+ /**
21
+ * The project manifest — the static AST graph (events/actions/queries/actors/
22
+ * workflows/projections + positions + event-causation edges) plus, when built
23
+ * with a booted app, the runtime `topology` (plugins, capabilities-with-kinds,
24
+ * source/sink stages, ctx-per-kind, handlers, bindings, hooks, triggers). Static
25
+ * shapes + runtime wiring — enough to render exactly how the app works and why.
26
+ */
27
+ export interface Manifest extends AstExtract {
28
+ readonly version: typeof MANIFEST_VERSION;
29
+ /** Runtime topology layer — present only when `buildManifest` got a booted app. */
30
+ readonly topology?: Topology;
31
+ /**
32
+ * The definitive internal model — a typed node + edge graph joining the static
33
+ * shapes with the runtime topology. The digital twin: traverse the edges to
34
+ * get `source → handler → effect → sink` paths. Always present.
35
+ */
36
+ readonly model: ManifestModel;
37
+ }
38
+ /**
39
+ * Recursively collect non-test `.ts` source files under `root` — the input to
40
+ * the AST extractor. Skips build output, deps, and test/declaration files so
41
+ * the manifest reflects authored source only.
42
+ */
43
+ export declare function collectSourceFiles(root: string): string[];
44
+ /**
45
+ * Read the topology a running app self-emitted to `<root>/.nwire/topology.json`.
46
+ * Returns `undefined` when the file is absent or unreadable (graceful — the
47
+ * static path then produces a manifest with no topology layer).
48
+ */
49
+ export declare function readEmittedTopology(root: string): Topology | undefined;
50
+ /**
51
+ * Build the manifest by extracting the static graph from the source tree at
52
+ * `root`. The topology layer (plugins/capabilities/stages/ctx-per-kind/…) needs
53
+ * a running app; it is resolved in priority order:
54
+ *
55
+ * 1. a booted `instance` passed in — `captureTopology(instance)` (legacy path);
56
+ * 2. else `<root>/.nwire/topology.json`, the file a running app self-emits.
57
+ *
58
+ * When neither is present the manifest carries no topology layer (graceful).
59
+ */
60
+ export declare function buildManifest(root: string, app?: string, instance?: any): Manifest;
61
+ /** Write the manifest to `<outDir>/manifest.json` (default `.nwire`). */
62
+ export declare function writeManifest(manifest: Manifest, outDir?: string): Promise<void>;
63
+ /**
64
+ * Load `<dir>/manifest.json` (default `.nwire`). Returns `undefined` when the
65
+ * file is absent — callers decide whether that's "build first" or an error.
66
+ */
67
+ export declare function readManifest(dir?: string): Manifest | undefined;
@@ -0,0 +1,103 @@
1
+ /**
2
+ * The build-time manifest — the static graph, written without booting an app.
3
+ *
4
+ * "Scan the graph, not the runtime." `buildManifest(root)` walks the source
5
+ * tree with the AST extractor (`extractFromFiles`) and produces a `Manifest`:
6
+ * every event / action / query / actor / workflow / projection with its
7
+ * `file:line:column`, the event-graph edges, and an `unanalyzable` list for
8
+ * anything not statically resolvable (never silently dropped). `writeManifest`
9
+ * serializes it to `.nwire/manifest.json`; `readManifest` loads it back — so
10
+ * Studio / please / trust / doc-gen can read the graph without a running app.
11
+ *
12
+ * equivalence harness proves this static extract matches it. Later units retire
13
+ * the runtime path and point consumers here.
14
+ */
15
+ import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
16
+ import { mkdir, writeFile } from "node:fs/promises";
17
+ import { join, resolve } from "node:path";
18
+ import { extractFromFiles } from "./ast-extract.js";
19
+ import { captureTopology } from "./topology.js";
20
+ import { buildGraph } from "./graph.js";
21
+ /** Bumped when the manifest shape changes, so readers can detect a stale file. */
22
+ export const MANIFEST_VERSION = 3;
23
+ const SKIP_DIRS = new Set(["node_modules", "dist", ".nwire", ".git", ".vitepress", "__tests__"]);
24
+ /**
25
+ * Recursively collect non-test `.ts` source files under `root` — the input to
26
+ * the AST extractor. Skips build output, deps, and test/declaration files so
27
+ * the manifest reflects authored source only.
28
+ */
29
+ export function collectSourceFiles(root) {
30
+ const out = [];
31
+ const walk = (dir) => {
32
+ for (const entry of readdirSync(dir)) {
33
+ const p = join(dir, entry);
34
+ const st = statSync(p);
35
+ if (st.isDirectory()) {
36
+ if (!SKIP_DIRS.has(entry))
37
+ walk(p);
38
+ continue;
39
+ }
40
+ if (entry.endsWith(".ts") &&
41
+ !entry.endsWith(".test.ts") &&
42
+ !entry.endsWith(".spec.ts") &&
43
+ !entry.endsWith(".steps.ts") &&
44
+ !entry.endsWith(".d.ts")) {
45
+ out.push(p);
46
+ }
47
+ }
48
+ };
49
+ if (existsSync(root))
50
+ walk(root);
51
+ return out;
52
+ }
53
+ /**
54
+ * Read the topology a running app self-emitted to `<root>/.nwire/topology.json`.
55
+ * Returns `undefined` when the file is absent or unreadable (graceful — the
56
+ * static path then produces a manifest with no topology layer).
57
+ */
58
+ export function readEmittedTopology(root) {
59
+ const p = resolve(root, ".nwire", "topology.json");
60
+ if (!existsSync(p))
61
+ return undefined;
62
+ try {
63
+ const file = JSON.parse(readFileSync(p, "utf8"));
64
+ return file.topology;
65
+ }
66
+ catch {
67
+ return undefined;
68
+ }
69
+ }
70
+ /**
71
+ * Build the manifest by extracting the static graph from the source tree at
72
+ * `root`. The topology layer (plugins/capabilities/stages/ctx-per-kind/…) needs
73
+ * a running app; it is resolved in priority order:
74
+ *
75
+ * 1. a booted `instance` passed in — `captureTopology(instance)` (legacy path);
76
+ * 2. else `<root>/.nwire/topology.json`, the file a running app self-emits.
77
+ *
78
+ * When neither is present the manifest carries no topology layer (graceful).
79
+ */
80
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
81
+ export function buildManifest(root, app = "", instance) {
82
+ const ast = extractFromFiles(collectSourceFiles(root), app);
83
+ const topology = instance ? captureTopology(instance) : readEmittedTopology(root);
84
+ const model = buildGraph(ast, topology);
85
+ return topology
86
+ ? { version: MANIFEST_VERSION, ...ast, topology, model }
87
+ : { version: MANIFEST_VERSION, ...ast, model };
88
+ }
89
+ /** Write the manifest to `<outDir>/manifest.json` (default `.nwire`). */
90
+ export async function writeManifest(manifest, outDir = ".nwire") {
91
+ await mkdir(outDir, { recursive: true });
92
+ await writeFile(resolve(outDir, "manifest.json"), JSON.stringify(manifest, null, 2));
93
+ }
94
+ /**
95
+ * Load `<dir>/manifest.json` (default `.nwire`). Returns `undefined` when the
96
+ * file is absent — callers decide whether that's "build first" or an error.
97
+ */
98
+ export function readManifest(dir = ".nwire") {
99
+ const p = resolve(dir, "manifest.json");
100
+ if (!existsSync(p))
101
+ return undefined;
102
+ return JSON.parse(readFileSync(p, "utf8"));
103
+ }