diff --git a/docs/typecheck.md b/docs/typecheck.md index 1b0eac252..830b084df 100644 --- a/docs/typecheck.md +++ b/docs/typecheck.md @@ -10,7 +10,7 @@ links: ```bash [Terminal] -npx nuxt typecheck [ROOTDIR] [--cwd=] [--logLevel=] [--dotenv=...] [-e, --extends=...] [--checker=] [-b, --build] +npx nuxt typecheck [ROOTDIR] [--cwd=] [--logLevel=] [--dotenv=...] [-e, --extends=...] [--checker=] [-b, --build] [--incremental] ``` @@ -36,6 +36,8 @@ The `typecheck` command runs [`vue-tsc`](https://github.com/vuejs/language-tools | `--checker=` | | 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 | ## Type checkers @@ -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. :: diff --git a/packages/nuxt-cli/src/commands/typecheck.ts b/packages/nuxt-cli/src/commands/typecheck.ts index 084879079..142a08240 100644 --- a/packages/nuxt-cli/src/commands/typecheck.ts +++ b/packages/nuxt-cli/src/commands/typecheck.ts @@ -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' @@ -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 ensureConfig?: (cwd: string) => Promise } @@ -79,6 +82,7 @@ const TYPE_CHECKERS: Record = { } }, args: supportsProjects => supportsProjects ? ['-b', '--noEmit'] : ['--noEmit'], + cachePackages: (_cwd, bin) => [bin, resolveModulePath('typescript', { from: bin, try: true })], }, 'golar': { label: 'Golar', @@ -99,6 +103,7 @@ const TYPE_CHECKERS: Record = { } }, args: supportsProjects => supportsProjects ? ['tsc', '--build', '--noEmit'] : ['tsc', '--noEmit'], + cachePackages: (cwd, bin) => [bin, resolveModulePath('@golar/vue', { from: withNodePath(cwd), try: true })], ensureConfig: ensureGolarConfig, }, } @@ -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' @@ -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) diff --git a/packages/nuxt-cli/src/utils/typecheck-cache.ts b/packages/nuxt-cli/src/utils/typecheck-cache.ts new file mode 100644 index 000000000..881121ec2 --- /dev/null +++ b/packages/nuxt-cli/src/utils/typecheck-cache.ts @@ -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 { + 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 { + 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 { + 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 { + 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 + } +} diff --git a/packages/nuxt-cli/test/unit/commands/typecheck.spec.ts b/packages/nuxt-cli/test/unit/commands/typecheck.spec.ts index dead4a332..43237934e 100644 --- a/packages/nuxt-cli/test/unit/commands/typecheck.spec.ts +++ b/packages/nuxt-cli/test/unit/commands/typecheck.spec.ts @@ -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' @@ -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): Promise { + const entries = new Map() + 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') + }) +}) diff --git a/packages/nuxt-cli/test/unit/help.spec.ts b/packages/nuxt-cli/test/unit/help.spec.ts index 92a463d5b..9769eff37 100644 --- a/packages/nuxt-cli/test/unit/help.spec.ts +++ b/packages/nuxt-cli/test/unit/help.spec.ts @@ -452,6 +452,8 @@ describe('help', () => { --checker= 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 " `) })