One command your AI agent runs after every change, and your CI runs on every push. verifyx bundles a project's checks (lint, format, type-check, complexity, dead code, and more) behind a single command designed to give AI coding agents real back-pressure against shipping hard-to-maintain code, while keeping quality high for everyone.
verify ships both:
- a CLI (
verifyx) that orchestrates those checks by convention, for AI agents and CI servers alike, and - a verify skill (
.claude/skills/,.agent-skills/) + a one-lineCLAUDE.md/AGENTS.mdpointer that steer AI assistants to run the checks and fix what they report.
What makes it worth wiring in:
- Auto-fixes locally, fails in CI (same command). Run it on your machine and it fixes what it can (lint, formatting) instead of just complaining. Run it under CI and the identical command is check-only, so a PR can't merge with problems that should have been fixed.
- Silent when green, so it's cheap to loop. A passing run prints nothing and exits
0, with no output to burn an agent's tokens or bury the one failure that matters. Agents can run it as often as they like. - Failure output written for an agent to act on. When a check fails it names the tool it ran, the exact command, and a docs link, so the agent (or you) knows what to fix and how instead of guessing.
- Convention over configuration. Checks are just
verify:*npm scripts run in parallel. Add, drop, or override any of them; there's no bespoke config format to learn.
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:
npm install --save-dev @makerx/verifyRequires Node.js >= 24. Invoke it via an npm script or npx verifyx.
The quickest way to wire it into a project:
npx verifyx initNote
Why is the command verifyx, not verify?
The package is @makerx/verify, but the CLI binary is verifyx. verify is a built-in cmd.exe
command on Windows, and both npm run script bodies and npx resolve commands through cmd there, so a
bare verify runs the Windows builtin ("VERIFY is off."), not this tool. Renaming the binary to verifyx
(a nod to the fact it fixes as well as verifies) makes every invocation (npx verifyx, npm scripts,
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.
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).
A check is either built-in or custom:
- Built-in checks ship with
verifyx(see Built-in checks below) and are invoked as subcommands (verifyx lint,verifyx complexity, and so on). Averify:*script wires one in by calling it:"verify:lint": "verifyx lint". - Custom checks are any command of your own, wired as a
verify:*script that runs whatever you like:"verify:custom": "node ./scripts/my-check.mjs". They aren'tverifyxsubcommands; you run them through the gate, or directly vianpm run verify:custom.
There are three ways to invoke the CLI, from "all my checks" down to "one specific check":
| Command | What it runs |
|---|---|
verifyx |
Every verify:* script: the checks you've curated (built-in and custom). With none defined, nothing runs. |
verifyx all |
Every built-in check with default options, plus any custom verify:* scripts. A verify:<name> overrides its built-in. |
verifyx <check> |
A single built-in by name, e.g. verifyx complexity. verifyx list shows them all. |
Both orchestration modes below (verifyx and verifyx all) run their checks in parallel and are completely silent on success: no preamble, no per-check output, just exit 0. Each check's output is buffered and printed only if it fails, so a run is cheap to loop or hand to an agent. --verbose prints every check's output (not just failures); --measure prints only a status/duration table; neither implies the other. (Running a single built-in, verifyx <check>, always prints its report, since you asked for that check specifically.)
With no subcommand, verifyx runs every verify:* script in parallel. Nothing is implicit: the scripts you define are the gate. This is the mode a top-level "verify": "verifyx" script points at, so npm run verify runs your whole gate.
You curate your checks by adding verify:* scripts. Prefer calling the built-ins (verifyx <check>) so their fix-vs-check behaviour stays centralised; drop to a raw command only for something bespoke:
verifyx all runs every built-in check with its default options, plus any custom verify:* scripts you've defined, all in parallel. It's the quick "run everything" without hand-curating a list. Where you've defined a verify:<name> script matching a built-in, it overrides that built-in, so you can swap a single check's implementation without redefining the rest, e.g. "verify:lint": "eslint ." makes verifyx all use ESLint for the lint step.
Fixable checks (lint, format) auto-fix by default so the person (or AI agent) running verifyx locally doesn't burn effort hand-fixing lint and formatting. When CI is set (as CI systems do), the same command is check-only and fails instead of rewriting, so a PR can't pass with unformatted or unlinted code. Force a mode with verifyx --fix or verifyx --check.
The same split works for any verify:<name> script, whether it overrides a built-in or runs a custom command: pair it with a verify:<name>:fix variant, and verifyx runs the :fix variant locally and the base script in CI (never both). So even a script wrapping a tool that doesn't know about fix-vs-check still fixes locally and only checks in CI:
{
"scripts": {
"verify:lint": "eslint .",
"verify:lint:fix": "eslint . --fix",
},
}verifyx runs your test suite as part of a verify run, by convention, so you don't have to wire it in as a check:
- Locally it runs your
verify:testscript if you have one, otherwise your standardtestscript. The run is buffered like any other check (silent on success, shown on failure), so it stays cheap in a loop; stream it with--verbose. - On CI (
CIset) it runs only atest:ciscript, if present. A plaintest(orverify:test) never runs on CI, because CI usually needs a different invocation (emittingjunit.xml, coverage, and so on). With notest:ci, verify runs no tests on CI; run them in a separate step. --no-testsskips the step entirely, handy when CI already runs tests in a dedicated step, or underverifyx all.
The test run is just another entry in the gate: it appears in --measure and fails the overall run if it fails.
Flags on the bare verifyx command:
--check/--fix: force check-only or auto-fix (defaults: fix locally, check under CI).--measure: print a status/duration summary table.--verbose: stream all output instead of suppressing passing runs.--no-tests: skip the automatic tests step.
| 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). |
format |
external | Formatting; writes locally, checks in CI (oxfmt). |
check-types |
external | TypeScript type check (tsc --noEmit); skips when there is no tsconfig.json. |
unused-code |
external | Unused files, exports and dependencies (knip). |
circular-deps |
external | Circular dependencies (skott). |
duplicate-code |
external | Copy-paste detection (jscpd). |
External checks shell out to 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 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.jsonformat: oxfmt configurationcheck-types: yourtsconfig.jsonunused-code:knip.jsoncircular-deps: skott optionsduplicate-code:.jscpd.jsonorpackage.json#jscpd
verifyx complexity --threshold 50 "src/**/*.ts"[pattern]: glob, directory, or file. Defaults to{src,server,shared}/**/*.ts.--threshold <n>: fail when any file's minimum maintainability index is belown.--ignore <glob>: exclude files (repeatable; appended to the default**/*test.ts*).
It parses your .ts/.tsx sources with the TypeScript compiler API and, for every function, computes three metrics: cyclomatic complexity (independent paths through the code), Halstead volume (a size measure derived from the operators and operands used), and SLOC (source lines of code, excluding blanks and comments). It then combines them into a single maintainability index (MI), a 0–100 score where lower means harder to maintain:
MI = 171 - 5.2 * ln(HalsteadVolume) - 0.23 * CyclomaticComplexity - 16.2 * ln(SLOC)
The result is clamped to 0–100; a function with zero Halstead volume or zero SLOC scores 100. As a rough guide: > 65 is good, 50–65 is moderate (watch for growth), and < 50 is hard to maintain. Thresholds are a matter of taste; pick one that fits your codebase and enforce it in CI.
A file's score is the minimum MI across its functions. When exactly one file matches, a detailed per-function breakdown (SLOC, cyclomatic complexity, Halstead metrics, and MI) is printed instead of the gate, handy for diagnosing one file at a time. Fix a failure by splitting the file, not by gaming the metric (deleting comments, joining lines, shortening names).
verifyx comments --max-lines 2 --pushback "src/**/*.ts"Capable coding models (Opus 4.8 very much included) love to narrate their work: multi-line comment blocks explaining what the code does rather than why. They add little value, drift out of sync as the code changes (and a later reader, human or LLM, may trust the stale comment over the code), and quietly grow the surface area you have to maintain. By default this check pushes back on exactly that, flagging any comment block taller than --max-lines (default 2) so the pressure is on the code to document itself.
[pattern]: glob, directory, or file to scan.--max-lines <n>: fail on comment blocks longer thannlines (default 2).--pushback: reframe the failure message as back-pressure aimed at an AI agent (see below).--warn: report the long-block violations without failing the run.--block-new-comments: also fail on any comment on a changed line (vsHEADlocally, the PR base in CI; see below).--ignore <glob>: exclude files (repeatable; the--block-new-commentsgate also readscomments.ignorefrom your verify config).
--pushback is the clever bit. An AI agent that hits a failing check will often take the path of least resistance and just delete or weaken the check to make the run pass. So rather than a dry error, the pushback message tells the agent that the only sanctioned way to keep the comment is to prefix it with context:, and that doing so pages a human to approve it. Confronted with a real person's time on the line, the agent tends to reconsider and remove the low-value comment instead of gaming the gate. It is a small piece of prompt-engineering baked into a lint failure, and in practice it stops agents silencing the check far more reliably than a plain error does.
If you want to go further and block new comments outright, override the verify:comments script and add --block-new-comments. That turns on a stricter, diff-based gate on top of the long-block check: any comment sitting on a changed line fails, whether you added or merely edited it. Machine directives (eslint-disable, @ts-expect-error, and friends) stay exempt, as does anything marked context:.
The "changed lines" are resolved per environment so the gate works the same locally and in CI. Locally it diffs the working tree against HEAD (your uncommitted changes). In CI (CI set) a clean checkout has nothing uncommitted, so it diffs against the merge base with the PR base branch instead, read from GITHUB_BASE_REF (GitHub Actions). Set VERIFY_DIFF_BASE to a ref to override the base explicitly; if no base can be resolved it falls back to HEAD. For CI, make sure the base branch is fetched (actions/checkout with fetch-depth: 0), or the merge base won't be found and the gate silently passes.
Two escape hatches keep genuinely useful comments alive. JSDoc (/** … */) is always allowed, and prefixing a comment's first line with context: marks it as durable context the code itself can't express:
// context: the upstream API returns seconds, not milliseconds, so do not "fix" this
const timeoutMs = timeout * 1000verifyx hardcoded-colors --root srcFails on literal hex or 0x colour values in source (.ts, .tsx, .css, .scss, .vue, .svelte, and similar), nudging you toward named design tokens. It is pure JavaScript (no grep), so it behaves the same on every OS.
--root <dir>: directory to scan (defaultsrc).--ignore <glob>: exclude files (repeatable).
Both default from your verify config (hardcodedColors.root and hardcodedColors.ignore), so you can set them once instead of in the script.
verifyx forbidden-stringsFails when a configured JSON value matches a disallowed glob, handy for catching things like a debug log level or a staging URL left in a committed config file. It is entirely config-driven (no flags): define rules under forbiddenStrings in your verify config. Each rule reads its file, looks up every dotted paths entry, and fails if the value matches the disallowed glob:
{
"verify": {
"forbiddenStrings": [{ "file": "app.json", "paths": ["env.LOG_LEVEL"], "disallowed": "debug" }],
},
}Interactively wire verifications and the agent integration into the current project:
verifyx initIt 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 topackage.json(never clobbering existing ones), - installs the external checks' tools as
--save-dev, - writes the
verifyskill: the sameSKILL.mdto.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), - if
unused-codeis selected, adds the other external tools (oxlint/oxfmt/skott/jscpd) to knip'signoreDependencies(verifyx runs them at runtime, so knip can't see them and would otherwise report them as unused). Merged intoknip.jsonorpackage.json#knip(created if neither exists), adding only what's missing; a code-basedknip.ts/knip.jsis 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.
Options:
--defaults-only: the non-interactive form of the "run all built-in checks" choice. Does not writeverify:*scripts; wires the top-levelverifyscript toverifyx allso 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.
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):
verifyx upgrade-docs # Claude + other agents
verifyx upgrade-docs --no-agents # only .claude/ + CLAUDE.mdExternal 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.
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 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.
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:
{
"verify": {
"comments": { "ignore": ["**/*.generated.ts"] },
"hardcodedColors": { "root": "src", "ignore": ["**/tokens.ts"] },
"forbiddenStrings": [{ "file": "app.json", "paths": ["env.LOG_LEVEL"], "disallowed": "debug" }],
},
}External checks don't use this file; each is configured through its own tool's config (.oxlintrc.json, tsconfig.json, knip.json, and so on), listed under Built-in checks.
verifyx is built to run the same command locally and in CI. The only thing that changes is the CI environment variable: when it's set (GitHub Actions, GitLab CI, and most providers export it automatically), verifyx switches from fix mode to check mode. Nothing is rewritten; any lint, formatting, type, complexity, or other issue fails the job instead, so a PR can't merge with a problem that should have been fixed locally first.
Because the tool is a pinned dev dependency, CI runs the exact version in your lockfile. A minimal GitHub Actions job:
name: verify
on: [push, pull_request]
jobs:
verify:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 24
cache: npm
- run: npm ci
- run: npm run verifynpm 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.
Every export is available from the package root:
import { analyzeComplexity, getCheck, runAll } from '@makerx/verify'
// Run the maintainability analysis directly.
const { failing } = analyzeComplexity({ pattern: 'src/**/*.ts', threshold: 50 })
// Run any single check by name, including the ones that shell out to an external tool.
const lint = await getCheck('lint')?.runDefault()Native checks also expose a direct runner (runComplexity, runComments, runHardcodedColors, runForbiddenStrings); external checks (lint, format, check-types, unused-code, circular-deps, duplicate-code) have no standalone function and are run via the registry (getCheck(name)?.runDefault()) or the orchestrators.
Entry points:
- Checks:
CHECKS,getCheck,recommendedChecks, and the nativerun*functions above. - Orchestration:
orchestrate(the bareverifyxrunner) andrunAll(verifyx all). - Complexity internals:
analyzeComplexity,scoreFiles,findSourceFiles,resolvePattern,forEachFunction,findLongCommentBlocks, and the metric helperscalculateCyclomaticComplexity,calculateHalstead,calculateMaintainabilityIndex,countSloc. - Scaffolding & config:
applyInit,applyEject(+ejectScripts), andloadVerifyConfig.
The verify runner, comments, hardcoded-colors, and forbidden-strings checks are ported from staff0rd/assist; the maintainability metrics originate there too. See Steering the Vibe: Verify and Complexity.
MIT
{ "scripts": { "verify": "verifyx", "verify:complexity": "verifyx complexity --threshold 50 \"src/**/*.ts\"", "verify:lint": "verifyx lint", "verify:custom": "node ./scripts/my-check.mjs", }, }