flowgraph-ai 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,991 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * FlowGraph CLI — verify maintenance contracts against source code.
4
+ *
5
+ * Usage:
6
+ * flowgraph verify [path/to/file.flowgraph.json]
7
+ * flowgraph verify --impact <node:id> [path/to/file.flowgraph.json]
8
+ * flowgraph render [path/to/file.flowgraph.json]
9
+ * flowgraph init
10
+ */
11
+
12
+ import { readFileSync, writeFileSync, existsSync, readdirSync } from "node:fs";
13
+ import { resolve, dirname, basename } from "node:path";
14
+
15
+ // ─── State ───────────────────────────────────────────────────────────────────
16
+
17
+ const results = [];
18
+
19
+ function record(status, category, id, message) {
20
+ results.push({ status, id, category, message });
21
+ }
22
+
23
+ // ─── CLI Parsing ─────────────────────────────────────────────────────────────
24
+
25
+ const args = process.argv.slice(2);
26
+ const command = args[0];
27
+
28
+ if (!command || command === "--help" || command === "-h") {
29
+ printUsage();
30
+ process.exit(0);
31
+ }
32
+
33
+ if (command === "init") {
34
+ runInit();
35
+ process.exit(0);
36
+ }
37
+
38
+ if (command === "verify") {
39
+ const verifyArgs = args.slice(1);
40
+ const impactIdx = verifyArgs.indexOf("--impact");
41
+ const impactNode = impactIdx !== -1 ? verifyArgs[impactIdx + 1] : null;
42
+
43
+ // Find flowgraph file: explicit arg, or auto-discover *.flowgraph.json
44
+ const explicitFile = verifyArgs.find(
45
+ (a) => !a.startsWith("--") && (impactIdx === -1 || verifyArgs.indexOf(a) !== impactIdx + 1)
46
+ );
47
+ const flowgraphPath = resolveFlowgraphPath(explicitFile);
48
+
49
+ if (!flowgraphPath) {
50
+ console.error("No flowgraph file found. Pass a path or run `flowgraph init` to create one.");
51
+ process.exit(1);
52
+ }
53
+
54
+ const projectRoot = dirname(flowgraphPath);
55
+ const flowgraph = JSON.parse(readFileSync(flowgraphPath, "utf-8"));
56
+
57
+ if (impactNode) {
58
+ runImpactAnalysis(impactNode, flowgraph, projectRoot);
59
+ } else {
60
+ runVerification(flowgraph, projectRoot, flowgraphPath);
61
+ }
62
+ } else if (command === "render") {
63
+ const explicitFile = args[1];
64
+ const flowgraphPath = resolveFlowgraphPath(explicitFile);
65
+
66
+ if (!flowgraphPath) {
67
+ console.error("No flowgraph file found. Pass a path or run `flowgraph-ai init` to create one.");
68
+ process.exit(1);
69
+ }
70
+
71
+ const flowgraph = JSON.parse(readFileSync(flowgraphPath, "utf-8"));
72
+ runRender(flowgraph, flowgraphPath);
73
+ } else {
74
+ console.error(`Unknown command: ${command}`);
75
+ printUsage();
76
+ process.exit(1);
77
+ }
78
+
79
+ // ─── Commands ────────────────────────────────────────────────────────────────
80
+
81
+ function printUsage() {
82
+ console.log(`
83
+ FlowGraph — machine-verifiable maintenance contracts
84
+
85
+ Usage:
86
+ flowgraph-ai verify [file.flowgraph.json] Verify contracts against source
87
+ flowgraph-ai verify --impact <node:id> [file] Show impact of changing a node
88
+ flowgraph-ai render [file.flowgraph.json] Render as Mermaid diagrams (markdown)
89
+ flowgraph-ai init Create a starter flowgraph
90
+
91
+ Options:
92
+ --help, -h Show this help message
93
+
94
+ If no file is specified, discovers *.flowgraph.json in the current directory.
95
+ `);
96
+ }
97
+
98
+ function resolveFlowgraphPath(explicit) {
99
+ if (explicit) {
100
+ const p = resolve(process.cwd(), explicit);
101
+ if (existsSync(p)) return p;
102
+ console.error(`File not found: ${explicit}`);
103
+ process.exit(1);
104
+ }
105
+
106
+ // Auto-discover
107
+ const cwd = process.cwd();
108
+ const entries = readdirSyncSafe(cwd);
109
+ const matches = entries.filter((e) => e.endsWith(".flowgraph.json"));
110
+
111
+ if (matches.length === 1) return resolve(cwd, matches[0]);
112
+ if (matches.length > 1) {
113
+ console.error("Multiple flowgraph files found. Specify one:");
114
+ for (const m of matches) console.error(` ${m}`);
115
+ process.exit(1);
116
+ }
117
+ return null;
118
+ }
119
+
120
+ function readdirSyncSafe(dir) {
121
+ try {
122
+ return readdirSync(dir);
123
+ } catch {
124
+ return [];
125
+ }
126
+ }
127
+
128
+ function runInit() {
129
+ const name = basename(process.cwd());
130
+ const template = {
131
+ $flowgraph: "2.1",
132
+ meta: { name, root: "src/" },
133
+ nodes: {
134
+ "type:ExampleConfig": {
135
+ kind: "type",
136
+ loc: "config.ts:1",
137
+ },
138
+ "method:loadConfig": {
139
+ kind: "method",
140
+ loc: "config.ts:10",
141
+ },
142
+ },
143
+ edges: [
144
+ {
145
+ from: "type:ExampleConfig",
146
+ to: "method:loadConfig",
147
+ rel: "co_change",
148
+ note: "adding a config field requires updating the loader",
149
+ },
150
+ ],
151
+ flows: {},
152
+ invariants: [],
153
+ };
154
+
155
+ const filename = `${name}.flowgraph.json`;
156
+ if (existsSync(filename)) {
157
+ console.error(`${filename} already exists.`);
158
+ process.exit(1);
159
+ }
160
+
161
+ writeFileSync(filename, JSON.stringify(template, null, 2) + "\n");
162
+ console.log(`Created ${filename} — edit it to match your project.`);
163
+ }
164
+
165
+ // ─── Helpers ─────────────────────────────────────────────────────────────────
166
+
167
+ function resolveLoc(loc, root, projectRoot) {
168
+ const parts = loc.split(":");
169
+ const linePart =
170
+ parts.length > 1 && /^\d+$/.test(parts[parts.length - 1])
171
+ ? parts.pop()
172
+ : undefined;
173
+ const pathPart = parts.join(":");
174
+ const filePath = resolve(projectRoot, root, pathPart);
175
+ return { filePath, line: linePart ? parseInt(linePart, 10) : undefined };
176
+ }
177
+
178
+ function readSource(filePath) {
179
+ try {
180
+ return readFileSync(filePath, "utf-8");
181
+ } catch {
182
+ return null;
183
+ }
184
+ }
185
+
186
+ function getRegion(source, lineNum, radius = 15) {
187
+ const lines = source.split("\n");
188
+ const start = Math.max(0, lineNum - 1 - radius);
189
+ const end = Math.min(lines.length, lineNum - 1 + radius);
190
+ return lines.slice(start, end).join("\n");
191
+ }
192
+
193
+ function esc(s) {
194
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
195
+ }
196
+
197
+ function has(source, pattern) {
198
+ if (typeof pattern === "string") return source.includes(pattern);
199
+ return pattern.test(source);
200
+ }
201
+
202
+ // ─── Node Verification ──────────────────────────────────────────────────────
203
+
204
+ function verifyTypeNode(id, node, root, projectRoot) {
205
+ const { filePath, line } = resolveLoc(node.loc, root, projectRoot);
206
+ const source = readSource(filePath);
207
+
208
+ if (!source) {
209
+ record("FAIL", "structural", id, `File not found: ${node.loc}`);
210
+ return;
211
+ }
212
+
213
+ const typeName = id.replace("type:", "");
214
+ const patterns = [
215
+ new RegExp(`(interface|type|enum|class|struct)\\s+${esc(typeName)}\\b`),
216
+ new RegExp(`(const|export const|let|var)\\s+${esc(typeName)}Schema\\s*=`),
217
+ new RegExp(`(const|export const|let|var)\\s+${esc(typeName)}\\s*=`),
218
+ new RegExp(`(def|class)\\s+${esc(typeName)}[:\\(\\b]`), // Python
219
+ ];
220
+
221
+ if (!patterns.some((p) => has(source, p))) {
222
+ record("FAIL", "structural", id, `Type '${typeName}' not found in ${node.loc}`);
223
+ return;
224
+ }
225
+
226
+ if (line) {
227
+ const region = getRegion(source, line, 5);
228
+ if (patterns.some((p) => has(region, p))) {
229
+ record("PASS", "structural", id, `Found at ${node.loc}`);
230
+ } else {
231
+ record("WARN", "structural", id, `Found in file but not near line ${line}`);
232
+ }
233
+ } else {
234
+ record("PASS", "structural", id, `Found in ${node.loc}`);
235
+ }
236
+
237
+ if (node.schema && !has(source, node.schema)) {
238
+ record("FAIL", "structural", id, `Schema '${node.schema}' not found`);
239
+ }
240
+
241
+ if (Array.isArray(node.values)) {
242
+ for (const val of node.values) {
243
+ if (!has(source, val)) {
244
+ record("FAIL", "structural", id, `Enum value '${val}' not found`);
245
+ }
246
+ }
247
+ }
248
+ }
249
+
250
+ function verifyMethodNode(id, node, root, projectRoot) {
251
+ const { filePath, line } = resolveLoc(node.loc, root, projectRoot);
252
+ const source = readSource(filePath);
253
+
254
+ if (!source) {
255
+ record("FAIL", "structural", id, `File not found: ${node.loc}`);
256
+ return;
257
+ }
258
+
259
+ const fullName = id.replace("method:", "");
260
+ const methodName = fullName.split(".").pop();
261
+
262
+ const patterns = [
263
+ new RegExp(`(async\\s+)?${esc(methodName)}\\s*\\(`),
264
+ new RegExp(`(private|public|protected)\\s+(async\\s+)?${esc(methodName)}\\s*\\(`),
265
+ new RegExp(`def\\s+${esc(methodName)}\\s*\\(`), // Python
266
+ new RegExp(`func\\s+${esc(methodName)}\\s*\\(`), // Go
267
+ new RegExp(`fn\\s+${esc(methodName)}\\s*[<(]`), // Rust
268
+ ];
269
+
270
+ if (!patterns.some((p) => has(source, p))) {
271
+ record("FAIL", "structural", id, `Method '${methodName}' not found in ${node.loc}`);
272
+ return;
273
+ }
274
+
275
+ if (line) {
276
+ const region = getRegion(source, line, 5);
277
+ if (patterns.some((p) => has(region, p))) {
278
+ record("PASS", "structural", id, `Found at ${node.loc}`);
279
+ } else {
280
+ record("WARN", "structural", id, `Method found but not near line ${line}`);
281
+ }
282
+ } else {
283
+ record("PASS", "structural", id, `Found in ${node.loc}`);
284
+ }
285
+ }
286
+
287
+ function verifyTableNode(id, node, root, projectRoot) {
288
+ const { filePath } = resolveLoc(node.loc, root, projectRoot);
289
+ const source = readSource(filePath);
290
+
291
+ if (!source) {
292
+ record("FAIL", "structural", id, `File not found: ${node.loc}`);
293
+ return;
294
+ }
295
+
296
+ const tableName = id.replace("table:", "");
297
+ if (
298
+ !has(
299
+ source,
300
+ new RegExp(
301
+ `CREATE TABLE\\s+(IF NOT EXISTS\\s+)?${esc(tableName)}\\b`,
302
+ "i"
303
+ )
304
+ )
305
+ ) {
306
+ record("FAIL", "structural", id, `CREATE TABLE '${tableName}' not found`);
307
+ return;
308
+ }
309
+
310
+ record("PASS", "structural", id, `Table found`);
311
+
312
+ if (Array.isArray(node.fk)) {
313
+ for (const fk of node.fk) {
314
+ const match = fk.match(/-> (\w+)/);
315
+ if (match && !has(source, match[1])) {
316
+ record("WARN", "structural", id, `FK to '${match[1]}' not in DDL`);
317
+ }
318
+ }
319
+ }
320
+ }
321
+
322
+ function verifyEndpointNode(id, node, root, projectRoot) {
323
+ const { filePath, line } = resolveLoc(node.loc, root, projectRoot);
324
+ const source = readSource(filePath);
325
+
326
+ if (!source) {
327
+ record("FAIL", "structural", id, `File not found: ${node.loc}`);
328
+ return;
329
+ }
330
+
331
+ const endpointStr = id.replace("endpoint:", "");
332
+ const spaceIdx = endpointStr.indexOf(" ");
333
+ if (spaceIdx === -1) {
334
+ // No HTTP method prefix — just check the string appears
335
+ if (has(source, endpointStr)) {
336
+ record("PASS", "structural", id, `Endpoint reference found`);
337
+ } else {
338
+ record("FAIL", "structural", id, `Endpoint '${endpointStr}' not found`);
339
+ }
340
+ return;
341
+ }
342
+
343
+ const httpMethod = endpointStr.substring(0, spaceIdx).toLowerCase();
344
+ const path = endpointStr.substring(spaceIdx + 1);
345
+
346
+ const routePatterns = [
347
+ new RegExp(`\\.${esc(httpMethod)}\\s*\\(\\s*['"\`]${esc(path)}['"\`]`),
348
+ new RegExp(`${esc(httpMethod)}.*${esc(path)}`),
349
+ ];
350
+
351
+ if (routePatterns.some((p) => has(source, p))) {
352
+ if (line) {
353
+ const region = getRegion(source, line, 10);
354
+ if (routePatterns.some((p) => has(region, p))) {
355
+ record("PASS", "structural", id, `Route found at ${node.loc}`);
356
+ } else {
357
+ record("WARN", "structural", id, `Route in file but not near line ${line}`);
358
+ }
359
+ } else {
360
+ record("PASS", "structural", id, `Route found in ${node.loc}`);
361
+ }
362
+ } else {
363
+ record("FAIL", "structural", id, `Route '${httpMethod} ${path}' not found`);
364
+ }
365
+ }
366
+
367
+ function verifyEventNode(id, node, root, projectRoot) {
368
+ const { filePath } = resolveLoc(node.loc, root, projectRoot);
369
+ const source = readSource(filePath);
370
+
371
+ if (!source) {
372
+ record("FAIL", "structural", id, `File not found: ${node.loc}`);
373
+ return;
374
+ }
375
+
376
+ const eventName = id.replace("event:", "");
377
+
378
+ if (
379
+ has(source, new RegExp(`['"\`]${esc(eventName)}['"\`]`)) ||
380
+ has(source, eventName)
381
+ ) {
382
+ record("PASS", "structural", id, `Event '${eventName}' found`);
383
+ } else {
384
+ record("FAIL", "structural", id, `Event '${eventName}' not found`);
385
+ }
386
+ }
387
+
388
+ // ─── Edge Verification ──────────────────────────────────────────────────────
389
+
390
+ function verifyEdge(edge, nodes, root, projectRoot) {
391
+ const edgeId = `${edge.from} -[${edge.rel}]-> ${edge.to}`;
392
+ const fromNode = nodes[edge.from];
393
+ const toNode = nodes[edge.to];
394
+
395
+ if (!fromNode) {
396
+ record("FAIL", "relational", edgeId, `Source node missing`);
397
+ return;
398
+ }
399
+ if (!toNode) {
400
+ record("FAIL", "relational", edgeId, `Target node missing`);
401
+ return;
402
+ }
403
+
404
+ const { filePath } = resolveLoc(fromNode.loc, root, projectRoot);
405
+ const source = readSource(filePath);
406
+ if (!source) {
407
+ record("FAIL", "relational", edgeId, `Source file not found`);
408
+ return;
409
+ }
410
+
411
+ switch (edge.rel) {
412
+ case "co_change": {
413
+ // Co-change edges are maintenance contracts — both nodes existing is the check
414
+ record(
415
+ "PASS",
416
+ "relational",
417
+ edgeId,
418
+ `Co-change contract${edge.note ? ": " + edge.note : ""}`
419
+ );
420
+ break;
421
+ }
422
+ case "validates": {
423
+ const schemaName = toNode?.schema;
424
+ if (schemaName) {
425
+ // Check for common validation patterns
426
+ const validationPatterns = [
427
+ new RegExp(`${esc(schemaName)}\\.(parse|safeParse|validate)\\s*\\(`),
428
+ new RegExp(`${esc(schemaName)}\\.check\\s*\\(`),
429
+ ];
430
+ if (validationPatterns.some((p) => has(source, p))) {
431
+ record("PASS", "relational", edgeId, `${schemaName} validation found`);
432
+ } else {
433
+ record("FAIL", "relational", edgeId, `No ${schemaName} validation call`);
434
+ }
435
+ } else {
436
+ record("WARN", "relational", edgeId, "No schema declared on target");
437
+ }
438
+ break;
439
+ }
440
+ case "calls": {
441
+ const methodName = edge.to.replace("method:", "").split(".").pop();
442
+ if (has(source, new RegExp(`${esc(methodName)}\\s*\\(`))) {
443
+ record("PASS", "relational", edgeId, "Call site found");
444
+ } else {
445
+ record("FAIL", "relational", edgeId, `No call to '${methodName}'`);
446
+ }
447
+ break;
448
+ }
449
+ case "writes":
450
+ case "reads": {
451
+ const tableName = edge.to.replace("table:", "");
452
+ const dbPatterns = [
453
+ new RegExp(
454
+ `(INSERT INTO|UPDATE|DELETE FROM|SELECT.*FROM)\\s+${esc(tableName)}`,
455
+ "i"
456
+ ),
457
+ new RegExp(`['"\`].*${esc(tableName)}.*['"\`]`),
458
+ ];
459
+ if (dbPatterns.some((p) => has(source, p))) {
460
+ record("PASS", "relational", edgeId, `DB op on '${tableName}' found`);
461
+ } else {
462
+ record("WARN", "relational", edgeId, "No direct DB op (may be indirect)");
463
+ }
464
+ break;
465
+ }
466
+ case "emits": {
467
+ const eventName = edge.to.replace("event:", "");
468
+ if (has(source, new RegExp(`['"\`]${esc(eventName)}['"\`]`))) {
469
+ record("PASS", "relational", edgeId, `Event '${eventName}' referenced`);
470
+ } else {
471
+ record("FAIL", "relational", edgeId, `No reference to '${eventName}'`);
472
+ }
473
+ break;
474
+ }
475
+ case "listens": {
476
+ const eventName = edge.to.replace("event:", "");
477
+ if (has(source, new RegExp(`['"\`]${esc(eventName)}['"\`]`))) {
478
+ record("PASS", "relational", edgeId, `Listener for '${eventName}' found`);
479
+ } else {
480
+ record("WARN", "relational", edgeId, `No listener for '${eventName}'`);
481
+ }
482
+ break;
483
+ }
484
+ default:
485
+ record("PASS", "relational", edgeId, `Relation '${edge.rel}' accepted`);
486
+ }
487
+ }
488
+
489
+ // ─── Flow Verification ──────────────────────────────────────────────────────
490
+
491
+ function verifyFlows(flows, nodes) {
492
+ for (const [name, flow] of Object.entries(flows)) {
493
+ let allExist = true;
494
+ const missing = [];
495
+
496
+ for (const step of flow.steps) {
497
+ if (!nodes[step.node]) {
498
+ allExist = false;
499
+ missing.push(step.node);
500
+ }
501
+
502
+ if (typeof step.then === "object" && step.then !== null) {
503
+ for (const target of Object.values(step.then)) {
504
+ if (
505
+ target !== "next" &&
506
+ target !== "DONE" &&
507
+ target !== "FAIL" &&
508
+ !nodes[target]
509
+ ) {
510
+ allExist = false;
511
+ missing.push(target);
512
+ }
513
+ }
514
+ }
515
+ }
516
+
517
+ if (allExist) {
518
+ record("PASS", "flow", name, `All ${flow.steps.length} step nodes exist`);
519
+ } else {
520
+ record("FAIL", "flow", name, `Missing nodes: ${missing.join(", ")}`);
521
+ }
522
+
523
+ let allReachable = true;
524
+ for (const step of flow.steps) {
525
+ if (typeof step.then === "object" && step.then !== null) {
526
+ for (const [cond, target] of Object.entries(step.then)) {
527
+ if (target !== "next" && target !== "DONE" && target !== "FAIL") {
528
+ const inSteps = flow.steps.some((s) => s.node === target);
529
+ if (!inSteps) {
530
+ record(
531
+ "WARN",
532
+ "flow",
533
+ name,
534
+ `'${cond}' -> '${target}' not in step list`
535
+ );
536
+ allReachable = false;
537
+ }
538
+ }
539
+ }
540
+ }
541
+ }
542
+
543
+ if (allReachable) {
544
+ record("PASS", "flow", name, "All branch targets reachable");
545
+ }
546
+ }
547
+ }
548
+
549
+ // ─── Invariant Verification ─────────────────────────────────────────────────
550
+
551
+ function verifyInvariants(invariants, nodes, root, projectRoot) {
552
+ for (const inv of invariants) {
553
+ // Check all scoped nodes exist
554
+ const missingNodes = (inv.scope || []).filter((s) => !nodes[s]);
555
+ if (missingNodes.length > 0) {
556
+ record(
557
+ "FAIL",
558
+ "invariant",
559
+ inv.id,
560
+ `Scoped nodes missing: ${missingNodes.join(", ")}`
561
+ );
562
+ continue;
563
+ }
564
+
565
+ // Check all scoped files are readable
566
+ const scopeNodes = (inv.scope || []).map((s) => nodes[s]).filter(Boolean);
567
+ let allFilesExist = true;
568
+ for (const n of scopeNodes) {
569
+ const { filePath } = resolveLoc(n.loc, root, projectRoot);
570
+ if (!readSource(filePath)) {
571
+ allFilesExist = false;
572
+ break;
573
+ }
574
+ }
575
+
576
+ if (!allFilesExist) {
577
+ record("FAIL", "invariant", inv.id, `Some scoped files not found`);
578
+ continue;
579
+ }
580
+
581
+ const enforceNote = inv.enforce ? ` [enforce: ${inv.enforce}]` : "";
582
+ record(
583
+ "WARN",
584
+ "invariant",
585
+ inv.id,
586
+ `${inv.rule} — requires manual/custom verification${enforceNote}`
587
+ );
588
+ }
589
+ }
590
+
591
+ // ─── Impact Analysis ────────────────────────────────────────────────────────
592
+
593
+ function runImpactAnalysis(nodeId, flowgraph, projectRoot) {
594
+ const edges = flowgraph.edges.filter((e) => !e._comment);
595
+ const node = flowgraph.nodes[nodeId];
596
+
597
+ console.log(`\n\x1b[36m${"=".repeat(64)}\x1b[0m`);
598
+ console.log(`\x1b[36m Impact Analysis: \x1b[1m${nodeId}\x1b[0m`);
599
+ if (node) {
600
+ console.log(`\x1b[36m Kind: ${node.kind} Loc: ${node.loc}\x1b[0m`);
601
+ } else {
602
+ console.log(`\x1b[31m Node not found in flowgraph!\x1b[0m`);
603
+ console.log(`\x1b[36m${"=".repeat(64)}\x1b[0m\n`);
604
+ console.log("Available nodes:");
605
+ for (const id of Object.keys(flowgraph.nodes).sort()) {
606
+ console.log(` ${id}`);
607
+ }
608
+ process.exit(1);
609
+ }
610
+ console.log(`\x1b[36m${"=".repeat(64)}\x1b[0m`);
611
+
612
+ // Outgoing
613
+ const outgoing = edges.filter((e) => e.from === nodeId);
614
+ console.log(
615
+ `\n\x1b[1m-> Outgoing edges\x1b[0m (${outgoing.length} — things this node affects):\n`
616
+ );
617
+ if (outgoing.length === 0) {
618
+ console.log(" (none)");
619
+ } else {
620
+ for (const e of outgoing) {
621
+ const marker =
622
+ e.rel === "co_change" ? "\x1b[31m! MUST CO-CHANGE\x1b[0m " : "";
623
+ console.log(` ${marker}\x1b[33m-[${e.rel}]->\x1b[0m ${e.to}`);
624
+ if (e.note) console.log(` ${e.note}`);
625
+ }
626
+ }
627
+
628
+ // Incoming
629
+ const incoming = edges.filter((e) => e.to === nodeId);
630
+ console.log(
631
+ `\n\x1b[1m<- Incoming edges\x1b[0m (${incoming.length} — things that depend on this node):\n`
632
+ );
633
+ if (incoming.length === 0) {
634
+ console.log(" (none)");
635
+ } else {
636
+ for (const e of incoming) {
637
+ const marker =
638
+ e.rel === "co_change" ? "\x1b[31m! MUST CO-CHANGE\x1b[0m " : "";
639
+ console.log(` ${marker}${e.from} \x1b[33m-[${e.rel}]->\x1b[0m`);
640
+ if (e.note) console.log(` ${e.note}`);
641
+ }
642
+ }
643
+
644
+ // Co-change summary
645
+ const cochangeOut = outgoing.filter((e) => e.rel === "co_change");
646
+ const cochangeIn = incoming.filter((e) => e.rel === "co_change");
647
+ if (cochangeOut.length + cochangeIn.length > 0) {
648
+ console.log(`\n\x1b[1;31m! Required co-changes:\x1b[0m\n`);
649
+ for (const e of cochangeOut) {
650
+ console.log(
651
+ ` -> You change \x1b[1m${nodeId}\x1b[0m, you MUST also update \x1b[1m${e.to}\x1b[0m`
652
+ );
653
+ if (e.note) console.log(` Reason: ${e.note}`);
654
+ }
655
+ for (const e of cochangeIn) {
656
+ console.log(
657
+ ` <- If \x1b[1m${e.from}\x1b[0m changes, this node (\x1b[1m${nodeId}\x1b[0m) must also be updated`
658
+ );
659
+ if (e.note) console.log(` Reason: ${e.note}`);
660
+ }
661
+ }
662
+
663
+ // Flows
664
+ const containingFlows = [];
665
+ for (const [name, flow] of Object.entries(flowgraph.flows || {})) {
666
+ const inFlow = flow.steps.some((s) => s.node === nodeId);
667
+ const branchTarget = flow.steps.some((s) => {
668
+ if (typeof s.then === "object" && s.then !== null) {
669
+ return Object.values(s.then).includes(nodeId);
670
+ }
671
+ return false;
672
+ });
673
+ if (inFlow || branchTarget)
674
+ containingFlows.push({ name, flow, inFlow, branchTarget });
675
+ }
676
+ console.log(`\n\x1b[1mFlows\x1b[0m (${containingFlows.length}):\n`);
677
+ if (containingFlows.length === 0) {
678
+ console.log(" (none)");
679
+ } else {
680
+ for (const { name, flow, inFlow, branchTarget } of containingFlows) {
681
+ const roles = [inFlow && "step", branchTarget && "branch target"]
682
+ .filter(Boolean)
683
+ .join(", ");
684
+ console.log(
685
+ ` \x1b[36m${name}\x1b[0m (${roles}) — trigger: ${flow.trigger}`
686
+ );
687
+ }
688
+ }
689
+
690
+ // Invariants
691
+ const scopedInvariants = (flowgraph.invariants || []).filter((inv) =>
692
+ (inv.scope || []).includes(nodeId)
693
+ );
694
+ console.log(`\n\x1b[1mInvariants\x1b[0m (${scopedInvariants.length}):\n`);
695
+ if (scopedInvariants.length === 0) {
696
+ console.log(" (none)");
697
+ } else {
698
+ for (const inv of scopedInvariants) {
699
+ console.log(` \x1b[33m${inv.id}\x1b[0m: ${inv.rule}`);
700
+ if (inv.enforce) console.log(` Enforce: ${inv.enforce}`);
701
+ }
702
+ }
703
+
704
+ console.log("");
705
+ }
706
+
707
+ // ─── Full Verification ──────────────────────────────────────────────────────
708
+
709
+ function runVerification(flowgraph, projectRoot, flowgraphPath) {
710
+ const root = flowgraph.meta.root || "";
711
+
712
+ console.log(`\n${"=".repeat(64)}`);
713
+ console.log(` FlowGraph Verification: ${flowgraph.meta.name || basename(flowgraphPath)}`);
714
+ console.log(` Spec version: ${flowgraph.$flowgraph || "unknown"}`);
715
+ console.log(`${"=".repeat(64)}\n`);
716
+
717
+ // 1. Structural — verify each node exists at loc
718
+ const nodeVerifiers = {
719
+ type: verifyTypeNode,
720
+ method: verifyMethodNode,
721
+ table: verifyTableNode,
722
+ endpoint: verifyEndpointNode,
723
+ event: verifyEventNode,
724
+ };
725
+
726
+ for (const [id, node] of Object.entries(flowgraph.nodes)) {
727
+ const verifier = nodeVerifiers[node.kind];
728
+ if (verifier) {
729
+ verifier(id, node, root, projectRoot);
730
+ } else {
731
+ // Custom kind — just check file exists
732
+ const { filePath } = resolveLoc(node.loc, root, projectRoot);
733
+ if (readSource(filePath)) {
734
+ record("PASS", "structural", id, `File exists at ${node.loc}`);
735
+ } else {
736
+ record("FAIL", "structural", id, `File not found: ${node.loc}`);
737
+ }
738
+ }
739
+ }
740
+
741
+ // 2. Relational — verify edges
742
+ for (const edge of flowgraph.edges) {
743
+ if (edge._comment) continue;
744
+ verifyEdge(edge, flowgraph.nodes, root, projectRoot);
745
+ }
746
+
747
+ // 3. Sequential — verify flows
748
+ if (flowgraph.flows) {
749
+ verifyFlows(flowgraph.flows, flowgraph.nodes);
750
+ }
751
+
752
+ // 4. Invariant
753
+ if (flowgraph.invariants) {
754
+ verifyInvariants(flowgraph.invariants, flowgraph.nodes, root, projectRoot);
755
+ }
756
+
757
+ // Print grouped results
758
+ const categories = ["structural", "relational", "flow", "invariant"];
759
+ for (const cat of categories) {
760
+ const catResults = results.filter((r) => r.category === cat);
761
+ if (catResults.length === 0) continue;
762
+
763
+ const p = catResults.filter((r) => r.status === "PASS").length;
764
+ const f = catResults.filter((r) => r.status === "FAIL").length;
765
+ const w = catResults.filter((r) => r.status === "WARN").length;
766
+
767
+ console.log(
768
+ `\n## ${cat.charAt(0).toUpperCase() + cat.slice(1)} (${p} pass, ${f} fail, ${w} warn)\n`
769
+ );
770
+ for (const r of catResults) {
771
+ const icon =
772
+ r.status === "PASS" ? "+" : r.status === "FAIL" ? "x" : "?";
773
+ const color =
774
+ r.status === "PASS"
775
+ ? "\x1b[32m"
776
+ : r.status === "FAIL"
777
+ ? "\x1b[31m"
778
+ : "\x1b[33m";
779
+ console.log(` ${color}[${r.status}]\x1b[0m ${icon} ${r.id}`);
780
+ if (r.message) console.log(` ${r.message}`);
781
+ }
782
+ }
783
+
784
+ // Summary
785
+ const pass = results.filter((r) => r.status === "PASS").length;
786
+ const fail = results.filter((r) => r.status === "FAIL").length;
787
+ const warn = results.filter((r) => r.status === "WARN").length;
788
+
789
+ console.log(`\n${"=".repeat(64)}`);
790
+ console.log(
791
+ ` Summary: \x1b[32m${pass} PASS\x1b[0m \x1b[31m${fail} FAIL\x1b[0m \x1b[33m${warn} WARN\x1b[0m (${results.length} total)`
792
+ );
793
+ console.log(`${"=".repeat(64)}\n`);
794
+
795
+ if (fail > 0) process.exit(1);
796
+ }
797
+
798
+ // ─── Mermaid Rendering ──────────────────────────────────────────────────────
799
+
800
+ function sanitizeMermaidId(id) {
801
+ return id.replace(/[:./ \-]/g, "_");
802
+ }
803
+
804
+ function shortLabel(id) {
805
+ return id.replace(/^[^:]+:/, "");
806
+ }
807
+
808
+ function renderDependencyGraph(fg) {
809
+ const lines = ["graph LR"];
810
+
811
+ // Group nodes by kind
812
+ const groups = {};
813
+ for (const [id, node] of Object.entries(fg.nodes)) {
814
+ const kind = node.kind;
815
+ if (!groups[kind]) groups[kind] = [];
816
+ groups[kind].push(id);
817
+ }
818
+
819
+ const kindLabels = {
820
+ type: "Types",
821
+ table: "Tables",
822
+ method: "Methods",
823
+ endpoint: "Endpoints",
824
+ event: "Events",
825
+ };
826
+
827
+ // Subgraphs
828
+ for (const [kind, ids] of Object.entries(groups)) {
829
+ lines.push(` subgraph ${kindLabels[kind] || kind}`);
830
+ for (const id of ids) {
831
+ lines.push(` ${sanitizeMermaidId(id)}["${shortLabel(id)}"]`);
832
+ }
833
+ lines.push(" end");
834
+ }
835
+
836
+ // Styles
837
+ lines.push("");
838
+ lines.push(" classDef type fill:#dae8fc,stroke:#6c8ebf,color:#333");
839
+ lines.push(" classDef table fill:#d5e8d4,stroke:#82b366,color:#333");
840
+ lines.push(" classDef method fill:#ffe6cc,stroke:#d6b656,color:#333");
841
+ lines.push(" classDef endpoint fill:#e1d5e7,stroke:#9673a6,color:#333");
842
+ lines.push(" classDef event fill:#fff2cc,stroke:#d6b656,color:#333");
843
+
844
+ for (const [kind, ids] of Object.entries(groups)) {
845
+ for (const id of ids) {
846
+ lines.push(` class ${sanitizeMermaidId(id)} ${kind}`);
847
+ }
848
+ }
849
+
850
+ // Edges
851
+ lines.push("");
852
+ for (const edge of fg.edges) {
853
+ if (edge._comment) continue;
854
+ const from = sanitizeMermaidId(edge.from);
855
+ const to = sanitizeMermaidId(edge.to);
856
+ if (edge.rel === "co_change") {
857
+ lines.push(` ${from} -.->|co_change| ${to}`);
858
+ } else {
859
+ lines.push(` ${from} -->|${edge.rel}| ${to}`);
860
+ }
861
+ }
862
+
863
+ return lines.join("\n");
864
+ }
865
+
866
+ function renderFlow(name, flow, allSteps) {
867
+ const lines = ["flowchart TD"];
868
+
869
+ lines.push(" classDef decision fill:#fff2cc,stroke:#d6b656,color:#333");
870
+ lines.push(" classDef terminal fill:#f8cecc,stroke:#b85450,color:#333");
871
+ lines.push(" classDef success fill:#d5e8d4,stroke:#82b366,color:#333");
872
+ lines.push("");
873
+
874
+ const steps = flow.steps;
875
+ const stepNodes = new Set(steps.map((s) => s.node));
876
+
877
+ // Collect external node references
878
+ const externalRefs = new Set();
879
+ for (const step of steps) {
880
+ if (typeof step.then === "object") {
881
+ for (const target of Object.values(step.then)) {
882
+ if (target !== "DONE" && target !== "FAIL" && target !== "next" && !stepNodes.has(target)) {
883
+ externalRefs.add(target);
884
+ }
885
+ }
886
+ }
887
+ }
888
+
889
+ // Trigger
890
+ lines.push(` trigger(["${flow.trigger}"])`);
891
+
892
+ // Declare step nodes
893
+ for (let i = 0; i < steps.length; i++) {
894
+ const step = steps[i];
895
+ const label = shortLabel(step.node);
896
+ const sid = `s${i}`;
897
+
898
+ if (typeof step.then === "object") {
899
+ lines.push(` ${sid}{"${label}"}`);
900
+ lines.push(` class ${sid} decision`);
901
+ } else {
902
+ lines.push(` ${sid}["${label}"]`);
903
+ }
904
+ }
905
+
906
+ // External reference nodes
907
+ for (const ref of externalRefs) {
908
+ const sid = sanitizeMermaidId(ref);
909
+ lines.push(` ${sid}["${shortLabel(ref)}"]:::external`);
910
+ }
911
+ if (externalRefs.size > 0) {
912
+ lines.push(" classDef external fill:#f5f5f5,stroke:#999,stroke-dasharray:5 5,color:#666");
913
+ }
914
+
915
+ // Terminals
916
+ lines.push(" done([DONE])");
917
+ lines.push(" class done success");
918
+ lines.push(" fail([FAIL])");
919
+ lines.push(" class fail terminal");
920
+ lines.push("");
921
+
922
+ // Trigger -> first step
923
+ lines.push(" trigger --> s0");
924
+
925
+ // Step edges
926
+ for (let i = 0; i < steps.length; i++) {
927
+ const step = steps[i];
928
+ const sid = `s${i}`;
929
+
930
+ if (step.then === "next") {
931
+ const next = i + 1 < steps.length ? `s${i + 1}` : "done";
932
+ lines.push(` ${sid} --> ${next}`);
933
+ } else if (typeof step.then === "object") {
934
+ for (const [label, target] of Object.entries(step.then)) {
935
+ let targetSid;
936
+ if (target === "DONE") {
937
+ targetSid = "done";
938
+ } else if (target === "FAIL") {
939
+ targetSid = "fail";
940
+ } else if (target === "next") {
941
+ targetSid = i + 1 < steps.length ? `s${i + 1}` : "done";
942
+ } else {
943
+ const idx = steps.findIndex((s) => s.node === target);
944
+ targetSid = idx >= 0 ? `s${idx}` : sanitizeMermaidId(target);
945
+ }
946
+ lines.push(` ${sid} -->|${label}| ${targetSid}`);
947
+ }
948
+ }
949
+ }
950
+
951
+ return lines.join("\n");
952
+ }
953
+
954
+ function runRender(flowgraph, flowgraphPath) {
955
+ const out = [];
956
+ out.push(`# ${flowgraph.meta.name} FlowGraph`, "");
957
+ if (flowgraph.meta.description) {
958
+ out.push(`> ${flowgraph.meta.description}`, "");
959
+ }
960
+
961
+ out.push("## Dependency Graph", "");
962
+ out.push("```mermaid");
963
+ out.push(renderDependencyGraph(flowgraph));
964
+ out.push("```", "");
965
+
966
+ if (flowgraph.flows && Object.keys(flowgraph.flows).length > 0) {
967
+ for (const [name, flow] of Object.entries(flowgraph.flows)) {
968
+ out.push(`## Flow: ${name}`, "");
969
+ out.push(`> ${flow.trigger}`, "");
970
+ out.push("```mermaid");
971
+ out.push(renderFlow(name, flow));
972
+ out.push("```", "");
973
+ }
974
+ }
975
+
976
+ if (flowgraph.invariants && flowgraph.invariants.length > 0) {
977
+ out.push("## Invariants", "");
978
+ out.push("| ID | Rule | Enforcement |");
979
+ out.push("|---|---|---|");
980
+ for (const inv of flowgraph.invariants) {
981
+ const rule = inv.rule.replace(/\|/g, "\\|");
982
+ const enforce = (inv.enforce || "").replace(/\|/g, "\\|");
983
+ out.push(`| ${inv.id} | ${rule} | ${enforce} |`);
984
+ }
985
+ out.push("");
986
+ }
987
+
988
+ const outputPath = flowgraphPath.replace(/\.json$/, ".md");
989
+ writeFileSync(outputPath, out.join("\n"));
990
+ console.log(`Rendered: ${outputPath}`);
991
+ }