From 18d804803e8d5c704cccf7c0c091b45ec27bb278 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 06:11:35 -0700 Subject: [PATCH 1/4] refactor: structure ACP generator errors Co-authored-by: codex --- packages/effect-acp/scripts/generate.test.ts | 127 ++++++++++ packages/effect-acp/scripts/generate.ts | 244 ++++++++++++++++--- 2 files changed, 335 insertions(+), 36 deletions(-) create mode 100644 packages/effect-acp/scripts/generate.test.ts diff --git a/packages/effect-acp/scripts/generate.test.ts b/packages/effect-acp/scripts/generate.test.ts new file mode 100644 index 00000000000..49273bed8e8 --- /dev/null +++ b/packages/effect-acp/scripts/generate.test.ts @@ -0,0 +1,127 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, describe, expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Schema from "effect/Schema"; +import * as Sink from "effect/Sink"; +import * as Stream from "effect/Stream"; +import { HttpClient, HttpClientResponse } from "effect/unstable/http"; +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; +import * as Generator from "./generate.ts"; + +const isDownloadError = Schema.is(Generator.AcpGeneratorDownloadError); +const isDownloadFileError = Schema.is(Generator.AcpGeneratorDownloadFileError); +const isDocumentDecodeError = Schema.is(Generator.AcpGeneratorDocumentDecodeError); +const isFormatExitError = Schema.is(Generator.AcpGeneratorFormatExitError); + +const httpClient = (response: Response) => + HttpClient.make((request) => Effect.succeed(HttpClientResponse.fromWeb(request, response))); + +function processHandle(exitCode: number) { + return ChildProcessSpawner.makeHandle({ + pid: ChildProcessSpawner.ProcessId(1), + exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(exitCode)), + isRunning: Effect.succeed(false), + kill: () => Effect.void, + unref: Effect.succeed(Effect.void), + stdin: Sink.drain, + stdout: Stream.empty, + stderr: Stream.empty, + all: Stream.empty, + getInputFd: () => Sink.drain, + getOutputFd: () => Stream.empty, + }); +} + +describe("ACP schema generator errors", () => { + it.effect("retains the URL, output path, and HTTP cause when a download fails", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const directory = yield* fs.makeTempDirectoryScoped({ prefix: "acp-generator-test-" }); + const url = "https://example.test/schema.json"; + const outputPath = `${directory}/schema.json`; + const error = yield* Generator.downloadFile(url, outputPath).pipe( + Effect.provideService( + HttpClient.HttpClient, + httpClient(new Response("unavailable", { status: 503 })), + ), + Effect.flip, + ); + + assert(isDownloadError(error)); + expect(error.url).toBe(url); + expect(error.outputPath).toBe(outputPath); + expect(error.stage).toBe("request"); + expect(error.cause).toBeDefined(); + expect(error.message).toContain(outputPath); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); + + it.effect("retains download context when the response cannot be written", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const outputPath = yield* fs.makeTempDirectoryScoped({ prefix: "acp-generator-test-" }); + const url = "https://example.test/schema.json"; + const error = yield* Generator.downloadFile(url, outputPath).pipe( + Effect.provideService( + HttpClient.HttpClient, + httpClient(new Response("{}", { status: 200 })), + ), + Effect.flip, + ); + + assert(isDownloadFileError(error)); + expect(error.url).toBe(url); + expect(error.outputPath).toBe(outputPath); + expect(error.stage).toBe("write-file"); + expect(error.cause).toBeDefined(); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); + + it.effect("adds source file context to upstream document decode failures", () => + Effect.gen(function* () { + const schemaPath = "/tmp/upstream-schema.json"; + const schemaError = yield* Generator.decodeUpstreamSchemaDocument( + "not-json", + schemaPath, + ).pipe(Effect.flip); + assert(isDocumentDecodeError(schemaError)); + expect(schemaError.document).toBe("schema"); + expect(schemaError.filePath).toBe(schemaPath); + expect(schemaError.cause).toBeDefined(); + + const metadataPath = "/tmp/upstream-meta.json"; + const metadataError = yield* Generator.decodeMetaDocument("not-json", metadataPath).pipe( + Effect.flip, + ); + assert(isDocumentDecodeError(metadataError)); + expect(metadataError.document).toBe("metadata"); + expect(metadataError.filePath).toBe(metadataPath); + expect(metadataError.cause).toBeDefined(); + }), + ); + + it.effect("reports formatter commands and nonzero exit codes structurally", () => { + let spawned: ChildProcess.StandardCommand | undefined; + const spawner = ChildProcessSpawner.make((command) => { + if (ChildProcess.isStandardCommand(command)) { + spawned = command; + } + return Effect.succeed(processHandle(23)); + }); + + return Effect.gen(function* () { + const generatedDir = "/tmp/acp-generated"; + const error = yield* Generator.formatGeneratedFiles(generatedDir).pipe(Effect.flip); + + assert(isFormatExitError(error)); + expect(error.command).toBe("bun"); + expect(error.args).toEqual(["oxfmt", generatedDir]); + expect(error.generatedDir).toBe(generatedDir); + expect(error.exitCode).toBe(23); + expect(error.message).toContain("23"); + expect(spawned?.command).toBe("bun"); + expect(spawned?.args).toEqual(error.args); + }).pipe(Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner)); + }); +}); diff --git a/packages/effect-acp/scripts/generate.ts b/packages/effect-acp/scripts/generate.ts index 1fe8e2edf8a..ac0c9d6ed70 100644 --- a/packages/effect-acp/scripts/generate.ts +++ b/packages/effect-acp/scripts/generate.ts @@ -15,11 +15,6 @@ import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; const CURRENT_SCHEMA_RELEASE = "v0.11.3"; -interface GenerateCommandError { - readonly _tag: "GenerateCommandError"; - readonly message: string; -} - interface GeneratedPaths { readonly generatedDir: string; readonly upstreamSchemaPath: string; @@ -28,6 +23,94 @@ interface GeneratedPaths { readonly metaOutputPath: string; } +export class AcpGeneratorDownloadError extends Schema.TaggedErrorClass()( + "AcpGeneratorDownloadError", + { + url: Schema.String, + outputPath: Schema.String, + stage: Schema.Literals(["request", "read-response"]), + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to download ${this.url} to ${this.outputPath} during ${this.stage}.`; + } +} + +export class AcpGeneratorDownloadFileError extends Schema.TaggedErrorClass()( + "AcpGeneratorDownloadFileError", + { + url: Schema.String, + outputPath: Schema.String, + stage: Schema.Literals(["create-directory", "write-file"]), + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to store the download from ${this.url} at ${this.outputPath} during ${this.stage}.`; + } +} + +export class AcpGeneratorDocumentDecodeError extends Schema.TaggedErrorClass()( + "AcpGeneratorDocumentDecodeError", + { + document: Schema.Literals(["schema", "metadata"]), + filePath: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to decode the upstream ACP ${this.document} document at ${this.filePath}.`; + } +} + +export class AcpGeneratorFormatProcessError extends Schema.TaggedErrorClass()( + "AcpGeneratorFormatProcessError", + { + stage: Schema.Literals(["spawn", "wait-for-exit"]), + command: Schema.String, + args: Schema.Array(Schema.String), + generatedDir: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `ACP generator formatting command ${JSON.stringify([this.command, ...this.args])} failed during ${this.stage} for ${this.generatedDir}.`; + } +} + +export class AcpGeneratorFormatExitError extends Schema.TaggedErrorClass()( + "AcpGeneratorFormatExitError", + { + command: Schema.String, + args: Schema.Array(Schema.String), + generatedDir: Schema.String, + exitCode: Schema.Number, + }, +) { + override get message(): string { + return `ACP generator formatting command ${JSON.stringify([this.command, ...this.args])} exited with code ${this.exitCode} for ${this.generatedDir}.`; + } +} + +export class AcpGeneratorSchemaValueDeclarationMissingError extends Schema.TaggedErrorClass()( + "AcpGeneratorSchemaValueDeclarationMissingError", + { typeDeclaration: Schema.String }, +) { + override get message(): string { + return `Generated ACP schema type declaration has no following value declaration: ${this.typeDeclaration}`; + } +} + +export class AcpGeneratorSchemaNameParseError extends Schema.TaggedErrorClass()( + "AcpGeneratorSchemaNameParseError", + { typeDeclaration: Schema.String }, +) { + override get message(): string { + return `Could not extract an ACP schema name from generated declaration: ${this.typeDeclaration}`; + } +} + const UpstreamJsonSchemaSchema = Schema.Struct({ $defs: Schema.Record(Schema.String, Schema.Json), }); @@ -66,18 +149,51 @@ const ensureGeneratedDir = Effect.fn("ensureGeneratedDir")(function* () { yield* fs.makeDirectory(generatedDir, { recursive: true }); }); -const downloadFile = Effect.fn("downloadFile")(function* (url: string, outputPath: string) { +export const downloadFile = Effect.fn("downloadFile")(function* (url: string, outputPath: string) { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; - yield* fs.makeDirectory(path.dirname(outputPath), { recursive: true }); + yield* fs.makeDirectory(path.dirname(outputPath), { recursive: true }).pipe( + Effect.mapError( + (cause) => + new AcpGeneratorDownloadFileError({ + url, + outputPath, + stage: "create-directory", + cause, + }), + ), + ); - const text = yield* HttpClient.get(url).pipe( + const response = yield* HttpClient.get(url).pipe( Effect.flatMap(HttpClientResponse.filterStatusOk), - Effect.flatMap((response) => response.text), + Effect.mapError( + (cause) => new AcpGeneratorDownloadError({ url, outputPath, stage: "request", cause }), + ), + ); + const text = yield* response.text.pipe( + Effect.mapError( + (cause) => + new AcpGeneratorDownloadError({ + url, + outputPath, + stage: "read-response", + cause, + }), + ), ); - yield* fs.writeFileString(outputPath, text); + yield* fs.writeFileString(outputPath, text).pipe( + Effect.mapError( + (cause) => + new AcpGeneratorDownloadFileError({ + url, + outputPath, + stage: "write-file", + cause, + }), + ), + ); }); const downloadSchemas = Effect.fn("downloadSchemas")(function* (tag: string) { @@ -85,14 +201,14 @@ const downloadSchemas = Effect.fn("downloadSchemas")(function* (tag: string) { const fs = yield* FileSystem.FileSystem; const baseUrl = `https://github.com/agentclientprotocol/agent-client-protocol/releases/download/${tag}`; - yield* downloadFile(`${baseUrl}/schema.unstable.json`, upstreamSchemaPath); - yield* downloadFile(`${baseUrl}/meta.unstable.json`, upstreamMetaPath); - yield* Effect.addFinalizer(() => Effect.all([fs.remove(upstreamSchemaPath), fs.remove(upstreamMetaPath)]).pipe( Effect.ignoreCause({ log: true }), ), ); + + yield* downloadFile(`${baseUrl}/schema.unstable.json`, upstreamSchemaPath); + yield* downloadFile(`${baseUrl}/meta.unstable.json`, upstreamMetaPath); }); const readFileString = Effect.fn("readJsonFile")(function* (filePath: string) { @@ -100,6 +216,69 @@ const readFileString = Effect.fn("readJsonFile")(function* (filePath: string) { return yield* fs.readFileString(filePath); }); +export const decodeUpstreamSchemaDocument = Effect.fn("decodeUpstreamSchemaDocument")(function* ( + raw: string, + filePath: string, +) { + return yield* decodeUpstreamSchema(raw).pipe( + Effect.mapError( + (cause) => new AcpGeneratorDocumentDecodeError({ document: "schema", filePath, cause }), + ), + ); +}); + +export const decodeMetaDocument = Effect.fn("decodeMetaDocument")(function* ( + raw: string, + filePath: string, +) { + return yield* decodeMetaJson(raw).pipe( + Effect.mapError( + (cause) => new AcpGeneratorDocumentDecodeError({ document: "metadata", filePath, cause }), + ), + ); +}); + +export const formatGeneratedFiles = Effect.fn("formatGeneratedFiles")(function* ( + generatedDir: string, +) { + const command = "bun"; + const args = ["oxfmt", generatedDir]; + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const child = yield* spawner.spawn(ChildProcess.make(command, args)).pipe( + Effect.mapError( + (cause) => + new AcpGeneratorFormatProcessError({ + stage: "spawn", + command, + args, + generatedDir, + cause, + }), + ), + ); + const exitCode = yield* child.exitCode.pipe( + Effect.mapError( + (cause) => + new AcpGeneratorFormatProcessError({ + stage: "wait-for-exit", + command, + args, + generatedDir, + cause, + }), + ), + ); + + if (exitCode !== 0) { + return yield* new AcpGeneratorFormatExitError({ + command, + args, + generatedDir, + exitCode, + }); + } +}); + const writeGeneratedFiles = Effect.fn("writeGeneratedFiles")(function* ( schemaOutput: string, metaOutput: string, @@ -128,12 +307,14 @@ function collectSchemaEntries( const constLine = lines[index + 1]; if (!constLine?.startsWith("export const ")) { - throw new Error(`Malformed generator output near: ${typeLine}`); + throw new AcpGeneratorSchemaValueDeclarationMissingError({ + typeDeclaration: typeLine, + }); } const match = /^export type ([A-Za-z0-9_]+)/.exec(typeLine); if (!match?.[1]) { - throw new Error(`Could not extract schema name from: ${typeLine}`); + throw new AcpGeneratorSchemaNameParseError({ typeDeclaration: typeLine }); } entries.push({ @@ -204,10 +385,10 @@ const generateSchemas = Effect.fn("generateSchemas")(function* (skipDownload: bo yield* downloadSchemas(CURRENT_SCHEMA_RELEASE); } - const upstreamSchema = yield* readFileString(upstreamSchemaPath).pipe( - Effect.flatMap(decodeUpstreamSchema), - ); - const upstreamMeta = yield* readFileString(upstreamMetaPath).pipe(Effect.flatMap(decodeMetaJson)); + const upstreamSchemaRaw = yield* readFileString(upstreamSchemaPath); + const upstreamSchema = yield* decodeUpstreamSchemaDocument(upstreamSchemaRaw, upstreamSchemaPath); + const upstreamMetaRaw = yield* readFileString(upstreamMetaPath); + const upstreamMeta = yield* decodeMetaDocument(upstreamMetaRaw, upstreamMetaPath); const normalizedDefinitions = Object.fromEntries( Object.entries(upstreamSchema.$defs).map(([name, schema]) => [ name, @@ -264,18 +445,7 @@ const generateSchemas = Effect.fn("generateSchemas")(function* (skipDownload: bo ); const { generatedDir } = yield* getGeneratedPaths(); - yield* Effect.service(ChildProcessSpawner.ChildProcessSpawner).pipe( - Effect.flatMap((spawner) => spawner.spawn(ChildProcess.make("bun", ["oxfmt", generatedDir]))), - Effect.flatMap((child) => child.exitCode), - Effect.tap((code) => - code === 0 - ? Effect.void - : Effect.fail({ - _tag: "GenerateCommandError", - message: `oxfmt failed with exit code ${code}`, - }), - ), - ); + yield* formatGeneratedFiles(generatedDir); }); const generateCommand = Command.make( @@ -292,8 +462,10 @@ const runtimeLayer = Layer.mergeAll( FetchHttpClient.layer, ); -Command.run(generateCommand, { version: "0.0.0" }).pipe( - Effect.scoped, - Effect.provide(runtimeLayer), - NodeRuntime.runMain, -); +if (import.meta.main) { + Command.run(generateCommand, { version: "0.0.0" }).pipe( + Effect.scoped, + Effect.provide(runtimeLayer), + NodeRuntime.runMain, + ); +} From fcda98adcc5e55e1e0b0502db22e88623343ab6c Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 07:31:31 -0700 Subject: [PATCH 2/4] Rerun Effect service convention checks Co-authored-by: codex From 162cbae5909a275ea0207dda980a329f9d093173 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 09:54:55 -0700 Subject: [PATCH 3/4] fix(acp): sanitize generator diagnostics Co-authored-by: codex --- packages/effect-acp/package.json | 1 + packages/effect-acp/scripts/generate.test.ts | 28 ++++++++--- packages/effect-acp/scripts/generate.ts | 53 ++++++++++++++------ pnpm-lock.yaml | 3 ++ 4 files changed, 64 insertions(+), 21 deletions(-) diff --git a/packages/effect-acp/package.json b/packages/effect-acp/package.json index 4455dd460e7..5ecd05009fb 100644 --- a/packages/effect-acp/package.json +++ b/packages/effect-acp/package.json @@ -38,6 +38,7 @@ "generate": "node scripts/generate.ts" }, "dependencies": { + "@t3tools/shared": "workspace:*", "effect": "catalog:" }, "devDependencies": { diff --git a/packages/effect-acp/scripts/generate.test.ts b/packages/effect-acp/scripts/generate.test.ts index 49273bed8e8..a9ea3f9af5d 100644 --- a/packages/effect-acp/scripts/generate.test.ts +++ b/packages/effect-acp/scripts/generate.test.ts @@ -34,11 +34,12 @@ function processHandle(exitCode: number) { } describe("ACP schema generator errors", () => { - it.effect("retains the URL, output path, and HTTP cause when a download fails", () => + it.effect("retains safe URL diagnostics, output path, and HTTP cause when a download fails", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const directory = yield* fs.makeTempDirectoryScoped({ prefix: "acp-generator-test-" }); - const url = "https://example.test/schema.json"; + const url = + "https://generator-user:generator-password@example.test/private/schema.json?token=generator-secret#fragment"; const outputPath = `${directory}/schema.json`; const error = yield* Generator.downloadFile(url, outputPath).pipe( Effect.provideService( @@ -49,11 +50,20 @@ describe("ACP schema generator errors", () => { ); assert(isDownloadError(error)); - expect(error.url).toBe(url); + expect(error).toMatchObject({ + urlInputLength: url.length, + urlProtocol: "https:", + urlHostname: "example.test", + }); + expect(error).not.toHaveProperty("url"); expect(error.outputPath).toBe(outputPath); expect(error.stage).toBe("request"); expect(error.cause).toBeDefined(); expect(error.message).toContain(outputPath); + const { cause: _, ...directDiagnostics } = error; + expect(JSON.stringify(directDiagnostics)).not.toMatch( + /generator-user|generator-password|private|token|generator-secret|fragment/, + ); }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); @@ -71,7 +81,12 @@ describe("ACP schema generator errors", () => { ); assert(isDownloadFileError(error)); - expect(error.url).toBe(url); + expect(error).toMatchObject({ + urlInputLength: url.length, + urlProtocol: "https:", + urlHostname: "example.test", + }); + expect(error).not.toHaveProperty("url"); expect(error.outputPath).toBe(outputPath); expect(error.stage).toBe("write-file"); expect(error.cause).toBeDefined(); @@ -116,12 +131,13 @@ describe("ACP schema generator errors", () => { assert(isFormatExitError(error)); expect(error.command).toBe("bun"); - expect(error.args).toEqual(["oxfmt", generatedDir]); + expect(error.argumentCount).toBe(2); + expect(error).not.toHaveProperty("args"); expect(error.generatedDir).toBe(generatedDir); expect(error.exitCode).toBe(23); expect(error.message).toContain("23"); expect(spawned?.command).toBe("bun"); - expect(spawned?.args).toEqual(error.args); + expect(spawned?.args).toEqual(["oxfmt", generatedDir]); }).pipe(Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner)); }); }); diff --git a/packages/effect-acp/scripts/generate.ts b/packages/effect-acp/scripts/generate.ts index ac0c9d6ed70..837de89d329 100644 --- a/packages/effect-acp/scripts/generate.ts +++ b/packages/effect-acp/scripts/generate.ts @@ -3,6 +3,7 @@ import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { make as makeJsonSchemaGenerator } from "@effect/openapi-generator/JsonSchemaGenerator"; +import { getUrlDiagnostics } from "@t3tools/shared/urlDiagnostics"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; @@ -23,31 +24,47 @@ interface GeneratedPaths { readonly metaOutputPath: string; } +const urlDiagnosticsSchema = { + urlInputLength: Schema.Number, + urlProtocol: Schema.optionalKey(Schema.String), + urlHostname: Schema.optionalKey(Schema.String), +}; + +function urlDiagnosticFields(url: string) { + const diagnostics = getUrlDiagnostics(url); + return { + urlInputLength: diagnostics.inputLength, + ...(diagnostics.protocol === undefined ? {} : { urlProtocol: diagnostics.protocol }), + ...(diagnostics.hostname === undefined ? {} : { urlHostname: diagnostics.hostname }), + }; +} + export class AcpGeneratorDownloadError extends Schema.TaggedErrorClass()( "AcpGeneratorDownloadError", { - url: Schema.String, + ...urlDiagnosticsSchema, outputPath: Schema.String, stage: Schema.Literals(["request", "read-response"]), cause: Schema.Defect(), }, ) { override get message(): string { - return `Failed to download ${this.url} to ${this.outputPath} during ${this.stage}.`; + const source = this.urlHostname === undefined ? "the configured source" : this.urlHostname; + return `Failed to download the ACP generator input from ${source} to ${this.outputPath} during ${this.stage}.`; } } export class AcpGeneratorDownloadFileError extends Schema.TaggedErrorClass()( "AcpGeneratorDownloadFileError", { - url: Schema.String, + ...urlDiagnosticsSchema, outputPath: Schema.String, stage: Schema.Literals(["create-directory", "write-file"]), cause: Schema.Defect(), }, ) { override get message(): string { - return `Failed to store the download from ${this.url} at ${this.outputPath} during ${this.stage}.`; + return `Failed to store the ACP generator download at ${this.outputPath} during ${this.stage}.`; } } @@ -69,13 +86,13 @@ export class AcpGeneratorFormatProcessError extends Schema.TaggedErrorClass new AcpGeneratorDownloadFileError({ - url, + ...urlDiagnosticFields(url), outputPath, stage: "create-directory", cause, @@ -168,14 +185,20 @@ export const downloadFile = Effect.fn("downloadFile")(function* (url: string, ou const response = yield* HttpClient.get(url).pipe( Effect.flatMap(HttpClientResponse.filterStatusOk), Effect.mapError( - (cause) => new AcpGeneratorDownloadError({ url, outputPath, stage: "request", cause }), + (cause) => + new AcpGeneratorDownloadError({ + ...urlDiagnosticFields(url), + outputPath, + stage: "request", + cause, + }), ), ); const text = yield* response.text.pipe( Effect.mapError( (cause) => new AcpGeneratorDownloadError({ - url, + ...urlDiagnosticFields(url), outputPath, stage: "read-response", cause, @@ -187,7 +210,7 @@ export const downloadFile = Effect.fn("downloadFile")(function* (url: string, ou Effect.mapError( (cause) => new AcpGeneratorDownloadFileError({ - url, + ...urlDiagnosticFields(url), outputPath, stage: "write-file", cause, @@ -250,7 +273,7 @@ export const formatGeneratedFiles = Effect.fn("formatGeneratedFiles")(function* new AcpGeneratorFormatProcessError({ stage: "spawn", command, - args, + argumentCount: args.length, generatedDir, cause, }), @@ -262,7 +285,7 @@ export const formatGeneratedFiles = Effect.fn("formatGeneratedFiles")(function* new AcpGeneratorFormatProcessError({ stage: "wait-for-exit", command, - args, + argumentCount: args.length, generatedDir, cause, }), @@ -272,7 +295,7 @@ export const formatGeneratedFiles = Effect.fn("formatGeneratedFiles")(function* if (exitCode !== 0) { return yield* new AcpGeneratorFormatExitError({ command, - args, + argumentCount: args.length, generatedDir, exitCode, }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 192723b7663..acd73bf83aa 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -736,6 +736,9 @@ importers: packages/effect-acp: dependencies: + '@t3tools/shared': + specifier: workspace:* + version: link:../shared effect: specifier: 4.0.0-beta.78 version: 4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5) From a55944731e63826cf610a07fd19562f88484398d Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 10:07:20 -0700 Subject: [PATCH 4/4] fix(acp): return typed schema collection failures Co-authored-by: codex --- packages/effect-acp/scripts/generate.test.ts | 34 +++++++++++++++++- packages/effect-acp/scripts/generate.ts | 37 +++++++++++++------- 2 files changed, 58 insertions(+), 13 deletions(-) diff --git a/packages/effect-acp/scripts/generate.test.ts b/packages/effect-acp/scripts/generate.test.ts index a9ea3f9af5d..e11cb460ecf 100644 --- a/packages/effect-acp/scripts/generate.test.ts +++ b/packages/effect-acp/scripts/generate.test.ts @@ -13,6 +13,10 @@ const isDownloadError = Schema.is(Generator.AcpGeneratorDownloadError); const isDownloadFileError = Schema.is(Generator.AcpGeneratorDownloadFileError); const isDocumentDecodeError = Schema.is(Generator.AcpGeneratorDocumentDecodeError); const isFormatExitError = Schema.is(Generator.AcpGeneratorFormatExitError); +const isSchemaNameParseError = Schema.is(Generator.AcpGeneratorSchemaNameParseError); +const isSchemaValueDeclarationMissingError = Schema.is( + Generator.AcpGeneratorSchemaValueDeclarationMissingError, +); const httpClient = (response: Response) => HttpClient.make((request) => Effect.succeed(HttpClientResponse.fromWeb(request, response))); @@ -61,7 +65,10 @@ describe("ACP schema generator errors", () => { expect(error.cause).toBeDefined(); expect(error.message).toContain(outputPath); const { cause: _, ...directDiagnostics } = error; - expect(JSON.stringify(directDiagnostics)).not.toMatch( + expect(directDiagnostics).not.toHaveProperty("url"); + expect(directDiagnostics.urlProtocol).toBe("https:"); + expect(directDiagnostics.urlHostname).toBe("example.test"); + expect(error.message).not.toMatch( /generator-user|generator-password|private|token|generator-secret|fragment/, ); }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), @@ -140,4 +147,29 @@ describe("ACP schema generator errors", () => { expect(spawned?.args).toEqual(["oxfmt", generatedDir]); }).pipe(Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner)); }); + + it.effect("returns malformed generated schema declarations as typed failures", () => + Effect.gen(function* () { + const missingValueError = yield* Generator.collectSchemaEntries( + "export type Session = string;", + ).pipe(Effect.flip); + assert(isSchemaValueDeclarationMissingError(missingValueError)); + expect(missingValueError).toMatchObject({ + lineIndex: 0, + typeDeclarationLength: 29, + nextLinePresent: false, + }); + expect(missingValueError).not.toHaveProperty("typeDeclaration"); + + const nameParseError = yield* Generator.collectSchemaEntries( + "export type @ = string;\nexport const invalid = Schema.String;", + ).pipe(Effect.flip); + assert(isSchemaNameParseError(nameParseError)); + expect(nameParseError).toMatchObject({ + lineIndex: 0, + typeDeclarationLength: 23, + }); + expect(nameParseError).not.toHaveProperty("typeDeclaration"); + }), + ); }); diff --git a/packages/effect-acp/scripts/generate.ts b/packages/effect-acp/scripts/generate.ts index 837de89d329..de88d5640e3 100644 --- a/packages/effect-acp/scripts/generate.ts +++ b/packages/effect-acp/scripts/generate.ts @@ -112,19 +112,27 @@ export class AcpGeneratorFormatExitError extends Schema.TaggedErrorClass()( "AcpGeneratorSchemaValueDeclarationMissingError", - { typeDeclaration: Schema.String }, + { + lineIndex: Schema.Number, + typeDeclarationLength: Schema.Number, + nextLinePresent: Schema.Boolean, + nextLineLength: Schema.optional(Schema.Number), + }, ) { override get message(): string { - return `Generated ACP schema type declaration has no following value declaration: ${this.typeDeclaration}`; + return `Generated ACP schema type declaration at line ${this.lineIndex + 1} has no following value declaration.`; } } export class AcpGeneratorSchemaNameParseError extends Schema.TaggedErrorClass()( "AcpGeneratorSchemaNameParseError", - { typeDeclaration: Schema.String }, + { + lineIndex: Schema.Number, + typeDeclarationLength: Schema.Number, + }, ) { override get message(): string { - return `Could not extract an ACP schema name from generated declaration: ${this.typeDeclaration}`; + return `Could not extract an ACP schema name from generated declaration at line ${this.lineIndex + 1}.`; } } @@ -313,9 +321,7 @@ const writeGeneratedFiles = Effect.fn("writeGeneratedFiles")(function* ( yield* fs.writeFileString(metaOutputPath, metaOutput); }); -function collectSchemaEntries( - chunk: string, -): ReadonlyArray<{ readonly name: string; readonly code: string }> { +export const collectSchemaEntries = Effect.fn("collectSchemaEntries")(function* (chunk: string) { const lines = chunk .split("\n") .map((line) => line.trim()) @@ -330,14 +336,20 @@ function collectSchemaEntries( const constLine = lines[index + 1]; if (!constLine?.startsWith("export const ")) { - throw new AcpGeneratorSchemaValueDeclarationMissingError({ - typeDeclaration: typeLine, + return yield* new AcpGeneratorSchemaValueDeclarationMissingError({ + lineIndex: index, + typeDeclarationLength: typeLine.length, + nextLinePresent: constLine !== undefined, + ...(constLine === undefined ? {} : { nextLineLength: constLine.length }), }); } const match = /^export type ([A-Za-z0-9_]+)/.exec(typeLine); if (!match?.[1]) { - throw new AcpGeneratorSchemaNameParseError({ typeDeclaration: typeLine }); + return yield* new AcpGeneratorSchemaNameParseError({ + lineIndex: index, + typeDeclarationLength: typeLine.length, + }); } entries.push({ @@ -348,7 +360,7 @@ function collectSchemaEntries( } return entries; -} +}); function normalizeNullableTypes(value: Schema.Json): Schema.Json { if (Array.isArray(value)) { @@ -431,7 +443,8 @@ const generateSchemas = Effect.fn("generateSchemas")(function* (skipDownload: bo const output = generator.generate("openapi-3.1", normalizedDefinitions as never, false).trim(); if (output.length > 0) { - for (const entry of collectSchemaEntries(output)) { + const schemaEntries = yield* collectSchemaEntries(output); + for (const entry of schemaEntries) { if (!generatedEntries.has(entry.name)) { generatedEntries.set(entry.name, entry.code); }