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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .changeset/consumer-compiler-only.md
Original file line number Diff line number Diff line change
@@ -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.
12 changes: 8 additions & 4 deletions packages/cli/src/cli.test.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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() });
Expand All @@ -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');
});
Expand Down
3 changes: 2 additions & 1 deletion packages/cli/src/format-fix.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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/);
});
});
4 changes: 3 additions & 1 deletion packages/cli/src/test-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: [],
};
}
Expand Down
68 changes: 56 additions & 12 deletions packages/core/src/fix/compiler.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
24 changes: 21 additions & 3 deletions packages/core/src/fix/verify.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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(
Expand Down
Loading