feat: 补全代码节点输入输出智能同步

- 为 JavaScript 和 Python 新节点生成 main 与显式调用脚手架

- 同步输入签名、静态返回字段和输出参数并推断受支持类型

- 增加安全改写边界与 Tinyflow 定向测试
This commit is contained in:
2026-08-03 16:20:54 +08:00
parent 08fe3ba1f1
commit 46398d1365
10 changed files with 2107 additions and 70 deletions

View File

@@ -0,0 +1,168 @@
import type { Parameter } from '#types';
export type CodeNodeEngine = 'javascript' | 'python';
const PYTHON_RESERVED_WORDS = new Set([
'False',
'None',
'True',
'and',
'as',
'assert',
'async',
'await',
'break',
'class',
'continue',
'def',
'del',
'elif',
'else',
'except',
'finally',
'for',
'from',
'global',
'if',
'import',
'in',
'is',
'lambda',
'nonlocal',
'not',
'or',
'pass',
'raise',
'return',
'try',
'while',
'with',
'yield',
]);
const JAVASCRIPT_RESERVED_WORDS = new Set([
'await',
'break',
'case',
'catch',
'class',
'const',
'continue',
'debugger',
'default',
'delete',
'do',
'else',
'enum',
'export',
'extends',
'false',
'finally',
'for',
'function',
'if',
'implements',
'import',
'in',
'instanceof',
'interface',
'let',
'new',
'null',
'package',
'private',
'protected',
'public',
'return',
'static',
'super',
'switch',
'this',
'throw',
'true',
'try',
'typeof',
'var',
'void',
'while',
'with',
'yield',
]);
/**
* 将代码节点执行引擎别名归一化为编辑器使用的语言名称。
*/
export function normalizeCodeNodeEngine(rawEngine?: string): CodeNodeEngine {
const normalized = (rawEngine || 'js').trim().toLowerCase();
return normalized === 'python' || normalized === 'py'
? 'python'
: 'javascript';
}
/**
* 判断参数名称能否安全写入目标语言的函数签名。
*/
export function isValidCodeNodeParameterName(name: string, rawEngine?: string) {
const engine = normalizeCodeNodeEngine(rawEngine);
if (engine === 'python') {
return /^[A-Za-z_]\w*$/.test(name) && !PYTHON_RESERVED_WORDS.has(name);
}
return (
/^[A-Za-z_$][\w$]*$/.test(name) && !JAVASCRIPT_RESERVED_WORDS.has(name)
);
}
/**
* 为新代码节点生成与当前运行时兼容的 main 脚手架。
*/
export function createCodeNodeScaffold(
rawEngine?: string,
parameters: Parameter[] = [],
outputDefs: Parameter[] = [],
) {
const engine = normalizeCodeNodeEngine(rawEngine);
const parameterNames = [
...new Set(
parameters
.map((parameter) => String(parameter.name || '').trim())
.filter((name) => isValidCodeNodeParameterName(name, engine)),
),
];
const outputNames = [
...new Set(
outputDefs
.map((parameter) => String(parameter.name || '').trim())
.filter(Boolean),
),
];
const argumentsText = parameterNames.join(', ');
if (engine === 'python') {
const outputLines = outputNames.map(
(name) => ` ${JSON.stringify(name)}: None,`,
);
return [
`def main(${argumentsText}):`,
' return {',
...outputLines,
' }',
'',
`_result = main(${argumentsText})`,
].join('\n');
}
const outputLines = outputNames.map(
(name) => ` ${JSON.stringify(name)}: null,`,
);
return [
`function main(${argumentsText}) {`,
' return {',
...outputLines,
' };',
'}',
'',
`_result = main(${argumentsText});`,
].join('\n');
}
export const DEFAULT_CODE_NODE_JAVASCRIPT = createCodeNodeScaffold('js');

View File

