Skip to content
Draft
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: 4 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] [--per-project]
```
<!--/typecheck-cmd-->

Expand All @@ -36,6 +36,7 @@ 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 |
| `--per-project` | | Type-check each project reference in its own process, one after another, to lower peak memory usage |
<!--/typecheck-opts-->

## Type checkers
Expand All @@ -46,6 +47,8 @@ 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.

Build mode checks every project reference in a single process, which holds on to the memory of each project until the end. On machines with little memory, `--per-project` checks each reference in its own process instead (`vue-tsc -p <reference> --noEmit`, or `golar tsc -p <reference> --noEmit`), one after another, so peak memory stays at that of the largest project. It keeps going after a failing project, so one run reports every error. Without memory pressure this is usually a little slower, as types shared between projects are loaded once per process. It is meant for Nuxt's generated project configs: if a referenced project has `references` of its own, those are not built first.

::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
100 changes: 94 additions & 6 deletions packages/nuxt-cli/src/commands/typecheck.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,8 @@ import { styleText } from 'node:util'
import { cancel, confirm, isCancel, select } from '@clack/prompts'
import { defineCommand } from 'citty'
import { resolveModulePath } from 'exsolve'
import { dirname, resolve } from 'pathe'
import { readPackageJSON, readTSConfig } from 'pkg-types'
import { dirname, relative, resolve } from 'pathe'
import { readPackageJSON, readTSConfig, resolveTSConfig } from 'pkg-types'
import { hasTTY } from 'std-env'
import { x } from 'tinyexec'

Expand Down Expand Up @@ -45,6 +45,8 @@ interface TypeCheckerBackend {
configNote?: string
resolve: (cwd: string, options?: { cache?: boolean }) => ResolvedTypeChecker
args: (supportsProjects: boolean) => string[]
/** Arguments to type-check a single project, without building its references. */
projectArgs: (project: string) => string[]
ensureConfig?: (cwd: string) => Promise<void>
}

Expand Down Expand Up @@ -81,6 +83,7 @@ const TYPE_CHECKERS: Record<TypeChecker, TypeCheckerBackend> = {
}
},
args: supportsProjects => supportsProjects ? ['-b', '--noEmit'] : ['--noEmit'],
projectArgs: project => ['-p', project, '--noEmit'],
},
'golar': {
label: 'Golar',
Expand All @@ -101,6 +104,7 @@ const TYPE_CHECKERS: Record<TypeChecker, TypeCheckerBackend> = {
}
},
args: supportsProjects => supportsProjects ? ['tsc', '--build', '--noEmit'] : ['tsc', '--noEmit'],
projectArgs: project => ['tsc', '-p', project, '--noEmit'],
ensureConfig: ensureGolarConfig,
},
}
Expand All @@ -117,17 +121,21 @@ export default defineCommand({
...logLevelArgs,
...dotEnvArgs,
...extendsArgs,
checker: {
'checker': {
type: 'string',
description: 'Type checker to use',
valueHint: CHECKER_PRIORITY.join('|'),
},
build: {
'build': {
type: 'boolean',
alias: 'b',
description: 'Type-check in build mode, using TypeScript project references (detected automatically by default)',
negativeDescription: 'Type-check without TypeScript project references',
},
'per-project': {
type: 'boolean',
description: 'Type-check each project reference in its own process, one after another, to lower peak memory usage',
},
},
async run(ctx) {
process.env.NODE_ENV = process.env.NODE_ENV || 'production'
Expand All @@ -147,8 +155,8 @@ export default defineCommand({
return
}

const [tsConfig] = await Promise.all([
readTSConfig(cwd).catch(() => ({} as TSConfig)),
const [{ path: tsConfigPath, config: tsConfig }] = await Promise.all([
readRootTSConfig(cwd),
writeTypes(cwd, ctx.args.dotenv, ctx.args.logLevel as 'silent' | 'info' | 'verbose', {
...ctx.data?.overrides,
...(ctx.args.extends.length > 0 && { extends: ctx.args.extends }),
Expand All @@ -161,6 +169,15 @@ 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.`)
}

if (ctx.args['per-project']) {
// Build mode only reads `tsconfig.json` in the root directory, not one found further up.
if (useProjectReferences && tsConfigPath === resolve(cwd, 'tsconfig.json') && supportsProjectReferences(tsConfig)) {
process.exitCode = await typecheckPerProject(cwd, typechecker, resolveProjectReferences(tsConfigPath, tsConfig))
return
}
logger.warn(`Ignoring ${styleText('cyan', '--per-project')}: it needs build mode and a ${styleText('cyan', 'tsconfig.json')} at the root of your project that only lists project references.`)
}

