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: 7 additions & 1 deletion docs/typecheck.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ links:

<!--typecheck-cmd-->
```bash [Terminal]
npx nuxt typecheck [ROOTDIR] [--cwd=<directory>] [--logLevel=<silent|info|verbose>] [--dotenv=<path>...] [-e, --extends=<layer-name>...] [--checker=<vue-tsc|golar>] [-b, --build]
npx nuxt typecheck [ROOTDIR] [--cwd=<directory>] [--logLevel=<silent|info|verbose>] [--dotenv=<path>...] [-e, --extends=<layer-name>...] [--checker=<vue-tsc|golar>] [-b, --build] [--incremental]
```
<!--/typecheck-cmd-->

Expand All @@ -36,6 +36,8 @@ The `typecheck` command runs [`vue-tsc`](https://github.com/vuejs/language-tools
| `--checker=<vue-tsc\|golar>` | | Type checker to use |
| `-b, --build` | | Type-check in build mode, using TypeScript project references (detected automatically by default) |
| `--no-build` | | Type-check without TypeScript project references |
| `--incremental` | `true` | Reuse type information from previous runs |
| `--no-incremental` | | Type-check from scratch, without reusing type information from previous runs |
<!--/typecheck-opts-->

## Type checkers
Expand All @@ -46,6 +48,10 @@ The `typecheck` command runs [`vue-tsc`](https://github.com/vuejs/language-tools

A solution-style `tsconfig.json` (one that only lists `references`) is type-checked in build mode automatically. `--build` forces it, and `--no-build` turns it off.

## Incremental type-checking

In build mode, type information from each run is stored in the `tsBuildInfoFile` of every project referenced by `tsconfig.json`, so later runs only re-check what changed. From Nuxt v4.7, Nuxt's generated project configs store it in `.nuxt/cache/typecheck`, which is kept by `nuxt prepare` and `nuxt dev`. The cache is discarded when the type checker or TypeScript version changes, and is not used if any referenced project has no `tsBuildInfoFile`. Pass `--no-incremental` to remove the cached type information and type-check from scratch.

::note
This command sets `process.env.NODE_ENV` to `production`. To override, define `NODE_ENV` in a [`.env`](/docs/directory-structure/env) file or as a command-line argument.
::
Expand Down
27 changes: 26 additions & 1 deletion packages/nuxt-cli/src/commands/typecheck.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { logger } from '../utils/logger'
import { resolveRootDir } from '../utils/paths'
import { withNodePath } from '../utils/resolve-nuxt'
import { createSpinner } from '../utils/spinner'
import { discardBuildInfo, resolveCacheKey, resolveIncrementalArgs } from '../utils/typecheck-cache'
import { dotEnvArgs, extendsArgs, logLevelArgs, rootDirArgs } from './_shared'

type TypeChecker = 'vue-tsc' | 'golar'
Expand All @@ -43,6 +44,8 @@ interface TypeCheckerBackend {
configNote?: string
resolve: (cwd: string, options?: { cache?: boolean }) => ResolvedTypeChecker
args: (supportsProjects: boolean) => string[]
/** Entry files of the packages whose versions key the incremental build info cache. */
cachePackages: (cwd: string, bin: string) => Array<string | undefined>
ensureConfig?: (cwd: string) => Promise<void>
}

Expand Down Expand Up @@ -79,6 +82,7 @@ const TYPE_CHECKERS: Record<TypeChecker, TypeCheckerBackend> = {
}
},
args: supportsProjects => supportsProjects ? ['-b', '--noEmit'] : ['--noEmit'],
cachePackages: (_cwd, bin) => [bin, resolveModulePath('typescript', { from: bin, try: true })],
},
'golar': {
label: 'Golar',
Expand All @@ -99,6 +103,7 @@ const TYPE_CHECKERS: Record<TypeChecker, TypeCheckerBackend> = {
}
},
args: supportsProjects => supportsProjects ? ['tsc', '--build', '--noEmit'] : ['tsc', '--noEmit'],
cachePackages: (cwd, bin) => [bin, resolveModulePath('@golar/vue', { from: withNodePath(cwd), try: true })],
ensureConfig: ensureGolarConfig,
},
}
Expand Down Expand Up @@ -126,6 +131,12 @@ export default defineCommand({
description: 'Type-check in build mode, using TypeScript project references (detected automatically by default)',
negativeDescription: 'Type-check without TypeScript project references',
},
incremental: {
type: 'boolean',
description: 'Reuse type information from previous runs',
negativeDescription: 'Type-check from scratch, without reusing type information from previous runs',
default: true,
},
},
async run(ctx) {
process.env.NODE_ENV = process.env.NODE_ENV || 'production'
Expand Down Expand Up @@ -167,8 +178,22 @@ export default defineCommand({
logger.warn(`Your ${styleText('cyan', 'tsconfig.json')} references Nuxt's generated project configs but also includes source files of its own, so type-checking will not run in build mode. Add ${styleText('cyan', '"files": []')} to your ${styleText('cyan', 'tsconfig.json')}, or pass ${styleText('cyan', '--build')} to override.`)
}

const backend = TYPE_CHECKERS[typechecker.checker]
const args = backend.args(useProjectReferences)
if (useProjectReferences && supportsProjectReferences(tsConfig)) {
if (!ctx.args.incremental) {
await discardBuildInfo(cwd)
}
else {
const cacheKey = resolveCacheKey(backend.cachePackages(cwd, typechecker.bin))
if (cacheKey) {
args.push(...await resolveIncrementalArgs(cwd, cacheKey))
}
}
}

const start = Date.now()
const result = await x(typechecker.bin, TYPE_CHECKERS[typechecker.checker].args(useProjectReferences), {
const result = await x(typechecker.bin, args, {
nodeOptions: { stdio: 'inherit', cwd },
})
const duration = formatDuration(Date.now() - start)
Expand Down
128 changes: 128 additions & 0 deletions packages/nuxt-cli/src/utils/typecheck-cache.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
import { existsSync, readFileSync } from 'node:fs'
import { mkdir, readFile, rm, utimes, writeFile } from 'node:fs/promises'
import { parseJSONC } from 'confbox/jsonc'
import { dirname, resolve } from 'pathe'

import { debug } from './logger'

interface ProjectConfig {
compilerOptions?: { tsBuildInfoFile?: unknown }
references?: Array<{ path: string }>
}

const STAMP_FILE = 'checker.stamp'

/**
* Builds a cache key such as `vue-tsc@3.3.10+typescript@6.0.3` from the resolved entry files of the
* packages that affect type-checking output. Returns `undefined` if any version cannot be read.
*/
export function resolveCacheKey(entries: Array<string | undefined>): string | undefined {
const ids: string[] = []
for (const entry of entries) {
const id = entry && readPackageId(entry)
if (!id) {
return undefined
}
ids.push(id)
}
return ids.join('+')
}

/**
* Returns `['--incremental']` if every project referenced by the solution-style `tsconfig.json` in
* `cwd` sets its own `tsBuildInfoFile`, after discarding build info written for a different cache key
* and marking the referenced configs as newer than their build info.
*/
export async function resolveIncrementalArgs(cwd: string, key: string): Promise<string[]> {
try {
const { configs, buildInfoFiles } = await resolveProjects(cwd)
if (configs.length === 0 || buildInfoFiles.length < configs.length) {
return []
}

await discardStaleBuildInfo(buildInfoFiles, key)

// Build mode skips a project whose config and root files are older than its build info, without
// checking the files they import.
const now = new Date()
await Promise.all(configs.map(config => utimes(config, now, now)))

return ['--incremental']
}
catch (error) {
debug('Skipping incremental type-checking:', error)
return []
}
}

/** Removes the build info of the projects referenced by the solution-style `tsconfig.json` in `cwd`. */
export async function discardBuildInfo(cwd: string): Promise<void> {
try {
const { buildInfoFiles } = await resolveProjects(cwd)
await Promise.all(buildInfoFiles.map(file => rm(file, { force: true })))
}
catch (error) {
debug('Could not remove build info:', error)
}
}

async function resolveProjects(cwd: string) {
const rootConfig = resolve(cwd, 'tsconfig.json')
const configs = (await readConfig(rootConfig)).references?.map(reference => resolveReference(dirname(rootConfig), reference.path)) ?? []

const buildInfoFiles: string[] = []
for (const config of configs) {
const tsBuildInfoFile = (await readConfig(config)).compilerOptions?.tsBuildInfoFile
if (typeof tsBuildInfoFile === 'string') {
buildInfoFiles.push(resolve(dirname(config), tsBuildInfoFile))
}
}
return { configs, buildInfoFiles }
}

async function discardStaleBuildInfo(buildInfoFiles: string[], key: string) {
const dirs = new Set(buildInfoFiles.map(file => dirname(file)))
for (const dir of dirs) {
const stamp = resolve(dir, STAMP_FILE)
if (await readFile(stamp, 'utf8').catch(() => undefined) === key) {
continue
}
await Promise.all(buildInfoFiles
.filter(file => dirname(file) === dir)
.map(file => rm(file, { force: true })))
await mkdir(dir, { recursive: true })
await writeFile(stamp, key)
}
}

function resolveReference(dir: string, path: string) {
const resolved = resolve(dir, path)
return resolved.endsWith('.json') ? resolved : resolve(resolved, 'tsconfig.json')
}

async function readConfig(path: string): Promise<ProjectConfig> {
return parseJSONC(await readFile(path, 'utf8'), { allowTrailingComma: true }) ?? {}
}

function readPackageId(entry: string): string | undefined {
let dir = dirname(entry)
while (true) {
const manifest = resolve(dir, 'package.json')
if (existsSync(manifest)) {
try {
const { name, version } = JSON.parse(readFileSync(manifest, 'utf8')) as { name?: string, version?: string }
if (name && version) {
return `${name}@${version}`
}
}
catch {
return undefined
}
}
const parent = dirname(dir)
if (parent === dir) {
return undefined
}
dir = parent
}
}
123 changes: 122 additions & 1 deletion packages/nuxt-cli/test/unit/commands/typecheck.spec.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { EventEmitter } from 'node:events'
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
import { mkdir, mkdtemp, readFile, rm, stat, utimes, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import process from 'node:process'

Expand Down Expand Up @@ -278,3 +278,124 @@ describe('typecheck type generation', () => {
expect(tinyexec).toHaveBeenCalledTimes(1)
})
})

describe('typecheck incremental cache', () => {
const vueTscKey = 'vue-tsc@3.3.10+typescript@6.0.3'

/** Pretend the named packages are installed with the given versions and entry files. */
async function installedPackages(packages: Record<string, { version: string, entries: string[] }>): Promise<void> {
const entries = new Map<string, string>()
for (const [name, { version, entries: ids }] of Object.entries(packages)) {
const dir = join(cwd, 'node_modules', name)
await mkdir(dir, { recursive: true })
await writeFile(join(dir, 'package.json'), JSON.stringify({ name, version, bin: './bin.js' }))
for (const id of ids) {
entries.set(id, join(dir, id.slice(name.length + 1) || 'index.js'))
}
}
resolveModulePath.mockImplementation((id: string) => entries.get(id))
}

function installedVueTsc(version = '3.3.10') {
return installedPackages({
'typescript': { version: '6.0.3', entries: ['typescript'] },
'vue-tsc': { version, entries: ['vue-tsc/bin/vue-tsc.js'] },
})
}

async function writeFixture(path: string, contents: unknown) {
await mkdir(join(cwd, path, '..'), { recursive: true })
await writeFile(join(cwd, path), typeof contents === 'string' ? contents : JSON.stringify(contents))
}

async function writeProjects() {
await writeFixture('tsconfig.json', { files: [], references: [{ path: './.nuxt/tsconfig.app.json' }, { path: './layer' }] })
await writeFixture('.nuxt/tsconfig.app.json', { compilerOptions: { tsBuildInfoFile: './cache/typecheck/tsconfig.app.tsbuildinfo' } })
await writeFixture('layer/tsconfig.json', { compilerOptions: { tsBuildInfoFile: '../.nuxt/cache/typecheck/layer.tsbuildinfo' } })
}

it('should type-check incrementally when every referenced project sets `tsBuildInfoFile`', async () => {
await installedVueTsc()
await writeProjects()

await runTypecheck()

expect(tinyexec).toHaveBeenCalledWith(expect.anything(), ['-b', '--noEmit', '--incremental'], expect.anything())
})

it('should mark the referenced configs as newer than their build info', async () => {
await installedVueTsc()
await writeProjects()
await utimes(join(cwd, '.nuxt/tsconfig.app.json'), 0, 0)

await runTypecheck()

expect((await stat(join(cwd, '.nuxt/tsconfig.app.json'))).mtimeMs).toBeGreaterThan(0)
})

it('should discard build info written by a different checker', async () => {
const stamp = join(cwd, '.nuxt/cache/typecheck/checker.stamp')
const buildInfo = '.nuxt/cache/typecheck/tsconfig.app.tsbuildinfo'
await installedVueTsc('3.3.9')
await writeProjects()
await writeFixture(buildInfo, '{}')
await runTypecheck()
expect(await readFile(stamp, 'utf8')).toBe('vue-tsc@3.3.9+typescript@6.0.3')

await writeFixture(buildInfo, '{}')
await runTypecheck()
expect(await readFile(join(cwd, buildInfo), 'utf8')).toBe('{}')

await installedVueTsc()
await runTypecheck()
await expect(stat(join(cwd, buildInfo))).rejects.toThrow()
expect(await readFile(stamp, 'utf8')).toBe(vueTscKey)
})

it('should remove the build info with `--no-incremental`', async () => {
const buildInfo = '.nuxt/cache/typecheck/tsconfig.app.tsbuildinfo'
await installedVueTsc()
await writeProjects()
await writeFixture(buildInfo, '{}')

await runTypecheck(['--no-incremental'])

expect(tinyexec).toHaveBeenCalledWith(expect.anything(), ['-b', '--noEmit'], expect.anything())
await expect(stat(join(cwd, buildInfo))).rejects.toThrow()
})

it('should leave the build info alone when the checker version cannot be read', async () => {
const buildInfo = '.nuxt/cache/typecheck/tsconfig.app.tsbuildinfo'
await writeProjects()
await writeFixture(buildInfo, '{}')

await runTypecheck()

expect(tinyexec).toHaveBeenCalledWith(expect.anything(), ['-b', '--noEmit'], expect.anything())
expect(await readFile(join(cwd, buildInfo), 'utf8')).toBe('{}')
})

it('should type-check without the cache when a checker manifest cannot be parsed', async () => {
await installedVueTsc()
await writeProjects()
await writeFixture('node_modules/vue-tsc/package.json', '{')

const { exitCode } = await runTypecheck()

expect(exitCode).toBeFalsy()
expect(tinyexec).toHaveBeenCalledWith(expect.anything(), ['-b', '--noEmit'], expect.anything())
})

it('should key the golar cache by golar and its vue plugin', async () => {
await installedPackages({
'golar': { version: '0.1.11', entries: ['golar/unstable'] },
'@golar/vue': { version: '0.1.12', entries: ['@golar/vue'] },
})
await writeProjects()

await runTypecheck(['--checker=golar'])

expect(tinyexec).toHaveBeenCalledWith(expect.anything(), ['tsc', '--build', '--noEmit', '--incremental'], expect.anything())
expect(await readFile(join(cwd, '.nuxt/cache/typecheck/checker.stamp'), 'utf8')).toBe('golar@0.1.11+@golar/vue@0.1.12')
})
})
2 changes: 2 additions & 0 deletions packages/nuxt-cli/test/unit/help.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -452,6 +452,8 @@ describe('help', () => {
--checker=<vue-tsc|golar> Type checker to use
-b, --build Type-check in build mode, using TypeScript project references (detected automatically by default)
--no-b, --no-build Type-check without TypeScript project references
--incremental Reuse type information from previous runs (Default: true)
--no-incremental Type-check from scratch, without reusing type information from previous runs
"
`)
})
Expand Down
Loading