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.
- package/LICENSE +21 -0
- package/README.md +210 -0
- package/bin/flowgraph.mjs +991 -0
- package/flowgraph-spec-v2.1.md +252 -0
- package/package.json +33 -0
|
@@ -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
|
+
}
|