const start = Date.now()
const result = await x(typechecker.bin, TYPE_CHECKERS[typechecker.checker].args(useProjectReferences), {
nodeOptions: { stdio: 'inherit', cwd },
Expand All @@ -181,6 +198,77 @@ export default defineCommand({
},
})

async function readRootTSConfig(cwd: string): Promise<{ path?: string, config: TSConfig }> {
const path = await resolveTSConfig(cwd).catch(() => undefined)
const config = path ? await readTSConfig(path).catch(() => ({} as TSConfig)) : {}
return { path, config }
}

/**
* Resolve the `references` of a tsconfig to config file paths, the way TypeScript does: relative to
* the referencing config, with a path that does not end in `.json` pointing at a directory.
*/
function resolveProjectReferences(tsConfigPath: string, config: TSConfig): string[] {
const root = dirname(tsConfigPath)
return (config.references ?? [])
.filter(reference => reference.path)
.map((reference) => {
const path = resolve(root, reference.path)
return path.endsWith('.json') ? path : resolve(path, 'tsconfig.json')
})
}

/**
* Type-check each project in its own checker process, one after another, so peak memory stays at
* that of the largest project rather than all of them. Continues after a failing project so a
* single run reports every error, and resolves to the exit code to use.
*/
async function typecheckPerProject(cwd: string, typechecker: TypeCheckerSetup, projects: string[]): Promise<number> {
const start = Date.now()
let exitCode = 0
let failed = 0

for (const project of projects) {
const name = relative(cwd, project)
const projectStart = Date.now()
const proc = x(typechecker.bin, TYPE_CHECKERS[typechecker.checker].projectArgs(project), {
nodeOptions: { stdio: 'inherit', cwd },
})
const result = await proc
const duration = formatDuration(Date.now() - projectStart)
const signal = proc.signalCode ?? null

if (signal) {
const hint = signal === 'SIGKILL' ? ', which usually means it ran out of memory' : ''
logger.error(`Type-checking ${styleText('cyan', name)} was killed by ${styleText('bold', signal)} after ${styleText('cyan', duration)}${hint}.`)
}
else if (result.exitCode === 0) {
if (hasTTY) {
logger.success(`${styleText('cyan', name)} passed in ${styleText('cyan', duration)}.`)
}
continue
}
else if (hasTTY) {
logger.error(`${styleText('cyan', name)} failed in ${styleText('cyan', duration)}.`)
}

failed++
exitCode ||= result.exitCode || 1
}

if (hasTTY) {
const duration = styleText('cyan', formatDuration(Date.now() - start))
if (failed === 0) {
logger.success(`Type check passed in ${duration}.`)
}
else {
logger.error(`Type check failed in ${duration} (${failed} of ${projects.length} projects).`)
}
}

return exitCode
}

const NUXT_PROJECT_REFERENCE_RE = /(?:^|[/\\])tsconfig\.(?:app|server|shared|node)\.json$/

/**
Expand Down
121 changes: 121 additions & 0 deletions packages/nuxt-cli/test/unit/commands/typecheck.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -223,3 +223,124 @@ describe('typecheck results', () => {
expect(exitCode).toBe(2)
})
})

describe('typecheck per project', () => {
const app = './.nuxt/tsconfig.app.json'

/** A checker process killed by `signal` before it could exit. */
function killed(signal: string) {
return Object.assign(Promise.resolve({ exitCode: undefined as unknown as number, stdout: '', stderr: '' }), { signalCode: signal })
}

beforeEach(async () => {
await writeFile(join(cwd, 'tsconfig.json'), JSON.stringify({
files: [],
references: [{ path: app }, { path: './.nuxt/tsconfig.server.json' }, { path: 'layers/base' }],
}))
})

it('should check each reference in its own process, resolving files and directories', async () => {
const { output, exitCode } = await runTypecheck(['--per-project'])

expect(tinyexec.mock.calls.map(call => (call as unknown[])[1])).toEqual([
['-p', join(cwd, '.nuxt/tsconfig.app.json'), '--noEmit'],
['-p', join(cwd, '.nuxt/tsconfig.server.json'), '--noEmit'],
['-p', join(cwd, 'layers/base/tsconfig.json'), '--noEmit'],
])
expect(tinyexec).toHaveBeenCalledWith(expect.stringContaining('vue-tsc'), expect.anything(), expect.objectContaining({
nodeOptions: expect.objectContaining({ cwd }),
}))
expect(output).toContain('.nuxt/tsconfig.app.json passed')
expect(output).toContain('layers/base/tsconfig.json passed')
expect(output).toContain('Type check passed')
expect(exitCode).toBeFalsy()
})

it('should not split a tsconfig found above the root directory', async () => {
const rootDir = join(cwd, 'app')
await mkdir(rootDir)

const { output } = await runTypecheck(['--per-project', '--build', `--cwd=${rootDir}`])

expect(output).toContain('Ignoring')
expect(tinyexec).toHaveBeenCalledTimes(1)
expect(tinyexec).toHaveBeenCalledWith(expect.anything(), ['-b', '--noEmit'], expect.anything())
})

it('should split with an explicit `--build`', async () => {
await runTypecheck(['--per-project', '--build'])

expect(tinyexec).toHaveBeenCalledTimes(3)
expect((tinyexec.mock.calls[0] as unknown[])[1]).toEqual(['-p', join(cwd, '.nuxt/tsconfig.app.json'), '--noEmit'])
})

it('should start the next project only after the previous one finished', async () => {
let finish!: () => void
tinyexec.mockReturnValueOnce(new Promise((resolve) => {
finish = () => resolve({ exitCode: 0, stdout: '', stderr: '' })
}))

const run = runTypecheck(['--per-project'])
await vi.waitFor(() => expect(tinyexec).toHaveBeenCalledTimes(1))
await new Promise(resolve => setTimeout(resolve, 20))
expect(tinyexec).toHaveBeenCalledTimes(1)

finish()
await run
expect(tinyexec).toHaveBeenCalledTimes(3)
})

it('should keep going after a failing project and exit with its code', async () => {
tinyexec
.mockResolvedValueOnce({ exitCode: 0, stdout: '', stderr: '' })
.mockResolvedValueOnce({ exitCode: 2, stdout: '', stderr: '' })
.mockResolvedValueOnce({ exitCode: 0, stdout: '', stderr: '' })

const { output, exitCode } = await runTypecheck(['--per-project'])

expect(tinyexec).toHaveBeenCalledTimes(3)
expect(output).toContain('.nuxt/tsconfig.server.json failed')
expect(output).toContain('Type check failed')
expect(output).toContain('1 of 3 projects')
expect(exitCode).toBe(2)
})

it('should report a project killed by a signal rather than as type errors', async () => {
tinyexec.mockReturnValueOnce(killed('SIGKILL'))

const { output, exitCode } = await runTypecheck(['--per-project'])

expect(tinyexec).toHaveBeenCalledTimes(3)
expect(output).toContain('was killed by SIGKILL')
expect(output).toContain('out of memory')
expect(output).not.toContain('.nuxt/tsconfig.app.json failed')
expect(exitCode).toBe(1)
})

it('should pass each project to golar', async () => {
await mkdir(join(cwd, 'node_modules/golar'), { recursive: true })
await writeFile(join(cwd, 'node_modules/golar/package.json'), JSON.stringify({ name: 'golar', bin: './bin.js' }))
installed('golar/unstable', '@golar/vue')

await runTypecheck(['--per-project', '--checker=golar'])

expect(tinyexec).toHaveBeenCalledWith(expect.stringContaining('golar'), ['tsc', '-p', join(cwd, '.nuxt/tsconfig.app.json'), '--noEmit'], expect.anything())
})

it('should run a single check when there are no project references to split', async () => {
await writeFile(join(cwd, 'tsconfig.json'), JSON.stringify({ include: ['src'] }))

const { output } = await runTypecheck(['--per-project'])

expect(output).toContain('Ignoring')
expect(tinyexec).toHaveBeenCalledTimes(1)
expect(tinyexec).toHaveBeenCalledWith(expect.anything(), ['--noEmit'], expect.anything())
})

it('should run a single check with `--no-build`', async () => {
const { output } = await runTypecheck(['--per-project', '--no-build'])

expect(output).toContain('Ignoring')
expect(tinyexec).toHaveBeenCalledWith(expect.anything(), ['--noEmit'], expect.anything())
})
})
1 change: 1 addition & 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,7 @@ 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
--per-project Type-check each project reference in its own process, one after another, to lower peak memory usage
"
`)
})
Expand Down
Loading