Skip to content
5 changes: 5 additions & 0 deletions .changeset/fold-downstream-errors.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"uptide": patch
---

`check` folds a compile error into the breaking site it comes from when the compiler traces its value there (an `errors.map(e => e.message)` under a `result.error.errors` that zod 4 no longer types, and the narrowing that depended on it). The rule's line shows `+N downstream errors`, `--details` lists them under their site, and `--json` carries them as that finding's `downstream`. They are no longer separate breaking sites. `fix` still verifies against every error.
5 changes: 5 additions & 0 deletions .changeset/pack-test-generators.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"uptide": patch
---

pack test runs a ground-truth repository's code generators (prisma generate, local graphql-codegen) after its install, as fix does. It fetches the Prisma engines the locked version needs itself, so generators stay off the network. A repository whose generator still fails is incomplete and fails the pack, unless its ground truth declares `withoutGenerated`
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ See the [pack compatibility matrix](docs/compatibility.md) and the [migration pa
| `stripe` | 14+ → latest, API version pinning | 6 | 100% | 92% | 92% | 21 | unkeyed/unkey, nextjs/saas-starter, starl3xx/wallet-to-social, AtlasDevHQ/atlas | verified |
| `typescript` | 5 → 6 | 12 | 100% | 99% | 99% | 1399 | m-lyon/recipe, robrechtme/recipes, JosephMaynard/dependency-radar, CroffZ/github-release-notifier, SignalK/signalk-derived-data, shogo82148/typescript-action, Borewit/music-metadata | verified |
| `vitest` | 4 → 5 | 6 | 100% | 97% | 97% | 32 | votingworks/vxsuite, vitorvasc/opentelemetry-ecosystem-explorer | verified |
| `zod` | 3 → 4 | 11 | 100% | 93% | 22% | 219 | ugurkocde/AwesomeIntune, howardyang2009/PATH, band-ai/band-sdk-typescript, vzdc-artcc/website, CANShift/canshift-core | verified |
| `zod` | 3 → 4 | 11 | 100% | 87% | 47% | 219 | ugurkocde/AwesomeIntune, howardyang2009/PATH, band-ai/band-sdk-typescript, vzdc-artcc/website, CANShift/canshift-core | verified |

4 more in validation: see the [compatibility matrix](docs/compatibility.md).
<!-- packs:end -->
Expand Down
6 changes: 3 additions & 3 deletions docs/compatibility.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ already run as assisted ([verification gate](packs.md)).
| `stripe` | 14+ → latest, API version pinning | 6 | 100% | 92% | 92% | 21 | unkeyed/unkey, nextjs/saas-starter, starl3xx/wallet-to-social, AtlasDevHQ/atlas | verified |
| `typescript` | 5 → 6 | 12 | 100% | 99% | 99% | 1399 | m-lyon/recipe, robrechtme/recipes, JosephMaynard/dependency-radar, CroffZ/github-release-notifier, SignalK/signalk-derived-data, shogo82148/typescript-action, Borewit/music-metadata | verified |
| `vitest` | 4 → 5 | 6 | 100% | 97% | 97% | 32 | votingworks/vxsuite, vitorvasc/opentelemetry-ecosystem-explorer | verified |
| `zod` | 3 → 4 | 11 | 100% | 93% | 22% | 219 | ugurkocde/AwesomeIntune, howardyang2009/PATH, band-ai/band-sdk-typescript, vzdc-artcc/website, CANShift/canshift-core | verified |
| `zod` | 3 → 4 | 11 | 100% | 87% | 47% | 219 | ugurkocde/AwesomeIntune, howardyang2009/PATH, band-ai/band-sdk-typescript, vzdc-artcc/website, CANShift/canshift-core | verified |

| Package | Rewrite rule | Precision | Real true positives | False positives | Note |
| --- | --- | ---: | ---: | ---: | --- |
Expand Down Expand Up @@ -104,11 +104,11 @@ already run as assisted ([verification gate](packs.md)).
| `typescript` | `import-assertions` | n/a | 0 | 0 | unexercised: runs assisted |
| `typescript` | `module-keyword` | n/a | 0 | 0 | unexercised: runs assisted |
| `vitest` | `removed-entrypoints` | n/a | 0 | 0 | unexercised: runs assisted |
| `zod` | `error-issues` | 100% | 52 | 0 | |
| `zod` | `error-issues` | 100% | 51 | 0 | |
| `zod` | `error-members` | n/a | 0 | 0 | unexercised: runs assisted |
| `zod` | `error-params` | 100% | 15 | 0 | |
| `zod` | `record-key` | 100% | 2 | 0 | |
| `zod` | `string-format` | 100% | 19 | 0 | |
| `zod` | `string-format` | 100% | 21 | 0 | |
<!-- packs:end -->

Dependencies without an active pack still receive [generic analysis](concepts.md).
Expand Down
26 changes: 26 additions & 0 deletions docs/concepts.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,32 @@ declaration it traces to. Common causes are grouped into one finding with a site
count; `--details` expands sites. For example, TypeScript 7 missing compiler API members form
one cause rather than dozens.

A compile error whose value comes from another breaking site is that site's consequence,
not a site of its own. When zod 4 drops `ZodError.errors`, `return { errors:
result.error.errors }` is the breaking site: one edit (`.issues`) fixes it. Every component
that calls that action and maps over the result (`errors.map(e => e.message)`, where `e` is
now implicitly `any`) errors too, and so does the narrowing that depended on it
(`savedAirport` possibly undefined). Those errors fold into the breaking site as
**downstream errors**. The rule:

- The new error's value is traced with the compiler, never by line distance: from the
expression at the error, through the declaration and initializer of a variable or
destructured binding, the receiver of a callback typed by an `any` value
(`errors.map(e => …)`), the members of a returned object literal, and the call of a
repository function. It folds only when the trace reaches the expression of another new
error that is typed `any` at the target, such as `result.error.errors` (TS2339).
- A repository function's call is followed only when every returned expression whose type
changed between the installed version and the target leads to such an expression. If one
changed return cannot be traced, the trace stops.
- The root must be a breaking finding at that expression (a pack's or the declaration
diff's, or an unattributed compile error). If it is a consequence itself, the error folds
into that one's root. Errors whose trace reaches no reported root stay as they are.
- The default view adds the count to the rule's line (`61 sites, +58 downstream errors`).
`--details` lists each folded error under its root, and `--json` carries them as the
root finding's `downstream`.
- Folding changes what is reported, never what has to compile. `fix` still verifies against
every diagnostic, so a fix that leaves a downstream error broken fails verification.

## Exit codes

For scripts and CI: **0** nothing breaking, **1** breaking changes found, **2** uptide could
Expand Down
44 changes: 41 additions & 3 deletions docs/packs.md
Original file line number Diff line number Diff line change
Expand Up @@ -253,11 +253,40 @@ They are scored like the others and never count toward `verified`.

`uptide pack test` fetches each repository once: a shallow fetch of the pinned commit only,
dependencies installed from its lockfile (npm, pnpm, Yarn or bun) with lifecycle scripts
off, and nothing from the repository is ever
executed. They are kept in `~/.cache/uptide/ground-truth` (`UPTIDE_GROUND_TRUTH_CACHE`), so
`pack test --offline` runs without network after the first time. `pnpm packs:fetch` fills
off: no lifecycle script or hook of the repository runs, and the only commands are its code
generators (below). They are kept in `~/.cache/uptide/ground-truth`
(`UPTIDE_GROUND_TRUTH_CACHE`), so `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`).

After the install and before `check`, the preparation runs the repository's known code
generators, the ones `fix` runs in its clone (UPT-90): `prisma generate` for a
`schema.prisma`, and GraphQL codegen for a `codegen.ts`/`yml` whose schema is local. On a
user's machine that generated code exists (`prisma generate` has run), so without it `check`
cannot resolve the imports of a generated client and scores those files as `unverified`: the
scoring environment, not the rule, would understate the pack. The generators use the
repository's own binaries, read local files only and reach no network beyond the registry; a
codegen config with a remote schema is not run. Prisma downloads its engine binaries on first
use when its install script did not run (the schema engine, and for Prisma 5 and 6 the query
engine library), so the generators run behind a download mirror nothing listens on. Before
they run, uptide fetches those engines itself, in its own code: the engine commit the locked
`@prisma/engines-version` names (read as data), for this machine's platform, from
binaries.prisma.sh, checked against the published sha256, and kept under the cache's
`prisma-engines/`. Prisma uses them through `PRISMA_SCHEMA_ENGINE_BINARY` and
`PRISMA_QUERY_ENGINE_LIBRARY`, so it generates the same on a fresh runner as on a machine
whose Prisma cache already holds them. The step applies to every pack and every ground-truth
repository alike, never to a single entry.

A generator that still fails, times out or is not run leaves the repository **incomplete**:
`pack test` does not score it (`incomplete, generated code missing: generator failed: …`),
counts it as "could not be scored", and fails the pack. The one exception is declared in the
ground truth, never assumed: an entry's `withoutGenerated` says why its generators cannot run
at that commit outside the project's own environment (a Prisma config that needs a live
database URL, a codegen config with no schema), and that repository is scored without the
generated code, with the failure and the reason printed. A failed generator runs again on the
next `pack test`, without a new fetch or install. `check` itself still runs no scripts.
Fixtures are synthetic and installed in this tree; they get no generator step.

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
Expand Down Expand Up @@ -299,6 +328,15 @@ An expected site has the severity of the rule or note it is expected under: a ru
A missed site counts against breaking recall only when its severity is breaking: a missed
`deprecated` note ("needs a look") or a decision is not a missed break.

Downstream errors are not plan sites (see [one root cause, one
finding](concepts.md#one-root-cause-one-finding)). `check` folds a compile error into the
breaking finding whose expression the compiler traces its value to, for example every
`errors.map(e => e.message)` under a `result.error.errors` that zod 4 no longer types. The
root stays a site, and the folded errors are its `downstream`, so `pack test` scores neither
a hit nor a false positive for them. A ground-truth site that `check` folds is a miss.
Ground truth leaves these consequences out, as it always has, and the folding is an engine
rule that applies to every user's `check`, not a scoring exception.

It prints precision and recall for each, `n/a` where there was nothing to measure (nothing
predicted, or nothing expected), every false positive as `repo file:line rule (severity)`,
every false negative the same way with the severity it was expected at, and sites found under
Expand Down
12 changes: 12 additions & 0 deletions fixtures/deps/issues-v1/index.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
/** v1: a parse failure lists its issues as `issues` and as `errors`. */
export interface Issue {
message: string;
}
export interface Failure {
issues: Issue[];
errors: Issue[];
}
export declare function parse(
input: unknown,
): { success: false; error: Failure } | { success: true; data: string };
export declare function legacy(): number;
6 changes: 6 additions & 0 deletions fixtures/deps/issues-v1/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"name": "issues",
"version": "1.0.0",
"types": "./index.d.ts",
"license": "MIT"
}
10 changes: 10 additions & 0 deletions fixtures/deps/issues-v2/index.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
/** v2: `errors` is gone (use `issues`), and so is `legacy`. */
export interface Issue {
message: string;
}
export interface Failure {
issues: Issue[];
}
export declare function parse(
input: unknown,
): { success: false; error: Failure } | { success: true; data: string };
6 changes: 6 additions & 0 deletions fixtures/deps/issues-v2/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"name": "issues",
"version": "2.0.0",
"types": "./index.d.ts",
"license": "MIT"
}
3 changes: 3 additions & 0 deletions packages/cli/src/format-check-details.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,9 @@ function findingLines(
if (f.usage.compileError)
lines.push(colors.dim(` compiler: ${f.usage.compileError.split('\n')[0]}`));
for (const detail of f.details ?? []) lines.push(colors.dim(` ${detail}`));
// Errors folded into this site (UPT-99, option cascades): every one, under its root.
for (const d of f.downstream ?? [])
lines.push(colors.dim(` ${d.file}:${d.line} TS${d.code}: ${d.message}`));
const others = (f.sites ?? []).filter((s) => s.line !== f.usage.line);
if (others.length > 0)
lines.push(
Expand Down
6 changes: 4 additions & 2 deletions packages/cli/src/format-check.ts
Original file line number Diff line number Diff line change
Expand Up @@ -288,11 +288,13 @@ function sectionLines(row: Row, colors: Colors): string[] {
)
return row.summary || plan.length ? [heading, ...coverage, ...plan] : [];
const scope = (g: PlanGroup): string => {
// Errors folded into these sites go with their edits: counted, never sites (UPT-99).
const downstream = g.downstream ? `, +${plural(g.downstream, 'downstream error')}` : '';
if (g.fixes !== g.sites)
return `${plural(g.fixes, 'fix', 'fixes')}, ${plural(g.sites, 'error')}`;
return `${plural(g.fixes, 'fix', 'fixes')}, ${plural(g.sites, 'error')}${downstream}`;
const only = g.locations[0];
// A single site is worth naming; the full path is in --details.
return g.sites === 1 && only ? `${basename(only.file)}:${only.line}` : plural(g.sites, 'site');
return `${g.sites === 1 && only ? `${basename(only.file)}:${only.line}` : plural(g.sites, 'site')}${downstream}`;
};
// A package with dozens of distinct changes (a compiler API that was removed) gets its
// largest ones here and the rest as a count: --details has every one.
Expand Down
1 change: 1 addition & 0 deletions packages/cli/src/pack.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,7 @@ export function formatPackTest(report: PackTestReport, color = false, limit = In
`${head} predicted ${r.predicted.length}, expected ${r.expected.length}, false positives ${r.falsePositives.length}, false negatives ${r.falseNegatives.length}`,
);
if (r.installedMismatch) out.push(` ${c.yellow('!')} ${r.installedMismatch}`);
for (const g of r.generatorFailed ?? []) out.push(` ${c.yellow('!')} ${g}`);
if (r.companions?.length)
out.push(
` with ${r.companions.map((m) => `${m.name} ${m.from} → ${m.to}${m.required ? ` (moved because ${m.reason})` : ''}`).join(', ')}`,
Expand Down
77 changes: 77 additions & 0 deletions packages/core/src/adapters/typescript/cause.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -313,3 +313,80 @@ describe('a member declared in a repository augmentation the target no longer re
});
});
});

