Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/zod-uuid-ts7-libs.md
Original file line number Diff line number Diff line change
@@ -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`).
2 changes: 1 addition & 1 deletion packages/core/src/adapters/typescript/compile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
24 changes: 24 additions & 0 deletions packages/core/src/fix/verify.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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([]);
});
26 changes: 9 additions & 17 deletions packages/core/src/fix/verify.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -59,27 +60,18 @@ function relatedDeclarations(
});
}

let bundledLibraries: Map<string, string> | 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__';
Expand Down
26 changes: 25 additions & 1 deletion packages/core/src/packs/zod/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' };
Expand Down Expand Up @@ -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,
),
},
{
Expand Down Expand Up @@ -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
Expand Down
27 changes: 27 additions & 0 deletions packages/core/src/packs/zod/zod.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
Loading