From f61f51120a4d7bfe4aaf87101b5858eb83c48b5f Mon Sep 17 00:00:00 2001 From: Lucas Braum Date: Thu, 8 Oct 2026 08:12:39 +0000 Subject: [PATCH 1/6] fix(check): move @types/ and lockstep peers with the package, and find hoisted packages from any workspace MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A companion plan now closes over what relates to the lead and to companions already placed: `@types/` moves with `` to the release that types the target (its major.minor, else its major), and 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) moves to that version even when its installed copy would accept the target. `check react` 18 → 19 moves react-dom, @types/react and @types/react-dom together, and prepare() diffs the types package at the version the plan chose. A workspace that only peer-depends on a package resolved it through its types and then looked for the runtime copy in its own node_modules alone; a hoisting installer keeps one copy at the root, which Node and now check find from any workspace. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_018u9b3Pp5H68DdYZGp5Qg1U Signed-off-by: Lucas Braum --- .../core/src/adapters/typescript/usages.ts | 30 +++- packages/core/src/check/check.ts | 25 +-- packages/core/src/check/companions.test.ts | 112 ++++++++++++ packages/core/src/check/companions.ts | 164 +++++++++++++++--- packages/core/src/check/types-release.ts | 49 ++++++ 5 files changed, 339 insertions(+), 41 deletions(-) create mode 100644 packages/core/src/check/types-release.ts diff --git a/packages/core/src/adapters/typescript/usages.ts b/packages/core/src/adapters/typescript/usages.ts index 813ca87..2f905a2 100644 --- a/packages/core/src/adapters/typescript/usages.ts +++ b/packages/core/src/adapters/typescript/usages.ts @@ -163,11 +163,12 @@ export function resolvePackageDir(repo: LoadedRepo, pkg: string): string | undef repo.project.getCompilerOptions(), repo.project.getModuleResolutionHost(), ).resolvedModule; - // Unresolved and not on disk under node_modules: not installed. Climbing from a missing - // directory would land on the repository's own package.json and call it the package. - const fallback = join(repo.dir, 'node_modules', pkg); - if (!resolved && !existsSync(join(fallback, 'package.json'))) return undefined; - let dir = resolved ? dirname(resolved.resolvedFileName) : fallback; + // Unresolved and not on disk under a node_modules Node would search: not installed. + // Climbing from a missing directory would land on the repository's own package.json and + // call it the package. + const fallback = hoistedPackageDir(repo.dir, pkg); + if (!resolved && fallback === undefined) return undefined; + let dir = resolved ? dirname(resolved.resolvedFileName) : (fallback as string); for (;;) { const pj = join(dir, 'package.json'); if (existsSync(pj)) { @@ -179,13 +180,26 @@ export function resolvePackageDir(repo: LoadedRepo, pkg: string): string | undef } } const parent = dirname(dir); - // Resolved to another package's declarations (@types/express for 'express'): the runtime package is under node_modules. - if (parent === dir) - return existsSync(join(fallback, 'package.json')) ? realpathSync(fallback) : undefined; + // Resolved to another package's declarations (@types/express for 'express'): the runtime + // package is under a node_modules Node would search from the workspace. + if (parent === dir) return fallback === undefined ? undefined : realpathSync(fallback); dir = parent; } } +/** + * `/node_modules/` for the workspace or the nearest ancestor that has it: what Node + * resolves from the workspace. A hoisting installer (yarn classic, npm) keeps one copy at the + * repository root, which a workspace that only peer-depends on the package still sees. + */ +function hoistedPackageDir(from: string, pkg: string): string | undefined { + for (let dir = from; ; dir = dirname(dir)) { + const candidate = join(dir, 'node_modules', pkg); + if (existsSync(join(candidate, 'package.json'))) return candidate; + if (dirname(dir) === dir) return undefined; + } +} + interface Resolved { /** Under the name the consumer wrote. */ path: string; diff --git a/packages/core/src/check/check.ts b/packages/core/src/check/check.ts index c3dc905..aeb6de1 100644 --- a/packages/core/src/check/check.ts +++ b/packages/core/src/check/check.ts @@ -55,6 +55,7 @@ import { isBehind, rankCandidates } from './rank.js'; import { groupRootCauses } from './root-cause.js'; import { runtimeChangeFindings } from './runtime-changes.js'; import { confirmGeneric, evidenceOf, tierOf } from './tier.js'; +import { typesPackageOf, typesReleaseFor } from './types-release.js'; import { unattributedFindings } from './unattributed.js'; import { verdictOf } from './verdict.js'; import { compareVersions, majorsBehind, parseVersion } from './version.js'; @@ -1011,8 +1012,11 @@ async function companionPlans( const target = opts.targets?.[lead] ?? (await fetcher.resolve(lead, 'latest').catch(() => undefined)); if (!target) continue; + // Every workspace's dependencies: a companion may be declared above the workspace that + // declares the lead (`@types/react` at the root, hoisted for the app that has `react`); + // `companionsOf` decides which of them a workspace of the lead can see. const installed = new Map(); - for (const workspace of declaring) + for (const workspace of workspaces) for (const [name, version] of Object.entries(installedByWorkspace[workspace] ?? {})) { if (linked(version)) continue; const known = installed.get(`${name}@${version}`); @@ -1294,7 +1298,7 @@ async function prepare( const nodeTypes = installedPackageDirOf(ctx.adapter, repo, '@types/node', '')?.dir; if (installedDir && nodeTypes) installedDir.types = [nodeTypes]; // Typed through DefinitelyTyped: the surface to diff is @types/'s, at the version matching the target's major. - const typesName = `@types/${name.startsWith('@') ? name.slice(1).replace('/', '__') : name}`; + const typesName = typesPackageOf(name); const typesDir = installedPackageDirOf(ctx.adapter, repo, typesName, ''); let types: Prepared['types']; let runtimeDir: PackageDir | undefined; @@ -1302,17 +1306,14 @@ async function prepare( const typesInstalled = readManifest(typesDir.dir)?.version ?? '0.0.0'; const major = parseVersion(target)?.major; const published = (await ctx.fetcher.versions?.(typesName).catch(() => [])) ?? []; - // The @types release of the target's major; never below what is installed (@types/passport 1.x - // types passport 0.x), else the latest release. - const sameMajor = major === undefined ? undefined : maxSatisfying(published, `${major}.x`); - const latestTypes = published.at(-1); + // The @types release that types the target (its major.minor, else its major; never below + // what is installed: @types/passport 1.x types passport 0.x), else the latest release. A + // companion plan that moves the types package names the release outright. const typesTarget = - sameMajor !== undefined && compareVersions(sameMajor, typesInstalled) >= 0 - ? sameMajor - : latestTypes !== undefined && compareVersions(latestTypes, typesInstalled) >= 0 - ? latestTypes - : typesInstalled; - if (typesTarget !== sameMajor && published.length > 0) + ctx.opts.targets?.[typesName] ?? + typesReleaseFor(published, target, typesInstalled) ?? + typesInstalled; + if (major !== undefined && parseVersion(typesTarget)?.major !== major && published.length > 0) notes.push( `${typesName} has no ${major}.x release above ${typesInstalled}; diffed against ${typesTarget}`, ); diff --git a/packages/core/src/check/companions.test.ts b/packages/core/src/check/companions.test.ts index 44210da..49fa397 100644 --- a/packages/core/src/check/companions.test.ts +++ b/packages/core/src/check/companions.test.ts @@ -149,3 +149,115 @@ describe('companionsOf, a member that already agrees', () => { expect(plan).toEqual({ companions: [], conflicts: [] }); }); }); + +describe('companionsOf, react 18 → 19', () => { + /** The registry as the upgrade sees it: react-dom peer-requires react at its own version, the types track minors. */ + const react: Record = { + react: { '18.2.0': {}, '18.3.1': {}, '19.0.0': {}, '19.2.1': {}, '19.3.0': {} }, + 'react-dom': { + '18.2.0': { dependencies: { scheduler: '^0.23.0' }, peerDependencies: { react: '^18.2.0' } }, + '18.3.1': { dependencies: { scheduler: '^0.23.2' }, peerDependencies: { react: '^18.3.1' } }, + '19.0.0': { dependencies: { scheduler: '0.25.0' }, peerDependencies: { react: '^19.0.0' } }, + '19.2.1': { dependencies: { scheduler: '0.27.0' }, peerDependencies: { react: '^19.2.1' } }, + '19.3.0': { dependencies: { scheduler: '0.28.0' }, peerDependencies: { react: '^19.3.0' } }, + }, + '@types/react': { + '18.2.0': { dependencies: { csstype: '^3.0.2' } }, + '18.3.31': { dependencies: { csstype: '^3.0.2' } }, + '19.0.10': { dependencies: { csstype: '^3.0.2' } }, + '19.0.14': { dependencies: { csstype: '^3.0.2' } }, + '19.2.7': { dependencies: { csstype: '^3.0.2' } }, + '19.3.0': { dependencies: { csstype: '^3.0.2' } }, + }, + '@types/react-dom': { + '18.2.0': { dependencies: { '@types/react': '*' } }, + '19.0.4': { peerDependencies: { '@types/react': '^19.0.0' } }, + '19.2.3': { peerDependencies: { '@types/react': '^19.0.0' } }, + }, + scheduler: { '0.23.0': {}, '0.25.0': {} }, + }; + const manifests = async (name: string) => react[name] ?? {}; + const installed = (name: string, version: string, workspaces: string[]): InstalledDependency => ({ + name, + version, + manifest: { name, version, ...react[name]?.[version] }, + workspaces, + }); + + it('moves react-dom, @types/react and @types/react-dom with react, the types from the root', async () => { + // excalidraw: the app declares react and react-dom, the root declares the types for everyone. + const plan = await companionsOf({ + name: 'react', + target: '19.0.0', + installed: [ + installed('react', '18.2.0', ['excalidraw-app']), + installed('react-dom', '18.2.0', ['excalidraw-app']), + installed('@types/react', '18.2.0', ['.']), + installed('@types/react-dom', '18.2.0', ['.']), + installed('scheduler', '0.23.0', ['excalidraw-app']), + ], + manifests, + }); + expect(plan.conflicts).toEqual([]); + expect(plan.companions).toEqual([ + { + name: '@types/react', + from: '18.2.0', + to: '19.0.14', + reason: '@types/react 19.0.14 types react 19.0.0', + }, + { + name: '@types/react-dom', + from: '18.2.0', + to: '19.0.4', + reason: '@types/react-dom 19.0.4 types react-dom 19.0.0', + }, + { + name: 'react-dom', + from: '18.2.0', + to: '19.0.0', + reason: 'react-dom 19.0.0 is released with react 19.0.0 (peer ^19.0.0)', + }, + ]); + expect(plan.reason).toBe('peer link, types for react, react-dom'); + }); + + it('moves a lockstep companion even when its installed copy accepts the target', async () => { + const plan = await companionsOf({ + name: 'react', + target: '19.2.1', + installed: [ + installed('react', '18.3.1', ['.']), + { + ...installed('react-dom', '18.3.1', ['.']), + manifest: { peerDependencies: { react: '^18.3.1 || ^19.0.0' } }, + }, + installed('@types/react', '18.3.31', ['.']), + ], + manifests: async (name) => + name === 'react-dom' + ? { + ...react['react-dom'], + '18.3.1': { peerDependencies: { react: '^18.3.1 || ^19.0.0' } }, + } + : manifests(name), + }); + expect(plan.companions.map((c) => `${c.name} ${c.to}`)).toEqual([ + '@types/react 19.2.7', + 'react-dom 19.2.1', + ]); + }); + + it('leaves a types package declared in an unrelated workspace alone', async () => { + const plan = await companionsOf({ + name: 'react', + target: '19.0.0', + installed: [ + installed('react', '18.2.0', ['apps/web']), + installed('@types/react', '18.2.0', ['apps/docs']), + ], + manifests, + }); + expect(plan).toEqual({ companions: [], conflicts: [] }); + }); +}); diff --git a/packages/core/src/check/companions.ts b/packages/core/src/check/companions.ts index 0dd0be8..82a07fe 100644 --- a/packages/core/src/check/companions.ts +++ b/packages/core/src/check/companions.ts @@ -2,6 +2,7 @@ import { satisfies } from '../fetch/range.js'; import type { Manifest } from '../list/evidence.js'; import { dependencyGroups } from '../list/groups.js'; import type { ListedDependency } from '../list/list.js'; +import { typedPackageOf, typesPackageOf, typesReleaseFor } from './types-release.js'; import { compareVersions, parseVersion } from './version.js'; /** @@ -11,12 +12,17 @@ import { compareVersions, parseVersion } from './version.js'; * group is the one `list` shows (`dependencyGroups`: family, peer link, shared pin); the * version each companion moves to is the one that agrees with the target: * - the version the target pins exactly (`ai@7.0.9` → `@ai-sdk/provider` 4.0.1); + * - else its release at the target's own version, when that release asks for the target + * (`react-dom` 19.0.0 peer-requires `react` ^19.0.0: released in lockstep); * - else the installed one, when its range for the target already accepts it * (`@modelcontextprotocol/sdk` 1.28.0 takes `zod` `^3.25 || ^4.0`: nothing to move); * - else its newest release that pins the target exactly, or whose range for it accepts * the target (`@ai-sdk/react@4.0.10` → `ai` 7.0.9); * - else its newest release whose exact pins agree with every exact pin of the target * (`@ai-sdk/openai` on `@ai-sdk/provider`). + * `@types/` moves with `` by name (nothing in its manifest says so): to the release + * that types the version `` moves to (`types-release.ts`). A companion can move with a + * companion: `@types/react-dom` with `react-dom`, which moves with `react`. */ export interface Companion { name: string; @@ -85,6 +91,16 @@ function agreeing( const pinned = targetManifest.dependencies?.[name]; if (pinned && EXACT.test(pinned) && versions[pinned]) return { version: pinned, reason: `${lead} ${target} pins ${name} ${pinned}` }; + // Released in lockstep: its release at the target's own version asks for the target + // (react-dom 19.0.0 peer-requires react ^19.0.0). The installed copy may accept the target + // too (`^18 || ^19`), but the upgrade the repository makes moves both. + const same = versions[target]; + const sameRange = same?.dependencies?.[lead] ?? same?.peerDependencies?.[lead]; + if (same && sameRange !== undefined && accepts(sameRange, target) && target !== installed.version) + return { + version: target, + reason: `${name} ${target} is released with ${lead} ${target} (${EXACT.test(sameRange) ? 'pins' : 'peer'} ${sameRange})`, + }; const current = versions[installed.version] ?? installed.manifest; const range = current.dependencies?.[lead] ?? current.peerDependencies?.[lead]; if (range !== undefined && accepts(range, target)) @@ -117,6 +133,30 @@ function agreeing( return undefined; } +/** Whether a workspace of `dep` serves a workspace of `host`: the same one, or an ancestor (the root hoists for all). */ +function visibleFrom(dep: InstalledDependency, host: InstalledDependency): boolean { + return dep.workspaces.some((d) => + host.workspaces.some((h) => h === d || d === '.' || h.startsWith(`${d}/`)), + ); +} + +/** `@types/` for a `` of the plan: the only link a types package has to its runtime. */ +function typesOf(dep: InstalledDependency, host: InstalledDependency): boolean { + return typedPackageOf(dep.name) === host.name && visibleFrom(dep, host); +} + +/** The `@types` release that types `host@hostTarget`, when it is not the installed one. */ +function typesMove( + dep: InstalledDependency, + host: string, + hostTarget: string, + versions: Manifests, +): { version: string; reason: string } | undefined { + const version = typesReleaseFor(Object.keys(versions), hostTarget, dep.version); + if (version === undefined) return undefined; + return { version, reason: `${dep.name} ${version} types ${host} ${hostTarget}` }; +} + /** * What moves with `name@target` in a repository, from its installed dependencies and the * registry's manifests of every version (`manifests`, one cached packument per package). @@ -131,25 +171,81 @@ export async function companionsOf(input: { if (!lead) return { companions: [], conflicts: [] }; const targetManifest = (await input.manifests(input.name))[input.target]; if (!targetManifest) return { companions: [], conflicts: [] }; - // Plausible members: related to the lead, then the rest of their families. - const direct = input.installed.filter((d) => d.name !== lead.name && related(lead, d)); - const families = new Set(direct.map((d) => scopeOf(d.name)).filter(Boolean)); + // Plausible members: related to the lead, the rest of their families, and the types + // packages of any of those; then what relates to a member the same way, until nothing new. const scope = scopeOf(lead.name); + const members: InstalledDependency[] = [lead]; + const families = new Set(); if (scope) families.add(scope); - const candidates = input.installed.filter( - (d) => - d.name !== lead.name && - (direct.includes(d) || - (families.has(scopeOf(d.name)) && d.workspaces.some((w) => lead.workspaces.includes(w)))), - ); + const grow = (): boolean => { + let grew = false; + for (const d of input.installed) { + if (members.includes(d) || d.name === lead.name) continue; + const host = members.find((m) => related(m, d) || typesOf(d, m)); + const scope = scopeOf(d.name); + const family = + scope !== undefined && + families.has(scope) && + d.workspaces.some((w) => lead.workspaces.includes(w)); + if (!host && !family) continue; + members.push(d); + if (host && scope !== undefined) families.add(scope); + grew = true; + } + return grew; + }; + while (grow()) { + // until the closure is complete + } + const candidates = members.filter((d) => d !== lead); if (candidates.length === 0) return { companions: [], conflicts: [] }; + // Each candidate moves to the version that agrees with a decided package: the lead, else a + // companion already placed (`@types/react-dom` with `react-dom`, which moved with `react`). + const decided = new Map([ + [lead.name, { version: input.target, manifest: targetManifest }], + ]); const moves = new Map(); - for (const c of candidates) - moves.set( - c.name, - agreeing(c, lead.name, input.target, targetManifest, await input.manifests(c.name)), - ); + const typesLinked = new Set(); + const names = new Set(members.map((m) => m.name)); + // A types package waits for the package it types (`@types/react-dom` for `react-dom`), which + // may be placed in a later pass; only when that never happens does its own manifest decide. + for (let progress = true, waitForTyped = true; progress || waitForTyped; ) { + if (!progress) waitForTyped = false; + progress = false; + for (const c of candidates) { + if (moves.get(c.name) !== undefined) continue; + const typed = typedPackageOf(c.name); + const typesHost = + typed !== undefined && decided.has(typed) + ? input.installed.find((d) => d.name === typed && typesOf(c, d)) + : undefined; + if (!typesHost && typed !== undefined && names.has(typed) && waitForTyped) continue; + const versions = await input.manifests(c.name); + let move: { version: string; reason: string } | undefined; + if (typesHost) { + move = typesMove( + c, + typed as string, + decided.get(typed as string)?.version as string, + versions, + ); + if (move) typesLinked.add(c.name); + } else { + for (const [host, at] of decided) { + const hostDep = input.installed.find((d) => d.name === host); + if (!hostDep || !related(hostDep, c)) continue; + move = agreeing(c, host, at.version, at.manifest, versions); + if (move) break; + } + } + moves.set(c.name, move); + if (move) { + decided.set(c.name, { version: move.version, manifest: versions[move.version] ?? {} }); + progress = true; + } + } + } // The group, as `list` draws it, with each member at the version that agrees. const listed = (d: InstalledDependency, latest: string): ListedDependency => @@ -177,29 +273,55 @@ export async function companionsOf(input: { const group = dependencyGroups(packages, metadata, targets).find((g) => g.members.some((m) => m.name === lead.name), ); - if (!group) return { companions: [], conflicts: [] }; + const inGroup = new Set((group?.members ?? [listed(lead, input.target)]).map((m) => m.name)); + // `list` links nothing to a types package: `@types/` joins the group of its ``, + // and what `list` would group with it (`@types/react-dom`, a peer of `@types/react`) follows. + for (let grew = true; grew; ) { + grew = false; + for (const c of candidates) { + if (inGroup.has(c.name) || moves.get(c.name) === undefined) continue; + const typed = typedPackageOf(c.name); + const joins = + (typesLinked.has(c.name) && typed !== undefined && inGroup.has(typed)) || + dependencyGroups(packages, metadata, targets).some( + (g) => + g.members.some((m) => m.name === c.name) && g.members.some((m) => inGroup.has(m.name)), + ); + if (!joins) continue; + inGroup.add(c.name); + grew = true; + } + } + if (inGroup.size < 2) return { companions: [], conflicts: [] }; const companions: Companion[] = []; const conflicts: string[] = []; - for (const member of group.members) { - if (member.name === lead.name) continue; + for (const member of candidates.filter((c) => inGroup.has(c.name))) { const move = moves.get(member.name); if (!move) { conflicts.push( - `${member.name} ${member.current} has no release that agrees with ${lead.name} ${input.target}`, + `${member.name} ${member.version} has no release that agrees with ${lead.name} ${input.target}`, ); continue; } - if (move.version === member.current) continue; + if (move.version === member.version) continue; companions.push({ name: member.name, - from: member.current, + from: member.version, to: move.version, reason: move.reason, }); } + const typed = [ + ...new Set( + companions.filter((c) => typesLinked.has(c.name)).map((c) => typedPackageOf(c.name)), + ), + ]; + const reason = [group?.reason, typed.length ? `types for ${typed.join(', ')}` : undefined] + .filter(Boolean) + .join(', '); return { companions: companions.sort((a, b) => a.name.localeCompare(b.name)), conflicts, - ...(group.reason ? { reason: group.reason } : {}), + ...(reason ? { reason } : {}), }; } diff --git a/packages/core/src/check/types-release.ts b/packages/core/src/check/types-release.ts new file mode 100644 index 0000000..4bf8ed1 --- /dev/null +++ b/packages/core/src/check/types-release.ts @@ -0,0 +1,49 @@ +import { compareVersions, parseVersion } from './version.js'; + +/** + * The `@types/` release that goes with a runtime version: DefinitelyTyped tracks the + * runtime's major and, for most packages, its minor (`@types/react` 19.0.x types react + * 19.0, 19.2.x types 19.2). The newest release at the runtime's major.minor, else the newest + * at its major, else the newest there is; never below what is installed (`@types/passport` + * 1.x types passport 0.x). Undefined when nothing is published. + */ +export function typesReleaseFor( + published: readonly string[], + runtimeTarget: string, + installed?: string, +): string | undefined { + const target = parseVersion(runtimeTarget); + const stable = published + .filter((v) => { + const p = parseVersion(v); + return p !== undefined && p.pre === undefined; + }) + .sort(compareVersions); + const notBelowInstalled = (v: string): boolean => + installed === undefined || compareVersions(v, installed) >= 0; + const newest = (match: (p: NonNullable>) => boolean) => + stable + .filter( + (v) => + notBelowInstalled(v) && + match(parseVersion(v) as NonNullable>), + ) + .at(-1); + if (!target) return newest(() => true); + return ( + newest((p) => p.major === target.major && p.minor === target.minor) ?? + newest((p) => p.major === target.major) ?? + newest(() => true) + ); +} + +/** `@types/react` for `react`, `@types/scope__name` for `@scope/name`. */ +export function typesPackageOf(name: string): string { + return `@types/${name.startsWith('@') ? name.slice(1).replace('/', '__') : name}`; +} + +/** The runtime package a `@types/*` package types, or undefined for any other name. */ +export function typedPackageOf(name: string): string | undefined { + if (!name.startsWith('@types/')) return undefined; + return name.slice('@types/'.length).replace(/^([^_]+)__/, '@$1/'); +} From c2a4ca076ff3ac29fea4cad4fc722c185c41425a Mon Sep 17 00:00:00 2001 From: Lucas Braum Date: Thu, 8 Oct 2026 08:40:33 +0000 Subject: [PATCH 2/6] fix(check): breaking means confirmed in every tier, compile coverage under every package, one finding for a compiler-option cause A type-surface change is breaking only when the compiler, the runtime probe or a pack confirms it at the site, in the verified tier as in the generic one; what nothing confirmed is possible impact, listed apart and never counted. Every compile says which of the files that use the package it type-checked and why it skipped the rest; the merged entry prints `compiled N of M files in K workspaces; skipped: ...` and its verdict says "types partly verified" when not every file was. Monorepos compile more of their files: a workspace package declared workspace:* under Yarn or npm resolves to its source, a scoped program keeps the tsconfig's ambient declaration files, and a repository on TypeScript 5 is read with TypeScript 5's defaults (strict off, automatic @types) rather than the bundled TypeScript 6's. The global JSX namespace @types/react 19 removes, read by "jsx": "preserve", is one cause: the diagnostics it explains are anchored at the jsx line of the tsconfig that sets it, as one site with the errors as evidence, and a pack rule claims the anchor when it claims most of the errors under it. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_018u9b3Pp5H68DdYZGp5Qg1U Signed-off-by: Lucas Braum --- .changeset/check-confirmed-breaking.md | 19 +++ docs/architecture.md | 70 ++++++++- docs/cli.md | 32 +++- .../__snapshots__/format-check.test.ts.snap | 2 +- packages/cli/src/format-check-details.ts | 20 +++ packages/cli/src/format-check.test.ts | 55 ++++++- packages/cli/src/format-check.ts | 64 +++++--- packages/cli/src/format-plan.test.ts | 2 +- .../core/src/adapters/typescript/compile.ts | 75 ++++++++-- .../adapters/typescript/config-cause.test.ts | 93 ++++++++++++ .../src/adapters/typescript/config-cause.ts | 93 ++++++++++++ .../typescript/legacy-options.test.ts | 69 +++++++++ .../src/adapters/typescript/legacy-options.ts | 104 +++++++++++++ packages/core/src/adapters/typescript/repo.ts | 57 ++++++- packages/core/src/check/anchor.test.ts | 141 ++++++++++++++++++ packages/core/src/check/check.ts | 66 +++++--- packages/core/src/check/companions.ts | 2 +- packages/core/src/check/match.test.ts | 1 + packages/core/src/check/merge.test.ts | 1 + packages/core/src/check/merge.ts | 5 + packages/core/src/check/tier.test.ts | 4 +- packages/core/src/check/tier.ts | 11 +- packages/core/src/check/types-release.test.ts | 28 ++++ packages/core/src/check/unattributed.test.ts | 53 +++++++ packages/core/src/check/unattributed.ts | 8 +- packages/core/src/check/verdict.test.ts | 30 +++- packages/core/src/check/verdict.ts | 25 ++++ packages/core/src/domain/report.ts | 14 +- packages/core/src/domain/usage.ts | 17 +++ packages/core/src/fix/select.ts | 3 +- packages/core/src/index.ts | 2 +- packages/core/src/packs/contract.ts | 29 +++- 32 files changed, 1101 insertions(+), 94 deletions(-) create mode 100644 .changeset/check-confirmed-breaking.md create mode 100644 packages/core/src/adapters/typescript/config-cause.test.ts create mode 100644 packages/core/src/adapters/typescript/config-cause.ts create mode 100644 packages/core/src/adapters/typescript/legacy-options.test.ts create mode 100644 packages/core/src/adapters/typescript/legacy-options.ts create mode 100644 packages/core/src/check/anchor.test.ts create mode 100644 packages/core/src/check/types-release.test.ts diff --git a/.changeset/check-confirmed-breaking.md b/.changeset/check-confirmed-breaking.md new file mode 100644 index 0000000..baec46e --- /dev/null +++ b/.changeset/check-confirmed-breaking.md @@ -0,0 +1,19 @@ +--- +"uptide": minor +--- + +`check` reports a type-surface change as breaking only when something confirms it at the +site, in every tier: the compiler rejects the code against the target, the runtime probe saw +the export go, or a migration pack found it. What nothing confirmed is "possible impact", +listed apart and never counted as breaking. Every analyzed package prints how much of the code +that uses it the compiler judged (`compiled 355 of 356 files in 5 workspaces; skipped: ...`), +and the verdict says "types partly verified" when not every file was. 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, a workspace that +only peer-depends on a package finds the hoisted copy, and a repository on TypeScript 5 is +read with TypeScript 5's defaults (`strict`, automatic `@types`) rather than the bundled +TypeScript 6's. A root cause that is a compiler option is one finding at the option: the +global `JSX` namespace that `@types/react` 19 removes, read by `"jsx": "preserve"`, is +reported once at the tsconfig line with the element errors as evidence. `check` and `fix` +move `@types/` with ``, 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. diff --git a/docs/architecture.md b/docs/architecture.md index 87335bb..ad379d4 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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/` moves with `` 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 @@ -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[]` for pnpm, `packages[/node_modules/]` 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`) @@ -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 " (`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); @@ -491,8 +532,29 @@ 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 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 diff --git a/docs/cli.md b/docs/cli.md index 825d142..f6582f2 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -91,16 +91,36 @@ 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 `: 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. ### Partial results diff --git a/packages/cli/src/__snapshots__/format-check.test.ts.snap b/packages/cli/src/__snapshots__/format-check.test.ts.snap index 3d61a37..90ce6b9 100644 --- a/packages/cli/src/__snapshots__/format-check.test.ts.snap +++ b/packages/cli/src/__snapshots__/format-check.test.ts.snap @@ -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 diff --git a/packages/cli/src/format-check-details.ts b/packages/cli/src/format-check-details.ts index 7771587..e90e2d9 100644 --- a/packages/cli/src/format-check-details.ts +++ b/packages/cli/src/format-check-details.ts @@ -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; @@ -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) => [ diff --git a/packages/cli/src/format-check.test.ts b/packages/cli/src/format-check.test.ts index 5aa27f7..dd229cf 100644 --- a/packages/cli/src/format-check.test.ts +++ b/packages/cli/src/format-check.test.ts @@ -364,21 +364,64 @@ describe('tiers, the time budget and failures on the first screen', () => { expect(formatCheck(report([verified]), { color: false })).not.toContain('generic: no pack'); }); - it('keeps what nothing confirmed off the first screen of a generic package', () => { + it('lists what nothing confirmed as possible impact, apart from the breaking count, in every tier', () => { const out = formatCheck(report([generic]), { color: false }); - expect(out).toContain('✗ 1 breaking · 1 unconfirmed in --details'); - expect(out).not.toContain('? '); + expect(out).toContain('✗ 1 breaking, 1 possible'); + expect(out).toContain( + '? possible impact: 1 site in 1 file not confirmed by the compiler or the runtime probe', + ); + expect(out).toMatch(/\n {4}\? sharp\.cache removed +image\.ts:4/); expect(out).not.toContain('unverified'); const none = formatCheck(report([{ ...generic, status: 'safe', findings: [unconfirmed] }]), { color: false, }); - expect(none).toContain('✓ nothing confirmed (12 call sites) · 1 unconfirmed in --details'); - // The verified tier still shows what it could not verify. + expect(none).toContain('? 1 possible impact'); + expect(none).not.toContain('✓'); + // The verified tier lists it the same way: breaking means confirmed in both. const kept = formatCheck( report([{ ...generic, name: 'zod', tier: 'verified', findings: [unconfirmed] }]), { color: false }, ); - expect(kept).toContain('? 1 unverified'); + expect(kept).toContain('? 1 possible impact'); + expect(kept).toContain('? possible impact: 1 site in 1 file'); + }); + + it('prints how much of the code the compiler judged under every analyzed package', () => { + const covered = pkg({ + name: 'zod', + tier: 'verified', + status: 'safe', + compile: { + baselineErrors: 0, + unresolvedInTarget: [], + unresolvedFiles: [], + unattributed: [], + newErrors: 0, + coverage: { + compiled: 12, + total: 40, + workspaces: 2, + skipped: [{ reason: 'not in the workspace tsconfig', count: 28 }], + }, + }, + }); + const out = formatCheck(report([covered]), { color: false }); + expect(out).toContain( + 'zod 0 breaking · types partly verified: compiled 12 of 40 files in 2 workspaces; skipped: not in the workspace tsconfig (28)', + ); + expect(out).toContain( + '\n compiled 12 of 40 files in 2 workspaces; skipped: not in the workspace tsconfig (28)', + ); + const full = { + ...covered, + compile: { + ...covered.compile, + coverage: { compiled: 40, total: 40, workspaces: 2, skipped: [] }, + }, + } as PackageReport; + const clean = formatCheck(report([full]), { color: false }); + expect(clean).toContain('zod 0 breaking · compiled against 5.3.0: 0 new type errors'); + expect(clean).toContain('\n compiled 40 of 40 files in 2 workspaces'); }); it('folds many no-impact upgrades into one line', () => { diff --git a/packages/cli/src/format-check.ts b/packages/cli/src/format-check.ts index 11dddfc..de2d1b3 100644 --- a/packages/cli/src/format-check.ts +++ b/packages/cli/src/format-check.ts @@ -2,6 +2,7 @@ import { basename } from 'node:path'; import { BY, type CheckReport, + coverageLine, isFailure, type PackageReport, type PlanGroup, @@ -143,14 +144,12 @@ function rowOf(p: PackageReport, multi: boolean, colors: Colors): Row | undefine if (p.installed === p.target && p.findings.length === 0 && p.status === 'safe') return undefined; // Out of time or failed: listed once, under "Not analyzed", with how to include them. if (p.skipReason === 'TIME_BUDGET' || isFailure(p)) return undefined; - // Without a pack, what nothing confirmed is for --details: the first screen acts on evidence. - const generic = p.tier === 'generic'; - const plan = planOf(p).filter((g) => !(generic && g.severity === 'unverified')); - const unconfirmed = generic ? sitesOf(planOf(p).filter((g) => g.severity === 'unverified')) : 0; - const aside = unconfirmed > 0 ? colors.dim(` · ${unconfirmed} unconfirmed in --details`) : ''; + // What nothing confirmed is possible impact, in every tier: counted apart, never as breaking. + const plan = planOf(p); const of = (severity: PlanGroup['severity']): PlanGroup[] => plan.filter((g) => g.severity === severity); const [breaking, unverified, deprecated] = [of('breaking'), of('unverified'), of('deprecated')]; + const aside = ''; const where = multi && p.workspace !== '*' && p.workspace !== '.' ? colors.dim(` (${p.workspace})`) : ''; const members = p.members ? ` (${plural(p.members.length, 'package')})` : ''; @@ -168,11 +167,11 @@ function rowOf(p: PackageReport, multi: boolean, colors: Colors): Row | undefine } else if (breaking.length > 0) { const sites = sitesOf(breaking); const files = filesOf(breaking); - const extra = unverified.length > 0 ? `, ${sitesOf(unverified)} unverified` : ''; + const extra = unverified.length > 0 ? `, ${sitesOf(unverified)} possible` : ''; verdict = `${colors.red('✗')} ${sites} breaking${sites > 1 ? ` in ${plural(files, 'file')}` : ''}${extra}${aside}`; by = byLine([...breaking, ...unverified]); } else if (unverified.length > 0) { - verdict = `${colors.magenta('?')} ${sitesOf(unverified)} unverified`; + verdict = `${colors.magenta('?')} ${sitesOf(unverified)} possible impact`; by = byLine(unverified); } else if (deprecated.length > 0) { verdict = `${colors.yellow('!')} ${sitesOf(deprecated)} deprecated${aside}`; @@ -184,8 +183,6 @@ function rowOf(p: PackageReport, multi: boolean, colors: Colors): Row | undefine } else if (gaps > 0) { // A workspace that imports it was not analyzed: "no impact" would claim more than is known. verdict = `${colors.magenta('?')} no impact in ${plural(p.callSitesChecked, 'site')}, ${plural(gaps, 'workspace')} not analyzed${aside}`; - } else if (unconfirmed > 0) { - verdict = `${colors.green('✓')} nothing confirmed ${colors.dim(`(${plural(p.callSitesChecked, 'call site')})`)}${aside}`; } else { verdict = `${colors.green('✓')} no impact ${colors.dim(`(${plural(p.callSitesChecked, 'call site')})`)}`; } @@ -219,9 +216,10 @@ function deprecatedNames(groups: PlanGroup[]): string { .join(', ')}${names.length > 3 ? ', ...' : ''}`; } -/** One line per rule; deprecations are one line for the package. */ +/** One line per rule; possible impact under its own line; deprecations are one line for the package. */ function sectionLines(row: Row, colors: Colors): string[] { - const acting = row.plan.filter((g) => g.severity !== 'deprecated'); + const acting = row.plan.filter((g) => g.severity === 'breaking'); + const possible = row.plan.filter((g) => g.severity === 'unverified'); const deprecated = row.plan.filter((g) => g.severity === 'deprecated'); const importers = importerNotes(row.p); // A peer the target asks for and the repository does not have at that version: the usual @@ -234,17 +232,22 @@ function sectionLines(row: Row, colors: Colors): string[] { ] : []; }); - // Every analyzed package says its verdict and what verified it, zero breaking included. + // Every analyzed package says its verdict and what verified it, zero breaking included, and + // how much of the code that uses it the compiler judged. const heading = row.summary ? `${colors.bold(row.p.name)} ${colors.dim(row.summary)}` : colors.bold(row.p.name); + const coverage = row.p.compile?.coverage + ? [colors.dim(` ${coverageLine(row.p.compile.coverage)}`)] + : []; if ( acting.length === 0 && + possible.length === 0 && deprecated.length === 0 && importers.length === 0 && peers.length === 0 ) - return row.summary ? [heading] : []; + return row.summary ? [heading, ...coverage] : []; const scope = (g: PlanGroup): string => { if (g.fixes !== g.sites) return `${plural(g.fixes, 'fix', 'fixes')}, ${plural(g.sites, 'error')}`; @@ -255,14 +258,19 @@ function sectionLines(row: Row, colors: Colors): string[] { // 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. const listed = acting.length > MAX_RULE_LINES ? acting.slice(0, MAX_RULE_LINES - 1) : acting; - const titleWidth = Math.max(...listed.map((g) => g.title.length), 0); - const scopeWidth = Math.max(...listed.map((g) => scope(g).length), 0); - const lines = [heading]; - for (const g of listed) { + // Possible impact gets the lines the breaking ones leave, and at least one. + const room = Math.max(1, MAX_RULE_LINES - listed.length); + const listedPossible = possible.length > room ? possible.slice(0, room - 1) : possible; + const shown = [...listed, ...listedPossible]; + const titleWidth = Math.max(...shown.map((g) => g.title.length), 0); + const scopeWidth = Math.max(...shown.map((g) => scope(g).length), 0); + const lines = [heading, ...coverage]; + const ruleLine = (g: PlanGroup): string => { const mark = g.severity === 'breaking' ? colors.red('✗') : colors.magenta('?'); - lines.push( - ` ${mark} ${pad(g.title, titleWidth)} ${pad(scope(g), scopeWidth)} ${colors.dim(byLabel(g))}`, - ); + return ` ${mark} ${pad(g.title, titleWidth)} ${pad(scope(g), scopeWidth)} ${colors.dim(byLabel(g))}`; + }; + for (const g of listed) { + lines.push(ruleLine(g)); if (g.note) lines.push(colors.dim(` ${g.note}`)); } if (listed.length < acting.length) { @@ -273,6 +281,22 @@ function sectionLines(row: Row, colors: Colors): string[] { ), ); } + // What the declarations changed at a place the code uses, and nothing confirmed breaks + // there: listed apart, never counted as breaking. + if (possible.length > 0) { + lines.push( + ` ${colors.magenta('?')} possible impact: ${plural(sitesOf(possible), 'site')} in ${plural(filesOf(possible), 'file')} ${colors.dim('not confirmed by the compiler or the runtime probe')}`, + ); + for (const g of listedPossible) lines.push(` ${ruleLine(g)}`); + if (listedPossible.length < possible.length) { + const rest = possible.slice(listedPossible.length); + lines.push( + colors.dim( + ` … ${plural(rest.length, 'more change')}, ${plural(sitesOf(rest), 'site')} (--details)`, + ), + ); + } + } if (deprecated.length > 0) { const sites = sitesOf(deprecated); const byRule = deprecated.reduce((n, g) => n + g.by.rule, 0); diff --git a/packages/cli/src/format-plan.test.ts b/packages/cli/src/format-plan.test.ts index bd02117..51b27b7 100644 --- a/packages/cli/src/format-plan.test.ts +++ b/packages/cli/src/format-plan.test.ts @@ -113,7 +113,7 @@ describe('formatPlan', () => { ' next 14.2.0: not analyzed: out of time', ' npx uptide list refresh discovery', '', - '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', 'Effort is an estimate from the findings: none (nothing affected), small (rules, or up to 5 sites by hand or agent), medium (up to 25), large (more).', '', ].join('\n'), diff --git a/packages/core/src/adapters/typescript/compile.ts b/packages/core/src/adapters/typescript/compile.ts index 3af3d8e..efaf6eb 100644 --- a/packages/core/src/adapters/typescript/compile.ts +++ b/packages/core/src/adapters/typescript/compile.ts @@ -4,10 +4,11 @@ import { tmpdir } from 'node:os'; import { dirname, join, relative } from 'node:path'; import { ts } from 'ts-morph'; import type { CompileOptions, RepoDir } from '../../domain/adapter.js'; -import type { CompileDiagnostic, CompileSignal } from '../../domain/usage.js'; +import type { CompileCoverage, CompileDiagnostic, CompileSignal } from '../../domain/usage.js'; import { satisfies } from '../../fetch/range.js'; import { onReset } from '../../shared-state.js'; import { findCause } from './cause.js'; +import { jsxNamespaceCause } from './config-cause.js'; import { type LoadedRepo, loadedRepo, ownsFile } from './repo.js'; import { consumerCopySatisfies, @@ -153,28 +154,57 @@ function importersOf(repo: LoadedRepo): Map> { return map; } -/** The files whose diagnostics can change: the ones using the package, and the ones importing those. */ +/** + * The files whose diagnostics can change: the ones using the package, and the ones importing + * those. `missing` counts the requested files the program does not hold (outside the + * workspace's tsconfig): nothing can be said about them, and the coverage says so. + */ function filesToCheck( repo: LoadedRepo, repoRef: RepoDir, program: ts.Program, requested: string[] | undefined, -): ts.SourceFile[] { +): { files: ts.SourceFile[]; missing: number } { const own = (f: ts.SourceFile): boolean => !f.isDeclarationFile && isRepoError({ category: ts.DiagnosticCategory.Error, file: f } as ts.Diagnostic, repo.dir) && ownsFile(repoRef, f.fileName); - if (!requested) return program.getSourceFiles().filter(own); + if (!requested) return { files: program.getSourceFiles().filter(own), missing: 0 }; const wanted = new Set(); const reverse = importersOf(repo); + let missing = 0; for (const rel of requested) { const abs = join(repo.dir, rel); + if (!ownsFile(repoRef, abs)) continue; + if (!program.getSourceFile(abs)) missing++; wanted.add(abs); for (const importer of reverse.get(abs) ?? []) wanted.add(importer); } - return [...wanted] - .map((p) => program.getSourceFile(p)) - .filter((f): f is ts.SourceFile => f !== undefined && own(f)); + return { + files: [...wanted] + .map((p) => program.getSourceFile(p)) + .filter((f): f is ts.SourceFile => f !== undefined && own(f)), + missing, + }; +} + +const NOT_IN_TSCONFIG = 'not in the workspace tsconfig'; + +/** What became of the files asked about: compiled, or skipped and why. */ +function coverageOf( + compiled: number, + missing: number, + skipped?: { reason: string; count: number }, +): CompileCoverage { + const reasons = [ + ...(skipped && skipped.count > 0 ? [skipped] : []), + ...(missing > 0 ? [{ reason: NOT_IN_TSCONFIG, count: missing }] : []), + ]; + return { + compiled, + total: compiled + reasons.reduce((n, r) => n + r.count, 0), + skipped: reasons, + }; } /** A baseline nobody can subtract from: bad config, or most files unable to see their imports. */ @@ -197,6 +227,14 @@ function structuralFailure( return undefined; } +/** The skip reason as the coverage names it: short, without the counts the message carries. */ +function skipReasonOf(skipped: string): string { + if (skipped.startsWith('invalid tsconfig')) return 'invalid tsconfig'; + if (skipped.includes('cannot resolve their imports')) + return 'most files cannot resolve their imports at the installed version'; + return skipped.split(';')[0] ?? skipped; +} + interface Overlay { program: ts.Program; /** Real directories of the targets and their linked dependencies. */ @@ -549,6 +587,7 @@ export async function compileAgainstTargets( diagnostics: [], baselineErrors: 0, skipped: `invalid tsconfig: ${message}`, + coverage: coverageOf(0, 0, { reason: 'invalid tsconfig', count: options.files?.length ?? 0 }), unresolvedInTarget: [], unresolvedFiles: [], linkedDependencies: [], @@ -589,7 +628,7 @@ export async function compileAgainstTargets( new Map(), ).program; } - const files = filesToCheck(repo, repoRef, checked, options.files); + const { files, missing } = filesToCheck(repo, repoRef, checked, options.files); const baseline = files.flatMap((f) => memoized ? baselineErrorsOf(repo, checked, f) @@ -603,6 +642,7 @@ export async function compileAgainstTargets( diagnostics: [], baselineErrors: baseline.length, skipped, + coverage: coverageOf(0, missing, { reason: skipReasonOf(skipped), count: files.length }), unresolvedInTarget: [], unresolvedFiles: [], linkedDependencies: [], @@ -640,15 +680,19 @@ export async function compileAgainstTargets( return `${relative(repo.dir, overlaid.fileName)}:${line + 1}`; }), ); + // One compiler option can explain hundreds of diagnostics (the JSX namespace the target no + // longer declares): those are anchored at the option, the rest traced to a declaration. + const jsx = jsxNamespaceCause( + { overlay: program, base: checked }, + fresh.map(({ d }) => d), + repo.dir, + targets.map((t) => t.name), + ); const diagnostics: CompileDiagnostic[] = fresh.map(({ d, overlaid }) => { const diagnostic = toDiagnostic(d, overlaid, repo.dir); - const cause = findCause( - { overlay: program, base: checked }, - d, - overlaid, - repo.dir, - erroredLines, - ); + const cause = + (jsx?.explains(d) ? jsx.cause : undefined) ?? + findCause({ overlay: program, base: checked }, d, overlaid, repo.dir, erroredLines); if (cause) diagnostic.cause = cause; return diagnostic; }); @@ -657,6 +701,7 @@ export async function compileAgainstTargets( (a, b) => a.file.localeCompare(b.file) || a.line - b.line || a.column - b.column, ), baselineErrors: baseline.length, + coverage: coverageOf(files.length, missing), unresolvedInTarget: [...unresolvedInTarget].sort(), unresolvedFiles: [...unresolvedFiles].sort(), linkedDependencies: deps.linked, diff --git a/packages/core/src/adapters/typescript/config-cause.test.ts b/packages/core/src/adapters/typescript/config-cause.test.ts new file mode 100644 index 0000000..a036d12 --- /dev/null +++ b/packages/core/src/adapters/typescript/config-cause.test.ts @@ -0,0 +1,93 @@ +import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { ts } from 'ts-morph'; +import { describe, expect, it } from 'vitest'; +import { jsxNamespaceCause } from './config-cause.js'; + +/** A consumer with one JSX file, type-checked against types with and without the global JSX namespace. */ +function programs( + jsx: string, + extra = '', +): { base: ts.Program; overlay: ts.Program; root: string } { + const root = mkdtempSync(join(tmpdir(), 'uptide-jsx-')); + mkdirSync(join(root, 'src')); + writeFileSync( + join(root, 'tsconfig.json'), + `{\n "compilerOptions": {\n "strict": true,\n "jsx": "${jsx}",${extra}\n "types": [],\n "noEmit": true\n },\n "include": ["src"]\n}\n`, + ); + writeFileSync(join(root, 'src', 'App.tsx'), 'export const app =
;\n'); + mkdirSync(join(root, 'with')); + writeFileSync( + join(root, 'with', 'index.d.ts'), + 'declare global { namespace JSX { interface IntrinsicElements { div: {} } } }\nexport {};\n', + ); + mkdirSync(join(root, 'without')); + writeFileSync(join(root, 'without', 'index.d.ts'), 'export {};\n'); + const parsed = ts.readConfigFile(join(root, 'tsconfig.json'), ts.sys.readFile); + const { options } = ts.parseJsonConfigFileContent( + parsed.config, + ts.sys, + root, + undefined, + join(root, 'tsconfig.json'), + ); + const program = (types: string): ts.Program => + ts.createProgram({ + rootNames: [join(root, 'src', 'App.tsx'), join(root, types, 'index.d.ts')], + options, + }); + return { base: program('with'), overlay: program('without'), root }; +} + +describe('the JSX namespace a compiler option reads', () => { + it('blames the tsconfig line that sets `jsx` when the target drops the global namespace', () => { + const { base, overlay, root } = programs('preserve'); + const fresh = overlay.getSemanticDiagnostics( + overlay.getSourceFile(join(root, 'src', 'App.tsx')), + ); + expect(fresh.map((d) => d.code)).toEqual([7026]); + const found = jsxNamespaceCause({ overlay, base }, fresh, root, ['@types/react']); + expect(found?.cause).toMatchObject({ + name: 'jsx', + file: 'tsconfig.json', + line: 4, + config: true, + }); + expect(found?.cause.reason).toContain('"jsx": "preserve"'); + expect(found?.cause.reason).toContain('@types/react no longer declares'); + expect(found?.explains(fresh[0] as ts.Diagnostic)).toBe(true); + expect(found?.explains({ code: 2322 } as ts.Diagnostic)).toBe(false); + }); + + it('is no cause when the namespace is still there, or when the option is the react-jsx runtime', () => { + const { base, overlay, root } = programs('preserve'); + const app = join(root, 'src', 'App.tsx'); + // Nothing new: the base program has the namespace, and so does this "overlay". + expect( + jsxNamespaceCause( + { overlay: base, base }, + base.getSemanticDiagnostics(base.getSourceFile(app)), + root, + ['x'], + ), + ).toBeUndefined(); + const explicit = programs('preserve', '\n "jsxImportSource": "react",'); + const diagnostics = explicit.overlay.getSemanticDiagnostics( + explicit.overlay.getSourceFile(join(explicit.root, 'src', 'App.tsx')), + ); + // With an import source the namespace comes from the runtime module: another cause entirely. + expect( + jsxNamespaceCause( + { overlay: explicit.overlay, base: explicit.base }, + diagnostics, + explicit.root, + ['x'], + ), + ).toBeUndefined(); + // Nothing that says the namespace is missing: nothing to anchor. + expect( + jsxNamespaceCause({ overlay, base }, [{ code: 2322 } as ts.Diagnostic], root, ['x']), + ).toBeUndefined(); + }); +}); diff --git a/packages/core/src/adapters/typescript/config-cause.ts b/packages/core/src/adapters/typescript/config-cause.ts new file mode 100644 index 0000000..0de2caa --- /dev/null +++ b/packages/core/src/adapters/typescript/config-cause.ts @@ -0,0 +1,93 @@ +import { readFileSync } from 'node:fs'; +import { dirname, isAbsolute, join, relative, resolve } from 'node:path'; +import { ts } from 'ts-morph'; +import type { DiagnosticCause } from '../../domain/usage.js'; + +/** + * A root cause that is a compiler option, not a declaration. `@types/react` 19 no longer + * declares the global `JSX` namespace: 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, in the tsconfig. Those diagnostics are + * anchored there, as evidence of the one change; a `JSX.Element` written in code is still a + * site of its own. + */ + +/** JSX element types came from the global namespace, and it is gone. */ +const JSX_NAMESPACE_CODES = new Set([7026, 2602]); + +interface Programs { + overlay: ts.Program; + base: ts.Program; +} + +/** The nearest config in the `extends` chain that sets `compilerOptions.jsx`, and the line of that setting. */ +function jsxSettingLocation(configFile: string): { file: string; line: number } | undefined { + const seen = new Set(); + for (let file = configFile; !seen.has(file); ) { + seen.add(file); + let text: string; + try { + text = readFileSync(file, 'utf8'); + } catch { + return undefined; + } + const parsed = ts.parseConfigFileTextToJson(file, text).config as + | { compilerOptions?: { jsx?: unknown }; extends?: string | string[] } + | undefined; + if (parsed?.compilerOptions?.jsx !== undefined) { + const at = text.search(/"jsx"\s*:/); + const line = at < 0 ? 1 : text.slice(0, at).split('\n').length; + return { file, line }; + } + const base = Array.isArray(parsed?.extends) ? parsed?.extends[0] : parsed?.extends; + if (typeof base !== 'string' || base.startsWith('@') || !base.startsWith('.')) { + // A package's config (`@tsconfig/node20`) or none: the option is not set in this repository. + return undefined; + } + const next = isAbsolute(base) ? base : resolve(dirname(file), base); + file = next.endsWith('.json') ? next : join(next, 'tsconfig.json'); + } + return undefined; +} + +function hasGlobalJsx(program: ts.Program): boolean { + const checker = program.getTypeChecker(); + return checker.resolveName('JSX', undefined, ts.SymbolFlags.Namespace, false) !== undefined; +} + +/** + * The tsconfig to blame when the target removed the global `JSX` namespace the workspace's + * `jsx` setting reads, with a predicate for the diagnostics it explains; undefined when the + * namespace is still there, or no diagnostic says it is missing. + */ +export function jsxNamespaceCause( + programs: Programs, + fresh: readonly ts.Diagnostic[], + repoDir: string, + targets: readonly string[], +): { cause: DiagnosticCause; explains: (d: ts.Diagnostic) => boolean } | undefined { + if (!fresh.some((d) => JSX_NAMESPACE_CODES.has(d.code))) return undefined; + const options = programs.overlay.getCompilerOptions(); + const jsx = options.jsx; + // react-jsx reads the namespace from the runtime module, not the global: another cause. + if (jsx === undefined || jsx === ts.JsxEmit.ReactJSX || jsx === ts.JsxEmit.ReactJSXDev) + return undefined; + if (options.jsxImportSource !== undefined) return undefined; + if (hasGlobalJsx(programs.overlay) || !hasGlobalJsx(programs.base)) return undefined; + const configFile = options.configFilePath as string | undefined; + const location = configFile ? jsxSettingLocation(configFile) : undefined; + const file = location?.file ?? configFile; + if (!file) return undefined; + const setting = ts.JsxEmit[jsx]?.toLowerCase() ?? String(jsx); + const who = targets.find((t) => t.startsWith('@types/')) ?? targets[0] ?? 'the target'; + return { + cause: { + name: 'jsx', + file: relative(repoDir, file).split('\\').join('/'), + line: location?.line ?? 1, + reason: `"jsx": "${setting}" reads JSX element types from the global JSX namespace, which ${who} no longer declares; "jsxImportSource": "react" (or "jsx": "react-jsx") makes the compiler read them from react/jsx-runtime`, + config: true, + }, + explains: (d) => JSX_NAMESPACE_CODES.has(d.code), + }; +} diff --git a/packages/core/src/adapters/typescript/legacy-options.test.ts b/packages/core/src/adapters/typescript/legacy-options.test.ts new file mode 100644 index 0000000..e2e5c2e --- /dev/null +++ b/packages/core/src/adapters/typescript/legacy-options.test.ts @@ -0,0 +1,69 @@ +import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { ts } from 'ts-morph'; +import { describe, expect, it } from 'vitest'; +import { + automaticTypes, + repositoryTypescriptMajor, + typescriptFiveDefaults, +} from './legacy-options.js'; + +describe("TypeScript 5's defaults under the bundled TypeScript 6", () => { + it('fills what the tsconfig leaves unset the way TypeScript 5 did', () => { + const d = typescriptFiveDefaults({}); + expect(d).toMatchObject({ + target: ts.ScriptTarget.ES5, + module: ts.ModuleKind.CommonJS, + moduleResolution: ts.ModuleResolutionKind.Node10, + esModuleInterop: false, + allowSyntheticDefaultImports: false, + strict: false, + ignoreDeprecations: '6.0', + }); + expect(d.types).toEqual([]); + }); + + it('keeps what is set, and derives the rest from it as TypeScript 5 did', () => { + const esm = typescriptFiveDefaults({ target: ts.ScriptTarget.ES2022, strict: true }); + expect(esm.target).toBeUndefined(); + expect(esm.strict).toBeUndefined(); + expect(esm.module).toBe(ts.ModuleKind.ES2015); + expect(esm.moduleResolution).toBe(ts.ModuleResolutionKind.Classic); + const node = typescriptFiveDefaults({ module: ts.ModuleKind.NodeNext }); + expect(node).toMatchObject({ + target: ts.ScriptTarget.ESNext, + moduleResolution: ts.ModuleResolutionKind.NodeNext, + esModuleInterop: true, + allowSyntheticDefaultImports: true, + }); + const bundler = typescriptFiveDefaults({ + module: ts.ModuleKind.ESNext, + moduleResolution: ts.ModuleResolutionKind.Bundler, + }); + expect(bundler.allowSyntheticDefaultImports).toBe(true); + expect(bundler.esModuleInterop).toBe(false); + }); + + it('includes every @types package of the type roots when `types` is unset', () => { + const root = mkdtempSync(join(tmpdir(), 'uptide-legacy-')); + const types = join(root, 'node_modules', '@types'); + for (const name of ['jest', 'react', 'scope__name']) + mkdirSync(join(types, name), { recursive: true }); + mkdirSync(join(types, 'not-needed'), { recursive: true }); + writeFileSync(join(types, 'not-needed', 'package.json'), '{"typings": null}'); + mkdirSync(join(types, '.hidden'), { recursive: true }); + const nested = join(root, 'packages', 'app'); + mkdirSync(nested, { recursive: true }); + writeFileSync(join(nested, 'tsconfig.json'), '{}'); + const options = { configFilePath: join(nested, 'tsconfig.json') } as ts.CompilerOptions; + expect(automaticTypes(options)).toEqual(['jest', 'react', 'scope__name']); + expect(typescriptFiveDefaults(options).types).toEqual(['jest', 'react', 'scope__name']); + expect(typescriptFiveDefaults({ ...options, types: ['node'] }).types).toBeUndefined(); + // The repository's compiler, found from the workspace up. + expect(repositoryTypescriptMajor(nested)).toBeUndefined(); + mkdirSync(join(root, 'node_modules', 'typescript'), { recursive: true }); + writeFileSync(join(root, 'node_modules', 'typescript', 'package.json'), '{"version":"5.8.3"}'); + expect(repositoryTypescriptMajor(nested)).toBe(5); + }); +}); diff --git a/packages/core/src/adapters/typescript/legacy-options.ts b/packages/core/src/adapters/typescript/legacy-options.ts new file mode 100644 index 0000000..81c6443 --- /dev/null +++ b/packages/core/src/adapters/typescript/legacy-options.ts @@ -0,0 +1,104 @@ +import { existsSync, readdirSync, readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { ts } from 'ts-morph'; + +/** + * The compiler bundled here 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 is + * type-checked by its own compiler with the old defaults, and a check that silently used the + * new ones would report errors (a strict-only null check, a missing test global) that are + * nobody's. The defaults the repository's own compiler would use are made explicit here, so + * the same tsconfig reads the same under both. A repository on TypeScript 6 is left as it is. + */ + +/** The major of the TypeScript the repository installs, found from the workspace up. */ +export function repositoryTypescriptMajor(from: string): number | undefined { + for (let dir = from; ; dir = dirname(dir)) { + const manifest = join(dir, 'node_modules', 'typescript', 'package.json'); + if (existsSync(manifest)) { + try { + const version = (JSON.parse(readFileSync(manifest, 'utf8')) as { version?: string }) + .version; + const major = version ? Number.parseInt(version, 10) : Number.NaN; + return Number.isNaN(major) ? undefined : major; + } catch { + return undefined; + } + } + if (dirname(dir) === dir) return undefined; + } +} + +/** + * Every `@types/*` package in the type roots, as TypeScript 5 included them when `types` is + * unset: each directory of `node_modules/@types` from the config's directory up, unless its + * package.json says `"typings": null`. + */ +export function automaticTypes(options: ts.CompilerOptions): string[] { + const roots = ts.getEffectiveTypeRoots(options, ts.sys) ?? []; + const names: string[] = []; + for (const root of roots) { + if (!ts.sys.directoryExists(root)) continue; + for (const entry of readdirSync(root, { withFileTypes: true })) { + if (!entry.isDirectory() || entry.name.startsWith('.')) continue; + const manifest = join(root, entry.name, 'package.json'); + try { + if (JSON.parse(readFileSync(manifest, 'utf8')).typings === null) continue; + } catch { + // No package.json, or an unreadable one: TypeScript 5 included the directory all the same. + } + if (!names.includes(entry.name)) names.push(entry.name); + } + } + return names; +} + +const { ES5, ES2015, ES2022, ES2023, ESNext } = ts.ScriptTarget; +const { CommonJS, Node16, Node18, Node20, NodeNext, Preserve, System } = ts.ModuleKind; + +/** + * TypeScript 5's values for the options `declared` leaves unset, as its `_computedOptions` + * derive them: target ES5 (ES2022/ES2023/ESNext under the node modules), module from the + * target, resolution from the module, interop only under the node modules and `preserve`. + */ +export function typescriptFiveDefaults(declared: ts.CompilerOptions): ts.CompilerOptions { + const out: ts.CompilerOptions = {}; + const module = declared.module; + const target = + declared.target ?? + (module === Node16 || module === Node18 + ? ES2022 + : module === Node20 + ? ES2023 + : module === NodeNext + ? ESNext + : ES5); + if (declared.target === undefined) out.target = target; + const moduleKind = module ?? (target >= ES2015 ? ts.ModuleKind.ES2015 : CommonJS); + if (module === undefined) out.module = moduleKind; + if (declared.moduleResolution === undefined) { + out.moduleResolution = + moduleKind === CommonJS + ? ts.ModuleResolutionKind.Node10 + : moduleKind === Node16 + ? ts.ModuleResolutionKind.Node16 + : moduleKind === Node18 || moduleKind === Node20 || moduleKind === NodeNext + ? ts.ModuleResolutionKind.NodeNext + : moduleKind === Preserve + ? ts.ModuleResolutionKind.Bundler + : ts.ModuleResolutionKind.Classic; + } + const resolution = declared.moduleResolution ?? out.moduleResolution; + const nodeish = [Node16, Node18, Node20, NodeNext, Preserve].includes(moduleKind); + const interop = declared.esModuleInterop ?? nodeish; + if (declared.esModuleInterop === undefined) out.esModuleInterop = interop; + if (declared.allowSyntheticDefaultImports === undefined) + out.allowSyntheticDefaultImports = + interop || moduleKind === System || resolution === ts.ModuleResolutionKind.Bundler; + if (declared.strict === undefined) out.strict = false; + if (declared.types === undefined) out.types = automaticTypes(declared); + // The old defaults are deprecated under the new compiler, not wrong: no diagnostic for them. + out.ignoreDeprecations = '6.0'; + return out; +} diff --git a/packages/core/src/adapters/typescript/repo.ts b/packages/core/src/adapters/typescript/repo.ts index 679c54f..6816e06 100644 --- a/packages/core/src/adapters/typescript/repo.ts +++ b/packages/core/src/adapters/typescript/repo.ts @@ -4,6 +4,7 @@ import { Project, type ResolutionHostFactory, ts } from 'ts-morph'; import type { RepoDir } from '../../domain/adapter.js'; import { UptideError } from '../../errors.js'; import { onReset } from '../../shared-state.js'; +import { repositoryTypescriptMajor, typescriptFiveDefaults } from './legacy-options.js'; import { type Lockfile, readLockfile } from './lockfile.js'; import { declaredPaths, workspaceSourceMap } from './workspace-source.js'; @@ -115,7 +116,17 @@ export function readInstalled( }), ); const lockfile = readLockfile(dir, declared); - return { dir, packageJson, lockfile, installed: new Map(lockfile?.installed ?? []) }; + const installed = new Map(lockfile?.installed ?? []); + // A workspace package is declared as `workspace:*`, `link:` or `file:`; what the lockfile + // records for it differs by manager (pnpm `link:../x`, Yarn `0.0.0-use.local`, npm nothing). + // The declared specifier says what it is, and that is what the rest of the engine reads. + for (const [name, spec] of declared) + if ( + /^(workspace|link|file):/.test(spec) && + !/^(workspace|link|file):/.test(installed.get(name) ?? '') + ) + installed.set(name, spec); + return { dir, packageJson, lockfile, installed }; } /** @@ -162,12 +173,18 @@ export function loadRepo(cwd: string, rootFiles?: string[]): LoadedRepo { let project: Project; if (existsSync(tsconfig)) { const declared = sourcePaths ? declaredPaths(tsconfig) : { paths: {} }; + // A repository on TypeScript 5 is read with TypeScript 5's defaults for what its tsconfig + // leaves unset (legacy-options.ts); one on TypeScript 6 with the compiler's own. + const major = repositoryTypescriptMajor(dir); + const legacy = + major === undefined || major < 6 ? typescriptFiveDefaults(declaredOptions(tsconfig)) : {}; project = new Project({ tsConfigFilePath: tsconfig, skipAddingFilesFromTsConfig: rootFiles !== undefined, skipFileDependencyResolution: true, resolutionHost: formatAwareResolution, compilerOptions: { + ...legacy, noEmit: true, skipLibCheck: true, ...(sourcePaths ? { paths: { ...declared.paths, ...sourcePaths } } : {}), @@ -203,7 +220,13 @@ export function loadRepo(cwd: string, rootFiles?: string[]): LoadedRepo { ...SYNTHETIC_EXCLUDES.map((e) => `!${join(dir, e)}`), ]); } - if (rootFiles !== undefined) project.addSourceFilesAtPaths(rootFiles); + if (rootFiles !== undefined) { + project.addSourceFilesAtPaths(rootFiles); + // A scoped program still needs what the tsconfig declares globally: the ambient + // declaration files it includes (`vite-env.d.ts`, `css.d.ts`, `global.d.ts`) are what + // make `*.module.css` imports and `declare global` names resolve. Nothing imports them. + if (existsSync(tsconfig)) project.addSourceFilesAtPaths(ambientDeclarations(tsconfig)); + } project.resolveSourceFileDependencies(); return { dir, @@ -217,6 +240,36 @@ export function loadRepo(cwd: string, rootFiles?: string[]): LoadedRepo { }; } +/** The options a tsconfig sets, through its `extends` chain: what is unset gets a default. */ +function declaredOptions(tsconfig: string): ts.CompilerOptions { + try { + const parsed = ts.readConfigFile(tsconfig, (p) => readFileSync(p, 'utf8')); + if (!parsed.config) return {}; + // The config's path is what the compiler's default type roots are relative to. + return ts.parseJsonConfigFileContent( + parsed.config, + ts.sys, + dirname(tsconfig), + undefined, + tsconfig, + ).options; + } catch { + return {}; + } +} + +/** The `.d.ts` files a tsconfig includes from the repository itself, never from node_modules. */ +function ambientDeclarations(tsconfig: string): string[] { + try { + const parsed = ts.readConfigFile(tsconfig, (p) => readFileSync(p, 'utf8')); + if (!parsed.config) return []; + const { fileNames } = ts.parseJsonConfigFileContent(parsed.config, ts.sys, dirname(tsconfig)); + return fileNames.filter((f) => /\.d\.[cm]?ts$/.test(f) && !f.includes('/node_modules/')); + } catch { + return []; + } +} + function readReferences(tsconfig: string): string[] { try { const parsed = ts.readConfigFile(tsconfig, (p) => readFileSync(p, 'utf8')); diff --git a/packages/core/src/check/anchor.test.ts b/packages/core/src/check/anchor.test.ts new file mode 100644 index 0000000..53c4844 --- /dev/null +++ b/packages/core/src/check/anchor.test.ts @@ -0,0 +1,141 @@ +import { describe, expect, it } from 'vitest'; +import type { Finding } from '../domain/report.js'; +import { planPackage } from '../fix/plan.js'; +import { selectedFindings } from '../fix/select.js'; +import { definePack, ruleFor } from '../packs/contract.js'; +import { sitesOf } from './check.js'; + +/** The JSX namespace cluster as `check` reports it: one anchor at the tsconfig, the errors under it. */ +const anchor: Finding = { + change: { + package: 'react', + from: '18.3.1', + to: '19.2.1', + path: 'cause:jsx', + kind: 'cause', + severity: 'breaking', + source: 'types', + confidence: 1, + evidence: 'checker', + }, + usage: { + file: 'apps/web/tsconfig.json', + line: 15, + column: 1, + endLine: 15, + endColumn: 1, + symbolPath: 'cause:jsx', + access: 'read', + snippet: '', + via: 'inferred', + }, + downstream: [ + { + file: 'apps/web/src/App.tsx', + line: 3, + code: 7026, + message: + "JSX element implicitly has type 'any' because no interface 'JSX.IntrinsicElements' exists.", + }, + { + file: 'apps/web/src/Nav.tsx', + line: 8, + code: 7026, + message: + "JSX element implicitly has type 'any' because no interface 'JSX.IntrinsicElements' exists.", + }, + { + file: 'apps/web/src/Nav.tsx', + line: 9, + code: 2322, + message: "Type 'X' is not assignable to type 'Y'.", + }, + ], + anchorOnly: true, + severity: 'breaking', + confidence: 1, + fixability: 'assisted', + reason: '"jsx": "preserve" reads the global JSX namespace, which @types/react no longer declares', + evidence: 'compiler', +}; +const plain: Finding = { + ...anchor, + anchorOnly: undefined, + change: { ...anchor.change, path: 'cause:parse' }, +}; + +describe('a cause that is a compiler option', () => { + it('counts as one site, is selected and planned at the option, and lists its errors as evidence', () => { + expect(sitesOf(anchor)).toBe(1); + expect(sitesOf(plain)).toBe(3); + const report = { + repo: '', + workspaces: ['.', 'apps/web'], + packages: [ + { + workspace: '.', + name: 'react', + installed: '18.3.1', + latest: '19.2.1', + target: '19.2.1', + majorsBehind: 1, + findings: [anchor], + callSitesChecked: 3, + unanalyzed: [], + status: 'breaking' as const, + notes: [], + timing: { fetchMs: 0, diffMs: 0, usagesMs: 0, compileMs: 0 }, + }, + ], + summary: {} as never, + }; + const selected = selectedFindings(report, 'react', false); + expect(selected.map((f) => `${f.usage.file}:${f.usage.line}`)).toEqual([ + 'apps/web/tsconfig.json:15', + ]); + const [group] = planPackage(report.packages[0] as never); + expect(group).toMatchObject({ severity: 'breaking', sites: 1, fixes: 1 }); + expect(group?.locations).toEqual([{ file: 'apps/web/tsconfig.json', line: 15 }]); + // Without the marker the cluster's sites are the errors themselves, as before. + const expanded = selectedFindings( + { ...report, packages: [{ ...report.packages[0], findings: [plain] }] } as never, + 'react', + false, + ); + expect(expanded).toHaveLength(3); + }); + + it('is claimed by the pack rule that claims most of the errors under it', () => { + const pack = definePack({ + meta: { + package: 'react', + from: '>=18 <19', + to: '>=19 <20', + sources: [{ title: 'guide', url: 'https://example.invalid' }], + maintainer: 'test', + }, + rules: [ + { + id: 'ref-object-nullable', + summary: 'refs', + severity: 'breaking', + kinds: ['type'], + symbols: /^TS2322$/, + guide: 'fix the ref', + }, + { + id: 'global-jsx-namespace', + summary: 'jsx', + severity: 'breaking', + kinds: ['type', 'signature', 'required'], + symbols: /^TS(?:2741|2746|2786|7026)$/, + guide: 'set jsxImportSource', + }, + ], + instructions: 'migrate', + }); + expect(ruleFor(pack.rules, anchor)?.id).toBe('global-jsx-namespace'); + expect(pack.ruleOf?.(anchor)).toBe('global-jsx-namespace'); + expect(ruleFor(pack.rules, { ...anchor, downstream: [] })).toBeUndefined(); + }); +}); diff --git a/packages/core/src/check/check.ts b/packages/core/src/check/check.ts index aeb6de1..a00f31b 100644 --- a/packages/core/src/check/check.ts +++ b/packages/core/src/check/check.ts @@ -54,7 +54,7 @@ import { mapWithLimit, mapWithSerialRetry } from './pool.js'; import { isBehind, rankCandidates } from './rank.js'; import { groupRootCauses } from './root-cause.js'; import { runtimeChangeFindings } from './runtime-changes.js'; -import { confirmGeneric, evidenceOf, tierOf } from './tier.js'; +import { confirmBreaking, evidenceOf, tierOf } from './tier.js'; import { typesPackageOf, typesReleaseFor } from './types-release.js'; import { unattributedFindings } from './unattributed.js'; import { verdictOf } from './verdict.js'; @@ -473,7 +473,7 @@ function importerOf(p: PackageReport): Importer { /** A finding's weight in the counts: an anchor stands for the errors under it, never for itself. */ export function sitesOf(f: Finding): number { - return f.change.kind === 'cause' ? (f.downstream?.length ?? 0) : 1; + return f.change.kind === 'cause' && !f.anchorOnly ? (f.downstream?.length ?? 0) : 1; } /** @@ -595,6 +595,20 @@ export function mergeAcrossWorkspaces( }, }; if (first.runtime) combined.runtime = first.runtime; + // What moves with the package is one plan for the repository, carried by whichever + // workspace reports it; the merged entry keeps every companion and conflict named. + const companions = [ + ...new Map( + list.flatMap((p) => p.companions ?? []).map((c) => [`${c.name}@${c.from}`, c]), + ).values(), + ].sort((a, b) => a.name.localeCompare(b.name) || a.from.localeCompare(b.from)); + const conflicts = [...new Set(list.flatMap((p) => p.companionConflicts ?? []))]; + if (companions.length > 0) combined.companions = companions; + if (conflicts.length > 0) combined.companionConflicts = conflicts; + const members = [ + ...new Map(list.flatMap((p) => p.members ?? []).map((m) => [m.name, m])).values(), + ]; + if (members.length > 1) combined.members = members; combined.importers = list.map(importerOf); const contexts = list.map((p) => p.planContext).filter((c) => c !== undefined); if (contexts.length > 0) { @@ -603,22 +617,41 @@ export function mergeAcrossWorkspaces( evidence: contexts.flatMap((c) => c.evidence ?? []), }; } - if (first.compile) { + const compiled = list.filter((p) => p.compile !== undefined); + const firstCompiled = compiled[0]; + if (firstCompiled?.compile) { + const coverages = compiled.map((p) => p.compile?.coverage).filter((c) => c !== undefined); + const skipped = new Map(); + for (const c of coverages) + for (const r of c.skipped) skipped.set(r.reason, (skipped.get(r.reason) ?? 0) + r.count); combined.compile = { - ...first.compile, + ...firstCompiled.compile, + // Skipped in one workspace, compiled in another: the coverage says how much of each. + ...(compiled.some((p) => p.compile?.skipped === undefined) ? { skipped: undefined } : {}), baselineErrors: sum((p) => p.compile?.baselineErrors ?? 0), + ...(coverages.length > 0 + ? { + coverage: { + compiled: coverages.reduce((n, c) => n + c.compiled, 0), + total: coverages.reduce((n, c) => n + c.total, 0), + workspaces: coverages.reduce((n, c) => n + c.workspaces, 0), + skipped: [...skipped].map(([reason, count]) => ({ reason, count })), + }, + } + : {}), unresolvedInTarget: [...new Set(list.flatMap((p) => p.compile?.unresolvedInTarget ?? []))], unresolvedFiles: [...new Set(list.flatMap((p) => p.compile?.unresolvedFiles ?? []))], unattributed: list.flatMap((p) => (p.compile?.unattributed ?? []).map((d) => ({ ...d, file: prefixed(p, d.file) })), ), - ...(list.every((p) => p.compile?.newErrors !== undefined) + ...(compiled.every((p) => p.compile?.newErrors !== undefined) ? { newErrors: sum((p) => p.compile?.newErrors ?? 0) } : {}), }; + if (combined.compile.skipped === undefined) delete combined.compile.skipped; } // One verdict for the merged entry: its findings and compile summary are the union. - if (first.verdict) + if (list.some((p) => p.verdict)) combined.verdict = verdictOf( combined, list.find((p) => p.verdict?.notVerified)?.verdict?.notVerified, @@ -1463,9 +1496,11 @@ async function checkGroup( // Led by the package asked for, when its companions are in the group: it names the report, // and its versions are the upgrade's. const plans = ctx.opts.companions ?? {}; - const leader = prepared.find((p) => plans[p.name] !== undefined && prepared.length > 1); + // A package asked for by name carries its plan even where it is analyzed alone: what moves + // with it is one decision for the repository, whichever workspace the report comes from. + const leader = prepared.find((p) => plans[p.name] !== undefined); const plan = leader ? plans[leader.name] : undefined; - const name = leader?.name ?? groupName(prepared.map((p) => p.name)); + const name = leader && prepared.length > 1 ? leader.name : groupName(prepared.map((p) => p.name)); const notes = prepared.flatMap((p) => prepared.length > 1 ? p.notes.map((n) => `${p.name}: ${n}`) : p.notes, ); @@ -1488,7 +1523,8 @@ async function checkGroup( installed: (leader?.installedVersion ?? installed[0]) as string, latest: (leader?.latest ?? (prepared.length === 1 ? prepared[0]?.latest : targets.at(-1))) as string, - target: (leader?.target ?? targets.at(-1)) as string, + target: (leader?.target ?? + (prepared.length === 1 ? prepared[0]?.target : targets.at(-1))) as string, ...(plan?.companions.length ? { companions: plan.companions } : {}), ...(plan?.conflicts.length ? { companionConflicts: plan.conflicts } : {}), majorsBehind: Math.max(...prepared.map((p) => majorsBehind(p.installedVersion, p.target))), @@ -1660,6 +1696,7 @@ async function checkGroup( unresolvedFiles: signal.unresolvedFiles, unattributed: merged.unattributed, newErrors: signal.diagnostics.length, + coverage: { ...signal.coverage, workspaces: 1 }, }; if (signal.skipped) notes.push(signal.skipped); else if (signal.baselineErrors > 0) @@ -1936,7 +1973,8 @@ async function checkGroup( // A pack that cannot read its versions adds nothing; the diff findings stand. } } - // Without a pack, only what the compiler or the runtime probe confirms is called breaking. + // Breaking means confirmed, in every tier: a type-surface change the compiler, the runtime + // probe or the pack did not confirm at the site is possible impact, never counted as breaking. const tier = tierOf( packsOf(ctx.opts), name, @@ -1947,13 +1985,7 @@ async function checkGroup( const p = runtimeOf(f); return evidenceOf(f, p?.runtime, p ? loadRootOf(p) : undefined); }; - const confirmed = - tier === 'generic' - ? confirmGeneric(findings, evidence) - : findings.map((f) => { - const found = f.severity === 'breaking' ? evidence(f) : undefined; - return found ? { ...f, evidence: found } : f; - }); + const confirmed = confirmBreaking(findings, evidence); findings.length = 0; findings.push(...confirmed); // An import the target no longer has: what it exports instead is what a person, or the diff --git a/packages/core/src/check/companions.ts b/packages/core/src/check/companions.ts index 82a07fe..4416811 100644 --- a/packages/core/src/check/companions.ts +++ b/packages/core/src/check/companions.ts @@ -2,7 +2,7 @@ import { satisfies } from '../fetch/range.js'; import type { Manifest } from '../list/evidence.js'; import { dependencyGroups } from '../list/groups.js'; import type { ListedDependency } from '../list/list.js'; -import { typedPackageOf, typesPackageOf, typesReleaseFor } from './types-release.js'; +import { typedPackageOf, typesReleaseFor } from './types-release.js'; import { compareVersions, parseVersion } from './version.js'; /** diff --git a/packages/core/src/check/match.test.ts b/packages/core/src/check/match.test.ts index 7d10ff9..b6289cb 100644 --- a/packages/core/src/check/match.test.ts +++ b/packages/core/src/check/match.test.ts @@ -405,6 +405,7 @@ describe('span-level confirmation', () => { }, ], baselineErrors: 0, + coverage: { compiled: 1, total: 1, skipped: [] }, unresolvedInTarget: [], unresolvedFiles: [], linkedDependencies: [], diff --git a/packages/core/src/check/merge.test.ts b/packages/core/src/check/merge.test.ts index 2996520..d8bb8f2 100644 --- a/packages/core/src/check/merge.test.ts +++ b/packages/core/src/check/merge.test.ts @@ -29,6 +29,7 @@ const usage: Usage = { const signal = (diagnostics: CompileSignal['diagnostics']): CompileSignal => ({ diagnostics, baselineErrors: 0, + coverage: { compiled: 1, total: 1, skipped: [] }, unresolvedInTarget: [], unresolvedFiles: [], linkedDependencies: [], diff --git a/packages/core/src/check/merge.ts b/packages/core/src/check/merge.ts index 025f952..4d474db 100644 --- a/packages/core/src/check/merge.ts +++ b/packages/core/src/check/merge.ts @@ -81,6 +81,11 @@ export function mergeSignals( }; const explained = (u: Usage): boolean => usagePaths(u).some((p) => changedPaths.has(p)); for (const d of signal.diagnostics) { + // A diagnostic a compiler option explains belongs to that cause, whatever usage it touches. + if (d.cause?.config) { + unattributed.push(d); + continue; + } const hit = innermost(merged.filter((u) => contains(u, d) && u.access !== 'import')); if (hit) { attach(hit, d); diff --git a/packages/core/src/check/tier.test.ts b/packages/core/src/check/tier.test.ts index 4d90ee0..717f3e5 100644 --- a/packages/core/src/check/tier.test.ts +++ b/packages/core/src/check/tier.test.ts @@ -3,7 +3,7 @@ import type { Finding, RuntimeReport } from '../domain/report.js'; import { stripePack } from '../packs/stripe/index.js'; import { zodPack } from '../packs/zod/index.js'; import { isBehind, rankCandidates } from './rank.js'; -import { confirmGeneric, evidenceOf, tierOf } from './tier.js'; +import { confirmBreaking, evidenceOf, tierOf } from './tier.js'; const finding = ( change: Partial, @@ -74,7 +74,7 @@ describe('evidence for a breaking finding', () => { const confirmed = finding({}, { compileError: 'Expected 2 arguments, but got 1.' }); const bare = finding({}); const deprecated = { ...finding({ kind: 'deprecated' }), severity: 'deprecated' } as Finding; - const out = confirmGeneric([confirmed, bare, deprecated], (f) => evidenceOf(f)); + const out = confirmBreaking([confirmed, bare, deprecated], (f) => evidenceOf(f)); expect(out.map((f) => [f.severity, f.evidence])).toEqual([ ['breaking', 'compiler'], ['unverified', undefined], diff --git a/packages/core/src/check/tier.ts b/packages/core/src/check/tier.ts index 699d3d9..db46fba 100644 --- a/packages/core/src/check/tier.ts +++ b/packages/core/src/check/tier.ts @@ -4,7 +4,7 @@ import { runtimeConfirms } from './file-kind.js'; /** The difference, in one line, wherever a tier is shown. */ export const TIER_LEGEND = - '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'; /** * `verified`: a migration pack covers this upgrade (rules, a guide, ground truth it is scored @@ -56,11 +56,12 @@ export function evidenceOf( } /** - * The generic tier's rule: a finding is breaking only with evidence. The rest is kept, as - * `unverified`, for `--details`: the declarations changed at a place the code uses, and - * nothing confirmed that the code breaks. The verified tier is never passed through here. + * Breaking means confirmed, in every tier: a finding is breaking only with evidence. The rest + * is kept as `unverified`, the "possible impact" a report lists apart: the declarations + * changed at a place the code uses, and nothing confirmed that the code breaks there. A pack's + * own findings carry their evidence (`pack`); its rules claim compiler-confirmed sites as before. */ -export function confirmGeneric( +export function confirmBreaking( findings: Finding[], evidence: (f: Finding) => Finding['evidence'], ): Finding[] { diff --git a/packages/core/src/check/types-release.test.ts b/packages/core/src/check/types-release.test.ts new file mode 100644 index 0000000..5ccb161 --- /dev/null +++ b/packages/core/src/check/types-release.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from 'vitest'; +import { typedPackageOf, typesPackageOf, typesReleaseFor } from './types-release.js'; + +const published = ['18.2.0', '18.3.31', '19.0.0', '19.0.14', '19.1.0-rc.1', '19.2.7', '19.3.0']; + +describe('the @types release that types a runtime version', () => { + it('prefers the newest release at the target major.minor, then the major, then the newest', () => { + expect(typesReleaseFor(published, '19.0.0', '18.2.0')).toBe('19.0.14'); + expect(typesReleaseFor(published, '19.2.1', '18.3.31')).toBe('19.2.7'); + // No release for the minor: the major's newest. + expect(typesReleaseFor(published, '19.1.0', '18.2.0')).toBe('19.3.0'); + // No release for the major (a 0.x package typed by @types 1.x): the newest there is. + expect(typesReleaseFor(['1.0.0', '1.1.0'], '0.7.0', '1.0.0')).toBe('1.1.0'); + }); + + it('never goes below what is installed, and skips prereleases', () => { + expect(typesReleaseFor(published, '18.3.1', '19.0.0')).toBe('19.3.0'); + expect(typesReleaseFor(['19.1.0-rc.1'], '19.1.0')).toBeUndefined(); + expect(typesReleaseFor([], '19.0.0')).toBeUndefined(); + }); + + it('names the types package of a runtime package and back', () => { + expect(typesPackageOf('react')).toBe('@types/react'); + expect(typesPackageOf('@scope/name')).toBe('@types/scope__name'); + expect(typedPackageOf('@types/scope__name')).toBe('@scope/name'); + expect(typedPackageOf('react')).toBeUndefined(); + }); +}); diff --git a/packages/core/src/check/unattributed.test.ts b/packages/core/src/check/unattributed.test.ts index efa510d..2f7a3b8 100644 --- a/packages/core/src/check/unattributed.test.ts +++ b/packages/core/src/check/unattributed.test.ts @@ -117,3 +117,56 @@ describe('root-cause clusters', () => { expect(out[1]?.change.path).toBe('TS2339'); }); }); + +describe('a compiler option as the cause', () => { + it('anchors the diagnostics at the option, as one site with the errors as evidence', () => { + const cause = { + name: 'jsx', + file: 'tsconfig.json', + line: 4, + reason: + '"jsx": "preserve" reads the global JSX namespace, which @types/react no longer declares', + config: true as const, + }; + const findings = unattributedFindings( + [ + diag({ + file: 'src/App.tsx', + line: 3, + code: 7026, + message: 'JSX element implicitly has type any', + cause, + }), + diag({ + file: 'src/Nav.tsx', + line: 8, + code: 7026, + message: 'JSX element implicitly has type any', + cause, + }), + diag({}), + ], + meta, + surface, + [], + 'apps/web/', + ); + expect(findings).toHaveLength(2); + const [anchor] = findings; + expect(anchor).toMatchObject({ + anchorOnly: true, + severity: 'breaking', + fixability: 'assisted', + reason: cause.reason, + change: { kind: 'cause', path: 'cause:jsx' }, + usage: { file: 'tsconfig.json', line: 4 }, + }); + expect(anchor?.downstream?.map((d) => d.file)).toEqual([ + 'apps/web/src/App.tsx', + 'apps/web/src/Nav.tsx', + ]); + expect(anchor?.change.notes).toBe( + `2 errors caused by \`jsx\` in apps/web/tsconfig.json:4: ${cause.reason}. One edit there resolves them.`, + ); + }); +}); diff --git a/packages/core/src/check/unattributed.ts b/packages/core/src/check/unattributed.ts index d8c4f17..1f56fe8 100644 --- a/packages/core/src/check/unattributed.ts +++ b/packages/core/src/check/unattributed.ts @@ -28,6 +28,8 @@ function quotedNames(message: string): string[] { /** `${n} errors caused by \`name\` (file:line), . Fix here first.`: the text the report prints for a cluster. */ export function describeCluster(count: number, cause: DiagnosticCause): string { + if (cause.config) + return `${count} error${count === 1 ? '' : 's'} caused by \`${cause.name}\` in ${cause.file}:${cause.line}: ${cause.reason}. One edit there resolves them.`; return `${count} error${count === 1 ? '' : 's'} caused by \`${cause.name}\` (${cause.file}:${cause.line}), ${cause.reason}. Fix here first.`; } @@ -84,10 +86,12 @@ export function unattributedFindings( code: d.code, message: d.message.split('\n')[0] ?? d.message, })), + // A compiler option is fixed once, where it is set: the errors under it are evidence. + ...(cause.config ? { anchorOnly: true as const } : {}), severity: 'breaking', confidence: 1, - fixability: 'unknown', - reason: UNATTRIBUTED_REASON, + fixability: cause.config ? 'assisted' : 'unknown', + reason: cause.config ? cause.reason : UNATTRIBUTED_REASON, }; }); return [...clustered, ...plainFindings(single, meta, surfaceB, unresolvedFiles)]; diff --git a/packages/core/src/check/verdict.test.ts b/packages/core/src/check/verdict.test.ts index 8a380ab..7ec94d6 100644 --- a/packages/core/src/check/verdict.test.ts +++ b/packages/core/src/check/verdict.test.ts @@ -1,6 +1,6 @@ import { expect, it } from 'vitest'; import type { PackageReport } from '../domain/report.js'; -import { verdictOf } from './verdict.js'; +import { coverageLine, verdictOf } from './verdict.js'; const report = (over: Partial): PackageReport => ({ @@ -46,3 +46,31 @@ it('says why the types were not verified, and keeps a stored report without a co // Stored before newErrors was kept: it was compiled, the count is unknown, none is invented. expect(verdictOf(report({ compile })).summary).toBe('0 breaking · compiled against 4.6.5'); }); + +it('says the types are partly verified when some files that use the package were not compiled', () => { + const coverage = { + compiled: 12, + total: 40, + workspaces: 2, + skipped: [ + { reason: 'not in the workspace tsconfig', count: 20 }, + { reason: 'most files cannot resolve their imports at the installed version', count: 8 }, + ], + }; + expect(coverageLine(coverage)).toBe( + 'compiled 12 of 40 files in 2 workspaces; skipped: not in the workspace tsconfig (20), most files cannot resolve their imports at the installed version (8)', + ); + expect(verdictOf(report({ compile: { ...compile, newErrors: 0, coverage } }))).toEqual({ + breaking: 0, + compiledAgainst: '4.6.5', + newErrors: 0, + partlyVerified: coverageLine(coverage), + summary: `0 breaking · types partly verified: ${coverageLine(coverage)}`, + }); + // Every file compiled: the clean verdict, with the coverage printed beside it. + const full = { compiled: 1, total: 1, workspaces: 1, skipped: [] }; + expect(coverageLine(full)).toBe('compiled 1 of 1 file in 1 workspace'); + expect(verdictOf(report({ compile: { ...compile, newErrors: 0, coverage: full } })).summary).toBe( + '0 breaking · compiled against 4.6.5: 0 new type errors', + ); +}); diff --git a/packages/core/src/check/verdict.ts b/packages/core/src/check/verdict.ts index 9a1d5bb..d6ee7db 100644 --- a/packages/core/src/check/verdict.ts +++ b/packages/core/src/check/verdict.ts @@ -10,12 +10,24 @@ export interface Verdict { newErrors?: number; /** Why the types were not verified, when they were not. */ notVerified?: string; + /** Set when some, not all, of the files were compiled: the coverage line. */ + partlyVerified?: string; /** `0 breaking · compiled against 4.6.5: 0 new type errors`, as terminal and HTML print it. */ summary: string; } const count = (n: number, noun: string): string => `${n} ${noun}${n === 1 ? '' : 's'}`; +/** + * `compiled 12 of 40 files in 2 workspaces; skipped: not in the workspace tsconfig (28)`: how + * much of the code that uses the package the compiler judged, printed under every check. + */ +export function coverageLine(c: NonNullable['coverage']): string { + if (!c) return ''; + const skipped = c.skipped.map((r) => `${r.reason} (${r.count})`).join(', '); + return `compiled ${c.compiled} of ${count(c.total, 'file')} in ${count(c.workspaces, 'workspace')}${skipped ? `; skipped: ${skipped}` : ''}`; +} + /** * Always says the breaking count and what verified it, zero included: an empty result is * only worth as much as the check behind it. `notCompiled` is why the compiler did not run, @@ -23,9 +35,22 @@ const count = (n: number, noun: string): string => `${n} ${noun}${n === 1 ? '' : */ export function verdictOf(p: PackageReport, notCompiled?: string): Verdict { const breaking = p.findings.filter((f) => f.severity === 'breaking').length; + const coverage = p.compile?.coverage; if (p.compile !== undefined && p.compile.skipped === undefined) { // Reports stored before the count was kept say what was compiled, without a number. const newErrors = p.compile.newErrors; + // Some of the files that use the package were not compiled: the verdict says so, instead + // of a clean result that only covers part of the code. + if (coverage && coverage.compiled < coverage.total) { + const partly = coverageLine(coverage); + return { + breaking, + compiledAgainst: p.target, + ...(newErrors !== undefined ? { newErrors } : {}), + partlyVerified: partly, + summary: `${breaking} breaking · types partly verified: ${partly}`, + }; + } return { breaking, compiledAgainst: p.target, diff --git a/packages/core/src/domain/report.ts b/packages/core/src/domain/report.ts index b86b1dd..a65d7e9 100644 --- a/packages/core/src/domain/report.ts +++ b/packages/core/src/domain/report.ts @@ -1,7 +1,7 @@ import type { ErrorCode } from '../errors.js'; import type { Change, Severity } from './change.js'; import type { RuntimeChange, RuntimeLoad } from './runtime.js'; -import type { CompileDiagnostic, Unanalyzed, Usage } from './usage.js'; +import type { CompileCoverage, CompileDiagnostic, Unanalyzed, Usage } from './usage.js'; /** `none`: an additive finding, nothing to change; it counts in callSitesChecked only. */ export type Fixability = 'mechanical' | 'assisted' | 'manual' | 'unknown' | 'none'; @@ -25,6 +25,11 @@ export interface Finding { usage: Usage; /** For a `cause` anchor: the errors it explains. The anchor itself is not a call site; these are. */ downstream?: DownstreamSite[]; + /** + * For a `cause` anchor that is a compiler option (`"jsx"` in a tsconfig): the anchor is the + * one site to fix, and `downstream` is the evidence; it counts as one site, not as many. + */ + anchorOnly?: true; /** * For a `module-format` finding: every `require()` site of the package in this file, the * finding's own first. Switching a file to `import()` is one unit of work however many @@ -161,6 +166,11 @@ export interface PackageReport { unattributed: CompileDiagnostic[]; /** New type errors at the target: every error the upgrade causes, explained or not. */ newErrors?: number; + /** + * How many of the files that use the package were type-checked against the target, over + * how many workspaces, and why the rest were not: `compiled 12 of 40 files in 2 workspaces`. + */ + coverage?: CompileCoverage & { workspaces: number }; }; /** * The breaking count and what verified it, for every analyzed package, zero included: @@ -171,6 +181,8 @@ export interface PackageReport { compiledAgainst?: string; newErrors?: number; notVerified?: string; + /** Set when some, not all, of the files were compiled: `compiled 12 of 40 files in 2 workspaces; skipped: ...`. */ + partlyVerified?: string; summary: string; }; /** Signal C: what loading installed and target in a child Node showed, one entry per member. */ diff --git a/packages/core/src/domain/usage.ts b/packages/core/src/domain/usage.ts index 3e62387..5118df3 100644 --- a/packages/core/src/domain/usage.ts +++ b/packages/core/src/domain/usage.ts @@ -118,6 +118,21 @@ export interface DiagnosticCause { line: number; /** Why it is to blame, as a clause: "whose type changed from `A` to `B`", "which itself fails to compile against the target", "imported from `x`, which is typed `any` against the target". */ reason: string; + /** + * A compiler option, not a declaration (`"jsx": "preserve"` in a tsconfig): the one edit that + * resolves the cluster is there, and the diagnostics under it are evidence, not sites to fix. + */ + config?: true; +} + +/** The files Signal B was asked to judge and what became of them: the denominator of a verdict. */ +export interface CompileCoverage { + /** Files type-checked against the target. */ + compiled: number; + /** Files that use the package (and the files importing them) the signal was asked about. */ + total: number; + /** Why the rest were not compiled, one entry per reason. */ + skipped: { reason: string; count: number }[]; } /** Signal B: the repo type-checked against the target version. */ @@ -127,6 +142,8 @@ export interface CompileSignal { baselineErrors: number; /** Why the overlay was not compiled: only a structurally broken baseline (invalid tsconfig, most files unresolvable). */ skipped?: string; + /** How many of the files asked about were type-checked, and why the others were not. */ + coverage: CompileCoverage; /** Bare imports inside the target package's own files that resolved neither there nor in the consumer's node_modules. */ unresolvedInTarget: string[]; /** Target declaration files (relative to the target) with an unresolved import; the compiler cannot vouch for symbols declared there. */ diff --git a/packages/core/src/fix/select.ts b/packages/core/src/fix/select.ts index f9b830f..dcaffbc 100644 --- a/packages/core/src/fix/select.ts +++ b/packages/core/src/fix/select.ts @@ -20,7 +20,8 @@ export function selectedFindings( ) ) continue; - if (finding.change.kind === 'cause') { + // A cluster's sites are its downstream errors, unless the anchor is the one site to edit. + if (finding.change.kind === 'cause' && !finding.anchorOnly) { for (const d of finding.downstream ?? []) selected.push({ ...finding, diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 5676c4e..05bfc0d 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -24,7 +24,7 @@ export { type TruthCase, type TruthScore, } from './check/truth.js'; -export { type Verdict, verdictOf } from './check/verdict.js'; +export { coverageLine, type Verdict, verdictOf } from './check/verdict.js'; export { compareVersions, majorsBehind, parseVersion } from './check/version.js'; export { classify, type UnclassifiedChange } from './diff/classify.js'; export { diffSurfaces, rawDiff } from './diff/diff.js'; diff --git a/packages/core/src/packs/contract.ts b/packages/core/src/packs/contract.ts index 0c11ef4..2f86861 100644 --- a/packages/core/src/packs/contract.ts +++ b/packages/core/src/packs/contract.ts @@ -206,14 +206,27 @@ export function ruleFor( rules: readonly R[], finding: Finding, ): R | undefined { - return ( - rules.find((r) => r.id === finding.rule) ?? - rules.find( - (r) => - r.kinds.includes(finding.change.kind) && - r.symbols.test(finding.change.path) && - (!r.message || r.message.test(finding.usage.compileError ?? '')), - ) + const set = rules.find((r) => r.id === finding.rule); + if (set) return set; + // A root-cause anchor is claimed by the rule that claims the errors under it: the one most + // of them match, from their codes and messages. + if (finding.change.kind === 'cause' && finding.downstream?.length) { + const votes = new Map(); + for (const d of finding.downstream.slice(0, 200)) { + const rule = ruleFor(rules, { + ...finding, + change: { ...finding.change, kind: 'type', path: `TS${d.code}` }, + usage: { ...finding.usage, compileError: d.message, compileCode: d.code }, + }); + if (rule) votes.set(rule, (votes.get(rule) ?? 0) + 1); + } + return [...votes].sort((a, b) => b[1] - a[1])[0]?.[0]; + } + return rules.find( + (r) => + r.kinds.includes(finding.change.kind) && + r.symbols.test(finding.change.path) && + (!r.message || r.message.test(finding.usage.compileError ?? '')), ); } From 616f13969d60ad8bdebb513d168ebf5c9b89e8e1 Mon Sep 17 00:00:00 2001 From: Lucas Braum Date: Thu, 8 Oct 2026 09:24:34 +0000 Subject: [PATCH 3/6] fix(check): compile monorepo workspaces from source, one copy of a target's types in the overlay, and one finding for a parameter several call sites trip Workspace packages hoisted to the root (Yarn, npm) and the packages they link themselves are mapped to their source, so a workspace that imports @tldraw/editor compiles instead of failing on an unbuilt .tsbuild. A type reference directive naming a package the overlay serves resolves inside the overlay, with its node_modules/@types as the only type root, so the installed copy of @types/react no longer sits beside the target's. An argument or JSX attribute the compiler rejects at a repository parameter typed with a package type is one finding at the parameter when two or more sites trip it, with the call sites as evidence. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_018u9b3Pp5H68DdYZGp5Qg1U Signed-off-by: Lucas Braum --- docs/architecture.md | 15 ++++ docs/cli.md | 5 +- .../src/adapters/typescript/cause.test.ts | 47 ++++++++++ .../core/src/adapters/typescript/cause.ts | 88 +++++++++++++++++++ .../core/src/adapters/typescript/compile.ts | 82 ++++++++++++++++- .../src/adapters/typescript/config-cause.ts | 1 + .../typescript/repo-installed.test.ts | 41 +++++++++ .../typescript/workspace-source.test.ts | 33 ++++++- .../adapters/typescript/workspace-source.ts | 50 ++++++++--- packages/core/src/check/merge.ts | 2 +- packages/core/src/check/unattributed.ts | 11 +-- packages/core/src/domain/usage.ts | 6 ++ 12 files changed, 359 insertions(+), 22 deletions(-) create mode 100644 packages/core/src/adapters/typescript/repo-installed.test.ts diff --git a/docs/architecture.md b/docs/architecture.md index ad379d4..f284eb9 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -257,6 +257,12 @@ holds the ambient `.d.ts` files the tsconfig includes (`vite-env.d.ts`, `css.d.t imports them, and without them `*.module.css` imports and `declare global` names fail. Nothing in the repository is executed. +A `/// ` or an automatic `types` entry naming a package the +overlay serves is resolved inside the overlay too (`resolveTypeReferenceDirectiveReferences`, +with the overlay's `node_modules/@types` as the only type root): resolved from the +repository it would bring the installed copy in beside the target, two copies of one +package's types, and what the target removed would still be declared. + 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 @@ -556,6 +562,15 @@ the tsconfig, the diagnostics are its `downstream` evidence, and a pack rule cla 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)`, rejected at eleven call sites once +the target's `useRef` returns `RefObject`) 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. 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 under `url` says nothing about `trim`, whose finding goes through the arbiter like any diff --git a/docs/cli.md b/docs/cli.md index f6582f2..86b4b27 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -120,7 +120,10 @@ A root cause that is a compiler option is one site. When the target drops the gl 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. +diagnostics as evidence, and `--details` shows a few of them. A repository parameter that +several call sites trip over (a hook typed `RefObject` once `useRef` returns +`RefObject`) is reported the same way: one finding at the parameter, with +the call sites as evidence. ### Partial results diff --git a/packages/core/src/adapters/typescript/cause.test.ts b/packages/core/src/adapters/typescript/cause.test.ts index fc4033a..84e74e8 100644 --- a/packages/core/src/adapters/typescript/cause.test.ts +++ b/packages/core/src/adapters/typescript/cause.test.ts @@ -80,4 +80,51 @@ describe('root cause guard', () => { reason: 'imported from `widget`, which is typed `any` against the target', }); }); + + it('anchors argument mismatches at a repository parameter when several call sites trip it', async () => { + const dir = consumerWith( + 'param.ts', + [ + "import { boxed, type Box } from 'widget';", + 'export function use(b: Box): number {', + ' return b.a;', + '}', + 'export const first = use(boxed());', + 'export const second = use(boxed());', + '', + ].join('\n'), + ); + const signal = await compileAgainstTarget({ dir }, 'widget', join(DEPS, 'widget-v3')); + const mismatches = signal.diagnostics.filter( + (d) => d.file === 'src/param.ts' && d.code === 2345, + ); + expect(mismatches.map((d) => d.line)).toEqual([5, 6]); + for (const d of mismatches) + expect(d.cause).toEqual({ + name: 'b', + file: 'src/param.ts', + line: 2, + reason: + "whose parameter `b: Box` no longer accepts what the target gives it; widen the parameter's type there", + anchorOnly: true, + }); + }); + + it('leaves a parameter one site trips to that site', async () => { + const dir = consumerWith( + 'single.ts', + [ + "import { boxed, type Box } from 'widget';", + 'export function use(b: Box): number {', + ' return b.a;', + '}', + 'export const only = use(boxed());', + '', + ].join('\n'), + ); + const signal = await compileAgainstTarget({ dir }, 'widget', join(DEPS, 'widget-v3')); + const at5 = signal.diagnostics.find((d) => d.file === 'src/single.ts' && d.line === 5); + expect(at5?.code).toBe(2345); + expect(at5?.cause?.anchorOnly).toBeUndefined(); + }); }); diff --git a/packages/core/src/adapters/typescript/cause.ts b/packages/core/src/adapters/typescript/cause.ts index 9913d6a..06c80ff 100644 --- a/packages/core/src/adapters/typescript/cause.ts +++ b/packages/core/src/adapters/typescript/cause.ts @@ -276,3 +276,91 @@ function causeAt(decl: ts.Node, reason: string, repoDir: string): DiagnosticCaus reason, }; } + +/** Argument not assignable to parameter; type not assignable (a JSX attribute to its prop). */ +const MISMATCH_CODES = new Set([2345, 2322]); + +/** + * A mismatch the compiler reports at an argument whose parameter is a repository declaration: + * `usePassThroughWheelEvents(ref)` rejected at eleven call sites because the hook's parameter + * is typed `RefObject` and the target's `useRef` now returns + * `RefObject`. The one edit is the parameter (or the prop, for a JSX + * attribute), as the migration guides say; the call sites are evidence. The parameter is + * blamed only when its declared type names something from outside the repository (a package + * or library type), and the caller clusters the result: a parameter one site trips is left + * to that site. + */ +export function parameterCause( + programs: Programs, + diagnostic: ts.Diagnostic, + file: ts.SourceFile, + repoDir: string, +): DiagnosticCause | undefined { + if (diagnostic.start === undefined || !MISMATCH_CODES.has(diagnostic.code)) return undefined; + const checker = programs.overlay.getTypeChecker(); + const inRepo = (n: ts.Node): boolean => { + const f = n.getSourceFile().fileName; + return f.startsWith(`${repoDir}/`) && !f.includes('/node_modules/'); + }; + const node = deepestAt(file, diagnostic.start); + let decl: ts.Declaration | undefined; + // An argument of a call: the parameter it lands on, through the resolved signature. + for (let n: ts.Node | undefined = node; n && !ts.isStatement(n); n = n.parent) { + const parent: ts.Node | undefined = n.parent; + if (parent && (ts.isCallExpression(parent) || ts.isNewExpression(parent))) { + // The callee of an inner call (`use(boxed())` reported at `boxed`): keep climbing. + const index = parent.arguments?.indexOf(n as ts.Expression) ?? -1; + if (index < 0) continue; + const signature = checker.getResolvedSignature(parent); + const parameters = signature?.parameters ?? []; + const parameter = parameters[Math.min(index, parameters.length - 1)]; + decl = parameter?.valueDeclaration; + break; + } + if (parent && ts.isJsxAttribute(parent) && parent.initializer === n) { + const symbol = checker.getSymbolAtLocation(parent.name); + decl = symbol?.declarations?.[0]; + break; + } + } + if (!decl || !inRepo(decl)) return undefined; + if (!ts.isParameter(decl) && !ts.isPropertySignature(decl) && !ts.isPropertyDeclaration(decl)) + return undefined; + const typeNode = decl.type; + if (!typeNode || !namesForeignType(typeNode, checker, inRepo)) return undefined; + const site = `${relative(repoDir, file.fileName)}:${file.getLineAndCharacterOfPosition(diagnostic.start).line + 1}`; + const at = decl.getSourceFile(); + const declLine = `${relative(repoDir, at.fileName)}:${at.getLineAndCharacterOfPosition(decl.getStart()).line + 1}`; + if (declLine === site) return undefined; + const what = ts.isParameter(decl) ? 'parameter' : 'prop'; + const name = ts.getNameOfDeclaration(decl)?.getText() ?? '(anonymous)'; + const cause = causeAt( + decl, + `whose ${what} \`${name}: ${clip(typeNode.getText())}\` no longer accepts what the target gives it; widen the ${what}'s type there`, + repoDir, + ); + // The anchor is the parameter itself, not the function that holds it. + return { ...cause, name, line: cause.line, anchorOnly: true }; +} + +/** Whether a type annotation refers to a type declared outside the repository. */ +function namesForeignType( + typeNode: ts.TypeNode, + checker: ts.TypeChecker, + inRepo: (n: ts.Node) => boolean, +): boolean { + let foreign = false; + const visit = (n: ts.Node): void => { + if (foreign) return; + if (ts.isTypeReferenceNode(n) || ts.isExpressionWithTypeArguments(n)) { + const name = ts.isTypeReferenceNode(n) ? n.typeName : n.expression; + const symbol = checker.getSymbolAtLocation(name); + const target = + symbol && symbol.flags & ts.SymbolFlags.Alias ? checker.getAliasedSymbol(symbol) : symbol; + if (target?.declarations?.some((d) => !inRepo(d))) foreign = true; + } + ts.forEachChild(n, visit); + }; + visit(typeNode); + return foreign; +} diff --git a/packages/core/src/adapters/typescript/compile.ts b/packages/core/src/adapters/typescript/compile.ts index efaf6eb..3a4101f 100644 --- a/packages/core/src/adapters/typescript/compile.ts +++ b/packages/core/src/adapters/typescript/compile.ts @@ -4,10 +4,15 @@ import { tmpdir } from 'node:os'; import { dirname, join, relative } from 'node:path'; import { ts } from 'ts-morph'; import type { CompileOptions, RepoDir } from '../../domain/adapter.js'; -import type { CompileCoverage, CompileDiagnostic, CompileSignal } from '../../domain/usage.js'; +import type { + CompileCoverage, + CompileDiagnostic, + CompileSignal, + DiagnosticCause, +} from '../../domain/usage.js'; import { satisfies } from '../../fetch/range.js'; import { onReset } from '../../shared-state.js'; -import { findCause } from './cause.js'; +import { findCause, parameterCause } from './cause.js'; import { jsxNamespaceCause } from './config-cause.js'; import { type LoadedRepo, loadedRepo, ownsFile } from './repo.js'; import { @@ -20,6 +25,7 @@ import { satisfyWanted, type Wanted, } from './target-deps.js'; +import { typesPackageOf } from '../../check/types-release.js'; import { resolvePackageDir } from './usages.js'; /** @@ -522,6 +528,61 @@ function overlayProgram( // Not `oldProgram: base`: structure reuse copies the baseline's module resolutions and // silently bypasses the overlay. Sharing source files and resolutions through the host // (above) gives the same saving without that. + // A `/// ` (or an automatic `types` entry) naming a target or a + // linked dependency must land in the overlay too: resolved from the repository it would + // bring the installed copy in beside the target, two copies of one package's types, and + // what the target removed (the global JSX namespace) would still be declared. + const served = (name: string): boolean => + targets.some( + (t) => + t.name === name || + t.specifier === name || + typesPackageOf(t.specifier ?? t.name) === typesPackageOf(name), + ) || + deps.links.has(name) || + deps.links.has(typesPackageOf(name)); + host.resolveTypeReferenceDirectiveReferences = ( + directives, + containingFile, + _redirect, + _opts, + containingSourceFile, + ) => + directives.map((directive) => { + const name = typeof directive === 'string' ? directive : directive.fileName; + const mode = + containingSourceFile && typeof directive !== 'string' + ? ts.getModeForFileReference(directive, containingSourceFile.impliedNodeFormat) + : undefined; + if (served(name)) { + // The primary lookup is the type roots, which default to the repository's + // node_modules/@types: the overlay's is the only root a served name may come from. + const inOverlay: ts.CompilerOptions = { + ...noPaths, + typeRoots: [join(overlay, 'node_modules', '@types')], + configFilePath: undefined, + }; + const found = ts.resolveTypeReferenceDirective( + name, + probe, + inOverlay, + host, + undefined, + undefined, + mode, + ); + return found; + } + return ts.resolveTypeReferenceDirective( + name, + containingFile, + options, + host, + undefined, + undefined, + mode, + ); + }); const program = ts.createProgram({ rootNames: [...base.getRootFileNames()], options, host }); // `declare module 'x'` inside the target makes the compiler try to resolve `x` too; an // augmentation target is not an import and must not count as a missing dependency. @@ -688,10 +749,27 @@ export async function compileAgainstTargets( repo.dir, targets.map((t) => t.name), ); + // A repository parameter several call sites trip over is one edit; one site keeps its + // own diagnostic, which may as well be the argument's. + const byParameter = new Map(); + const parameterOf = new Map(); + for (const { d, overlaid } of fresh) { + const cause = parameterCause({ overlay: program, base: checked }, d, overlaid, repo.dir); + if (!cause) continue; + const key = `${cause.file}:${cause.line}:${cause.name}`; + byParameter.set(key, cause); + parameterOf.set(d, key); + } + const shared = new Map(); + for (const key of parameterOf.values()) shared.set(key, (shared.get(key) ?? 0) + 1); const diagnostics: CompileDiagnostic[] = fresh.map(({ d, overlaid }) => { const diagnostic = toDiagnostic(d, overlaid, repo.dir); + const parameter = parameterOf.get(d); const cause = (jsx?.explains(d) ? jsx.cause : undefined) ?? + (parameter !== undefined && (shared.get(parameter) ?? 0) >= 2 + ? byParameter.get(parameter) + : undefined) ?? findCause({ overlay: program, base: checked }, d, overlaid, repo.dir, erroredLines); if (cause) diagnostic.cause = cause; return diagnostic; diff --git a/packages/core/src/adapters/typescript/config-cause.ts b/packages/core/src/adapters/typescript/config-cause.ts index 0de2caa..62fab37 100644 --- a/packages/core/src/adapters/typescript/config-cause.ts +++ b/packages/core/src/adapters/typescript/config-cause.ts @@ -87,6 +87,7 @@ export function jsxNamespaceCause( line: location?.line ?? 1, reason: `"jsx": "${setting}" reads JSX element types from the global JSX namespace, which ${who} no longer declares; "jsxImportSource": "react" (or "jsx": "react-jsx") makes the compiler read them from react/jsx-runtime`, config: true, + anchorOnly: true, }, explains: (d) => JSX_NAMESPACE_CODES.has(d.code), }; diff --git a/packages/core/src/adapters/typescript/repo-installed.test.ts b/packages/core/src/adapters/typescript/repo-installed.test.ts new file mode 100644 index 0000000..91a2dab --- /dev/null +++ b/packages/core/src/adapters/typescript/repo-installed.test.ts @@ -0,0 +1,41 @@ +import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { readInstalled } from './repo.js'; + +describe('readInstalled', () => { + it('records a workspace dependency under its declared specifier, whatever the lockfile wrote for it', () => { + // Yarn Berry records a workspace package as `0.0.0-use.local`, which nothing downstream + // recognises as a link; the declared `workspace:*` is what says it is one. + const root = mkdtempSync(join(tmpdir(), 'uptide-ws-lock-')); + const app = join(root, 'packages', 'app'); + mkdirSync(app, { recursive: true }); + writeFileSync( + join(root, 'package.json'), + JSON.stringify({ name: 'mono', workspaces: ['packages/*'] }), + ); + writeFileSync( + join(app, 'package.json'), + JSON.stringify({ + name: '@acme/app', + dependencies: { '@acme/ui': 'workspace:*', react: '^18.3.1' }, + }), + ); + writeFileSync( + join(root, 'yarn.lock'), + [ + '"@acme/ui@workspace:*, @acme/ui@workspace:packages/ui":', + ' version: 0.0.0-use.local', + ' resolution: "@acme/ui@workspace:packages/ui"', + '', + '"react@npm:^18.3.1":', + ' version: 18.3.1', + '', + ].join('\n'), + ); + const { installed } = readInstalled(app); + expect(installed.get('@acme/ui')).toBe('workspace:*'); + expect(installed.get('react')).toBe('18.3.1'); + }); +}); diff --git a/packages/core/src/adapters/typescript/workspace-source.test.ts b/packages/core/src/adapters/typescript/workspace-source.test.ts index 9b7af04..537c3fb 100644 --- a/packages/core/src/adapters/typescript/workspace-source.test.ts +++ b/packages/core/src/adapters/typescript/workspace-source.test.ts @@ -1,4 +1,4 @@ -import { mkdirSync, mkdtempSync, utimesSync, writeFileSync } from 'node:fs'; +import { mkdirSync, mkdtempSync, symlinkSync, utimesSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join, resolve } from 'node:path'; import { describe, expect, it } from 'vitest'; @@ -39,4 +39,35 @@ describe('workspaceSourceMap', () => { 'dep: compiled against its built lib/index.d.ts, which is older than its source; rebuild it or the results may be stale', ]); }); + + it("follows workspace links hoisted to the root, and the linked packages' own links", () => { + // A Yarn or npm workspace: `packages/app` depends on `@acme/ui` (workspace:*), hoisted to the + // root node_modules as a symlink; `@acme/ui` re-exports from `@acme/core`, linked the same way. + const repo = mkdtempSync(join(tmpdir(), 'uptide-hoist-')); + const pkg = (name: string, deps: Record = {}): string => { + const dir = join(repo, 'packages', name.replace('@acme/', '')); + mkdirSync(join(dir, 'src'), { recursive: true }); + writeFileSync( + join(dir, 'package.json'), + JSON.stringify({ name, types: './dist/index.d.ts', dependencies: deps }), + ); + writeFileSync( + join(dir, 'tsconfig.json'), + JSON.stringify({ compilerOptions: { outDir: 'dist', rootDir: 'src' } }), + ); + writeFileSync(join(dir, 'src/index.ts'), 'export const x = 1;\n'); + return dir; + }; + const core = pkg('@acme/core'); + const ui = pkg('@acme/ui', { '@acme/core': 'workspace:*' }); + const app = pkg('@acme/app', { '@acme/ui': 'workspace:*' }); + mkdirSync(join(repo, 'node_modules', '@acme'), { recursive: true }); + symlinkSync(core, join(repo, 'node_modules', '@acme', 'core'), 'dir'); + symlinkSync(ui, join(repo, 'node_modules', '@acme', 'ui'), 'dir'); + const map = workspaceSourceMap(app, new Map([['@acme/ui', 'workspace:*']])); + expect(map.paths).toEqual({ + '@acme/ui': [join(ui, 'src/index.ts')], + '@acme/core': [join(core, 'src/index.ts')], + }); + }); }); diff --git a/packages/core/src/adapters/typescript/workspace-source.ts b/packages/core/src/adapters/typescript/workspace-source.ts index 442077a..62b1cb9 100644 --- a/packages/core/src/adapters/typescript/workspace-source.ts +++ b/packages/core/src/adapters/typescript/workspace-source.ts @@ -102,15 +102,21 @@ function readdirSafe(dir: string): string[] { } } -/** The dependency's directory: the symlink under node_modules, or the link target itself. */ +/** + * The dependency's directory: the symlink under the workspace's node_modules or an ancestor's + * (Yarn and npm hoist workspace links to the root), or the link target itself. + */ function linkedDir(repoDir: string, name: string, specifier: string): string | undefined { - const viaNodeModules = join(repoDir, 'node_modules', name); - if (existsSync(viaNodeModules)) { - try { - return realpathSync(viaNodeModules); - } catch { - // dangling link + for (let dir = repoDir; ; dir = dirname(dir)) { + const viaNodeModules = join(dir, 'node_modules', name); + if (existsSync(viaNodeModules)) { + try { + return realpathSync(viaNodeModules); + } catch { + // dangling link + } } + if (dirname(dir) === dir) break; } const m = /^(?:link|file):(.+)$/.exec(specifier); if (m) { @@ -120,22 +126,42 @@ function linkedDir(repoDir: string, name: string, specifier: string): string | u return undefined; } +/** + * The map covers the workspace's own linked dependencies and, through them, theirs: a source + * entry of `@tldraw/editor` re-exports from `@tldraw/utils`, which must map to source too or + * every name that passes through it is missing. Each package is visited once. + */ export function workspaceSourceMap( repoDir: string, installed: Map, ): WorkspaceSourceMap { const paths: Record = {}; const warnings: string[] = []; - for (const [name, specifier] of installed) { - if (!LINK.test(specifier)) continue; - const depDir = linkedDir(repoDir, name, specifier); + const queue: { from: string; name: string; specifier: string }[] = [...installed] + .filter(([, specifier]) => LINK.test(specifier)) + .map(([name, specifier]) => ({ from: repoDir, name, specifier })); + const seen = new Set(); + for (let next = queue.shift(); next; next = queue.shift()) { + const { from, name, specifier } = next; + if (seen.has(name)) continue; + seen.add(name); + const depDir = linkedDir(from, name, specifier); if (!depDir) continue; - let manifest: Manifest; + let manifest: Manifest & { + dependencies?: Record; + peerDependencies?: Record; + }; try { - manifest = JSON.parse(readFileSync(join(depDir, 'package.json'), 'utf8')) as Manifest; + manifest = JSON.parse(readFileSync(join(depDir, 'package.json'), 'utf8')) as typeof manifest; } catch { continue; } + for (const [dep, range] of Object.entries({ + ...manifest.peerDependencies, + ...manifest.dependencies, + })) + if (LINK.test(range) && !seen.has(dep)) + queue.push({ from: depDir, name: dep, specifier: range }); let mapped = 0; let unmapped: string | undefined; for (const [subpath, distFile] of typesTargets(manifest)) { diff --git a/packages/core/src/check/merge.ts b/packages/core/src/check/merge.ts index 4d474db..eced8d1 100644 --- a/packages/core/src/check/merge.ts +++ b/packages/core/src/check/merge.ts @@ -82,7 +82,7 @@ export function mergeSignals( const explained = (u: Usage): boolean => usagePaths(u).some((p) => changedPaths.has(p)); for (const d of signal.diagnostics) { // A diagnostic a compiler option explains belongs to that cause, whatever usage it touches. - if (d.cause?.config) { + if (d.cause?.config || d.cause?.anchorOnly) { unattributed.push(d); continue; } diff --git a/packages/core/src/check/unattributed.ts b/packages/core/src/check/unattributed.ts index 1f56fe8..52b0782 100644 --- a/packages/core/src/check/unattributed.ts +++ b/packages/core/src/check/unattributed.ts @@ -28,7 +28,7 @@ function quotedNames(message: string): string[] { /** `${n} errors caused by \`name\` (file:line), . Fix here first.`: the text the report prints for a cluster. */ export function describeCluster(count: number, cause: DiagnosticCause): string { - if (cause.config) + if (cause.config || cause.anchorOnly) return `${count} error${count === 1 ? '' : 's'} caused by \`${cause.name}\` in ${cause.file}:${cause.line}: ${cause.reason}. One edit there resolves them.`; return `${count} error${count === 1 ? '' : 's'} caused by \`${cause.name}\` (${cause.file}:${cause.line}), ${cause.reason}. Fix here first.`; } @@ -86,12 +86,13 @@ export function unattributedFindings( code: d.code, message: d.message.split('\n')[0] ?? d.message, })), - // A compiler option is fixed once, where it is set: the errors under it are evidence. - ...(cause.config ? { anchorOnly: true as const } : {}), + // A cause that is itself the one edit (a compiler option, a parameter's type) is the + // site; the errors under it are evidence. + ...(cause.anchorOnly || cause.config ? { anchorOnly: true as const } : {}), severity: 'breaking', confidence: 1, - fixability: cause.config ? 'assisted' : 'unknown', - reason: cause.config ? cause.reason : UNATTRIBUTED_REASON, + fixability: cause.anchorOnly || cause.config ? 'assisted' : 'unknown', + reason: cause.anchorOnly || cause.config ? cause.reason : UNATTRIBUTED_REASON, }; }); return [...clustered, ...plainFindings(single, meta, surfaceB, unresolvedFiles)]; diff --git a/packages/core/src/domain/usage.ts b/packages/core/src/domain/usage.ts index 5118df3..6f954a2 100644 --- a/packages/core/src/domain/usage.ts +++ b/packages/core/src/domain/usage.ts @@ -123,6 +123,12 @@ export interface DiagnosticCause { * resolves the cluster is there, and the diagnostics under it are evidence, not sites to fix. */ config?: true; + /** + * The one edit is at the cause itself (a compiler option, a parameter's type), so the + * cluster is one site and its diagnostics are evidence; without it, the diagnostics are + * the sites and the cause is where to look first. + */ + anchorOnly?: true; } /** The files Signal B was asked to judge and what became of them: the denominator of a verdict. */ From aa4a3627f105d18951b088dcb063720dc8c66cc9 Mon Sep 17 00:00:00 2001 From: Lucas Braum Date: Thu, 8 Oct 2026 09:36:18 +0000 Subject: [PATCH 4/6] fix(check): a parameter in another workspace's source is the repository's, and its path is reported as such The overlay links a target once; a type reference directive naming it is left to the repository's resolution, since a real upgrade keeps the nested copy a dependency pins (tldraw's lockfile keeps @types/react 18 for a range of 16 || 17 || 18), and the errors that follow are the ones its own compiler reports. The workspace job carries the repository root, so a parameter in packages/editor that call sites in packages/tldraw trip is anchored there, under its repository path. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_018u9b3Pp5H68DdYZGp5Qg1U Signed-off-by: Lucas Braum --- docs/architecture.md | 10 +-- .../core/src/adapters/typescript/cause.ts | 4 +- .../core/src/adapters/typescript/compile.ts | 64 ++----------------- packages/core/src/check/check.ts | 12 +++- packages/core/src/check/unattributed.ts | 8 ++- packages/core/src/domain/adapter.ts | 2 + 6 files changed, 30 insertions(+), 70 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index f284eb9..18a8396 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -257,12 +257,6 @@ holds the ambient `.d.ts` files the tsconfig includes (`vite-env.d.ts`, `css.d.t imports them, and without them `*.module.css` imports and `declare global` names fail. Nothing in the repository is executed. -A `/// ` or an automatic `types` entry naming a package the -overlay serves is resolved inside the overlay too (`resolveTypeReferenceDirectiveReferences`, -with the overlay's `node_modules/@types` as the only type root): resolved from the -repository it would bring the installed copy in beside the target, two copies of one -package's types, and what the target removed would still be declared. - 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 @@ -568,7 +562,9 @@ is declared in the repository with a type that names something from outside it (`usePassThroughWheelEvents(ref: RefObject)`, rejected at eleven call sites once the target's `useRef` returns `RefObject`) 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. A parameter one site trips is left to +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 diff --git a/packages/core/src/adapters/typescript/cause.ts b/packages/core/src/adapters/typescript/cause.ts index 06c80ff..51d854e 100644 --- a/packages/core/src/adapters/typescript/cause.ts +++ b/packages/core/src/adapters/typescript/cause.ts @@ -295,12 +295,14 @@ export function parameterCause( diagnostic: ts.Diagnostic, file: ts.SourceFile, repoDir: string, + /** The repository root: another workspace's source (mapped from `workspace:*`) is the repository's too. */ + rootDir = repoDir, ): DiagnosticCause | undefined { if (diagnostic.start === undefined || !MISMATCH_CODES.has(diagnostic.code)) return undefined; const checker = programs.overlay.getTypeChecker(); const inRepo = (n: ts.Node): boolean => { const f = n.getSourceFile().fileName; - return f.startsWith(`${repoDir}/`) && !f.includes('/node_modules/'); + return f.startsWith(`${rootDir}/`) && !f.includes('/node_modules/'); }; const node = deepestAt(file, diagnostic.start); let decl: ts.Declaration | undefined; diff --git a/packages/core/src/adapters/typescript/compile.ts b/packages/core/src/adapters/typescript/compile.ts index 3a4101f..bba9b3b 100644 --- a/packages/core/src/adapters/typescript/compile.ts +++ b/packages/core/src/adapters/typescript/compile.ts @@ -25,7 +25,6 @@ import { satisfyWanted, type Wanted, } from './target-deps.js'; -import { typesPackageOf } from '../../check/types-release.js'; import { resolvePackageDir } from './usages.js'; /** @@ -528,61 +527,6 @@ function overlayProgram( // Not `oldProgram: base`: structure reuse copies the baseline's module resolutions and // silently bypasses the overlay. Sharing source files and resolutions through the host // (above) gives the same saving without that. - // A `/// ` (or an automatic `types` entry) naming a target or a - // linked dependency must land in the overlay too: resolved from the repository it would - // bring the installed copy in beside the target, two copies of one package's types, and - // what the target removed (the global JSX namespace) would still be declared. - const served = (name: string): boolean => - targets.some( - (t) => - t.name === name || - t.specifier === name || - typesPackageOf(t.specifier ?? t.name) === typesPackageOf(name), - ) || - deps.links.has(name) || - deps.links.has(typesPackageOf(name)); - host.resolveTypeReferenceDirectiveReferences = ( - directives, - containingFile, - _redirect, - _opts, - containingSourceFile, - ) => - directives.map((directive) => { - const name = typeof directive === 'string' ? directive : directive.fileName; - const mode = - containingSourceFile && typeof directive !== 'string' - ? ts.getModeForFileReference(directive, containingSourceFile.impliedNodeFormat) - : undefined; - if (served(name)) { - // The primary lookup is the type roots, which default to the repository's - // node_modules/@types: the overlay's is the only root a served name may come from. - const inOverlay: ts.CompilerOptions = { - ...noPaths, - typeRoots: [join(overlay, 'node_modules', '@types')], - configFilePath: undefined, - }; - const found = ts.resolveTypeReferenceDirective( - name, - probe, - inOverlay, - host, - undefined, - undefined, - mode, - ); - return found; - } - return ts.resolveTypeReferenceDirective( - name, - containingFile, - options, - host, - undefined, - undefined, - mode, - ); - }); const program = ts.createProgram({ rootNames: [...base.getRootFileNames()], options, host }); // `declare module 'x'` inside the target makes the compiler try to resolve `x` too; an // augmentation target is not an import and must not count as a missing dependency. @@ -754,7 +698,13 @@ export async function compileAgainstTargets( const byParameter = new Map(); const parameterOf = new Map(); for (const { d, overlaid } of fresh) { - const cause = parameterCause({ overlay: program, base: checked }, d, overlaid, repo.dir); + const cause = parameterCause( + { overlay: program, base: checked }, + d, + overlaid, + repo.dir, + repoRef.root ?? repo.dir, + ); if (!cause) continue; const key = `${cause.file}:${cause.line}:${cause.name}`; byParameter.set(key, cause); diff --git a/packages/core/src/check/check.ts b/packages/core/src/check/check.ts index a00f31b..a161cbf 100644 --- a/packages/core/src/check/check.ts +++ b/packages/core/src/check/check.ts @@ -1,5 +1,5 @@ import { readFileSync, realpathSync } from 'node:fs'; -import { join, relative, resolve, sep } from 'node:path'; +import { join, posix, relative, resolve, sep } from 'node:path'; import { typescriptAdapter } from '../adapters/typescript/index.js'; import { createFsSurfaceCache } from '../cache/fs-surface-cache.js'; import { diffDirs } from '../diff-package.js'; @@ -547,8 +547,10 @@ export function mergeAcrossWorkspaces( for (const list of groups.values()) { const catalog = list.some((p) => catalogByWorkspace[p.workspace]?.includes(p.name)); if (list.length < 2 && !catalog) continue; + // A cause in another workspace's source is reported relative to this one (`../editor/x.ts`): + // normalized, it is the repository path. const prefixed = (p: PackageReport, file: string): string => - p.workspace === '.' ? file : `${p.workspace}/${file}`; + posix.normalize(p.workspace === '.' ? file : `${p.workspace}/${file}`); const first = list[0] as PackageReport; const sum = (pick: (p: PackageReport) => number): number => list.reduce((n, p) => n + pick(p), 0); @@ -809,7 +811,11 @@ async function checkWorkspace(ctx: Ctx, job: WorkspaceJob): Promise ({ - file: `${filePrefix}${d.file}`, + file: posix.normalize(`${filePrefix}${d.file}`), line: d.line, code: d.code, message: d.message.split('\n')[0] ?? d.message, diff --git a/packages/core/src/domain/adapter.ts b/packages/core/src/domain/adapter.ts index 4bb4378..47e9cc7 100644 --- a/packages/core/src/domain/adapter.ts +++ b/packages/core/src/domain/adapter.ts @@ -14,6 +14,8 @@ export interface PackageDir { /** A consumer repository on disk. Read only: nothing in it is ever executed. */ export interface RepoDir { dir: string; + /** The repository root when `dir` is a workspace inside it: what another workspace's source is still part of. */ + root?: string; /** Absolute source roots for a named check; imports are followed by the compiler. */ rootFiles?: string[]; /** From d82a9222ecb3ca8652c5f930cac64715fac12989 Mon Sep 17 00:00:00 2001 From: Lucas Braum Date: Thu, 8 Oct 2026 10:03:45 +0000 Subject: [PATCH 5/6] fix(check): read companion manifests from disk, not from a program per workspace Gathering every workspace's installed manifests for a companion plan loaded a full ts-morph program for each of them in the engine thread, which a 28-workspace repository cannot afford; the manifest is found the way Node resolves it, node_modules/ in the workspace or above. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_018u9b3Pp5H68DdYZGp5Qg1U Signed-off-by: Lucas Braum --- packages/core/src/adapters/typescript/index.ts | 7 +++++++ packages/core/src/adapters/typescript/usages.ts | 2 +- packages/core/src/check/check.ts | 17 +++++++++++------ 3 files changed, 19 insertions(+), 7 deletions(-) diff --git a/packages/core/src/adapters/typescript/index.ts b/packages/core/src/adapters/typescript/index.ts index 7d87474..f2b5c94 100644 --- a/packages/core/src/adapters/typescript/index.ts +++ b/packages/core/src/adapters/typescript/index.ts @@ -10,6 +10,7 @@ import { resolveEntryPoints } from './entry-points.js'; import { forgetRepo, loadedRepo, readInstalled, workspacePackagesOf } from './repo.js'; import { findUsagesInRepo, + hoistedPackageDir, importedPackagesOf, installedDependenciesOf, installedPackageDir, @@ -126,6 +127,8 @@ export interface TypescriptAdapter extends LanguageAdapter { findUsages: NonNullable; /** Where the repository's compiler finds `pkg`, as a package root. */ installedPackageDir(repo: RepoDir, pkg: string): string | undefined; + /** Where Node finds `pkg` from the workspace (`node_modules/` here or above): no program is loaded. */ + installedPackageDirCheap(repo: RepoDir, pkg: string): string | undefined; extractSurfaceDetailed(pkg: PackageDir): Promise; /** The surface of the copy of `pkg` the repository actually resolves to (installed on disk), or undefined when it cannot be resolved. */ installedSurface(repo: RepoDir, pkg: string, version: string): Promise; @@ -145,6 +148,10 @@ export function createTypescriptAdapter(opts: TypescriptAdapterOptions = {}): Ty installedPackageDir(repo, pkg) { return installedPackageDir(repo, pkg); }, + installedPackageDirCheap(repo, pkg) { + const dir = hoistedPackageDir(realpathSync(repo.dir), pkg); + return dir ? realpathSync(dir) : undefined; + }, async importedPackages(repo) { return importedPackagesOf(repo); }, diff --git a/packages/core/src/adapters/typescript/usages.ts b/packages/core/src/adapters/typescript/usages.ts index 2f905a2..2452093 100644 --- a/packages/core/src/adapters/typescript/usages.ts +++ b/packages/core/src/adapters/typescript/usages.ts @@ -192,7 +192,7 @@ export function resolvePackageDir(repo: LoadedRepo, pkg: string): string | undef * resolves from the workspace. A hoisting installer (yarn classic, npm) keeps one copy at the * repository root, which a workspace that only peer-depends on the package still sees. */ -function hoistedPackageDir(from: string, pkg: string): string | undefined { +export function hoistedPackageDir(from: string, pkg: string): string | undefined { for (let dir = from; ; dir = dirname(dir)) { const candidate = join(dir, 'node_modules', pkg); if (existsSync(join(candidate, 'package.json'))) return candidate; diff --git a/packages/core/src/check/check.ts b/packages/core/src/check/check.ts index a161cbf..c8e36b7 100644 --- a/packages/core/src/check/check.ts +++ b/packages/core/src/check/check.ts @@ -1063,12 +1063,17 @@ async function companionPlans( known.workspaces.push(workspace); continue; } - const dir = installedPackageDirOf( - adapter, - { dir: resolve(opts.cwd, workspace) }, - name, - version, - )?.dir; + // Read from disk as Node would resolve it: loading a program for every workspace + // just to find a manifest is what a 28-workspace repository cannot afford. + const cheap = ( + adapter as { + installedPackageDirCheap?: (repo: RepoDir, pkg: string) => string | undefined; + } + ).installedPackageDirCheap; + const dir = cheap + ? cheap({ dir: resolve(opts.cwd, workspace) }, name) + : installedPackageDirOf(adapter, { dir: resolve(opts.cwd, workspace) }, name, version) + ?.dir; let manifest = {}; try { if (dir) manifest = JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8')); From f10da610266d9c884ca3d1b103f69108bcd451d9 Mon Sep 17 00:00:00 2001 From: Lucas Braum Date: Thu, 8 Oct 2026 15:14:44 +0000 Subject: [PATCH 6/6] docs(check): possible impact and coverage in the README sample and tier table; changeset says what changed in check Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_018u9b3Pp5H68DdYZGp5Qg1U Signed-off-by: Lucas Braum --- .changeset/check-confirmed-breaking.md | 37 +++++++++++++++----------- README.md | 13 ++++++++- packages/cli/src/format-check.test.ts | 2 +- packages/cli/src/format-check.ts | 2 +- 4 files changed, 36 insertions(+), 18 deletions(-) diff --git a/.changeset/check-confirmed-breaking.md b/.changeset/check-confirmed-breaking.md index baec46e..1543e94 100644 --- a/.changeset/check-confirmed-breaking.md +++ b/.changeset/check-confirmed-breaking.md @@ -2,18 +2,25 @@ "uptide": minor --- -`check` reports a type-surface change as breaking only when something confirms it at the -site, in every tier: the compiler rejects the code against the target, the runtime probe saw -the export go, or a migration pack found it. What nothing confirmed is "possible impact", -listed apart and never counted as breaking. Every analyzed package prints how much of the code -that uses it the compiler judged (`compiled 355 of 356 files in 5 workspaces; skipped: ...`), -and the verdict says "types partly verified" when not every file was. 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, a workspace that -only peer-depends on a package finds the hoisted copy, and a repository on TypeScript 5 is -read with TypeScript 5's defaults (`strict`, automatic `@types`) rather than the bundled -TypeScript 6's. A root cause that is a compiler option is one finding at the option: the -global `JSX` namespace that `@types/react` 19 removes, read by `"jsx": "preserve"`, is -reported once at the tsconfig line with the element errors as evidence. `check` and `fix` -move `@types/` with ``, 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. +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: + ` 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/` with ``, 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. diff --git a/README.md b/README.md index ffe23cb..60b2174 100644 --- a/README.md +++ b/README.md @@ -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) @@ -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 diff --git a/packages/cli/src/format-check.test.ts b/packages/cli/src/format-check.test.ts index dd229cf..5b784ec 100644 --- a/packages/cli/src/format-check.test.ts +++ b/packages/cli/src/format-check.test.ts @@ -368,7 +368,7 @@ describe('tiers, the time budget and failures on the first screen', () => { const out = formatCheck(report([generic]), { color: false }); expect(out).toContain('✗ 1 breaking, 1 possible'); expect(out).toContain( - '? possible impact: 1 site in 1 file not confirmed by the compiler or the runtime probe', + '? possible impact: 1 site in 1 file the types changed where this code uses them; nothing confirmed that it breaks, so these are not counted as breaking', ); expect(out).toMatch(/\n {4}\? sharp\.cache removed +image\.ts:4/); expect(out).not.toContain('unverified'); diff --git a/packages/cli/src/format-check.ts b/packages/cli/src/format-check.ts index de2d1b3..3bf7f25 100644 --- a/packages/cli/src/format-check.ts +++ b/packages/cli/src/format-check.ts @@ -285,7 +285,7 @@ function sectionLines(row: Row, colors: Colors): string[] { // there: listed apart, never counted as breaking. if (possible.length > 0) { lines.push( - ` ${colors.magenta('?')} possible impact: ${plural(sitesOf(possible), 'site')} in ${plural(filesOf(possible), 'file')} ${colors.dim('not confirmed by the compiler or the runtime probe')}`, + ` ${colors.magenta('?')} possible impact: ${plural(sitesOf(possible), 'site')} in ${plural(filesOf(possible), 'file')} ${colors.dim('the types changed where this code uses them; nothing confirmed that it breaks, so these are not counted as breaking')}`, ); for (const g of listedPossible) lines.push(` ${ruleLine(g)}`); if (listedPossible.length < possible.length) {