From e670047e6a504c84af66f3c2139e5d15c4f75107 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B0=8F=E6=A9=98?= Date: Wed, 6 May 2026 06:26:14 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20build=20pipeline=20=E2=80=94=20.ts=20?= =?UTF-8?q?=E2=86=92=20.esm.js=20+=20.yaml=20+=20.d.ts=20=E4=B8=89?= =?UTF-8?q?=E4=BB=B6=E5=A5=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - add command auto-detects .ts vs .esm.js input - .ts: Bun.build → bundle + descriptor extraction + JSON Schema → .d.ts - .esm.js: requires .yaml alongside, .d.ts optional - JSON Schema → TypeScript type converter - hello-world example workflow - 63 tests pass, biome clean Closes #7 小橘 --- biome.json | 10 + examples/hello-world.ts | 2 +- .../cli-workflow/__tests__/commands.test.ts | 36 ++- .../cli-workflow/__tests__/fork-cli.test.ts | 3 +- .../cli-workflow/__tests__/thread-cli.test.ts | 3 +- packages/cli-workflow/src/add-argv.ts | 86 ++++--- packages/cli-workflow/src/bundle-store.ts | 4 +- packages/cli-workflow/src/cmd-add.ts | 213 ++++++++++-------- .../workflow/__tests__/build-pipeline.test.ts | 41 ++-- .../__tests__/bundle-validator.test.ts | 8 + .../fixtures/minimal-build-workflow.ts | 30 +++ packages/workflow/src/build-pipeline.ts | 11 +- packages/workflow/src/bundle-validator.ts | 53 +++-- packages/workflow/src/index.ts | 10 +- packages/workflow/src/json-schema-to-ts.ts | 131 ++++++----- 15 files changed, 405 insertions(+), 236 deletions(-) create mode 100644 packages/workflow/__tests__/fixtures/minimal-build-workflow.ts diff --git a/biome.json b/biome.json index 61030ca..930f8e0 100644 --- a/biome.json +++ b/biome.json @@ -38,6 +38,16 @@ } } } + }, + { + "includes": ["examples/**/*.ts", "packages/workflow/__tests__/fixtures/**/*.ts"], + "linter": { + "rules": { + "style": { + "noDefaultExport": "off" + } + } + } } ], "linter": { diff --git a/examples/hello-world.ts b/examples/hello-world.ts index 6c7489a..96e3b74 100644 --- a/examples/hello-world.ts +++ b/examples/hello-world.ts @@ -19,7 +19,7 @@ export const descriptor = { }; const greeter: Role = async (ctx) => ({ - content: "Hello, " + ctx.start.content, + content: `Hello, ${ctx.start.content}`, meta: { greeting: "Hello!" }, }); diff --git a/packages/cli-workflow/__tests__/commands.test.ts b/packages/cli-workflow/__tests__/commands.test.ts index 301cbf8..0d2f04a 100644 --- a/packages/cli-workflow/__tests__/commands.test.ts +++ b/packages/cli-workflow/__tests__/commands.test.ts @@ -5,14 +5,13 @@ import { join } from "node:path"; import { fileURLToPath } from "node:url"; import { getRegisteredWorkflow, readWorkflowRegistry } from "@uncaged/workflow"; - -import { addCliArgs, MINIMAL_DESCRIPTOR_YAML } from "./bundle-fixture.js"; import { cmdAdd } from "../src/cmd-add.js"; import { cmdHistory } from "../src/cmd-history.js"; import { cmdList, formatListLines } from "../src/cmd-list.js"; import { cmdRemove } from "../src/cmd-remove.js"; import { cmdRollback } from "../src/cmd-rollback.js"; import { cmdShow } from "../src/cmd-show.js"; +import { addCliArgs, MINIMAL_DESCRIPTOR_YAML } from "./bundle-fixture.js"; describe("cli workflow commands", () => { let prevEnv: string | undefined; @@ -144,6 +143,39 @@ export default async function* (input) { expect(entry.hash).toBe(hash); }); + test("add from .esm.js with --descriptor uses explicit YAML path", async () => { + const bundleDir = join(storageRoot, "w"); + await mkdir(bundleDir, { recursive: true }); + const bundlePath = join(bundleDir, "app.esm.js"); + const yamlPath = join(bundleDir, "desc.yaml"); + await writeFile( + bundlePath, + `export default async function* (input) { + yield { role: "a", content: "x", meta: {} }; + return { returnCode: 0, summary: "x" }; +} +`, + "utf8", + ); + await writeFile(yamlPath, MINIMAL_DESCRIPTOR_YAML, "utf8"); + + const added = await cmdAdd(storageRoot, { + name: "app", + filePath: bundlePath, + descriptorPath: yamlPath, + typesPath: null, + }); + expect(added.ok).toBe(true); + if (!added.ok) { + return; + } + const yamlStored = await readFile( + join(storageRoot, "bundles", `${added.value.hash}.yaml`), + "utf8", + ); + expect(yamlStored).toContain("fixture"); + }); + test("add from .esm.js warns when optional .d.ts is missing", async () => { const bundleDir = join(storageRoot, "src"); await mkdir(bundleDir, { recursive: true }); diff --git a/packages/cli-workflow/__tests__/fork-cli.test.ts b/packages/cli-workflow/__tests__/fork-cli.test.ts index 1b5d345..23c4248 100644 --- a/packages/cli-workflow/__tests__/fork-cli.test.ts +++ b/packages/cli-workflow/__tests__/fork-cli.test.ts @@ -2,12 +2,11 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; - -import { addCliArgs, MINIMAL_DESCRIPTOR_YAML } from "./bundle-fixture.js"; import { cmdAdd } from "../src/cmd-add.js"; import { cmdFork } from "../src/cmd-fork.js"; import { cmdRun } from "../src/cmd-run.js"; import { pathExists } from "../src/fs-utils.js"; +import { addCliArgs, MINIMAL_DESCRIPTOR_YAML } from "./bundle-fixture.js"; /** Three-role workflow that respects `input.steps` for fork/resume. */ const threeRoleBundleSource = `export default async function* (input) { diff --git a/packages/cli-workflow/__tests__/thread-cli.test.ts b/packages/cli-workflow/__tests__/thread-cli.test.ts index f75f46e..c00c25d 100644 --- a/packages/cli-workflow/__tests__/thread-cli.test.ts +++ b/packages/cli-workflow/__tests__/thread-cli.test.ts @@ -4,8 +4,6 @@ import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; - -import { addCliArgs, MINIMAL_DESCRIPTOR_YAML } from "./bundle-fixture.js"; import { cmdAdd } from "../src/cmd-add.js"; import { cmdKill } from "../src/cmd-kill.js"; import { cmdPause } from "../src/cmd-pause.js"; @@ -15,6 +13,7 @@ import { cmdRun } from "../src/cmd-run.js"; import { cmdThreadRemove, cmdThreadShow } from "../src/cmd-thread.js"; import { cmdThreads } from "../src/cmd-threads.js"; import { pathExists } from "../src/fs-utils.js"; +import { addCliArgs, MINIMAL_DESCRIPTOR_YAML } from "./bundle-fixture.js"; const fastBundleSource = `export default async function* (input) { yield { role: "planner", content: "plan", meta: { plan: input.prompt } }; diff --git a/packages/cli-workflow/src/add-argv.ts b/packages/cli-workflow/src/add-argv.ts index 5e684a5..479af4c 100644 --- a/packages/cli-workflow/src/add-argv.ts +++ b/packages/cli-workflow/src/add-argv.ts @@ -9,49 +9,81 @@ export type ParsedAddArgv = { typesPath: string | null; }; +type ParsedLongFlag = + | { advance: 2; kind: "descriptor"; value: string } + | { advance: 2; kind: "types"; value: string }; + +type PositionalSlots = { + name: string | undefined; + filePath: string | undefined; +}; + +function assignPositional(tok: string, slots: PositionalSlots): Result { + if (slots.name === undefined) { + slots.name = tok; + return ok(undefined); + } + if (slots.filePath === undefined) { + slots.filePath = tok; + return ok(undefined); + } + return err("too many arguments"); +} + +function tryParseAddLongFlag(argv: string[], index: number): Result { + const tok = argv[index]; + if (tok !== "--descriptor" && tok !== "--types") { + return ok(null); + } + const value = argv[index + 1]; + if (value === undefined || value.startsWith("--")) { + return err( + tok === "--descriptor" ? "missing value for --descriptor" : "missing value for --types", + ); + } + if (tok === "--descriptor") { + return ok({ advance: 2, kind: "descriptor", value }); + } + return ok({ advance: 2, kind: "types", value }); +} + export function parseAddArgv(argv: string[]): Result { - let name: string | undefined; - let filePath: string | undefined; + const slots: PositionalSlots = { name: undefined, filePath: undefined }; let descriptorPath: string | null = null; let typesPath: string | null = null; let i = 0; while (i < argv.length) { + const flag = tryParseAddLongFlag(argv, i); + if (!flag.ok) { + return flag; + } + if (flag.value !== null) { + const f = flag.value; + if (f.kind === "descriptor") { + descriptorPath = f.value; + } else { + typesPath = f.value; + } + i += f.advance; + continue; + } + const tok = argv[i]; - if (tok === "--descriptor") { - const value = argv[i + 1]; - if (value === undefined || value.startsWith("--")) { - return err("missing value for --descriptor"); - } - descriptorPath = value; - i += 2; - continue; - } - if (tok === "--types") { - const value = argv[i + 1]; - if (value === undefined || value.startsWith("--")) { - return err("missing value for --types"); - } - typesPath = value; - i += 2; - continue; - } - if (tok !== undefined && tok.startsWith("--")) { + if (tok?.startsWith("--")) { return err(`unknown add flag: ${tok}`); } if (tok === undefined) { break; } - if (name === undefined) { - name = tok; - } else if (filePath === undefined) { - filePath = tok; - } else { - return err("too many arguments"); + const placed = assignPositional(tok, slots); + if (!placed.ok) { + return placed; } i += 1; } + const { name, filePath } = slots; if (name === undefined || name === "" || filePath === undefined || filePath === "") { return err("add requires "); } diff --git a/packages/cli-workflow/src/bundle-store.ts b/packages/cli-workflow/src/bundle-store.ts index 501bb28..743c2fb 100644 --- a/packages/cli-workflow/src/bundle-store.ts +++ b/packages/cli-workflow/src/bundle-store.ts @@ -12,9 +12,7 @@ async function pathExists(path: string): Promise { } } -export type BundleFileSource = - | { kind: "text"; text: string } - | { kind: "path"; path: string }; +export type BundleFileSource = { kind: "text"; text: string } | { kind: "path"; path: string }; export type WorkflowBundleStoreInput = { esmJs: BundleFileSource; diff --git a/packages/cli-workflow/src/cmd-add.ts b/packages/cli-workflow/src/cmd-add.ts index 9f1f0d0..0e44be9 100644 --- a/packages/cli-workflow/src/cmd-add.ts +++ b/packages/cli-workflow/src/cmd-add.ts @@ -56,6 +56,127 @@ async function registerHash( return ok(undefined); } +async function addFromTypeScript( + storageRoot: string, + workflowName: string, + resolvedTsPath: string, +): Promise> { + const built = await buildWorkflowFromTypeScript(resolvedTsPath); + if (!built.ok) { + return built; + } + + const encoder = new TextEncoder(); + const bytes = encoder.encode(built.value.esmJsSource); + const hash = hashWorkflowBundleBytes(bytes); + + const stored = await storeWorkflowBundleArtifacts(storageRoot, hash, { + esmJs: { kind: "text", text: built.value.esmJsSource }, + yaml: { kind: "text", text: built.value.yamlSource }, + dts: { kind: "text", text: built.value.dtsSource }, + }); + if (!stored.ok) { + return stored; + } + + const regResult = await registerHash(storageRoot, workflowName, hash); + if (!regResult.ok) { + return regResult; + } + + return ok({ hash, warnings: [] }); +} + +async function resolveYamlAndOptionalTypes( + args: ParsedAddArgv, + resolvedBundlePath: string, +): Promise> { + const warnings: string[] = []; + const yamlResolved = + args.descriptorPath !== null + ? resolve(args.descriptorPath) + : defaultDescriptorPath(resolvedBundlePath); + + let yamlText: string; + try { + yamlText = await readFile(yamlResolved, "utf8"); + } catch { + return err(`descriptor YAML not found: ${yamlResolved}`); + } + + let dtsText: string | null = null; + if (args.typesPath !== null) { + const typesResolved = resolve(args.typesPath); + try { + dtsText = await readFile(typesResolved, "utf8"); + } catch { + return err(`types file not found: ${typesResolved}`); + } + } else { + const typesDefault = defaultTypesPath(resolvedBundlePath); + try { + dtsText = await readFile(typesDefault, "utf8"); + } catch { + warnings.push(`optional types file not found (${basename(typesDefault)}); skipped`); + } + } + + return ok({ yamlText, dtsText, warnings }); +} + +async function addFromEsmJs( + storageRoot: string, + workflowName: string, + args: ParsedAddArgv, + resolvedBundlePath: string, +): Promise> { + let source: string; + try { + source = await readFile(resolvedBundlePath, "utf8"); + } catch (e) { + const message = e instanceof Error ? e.message : String(e); + return err(`failed to read bundle: ${message}`); + } + + const validated = validateWorkflowBundle({ + filePath: resolvedBundlePath, + source, + }); + if (!validated.ok) { + return validated; + } + + const companions = await resolveYamlAndOptionalTypes(args, resolvedBundlePath); + if (!companions.ok) { + return companions; + } + + const encoder = new TextEncoder(); + const bytes = encoder.encode(source); + const hash = hashWorkflowBundleBytes(bytes); + + const dts = + companions.value.dtsText === null + ? null + : { kind: "text" as const, text: companions.value.dtsText }; + + const stored = await storeWorkflowBundleArtifacts(storageRoot, hash, { + esmJs: { kind: "text", text: source }, + yaml: { kind: "text", text: companions.value.yamlText }, + dts, + }); + if (!stored.ok) { + return stored; + } + + const regResult = await registerHash(storageRoot, workflowName, hash); + if (!regResult.ok) { + return regResult; + } + + return ok({ hash, warnings: companions.value.warnings }); +} + export async function cmdAdd( storageRoot: string, args: ParsedAddArgv, @@ -73,103 +194,15 @@ export async function cmdAdd( return err(`file not found: ${args.filePath}`); } - const warnings: string[] = []; - if (isTypeScriptWorkflow(resolvedPath)) { - const built = await buildWorkflowFromTypeScript(resolvedPath); - if (!built.ok) { - return built; - } - - const encoder = new TextEncoder(); - const bytes = encoder.encode(built.value.esmJsSource); - const hash = hashWorkflowBundleBytes(bytes); - - const stored = await storeWorkflowBundleArtifacts(storageRoot, hash, { - esmJs: { kind: "text", text: built.value.esmJsSource }, - yaml: { kind: "text", text: built.value.yamlSource }, - dts: { kind: "text", text: built.value.dtsSource }, - }); - if (!stored.ok) { - return stored; - } - - const regResult = await registerHash(storageRoot, args.name, hash); - if (!regResult.ok) { - return regResult; - } - - return ok({ hash, warnings }); + return addFromTypeScript(storageRoot, args.name, resolvedPath); } if (!isEsmBundle(resolvedPath)) { return err('workflow file must be ".ts" or end with ".esm.js"'); } - let source: string; - try { - source = await readFile(resolvedPath, "utf8"); - } catch (e) { - const message = e instanceof Error ? e.message : String(e); - return err(`failed to read bundle: ${message}`); - } - - const validated = validateWorkflowBundle({ - filePath: resolvedPath, - source, - }); - if (!validated.ok) { - return validated; - } - - const yamlResolved = - args.descriptorPath !== null ? resolve(args.descriptorPath) : defaultDescriptorPath(resolvedPath); - - let yamlText: string; - try { - yamlText = await readFile(yamlResolved, "utf8"); - } catch { - return err(`descriptor YAML not found: ${yamlResolved}`); - } - - let dtsSource: { kind: "text"; text: string } | null = null; - if (args.typesPath !== null) { - const typesResolved = resolve(args.typesPath); - try { - const text = await readFile(typesResolved, "utf8"); - dtsSource = { kind: "text", text }; - } catch { - return err(`types file not found: ${typesResolved}`); - } - } else { - const typesDefault = defaultTypesPath(resolvedPath); - try { - const text = await readFile(typesDefault, "utf8"); - dtsSource = { kind: "text", text }; - } catch { - warnings.push(`optional types file not found (${basename(typesDefault)}); skipped`); - } - } - - const encoder = new TextEncoder(); - const bytes = encoder.encode(source); - const hash = hashWorkflowBundleBytes(bytes); - - const stored = await storeWorkflowBundleArtifacts(storageRoot, hash, { - esmJs: { kind: "text", text: source }, - yaml: { kind: "text", text: yamlText }, - dts: dtsSource, - }); - if (!stored.ok) { - return stored; - } - - const regResult = await registerHash(storageRoot, args.name, hash); - if (!regResult.ok) { - return regResult; - } - - return ok({ hash, warnings }); + return addFromEsmJs(storageRoot, args.name, args, resolvedPath); } export function formatAddSuccess(name: string, filePath: string, hash: string): string { diff --git a/packages/workflow/__tests__/build-pipeline.test.ts b/packages/workflow/__tests__/build-pipeline.test.ts index f98001c..ee0b775 100644 --- a/packages/workflow/__tests__/build-pipeline.test.ts +++ b/packages/workflow/__tests__/build-pipeline.test.ts @@ -1,35 +1,34 @@ import { describe, expect, test } from "bun:test"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; import { buildWorkflowFromTypeScript } from "../src/build-pipeline.js"; describe("buildWorkflowFromTypeScript", () => { - test("produces valid ESM bundle text, YAML, and d.ts from hello-world.ts", async () => { - const helloTs = fileURLToPath(new URL("../../../examples/hello-world.ts", import.meta.url)); - const r = await buildWorkflowFromTypeScript(helloTs); + test("produces ESM + YAML + d.ts and the bundle default export runs", async () => { + const thisFile = fileURLToPath(import.meta.url); + const entryTs = join(dirname(thisFile), "fixtures/minimal-build-workflow.ts"); + + const r = await buildWorkflowFromTypeScript(entryTs); expect(r.ok).toBe(true); if (!r.ok) { return; } - expect(r.value.esmJsSource.length).toBeGreaterThan(500); - expect(r.value.yamlSource).toContain("hello world"); - expect(r.value.dtsSource).toContain("greeter"); - expect(r.value.dtsSource).toContain("greeting: string"); - }); + expect(r.value.esmJsSource.length).toBeGreaterThan(200); + expect(r.value.yamlSource).toContain("minimal fixture"); + expect(r.value.dtsSource).toContain("r:"); + expect(r.value.dtsSource).toContain("x: string"); - test("built bundle default export is executable", async () => { - const helloTs = fileURLToPath(new URL("../../../examples/hello-world.ts", import.meta.url)); - const r = await buildWorkflowFromTypeScript(helloTs); - expect(r.ok).toBe(true); - if (!r.ok) { - return; + const dir = await mkdtemp(join(tmpdir(), "uncaged-wf-build-")); + try { + const out = join(dir, "workflow.esm.js"); + await writeFile(out, r.value.esmJsSource, "utf8"); + const mod = (await import(pathToFileURL(out).href)) as { default: unknown }; + expect(typeof mod.default).toBe("function"); + } finally { + await rm(dir, { recursive: true, force: true }); } - - const tmp = `/tmp/uncaged-wf-build-test-${Date.now()}.esm.js`; - await Bun.write(tmp, r.value.esmJsSource); - - const href = pathToFileURL(tmp).href; - const mod = (await import(href)) as { default: unknown }; - expect(typeof mod.default).toBe("function"); }); }); diff --git a/packages/workflow/__tests__/bundle-validator.test.ts b/packages/workflow/__tests__/bundle-validator.test.ts index 9d9c6bc..2639fba 100644 --- a/packages/workflow/__tests__/bundle-validator.test.ts +++ b/packages/workflow/__tests__/bundle-validator.test.ts @@ -3,6 +3,14 @@ import { describe, expect, test } from "bun:test"; import { validateWorkflowBundle } from "../src/bundle-validator.js"; describe("validateWorkflowBundle", () => { + test("accepts export { local as default } when local is a call expression result", () => { + const source = `var wf = createFn({}); +export { wf as default }; +`; + const r = validateWorkflowBundle({ filePath: "/tmp/w.esm.js", source }); + expect(r.ok).toBe(true); + }); + test("accepts minimal valid builtin-only bundle", () => { const source = `import fs from "node:fs"; diff --git a/packages/workflow/__tests__/fixtures/minimal-build-workflow.ts b/packages/workflow/__tests__/fixtures/minimal-build-workflow.ts new file mode 100644 index 0000000..981431f --- /dev/null +++ b/packages/workflow/__tests__/fixtures/minimal-build-workflow.ts @@ -0,0 +1,30 @@ +import { createRoleModerator } from "../../src/create-role-moderator.js"; +import { END, type Role } from "../../src/types.js"; + +type RMeta = { x: string }; + +export const descriptor = { + description: "minimal fixture workflow for build-pipeline tests", + roles: { + r: { + description: "single role", + schema: { + type: "object", + properties: { x: { type: "string" } }, + required: ["x"], + }, + }, + }, +}; + +const r: Role = async () => ({ + content: "", + meta: { x: "y" }, +}); + +export default createRoleModerator({ + roles: { r }, + moderator() { + return END; + }, +}); diff --git a/packages/workflow/src/build-pipeline.ts b/packages/workflow/src/build-pipeline.ts index c0f0af1..cf93f24 100644 --- a/packages/workflow/src/build-pipeline.ts +++ b/packages/workflow/src/build-pipeline.ts @@ -6,10 +6,7 @@ import { validateWorkflowBundle } from "./bundle-validator.js"; import { stringifyWorkflowDescriptor } from "./generate-descriptor.js"; import { generateWorkflowBundleTypes } from "./generate-types.js"; import { err, ok, type Result } from "./result.js"; -import { - validateWorkflowDescriptor, - type WorkflowDescriptor, -} from "./workflow-descriptor.js"; +import { validateWorkflowDescriptor } from "./workflow-descriptor.js"; export type BuildPipelineResult = { esmJsSource: string; @@ -33,9 +30,9 @@ async function findPackageRoot(startDir: string): Promise { } } -async function loadDescriptorFromSourceTs(absoluteTsPath: string): Promise< - Result -> { +async function loadDescriptorFromSourceTs( + absoluteTsPath: string, +): Promise> { let mod: Record; try { const href = pathToFileURL(absoluteTsPath).href; diff --git a/packages/workflow/src/bundle-validator.ts b/packages/workflow/src/bundle-validator.ts index 47164ba..7a004eb 100644 --- a/packages/workflow/src/bundle-validator.ts +++ b/packages/workflow/src/bundle-validator.ts @@ -9,6 +9,7 @@ import type { ImportDeclaration, Node, Program, + VariableDeclaration, } from "acorn"; import * as acorn from "acorn"; @@ -72,21 +73,22 @@ function exportSpecifierIsDefaultReExport(spec: ExportSpecifier): boolean { return spec.exported.type === "Identifier" && spec.exported.name === "default"; } +function exportNamedDeclarationOffersDefault(named: ExportNamedDeclaration): boolean { + if (named.source !== null && named.source !== undefined) { + return false; + } + return named.specifiers.some( + (spec) => spec.type === "ExportSpecifier" && exportSpecifierIsDefaultReExport(spec), + ); +} + function programHasDefaultExport(body: readonly Node[]): boolean { for (const stmt of body) { if (stmt.type === "ExportDefaultDeclaration") { return true; } - if (stmt.type === "ExportNamedDeclaration") { - const named = stmt as ExportNamedDeclaration; - if (named.source !== null && named.source !== undefined) { - continue; - } - for (const spec of named.specifiers) { - if (spec.type === "ExportSpecifier" && exportSpecifierIsDefaultReExport(spec)) { - return true; - } - } + if (stmt.type === "ExportNamedDeclaration" && exportNamedDeclarationOffersDefault(stmt)) { + return true; } } return false; @@ -123,6 +125,22 @@ function bindingInitializerIsCallable(init: Node): boolean { ); } +function variableDeclarationBindsCallableName(stmt: VariableDeclaration, name: string): boolean { + for (const decl of stmt.declarations) { + if (decl.id.type !== "Identifier" || decl.id.name !== name) { + continue; + } + const init = decl.init; + if (init === null || init === undefined) { + continue; + } + if (bindingInitializerIsCallable(init)) { + return true; + } + } + return false; +} + function programDeclaresCallableExportBinding(program: Program, name: string): boolean { for (const stmt of program.body) { if (stmt.type === "FunctionDeclaration") { @@ -132,19 +150,8 @@ function programDeclaresCallableExportBinding(program: Program, name: string): b return true; } } - if (stmt.type === "VariableDeclaration") { - for (const decl of stmt.declarations) { - if (decl.id.type !== "Identifier" || decl.id.name !== name) { - continue; - } - const init = decl.init; - if (init === null || init === undefined) { - continue; - } - if (bindingInitializerIsCallable(init)) { - return true; - } - } + if (stmt.type === "VariableDeclaration" && variableDeclarationBindsCallableName(stmt, name)) { + return true; } } return false; diff --git a/packages/workflow/src/index.ts b/packages/workflow/src/index.ts index 60af519..8c4716d 100644 --- a/packages/workflow/src/index.ts +++ b/packages/workflow/src/index.ts @@ -5,14 +5,11 @@ export { encodeCrockfordBase32Bits, encodeUint64AsCrockford, } from "./base32.js"; -export { validateWorkflowBundle, type WorkflowBundleValidationInput } from "./bundle-validator.js"; export { - buildWorkflowFromTypeScript, type BuildPipelineResult, + buildWorkflowFromTypeScript, } from "./build-pipeline.js"; -export { stringifyWorkflowDescriptor } from "./generate-descriptor.js"; -export { generateWorkflowBundleTypes } from "./generate-types.js"; -export { jsonSchemaToTypeString } from "./json-schema-to-ts.js"; +export { validateWorkflowBundle, type WorkflowBundleValidationInput } from "./bundle-validator.js"; export { createRoleModerator } from "./create-role-moderator.js"; export { type ExecuteThreadIo, @@ -28,7 +25,10 @@ export { parseThreadDataJsonl, selectForkHistoricalSteps, } from "./fork-thread.js"; +export { stringifyWorkflowDescriptor } from "./generate-descriptor.js"; +export { generateWorkflowBundleTypes } from "./generate-types.js"; export { hashWorkflowBundleBytes } from "./hash.js"; +export { jsonSchemaToTypeString } from "./json-schema-to-ts.js"; export { type CreateLoggerOptions, createLogger, diff --git a/packages/workflow/src/json-schema-to-ts.ts b/packages/workflow/src/json-schema-to-ts.ts index 9b7cc57..9ab25d4 100644 --- a/packages/workflow/src/json-schema-to-ts.ts +++ b/packages/workflow/src/json-schema-to-ts.ts @@ -6,23 +6,84 @@ export function jsonSchemaToTypeString(schema: unknown): string { return schemaToTs(schema); } +function schemaEnumToTs(record: Record): string | null { + const en = record.enum; + if (!Array.isArray(en) || en.length === 0) { + return null; + } + const literals = en + .filter((v): v is string | number | boolean => v !== null && v !== undefined) + .map((v) => (typeof v === "string" ? JSON.stringify(v) : String(v))); + if (literals.length === 0) { + return null; + } + return literals.join(" | "); +} + +function schemaArrayToTs(record: Record): string | null { + if (record.type !== "array") { + return null; + } + const items = record.items; + if (items === undefined || items === null) { + return "unknown[]"; + } + if (Array.isArray(items)) { + if (items.length === 0) { + return "unknown[]"; + } + const parts = items.map((it) => schemaToTs(it)); + return `[${parts.join(", ")}]`; + } + return `${schemaToTs(items)}[]`; +} + +function schemaObjectToTs(record: Record): string | null { + if (record.type !== "object") { + return null; + } + const propsRaw = record.properties; + const requiredRaw = record.required; + const required = new Set( + Array.isArray(requiredRaw) ? requiredRaw.filter((x): x is string => typeof x === "string") : [], + ); + + if (propsRaw === null || propsRaw === undefined) { + return "Record"; + } + if (typeof propsRaw !== "object" || Array.isArray(propsRaw)) { + return "Record"; + } + + const props = propsRaw as Record; + const entries = Object.entries(props); + if (entries.length === 0) { + return "{}"; + } + + const parts: string[] = []; + for (const [key, subSchema] of entries) { + const optional = !required.has(key); + const ts = schemaToTs(subSchema); + const safeKey = /^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(key) ? key : JSON.stringify(key); + const suffix = optional ? " | null" : ""; + parts.push(`${safeKey}: ${ts}${suffix}`); + } + return `{ ${parts.join("; ")} }`; +} + function schemaToTs(schema: unknown): string { if (schema === null || typeof schema !== "object" || Array.isArray(schema)) { return "unknown"; } - const s = schema as Record; + const record = schema as Record; - if ("enum" in s && Array.isArray(s.enum) && s.enum.length > 0) { - const literals = s.enum - .filter((v): v is string | number | boolean => v !== null && v !== undefined) - .map((v) => (typeof v === "string" ? JSON.stringify(v) : String(v))); - if (literals.length === 0) { - return "unknown"; - } - return literals.join(" | "); + const fromEnum = schemaEnumToTs(record); + if (fromEnum !== null) { + return fromEnum; } - const t = s.type; + const t = record.type; if (t === "string") { return "string"; } @@ -32,51 +93,15 @@ function schemaToTs(schema: unknown): string { if (t === "boolean") { return "boolean"; } - if (t === "array") { - const items = s.items; - if (items === undefined || items === null) { - return "unknown[]"; - } - if (Array.isArray(items)) { - if (items.length === 0) { - return "unknown[]"; - } - const parts = items.map((it) => schemaToTs(it)); - return `[${parts.join(", ")}]`; - } - return `${schemaToTs(items)}[]`; + + const fromArray = schemaArrayToTs(record); + if (fromArray !== null) { + return fromArray; } - if (t === "object") { - const propsRaw = s.properties; - const requiredRaw = s.required; - const required = new Set( - Array.isArray(requiredRaw) - ? requiredRaw.filter((x): x is string => typeof x === "string") - : [], - ); - if (propsRaw === null || propsRaw === undefined) { - return "Record"; - } - if (typeof propsRaw !== "object" || Array.isArray(propsRaw)) { - return "Record"; - } - - const props = propsRaw as Record; - const entries = Object.entries(props); - if (entries.length === 0) { - return "{}"; - } - - const parts: string[] = []; - for (const [key, subSchema] of entries) { - const optional = !required.has(key); - const ts = schemaToTs(subSchema); - const safeKey = /^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(key) ? key : JSON.stringify(key); - const suffix = optional ? " | null" : ""; - parts.push(`${safeKey}: ${ts}${suffix}`); - } - return `{ ${parts.join("; ")} }`; + const fromObject = schemaObjectToTs(record); + if (fromObject !== null) { + return fromObject; } return "unknown";