mirror of
https://github.com/gorules/zen.git
synced 2026-10-04 08:02:28 +00:00
293 lines
9.8 KiB
TypeScript
293 lines
9.8 KiB
TypeScript
import { describe, expect, it } from '@jest/globals';
|
|
import ts from 'typescript';
|
|
|
|
const { Workspace } = require('../index.js');
|
|
|
|
type VariableTypeJson = any;
|
|
|
|
const variableTypeToTs = (t: VariableTypeJson): string => {
|
|
switch (t?.type) {
|
|
case 'number':
|
|
return 'number';
|
|
case 'string':
|
|
return 'string';
|
|
case 'bool':
|
|
return 'boolean';
|
|
case 'date':
|
|
return 'string';
|
|
case 'null':
|
|
return 'null';
|
|
case 'const':
|
|
return JSON.stringify(t.value);
|
|
case 'enum':
|
|
return t.values.map((v: string) => JSON.stringify(v)).join(' | ') || 'string';
|
|
case 'array':
|
|
return `Array<${variableTypeToTs(t.items)}>`;
|
|
case 'object':
|
|
return `{ ${Object.entries(t.fields ?? {})
|
|
.map(([k, v]) => `${JSON.stringify(k)}: ${variableTypeToTs(v)}`)
|
|
.join('; ')} }`;
|
|
case 'nullable':
|
|
return `(${variableTypeToTs(t.inner)}) | null`;
|
|
default:
|
|
return 'any';
|
|
}
|
|
};
|
|
|
|
const resolveWithTsc = (source: string, inputType: VariableTypeJson): string | null => {
|
|
const fileName = 'handler.ts';
|
|
const virtual = [
|
|
`type Input = ${variableTypeToTs(inputType)};`,
|
|
source,
|
|
'type __Expand<T> = T extends Date ? Date : T extends (infer U)[] ? __Expand<U>[] : T extends object ? { [K in keyof T]: __Expand<T[K]> } : T;',
|
|
'type __Result = __Expand<Awaited<ReturnType<typeof handler>>>;',
|
|
].join('\n');
|
|
|
|
const options: ts.CompilerOptions = {
|
|
target: ts.ScriptTarget.ES2022,
|
|
module: ts.ModuleKind.ESNext,
|
|
skipLibCheck: true,
|
|
types: [],
|
|
};
|
|
const host = ts.createCompilerHost(options);
|
|
const getSourceFile = host.getSourceFile.bind(host);
|
|
host.getSourceFile = (name, languageVersion, ...rest) =>
|
|
name === fileName
|
|
? ts.createSourceFile(fileName, virtual, languageVersion, true, ts.ScriptKind.TS)
|
|
: getSourceFile(name, languageVersion, ...rest);
|
|
host.writeFile = () => undefined;
|
|
|
|
const program = ts.createProgram([fileName], options, host);
|
|
const checker = program.getTypeChecker();
|
|
const sourceFile = program.getSourceFile(fileName);
|
|
if (!sourceFile) {
|
|
return null;
|
|
}
|
|
|
|
let result: string | null = null;
|
|
sourceFile.forEachChild((node) => {
|
|
if (ts.isTypeAliasDeclaration(node) && node.name.text === '__Result') {
|
|
const type = checker.getTypeAtLocation(node.name);
|
|
result = checker.typeToString(
|
|
type,
|
|
node,
|
|
ts.TypeFormatFlags.NoTruncation | ts.TypeFormatFlags.InTypeAlias,
|
|
);
|
|
}
|
|
});
|
|
return result;
|
|
};
|
|
|
|
const personSchema = JSON.stringify({
|
|
type: 'object',
|
|
properties: { age: { type: 'number' }, name: { type: 'string' } },
|
|
required: ['age', 'name'],
|
|
});
|
|
|
|
const functionGraph = (source: string) => ({
|
|
nodes: [
|
|
{ id: 'in', name: 'in', type: 'inputNode', content: { schema: personSchema } },
|
|
{ id: 'fn', name: 'fn', type: 'functionNode', content: { source } },
|
|
{
|
|
id: 'after',
|
|
name: 'after',
|
|
type: 'expressionNode',
|
|
content: { expressions: [{ id: 'e1', key: 'grand', value: 'total + 1' }] },
|
|
},
|
|
{ id: 'out', name: 'out', type: 'outputNode', content: {} },
|
|
],
|
|
edges: [
|
|
{ id: 'a', sourceId: 'in', targetId: 'fn' },
|
|
{ id: 'b', sourceId: 'fn', targetId: 'after' },
|
|
{ id: 'c', sourceId: 'after', targetId: 'out' },
|
|
],
|
|
});
|
|
|
|
describe('Workspace function type resolution', () => {
|
|
it('types annotated handlers through tsc', () => {
|
|
const ws = new Workspace(resolveWithTsc);
|
|
ws.setDocument(
|
|
'g',
|
|
functionGraph(
|
|
'export const handler = async (input: { age: number }): Promise<{ total: number }> => ({ total: input.age * 2 });',
|
|
),
|
|
);
|
|
|
|
const diagnostics = ws.diagnostics('g');
|
|
expect(diagnostics).toHaveLength(0);
|
|
|
|
const outputs = ws.outputs({ policyPath: 'g' });
|
|
const grand = outputs.find((o: any) => o.path === 'grand');
|
|
expect(grand?.resolvedType).toEqual({ type: 'number' });
|
|
expect(ws.uncheckedNodes('g')).toHaveLength(0);
|
|
});
|
|
|
|
it('infers unannotated handlers against the dynamic input type', () => {
|
|
const ws = new Workspace(resolveWithTsc);
|
|
ws.setDocument(
|
|
'g',
|
|
functionGraph('export const handler = async (input: Input) => ({ total: input.age * 2, label: input.name });'),
|
|
);
|
|
|
|
const diagnostics = ws.diagnostics('g');
|
|
expect(diagnostics).toHaveLength(0);
|
|
|
|
const outputs = ws.outputs({ policyPath: 'g' });
|
|
expect(outputs.find((o: any) => o.path === 'grand')?.resolvedType).toEqual({ type: 'number' });
|
|
});
|
|
|
|
it('reports resolved any as an error', () => {
|
|
const ws = new Workspace(resolveWithTsc);
|
|
ws.setDocument(
|
|
'g',
|
|
functionGraph('export const handler = async (input: any) => input.whatever;'),
|
|
);
|
|
|
|
const diagnostics = ws.diagnostics('g');
|
|
const errors = diagnostics.filter((d: any) => d.severity === 'error' && d.blockId === 'fn');
|
|
expect(errors).toHaveLength(1);
|
|
expect(errors[0].message).toContain('any');
|
|
});
|
|
|
|
it('warns when no resolver is registered', () => {
|
|
const ws = new Workspace();
|
|
ws.setDocument(
|
|
'g',
|
|
functionGraph('export const handler = async (input) => ({ total: 1 });'),
|
|
);
|
|
|
|
const diagnostics = ws.diagnostics('g');
|
|
const warnings = diagnostics.filter((d: any) => d.code === 'UNRESOLVED_FUNCTION_TYPE');
|
|
expect(warnings).toHaveLength(1);
|
|
expect(warnings[0].blockId).toBe('fn');
|
|
|
|
const requests = ws.functionResolutionRequests();
|
|
expect(requests).toHaveLength(1);
|
|
ws.setFunctionType(requests[0].source, requests[0].inputType, '{ total: number }');
|
|
expect(ws.diagnostics('g')).toHaveLength(0);
|
|
});
|
|
});
|
|
|
|
describe('Workspace function inference over collections', () => {
|
|
const itemsSchema = JSON.stringify({
|
|
type: 'object',
|
|
properties: {
|
|
items: {
|
|
type: 'array',
|
|
items: {
|
|
type: 'object',
|
|
properties: { qty: { type: 'number' }, name: { type: 'string' } },
|
|
required: ['qty', 'name'],
|
|
},
|
|
},
|
|
},
|
|
required: ['items'],
|
|
});
|
|
|
|
it('infers mapped and reduced collection types', () => {
|
|
const ws = new Workspace(resolveWithTsc);
|
|
ws.setDocument('g', {
|
|
nodes: [
|
|
{ id: 'in', name: 'in', type: 'inputNode', content: { schema: itemsSchema } },
|
|
{
|
|
id: 'fn',
|
|
name: 'fn',
|
|
type: 'functionNode',
|
|
content: {
|
|
source: [
|
|
'export const handler = async (input: Input) => ({',
|
|
' doubled: input.items.map((item) => ({ value: item.qty * 2, label: item.name })),',
|
|
' total: input.items.reduce((acc, item) => acc + item.qty, 0),',
|
|
" tiers: input.items.map((item) => (item.qty > 10 ? 'bulk' : 'single') as 'bulk' | 'single'),",
|
|
'});',
|
|
].join('\n'),
|
|
},
|
|
},
|
|
{
|
|
id: 'after',
|
|
name: 'after',
|
|
type: 'expressionNode',
|
|
content: {
|
|
expressions: [
|
|
{ id: 'e1', key: 'grandTotal', value: 'total + 1' },
|
|
{ id: 'e2', key: 'values', value: 'map(doubled, #.value)' },
|
|
{ id: 'e3', key: 'labels', value: 'map(doubled, #.label)' },
|
|
{ id: 'e4', key: 'tierList', value: 'tiers' },
|
|
],
|
|
},
|
|
},
|
|
{ id: 'out', name: 'out', type: 'outputNode', content: {} },
|
|
],
|
|
edges: [
|
|
{ id: 'a', sourceId: 'in', targetId: 'fn' },
|
|
{ id: 'b', sourceId: 'fn', targetId: 'after' },
|
|
{ id: 'c', sourceId: 'after', targetId: 'out' },
|
|
],
|
|
});
|
|
|
|
const diagnostics = ws.diagnostics('g');
|
|
const errors = diagnostics.filter((d: any) => d.severity === 'error');
|
|
expect(errors).toHaveLength(0);
|
|
|
|
const outputs = ws.outputs({ policyPath: 'g' });
|
|
const byPath = Object.fromEntries(outputs.map((o: any) => [o.path, o.resolvedType]));
|
|
expect(byPath.grandTotal).toEqual({ type: 'number' });
|
|
expect(byPath.values).toEqual({ type: 'array', items: { type: 'number' } });
|
|
expect(byPath.labels).toEqual({ type: 'array', items: { type: 'string' } });
|
|
expect(byPath.tierList.type).toBe('array');
|
|
expect(byPath.tierList.items.type).toBe('enum');
|
|
|
|
const inspected = ws.inspect({
|
|
policyPath: 'g',
|
|
blockId: 'after',
|
|
pos: 5,
|
|
target: { kind: 'expression', id: 'e2' },
|
|
});
|
|
expect(inspected?.kind?.type).toBe('array');
|
|
});
|
|
});
|
|
|
|
describe('Workspace graph decision tables', () => {
|
|
const typedTableGraph = (columnType: string, cells: string[]) => ({
|
|
nodes: [
|
|
{ id: 'in', name: 'in', type: 'inputNode', content: { schema: personSchema } },
|
|
{
|
|
id: 'dt',
|
|
name: 'dt',
|
|
type: 'decisionTableNode',
|
|
content: {
|
|
hitPolicy: 'first',
|
|
inputs: [{ id: 'c1', name: 'Age', field: 'age' }],
|
|
outputs: [{ id: 'o1', name: 'Score', field: 'score', type: columnType }],
|
|
rules: cells.map((cell, i) => ({ _id: `r${i}`, c1: i === 0 ? '< 18' : '', o1: cell })),
|
|
},
|
|
},
|
|
{ id: 'out', name: 'out', type: 'outputNode', content: {} },
|
|
],
|
|
edges: [
|
|
{ id: 'a', sourceId: 'in', targetId: 'dt' },
|
|
{ id: 'b', sourceId: 'dt', targetId: 'out' },
|
|
],
|
|
});
|
|
|
|
it('checks cells against the declared output type', () => {
|
|
const ws = new Workspace();
|
|
ws.setDocument('g', typedTableGraph('number', ['10', "'high'"]));
|
|
|
|
const diagnostics = ws.diagnostics('g');
|
|
expect(diagnostics).toHaveLength(1);
|
|
expect(diagnostics[0].code).toBe('TYPE_MISMATCH');
|
|
expect(diagnostics[0].message).toContain('output cell must be `number`');
|
|
expect(diagnostics[0].target).toEqual({ kind: 'decisionTableCell', row: 'r1', col: 'o1' });
|
|
});
|
|
|
|
it('uses the declared type as the output schema', () => {
|
|
const ws = new Workspace();
|
|
ws.setDocument('g', typedTableGraph('number', ['10', '20']));
|
|
|
|
expect(ws.diagnostics('g')).toHaveLength(0);
|
|
const outputs = ws.outputs({ policyPath: 'g' });
|
|
expect(outputs.find((o: any) => o.path === 'score')?.resolvedType).toEqual({ type: 'number' });
|
|
});
|
|
});
|