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
7 changes: 7 additions & 0 deletions .changeset/quiet-project-scope.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"uptide": patch
---

Respect repository tsconfig file selection, recursive project references, and workspace project options when checking dependency upgrades. Compiler diagnostics outside every TypeScript project no longer produce breaking findings; pack rules and build-configuration findings retain their own scope. Repositories without a tsconfig retain importer-based compilation, with that fallback stated in the report.

Anchor missing members supplied by dependency augmentations at their setup import, retaining affected calls as evidence instead of counting each call as a separate breaking edit.
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,9 +34,9 @@ Everything else: [getting started](docs/getting-started.md).

- Renovate and Dependabot bump the version. Uptide says what the bump breaks in your code,
fixes it on a branch, and verifies the result with your compiler and your tests.
- Breaking means compiler-confirmed: a finding counts only when your code fails to compile
against the target at that site, the runtime probe saw the export go, or a migration pack
found it. Everything else is listed as possible impact, never counted.
- Breaking findings require compiler, runtime, or pack confirmation. Everything else is possible impact.
Compiler errors outside your tsconfig projects never count; pack rules still apply. Without a tsconfig, importer-based
compilation is used and reported.
- A migration is a pull request you can read: rule-based edits first, assisted edits only
where a rule cannot, each kept only if its compiler error disappears and no new one
appears, and a report that says what was verified and what was not. Accepted agent edits
Expand Down
109 changes: 109 additions & 0 deletions packages/core/src/adapters/typescript/augmentation-import.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
import { relative } from 'node:path';
import type { ts } from 'ts-morph';
import type { DiagnosticCause } from '../../domain/usage.js';
import { onReset } from '../../shared-state.js';

/** Whether a declaration sits inside `declare global` or `declare module "x"`. */
export function isAugmentation(tsc: typeof ts, decl: ts.Node): boolean {
for (let n: ts.Node | undefined = decl.parent; n && !tsc.isSourceFile(n); n = n.parent)
if (
tsc.isModuleDeclaration(n) &&
(n.flags & tsc.NodeFlags.GlobalAugmentation || tsc.isStringLiteral(n.name))
)
return true;
return false;
}

/** Last node_modules segment also handles pnpm and bun's nested package stores. */
export function packageOf(fileName: string): string {
const names = [...fileName.matchAll(/\/node_modules\/((?:@[^/]+\/)?[^/]+)(?=\/)/g)];
return names.at(-1)?.[1] ?? (fileName.split('/').at(-2) ?? fileName).replace(/-v\d+$/, '');
}

let registrations = new WeakMap<ts.Program, Map<string, ts.ImportDeclaration | undefined>>();
onReset(() => {
registrations = new WeakMap();
});

