@jsleekr/graft 5.7.1 → 5.8.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/README.md +114 -83
- package/dist/analyzer/scope.js +18 -6
- package/dist/codegen/backend.d.ts +1 -0
- package/dist/codegen/claude-backend.d.ts +1 -0
- package/dist/codegen/claude-backend.js +19 -3
- package/dist/codegen/codegen.js +26 -8
- package/dist/codegen/hooks.d.ts +5 -0
- package/dist/codegen/hooks.js +83 -0
- package/dist/codegen/orchestration.js +48 -3
- package/dist/codegen/settings.js +19 -11
- package/dist/errors/diagnostics.d.ts +8 -2
- package/dist/errors/diagnostics.js +66 -9
- package/dist/index.js +131 -3
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,71 +1,88 @@
|
|
|
1
|
-
[](https://nodejs.org)
|
|
1
|
+
[](https://www.npmjs.com/package/@jsleekr/graft)
|
|
2
|
+
[](https://nodejs.org)
|
|
4
3
|
[](./LICENSE)
|
|
5
4
|
|
|
6
5
|
# Graft
|
|
7
6
|
|
|
8
|
-
**
|
|
7
|
+
**Define multi-agent pipelines in 10 lines. Compile to Claude Code in 1 second.**
|
|
9
8
|
|
|
10
|
-
Graft compiles `.gft` files into [Claude Code](https://docs.anthropic.com/en/docs/claude-code) harness structures
|
|
9
|
+
Graft is a graph-native language that compiles `.gft` files into [Claude Code](https://docs.anthropic.com/en/docs/claude-code) harness structures — agents, hooks, orchestration, and settings — with compile-time token budget analysis.
|
|
11
10
|
|
|
12
|
-
##
|
|
11
|
+
## 10-Minute Getting Started
|
|
12
|
+
|
|
13
|
+
### 1. Install
|
|
13
14
|
|
|
14
15
|
```bash
|
|
15
|
-
npm install -g @
|
|
16
|
+
npm install -g @jsleekr/graft
|
|
16
17
|
```
|
|
17
18
|
|
|
18
|
-
|
|
19
|
+
Requires Node.js 20+.
|
|
19
20
|
|
|
20
|
-
|
|
21
|
-
context UserRequest(max_tokens: 500) {
|
|
22
|
-
question: String
|
|
23
|
-
}
|
|
21
|
+
### 2. Create a project
|
|
24
22
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
confidence: Float(0..1)
|
|
30
|
-
}
|
|
31
|
-
}
|
|
23
|
+
```bash
|
|
24
|
+
graft init my-pipeline
|
|
25
|
+
cd my-pipeline
|
|
26
|
+
```
|
|
32
27
|
|
|
33
|
-
node
|
|
34
|
-
reads: [Research.findings]
|
|
35
|
-
produces Answer { response: String }
|
|
36
|
-
}
|
|
28
|
+
This creates `pipeline.gft` — a simple two-node pipeline ready to compile.
|
|
37
29
|
|
|
38
|
-
|
|
30
|
+
### 3. Compile
|
|
39
31
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
}
|
|
32
|
+
```bash
|
|
33
|
+
graft compile pipeline.gft
|
|
43
34
|
```
|
|
44
35
|
|
|
45
|
-
|
|
36
|
+
Output:
|
|
46
37
|
|
|
47
38
|
```
|
|
48
|
-
$ graft compile hello.gft
|
|
49
|
-
|
|
50
39
|
✓ Parse OK
|
|
51
40
|
✓ Scope check OK
|
|
52
41
|
✓ Type check OK
|
|
53
42
|
✓ Token analysis:
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
Best path:
|
|
43
|
+
Analyst in ~ 500 out ~ 2,000
|
|
44
|
+
Reviewer in ~ 840 out ~ 1,000
|
|
45
|
+
Best path: 4,340 tokens ✓ within budget (10,000)
|
|
57
46
|
|
|
58
47
|
Generated:
|
|
59
|
-
.claude/agents/
|
|
60
|
-
.claude/agents/
|
|
61
|
-
.claude/hooks/
|
|
62
|
-
.claude/CLAUDE.md
|
|
63
|
-
.claude/settings.json
|
|
48
|
+
.claude/agents/analyst.md ← agent definition
|
|
49
|
+
.claude/agents/reviewer.md ← agent definition
|
|
50
|
+
.claude/hooks/analyst-to-reviewer.js ← edge transform
|
|
51
|
+
.claude/CLAUDE.md ← orchestration plan
|
|
52
|
+
.claude/settings.json ← model routing + hooks
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
### 4. Run in Claude Code
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
# Create input
|
|
59
|
+
echo '{"question": "What is Graft?"}' > .graft/session/input.json
|
|
60
|
+
|
|
61
|
+
# Open in Claude Code
|
|
62
|
+
claude
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
Then tell Claude Code:
|
|
66
|
+
|
|
67
|
+
> `.claude/CLAUDE.md`의 실행 계획을 따라서 파이프라인을 실행해줘. 입력은 `.graft/session/input.json`에 있어.
|
|
68
|
+
|
|
69
|
+
Claude Code reads the generated `.claude/` structure and runs the pipeline automatically.
|
|
70
|
+
|
|
71
|
+
### 5. Check the results
|
|
72
|
+
|
|
73
|
+
```bash
|
|
74
|
+
cat .graft/session/node_outputs/reviewer.json
|
|
64
75
|
```
|
|
65
76
|
|
|
66
|
-
|
|
77
|
+
That's it. You have a working multi-agent pipeline.
|
|
67
78
|
|
|
68
|
-
|
|
79
|
+
---
|
|
80
|
+
|
|
81
|
+
## How It Works
|
|
82
|
+
|
|
83
|
+
```
|
|
84
|
+
.gft Source → Graft Compiler → .claude/ output → Claude Code runs it
|
|
85
|
+
```
|
|
69
86
|
|
|
70
87
|
| Graft Source | Generated Output | Purpose |
|
|
71
88
|
|-------------|-----------------|---------|
|
|
@@ -81,9 +98,53 @@ Multi-agent systems waste tokens passing full context between agents. Graft fixe
|
|
|
81
98
|
|
|
82
99
|
- **Edge transforms** extract only what the next agent needs (`select`, `drop`, `compact`, `filter`)
|
|
83
100
|
- **Compile-time token analysis** catches budget overruns before you spend API credits
|
|
84
|
-
- **Typed output schemas** enforce structured JSON
|
|
101
|
+
- **Typed output schemas** enforce structured JSON between agents
|
|
85
102
|
- **Explicit `reads`** declarations prevent context leaks — the compiler verifies scope
|
|
86
103
|
|
|
104
|
+
## Example: Code Review Pipeline
|
|
105
|
+
|
|
106
|
+
```graft
|
|
107
|
+
context PullRequest(max_tokens: 2k) {
|
|
108
|
+
diff: String
|
|
109
|
+
description: String
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
node SecurityReviewer(model: sonnet, budget: 4k/2k) {
|
|
113
|
+
reads: [PullRequest]
|
|
114
|
+
produces SecurityAnalysis {
|
|
115
|
+
vulnerabilities: List<String>
|
|
116
|
+
risk_level: enum(low, medium, high, critical)
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
node LogicReviewer(model: sonnet, budget: 4k/2k) {
|
|
121
|
+
reads: [PullRequest]
|
|
122
|
+
produces LogicAnalysis {
|
|
123
|
+
issues: List<String>
|
|
124
|
+
complexity: Int
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
node SeniorReviewer(model: opus, budget: 6k/3k) {
|
|
129
|
+
reads: [SecurityAnalysis, LogicAnalysis, PullRequest]
|
|
130
|
+
produces FinalReview {
|
|
131
|
+
approved: Bool
|
|
132
|
+
summary: String
|
|
133
|
+
action_items: List<String>
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
edge SecurityReviewer -> SeniorReviewer | select(vulnerabilities, risk_level) | compact
|
|
138
|
+
edge LogicReviewer -> SeniorReviewer | select(issues) | compact
|
|
139
|
+
|
|
140
|
+
graph CodeReview(input: PullRequest, output: FinalReview, budget: 25k) {
|
|
141
|
+
parallel { SecurityReviewer LogicReviewer }
|
|
142
|
+
-> SeniorReviewer -> done
|
|
143
|
+
}
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
This compiles to 3 agents running in parallel, with edge transforms that strip unnecessary data before the senior review.
|
|
147
|
+
|
|
87
148
|
## Language Features
|
|
88
149
|
|
|
89
150
|
### Contexts and Nodes
|
|
@@ -153,8 +214,6 @@ A -> let score = A.risk_score * 2
|
|
|
153
214
|
-> B -> done
|
|
154
215
|
```
|
|
155
216
|
|
|
156
|
-
Supported: arithmetic (`+`, `-`, `*`, `/`, `%`), comparison (`>`, `>=`, `==`, `!=`), logical (`&&`, `||`), null coalescing (`??`), string interpolation, builtins (`len`, `max`, `min`, `abs`, `round`, `keys`, `str`).
|
|
157
|
-
|
|
158
217
|
### Type System
|
|
159
218
|
|
|
160
219
|
```
|
|
@@ -172,63 +231,35 @@ graft compile <file.gft> [--out-dir <dir>] # Compile to .claude/ structure
|
|
|
172
231
|
graft check <file.gft> # Parse + analyze only
|
|
173
232
|
graft run <file.gft> --input <json> [--dry-run] [--verbose] # Compile and execute
|
|
174
233
|
graft init <name> # Scaffold a new project
|
|
234
|
+
graft watch <file.gft> [--out-dir <dir>] # Watch and recompile on changes
|
|
235
|
+
graft visualize <file.gft> # Output pipeline DAG as Mermaid diagram
|
|
175
236
|
```
|
|
176
237
|
|
|
177
|
-
## Editor Support
|
|
178
|
-
|
|
179
|
-
### VS Code
|
|
180
|
-
|
|
181
|
-
The [Graft VS Code extension](editors/vscode/) provides:
|
|
182
|
-
- Syntax highlighting (TextMate grammar)
|
|
183
|
-
- Real-time diagnostics
|
|
184
|
-
- Hover information (types, token budgets)
|
|
185
|
-
- Go-to-definition, find references, rename
|
|
186
|
-
- Completions (keywords, declarations, imports)
|
|
187
|
-
- Code actions (auto-import)
|
|
188
|
-
- Document symbols
|
|
189
|
-
|
|
190
238
|
## Programmatic API
|
|
191
239
|
|
|
192
240
|
```typescript
|
|
193
|
-
import {
|
|
194
|
-
import { Executor } from '@
|
|
195
|
-
import type { Program
|
|
241
|
+
import { compile } from '@jsleekr/graft/compiler';
|
|
242
|
+
import { Executor } from '@jsleekr/graft/runtime';
|
|
243
|
+
import type { Program } from '@jsleekr/graft/types';
|
|
196
244
|
|
|
197
|
-
const result =
|
|
245
|
+
const result = compile(source, 'pipeline.gft');
|
|
198
246
|
if (result.success) {
|
|
199
247
|
console.log(`Parsed ${result.program.nodes.length} nodes`);
|
|
200
248
|
}
|
|
201
249
|
```
|
|
202
250
|
|
|
251
|
+
## Editor Support
|
|
252
|
+
|
|
253
|
+
The [Graft VS Code extension](editors/vscode/) provides syntax highlighting, real-time diagnostics, hover, go-to-definition, find references, rename, completions, and code actions.
|
|
254
|
+
|
|
203
255
|
## Development
|
|
204
256
|
|
|
205
257
|
```bash
|
|
206
258
|
git clone https://github.com/JSLEEKR/graft.git
|
|
207
259
|
cd graft && npm install
|
|
208
260
|
npm run build # Compile TypeScript
|
|
209
|
-
npm test # Run all 1,
|
|
210
|
-
```
|
|
211
|
-
|
|
212
|
-
## Version History
|
|
213
|
-
|
|
214
|
-
| Version | Highlights |
|
|
215
|
-
|---------|-----------|
|
|
216
|
-
| **v5.3** | Parallel codegen test coverage, hook entry merging |
|
|
217
|
-
| **v5.2** | Parallel→sequential edge transforms, agent input overrides, graceful hooks |
|
|
218
|
-
| **v5.1** | Claude Code compatibility, `graft init`, CI/CD, README rewrite |
|
|
219
|
-
| **v5.0** | Condition-to-Expr AST unification, strict equality, codegen expression display |
|
|
220
|
-
| **v4.9** | Codegen expression display |
|
|
221
|
-
| **v4.8** | LSP expression intelligence — hover, go-to-def, completions |
|
|
222
|
-
| **v4.7** | Null coalescing (`??`), runtime expression hardening |
|
|
223
|
-
| **v4.6** | Logical operators (`&&`, `||`), conditional type warnings |
|
|
224
|
-
| **v4.5** | Comparison operators, conditional expressions |
|
|
225
|
-
| **v4.4** | String interpolation, expression extraction |
|
|
226
|
-
| **v4.0** | Variables, expressions, graph parameters, graph calls |
|
|
227
|
-
| **v3.0** | Pluggable codegen backends, field-level writes, failure strategies |
|
|
228
|
-
| **v2.0** | Import system, persistent memory |
|
|
229
|
-
| **v1.0** | Full compiler pipeline, CLI |
|
|
230
|
-
|
|
231
|
-
See [CHANGELOG.md](CHANGELOG.md) for full details.
|
|
261
|
+
npm test # Run all 1,574 tests
|
|
262
|
+
```
|
|
232
263
|
|
|
233
264
|
## License
|
|
234
265
|
|
package/dist/analyzer/scope.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { GraftError } from '../errors/diagnostics.js';
|
|
1
|
+
import { GraftError, didYouMean } from '../errors/diagnostics.js';
|
|
2
2
|
import { ProgramIndex } from '../program-index.js';
|
|
3
3
|
import { checkVarCollision, checkExprSources, checkGraphCallArgs, checkGraphRecursion, } from './graph-checker.js';
|
|
4
4
|
export class ScopeChecker {
|
|
@@ -56,17 +56,28 @@ export class ScopeChecker {
|
|
|
56
56
|
const isProduces = this.index.producesFieldsMap.has(ref.context);
|
|
57
57
|
const isMemory = this.index.memoryMap.has(ref.context);
|
|
58
58
|
if (!isContext && !isProduces && !isMemory) {
|
|
59
|
-
|
|
59
|
+
const allNames = [
|
|
60
|
+
...this.index.contextMap.keys(),
|
|
61
|
+
...this.index.producesFieldsMap.keys(),
|
|
62
|
+
...this.index.memoryMap.keys(),
|
|
63
|
+
];
|
|
64
|
+
const suggestion = didYouMean(ref.context, allNames);
|
|
65
|
+
const help = suggestion
|
|
66
|
+
? `did you mean '${suggestion}'?`
|
|
67
|
+
: undefined;
|
|
68
|
+
errors.push(new GraftError(`'${ref.context}' is not declared as a context, produces output, or memory`, ref.location, 'error', 'SCOPE_UNDEFINED_REF', help));
|
|
60
69
|
continue;
|
|
61
70
|
}
|
|
62
71
|
// Check partial reference fields
|
|
63
72
|
if (ref.field) {
|
|
64
73
|
if (isContext) {
|
|
65
74
|
const ctx = this.index.contextMap.get(ref.context);
|
|
66
|
-
const fieldNames =
|
|
75
|
+
const fieldNames = ctx.fields.map(f => f.name);
|
|
76
|
+
const fieldSet = new Set(fieldNames);
|
|
67
77
|
for (const f of ref.field) {
|
|
68
|
-
if (!
|
|
69
|
-
|
|
78
|
+
if (!fieldSet.has(f)) {
|
|
79
|
+
const suggestion = didYouMean(f, fieldNames);
|
|
80
|
+
errors.push(new GraftError(`Field '${f}' does not exist in context '${ref.context}'`, ref.location, 'error', 'SCOPE_FIELD_NOT_FOUND', suggestion ? `did you mean '${suggestion}'?` : undefined));
|
|
70
81
|
}
|
|
71
82
|
}
|
|
72
83
|
}
|
|
@@ -74,7 +85,8 @@ export class ScopeChecker {
|
|
|
74
85
|
const fields = this.index.producesFieldsMap.get(ref.context);
|
|
75
86
|
for (const f of ref.field) {
|
|
76
87
|
if (!fields.has(f)) {
|
|
77
|
-
|
|
88
|
+
const suggestion = didYouMean(f, [...fields.keys()]);
|
|
89
|
+
errors.push(new GraftError(`Field '${f}' does not exist in produces '${ref.context}'`, ref.location, 'error', 'SCOPE_FIELD_NOT_FOUND', suggestion ? `did you mean '${suggestion}'?` : undefined));
|
|
78
90
|
}
|
|
79
91
|
}
|
|
80
92
|
}
|
|
@@ -11,6 +11,7 @@ export interface CodegenBackend {
|
|
|
11
11
|
readonly name: string;
|
|
12
12
|
generateAgent(node: NodeDecl, memoryNames: Set<string>, ctx: CodegenContext): string;
|
|
13
13
|
generateHook(edge: EdgeDecl, ctx: CodegenContext): string | null;
|
|
14
|
+
generateConditionalHook?(edge: EdgeDecl, ctx: CodegenContext): string | null;
|
|
14
15
|
generateOrchestration(ctx: CodegenContext): string;
|
|
15
16
|
generateSettings(ctx: CodegenContext): Record<string, unknown>;
|
|
16
17
|
}
|
|
@@ -4,6 +4,7 @@ export declare class ClaudeCodeBackend implements CodegenBackend {
|
|
|
4
4
|
readonly name = "claude";
|
|
5
5
|
generateAgent(node: NodeDecl, memoryNames: Set<string>, ctx: CodegenContext): string;
|
|
6
6
|
generateHook(edge: EdgeDecl, _ctx: CodegenContext): string | null;
|
|
7
|
+
generateConditionalHook(edge: EdgeDecl, _ctx: CodegenContext): string | null;
|
|
7
8
|
generateOrchestration(ctx: CodegenContext): string;
|
|
8
9
|
generateSettings(ctx: CodegenContext): Record<string, unknown>;
|
|
9
10
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { generateAgent } from './agents.js';
|
|
2
|
-
import { generateHook } from './hooks.js';
|
|
2
|
+
import { generateHook, generateConditionalHook } from './hooks.js';
|
|
3
3
|
import { generateOrchestration } from './orchestration.js';
|
|
4
4
|
import { generateSettings } from './settings.js';
|
|
5
5
|
export class ClaudeCodeBackend {
|
|
@@ -7,8 +7,9 @@ export class ClaudeCodeBackend {
|
|
|
7
7
|
generateAgent(node, memoryNames, ctx) {
|
|
8
8
|
// Compute input overrides: map produces names to actual file paths
|
|
9
9
|
const inputOverrides = new Map();
|
|
10
|
-
// 1. For edges with transforms: use the transformed output path
|
|
11
|
-
// 2. For edges without transforms: use the source's raw output path
|
|
10
|
+
// 1. For direct edges with transforms: use the transformed output path
|
|
11
|
+
// 2. For direct edges without transforms: use the source's raw output path
|
|
12
|
+
// 3. For conditional edges targeting this node: use the source's raw output path
|
|
12
13
|
for (const edge of ctx.program.edges) {
|
|
13
14
|
if (edge.target.kind === 'direct' && edge.target.node === node.name) {
|
|
14
15
|
const sourceNode = ctx.program.nodes.find(n => n.name === edge.source);
|
|
@@ -22,6 +23,18 @@ export class ClaudeCodeBackend {
|
|
|
22
23
|
}
|
|
23
24
|
}
|
|
24
25
|
}
|
|
26
|
+
else if (edge.target.kind === 'conditional') {
|
|
27
|
+
const isTarget = edge.target.branches.some(b => b.target === node.name);
|
|
28
|
+
if (isTarget) {
|
|
29
|
+
const sourceNode = ctx.program.nodes.find(n => n.name === edge.source);
|
|
30
|
+
if (sourceNode) {
|
|
31
|
+
const producesName = sourceNode.produces.name;
|
|
32
|
+
if (!inputOverrides.has(producesName)) {
|
|
33
|
+
inputOverrides.set(producesName, `.graft/session/node_outputs/${edge.source.toLowerCase()}.json`);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
25
38
|
}
|
|
26
39
|
// 3. For produces reads with no corresponding edge: resolve to producer's raw output
|
|
27
40
|
for (const ref of node.reads) {
|
|
@@ -38,6 +51,9 @@ export class ClaudeCodeBackend {
|
|
|
38
51
|
generateHook(edge, _ctx) {
|
|
39
52
|
return generateHook(edge);
|
|
40
53
|
}
|
|
54
|
+
generateConditionalHook(edge, _ctx) {
|
|
55
|
+
return generateConditionalHook(edge);
|
|
56
|
+
}
|
|
41
57
|
generateOrchestration(ctx) {
|
|
42
58
|
return generateOrchestration(ctx.program, ctx.report);
|
|
43
59
|
}
|
package/dist/codegen/codegen.js
CHANGED
|
@@ -18,14 +18,27 @@ export function generate(program, report, sourceFile, index, backend) {
|
|
|
18
18
|
}
|
|
19
19
|
// Hooks
|
|
20
20
|
for (const edge of program.edges) {
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
const
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
21
|
+
if (edge.target.kind === 'conditional') {
|
|
22
|
+
// Conditional edge → router hook
|
|
23
|
+
const hook = be.generateConditionalHook?.(edge, ctx);
|
|
24
|
+
if (hook) {
|
|
25
|
+
files.push({
|
|
26
|
+
path: `.claude/hooks/${edge.source.toLowerCase()}-router.js`,
|
|
27
|
+
content: hook,
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
else {
|
|
32
|
+
// Direct edge → transform hook
|
|
33
|
+
const hook = be.generateHook(edge, ctx);
|
|
34
|
+
if (hook && edge.target.kind === 'direct') {
|
|
35
|
+
const source = edge.source.toLowerCase();
|
|
36
|
+
const target = edge.target.node.toLowerCase();
|
|
37
|
+
files.push({
|
|
38
|
+
path: `.claude/hooks/${source}-to-${target}.js`,
|
|
39
|
+
content: hook,
|
|
40
|
+
});
|
|
41
|
+
}
|
|
29
42
|
}
|
|
30
43
|
}
|
|
31
44
|
// Orchestration
|
|
@@ -42,6 +55,11 @@ export function generate(program, report, sourceFile, index, backend) {
|
|
|
42
55
|
// Runtime scaffold
|
|
43
56
|
files.push({ path: '.graft/session/node_outputs/.gitkeep', content: '' });
|
|
44
57
|
files.push({ path: '.graft/token_log.txt', content: '' });
|
|
58
|
+
// Routing scaffold (only when conditional edges exist)
|
|
59
|
+
const hasConditionalEdges = program.edges.some(e => e.target.kind === 'conditional');
|
|
60
|
+
if (hasConditionalEdges) {
|
|
61
|
+
files.push({ path: '.graft/session/routing/.gitkeep', content: '' });
|
|
62
|
+
}
|
|
45
63
|
// Memory scaffold — conditional
|
|
46
64
|
if (program.memories.length > 0) {
|
|
47
65
|
files.push({ path: '.graft/memory/.gitkeep', content: '' });
|
package/dist/codegen/hooks.d.ts
CHANGED
|
@@ -1,2 +1,7 @@
|
|
|
1
1
|
import { EdgeDecl } from '../parser/ast.js';
|
|
2
2
|
export declare function generateHook(edge: EdgeDecl): string | null;
|
|
3
|
+
/**
|
|
4
|
+
* Generate a router hook for conditional edges.
|
|
5
|
+
* Evaluates branch conditions and writes routing decision.
|
|
6
|
+
*/
|
|
7
|
+
export declare function generateConditionalHook(edge: EdgeDecl): string | null;
|
package/dist/codegen/hooks.js
CHANGED
|
@@ -163,3 +163,86 @@ function filterToJs(t) {
|
|
|
163
163
|
const op = condition.op === '==' ? '===' : condition.op === '!=' ? '!==' : condition.op;
|
|
164
164
|
return `result[${JSON.stringify(field)}] = (result[${JSON.stringify(field)}] || []).filter(item => item[${JSON.stringify(fieldName)}] ${op} ${valueStr});`;
|
|
165
165
|
}
|
|
166
|
+
/**
|
|
167
|
+
* Generate a router hook for conditional edges.
|
|
168
|
+
* Evaluates branch conditions and writes routing decision.
|
|
169
|
+
*/
|
|
170
|
+
export function generateConditionalHook(edge) {
|
|
171
|
+
if (edge.target.kind !== 'conditional')
|
|
172
|
+
return null;
|
|
173
|
+
const source = edge.source.toLowerCase();
|
|
174
|
+
const branches = edge.target.branches;
|
|
175
|
+
const targets = branches.map(b => b.target).filter(t => t !== 'done');
|
|
176
|
+
const conditionCode = branchesToJs(branches);
|
|
177
|
+
return `#!/usr/bin/env node
|
|
178
|
+
// Auto-generated by Graft Compiler
|
|
179
|
+
// Conditional routing: ${edge.source} -> {${branches.map(b => b.target).join(', ')}}
|
|
180
|
+
|
|
181
|
+
const fs = require('fs');
|
|
182
|
+
const path = require('path');
|
|
183
|
+
|
|
184
|
+
const INPUT = path.resolve('.graft/session/node_outputs/${source}.json');
|
|
185
|
+
const ROUTE = path.resolve('.graft/session/routing/${source}_route.json');
|
|
186
|
+
|
|
187
|
+
if (!fs.existsSync(INPUT)) {
|
|
188
|
+
process.exit(0);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const data = JSON.parse(fs.readFileSync(INPUT, 'utf-8'));
|
|
192
|
+
|
|
193
|
+
${conditionCode}
|
|
194
|
+
|
|
195
|
+
fs.mkdirSync(path.dirname(ROUTE), { recursive: true });
|
|
196
|
+
fs.writeFileSync(ROUTE, JSON.stringify({ target, from: ${JSON.stringify(edge.source)} }, null, 2));
|
|
197
|
+
`;
|
|
198
|
+
}
|
|
199
|
+
function branchesToJs(branches) {
|
|
200
|
+
const lines = [];
|
|
201
|
+
let first = true;
|
|
202
|
+
for (const branch of branches) {
|
|
203
|
+
if (!branch.condition) {
|
|
204
|
+
// else branch
|
|
205
|
+
if (first) {
|
|
206
|
+
lines.push(`let target = ${JSON.stringify(branch.target)};`);
|
|
207
|
+
}
|
|
208
|
+
else {
|
|
209
|
+
lines.push(`} else {`);
|
|
210
|
+
lines.push(` target = ${JSON.stringify(branch.target)};`);
|
|
211
|
+
lines.push(`}`);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
else {
|
|
215
|
+
const jsCondition = exprToJs(branch.condition);
|
|
216
|
+
if (first) {
|
|
217
|
+
lines.push(`let target = null;`);
|
|
218
|
+
lines.push(`if (${jsCondition}) {`);
|
|
219
|
+
lines.push(` target = ${JSON.stringify(branch.target)};`);
|
|
220
|
+
first = false;
|
|
221
|
+
}
|
|
222
|
+
else {
|
|
223
|
+
lines.push(`} else if (${jsCondition}) {`);
|
|
224
|
+
lines.push(` target = ${JSON.stringify(branch.target)};`);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
// Close the if chain if we have conditions but no else
|
|
229
|
+
if (!first && !branches.some(b => !b.condition)) {
|
|
230
|
+
lines.push(`}`);
|
|
231
|
+
}
|
|
232
|
+
return lines.join('\n');
|
|
233
|
+
}
|
|
234
|
+
function exprToJs(expr) {
|
|
235
|
+
if (expr.kind === 'binary') {
|
|
236
|
+
const left = exprToJs(expr.left);
|
|
237
|
+
const right = exprToJs(expr.right);
|
|
238
|
+
const op = expr.op === '==' ? '===' : expr.op === '!=' ? '!==' : expr.op;
|
|
239
|
+
return `${left} ${op} ${right}`;
|
|
240
|
+
}
|
|
241
|
+
if (expr.kind === 'field_access') {
|
|
242
|
+
return `data[${JSON.stringify(expr.segments[0])}]`;
|
|
243
|
+
}
|
|
244
|
+
if (expr.kind === 'literal') {
|
|
245
|
+
return JSON.stringify(expr.value);
|
|
246
|
+
}
|
|
247
|
+
return formatExpr(expr);
|
|
248
|
+
}
|
|
@@ -7,12 +7,16 @@ export function generateOrchestration(program, report) {
|
|
|
7
7
|
const index = new ProgramIndex(program);
|
|
8
8
|
const memoryNames = new Set(program.memories.map(m => m.name));
|
|
9
9
|
const edgeMap = new Map();
|
|
10
|
+
const conditionalEdgeMap = new Map();
|
|
10
11
|
for (const edge of program.edges) {
|
|
11
12
|
if (edge.target.kind === 'direct' && edge.transforms.length > 0) {
|
|
12
13
|
edgeMap.set(`${edge.source}->${edge.target.node}`, { transforms: edge.transforms });
|
|
13
14
|
}
|
|
15
|
+
else if (edge.target.kind === 'conditional') {
|
|
16
|
+
conditionalEdgeMap.set(edge.source, edge);
|
|
17
|
+
}
|
|
14
18
|
}
|
|
15
|
-
const { text: steps } = generateSteps(graph.flow, report, edgeMap, 1, null, index.nodeMap, memoryNames);
|
|
19
|
+
const { text: steps } = generateSteps(graph.flow, report, edgeMap, conditionalEdgeMap, 1, null, index.nodeMap, memoryNames);
|
|
16
20
|
// Memory preamble
|
|
17
21
|
const memorySection = program.memories.length > 0
|
|
18
22
|
? `
|
|
@@ -70,7 +74,42 @@ function describeTransforms(transforms) {
|
|
|
70
74
|
}
|
|
71
75
|
return parts.join(', then ');
|
|
72
76
|
}
|
|
73
|
-
function
|
|
77
|
+
function describeCondition(expr) {
|
|
78
|
+
if (expr.kind === 'binary') {
|
|
79
|
+
const left = expr.left.kind === 'field_access' ? `\`${expr.left.segments[0]}\`` : formatExpr(expr.left);
|
|
80
|
+
const right = expr.right.kind === 'literal' ? `\`${expr.right.value}\`` : formatExpr(expr.right);
|
|
81
|
+
return `${left} ${expr.op} ${right}`;
|
|
82
|
+
}
|
|
83
|
+
return formatExpr(expr);
|
|
84
|
+
}
|
|
85
|
+
function generateConditionalRoutingStep(stepNum, edge, nodeMap, report) {
|
|
86
|
+
if (edge.target.kind !== 'conditional')
|
|
87
|
+
return '';
|
|
88
|
+
const source = edge.source.toLowerCase();
|
|
89
|
+
const branches = edge.target.branches;
|
|
90
|
+
let text = `
|
|
91
|
+
### Step ${stepNum}: Conditional routing from ${edge.source}
|
|
92
|
+
- **Automatic**: Router hook evaluates conditions on ${edge.source}'s output
|
|
93
|
+
- Routing file: \`.graft/session/routing/${source}_route.json\`
|
|
94
|
+
- Read the \`target\` field and proceed accordingly:
|
|
95
|
+
`;
|
|
96
|
+
for (const branch of branches) {
|
|
97
|
+
const label = branch.condition ? describeCondition(branch.condition) : 'else (default)';
|
|
98
|
+
if (branch.target === 'done') {
|
|
99
|
+
text += ` - If ${label}: **pipeline complete**\n`;
|
|
100
|
+
}
|
|
101
|
+
else {
|
|
102
|
+
const nodeReport = report.nodes.find(n => n.name === branch.target);
|
|
103
|
+
const tokenInfo = nodeReport
|
|
104
|
+
? ` (tokens: input ~${nodeReport.estimatedIn.toLocaleString('en-US')} / output ~${nodeReport.estimatedOut.toLocaleString('en-US')})`
|
|
105
|
+
: '';
|
|
106
|
+
text += ` - If ${label}: run **${branch.target}** agent${tokenInfo}\n`;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
text += `- Each branch agent reads from \`.graft/session/node_outputs/${source}.json\`\n`;
|
|
110
|
+
return text;
|
|
111
|
+
}
|
|
112
|
+
function generateSteps(flow, report, edgeMap, conditionalEdgeMap, startStep, prevNode, nodeMap, memoryNames) {
|
|
74
113
|
let text = '';
|
|
75
114
|
let stepNum = startStep;
|
|
76
115
|
let prev = prevNode;
|
|
@@ -154,9 +193,15 @@ function generateSteps(flow, report, edgeMap, startStep, prevNode, nodeMap, memo
|
|
|
154
193
|
- Completion: \`===NODE_COMPLETE:${lowerName}===\`
|
|
155
194
|
- Output: \`.graft/session/node_outputs/${lowerName}.json\`
|
|
156
195
|
`;
|
|
196
|
+
stepNum++;
|
|
197
|
+
// Conditional routing after this node
|
|
198
|
+
const condEdge = conditionalEdgeMap.get(step.name);
|
|
199
|
+
if (condEdge && condEdge.target.kind === 'conditional') {
|
|
200
|
+
text += generateConditionalRoutingStep(stepNum, condEdge, nodeMap, report);
|
|
201
|
+
stepNum++;
|
|
202
|
+
}
|
|
157
203
|
prev = step.name;
|
|
158
204
|
prevParallelBranches = [];
|
|
159
|
-
stepNum++;
|
|
160
205
|
break;
|
|
161
206
|
}
|
|
162
207
|
case 'parallel': {
|
package/dist/codegen/settings.js
CHANGED
|
@@ -42,17 +42,25 @@ export function generateSettings(program, sourceFile, index) {
|
|
|
42
42
|
// Collect all hook commands, then merge into a single "Write" matcher entry
|
|
43
43
|
const hookCommands = [];
|
|
44
44
|
for (const edge of program.edges) {
|
|
45
|
-
if (edge.
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
45
|
+
if (edge.target.kind === 'conditional') {
|
|
46
|
+
// Conditional edge → router hook
|
|
47
|
+
const source = edge.source.toLowerCase();
|
|
48
|
+
hookCommands.push({
|
|
49
|
+
type: 'command',
|
|
50
|
+
command: `node .claude/hooks/${source}-router.js`,
|
|
51
|
+
if: `Write(.graft/session/node_outputs/${source}.json)`,
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
else if (edge.target.kind === 'direct' && edge.transforms.length > 0) {
|
|
55
|
+
// Direct edge with transforms → transform hook
|
|
56
|
+
const source = edge.source.toLowerCase();
|
|
57
|
+
const target = edge.target.node.toLowerCase();
|
|
58
|
+
hookCommands.push({
|
|
59
|
+
type: 'command',
|
|
60
|
+
command: `node .claude/hooks/${source}-to-${target}.js`,
|
|
61
|
+
if: `Write(.graft/session/node_outputs/${source}.json)`,
|
|
62
|
+
});
|
|
63
|
+
}
|
|
56
64
|
}
|
|
57
65
|
const hookEntries = [];
|
|
58
66
|
if (hookCommands.length > 0) {
|
|
@@ -16,6 +16,12 @@ export declare class GraftError extends Error {
|
|
|
16
16
|
readonly location: SourceLocation;
|
|
17
17
|
readonly severity: 'error' | 'warning';
|
|
18
18
|
readonly code?: GraftErrorCode | undefined;
|
|
19
|
-
|
|
20
|
-
|
|
19
|
+
readonly help?: string | undefined;
|
|
20
|
+
constructor(message: string, location: SourceLocation, severity?: 'error' | 'warning', code?: GraftErrorCode | undefined, help?: string | undefined);
|
|
21
|
+
format(source: string, filename?: string): string;
|
|
21
22
|
}
|
|
23
|
+
/**
|
|
24
|
+
* Find the closest match to `name` from `candidates` using Levenshtein distance.
|
|
25
|
+
* Returns the best match if distance <= maxDistance, otherwise undefined.
|
|
26
|
+
*/
|
|
27
|
+
export declare function didYouMean(name: string, candidates: string[], maxDistance?: number): string | undefined;
|
|
@@ -2,24 +2,81 @@ export class GraftError extends Error {
|
|
|
2
2
|
location;
|
|
3
3
|
severity;
|
|
4
4
|
code;
|
|
5
|
-
|
|
5
|
+
help;
|
|
6
|
+
constructor(message, location, severity = 'error', code, help) {
|
|
6
7
|
super(message);
|
|
7
8
|
this.location = location;
|
|
8
9
|
this.severity = severity;
|
|
9
10
|
this.code = code;
|
|
11
|
+
this.help = help;
|
|
10
12
|
this.name = 'GraftError';
|
|
11
13
|
}
|
|
12
|
-
format(source) {
|
|
14
|
+
format(source, filename) {
|
|
13
15
|
const lines = source.split('\n');
|
|
14
16
|
const lineIdx = this.location.line - 1;
|
|
15
17
|
const line = (lineIdx >= 0 && lineIdx < lines.length) ? lines[lineIdx] : '';
|
|
16
18
|
const col = Math.max(0, this.location.column - 1);
|
|
17
|
-
const
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
19
|
+
const lineNumStr = String(this.location.line);
|
|
20
|
+
const gutter = ' '.repeat(lineNumStr.length);
|
|
21
|
+
// Underline: use length if available, otherwise single caret
|
|
22
|
+
const underlineLen = this.location.length && this.location.length > 0
|
|
23
|
+
? this.location.length
|
|
24
|
+
: 1;
|
|
25
|
+
const underline = '^'.repeat(underlineLen);
|
|
26
|
+
const label = this.severity === 'warning' ? 'warning' : 'error';
|
|
27
|
+
const codeStr = this.code ? `[${this.code}]` : '';
|
|
28
|
+
const file = filename || '<source>';
|
|
29
|
+
const result = [
|
|
30
|
+
`${label}${codeStr}: ${this.message}`,
|
|
31
|
+
` ${gutter}--> ${file}:${this.location.line}:${this.location.column}`,
|
|
32
|
+
` ${gutter} |`,
|
|
33
|
+
` ${lineNumStr} | ${line}`,
|
|
34
|
+
` ${gutter} | ${' '.repeat(col)}${underline}`,
|
|
35
|
+
];
|
|
36
|
+
if (this.help) {
|
|
37
|
+
result.push(` ${gutter} |`);
|
|
38
|
+
result.push(` ${gutter} = help: ${this.help}`);
|
|
39
|
+
}
|
|
40
|
+
return result.join('\n');
|
|
24
41
|
}
|
|
25
42
|
}
|
|
43
|
+
/**
|
|
44
|
+
* Find the closest match to `name` from `candidates` using Levenshtein distance.
|
|
45
|
+
* Returns the best match if distance <= maxDistance, otherwise undefined.
|
|
46
|
+
*/
|
|
47
|
+
export function didYouMean(name, candidates, maxDistance = 3) {
|
|
48
|
+
let best;
|
|
49
|
+
let bestDist = maxDistance + 1;
|
|
50
|
+
for (const candidate of candidates) {
|
|
51
|
+
const dist = levenshtein(name.toLowerCase(), candidate.toLowerCase());
|
|
52
|
+
if (dist < bestDist) {
|
|
53
|
+
bestDist = dist;
|
|
54
|
+
best = candidate;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return bestDist <= maxDistance ? best : undefined;
|
|
58
|
+
}
|
|
59
|
+
function levenshtein(a, b) {
|
|
60
|
+
const m = a.length;
|
|
61
|
+
const n = b.length;
|
|
62
|
+
if (m === 0)
|
|
63
|
+
return n;
|
|
64
|
+
if (n === 0)
|
|
65
|
+
return m;
|
|
66
|
+
// Single-row DP
|
|
67
|
+
let prev = new Array(n + 1);
|
|
68
|
+
let curr = new Array(n + 1);
|
|
69
|
+
for (let j = 0; j <= n; j++)
|
|
70
|
+
prev[j] = j;
|
|
71
|
+
for (let i = 1; i <= m; i++) {
|
|
72
|
+
curr[0] = i;
|
|
73
|
+
for (let j = 1; j <= n; j++) {
|
|
74
|
+
const cost = a[i - 1] === b[j - 1] ? 0 : 1;
|
|
75
|
+
curr[j] = Math.min(curr[j - 1] + 1, // insert
|
|
76
|
+
prev[j] + 1, // delete
|
|
77
|
+
prev[j - 1] + cost);
|
|
78
|
+
}
|
|
79
|
+
[prev, curr] = [curr, prev];
|
|
80
|
+
}
|
|
81
|
+
return prev[n];
|
|
82
|
+
}
|
package/dist/index.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
import { Command } from 'commander';
|
|
3
3
|
import * as fs from 'node:fs';
|
|
4
4
|
import * as path from 'node:path';
|
|
5
|
-
import { compile, compileAndWrite } from './compiler.js';
|
|
5
|
+
import { compile, compileAndWrite, compileToProgram } from './compiler.js';
|
|
6
6
|
import { VERSION } from './version.js';
|
|
7
7
|
import { formatTokenReport } from './format.js';
|
|
8
8
|
const KNOWN_BACKENDS = new Set(['claude']);
|
|
@@ -34,7 +34,7 @@ program
|
|
|
34
34
|
if (!result.success) {
|
|
35
35
|
console.error('\n✗ Compilation failed:\n');
|
|
36
36
|
for (const err of result.errors) {
|
|
37
|
-
console.error(err.format(source));
|
|
37
|
+
console.error(err.format(source, file));
|
|
38
38
|
console.error('');
|
|
39
39
|
}
|
|
40
40
|
process.exit(1);
|
|
@@ -67,7 +67,7 @@ program
|
|
|
67
67
|
if (!result.success) {
|
|
68
68
|
console.error('\n✗ Check failed:\n');
|
|
69
69
|
for (const err of result.errors) {
|
|
70
|
-
console.error(err.format(source));
|
|
70
|
+
console.error(err.format(source, file));
|
|
71
71
|
console.error('');
|
|
72
72
|
}
|
|
73
73
|
process.exit(1);
|
|
@@ -170,6 +170,134 @@ graph ${safeName}(input: Input, output: Output, budget: 10k) {
|
|
|
170
170
|
console.log(` # Open in Claude Code to run the pipeline`);
|
|
171
171
|
console.log('');
|
|
172
172
|
});
|
|
173
|
+
program
|
|
174
|
+
.command('watch')
|
|
175
|
+
.description('Watch .gft file and recompile on changes')
|
|
176
|
+
.argument('<file>', '.gft source file')
|
|
177
|
+
.option('--out-dir <dir>', 'output directory', '.')
|
|
178
|
+
.action((file, opts) => {
|
|
179
|
+
const resolved = path.resolve(file);
|
|
180
|
+
if (!fs.existsSync(resolved)) {
|
|
181
|
+
console.error(`Error: file not found: ${resolved}`);
|
|
182
|
+
process.exit(1);
|
|
183
|
+
}
|
|
184
|
+
function doCompile() {
|
|
185
|
+
const source = fs.readFileSync(resolved, 'utf-8');
|
|
186
|
+
try {
|
|
187
|
+
const result = compileAndWrite(source, resolved, path.resolve(opts.outDir));
|
|
188
|
+
if (!result.success) {
|
|
189
|
+
console.error('\n✗ Compilation failed:\n');
|
|
190
|
+
for (const err of result.errors) {
|
|
191
|
+
console.error(err.format(source, file));
|
|
192
|
+
console.error('');
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
else {
|
|
196
|
+
const timestamp = new Date().toLocaleTimeString();
|
|
197
|
+
const fileCount = result.files?.length || 0;
|
|
198
|
+
console.log(`[${timestamp}] ✓ Compiled ${file} → ${fileCount} files`);
|
|
199
|
+
for (const w of result.warnings) {
|
|
200
|
+
console.log(` ⚠ ${w.message}`);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
catch (e) {
|
|
205
|
+
console.error(`Error: ${e instanceof Error ? e.message : String(e)}`);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
// Initial compile
|
|
209
|
+
doCompile();
|
|
210
|
+
console.log(`\nWatching ${file} for changes... (Ctrl+C to stop)\n`);
|
|
211
|
+
// Watch for changes
|
|
212
|
+
let debounceTimer = null;
|
|
213
|
+
fs.watch(resolved, () => {
|
|
214
|
+
if (debounceTimer)
|
|
215
|
+
clearTimeout(debounceTimer);
|
|
216
|
+
debounceTimer = setTimeout(doCompile, 100);
|
|
217
|
+
});
|
|
218
|
+
// Also watch imported files in the same directory
|
|
219
|
+
const dir = path.dirname(resolved);
|
|
220
|
+
try {
|
|
221
|
+
fs.watch(dir, { recursive: false }, (_, filename) => {
|
|
222
|
+
if (filename && filename.endsWith('.gft') && filename !== path.basename(resolved)) {
|
|
223
|
+
if (debounceTimer)
|
|
224
|
+
clearTimeout(debounceTimer);
|
|
225
|
+
debounceTimer = setTimeout(doCompile, 100);
|
|
226
|
+
}
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
catch {
|
|
230
|
+
// Ignore if directory watch fails
|
|
231
|
+
}
|
|
232
|
+
});
|
|
233
|
+
program
|
|
234
|
+
.command('visualize')
|
|
235
|
+
.description('Output pipeline DAG as a Mermaid diagram')
|
|
236
|
+
.argument('<file>', '.gft source file')
|
|
237
|
+
.option('--format <fmt>', 'output format: mermaid', 'mermaid')
|
|
238
|
+
.action((file) => {
|
|
239
|
+
const source = readSource(file);
|
|
240
|
+
const result = compileToProgram(source, path.resolve(file));
|
|
241
|
+
if (!result.success || !result.program) {
|
|
242
|
+
console.error('\n✗ Compilation failed:\n');
|
|
243
|
+
for (const err of result.errors) {
|
|
244
|
+
console.error(err.format(source, file));
|
|
245
|
+
console.error('');
|
|
246
|
+
}
|
|
247
|
+
process.exit(1);
|
|
248
|
+
}
|
|
249
|
+
const { program: prog } = result;
|
|
250
|
+
const lines = ['graph TD'];
|
|
251
|
+
// Nodes
|
|
252
|
+
for (const node of prog.nodes) {
|
|
253
|
+
const model = node.model;
|
|
254
|
+
lines.push(` ${node.name}["${node.name}<br/><small>${model}</small>"]`);
|
|
255
|
+
}
|
|
256
|
+
// Direct edges
|
|
257
|
+
for (const edge of prog.edges) {
|
|
258
|
+
if (edge.target.kind === 'direct') {
|
|
259
|
+
const label = edge.transforms.length > 0
|
|
260
|
+
? edge.transforms.map(t => t.type).join(' → ')
|
|
261
|
+
: '';
|
|
262
|
+
if (label) {
|
|
263
|
+
lines.push(` ${edge.source} -->|${label}| ${edge.target.node}`);
|
|
264
|
+
}
|
|
265
|
+
else {
|
|
266
|
+
lines.push(` ${edge.source} --> ${edge.target.node}`);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
else if (edge.target.kind === 'conditional') {
|
|
270
|
+
for (const branch of edge.target.branches) {
|
|
271
|
+
const target = branch.target === 'done' ? 'done((done))' : branch.target;
|
|
272
|
+
const label = branch.condition
|
|
273
|
+
? formatExprForMermaid(branch.condition)
|
|
274
|
+
: 'else';
|
|
275
|
+
lines.push(` ${edge.source} -->|${label}| ${target}`);
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
// Graph flow (parallel blocks)
|
|
280
|
+
if (prog.graphs[0]) {
|
|
281
|
+
for (const step of prog.graphs[0].flow) {
|
|
282
|
+
if (step.kind === 'parallel') {
|
|
283
|
+
lines.push(` subgraph parallel["parallel"]`);
|
|
284
|
+
for (const b of step.branches) {
|
|
285
|
+
lines.push(` ${b}`);
|
|
286
|
+
}
|
|
287
|
+
lines.push(` end`);
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
console.log(lines.join('\n'));
|
|
292
|
+
});
|
|
293
|
+
function formatExprForMermaid(expr) {
|
|
294
|
+
if (expr.kind === 'binary') {
|
|
295
|
+
const left = expr.left.kind === 'field_access' ? expr.left.segments[0] : '?';
|
|
296
|
+
const right = expr.right.kind === 'literal' ? String(expr.right.value) : '?';
|
|
297
|
+
return `${left} ${expr.op} ${right}`;
|
|
298
|
+
}
|
|
299
|
+
return '?';
|
|
300
|
+
}
|
|
173
301
|
function readSource(file) {
|
|
174
302
|
const resolved = path.resolve(file);
|
|
175
303
|
if (!fs.existsSync(resolved)) {
|