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/check-reworded-errors.md
Original file line number Diff line number Diff line change
@@ -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.
10 changes: 10 additions & 0 deletions docs/packs.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<owner>__<repo>@<commit>"`, 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
Expand Down
99 changes: 99 additions & 0 deletions packages/core/src/adapters/typescript/compile.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
47 changes: 44 additions & 3 deletions packages/core/src/adapters/typescript/compile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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.
Expand All @@ -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.
Expand Down
Loading