/**
* A dependency can supply the augmentation rather than the repository declaring it.
* jest-dom's setup import is the edit; thousands of expect(...).toBeInTheDocument()
* calls are evidence. Prove that the baseline receiver inherits an augmentation from
* that unchanged dependency before anchoring a missing member at its side-effect import.
*/
export function augmentationImportCause(
programs: { ts: typeof ts; base: ts.Program; overlay: ts.Program },
before: ts.Node,
member: ts.Declaration,
repoDir: string,
rootDir: string,
): DiagnosticCause | undefined {
const tsc = programs.ts;
if (!tsc.isPropertyAccessExpression(before.parent) || before.parent.name !== before)
return undefined;
const provider = member.getSourceFile();
if (programs.overlay.getSourceFile(provider.fileName)?.text !== provider.text) return undefined;
const pkg = packageOf(provider.fileName);
const checker = programs.base.getTypeChecker();
const visited = new Set<ts.Type>();
const augmented = (type: ts.Type): boolean => {
if (visited.has(type)) return false;
visited.add(type);
if (
type.symbol?.declarations?.some(
(d) => packageOf(d.getSourceFile().fileName) === pkg && isAugmentation(tsc, d),
)
)
return true;
if (type.isUnionOrIntersection()) return type.types.some(augmented);
if (!(type.flags & tsc.TypeFlags.Object)) return false;
const target = (type as ts.TypeReference).target ?? type;
if (!((target as ts.ObjectType).objectFlags & tsc.ObjectFlags.ClassOrInterface)) return false;
return checker.getBaseTypes(target as ts.InterfaceType).some(augmented);
};
if (!augmented(checker.getTypeAtLocation(before.parent.expression))) return undefined;

let imports = registrations.get(programs.base);
if (!imports) {
imports = new Map();
registrations.set(programs.base, imports);
}
const key = `${rootDir}|${pkg}`;
if (!imports.has(key)) {
const candidates = programs.base
.getSourceFiles()
.filter((f) => f.fileName.startsWith(`${rootDir}/`) && !f.fileName.includes('/node_modules/'))
.flatMap((f) =>
f.statements.filter(
(s): s is ts.ImportDeclaration =>
tsc.isImportDeclaration(s) &&
!s.importClause &&
tsc.isStringLiteral(s.moduleSpecifier) &&
(s.moduleSpecifier.text === pkg || s.moduleSpecifier.text.startsWith(`${pkg}/`)) &&
packageOf(
tsc.resolveModuleName(
s.moduleSpecifier.text,
f.fileName,
programs.base.getCompilerOptions(),
tsc.sys,
).resolvedModule?.resolvedFileName ?? '',
) === pkg,
),
)
.sort(
(a, b) =>
a.getSourceFile().fileName.localeCompare(b.getSourceFile().fileName) || a.pos - b.pos,
);
imports.set(key, candidates[0]);
}
const declaration = imports.get(key);
if (!declaration) return undefined;
const source = declaration.getSourceFile();
return {
name: pkg,
file: relative(repoDir, source.fileName).split('\\').join('/'),
line: source.getLineAndCharacterOfPosition(declaration.getStart()).line + 1,
reason: `which imports ${pkg}'s type augmentation, inherited by the installed type but no longer read by the target; update the registration here`,
anchorOnly: true,
};
}
70 changes: 69 additions & 1 deletion packages/core/src/adapters/typescript/cause.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { cpSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs';
import { cpSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join, resolve } from 'node:path';
import { describe, expect, it } from 'vitest';
Expand Down Expand Up @@ -205,6 +205,74 @@ describe('a member declared in a repository augmentation the target no longer re
}
});

it.each(['direct', 'inherited', 'reference'])(
'anchors a dependency augmentation at its side-effect import (%s)',
async (mode) => {
const dir = withMatchers({
'setup.ts': "import 'matchers-extra';\n",
'odd.test.ts':
"import { expect } from 'matchers';\nexpect(3).toBeOdd();\nexpect(5).toBeOdd();\n",
});
const config = JSON.parse(readFileSync(join(dir, 'tsconfig.json'), 'utf8'));
if (mode !== 'direct') {
const extra = join(dir, 'node_modules/matchers-extra');
mkdirSync(extra, { recursive: true });
writeFileSync(
join(extra, 'package.json'),
JSON.stringify({ name: 'matchers-extra', version: '1.0.0', types: 'index.d.ts' }),
);
writeFileSync(
join(extra, 'index.d.ts'),
'import type { ExtraMatchers } from "./matchers";\ndeclare global { namespace checks { interface Matchers<R> extends ExtraMatchers<R> {} } }\n',
);
writeFileSync(
join(extra, 'matchers.d.ts'),
'export interface ExtraMatchers<R> { toBeOdd(): R; }\n',
);
if (mode === 'reference') {
writeFileSync(join(extra, 'globals.d.ts'), readFileSync(join(extra, 'index.d.ts')));
writeFileSync(join(extra, 'index.d.ts'), '/// <reference path="./globals.d.ts" />\n');
}
config.compilerOptions.paths['matchers-extra'] = [join(extra, 'index.d.ts')];
}
// A Vite-style solution: selecting its app project makes these diagnostics visible.
writeFileSync(join(dir, 'tsconfig.app.json'), JSON.stringify(config));
writeFileSync(
join(dir, 'tsconfig.json'),
JSON.stringify({ files: [], references: [{ path: './tsconfig.app.json' }] }),
);
const signal = await compileAgainstTarget({ dir }, 'matchers', join(DEPS, 'matchers-v2'));
const missing = signal.diagnostics.filter((d) => d.code === 2339);
expect(missing).toHaveLength(2);
for (const d of missing)
expect(d.cause).toMatchObject({
file: 'src/setup.ts',
line: 1,
anchorOnly: true,
});
},
);

