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
5 changes: 5 additions & 0 deletions .changeset/gap-cli-typescript-alias.md
Original file line number Diff line number Diff line change
@@ -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.
5 changes: 3 additions & 2 deletions packages/oracles/oracle-gaps/check.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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`,
Expand Down
2 changes: 1 addition & 1 deletion packages/oracles/oracle-gaps/gap-report-model-oracle.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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`).
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
//
Expand Down
2 changes: 1 addition & 1 deletion packages/oracles/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
179 changes: 178 additions & 1 deletion packages/oracles/tests/package.test.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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<string>, 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<string>
) =>
run(
`npm`,
[
command,
`--prefer-offline`,
`--no-audit`,
`--no-fund`,
`--ignore-scripts`,
...specs,
],
directory,
)
const project = (name: string, ...specs: Array<string>) => {
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<unknown> }
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)
})
6 changes: 3 additions & 3 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading