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
26 changes: 26 additions & 0 deletions .changeset/check-confirmed-breaking.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
---
"uptide": minor
---

What changed in `check`:

- **Breaking means confirmed.** A type-surface change is reported as breaking only when the
compiler rejects your code against the target at that site, the runtime probe saw the
export go, or a migration pack found it, in the verified tier as in the generic one. What
nothing confirmed is listed as "possible impact", apart from the breaking count.
- **Coverage, under every package.** `compiled 355 of 356 files in 5 workspaces; skipped:
<reason (count)>` says how much of the code that uses the package the compiler judged, and
the verdict says "types partly verified" when not every file was.
- **TypeScript-version-aware defaults.** A repository on TypeScript 5 is read with
TypeScript 5's defaults for what its tsconfig leaves unset (`strict` off, automatic
`@types`), not the bundled TypeScript 6's. Monorepos compile far more of their files:
workspace packages declared `workspace:*` under Yarn and npm resolve to their source,
scoped programs keep the tsconfig's ambient declaration files, and a workspace that only
peer-depends on a package finds the hoisted copy.
- **Companions.** `check` and `fix` move `@types/<pkg>` with `<pkg>`, and a package released
in lockstep with it (react-dom with react): `check react` 18 → 19 moves react-dom,
@types/react and @types/react-dom together.
- **One finding for a root cause that is one edit.** A compiler option (the global `JSX`
namespace `@types/react` 19 removes, read by `"jsx": "preserve"`) and a repository
parameter several call sites trip are each one finding at the place to edit, with the
compiler errors as evidence.
13 changes: 12 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,12 +38,14 @@ zod 3.25.76 → 4.6.5 major · latest on npm verifie
stripe (packages/api) 14.25.0 → 23.0.0 9 majors behind · latest on npm verified ✗ 4 breaking in 3 files 1 auto-fixable · 3 need the agent (LLM)

zod 28 breaking · compiled against 4.6.5: 28 new type errors
compiled 11 of 11 files in 2 workspaces
✗ New error API (required_error → error) 24 sites auto-fixable
✗ ZodTypeDef removed 1 fix, 3 errors needs the agent (LLM)
✗ .ip() removed monitoring.ts:6 needs the agent (LLM)
! 15 deprecated calls (.uuid, .datetime, .email, ...) 8 auto-fixable

stripe 4 breaking · compiled against 23.0.0: 4 new type errors
compiled 7 of 7 files in 1 workspace
✗ Test fixture casts widened renewal.test.ts:7 auto-fixable
✗ Subscription billing period moved to items 2 sites needs the agent (LLM)
✗ apiVersion no longer matches the SDK client.ts:9 needs the agent (LLM)
Expand Down Expand Up @@ -206,7 +208,16 @@ Every direct dependency can be discovered and selected for analysis. What differ
| Tier | Dependencies | What you get |
| --- | --- | --- |
| **Verified** | zod 3 → 4, stripe 14 and newer, ai 6 → 7 | A migration pack: rules written for that dependency, a guide for the agent, behavior checks (zod schemas compared on generated inputs; Stripe changelog filtered to what you call), and ground truth from public repositories the pack is scored against ([docs/packs.md](docs/packs.md)). `fix` migrates by rule first, by agent for the rest. |
| **Generic** | any other dependency | The same analysis without a pack. A finding is called breaking only when your compiler or the runtime probe confirms it, or when it is a `require()` of an ESM-only package or an import of a removed export; everything else is in `--details`. `fix` migrates with the agent alone, under the same verification, and says so in the pull request. |
| **Generic** | any other dependency | The same analysis without a pack. `fix` migrates with the agent alone, under the same verification, and says so in the pull request. |

In both tiers a finding is called breaking only when something confirms it at the site: your
compiler rejects the code against the target, the runtime probe saw the export go, it is a
`require()` of an ESM-only package or an import of a removed export, or a pack found it.
Everything else the declaration diff suggests is **possible impact**: the types changed where
your code uses them, nothing confirmed that it breaks, so it is listed under its own line and
never counted as breaking. Under every analyzed package one line says how much of the code that
uses it the compiler judged (`compiled 11 of 11 files in 2 workspaces`); when files were
skipped, the verdict says "types partly verified" and why.

