diff --git a/.changeset/check-unresolved-tsconfig-companions.md b/.changeset/check-unresolved-tsconfig-companions.md new file mode 100644 index 0000000..ea9234b --- /dev/null +++ b/.changeset/check-unresolved-tsconfig-companions.md @@ -0,0 +1,19 @@ +--- +"uptide": minor +--- + +Two fixes to `check`: + +- **A project whose tsconfig cannot be resolved is not compiled.** A tsconfig that `extends` a + package or file that is not installed describes options the repository never builds with, so + what a compiler reports there is not a place to change. `check` skips it, never turns its + diagnostics into findings, and says so in the coverage line (a file governed by such a + nested project is dropped from the workspace that happens to reach it): + `compiled 5 of 7 files in 2 workspaces; not compiled: docs/tsconfig.json (extends "@tsconfig/docusaurus/tsconfig.json" cannot be resolved)`. +- **A package that only peers on the package is left in place when its peer range rejects the + target.** What always moves with the package stays as before: the release group published at + the target's own version (`react-dom`), `@types/*`, the exact pins of the target, and the + packages a pack names in `companions` (each with the official page that says so), even when their installed range already accepts the + target. Any other package whose installed peer range rejects the target is not moved and not + compiled at another version; `check` lists it under possible impact, never as breaking: + `? possible impact, peer conflict: next-mdx-remote-client 1.1.2 declares react >= 18.3.0 < 19.0.0`. diff --git a/docs/packs.md b/docs/packs.md index 82b2e0d..4d22db6 100644 --- a/docs/packs.md +++ b/docs/packs.md @@ -40,6 +40,25 @@ that no source supports does not belong in a pack. `supports(from, to)` defaults ranges; a pack may narrow it (stripe also requires the target to be newer than what is installed). +A pack may also name `companions`: packages that always move with the package, even when +their installed version already accepts the target. + +```ts +companions: [{ name: 'ai-sdk-ollama', source: 'https://github.com/jagreehal/ai-sdk-ollama/blob/main/README.md' }], +``` + +**Companions come from official docs, never from ground truth alone.** `source` is the `https` +URL of the official migration guide or changelog that says the package moves with the +package: the leader's documentation or the companion's own, never the ground truth. A +ground-truth repository that moved a package shows what one repository did, not that the +package must move. `uptide pack test` fails a pack whose `companions` has an entry with no +`source`, one that is not an `https` URL, or one that names the pack's own package. + +A release-group member published at the target's own version (`react-dom`) and `@types/*` +need no entry. A package that only peers on the package and whose installed range rejects the +target is left in place and listed under possible impact as a peer conflict; it is never +compiled at another version. + ### Mechanical rules: detect and rewrite ```ts diff --git a/packages/cli/src/format-check.test.ts b/packages/cli/src/format-check.test.ts index 5b784ec..4e2e0e1 100644 --- a/packages/cli/src/format-check.test.ts +++ b/packages/cli/src/format-check.test.ts @@ -261,6 +261,29 @@ describe('formatCheck, the first screen', () => { expect(out).toContain('npx uptide@next fix ai'); }); + it('lists a peer conflict as possible impact, never as breaking', () => { + const out = formatCheck( + report([ + pkg({ + name: 'react', + installed: '18.3.1', + latest: '19.2.1', + target: '19.2.1', + majorsBehind: 1, + status: 'breaking', + findings: [removed('app/chat.ts', 3)], + peerConflicts: ['next-mdx-remote-client 1.1.2 declares react >= 18.3.0 < 19.0.0'], + }), + ]), + shown, + ); + expect(out).toContain( + '? possible impact, peer conflict: next-mdx-remote-client 1.1.2 declares react >= 18.3.0 < 19.0.0', + ); + // One breaking site (the finding), not two: the peer conflict adds nothing to the count. + expect(out).toMatch(/\b1 breaking\b|1 site/); + }); + it('says where each target came from: the npm dist-tag, or the one asked for', () => { const latest = formatCheck(report([pkg({ name: 'twilio' })]), shown); expect(latest).toContain('5.0.1 → 5.3.0 minor · latest on npm'); diff --git a/packages/cli/src/format-check.ts b/packages/cli/src/format-check.ts index 3bf7f25..d849cea 100644 --- a/packages/cli/src/format-check.ts +++ b/packages/cli/src/format-check.ts @@ -195,7 +195,10 @@ function rowOf(p: PackageReport, multi: boolean, colors: Colors): Row | undefine by, plan, quiet: - plan.length === 0 && gaps === 0 && !['no-types', 'skipped', 'unknown'].includes(p.status), + plan.length === 0 && + gaps === 0 && + !p.peerConflicts?.length && + !['no-types', 'skipped', 'unknown'].includes(p.status), ...(verdictLine(p) ? { summary: verdictLine(p) } : {}), }; } @@ -313,6 +316,11 @@ function sectionLines(row: Row, colors: Colors): string[] { ); for (const conflict of row.p.companionConflicts ?? []) lines.push(` ${colors.red('✗')} ${conflict}: no consistent upgrade`); + // Left in place, as the repository's own upgrade may leave it: possible impact, never breaking. + for (const conflict of row.p.peerConflicts ?? []) + lines.push( + ` ${colors.magenta('?')} possible impact, peer conflict: ${conflict} ${colors.dim('left where it is, not compiled at another version, not counted as breaking')}`, + ); // An importer the manifest does not show, or one the analysis could not reach: the reader // decides whether the sites above are all of them. for (const note of importers) lines.push(` ${colors.yellow('⚠')} ${note}`); diff --git a/packages/core/src/adapters/typescript/compile.test.ts b/packages/core/src/adapters/typescript/compile.test.ts index ebc61ba..6925019 100644 --- a/packages/core/src/adapters/typescript/compile.test.ts +++ b/packages/core/src/adapters/typescript/compile.test.ts @@ -53,6 +53,60 @@ describe('Signal B: compile against the target version', () => { }); }); + it('does not compile a project whose tsconfig extends what is not installed, and says so', async () => { + const dir = consumerCopy((d) => { + const config = JSON.parse(readFileSync(join(d, 'tsconfig.json'), 'utf8')) as object; + writeFileSync( + join(d, 'tsconfig.json'), + JSON.stringify({ extends: '@not-installed/base/tsconfig.json', ...config }), + ); + }); + const signal = await compileAgainstTarget({ dir }, 'synthetic', join(ROOT, 'synthetic-v2')); + // No diagnostics at all: none of them could be a place to change. + expect(signal.diagnostics).toEqual([]); + expect(signal.skipped).toBe( + 'not compiled: tsconfig.json (extends "@not-installed/base/tsconfig.json" cannot be resolved)', + ); + expect(signal.coverage.compiled).toBe(0); + expect(signal.coverage.notCompiled).toEqual([ + { + path: 'tsconfig.json', + reason: 'extends "@not-installed/base/tsconfig.json" cannot be resolved', + }, + ]); + }); + + it('does not judge files of a nested project whose tsconfig cannot be resolved', async () => { + const dir = consumerCopy((d) => { + // A docs site inside the workspace, with its own tsconfig that extends what is not installed. + mkdirSync(join(d, 'src/docs'), { recursive: true }); + writeFileSync( + join(d, 'src/docs/tsconfig.json'), + JSON.stringify({ extends: '@not-installed/docs/tsconfig.json' }), + ); + writeFileSync( + join(d, 'src/docs/page.ts'), + readFileSync(join(d, 'src/chains.ts'), 'utf8').replace('./lib/index.js', '../lib/index.js'), + ); + }); + const signal = await compileAgainstTarget({ dir }, 'synthetic', join(ROOT, 'synthetic-v2'), { + files: ['src/chains.ts', 'src/docs/page.ts'], + }); + // The workspace's own file is judged; the docs file would give the same error and does not. + expect(signal.diagnostics.map((d) => `${d.file}:${d.line} ${d.code}`)).toEqual([ + 'src/chains.ts:6 2554', + ]); + expect(signal.coverage.compiled).toBe(1); + expect(signal.coverage.total).toBe(2); + expect(signal.coverage.skipped).toEqual([{ reason: 'tsconfig cannot be resolved', count: 1 }]); + expect(signal.coverage.notCompiled).toEqual([ + { + path: 'src/docs/tsconfig.json', + reason: 'extends "@not-installed/docs/tsconfig.json" cannot be resolved', + }, + ]); + }); + it('subtracts pre-existing errors instead of skipping: the upgrade error is still found', async () => { const dir = consumerCopy((d) => writeFileSync(join(d, 'src/broken.ts'), 'export const n: number = "not a number";\n'), diff --git a/packages/core/src/adapters/typescript/compile.ts b/packages/core/src/adapters/typescript/compile.ts index 1ccddc3..0680b3e 100644 --- a/packages/core/src/adapters/typescript/compile.ts +++ b/packages/core/src/adapters/typescript/compile.ts @@ -1,4 +1,12 @@ -import { mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, symlinkSync } from 'node:fs'; +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + realpathSync, + rmSync, + symlinkSync, +} from 'node:fs'; import { builtinModules } from 'node:module'; import { tmpdir } from 'node:os'; import { dirname, join, relative } from 'node:path'; @@ -13,7 +21,7 @@ import type { import { satisfies } from '../../fetch/range.js'; import { onReset } from '../../shared-state.js'; import { findCause, parameterCause } from './cause.js'; -import { type Compiler, repositoryCompiler } from './compiler.js'; +import { type Compiler, repositoryCompiler, unresolvedConfig } from './compiler.js'; import { jsxNamespaceCause } from './config-cause.js'; import { type LoadedRepo, loadedRepo, ownsFile } from './repo.js'; import { @@ -201,6 +209,28 @@ function filesToCheck( const NOT_IN_TSCONFIG = 'not in the workspace tsconfig'; +const nestedConfigs = new Map(); +onReset(() => nestedConfigs.clear()); + +/** + * The unresolvable tsconfig that governs a file, when it sits in a project nested below the + * workspace (the nearest `tsconfig.json` above it, short of the workspace's own). + */ +function nestedUnresolvedConfig( + tsc: typeof ts, + repo: LoadedRepo, + rel: string, +): { path: string; reason: string } | undefined { + for (let dir = dirname(join(repo.dir, rel)); dir.startsWith(`${repo.dir}/`); dir = dirname(dir)) { + const config = join(dir, 'tsconfig.json'); + if (!existsSync(config)) continue; + if (!nestedConfigs.has(config)) nestedConfigs.set(config, unresolvedConfig(tsc, config)); + const reason = nestedConfigs.get(config); + return reason === undefined ? undefined : { path: relative(repo.dir, config), reason }; + } + return undefined; +} + /** What became of the files asked about: compiled, or skipped and why. */ function coverageOf( compiler: Compiler, @@ -721,6 +751,26 @@ export async function compileAgainstTargets( timing: { baselineMs: 0, overlayMs: 0, dependenciesMs: 0 }, }; } + // A project whose tsconfig cannot be resolved is not compiled: its diagnostics would come from + // options the repository never builds with, and none of them is a place to change. + const unresolved = repo.tsconfig ? unresolvedConfig(compiler.ts, repo.tsconfig) : undefined; + if (repo.tsconfig && unresolved !== undefined) { + const requested = options.files?.length ?? 0; + return { + diagnostics: [], + baselineErrors: 0, + skipped: `not compiled: tsconfig.json (${unresolved})`, + coverage: { + ...coverageOf(compiler, 0, 0, { reason: 'tsconfig cannot be resolved', count: requested }), + notCompiled: [{ path: 'tsconfig.json', reason: unresolved }], + }, + unresolvedInTarget: [], + unresolvedFiles: [], + linkedDependencies: [], + unsatisfiedDependencies: [], + timing: { baselineMs: 0, overlayMs: 0, dependenciesMs: 0 }, + }; + } const t0 = Date.now(); const base = nativeBase(repo, compiler); // Diagnostics come from a baseline that checks JavaScript too; files and resolutions are the @@ -755,7 +805,36 @@ export async function compileAgainstTargets( new Map(), ).program; } - const { files, missing } = filesToCheck(repo, repoRef, checked, options.files); + // Files that belong to a nested project whose tsconfig cannot be resolved (a docs site whose + // `extends` is not installed) are not judged under this workspace's options: they are + // dropped, counted, and named in the coverage. + const unresolvedProjects = new Map(); + const requested = (options.files ?? []).filter((rel) => { + const nested = nestedUnresolvedConfig(compiler.ts, repo, rel); + if (!nested) return true; + const known = unresolvedProjects.get(nested.path); + unresolvedProjects.set(nested.path, { reason: nested.reason, count: (known?.count ?? 0) + 1 }); + return false; + }); + const withNotCompiled = (coverage: CompileCoverage): CompileCoverage => { + if (unresolvedProjects.size === 0) return coverage; + const count = [...unresolvedProjects.values()].reduce((n, p) => n + p.count, 0); + return { + ...coverage, + total: coverage.total + count, + skipped: [...coverage.skipped, { reason: 'tsconfig cannot be resolved', count }], + notCompiled: [ + ...(coverage.notCompiled ?? []), + ...[...unresolvedProjects].map(([path, p]) => ({ path, reason: p.reason })), + ], + }; + }; + const { files, missing } = filesToCheck( + repo, + repoRef, + checked, + options.files ? requested : undefined, + ); const baseline = files.flatMap((f) => memoized ? baselineErrorsOf(repo, checked, f) @@ -769,10 +848,12 @@ export async function compileAgainstTargets( diagnostics: [], baselineErrors: baseline.length, skipped, - coverage: coverageOf(compiler, 0, missing, { - reason: skipReasonOf(skipped), - count: files.length, - }), + coverage: withNotCompiled( + coverageOf(compiler, 0, missing, { + reason: skipReasonOf(skipped), + count: files.length, + }), + ), unresolvedInTarget: [], unresolvedFiles: [], linkedDependencies: [], @@ -850,7 +931,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(compiler, files.length, missing), + coverage: withNotCompiled(coverageOf(compiler, files.length, missing)), unresolvedInTarget: [...unresolvedInTarget].sort(), unresolvedFiles: [...unresolvedFiles].sort(), linkedDependencies: deps.linked, diff --git a/packages/core/src/adapters/typescript/compiler.ts b/packages/core/src/adapters/typescript/compiler.ts index 4b1484a..509b3e1 100644 --- a/packages/core/src/adapters/typescript/compiler.ts +++ b/packages/core/src/adapters/typescript/compiler.ts @@ -82,3 +82,31 @@ export function repositoryCompiler(dir: string): Compiler { export function describeCompiler(c: { version: string; own: boolean }): string { return `${c.own ? "the repo's" : 'the bundled'} TypeScript ${c.version}`; } + +/** + * Why a tsconfig cannot be read whole, when it cannot: an `extends` naming a file or package + * that is not there (a project whose dependencies are not installed). Its options are then + * whatever the rest of the file says, not what the repository builds with, so what a compiler + * reports for it says nothing about the upgrade. `undefined`: the config resolves. + */ +export function unresolvedConfig(tsc: typeof ts, tsconfig: string): string | undefined { + try { + const read = tsc.readConfigFile(tsconfig, (p) => tsc.sys.readFile(p)); + const extended = (read.config as { extends?: unknown } | undefined)?.extends; + if (read.config === undefined || extended === undefined) return undefined; + const parsed = tsc.parseJsonConfigFileContent( + read.config, + tsc.sys, + dirname(tsconfig), + undefined, + tsconfig, + ); + // 6053: "File '' not found." + const missing = parsed.errors.find((e) => e.code === 6053); + if (!missing) return undefined; + const names = Array.isArray(extended) ? extended : [extended]; + return `extends ${names.map((n) => JSON.stringify(n)).join(', ')} cannot be resolved`; + } catch { + return undefined; + } +} diff --git a/packages/core/src/adapters/typescript/unresolved-config.test.ts b/packages/core/src/adapters/typescript/unresolved-config.test.ts new file mode 100644 index 0000000..6f84ba8 --- /dev/null +++ b/packages/core/src/adapters/typescript/unresolved-config.test.ts @@ -0,0 +1,38 @@ +import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { ts } from 'ts-morph'; +import { expect, it } from 'vitest'; +import { unresolvedConfig } from './compiler.js'; + +const project = (config: object, files: Record = {}): string => { + const dir = mkdtempSync(join(tmpdir(), 'uptide-unresolved-')); + writeFileSync(join(dir, 'tsconfig.json'), JSON.stringify(config)); + for (const [name, content] of Object.entries(files)) { + mkdirSync(join(dir, name, '..'), { recursive: true }); + writeFileSync(join(dir, name), JSON.stringify(content)); + } + return join(dir, 'tsconfig.json'); +}; + +it('names an extends that points to a package that is not installed', () => { + const tsconfig = project({ extends: '@not-installed/base/tsconfig.json' }); + expect(unresolvedConfig(ts, tsconfig)).toBe( + 'extends "@not-installed/base/tsconfig.json" cannot be resolved', + ); +}); + +it('accepts a config without extends, and one whose extends resolves', () => { + expect(unresolvedConfig(ts, project({ compilerOptions: { strict: true } }))).toBeUndefined(); + const tsconfig = project( + { extends: './base.json' }, + { 'base.json': { compilerOptions: { strict: true } } }, + ); + expect(unresolvedConfig(ts, tsconfig)).toBeUndefined(); +}); + +it('says nothing for a file it cannot read', () => { + expect( + unresolvedConfig(ts, join(tmpdir(), 'uptide-no-such-dir', 'tsconfig.json')), + ).toBeUndefined(); +}); diff --git a/packages/core/src/check/check.ts b/packages/core/src/check/check.ts index ac2c5f3..083f627 100644 --- a/packages/core/src/check/check.ts +++ b/packages/core/src/check/check.ts @@ -616,6 +616,8 @@ export function mergeAcrossWorkspaces( const conflicts = [...new Set(list.flatMap((p) => p.companionConflicts ?? []))]; if (companions.length > 0) combined.companions = companions; if (conflicts.length > 0) combined.companionConflicts = conflicts; + const peerConflicts = [...new Set(list.flatMap((p) => p.peerConflicts ?? []))].sort(); + if (peerConflicts.length > 0) combined.peerConflicts = peerConflicts; const members = [ ...new Map(list.flatMap((p) => p.members ?? []).map((m) => [m.name, m])).values(), ]; @@ -635,6 +637,12 @@ export function mergeAcrossWorkspaces( 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); + const notCompiled = compiled.flatMap((p) => + (p.compile?.coverage?.notCompiled ?? []).map((n) => ({ + path: prefixed(p, n.path), + reason: n.reason, + })), + ); combined.compile = { ...firstCompiled.compile, // Skipped in one workspace, compiled in another: the coverage says how much of each. @@ -647,6 +655,7 @@ export function mergeAcrossWorkspaces( 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 })), + ...(notCompiled.length > 0 ? { notCompiled } : {}), ...(coverages.some((c) => c.compilers) ? { compilers: uniqueCompilers(coverages.flatMap((c) => c.compilers ?? [])) } : {}), @@ -1100,8 +1109,10 @@ async function companionPlans( target, installed: [...installed.values()], manifests: (name) => manifests(name), + lockstep: (packsOf(opts).find((p) => p.name === lead)?.companions ?? []).map((c) => c.name), }); - if (plan.companions.length > 0 || plan.conflicts.length > 0) plans[lead] = plan; + if (plan.companions.length > 0 || plan.conflicts.length > 0 || plan.peerConflicts.length > 0) + plans[lead] = plan; } catch { // A registry that cannot answer leaves the package alone, as before. } @@ -1550,6 +1561,7 @@ async function checkGroup( (prepared.length === 1 ? prepared[0]?.target : targets.at(-1))) as string, ...(plan?.companions.length ? { companions: plan.companions } : {}), ...(plan?.conflicts.length ? { companionConflicts: plan.conflicts } : {}), + ...(plan?.peerConflicts.length ? { peerConflicts: plan.peerConflicts } : {}), majorsBehind: Math.max(...prepared.map((p) => majorsBehind(p.installedVersion, p.target))), findings: [], callSitesChecked: 0, diff --git a/packages/core/src/check/companions.test.ts b/packages/core/src/check/companions.test.ts index 49fa397..582e090 100644 --- a/packages/core/src/check/companions.test.ts +++ b/packages/core/src/check/companions.test.ts @@ -85,7 +85,7 @@ describe('companionsOf', () => { installed: repository, manifests, }); - expect(plan).toEqual({ companions: [], conflicts: [] }); + expect(plan).toEqual({ companions: [], conflicts: [], peerConflicts: [] }); }); it('reports a member with no release that agrees, instead of moving the package alone', async () => { @@ -146,7 +146,7 @@ describe('companionsOf, a member that already agrees', () => { manifests: async (name) => name === '@modelcontextprotocol/sdk' ? versions : manifests(name), }); - expect(plan).toEqual({ companions: [], conflicts: [] }); + expect(plan).toEqual({ companions: [], conflicts: [], peerConflicts: [] }); }); }); @@ -248,6 +248,68 @@ describe('companionsOf, react 18 → 19', () => { ]); }); + it('leaves a package whose peer range rejects the target in place and reports it as a peer conflict', async () => { + // next-mdx-remote-client 1.x peers react `>= 18.3.0 < 19.0.0`: the real upgrade kept it at + // 1.1.2. It is not released with react (no 19.2.1), so nothing moves it, and nothing is + // compiled at another version of it. + const mdx = { + '1.1.2': { peerDependencies: { react: '>= 18.3.0 < 19.0.0' } }, + '2.0.0': { peerDependencies: { react: '>=19.0.0' } }, + '2.1.12': { peerDependencies: { react: '>= 19.1.0' } }, + }; + const plan = await companionsOf({ + name: 'react', + target: '19.2.1', + installed: [ + installed('react', '18.3.1', ['apps/docs']), + { + ...installed('next-mdx-remote-client', '1.1.2', ['apps/docs']), + manifest: mdx['1.1.2'], + }, + { + ...installed('react-dom', '18.3.1', ['apps/docs']), + manifest: { peerDependencies: { react: '^18.3.1' } }, + }, + ], + manifests: async (name) => + name === 'next-mdx-remote-client' + ? mdx + : name === 'react-dom' + ? { ...react['react-dom'], '18.3.1': { peerDependencies: { react: '^18.3.1' } } } + : manifests(name), + }); + expect(plan.peerConflicts).toEqual([ + 'next-mdx-remote-client 1.1.2 declares react >= 18.3.0 < 19.0.0', + ]); + // Not an upgrade that cannot agree, and not moved: only the release-group member moves. + expect(plan.conflicts).toEqual([]); + expect(plan.companions.map((c) => `${c.name} ${c.to}`)).toEqual(['react-dom 19.2.1']); + }); + + it('moves a package the pack names even when its installed peer range accepts the target', async () => { + // No release of it at the target's version, so only the pack's list moves it. + const wrapper = { + '2.0.0': { peerDependencies: { react: '^18 || ^19' } }, + '2.1.0': { peerDependencies: { react: '^18 || ^19' } }, + }; + const input = { + name: 'react', + target: '19.2.1', + installed: [ + installed('react', '18.3.1', ['.']), + { ...installed('react-wrapper', '2.0.0', ['.']), manifest: wrapper['2.0.0'] }, + ], + manifests: async (name: string) => (name === 'react-wrapper' ? wrapper : manifests(name)), + }; + const alone = await companionsOf(input); + expect(alone.companions).toEqual([]); + expect(alone.peerConflicts).toEqual([]); + const named = await companionsOf({ ...input, lockstep: ['react-wrapper'] }); + expect(named.companions.map((c) => `${c.name} ${c.from} → ${c.to}`)).toEqual([ + 'react-wrapper 2.0.0 → 2.1.0', + ]); + }); + it('leaves a types package declared in an unrelated workspace alone', async () => { const plan = await companionsOf({ name: 'react', @@ -258,6 +320,6 @@ describe('companionsOf, react 18 → 19', () => { ], manifests, }); - expect(plan).toEqual({ companions: [], conflicts: [] }); + expect(plan).toEqual({ companions: [], conflicts: [], peerConflicts: [] }); }); }); diff --git a/packages/core/src/check/companions.ts b/packages/core/src/check/companions.ts index 4416811..c8fa96b 100644 --- a/packages/core/src/check/companions.ts +++ b/packages/core/src/check/companions.ts @@ -20,6 +20,12 @@ import { compareVersions, parseVersion } from './version.js'; * 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`). + * A package that only peers on the lead moves when it is released in lockstep with it (a + * release at the target's own version: `react-dom`, `@types/react`) or the pack names it in + * `companions`; either way even when its installed range already accepts the target. Any + * other package whose installed peer range rejects the target stays where it is, is never + * compiled at another version, and is reported as a peer conflict (`peerConflicts`): real + * upgrades often leave one in place, and what that breaks is for a person to judge. * `@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`. @@ -44,6 +50,11 @@ export interface CompanionPlan { companions: Companion[]; /** Members of the group with no release that agrees: the upgrade cannot be consistent. */ conflicts: string[]; + /** + * Packages left where they are though their installed peer range rejects the target: + * `next-mdx-remote-client 1.1.2 declares react >= 18.3.0 < 19.0.0`. Possible impact, never breaking. + */ + peerConflicts: string[]; /** The group as `list` names it (`@ai-sdk family, shared @ai-sdk/provider-utils`). */ reason?: string; } @@ -79,6 +90,34 @@ function related(lead: InstalledDependency, other: InstalledDependency): boolean return false; } +/** Whether `name` is released with `lead@target`: it has a release at the target's own version that asks for it. */ +function releasedWith(lead: string, target: string, versions: Manifests): boolean { + const same = versions[target]; + const range = same?.dependencies?.[lead] ?? same?.peerDependencies?.[lead]; + return same !== undefined && range !== undefined && accepts(range, target); +} + +/** + * ` declares ` when the package only peers on `host`, its + * installed range rejects the target, and nothing says it moves with it (named by the pack, or + * released at the target's own version). Undefined: it is a companion, or it agrees already. + */ +function peerConflictOf( + installed: InstalledDependency, + host: string, + target: string, + versions: Manifests, + lockstep: readonly string[] = [], +): string | undefined { + if (lockstep.includes(installed.name) || releasedWith(host, target, versions)) return undefined; + const current = versions[installed.version] ?? installed.manifest; + // A dependency (an exact pin) is moved by the pin logic; only a bare peer is left alone. + if (current.dependencies?.[host] !== undefined) return undefined; + const range = current.peerDependencies?.[host]; + if (range === undefined || accepts(range, target)) return undefined; + return `${installed.name} ${installed.version} declares ${host} ${range}`; +} + /** The release of `name` that agrees with `lead@target`, and why; undefined when none does. */ function agreeing( installed: InstalledDependency, @@ -86,6 +125,7 @@ function agreeing( target: string, targetManifest: Pick, versions: Manifests, + lockstep: readonly string[] = [], ): { version: string; reason: string } | undefined { const name = installed.name; const pinned = targetManifest.dependencies?.[name]; @@ -103,7 +143,8 @@ function agreeing( }; const current = versions[installed.version] ?? installed.manifest; const range = current.dependencies?.[lead] ?? current.peerDependencies?.[lead]; - if (range !== undefined && accepts(range, target)) + // A package the pack names moves even when the installed copy accepts the target. + if (range !== undefined && accepts(range, target) && !lockstep.includes(name)) return { version: installed.version, reason: `${name} ${installed.version} accepts ${lead} ${target}`, @@ -166,11 +207,14 @@ export async function companionsOf(input: { target: string; installed: InstalledDependency[]; manifests: (name: string) => Promise; + /** Packages the pack says always move with the lead (`MigrationPack.companions`). */ + lockstep?: readonly string[]; }): Promise { + const none: CompanionPlan = { companions: [], conflicts: [], peerConflicts: [] }; const lead = input.installed.find((d) => d.name === input.name); - if (!lead) return { companions: [], conflicts: [] }; + if (!lead) return none; const targetManifest = (await input.manifests(input.name))[input.target]; - if (!targetManifest) return { companions: [], conflicts: [] }; + if (!targetManifest) return none; // 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); @@ -198,7 +242,7 @@ export async function companionsOf(input: { // until the closure is complete } const candidates = members.filter((d) => d !== lead); - if (candidates.length === 0) return { companions: [], conflicts: [] }; + if (candidates.length === 0) return none; // 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`). @@ -207,6 +251,8 @@ export async function companionsOf(input: { ]); const moves = new Map(); const typesLinked = new Set(); + /** Peer-linked packages left in place, by name. */ + const peerConflicts = new Map(); 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. @@ -235,8 +281,16 @@ export async function companionsOf(input: { 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; + const left = peerConflictOf(c, host, at.version, versions, input.lockstep); + if (left) { + peerConflicts.set(c.name, left); + continue; + } + move = agreeing(c, host, at.version, at.manifest, versions, input.lockstep); + if (move) { + peerConflicts.delete(c.name); + break; + } } } moves.set(c.name, move); @@ -274,6 +328,9 @@ export async function companionsOf(input: { g.members.some((m) => m.name === lead.name), ); const inGroup = new Set((group?.members ?? [listed(lead, input.target)]).map((m) => m.name)); + // A package the pack names is a member whatever `list` draws: the pack knows it moves with the lead. + for (const c of candidates) + if (input.lockstep?.includes(c.name) && moves.get(c.name) !== undefined) inGroup.add(c.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; ) { @@ -292,11 +349,13 @@ export async function companionsOf(input: { grew = true; } } - if (inGroup.size < 2) return { companions: [], conflicts: [] }; + if (inGroup.size < 2) return none; const companions: Companion[] = []; const conflicts: string[] = []; for (const member of candidates.filter((c) => inGroup.has(c.name))) { const move = moves.get(member.name); + // Left in place on purpose: reported as a peer conflict, not as an upgrade that cannot agree. + if (!move && peerConflicts.has(member.name)) continue; if (!move) { conflicts.push( `${member.name} ${member.version} has no release that agrees with ${lead.name} ${input.target}`, @@ -322,6 +381,7 @@ export async function companionsOf(input: { return { companions: companions.sort((a, b) => a.name.localeCompare(b.name)), conflicts, + peerConflicts: [...peerConflicts.values()].sort(), ...(reason ? { reason } : {}), }; } diff --git a/packages/core/src/check/verdict.test.ts b/packages/core/src/check/verdict.test.ts index 282d5ca..5a795e6 100644 --- a/packages/core/src/check/verdict.test.ts +++ b/packages/core/src/check/verdict.test.ts @@ -90,3 +90,21 @@ it('says the types are partly verified when some files that use the package were '0 breaking · compiled against 4.6.5: 0 new type errors', ); }); + +it('names a project that was not compiled, with its path and why', () => { + const coverage = { + compiled: 5, + total: 7, + workspaces: 2, + skipped: [{ reason: 'tsconfig cannot be resolved', count: 2 }], + notCompiled: [ + { + path: 'docs/tsconfig.json', + reason: 'extends "@tsconfig/docusaurus/tsconfig.json" cannot be resolved', + }, + ], + }; + expect(coverageLine(coverage)).toBe( + 'compiled 5 of 7 files in 2 workspaces; skipped: tsconfig cannot be resolved (2); not compiled: docs/tsconfig.json (extends "@tsconfig/docusaurus/tsconfig.json" cannot be resolved)', + ); +}); diff --git a/packages/core/src/check/verdict.ts b/packages/core/src/check/verdict.ts index 9e40f04..1a363b6 100644 --- a/packages/core/src/check/verdict.ts +++ b/packages/core/src/check/verdict.ts @@ -27,7 +27,8 @@ export function coverageLine(c: NonNullable['coverage' if (!c) return ''; const skipped = c.skipped.map((r) => `${r.reason} (${r.count})`).join(', '); const compilers = compilersPhrase(c.compilers ?? []); - return `compiled ${c.compiled} of ${count(c.total, 'file')} in ${count(c.workspaces, 'workspace')}${compilers ? ` with ${compilers}` : ''}${skipped ? `; skipped: ${skipped}` : ''}`; + const notCompiled = (c.notCompiled ?? []).map((n) => `; not compiled: ${n.path} (${n.reason})`); + return `compiled ${c.compiled} of ${count(c.total, 'file')} in ${count(c.workspaces, 'workspace')}${compilers ? ` with ${compilers}` : ''}${skipped ? `; skipped: ${skipped}` : ''}${notCompiled.join('')}`; } /** diff --git a/packages/core/src/domain/report.ts b/packages/core/src/domain/report.ts index a65d7e9..23de32f 100644 --- a/packages/core/src/domain/report.ts +++ b/packages/core/src/domain/report.ts @@ -129,6 +129,12 @@ export interface PackageReport { companions?: { name: string; from: string; to: string; reason: string }[]; /** Members of that group with no release that agrees with the target. */ companionConflicts?: string[]; + /** + * Packages left in place because their installed peer range rejects the target and nothing + * moves them with it: `next-mdx-remote-client 1.1.2 declares react >= 18.3.0 < 19.0.0`. + * Possible impact; never compiled at another version, never counted as breaking. + */ + peerConflicts?: string[]; /** Set when the entry merges several workspaces (`workspace` is then `*`). */ workspaces?: string[]; /** diff --git a/packages/core/src/domain/usage.ts b/packages/core/src/domain/usage.ts index 974dabb..b7030f4 100644 --- a/packages/core/src/domain/usage.ts +++ b/packages/core/src/domain/usage.ts @@ -139,6 +139,12 @@ export interface CompileCoverage { total: number; /** Why the rest were not compiled, one entry per reason. */ skipped: { reason: string; count: number }[]; + /** + * Projects whose tsconfig cannot be resolved (an `extends` to a package that is not installed): + * not compiled, and their diagnostics are never findings. `path` is relative to the workspace + * here and to the repository once the workspaces are merged. + */ + notCompiled?: { path: string; reason: string }[]; /** Which TypeScript judged: the repository's own install, or the bundled fallback. Absent in reports stored before it was kept. */ compilers?: { version: string; own: boolean }[]; } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 05bfc0d..a09d1f9 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1,3 +1,4 @@ +export { unresolvedConfig } from './adapters/typescript/compiler.js'; export { createTypescriptAdapter, type TypescriptAdapterOptions, diff --git a/packages/core/src/packs/ai/index.ts b/packages/core/src/packs/ai/index.ts index ab8ceb7..6779aa6 100644 --- a/packages/core/src/packs/ai/index.ts +++ b/packages/core/src/packs/ai/index.ts @@ -79,6 +79,16 @@ function onlyOnLine( } export const aiPack = definePack({ + // A community provider that only peers on `ai`: its 3.x range rejects `ai` 7 and nothing in the + // registry says to move it. The AI SDK's own 7.0 guide does not name community providers, so the + // source is the package's own documentation, which says each `ai-sdk-ollama` major targets one + // `ai` major (4.x is `ai@^7`, 3.x stays on `ai@^6`). + companions: [ + { + name: 'ai-sdk-ollama', + source: 'https://github.com/jagreehal/ai-sdk-ollama/blob/main/README.md', + }, + ], meta: { package: 'ai', from: '>=6 <7', diff --git a/packages/core/src/packs/contract.test.ts b/packages/core/src/packs/contract.test.ts index 25ca25a..91e0850 100644 --- a/packages/core/src/packs/contract.test.ts +++ b/packages/core/src/packs/contract.test.ts @@ -4,6 +4,7 @@ import { fileURLToPath } from 'node:url'; import { describe, expect, it } from 'vitest'; import type { Finding } from '../domain/report.js'; import { + companionProblems, definePack, type GroundTruth, recordedStatus, @@ -126,6 +127,39 @@ describe('the verified gate', () => { }); }); +describe('companionProblems', () => { + const source = 'https://example.com/toy/changelog'; + + it('accepts an entry with a name and an https source, and no entries at all', () => { + expect(companionProblems(undefined, 'toy')).toEqual([]); + expect(companionProblems([{ name: 'toy-plugin', source }], 'toy')).toEqual([]); + }); + + it('refuses an entry with no source, an empty one, or one that is not an https URL', () => { + const problems = companionProblems( + [ + { name: 'no-source' }, + { name: 'empty-source', source: '' }, + { name: 'plain-text', source: 'the migration guide' }, + { name: 'insecure', source: 'http://example.com/guide' }, + { name: 'local', source: 'file:///etc/hosts' }, + ], + 'toy', + ); + expect(problems).toHaveLength(5); + expect(problems[0]).toBe( + 'companions[0] "no-source": source must be the https URL of the official migration guide or changelog that says it moves with toy', + ); + }); + + it("refuses an entry without a name, or one that names the pack's own package", () => { + expect(companionProblems([{ source }], 'toy')).toEqual(['companions[0]: name is empty']); + expect(companionProblems([{ name: 'toy', source }], 'toy')).toEqual([ + 'companions[0] "toy": a pack does not name its own package', + ]); + }); +}); + describe('definePack', () => { const finding = (line: number, column: number, rule?: string): Finding => ({ change: { @@ -186,6 +220,13 @@ describe('definePack', () => { instructions: 'only the site', }); + it('carries the packages the pack says always move with it, and none by default', () => { + expect(pack.companions).toBeUndefined(); + const companions = [{ name: 'toy-plugin', source: 'https://example.com/toy/changelog' }]; + const named = definePack({ meta: pack.meta, rules: [], instructions: 'x', companions }); + expect(named.companions).toEqual(companions); + }); + it('rewrites only the reported occurrence, within the supported versions', () => { const text = 'oldName(oldName(1));'; const out = pack.transform(text, finding(1, 9), { diff --git a/packages/core/src/packs/contract.ts b/packages/core/src/packs/contract.ts index 2f86861..c67eb7c 100644 --- a/packages/core/src/packs/contract.ts +++ b/packages/core/src/packs/contract.ts @@ -3,7 +3,13 @@ import { join } from 'node:path'; import type { Finding } from '../domain/report.js'; import type { Usage } from '../domain/usage.js'; import { satisfies } from '../fetch/range.js'; -import type { MigrationPack, MigrationRule, PackContext, TransformResult } from './types.js'; +import type { + MigrationPack, + MigrationRule, + PackCompanion, + PackContext, + TransformResult, +} from './types.js'; /** * The public contract of a migration pack (docs/packs.md). `MigrationPack` is what the runner @@ -298,6 +304,8 @@ export interface PackSpec { instructions: string; /** The version `fix` falls back to when the registry cannot answer. */ defaultTarget?: string; + /** Packages that always move with the package, each with its official source (`MigrationPack.companions`). */ + companions?: readonly PackCompanion[]; } /** @@ -313,6 +321,7 @@ export function definePack(spec: PackSpec): Pack { rules: spec.rules, behavior, instructions: spec.instructions, + ...(spec.companions ? { companions: spec.companions } : {}), defaultTarget: spec.defaultTarget ?? '', supports: (from, to) => satisfies(from, spec.meta.from) && satisfies(to, spec.meta.to), transform(text, finding, context) { @@ -392,3 +401,32 @@ export function replaceAtSite( lines[index] = `${line.slice(0, match.index)}${after}${line.slice(match.index + before.length)}`; return { text: lines.join('\n'), applied: true, reason: `\`${before}\` is \`${after}\`` }; } + +/** + * What is wrong with a pack's `companions`, as sentences: `pack test` refuses to score a pack + * with any. Every entry names the package and an `https` URL of the official migration guide or + * changelog that says it moves with the leader; an entry never rests on a ground-truth + * repository alone, which only shows what one repository did. + */ +export function companionProblems( + companions: readonly Partial[] | undefined, + packageName: string, +): string[] { + const problems: string[] = []; + (companions ?? []).forEach((c, i) => { + const where = `companions[${i}]${c.name ? ` "${c.name}"` : ''}`; + if (!c.name?.trim()) problems.push(`${where}: name is empty`); + if (c.name === packageName) problems.push(`${where}: a pack does not name its own package`); + let https = false; + try { + https = c.source !== undefined && new URL(c.source).protocol === 'https:'; + } catch { + https = false; + } + if (!https) + problems.push( + `${where}: source must be the https URL of the official migration guide or changelog that says it moves with ${packageName}`, + ); + }); + return problems; +} diff --git a/packages/core/src/packs/tooling/pack-test.test.ts b/packages/core/src/packs/tooling/pack-test.test.ts new file mode 100644 index 0000000..5598196 --- /dev/null +++ b/packages/core/src/packs/tooling/pack-test.test.ts @@ -0,0 +1,28 @@ +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { expect, it } from 'vitest'; +import { aiPack } from '../ai/index.js'; +import type { PackVerification } from '../contract.js'; +import { registeredPacks } from '../index.js'; +import { testPack } from './pack-test.js'; + +const root = fileURLToPath(new URL('../../../../../', import.meta.url)); +const verification = JSON.parse( + readFileSync(fileURLToPath(new URL('../ai/verification.json', import.meta.url)), 'utf8'), +) as PackVerification; + +it('fails a pack with a companion that has no source, and scores nothing', async () => { + const pack = { ...aiPack, companions: [{ name: 'ai-sdk-ollama', source: '' }] }; + const report = await testPack({ dir: 'ai', pack, verification }, { root, fixturesOnly: true }); + expect(report.problems).toEqual([ + 'companions[0] "ai-sdk-ollama": source must be the https URL of the official migration guide or changelog that says it moves with ai', + ]); + expect(report.passed).toBe(false); +}); + +it('passes every registered pack: each companion it names carries its source', async () => { + for (const entry of registeredPacks()) { + const report = await testPack(entry, { root, fixturesOnly: true }); + expect(report.problems, entry.pack.name).toEqual([]); + } +}); diff --git a/packages/core/src/packs/tooling/pack-test.ts b/packages/core/src/packs/tooling/pack-test.ts index b0c7361..345d87d 100644 --- a/packages/core/src/packs/tooling/pack-test.ts +++ b/packages/core/src/packs/tooling/pack-test.ts @@ -5,6 +5,7 @@ import { check } from '../../check/check.js'; import type { Companion } from '../../check/companions.js'; import type { CheckReport } from '../../domain/report.js'; import { + companionProblems, type GroundTruth, type GroundTruthRepo, type Pack, @@ -246,6 +247,7 @@ export async function testPack( const problems: string[] = []; if (!pack.meta.sources.length) problems.push('meta.sources lists no changelog or guide'); if (!pack.meta.maintainer) problems.push('meta.maintainer is empty'); + problems.push(...companionProblems(pack.companions, pack.name)); const fixtures = runFixtures(pack, dir, { update: options.updateFixtures === true }); if (fixtures.cases.length === 0 && pack.rules.some((r) => r.rewrite || r.detect)) problems.push('rules that rewrite or detect need fixtures (fixtures//before.ts)'); diff --git a/packages/core/src/packs/types.ts b/packages/core/src/packs/types.ts index 404651d..fb9d131 100644 --- a/packages/core/src/packs/types.ts +++ b/packages/core/src/packs/types.ts @@ -92,8 +92,23 @@ export interface MigrationRule { perFile?: boolean; } /** Packs own dependency knowledge. The runner owns git, verification, and publication. */ +/** A package a pack says always moves with its own, and the official page that says so. */ +export interface PackCompanion { + name: string; + /** An `https` URL to the official migration guide or changelog that says it moves with the leader. */ + source: string; +} + export interface MigrationPack { readonly name: string; + /** + * Packages that always move with this one, even when the installed version of each already + * accepts the target. Each carries the `source` that says so: the URL of an official + * migration guide or changelog (the package's own, when the leader's does not name it). + * Release-group members published at the target's own version (`react-dom`) and `@types/*` + * need no entry. + */ + readonly companions?: readonly PackCompanion[]; readonly defaultTarget: string; readonly rules: readonly MigrationRule[]; supports(from: string, to: string): boolean; diff --git a/scripts/packs-truth-compiler.ts b/scripts/packs-truth-compiler.ts index 38368d5..da471ba 100644 --- a/scripts/packs-truth-compiler.ts +++ b/scripts/packs-truth-compiler.ts @@ -1,5 +1,6 @@ import { createRequire } from 'node:module'; import { dirname, join, relative } from 'node:path'; +import { unresolvedConfig } from '@uptide/core'; export interface Diagnostic { file: string; @@ -18,7 +19,11 @@ const DEPRECATED = new Set([6385, 6387]); export function compilerDiagnostics( project: string, configs: string[], -): { compiler: string; diagnostics: Diagnostic[] } { +): { + compiler: string; + diagnostics: Diagnostic[]; + notCompiled: { path: string; reason: string }[]; +} { const require = createRequire(join(project, 'package.json')); let ts = require( require.resolve('typescript', { @@ -33,8 +38,16 @@ export function compilerDiagnostics( ts = own; } const out: Diagnostic[] = []; + const notCompiled: { path: string; reason: string }[] = []; for (const config of configs) { const file = join(project, config); + // A tsconfig that extends what is not installed is no project the repository builds: its + // diagnostics come from options it never has, so none of them is a site (as `check` does). + const unresolved = unresolvedConfig(ts, file); + if (unresolved !== undefined) { + notCompiled.push({ path: config, reason: unresolved }); + continue; + } const parsed = ts.getParsedCommandLineOfConfigFile( file, {}, @@ -77,6 +90,7 @@ export function compilerDiagnostics( } return { compiler, + notCompiled, diagnostics: out.sort((a, b) => a.file.localeCompare(b.file) || a.line - b.line), }; } diff --git a/scripts/packs-truth.ts b/scripts/packs-truth.ts index 5b1ff02..a361019 100644 --- a/scripts/packs-truth.ts +++ b/scripts/packs-truth.ts @@ -184,7 +184,11 @@ for (const swap of swaps) { for (const at of installs(swap.name, project)) links.push({ at, dir }); } -const { compiler: compilerName, diagnostics: baseline } = compilerDiagnostics(project, configs); +const { + compiler: compilerName, + diagnostics: baseline, + notCompiled, +} = compilerDiagnostics(project, configs); for (const link of links) { const at = join(project, link.at); renameSync(at, `${at}.uptide-orig`); @@ -224,6 +228,7 @@ console.log( links: links.map((l) => `${l.at} → ${basename(l.dir)}`), compiledWith: compilerName, tsconfigs: configs, + notCompiled, compiler: compiler.map((d) => ({ ...d, message: d.message.slice(0, 200),