Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions .changeset/check-unresolved-tsconfig-companions.md
Original file line number Diff line number Diff line change
@@ -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`.
19 changes: 19 additions & 0 deletions docs/packs.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
23 changes: 23 additions & 0 deletions packages/cli/src/format-check.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
10 changes: 9 additions & 1 deletion packages/cli/src/format-check.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) } : {}),
};
}
Expand Down Expand Up @@ -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}`);
Expand Down
54 changes: 54 additions & 0 deletions packages/core/src/adapters/typescript/compile.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
Expand Down
97 changes: 89 additions & 8 deletions packages/core/src/adapters/typescript/compile.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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 {
Expand Down Expand Up @@ -201,6 +209,28 @@ function filesToCheck(

const NOT_IN_TSCONFIG = 'not in the workspace tsconfig';

const nestedConfigs = new Map<string, string | undefined>();
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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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<string, { reason: string; count: number }>();
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)
Expand All @@ -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: [],
Expand Down Expand Up @@ -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,
Expand Down
28 changes: 28 additions & 0 deletions packages/core/src/adapters/typescript/compiler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 '<extends>' 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;
}
}
38 changes: 38 additions & 0 deletions packages/core/src/adapters/typescript/unresolved-config.test.ts
Original file line number Diff line number Diff line change
@@ -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, object> = {}): 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();
});
Loading
Loading