Skip to content

Commit 413abd4

Browse files
committed
feat(export): add graph JSON export
1 parent 35fe183 commit 413abd4

7 files changed

Lines changed: 299 additions & 6 deletions

File tree

README.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -102,7 +102,8 @@ OrgScript is intentionally artifact-first. A single `.orgs` file can produce mul
102102
5. HTML documentation
103103
6. BPMN skeleton exports
104104
7. LittleHorse workflow skeletons
105-
8. AI-ready structured JSON exports
105+
8. Graph JSON exports
106+
9. AI-ready structured JSON exports
106107

107108
Generated examples live under:
108109

@@ -202,6 +203,7 @@ orgscript export mermaid <file>
202203
orgscript export html <file>
203204
orgscript export bpmn <file>
204205
orgscript export littlehorse <file>
206+
orgscript export graph <file>
205207
orgscript export context <file>
206208
orgscript analyze <file> [--json]
207209
```

docs/OrgScript-Handbuch-DE.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@ orgscript export markdown ./examples/lead-qualification.orgs --with-annotations
7070
orgscript export context ./examples/lead-qualification.orgs
7171
orgscript export bpmn ./examples/lead-qualification.orgs
7272
orgscript export littlehorse ./examples/lead-qualification.orgs
73+
orgscript export graph ./examples/lead-qualification.orgs
7374
```
7475

7576
Was sie tun:
@@ -80,6 +81,7 @@ Was sie tun:
8081
- `export context` erzeugt ein strukturiertes Paket fuer KI und Tooling
8182
- `export bpmn` erzeugt ein BPMN-XML-Skelett fuer Prozessbloecke
8283
- `export littlehorse` erzeugt ein LittleHorse-Workflow-Skelett (Pseudo-Code)
84+
- `export graph` erzeugt ein kompaktes Knoten-und-Kanten-JSON
8385

8486
## Kommentare und Annotationen
8587

@@ -164,6 +166,7 @@ Standardverhalten der Exporter:
164166
- Annotationen sind in `export context` enthalten
165167
- Annotationen erscheinen in Markdown und HTML nur mit `--with-annotations`
166168
- BPMN- und LittleHorse-Exporter sind Skelette und brauchen manuelle Nacharbeit
169+
- Graph-Export ist eine kompakte Strukturansicht, kein semantischer Ersatz fuer das kanonische Modell
167170

168171
So bleibt Geschaeftslogik explizit und Kommentare werden keine versteckte zweite Sprache.
169172

