@jsleekr/graft 5.7.2 → 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 +2 -0
- 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
|
@@ -231,6 +231,8 @@ graft compile <file.gft> [--out-dir <dir>] # Compile to .claude/ structure
|
|
|
231
231
|
graft check <file.gft> # Parse + analyze only
|
|
232
232
|
graft run <file.gft> --input <json> [--dry-run] [--verbose] # Compile and execute
|
|
233
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
|
|
234
236
|
```
|
|
235
237
|
|
|
236
238
|
## Programmatic API
|
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)) {
|