diff --git a/.changeset/quiet-project-scope.md b/.changeset/quiet-project-scope.md new file mode 100644 index 00000000..5a29e777 --- /dev/null +++ b/.changeset/quiet-project-scope.md @@ -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. diff --git a/README.md b/README.md index 5768ce85..a249bc46 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. + 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 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..c3748e36 --- /dev/null +++ b/packages/core/src/adapters/typescript/augmentation-import.ts @@ -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>(); +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(); + 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[] = []; 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..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 } 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,7 +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 { 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, @@ -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,125 @@ 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))); + // 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) { + const files = requested?.filter((f) => owner(join(dir, f)) === project); + if (files && !files.length) continue; + signals.push( + await compileProject( + ref, + targets, + { ...options, ...(options.files ? { files } : {}) }, + project.config, + (file) => owner(file) === project, + ), + ); + } + 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, + 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); + 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 { @@ -811,10 +921,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: [], @@ -881,12 +991,14 @@ export async function compileAgainstTargets( ], }; }; - 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) @@ -1005,6 +1117,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..4edb2e40 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 } from './project-scope.js'; import { forgetRepo, loadedRepo, readInstalled, workspacePackagesOf } from './repo.js'; import { findUsagesInRepo, @@ -173,6 +174,7 @@ export function createTypescriptAdapter(opts: TypescriptAdapterOptions = {}): Ty }, forgetRepo(repo) { forgetRepo(realpathSync(repo.dir)); + 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 new file mode 100644 index 00000000..572637cc --- /dev/null +++ b/packages/core/src/adapters/typescript/project-scope.test.ts @@ -0,0 +1,227 @@ +import { mkdirSync, mkdtempSync, realpathSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { ts } from 'ts-morph'; +import { describe, expect, it, vi } 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('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', { + 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.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' }] }); + 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); + 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); + 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..801902d1 --- /dev/null +++ b/packages/core/src/adapters/typescript/project-scope.ts @@ -0,0 +1,179 @@ +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; + covers(file: string): 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 = repo.root ? root : 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[] = []; + 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)); + 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 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); + } + 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))) || 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 338251eb..c6ab4fe7 100644 --- a/packages/core/src/adapters/typescript/repo.ts +++ b/packages/core/src/adapters/typescript/repo.ts @@ -65,13 +65,16 @@ 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()]); + // 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) { - repo = loadRepo(dir, rootFiles); + repo = loadRepo(dir, rootFiles, configPath); repos.set(key, repo); } return repo; @@ -175,10 +178,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 +208,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..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,6 +58,97 @@ 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 example compiler errors (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/') && f.evidence === 'compiler', + ), + ).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 +158,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 +173,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..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> = {}; @@ -767,6 +770,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 })), @@ -2274,8 +2278,11 @@ 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. if (pack?.runtimeFindings && only) { try { + const inventory = packInventory(ctx.opts.cwd, workspace, pack.inventory); findings.push( ...pack.runtimeFindings({ root: ctx.opts.cwd, @@ -2284,7 +2291,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'); 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/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/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. */