From 580726135eef5b18d2af6845dcef3dbca1c1205f Mon Sep 17 00:00:00 2001 From: Lucas Braum Date: Wed, 14 Oct 2026 17:46:21 -0700 Subject: [PATCH 1/5] fix(check): respect TypeScript project compile scope Signed-off-by: Lucas Braum --- .changeset/quiet-project-scope.md | 5 + README.md | 6 +- .../src/adapters/typescript/compile.test.ts | 21 +++ .../core/src/adapters/typescript/compile.ts | 109 ++++++++++++- .../core/src/adapters/typescript/index.ts | 12 ++ .../adapters/typescript/project-scope.test.ts | 152 ++++++++++++++++++ .../src/adapters/typescript/project-scope.ts | 120 ++++++++++++++ packages/core/src/adapters/typescript/repo.ts | 12 +- packages/core/src/check/check.test.ts | 45 +++++- packages/core/src/check/check.ts | 15 ++ .../core/src/check/companion-group.test.ts | 99 +++++++----- packages/core/src/check/verdict.test.ts | 6 + packages/core/src/check/verdict.ts | 2 +- packages/core/src/domain/adapter.ts | 6 + packages/core/src/domain/usage.ts | 2 + 15 files changed, 549 insertions(+), 63 deletions(-) create mode 100644 .changeset/quiet-project-scope.md create mode 100644 packages/core/src/adapters/typescript/project-scope.test.ts create mode 100644 packages/core/src/adapters/typescript/project-scope.ts diff --git a/.changeset/quiet-project-scope.md b/.changeset/quiet-project-scope.md new file mode 100644 index 00000000..ef1457d4 --- /dev/null +++ b/.changeset/quiet-project-scope.md @@ -0,0 +1,5 @@ +--- +"uptide": patch +--- + +Respect repository tsconfig file selection, recursive project references, and workspace project options when checking dependency upgrades. Files outside every TypeScript project no longer produce breaking findings. Repositories without a tsconfig retain importer-based compilation, with that fallback stated in the report. diff --git a/README.md b/README.md index 5768ce85..16a96caa 100644 --- a/README.md +++ b/README.md @@ -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. + Files outside your tsconfig projects never count; 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 diff --git a/packages/core/src/adapters/typescript/compile.test.ts b/packages/core/src/adapters/typescript/compile.test.ts index 0735423c..10569ca4 100644 --- a/packages/core/src/adapters/typescript/compile.test.ts +++ b/packages/core/src/adapters/typescript/compile.test.ts @@ -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 }, diff --git a/packages/core/src/adapters/typescript/compile.ts b/packages/core/src/adapters/typescript/compile.ts index 7f65a9d1..31881e32 100644 --- a/packages/core/src/adapters/typescript/compile.ts +++ b/packages/core/src/adapters/typescript/compile.ts @@ -9,7 +9,7 @@ import { } from 'node:fs'; import { builtinModules } from 'node:module'; import { tmpdir } from 'node:os'; -import { dirname, join, relative } from 'node:path'; +import { dirname, join, relative, resolve } from 'node:path'; import { ts } from 'ts-morph'; import type { CompileOptions, RepoDir } from '../../domain/adapter.js'; import type { @@ -23,6 +23,7 @@ import { onReset } from '../../shared-state.js'; import { augmentationCause, findCause, parameterCause } from './cause.js'; import { type Compiler, repositoryCompiler, unresolvedConfig } from './compiler.js'; import { jsxNamespaceCause } from './config-cause.js'; +import { projectScope } from './project-scope.js'; import { type LoadedRepo, loadedRepo, ownsFile } from './repo.js'; import { consumerCopySatisfies, @@ -242,7 +243,7 @@ function filesToCheck( }; } -const NOT_IN_TSCONFIG = 'not in the workspace tsconfig'; +const NOT_IN_TSCONFIG = 'outside the TypeScript project'; const nestedConfigs = new Map(); onReset(() => nestedConfigs.clear()); @@ -776,16 +777,109 @@ export function compileAgainstTarget( return compileAgainstTargets(repoRef, [{ name: pkg, dir: targetDir }], options); } -/** Several packages swapped in one overlay: a release group upgraded together. */ +interface ProjectSignal extends CompileSignal { + checkedFiles?: string[]; +} + +/** Compile each project with its own options, never promote importer discovery to build roots. */ export async function compileAgainstTargets( repoRef: RepoDir, targets: Target[], options: CompileOptions = {}, ): Promise { - const compiler = repositoryCompiler(realpathSync(repoRef.dir)); + const dir = realpathSync(repoRef.dir); + const scope = projectScope({ ...repoRef, dir }); + if (scope.projects.length === 0) { + const { checkedFiles: _checkedFiles, ...signal } = await compileProject( + repoRef, + targets, + options, + ); + signal.coverage.noTsconfig = true; + return signal; + } + const requested = options.files ?? repoRef.rootFiles?.map((f) => relative(dir, realpathSync(f))); + const outside = (requested ?? []).filter((f) => !scope.covers(join(dir, f))); + const signals: ProjectSignal[] = []; + for (const project of scope.projects) { + // An enclosing project already reports this unresolved nested config and skips its files. + if ( + project.invalid && + requested?.length && + requested + .filter((f) => join(dir, f).startsWith(`${dirname(project.config)}/`)) + .every((f) => scope.projects.some((p) => !p.invalid && p.files.has(join(dir, f)))) + ) + continue; + const covers = (file: string): boolean => + project.invalid + ? resolve(file).startsWith(`${dirname(project.config)}/`) + : project.files.has(resolve(file)); + const files = options.files?.filter((f) => covers(join(dir, f))); + const roots = repoRef.rootFiles?.filter((f) => covers(realpathSync(f))); + if (requested && !(options.files ? files?.length : roots?.length)) continue; + const ref = { ...repoRef, ...(roots ? { rootFiles: roots } : {}) }; + signals.push( + await compileProject( + ref, + targets, + { + ...options, + ...(files ? { files } : {}), + }, + project.config, + ), + ); + } + const unique = (items: T[]): T[] => [ + ...new Map(items.map((v) => [JSON.stringify(v), v])).values(), + ]; + const skips = signals + .flatMap((s) => s.coverage.skipped) + .filter((s) => s.reason !== NOT_IN_TSCONFIG); + if (outside.length) skips.push({ reason: NOT_IN_TSCONFIG, count: outside.length }); + const compiled = new Set(signals.flatMap((s) => s.checkedFiles ?? [])).size; + return { + diagnostics: unique(signals.flatMap((s) => s.diagnostics)), + baselineErrors: signals.reduce((n, s) => n + s.baselineErrors, 0), + ...(signals.length && signals.every((s) => s.skipped) + ? { skipped: unique(signals.map((s) => s.skipped)).join('; ') } + : {}), + coverage: { + compiled, + total: compiled + skips.reduce((n, s) => n + s.count, 0), + skipped: skips, + compilers: unique(signals.flatMap((s) => s.coverage.compilers ?? [])), + ...(signals.some((s) => s.coverage.notCompiled) + ? { + notCompiled: unique(signals.flatMap((s) => s.coverage.notCompiled ?? [])), + } + : {}), + }, + unresolvedInTarget: unique(signals.flatMap((s) => s.unresolvedInTarget)), + unresolvedFiles: unique(signals.flatMap((s) => s.unresolvedFiles)), + linkedDependencies: unique(signals.flatMap((s) => s.linkedDependencies)), + linkedDependencyDirs: Object.assign({}, ...signals.map((s) => s.linkedDependencyDirs)), + unsatisfiedDependencies: unique(signals.flatMap((s) => s.unsatisfiedDependencies)), + timing: { + baselineMs: signals.reduce((n, s) => n + s.timing.baselineMs, 0), + overlayMs: signals.reduce((n, s) => n + s.timing.overlayMs, 0), + dependenciesMs: signals.reduce((n, s) => n + s.timing.dependenciesMs, 0), + }, + }; +} + +/** Several packages swapped in one overlay: a release group upgraded together. */ +async function compileProject( + repoRef: RepoDir, + targets: Target[], + options: CompileOptions = {}, + configPath?: string, +): Promise { + const compiler = repositoryCompiler(configPath ? dirname(configPath) : realpathSync(repoRef.dir)); let repo: LoadedRepo; try { - repo = loadedRepo(realpathSync(repoRef.dir), repoRef.rootFiles); + repo = loadedRepo(realpathSync(repoRef.dir), repoRef.rootFiles, configPath); } catch (err) { const message = (err instanceof Error ? err.message : String(err)).split('\n')[0] ?? ''; return { @@ -811,10 +905,10 @@ export async function compileAgainstTargets( return { diagnostics: [], baselineErrors: 0, - skipped: `not compiled: tsconfig.json (${unresolved})`, + skipped: `not compiled: ${relative(repo.dir, repo.tsconfig)} (${unresolved})`, coverage: { ...coverageOf(compiler, 0, 0, { reason: 'tsconfig cannot be resolved', count: requested }), - notCompiled: [{ path: 'tsconfig.json', reason: unresolved }], + notCompiled: [{ path: relative(repo.dir, repo.tsconfig), reason: unresolved }], }, unresolvedInTarget: [], unresolvedFiles: [], @@ -1005,6 +1099,7 @@ export async function compileAgainstTargets( ), baselineErrors: baseline.length, coverage: withNotCompiled(coverageOf(compiler, files.length, missing)), + checkedFiles: files.map((f) => f.fileName), unresolvedInTarget: [...unresolvedInTarget].sort(), unresolvedFiles: [...unresolvedFiles].sort(), linkedDependencies: deps.linked, diff --git a/packages/core/src/adapters/typescript/index.ts b/packages/core/src/adapters/typescript/index.ts index f2b5c949..b3cd77bb 100644 --- a/packages/core/src/adapters/typescript/index.ts +++ b/packages/core/src/adapters/typescript/index.ts @@ -7,6 +7,7 @@ import type { ApiSurface, ApiSymbol } from '../../domain/surface.js'; import { compareTypesWithChecker } from './compat.js'; import { compileAgainstTarget, compileAgainstTargets } from './compile.js'; import { resolveEntryPoints } from './entry-points.js'; +import { forgetProjectScope, projectScope } from './project-scope.js'; import { forgetRepo, loadedRepo, readInstalled, workspacePackagesOf } from './repo.js'; import { findUsagesInRepo, @@ -138,6 +139,16 @@ export function createTypescriptAdapter(opts: TypescriptAdapterOptions = {}): Ty const now = opts.now ?? (() => new Date()); const adapter: TypescriptAdapter = { id: 'typescript', + async compileScope(repo, files) { + const scope = projectScope(repo); + const noTsconfig = scope.projects.length === 0; + return { + noTsconfig, + outside: noTsconfig + ? [] + : files.filter((file) => !scope.covers(join(realpathSync(repo.dir), file))), + }; + }, compareTypes: compareTypesWithChecker, findUsages: (repo, pkg, surface) => findUsagesInRepo(adapter, repo, pkg, surface), compileAgainst: compileAgainstTarget, @@ -173,6 +184,7 @@ export function createTypescriptAdapter(opts: TypescriptAdapterOptions = {}): Ty }, forgetRepo(repo) { forgetRepo(realpathSync(repo.dir)); + forgetProjectScope(); }, async installedSurface(repo, pkg, version) { const dir = installedPackageDir(repo, pkg); diff --git a/packages/core/src/adapters/typescript/project-scope.test.ts b/packages/core/src/adapters/typescript/project-scope.test.ts new file mode 100644 index 00000000..eabb7e64 --- /dev/null +++ b/packages/core/src/adapters/typescript/project-scope.test.ts @@ -0,0 +1,152 @@ +import { mkdirSync, mkdtempSync, realpathSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { compileAgainstTarget } from './compile.js'; +import { projectScope } from './project-scope.js'; + +function fixture() { + const root = realpathSync(mkdtempSync(join(tmpdir(), 'uptide-project-scope-'))); + const dir = join(root, 'repo'); + const write = (file: string, text: string | object) => { + mkdirSync(dirname(join(root, file)), { recursive: true }); + writeFileSync(join(root, file), typeof text === 'string' ? text : JSON.stringify(text)); + }; + write('repo/package.json', { name: 'consumer', dependencies: { widget: '1.0.0' } }); + write('repo/node_modules/widget/package.json', { + name: 'widget', + version: '1.0.0', + types: 'index.d.ts', + }); + write('repo/node_modules/widget/index.d.ts', 'export declare function read(): { item: string };'); + write('target/package.json', { name: 'widget', version: '2.0.0', types: 'index.d.ts' }); + write('target/index.d.ts', 'export declare function read(): { item?: string };'); + const source = "import { read } from 'widget';\nexport const value: string = read().item;\n"; + const options = { strict: true, module: 'ESNext', moduleResolution: 'Bundler', types: [] }; + const compile = (files: string[], workspace = dir) => + compileAgainstTarget( + { dir: workspace, root: dir, rootFiles: files.map((f) => join(workspace, f)) }, + 'widget', + join(root, 'target'), + { files }, + ); + return { root, dir, write, source, options, compile }; +} + +describe('repository compile projects', () => { + it('honors inherited include/exclude and explicit files, even outside include', async () => { + const f = fixture(); + f.write('repo/tsconfig.base.json', { + compilerOptions: f.options, + include: ['src'], + exclude: ['src/excluded.ts'], + }); + f.write('repo/tsconfig.json', { extends: './tsconfig.base.json', files: ['explicit.ts'] }); + for (const file of ['src/main.ts', 'src/excluded.ts', 'explicit.ts', 'examples/demo.ts']) + f.write(`repo/${file}`, f.source); + const signal = await f.compile([ + 'src/main.ts', + 'src/excluded.ts', + 'explicit.ts', + 'examples/demo.ts', + ]); + expect(signal.diagnostics.map((d) => d.file).sort()).toEqual(['explicit.ts', 'src/main.ts']); + expect(signal.coverage.skipped).toEqual([ + { reason: 'outside the TypeScript project', count: 2 }, + ]); + }); + + it('includes an excluded file imported by a project root', async () => { + const f = fixture(); + f.write('repo/tsconfig.json', { + compilerOptions: f.options, + include: ['src'], + exclude: ['examples'], + }); + f.write('repo/src/main.ts', "import '../examples/demo';"); + f.write('repo/examples/demo.ts', f.source); + const signal = await f.compile(['examples/demo.ts']); + expect(signal.diagnostics.map((d) => d.file)).toEqual(['examples/demo.ts']); + }); + + it('follows recursive project references and custom config names with their own options', async () => { + const f = fixture(); + f.write('repo/tsconfig.json', { files: [], references: [{ path: './projects.json' }] }); + f.write('repo/projects.json', { + files: [], + references: [ + { path: './strict/config.json' }, + { path: './loose/config.json' }, + { path: './tsconfig.json' }, + ], + }); + f.write('repo/strict/config.json', { compilerOptions: f.options, files: ['main.ts'] }); + f.write('repo/loose/config.json', { + compilerOptions: { ...f.options, strict: false }, + files: ['main.ts'], + }); + f.write('repo/strict/main.ts', f.source); + f.write('repo/loose/main.ts', f.source); + f.write('repo/examples/demo.ts', f.source); + const signal = await f.compile(['strict/main.ts', 'loose/main.ts', 'examples/demo.ts']); + expect(signal.diagnostics.map((d) => d.file)).toEqual(['strict/main.ts']); + expect(signal.coverage.compiled).toBe(2); + expect(signal.coverage.skipped).toEqual([ + { reason: 'outside the TypeScript project', count: 1 }, + ]); + }); + + it('uses independent workspace configs and does not synthesize a project for an uncovered workspace', async () => { + const f = fixture(); + f.write('repo/packages/a/tsconfig.json', { compilerOptions: f.options, include: ['src'] }); + f.write('repo/packages/b/tsconfig.json', { + compilerOptions: { ...f.options, strict: false }, + include: ['src'], + }); + for (const name of ['a', 'b', 'c']) { + f.write(`repo/packages/${name}/package.json`, { name }); + f.write(`repo/packages/${name}/src/main.ts`, f.source); + f.write(`repo/packages/${name}/examples/demo.ts`, f.source); + } + const a = await f.compile(['src/main.ts', 'examples/demo.ts'], join(f.dir, 'packages/a')); + const b = await f.compile(['src/main.ts'], join(f.dir, 'packages/b')); + const c = await f.compile(['src/main.ts'], join(f.dir, 'packages/c')); + expect(a.diagnostics.map((d) => d.file)).toEqual(['src/main.ts']); + expect(b.diagnostics).toEqual([]); + expect(c.diagnostics).toEqual([]); + expect(c.coverage.skipped).toEqual([{ reason: 'outside the TypeScript project', count: 1 }]); + }); + + it('applies the root project file selection to a workspace without a config', async () => { + const f = fixture(); + f.write('repo/tsconfig.json', { compilerOptions: f.options, include: ['packages/*/src'] }); + f.write('repo/packages/a/package.json', { name: 'a' }); + f.write('repo/packages/a/src/main.ts', f.source); + f.write('repo/packages/a/examples/demo.ts', f.source); + const signal = await f.compile(['src/main.ts', 'examples/demo.ts'], join(f.dir, 'packages/a')); + expect(signal.diagnostics.map((d) => d.file)).toEqual(['src/main.ts']); + expect(signal.coverage.skipped).toEqual([ + { reason: 'outside the TypeScript project', count: 1 }, + ]); + }); + + it('reports a file once when two projects both compile it', async () => { + const f = fixture(); + f.write('repo/tsconfig.json', { compilerOptions: f.options, include: ['src'] }); + f.write('repo/tsconfig.build.json', { extends: './tsconfig.json' }); + f.write('repo/src/main.ts', f.source); + const signal = await f.compile(['src/main.ts']); + expect(signal.diagnostics.map((d) => d.file)).toEqual(['src/main.ts']); + expect(signal.coverage.compiled).toBe(1); + expect(signal.coverage.total).toBe(1); + }); + + it('preserves importer-based compilation when no tsconfig exists and reports that fallback', async () => { + const f = fixture(); + f.write('repo/examples/demo.ts', f.source); + expect(projectScope({ dir: f.dir }).projects).toEqual([]); + const signal = await f.compile(['examples/demo.ts']); + expect(signal.diagnostics.map((d) => d.file)).toEqual(['examples/demo.ts']); + expect(signal.coverage.noTsconfig).toBe(true); + }); +}); diff --git a/packages/core/src/adapters/typescript/project-scope.ts b/packages/core/src/adapters/typescript/project-scope.ts new file mode 100644 index 00000000..ea230525 --- /dev/null +++ b/packages/core/src/adapters/typescript/project-scope.ts @@ -0,0 +1,120 @@ +import { existsSync, realpathSync } from 'node:fs'; +import { basename, dirname, join, relative, resolve } from 'node:path'; +import type { ts } from 'ts-morph'; +import type { RepoDir } from '../../domain/adapter.js'; +import { onReset } from '../../shared-state.js'; +import { repositoryCompiler } from './compiler.js'; +import { inheritedTsconfigOf } from './repo.js'; + +export interface CompileProject { + config: string; + /** The compiler's file closure, including imports of excluded files (exclude only filters roots). */ + files: Set; + invalid: boolean; +} + +export interface ProjectScope { + projects: CompileProject[]; + covers(file: string): boolean; +} + +const scopes = new Map(); +onReset(() => scopes.clear()); + +export function forgetProjectScope(): void { + scopes.clear(); +} + +/** All independent projects plus their recursive references; extended option files aren't projects. */ +export function projectScope(repo: RepoDir): ProjectScope { + const root = realpathSync(repo.root ?? repo.dir); + const inherited = inheritedTsconfigOf(realpathSync(repo.dir)); + const key = JSON.stringify([root, inherited]); + const known = scopes.get(key); + if (known) return known; + const tsc = repositoryCompiler(root).ts; + const paths = new Set( + tsc.sys.readDirectory( + root, + ['.json'], + [ + '**/node_modules/**', + '**/.git/**', + '**/dist/**', + '**/build/**', + '**/coverage/**', + '**/.next/**', + '**/.turbo/**', + ], + ['**/tsconfig.json', '**/tsconfig.*.json'], + ), + ); + // A workspace without its own config can be included by the root's project. Its include + // and exclude still apply; inheriting just the compiler options would invent build roots. + if ( + !existsSync(join(root, 'tsconfig.json')) && + inherited && + (!repo.root || !relative(root, inherited).startsWith('..')) + ) + paths.add(inherited); + const referenced = new Set(); + const parsed = new Map(); + for (const path of paths) { + const compiler = repositoryCompiler(dirname(path)).ts; + const command = compiler.getParsedCommandLineOfConfigFile( + path, + {}, + { + ...compiler.sys, + onUnRecoverableConfigFileDiagnostic: () => {}, + }, + ); + if (!command) continue; + const configFile = command.options.configFile as + | (ts.SourceFile & { extendedSourceFiles?: string[] }) + | undefined; + parsed.set(path, { command, extended: configFile?.extendedSourceFiles ?? [] }); + for (const ref of command.projectReferences ?? []) { + const config = compiler.resolveProjectReferencePath(ref); + if (existsSync(config)) { + paths.add(config); + referenced.add(config); + } + } + } + const bases = new Set([...parsed.values()].flatMap((p) => p.extended)); + const projects: CompileProject[] = []; + for (const config of paths) { + const entry = parsed.get(config); + if (bases.has(config) && basename(config) !== 'tsconfig.json' && !referenced.has(config)) + continue; + const command = entry?.command; + const invalid = !command || command.errors.some((e) => ![18002, 18003].includes(e.code)); + const files = new Set(); + if (command && !invalid) { + const compiler = repositoryCompiler(dirname(config)).ts; + const program = compiler.createProgram({ + rootNames: command.fileNames, + options: { ...command.options, noEmit: true, skipLibCheck: true }, + projectReferences: command.projectReferences, + }); + for (const file of program.getSourceFiles()) { + if (!file.fileName.includes('/node_modules/')) files.add(resolve(file.fileName)); + } + } + projects.push({ config, files, invalid }); + } + const scope: ProjectScope = { + projects, + // An invalid project cannot establish that a file is outside it. Its compile is skipped + // with the config error; don't claim an exclusion based on incomplete configuration. + covers: (file) => + projects.some( + (p) => + p.files.has(resolve(file)) || + (p.invalid && resolve(file).startsWith(`${dirname(p.config)}/`)), + ), + }; + scopes.set(key, scope); + return scope; +} diff --git a/packages/core/src/adapters/typescript/repo.ts b/packages/core/src/adapters/typescript/repo.ts index 338251eb..7c7f0d82 100644 --- a/packages/core/src/adapters/typescript/repo.ts +++ b/packages/core/src/adapters/typescript/repo.ts @@ -65,13 +65,13 @@ const repos = new Map(); onReset(() => repos.clear()); /** `loadRepo`, cached by directory: a parsed program is reused across every package checked in a workspace. */ -export function loadedRepo(dir: string, rootFiles?: string[]): LoadedRepo { +export function loadedRepo(dir: string, rootFiles?: string[], configPath?: string): LoadedRepo { dir = realpathSync(dir); rootFiles = rootFiles?.map((file) => realpathSync(file)); - const key = JSON.stringify([dir, rootFiles?.slice().sort()]); + const key = JSON.stringify([dir, rootFiles?.slice().sort(), configPath]); let repo = repos.get(key); if (!repo) { - repo = loadRepo(dir, rootFiles); + repo = loadRepo(dir, rootFiles, configPath); repos.set(key, repo); } return repo; @@ -175,10 +175,10 @@ const formatAwareResolution: ResolutionHostFactory = (host, getOptions) => { }; }; -export function loadRepo(cwd: string, rootFiles?: string[]): LoadedRepo { +export function loadRepo(cwd: string, rootFiles?: string[], configPath?: string): LoadedRepo { const { dir, packageJson, lockfile, installed } = readInstalled(cwd); - const tsconfig = join(dir, 'tsconfig.json'); + const tsconfig = configPath ?? join(dir, 'tsconfig.json'); // Workspace dependencies resolve to their source, not to whatever their last build left in dist. const sources = workspaceSourceMap(dir, installed); const sourcePaths = Object.keys(sources.paths).length > 0 ? sources.paths : undefined; @@ -205,7 +205,7 @@ export function loadRepo(cwd: string, rootFiles?: string[]): LoadedRepo { }, }); // Project references one level down: their files are part of what this repo compiles. - for (const ref of rootFiles === undefined ? readReferences(tsconfig) : []) { + for (const ref of rootFiles === undefined && !configPath ? readReferences(tsconfig) : []) { try { project.addSourceFilesFromTsConfig(ref); } catch { diff --git a/packages/core/src/check/check.test.ts b/packages/core/src/check/check.test.ts index 20e0fadf..0ee22c8c 100644 --- a/packages/core/src/check/check.test.ts +++ b/packages/core/src/check/check.test.ts @@ -57,6 +57,44 @@ process.env.UPTIDE_CACHE_DIR = mkdtempSync(join(tmpdir(), 'uptide-test-cache-')) const adapter = createTypescriptAdapter({ now: () => new Date('2026-09-27T00:00:00.000Z') }); +it.each([true, false])( + 'excludes guardrails-style examples from findings (compile=%s)', + async (compile) => { + const dir = mkdtempSync(join(tmpdir(), 'uptide-guardrails-scope-')); + cpSync(CONSUMER, dir, { recursive: true }); + const config = JSON.parse(readFileSync(join(dir, 'tsconfig.json'), 'utf8')); + for (const key of Object.keys(config.compilerOptions.paths)) + config.compilerOptions.paths[key] = config.compilerOptions.paths[key].map((p: string) => + resolve(CONSUMER, p), + ); + writeFileSync(join(dir, 'tsconfig.json'), JSON.stringify(config)); + mkdirSync(join(dir, 'examples')); + writeFileSync( + join(dir, 'examples/multiturn.ts'), + "import { createClient } from 'synthetic';\ncreateClient('one');\ncreateClient('two');\ncreateClient('three');\ncreateClient('four');\n", + ); + const result = await check({ + cwd: dir, + only: ['synthetic'], + fetcher, + cache: memoryCache(), + runtime: false, + compile, + }); + const report = result.packages[0]; + expect(report?.findings.some((f) => f.usage.file.startsWith('examples/'))).toBe(false); + if (compile) { + expect( + report?.findings.some((f) => f.severity === 'breaking' && f.usage.file === 'src/chains.ts'), + ).toBe(true); + expect(report?.compile?.coverage?.skipped).toContainEqual({ + reason: 'outside the TypeScript project', + count: 1, + }); + } + }, +); + it('compiles every named importer even when an unused removed binding has no attributed usage', async () => { const dir = mkdtempSync(join(tmpdir(), 'uptide-scoped-import-')); cpSync(CONSUMER, dir, { recursive: true }); @@ -66,7 +104,10 @@ it('compiles every named importer even when an unused removed binding has no att resolve(CONSUMER, p), ); writeFileSync(join(dir, 'tsconfig.json'), JSON.stringify(config)); - writeFileSync(join(dir, 'unused.ts'), "import { parseLegacy } from 'synthetic';\nexport {};\n"); + writeFileSync( + join(dir, 'src/unused.ts'), + "import { parseLegacy } from 'synthetic';\nexport {};\n", + ); const result = await check({ cwd: dir, only: ['synthetic'], @@ -78,7 +119,7 @@ it('compiles every named importer even when an unused removed binding has no att expect.objectContaining({ severity: 'breaking', evidence: 'compiler', - usage: expect.objectContaining({ file: 'unused.ts', line: 1 }), + usage: expect.objectContaining({ file: 'src/unused.ts', line: 1 }), }), ); }); diff --git a/packages/core/src/check/check.ts b/packages/core/src/check/check.ts index f7d26547..f3918afe 100644 --- a/packages/core/src/check/check.ts +++ b/packages/core/src/check/check.ts @@ -767,6 +767,7 @@ export function mergeAcrossWorkspaces( ? { coverage: { compiled: coverages.reduce((n, c) => n + c.compiled, 0), + ...(coverages.some((c) => c.noTsconfig) ? { noTsconfig: true } : {}), 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 })), @@ -2340,6 +2341,20 @@ async function checkGroup( `${f.change.package} ${f.change.to} exports: ${exported.slice(0, 60).join(', ')}${exported.length > 60 ? `, and ${exported.length - 60} more` : ''}`, ]; } + if (ctx.adapter.compileScope) { + const scope = await ctx.adapter.compileScope(repo, [ + ...new Set(findings.map((f) => f.usage.file)), + ]); + const outside = new Set(scope.outside); + const covered = findings.filter((f) => !outside.has(f.usage.file)); + findings.length = 0; + findings.push(...covered); + if (outside.size) + notes.push( + `${outside.size} file(s) outside the TypeScript project; not counted as findings`, + ); + if (scope.noTsconfig) notes.push('No tsconfig found; using importer-based compile scope.'); + } const unanalyzedCount = prepared.reduce((n, p) => n + p.scan.unanalyzed.length, 0); let status = statusOf(findings, usages.length, unanalyzedCount); // Nothing to diff and nothing found: the honest status is "no types", with the reason in the notes. diff --git a/packages/core/src/check/companion-group.test.ts b/packages/core/src/check/companion-group.test.ts index 0b3d274c..a01df5a1 100644 --- a/packages/core/src/check/companion-group.test.ts +++ b/packages/core/src/check/companion-group.test.ts @@ -148,48 +148,59 @@ it.each([false, true])( }, ); -it('checks an inventory-only configuration when the package has no imported usages', async () => { - const cwd = consumer(); - writeFileSync(join(cwd, 'src/index.ts'), 'export {};'); - writeFileSync(join(cwd, 'next.config.mjs'), 'export default { eslint: {} };'); - const pack = definePack({ - meta: { package: 'ui', from: '^1', to: '^2', sources: [], maintainer: 'test' }, - inventory: ['next.config.{js,mjs,ts,cjs}'], - rules: [], - instructions: '', - behavior: [ - { - id: 'removed-eslint', - summary: 'removed config', - reported: ['finding'], - severity: 'breaking', - detect: (text, file) => - file === 'next.config.mjs' && text.includes('eslint') - ? [{ line: 1, column: 18, snippet: 'eslint' }] - : [], +it.each([true, false])( + 'scopes inventory-only configuration findings to tsconfig (included=%s)', + async (included) => { + const cwd = consumer(); + writeFileSync(join(cwd, 'src/index.ts'), 'export {};'); + writeFileSync(join(cwd, 'next.config.mjs'), 'export default { eslint: {} };'); + if (included) + writeFileSync( + join(cwd, 'tsconfig.json'), + JSON.stringify({ + compilerOptions: { strict: true, noEmit: true, allowJs: true }, + include: ['src', 'next.config.mjs'], + }), + ); + const pack = definePack({ + meta: { package: 'ui', from: '^1', to: '^2', sources: [], maintainer: 'test' }, + inventory: ['next.config.{js,mjs,ts,cjs}'], + rules: [], + instructions: '', + behavior: [ + { + id: 'removed-eslint', + summary: 'removed config', + reported: ['finding'], + severity: 'breaking', + detect: (text, file) => + file === 'next.config.mjs' && text.includes('eslint') + ? [{ line: 1, column: 18, snippet: 'eslint' }] + : [], + }, + ], + }); + const fetcher: PackageFetcher = { + resolve: async () => '2.0.0', + versions: async (name) => Object.keys(registry[name] ?? {}), + manifests: async (name) => registry[name] ?? {}, + fetch: async (name, version) => { + const dir = mkdtempSync(join(tmpdir(), 'uptide-inventory-target-')); + writePackage(dir, name, version); + return { name, version, dir }; }, - ], - }); - const fetcher: PackageFetcher = { - resolve: async () => '2.0.0', - versions: async (name) => Object.keys(registry[name] ?? {}), - manifests: async (name) => registry[name] ?? {}, - fetch: async (name, version) => { - const dir = mkdtempSync(join(tmpdir(), 'uptide-inventory-target-')); - writePackage(dir, name, version); - return { name, version, dir }; - }, - }; - const result = await check({ - cwd, - only: ['ui'], - packs: [pack], - adapter, - fetcher, - cache: memoryCache(), - runtime: false, - }); - expect( - result.packages.flatMap((p) => p.findings).map((f) => [f.usage.file, f.rule]), - ).toContainEqual(['next.config.mjs', 'removed-eslint']); -}); + }; + const result = await check({ + cwd, + only: ['ui'], + packs: [pack], + adapter, + fetcher, + cache: memoryCache(), + runtime: false, + }); + const sites = result.packages.flatMap((p) => p.findings).map((f) => [f.usage.file, f.rule]); + if (included) expect(sites).toContainEqual(['next.config.mjs', 'removed-eslint']); + else expect(sites).not.toContainEqual(['next.config.mjs', 'removed-eslint']); + }, +); diff --git a/packages/core/src/check/verdict.test.ts b/packages/core/src/check/verdict.test.ts index 197a1e27..53e50bd7 100644 --- a/packages/core/src/check/verdict.test.ts +++ b/packages/core/src/check/verdict.test.ts @@ -123,3 +123,9 @@ it('says a package without a pack is not analyzed, instead of 0 breaking', () => const breaking = { severity: 'breaking' } as PackageReport['findings'][number]; expect(verdictOf({ ...generic, findings: [breaking] }).summary).toMatch(/^1 breaking ยท /); }); + +it('states the importer-based fallback when no tsconfig exists', () => { + expect( + coverageLine({ compiled: 1, total: 1, workspaces: 1, skipped: [], noTsconfig: true }), + ).toContain('no tsconfig found, using importer-based compile scope'); +}); diff --git a/packages/core/src/check/verdict.ts b/packages/core/src/check/verdict.ts index 94d7c85b..6ca4e39b 100644 --- a/packages/core/src/check/verdict.ts +++ b/packages/core/src/check/verdict.ts @@ -32,7 +32,7 @@ export function coverageLine(c: NonNullable['coverage' const skipped = c.skipped.map((r) => `${r.reason} (${r.count})`).join(', '); const compilers = compilersPhrase(c.compilers ?? []); 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('')}`; + return `compiled ${c.compiled} of ${count(c.total, 'file')} in ${count(c.workspaces, 'workspace')}${compilers ? ` with ${compilers}` : ''}${skipped ? `; skipped: ${skipped}` : ''}${notCompiled.join('')}${c.noTsconfig ? '; no tsconfig found, using importer-based compile scope' : ''}`; } /** diff --git a/packages/core/src/domain/adapter.ts b/packages/core/src/domain/adapter.ts index 47e9cc76..fd0dbd79 100644 --- a/packages/core/src/domain/adapter.ts +++ b/packages/core/src/domain/adapter.ts @@ -84,6 +84,12 @@ export interface CompileOptions { } export interface LanguageAdapter { + /** Classify candidate sites by the repository's build projects, independently of compile success. */ + compileScope?( + repo: RepoDir, + files: string[], + ): Promise<{ outside: string[]; noTsconfig: boolean }>; + /** "typescript" */ readonly id: string; extractSurface(input: PackageDir): Promise; diff --git a/packages/core/src/domain/usage.ts b/packages/core/src/domain/usage.ts index 0c3f7b27..aa913f50 100644 --- a/packages/core/src/domain/usage.ts +++ b/packages/core/src/domain/usage.ts @@ -139,6 +139,8 @@ export interface DiagnosticCause { /** The files Signal B was asked to judge and what became of them: the denominator of a verdict. */ export interface CompileCoverage { + /** No tsconfig was found: importer-based compilation was used. */ + noTsconfig?: boolean; /** Files type-checked against the target. */ compiled: number; /** Files that use the package (and the files importing them) the signal was asked about. */ From 1b47456cb0308631618ac8fef55bd6959d5d8187 Mon Sep 17 00:00:00 2001 From: Lucas Braum Date: Wed, 14 Oct 2026 17:46:27 -0700 Subject: [PATCH 2/5] fix(check): keep pack inventory findings outside the TypeScript project scope Config files a pack reads (next.config.mjs) are read by their tool, not compiled, so the tsconfig scope filter must not drop them. Restores the inventory-only assertion in companion-group.test.ts that #138 had flipped. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Lucas Braum --- packages/core/src/check/check.ts | 8 +- .../core/src/check/companion-group.test.ts | 99 +++++++++---------- 2 files changed, 50 insertions(+), 57 deletions(-) diff --git a/packages/core/src/check/check.ts b/packages/core/src/check/check.ts index f3918afe..ab42b8ed 100644 --- a/packages/core/src/check/check.ts +++ b/packages/core/src/check/check.ts @@ -2275,8 +2275,12 @@ async function checkGroup( targetDir: target?.runtime?.dir ?? target?.pkg.dir ?? p.installedDir.dir, }; }; + // Config files the pack reads (next.config.mjs): read by their tool, not compiled, so the + // TypeScript project scope does not apply to them. + let inventory: string[] = []; if (pack?.runtimeFindings && only) { try { + inventory = packInventory(ctx.opts.cwd, workspace, pack.inventory); findings.push( ...pack.runtimeFindings({ root: ctx.opts.cwd, @@ -2285,7 +2289,7 @@ async function checkGroup( to: only.target, ...packDirs(only), usages: only.scan.usages, - files: packInventory(ctx.opts.cwd, workspace, pack.inventory), + files: inventory, read: (file) => { try { return readFileSync(resolve(ctx.opts.cwd, file), 'utf8'); @@ -2343,7 +2347,7 @@ async function checkGroup( } if (ctx.adapter.compileScope) { const scope = await ctx.adapter.compileScope(repo, [ - ...new Set(findings.map((f) => f.usage.file)), + ...new Set(findings.map((f) => f.usage.file).filter((file) => !inventory.includes(file))), ]); const outside = new Set(scope.outside); const covered = findings.filter((f) => !outside.has(f.usage.file)); diff --git a/packages/core/src/check/companion-group.test.ts b/packages/core/src/check/companion-group.test.ts index a01df5a1..0b3d274c 100644 --- a/packages/core/src/check/companion-group.test.ts +++ b/packages/core/src/check/companion-group.test.ts @@ -148,59 +148,48 @@ it.each([false, true])( }, ); -it.each([true, false])( - 'scopes inventory-only configuration findings to tsconfig (included=%s)', - async (included) => { - const cwd = consumer(); - writeFileSync(join(cwd, 'src/index.ts'), 'export {};'); - writeFileSync(join(cwd, 'next.config.mjs'), 'export default { eslint: {} };'); - if (included) - writeFileSync( - join(cwd, 'tsconfig.json'), - JSON.stringify({ - compilerOptions: { strict: true, noEmit: true, allowJs: true }, - include: ['src', 'next.config.mjs'], - }), - ); - const pack = definePack({ - meta: { package: 'ui', from: '^1', to: '^2', sources: [], maintainer: 'test' }, - inventory: ['next.config.{js,mjs,ts,cjs}'], - rules: [], - instructions: '', - behavior: [ - { - id: 'removed-eslint', - summary: 'removed config', - reported: ['finding'], - severity: 'breaking', - detect: (text, file) => - file === 'next.config.mjs' && text.includes('eslint') - ? [{ line: 1, column: 18, snippet: 'eslint' }] - : [], - }, - ], - }); - const fetcher: PackageFetcher = { - resolve: async () => '2.0.0', - versions: async (name) => Object.keys(registry[name] ?? {}), - manifests: async (name) => registry[name] ?? {}, - fetch: async (name, version) => { - const dir = mkdtempSync(join(tmpdir(), 'uptide-inventory-target-')); - writePackage(dir, name, version); - return { name, version, dir }; +it('checks an inventory-only configuration when the package has no imported usages', async () => { + const cwd = consumer(); + writeFileSync(join(cwd, 'src/index.ts'), 'export {};'); + writeFileSync(join(cwd, 'next.config.mjs'), 'export default { eslint: {} };'); + const pack = definePack({ + meta: { package: 'ui', from: '^1', to: '^2', sources: [], maintainer: 'test' }, + inventory: ['next.config.{js,mjs,ts,cjs}'], + rules: [], + instructions: '', + behavior: [ + { + id: 'removed-eslint', + summary: 'removed config', + reported: ['finding'], + severity: 'breaking', + detect: (text, file) => + file === 'next.config.mjs' && text.includes('eslint') + ? [{ line: 1, column: 18, snippet: 'eslint' }] + : [], }, - }; - const result = await check({ - cwd, - only: ['ui'], - packs: [pack], - adapter, - fetcher, - cache: memoryCache(), - runtime: false, - }); - const sites = result.packages.flatMap((p) => p.findings).map((f) => [f.usage.file, f.rule]); - if (included) expect(sites).toContainEqual(['next.config.mjs', 'removed-eslint']); - else expect(sites).not.toContainEqual(['next.config.mjs', 'removed-eslint']); - }, -); + ], + }); + const fetcher: PackageFetcher = { + resolve: async () => '2.0.0', + versions: async (name) => Object.keys(registry[name] ?? {}), + manifests: async (name) => registry[name] ?? {}, + fetch: async (name, version) => { + const dir = mkdtempSync(join(tmpdir(), 'uptide-inventory-target-')); + writePackage(dir, name, version); + return { name, version, dir }; + }, + }; + const result = await check({ + cwd, + only: ['ui'], + packs: [pack], + adapter, + fetcher, + cache: memoryCache(), + runtime: false, + }); + expect( + result.packages.flatMap((p) => p.findings).map((f) => [f.usage.file, f.rule]), + ).toContainEqual(['next.config.mjs', 'removed-eslint']); +}); From 66fb8fced582c438ada2d65c1aee504891a7827e Mon Sep 17 00:00:00 2001 From: Lucas Braum Date: Wed, 14 Oct 2026 17:46:34 -0700 Subject: [PATCH 3/5] fix(check): scope only compiler diagnostics and reuse workspace programs Signed-off-by: Lucas Braum --- .changeset/quiet-project-scope.md | 2 +- README.md | 2 +- .../core/src/adapters/typescript/compile.ts | 72 ++++++++------ .../core/src/adapters/typescript/index.ts | 14 +-- .../adapters/typescript/project-scope.test.ts | 77 ++++++++++++++- .../src/adapters/typescript/project-scope.ts | 91 ++++++++++++++---- packages/core/src/adapters/typescript/repo.ts | 3 + packages/core/src/check/check.test.ts | 58 ++++++++++- packages/core/src/check/check.ts | 20 +--- packages/core/src/check/project-scope.test.ts | 95 +++++++++++++++++++ packages/core/src/domain/adapter.ts | 6 -- 11 files changed, 358 insertions(+), 82 deletions(-) create mode 100644 packages/core/src/check/project-scope.test.ts diff --git a/.changeset/quiet-project-scope.md b/.changeset/quiet-project-scope.md index ef1457d4..6113523e 100644 --- a/.changeset/quiet-project-scope.md +++ b/.changeset/quiet-project-scope.md @@ -2,4 +2,4 @@ "uptide": patch --- -Respect repository tsconfig file selection, recursive project references, and workspace project options when checking dependency upgrades. Files outside every TypeScript project no longer produce breaking findings. Repositories without a tsconfig retain importer-based compilation, with that fallback stated in the report. +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. diff --git a/README.md b/README.md index 16a96caa..a249bc46 100644 --- a/README.md +++ b/README.md @@ -35,7 +35,7 @@ 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 findings require compiler, runtime, or pack confirmation. Everything else is possible impact. - Files outside your tsconfig projects never count; without a tsconfig, importer-based + 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 diff --git a/packages/core/src/adapters/typescript/compile.ts b/packages/core/src/adapters/typescript/compile.ts index 31881e32..a5b2a5c2 100644 --- a/packages/core/src/adapters/typescript/compile.ts +++ b/packages/core/src/adapters/typescript/compile.ts @@ -9,7 +9,7 @@ import { } from 'node:fs'; import { builtinModules } from 'node:module'; import { tmpdir } from 'node:os'; -import { dirname, join, relative, resolve } from 'node:path'; +import { basename, dirname, join, relative } from 'node:path'; import { ts } from 'ts-morph'; import type { CompileOptions, RepoDir } from '../../domain/adapter.js'; import type { @@ -23,8 +23,8 @@ import { onReset } from '../../shared-state.js'; import { augmentationCause, findCause, parameterCause } from './cause.js'; import { type Compiler, repositoryCompiler, unresolvedConfig } from './compiler.js'; import { jsxNamespaceCause } from './config-cause.js'; -import { projectScope } from './project-scope.js'; -import { type LoadedRepo, loadedRepo, ownsFile } from './repo.js'; +import { type CompileProject, projectScope } from './project-scope.js'; +import { type LoadedRepo, loadedRepo, loadRepo, ownsFile } from './repo.js'; import { consumerCopySatisfies, type DependencyLinks, @@ -799,35 +799,44 @@ export async function compileAgainstTargets( return signal; } const requested = options.files ?? repoRef.rootFiles?.map((f) => relative(dir, realpathSync(f))); - const outside = (requested ?? []).filter((f) => !scope.covers(join(dir, f))); + // A workspace is checked with its own config, not every consumer project that imports + // its source. Prefer the nearest covering default config, then named projects, falling + // back to another covering project for files included from elsewhere. This preserves + // workspace compiler options and avoids repeated programs for transitive references. + const owners = new Map(); + const owner = (file: string): CompileProject | undefined => { + if (owners.has(file)) return owners.get(file); + const enclosing = (p: CompileProject): boolean => file.startsWith(`${dirname(p.config)}/`); + const rank = (p: CompileProject): number => + enclosing(p) ? (basename(p.config) === 'tsconfig.json' ? 2 : 1) : 0; + const candidates = scope.projects + .slice() + .sort( + (a, b) => + rank(b) - rank(a) || + (enclosing(b) ? dirname(b.config).length : 0) - + (enclosing(a) ? dirname(a.config).length : 0), + ); + const project = candidates.find((p) => p.covers(file)); + owners.set(file, project); + return project; + }; + const outside = (requested ?? []).filter((f) => !owner(join(dir, f))); + // Keep the scanner's scoped program when all its roots are covered. Removing uncovered + // roots is necessary, but splitting those roots per consumer project wastes the shared AST. + const roots = repoRef.rootFiles?.filter((f) => owner(realpathSync(f))); + const ref = { ...repoRef, ...(roots ? { rootFiles: roots } : {}) }; const signals: ProjectSignal[] = []; for (const project of scope.projects) { - // An enclosing project already reports this unresolved nested config and skips its files. - if ( - project.invalid && - requested?.length && - requested - .filter((f) => join(dir, f).startsWith(`${dirname(project.config)}/`)) - .every((f) => scope.projects.some((p) => !p.invalid && p.files.has(join(dir, f)))) - ) - continue; - const covers = (file: string): boolean => - project.invalid - ? resolve(file).startsWith(`${dirname(project.config)}/`) - : project.files.has(resolve(file)); - const files = options.files?.filter((f) => covers(join(dir, f))); - const roots = repoRef.rootFiles?.filter((f) => covers(realpathSync(f))); - if (requested && !(options.files ? files?.length : roots?.length)) continue; - const ref = { ...repoRef, ...(roots ? { rootFiles: roots } : {}) }; + const files = requested?.filter((f) => owner(join(dir, f)) === project); + if (files && !files.length) continue; signals.push( await compileProject( ref, targets, - { - ...options, - ...(files ? { files } : {}), - }, + { ...options, ...(options.files ? { files } : {}) }, project.config, + (file) => owner(file) === project, ), ); } @@ -875,11 +884,18 @@ async function compileProject( targets: Target[], options: CompileOptions = {}, configPath?: string, + inProject: (file: string) => boolean = () => true, ): Promise { const compiler = repositoryCompiler(configPath ? dirname(configPath) : realpathSync(repoRef.dir)); let repo: LoadedRepo; try { - repo = loadedRepo(realpathSync(repoRef.dir), repoRef.rootFiles, configPath); + const dir = realpathSync(repoRef.dir); + // An alternate project's program is used for this compile only, not retained beside + // every other project's AST/checker until the whole workspace finishes. + repo = + configPath && configPath !== join(dir, 'tsconfig.json') + ? loadRepo(dir, repoRef.rootFiles, configPath) + : loadedRepo(dir, repoRef.rootFiles, configPath); } catch (err) { const message = (err instanceof Error ? err.message : String(err)).split('\n')[0] ?? ''; return { @@ -975,12 +991,14 @@ async function compileProject( ], }; }; - const { files, missing } = filesToCheck( + const { files: candidates, missing } = filesToCheck( repo, repoRef, checked, options.files ? requested : undefined, ); + + const files = candidates.filter((file) => inProject(file.fileName)); const baseline = files.flatMap((f) => memoized ? baselineErrorsOf(repo, checked, f) diff --git a/packages/core/src/adapters/typescript/index.ts b/packages/core/src/adapters/typescript/index.ts index b3cd77bb..4edb2e40 100644 --- a/packages/core/src/adapters/typescript/index.ts +++ b/packages/core/src/adapters/typescript/index.ts @@ -7,7 +7,7 @@ import type { ApiSurface, ApiSymbol } from '../../domain/surface.js'; import { compareTypesWithChecker } from './compat.js'; import { compileAgainstTarget, compileAgainstTargets } from './compile.js'; import { resolveEntryPoints } from './entry-points.js'; -import { forgetProjectScope, projectScope } from './project-scope.js'; +import { forgetProjectScope } from './project-scope.js'; import { forgetRepo, loadedRepo, readInstalled, workspacePackagesOf } from './repo.js'; import { findUsagesInRepo, @@ -139,16 +139,6 @@ export function createTypescriptAdapter(opts: TypescriptAdapterOptions = {}): Ty const now = opts.now ?? (() => new Date()); const adapter: TypescriptAdapter = { id: 'typescript', - async compileScope(repo, files) { - const scope = projectScope(repo); - const noTsconfig = scope.projects.length === 0; - return { - noTsconfig, - outside: noTsconfig - ? [] - : files.filter((file) => !scope.covers(join(realpathSync(repo.dir), file))), - }; - }, compareTypes: compareTypesWithChecker, findUsages: (repo, pkg, surface) => findUsagesInRepo(adapter, repo, pkg, surface), compileAgainst: compileAgainstTarget, @@ -184,7 +174,7 @@ export function createTypescriptAdapter(opts: TypescriptAdapterOptions = {}): Ty }, forgetRepo(repo) { forgetRepo(realpathSync(repo.dir)); - forgetProjectScope(); + if (!repo.root) forgetProjectScope(); }, async installedSurface(repo, pkg, version) { const dir = installedPackageDir(repo, pkg); diff --git a/packages/core/src/adapters/typescript/project-scope.test.ts b/packages/core/src/adapters/typescript/project-scope.test.ts index eabb7e64..572637cc 100644 --- a/packages/core/src/adapters/typescript/project-scope.test.ts +++ b/packages/core/src/adapters/typescript/project-scope.test.ts @@ -1,7 +1,8 @@ import { mkdirSync, mkdtempSync, realpathSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { dirname, join } from 'node:path'; -import { describe, expect, it } from 'vitest'; +import { ts } from 'ts-morph'; +import { describe, expect, it, vi } from 'vitest'; import { compileAgainstTarget } from './compile.js'; import { projectScope } from './project-scope.js'; @@ -34,6 +35,38 @@ function fixture() { } describe('repository compile projects', () => { + it('does not load unrelated source programs to establish membership of an explicit root', () => { + const f = fixture(); + f.write('repo/tsconfig.json', { compilerOptions: f.options, files: ['src/main.ts'] }); + f.write('repo/src/main.ts', f.source); + f.write('repo/unrelated/tsconfig.json', { compilerOptions: f.options, include: ['src'] }); + f.write('repo/unrelated/src/large.ts', f.source); + const read = vi.spyOn(ts.sys, 'readFile'); + try { + expect(projectScope({ dir: f.dir }).covers(join(f.dir, 'src/main.ts'))).toBe(true); + expect(read.mock.calls.map(([path]) => path)).not.toContain( + join(f.dir, 'unrelated/src/large.ts'), + ); + } finally { + read.mockRestore(); + } + }); + it('checks a workspace source once in its own project, not again in importing consumer projects', async () => { + const f = fixture(); + f.write('repo/tsconfig.json', { compilerOptions: f.options, include: ['packages'] }); + f.write('repo/packages/lib/package.json', { name: 'lib' }); + f.write('repo/packages/lib/tsconfig.json', { + compilerOptions: { ...f.options, strict: false }, + include: ['src'], + }); + f.write('repo/packages/lib/src/main.ts', f.source); + f.write('repo/consumer/tsconfig.json', { compilerOptions: f.options, files: ['main.ts'] }); + f.write('repo/consumer/main.ts', "import '../packages/lib/src/main';"); + const signal = await f.compile(['src/main.ts'], join(f.dir, 'packages/lib')); + expect(signal.diagnostics).toEqual([]); + expect(signal.coverage.compiled).toBe(1); + }); + it('honors inherited include/exclude and explicit files, even outside include', async () => { const f = fixture(); f.write('repo/tsconfig.base.json', { @@ -69,6 +102,47 @@ describe('repository compile projects', () => { expect(signal.diagnostics.map((d) => d.file)).toEqual(['examples/demo.ts']); }); + it.each([false, true])( + 'matches compiler membership through aliases, references and JavaScript (allowJs=%s)', + (allowJs) => { + const f = fixture(); + f.write('repo/tsconfig.json', { + compilerOptions: { ...f.options, allowJs, paths: { '#shared': ['./excluded/shared.ts'] } }, + files: ['src/main.ts'], + }); + f.write( + 'repo/src/main.ts', + "/// \nimport '#shared';\nimport '../excluded/plain.js';\n", + ); + f.write('repo/excluded/shared.ts', "export * from './nested';\n"); + f.write('repo/excluded/nested.ts', 'export const value = 1;'); + f.write('repo/excluded/global.d.ts', "import './ambient-source';\n"); + f.write('repo/excluded/ambient-source.ts', 'export {};'); + f.write('repo/excluded/plain.js', 'export const value = 1;'); + f.write('repo/excluded/unreferenced.ts', 'export {};'); + const parsed = ts.getParsedCommandLineOfConfigFile( + join(f.dir, 'tsconfig.json'), + {}, + { ...ts.sys, onUnRecoverableConfigFileDiagnostic() {} }, + ); + if (!parsed) throw new Error('fixture config must parse'); + const program = ts.createProgram(parsed.fileNames, parsed.options); + const scope = projectScope({ dir: f.dir }); + for (const file of [ + 'src/main.ts', + 'excluded/shared.ts', + 'excluded/nested.ts', + 'excluded/global.d.ts', + 'excluded/ambient-source.ts', + 'excluded/plain.js', + 'excluded/unreferenced.ts', + ]) + expect(scope.covers(join(f.dir, file)), file).toBe( + program.getSourceFile(join(f.dir, file)) !== undefined, + ); + }, + ); + it('follows recursive project references and custom config names with their own options', async () => { const f = fixture(); f.write('repo/tsconfig.json', { files: [], references: [{ path: './projects.json' }] }); @@ -135,6 +209,7 @@ describe('repository compile projects', () => { f.write('repo/tsconfig.json', { compilerOptions: f.options, include: ['src'] }); f.write('repo/tsconfig.build.json', { extends: './tsconfig.json' }); f.write('repo/src/main.ts', f.source); + expect(projectScope({ dir: f.dir }).projects).toHaveLength(1); const signal = await f.compile(['src/main.ts']); expect(signal.diagnostics.map((d) => d.file)).toEqual(['src/main.ts']); expect(signal.coverage.compiled).toBe(1); diff --git a/packages/core/src/adapters/typescript/project-scope.ts b/packages/core/src/adapters/typescript/project-scope.ts index ea230525..801902d1 100644 --- a/packages/core/src/adapters/typescript/project-scope.ts +++ b/packages/core/src/adapters/typescript/project-scope.ts @@ -11,6 +11,7 @@ export interface CompileProject { /** The compiler's file closure, including imports of excluded files (exclude only filters roots). */ files: Set; invalid: boolean; + covers(file: string): boolean; } export interface ProjectScope { @@ -29,7 +30,7 @@ export function forgetProjectScope(): void { export function projectScope(repo: RepoDir): ProjectScope { const root = realpathSync(repo.root ?? repo.dir); const inherited = inheritedTsconfigOf(realpathSync(repo.dir)); - const key = JSON.stringify([root, inherited]); + const key = repo.root ? root : JSON.stringify([root, inherited]); const known = scopes.get(key); if (known) return known; const tsc = repositoryCompiler(root).ts; @@ -84,36 +85,94 @@ export function projectScope(repo: RepoDir): ProjectScope { } const bases = new Set([...parsed.values()].flatMap((p) => p.extended)); const projects: CompileProject[] = []; + const configurations = new Set(); + // Scope needs only import edges, not ASTs, binding, library types or project-reference + // programs. Share the small preprocessing result across overlapping configs. + const imports = new Map(); for (const config of paths) { const entry = parsed.get(config); if (bases.has(config) && basename(config) !== 'tsconfig.json' && !referenced.has(config)) continue; const command = entry?.command; const invalid = !command || command.errors.some((e) => ![18002, 18003].includes(e.code)); - const files = new Set(); if (command && !invalid) { + const { configFilePath: _path, configFile: _file, ...options } = command.options; + const identity = JSON.stringify([ + repositoryCompiler(dirname(config)).version, + Object.entries(options).sort(([a], [b]) => a.localeCompare(b)), + command.fileNames.slice().sort(), + command.projectReferences, + ]); + // A config that only extends another project without changing its roots/options is + // the same compile (Excalidraw's examples/excalidraw/tsconfig.json, for example). + if (configurations.has(identity)) continue; + configurations.add(identity); + } + const files = new Set(command?.fileNames.map((f) => resolve(f)) ?? []); + let expanded = false; + const covers = (file: string): boolean => { + file = resolve(file); + if (invalid) return file.startsWith(`${dirname(config)}/`); + if (files.has(file)) return true; + if (expanded || !command || command.options.noResolve) return false; + expanded = true; const compiler = repositoryCompiler(dirname(config)).ts; - const program = compiler.createProgram({ - rootNames: command.fileNames, - options: { ...command.options, noEmit: true, skipLibCheck: true }, - projectReferences: command.projectReferences, - }); - for (const file of program.getSourceFiles()) { - if (!file.fileName.includes('/node_modules/')) files.add(resolve(file.fileName)); + const host = compiler.sys; + const cache = compiler.createModuleResolutionCache( + dirname(config), + (f) => f, + command.options, + ); + const add = (path: string | undefined) => { + // Dependency declarations cannot promote arbitrary examples into build roots. + // Workspace symlinks are resolved by the compiler before this test. + if ( + path && + !path.includes('/node_modules/') && + (!/\.[cm]?jsx?$/.test(path) || (command.options.allowJs ?? command.options.checkJs)) && + host.fileExists(path) + ) + files.add(resolve(path)); + }; + const addType = (name: string, containingFile: string) => + add( + compiler.resolveTypeReferenceDirective(name, containingFile, command.options, host) + .resolvedTypeReferenceDirective?.resolvedFileName, + ); + for (const name of compiler.getAutomaticTypeDirectiveNames(command.options, host)) + addType(name, config); + for (const path of files) { + let info = imports.get(path); + if (!info) { + info = compiler.preProcessFile(host.readFile(path) ?? '', true, true); + imports.set(path, info); + } + const mode = compiler.getImpliedNodeFormatForFile(path, undefined, host, command.options); + for (const ref of info.importedFiles) + add( + compiler.resolveModuleName( + ref.fileName, + path, + command.options, + host, + cache, + undefined, + mode, + ).resolvedModule?.resolvedFileName, + ); + for (const ref of info.referencedFiles) add(resolve(dirname(path), ref.fileName)); + for (const ref of info.typeReferenceDirectives) addType(ref.fileName, path); } - } - projects.push({ config, files, invalid }); + return files.has(file); + }; + projects.push({ config, files, invalid, covers }); } const scope: ProjectScope = { projects, // An invalid project cannot establish that a file is outside it. Its compile is skipped // with the config error; don't claim an exclusion based on incomplete configuration. covers: (file) => - projects.some( - (p) => - p.files.has(resolve(file)) || - (p.invalid && resolve(file).startsWith(`${dirname(p.config)}/`)), - ), + projects.some((p) => p.files.has(resolve(file))) || projects.some((p) => p.covers(file)), }; scopes.set(key, scope); return scope; diff --git a/packages/core/src/adapters/typescript/repo.ts b/packages/core/src/adapters/typescript/repo.ts index 7c7f0d82..c6ab4fe7 100644 --- a/packages/core/src/adapters/typescript/repo.ts +++ b/packages/core/src/adapters/typescript/repo.ts @@ -68,6 +68,9 @@ onReset(() => repos.clear()); export function loadedRepo(dir: string, rootFiles?: string[], configPath?: string): LoadedRepo { dir = realpathSync(dir); rootFiles = rootFiles?.map((file) => realpathSync(file)); + // The scanner and compiler share the same project when its explicit roots and config agree. + // Naming the workspace's default config must not allocate a second ts-morph program. + if (rootFiles && configPath === join(dir, 'tsconfig.json')) configPath = undefined; const key = JSON.stringify([dir, rootFiles?.slice().sort(), configPath]); let repo = repos.get(key); if (!repo) { diff --git a/packages/core/src/check/check.test.ts b/packages/core/src/check/check.test.ts index 0ee22c8c..76347376 100644 --- a/packages/core/src/check/check.test.ts +++ b/packages/core/src/check/check.test.ts @@ -12,6 +12,7 @@ import type { ApiSurface } from '../domain/surface.js'; import { UptideError } from '../errors.js'; import { fix } from '../fix/run.js'; import { isolatedPnpmWorkspace, zodFixture } from '../fix/test-fixture.js'; +import { definePack } from '../packs/contract.js'; import { onReset } from '../shared-state.js'; import { check, @@ -57,8 +58,57 @@ process.env.UPTIDE_CACHE_DIR = mkdtempSync(join(tmpdir(), 'uptide-test-cache-')) const adapter = createTypescriptAdapter({ now: () => new Date('2026-09-27T00:00:00.000Z') }); +it('keeps pattern-rule findings outside tsconfig even without inventory', async () => { + const cwd = mkdtempSync(join(tmpdir(), 'uptide-pattern-scope-')); + cpSync(CONSUMER, cwd, { recursive: true }); + const config = JSON.parse(readFileSync(join(cwd, 'tsconfig.json'), 'utf8')); + for (const key of Object.keys(config.compilerOptions.paths)) + config.compilerOptions.paths[key] = config.compilerOptions.paths[key].map((p: string) => + resolve(CONSUMER, p), + ); + writeFileSync(join(cwd, 'tsconfig.json'), JSON.stringify(config)); + mkdirSync(join(cwd, 'examples')); + writeFileSync( + join(cwd, 'examples/pattern.ts'), + "import { createClient } from 'synthetic';\ncreateClient('old-pattern');\n", + ); + const pack = definePack({ + meta: { package: 'synthetic', from: '^1', to: '^2', sources: [], maintainer: 'test' }, + instructions: '', + rules: [ + { + id: 'old-pattern', + symbols: /createClient/, + guide: 'Replace the old pattern.', + summary: 'old pattern', + severity: 'breaking', + kinds: [], + detect: (text, file) => + file === 'examples/pattern.ts' && text.includes('old-pattern') + ? [{ line: 2, column: 1, snippet: "createClient('old-pattern')" }] + : [], + }, + ], + }); + const result = await check({ + cwd, + only: ['synthetic'], + packs: [pack], + fetcher, + cache: memoryCache(), + runtime: false, + }); + expect(result.packages.flatMap((p) => p.findings)).toContainEqual( + expect.objectContaining({ + rule: 'old-pattern', + severity: 'breaking', + usage: expect.objectContaining({ file: 'examples/pattern.ts', line: 2 }), + }), + ); +}); + it.each([true, false])( - 'excludes guardrails-style examples from findings (compile=%s)', + 'excludes guardrails-style example compiler errors (compile=%s)', async (compile) => { const dir = mkdtempSync(join(tmpdir(), 'uptide-guardrails-scope-')); cpSync(CONSUMER, dir, { recursive: true }); @@ -82,7 +132,11 @@ it.each([true, false])( compile, }); const report = result.packages[0]; - expect(report?.findings.some((f) => f.usage.file.startsWith('examples/'))).toBe(false); + expect( + report?.findings.some( + (f) => f.usage.file.startsWith('examples/') && f.evidence === 'compiler', + ), + ).toBe(false); if (compile) { expect( report?.findings.some((f) => f.severity === 'breaking' && f.usage.file === 'src/chains.ts'), diff --git a/packages/core/src/check/check.ts b/packages/core/src/check/check.ts index ab42b8ed..3ef8f005 100644 --- a/packages/core/src/check/check.ts +++ b/packages/core/src/check/check.ts @@ -179,6 +179,9 @@ export async function check(opts: CheckOptions): Promise { if (!adapter.installedDependencies) throw new AdapterCapabilityError(adapter.id, 'findUsages'); const root: RepoDir = { dir: opts.cwd }; + // One check owns a project-scope snapshot. Workspace cleanup releases its programs, + // while the small config/import index is reused until the next check starts. + adapter.forgetRepo?.(root); const workspaces = (await adapter.workspacePackages?.(root)) ?? ['.']; const memo = memoizingFetcher(fetcher); const installedByWorkspace: Record> = {}; @@ -2277,10 +2280,9 @@ async function checkGroup( }; // Config files the pack reads (next.config.mjs): read by their tool, not compiled, so the // TypeScript project scope does not apply to them. - let inventory: string[] = []; if (pack?.runtimeFindings && only) { try { - inventory = packInventory(ctx.opts.cwd, workspace, pack.inventory); + const inventory = packInventory(ctx.opts.cwd, workspace, pack.inventory); findings.push( ...pack.runtimeFindings({ root: ctx.opts.cwd, @@ -2345,20 +2347,6 @@ async function checkGroup( `${f.change.package} ${f.change.to} exports: ${exported.slice(0, 60).join(', ')}${exported.length > 60 ? `, and ${exported.length - 60} more` : ''}`, ]; } - if (ctx.adapter.compileScope) { - const scope = await ctx.adapter.compileScope(repo, [ - ...new Set(findings.map((f) => f.usage.file).filter((file) => !inventory.includes(file))), - ]); - const outside = new Set(scope.outside); - const covered = findings.filter((f) => !outside.has(f.usage.file)); - findings.length = 0; - findings.push(...covered); - if (outside.size) - notes.push( - `${outside.size} file(s) outside the TypeScript project; not counted as findings`, - ); - if (scope.noTsconfig) notes.push('No tsconfig found; using importer-based compile scope.'); - } const unanalyzedCount = prepared.reduce((n, p) => n + p.scan.unanalyzed.length, 0); let status = statusOf(findings, usages.length, unanalyzedCount); // Nothing to diff and nothing found: the honest status is "no types", with the reason in the notes. diff --git a/packages/core/src/check/project-scope.test.ts b/packages/core/src/check/project-scope.test.ts new file mode 100644 index 00000000..15d42695 --- /dev/null +++ b/packages/core/src/check/project-scope.test.ts @@ -0,0 +1,95 @@ +import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { expect, it } from 'vitest'; +import { createTypescriptAdapter } from '../adapters/typescript/index.js'; +import type { PackageFetcher } from '../domain/io.js'; +import { nextPack } from '../packs/next/index.js'; +import { sentryNextjsPack } from '../packs/sentry-nextjs/index.js'; +import { check } from './check.js'; + +process.env.UPTIDE_CACHE_DIR = mkdtempSync(join(tmpdir(), 'uptide-scope-cache-')); + +it.each([ + { + name: 'wortkarte next.config.mjs:3', + pack: nextPack, + from: '15.5.27', + to: '16.3.8', + file: 'next.config.mjs', + text: "/** @type {import('next').NextConfig} */\nexport default {\n eslint: { ignoreDuringBuilds: true },\n};\n", + expected: [[3, 'lint-removal']], + }, + { + name: 'coh3-stats next.config.js:6/:69', + pack: sentryNextjsPack, + from: '10.75.3', + to: '11.1.0', + file: 'next.config.js', + text: [ + ...Array(5).fill('// config'), + 'const { withSentryConfig } = require("@sentry/nextjs");', + ...Array(61).fill(''), + 'module.exports = withSentryConfig({}, {', + ' disableLogger: true,', + '});', + ].join('\n'), + expected: [ + [6, 'with-sentry-config-import'], + [69, 'build-options'], + ], + }, +])( + 'keeps pack findings outside tsconfig: $name', + async ({ pack, from, to, file, text, expected }) => { + const cwd = mkdtempSync(join(tmpdir(), 'uptide-config-scope-')); + const writePackage = (dir: string, version: string) => { + mkdirSync(dir, { recursive: true }); + writeFileSync( + join(dir, 'package.json'), + JSON.stringify({ name: pack.name, version, types: 'index.d.ts' }), + ); + writeFileSync( + join(dir, 'index.d.ts'), + 'export interface NextConfig {}\nexport declare function withSentryConfig(...args: unknown[]): unknown;', + ); + return { name: pack.name, version, dir }; + }; + const dependencies = { [pack.name]: from }; + writeFileSync(join(cwd, 'package.json'), JSON.stringify({ name: 'consumer', dependencies })); + writeFileSync( + join(cwd, 'package-lock.json'), + JSON.stringify({ + lockfileVersion: 3, + packages: { '': { dependencies }, [`node_modules/${pack.name}`]: { version: from } }, + }), + ); + writePackage(join(cwd, 'node_modules', pack.name), from); + writeFileSync( + join(cwd, 'tsconfig.json'), + JSON.stringify({ compilerOptions: { allowJs: true }, include: ['src/**/*.ts'] }), + ); + mkdirSync(join(cwd, 'src')); + writeFileSync(join(cwd, 'src/index.ts'), 'export {};'); + writeFileSync(join(cwd, file), text); + const target = writePackage(join(cwd, 'target'), to); + const fetcher: PackageFetcher = { + resolve: async () => to, + fetch: async () => target, + versions: async () => [from, to], + manifests: async () => ({ [from]: {}, [to]: {} }), + }; + const result = await check({ + cwd, + only: [pack.name], + targets: { [pack.name]: to }, + packs: [pack], + adapter: createTypescriptAdapter(), + fetcher, + runtime: false, + }); + const sites = result.packages.flatMap((p) => p.findings).filter((f) => f.usage.file === file); + expect(sites.map((f) => [f.usage.line, f.rule])).toEqual(expected); + expect(sites.every((f) => f.severity === 'breaking')).toBe(true); + }, +); diff --git a/packages/core/src/domain/adapter.ts b/packages/core/src/domain/adapter.ts index fd0dbd79..47e9cc76 100644 --- a/packages/core/src/domain/adapter.ts +++ b/packages/core/src/domain/adapter.ts @@ -84,12 +84,6 @@ export interface CompileOptions { } export interface LanguageAdapter { - /** Classify candidate sites by the repository's build projects, independently of compile success. */ - compileScope?( - repo: RepoDir, - files: string[], - ): Promise<{ outside: string[]; noTsconfig: boolean }>; - /** "typescript" */ readonly id: string; extractSurface(input: PackageDir): Promise; From 79fa83394d8363054442ab82b353523348dbeab3 Mon Sep 17 00:00:00 2001 From: Lucas Braum Date: Wed, 14 Oct 2026 18:22:35 -0700 Subject: [PATCH 4/5] fix(check): anchor dependency augmentation errors at setup imports Signed-off-by: Lucas Braum --- .changeset/quiet-project-scope.md | 2 + .../typescript/augmentation-import.ts | 105 ++++++++++++++++++ .../src/adapters/typescript/cause.test.ts | 70 +++++++++++- .../core/src/adapters/typescript/cause.ts | 22 +--- 4 files changed, 178 insertions(+), 21 deletions(-) create mode 100644 packages/core/src/adapters/typescript/augmentation-import.ts diff --git a/.changeset/quiet-project-scope.md b/.changeset/quiet-project-scope.md index 6113523e..5a29e777 100644 --- a/.changeset/quiet-project-scope.md +++ b/.changeset/quiet-project-scope.md @@ -3,3 +3,5 @@ --- 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. diff --git a/packages/core/src/adapters/typescript/augmentation-import.ts b/packages/core/src/adapters/typescript/augmentation-import.ts new file mode 100644 index 00000000..03c11942 --- /dev/null +++ b/packages/core/src/adapters/typescript/augmentation-import.ts @@ -0,0 +1,105 @@ +import { relative } from 'node:path'; +import type { ts } from 'ts-morph'; +import type { DiagnosticCause } from '../../domain/usage.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+$/, ''); +} + +const 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(); + 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, + }; +} diff --git a/packages/core/src/adapters/typescript/cause.test.ts b/packages/core/src/adapters/typescript/cause.test.ts index d9e2fd58..ef07ae0d 100644 --- a/packages/core/src/adapters/typescript/cause.test.ts +++ b/packages/core/src/adapters/typescript/cause.test.ts @@ -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'; @@ -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 extends ExtraMatchers {} } }\n', + ); + writeFileSync( + join(extra, 'matchers.d.ts'), + 'export interface ExtraMatchers { toBeOdd(): R; }\n', + ); + if (mode === 'reference') { + writeFileSync(join(extra, 'globals.d.ts'), readFileSync(join(extra, 'index.d.ts'))); + writeFileSync(join(extra, 'index.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'), diff --git a/packages/core/src/adapters/typescript/cause.ts b/packages/core/src/adapters/typescript/cause.ts index 79945906..28d66d6c 100644 --- a/packages/core/src/adapters/typescript/cause.ts +++ b/packages/core/src/adapters/typescript/cause.ts @@ -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 @@ -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}`; @@ -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[] = []; From 02f8eb4edcc69716fd1935a6057b47dc155a70ab Mon Sep 17 00:00:00 2001 From: Lucas Braum Date: Wed, 14 Oct 2026 18:56:33 -0700 Subject: [PATCH 5/5] fix(check): discard setup-import lookups with the rest of shared state The setup-import cache added for dependency augmentations is keyed by program, so register it with shared-state like every other module-level TypeScript holder. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Lucas Braum --- .../core/src/adapters/typescript/augmentation-import.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/core/src/adapters/typescript/augmentation-import.ts b/packages/core/src/adapters/typescript/augmentation-import.ts index 03c11942..c3748e36 100644 --- a/packages/core/src/adapters/typescript/augmentation-import.ts +++ b/packages/core/src/adapters/typescript/augmentation-import.ts @@ -1,6 +1,7 @@ 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 { @@ -19,7 +20,10 @@ export function packageOf(fileName: string): string { return names.at(-1)?.[1] ?? (fileName.split('/').at(-2) ?? fileName).replace(/-v\d+$/, ''); } -const registrations = new WeakMap>(); +let registrations = new WeakMap>(); +onReset(() => { + registrations = new WeakMap(); +}); /** * A dependency can supply the augmentation rather than the repository declaring it.