@@ -0,0 +1,544 @@
import { describe, expect, it } from 'vitest';
import type { Parameter } from '#types';
import { createCodeNodeScaffold } from './codeNodeScaffold';
import { getAvailableNodes } from './nodePalette';
import {
analyzeCodeNodeOutputs,
reconcileInferredCodeNodeOutputs,
syncCodeNodeInputParameters,
syncCodeNodeOutputDefinitions,
} from './codeNodeSync';
describe('code node scaffold', () => {
it('is attached to newly created code nodes', () => {
const codeNode = getAvailableNodes().find(
(node) => node.type === 'codeNode',
);
expect(codeNode?.extra).toMatchObject({
code: createCodeNodeScaffold('js'),
codeScaffoldManaged: true,
codeScaffoldVersion: 1,
engine: 'js',
});
});
it('creates an explicit javascript main call', () => {
expect(
createCodeNodeScaffold('js', [{ name: 'data' }], [{ name: 'result' }]),
).toBe(
[
'function main(data) {',
' return {',
' "result": null,',
' };',
'}',
'',
'_result = main(data);',
].join('\n'),
);
});
it('creates an explicit python main call', () => {
expect(
createCodeNodeScaffold(
'python',
[{ name: 'data' }],
[{ name: 'result' }],
),
).toBe(
[
'def main(data):',
' return {',
' "result": None,',
' }',
'',
'_result = main(data)',
].join('\n'),
);
});
it('does not generate invalid or duplicate function parameters', () => {
const code = createCodeNodeScaffold('python', [
{ name: 'data' },
{ name: 'class' },
{ name: 'data' },
]);
expect(code).toContain('def main(data):');
expect(code).toContain('_result = main(data)');
});
});
describe.each([
{
engine: 'js',
emptyCode: createCodeNodeScaffold('js'),
mainWithData: 'function main(data)',
callWithData: '_result = main(data);',
mainWithQuery: 'function main(query)',
callWithQuery: '_result = main(query);',
},
{
engine: 'python',
emptyCode: createCodeNodeScaffold('python'),
mainWithData: 'def main(data):',
callWithData: '_result = main(data)',
mainWithQuery: 'def main(query):',
callWithQuery: '_result = main(query)',
},
])('code node input sync for $engine', (fixture) => {
it('syncs a newly configured input name to main and its call', () => {
const blankParameter: Parameter = {
id: 'input-1',
name: '',
dataType: 'String',
};
const blankResult = syncCodeNodeInputParameters(
fixture.emptyCode,
fixture.engine,
[],
[blankParameter],
);
expect(blankResult.synced).toBe(false);
expect(blankResult.code).toBe(fixture.emptyCode);
const namedResult = syncCodeNodeInputParameters(
blankResult.code,
fixture.engine,
blankResult.parameters,
[{ ...blankResult.parameters[0], name: 'data' }],
);
expect(namedResult.synced).toBe(true);
expect(namedResult.code).toContain(fixture.mainWithData);
expect(namedResult.code).toContain(fixture.callWithData);
expect(namedResult.parameters[0].codeSyncName).toBe('data');
});
it('keeps the previous binding while the user clears and renames it', () => {
const initial = syncCodeNodeInputParameters(
fixture.emptyCode,
fixture.engine,
[],
[{ id: 'input-1', name: 'data', dataType: 'String' }],
);
const cleared = syncCodeNodeInputParameters(
initial.code,
fixture.engine,
initial.parameters,
[{ ...initial.parameters[0], name: '' }],
);
const renamed = syncCodeNodeInputParameters(
cleared.code,
fixture.engine,
cleared.parameters,
[{ ...cleared.parameters[0], name: 'query' }],
);
expect(cleared.synced).toBe(false);
expect(cleared.parameters[0].codeSyncName).toBe('data');
expect(renamed.synced).toBe(true);
expect(renamed.code).toContain(fixture.mainWithQuery);
expect(renamed.code).toContain(fixture.callWithQuery);
});
it('does not overwrite a user-managed main signature', () => {
const initial = syncCodeNodeInputParameters(
fixture.emptyCode,
fixture.engine,
[],
[{ id: 'input-1', name: 'data' }],
);
const manualCode = initial.code.replace(
fixture.mainWithData,
fixture.engine === 'python'
? 'def main(custom):'
: 'function main(custom)',
);
const result = syncCodeNodeInputParameters(
manualCode,
fixture.engine,
initial.parameters,
[{ ...initial.parameters[0], name: 'query' }],
);
expect(result.synced).toBe(false);
expect(result.code).toBe(manualCode);
});
it('does not partially update multiple explicit main calls', () => {
const initial = syncCodeNodeInputParameters(
fixture.emptyCode,
fixture.engine,
[],
[{ id: 'input-1', name: 'data' }],
);
const duplicatedCall = [
initial.code,
fixture.engine === 'python'
? '_result = main(data)'
: '_result = main(data);',
].join('\n');
const result = syncCodeNodeInputParameters(
duplicatedCall,
fixture.engine,
initial.parameters,
[{ ...initial.parameters[0], name: 'query' }],
);
expect(result.synced).toBe(false);
expect(result.code).toBe(duplicatedCall);
});
it('syncs input ordering and deletion', () => {
const initial = syncCodeNodeInputParameters(
fixture.emptyCode,
fixture.engine,
[],
[
{ id: 'input-1', name: 'data' },
{ id: 'input-2', name: 'question' },
],
);
const reordered = syncCodeNodeInputParameters(
initial.code,
fixture.engine,
initial.parameters,
[initial.parameters[1], initial.parameters[0]],
);
const deleted = syncCodeNodeInputParameters(
reordered.code,
fixture.engine,
reordered.parameters,
[reordered.parameters[0]],
);
expect(reordered.code).toContain('main(question, data)');
expect(deleted.code).toContain('main(question)');
expect(deleted.code).not.toContain('main(question, data)');
});
});
describe.each([
{
engine: 'js',
emptyCode: createCodeNodeScaffold('js'),
expectedEntry: '"answer": null',
},
{
engine: 'python',
emptyCode: createCodeNodeScaffold('python'),
expectedEntry: '"answer": None',
},
])('code node output sync for $engine', (fixture) => {
it('adds a configured output name to the return object', () => {
const blankOutput: Parameter = {
id: 'output-1',
name: '',
dataType: 'String',
};
const blankResult = syncCodeNodeOutputDefinitions(
fixture.emptyCode,
fixture.engine,
[],
[blankOutput],
);
const namedResult = syncCodeNodeOutputDefinitions(
blankResult.code,
fixture.engine,
blankResult.outputDefs,
[{ ...blankResult.outputDefs[0], name: 'answer' }],
);
expect(namedResult.synced).toBe(true);
expect(namedResult.code).toContain(fixture.expectedEntry);
expect(namedResult.outputDefs[0].codeSyncName).toBe('answer');
});
it('renames the matching return key', () => {
const initial = syncCodeNodeOutputDefinitions(
fixture.emptyCode,
fixture.engine,
[],
[{ id: 'output-1', name: 'answer', dataType: 'String' }],
);
const renamed = syncCodeNodeOutputDefinitions(
initial.code,
fixture.engine,
initial.outputDefs,
[{ ...initial.outputDefs[0], name: 'message' }],
);
expect(renamed.synced).toBe(true);
expect(renamed.code).toContain(
fixture.engine === 'python' ? '"message": None' : '"message": null',
);
expect(renamed.code).not.toContain(fixture.expectedEntry);
});
it('adds the renamed key when the previous return key is absent', () => {
const renamed = syncCodeNodeOutputDefinitions(
fixture.emptyCode,
fixture.engine,
[
{
id: 'output-1',
name: 'answer',
dataType: 'String',
codeSyncName: 'answer',
},
],
[
{
id: 'output-1',
name: 'message',
dataType: 'String',
codeSyncName: 'answer',
},
],
);
expect(renamed.code).toContain(
fixture.engine === 'python' ? '"message": None' : '"message": null',
);
});
});
describe('code node output inference', () => {
it.each([
{
engine: 'js',
code: [
'function main(data) {',
' const answer = data;',
' return { answer };',
'}',
'_result = main(data);',
].join('\n'),
},
{
engine: 'python',
code: [
'def main(data):',
' answer = data',
' return {"answer": answer}',
'_result = main(data)',
].join('\n'),
},
])(
'preserves the return expression when renaming for $engine',
({ code, engine }) => {
const renamed = syncCodeNodeOutputDefinitions(
code,
engine,
[
{
id: 'output-1',
name: 'answer',
dataType: 'String',
codeSyncName: 'answer',
},
],
[
{
id: 'output-1',
name: 'message',
dataType: 'String',
codeSyncName: 'answer',
},
],
);
expect(renamed.code).toContain('"message": answer');
},
);
it('infers the python return fields used by the executable example', () => {
const code = [
'import json',
'def main(data):',
' r1 = data + "111"',
' r2 = "222"',
' return {',
' "r1": r1,',
' "r2": r2,',
' }',
'_result = main(data)',
].join('\n');
expect(
analyzeCodeNodeOutputs(code, 'python', [
{ name: 'data', dataType: 'String' },
]),
).toEqual([
{ name: 'r1', dataType: 'String' },
{ name: 'r2', dataType: 'String' },
]);
});
it('infers supported python primitive and container types', () => {
const code = [
'def main(data, count, file):',
' text = f"{data}-ok"',
' total = count + 1',
' return {',
' "text": text,',
' "total": total,',
' "ok": True,',
' "file": file,',
' "objectValue": {},',
' "arrayValue": [],',
' }',
'_result = main(data, count, file)',
].join('\n');
expect(
analyzeCodeNodeOutputs(code, 'python', [
{ name: 'data', dataType: 'String' },
{ name: 'count', dataType: 'Number' },
{ name: 'file', dataType: 'File' },
]),
).toEqual([
{ name: 'text', dataType: 'String' },
{ name: 'total', dataType: 'Number' },
{ name: 'ok', dataType: 'Boolean' },
{ name: 'file', dataType: 'File' },
{ name: 'objectValue', dataType: 'Object' },
{ name: 'arrayValue', dataType: 'Array' },
]);
});
it('infers supported javascript primitive and container types', () => {
const code = [
'function main(data, count, file) {',
' const text = data + "x";',
' const total = count + 1;',
' return {',
' text,',
' total,',
' ok: true,',
' file,',
' objectValue: {},',
' arrayValue: [],',
' };',
'}',
'_result = main(data, count, file);',
].join('\n');
expect(
analyzeCodeNodeOutputs(code, 'js', [
{ name: 'data', dataType: 'String' },
{ name: 'count', dataType: 'Number' },
{ name: 'file', dataType: 'File' },
]),
).toEqual([
{ name: 'text', dataType: 'String' },
{ name: 'total', dataType: 'Number' },
{ name: 'ok', dataType: 'Boolean' },
{ name: 'file', dataType: 'File' },
{ name: 'objectValue', dataType: 'Object' },
{ name: 'arrayValue', dataType: 'Array' },
]);
});
it('falls back to String for a static key with a dynamic value', () => {
const code = [
'function main(data) {',
' return { answer: externalCall(data) };',
'}',
'_result = main(data);',
].join('\n');
expect(analyzeCodeNodeOutputs(code, 'js', [])).toEqual([
{ name: 'answer', dataType: 'String' },
]);
});
it('does not infer assignment types across complex control flow', () => {
const code = [
'function main(data) {',
' let value = 1;',
' if (data) {',
' value = true;',
' }',
' return { value };',
'}',
'_result = main(data);',
].join('\n');
expect(analyzeCodeNodeOutputs(code, 'js', [])).toEqual([
{ name: 'value', dataType: 'String' },
]);
});
it.each([
{
engine: 'js',
code: [
'function main(data) {',
' return { fixed: 1, ...data };',
'}',
'_result = main(data);',
].join('\n'),
},
{
engine: 'python',
code: [
'def main(data):',
' return {"fixed": 1, **data}',
'_result = main(data)',
].join('\n'),
},
])(
'does not infer fields from a spread return object for $engine',
({ code, engine }) => {
expect(analyzeCodeNodeOutputs(code, engine, [])).toBeNull();
},
);
it('preserves manual output types and removes stale inferred fields', () => {
const outputs = reconcileInferredCodeNodeOutputs(
[
{
id: 'manualized',
name: 'answer',
dataType: 'Number',
codeInferred: true,
codeInferredDataType: 'String',
},
{
id: 'stale',
name: 'oldField',
dataType: 'String',
codeInferred: true,
codeInferredDataType: 'String',
},
],
[
{ name: 'answer', dataType: 'String' },
{ name: 'newField', dataType: 'Boolean' },
],
() => 'generated',
);
expect(outputs).toEqual([
{
id: 'manualized',
name: 'answer',
dataType: 'Number',
},
{
id: 'generated',
name: 'newField',
dataType: 'Boolean',
codeInferred: true,
codeInferredDataType: 'Boolean',
codeSyncName: 'newField',
},
]);
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -2,6 +2,8 @@ import type { Node } from '@xyflow/svelte';
import type { TinyflowOptions } from '#types';
import { DEFAULT_CODE_NODE_JAVASCRIPT } from './codeNodeScaffold';
export type NodePaletteItem = {
icon?: string;
title: string;
@@ -76,6 +78,12 @@ const BUILT_IN_NODES: NodePaletteItem[] = [
sortNo: 700,
description: '动态执行代码',
category: '逻辑',
extra: {
code: DEFAULT_CODE_NODE_JAVASCRIPT,
codeScaffoldManaged: true,
codeScaffoldVersion: 1,
engine: 'js',
},
},
{
icon: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M2 4C2 3.44772 2.44772 3 3 3H21C21.5523 3 22 3.44772 22 4V20C22 20.5523 21.5523 21 21 21H3C2.44772 21 2 20.5523 2 20V4ZM4 5V19H20V5H4ZM7 8H17V11H15V10H13V14H14.5V16H9.5V14H11V10H9V11H7V8Z"></path></svg>',