diff --git a/.changeset/zod-deprecated-sites.md b/.changeset/zod-deprecated-sites.md new file mode 100644 index 00000000..00b8baac --- /dev/null +++ b/.changeset/zod-deprecated-sites.md @@ -0,0 +1,5 @@ +--- +"uptide": patch +--- + +The zod pack reports `.refine(check, { message })`, the type names zod 4 marks deprecated (`ZodTypeAny`, `ZodIssue`, `ZodSchema`, `ZodErrorMap` and the others, including `import('zod').ZodTypeAny`) and `errorMap` options with `ctx.defaultError`, as deprecated findings. diff --git a/README.md b/README.md index c613a07c..09849d2f 100644 --- a/README.md +++ b/README.md @@ -68,7 +68,7 @@ See the [pack compatibility matrix](docs/compatibility.md) and the [migration pa | `stripe` | 14+ → latest, API version pinning | 5 | 100% | 92% | 21 | unkeyed/unkey, nextjs/saas-starter, starl3xx/wallet-to-social, AtlasDevHQ/atlas | verified | | `typescript` | 5 → 6 | 12 | 100% | 99% | 1399 | m-lyon/recipe, robrechtme/recipes, JosephMaynard/dependency-radar, CroffZ/github-release-notifier, SignalK/signalk-derived-data, shogo82148/typescript-action, Borewit/music-metadata | verified | | `vitest` | 4 → 5 | 6 | 100% | 97% | 32 | votingworks/vxsuite, vitorvasc/opentelemetry-ecosystem-explorer | verified | -| `zod` | 3 → 4 | 8 | 100% | 65% | 219 | ugurkocde/AwesomeIntune, howardyang2009/PATH, band-ai/band-sdk-typescript, vzdc-artcc/website, CANShift/canshift-core | verified | +| `zod` | 3 → 4 | 11 | 100% | 76% | 219 | ugurkocde/AwesomeIntune, howardyang2009/PATH, band-ai/band-sdk-typescript, vzdc-artcc/website, CANShift/canshift-core | verified | 4 more in validation: see the [compatibility matrix](docs/compatibility.md). diff --git a/docs/compatibility.md b/docs/compatibility.md index d9e75cb5..7f20a564 100644 --- a/docs/compatibility.md +++ b/docs/compatibility.md @@ -40,7 +40,7 @@ already run as assisted ([verification gate](packs.md)). | `stripe` | 14+ → latest, API version pinning | 5 | 100% | 92% | 21 | unkeyed/unkey, nextjs/saas-starter, starl3xx/wallet-to-social, AtlasDevHQ/atlas | verified | | `typescript` | 5 → 6 | 12 | 100% | 99% | 1399 | m-lyon/recipe, robrechtme/recipes, JosephMaynard/dependency-radar, CroffZ/github-release-notifier, SignalK/signalk-derived-data, shogo82148/typescript-action, Borewit/music-metadata | verified | | `vitest` | 4 → 5 | 6 | 100% | 97% | 32 | votingworks/vxsuite, vitorvasc/opentelemetry-ecosystem-explorer | verified | -| `zod` | 3 → 4 | 8 | 100% | 65% | 219 | ugurkocde/AwesomeIntune, howardyang2009/PATH, band-ai/band-sdk-typescript, vzdc-artcc/website, CANShift/canshift-core | verified | +| `zod` | 3 → 4 | 11 | 100% | 76% | 219 | ugurkocde/AwesomeIntune, howardyang2009/PATH, band-ai/band-sdk-typescript, vzdc-artcc/website, CANShift/canshift-core | verified | | Package | Rewrite rule | Precision | Real true positives | False positives | Note | | --- | --- | ---: | ---: | ---: | --- | diff --git a/packages/core/src/packs/zod/RESEARCH.md b/packages/core/src/packs/zod/RESEARCH.md new file mode 100644 index 00000000..402cee01 --- /dev/null +++ b/packages/core/src/packs/zod/RESEARCH.md @@ -0,0 +1,22 @@ +# zod pack: what the rules do not reach (UPT-92) + +Misses left in the ground truth after the `refine-message`, `deprecated-names` and `error-map` +rules (breaking recall 65% → 76%, 17 misses): + +- **Compiler errors with no zod API at the line (10).** `post-runs.ts:55,82` and + `resume-run.ts:52,127` (PATH) pass a value to a function of the repository's own whose + parameter type is inferred from a schema defined in another file; `mcp/sdk.ts:359`, + `server.ts:311`, `sse.ts:239`, `stdio.ts:93` (band-sdk) pass a shape to the MCP SDK's + `tool()`. The line names no zod symbol; the cause is a schema type elsewhere, and the + diagnostic (TS2345, TS2322) is the only evidence. Attributing it needs the checker's type of + the argument traced back to a zod type, which is the type diff's job, not a syntax rule's. + `open-workflow.ts:149` is the same: `z.unknown()` is fine, the error is the declared type + of the map it is assigned into, two lines up. Left as they are. +- **`name === "ZodArray"` (wire-step-plugins.ts).** Four lines the upgrade changed compare + zod 3's `_def.typeName` strings. A rule for it found those and one more at line 90, a + comparison the upgrade left alone, so it had a breaking false positive on this truth and + was dropped rather than shipped as `deprecated`. +- `LessonForm.tsx:22` (`error.errors` on a binding the scan cannot type) and + `validate-dashboard.ts:21` (a line the upgrade touched with no zod symbol) are `error-issues` + and `generic` misses outside these rules. +- `prompt/index.ts:41` is a single-argument `z.record` (`record-key`); not investigated here. diff --git a/packages/core/src/packs/zod/fixtures/deprecated-names/after.ts b/packages/core/src/packs/zod/fixtures/deprecated-names/after.ts new file mode 100644 index 00000000..29c825b2 --- /dev/null +++ b/packages/core/src/packs/zod/fixtures/deprecated-names/after.ts @@ -0,0 +1,14 @@ +import { z, type ZodRawShape, type ZodTypeAny } from 'zod'; // @uptide deprecated-names at:ZodTypeAny + +// Shape of packages/sdk/src/mcp/zod.ts in band-ai/band-sdk-typescript. +export function toShape(): Record { // @uptide deprecated-names at:import('zod') + const shape: Record = {}; // @uptide deprecated-names at:ZodTypeAny + return shape; +} +export const issue = (i: z.ZodIssue) => i.message; // @uptide deprecated-names at:z.ZodIssue +export type Raw = ZodRawShape; // @uptide deprecated-names keep at:ZodRawShape + +// Left alone: a local type with the name, and the replacement. +type ZodSchema = { local: true }; +export const local: ZodSchema = { local: true }; // @uptide deprecated-names keep at:ZodSchema +export const ok = (t: z.ZodType) => t; // @uptide deprecated-names keep at:z.ZodType diff --git a/packages/core/src/packs/zod/fixtures/deprecated-names/before.ts b/packages/core/src/packs/zod/fixtures/deprecated-names/before.ts new file mode 100644 index 00000000..29c825b2 --- /dev/null +++ b/packages/core/src/packs/zod/fixtures/deprecated-names/before.ts @@ -0,0 +1,14 @@ +import { z, type ZodRawShape, type ZodTypeAny } from 'zod'; // @uptide deprecated-names at:ZodTypeAny + +// Shape of packages/sdk/src/mcp/zod.ts in band-ai/band-sdk-typescript. +export function toShape(): Record { // @uptide deprecated-names at:import('zod') + const shape: Record = {}; // @uptide deprecated-names at:ZodTypeAny + return shape; +} +export const issue = (i: z.ZodIssue) => i.message; // @uptide deprecated-names at:z.ZodIssue +export type Raw = ZodRawShape; // @uptide deprecated-names keep at:ZodRawShape + +// Left alone: a local type with the name, and the replacement. +type ZodSchema = { local: true }; +export const local: ZodSchema = { local: true }; // @uptide deprecated-names keep at:ZodSchema +export const ok = (t: z.ZodType) => t; // @uptide deprecated-names keep at:z.ZodType diff --git a/packages/core/src/packs/zod/fixtures/error-map/after.ts b/packages/core/src/packs/zod/fixtures/error-map/after.ts new file mode 100644 index 00000000..2e324f07 --- /dev/null +++ b/packages/core/src/packs/zod/fixtures/error-map/after.ts @@ -0,0 +1,12 @@ +import { z } from 'zod'; + +// Shape of packages/schema/src/nodes.ts in howardyang2009/PATH. +const unknownStep = (_issue: unknown, ctx: { defaultError: string }) => ({ message: ctx.defaultError }); // @uptide error-map at:defaultError +export const node = z.union( + [z.string(), z.number()], + { errorMap: unknownStep }, // @uptide error-map at:errorMap +); + +// Left alone: the zod 4 option, and an errorMap property that is no call's option. +export const next = z.string({ error: () => 'no' }); +export const registry = { errorMap: unknownStep }; // @uptide error-map keep at:errorMap diff --git a/packages/core/src/packs/zod/fixtures/error-map/before.ts b/packages/core/src/packs/zod/fixtures/error-map/before.ts new file mode 100644 index 00000000..2e324f07 --- /dev/null +++ b/packages/core/src/packs/zod/fixtures/error-map/before.ts @@ -0,0 +1,12 @@ +import { z } from 'zod'; + +// Shape of packages/schema/src/nodes.ts in howardyang2009/PATH. +const unknownStep = (_issue: unknown, ctx: { defaultError: string }) => ({ message: ctx.defaultError }); // @uptide error-map at:defaultError +export const node = z.union( + [z.string(), z.number()], + { errorMap: unknownStep }, // @uptide error-map at:errorMap +); + +// Left alone: the zod 4 option, and an errorMap property that is no call's option. +export const next = z.string({ error: () => 'no' }); +export const registry = { errorMap: unknownStep }; // @uptide error-map keep at:errorMap diff --git a/packages/core/src/packs/zod/fixtures/error-params/after.ts b/packages/core/src/packs/zod/fixtures/error-params/after.ts index ab78f94c..51196ca4 100644 --- a/packages/core/src/packs/zod/fixtures/error-params/after.ts +++ b/packages/core/src/packs/zod/fixtures/error-params/after.ts @@ -11,6 +11,9 @@ const label = (field: string) => `${field} is required`; export const city = z.string(((options: T) => { const { required_error, invalid_type_error, ...rest } = options; return { ...rest, error: (iss: { input: unknown }) => iss.input === undefined ? required_error : invalid_type_error }; })({ required_error: label('City') })); // @uptide error-params at:z.string // Left for a person: an existing errorMap must be merged by hand, a spread may hide the keys. -export const merged = z.string({ required_error: 'x', errorMap: () => ({ message: 'y' }) }); // @uptide error-params keep at:z.string +export const merged = z.string({ // @uptide error-params keep at:z.string + required_error: 'x', + errorMap: () => ({ message: 'y' }), // @uptide error-map at:errorMap +}); const base = { description: 'shared' }; export const spread = z.string({ ...base, required_error: 'x' }); // @uptide error-params keep at:z.string diff --git a/packages/core/src/packs/zod/fixtures/error-params/before.ts b/packages/core/src/packs/zod/fixtures/error-params/before.ts index 67f72b9f..70b723c2 100644 --- a/packages/core/src/packs/zod/fixtures/error-params/before.ts +++ b/packages/core/src/packs/zod/fixtures/error-params/before.ts @@ -11,6 +11,9 @@ const label = (field: string) => `${field} is required`; export const city = z.string({ required_error: label('City') }); // @uptide error-params at:z.string // Left for a person: an existing errorMap must be merged by hand, a spread may hide the keys. -export const merged = z.string({ required_error: 'x', errorMap: () => ({ message: 'y' }) }); // @uptide error-params keep at:z.string +export const merged = z.string({ // @uptide error-params keep at:z.string + required_error: 'x', + errorMap: () => ({ message: 'y' }), // @uptide error-map at:errorMap +}); const base = { description: 'shared' }; export const spread = z.string({ ...base, required_error: 'x' }); // @uptide error-params keep at:z.string diff --git a/packages/core/src/packs/zod/fixtures/refine-message/after.ts b/packages/core/src/packs/zod/fixtures/refine-message/after.ts new file mode 100644 index 00000000..61845c94 --- /dev/null +++ b/packages/core/src/packs/zod/fixtures/refine-message/after.ts @@ -0,0 +1,15 @@ +import { z } from 'zod'; + +// Shape of actions/event.ts in vzdc-artcc/website: a refinement with a message option. +export const event = z + .object({ start: z.date(), end: z.date() }) + .refine((e) => e.end > e.start, { + message: 'End must follow start.', // @uptide refine-message at:message + path: ['end'], + }); +export const loa = z.object({ n: z.number() }).refine((v) => v.n > 0, { message: 'Positive.' }); // @uptide refine-message at:message + +// Left alone: a string, the new option, and a refine that is not zod's. +export const a = z.string().refine((v) => v.length > 0, 'Required'); // @uptide refine-message keep at:refine +export const b = z.string().refine((v) => v.length > 0, { error: 'Required' }); // @uptide refine-message keep at:refine +export const c = z.string().refine((v) => v.length > 0); // @uptide refine-message keep at:refine diff --git a/packages/core/src/packs/zod/fixtures/refine-message/before.ts b/packages/core/src/packs/zod/fixtures/refine-message/before.ts new file mode 100644 index 00000000..61845c94 --- /dev/null +++ b/packages/core/src/packs/zod/fixtures/refine-message/before.ts @@ -0,0 +1,15 @@ +import { z } from 'zod'; + +// Shape of actions/event.ts in vzdc-artcc/website: a refinement with a message option. +export const event = z + .object({ start: z.date(), end: z.date() }) + .refine((e) => e.end > e.start, { + message: 'End must follow start.', // @uptide refine-message at:message + path: ['end'], + }); +export const loa = z.object({ n: z.number() }).refine((v) => v.n > 0, { message: 'Positive.' }); // @uptide refine-message at:message + +// Left alone: a string, the new option, and a refine that is not zod's. +export const a = z.string().refine((v) => v.length > 0, 'Required'); // @uptide refine-message keep at:refine +export const b = z.string().refine((v) => v.length > 0, { error: 'Required' }); // @uptide refine-message keep at:refine +export const c = z.string().refine((v) => v.length > 0); // @uptide refine-message keep at:refine diff --git a/packages/core/src/packs/zod/fixtures/removed-exports/after.ts b/packages/core/src/packs/zod/fixtures/removed-exports/after.ts index 66997135..225ed1df 100644 --- a/packages/core/src/packs/zod/fixtures/removed-exports/after.ts +++ b/packages/core/src/packs/zod/fixtures/removed-exports/after.ts @@ -1,5 +1,5 @@ import { z, type SafeParseReturnType, type ZodTypeDef } from 'zod'; // @uptide removed-exports at:SafeParseReturnType -import { ZodTypeAny } from 'zod'; +import { ZodType } from 'zod'; // Shape of actions/event.ts in vzdc-artcc/website: a result typed with a zod 3 root export. export const parse = (input: unknown): SafeParseReturnType => // @uptide removed-exports at:SafeParseReturnType @@ -7,7 +7,7 @@ export const parse = (input: unknown): SafeParseReturnType => / export type Def = z.ZodTypeDef; // @uptide removed-exports at:z.ZodTypeDef export type Shape = Record>; // @uptide removed-exports at:import('zod') -export const schema: ZodTypeAny = z.string(); +export const schema: ZodType = z.string(); // Left alone: a local type that shares the name, and a member that is not the export. type SafeParseSuccess = { success: true }; diff --git a/packages/core/src/packs/zod/fixtures/removed-exports/before.ts b/packages/core/src/packs/zod/fixtures/removed-exports/before.ts index 66997135..225ed1df 100644 --- a/packages/core/src/packs/zod/fixtures/removed-exports/before.ts +++ b/packages/core/src/packs/zod/fixtures/removed-exports/before.ts @@ -1,5 +1,5 @@ import { z, type SafeParseReturnType, type ZodTypeDef } from 'zod'; // @uptide removed-exports at:SafeParseReturnType -import { ZodTypeAny } from 'zod'; +import { ZodType } from 'zod'; // Shape of actions/event.ts in vzdc-artcc/website: a result typed with a zod 3 root export. export const parse = (input: unknown): SafeParseReturnType => // @uptide removed-exports at:SafeParseReturnType @@ -7,7 +7,7 @@ export const parse = (input: unknown): SafeParseReturnType => / export type Def = z.ZodTypeDef; // @uptide removed-exports at:z.ZodTypeDef export type Shape = Record>; // @uptide removed-exports at:import('zod') -export const schema: ZodTypeAny = z.string(); +export const schema: ZodType = z.string(); // Left alone: a local type that shares the name, and a member that is not the export. type SafeParseSuccess = { success: true }; diff --git a/packages/core/src/packs/zod/guide.ts b/packages/core/src/packs/zod/guide.ts index f9a86bc7..38ae1277 100644 --- a/packages/core/src/packs/zod/guide.ts +++ b/packages/core/src/packs/zod/guide.ts @@ -13,6 +13,12 @@ export const zodGuide = { 'ZodError.formErrors and ZodError.errors were removed in Zod 4 and read undefined. formErrors was identical to flatten(): use z.flattenError(error), which returns the same { formErrors, fieldErrors } object (flatten() is deprecated). errors was an alias of issues: use error.issues. Keep the returned shape and the control flow; do not cast to hide the change.', removedExports: "Zod 4's root no longer exports the names zod 3 kept for its internals: SafeParseReturnType, SafeParseSuccess and SafeParseError (use ZodSafeParseResult, which takes only the output type), ZodTypeDef and the Zod*Def interfaces (gone), ZodEffects (a refinement or transform is now the schema itself, a ZodPipe for transform), ZodNativeEnum (use ZodEnum), and the parse and issue helpers. They are still exported from 'zod/v3', which keeps zod 3's runtime and types, not the schemas you build with 'zod'. Replace the name with its zod 4 equivalent; do not import it from 'zod/v3' while the schemas come from 'zod'.", + refineMessage: + 'refine(check, { message }) still works in Zod 4 but message is deprecated: pass { error: message } (or a string as the second argument). path and params are unchanged.', + deprecatedNames: + 'ZodTypeAny, ZodSchema, ZodIssue, ZodIssueCode, ZodErrorMap, ZodFlattenedError and ZodFormattedError are deprecated aliases. Use z.ZodType, z.core.$ZodIssue, the raw issue code strings, z.core.$ZodErrorMap, z.core.$ZodFlattenedError and z.core.$ZodFormattedError.', + errorMap: + 'errorMap was removed from schema params and refinements: pass error, a function that receives the issue and returns a message or undefined to use the default. There is no ctx: ctx.defaultError has no equivalent, return undefined to fall through to the default message.', generics: 'ZodType now takes Output and Input, defaulting to unknown. The definition type parameter is gone. Remove reliance on ZodTypeDef without weakening the input/output contract. SafeParse success contains data; failure contains error. Narrow on success before accessing either. Preserve validation and type safety; do not cast to any to hide diagnostics.', }; diff --git a/packages/core/src/packs/zod/index.ts b/packages/core/src/packs/zod/index.ts index f361a5e0..46b58c46 100644 --- a/packages/core/src/packs/zod/index.ts +++ b/packages/core/src/packs/zod/index.ts @@ -24,7 +24,13 @@ import { } from './error-members.js'; import { zodGuide } from './guide.js'; import { defaultMessageSites } from './messages.js'; -import { removedExportFindings, removedExportSites } from './removed-exports.js'; +import { removedExportFindings, removedExportSites, siteFindings } from './removed-exports.js'; +import { + deprecatedNameSites, + errorMapSites, + refineMessageSites, + siteRules, +} from './source-rules.js'; const params: Record = { string: 0, @@ -399,6 +405,33 @@ export const zodPack: Pack = { guide: zodGuide.removedExports, detect: (text, file) => removedExportSites(text, file), }, + { + id: 'refine-message', + summary: 'refine(check, { message }): message is deprecated, use error', + severity: 'deprecated', + kinds: [], + symbols: /$^/, + guide: zodGuide.refineMessage, + detect: (text, file) => refineMessageSites(text, file), + }, + { + id: 'deprecated-names', + summary: 'ZodTypeAny, ZodSchema, ZodIssue and the other aliases zod 4 marks deprecated', + severity: 'deprecated', + kinds: [], + symbols: /$^/, + guide: zodGuide.deprecatedNames, + detect: (text, file) => deprecatedNameSites(text, file), + }, + { + id: 'error-map', + summary: 'errorMap options and ctx.defaultError: use error', + severity: 'deprecated', + kinds: [], + symbols: /$^/, + guide: zodGuide.errorMap, + detect: (text, file) => errorMapSites(text, file), + }, { id: 'types', summary: 'ZodType generics and ZodTypeDef changed; type-level code is migrated by the agent', @@ -439,6 +472,9 @@ export const zodPack: Pack = { rewrite: rewriteErrorMembers, }), ...removedExportFindings({ root, workspace, from, to, usages, read }), + ...siteRules.flatMap((spec) => + siteFindings({ root, workspace, from, to, usages, read }, spec), + ), ]; const key = (f: Finding) => `${f.rule}:${f.usage.file}:${f.usage.line}`; const seen = new Set(own.map(key)); diff --git a/packages/core/src/packs/zod/removed-exports.ts b/packages/core/src/packs/zod/removed-exports.ts index 7b96fd1c..717f006d 100644 --- a/packages/core/src/packs/zod/removed-exports.ts +++ b/packages/core/src/packs/zod/removed-exports.ts @@ -181,7 +181,11 @@ const isZod = (node: ts.Node | undefined): boolean => node !== undefined && ts.isStringLiteral(node) && node.text === ZOD; /** The sites of names zod 4's root no longer exports, one per line the code names them on. */ -export function removedExportSites(text: string, file: string): SourceSite[] { +export function removedExportSites( + text: string, + file: string, + listed: ReadonlySet = GONE, +): SourceSite[] { if (!text.includes(ZOD)) return []; const tsx = /\.[cm]?[jt]sx$/.test(file); const source = ts.createSourceFile( @@ -205,7 +209,7 @@ export function removedExportSites(text: string, file: string): SourceSite[] { for (const specifier of bindings.elements) { const imported = (specifier.propertyName ?? specifier.name).text; if (imported === 'z') spaces.add(specifier.name.text); - else if (GONE.has(imported)) names.set(specifier.name.text, imported); + else if (listed.has(imported)) names.set(specifier.name.text, imported); } } const sites = new Map(); @@ -223,24 +227,24 @@ export function removedExportSites(text: string, file: string): SourceSite[] { const visit = (node: ts.Node): void => { if (ts.isImportSpecifier(node) && ts.isImportDeclaration(node.parent.parent.parent)) { const imported = (node.propertyName ?? node.name).text; - if (isZod(node.parent.parent.parent.moduleSpecifier) && GONE.has(imported)) + if (isZod(node.parent.parent.parent.moduleSpecifier) && listed.has(imported)) add(node, imported); } else if (ts.isIdentifier(node) && names.has(node.text) && usesBinding(node)) { add(node, names.get(node.text) as string); } else if (ts.isQualifiedName(node)) { - if (ts.isIdentifier(node.left) && spaces.has(node.left.text) && GONE.has(node.right.text)) + if (ts.isIdentifier(node.left) && spaces.has(node.left.text) && listed.has(node.right.text)) add(node, node.right.text); } else if (ts.isPropertyAccessExpression(node)) { if ( ts.isIdentifier(node.expression) && spaces.has(node.expression.text) && - GONE.has(node.name.text) + listed.has(node.name.text) ) add(node, node.name.text); } else if (ts.isImportTypeNode(node)) { const argument = node.argument; const name = node.qualifier && ts.isIdentifier(node.qualifier) ? node.qualifier.text : ''; - if (ts.isLiteralTypeNode(argument) && isZod(argument.literal) && GONE.has(name)) + if (ts.isLiteralTypeNode(argument) && isZod(argument.literal) && listed.has(name)) add(node, name); } ts.forEachChild(node, visit); @@ -266,26 +270,48 @@ function usesBinding(node: ts.Identifier): boolean { ); } +export interface SiteRule { + rule: string; + severity: 'breaking' | 'deprecated'; + sites: (text: string, file: string) => SourceSite[]; + reason: (name: string) => string; +} + +export const removedExportRule: SiteRule = { + rule: RULE, + severity: 'breaking', + sites: removedExportSites, + reason: (name) => `${name} is exported from zod 3's root and, in zod 4, only from 'zod/v3'`, +}; + /** * The pack's findings for one workspace: its files that mention zod, whether or not the type * scan visited them (`import('zod').ZodTypeAny` alone is no import). Files are * workspace-relative, as the usages are. */ -export function removedExportFindings(input: { - root: string; - workspace: string; - from: string; - to: string; - usages: readonly Usage[]; - read: (file: string) => string | undefined; -}): Finding[] { +export function removedExportFindings(input: Parameters[0]): Finding[] { + return siteFindings(input, removedExportRule); +} + +/** One rule's findings over the workspace's files, whether or not the type scan visited them. */ +export function siteFindings( + input: { + root: string; + workspace: string; + from: string; + to: string; + usages: readonly Usage[]; + read: (file: string) => string | undefined; + }, + spec: SiteRule, +): Finding[] { const dir = resolve(input.root, input.workspace); const direct = input.usages.map((u) => u.file); const findings: Finding[] = []; for (const file of [...new Set([...direct, ...projectFiles(dir).files])].sort()) { const text = input.read(join(input.workspace, file)); if (text === undefined) continue; - for (const site of removedExportSites(text, file)) + for (const site of spec.sites(text, file)) findings.push({ change: { package: ZOD, @@ -293,7 +319,7 @@ export function removedExportFindings(input: { to: input.to, path: site.name as string, kind: 'removed', - severity: 'breaking', + severity: spec.severity, source: 'pack', confidence: 1, }, @@ -308,11 +334,11 @@ export function removedExportFindings(input: { snippet: site.snippet, via: 'direct', }, - severity: 'breaking', + severity: spec.severity, confidence: 1, fixability: 'assisted', - reason: `${site.name} is exported from zod 3's root and, in zod 4, only from 'zod/v3'`, - rule: RULE, + reason: spec.reason(site.name as string), + rule: spec.rule, }); } return findings; diff --git a/packages/core/src/packs/zod/source-rules.ts b/packages/core/src/packs/zod/source-rules.ts new file mode 100644 index 00000000..ddb31b35 --- /dev/null +++ b/packages/core/src/packs/zod/source-rules.ts @@ -0,0 +1,135 @@ +import { ts } from 'ts-morph'; +import type { SourceSite } from '../contract.js'; +import { removedExportSites, type SiteRule } from './removed-exports.js'; + +/** + * Zod 3 code that zod 4 still compiles but marks `@deprecated`, or that it stopped honouring + * without a type the checker can follow: `.refine(fn, { message })`, the type names zod 4's root + * keeps as aliases and `errorMap`. Read from the syntax of a file + * that mentions zod. + */ +const DEPRECATED = new Set([ + 'ZodTypeAny', + 'ZodSchema', + 'ZodIssue', + 'ZodIssueCode', + 'ZodErrorMap', + 'ZodFlattenedError', + 'ZodFormattedError', + 'ZodFirstPartySchemaTypes', +]); + +function parse(text: string, file: string): ts.SourceFile | undefined { + if (!text.includes('zod')) return undefined; + const tsx = /\.[cm]?[jt]sx$/.test(file); + return ts.createSourceFile( + tsx ? 'consumer.tsx' : 'consumer.ts', + text, + ts.ScriptTarget.Latest, + true, + tsx ? ts.ScriptKind.TSX : ts.ScriptKind.TS, + ); +} + +function collect( + text: string, + file: string, + pick: (node: ts.Node) => { at: ts.Node; name: string } | undefined, +): SourceSite[] { + const source = parse(text, file); + if (!source) return []; + const lines = text.split('\n'); + const sites = new Map(); + const visit = (node: ts.Node): void => { + const hit = pick(node); + if (hit) { + const { line, character } = source.getLineAndCharacterOfPosition(hit.at.getStart(source)); + if (!sites.has(line)) + sites.set(line, { + line: line + 1, + column: character + 1, + snippet: (lines[line] ?? '').trim(), + name: hit.name, + }); + } + ts.forEachChild(node, visit); + }; + visit(source); + return [...sites.values()].sort((a, b) => a.line - b.line); +} + +const keyName = (name: ts.PropertyName): string | undefined => + ts.isIdentifier(name) || ts.isStringLiteral(name) ? name.text : undefined; + +/** `.refine(check, { message })`: the second argument's `message`, one site per property. */ +export function refineMessageSites(text: string, file: string): SourceSite[] { + return collect(text, file, (node) => { + if ( + !ts.isCallExpression(node) || + !ts.isPropertyAccessExpression(node.expression) || + node.expression.name.text !== 'refine' + ) + return; + const params = node.arguments[1]; + if (!params || !ts.isObjectLiteralExpression(params)) return; + const message = params.properties.find( + (p) => p.name !== undefined && keyName(p.name) === 'message', + ); + return message && { at: message, name: 'refine#message' }; + }); +} + +export const refineMessageRule: SiteRule = { + rule: 'refine-message', + severity: 'deprecated', + sites: refineMessageSites, + reason: () => 'refine(check, { message }): message is deprecated in zod 4, use error', +}; + +/** The deprecated type names of zod 4's root, imported from it or reached as `z.Name`. */ +export const deprecatedNameSites = (text: string, file: string): SourceSite[] => + removedExportSites(text, file, DEPRECATED); + +export const deprecatedNameRule: SiteRule = { + rule: 'deprecated-names', + severity: 'deprecated', + sites: deprecatedNameSites, + reason: (name) => `${name} is deprecated in zod 4`, +}; + +/** `errorMap` in the options of a call, and `ctx.defaultError` read inside one. */ +export function errorMapSites(text: string, file: string): SourceSite[] { + return collect(text, file, (node) => { + if ( + ts.isPropertyAssignment(node) || + ts.isShorthandPropertyAssignment(node) || + ts.isMethodDeclaration(node) + ) { + const name = node.name && keyName(node.name); + const object = node.parent; + if ( + name === 'errorMap' && + ts.isObjectLiteralExpression(object) && + ts.isCallExpression(object.parent) + ) + return { at: node, name: 'errorMap' }; + } + if (ts.isPropertyAccessExpression(node) && node.name.text === 'defaultError') + return { at: node.name, name: 'defaultError' }; + return undefined; + }); +} + +export const errorMapRule: SiteRule = { + rule: 'error-map', + // Deprecated, and a compile error where the call's types reject it (TS2353, TS2769): the type + // diff reports the compile error at the line TypeScript names, which this cannot always match. + severity: 'deprecated', + sites: errorMapSites, + reason: (name) => + name === 'defaultError' + ? 'zod 4 error functions receive the issue only: ctx.defaultError no longer exists' + : 'errorMap is deprecated in zod 4: use error', +}; + +export const siteRules = [refineMessageRule, deprecatedNameRule, errorMapRule]; diff --git a/packages/core/src/packs/zod/verification.json b/packages/core/src/packs/zod/verification.json index a7a893f1..1a3bd2bb 100644 --- a/packages/core/src/packs/zod/verification.json +++ b/packages/core/src/packs/zod/verification.json @@ -4,10 +4,10 @@ "repos": 5, "breaking": { "precision": 1, - "recall": 0.6471, + "recall": 0.7639, "falsePositives": 0, "truePositives": 55, - "falseNegatives": 30 + "falseNegatives": 17 }, "behavior": { "precision": 1,