@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 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
@@ -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
- errors.push(new GraftError(`'${ref.context}' is not declared as a context, produces output, or memory`, ref.location, 'error', 'SCOPE_UNDEFINED_REF'));
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 = new Set(ctx.fields.map(f => f.name));
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 (!fieldNames.has(f)) {
69
- errors.push(new GraftError(`Field '${f}' does not exist in context '${ref.context}'`, ref.location, 'error', 'SCOPE_FIELD_NOT_FOUND'));
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
- errors.push(new GraftError(`Field '${f}' does not exist in produces '${ref.context}'`, ref.location, 'error', 'SCOPE_FIELD_NOT_FOUND'));
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
  }
@@ -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
- const hook = be.generateHook(edge, ctx);
22
- if (hook && edge.target.kind === 'direct') {
23
- const source = edge.source.toLowerCase();
24
- const target = edge.target.node.toLowerCase();
25
- files.push({
26
- path: `.claude/hooks/${source}-to-${target}.js`,
27
- content: hook,
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: '' });
@@ -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;
@@ -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 generateSteps(flow, report, edgeMap, startStep, prevNode, nodeMap, memoryNames) {
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': {
@@ -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.transforms.length === 0)
46
- continue;
47
- if (edge.target.kind !== 'direct')
48
- continue;
49
- const source = edge.source.toLowerCase();
50
- const target = edge.target.node.toLowerCase();
51
- hookCommands.push({
52
- type: 'command',
53
- command: `node .claude/hooks/${source}-to-${target}.js`,
54
- if: `Write(.graft/session/node_outputs/${source}.json)`,
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
- constructor(message: string, location: SourceLocation, severity?: 'error' | 'warning', code?: GraftErrorCode | undefined);
20
- format(source: string): string;
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
- constructor(message, location, severity = 'error', code) {
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 pointer = ' '.repeat(col) + '^';
18
- return [
19
- `Error at line ${this.location.line}:${this.location.column}:`,
20
- ` ${line}`,
21
- ` ${pointer}`,
22
- ` ${this.message}`,
23
- ].join('\n');
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)) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jsleekr/graft",
3
- "version": "5.7.2",
3
+ "version": "5.8.0",
4
4
  "description": "Graft compiler — compile .gft graph DSL to Claude Code harness structures",
5
5
  "type": "module",
6
6
  "license": "MIT",