it('does not blame a side-effect import for an ordinary member removed by the upgraded package', async () => {
const dir = withMatchers({
'setup.ts': "import 'matchers';\n",
'plain.test.ts': "import { expect } from 'matchers';\nexpect(1).toBe(1);\n",
});
const target = mkdtempSync(join(tmpdir(), 'uptide-removed-matcher-'));
cpSync(join(DEPS, 'matchers-v2'), target, { recursive: true });
writeFileSync(
join(target, 'index.d.ts'),
readFileSync(join(target, 'index.d.ts'), 'utf8').replace(
'toBe(expected: T): R;',
'other(expected: T): R;',
),
);
const signal = await compileAgainstTarget({ dir }, 'matchers', target);
const missing = signal.diagnostics.filter((d) => d.file === 'src/plain.test.ts');
expect(missing).toHaveLength(1);
expect(missing[0]?.cause).toBeUndefined();
});

it('leaves a member the package itself dropped to the site', async () => {
const dir = withMatchers({
'plain.test.ts': ["import { expect } from 'matchers';", 'expect(1).toBe(1);', ''].join('\n'),
Expand Down
22 changes: 2 additions & 20 deletions packages/core/src/adapters/typescript/cause.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { relative } from 'node:path';
import type { ts } from 'ts-morph';
import type { DiagnosticCause } from '../../domain/usage.js';
import { augmentationImportCause, isAugmentation, packageOf } from './augmentation-import.js';

/**
* Root cause of an `unknown`/`any` diagnostic. When an upgrade changes what a package
Expand Down Expand Up @@ -428,7 +429,7 @@ export function augmentationCause(
decl =
declarations.find((d) => d.getSourceFile().fileName.startsWith(`${repoDir}/`)) ??
declarations[0];
if (!decl) return undefined;
if (!decl) return augmentationImportCause(programs, before, member, repoDir, rootDir);
elsewhere = packageOf(member.getSourceFile().fileName);
}
const site = `${relative(repoDir, file.fileName)}:${file.getLineAndCharacterOfPosition(diagnostic.start).line + 1}`;
Expand Down Expand Up @@ -470,25 +471,6 @@ export function augmentationCause(
};
}

/** Whether a declaration sits inside `declare global` or `declare module "x"`. */
function isAugmentation(tsc: typeof ts, decl: ts.Node): boolean {
for (let n: ts.Node | undefined = decl.parent; n && !tsc.isSourceFile(n); n = n.parent)
if (
tsc.isModuleDeclaration(n) &&
(n.flags & tsc.NodeFlags.GlobalAugmentation || tsc.isStringLiteral(n.name))
)
return true;
return false;
}

/** The package a file under node_modules (or a fixture directory) belongs to, as a short label. */
function packageOf(fileName: string): string {
const m = /\/node_modules\/((?:@[^/]+\/)?[^/]+)\//.exec(fileName);
if (m) return m[1] as string;
const dir = fileName.split('/').at(-2) ?? fileName;
return dir.replace(/-v\d+$/, '');
}

/** `jest.Matchers` for a member inside `namespace jest { interface Matchers }`; the holder's name alone otherwise. */
function qualifiedName(tsc: typeof ts, decl: ts.Node, holder: ts.Node | undefined): string {
const parts: string[] = [];
Expand Down
21 changes: 21 additions & 0 deletions packages/core/src/adapters/typescript/compile.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,27 @@ function consumerCopy(mutate: (dir: string) => void): string {
}

describe('Signal B: compile against the target version', () => {
it('does not compile guardrails-style examples outside the tsconfig, even as explicit importers', async () => {
const dir = consumerCopy((d) => {
mkdirSync(join(d, 'examples'), { recursive: true });
writeFileSync(
join(d, 'examples/multiturn.ts'),
"import { createClient } from 'synthetic';\ncreateClient('one');\ncreateClient('two');\ncreateClient('three');\ncreateClient('four');\n",
);
});
const signal = await compileAgainstTarget(
{ dir, rootFiles: [join(dir, 'src/chains.ts'), join(dir, 'examples/multiturn.ts')] },
'synthetic',
join(ROOT, 'synthetic-v2'),
{ files: ['src/chains.ts', 'examples/multiturn.ts'] },
);
expect(signal.diagnostics.map((d) => d.file)).toEqual(['src/chains.ts']);
expect(signal.coverage.skipped).toContainEqual({
reason: 'outside the TypeScript project',
count: 1,
});
});

it('reports the errors the upgrade introduces, at the right lines', async () => {
const signal = await compileAgainstTarget(
{ dir: CONSUMER },
Expand Down
Loading
Loading