From 22a8224f68991eaa31f3d7b117712fe755d4cd0d Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 17:41:08 +0000 Subject: [PATCH 1/6] fix(check): skip a project whose tsconfig cannot be resolved; move a companion only when its peers reject the target, to the lowest release that accepts it A tsconfig that extends a package that is not installed is reported as "not compiled: ()" in the coverage line and contributes no findings, in check and in the ground-truth draft script. A companion whose installed copy accepts the new target is no longer moved; one that must move goes to the lowest release above the installed one whose range accepts the target. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01AKRx4kuV6oi25MP5WguxW9 Signed-off-by: Claude --- .../check-unresolved-tsconfig-companions.md | 15 +++++ .../src/adapters/typescript/compile.test.ts | 23 ++++++++ .../core/src/adapters/typescript/compile.ts | 22 +++++++- .../core/src/adapters/typescript/compiler.ts | 28 ++++++++++ .../typescript/unresolved-config.test.ts | 38 +++++++++++++ packages/core/src/check/check.ts | 7 +++ packages/core/src/check/companions.test.ts | 55 +++++++++++++++++-- packages/core/src/check/companions.ts | 40 ++++++++------ packages/core/src/check/verdict.test.ts | 18 ++++++ packages/core/src/check/verdict.ts | 3 +- packages/core/src/domain/usage.ts | 6 ++ packages/core/src/index.ts | 1 + scripts/packs-truth-compiler.ts | 16 +++++- scripts/packs-truth.ts | 7 ++- 14 files changed, 254 insertions(+), 25 deletions(-) create mode 100644 .changeset/check-unresolved-tsconfig-companions.md create mode 100644 packages/core/src/adapters/typescript/unresolved-config.test.ts diff --git a/.changeset/check-unresolved-tsconfig-companions.md b/.changeset/check-unresolved-tsconfig-companions.md new file mode 100644 index 0000000..31d8fca --- /dev/null +++ b/.changeset/check-unresolved-tsconfig-companions.md @@ -0,0 +1,15 @@ +--- +"uptide": patch +--- + +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: + `compiled 5 of 7 files in 2 workspaces; not compiled: docs/tsconfig.json (extends "@tsconfig/docusaurus/tsconfig.json" cannot be resolved)`. +- **Companions move only when they have to, and by the smallest step.** A companion whose + installed version already accepts the new target stays where it is. One whose peer range + rejects it moves to the lowest release above the installed one that accepts the target (a + release that pins the target exactly still wins), not the newest major the registry has. diff --git a/packages/core/src/adapters/typescript/compile.test.ts b/packages/core/src/adapters/typescript/compile.test.ts index ebc61ba..723b88b 100644 --- a/packages/core/src/adapters/typescript/compile.test.ts +++ b/packages/core/src/adapters/typescript/compile.test.ts @@ -53,6 +53,29 @@ 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('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..240c4ac 100644 --- a/packages/core/src/adapters/typescript/compile.ts +++ b/packages/core/src/adapters/typescript/compile.ts @@ -13,7 +13,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 { @@ -721,6 +721,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 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..d5dc00e 100644 --- a/packages/core/src/check/check.ts +++ b/packages/core/src/check/check.ts @@ -635,6 +635,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 +653,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 ?? [])) } : {}), diff --git a/packages/core/src/check/companions.test.ts b/packages/core/src/check/companions.test.ts index 49fa397..ec5986f 100644 --- a/packages/core/src/check/companions.test.ts +++ b/packages/core/src/check/companions.test.ts @@ -222,7 +222,7 @@ describe('companionsOf, react 18 → 19', () => { 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 () => { + it('leaves a companion alone when its installed copy already accepts the target', async () => { const plan = await companionsOf({ name: 'react', target: '19.2.1', @@ -242,12 +242,59 @@ describe('companionsOf, react 18 → 19', () => { } : manifests(name), }); - expect(plan.companions.map((c) => `${c.name} ${c.to}`)).toEqual([ - '@types/react 19.2.7', - 'react-dom 19.2.1', + expect(plan.companions.map((c) => `${c.name} ${c.to}`)).toEqual(['@types/react 19.2.7']); + }); + + it('moves a companion whose peer range rejects the target to the lowest release that accepts it', async () => { + // next-mdx-remote-client 1.x peers react `>= 18.3.0 < 19.0.0`; 2.0.0 is the first release + // that takes 19. The newest (2.1.12 wants `>= 19.1.0`) is a different, larger upgrade. + const mdx = { + '1.1.2': { peerDependencies: { react: '>= 18.3.0 < 19.0.0' } }, + '1.1.9': { peerDependencies: { react: '>= 18.3.0 < 19.0.0' } }, + '2.0.0': { peerDependencies: { react: '>=19.0.0' } }, + '2.1.2': { peerDependencies: { react: '^19.1.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'], + }, + ], + manifests: async (name) => (name === 'next-mdx-remote-client' ? mdx : manifests(name)), + }); + expect(plan.companions).toEqual([ + { + name: 'next-mdx-remote-client', + from: '1.1.2', + to: '2.0.0', + reason: 'next-mdx-remote-client 2.0.0 accepts react 19.2.1 (>=19.0.0)', + }, ]); }); + it('does not pick a release older than the installed one', async () => { + const lib = { + '0.9.0': { peerDependencies: { react: '*' } }, + '1.0.0': { peerDependencies: { react: '^18' } }, + '2.0.0': { peerDependencies: { react: '^19' } }, + }; + const plan = await companionsOf({ + name: 'react', + target: '19.0.0', + installed: [ + installed('react', '18.2.0', ['.']), + { ...installed('lib', '1.0.0', ['.']), manifest: lib['1.0.0'] }, + ], + manifests: async (name) => (name === 'lib' ? lib : manifests(name)), + }); + expect(plan.companions.map((c) => `${c.name} ${c.to}`)).toEqual(['lib 2.0.0']); + }); + it('leaves a types package declared in an unrelated workspace alone', async () => { const plan = await companionsOf({ name: 'react', diff --git a/packages/core/src/check/companions.ts b/packages/core/src/check/companions.ts index 4416811..e92cb94 100644 --- a/packages/core/src/check/companions.ts +++ b/packages/core/src/check/companions.ts @@ -91,9 +91,17 @@ function agreeing( const pinned = targetManifest.dependencies?.[name]; if (pinned && EXACT.test(pinned) && versions[pinned]) return { version: pinned, reason: `${lead} ${target} pins ${name} ${pinned}` }; + // Nothing to move when the installed copy already accepts the target: a companion moves + // only when its own peer or dependency range rejects the new version. + const current = versions[installed.version] ?? installed.manifest; + const range = current.dependencies?.[lead] ?? current.peerDependencies?.[lead]; + if (range !== undefined && accepts(range, target)) + return { + version: installed.version, + reason: `${name} ${installed.version} accepts ${lead} ${target}`, + }; // 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. + // (react-dom 19.0.0 peer-requires react ^19.0.0). const same = versions[target]; const sameRange = same?.dependencies?.[lead] ?? same?.peerDependencies?.[lead]; if (same && sameRange !== undefined && accepts(sameRange, target) && target !== installed.version) @@ -101,24 +109,22 @@ function agreeing( 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)) - return { - version: installed.version, - reason: `${name} ${installed.version} accepts ${lead} ${target}`, - }; const candidates = Object.keys(versions).filter(stable).sort(compareVersions).reverse(); + // A release that pins the target exactly agrees with it: the newest such release. for (const version of candidates) { const m = versions[version] ?? {}; - const range = m.dependencies?.[lead] ?? m.peerDependencies?.[lead]; - if (range === undefined || !accepts(range, target)) continue; - return { - version, - reason: EXACT.test(range) - ? `${name} ${version} pins ${lead} ${target}` - : `${name} ${version} accepts ${lead} ${target} (${range})`, - }; + const pin = m.dependencies?.[lead] ?? m.peerDependencies?.[lead]; + if (pin !== undefined && EXACT.test(pin) && accepts(pin, target)) + return { version, reason: `${name} ${version} pins ${lead} ${target}` }; + } + // Else the lowest release past the installed one whose range accepts the target: the + // smallest move that agrees, not the newest major the registry has. + for (const version of [...candidates].reverse()) { + if (compareVersions(version, installed.version) <= 0) continue; + const m = versions[version] ?? {}; + const accepted = m.dependencies?.[lead] ?? m.peerDependencies?.[lead]; + if (accepted === undefined || !accepts(accepted, target)) continue; + return { version, reason: `${name} ${version} accepts ${lead} ${target} (${accepted})` }; } const leadPins = exactPins(targetManifest); for (const version of candidates) { 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/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/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), From 6841f2b5f30cb866ed09a8e05b50423b16b660dd Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 17:52:48 +0000 Subject: [PATCH 2/6] fix(check): drop files governed by a nested project with an unresolvable tsconfig The workspace that reaches such a file (excalidraw's root reaches dev-docs) would judge it under its own options. The file is counted as not compiled and its project named in the coverage line. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01AKRx4kuV6oi25MP5WguxW9 Signed-off-by: Claude --- .../check-unresolved-tsconfig-companions.md | 3 +- .../src/adapters/typescript/compile.test.ts | 31 ++++++++ .../core/src/adapters/typescript/compile.ts | 75 +++++++++++++++++-- 3 files changed, 101 insertions(+), 8 deletions(-) diff --git a/.changeset/check-unresolved-tsconfig-companions.md b/.changeset/check-unresolved-tsconfig-companions.md index 31d8fca..3396432 100644 --- a/.changeset/check-unresolved-tsconfig-companions.md +++ b/.changeset/check-unresolved-tsconfig-companions.md @@ -7,7 +7,8 @@ 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: + 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)`. - **Companions move only when they have to, and by the smallest step.** A companion whose installed version already accepts the new target stays where it is. One whose peer range diff --git a/packages/core/src/adapters/typescript/compile.test.ts b/packages/core/src/adapters/typescript/compile.test.ts index 723b88b..6925019 100644 --- a/packages/core/src/adapters/typescript/compile.test.ts +++ b/packages/core/src/adapters/typescript/compile.test.ts @@ -76,6 +76,37 @@ describe('Signal B: compile against the target version', () => { ]); }); + 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 240c4ac..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'; @@ -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, @@ -775,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) @@ -789,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: [], @@ -870,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, From 8594a866102f39e9c241cca9891cef07e65060f1 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 18:29:37 +0000 Subject: [PATCH 3/6] fix(check): move a companion only when it is released with the package or the pack names it; report a rejecting peer as possible impact Replaces the rule that moved any companion whose peer range rejects the target. Packages in a pack's companions, release-group members published at the target's version and @types/* always move, even when the installed range accepts the target. Any other package whose installed peer range rejects the target stays where it is, is not compiled at another version, and is listed as a peer conflict under possible impact, never as breaking. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01AKRx4kuV6oi25MP5WguxW9 Signed-off-by: Claude --- .../check-unresolved-tsconfig-companions.md | 11 +- docs/packs.md | 6 + packages/cli/src/format-check.test.ts | 23 ++++ packages/cli/src/format-check.ts | 10 +- packages/core/src/check/check.ts | 7 +- packages/core/src/check/companions.test.ts | 75 +++++++----- packages/core/src/check/companions.ts | 112 +++++++++++++----- packages/core/src/domain/report.ts | 6 + packages/core/src/packs/types.ts | 6 + 9 files changed, 191 insertions(+), 65 deletions(-) diff --git a/.changeset/check-unresolved-tsconfig-companions.md b/.changeset/check-unresolved-tsconfig-companions.md index 3396432..7001fb5 100644 --- a/.changeset/check-unresolved-tsconfig-companions.md +++ b/.changeset/check-unresolved-tsconfig-companions.md @@ -10,7 +10,10 @@ Two fixes to `check`: 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)`. -- **Companions move only when they have to, and by the smallest step.** A companion whose - installed version already accepts the new target stays where it is. One whose peer range - rejects it moves to the lowest release above the installed one that accepts the target (a - release that pins the target exactly still wins), not the newest major the registry has. +- **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`, 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..235b5c0 100644 --- a/docs/packs.md +++ b/docs/packs.md @@ -40,6 +40,12 @@ 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. 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/check/check.ts b/packages/core/src/check/check.ts index d5dc00e..2c8e42f 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(), ]; @@ -1107,8 +1109,10 @@ async function companionPlans( target, installed: [...installed.values()], manifests: (name) => manifests(name), + lockstep: packsOf(opts).find((p) => p.name === lead)?.companions ?? [], }); - 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. } @@ -1557,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 ec5986f..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: [] }); }); }); @@ -222,7 +222,7 @@ describe('companionsOf, react 18 → 19', () => { expect(plan.reason).toBe('peer link, types for react, react-dom'); }); - it('leaves a companion alone when its installed copy already accepts the target', async () => { + it('moves a lockstep companion even when its installed copy accepts the target', async () => { const plan = await companionsOf({ name: 'react', target: '19.2.1', @@ -242,17 +242,19 @@ describe('companionsOf, react 18 → 19', () => { } : manifests(name), }); - expect(plan.companions.map((c) => `${c.name} ${c.to}`)).toEqual(['@types/react 19.2.7']); + expect(plan.companions.map((c) => `${c.name} ${c.to}`)).toEqual([ + '@types/react 19.2.7', + 'react-dom 19.2.1', + ]); }); - it('moves a companion whose peer range rejects the target to the lowest release that accepts it', async () => { - // next-mdx-remote-client 1.x peers react `>= 18.3.0 < 19.0.0`; 2.0.0 is the first release - // that takes 19. The newest (2.1.12 wants `>= 19.1.0`) is a different, larger upgrade. + 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' } }, - '1.1.9': { peerDependencies: { react: '>= 18.3.0 < 19.0.0' } }, '2.0.0': { peerDependencies: { react: '>=19.0.0' } }, - '2.1.2': { peerDependencies: { react: '^19.1.0' } }, '2.1.12': { peerDependencies: { react: '>= 19.1.0' } }, }; const plan = await companionsOf({ @@ -264,35 +266,48 @@ describe('companionsOf, react 18 → 19', () => { ...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 : manifests(name)), + 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.companions).toEqual([ - { - name: 'next-mdx-remote-client', - from: '1.1.2', - to: '2.0.0', - reason: 'next-mdx-remote-client 2.0.0 accepts react 19.2.1 (>=19.0.0)', - }, + 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('does not pick a release older than the installed one', async () => { - const lib = { - '0.9.0': { peerDependencies: { react: '*' } }, - '1.0.0': { peerDependencies: { react: '^18' } }, - '2.0.0': { peerDependencies: { react: '^19' } }, + 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 plan = await companionsOf({ + const input = { name: 'react', - target: '19.0.0', + target: '19.2.1', installed: [ - installed('react', '18.2.0', ['.']), - { ...installed('lib', '1.0.0', ['.']), manifest: lib['1.0.0'] }, + installed('react', '18.3.1', ['.']), + { ...installed('react-wrapper', '2.0.0', ['.']), manifest: wrapper['2.0.0'] }, ], - manifests: async (name) => (name === 'lib' ? lib : manifests(name)), - }); - expect(plan.companions.map((c) => `${c.name} ${c.to}`)).toEqual(['lib 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 () => { @@ -305,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 e92cb94..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,22 +125,15 @@ 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]; if (pinned && EXACT.test(pinned) && versions[pinned]) return { version: pinned, reason: `${lead} ${target} pins ${name} ${pinned}` }; - // Nothing to move when the installed copy already accepts the target: a companion moves - // only when its own peer or dependency range rejects the new version. - const current = versions[installed.version] ?? installed.manifest; - const range = current.dependencies?.[lead] ?? current.peerDependencies?.[lead]; - if (range !== undefined && accepts(range, target)) - return { - version: installed.version, - reason: `${name} ${installed.version} accepts ${lead} ${target}`, - }; // 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). + // (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) @@ -109,22 +141,25 @@ function agreeing( 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]; + // 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}`, + }; const candidates = Object.keys(versions).filter(stable).sort(compareVersions).reverse(); - // A release that pins the target exactly agrees with it: the newest such release. for (const version of candidates) { const m = versions[version] ?? {}; - const pin = m.dependencies?.[lead] ?? m.peerDependencies?.[lead]; - if (pin !== undefined && EXACT.test(pin) && accepts(pin, target)) - return { version, reason: `${name} ${version} pins ${lead} ${target}` }; - } - // Else the lowest release past the installed one whose range accepts the target: the - // smallest move that agrees, not the newest major the registry has. - for (const version of [...candidates].reverse()) { - if (compareVersions(version, installed.version) <= 0) continue; - const m = versions[version] ?? {}; - const accepted = m.dependencies?.[lead] ?? m.peerDependencies?.[lead]; - if (accepted === undefined || !accepts(accepted, target)) continue; - return { version, reason: `${name} ${version} accepts ${lead} ${target} (${accepted})` }; + const range = m.dependencies?.[lead] ?? m.peerDependencies?.[lead]; + if (range === undefined || !accepts(range, target)) continue; + return { + version, + reason: EXACT.test(range) + ? `${name} ${version} pins ${lead} ${target}` + : `${name} ${version} accepts ${lead} ${target} (${range})`, + }; } const leadPins = exactPins(targetManifest); for (const version of candidates) { @@ -172,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); @@ -204,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`). @@ -213,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. @@ -241,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); @@ -280,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; ) { @@ -298,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}`, @@ -328,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/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/packs/types.ts b/packages/core/src/packs/types.ts index 404651d..92d4d48 100644 --- a/packages/core/src/packs/types.ts +++ b/packages/core/src/packs/types.ts @@ -94,6 +94,12 @@ export interface MigrationRule { /** Packs own dependency knowledge. The runner owns git, verification, and publication. */ export interface MigrationPack { readonly name: string; + /** + * Packages that always move with this one, even when the installed version of each already + * accepts the target (the ones its migration guide upgrades together). Release-group + * members published at the target's own version (`react-dom`) and `@types/*` need no entry. + */ + readonly companions?: readonly string[]; readonly defaultTarget: string; readonly rules: readonly MigrationRule[]; supports(from: string, to: string): boolean; From cef6f17f05ab4e081f1a0531a7cd8a1792ddbab2 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 18:43:54 +0000 Subject: [PATCH 4/6] feat(packs): a pack can name the packages that always move with it; ai names ai-sdk-ollama ai-sdk-ollama only peers on ai, and its installed range rejects ai 7, so it is a peer conflict unless the pack says it moves with ai. The repository's upgrade moved it (4.1.0). Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01AKRx4kuV6oi25MP5WguxW9 Signed-off-by: Claude --- packages/core/src/packs/ai/index.ts | 3 +++ packages/core/src/packs/contract.test.ts | 11 +++++++++++ packages/core/src/packs/contract.ts | 3 +++ 3 files changed, 17 insertions(+) diff --git a/packages/core/src/packs/ai/index.ts b/packages/core/src/packs/ai/index.ts index ab8ceb7..fd9b4f9 100644 --- a/packages/core/src/packs/ai/index.ts +++ b/packages/core/src/packs/ai/index.ts @@ -79,6 +79,9 @@ 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, but its 4.x takes `ai` 7 and the repository's upgrade moved it. + companions: ['ai-sdk-ollama'], 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..e009c2b 100644 --- a/packages/core/src/packs/contract.test.ts +++ b/packages/core/src/packs/contract.test.ts @@ -186,6 +186,17 @@ 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 named = definePack({ + meta: pack.meta, + rules: [], + instructions: 'x', + companions: ['toy-plugin'], + }); + expect(named.companions).toEqual(['toy-plugin']); + }); + 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..084dbc9 100644 --- a/packages/core/src/packs/contract.ts +++ b/packages/core/src/packs/contract.ts @@ -298,6 +298,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 (`MigrationPack.companions`). */ + companions?: readonly string[]; } /** @@ -313,6 +315,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) { From 772bfadfecbb21e1711a50fdc587da715ea385ac Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 23:12:21 +0000 Subject: [PATCH 5/6] feat(packs): a companion carries the official source that says it moves; pack test fails without one companions entries are now { name, source }. source is the https URL of the official migration guide or changelog that says the package moves with the leader, never ground truth alone. pack test reports an entry with no source, a non-https source, or the pack's own package as a problem and scores nothing. ai-sdk-ollama cites the package's own documentation (its README states each major targets one ai major), because the AI SDK 7.0 guide does not name community providers. docs/packs.md states the rule. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01AKRx4kuV6oi25MP5WguxW9 Signed-off-by: Claude --- .../check-unresolved-tsconfig-companions.md | 2 +- docs/packs.md | 21 +++++++-- packages/core/src/check/check.ts | 2 +- packages/core/src/packs/ai/index.ts | 11 ++++- packages/core/src/packs/contract.test.ts | 44 ++++++++++++++++--- packages/core/src/packs/contract.ts | 41 +++++++++++++++-- .../core/src/packs/tooling/pack-test.test.ts | 28 ++++++++++++ packages/core/src/packs/tooling/pack-test.ts | 2 + packages/core/src/packs/types.ts | 15 +++++-- 9 files changed, 145 insertions(+), 21 deletions(-) create mode 100644 packages/core/src/packs/tooling/pack-test.test.ts diff --git a/.changeset/check-unresolved-tsconfig-companions.md b/.changeset/check-unresolved-tsconfig-companions.md index 7001fb5..bac1a34 100644 --- a/.changeset/check-unresolved-tsconfig-companions.md +++ b/.changeset/check-unresolved-tsconfig-companions.md @@ -13,7 +13,7 @@ Two fixes to `check`: - **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`, even when their installed range already accepts 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 235b5c0..9d853be 100644 --- a/docs/packs.md +++ b/docs/packs.md @@ -41,10 +41,23 @@ ranges; a pack may narrow it (stripe also requires the target to be newer than w installed). A pack may also name `companions`: packages that always move with the package, even when -their installed version already accepts the target. 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. +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 companion's own documentation, when the leader's guide does not name it). 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 diff --git a/packages/core/src/check/check.ts b/packages/core/src/check/check.ts index 2c8e42f..083f627 100644 --- a/packages/core/src/check/check.ts +++ b/packages/core/src/check/check.ts @@ -1109,7 +1109,7 @@ async function companionPlans( target, installed: [...installed.values()], manifests: (name) => manifests(name), - lockstep: packsOf(opts).find((p) => p.name === lead)?.companions ?? [], + lockstep: (packsOf(opts).find((p) => p.name === lead)?.companions ?? []).map((c) => c.name), }); if (plan.companions.length > 0 || plan.conflicts.length > 0 || plan.peerConflicts.length > 0) plans[lead] = plan; diff --git a/packages/core/src/packs/ai/index.ts b/packages/core/src/packs/ai/index.ts index fd9b4f9..6779aa6 100644 --- a/packages/core/src/packs/ai/index.ts +++ b/packages/core/src/packs/ai/index.ts @@ -80,8 +80,15 @@ 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, but its 4.x takes `ai` 7 and the repository's upgrade moved it. - companions: ['ai-sdk-ollama'], + // 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 e009c2b..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: { @@ -188,13 +222,9 @@ describe('definePack', () => { it('carries the packages the pack says always move with it, and none by default', () => { expect(pack.companions).toBeUndefined(); - const named = definePack({ - meta: pack.meta, - rules: [], - instructions: 'x', - companions: ['toy-plugin'], - }); - expect(named.companions).toEqual(['toy-plugin']); + 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', () => { diff --git a/packages/core/src/packs/contract.ts b/packages/core/src/packs/contract.ts index 084dbc9..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,8 +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 (`MigrationPack.companions`). */ - companions?: readonly string[]; + /** Packages that always move with the package, each with its official source (`MigrationPack.companions`). */ + companions?: readonly PackCompanion[]; } /** @@ -395,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 92d4d48..fb9d131 100644 --- a/packages/core/src/packs/types.ts +++ b/packages/core/src/packs/types.ts @@ -92,14 +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 (the ones its migration guide upgrades together). Release-group - * members published at the target's own version (`react-dom`) and `@types/*` need no entry. + * 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 string[]; + readonly companions?: readonly PackCompanion[]; readonly defaultTarget: string; readonly rules: readonly MigrationRule[]; supports(from: string, to: string): boolean; From e4fea66e7bbedaa022bf99faa3906cdb390c702f Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 07:12:55 +0000 Subject: [PATCH 6/6] docs(packs): a companion's source is the leader's docs or its own; changeset is minor New fields in the report (peerConflicts) and in the pack format (companions). Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01AKRx4kuV6oi25MP5WguxW9 Signed-off-by: Claude --- .changeset/check-unresolved-tsconfig-companions.md | 2 +- docs/packs.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.changeset/check-unresolved-tsconfig-companions.md b/.changeset/check-unresolved-tsconfig-companions.md index bac1a34..ea9234b 100644 --- a/.changeset/check-unresolved-tsconfig-companions.md +++ b/.changeset/check-unresolved-tsconfig-companions.md @@ -1,5 +1,5 @@ --- -"uptide": patch +"uptide": minor --- Two fixes to `check`: diff --git a/docs/packs.md b/docs/packs.md index 9d853be..4d22db6 100644 --- a/docs/packs.md +++ b/docs/packs.md @@ -49,7 +49,7 @@ companions: [{ name: 'ai-sdk-ollama', source: 'https://github.com/jagreehal/ai-s **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 companion's own documentation, when the leader's guide does not name it). A +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.