docs/OrgScript-Manual-EN.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@ orgscript export markdown ./examples/lead-qualification.orgs --with-annotations
7070
orgscript export context ./examples/lead-qualification.orgs
7171
orgscript export bpmn ./examples/lead-qualification.orgs
7272
orgscript export littlehorse ./examples/lead-qualification.orgs
73+
orgscript export graph ./examples/lead-qualification.orgs
7374
```
7475

7576
What they do:
@@ -80,6 +81,7 @@ What they do:
8081
- `export context` creates a structured AI/tooling context bundle
8182
- `export bpmn` creates a BPMN XML skeleton for process blocks
8283
- `export littlehorse` creates a LittleHorse workflow skeleton (pseudo-code scaffold)
84+
- `export graph` creates a compact nodes-and-edges JSON graph
8385

8486
## Comments and annotations
8587

@@ -164,6 +166,7 @@ Default exporter policy:
164166
- annotations are included in `export context`
165167
- annotations appear in Markdown and HTML only when you pass `--with-annotations`
166168
- BPMN and LittleHorse exporters are skeletons and require manual review before use
169+
- Graph export is a compact structural view, not a semantic replacement for the canonical model
167170

168171
This keeps business meaning explicit and prevents comments from becoming a hidden second language.
169172

src/command-line.js

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ const { toMermaidMarkdown } = require("./export-mermaid");
1818
const { toHtmlDocumentation } = require("./export-html");
1919
const { toBpmnXml } = require("./export-bpmn");
2020
const { toLittleHorseSkeleton } = require("./export-littlehorse");
21+
const { toGraphJson } = require("./export-graph");
2122
const { formatDocument } = require("./formatter");
2223
const { lintDocument, summarizeFindings } = require("./linter");
2324
const { buildModel, validateFile } = require("./validate");
@@ -157,6 +158,7 @@ Targets:
157158
html Self-contained documentation page
158159
bpmn BPMN XML skeleton
159160
littlehorse LittleHorse workflow skeleton (pseudo-code)
161+
graph Compact nodes + edges graph JSON
160162
161163
Options:
162164
--with-annotations Include annotations and document metadata in supported Markdown and HTML exports
@@ -245,6 +247,16 @@ ${docs}`);
245247
Usage:
246248
orgscript export littlehorse <file>
247249
250+
${docs}`);
251+
return;
252+
}
253+
254+
if (target === "graph") {
255+
console.log(`orgscript export graph
256+
257+
Usage:
258+
orgscript export graph <file>
259+
248260
${docs}`);
249261
return;
250262
}
@@ -487,6 +499,27 @@ function run(args) {
487499
}
488500
}
489501

502+
if (command === "export" && maybeSubcommand === "graph") {
503+
const absolutePath = resolveFile("export", maybeFile);
504+
const result = buildModel(absolutePath);
505+
506+
if (!result.ok) {
507+
printDiagnostics(
508+
`EXPORT ${toDisplayPath(absolutePath)}`,
509+
createValidateReport(absolutePath, result).diagnostics
510+
);
511+
process.exit(1);
512+
}
513+
514+
try {
515+
console.log(JSON.stringify(toGraphJson(toCanonicalModel(result.ast)), null, 2));
516+
process.exit(0);
517+
} catch (error) {
518+
console.error(`Cannot export graph from ${absolutePath}: ${error.message}`);
519+
process.exit(1);
520+
}
521+
}
522+
490523
if (command === "export" && maybeSubcommand === "html") {
491524
const absolutePath = resolveFile("export", maybeFile);
492525
const result = buildModel(absolutePath);

src/export-graph.js

Lines changed: 250 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,250 @@
1+
function toGraphJson(model) {
2+
const nodes = [];
3+
const edges = [];
4+
const supported = (model.body || []).filter(
5+
(node) => node.type === "process" || node.type === "stateflow"
6+
);
7+
8+
if (supported.length === 0) {
9+
throw new Error(
10+
"No graph-exportable blocks found. Supported block types: process, stateflow."
11+
);
12+
}
13+
14+
supported.forEach((node, index) => {
15+
if (node.type === "process") {
16+
renderProcessGraph(node, index + 1, nodes, edges);
17+
return;
18+
}
19+
20+
if (node.type === "stateflow") {
21+
renderStateflowGraph(node, index + 1, nodes, edges);
22+
}
23+
});
24+
25+
return {
26+
version: "0.1",
27+
type: "graph",
28+
nodes,
29+
edges,
30+
};
31+
}
32+
33+
function renderProcessGraph(node, index, nodes, edges) {
34+
const groupId = `process:${node.name}`;
35+
const prefix = `p${index}`;
36+
nodes.push({
37+
id: groupId,
38+
type: "process",
39+
label: node.name,
40+
});
41+
42+
const builder = createGraphBuilder(prefix, groupId, nodes, edges);
43+
const startId = builder.addNode("start", node.name || "start");
44+
const exits = builder.renderSequence(node.body || [], [{ id: startId }]);
45+
46+
if (exits.length > 0) {
47+
const endId = builder.addNode("end", "done");
48+
builder.connectIncoming(exits, endId);
49+
}
50+
}
51+
52+
function renderStateflowGraph(node, index, nodes, edges) {
53+
const groupId = `stateflow:${node.name}`;
54+
nodes.push({
55+
id: groupId,
56+
type: "stateflow",
57+
label: node.name,
58+
});
59+
60+
const prefix = `s${index}`;
61+
const stateIds = new Map();
62+
(node.states || []).forEach((state, stateIndex) => {
63+
const id = `${prefix}_state_${stateIndex + 1}`;
64+
stateIds.set(state, id);
65+
nodes.push({
66+
id,
67+
type: "state",
68+
label: state,
69+
group: groupId,
70+
});
71+
});
72+
73+
(node.transitions || []).forEach((edge) => {
74+
const from = stateIds.get(edge.from) || `${prefix}_${sanitizeId(edge.from)}`;
75+
const to = stateIds.get(edge.to) || `${prefix}_${sanitizeId(edge.to)}`;
76+
edges.push({
77+
from,
78+
to,
79+
label: "",
80+
type: "transition",
81+
group: groupId,
82+
});
83+
});
84+
}
85+
86+
function createGraphBuilder(prefix, groupId, nodes, edges) {
87+
let counter = 0;
88+
89+
function addNode(type, label) {
90+
counter += 1;
91+
const id = `${prefix}_${type}_${counter}`;
92+
nodes.push({
93+
id,
94+
type,
95+
label,
96+
group: groupId,
97+
});
98+
return id;
99+
}
100+
101+
function connectIncoming(connectors, targetId) {
102+
connectors.forEach((connector) => {
103+
edges.push({
104+
from: connector.id,
105+
to: targetId,
106+
label: connector.label || "",
107+
type: "flow",
108+
group: groupId,
109+
});
110+
});
111+
}
112+
113+
function renderSequence(statements, incoming) {
114+
let pending = incoming;
115+
116+
for (const statement of statements) {
117+
pending = renderStatement(statement, pending);
118+
}
119+
120+
return pending;
121+
}
122+
123+
function renderStatement(statement, incoming) {
124+
if (statement.type === "when") {
125+
const id = addNode("trigger", `when ${statement.trigger || "unknown"}`);
126+
connectIncoming(incoming, id);
127+
return [{ id }];
128+
}
129+
130+
if (statement.type === "if") {
131+
const decisionId = addNode("decision", `if ${formatCondition(statement.condition)}`);
132+
connectIncoming(incoming, decisionId);
133+
134+
const exits = [];
135+
exits.push(
136+
...renderSequence(statement.then || [], [
137+
{ id: decisionId, label: `if ${formatCondition(statement.condition)}` },
138+
])
139+
);
140+
141+
for (const branch of statement.elseIf || []) {
142+
exits.push(
143+
...renderSequence(branch.then || [], [
144+
{ id: decisionId, label: `else if ${formatCondition(branch.condition)}` },
145+
])
146+
);
147+
}
148+
149+
if (statement.else && (statement.else.body || []).length > 0) {
150+
exits.push(...renderSequence(statement.else.body || [], [{ id: decisionId, label: "else" }]));
151+
} else {
152+
exits.push({ id: decisionId, label: "else" });
153+
}
154+
155+
const joinId = addNode("merge", "merge");
156+
connectIncoming(exits, joinId);
157+
return [{ id: joinId }];
158+
}
159+
160+
if (statement.type === "stop") {
161+
const endId = addNode("stop", "stop");
162+
connectIncoming(incoming, endId);
163+
return [];
164+
}
165+
166+
const id = addNode("action", formatAction(statement));
167+
connectIncoming(incoming, id);
168+
return [{ id }];
169+
}
170+
171+
return {
172+
addNode,
173+
connectIncoming,
174+
renderSequence,
175+
};
176+
}
177+
178+
function formatAction(statement) {
179+
if (statement.type === "assign") {
180+
return `assign ${statement.target || "?"} = ${formatExpression(statement.value)}`;
181+
}
182+
183+
if (statement.type === "transition") {
184+
return `transition ${statement.target || "?"} to ${formatExpression(statement.value)}`;
185+
}
186+
187+
if (statement.type === "notify") {
188+
return `notify ${statement.target} "${statement.message}"`;
189+
}
190+
191+
if (statement.type === "create") {
192+
return `create ${statement.entity}`;
193+
}
194+
195+
if (statement.type === "update") {
196+
return `update ${statement.target || "?"} = ${formatExpression(statement.value)}`;
197+
}
198+
199+
if (statement.type === "require") {
200+
return `require ${statement.requirement}`;
201+
}
202+
203+
return statement.type;
204+
}
205+
206+
function formatCondition(condition) {
207+
if (!condition) {
208+
return "unknown condition";
209+
}
210+
211+
if (condition.type === "logical") {
212+
return condition.conditions.map(formatCondition).join(` ${condition.operator} `);
213+
}
214+
215+
return `${formatExpression(condition.left)} ${condition.operator} ${formatExpression(condition.right)}`;
216+
}
217+
218+
function formatExpression(expression) {
219+
if (!expression) {
220+
return "?";
221+
}
222+
223+
if (expression.type === "field") {
224+
return expression.path;
225+
}
226+
227+
if (expression.type === "identifier") {
228+
return expression.value;
229+
}
230+
231+
if (expression.type === "string") {
232+
return `"${expression.value}"`;
233+
}
234+
235+
if (expression.type === "boolean") {
236+
return expression.value ? "true" : "false";
237+
}
238+
239+
return String(expression.value);
240+
}
241+
242+
function sanitizeId(value) {
243+
return String(value)
244+
.replace(/[^A-Za-z0-9_]/g, "_")
245+
.replace(/^(\d)/, "_$1");
246+
}
247+
248+
module.exports = {
249+
toGraphJson,
250+
};

src/index.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ const { toMermaidMarkdown } = require("./export-mermaid");
66
const { toHtmlDocumentation } = require("./export-html");
77
const { toBpmnXml } = require("./export-bpmn");
88
const { toLittleHorseSkeleton } = require("./export-littlehorse");
9+
const { toGraphJson } = require("./export-graph");
910
const { analyzeDocument } = require("./analyze");
1011
const { toAiContext } = require("./export-context");
1112
const { formatDocument } = require("./formatter");
@@ -28,5 +29,6 @@ module.exports = {
2829
toHtmlDocumentation,
2930
toBpmnXml,
3031
toLittleHorseSkeleton,
32+
toGraphJson,
3133
toAiContext,
3234
};

0 commit comments

Comments
 (0)