diff --git a/.changeset/zod-uuid-ts7-libs.md b/.changeset/zod-uuid-ts7-libs.md new file mode 100644 index 0000000..b380a83 --- /dev/null +++ b/.changeset/zod-uuid-ts7-libs.md @@ -0,0 +1,5 @@ +--- +"uptide": patch +--- + +`fix zod` rewrites `z.string().uuid()` to `z.guid()` by rule without `--include-deprecated`: zod 4's `uuid()` also checks the RFC version and variant, so ids zod 3 accepted stop validating, and `check` now counts the site as breaking. `fix` no longer reports false pre-existing type errors on a TypeScript 7 repository whose `lib` names a library newer than its `target` (`lib: ["ES2023"]` under `ES2022`). diff --git a/packages/core/src/adapters/typescript/compile.ts b/packages/core/src/adapters/typescript/compile.ts index 70fbae3..309682e 100644 --- a/packages/core/src/adapters/typescript/compile.ts +++ b/packages/core/src/adapters/typescript/compile.ts @@ -373,7 +373,7 @@ onReset(() => { bundledLibs = undefined; }); -function bundledLib(fileName: string): string | undefined { +export function bundledLib(fileName: string): string | undefined { if (!/^lib(?:\..+)?\.d\.ts$/.test(basename(fileName))) return undefined; bundledLibs ??= new Project({ useInMemoryFileSystem: true }); return bundledLibs.addSourceFileAtPathIfExists(fileName)?.getFullText(); diff --git a/packages/core/src/fix/verify.test.ts b/packages/core/src/fix/verify.test.ts index ef661ab..56a08f6 100644 --- a/packages/core/src/fix/verify.test.ts +++ b/packages/core/src/fix/verify.test.ts @@ -168,3 +168,27 @@ it('matches a pre-existing error that an added line moved down, never one on an expect(newDiagnostics([at(2)], [at(3)], moves)).toEqual([at(3)]); expect(moved(at(2), moves)).toEqual(at(2)); }); + +it('finds an explicit lib newer than the target with a TS 7-style install (UPT-100)', () => { + // TypeScript 7 ships no compiler API and no lib files, so the bundled compiler serves the + // standard library; `lib: ["ES2023"]` under `target: "ES2022"` names a lib the target's + // default does not load. + const root = realpathSync(mkdtempSync(join(scratch, 'ts7-lib-'))); + const files = { + 'package.json': JSON.stringify({ name: 'app', devDependencies: { typescript: '7.0.2' } }), + 'node_modules/typescript/package.json': JSON.stringify({ + name: 'typescript', + version: '7.0.2', + }), + 'tsconfig.json': JSON.stringify({ + compilerOptions: { target: 'ES2022', lib: ['ES2023'], strict: true, types: [] }, + include: ['src'], + }), + 'src/index.ts': 'export const owns = Object.hasOwn({ value: 1 }, "value");\n', + }; + for (const [file, text] of Object.entries(files)) { + mkdirSync(join(root, file, '..'), { recursive: true }); + writeFileSync(join(root, file), text); + } + expect(diagnostics(root, ['.'])).toEqual([]); +}); diff --git a/packages/core/src/fix/verify.ts b/packages/core/src/fix/verify.ts index 0ffb953..3c13048 100644 --- a/packages/core/src/fix/verify.ts +++ b/packages/core/src/fix/verify.ts @@ -3,7 +3,8 @@ import { createHash } from 'node:crypto'; import { existsSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs'; import { builtinModules } from 'node:module'; import { basename, dirname, isAbsolute, join, relative } from 'node:path'; -import { Project, ts } from 'ts-morph'; +import { ts } from 'ts-morph'; +import { bundledLib } from '../adapters/typescript/compile.js'; import { resolveCompiler } from '../adapters/typescript/compiler.js'; import { readInstalled } from '../adapters/typescript/repo.js'; import { workspaceSourceMap } from '../adapters/typescript/workspace-source.js'; @@ -59,27 +60,18 @@ function relatedDeclarations( }); } -let bundledLibraries: Map | undefined; +/** + * The bundled compiler's host for a repository without a compiler API (TypeScript 7): every + * lib it names, `lib: ["ES2023"]` under an older target included, comes from ts-morph's copy. + */ function bundledHost(options: ts.CompilerOptions): ts.CompilerHost { - if (!bundledLibraries?.has(ts.getDefaultLibFileName(options))) { - const project = new Project({ - useInMemoryFileSystem: true, - compilerOptions: { target: options.target ?? ts.ScriptTarget.ES2025 }, - }); - project.createSourceFile('probe.ts', 'const value = 1;'); - bundledLibraries = new Map( - project - .getProgram() - .compilerObject.getSourceFiles() - .filter((f) => f.fileName.includes('/typescript/lib/')) - .map((f) => [basename(f.fileName), f.text]), - ); - } const host = ts.createCompilerHost(options); const read = host.readFile, exists = host.fileExists; const lib = (file: string) => - file.startsWith('/__uptide_lib__/') ? bundledLibraries?.get(basename(file)) : undefined; + file.startsWith('/__uptide_lib__/') + ? bundledLib(join('/node_modules/typescript/lib', basename(file))) + : undefined; host.readFile = (file) => lib(file) ?? read(file); host.fileExists = (file) => lib(file) !== undefined || exists(file); host.getDefaultLibLocation = () => '/__uptide_lib__'; diff --git a/packages/core/src/packs/zod/index.ts b/packages/core/src/packs/zod/index.ts index 46b58c4..9689b21 100644 --- a/packages/core/src/packs/zod/index.ts +++ b/packages/core/src/packs/zod/index.ts @@ -307,6 +307,26 @@ function atSite( } return { text, applied: false, reason: 'no safe mechanical transform for this reported site' }; } +const UUID = /^ZodString#uuid$/; +/** + * zod 4 keeps `z.string().uuid()` as a deprecated alias, but it now checks the RFC version and + * variant bits: ids zod 3 accepted, such as 11111111-1111-1111-1111-111111111111, stop + * validating. The type diff calls the site deprecated; the pack knows it is breaking, so `fix` + * rewrites it to z.guid() without --include-deprecated (UPT-100). + */ +function uuidChecksRfc({ findings }: { findings: Finding[] }): Finding[] { + return findings.map((f) => + f.severity === 'deprecated' && UUID.test(f.change.path) + ? { + ...f, + severity: 'breaking', + evidence: 'pack', + reason: + 'zod 4 uuid() also checks the RFC version and variant, so ids zod 3 accepted stop validating; z.guid() keeps the zod 3 check', + } + : f, + ); +} function transform(text: string, finding: Finding, context: PackContext): TransformResult { if (!zodPack.supports(context.from, context.to)) return { text, applied: false, reason: 'pack supports zod 3 to 4 only' }; @@ -359,9 +379,12 @@ export const zodPack: Pack = { kinds: ['deprecated'], symbols: /#(email|uuid|url|base64|datetime)$/, guide: zodGuide.formats, + // uuid() is a behavior change, not only a deprecation (`uuidChecksRfc`): no flag needed. rewrite: (text, finding, context) => atSite(text, finding, (call) => - context.includeDeprecated ? formatTransform(text, call) : undefined, + context.includeDeprecated || UUID.test(finding.change.path) + ? formatTransform(text, call) + : undefined, ), }, { @@ -453,6 +476,7 @@ export const zodPack: Pack = { instructions: zodGuide.generics, transform, guide: (f) => ruleFor(zodPack.rules, f)?.guide ?? zodGuide.generics, + foldFindings: uuidChecksRfc, runtimeFindings: ({ root, workspace, from, to, usages, files = [], read }) => { // The base hook (every `detect` over the files the scan visited), then the zod passes that // reach further: schemas imported from the repository's own modules, files that only name diff --git a/packages/core/src/packs/zod/zod.test.ts b/packages/core/src/packs/zod/zod.test.ts index 096b2b7..92c3bb0 100644 --- a/packages/core/src/packs/zod/zod.test.ts +++ b/packages/core/src/packs/zod/zod.test.ts @@ -169,3 +169,30 @@ it('record matching leaves error options with the existing rule', () => { rule: 'error-params', }); }); + +it('treats z.string().uuid() as breaking and rewrites it to z.guid() without the flag (UPT-100)', () => { + // zod 4's uuid() rejects ids zod 3 accepted; the type diff only calls the site deprecated. + const text = "import { z } from 'zod';\nconst uuid = z.string().uuid();\nz.string().email();\n"; + const site = (token: string, path: string): Finding => { + const finding = at(text, token, true); + return { + ...finding, + change: { ...finding.change, path }, + severity: 'deprecated', + usage: { ...finding.usage, file: 'src/create.ts' }, + }; + }; + const [uuid, email] = + zodPack.foldFindings?.({ + root: '/repo', + workspace: '.', + findings: [site('uuid()', 'ZodString#uuid'), site('email', 'ZodString#email')], + }) ?? []; + expect(uuid).toMatchObject({ severity: 'breaking', evidence: 'pack' }); + expect(email?.severity).toBe('deprecated'); + const flagless = { ...context, includeDeprecated: false }; + const result = zodPack.transform(text, uuid as Finding, flagless); + expect(result).toMatchObject({ applied: true, rule: 'string-format' }); + expect(result.text).toContain('const uuid = z.guid();'); + expect(zodPack.transform(text, email as Finding, flagless).applied).toBe(false); +});