@jsleekr/graft 5.8.0 → 6.0.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.
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Runtime result validator — checks pipeline output against .gft schema.
3
+ */
4
+ import { Program } from '../parser/ast.js';
5
+ import { RunResult } from './executor.js';
6
+ export type CheckStatus = 'pass' | 'warn' | 'fail';
7
+ export interface Check {
8
+ status: CheckStatus;
9
+ category: 'schema' | 'type' | 'range' | 'empty' | 'budget';
10
+ node: string;
11
+ field?: string;
12
+ message: string;
13
+ }
14
+ export interface QualityReport {
15
+ score: number;
16
+ checks: Check[];
17
+ passed: number;
18
+ warned: number;
19
+ failed: number;
20
+ }
21
+ /**
22
+ * Validate a RunResult against the Program's produces schemas.
23
+ */
24
+ export declare function validateResult(result: RunResult, program: Program): QualityReport;
25
+ /**
26
+ * Format a QualityReport for human-readable output.
27
+ */
28
+ export declare function formatQualityReport(report: QualityReport): string;
@@ -0,0 +1,129 @@
1
+ import { ProgramIndex } from '../program-index.js';
2
+ /**
3
+ * Validate a RunResult against the Program's produces schemas.
4
+ */
5
+ export function validateResult(result, program) {
6
+ const checks = [];
7
+ const index = new ProgramIndex(program);
8
+ // Validate each node's output
9
+ for (const nr of result.nodeResults) {
10
+ if (!nr.success) {
11
+ checks.push({ status: 'fail', category: 'schema', node: nr.node, message: `Node failed: ${nr.error ?? 'unknown error'}` });
12
+ continue;
13
+ }
14
+ const nodeDecl = index.nodeMap.get(nr.node);
15
+ if (!nodeDecl)
16
+ continue;
17
+ validateNodeOutput(nr, nodeDecl, checks);
18
+ }
19
+ // Budget check
20
+ if (result.tokenUsage) {
21
+ const { fraction } = result.tokenUsage;
22
+ if (fraction > 0.95) {
23
+ checks.push({ status: 'fail', category: 'budget', node: '*', message: `Token budget nearly exhausted: ${Math.round(fraction * 100)}%` });
24
+ }
25
+ else if (fraction > 0.8) {
26
+ checks.push({ status: 'warn', category: 'budget', node: '*', message: `Token budget high: ${Math.round(fraction * 100)}%` });
27
+ }
28
+ else {
29
+ checks.push({ status: 'pass', category: 'budget', node: '*', message: `Token budget OK: ${Math.round(fraction * 100)}%` });
30
+ }
31
+ }
32
+ const passed = checks.filter(c => c.status === 'pass').length;
33
+ const warned = checks.filter(c => c.status === 'warn').length;
34
+ const failed = checks.filter(c => c.status === 'fail').length;
35
+ const total = checks.length;
36
+ const score = total > 0 ? passed / total : 1.0;
37
+ return { score, checks, passed, warned, failed };
38
+ }
39
+ function validateNodeOutput(nr, nodeDecl, checks) {
40
+ const output = nr.output;
41
+ if (output === null || output === undefined) {
42
+ checks.push({ status: 'fail', category: 'schema', node: nr.node, message: 'Output is null' });
43
+ return;
44
+ }
45
+ if (typeof output !== 'object' || Array.isArray(output)) {
46
+ checks.push({ status: 'fail', category: 'schema', node: nr.node, message: `Output is ${typeof output}, expected object` });
47
+ return;
48
+ }
49
+ const obj = output;
50
+ const fields = nodeDecl.produces.fields;
51
+ // Check each declared field
52
+ for (const field of fields) {
53
+ const value = obj[field.name];
54
+ // Schema: field exists?
55
+ if (value === undefined) {
56
+ checks.push({ status: 'fail', category: 'schema', node: nr.node, field: field.name, message: `Missing field: ${field.name}` });
57
+ continue;
58
+ }
59
+ // Type check
60
+ const typeCheck = checkType(value, field.type);
61
+ if (typeCheck !== null) {
62
+ checks.push({ status: 'fail', category: 'type', node: nr.node, field: field.name, message: typeCheck });
63
+ continue;
64
+ }
65
+ // Empty check
66
+ if (isEmpty(value)) {
67
+ checks.push({ status: 'warn', category: 'empty', node: nr.node, field: field.name, message: `Field '${field.name}' is empty` });
68
+ continue;
69
+ }
70
+ // Range check for Float(min..max)
71
+ if (field.type.kind === 'primitive_range' && typeof value === 'number') {
72
+ if (value < field.type.min || value > field.type.max) {
73
+ checks.push({
74
+ status: 'fail', category: 'range', node: nr.node, field: field.name,
75
+ message: `Value ${value} out of range [${field.type.min}..${field.type.max}]`,
76
+ });
77
+ continue;
78
+ }
79
+ }
80
+ checks.push({ status: 'pass', category: 'schema', node: nr.node, field: field.name, message: `${field.name} OK` });
81
+ }
82
+ }
83
+ function checkType(value, type) {
84
+ switch (type.kind) {
85
+ case 'primitive':
86
+ switch (type.name) {
87
+ case 'String': return typeof value === 'string' ? null : `Expected String, got ${typeof value}`;
88
+ case 'Int': return typeof value === 'number' && Number.isInteger(value) ? null : `Expected Int, got ${typeof value}`;
89
+ case 'Float': return typeof value === 'number' ? null : `Expected Float, got ${typeof value}`;
90
+ case 'Bool': return typeof value === 'boolean' ? null : `Expected Bool, got ${typeof value}`;
91
+ default: return null; // Unknown primitive, skip
92
+ }
93
+ case 'primitive_range':
94
+ return typeof value === 'number' ? null : `Expected Float, got ${typeof value}`;
95
+ case 'list':
96
+ return Array.isArray(value) ? null : `Expected List, got ${typeof value}`;
97
+ case 'map':
98
+ return (typeof value === 'object' && value !== null && !Array.isArray(value)) ? null : `Expected Map, got ${typeof value}`;
99
+ case 'optional':
100
+ return null; // null is valid for Optional
101
+ default:
102
+ return null; // Unknown type, skip
103
+ }
104
+ }
105
+ function isEmpty(value) {
106
+ if (value === null || value === undefined || value === '')
107
+ return true;
108
+ if (Array.isArray(value) && value.length === 0)
109
+ return true;
110
+ if (typeof value === 'object' && !Array.isArray(value) && Object.keys(value).length === 0)
111
+ return true;
112
+ return false;
113
+ }
114
+ /**
115
+ * Format a QualityReport for human-readable output.
116
+ */
117
+ export function formatQualityReport(report) {
118
+ const lines = [];
119
+ lines.push('\u2500\u2500 Quality Check ' + '\u2500'.repeat(33));
120
+ for (const check of report.checks) {
121
+ const icon = check.status === 'pass' ? '\u2713' : check.status === 'warn' ? '\u26a0' : '\u2717';
122
+ const fieldStr = check.field ? ` ${check.node}.${check.field}` : '';
123
+ lines.push(` ${icon} ${check.message}${fieldStr ? ` [${check.node}.${check.field}]` : ''}`);
124
+ }
125
+ lines.push('\u2500'.repeat(48));
126
+ const pct = Math.round(report.score * 100);
127
+ lines.push(`Quality: ${pct}% (${report.passed}/${report.passed + report.warned + report.failed} checks passed)`);
128
+ return lines.join('\n');
129
+ }
@@ -0,0 +1,33 @@
1
+ import { ContextDecl, Field, TypeExpr } from './parser/ast.js';
2
+ /**
3
+ * Generate minimal valid test data for a TypeExpr.
4
+ */
5
+ export declare function generateTestValue(type: TypeExpr): unknown;
6
+ /**
7
+ * Generate a minimal valid JSON object from a context declaration's field schema.
8
+ */
9
+ export declare function generateTestInput(context: ContextDecl): Record<string, unknown>;
10
+ /**
11
+ * Validate that an output object matches the expected field schema.
12
+ * Returns an array of error strings (empty if valid).
13
+ */
14
+ export declare function validateOutput(output: unknown, fields: Field[], prefix?: string): string[];
15
+ export interface TestInput {
16
+ source: string;
17
+ sourceFile: string;
18
+ input?: Record<string, unknown>;
19
+ verbose?: boolean;
20
+ }
21
+ export interface NodeTestResult {
22
+ node: string;
23
+ passed: boolean;
24
+ output: unknown;
25
+ validationErrors: string[];
26
+ }
27
+ export interface TestResult {
28
+ success: boolean;
29
+ inputUsed: Record<string, unknown>;
30
+ nodeResults: NodeTestResult[];
31
+ compileErrors: string[];
32
+ }
33
+ export declare function runTest(opts: TestInput): Promise<TestResult>;
@@ -0,0 +1,223 @@
1
+ import { compileToProgram } from './compiler.js';
2
+ import { ProgramIndex } from './program-index.js';
3
+ import { generateMockOutput } from './runtime/prompt-builder.js';
4
+ import { executeFlowNodes } from './runtime/flow-runner.js';
5
+ // --- Test input generation ---
6
+ /**
7
+ * Generate minimal valid test data for a TypeExpr.
8
+ */
9
+ export function generateTestValue(type) {
10
+ switch (type.kind) {
11
+ case 'primitive':
12
+ switch (type.name) {
13
+ case 'String': return 'test';
14
+ case 'Int': return 1;
15
+ case 'Float': return 0.5;
16
+ case 'Bool': return true;
17
+ }
18
+ break;
19
+ case 'primitive_range':
20
+ return (type.min + type.max) / 2;
21
+ case 'list':
22
+ return [generateTestValue(type.element)];
23
+ case 'map':
24
+ return { [String(generateTestValue(type.key))]: generateTestValue(type.value) };
25
+ case 'optional':
26
+ return null;
27
+ case 'token_bounded':
28
+ return generateTestValue(type.inner);
29
+ case 'enum':
30
+ return type.values[0];
31
+ case 'struct':
32
+ return generateTestInput({ name: '', maxTokens: 0, fields: type.fields, location: { line: 0, column: 0, offset: 0 } });
33
+ case 'domain':
34
+ return `test.${type.name.toLowerCase()}`;
35
+ default: {
36
+ const _exhaustive = type;
37
+ return _exhaustive;
38
+ }
39
+ }
40
+ }
41
+ /**
42
+ * Generate a minimal valid JSON object from a context declaration's field schema.
43
+ */
44
+ export function generateTestInput(context) {
45
+ const result = {};
46
+ for (const field of context.fields) {
47
+ result[field.name] = generateTestValue(field.type);
48
+ }
49
+ return result;
50
+ }
51
+ // --- Output validation ---
52
+ /**
53
+ * Validate that an output object matches the expected field schema.
54
+ * Returns an array of error strings (empty if valid).
55
+ */
56
+ export function validateOutput(output, fields, prefix = '') {
57
+ const errors = [];
58
+ if (output === null || output === undefined || typeof output !== 'object' || Array.isArray(output)) {
59
+ errors.push(`${prefix || 'output'}: expected object, got ${output === null ? 'null' : typeof output}`);
60
+ return errors;
61
+ }
62
+ const obj = output;
63
+ for (const field of fields) {
64
+ const path = prefix ? `${prefix}.${field.name}` : field.name;
65
+ if (!(field.name in obj)) {
66
+ // Optional fields are allowed to be missing
67
+ if (field.type.kind === 'optional')
68
+ continue;
69
+ errors.push(`${path}: missing required field`);
70
+ continue;
71
+ }
72
+ errors.push(...validateValue(obj[field.name], field.type, path));
73
+ }
74
+ return errors;
75
+ }
76
+ function validateValue(value, type, path) {
77
+ const errors = [];
78
+ switch (type.kind) {
79
+ case 'primitive':
80
+ switch (type.name) {
81
+ case 'String':
82
+ if (typeof value !== 'string')
83
+ errors.push(`${path}: expected string, got ${typeof value}`);
84
+ break;
85
+ case 'Int':
86
+ if (typeof value !== 'number' || !Number.isInteger(value))
87
+ errors.push(`${path}: expected integer, got ${typeof value === 'number' ? 'float' : typeof value}`);
88
+ break;
89
+ case 'Float':
90
+ if (typeof value !== 'number')
91
+ errors.push(`${path}: expected number, got ${typeof value}`);
92
+ break;
93
+ case 'Bool':
94
+ if (typeof value !== 'boolean')
95
+ errors.push(`${path}: expected boolean, got ${typeof value}`);
96
+ break;
97
+ }
98
+ break;
99
+ case 'primitive_range':
100
+ if (typeof value !== 'number') {
101
+ errors.push(`${path}: expected number, got ${typeof value}`);
102
+ }
103
+ else if (value < type.min || value > type.max) {
104
+ errors.push(`${path}: value ${value} out of range [${type.min}..${type.max}]`);
105
+ }
106
+ break;
107
+ case 'list':
108
+ if (!Array.isArray(value)) {
109
+ errors.push(`${path}: expected array, got ${typeof value}`);
110
+ }
111
+ else {
112
+ for (let i = 0; i < value.length; i++) {
113
+ errors.push(...validateValue(value[i], type.element, `${path}[${i}]`));
114
+ }
115
+ }
116
+ break;
117
+ case 'map':
118
+ if (value === null || typeof value !== 'object' || Array.isArray(value)) {
119
+ errors.push(`${path}: expected object (map), got ${typeof value}`);
120
+ }
121
+ break;
122
+ case 'optional':
123
+ if (value !== null && value !== undefined) {
124
+ errors.push(...validateValue(value, type.inner, path));
125
+ }
126
+ break;
127
+ case 'token_bounded':
128
+ errors.push(...validateValue(value, type.inner, path));
129
+ break;
130
+ case 'enum':
131
+ if (typeof value !== 'string' || !type.values.includes(value)) {
132
+ errors.push(`${path}: expected enum value (${type.values.join(', ')}), got ${JSON.stringify(value)}`);
133
+ }
134
+ break;
135
+ case 'struct':
136
+ errors.push(...validateOutput(value, type.fields, path));
137
+ break;
138
+ case 'domain':
139
+ if (typeof value !== 'string')
140
+ errors.push(`${path}: expected string (${type.name}), got ${typeof value}`);
141
+ break;
142
+ }
143
+ return errors;
144
+ }
145
+ export async function runTest(opts) {
146
+ // Compile the source
147
+ const compileResult = compileToProgram(opts.source, opts.sourceFile);
148
+ if (!compileResult.success || !compileResult.program) {
149
+ return {
150
+ success: false,
151
+ inputUsed: opts.input ?? {},
152
+ nodeResults: [],
153
+ compileErrors: compileResult.errors.map(e => e.message),
154
+ };
155
+ }
156
+ const program = compileResult.program;
157
+ const index = compileResult.index ?? new ProgramIndex(program);
158
+ if (program.graphs.length === 0) {
159
+ return {
160
+ success: false,
161
+ inputUsed: opts.input ?? {},
162
+ nodeResults: [],
163
+ compileErrors: ['No graph declaration found'],
164
+ };
165
+ }
166
+ const graph = program.graphs[0];
167
+ // Generate or use provided input
168
+ const inputContext = index.contextMap.get(graph.input);
169
+ const inputUsed = opts.input ?? (inputContext ? generateTestInput(inputContext) : {});
170
+ // Execute in dry-run mode
171
+ const outputs = new Map();
172
+ const nodeResults = [];
173
+ const executeNode = async (name) => {
174
+ const nodeDecl = index.nodeMap.get(name);
175
+ if (!nodeDecl) {
176
+ const nr = { node: name, passed: false, output: null, validationErrors: [`Node '${name}' not found`] };
177
+ nodeResults.push(nr);
178
+ return { node: name, output: null, durationMs: 0, success: false, error: `Node '${name}' not found` };
179
+ }
180
+ // Generate mock output
181
+ const mockOutput = generateMockOutput(nodeDecl);
182
+ outputs.set(nodeDecl.name, mockOutput);
183
+ outputs.set(nodeDecl.produces.name, mockOutput);
184
+ // Validate mock output against produces schema
185
+ const validationErrors = validateOutput(mockOutput, nodeDecl.produces.fields);
186
+ const passed = validationErrors.length === 0;
187
+ const nr = { node: name, passed, output: mockOutput, validationErrors };
188
+ nodeResults.push(nr);
189
+ return { node: name, output: mockOutput, durationMs: 0, success: true };
190
+ };
191
+ const flowCtx = {
192
+ executeNode,
193
+ getFailureStrategy: (name) => index.nodeMap.get(name)?.onFailure,
194
+ getConditionalEdge: (sourceName) => {
195
+ const edges = index.edgesBySource.get(sourceName);
196
+ if (!edges)
197
+ return null;
198
+ for (const edge of edges) {
199
+ if (edge.target.kind === 'conditional') {
200
+ return { branches: edge.target.branches, transforms: edge.transforms };
201
+ }
202
+ }
203
+ return null;
204
+ },
205
+ outputs,
206
+ input: inputUsed,
207
+ };
208
+ const executionErrors = [];
209
+ const executionResults = [];
210
+ try {
211
+ await executeFlowNodes(graph.flow, executionResults, executionErrors, flowCtx);
212
+ }
213
+ catch (e) {
214
+ executionErrors.push(e instanceof Error ? e.message : String(e));
215
+ }
216
+ const allPassed = nodeResults.every(r => r.passed) && executionErrors.length === 0;
217
+ return {
218
+ success: allPassed,
219
+ inputUsed,
220
+ nodeResults,
221
+ compileErrors: [],
222
+ };
223
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jsleekr/graft",
3
- "version": "5.8.0",
3
+ "version": "6.0.0",
4
4
  "description": "Graft compiler — compile .gft graph DSL to Claude Code harness structures",
5
5
  "type": "module",
6
6
  "license": "MIT",