describe('downstream consequences (UPT-99)', () => {
/** `issues` 1 → 2 drops `Failure.errors` and `legacy`. */
function issuesConsumer(files: Record<string, string>): string {
const dir = consumerWith('placeholder.ts', 'export {};\n');
const tsconfig = JSON.parse(readFileSync(join(dir, 'tsconfig.json'), 'utf8'));
tsconfig.compilerOptions.paths.issues = [join(DEPS, 'issues-v1/index.d.ts')];
writeFileSync(join(dir, 'tsconfig.json'), JSON.stringify(tsconfig));
for (const [name, source] of Object.entries(files))
writeFileSync(join(dir, 'src', name), source);
return dir;
}

it('traces a value to the removed member it was read from, not an unrelated error beside it', async () => {
const dir = issuesConsumer({
'action.ts': [
"import { parse } from 'issues';",
'export function save(input: unknown) {',
' const result = parse(input);',
' if (!result.success) return { errors: result.error.errors };',
' return { saved: result.data };',
'}',
'',
].join('\n'),
'form.ts': [
"import { legacy } from 'issues';",
"import { save } from './action';",
'export function submit(input: unknown): string {',
' const { saved, errors } = save(input);',
" if (errors) return errors.map((e) => e.message).join('. ');",
' return saved.toUpperCase();',
'}',
'export const n = legacy();',
'',
].join('\n'),
});
const signal = await compileAgainstTarget({ dir }, 'issues', join(DEPS, 'issues-v2'));
const at = (file: string, code: number) =>
signal.diagnostics.find((d) => d.file === `src/${file}` && d.code === code);
const root = at('action.ts', 2339);
expect(root).toMatchObject({ line: 4 });
expect(root?.origin).toBeUndefined();
const where = { file: 'src/action.ts', line: 4, column: root?.column };
// `e` is implicitly `any` because `errors` is: folded.
expect(at('form.ts', 7006)?.origin).toEqual(where);
// `saved` is no longer narrowed by `errors`: the return type changed only at the root.
expect(at('form.ts', 18048)?.origin).toEqual(where);
// The removed `legacy` import is its own break.
expect(at('form.ts', 2305)?.origin).toBeUndefined();
});

it('does not follow a call whose return also changed somewhere it cannot trace', async () => {
const dir = issuesConsumer({
'action.ts': [
"import { parse, legacy } from 'issues';",
'export function save(input: unknown) {',
' const result = parse(input);',
' if (!result.success) return { errors: result.error.errors };',
' return { saved: result.data, count: legacy() };',
'}',
'',
].join('\n'),
'form.ts': [
"import { save } from './action';",
'export function submit(input: unknown): string {',
' const { errors } = save(input);',
" return errors ? errors.map((e) => e.message).join('. ') : '';",
'}',
'',
].join('\n'),
});
const signal = await compileAgainstTarget({ dir }, 'issues', join(DEPS, 'issues-v2'));
const implicitAny = signal.diagnostics.find((d) => d.file === 'src/form.ts' && d.code === 7006);
expect(implicitAny).toBeDefined();
expect(implicitAny?.origin).toBeUndefined();
});
});
Loading
Loading