diff --git a/.changeset/check-reworded-errors.md b/.changeset/check-reworded-errors.md new file mode 100644 index 0000000..bc6656e --- /dev/null +++ b/.changeset/check-reworded-errors.md @@ -0,0 +1,5 @@ +--- +'uptide': patch +--- + +`check` no longer reports a pre-existing type error as a breaking site when the upgrade only changes its wording, such as a type the message prints in full. An error at the same place with a different reason is still reported. diff --git a/docs/packs.md b/docs/packs.md index 1ad07b7..9df385a 100644 --- a/docs/packs.md +++ b/docs/packs.md @@ -235,6 +235,16 @@ executed. They are kept in `~/.cache/uptide/ground-truth` (`UPTIDE_GROUND_TRUTH_ `pack test --offline` runs without network after the first time. `pnpm packs:fetch` fills the cache for every pack (and `--corpus` for the repositories in `fixtures/corpus.json`). +The cache is large: each entry is a checkout with its dependencies installed. Measured on +2026-10-14, per pack: ai 2.1 GB, lucide-react 3.6, next 1.4, react 4.3, @sentry/browser 0.7, +@sentry/nextjs 4.3, @sentry/react 1.4, stripe 2.1, vitest 2.1, zod 2.2. Every pack at once is +about 24 GB, more than the 14 GB of disk a standard GitHub-hosted Linux runner documents, and +it grows with each pack. Keep it bounded the same way locally and on a runner: score one pack +at a time and evict its repositories before the next (`rm -rf +"$UPTIDE_GROUND_TRUTH_CACHE/__@"`, one directory per `ground-truth.json` +entry), so the cache holds at most one pack's ground truth, under 5 GB today. Registry +tarballs, kept beside it in `~/.cache/uptide`, add about 1 GB. + ## Scoring: `uptide pack test` ```sh diff --git a/packages/core/src/adapters/typescript/compile.test.ts b/packages/core/src/adapters/typescript/compile.test.ts index 6925019..0735423 100644 --- a/packages/core/src/adapters/typescript/compile.test.ts +++ b/packages/core/src/adapters/typescript/compile.test.ts @@ -119,6 +119,105 @@ describe('Signal B: compile against the target version', () => { ]); }); + it('does not report a pre-existing error as new because the upgrade rewords it', async () => { + // `Mode` widens in v2: the same broken assignment, at the same place, names one more + // member. It was broken before the upgrade, and the upgrade did not break it. + const dir = consumerCopy((d) => + writeFileSync( + join(d, 'src/mode.ts'), + "import type { Mode } from 'synthetic';\nexport const pick = (m: Mode): 'loose' => m;\n", + ), + ); + const signal = await compileAgainstTarget({ dir }, 'synthetic', join(ROOT, 'synthetic-v2')); + expect(signal.baselineErrors).toBe(1); + expect(signal.diagnostics.map((d) => `${d.file}:${d.line} ${d.code}`)).toEqual([ + 'src/chains.ts:6 2554', + ]); + }); + + it('reports a different reason at the place of a pre-existing error', async () => { + // `shapes` 2 requires one more property: the literal that already missed `a` and `b` now + // misses `c` too. Same file, code and position as the baseline error, and a new break. + const root = mkdtempSync(join(tmpdir(), 'uptide-same-place-')); + const release = (version: string, props: string): string => { + const pkg = join(root, 'shapes', version); + mkdirSync(pkg, { recursive: true }); + writeFileSync( + join(pkg, 'package.json'), + JSON.stringify({ name: 'shapes', version, types: 'index.d.ts' }), + ); + writeFileSync(join(pkg, 'index.d.ts'), `export interface Opts { ${props} }\n`); + return pkg; + }; + release('1.0.0', 'a: string; b: string;'); + const target = release('2.0.0', 'a: string; b: string; c: string;'); + const dir = join(root, 'repo'); + mkdirSync(join(dir, 'src'), { recursive: true }); + cpSync(join(root, 'shapes', '1.0.0'), join(dir, 'node_modules/shapes'), { recursive: true }); + writeFileSync( + join(dir, 'package.json'), + JSON.stringify({ name: 'repo', dependencies: { shapes: '1.0.0' } }), + ); + writeFileSync( + join(dir, 'tsconfig.json'), + JSON.stringify({ + compilerOptions: { strict: true, noEmit: true, skipLibCheck: true, types: [] }, + include: ['src'], + }), + ); + writeFileSync( + join(dir, 'src/index.ts'), + "import type { Opts } from 'shapes';\nexport const opts: Opts = {};\n", + ); + const signal = await compileAgainstTarget({ dir }, 'shapes', target); + expect(signal.baselineErrors).toBe(1); + expect(signal.diagnostics.map((d) => `${d.file}:${d.line} ${d.code} ${d.message}`)).toEqual([ + "src/index.ts:2 2739 Type '{}' is missing the following properties from type 'Opts': a, b, c", + ]); + }); + + it('reports a new error at the place of a pre-existing one that ends on the same reason', async () => { + // `opts` already breaks on property `a`; `shapes` 2 breaks `b` too and checks it first. Same + // file, code, position and last message (`Type 'string' is not assignable to type 'number'`), + // about another property: a new break, which only the property the chain names tells apart. + const root = mkdtempSync(join(tmpdir(), 'uptide-same-reason-')); + const release = (version: string, props: string): string => { + const pkg = join(root, 'shapes', version); + mkdirSync(pkg, { recursive: true }); + writeFileSync( + join(pkg, 'package.json'), + JSON.stringify({ name: 'shapes', version, types: 'index.d.ts' }), + ); + writeFileSync(join(pkg, 'index.d.ts'), `export interface Opts { ${props} }\n`); + return pkg; + }; + release('1.0.0', 'a: number; b: string;'); + const target = release('2.0.0', 'b: number; a: number;'); + const dir = join(root, 'repo'); + mkdirSync(join(dir, 'src'), { recursive: true }); + cpSync(join(root, 'shapes', '1.0.0'), join(dir, 'node_modules/shapes'), { recursive: true }); + writeFileSync( + join(dir, 'package.json'), + JSON.stringify({ name: 'repo', dependencies: { shapes: '1.0.0' } }), + ); + writeFileSync( + join(dir, 'tsconfig.json'), + JSON.stringify({ + compilerOptions: { strict: true, noEmit: true, skipLibCheck: true, types: [] }, + include: ['src'], + }), + ); + writeFileSync( + join(dir, 'src/index.ts'), + "import type { Opts } from 'shapes';\nconst value = { a: 'x', b: 'y' };\nexport const opts: Opts = value;\n", + ); + const signal = await compileAgainstTarget({ dir }, 'shapes', target); + expect(signal.baselineErrors).toBe(1); + expect(signal.diagnostics.map((d) => `${d.file}:${d.line} ${d.code} ${d.message}`)).toEqual([ + expect.stringMatching(/^src\/index\.ts:3 2322 .*Types of property 'b' are incompatible/s), + ]); + }); + it('scopes both baseline and target roots, following imports without loading unrelated files', async () => { const dir = consumerCopy((d) => { writeFileSync(join(d, 'src/root.ts'), "import './chains';\n"); diff --git a/packages/core/src/adapters/typescript/compile.ts b/packages/core/src/adapters/typescript/compile.ts index 3f79502..7f65a9d 100644 --- a/packages/core/src/adapters/typescript/compile.ts +++ b/packages/core/src/adapters/typescript/compile.ts @@ -42,8 +42,10 @@ import { resolvePackageDir } from './usages.js'; * here", not "because `items[].quantity` was removed". * * Both checks always run. Only diagnostics present in the overlay and absent from the - * baseline count (matched on file, code and message; positions are not part of the key), - * so a repository with pre-existing errors still gets the upgrade's errors. The overlay + * baseline count (matched on file, code and message, so a moved line still matches; or on + * file, code, position and the same chain of messages up to the types it prints, so an error + * the upgrade only rewords still matches), so a repository with pre-existing errors still gets the upgrade's + * errors. The overlay * is skipped only when the baseline is structurally broken: an invalid tsconfig, or most * files failing to resolve their imports. * @@ -104,6 +106,39 @@ function keyOf(d: ts.Diagnostic, repoDir: string): string { return `${file}|${d.code}|${messageOf(d).replace(/\s+/g, ' ').trim()}`; } +/** + * Every message of the chain, in order, with each type the compiler prints masked: what the + * error says about the code, without how it spells the types on the way. A quoted plain name + * (a property, a parameter, a named type) is kept, so the chain still says which member fails; + * a printed type expression (`'{ a: string; }'`, `'() => void'`) is what an upgrade rewords. + */ +function shapeOf(d: ts.Diagnostic): string { + const parts: string[] = []; + const walk = (chain: string | ts.DiagnosticMessageChain): void => { + const text = typeof chain === 'string' ? chain : chain.messageText; + parts.push( + text.replace(/'([^']*)'/g, (quoted, inner: string) => + /^[\w$.#"-]+$/.test(inner) ? quoted : "'…'", + ), + ); + if (typeof chain !== 'string') for (const next of chain.next ?? []) walk(next); + }; + walk(d.messageText); + return parts.join(' ').replace(/\s+/g, ' ').trim(); +} + +/** + * The same error at the same place: both programs check the same source text, so a position + * names one construct, and the same chain of messages with only printed types differing is the + * same complaint about it. An error the upgrade only rewords (a widened type in the elaborated + * assignment) was there before the upgrade, and the upgrade did not break that line. A chain + * that names another member, or another reason, at the same place is a different error, and is + * reported. + */ +function placeOf(d: ts.Diagnostic, repoDir: string): string { + return `${relative(repoDir, d.file?.fileName ?? '')}|${d.code}|${d.start ?? -1}|${shapeOf(d)}`; +} + function toDiagnostic(d: ts.Diagnostic, file: ts.SourceFile, repoDir: string): CompileDiagnostic { const start = d.start ?? 0; const from = file.getLineAndCharacterOfPosition(start); @@ -879,6 +914,7 @@ export async function compileAgainstTargets( }; } const known = new Set(baseline.map((d) => keyOf(d, repo.dir))); + const knownPlaces = new Set(baseline.map((d) => placeOf(d, repo.dir))); const overlay = mkdtempSync(join(tmpdir(), 'uptide-overlay-')); const deps = newLinks(); // Group members are already linked; nothing may fetch another version of them. @@ -899,7 +935,12 @@ export async function compileAgainstTargets( const overlaid = program.getSourceFile(file.fileName); if (!overlaid) continue; for (const d of program.getSemanticDiagnostics(overlaid)) { - if (isRepoError(d, repo.dir) && !known.has(keyOf(d, repo.dir))) fresh.push({ d, overlaid }); + if ( + isRepoError(d, repo.dir) && + !known.has(keyOf(d, repo.dir)) && + !knownPlaces.has(placeOf(d, repo.dir)) + ) + fresh.push({ d, overlaid }); } } // Causes are traced once every new diagnostic is known: a declaration that fails itself is a cause.