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
50 changes: 43 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,15 +19,17 @@ What makes it worth wiring in:
Install it as a **dev dependency** so the version is pinned in `package.json`, so the exact same tool runs on your machine and in CI/CD:

```sh
npm install --save-dev @makerx/verify
npm install --save-dev @makerx/verify # npm
pnpm add -D @makerx/verify # pnpm (add -w at a workspace root)
```

Requires Node.js >= 24. Invoke it via an npm script or `npx verifyx`.
Requires Node.js >= 24. Invoke it via a package.json script, `npx verifyx` (npm) or `pnpm exec verifyx` (pnpm).

The quickest way to wire it into a project:

```sh
npx verifyx init
npx verifyx init # npm
pnpm exec verifyx init # pnpm (needs pnpm 11.26+ or 12.4+)
```

> [!NOTE]
Expand All @@ -40,6 +42,16 @@ npx verifyx init
> and a typed `verifyx`) work identically on macOS, Linux, and Windows. Your npm **script** can still be named
> `verify` (that's a script lookup, not command resolution), so `npm run verify` works everywhere.

## Package managers

verifyx works with npm and pnpm, and uses the project's package manager to run scripts, install tools and write agent docs. It looks in the project directory, then its parents, and goes by the first of these it finds:

1. `devEngines.packageManager` in `package.json`
2. `packageManager` in `package.json`, e.g. `"pnpm@11.26.0"`
3. a lockfile: `pnpm-lock.yaml` or `pnpm-workspace.yaml` for pnpm, `package-lock.json` for npm

On pnpm, checks run with `pnpm run`, and skip pnpm's dependency check so parallel checks never install mid-run. Run `pnpm verify` to have pnpm check dependencies once before verifyx starts.

## How `verifyx` decides what to run

A **`verify:*` script** is any npm script in your `package.json` whose name starts with `verify:` (for example `verify:lint`, `verify:complexity`, `verify:custom`). Each one is a single **check**. `verifyx` runs them all **in parallel**, and together they form your project's verification gate. You decide which checks make up that gate by adding these scripts (`verifyx init` scaffolds a starting set).
Expand Down Expand Up @@ -130,7 +142,7 @@ Flags on the bare `verifyx` command:

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`.

Because checks are named for their function, **on failure** an external check prints the tool it used, the exact argv 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.
Because checks are named for their function, **on failure** an external check prints the tool it used, the exact argv 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 the command that ran (`npm run verify:<name>` or `pnpm run verify:<name>`).

**Passing extra arguments through.** When you run an external check directly, anything after `--` is forwarded as literal argv entries to the underlying tool, so you can tweak an invocation without ejecting it: `verifyx circular-deps -- src/index.ts` asks skott to start at that file, while `verifyx lint -- --quiet` passes `--quiet` to oxlint. With no entrypoint, skott scans the current project, so `verifyx init` scaffolds the cross-platform default `verifyx circular-deps` without a shell glob.

Expand Down Expand Up @@ -264,9 +276,9 @@ verifyx init
It first asks how `verify` should run: **run all built-in checks** (`verifyx all`, no `verify:*` scripts) or **pick specific checks** to wire up. Then you multi-select **agent targets** (Claude and/or other agents), and, if you chose to pick, the **checks**. After that it:

- writes the selected `verify:*` scripts to `package.json` (never clobbering existing ones),
- installs the external checks' tools as `--save-dev`, **skipping any already declared in `package.json` or present in `node_modules`** (so an existing `typescript`/`oxlint` is never re-installed or version-bumped). If the install hits a conflict (e.g. a peer-dependency clash), it isolates the failing package(s), installs the rest, and reports what to install manually at the end instead of aborting,
- installs the external checks' tools as devDependencies with the project's package manager, **skipping any already declared in `package.json` or present in `node_modules`** (so an existing `typescript`/`oxlint` is never re-installed or version-bumped). On npm, if the install hits a conflict (e.g. a peer-dependency clash), it isolates the failing package(s), installs the rest, and reports what to install manually at the end instead of aborting (for pnpm, see [On pnpm](#on-pnpm)),
- writes the **`verify` skill**: the same `SKILL.md` to `.claude/skills/verify/` (Claude) and `.agent-skills/verify/` (cross-vendor), so the integration is identical everywhere,
- appends a one-line pointer to `CLAUDE.md` / `AGENTS.md` (only if not already present; existing content is never rewritten),
- appends a one-line pointer to `CLAUDE.md` / `AGENTS.md` (only if not already present; existing content is never rewritten). Both use the project's command (`npm run verify` or `pnpm verify`),
- if `unused-code` is selected, adds the other external tools (`oxlint`/`oxfmt`/`skott`/`jscpd`) to knip's `ignoreDependencies` (verifyx runs them at runtime, so knip can't see them and would otherwise report them as unused), and adds system binaries your scripts invoke (`uv`, `az`, `python`, `terraform`, …) that knip's own global list doesn't cover to `ignoreBinaries`. Merged into `knip.json` or `package.json#knip` (created if neither exists), adding only what's missing; a code-based `knip.ts`/`knip.js` is left for you to edit.

The skill auto-triggers on "verify"/"run checks", so agents run the checks proactively; the pointer reinforces it for tools that read `CLAUDE.md`/`AGENTS.md` as standing instructions.
Expand All @@ -276,6 +288,11 @@ Options:
- `--defaults-only`: the non-interactive form of the "run all built-in checks" choice. Does **not** write `verify:*` scripts; wires the top-level `verify` script to `verifyx all` so it runs every built-in (still installs opted-in tools and writes the skill + pointer).
- `--yes`: non-interactive; use `--select <name>` (repeatable), `--no-claude`, `--agents`.

#### On pnpm

- init needs pnpm 11.26+ or 12.4+, and stops before changing anything on an older version.
- pnpm 11+ stops an install when a dependency has an install script (such as a native build) that the project hasn't allowed or blocked under `allowBuilds` in `pnpm-workspace.yaml`. init blocks the one it knows about: `@parcel/watcher`, which skott pulls in but doesn't need.

### `verifyx upgrade-docs`

Idempotently create/refresh the skill and the `CLAUDE.md`/`AGENTS.md` pointer (created / appended / updated / unchanged; refuses to write through symlinks, never rewrites your instruction files):
Expand Down Expand Up @@ -334,7 +351,26 @@ jobs:
- run: npm run verify
```

`npm run verify` exits non-zero on the first failing check (failing the job), and its buffered output is flushed so the log shows exactly what failed and how to fix it. To force a mode regardless of environment, use `verifyx --check` or `verifyx --fix`.
The same job on pnpm (`pnpm/action-setup` reads the version from `packageManager`):

```yaml
name: verify
on: [push, pull_request]
jobs:
verify:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v6
- uses: actions/setup-node@v4
with:
node-version: 24
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm verify
```

`npm run verify` (or `pnpm verify`) exits non-zero on the first failing check (failing the job), and its buffered output is flushed so the log shows exactly what failed and how to fix it. To force a mode regardless of environment, use `verifyx --check` or `verifyx --fix`.

## Programmatic API

Expand Down
7 changes: 6 additions & 1 deletion src/checks/external.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import path from 'node:path'
import { color } from '../shared/color.ts'
import { resolveMode } from '../shared/mode.ts'
import { emit } from '../shared/output.ts'
import { detectPackageManager, PACKAGE_MANAGER_COMMANDS } from '../shared/packageManager.ts'
import { appendArgv, formatArgv, formatShellCommand, runArgvCommand } from '../shared/spawn.ts'
import { type MaxWarningsSupport, withinBudget } from './maxWarnings.ts'
import type { Check, CheckMode, CheckResult, RunDefaultOptions } from './types.ts'
Expand Down Expand Up @@ -37,6 +38,8 @@ export type ExternalCheckSpec = {
/** Command run in fix mode. When omitted, the check is not fixable and always runs `checkCommand`. */
fixCommand?: readonly string[]
devDeps: string[]
/** Packages whose build scripts `init` denies on pnpm. See `Check['scaffold']` for why. */
pnpmDenyBuilds?: string[]
recommended?: boolean
/** Docs / config reference for the underlying tool, surfaced when the check fails. */
docs?: string
Expand Down Expand Up @@ -114,6 +117,7 @@ export function defineExternalCheck(spec: ExternalCheckSpec): Check {
scaffold: {
script: `verifyx ${spec.name}`,
devDeps: spec.devDeps,
pnpmDenyBuilds: spec.pnpmDenyBuilds,
},
// `verifyx eject <name>` inlines these raw commands into the consumer's verify:* scripts. Lazy so
// formatShellCommand's unserializable-arg throw fires on eject only, not when CHECKS is built at import.
Expand All @@ -125,7 +129,8 @@ export function defineExternalCheck(spec: ExternalCheckSpec): Check {
},
async runDefault({ extraArgs = [], maxWarnings }: RunDefaultOptions = {}): Promise<CheckResult> {
if (!hasLocalBin(spec.bin)) {
console.log(color.dim(`${spec.name}: ${spec.bin} not installed — skipping (add it with \`npx verifyx init\`)`))
const cli = PACKAGE_MANAGER_COMMANDS[detectPackageManager(process.cwd())].runCli
console.log(color.dim(`${spec.name}: ${spec.bin} not installed — skipping (add it with \`${cli} init\`)`))
return { name: spec.name, ok: true, skipped: true }
}
if (spec.canRun && !spec.canRun()) {
Expand Down
1 change: 1 addition & 0 deletions src/checks/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ export const CHECKS: Check[] = [
bin: 'skott',
checkCommand: ['skott', '--displayMode=raw', '--showCircularDependencies', '--exitCodeOnCircularDependencies=1'],
devDeps: ['skott'],
pnpmDenyBuilds: ['@parcel/watcher'],
docs: 'https://github.com/antoine-coulon/skott',
}),
defineExternalCheck({
Expand Down
9 changes: 9 additions & 0 deletions src/checks/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,15 @@ export type Check = {
script: string
/** Extra devDependencies the check needs, installed on opt-in. */
devDeps?: string[]
/**
* Dependencies of `devDeps` that run a build script on install, but that the check works without. For example,
* skott depends on `@parcel/watcher`, which compiles native code.
*
* pnpm 11+ fails an install until each such build script is allowed or denied. So on pnpm, `verifyx init` denies
* these by adding `'@parcel/watcher': false` under `allowBuilds` in `pnpm-workspace.yaml`. If the project already
* allows or denies the package, init leaves it alone.
*/
pnpmDenyBuilds?: string[]
}
/**
* External checks only: the raw tool commands `verifyx eject <name>` inlines into `verify:<name>` (and
Expand Down
43 changes: 35 additions & 8 deletions src/commands/registerInit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,11 @@ import { CHECKS, recommendedChecks } from '../checks/registry.ts'
import type { AgentTarget } from '../scaffold/agentFiles.ts'
import { applyInit, type InitResult } from '../scaffold/init.ts'
import { type InstallReport, installDevDeps } from '../scaffold/installDeps.ts'
import { checkPnpmVersion } from '../scaffold/pnpmVersion.ts'
import { ACTION_MARK } from '../scaffold/writeManaged.ts'
import { color } from '../shared/color.ts'
import { detectPackageManager, PACKAGE_MANAGER_COMMANDS, type PackageManager } from '../shared/packageManager.ts'
import { captureArgvCommand } from '../shared/spawn.ts'

export type Choice = { name: string; message: string; enabled?: boolean }

Expand Down Expand Up @@ -84,8 +87,18 @@ function report(result: InitResult, defaultsOnly: boolean): void {
}
}

/** How to finish a failed install by hand. pnpm already prints the cause, so for pnpm this only points to that output. */
function manualInstallAdvice(manager: PackageManager, failed: readonly string[]): string {
if (manager === 'pnpm') return " (see pnpm's output above)"
return (
`\n init finished; the rest is wired up. Resolve these yourself — e.g. a peer-dependency conflict may need` +
`\n a compatible version or --legacy-peer-deps. Install manually with:` +
`\n ${PACKAGE_MANAGER_COMMANDS.npm.addDevDependencies(failed).join(' ')}`
)
}

/** Summarise the dependency install: what was skipped, installed, and — last, so it's the takeaway — what failed. */
function reportInstall(install: InstallReport): void {
function reportInstall(install: InstallReport, manager: PackageManager): void {
if (install.skipped.length > 0) {
console.log(color.dim(`\nSkipped ${install.skipped.length} already-installed devDependenc(ies): ${install.skipped.join(', ')}`))
}
Expand All @@ -95,15 +108,17 @@ function reportInstall(install: InstallReport): void {
if (install.failed.length > 0) {
console.error(
color.yellow(
`\n⚠ Failed to install ${install.failed.length} devDependenc(ies): ${install.failed.join(', ')}` +
`\n init finished; the rest is wired up. Resolve these yourself — e.g. a peer-dependency conflict may need` +
`\n a compatible version or --legacy-peer-deps. Install manually with:` +
`\n npm install --save-dev ${install.failed.join(' ')}`,
`\n⚠ Failed to install ${install.failed.length} devDependenc(ies): ${install.failed.join(', ')}${manualInstallAdvice(manager, install.failed)}`,
),
)
}
}

async function pnpmBlocker(cwd: string): Promise<string | undefined> {
const check = checkPnpmVersion(await captureArgvCommand(['pnpm', '--version'], { cwd }))
return check.ok ? undefined : check.message
}

/** `verifyx init` — interactively scaffold checks + agent files into the current project. */
export function registerInit(program: Command): void {
program
Expand All @@ -116,16 +131,28 @@ export function registerInit(program: Command): void {
.option('--agents', 'also write .agent-skills/ files (non-interactive)')
.action(async (opts: InitCliOptions) => {
const cwd = process.cwd()
const manager = detectPackageManager(cwd)
const blocker = manager === 'pnpm' ? await pnpmBlocker(cwd) : undefined
if (blocker) {
console.error(color.yellow(blocker))
process.exitCode = 1
return
}
const { checks, targets, defaultsOnly } = await resolveSelections(opts)

const result = applyInit({ cwd, checks, targets, defaultsOnly })
const result = applyInit({ cwd, checks, targets, defaultsOnly, packageManager: manager })
report(result, defaultsOnly)

let install: InstallReport | undefined
if (result.devDeps.length > 0) {
console.log(color.dim(`\nResolving ${result.devDeps.length} devDependenc(ies): ${result.devDeps.join(', ')}`))
reportInstall(await installDevDeps(result.devDeps, cwd))
install = await installDevDeps(result.devDeps, cwd, { packageManager: manager, pnpmDenyBuilds: result.pnpmDenyBuilds })
reportInstall(install, manager)
}

console.log(color.dim('\nRun `npm run verify` (or `npx verifyx`) to run your verifications.'))
const { runGate, runCli } = PACKAGE_MANAGER_COMMANDS[manager]
console.log(color.dim(`\nRun \`${runGate}\` (or \`${runCli}\`) to run your verifications.`))
// pnpm only: a failed add usually leaves every later `pnpm install` failing too.
if (manager === 'pnpm' && (install?.failed.length ?? 0) > 0) process.exitCode = 1
})
}
4 changes: 3 additions & 1 deletion src/commands/registerUpgradeDocs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type { Command } from 'commander'

import { type AgentTarget, writeAgentFiles } from '../scaffold/agentFiles.ts'
import { ACTION_MARK, summarise } from '../scaffold/writeManaged.ts'
import { detectPackageManager } from '../shared/packageManager.ts'

/** `verifyx upgrade-docs` — refresh the verify skill and append the pointer to CLAUDE.md / AGENTS.md. */
export function registerUpgradeDocs(program: Command): void {
Expand All @@ -15,7 +16,8 @@ export function registerUpgradeDocs(program: Command): void {
if (opts.claude !== false) targets.push('claude')
if (opts.agents !== false) targets.push('agents')

const results = writeAgentFiles(process.cwd(), targets)
const cwd = process.cwd()
const results = writeAgentFiles(cwd, targets, detectPackageManager(cwd))
for (const result of results) {
if (result.action === 'unchanged') continue
console.log(` ${ACTION_MARK[result.action]} ${result.path} (${result.action})`)
Expand Down
Loading
Loading