From f111034c15ed52d02efc3eb58bc70aab6c57c09c Mon Sep 17 00:00:00 2001 From: Paul Robson Date: Tue, 6 Oct 2026 21:44:43 +1000 Subject: [PATCH] fix(check-types): guard tsc -b against emitting into source tree - fail before tsc -b when a config in the reference graph has no noEmit/outDir - fall back to tsc --noEmit on malformed tsconfig (TS Debug Failure on Windows paths) - registry test for per-cwd command resolution - README: tsbuildinfo note --- README.md | 24 ++++++++-------- src/checks/external.ts | 7 +++++ src/checks/registry.test.ts | 19 +++++++++++++ src/checks/registry.ts | 3 +- src/checks/tsconfig.test.ts | 50 ++++++++++++++++++++++++++++++++- src/checks/tsconfig.ts | 55 +++++++++++++++++++++++++++++++++++-- 6 files changed, 142 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index fee083a..4807c81 100644 --- a/README.md +++ b/README.md @@ -114,18 +114,18 @@ Flags on the bare `verifyx` command: ## Built-in checks -| Check | Kind | What it catches | -| ------------------- | -------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `complexity` | native | Maintainability-index gate (cyclomatic complexity + Halstead volume + SLOC). Fails files below a threshold. | -| `comments` | native | Flags comment blocks taller than `--max-lines` (default 2), to push for self-documenting code. JSDoc and `context:`-prefixed blocks are always allowed. Add `--block-new-comments` to also fail any comment on a changed line (vs `HEAD` locally, the PR base in CI). | -| `hardcoded-colors` | native | Literal hex / `0x` colour values in source (cross-platform; suggests using design tokens). | -| `forbidden-strings` | native | Disallowed JSON config values, from rules in your verify config. | -| `lint` | external | Linting; auto-fixes locally, checks in CI ([oxlint](https://oxc.rs)). | -| `format` | external | Formatting; writes locally, checks in CI ([oxfmt](https://oxc.rs)). | -| `check-types` | external | TypeScript type check (`tsc --noEmit`, or `tsc -b` when `tsconfig.json` has `references`, so referenced configs need `noEmit`); skips when there is no `tsconfig.json`. | -| `unused-code` | external | Unused files, exports and dependencies ([knip](https://knip.dev)). | -| `circular-deps` | external | Circular dependencies ([skott](https://github.com/antoine-coulon/skott)). | -| `duplicate-code` | external | Copy-paste detection ([jscpd](https://github.com/kucherenko/jscpd)). | +| Check | Kind | What it catches | +| ------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `complexity` | native | Maintainability-index gate (cyclomatic complexity + Halstead volume + SLOC). Fails files below a threshold. | +| `comments` | native | Flags comment blocks taller than `--max-lines` (default 2), to push for self-documenting code. JSDoc and `context:`-prefixed blocks are always allowed. Add `--block-new-comments` to also fail any comment on a changed line (vs `HEAD` locally, the PR base in CI). | +| `hardcoded-colors` | native | Literal hex / `0x` colour values in source (cross-platform; suggests using design tokens). | +| `forbidden-strings` | native | Disallowed JSON config values, from rules in your verify config. | +| `lint` | external | Linting; auto-fixes locally, checks in CI ([oxlint](https://oxc.rs)). | +| `format` | external | Formatting; writes locally, checks in CI ([oxfmt](https://oxc.rs)). | +| `check-types` | external | TypeScript type check (`tsc --noEmit`, or `tsc -b` when `tsconfig.json` has `references`); skips when there is no `tsconfig.json`. In build mode it fails up front if any config in the reference graph would write output next to its sources (set `noEmit` or an `outDir`). `tsc -b` still writes `.tsbuildinfo` files; point `tsBuildInfoFile` at `node_modules/.tmp` to keep them out of the tree. | +| `unused-code` | external | Unused files, exports and dependencies ([knip](https://knip.dev)). | +| `circular-deps` | external | Circular dependencies ([skott](https://github.com/antoine-coulon/skott)). | +| `duplicate-code` | external | Copy-paste detection ([jscpd](https://github.com/kucherenko/jscpd)). | External checks spawn their tool and **skip gracefully when it is not installed**; `verifyx init` installs the ones you opt into. They run the tool from your local `node_modules/.bin` regardless of how `verifyx` was invoked. `oxlint`/`oxfmt`/`tsc` are resolved if present; the rest are declared as optional `peerDependencies`. diff --git a/src/checks/external.ts b/src/checks/external.ts index 6519930..d1da8c3 100644 --- a/src/checks/external.ts +++ b/src/checks/external.ts @@ -44,6 +44,8 @@ export type ExternalCheckSpec = { failureAdvice?: string /** Extra guard beyond bin presence (e.g. require a tsconfig). */ canRun?: () => boolean + /** Returns a failure message to fail the check without running the tool (e.g. it would write into the source tree). */ + preflight?: () => string | undefined /** Rewrite the tool's captured output before it is printed, e.g. to strip a tool's own hardcoded colouring. */ transformOutput?: (output: string) => string maxWarnings?: MaxWarningsSupport @@ -130,6 +132,11 @@ export function defineExternalCheck(spec: ExternalCheckSpec): Check { console.log(color.dim(`${spec.name}: not applicable here — skipping`)) return { name: spec.name, ok: true, skipped: true } } + const blocked = spec.preflight?.() + if (blocked) { + console.error(blocked) + return { name: spec.name, ok: false } + } // quiet: buffer the tool's output and flush only on failure (streamed live under --verbose). const runReport = async (argv: string[]): Promise => { const code = await runArgvCommand(argv, { env: envWithLocalBin(), quiet: true, transform: spec.transformOutput }) diff --git a/src/checks/registry.test.ts b/src/checks/registry.test.ts index 3383509..159e2eb 100644 --- a/src/checks/registry.test.ts +++ b/src/checks/registry.test.ts @@ -1,3 +1,7 @@ +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' + import { describe, expect, it } from 'vitest' import { CHECKS, getCheck, recommendedChecks } from './registry.ts' @@ -52,6 +56,21 @@ describe('check registry', () => { expect(getCheck('complexity')?.eject).toBeUndefined() }) + it('resolves the check-types command in the current project at eject time', () => { + const originalCwd = process.cwd() + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'verifyx-registry-')) + try { + process.chdir(dir) + fs.writeFileSync('tsconfig.json', '{}') + expect(getCheck('check-types')?.eject?.check).toBe('tsc --noEmit') + fs.writeFileSync('tsconfig.json', '{ "files": [], "references": [{ "path": "./tsconfig.app.json" }] }') + expect(getCheck('check-types')?.eject?.check).toBe('tsc -b') + } finally { + process.chdir(originalCwd) + fs.rmSync(dir, { recursive: true, force: true }) + } + }) + it('returns undefined for unknown checks', () => { expect(getCheck('nope')).toBeUndefined() }) diff --git a/src/checks/registry.ts b/src/checks/registry.ts index 1c7813b..67ae623 100644 --- a/src/checks/registry.ts +++ b/src/checks/registry.ts @@ -7,7 +7,7 @@ import { defineExternalCheck } from './external.ts' import { runForbiddenStrings } from './forbidden-strings.ts' import { runHardcodedColors } from './hardcoded-colors.ts' import { jscpdCount } from './maxWarnings.ts' -import { typeCheckCommand } from './tsconfig.ts' +import { typeCheckCommand, typeCheckPreflight } from './tsconfig.ts' import type { Check, CheckResult } from './types.ts' function nativeCheck(name: string, description: string, recommended: boolean, run: () => CheckResult, script = `verifyx ${name}`): Check { @@ -64,6 +64,7 @@ export const CHECKS: Check[] = [ }, devDeps: ['typescript'], canRun: () => fs.existsSync('tsconfig.json'), + preflight: () => typeCheckPreflight(), recommended: true, docs: 'https://www.typescriptlang.org/tsconfig', }), diff --git a/src/checks/tsconfig.test.ts b/src/checks/tsconfig.test.ts index f255448..3dcfc83 100644 --- a/src/checks/tsconfig.test.ts +++ b/src/checks/tsconfig.test.ts @@ -4,7 +4,7 @@ import path from 'node:path' import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import { typeCheckCommand } from './tsconfig.ts' +import { typeCheckCommand, typeCheckPreflight } from './tsconfig.ts' let dir: string beforeEach(() => { @@ -36,7 +36,55 @@ describe('typeCheckCommand', () => { expect(typeCheckCommand(dir)).toEqual(['tsc', '--noEmit']) }) + it('returns tsc --noEmit for a malformed tsconfig, leaving tsc to report the syntax error', () => { + writeTsconfig('{ "compilerOptions": { "strict": true }, "include": ["src"],') + expect(typeCheckCommand(dir)).toEqual(['tsc', '--noEmit']) + }) + it('returns tsc --noEmit rather than throwing when tsconfig is missing (eject ignores canRun)', () => { expect(typeCheckCommand(dir)).toEqual(['tsc', '--noEmit']) }) }) + +describe('typeCheckPreflight', () => { + const write = (file: string, config: object) => { + fs.mkdirSync(path.dirname(path.join(dir, file)), { recursive: true }) + fs.writeFileSync(path.join(dir, file), JSON.stringify(config)) + } + + it('passes the Vite template shape: empty root, referenced configs set noEmit', () => { + write('tsconfig.json', { files: [], references: [{ path: './tsconfig.app.json' }, { path: './tsconfig.node.json' }] }) + write('tsconfig.app.json', { compilerOptions: { noEmit: true }, include: ['src'] }) + write('tsconfig.node.json', { compilerOptions: { noEmit: true }, include: ['vite.config.ts'] }) + expect(typeCheckPreflight(dir)).toBeUndefined() + }) + + it('passes composite references that build into an outDir', () => { + write('tsconfig.json', { files: [], references: [{ path: './a' }, { path: './b' }] }) + write('a/tsconfig.json', { compilerOptions: { composite: true, outDir: 'dist' }, include: ['*.ts'] }) + write('b/tsconfig.json', { compilerOptions: { composite: true, outDir: 'dist' }, include: ['*.ts'], references: [{ path: '../a' }] }) + expect(typeCheckPreflight(dir)).toBeUndefined() + }) + + it('blocks configs that would emit beside source, including the root and ones inheriting via extends', () => { + write('tsconfig.base.json', { compilerOptions: { strict: true } }) + write('tsconfig.json', { include: ['src'], references: [{ path: './lib' }] }) + write('lib/tsconfig.json', { extends: '../tsconfig.base.json', include: ['*.ts'] }) + const message = typeCheckPreflight(dir) + expect(message).toContain('tsconfig.json') + expect(message).toContain(path.join('lib', 'tsconfig.json')) + expect(message).toContain('noEmit') + }) + + it('honours noEmit inherited via extends', () => { + write('tsconfig.base.json', { compilerOptions: { noEmit: true } }) + write('tsconfig.json', { files: [], references: [{ path: './lib' }] }) + write('lib/tsconfig.json', { extends: '../tsconfig.base.json', include: ['*.ts'] }) + expect(typeCheckPreflight(dir)).toBeUndefined() + }) + + it('does not run for a plain tsconfig', () => { + write('tsconfig.json', { include: ['src'] }) + expect(typeCheckPreflight(dir)).toBeUndefined() + }) +}) diff --git a/src/checks/tsconfig.ts b/src/checks/tsconfig.ts index 4ca35e8..9fd0c4b 100644 --- a/src/checks/tsconfig.ts +++ b/src/checks/tsconfig.ts @@ -2,13 +2,64 @@ import path from 'node:path' import ts from 'typescript' +// TS asserts on Windows backslash paths when reporting a config parse error. +const configPath = (cwd: string) => path.join(cwd, 'tsconfig.json').replaceAll('\\', '/') + /** returns `true` when the tsconfig declares project `references` (e.g. Vite's solution-style root) */ function hasProjectReferences(cwd: string = process.cwd()): boolean { - const { config } = ts.readConfigFile(path.join(cwd, 'tsconfig.json'), ts.sys.readFile) - return Array.isArray(config?.references) && config.references.length > 0 + try { + const { config, error } = ts.readConfigFile(configPath(cwd), ts.sys.readFile) + return !error && Array.isArray(config?.references) && config.references.length > 0 + } catch { + return false + } } /** Generate the appropriate type-check command for the project's tsconfig */ export function typeCheckCommand(cwd: string = process.cwd()): readonly string[] { return hasProjectReferences(cwd) ? ['tsc', '-b'] : ['tsc', '--noEmit'] } + +// No-op readDirectory: only compiler options are needed, so skip expanding `include` globs. +const optionsOnlyHost: ts.ParseConfigFileHost = { + ...ts.sys, + readDirectory: () => [], + onUnRecoverableConfigFileDiagnostic: () => {}, +} + +function writesBesideSource(parsed: ts.ParsedCommandLine): boolean { + const { noEmit, outDir, outFile, emitDeclarationOnly, declarationDir } = parsed.options + const hasInputs = parsed.fileNames.length > 0 || Object.keys(parsed.wildcardDirectories ?? {}).length > 0 + return hasInputs && !noEmit && !outDir && !outFile && !(emitDeclarationOnly && declarationDir) +} + +/** Configs in the reference graph that `tsc -b` would emit next to their sources. */ +function configsEmittingBesideSource(cwd: string = process.cwd()): string[] { + const offenders: string[] = [] + const seen = new Set() + const visit = (file: string) => { + if (seen.has(file)) return + seen.add(file) + const parsed = ts.getParsedCommandLineOfConfigFile(file, undefined, optionsOnlyHost) + if (!parsed) return + if (writesBesideSource(parsed)) offenders.push(path.relative(cwd, file) || 'tsconfig.json') + for (const ref of parsed.projectReferences ?? []) visit(ts.resolveProjectReferencePath(ref).replaceAll('\\', '/')) + } + try { + visit(configPath(cwd)) + } catch { + // Unparseable config: let tsc report it. + } + return offenders +} + +/** Fails `tsc -b` up front rather than letting it write .js files into the source tree. */ +export function typeCheckPreflight(cwd: string = process.cwd()): string | undefined { + if (!hasProjectReferences(cwd)) return undefined + const offenders = configsEmittingBesideSource(cwd) + if (offenders.length === 0) return undefined + return [ + `check-types runs \`tsc -b\` because tsconfig.json has \`references\`, and these configs would write output next to their sources: ${offenders.join(', ')}.`, + 'Set `"noEmit": true` (or an `outDir`) in each. To keep .tsbuildinfo files out of the tree, set `"tsBuildInfoFile": "./node_modules/.tmp/.tsbuildinfo"`.', + ].join('\n') +}