From 8411fd01cc289f879ef7a6fb07395e6ec016faad Mon Sep 17 00:00:00 2001 From: "Rob Moore (MakerX)" Date: Thu, 9 Jul 2026 16:12:23 +0800 Subject: [PATCH] feat: configurable external checks via -- passthrough and verifyx eject Give external checks (skott, oxlint, etc.) two escape hatches for when their default invocation needs tweaking: - Passthrough: `verifyx circular-deps -- src/*.ts` forwards everything after `--` verbatim to the underlying tool (globs left unquoted so the shell still expands them). `verifyx init` scaffolds circular-deps as `verifyx circular-deps -- src/*.ts` so skott's required target is visible and editable in package.json. - `verifyx eject `: replaces the CLI wrapper script with the raw tool command so a consumer can own it -- e.g. verify:lint -> `oxlint .` and verify:lint:fix -> `oxlint --fix .`, verify:circular-deps -> the raw skott command. Fixable checks get both the base and :fix scripts (the fix-locally/check-in-CI pairing verifyx already understands). Native checks refuse to eject (no shell command to hand over). Adds RunDefaultOptions.extraArgs, Check.eject, the appendArgs helper, scaffoldArgs on the external spec, setScripts, and the eject command + applyEject/ejectScripts programmatic API, with tests. Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 15 +++++++- src/checks/external.test.ts | 45 +++++++++++++++++++++++- src/checks/external.ts | 23 ++++++++++--- src/checks/registry.test.ts | 13 +++++++ src/checks/registry.ts | 2 ++ src/checks/types.ts | 18 +++++++++- src/cli.ts | 2 ++ src/commands/registerChecks.ts | 6 ++-- src/commands/registerEject.ts | 25 ++++++++++++++ src/index.ts | 3 +- src/scaffold/eject.test.ts | 62 ++++++++++++++++++++++++++++++++++ src/scaffold/eject.ts | 33 ++++++++++++++++++ src/scaffold/packageScripts.ts | 10 ++++++ 13 files changed, 247 insertions(+), 10 deletions(-) create mode 100644 src/commands/registerEject.ts create mode 100644 src/scaffold/eject.test.ts create mode 100644 src/scaffold/eject.ts diff --git a/README.md b/README.md index 8a93d0b..6c7ea97 100644 --- a/README.md +++ b/README.md @@ -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:` script, a failure shows that `npm run verify:` 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) @@ -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 ` 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:` (check-mode) and the `verify::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:` 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: @@ -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 diff --git a/src/checks/external.test.ts b/src/checks/external.test.ts index d0491d0..62d8a69 100644 --- a/src/checks/external.test.ts +++ b/src/checks/external.test.ts @@ -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' } @@ -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') + }) +}) diff --git a/src/checks/external.ts b/src/checks/external.ts index 8a6c1ca..5b4b8b0 100644 --- a/src/checks/external.ts +++ b/src/checks/external.ts @@ -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'] @@ -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:` + * 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. */ @@ -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 { + scaffold: { + script: spec.scaffoldArgs ? `verifyx ${spec.name} -- ${spec.scaffoldArgs}` : `verifyx ${spec.name}`, + devDeps: spec.devDeps, + }, + // `verifyx eject ` inlines these raw commands into the consumer's verify:* scripts. + eject: { check: spec.checkCommand, fix: spec.fixCommand }, + async runDefault({ extraArgs }: RunDefaultOptions = {}): Promise { 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 } @@ -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. diff --git a/src/checks/registry.test.ts b/src/checks/registry.test.ts index 05a7e91..4dd066c 100644 --- a/src/checks/registry.test.ts +++ b/src/checks/registry.test.ts @@ -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() }) diff --git a/src/checks/registry.ts b/src/checks/registry.ts index a1bbd9b..0083f94 100644 --- a/src/checks/registry.ts +++ b/src/checks/registry.ts @@ -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', diff --git a/src/checks/types.ts b/src/checks/types.ts index 35abe7f..bc43239 100644 --- a/src/checks/types.ts +++ b/src/checks/types.ts @@ -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 @@ -19,7 +25,7 @@ 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 + runDefault: (options?: RunDefaultOptions) => Promise /** How `verify init` wires this check into a consuming project. */ scaffold: { /** The npm script body written as `verify:`. */ @@ -27,4 +33,14 @@ export type Check = { /** Extra devDependencies the check needs, installed on opt-in. */ devDeps?: string[] } + /** + * External checks only: the raw tool commands `verifyx eject ` inlines into `verify:` (and + * `verify::fix`) so a consumer can own the invocation. Absent for native checks (they have no shell command). + */ + eject?: { + /** Body for the `verify:` script — the check-mode command. */ + check: string + /** Body for the `verify::fix` script, when the tool is fixable. */ + fix?: string + } } diff --git a/src/cli.ts b/src/cli.ts index 1294746..23b4a90 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -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' @@ -52,6 +53,7 @@ registerChecks(program) registerList(program) registerInit(program) registerUpgradeDocs(program) +registerEject(program) program.parseAsync().catch((error) => { console.error(error) diff --git a/src/commands/registerChecks.ts b/src/commands/registerChecks.ts index 791e699..c1b1dbe 100644 --- a/src/commands/registerChecks.ts +++ b/src/commands/registerChecks.ts @@ -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) }) } diff --git a/src/commands/registerEject.ts b/src/commands/registerEject.ts new file mode 100644 index 0000000..377d74a --- /dev/null +++ b/src/commands/registerEject.ts @@ -0,0 +1,25 @@ +import type { Command } from 'commander' + +import { applyEject } from '../scaffold/eject.ts' +import { color } from '../shared/color.ts' + +/** + * `verifyx eject ` — replace a `verifyx ` 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('', '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 + } + }) +} diff --git a/src/index.ts b/src/index.ts index 6f47bf1..d91b1a8 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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 { @@ -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' diff --git a/src/scaffold/eject.test.ts b/src/scaffold/eject.test.ts new file mode 100644 index 0000000..9560a6a --- /dev/null +++ b/src/scaffold/eject.test.ts @@ -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): void { + fs.writeFileSync(path.join(dir, 'package.json'), JSON.stringify({ name: 'scratch', scripts })) +} +function readScripts(): Record { + return (JSON.parse(fs.readFileSync(path.join(dir, 'package.json'), 'utf-8')) as { scripts: Record }).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', + ) + }) +}) diff --git a/src/scaffold/eject.ts b/src/scaffold/eject.ts new file mode 100644 index 0000000..e0340e6 --- /dev/null +++ b/src/scaffold/eject.ts @@ -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 +} + +/** 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 { + 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 = { [`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 } +} diff --git a/src/scaffold/packageScripts.ts b/src/scaffold/packageScripts.ts index 09a4fdf..c49ccd7 100644 --- a/src/scaffold/packageScripts.ts +++ b/src/scaffold/packageScripts.ts @@ -26,3 +26,13 @@ export function addVerifyScripts(packageJsonPath: string, scripts: Record` wrapper script with the underlying raw tool command. + */ +export function setScripts(packageJsonPath: string, scripts: Record): 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`) +}