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
15 changes: 14 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,8 @@ External checks shell out to their tool and **skip gracefully when it is not ins

Because checks are named for their function, **on failure** an external check prints the tool it used, the exact command it ran, and a docs link, so you (or an agent) can add the tool's config (e.g. `knip.json`) without guessing. On success it prints nothing (output is buffered and flushed only on failure, to keep runs quiet and cheap). If you override a check with your own `verify:<name>` script, a failure shows that `npm run verify:<name>` was what ran.

**Passing extra arguments through.** When you run an external check directly, anything after `--` is forwarded verbatim to the underlying tool, so you can tweak an invocation without ejecting it: `verifyx circular-deps -- src/*.ts` runs skott against `src/*.ts`, `verifyx lint -- --quiet` passes `--quiet` to oxlint. `verifyx init` scaffolds `verify:circular-deps` as `verifyx circular-deps -- src/*.ts` (skott needs a target), so the default is visible in your `package.json` and easy to point at your own source layout.

Each external check is configured through its **tool's own config file**, exactly as you would use that tool standalone:

- `lint`: [`.oxlintrc.json`](https://oxc.rs/docs/guide/usage/linter.html)
Expand Down Expand Up @@ -251,6 +253,17 @@ verifyx upgrade-docs # Claude + other agents
verifyx upgrade-docs --no-agents # only .claude/ + CLAUDE.md
```

### `verifyx eject`

External checks wrap their tool behind the CLI (`verify:lint` runs `verifyx lint`, which runs `oxlint`) so fix-vs-check behaviour stays centralised. When you outgrow that and want to own the raw invocation, **eject** it: `verifyx eject <check>` replaces the wrapper script in `package.json` with the underlying command.

```sh
verifyx eject circular-deps # verify:circular-deps → skott --displayMode=raw --showCircularDependencies --exitCodeOnCircularDependencies=1
verifyx eject lint # verify:lint → oxlint . AND verify:lint:fix → oxlint --fix .
```

For a fixable check (`lint`, `format`) it writes both the base `verify:<name>` (check-mode) and the `verify:<name>:fix` variant, which is exactly the [fix-locally / check-in-CI](#fix-locally-check-in-ci) pairing `verifyx` already understands — so an ejected check keeps auto-fixing locally and only checking in CI. Ejecting **overwrites** the existing `verify:<name>` script (that's the point — it hands you the raw command to edit), and leaves every other script untouched. Only external checks can be ejected; native checks (`complexity`, `comments`, …) run in-process and have no shell command to hand over.

## Configuration

The **native** checks that take persistent settings read them from a `verify.config.json` file, or a `verify` key in `package.json` (the standalone file wins if both are present). Each option is documented alongside its check above; collected here for reference:
Expand Down Expand Up @@ -312,7 +325,7 @@ Entry points:
- **Checks**: `CHECKS`, `getCheck`, `recommendedChecks`, and the native `run*` functions above.
- **Orchestration**: `orchestrate` (the bare `verifyx` runner) and `runAll` (`verifyx all`).
- **Complexity internals**: `analyzeComplexity`, `scoreFiles`, `findSourceFiles`, `resolvePattern`, `forEachFunction`, `findLongCommentBlocks`, and the metric helpers `calculateCyclomaticComplexity`, `calculateHalstead`, `calculateMaintainabilityIndex`, `countSloc`.
- **Scaffolding & config**: `applyInit` and `loadVerifyConfig`.
- **Scaffolding & config**: `applyInit`, `applyEject` (+ `ejectScripts`), and `loadVerifyConfig`.

## Attribution

Expand Down
45 changes: 44 additions & 1 deletion src/checks/external.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest'

import { externalFailureHint, selectCommand } from './external.ts'
import { appendArgs, defineExternalCheck, externalFailureHint, selectCommand } from './external.ts'

const fixable = { checkCommand: 'oxfmt --check .', fixCommand: 'oxfmt .' }
const notFixable = { checkCommand: 'tsc --noEmit' }
Expand Down Expand Up @@ -36,3 +36,46 @@ describe('externalFailureHint', () => {
expect(hint).not.toContain('undefined')
})
})

describe('appendArgs', () => {
it('appends passthrough args verbatim, unquoted (so shell globs still expand)', () => {
expect(appendArgs('skott --showCircularDependencies', ['src/*.ts'])).toBe('skott --showCircularDependencies src/*.ts')
})

it('returns the command unchanged when there are no extra args', () => {
expect(appendArgs('oxlint .', [])).toBe('oxlint .')
expect(appendArgs('oxlint .')).toBe('oxlint .')
})
})

describe('defineExternalCheck', () => {
it('exposes raw commands for eject, including the fix variant when fixable', () => {
const lint = defineExternalCheck({
name: 'lint',
description: '',
bin: 'oxlint',
checkCommand: 'oxlint .',
fixCommand: 'oxlint --fix .',
devDeps: [],
})
expect(lint.eject).toEqual({ check: 'oxlint .', fix: 'oxlint --fix .' })
})

it('scaffolds default trailing args after `--` so a consumer can see and tweak them', () => {
const circular = defineExternalCheck({
name: 'circular-deps',
description: '',
bin: 'skott',
checkCommand: 'skott',
devDeps: [],
scaffoldArgs: 'src/*.ts',
})
expect(circular.scaffold.script).toBe('verifyx circular-deps -- src/*.ts')
expect(circular.eject).toEqual({ check: 'skott', fix: undefined })
})

it('scaffolds a bare CLI call when there are no default args', () => {
const knip = defineExternalCheck({ name: 'unused-code', description: '', bin: 'knip', checkCommand: 'knip', devDeps: [] })
expect(knip.scaffold.script).toBe('verifyx unused-code')
})
})
23 changes: 19 additions & 4 deletions src/checks/external.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import path from 'node:path'
import { color } from '../shared/color.ts'
import { resolveMode } from '../shared/mode.ts'
import { runCommand } from '../shared/spawn.ts'
import type { Check, CheckMode, CheckResult } from './types.ts'
import type { Check, CheckMode, CheckResult, RunDefaultOptions } from './types.ts'

const BIN_EXTENSIONS = ['', '.cmd', '.ps1', '.exe']

Expand Down Expand Up @@ -39,6 +39,16 @@ export type ExternalCheckSpec = {
docs?: string
/** Extra guard beyond bin presence (e.g. require a tsconfig). */
canRun?: () => boolean
/**
* Default trailing args scaffolded after `--` (e.g. skott's `src/*.ts` target), surfaced in the `verify:<name>`
* script so a consumer can see and tweak them. Not baked into `runDefault`; only the scaffolded script carries them.
*/
scaffoldArgs?: string
}

