From c0057ef7c0bc252089e086213c59add797583e60 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 19:05:19 +0000 Subject: [PATCH 1/2] fix(check): an error the upgrade only rewords is not new The compile signal subtracts baseline errors matched on file, code and message. A pre-existing error whose message prints a type the upgrade changes no longer matched, and was reported as a breaking site the upgrade made. OpenMetadata's sixteen `Property 'backHandler' does not exist` errors did so once React Router 7's navigate turned `() => void` into `() => void | Promise` in the elaborated type. Both programs check the same source text, so an error at the same file, code and position whose chain of messages is the same once the type expressions it prints are masked is the same error, and now counts as pre-existing too. Quoted plain names stay, so a chain that names another member is still reported: an assignment that already failed on property `a` and now fails first on `b` ends on the same last message, and is new. A different reason at the place (one more missing property) is reported too. Moved lines still match on the full message, as before. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01EV6fvvLupC2Hdbwcg7iovY Signed-off-by: Claude --- .changeset/check-reworded-errors.md | 5 + .../src/adapters/typescript/compile.test.ts | 99 +++++++++++++++++++ .../core/src/adapters/typescript/compile.ts | 47 ++++++++- 3 files changed, 148 insertions(+), 3 deletions(-) create mode 100644 .changeset/check-reworded-errors.md 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/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. From 96c896c314243d5d67c0758fac241f900eab932d Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 21:36:36 +0000 Subject: [PATCH 2/2] docs(packs): the ground-truth cache size, and evicting it per pack Every registered pack's ground truth installed at once is about 24 GB, measured per pack while re-scoring them all; a standard hosted runner documents 14 GB of disk. Scored one pack at a time with that pack's repositories evicted before the next, the cache stays under 5 GB. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01EV6fvvLupC2Hdbwcg7iovY Signed-off-by: Claude --- docs/packs.md | 10 ++++++++++ 1 file changed, 10 insertions(+) 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