infoalloggi-monorepo/apps/infoalloggi/flow-gen.ts

168 lines
4.9 KiB
TypeScript

import * as fs from "node:fs";
import * as path from "node:path";
interface FlowStep {
id: string;
label: string;
major: number;
level: number;
file: string; // New: Track file path
line: number; // New: Track line number
}
interface FlowMap {
[flowId: string]: FlowStep[];
}
/** CONFIGURATION **/
const SRC_DIR = "./src";
const OUTPUT_DIR = "./docs/flows";
const FILE_EXTENSIONS = [".ts", ".js", ".tsx", ".jsx"];
// Updated Regex: Handles //FLOWS and // FLOWS
const FLOW_REGEX = /\/\/\s*FLOWS:\s*([^|]+)\s*\|\s*([\w.]+)\s*\|\s*(.*)/g;
/** UTILS **/
function getFiles(dir: string, allFiles: string[] = []): string[] {
if (!fs.existsSync(dir)) return allFiles;
const files = fs.readdirSync(dir);
for (const file of files) {
const name = path.join(dir, file);
if (fs.statSync(name).isDirectory()) getFiles(name, allFiles);
else if (FILE_EXTENSIONS.includes(path.extname(name))) allFiles.push(name);
}
return allFiles;
}
function safeId(id: string): string {
// Mermaid IDs cannot contain dots
return `step_${id.replace(/\./g, "_")}`;
}
/** CORE LOGIC **/
function main() {
const flowData: FlowMap = {};
const files = getFiles(SRC_DIR);
console.log(`🔍 Scanning ${files.length} files for // FLOWS comments...`);
// 1. Extraction
files.forEach((file) => {
const content = fs.readFileSync(file, "utf-8");
const lines = content.split("\n");
lines.forEach((lineText, index) => {
let match: RegExpExecArray | null;
FLOW_REGEX.lastIndex = 0;
match = FLOW_REGEX.exec(lineText);
while (match !== null) {
const [_, flowId, id, label] = match.map((s) => s.trim());
if (!flowId || !id || !label) {
console.warn(`⚠️ Invalid FLOWS comment in ${file}: ${match[0]}`);
continue;
}
if (!flowData[flowId]) flowData[flowId] = [];
flowData[flowId].push({
id,
label,
major: parseInt(id.split(".")?.[0] || "0"),
level: id.split(".").length,
file: path.relative(process.cwd(), file), // Store relative path
line: index + 1, // Store line number (1-based)
});
match = FLOW_REGEX.exec(lineText);
}
});
});
if (Object.keys(flowData).length === 0) {
console.log(
"⚠️ No flow comments found. Try adding: // FLOWS: myflow | 1 | Start",
);
return;
}
// 2. Generate Output
if (!fs.existsSync(OUTPUT_DIR)) fs.mkdirSync(OUTPUT_DIR, { recursive: true });
for (const [flowId, steps] of Object.entries(flowData)) {
// Sort logically (1, 1.1, 1.2, 2)
const sorted = steps.sort((a, b) =>
a.id.localeCompare(b.id, undefined, { numeric: true }),
);
const mermaid = ["graph TD"];
const connections = new Set<string>();
sorted.forEach((current) => {
const nextSteps = sorted.filter((candidate) => {
const currentIdPrefix = `${current.id}.`;
// 1. DESCEND: Link to immediate children (3 -> 3.1, 3 -> 3.2)
// Check if candidate starts with current ID and is exactly one level deeper
if (
candidate.id.startsWith(currentIdPrefix) &&
candidate.level === current.level + 1
) {
// Ensure it's a direct child (e.g., 3 -> 3.1, not 3 -> 3.1.1)
const remaining = candidate.id.replace(currentIdPrefix, "");
return !remaining.includes(".");
}
// 2. MERGE: Link leaf nodes to the next major integer (3.1.2 -> 4, 3.2.1 -> 4)
const isLeaf = !sorted.some(
(s) => s.id.startsWith(currentIdPrefix) && s.id !== current.id,
);
if (
isLeaf &&
candidate.major === current.major + 1 &&
candidate.level === 1
) {
return true;
}
// 3. SEQUENCE (Optional): If you want 3.1.1 -> 3.1.2
// If they share the exact same parent and are consecutive
const currentParent = current.id.includes(".")
? current.id.substring(0, current.id.lastIndexOf("."))
: null;
const candParent = candidate.id.includes(".")
? candidate.id.substring(0, candidate.id.lastIndexOf("."))
: null;
if (
currentParent !== null &&
currentParent === candParent &&
candidate.level === current.level
) {
// Only link if you want branches to be sequential.
// In your case, you likely want 3.1.1 and 3.1.2 to both go to 4 independently.
// So we leave this FALSE to keep them as parallel paths.
return false;
}
return false;
});
nextSteps.forEach((next) => {
const link = ` ${safeId(current.id)}("${current.label}") --> ${safeId(next.id)}("${next.label}")`;
connections.add(link);
});
});
// Handle single-node flows (start node with no connections)
if (connections.size === 0 && sorted.length > 0 && sorted[0]) {
mermaid.push(` ${safeId(sorted[0].id)}("${sorted[0].label}")`);
} else {
connections.forEach((c) => mermaid.push(c));
}
const outPath = path.join(OUTPUT_DIR, `${flowId}.md`);
const header = "```mermaid\n\n";
const footer = "\n```";
fs.writeFileSync(outPath, header + mermaid.join("\n") + footer);
console.log(`✅ Generated: ${outPath}`);
}
}
main();