From 887ce2466e8031da8a83bc4fc97c898937149a52 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 7 Sep 2026 10:27:39 +0000 Subject: [PATCH 1/4] Bump zod from 3.25.76 to 4.5.4 Bumps [zod](https://github.com/colinhacks/zod) from 3.25.76 to 4.5.4. - [Release notes](https://github.com/colinhacks/zod/releases) - [Commits](https://github.com/colinhacks/zod/compare/v3.25.76...v4.5.4) --- updated-dependencies: - dependency-name: zod dependency-version: 4.5.4 dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- package-lock.json | 8 ++++---- package.json | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package-lock.json b/package-lock.json index c6c2e47..e7979b8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,7 +12,7 @@ "@modelcontextprotocol/sdk": "1.30.0", "@napi-rs/keyring": "2.0.0", "asana": "3.2.0", - "zod": "3.25.76" + "zod": "4.5.4" }, "bin": { "asana-command-mcp": "dist/index.js" @@ -5180,9 +5180,9 @@ "peer": true }, "node_modules/zod": { - "version": "3.25.76", - "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", - "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.5.4.tgz", + "integrity": "sha512-sC95tT5iHHH9gtpj6A81kh+NEaRAUFN+qlUPDUbRfOMvNf5QCBqsb3WgvnpVtK5Y+4UfA6KqufotuTvMGiTlsA==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/colinhacks" diff --git a/package.json b/package.json index f52f661..c5028aa 100644 --- a/package.json +++ b/package.json @@ -30,7 +30,7 @@ "@modelcontextprotocol/sdk": "1.30.0", "@napi-rs/keyring": "2.0.0", "asana": "3.2.0", - "zod": "3.25.76" + "zod": "4.5.4" }, "devDependencies": { "@biomejs/biome": "2.1.2", From 044bca0b628840b81d41cf96742e1468720b7c18 Mon Sep 17 00:00:00 2001 From: Antoni Sobkowicz Date: Mon, 7 Sep 2026 13:56:58 +0200 Subject: [PATCH 2/4] fix(errors): pass a key schema to z.record for zod v4 zod v4 requires z.record(keySchema, valueSchema); the single-argument form used for the error-details bag no longer compiles. --- src/errors.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/errors.ts b/src/errors.ts index 1d02689..523d127 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -29,7 +29,7 @@ export const ErrorPayloadSchema = z.object({ message: z.string(), retryable: z.boolean(), suggested_action: z.string().optional(), - details: z.record(z.unknown()).optional(), + details: z.record(z.string(), z.unknown()).optional(), }), asana_request_ids: z.array(z.string()), }); From d252919bf8535412e3cf17c1bf482e8c8341e045 Mon Sep 17 00:00:00 2001 From: Antoni Sobkowicz Date: Mon, 7 Sep 2026 13:57:10 +0200 Subject: [PATCH 3/4] fix(types): recover generic envelope typing lost under zod v4 zod v4 tightened z.ZodTypeAny's inferred output from any to unknown, which surfaced two latent typing gaps that zod v3 had been silently papering over: - singleObjectEnvelope and the test suite's duplicated z.object({ data: schema }).parse(...).data pattern couldn't statically resolve `.data` on a generic schema's parsed output. Consolidated the test-side duplication into one parseEnvelopeData helper and gave singleObjectEnvelope an explicit cast-backed return type; both keep the runtime behavior identical. - mutationVariantsToSchemas returned z.ZodTypeAny for both schemas, so every mutation tool's parsed output was effectively `any` and none of its status/outcome/data union members were ever checked. Made it generic over the variant tuple so callers get the real discriminated union back. This caught a genuine pre-existing bug in workflow.ts: addDependency/removeDependency shared one implementation typed to return the union of both outcomes, silently assignable to each specific one only because of the `any` leak. --- src/asana_contracts.ts | 8 +++---- src/mutation_envelope.ts | 29 ++++++++++++++++++++----- src/tools/workflow.ts | 16 ++++++++++++-- tests/comments.test.ts | 9 ++++++-- tests/helpers/tool_test_helpers.ts | 13 +++++++++++ tests/releases.test.ts | 11 +++++++--- tests/schema_discovery.test.ts | 4 ++-- tests/ticket_mutations.test.ts | 11 +++++++--- tests/tickets.test.ts | 10 ++++++--- tests/tool_definitions/workflow.test.ts | 4 ++-- tests/workflow.test.ts | 3 ++- 11 files changed, 91 insertions(+), 27 deletions(-) diff --git a/src/asana_contracts.ts b/src/asana_contracts.ts index 5400a55..13cbef1 100644 --- a/src/asana_contracts.ts +++ b/src/asana_contracts.ts @@ -137,10 +137,10 @@ export const NextPageSchema = z }) .nullable(); -export function singleObjectEnvelope(dataSchema: T) { - return z.object({ - data: dataSchema, - }); +export function singleObjectEnvelope( + dataSchema: T, +): z.ZodType<{ data: z.infer }> { + return z.object({ data: dataSchema }) as unknown as z.ZodType<{ data: z.infer }>; } export function collectionEnvelope(itemSchema: T) { diff --git a/src/mutation_envelope.ts b/src/mutation_envelope.ts index c17ca16..8e63852 100644 --- a/src/mutation_envelope.ts +++ b/src/mutation_envelope.ts @@ -67,11 +67,25 @@ function zodUnionFromSchemas(schemas: readonly z.ZodTypeAny[]): z.ZodTypeAny { return z.union([first, second, ...rest]); } -export function mutationVariantsToSchemas( - variants: readonly MutationVariant[], +type MutationVariantOutput = V extends MutationVariant< + infer TStatus, + infer TOutcome, + infer TDataSchema +> + ? MutationMetadata & { + status: TStatus; + outcome: TOutcome; + data: z.infer; + } + : never; + +export function mutationVariantsToSchemas< + const TVariants extends readonly MutationVariant[], +>( + variants: TVariants, ): { - runtimeSchema: z.ZodTypeAny; - protocolSchema: z.ZodTypeAny; + runtimeSchema: z.ZodType>; + protocolSchema: z.ZodType>; } { if (variants.length === 0) { throw new Error("mutationVariantsToSchemas requires at least one variant"); @@ -106,7 +120,12 @@ export function mutationVariantsToSchemas( data: zodUnionFromSchemas(dataSchemas), }); - return { runtimeSchema, protocolSchema }; + return { + runtimeSchema: runtimeSchema as z.ZodType>, + protocolSchema: protocolSchema as unknown as z.ZodType< + MutationVariantOutput + >, + }; } export function buildMutationResult< diff --git a/src/tools/workflow.ts b/src/tools/workflow.ts index 801df62..a7e8047 100644 --- a/src/tools/workflow.ts +++ b/src/tools/workflow.ts @@ -226,8 +226,20 @@ export function createWorkflowService( return { addDependency: (ticketIdentifier, dependencyIdentifier, snapshot, deadlineMs) => - changeDependency("add", ticketIdentifier, dependencyIdentifier, snapshot, deadlineMs), + changeDependency( + "add", + ticketIdentifier, + dependencyIdentifier, + snapshot, + deadlineMs, + ) as Promise, removeDependency: (ticketIdentifier, dependencyIdentifier, snapshot, deadlineMs) => - changeDependency("remove", ticketIdentifier, dependencyIdentifier, snapshot, deadlineMs), + changeDependency( + "remove", + ticketIdentifier, + dependencyIdentifier, + snapshot, + deadlineMs, + ) as Promise, }; } diff --git a/tests/comments.test.ts b/tests/comments.test.ts index c988457..5a42aa4 100644 --- a/tests/comments.test.ts +++ b/tests/comments.test.ts @@ -26,7 +26,12 @@ import { GetCommentsOutputSchema, } from "../src/tools/comments.js"; import type { TicketService } from "../src/tools/tickets.js"; -import { buildDiscoverySnapshot, DEADLINE_MS, TEAMSPACE_ID } from "./helpers/tool_test_helpers.js"; +import { + buildDiscoverySnapshot, + DEADLINE_MS, + parseEnvelopeData, + TEAMSPACE_ID, +} from "./helpers/tool_test_helpers.js"; const TICKET_GID = "1700000000000001"; const OTHER_TICKET_GID = "1700000000000002"; @@ -123,7 +128,7 @@ function executor(bundle: AsanaResourceBundle, state: ExecutorState): AsanaReque ): Promise> { const response = await callback(bundle); collectRequestId(response, trace); - return z.object({ data: schema }).parse(response.data).data; + return parseEnvelopeData(schema, response.data); } return { diff --git a/tests/helpers/tool_test_helpers.ts b/tests/helpers/tool_test_helpers.ts index a42e149..51c0d7f 100644 --- a/tests/helpers/tool_test_helpers.ts +++ b/tests/helpers/tool_test_helpers.ts @@ -1,3 +1,4 @@ +import { z } from "zod"; import type { AsanaRequestExecutorPort } from "../../src/asana_gateway.js"; import type { Config } from "../../src/config.js"; import type { DiscoveryResult } from "../../src/schema_discovery.js"; @@ -39,6 +40,18 @@ function unexpectedExecutorCall(method: string): never { throw new UnexpectedExecutorCallError(method); } +/** + * Zod v4 can't statically resolve `.data` on `z.object({ data: schema }).parse(...)` when + * `schema` is a generic type parameter (its mapped object-shape type can't distribute over an + * unresolved `T`); the runtime parse is fine, so this only needs to fix the static type. + */ +export function parseEnvelopeData( + schema: TSchema, + value: unknown, +): z.infer { + return (z.object({ data: schema }).parse(value) as { data: z.infer }).data; +} + export function createUnexpectedExecutorFake(): AsanaRequestExecutorPort { return { createTrace: () => unexpectedExecutorCall("createTrace"), diff --git a/tests/releases.test.ts b/tests/releases.test.ts index ea77262..747e798 100644 --- a/tests/releases.test.ts +++ b/tests/releases.test.ts @@ -9,7 +9,7 @@ import type { WorkspacesApi, } from "asana"; import { describe, expect, it } from "vitest"; -import { z } from "zod"; +import type { z } from "zod"; import type { Task } from "../src/asana_contracts.js"; import type { AsanaHttpResult, @@ -21,7 +21,12 @@ import { CommandError } from "../src/errors.js"; import type { DiscoveryResult, ReleaseReference } from "../src/schema_discovery.js"; import { createReleaseService } from "../src/tools/releases.js"; import type { TicketService } from "../src/tools/tickets.js"; -import { buildDiscoverySnapshot, DEADLINE_MS, TEAMSPACE_ID } from "./helpers/tool_test_helpers.js"; +import { + buildDiscoverySnapshot, + DEADLINE_MS, + parseEnvelopeData, + TEAMSPACE_ID, +} from "./helpers/tool_test_helpers.js"; const TICKET_GID = "1700000000000001"; const RELEASE_GID = "1800000000000101"; @@ -101,7 +106,7 @@ function executor(bundle: AsanaResourceBundle, observed: ExecutorState): AsanaRe if (trace !== undefined && typeof requestId === "string") { trace.requestIds.push(requestId); } - return z.object({ data: schema }).parse(response.data).data; + return parseEnvelopeData(schema, response.data); } return { createTrace: () => { diff --git a/tests/schema_discovery.test.ts b/tests/schema_discovery.test.ts index 3a6d2c9..3b2eaf0 100644 --- a/tests/schema_discovery.test.ts +++ b/tests/schema_discovery.test.ts @@ -13,6 +13,7 @@ import { type FieldDefinition, readReferencedReleaseGids, } from "../src/schema_discovery.js"; +import { parseEnvelopeData } from "./helpers/tool_test_helpers.js"; const TEAMSPACE_ID = "1600000000000001"; const WORKSPACE = { gid: "1500000000000001", name: "Command Workspace" }; @@ -94,8 +95,7 @@ function createFakeExecutor(state: FakeState): AsanaRequestExecutorPort { }, }; const result = await callback(resources as never); - const envelope = z.object({ data: schema }); - return envelope.parse(result.data).data; + return parseEnvelopeData(schema, result.data); }, write: async () => unusedMethod("write"), readPage: async (schema, _options, callback) => { diff --git a/tests/ticket_mutations.test.ts b/tests/ticket_mutations.test.ts index fa8a4a1..36bbd1b 100644 --- a/tests/ticket_mutations.test.ts +++ b/tests/ticket_mutations.test.ts @@ -9,7 +9,7 @@ import type { WorkspacesApi, } from "asana"; import { describe, expect, it } from "vitest"; -import { z } from "zod"; +import type { z } from "zod"; import type { Task } from "../src/asana_contracts.js"; import type { AsanaHttpResult, @@ -25,7 +25,12 @@ import { createTicketService, UPDATE_PENDING_WARNING, } from "../src/tools/tickets.js"; -import { buildDiscoverySnapshot, DEADLINE_MS, TEAMSPACE_ID } from "./helpers/tool_test_helpers.js"; +import { + buildDiscoverySnapshot, + DEADLINE_MS, + parseEnvelopeData, + TEAMSPACE_ID, +} from "./helpers/tool_test_helpers.js"; const TASK_GID = "1700000000000001"; const TYPE_FIELD_GID = "1900000000000010"; @@ -114,7 +119,7 @@ function executor( if (trace !== undefined && typeof requestId === "string") { trace.requestIds.push(requestId); } - return z.object({ data: schema }).parse(response.data).data; + return parseEnvelopeData(schema, response.data); } return { createTrace: () => { diff --git a/tests/tickets.test.ts b/tests/tickets.test.ts index b50a9ff..d38187b 100644 --- a/tests/tickets.test.ts +++ b/tests/tickets.test.ts @@ -9,7 +9,6 @@ import type { WorkspacesApi, } from "asana"; import { describe, expect, it } from "vitest"; -import { z } from "zod"; import { FULL_TASK_FIELDS, type Task } from "../src/asana_contracts.js"; import type { AsanaHttpResult, @@ -19,7 +18,12 @@ import type { } from "../src/asana_gateway.js"; import type { DiscoveryResult } from "../src/schema_discovery.js"; import { createTicketService, projectTicketView, TicketViewSchema } from "../src/tools/tickets.js"; -import { buildDiscoverySnapshot, DEADLINE_MS, TEAMSPACE_ID } from "./helpers/tool_test_helpers.js"; +import { + buildDiscoverySnapshot, + DEADLINE_MS, + parseEnvelopeData, + TEAMSPACE_ID, +} from "./helpers/tool_test_helpers.js"; const TICKET_GID = "1700000000000001"; const RELEASE_GID = "1700000000000098"; @@ -93,7 +97,7 @@ function createExecutor( traces.push(trace); } const result = await callback(resources); - return z.object({ data: schema }).parse(result.data).data; + return parseEnvelopeData(schema, result.data); }, write: async () => unexpectedCall("AsanaRequestExecutor.write"), readPage: async () => unexpectedCall("AsanaRequestExecutor.readPage"), diff --git a/tests/tool_definitions/workflow.test.ts b/tests/tool_definitions/workflow.test.ts index a8cc832..440cb4d 100644 --- a/tests/tool_definitions/workflow.test.ts +++ b/tests/tool_definitions/workflow.test.ts @@ -122,10 +122,10 @@ describe("workflow tool definitions", () => { dependencies: [{ gid: DEPENDENCY_GID, name: "Blocking ticket" }], }, }; - const method: WorkflowService[typeof methodName] = async (...args) => { + const method: WorkflowService[typeof methodName] = (async (...args) => { observedCalls.push(args); return mutation; - }; + }) as WorkflowService[typeof methodName]; const workflow = workflowService({ [methodName]: method }); const context: CallContext = { deadlineMs: DEADLINE_MS, diff --git a/tests/workflow.test.ts b/tests/workflow.test.ts index 62ab05a..00fa68d 100644 --- a/tests/workflow.test.ts +++ b/tests/workflow.test.ts @@ -25,6 +25,7 @@ import { buildDiscoverySnapshot, createUnexpectedTicketServiceFake, DEADLINE_MS, + parseEnvelopeData, TEAMSPACE_ID, } from "./helpers/tool_test_helpers.js"; @@ -125,7 +126,7 @@ function executor(bundle: AsanaResourceBundle, observed: ExecutorState): AsanaRe observed.writes.push(options.deadlineMs); const response = await callback(bundle); collectRequestId(response, trace); - return z.object({ data: schema }).parse(response.data).data; + return parseEnvelopeData(schema, response.data); }, readPage: async (schema, options, callback, trace) => { observed.reads.push(options.deadlineMs); From ad60e741b7094962ccbf066c4ca41140d9121576 Mon Sep 17 00:00:00 2001 From: Antoni Sobkowicz Date: Mon, 7 Sep 2026 13:57:19 +0200 Subject: [PATCH 4/4] fix(schemas): reorder .describe() to survive zod v4's optional/nullable wrappers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Under zod v4, .describe(X).optional() (and .nullable()/.nullish()) silently drops the description entirely instead of erroring — the wrapper doesn't forward it. This affected 28 fields across ticket_inputs.ts, comments.ts, and tools/tickets.ts, all part of the public MCP tool schemas shown to calling agents, and only 2 of the 28 had test coverage that happened to catch it. Reordered every affected chain to .optional().describe(...) (verified via a full-codebase scan). Added a permanent regression test that scans src/ for the dangerous ordering, since this class of bug is silent at both the type and runtime level. --- src/ticket_inputs.ts | 68 +++++++++++----------- src/tool_definitions/comments.ts | 10 ++-- src/tools/tickets.ts | 4 +- tests/zod_describe_order.test.ts | 98 ++++++++++++++++++++++++++++++++ 4 files changed, 139 insertions(+), 41 deletions(-) create mode 100644 tests/zod_describe_order.test.ts diff --git a/src/ticket_inputs.ts b/src/ticket_inputs.ts index 8f43751..923f57b 100644 --- a/src/ticket_inputs.ts +++ b/src/ticket_inputs.ts @@ -71,15 +71,15 @@ export const ListTicketFiltersSchema = z .string() .trim() .min(1) - .describe("Opaque cursor from a prior call with exactly the same filters and limit") - .optional(), - completed: z.boolean().describe("Exact ticket completion state").optional(), - type: NonEmptyNameSchema.describe("Teamspace-local ticket type name").optional(), - label: NonEmptyNameSchema.describe("Teamspace-local label name").optional(), - assignee: NonEmptyNameSchema.describe( + .optional() + .describe("Opaque cursor from a prior call with exactly the same filters and limit"), + completed: z.boolean().optional().describe("Exact ticket completion state"), + type: NonEmptyNameSchema.optional().describe("Teamspace-local ticket type name"), + label: NonEmptyNameSchema.optional().describe("Teamspace-local label name"), + assignee: NonEmptyNameSchema.optional().describe( "Assignee name, email address, or numeric Asana user GID", - ).optional(), - release: NonEmptyNameSchema.describe("Release project name or numeric GID").optional(), + ), + release: NonEmptyNameSchema.optional().describe("Release project name or numeric GID"), }) .strict(); @@ -89,10 +89,10 @@ export const SearchTicketFiltersSchema = z .string() .trim() .min(1, "Search text must not be empty") - .describe("Distinctive text to search for in ticket names and descriptions") - .optional(), + .optional() + .describe("Distinctive text to search for in ticket names and descriptions"), assignee: WorkspaceSearchAssigneeSchema.optional(), - completed: z.boolean().describe("Exact completion state").optional(), + completed: z.boolean().optional().describe("Exact completion state"), "completed_on.before": DateOnlySchema.optional(), "completed_on.after": DateOnlySchema.optional(), compact: z @@ -143,26 +143,26 @@ export const LabelUpdateSchema = z export const UpdateTicketFieldsSchema = z .object({ - name: NonEmptyNameSchema.describe("The replacement ticket name").optional(), + name: NonEmptyNameSchema.optional().describe("The replacement ticket name"), description: z .string() + .optional() .describe( "The replacement plain-text description; an empty string clears it. Markdown is not rendered; use description_html for rich formatting.", - ) - .optional(), - description_html: z.string().describe(TICKET_DESCRIPTION_HTML_DESCRIPTION).optional(), - completed: z.boolean().describe("Whether the ticket is completed").optional(), - type: NonEmptyNameSchema.describe("A Teamspace-local ticket type option name").optional(), + ), + description_html: z.string().optional().describe(TICKET_DESCRIPTION_HTML_DESCRIPTION), + completed: z.boolean().optional().describe("Whether the ticket is completed"), + type: NonEmptyNameSchema.optional().describe("A Teamspace-local ticket type option name"), labels: LabelUpdateSchema.optional(), assignee: AssigneeIdentifierSchema.nullable() - .describe("An Asana user GID or email address, or null to clear the assignee") - .optional(), + .optional() + .describe("An Asana user GID or email address, or null to clear the assignee"), predicted_start_on: DateOnlySchema.nullable() - .describe("The predicted start date in YYYY-MM-DD form, or null to clear it") - .optional(), + .optional() + .describe("The predicted start date in YYYY-MM-DD form, or null to clear it"), predicted_completion_on: DateOnlySchema.nullable() - .describe("The predicted completion date in YYYY-MM-DD form, or null to clear it") - .optional(), + .optional() + .describe("The predicted completion date in YYYY-MM-DD form, or null to clear it"), }) .strict(); @@ -171,22 +171,22 @@ export const CreateTicketFieldsSchema = z name: NonEmptyNameSchema.describe("The ticket name"), description: z .string() + .optional() .describe( "The initial plain-text description. Markdown is not rendered; use description_html for rich formatting.", - ) - .optional(), - description_html: z.string().describe(TICKET_DESCRIPTION_HTML_DESCRIPTION).optional(), - type: NonEmptyNameSchema.describe("A Teamspace-local ticket type option name").optional(), - labels: LabelNamesSchema.describe("Initial Teamspace-local label option names").optional(), - assignee: AssigneeIdentifierSchema.describe( + ), + description_html: z.string().optional().describe(TICKET_DESCRIPTION_HTML_DESCRIPTION), + type: NonEmptyNameSchema.optional().describe("A Teamspace-local ticket type option name"), + labels: LabelNamesSchema.optional().describe("Initial Teamspace-local label option names"), + assignee: AssigneeIdentifierSchema.optional().describe( "Initial assignee user GID or email address", - ).optional(), - predicted_start_on: DateOnlySchema.describe( + ), + predicted_start_on: DateOnlySchema.optional().describe( "Initial predicted start date in YYYY-MM-DD form", - ).optional(), - predicted_completion_on: DateOnlySchema.describe( + ), + predicted_completion_on: DateOnlySchema.optional().describe( "Initial predicted completion date in YYYY-MM-DD form", - ).optional(), + ), }) .strict(); diff --git a/src/tool_definitions/comments.ts b/src/tool_definitions/comments.ts index 284d052..b3d06ef 100644 --- a/src/tool_definitions/comments.ts +++ b/src/tool_definitions/comments.ts @@ -19,8 +19,8 @@ const GetCommentsInputSchema = withTicketId({ .describe("Maximum number of comments to return, from 1 to 100"), cursor: z .string() - .describe("Opaque cursor from a prior call for the same ticket and limit") - .optional(), + .optional() + .describe("Opaque cursor from a prior call for the same ticket and limit"), }).strict(); const getComments = defineTeamspaceScopedTool({ @@ -53,11 +53,11 @@ const AddCommentInputSchema = withTicketId({ .string() .trim() .min(1, "Comment text must not be empty") + .optional() .describe( "Plain-text comment; Markdown is not rendered; exactly one of text or text_html must be provided.", - ) - .optional(), - text_html: z.string().describe(COMMENT_TEXT_HTML_DESCRIPTION).optional(), + ), + text_html: z.string().optional().describe(COMMENT_TEXT_HTML_DESCRIPTION), }) .strict() .superRefine((value, context) => { diff --git a/src/tools/tickets.ts b/src/tools/tickets.ts index f83b14e..97d06c2 100644 --- a/src/tools/tickets.ts +++ b/src/tools/tickets.ts @@ -49,12 +49,12 @@ const TicketLookupSchema = z.object({ const AssigneeViewSchema = z.object({ gid: GidSchema.describe("Numeric Asana user GID"), name: z.string().describe("Assignee display name"), - email: z.string().describe("Assignee email when Asana returns it").optional(), + email: z.string().optional().describe("Assignee email when Asana returns it"), }); const DependencyViewSchema = z.object({ gid: GidSchema.describe("Numeric GID of a task blocking this ticket"), - name: z.string().describe("Blocking task name when Asana returns it").optional(), + name: z.string().optional().describe("Blocking task name when Asana returns it"), }); export const TicketViewSchema = z.object({ diff --git a/tests/zod_describe_order.test.ts b/tests/zod_describe_order.test.ts new file mode 100644 index 0000000..d2e0c77 --- /dev/null +++ b/tests/zod_describe_order.test.ts @@ -0,0 +1,98 @@ +import { readdirSync, readFileSync, statSync } from "node:fs"; +import { join, relative } from "node:path"; +import { describe, expect, it } from "vitest"; + +const SRC_ROOT = join(import.meta.dirname, "../src"); +const WRAPPER_CALL_PATTERN = /^\.(optional|nullable|nullish)\(/; + +function listTypeScriptFiles(directory: string, files: string[] = []): string[] { + for (const entry of readdirSync(directory)) { + const fullPath = join(directory, entry); + const stats = statSync(fullPath); + if (stats.isDirectory()) { + listTypeScriptFiles(fullPath, files); + } else if (entry.endsWith(".ts")) { + files.push(fullPath); + } + } + return files; +} + +/** + * Zod v4 silently drops a schema's description when `.optional()`, `.nullable()`, or + * `.nullish()` is chained after `.describe(...)` (the wrapper doesn't forward it) — no type + * error, no runtime error, just an undocumented field in the public tool schema. `.describe()` + * must always be the last call in a chain. See CODE-1176-adjacent zod v4 migration notes. + */ +function findDescribeBeforeWrapper(content: string): number[] { + const violationLines: number[] = []; + let searchIndex = 0; + while (true) { + const describeStart = content.indexOf(".describe(", searchIndex); + if (describeStart === -1) { + break; + } + + let parenDepth = 0; + let cursor = describeStart + ".describe(".length - 1; + let stringDelimiter: string | null = null; + let closeIndex = -1; + for (; cursor < content.length; cursor += 1) { + const character = content[cursor]; + if (stringDelimiter !== null) { + if (character === "\\") { + cursor += 1; + continue; + } + if (character === stringDelimiter) { + stringDelimiter = null; + } + continue; + } + if (character === '"' || character === "'" || character === "`") { + stringDelimiter = character; + continue; + } + if (character === "(") { + parenDepth += 1; + } else if (character === ")") { + parenDepth -= 1; + if (parenDepth === 0) { + closeIndex = cursor; + break; + } + } + } + if (closeIndex === -1) { + searchIndex = describeStart + 1; + continue; + } + + let afterIndex = closeIndex + 1; + while (afterIndex < content.length && /\s/.test(content[afterIndex] ?? "")) { + afterIndex += 1; + } + if (WRAPPER_CALL_PATTERN.test(content.slice(afterIndex, afterIndex + 12))) { + violationLines.push(content.slice(0, describeStart).split("\n").length); + } + searchIndex = closeIndex + 1; + } + return violationLines; +} + +describe("zod .describe() chain order", () => { + it("never calls .optional(), .nullable(), or .nullish() after .describe() in src/", () => { + const violations: string[] = []; + for (const filePath of listTypeScriptFiles(SRC_ROOT)) { + const content = readFileSync(filePath, "utf8"); + const lines = findDescribeBeforeWrapper(content); + for (const line of lines) { + violations.push(`${relative(SRC_ROOT, filePath)}:${line}`); + } + } + expect( + violations, + `Found .describe().optional()/.nullable()/.nullish() ordering (drops the description under zod v4). Swap to .optional().describe(...) etc. at:\n${violations.join("\n")}`, + ).toEqual([]); + }); +});