diff --git a/.changeset/gap-cli-typescript-alias.md b/.changeset/gap-cli-typescript-alias.md new file mode 100644 index 0000000..3b54b47 --- /dev/null +++ b/.changeset/gap-cli-typescript-alias.md @@ -0,0 +1,5 @@ +--- +'@tanstack/oracles': patch +--- + +The `oracle-gaps` CLI's TypeScript is now the npm alias dependency `typescript-for-tanstack-oracles` (`npm:typescript@^5.9.2`), and the CLI resolves it by that name. Before, the plain `typescript` dependency was hoisted by npm to an adopter's root. A project without TypeScript got a `typescript` package, and a project with its own TypeScript range could have that range deduped up to 5.9. Now neither happens. npm still links the alias's `tsc` and `tsserver` into `node_modules/.bin` in a project without TypeScript. After an incremental `npm install` of this package into a project with TypeScript, `.bin/tsc` points at the alias until a clean install (`npm ci`, or removing `node_modules`); a later plain `npm install` does not repair it. That is still strictly better than before, when the same incremental install raised the project's root `typescript` itself, for example from 5.4.5 to 5.9.3. On a clean install, the project's own `typescript` provides `tsc`. pnpm links only direct dependencies' commands, so it is not affected. diff --git a/packages/oracles/oracle-gaps/check.mjs b/packages/oracles/oracle-gaps/check.mjs index 8a88370..aa91ff7 100644 --- a/packages/oracles/oracle-gaps/check.mjs +++ b/packages/oracles/oracle-gaps/check.mjs @@ -268,8 +268,9 @@ export async function openSession(pkg) { // The analysis reads with this CLI's own TypeScript, never the package's: a // TypeScript 7 package exposes its version and no compiler API. The CLI's // dependency is TypeScript 5, so a copy without the API is a broken install, - // refused before any work. - const ts = createRequire(import.meta.url)(`typescript`) + // refused before any work. The dependency is an npm alias, so installing + // this package puts no `typescript` where an adopter's tools resolve it. + const ts = createRequire(import.meta.url)(`typescript-for-tanstack-oracles`) if (typeof ts.createSourceFile !== `function`) throw new Refusal( `the TypeScript this CLI depends on (${ts.version}) has no compiler API`, diff --git a/packages/oracles/oracle-gaps/gap-report-model-oracle.mjs b/packages/oracles/oracle-gaps/gap-report-model-oracle.mjs index 97d2b1d..bde054d 100644 --- a/packages/oracles/oracle-gaps/gap-report-model-oracle.mjs +++ b/packages/oracles/oracle-gaps/gap-report-model-oracle.mjs @@ -23,7 +23,7 @@ import fc from 'fast-check' // TypeScript is the authority on what a token is (law 6), not the CLI. const ts = createRequire( join(dirname(fileURLToPath(import.meta.url)), `../package.json`), -)(`typescript`) +)(`typescript-for-tanstack-oracles`) // The prefix of the framework's environment variables that the fixtures read // and the end-to-end oracle passes to the CLI (`--env-prefix`). diff --git a/packages/oracles/oracle-gaps/gap-report-mutant-validity-oracle.property.test.mjs b/packages/oracles/oracle-gaps/gap-report-mutant-validity-oracle.property.test.mjs index e0e75d9..07161a2 100644 --- a/packages/oracles/oracle-gaps/gap-report-mutant-validity-oracle.property.test.mjs +++ b/packages/oracles/oracle-gaps/gap-report-mutant-validity-oracle.property.test.mjs @@ -103,7 +103,9 @@ import { oracleProperty, pass, violation } from '../src/oracle-property.ts' import { instrument } from './schemata.mjs' const here = dirname(fileURLToPath(import.meta.url)) -const ts = createRequire(join(here, `../package.json`))(`typescript`) +const ts = createRequire(join(here, `../package.json`))( + `typescript-for-tanstack-oracles`, +) const LAW_1 = `every emitted mutant that is not dead code differs from the original on some input` const LAW_2 = `every skipped site equals the original on every input` diff --git a/packages/oracles/oracle-gaps/gap-report-probe-form-oracle.property.test.mjs b/packages/oracles/oracle-gaps/gap-report-probe-form-oracle.property.test.mjs index 16cb1f9..c67bb24 100644 --- a/packages/oracles/oracle-gaps/gap-report-probe-form-oracle.property.test.mjs +++ b/packages/oracles/oracle-gaps/gap-report-probe-form-oracle.property.test.mjs @@ -51,7 +51,9 @@ import { import { instrument } from './schemata.mjs' const here = dirname(fileURLToPath(import.meta.url)) -const ts = createRequire(join(here, `../package.json`))(`typescript`) +const ts = createRequire(join(here, `../package.json`))( + `typescript-for-tanstack-oracles`, +) // The guard's line in the rendered function. const GUARD_LINE = 2 diff --git a/packages/oracles/oracle-gaps/gap-report-static-oracle.property.test.mjs b/packages/oracles/oracle-gaps/gap-report-static-oracle.property.test.mjs index 95d1cf3..db238a4 100644 --- a/packages/oracles/oracle-gaps/gap-report-static-oracle.property.test.mjs +++ b/packages/oracles/oracle-gaps/gap-report-static-oracle.property.test.mjs @@ -125,7 +125,9 @@ import { parseArgs, usage } from './args.mjs' import { keyAndSelect } from './report.mjs' const here = dirname(fileURLToPath(import.meta.url)) -const ts = createRequire(join(here, `../package.json`))(`typescript`) +const ts = createRequire(join(here, `../package.json`))( + `typescript-for-tanstack-oracles`, +) // ## Production driver // diff --git a/packages/oracles/package.json b/packages/oracles/package.json index 540ae55..e36a815 100644 --- a/packages/oracles/package.json +++ b/packages/oracles/package.json @@ -67,7 +67,7 @@ "typecheck": "tsc --noEmit" }, "dependencies": { - "typescript": "^5.9.2" + "typescript-for-tanstack-oracles": "npm:typescript@^5.9.2" }, "peerDependencies": { "fast-check": "^4.0.0", diff --git a/packages/oracles/tests/oracle-gaps-owns-through-calls.test.ts b/packages/oracles/tests/oracle-gaps-owns-through-calls.test.ts index 6c32cde..d5ea06d 100644 --- a/packages/oracles/tests/oracle-gaps-owns-through-calls.test.ts +++ b/packages/oracles/tests/oracle-gaps-owns-through-calls.test.ts @@ -8,7 +8,7 @@ import { import { tmpdir } from 'node:os' import { join } from 'node:path' import { describe, expect, test } from 'vitest' -import ts from 'typescript' +import ts from 'typescript-for-tanstack-oracles' import { analyze } from '../oracle-gaps/analysis.mjs' // The gap report's refusal of a run whose selected sources no oracle owns. A diff --git a/packages/oracles/tests/package.test.ts b/packages/oracles/tests/package.test.ts index 295c51d..f3487f8 100644 --- a/packages/oracles/tests/package.test.ts +++ b/packages/oracles/tests/package.test.ts @@ -1,6 +1,16 @@ import { spawnSync } from 'node:child_process' -import { readFileSync } from 'node:fs' +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + readdirSync, + readlinkSync, + rmSync, + writeFileSync, +} from 'node:fs' import { createRequire, isBuiltin } from 'node:module' +import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' import { fileURLToPath } from 'node:url' import { describe, expect, it } from 'vitest' @@ -202,4 +212,171 @@ console.log(JSON.stringify({ failed, unreached }))`, expect(load.status, load.stderr).toBe(0) expect(JSON.parse(load.stdout)).toEqual({ failed: [], unreached: [] }) }, 60_000) + + // The gap CLI analyzes with its own TypeScript, which the package depends + // on under the npm alias `typescript-for-tanstack-oracles`. With a plain + // `typescript` dependency, npm hoisted it to a project without TypeScript, + // and an incremental install into a project with `typescript@^5.4.5` + // deduped the project's root TypeScript up to 5.9. Under the alias, the + // project's `typescript` is its own. npm links the bins of every package at + // the root, so the alias's `tsc` remains a known residue, asserted below as + // documented behavior: in a project without TypeScript, and after an + // incremental install into one with TypeScript, until a clean install + // (`npm ci`). The alias name sorts after `typescript`, so on a clean + // install the project's own `typescript` claims `tsc` first. The tarball + // installs with npm's cache first; npm fetches only what the cache lacks, + // from the configured registry. + it(`installs with npm without a typescript the project resolves, and its gap CLI analyzes there`, () => { + const packageDirectory = dirname(dirname(fileURLToPath(import.meta.url))) + const local = createRequire(import.meta.url) + const versionOf = (name: string) => + (local(`${name}/package.json`) as { version: string }).version + const scratch = mkdtempSync(join(tmpdir(), `oracles-npm-install-`)) + // pnpm's own `npm_` variables would configure npm as pnpm runs. + const env = Object.fromEntries( + Object.entries(process.env).filter( + ([key]) => !key.startsWith(`npm_`) && !key.startsWith(`ORACLE_`), + ), + ) + const run = (command: string, args: Array, cwd: string) => { + const ran = spawnSync(command, args, { cwd, encoding: `utf8`, env }) + expect(ran.status, `${ran.stdout}\n${ran.stderr}`).toBe(0) + return ran + } + try { + run( + process.execPath, + [ + join(dirname(local.resolve(`vite/package.json`)), `bin/vite.js`), + `build`, + ], + packageDirectory, + ) + run( + `npm`, + [`pack`, `--ignore-scripts`, `--pack-destination`, scratch], + packageDirectory, + ) + const tarball = join( + scratch, + readdirSync(scratch).find((file) => file.endsWith(`.tgz`))!, + ) + const npm = ( + directory: string, + command: string, + ...specs: Array + ) => + run( + `npm`, + [ + command, + `--prefer-offline`, + `--no-audit`, + `--no-fund`, + `--ignore-scripts`, + ...specs, + ], + directory, + ) + const project = (name: string, ...specs: Array) => { + const directory = join(scratch, name) + mkdirSync(directory) + writeFileSync( + join(directory, `package.json`), + JSON.stringify({ name, private: true, type: `module` }), + ) + npm(directory, `install`, ...specs) + return directory + } + // Where the project's `tsc` command links. + const tscOf = (directory: string) => + readlinkSync(join(directory, `node_modules/.bin/tsc`)) + const aliasTsc = join( + `..`, + `typescript-for-tanstack-oracles`, + `bin`, + `tsc`, + ) + const ownTsc = join(`..`, `typescript`, `bin`, `tsc`) + const typescriptOf = (directory: string) => + ( + JSON.parse( + readFileSync( + join(directory, `node_modules/typescript/package.json`), + `utf8`, + ), + ) as { version: string } + ).version + + // A project with no TypeScript of its own. + const bare = project( + `bare`, + tarball, + `vitest@${versionOf(`vitest`)}`, + `fast-check@${versionOf(`fast-check`)}`, + ) + expect(existsSync(join(bare, `node_modules/typescript`))).toBe(false) + // The known residue: no other package claims `tsc`. + expect(tscOf(bare)).toBe(aliasTsc) + mkdirSync(join(bare, `src`)) + mkdirSync(join(bare, `tests`)) + writeFileSync( + join(bare, `src/order.ts`), + `export function larger(a: number, b: number): number {\n return a > b ? a : b\n}\n`, + ) + writeFileSync( + join(bare, `tests/order-oracle.test.ts`), + [ + `import fc from 'fast-check'`, + `import { differs, oracleProperty, pass } from '@tanstack/oracles'`, + `import { larger } from '../src/order'`, + `oracleProperty({`, + ` id: 'scratch.larger',`, + ` owns: ['src/order.ts'],`, + ` law: { statement: 'larger returns the larger argument.', authority: 'arithmetic', limits: 'small integers' },`, + ` histories: { arbitrary: fc.tuple(fc.integer({ min: -3, max: 3 }), fc.integer({ min: -3, max: 3 })), runs: 20, fixedSeeds: [1] },`, + ` model: ([a, b]: [number, number]) => Math.max(a, b),`, + ` driver: ([a, b]: [number, number], observe) => observe('larger', larger(a, b)),`, + ` check: (_plan, trace, expected) => differs('larger', 'larger', trace[0]?.value, expected) ?? pass,`, + `})`, + ``, + ].join(`\n`), + ) + run( + process.execPath, + [ + join(bare, `node_modules/@tanstack/oracles/oracle-gaps/index.mjs`), + `--package`, + bare, + `--source`, + `src/order.ts`, + `--contexts`, + `1`, + `--out`, + join(scratch, `out`), + ], + bare, + ) + // The analysis parsed the source with the CLI's TypeScript and found + // its mutants. + const { results } = JSON.parse( + readFileSync(join(scratch, `out/results.json`), `utf8`), + ) as { results: Array } + expect(results.length).toBeGreaterThan(0) + + // A project with its own TypeScript, of a version the alias's range + // excludes, then installs the package: npm saves `^5.4.5`, which a plain + // dependency deduped up to 5.9. The project keeps 5.4.5. + const owned = project(`owned`, `typescript@5.4.5`) + npm(owned, `install`, tarball) + expect(typescriptOf(owned)).toBe(`5.4.5`) + // The known residue of the incremental install, until a clean one. + expect(tscOf(owned)).toBe(aliasTsc) + npm(owned, `ci`) + expect(typescriptOf(owned)).toBe(`5.4.5`) + expect(tscOf(owned)).toBe(ownTsc) + } finally { + rmSync(scratch, { recursive: true, force: true }) + } + }, 180_000) }) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 224f604..80a1263 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -80,9 +80,9 @@ importers: packages/oracles: dependencies: - typescript: - specifier: ^5.9.2 - version: 5.9.3 + typescript-for-tanstack-oracles: + specifier: npm:typescript@^5.9.2 + version: typescript@5.9.3 devDependencies: '@fast-check/vitest': specifier: ^0.5.0