The tier is on every row of `check`, in the HTML report and in the pull request. A generic
`fix` needs a provider API key (there are no rules to fall back on; without a key it says
Expand Down
81 changes: 77 additions & 4 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,21 @@ and the range style restored after, so `^4.0.1` stays at the 4.0.1 the target pi
lockfile may change inside any of their subtrees and nowhere else. A conflict stops `fix`
before it writes anything.

Two links `list` does not draw are added for the plan. `@types/<pkg>` moves with `<pkg>` by
name (nothing in its manifest says so), to the release that types the target: the newest at
the target's major.minor, else at its major, else the newest there is, never below what is
installed (`src/check/types-release.ts`; `prepare` diffs the types package at that version).
A package whose release at the target's own version asks for the target (react-dom 19.0.0
peer-requires react ^19.0.0) is released in lockstep and moves to that version, even when the
installed copy's range would accept the target. The closure is transitive and companions can
move with companions: `@types/react-dom` with `react-dom`, which moves with `react`; a types
package waits for the package it types before its own manifest may place it. Every workspace's
dependencies are candidates, since a hoisting installer keeps the root's `@types/react` for the
app that declares `react`; a candidate must be visible from a workspace of the package it
follows (the same one, or an ancestor). `check react` 18 → 19 moves react-dom, @types/react
and @types/react-dom together; the plan is carried by every report of the package, alone or
leading its group, and the merged entry keeps every companion.

### One dependency, one decision

A dependency at the same installed version and target in several workspaces is one entry
Expand Down Expand Up @@ -231,10 +246,27 @@ v1 and berry, bun's text lockfile), never from `node_modules` alone. The lockfil
workspace root above the package being checked; the importer read is that package's
(`importers[<rel>]` for pnpm, `packages[<rel>/node_modules/<name>]` before the hoisted
entry for npm, the declared range's entry for yarn, the workspace's nested key for bun),
so two workspace packages on different versions of the same dependency are read correctly. The
so two workspace packages on different versions of the same dependency are read correctly. A
dependency declared `workspace:`, `link:` or `file:` is recorded under that specifier whatever
the lockfile says for it (Yarn writes `0.0.0-use.local`, npm nothing), so the source mapping
below and the `workspace` status read it the same way under every manager. The
ts-morph project comes from the repo's `tsconfig.json` (its `paths`, `include`, and
project references one level down) or, without one, from `**/*.ts,tsx` minus
`node_modules`, `dist` and `build`. Nothing in the repository is executed.
`node_modules`, `dist` and `build`. A scoped program (the files that use one package) also
holds the ambient `.d.ts` files the tsconfig includes (`vite-env.d.ts`, `css.d.ts`): nothing
imports them, and without them `*.module.css` imports and `declare global` names fail.
Nothing in the repository is executed.

The compiler bundled with ts-morph is TypeScript 6, whose defaults for an option a tsconfig
leaves unset differ from TypeScript 5's: `strict` on, no automatic `@types` inclusion, a
modern `target`, `module` and resolution, `esModuleInterop` on. A repository on TypeScript 5
(or with none installed) is read with TypeScript 5's defaults made explicit
(`src/adapters/typescript/legacy-options.ts`: target ES5, module and resolution derived from
it, interop off, strict off, every `@types/*` package of the type roots), so a scoped program
reports what the repository's own compiler would; a repository on TypeScript 6 keeps the
compiler's own. A package's `main` pointing at a `.ts` source is not resolved from
`node_modules` by the bundled compiler either, which is one more reason workspace
dependencies are mapped to their source.

### Signal A (`src/adapters/typescript/usages.ts`)

Expand Down Expand Up @@ -320,6 +352,15 @@ inside pkg@ver, results may be incomplete"). Those never become findings: with
`skipLibCheck` an unresolved import inside a declaration file makes the type `any`,
which hides errors rather than inventing them.