/** Append user/scaffold `extraArgs` to an external tool's command, preserving shell semantics (globs unquoted). */
export function appendArgs(command: string, extraArgs: readonly string[] = []): string {
return extraArgs.length > 0 ? `${command} ${extraArgs.join(' ')}` : command
}

/** Pick the command for the run mode: the fix command only in fix mode and only when the check is fixable. */
Expand All @@ -63,8 +73,13 @@ export function defineExternalCheck(spec: ExternalCheckSpec): Check {
kind: 'external',
recommended: spec.recommended ?? false,
// Scaffold as a call into this CLI so fix-vs-check lives in one place, not the consumer's script.
scaffold: { script: `verifyx ${spec.name}`, devDeps: spec.devDeps },
async runDefault(): Promise<CheckResult> {
scaffold: {
script: spec.scaffoldArgs ? `verifyx ${spec.name} -- ${spec.scaffoldArgs}` : `verifyx ${spec.name}`,
devDeps: spec.devDeps,
},
// `verifyx eject <name>` inlines these raw commands into the consumer's verify:* scripts.
eject: { check: spec.checkCommand, fix: spec.fixCommand },
async runDefault({ extraArgs }: RunDefaultOptions = {}): Promise<CheckResult> {
if (!hasLocalBin(spec.bin)) {
console.log(color.dim(`${spec.name}: ${spec.bin} not installed — skipping (add it with \`npx verifyx init\`)`))
return { name: spec.name, ok: true, skipped: true }
Expand All @@ -73,7 +88,7 @@ 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 command = selectCommand(spec, resolveMode())
const command = appendArgs(selectCommand(spec, resolveMode()), extraArgs)
// quiet: buffer the tool's output and flush only on failure (streamed live under --verbose).
const code = await runCommand(command, { env: envWithLocalBin(), quiet: true })
// Only on failure — passing runs stay silent to save tokens.
Expand Down
13 changes: 13 additions & 0 deletions src/checks/registry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,19 @@ describe('check registry', () => {
expect(getCheck('complexity')?.scaffold.script).toBe('verifyx complexity')
})

it('scaffolds circular-deps with a default skott target after `--`', () => {
expect(getCheck('circular-deps')?.scaffold.script).toBe('verifyx circular-deps -- src/*.ts')
})

it('exposes raw tool commands for eject on external checks, but not native ones', () => {
expect(getCheck('lint')?.eject).toEqual({ check: 'oxlint .', fix: 'oxlint --fix .' })
expect(getCheck('circular-deps')?.eject?.check).toBe(
'skott --displayMode=raw --showCircularDependencies --exitCodeOnCircularDependencies=1',
)
expect(getCheck('circular-deps')?.eject?.fix).toBeUndefined()
expect(getCheck('complexity')?.eject).toBeUndefined()
})

it('returns undefined for unknown checks', () => {
expect(getCheck('nope')).toBeUndefined()
})
Expand Down
2 changes: 2 additions & 0 deletions src/checks/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,8 @@ export const CHECKS: Check[] = [
checkCommand: 'skott --displayMode=raw --showCircularDependencies --exitCodeOnCircularDependencies=1',
devDeps: ['skott'],
docs: 'https://github.com/antoine-coulon/skott',
// skott needs a target; scaffold it after `--` so consumers can see and adjust it (e.g. to their source layout).
scaffoldArgs: 'src/*.ts',
}),
defineExternalCheck({
name: 'duplicate-code',
Expand Down
18 changes: 17 additions & 1 deletion src/checks/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,12 @@ export type CheckResult = {
durationMs?: number
}

/** Per-run options for a check. `extraArgs` are the tokens a user passes after `--` (external checks only). */
export type RunDefaultOptions = {
/** Extra arguments appended verbatim to an external check's underlying command (e.g. `verifyx circular-deps -- src/*.ts`). */
extraArgs?: string[]
}

/** A single verification. Native checks run in-process; external checks shell out to a tool. */
export type Check = {
name: string
Expand All @@ -19,12 +25,22 @@ export type Check = {
/** Whether `verifyx init` preselects this check as a recommended default. */
recommended: boolean
/** Run the check with its default options and print its own report. Resolves to the outcome. */
runDefault: () => Promise<CheckResult>
runDefault: (options?: RunDefaultOptions) => Promise<CheckResult>
/** How `verify init` wires this check into a consuming project. */
scaffold: {
/** The npm script body written as `verify:<name>`. */
script: string
/** Extra devDependencies the check needs, installed on opt-in. */
devDeps?: string[]
}
/**
* External checks only: the raw tool commands `verifyx eject <name>` inlines into `verify:<name>` (and
* `verify:<name>:fix`) so a consumer can own the invocation. Absent for native checks (they have no shell command).
*/
eject?: {
/** Body for the `verify:<name>` script — the check-mode command. */
check: string
/** Body for the `verify:<name>:fix` script, when the tool is fixable. */
fix?: string
}
}
2 changes: 2 additions & 0 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { createRequire } from 'node:module'
import { Command } from 'commander'

import { registerChecks } from './commands/registerChecks.ts'
import { registerEject } from './commands/registerEject.ts'
import { registerInit } from './commands/registerInit.ts'
import { registerList } from './commands/registerList.ts'
import { registerUpgradeDocs } from './commands/registerUpgradeDocs.ts'
Expand Down Expand Up @@ -52,6 +53,7 @@ registerChecks(program)
registerList(program)
registerInit(program)
registerUpgradeDocs(program)
registerEject(program)

program.parseAsync().catch((error) => {
console.error(error)
Expand Down
6 changes: 4 additions & 2 deletions src/commands/registerChecks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,8 +74,10 @@ export function registerChecks(program: Command): void {
program
.command(check.name)
.description(check.description)
.action(async () => {
const result = await check.runDefault()
// Everything after `--` is forwarded verbatim to the underlying tool (e.g. `verifyx circular-deps -- src/*.ts`).
.argument('[toolArgs...]', 'extra arguments passed through to the underlying tool (after `--`)')
.action(async (toolArgs: string[]) => {
const result = await check.runDefault({ extraArgs: toolArgs })
finish(result.ok)
})
}
Expand Down
25 changes: 25 additions & 0 deletions src/commands/registerEject.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import type { Command } from 'commander'

import { applyEject } from '../scaffold/eject.ts'
import { color } from '../shared/color.ts'

/**
* `verifyx eject <check>` — replace a `verifyx <name>` wrapper script with the underlying tool's raw command,
* so a consumer can own and customise the invocation (e.g. `verify:lint` → `oxlint .`, `verify:lint:fix` → `oxlint --fix .`).
*/
export function registerEject(program: Command): void {
program
.command('eject')
.description("Inline an external check's raw tool command into its verify:* script(s) so you can customise it")
.argument('<check>', 'the external check to eject (e.g. lint, circular-deps)')
.action((name: string) => {
try {
const { scripts } = applyEject(process.cwd(), name)
console.log(color.green(`Ejected ${name} into package.json:`))
for (const [script, body] of Object.entries(scripts)) console.log(` ${color.cyan(script)}: ${body}`)
} catch (error) {
console.error(color.yellow((error as Error).message))
process.exitCode = 1
}
})
}
3 changes: 2 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ export { runComplexity } from './checks/complexity.ts'
export { runForbiddenStrings } from './checks/forbidden-strings.ts'
export { runHardcodedColors } from './checks/hardcoded-colors.ts'
export { CHECKS, getCheck, recommendedChecks } from './checks/registry.ts'
export type { Check, CheckKind, CheckMode, CheckResult } from './checks/types.ts'
export type { Check, CheckKind, CheckMode, CheckResult, RunDefaultOptions } from './checks/types.ts'
export { type CommentBlockViolation, findLongCommentBlocks } from './comments.ts'
export { type FunctionCallback, forEachFunction } from './functions.ts'
export {
Expand All @@ -26,5 +26,6 @@ export {
} from './metrics.ts'
export { orchestrate } from './orchestrator/run.ts'
export { runAll } from './orchestrator/runAll.ts'
export { applyEject, type EjectResult, ejectScripts } from './scaffold/eject.ts'
export { applyInit, type InitOptions, type InitResult } from './scaffold/init.ts'
export { type ForbiddenStringsRule, loadVerifyConfig, type VerifyConfig } from './shared/config.ts'
62 changes: 62 additions & 0 deletions src/scaffold/eject.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'

import { afterEach, beforeEach, describe, expect, it } from 'vitest'

import { applyEject, ejectScripts } from './eject.ts'

let dir: string

beforeEach(() => {
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'verify-eject-'))
})
afterEach(() => {
fs.rmSync(dir, { recursive: true, force: true })
})

function writePkg(scripts: Record<string, string>): void {
fs.writeFileSync(path.join(dir, 'package.json'), JSON.stringify({ name: 'scratch', scripts }))
}
function readScripts(): Record<string, string> {
return (JSON.parse(fs.readFileSync(path.join(dir, 'package.json'), 'utf-8')) as { scripts: Record<string, string> }).scripts
}

describe('ejectScripts', () => {
it('inlines the raw check command, plus a :fix variant for fixable tools', () => {
expect(ejectScripts('lint')).toEqual({ 'verify:lint': 'oxlint .', 'verify:lint:fix': 'oxlint --fix .' })
})

it('inlines only the check command for a non-fixable tool', () => {
expect(ejectScripts('circular-deps')).toEqual({
'verify:circular-deps': 'skott --displayMode=raw --showCircularDependencies --exitCodeOnCircularDependencies=1',
})
})

it('refuses to eject a native check', () => {
expect(() => ejectScripts('complexity')).toThrow(/only external checks can be ejected/)
})

it('refuses to eject an unknown check', () => {
expect(() => ejectScripts('nope')).toThrow(/Unknown check/)
})
})

describe('applyEject', () => {
it('overwrites the verifyx wrapper script with the raw tool command', () => {
writePkg({ 'verify:lint': 'verifyx lint' })
const result = applyEject(dir, 'lint')
expect(result.scripts['verify:lint']).toBe('oxlint .')
expect(readScripts()['verify:lint']).toBe('oxlint .')
expect(readScripts()['verify:lint:fix']).toBe('oxlint --fix .')
})

it('leaves unrelated scripts untouched', () => {
writePkg({ build: 'tsc', 'verify:circular-deps': 'verifyx circular-deps -- src/*.ts' })
applyEject(dir, 'circular-deps')
expect(readScripts().build).toBe('tsc')
expect(readScripts()['verify:circular-deps']).toBe(
'skott --displayMode=raw --showCircularDependencies --exitCodeOnCircularDependencies=1',
)
})
})
33 changes: 33 additions & 0 deletions src/scaffold/eject.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import path from 'node:path'

import { CHECKS, getCheck } from '../checks/registry.ts'
import { setScripts } from './packageScripts.ts'

export type EjectResult = {
/** The `verify:*` scripts written, keyed by script name. */
scripts: Record<string, string>
}

/** The verify:* scripts an eject of `check` would write: its raw check command, plus a `:fix` variant if fixable. */
export function ejectScripts(name: string): Record<string, string> {
const check = getCheck(name)
if (!check) {
const known = CHECKS.filter((c) => c.eject)
.map((c) => c.name)
.join(', ')
throw new Error(`Unknown check "${name}". Ejectable checks: ${known}.`)
}
if (!check.eject) {
throw new Error(`"${name}" is a ${check.kind} check with no underlying command to eject; only external checks can be ejected.`)
}
const scripts: Record<string, string> = { [`verify:${name}`]: check.eject.check }
if (check.eject.fix) scripts[`verify:${name}:fix`] = check.eject.fix
return scripts
}

/** Inline an external check's raw tool command into the consumer's `verify:*` scripts, overwriting the wrapper. */
export function applyEject(cwd: string, name: string): EjectResult {
const scripts = ejectScripts(name)
setScripts(path.join(cwd, 'package.json'), scripts)
return { scripts }
}
10 changes: 10 additions & 0 deletions src/scaffold/packageScripts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,3 +26,13 @@ export function addVerifyScripts(packageJsonPath: string, scripts: Record<string
fs.writeFileSync(packageJsonPath, `${JSON.stringify(pkg, null, 2)}\n`)
return added
}

/**
* Set the given scripts in package.json, overwriting any existing bodies. Used by `verifyx eject`, which
* deliberately replaces a `verifyx <name>` wrapper script with the underlying raw tool command.
*/
export function setScripts(packageJsonPath: string, scripts: Record<string, string>): void {
const pkg = JSON.parse(fs.readFileSync(packageJsonPath, 'utf-8')) as PackageJson
pkg.scripts = { ...pkg.scripts, ...scripts }
fs.writeFileSync(packageJsonPath, `${JSON.stringify(pkg, null, 2)}\n`)
}