From 09d9e7b525867dc13f42abe0de84c8459a0f8e0e Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 16:22:09 +0000 Subject: [PATCH 1/2] fix(fix): verify with the repository's own TypeScript, never one from NODE_PATH resolveCompiler required typescript from the repository with Node's resolution, which also searches NODE_PATH and the global folders: with a global TypeScript 6 on NODE_PATH, a repository without its own compiler was verified by it (TS5107 for moduleResolution node10, which CI, with no NODE_PATH, never saw). It now takes node_modules/typescript of the repository or an ancestor, else the bundled compiler. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01B2T8GmEVnZwfNrugajEMqi Signed-off-by: Claude --- .changeset/consumer-compiler-only.md | 8 +++ packages/core/src/fix/compiler.test.ts | 68 +++++++++++++++++++++----- packages/core/src/fix/verify.ts | 24 +++++++-- 3 files changed, 85 insertions(+), 15 deletions(-) create mode 100644 .changeset/consumer-compiler-only.md diff --git a/.changeset/consumer-compiler-only.md b/.changeset/consumer-compiler-only.md new file mode 100644 index 0000000..038bdab --- /dev/null +++ b/.changeset/consumer-compiler-only.md @@ -0,0 +1,8 @@ +--- +"uptide": patch +--- + +`fix` verifies with the repository's own TypeScript (its `node_modules/typescript`, or an +ancestor's) or the bundled one, and never with a TypeScript that `NODE_PATH` or Node's global +folders happen to provide. A machine with a global TypeScript 6 made verification report +TS5107 deprecations the repository never sees. diff --git a/packages/core/src/fix/compiler.test.ts b/packages/core/src/fix/compiler.test.ts index b127c0c..67ba219 100644 --- a/packages/core/src/fix/compiler.test.ts +++ b/packages/core/src/fix/compiler.test.ts @@ -1,27 +1,71 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; import { ts } from 'ts-morph'; -import { expect, it } from 'vitest'; -import { resolveCompiler } from './verify.js'; +import { afterAll, expect, it } from 'vitest'; +import { consumerCompilerDir, resolveCompiler } from './verify.js'; + +const scratch = mkdtempSync(join(tmpdir(), 'uptide-compiler-')); +afterAll(() => rmSync(scratch, { recursive: true, force: true })); it('prefers the consumer compiler to the bundled compiler', () => { const consumer = { ...ts, version: 'consumer' }; expect( - resolveCompiler('/consumer', ts, (path) => { - expect(path).toBe('/consumer/package.json'); - return consumer; - }), + resolveCompiler( + '/consumer', + ts, + (path) => { + expect(path).toBe('/consumer/node_modules/typescript'); + return consumer; + }, + () => '/consumer/node_modules/typescript', + ), ).toBe(consumer); }); it('uses the bundled compiler when the consumer has no TypeScript', () => { expect( - resolveCompiler('/consumer', ts, () => { - throw new Error('not installed'); - }), + resolveCompiler( + '/consumer', + ts, + () => { + throw new Error('never loaded'); + }, + () => undefined, + ), ).toBe(ts); }); it('explains how to recover if neither compiler exists', () => { expect(() => - resolveCompiler('/consumer', null as unknown as typeof ts, () => { - throw new Error('missing'); - }), + resolveCompiler( + '/consumer', + null as unknown as typeof ts, + () => { + throw new Error('missing'); + }, + () => undefined, + ), ).toThrow('TypeScript compiler unavailable'); }); +it("finds the repository's own TypeScript in an ancestor, and nothing else", () => { + // A workspace below the root that installs it: the root's copy is the one its build uses. + const root = join(scratch, 'repo'); + const workspace = join(root, 'packages', 'app'); + mkdirSync(workspace, { recursive: true }); + expect(consumerCompilerDir(workspace)).toBeUndefined(); + const own = join(root, 'node_modules', 'typescript'); + mkdirSync(own, { recursive: true }); + writeFileSync( + join(own, 'package.json'), + JSON.stringify({ name: 'typescript', version: '0.0.0-own', main: 'index.js' }), + ); + writeFileSync(join(own, 'index.js'), "module.exports = { version: '0.0.0-own' };"); + expect(consumerCompilerDir(workspace)).toBe(own); + expect(resolveCompiler(workspace).version).toBe('0.0.0-own'); +}); +it('never takes a compiler from NODE_PATH or the global folders', () => { + // Whatever this machine has installed globally, a repository without TypeScript gets the + // bundled compiler (a global 6.x once made a test see TS5107 that CI never saw). + const bare = join(scratch, 'bare'); + mkdirSync(bare, { recursive: true }); + expect(resolveCompiler(bare)).toBe(ts); +}); diff --git a/packages/core/src/fix/verify.ts b/packages/core/src/fix/verify.ts index 7dadfe8..a302f0b 100644 --- a/packages/core/src/fix/verify.ts +++ b/packages/core/src/fix/verify.ts @@ -1,6 +1,6 @@ import { existsSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs'; import { builtinModules, createRequire } from 'node:module'; -import { basename, dirname, join, relative } from 'node:path'; +import { basename, dirname, join, relative, resolve } from 'node:path'; import { Project, ts } from 'ts-morph'; import { readInstalled } from '../adapters/typescript/repo.js'; import { workspaceSourceMap } from '../adapters/typescript/workspace-source.js'; @@ -9,14 +9,32 @@ import { command } from './process.js'; import { isServiceTest, serviceNeeds, unitConfig } from './services.js'; import type { FixDiagnostic, TestResult } from './types.js'; +/** + * The repository's own TypeScript: `node_modules/typescript` in `dir` or the nearest ancestor + * that has one, as its own build would find it. Never `NODE_PATH` or Node's global folders: + * a compiler the repository did not install is not its compiler, and verifying with one + * reports errors (or deprecations) the repository never sees. + */ +export function consumerCompilerDir(dir: string): string | undefined { + for (let current = resolve(dir); ; current = dirname(current)) { + const manifest = join(current, 'node_modules', 'typescript', 'package.json'); + if (existsSync(manifest)) return dirname(manifest); + if (dirname(current) === current) return undefined; + } +} + /** The consumer's compiler is authoritative; bundles carry ts-morph's compiler as fallback. */ export function resolveCompiler( dir: string, bundled: typeof ts | undefined = ts, - load = (path: string) => createRequire(path)('typescript') as typeof ts, + load = (packageDir: string) => + createRequire(join(packageDir, 'package.json'))(packageDir) as typeof ts, + find: (dir: string) => string | undefined = consumerCompilerDir, ): typeof ts { try { - return load(join(dir, 'package.json')); + const own = find(dir); + if (own === undefined) throw new Error('the repository installs no TypeScript'); + return load(own); } catch { if (bundled) return bundled; throw new UptideError( From b75192ccc42accff9a90b375e590d19f4dec3982 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 16:22:09 +0000 Subject: [PATCH 2/2] test(cli): stored runs in temporary directories, not /repo fixReport's prBody was /repo/.uptide/pr-body.md, and fix writes the report page next to it: where tests run as root the write succeeds, so the verify summary test saw its own report path replaced, and /repo was left behind. A fresh temporary directory per report; the summary test asserts the absolute path of its own stored run. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01B2T8GmEVnZwfNrugajEMqi Signed-off-by: Claude --- packages/cli/src/cli.test.ts | 12 ++++++++---- packages/cli/src/format-fix.test.ts | 3 ++- packages/cli/src/test-utils.ts | 4 +++- 3 files changed, 13 insertions(+), 6 deletions(-) diff --git a/packages/cli/src/cli.test.ts b/packages/cli/src/cli.test.ts index 4709a36..bcb07be 100644 --- a/packages/cli/src/cli.test.ts +++ b/packages/cli/src/cli.test.ts @@ -1,4 +1,5 @@ -import { writeFileSync } from 'node:fs'; +import { mkdtempSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; import { join } from 'node:path'; import type { FixReport } from '@uptide/core'; import { describe, expect, it } from 'vitest'; @@ -480,12 +481,15 @@ describe('uptide verify', () => { ); }); it('ends with the summary and the next commands, never the PR body', async () => { + // The stored run lives in the source repository's .git, outside the working directory. + const source = mkdtempSync(join(tmpdir(), 'uptide-source-')); + const stored = join(source, '.git/uptide/uptide__zod-4.6.5'); const engine = fakeEngine({ fix: async () => ({ ...fixReport(true), - source: '/repo', + source, base: 'main', - html: '/repo/.git/uptide/uptide__zod-4.6.5/report.html', + prBody: join(stored, 'pr-body.md'), }), }); const io = memoryIo({ cwd: pnpmGitRepo() }); @@ -494,7 +498,7 @@ describe('uptide verify', () => { expect(io.stdout()).toContain(' Risk '); expect(io.stdout()).toContain('pr --branch uptide/zod-4.6.5'); // A path outside the working directory stays absolute, so it works as typed. - expect(io.stdout()).toContain('open /repo/.git/uptide/uptide__zod-4.6.5/report.html'); + expect(io.stdout()).toContain(`open ${join(stored, 'report.html')}`); expect(io.stdout()).not.toContain('### What changed'); expect(io.stdout()).not.toContain('## Upgrade'); }); diff --git a/packages/cli/src/format-fix.test.ts b/packages/cli/src/format-fix.test.ts index 9620348..9edc1f2 100644 --- a/packages/cli/src/format-fix.test.ts +++ b/packages/cli/src/format-fix.test.ts @@ -57,6 +57,7 @@ describe('formatFixSummary', () => { { ...fixReport(true), prUrl: 'https://github.com/o/r/pull/9' }, { color: false }, ); - expect(published).toContain('https://github.com/o/r/pull/9 the pull request'); + // Aligned with the other next steps, whose longest entry sets the padding. + expect(published).toMatch(/https:\/\/github\.com\/o\/r\/pull\/9 +the pull request/); }); }); diff --git a/packages/cli/src/test-utils.ts b/packages/cli/src/test-utils.ts index 11dc416..91f1164 100644 --- a/packages/cli/src/test-utils.ts +++ b/packages/cli/src/test-utils.ts @@ -120,7 +120,9 @@ export function fixReport(passed: boolean): FixReport { }, llm: { inputTokens: 0, outputTokens: 0, costUsd: 0, available: false }, timingMs: 1, - prBody: '/repo/.uptide/pr-body.md', + // A real directory of its own: `fix` writes the report page next to the stored run, and + // a fixed path outside the temp dir is written to wherever the tests run as root. + prBody: join(mkdtempSync(join(tmpdir(), 'uptide-cli-run-')), 'pr-body.md'), notes: [], }; }