Every compile says what it covered (`CompileSignal.coverage`): the files it was asked about
(the package's usage files and the files importing them, per workspace) and how many it
type-checked, with a reason for each file it did not: outside the workspace's tsconfig, a
workspace whose baseline is structurally broken, an invalid tsconfig. Merged across
workspaces into `PackageReport.compile.coverage` (`compiled 355 of 356 files in 5
workspaces; skipped: ...`), it is printed under every analyzed package, and a package whose
files were not all compiled gets the verdict "types partly verified" with that line instead
of "compiled against <version>" (`src/check/verdict.ts`).

Cost is proportional to the package, not the repository. The overlay is a raw compiler
`Program` that shares the baseline's parsed and bound source files and its module
resolutions (only the target and its linked dependencies are parsed, once per compile);
Expand Down Expand Up @@ -491,8 +532,40 @@ becomes `info`: "diff says X but your code compiles against the target", confide
never listed, never counted. If the symbol's declaration file in the target has an
unresolved import, the compiler's silence proves nothing there and the finding is
`unverified` instead: shown in its own section, not counted as breaking. Deprecations are
untouched. Without Signal B (`--no-compile`, or a skipped overlay) verdicts stand and the
package carries the note "unverified: compile check skipped".
untouched.

Breaking means confirmed, in every tier (`src/check/tier.ts`, `confirmBreaking`). A finding
stays breaking only with evidence: the compiler rejects the site, the runtime probe saw the
export go, a `require()` of an ESM-only target, the import of a name the target no longer
exports, or a migration pack found it in the code. A type-surface change the declaration
diff reports at a site nothing confirmed, in a file the compile did not reach as much as in
one it did, is `unverified`: the "possible impact" a report lists apart and never counts as
breaking. Packs keep their rules, and their breaking findings follow the same rule: a rule
claims the compiler-confirmed sites it matches, and a pack's own `detect` sites carry their
evidence. Without Signal B (`--no-compile`, or a skipped overlay) nothing is confirmed, every
surface change is possible impact, and the package carries the note "unverified: compile
check skipped".

A root cause can be a compiler option (`src/adapters/typescript/config-cause.ts`). The
target removes the global `JSX` namespace (@types/react 19); a workspace whose `jsx` is
`preserve` or `react` and names no `jsxImportSource` reads JSX element types from that
namespace, so every element in every file errors (TS7026, TS2602) with one fix. Those
diagnostics are anchored at the `jsx` line of the tsconfig that sets it (through `extends`),
as one `cause` finding marked `anchorOnly`: it counts as one site, the plan and `fix` list
the tsconfig, the diagnostics are its `downstream` evidence, and a pack rule claims the
anchor when it claims most of the errors under it (`ruleFor`). A `JSX.Element` written in
code is still a site of its own.

A root cause can be a repository parameter (`parameterCause` in `cause.ts`). A mismatch the
compiler reports at an argument (TS2345) or a JSX attribute (TS2322) whose parameter or prop
is declared in the repository with a type that names something from outside it
(`usePassThroughWheelEvents(ref: RefObject<HTMLElement>)`, rejected at eleven call sites once
the target's `useRef` returns `RefObject<HTMLElement | null>`) is anchored at the parameter,
`anchorOnly`, when at least two sites trip it: the one edit is the parameter's type, as the
migration guides say, and the call sites are evidence. The parameter may sit in another
workspace whose source the program maps (`packages/editor` for a call in `packages/tldraw`);
its path is the repository's. A parameter one site trips is left to
that site, which may as well be the argument's.

A diagnostic confirms exactly one usage: the innermost whose span contains the
diagnostic's start. `z.string().trim().url()` is three usages on one line, and an error
Expand Down
35 changes: 29 additions & 6 deletions docs/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,16 +91,39 @@ or incomplete analysis. A failing package/workspace does not erase successful re
Every row says `verified` or `generic`. Verified: a migration pack covers the upgrade, and
its ground truth from at least two public repositories has no false positive among breaking
findings ([docs/packs.md](packs.md)). A pack that has not met that bar yet is a candidate and
the row says generic. Generic: no verified pack; a finding is breaking only with evidence, which `--details` names under
each site:
the row says generic. Generic: no verified pack. In both tiers a finding is breaking only
with evidence, which `--details` names under each site:

- your code does not compile against the target at that site;
- the runtime probe loaded the target and the export is gone or changed;
- a `require()` of a package whose target is ESM-only;
- the import of a name the target no longer exports.

Everything else the declaration diff suggests is listed as unverified in `--details` and
counted on the first screen only as "N unconfirmed".
- the import of a name the target no longer exports;
- a migration pack found it in the code (verified tier).

Everything else the declaration diff suggests is possible impact: the row counts it apart
(`✗ 3 breaking, 17 possible`), the package lists it under "possible impact: N sites in M
files, not confirmed by the compiler or the runtime probe", and `--details` has every site.
It is never counted as breaking.

### Coverage

Under every analyzed package, one line says how much of the code that uses it the compiler
judged: `compiled 355 of 356 files in 5 workspaces; skipped: most files cannot resolve their
imports at the installed version (1)`. The files are the ones that import the package and the
ones importing those, per workspace, each compiled under its own tsconfig; a skipped one
says why (not in the workspace tsconfig, a workspace whose baseline cannot resolve its
imports, an invalid tsconfig). When not every file was compiled, the verdict says `types
partly verified: compiled N of M files ...` instead of `compiled against <version>`: a clean
result only covers what was compiled.

A root cause that is a compiler option is one site. When the target drops the global `JSX`
namespace and the workspace's `"jsx": "preserve"` (or `"react"`) reads JSX element types
from it, every element in every file errors with one fix: `check` reports one finding at the
`jsx` line of the tsconfig that sets it (`"jsxImportSource": "react"` resolves it), with the
diagnostics as evidence, and `--details` shows a few of them. A repository parameter that
several call sites trip over (a hook typed `RefObject<HTMLElement>` once `useRef` returns
`RefObject<HTMLElement | null>`) is reported the same way: one finding at the parameter, with
the call sites as evidence.

### Partial results

Expand Down
2 changes: 1 addition & 1 deletion packages/cli/src/__snapshots__/format-check.test.ts.snap
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ zod 3.25.76 → 4.6.5 major · latest on npm
stripe (packages/api) 14.25.0 → 23.0.0 9 majors behind · latest on npm verified ✗ 4 breaking in 3 files 1 auto-fixable · 3 need the agent (LLM)
vitest 3.2.4 → 5.0.3 2 majors behind · latest on npm ✓ no impact (9 call sites)

verified: migration pack · generic: no pack, breaking only if the compiler or the runtime probe confirms it
verified: migration pack · generic: no pack · in both, breaking only if the compiler, the runtime probe or the pack confirms it

zod 28 breaking · compiled against 4.6.5
✗ New error API (required_error → error) 24 sites auto-fixable
Expand Down
20 changes: 20 additions & 0 deletions packages/cli/src/format-check-details.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ export interface DetailOptions {
}

const plural = (n: number, one: string, many = `${one}s`): string => `${n} ${n === 1 ? one : many}`;
/** Errors shown under a compiler-option cause: enough to see the shape, not the thousand of them. */
const EVIDENCE_SITES = 8;

function describeChange(f: Finding): string {
const c = f.change;
Expand Down Expand Up @@ -90,6 +92,24 @@ function siteLines(
return findings.flatMap((f) => {
if (f.change.kind !== 'cause') return findingLines(f, mark, colors, tint);
const sites = f.downstream ?? [];
// A compiler option is one site; the errors under it are evidence, a few of them shown.
if (f.anchorOnly) {
const shown = sites.slice(0, EVIDENCE_SITES);
return [
` ${tint(mark)} ${f.usage.file}:${f.usage.line} \`${f.change.path.replace(/^cause:/, '')}\``,
` ${f.reason}`,
colors.dim(
` evidence: ${plural(sites.length, 'error')} at the target${shown.length ? ', for example' : ''}`,
),
...shown.map((site) =>
colors.dim(` ${site.file}:${site.line} TS${site.code}: ${site.message}`),
),
...(sites.length > shown.length
? [colors.dim(` … and ${sites.length - shown.length} more`)]
: []),
...(f.details ?? []).map((detail) => colors.dim(` ${detail}`)),
];
}
return [
` ${tint(mark)} ${f.reason} (${plural(sites.length, 'site')})`,
...sites.flatMap((site) => [
Expand Down
Loading
Loading