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`) +}