
React Hook Form Audit
- 82 installs
- 191 repo stars
- Updated July 24, 2026
- pproenca/dot-skills
react-hook-form-audit is a Claude Code skill for frontend development. It helps solo builders move faster with AI-assisted coding.
Key points
- react-hook-form-audit
- Frontend Development
- AI-coding skill
React Hook Form Audit by the numbers
- 82 all-time installs (skills.sh)
- +6 installs in the week ending Aug 4, 2026 (Skillselion tracking)
- Ranked #1,101 of 2,245 Frontend Development skills by installs in the Skillselion catalog
- Data as of Aug 4, 2026 (Skillselion catalog sync)
npx skills add https://github.com/pproenca/dot-skills --skill react-hook-form-auditAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 82 |
|---|---|
| repo stars | ★ 191 |
| Last updated | July 24, 2026 |
| Repository | pproenca/dot-skills ↗ |
How do I helps with frontend development tasks during ai-assisted development?
Helps with frontend development tasks during AI-assisted development.
Who is it for?
Best when you're working on frontend development and need structured help with react-hook-form-audit.
Skip if: Teams with no frontend development needs, or anyone wanting a generic chat assistant without this specific workflow.
When should I use this skill?
When you need to helps with frontend development tasks during ai-assisted development, or when react-hook-form-audit is a claude code skill for frontend development. it helps solo builders move faster with ai-assisted coding.
What you get
Structured output aligned to react-hook-form-audit: react-hook-form-audit; Frontend Development; AI-coding skill.
Files
React Hook Form Audit for Next.js
Static-analysis audit that detects 15 React Hook Form anti-patterns in Next.js App Router codebases. Combines ripgrep (fast pass for regex-detectable rules) with ts-morph (AST pass for structural rules). Outputs a markdown report grouped by severity, with file:line references and links back to the companion react-hook-form distillation skill.
When to Apply
- User asks to audit, review, or lint React Hook Form usage in a Next.js project
- User reports symptoms like "the form re-renders too much" or "we have RHF bugs we can't pin down"
- Before a PR review of new RHF-heavy code
- As a CI gate (exit code 1 on CRITICAL/HIGH findings)
- After upgrading react-hook-form, to catch advice that drifted
Tool Requirements
rg(ripgrep) — install viabrew install ripgrepor your package managernode≥ 18 withnpm— used to run the AST detectors viats-morphjq— for JSON munging in the orchestrator
ts-morph is installed on first run into scripts/node_modules/ (one-time, ~30s). The skill never touches the audited project's node_modules.
Risk Level
Read-only. The skill reads source files and writes one markdown report (plus an optional JSON sidecar) to the audited project root. No git operations, no package mutations, no network calls.
Workflow Overview
1. detect-project.sh Verify Next.js + react-hook-form in package.json
2. collect-files.sh Ripgrep for "use client" files importing RHF
3. detect-fast.sh Ripgrep detectors (rules 5, 11, 14)
4. detect-ast.mjs ts-morph detectors (rules 1-4, 6-10, 12-13, 15)
5. render-report.mjs Render markdown + JSON; print summary; exit 0/1See `references/workflow.md` for the per-step contract, error handling, and CI integration.
Usage
# Audit the current directory
bash scripts/audit.sh
# Audit a specific project
bash scripts/audit.sh --project /path/to/nextjs-app
# Preview without writing the report file
bash scripts/audit.sh --dry-runExit codes:
0— no CRITICAL or HIGH findings1— CRITICAL or HIGH findings exist2— environment or configuration error (missing tool, invalid project)
Detector Catalog
15 detectors across 4 severities. See `references/detectors.md` for per-rule pattern, AST shape, false-positive notes, and the line of advice each detector enforces.
| ID | Severity | What it catches |
|---|---|---|
| 01 | CRITICAL | watch() in same component as useForm() |
| 02 | CRITICAL | watch() with no args (subscribes to all fields) |
| 03 | CRITICAL | useForm() without defaultValues |
| 04 | CRITICAL | useEffect depends on the useForm return |
| 05 | CRITICAL | RHF imported in a non-"use client" file |
| 06 | HIGH | <Controller> inlined inside useForm() parent |
| 07 | HIGH | Async submit handler without try/catch |
| 08 | HIGH | Validation schema defined inside the component |
| 09 | HIGH | Submit calls fetch/axios but never setError('root.*') |
| 10 | HIGH | RHF mixed with useActionState in same component |
| 11 | MEDIUM | mode: 'onChange' without explanatory comment |
| 12 | MEDIUM | register({ disabled: <state> }) for visual disable |
| 13 | MEDIUM | useFieldArray map missing field.id as key |
| 14 | LOW | reValidateMode: 'onBlur' (now demoted advice) |
| 15 | LOW | useFormContext() usage (manual review) |
How to Use
1. Confirm project is a Next.js App Router app with react-hook-form installed (detect-project.sh will check) 2. Run bash scripts/audit.sh [--project <path>] 3. Read the generated .rhf-audit-report.md in the project root 4. For each finding, follow the link to the companion distillation rule for the fix 5. After fixing, re-run to verify a clean audit
If a detector is too noisy in your project, narrow include_globs / widen exclude_globs in config.json rather than disabling detectors — the catalog is small enough that each finding should be either a real issue or a documented exception worth a comment.
Setup
The skill ships with sensible defaults in config.json. On first run, audit.sh will install ts-morph into scripts/node_modules/. Override settings by editing config.json:
project_root— absolute path to the project (defaults to current directory)report_path/json_report_path— output filenames (relative to project root)rule_link_base— base URL or path for companion-rule linksinclude_globs/exclude_globs— narrow the scan surface
Reference Files
- `references/workflow.md` — detailed workflow, error handling, CI integration
- `references/detectors.md` — per-detector spec, AST shape, false-positive notes
- `gotchas.md` — accumulated failure modes (append-only)
Related Skills
react-hook-form— the companion distillation skill with the 45 rules this auditor enforces. Findings link directly to its reference files.react-19— for Server Action /useActionStatepatterns the audit explicitly does NOT cover
{
"project_root": "",
"report_path": ".rhf-audit-report.md",
"json_report_path": ".rhf-audit-report.json",
"rule_link_base": "https://github.com/pproenca/dot-skills/blob/master/skills/.curated/react-hook-form/references",
"include_globs": ["app/**/*.tsx", "app/**/*.ts", "src/**/*.tsx", "src/**/*.ts", "components/**/*.tsx"],
"exclude_globs": ["**/node_modules/**", "**/.next/**", "**/dist/**", "**/build/**", "**/*.test.tsx", "**/*.test.ts", "**/*.stories.tsx"],
"_setup_instructions": {
"project_root": "Absolute path to the Next.js project to audit. Defaults to the current working directory when empty.",
"report_path": "Path (relative to project_root) for the markdown report. Default: .rhf-audit-report.md",
"json_report_path": "Path (relative to project_root) for the machine-readable JSON report. Set to empty string to disable JSON output.",
"rule_link_base": "Base URL or local path the report uses when linking to companion distillation rule files. Default points to the dot-skills repo on GitHub; replace with a local path like 'skills/.curated/react-hook-form/references' for offline use.",
"include_globs": "Globs (relative to project_root) of files to scan. Default covers Next.js App Router and conventional src/ layouts.",
"exclude_globs": "Globs to skip — tests, stories, build output, deps."
}
}
Gotchas
No known gotchas yet — append findings here as the skill is used in real projects.
Format:
### {Short title}
{What goes wrong and how to avoid it. Be specific — exact command, exact error.}
Added: YYYY-MM-DD{
"version": "0.1.0",
"organization": "Community",
"technology": "React Hook Form on Next.js App Router",
"discipline": "composition",
"type": "automation",
"date": "May 2026",
"abstract": "Audits Next.js App Router (14/15+) codebases for violations of React Hook Form best practices. Runs ripgrep for regex-detectable patterns and a ts-morph AST pass for structural ones, then emits a markdown report grouped by severity with file:line citations and links back to the companion `react-hook-form` distillation skill. Read-only; produces a single report file. Exits non-zero on CRITICAL/HIGH findings for CI integration.",
"references": [
"https://react-hook-form.com/docs",
"https://nextjs.org/docs/app",
"https://ts-morph.com/",
"https://github.com/BurntSushi/ripgrep"
]
}
Detectors
Per-detector reference: what each rule catches, the pattern it looks for, known false positives, and the companion distillation rule that prescribes the fix.
Severity Legend
| Severity | Meaning | CI effect |
|---|---|---|
| CRITICAL | Correctness or major performance bug — likely user-visible | Fails the audit (exit 1) |
| HIGH | Anti-pattern with concrete consequence — should be fixed | Fails the audit (exit 1) |
| MEDIUM | Discouraged pattern; may be deliberate | Reported, doesn't fail |
| LOW | Informational — review and confirm | Reported, doesn't fail |
---
Rule 01 — rhf-audit-01-watch-at-form-root
Severity: CRITICAL · Tool: ts-morph · Companion: sub-usewatch-over-watch
What it catches: A call to watch(...) inside the same function body as useForm(). This is the canonical performance footgun — every watched-value change re-renders the entire form component.
AST shape:
FunctionDeclaration / ArrowFunction
├── CallExpression { useForm }
└── CallExpression { watch } ← flaggedFix: Move the watch consumer into a child component that uses useWatch({ control, name }) instead.
False positives: None known — every co-location of useForm and watch in the same function is suspect.
---
Rule 02 — rhf-audit-02-watch-all-fields
Severity: CRITICAL · Tool: ts-morph · Companion: sub-watch-specific-fields
What it catches: watch() called with zero arguments, which subscribes to every field in the form. Strictly worse than Rule 01.
AST shape:
CallExpression { watch } with arguments.length === 0Fix: Pass specific field names: watch(['quantity', 'price']), or use useWatch({ control, name: 'quantity' }) in a child component.
Note: Rule 02 fires instead of (not in addition to) Rule 01 when args.length is zero, so you won't get duplicate findings.
---
Rule 03 — rhf-audit-03-missing-default-values
Severity: CRITICAL · Tool: ts-morph · Companion: formcfg-default-values
What it catches: useForm() invoked with no options object, or with an options object that lacks a defaultValues property.
Why CRITICAL: without defaultValues, fields are uncontrolled until first interaction, reset() has nothing to reset to, and TypeScript can't infer the form's shape correctly.
AST shape:
CallExpression { useForm }
├── arguments.length === 0 ← flagged
└── arguments[0] is ObjectLiteralExpression without `defaultValues` property ← flaggedFalse positives: Custom hooks that wrap useForm and inject defaultValues upstream. If you have these, exclude their callers via exclude_globs and audit the wrapper itself.
---
Rule 04 — rhf-audit-04-useeffect-depends-useform
Severity: CRITICAL · Tool: ts-morph · Companion: formcfg-useeffect-dependency
What it catches: useEffect(fn, [deps]) where deps includes the variable bound to useForm()'s return (e.g., const form = useForm(); useEffect(..., [form])). The return object is a new reference every render, causing infinite effect re-runs.
Whitelist: register, control, setValue, setError, clearErrors, reset, subscribe, trigger, unregister — these are stable refs and are intentionally listed as deps.
Fix: Destructure the stable callbacks you need and depend on them, not on the form object.
---
Rule 05 — rhf-audit-05-non-use-client
Severity: CRITICAL · Tool: ripgrep · Companion: _(Next.js-specific, no distillation rule)_
What it catches: A file imports react-hook-form but the first 10 lines do not contain a "use client" directive.
Why CRITICAL: RHF's hooks (useForm, useWatch, etc.) only work in client components. In Next.js App Router, a Server Component that imports RHF will throw at request time.
False positives: Pages Router projects — every file is implicitly a client component there. detect-project.sh reports the router, and the report's introduction notes whether this rule applies. Suppress by excluding the Pages Router paths in config.json.
---
Rule 06 — rhf-audit-06-controller-inlined
Severity: HIGH · Tool: ts-morph · Companion: ctrl-usecontroller-isolation
What it catches: A <Controller> JSX element rendered directly inside the function that calls useForm(). Every parent re-render flows through every inlined Controller, defeating the isolation Controller is meant to provide.
Fix: Move the Controller into a child component (or use useController in a child) — both isolate the re-render.
---
Rule 07 — rhf-audit-07-async-submit-no-trycatch
Severity: HIGH · Tool: ts-morph · Companion: formstate-async-submit-lifecycle
What it catches: The function passed to handleSubmit(fn) is async (or contains await) but does not contain a try { ... } catch { ... }. If the handler throws, isSubmitting stays true forever and the form is stuck.
AST shape:
CallExpression { handleSubmit }
└── arguments[0] is async ArrowFunction / FunctionExpression
└── no TryStatement descendants ← flaggedHandler resolution: if handleSubmit(myFn) is passed an identifier, the detector resolves myFn to a variable or function declaration inside the same component and inspects that.
False positives: Handlers that delegate to a wrapper utility which already handles errors. The detector can't see across function boundaries — if you have such a wrapper, document it via a comment so reviewers can dismiss the finding.
---
Rule 08 — rhf-audit-08-schema-inside-component
Severity: HIGH · Tool: ts-morph · Companion: valid-resolver-caching
What it catches: A call to z.object, yup.object, Joi.object, valibot.object, or bare object(...) inside a component body. The schema is recreated on every render, and the resolver re-validates against a fresh schema reference each time.
Fix: Hoist the schema to module scope: define it outside the component, then reference it inside.
---
Rule 09 — rhf-audit-09-no-server-error-setError
Severity: HIGH · Tool: ts-morph · Companion: valid-server-errors
What it catches: A submit handler calls fetch, axios, or anything matching api.X / http.X, but never calls setError('root.serverError', ...) (or any setError('root.*', ...)).
Why HIGH: server failures will be silently swallowed by handleSubmit. The user gets no feedback that "saving" actually failed.
Fix: try { await api(...) } catch { setError('root.serverError', { message: '...' }) } — combined with rendering errors.root?.serverError in the JSX.
---
Rule 10 — rhf-audit-10-rhf-with-useactionstate
Severity: HIGH · Tool: ts-morph · Companion: _(Next.js-specific)_
What it catches: Both useForm() and useActionState() are called inside the same component. Mixing client-side RHF validation with React 19's Server Action state machine produces duplicated state, race conditions on submit, and unclear ownership.
Recommendation: Pick one. For pure client-side validation, use RHF and submit via your own handler. For Server Actions with simple validation, use useActionState alone. If you genuinely need both — RHF for input UX + Server Action for submission — document it explicitly because the failure modes are subtle.
---
Rule 11 — rhf-audit-11-onchange-mode
Severity: MEDIUM · Tool: ripgrep · Companion: formcfg-validation-mode
What it catches: mode: 'onChange' in any options object.
Why MEDIUM: sometimes deliberate (real-time password strength, "available username" checks). But it's the worst default for performance — every keystroke validates and re-renders.
Fix: If real-time feedback is essential for the use case, keep it and add a comment explaining why. Otherwise switch to mode: 'onSubmit' (the default).
---
Rule 12 — rhf-audit-12-disabled-visual
Severity: MEDIUM · Tool: ts-morph · Companion: formcfg-disabled-prop
What it catches: register('name', { disabled: <expression> }) where the expression is an Identifier (state variable), PropertyAccess, PrefixUnary (e.g. !x), or BinaryExpression — i.e., a reactive value rather than a literal true.
Why this matters: register's disabled option clears the field's value to undefined and skips validation. If you only want the input greyed out, use the HTML disabled attribute directly on the input.
Fix: <input {...register('name')} disabled={condition} /> for visual disable; {...register('name', { disabled: condition })} only when you truly want the field excluded from submission and validation.
---
Rule 13 — rhf-audit-13-fieldarray-no-field-id
Severity: MEDIUM · Tool: ts-morph · Companion: array-use-field-id-as-key
What it catches: A .map() over fields from useFieldArray's destructured return, where the inner JSX either has no key attribute or uses something other than field.id (e.g., the array index).
Why MEDIUM: index keys cause state corruption when items are added, removed, or reordered. field.id is stable and unique.
---
Rule 14 — rhf-audit-14-revalidate-onblur
Severity: LOW · Tool: ripgrep · Companion: formcfg-revalidate-mode
What it catches: reValidateMode: 'onBlur' in any options object.
Why LOW (not higher): the demoted advice. The default onChange gives users immediate positive feedback when they fix an error. Overriding to onBlur is a UX trade-off, justified only when validation is genuinely expensive. The detector flags it for review, not as a definite bug.
---
Rule 15 — rhf-audit-15-useformcontext
Severity: LOW · Tool: ts-morph · Companion: sub-useformcontext-sparingly
What it catches: Any useFormContext() call site.
Why informational: FormContext is occasionally the right tool (e.g., a generic reusable Field component). It's just worth a manual check — shallow uses add implicit coupling without payoff. Confirm the consuming component is deep enough that prop drilling would be worse.
---
Adding New Detectors
To extend the catalog:
1. Pick a rule ID slot: rhf-audit-NN-<short-name> where NN is the next available number 2. Decide tool: ripgrep (regex-detectable, line-level) or ts-morph (structural) 3. Add to the appropriate script (detect-fast.sh or detect-ast.mjs) 4. Add a row to the catalog table in SKILL.md 5. Add an entry in RULE_TO_FILE in render-report.mjs mapping to the companion distillation rule (or null if Next.js-specific) 6. Document it in this file with severity, tool, companion, AST shape, and false positives
Workflow
Detailed end-to-end workflow for the React Hook Form audit. Read this when you need to understand what each step does, how errors are handled, or how to integrate the audit into CI.
Entry Point
scripts/audit.sh orchestrates everything. It accepts:
--project <path>— overrideproject_rootfromconfig.json--dry-run— skip writing the report file; print the summary only-h/--help— show usage
Step 1: Detect Project
scripts/detect-project.sh <project-root>
Purpose: Fail fast when the target isn't a Next.js + RHF project.
Checks:
package.jsonexists at<project-root>nextis listed independenciesordevDependenciesreact-hook-formis listed independenciesordevDependencies- Reports detected Next.js version, RHF version, and router (App vs Pages)
Failure mode: exits 2 with a message telling the user exactly what's missing.
Why we check this: detectors 05 (use-client) and 10 (useActionState mixing) are Next.js-specific. Running them on a Vite or CRA app would produce misleading results.
Step 2: Collect Files
scripts/collect-files.sh <project-root> <config-file>
Purpose: Narrow the AST pass to only files that import react-hook-form. This keeps the audit fast on large monorepos.
How it works:
- Reads
include_globs/exclude_globsfromconfig.json - Runs ripgrep for
from ['"]react-hook-form(/|['"])(matches subpath imports too) - Emits a JSON array of relative paths to stdout
Empty result: if no files match, audit.sh exits 0 with "nothing to audit" — not an error.
Failure mode: ripgrep returning exit code 1 (no matches) is converted to an empty array, not a failure.
Step 3: Fast Pass (ripgrep)
scripts/detect-fast.sh <project-root> <files-json>
Purpose: Run the detectors whose patterns are reliably detectable with a line-level regex.
Implements:
- Rule 05: file missing
"use client"directive while importing RHF - Rule 11:
mode: 'onChange' - Rule 14:
reValidateMode: 'onBlur'
Output: JSON array of finding objects, each with rule, severity, message, file, line, column, snippet.
Why these are fast-pass: they're string-shaped, not structure-shaped. The cost of spinning up ts-morph just to find mode: 'onChange' is wasted; ripgrep does it in milliseconds.
Step 4: AST Pass (ts-morph)
node scripts/detect-ast.mjs --project <root> --files <files-json>
Purpose: Run detectors that need to understand code structure — "is this watch() call inside the same function as useForm()?", "does this async submit handler have a try/catch?"
First-run setup: audit.sh runs npm install in scripts/ to pull ts-morph into a local node_modules. This is one-time and self-contained — the audited project's node_modules is never touched.
Implements:
- Rule 01:
watch()in same enclosing function asuseForm() - Rule 02:
watch()with no arguments - Rule 03:
useForm()withoutdefaultValuesin its options object - Rule 04:
useEffectdeps array contains theuseFormreturn variable - Rule 06:
<Controller>JSX inside the function that callsuseForm() - Rule 07: async submit handler (passed to
handleSubmit) withouttry/catch - Rule 08: schema literal (
z.object,yup.object,Joi.object,valibot.object) defined inside the component - Rule 09: submit handler calls
fetch/axiosbut neversetError('root.*') - Rule 10:
useActionStatein same component asuseForm - Rule 12:
register('name', { disabled: <state> })where the state is an Identifier/PropertyAccess/Binary - Rule 13:
fields.map(...)fromuseFieldArraywithoutkey={field.id} - Rule 15: any
useFormContext()usage (informational)
Output: JSON array of findings, same shape as the fast pass.
Failure mode: any uncaught exception is fatal — audit.sh propagates the exit code 2.
Step 5: Render Report
node scripts/render-report.mjs --findings <all.json> --project <root> [--rule-link-base <base>]
Purpose: Merge fast-pass and AST-pass findings, sort by severity then file:line, render as markdown.
Output structure:
- Header: project path, generation timestamp, total finding count
- Summary table: count per severity
- By-rule table: count per rule, with link to companion distillation rule
- One section per severity, each finding rendered with file:line:column, rule ID, link, message, and a snippet
Companion-rule links: the report links each finding back to the corresponding rule file in the react-hook-form distillation skill. Configure rule_link_base in config.json:
- For PR review (default): GitHub URL to the dot-skills repo
- For local agents: a relative path like
skills/.curated/react-hook-form/references
Exit Codes
0— no CRITICAL or HIGH findings1— at least one CRITICAL or HIGH finding (CI-friendly for blocking merges)2— environment or configuration error (missingrg,node,jq; invalid project;ts-morphinstall failure)
MEDIUM and LOW findings do NOT block — they appear in the report for review but don't fail CI.
CI Integration
GitHub Actions example:
- name: Install ripgrep + jq
run: sudo apt-get update && sudo apt-get install -y ripgrep jq
- name: Run RHF audit
run: bash skills/.curated/react-hook-form-audit/scripts/audit.sh --project .
- name: Upload report
if: always()
uses: actions/upload-artifact@v4
with:
name: rhf-audit-report
path: |
.rhf-audit-report.md
.rhf-audit-report.jsonThe skill's exit code drives the job result: red on CRITICAL/HIGH, green otherwise.
Error Handling
| Failure | Where | Exit | Recovery |
|---|---|---|---|
rg missing | audit.sh dependency check | 2 | Install ripgrep |
node missing | audit.sh dependency check | 2 | Install Node 18+ |
jq missing | audit.sh dependency check | 2 | Install jq |
ts-morph install fails | audit.sh first-run | 2 | Run cd scripts && npm install manually with verbose output |
package.json not found | detect-project.sh | 2 | Verify --project path is correct |
next / react-hook-form missing from deps | detect-project.sh | 2 | Verify the project actually uses RHF; otherwise skip the audit |
| File parse error in AST pass | detect-ast.mjs | 2 | Likely a TypeScript syntax error in the target file — fix the file or exclude it via exclude_globs |
| No candidate files found | collect-files.sh | 0 | Not an error; the project doesn't use RHF in scanned paths |
Idempotency
The audit is fully idempotent. Running it twice produces identical results (modulo the timestamp in the report header). Re-running overwrites the previous report — there's no "merge with previous" mode.
Limitations
- Cross-file analysis: the AST pass runs file-by-file. It does not trace identifiers across module boundaries. A
Controllerdefined in file A and imported into file B that callsuseFormwill not be flagged for rule 06. - Type information: ts-morph runs without
tsconfig.jsonand skips library file resolution. Detectors that would benefit from type-checking (e.g., "is thisIdentifieractually theuseFormreturn?") fall back to name-matching heuristics. - JSX in non-`.tsx` files: detectors only run on files matched by
include_globs. Adjust if you put JSX in.jsfiles. - Server vs Client components: rule 05 checks for the
"use client"directive at the top of the file. Files inside a folder marked"use client"at a layout level are still flagged — Next.js does not currently propagate the directive transitively, and the safe assumption is per-file.
node_modules/
package-lock.json
#!/usr/bin/env bash
# audit.sh — Orchestrate the React Hook Form audit on a Next.js codebase.
# Part of: react-hook-form-audit
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
SKILL_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
CONFIG_FILE="$SKILL_DIR/config.json"
usage() {
cat >&2 <<EOF
Usage: $0 [--project <path>] [--dry-run]
Audits a Next.js App Router project for React Hook Form anti-patterns.
Options:
--project <path> Project root to audit. Defaults to project_root in config.json,
or the current working directory when that is empty.
--dry-run Skip writing the report file; print summary only.
Exit codes:
0 No CRITICAL or HIGH findings
1 CRITICAL or HIGH findings exist
2 Configuration or environment error
EOF
}
PROJECT_OVERRIDE=""
DRY_RUN=0
while [[ $# -gt 0 ]]; do
case "$1" in
--project) PROJECT_OVERRIDE="$2"; shift 2 ;;
--dry-run) DRY_RUN=1; shift ;;
-h|--help) usage; exit 0 ;;
*) echo "Unknown argument: $1" >&2; usage; exit 2 ;;
esac
done
# --- Dependency checks ---
require() {
if ! command -v "$1" >/dev/null 2>&1; then
echo "ERROR: '$1' is required but not installed." >&2
echo " Install with: $2" >&2
exit 2
fi
}
require rg "brew install ripgrep # or your package manager equivalent"
require node "Install Node.js 18+ from https://nodejs.org or via your package manager"
require npm "Ships with Node.js — re-install Node if missing"
require jq "brew install jq # or your package manager equivalent"
# --- Lazy-install ts-morph the first time we run ---
if [[ ! -d "$SCRIPT_DIR/node_modules/ts-morph" ]]; then
echo "→ First run: installing ts-morph (one-time, ~30s)"
(cd "$SCRIPT_DIR" && npm install --silent --no-audit --no-fund --prefer-offline) || {
echo "ERROR: failed to install ts-morph. Run manually: cd $SCRIPT_DIR && npm install" >&2
exit 2
}
fi
# --- Resolve project root ---
PROJECT_ROOT="$PROJECT_OVERRIDE"
if [[ -z "$PROJECT_ROOT" ]]; then
PROJECT_ROOT="$(jq -r '.project_root // ""' "$CONFIG_FILE")"
fi
if [[ -z "$PROJECT_ROOT" ]]; then
PROJECT_ROOT="$(pwd)"
fi
if [[ ! -d "$PROJECT_ROOT" ]]; then
echo "ERROR: project_root '$PROJECT_ROOT' is not a directory." >&2
exit 2
fi
PROJECT_ROOT="$(cd "$PROJECT_ROOT" && pwd)"
echo "Auditing: $PROJECT_ROOT"
# --- Resolve outputs ---
REPORT_PATH="$(jq -r '.report_path // ".rhf-audit-report.md"' "$CONFIG_FILE")"
JSON_REPORT_PATH="$(jq -r '.json_report_path // ""' "$CONFIG_FILE")"
RULE_LINK_BASE="$(jq -r '.rule_link_base // ""' "$CONFIG_FILE")"
# --- Step 1: Detect project ---
echo "→ Step 1/5: detecting Next.js + react-hook-form"
"$SCRIPT_DIR/detect-project.sh" "$PROJECT_ROOT" || exit 2
# --- Step 2: Collect candidate files ---
echo "→ Step 2/5: collecting candidate files"
FILES_JSON="$(mktemp -t rhf-audit-files.XXXXXX.json)"
FAST_JSON=""
AST_JSON=""
ALL_JSON=""
trap 'rm -f "$FILES_JSON" "$FAST_JSON" "$AST_JSON" "$ALL_JSON" 2>/dev/null || true' EXIT
"$SCRIPT_DIR/collect-files.sh" "$PROJECT_ROOT" "$CONFIG_FILE" > "$FILES_JSON"
FILE_COUNT="$(jq 'length' "$FILES_JSON")"
echo " found $FILE_COUNT candidate file(s)"
if [[ "$FILE_COUNT" -eq 0 ]]; then
echo " no React Hook Form usages found — nothing to audit."
exit 0
fi
# --- Step 3: Fast pass (ripgrep) ---
echo "→ Step 3/5: ripgrep fast pass"
FAST_JSON="$(mktemp -t rhf-audit-fast.XXXXXX.json)"
"$SCRIPT_DIR/detect-fast.sh" "$PROJECT_ROOT" "$FILES_JSON" > "$FAST_JSON"
FAST_COUNT="$(jq 'length' "$FAST_JSON")"
echo " fast pass: $FAST_COUNT finding(s)"
# --- Step 4: AST pass (ts-morph) ---
echo "→ Step 4/5: ts-morph AST pass"
AST_JSON="$(mktemp -t rhf-audit-ast.XXXXXX.json)"
node "$SCRIPT_DIR/detect-ast.mjs" --project "$PROJECT_ROOT" --files "$FILES_JSON" > "$AST_JSON"
AST_COUNT="$(jq 'length' "$AST_JSON")"
echo " AST pass: $AST_COUNT finding(s)"
# --- Step 5: Render report ---
echo "→ Step 5/5: rendering report"
ALL_JSON="$(mktemp -t rhf-audit-all.XXXXXX.json)"
jq -s 'add' "$FAST_JSON" "$AST_JSON" > "$ALL_JSON"
REPORT_OPTS=(--findings "$ALL_JSON" --project "$PROJECT_ROOT")
if [[ -n "$RULE_LINK_BASE" ]]; then
REPORT_OPTS+=(--rule-link-base "$RULE_LINK_BASE")
fi
REPORT_MD="$(node "$SCRIPT_DIR/render-report.mjs" "${REPORT_OPTS[@]}")"
if [[ "$DRY_RUN" -eq 1 ]]; then
echo ""
echo "(dry-run) Report would be written to: $PROJECT_ROOT/$REPORT_PATH"
else
printf '%s\n' "$REPORT_MD" > "$PROJECT_ROOT/$REPORT_PATH"
echo " wrote $PROJECT_ROOT/$REPORT_PATH"
if [[ -n "$JSON_REPORT_PATH" ]]; then
cp "$ALL_JSON" "$PROJECT_ROOT/$JSON_REPORT_PATH"
echo " wrote $PROJECT_ROOT/$JSON_REPORT_PATH"
fi
fi
# --- Summary + exit code ---
echo ""
echo "── Summary ──"
jq -r '
group_by(.severity)
| map({severity: .[0].severity, count: length})
| sort_by({"CRITICAL":0,"HIGH":1,"MEDIUM":2,"LOW":3}[.severity])
| .[]
| " \(.severity)\t\(.count)"
' "$ALL_JSON"
CRIT_HIGH_COUNT="$(jq '[ .[] | select(.severity == "CRITICAL" or .severity == "HIGH") ] | length' "$ALL_JSON")"
if [[ "$CRIT_HIGH_COUNT" -gt 0 ]]; then
echo ""
echo "EXIT 1: $CRIT_HIGH_COUNT CRITICAL or HIGH finding(s) — see report"
exit 1
fi
echo ""
echo "EXIT 0: no CRITICAL or HIGH findings"
#!/usr/bin/env bash
# collect-files.sh — Collect candidate files that import react-hook-form.
# Part of: react-hook-form-audit
# Output: JSON array of relative paths on stdout
set -euo pipefail
if [[ $# -ne 2 ]]; then
echo "Usage: $0 <project-root> <config-file>" >&2
exit 2
fi
PROJECT_ROOT="$1"
CONFIG_FILE="$2"
# Build ripgrep args from include/exclude globs.
RG_ARGS=(--files-with-matches --glob '!**/node_modules/**')
while IFS= read -r glob; do
RG_ARGS+=(--glob "$glob")
done < <(jq -r '.include_globs[]?' "$CONFIG_FILE")
while IFS= read -r glob; do
RG_ARGS+=(--glob "!$glob")
done < <(jq -r '.exclude_globs[]?' "$CONFIG_FILE")
# Match `from 'react-hook-form'` (handles single/double quotes and subpath imports)
PATTERN="from ['\"]react-hook-form(/|['\"])"
cd "$PROJECT_ROOT"
# rg exits 1 when no matches — convert to empty list so the pipeline continues.
# Pass `.` explicitly so rg searches the directory and never tries to read from stdin
# (which would hang inside command substitution).
MATCHES="$(rg "${RG_ARGS[@]}" "$PATTERN" . 2>/dev/null || true)"
# Strip leading ./ that rg prepends when searching `.` explicitly.
MATCHES="$(printf '%s' "$MATCHES" | sed 's|^\./||')"
if [[ -z "$MATCHES" ]]; then
echo "[]"
exit 0
fi
# Emit JSON array of relative paths.
printf '%s\n' "$MATCHES" | jq -R . | jq -s .
#!/usr/bin/env node
// detect-ast.mjs — Structural detectors via ts-morph AST analysis.
// Part of: react-hook-form-audit
//
// Implements:
// Rule 1 sub-usewatch-over-watch watch() in same component as useForm()
// Rule 2 sub-watch-specific-fields watch() with no args
// Rule 3 formcfg-default-values useForm without defaultValues
// Rule 4 formcfg-useeffect-dependency useEffect depending on useForm return
// Rule 6 ctrl-usecontroller-isolation <Controller> inlined in useForm parent
// Rule 7 formstate-async-submit-lifecycle async submit without try/catch
// Rule 8 valid-resolver-caching schema defined inside component
// Rule 9 valid-server-errors submit fetches but never setError('root.*')
// Rule 10 next-rhf-useactionstate-mix RHF + useActionState in same component
// Rule 12 formcfg-disabled-prop register({ disabled: <state> }) for visual disable
// Rule 13 array-use-field-id-as-key useFieldArray map missing field.id key
// Rule 15 sub-useformcontext-sparingly bare useFormContext() usage flagged
//
// Output: JSON array of findings on stdout.
import { resolve, dirname, relative } from 'node:path';
import { fileURLToPath } from 'node:url';
import { readFileSync, existsSync } from 'node:fs';
import { createRequire } from 'node:module';
// Load ts-morph from this script's local node_modules (installed by audit.sh).
const here = dirname(fileURLToPath(import.meta.url));
const require_ = createRequire(import.meta.url);
const tsMorphPath = resolve(here, 'node_modules', 'ts-morph');
if (!existsSync(tsMorphPath)) {
process.stderr.write(`ts-morph not found at ${tsMorphPath}. Run: cd "${here}" && npm install\n`);
process.exit(2);
}
const { Project, Node, SyntaxKind } = require_(tsMorphPath);
// --- CLI ---
const args = parseArgs(process.argv.slice(2));
if (!args.project || !args.files) {
process.stderr.write('Usage: detect-ast.mjs --project <root> --files <files.json>\n');
process.exit(2);
}
const projectRoot = resolve(args.project);
const filePaths = JSON.parse(readFileSync(args.files, 'utf8'));
function parseArgs(argv) {
const out = {};
for (let i = 0; i < argv.length; i++) {
const a = argv[i];
if (a === '--project') out.project = argv[++i];
else if (a === '--files') out.files = argv[++i];
}
return out;
}
// --- Project setup ---
const project = new Project({
useInMemoryFileSystem: false,
skipAddingFilesFromTsConfig: true,
skipFileDependencyResolution: true,
skipLoadingLibFiles: true,
compilerOptions: { allowJs: true, jsx: 4 /* Preserve */ },
});
for (const relPath of filePaths) {
const abs = resolve(projectRoot, relPath);
if (existsSync(abs)) project.addSourceFileAtPath(abs);
}
// --- Findings accumulator ---
const findings = [];
function addFinding({ rule, severity, message, file, node, snippetOverride }) {
const sf = node.getSourceFile();
const { line, column } = sf.getLineAndColumnAtPos(node.getStart());
const lineText = sf.getFullText().split(/\r?\n/)[line - 1] ?? '';
findings.push({
rule,
severity,
message,
file: relative(projectRoot, sf.getFilePath()),
line,
column,
snippet: (snippetOverride ?? lineText).trim(),
});
}
// --- Helpers ---
function getEnclosingComponentFn(node) {
let n = node.getParent();
while (n) {
if (
Node.isFunctionDeclaration(n) ||
Node.isArrowFunction(n) ||
Node.isFunctionExpression(n) ||
Node.isMethodDeclaration(n)
) {
return n;
}
n = n.getParent();
}
return null;
}
function getCallCalleeName(call) {
const expr = call.getExpression();
if (Node.isIdentifier(expr)) return expr.getText();
if (Node.isPropertyAccessExpression(expr)) return expr.getName();
return null;
}
function findCallsByName(scope, name) {
return scope
.getDescendantsOfKind(SyntaxKind.CallExpression)
.filter((c) => getCallCalleeName(c) === name);
}
function functionContainsAwaitOrAsync(fn) {
if (!fn) return false;
if (fn.isAsync && fn.isAsync()) return true;
return fn.getDescendantsOfKind(SyntaxKind.AwaitExpression).length > 0;
}
function functionHasTryCatch(fn) {
if (!fn) return false;
return fn.getDescendantsOfKind(SyntaxKind.TryStatement).length > 0;
}
function functionCallsSetErrorRoot(fn) {
if (!fn) return false;
const calls = findCallsByName(fn, 'setError');
return calls.some((call) => {
const arg0 = call.getArguments()[0];
if (!arg0) return false;
if (Node.isStringLiteral(arg0) || Node.isNoSubstitutionTemplateLiteral(arg0)) {
return arg0.getLiteralText().startsWith('root');
}
return false;
});
}
function functionCallsNetworkApi(fn) {
if (!fn) return false;
for (const call of fn.getDescendantsOfKind(SyntaxKind.CallExpression)) {
const name = getCallCalleeName(call);
if (name === 'fetch') return true;
if (call.getExpression().getText().match(/^(axios|api|http)(\.|$)/)) return true;
}
return false;
}
// --- Detectors per source file ---
for (const sf of project.getSourceFiles()) {
const useFormCalls = findCallsByName(sf, 'useForm');
if (useFormCalls.length === 0) continue;
for (const useFormCall of useFormCalls) {
const componentFn = getEnclosingComponentFn(useFormCall);
if (!componentFn) continue;
// --- Rule 3: useForm called without defaultValues option ---
const arg0 = useFormCall.getArguments()[0];
let hasDefaultValues = false;
if (arg0 && Node.isObjectLiteralExpression(arg0)) {
hasDefaultValues = arg0.getProperties().some((p) => {
if (Node.isPropertyAssignment(p) || Node.isShorthandPropertyAssignment(p)) {
return p.getName() === 'defaultValues';
}
return false;
});
}
if (!hasDefaultValues) {
addFinding({
rule: 'rhf-audit-03-missing-default-values',
severity: 'CRITICAL',
message: 'useForm() called without `defaultValues`. Uncontrolled fields become undefined, and reset() has nothing to reset to.',
node: useFormCall,
});
}
// --- Identifiers destructured from useForm (e.g. const { register, watch } = useForm()) ---
const destructuredNames = new Set();
const variableDecl = useFormCall.getFirstAncestorByKind(SyntaxKind.VariableDeclaration);
if (variableDecl) {
const nameNode = variableDecl.getNameNode();
if (Node.isObjectBindingPattern(nameNode)) {
for (const el of nameNode.getElements()) {
destructuredNames.add(el.getName());
}
}
}
// --- Rule 1 / 2: watch() in same component as useForm() ---
const watchCalls = findCallsByName(componentFn, 'watch');
for (const watchCall of watchCalls) {
const watchArgs = watchCall.getArguments();
if (watchArgs.length === 0) {
addFinding({
rule: 'rhf-audit-02-watch-all-fields',
severity: 'CRITICAL',
message: 'watch() called with no arguments — subscribes to every field. Re-renders the component on every keystroke anywhere in the form.',
node: watchCall,
});
} else {
addFinding({
rule: 'rhf-audit-01-watch-at-form-root',
severity: 'CRITICAL',
message: 'watch() called in the same component as useForm() — re-renders the entire form on every change. Use useWatch() in a child component instead.',
node: watchCall,
});
}
}
// --- Rule 6: <Controller> inlined inside the useForm parent component ---
const controllerEls = componentFn.getDescendantsOfKind(SyntaxKind.JsxOpeningElement)
.concat(componentFn.getDescendantsOfKind(SyntaxKind.JsxSelfClosingElement));
for (const el of controllerEls) {
const tag = el.getTagNameNode();
if (tag && tag.getText() === 'Controller') {
addFinding({
rule: 'rhf-audit-06-controller-inlined',
severity: 'HIGH',
message: '<Controller> rendered directly inside the component that calls useForm(). Move it into a child component so parent re-renders do not propagate to every controlled input.',
node: el,
});
}
}
// --- Rule 10: RHF + useActionState in the same component ---
const useActionStateCalls = findCallsByName(componentFn, 'useActionState');
for (const call of useActionStateCalls) {
addFinding({
rule: 'rhf-audit-10-rhf-with-useactionstate',
severity: 'HIGH',
message: 'useActionState() and useForm() in the same component. Pick one: react-hook-form for client-side validation OR useActionState for Server Action submission. Mixing leads to duplicated state and race conditions.',
node: call,
});
}
// --- Rule 8: schema defined inside the component ---
for (const call of componentFn.getDescendantsOfKind(SyntaxKind.CallExpression)) {
const exprText = call.getExpression().getText();
if (/^(z\.object|yup\.object|Joi\.object|valibot\.object|object)$/.test(exprText)) {
// Skip if the call is at module top level (we want only inside-component).
// Also skip if the call is a direct property of useForm's options.
if (call.getFirstAncestor((a) => a === componentFn)) {
addFinding({
rule: 'rhf-audit-08-schema-inside-component',
severity: 'HIGH',
message: 'Validation schema defined inside the component body — recreated on every render. Hoist it to module scope so the resolver caches it.',
node: call,
});
}
}
}
// --- Rule 4: useEffect depending on useForm return ---
const useEffectCalls = findCallsByName(componentFn, 'useEffect');
for (const eff of useEffectCalls) {
const depsArg = eff.getArguments()[1];
if (!depsArg || !Node.isArrayLiteralExpression(depsArg)) continue;
for (const dep of depsArg.getElements()) {
if (Node.isIdentifier(dep) && destructuredNames.has(dep.getText())) {
// Whitelisted: these are stable refs and meant to be deps.
if (['register', 'control', 'setValue', 'setError', 'clearErrors', 'reset', 'subscribe', 'trigger', 'unregister'].includes(dep.getText())) {
continue;
}
}
if (Node.isIdentifier(dep)) {
// Detect a dep that is literally the useForm return variable.
const declVarStmt = useFormCall.getFirstAncestorByKind(SyntaxKind.VariableStatement);
if (declVarStmt) {
const decls = declVarStmt.getDeclarations();
for (const d of decls) {
const nameNode = d.getNameNode();
if (Node.isIdentifier(nameNode) && nameNode.getText() === dep.getText()) {
addFinding({
rule: 'rhf-audit-04-useeffect-depends-useform',
severity: 'CRITICAL',
message: `useEffect lists '${dep.getText()}' (the useForm return) as a dependency. The form object is a new reference every render — this causes infinite re-runs. Destructure stable callbacks instead.`,
node: eff,
});
}
}
}
}
}
}
// --- Rule 7 / 9: async submit handler analysis ---
// Find handleSubmit(fn) calls — the inner fn is the user's submit handler.
const handleSubmitCalls = findCallsByName(componentFn, 'handleSubmit');
for (const hs of handleSubmitCalls) {
const submitFn = hs.getArguments()[0];
if (!submitFn) continue;
const isFnLike = Node.isArrowFunction(submitFn) || Node.isFunctionExpression(submitFn);
// Also handle the case where it's an identifier referring to a fn defined in the component.
let fnToInspect = isFnLike ? submitFn : null;
if (!fnToInspect && Node.isIdentifier(submitFn)) {
const name = submitFn.getText();
// Look for variable declaration with the same name inside componentFn.
for (const vd of componentFn.getDescendantsOfKind(SyntaxKind.VariableDeclaration)) {
if (vd.getNameNode().getText() === name) {
const init = vd.getInitializer();
if (init && (Node.isArrowFunction(init) || Node.isFunctionExpression(init))) {
fnToInspect = init;
break;
}
}
}
if (!fnToInspect) {
for (const fd of componentFn.getDescendantsOfKind(SyntaxKind.FunctionDeclaration)) {
if (fd.getName && fd.getName() === name) {
fnToInspect = fd;
break;
}
}
}
}
if (!fnToInspect) continue;
const isAsync = functionContainsAwaitOrAsync(fnToInspect);
const hasTryCatch = functionHasTryCatch(fnToInspect);
const callsNetwork = functionCallsNetworkApi(fnToInspect);
const callsSetErrorRoot = functionCallsSetErrorRoot(fnToInspect);
if (isAsync && !hasTryCatch) {
addFinding({
rule: 'rhf-audit-07-async-submit-no-trycatch',
severity: 'HIGH',
message: 'Async submit handler has no try/catch. If it throws, isSubmitting stays true and the form becomes unrecoverable. Wrap in try/catch and surface errors via setError().',
node: fnToInspect,
});
}
if (callsNetwork && !callsSetErrorRoot) {
addFinding({
rule: 'rhf-audit-09-no-server-error-setError',
severity: 'HIGH',
message: "Submit handler calls fetch/axios but never setError('root.serverError', ...). Server failures will be silently dropped. Route API errors through setError so they render.",
node: fnToInspect,
});
}
}
// --- Rule 12: register('name', { disabled: <state> }) — likely visual disable ---
const registerCalls = findCallsByName(componentFn, 'register');
for (const reg of registerCalls) {
const opts = reg.getArguments()[1];
if (!opts || !Node.isObjectLiteralExpression(opts)) continue;
const disabledProp = opts.getProperties().find((p) => {
if (!(Node.isPropertyAssignment(p) || Node.isShorthandPropertyAssignment(p))) return false;
return p.getName() === 'disabled';
});
if (!disabledProp) continue;
if (Node.isPropertyAssignment(disabledProp)) {
const initVal = disabledProp.getInitializer();
// Flag when the value isn't a literal true (intentional exclusion) but a reactive state.
// Heuristic: an Identifier, PropertyAccess, PrefixUnary on identifier, or BinaryExpression.
const kind = initVal?.getKind();
if (
kind === SyntaxKind.Identifier ||
kind === SyntaxKind.PropertyAccessExpression ||
kind === SyntaxKind.PrefixUnaryExpression ||
kind === SyntaxKind.BinaryExpression
) {
addFinding({
rule: 'rhf-audit-12-disabled-visual',
severity: 'MEDIUM',
message: "register({ disabled: <state> }) clears the field's value and skips validation. If you only want it greyed out, use the HTML `disabled` attribute on the input instead.",
node: disabledProp,
});
}
}
}
// --- Rule 13: useFieldArray map missing field.id key ---
const useFieldArrayCalls = findCallsByName(componentFn, 'useFieldArray');
for (const ufa of useFieldArrayCalls) {
// Find the `fields` identifier destructured from this call.
const vd = ufa.getFirstAncestorByKind(SyntaxKind.VariableDeclaration);
if (!vd) continue;
const nameNode = vd.getNameNode();
if (!Node.isObjectBindingPattern(nameNode)) continue;
const fieldsBinding = nameNode.getElements().find((el) => el.getName() === 'fields' || el.getPropertyNameNode()?.getText() === 'fields');
if (!fieldsBinding) continue;
const fieldsVar = fieldsBinding.getName();
// Find map() calls on the fields variable.
for (const callExpr of componentFn.getDescendantsOfKind(SyntaxKind.CallExpression)) {
const expr = callExpr.getExpression();
if (!Node.isPropertyAccessExpression(expr)) continue;
if (expr.getName() !== 'map') continue;
if (expr.getExpression().getText() !== fieldsVar) continue;
const mapCallback = callExpr.getArguments()[0];
if (!mapCallback || !(Node.isArrowFunction(mapCallback) || Node.isFunctionExpression(mapCallback))) continue;
// Inspect the returned JSX for a `key=` attribute.
const jsxOpenings = mapCallback.getDescendantsOfKind(SyntaxKind.JsxOpeningElement)
.concat(mapCallback.getDescendantsOfKind(SyntaxKind.JsxSelfClosingElement));
if (jsxOpenings.length === 0) continue;
const first = jsxOpenings[0];
const keyAttr = first.getAttributes().find((a) => {
if (a.getKind() !== SyntaxKind.JsxAttribute) return false;
const nameNode = a.getNameNode();
return nameNode && nameNode.getText() === 'key';
});
if (!keyAttr) {
addFinding({
rule: 'rhf-audit-13-fieldarray-no-field-id',
severity: 'MEDIUM',
message: `${fieldsVar}.map() renders JSX without a 'key' attribute. Use field.id as key — array index causes state corruption when items are added or reordered.`,
node: first,
});
} else {
const keyExpr = keyAttr.getInitializer();
if (keyExpr && Node.isJsxExpression(keyExpr)) {
const inner = keyExpr.getExpression();
const text = inner?.getText() ?? '';
if (!/\.id\b/.test(text)) {
addFinding({
rule: 'rhf-audit-13-fieldarray-no-field-id',
severity: 'MEDIUM',
message: `${fieldsVar}.map() uses key={${text}} instead of field.id. Array index keys cause state corruption when items are added or reordered.`,
node: keyAttr,
});
}
}
}
}
}
}
// --- Rule 15: useFormContext() usage flag (LOW — informational) ---
for (const call of findCallsByName(sf, 'useFormContext')) {
addFinding({
rule: 'rhf-audit-15-useformcontext',
severity: 'LOW',
message: 'useFormContext() found. Verify the consuming component is genuinely deep enough that prop drilling would be worse. Shallow uses add implicit coupling without payoff.',
node: call,
});
}
}
process.stdout.write(JSON.stringify(findings, null, 2));
#!/usr/bin/env bash
# detect-fast.sh — Ripgrep-based detectors for patterns visible at the line level.
# Part of: react-hook-form-audit
# Implements: rule 5 (non-use-client), rule 11 (onChange mode), rule 14 (reValidateMode: onBlur)
# Output: JSON array of findings on stdout
set -euo pipefail
if [[ $# -ne 2 ]]; then
echo "Usage: $0 <project-root> <files-json>" >&2
exit 2
fi
PROJECT_ROOT="$1"
FILES_JSON="$2"
cd "$PROJECT_ROOT"
# Build a temporary "filter to candidate files" list for rg.
FILE_LIST="$(mktemp -t rhf-audit-flist.XXXXXX)"
trap 'rm -f "$FILE_LIST"' EXIT
jq -r '.[]' "$FILES_JSON" > "$FILE_LIST"
# Empty-array shortcut.
if [[ ! -s "$FILE_LIST" ]]; then
echo "[]"
exit 0
fi
# emit_findings runs ripgrep with JSON output and converts to our finding shape.
# Args: <rule-id> <severity> <message> <pattern>
emit_findings() {
local rule="$1" severity="$2" message="$3" pattern="$4"
# rg --json emits one JSON object per match line.
# rg exits 1 when there are no matches; tolerate that with `|| true`.
while IFS= read -r file; do
[[ -z "$file" ]] && continue
(rg --json -nP "$pattern" "$file" 2>/dev/null || true) | \
jq -c --arg rule "$rule" --arg severity "$severity" --arg message "$message" '
select(.type == "match")
| {
rule: $rule,
severity: $severity,
message: $message,
file: .data.path.text,
line: .data.line_number,
column: ((.data.submatches[0].start // 0) + 1),
snippet: (.data.lines.text | rtrimstr("\n"))
}
'
done < "$FILE_LIST"
}
# --- Rule 5: RHF imported in non-"use client" file (Next.js App Router) ---
# Detect any candidate file whose first 5 non-empty lines do NOT include "use client".
# This is best-effort — Pages Router files don't need "use client", so the report
# should call this out as a heuristic that matters only for App Router.
rule_use_client() {
local file
while IFS= read -r file; do
[[ -z "$file" ]] && continue
# Look only at the first 10 lines for the directive.
if ! head -n 10 "$file" 2>/dev/null | rg -q "^[[:space:]]*['\"]use client['\"]"; then
# Emit a synthetic finding pointing at line 1.
jq -nc --arg file "$file" --arg rule "rhf-audit-05-non-use-client" --arg severity "CRITICAL" \
--arg message "File imports react-hook-form but is missing \"use client\" directive. RHF hooks only work in client components." \
--arg snippet "$(head -n 1 "$file" 2>/dev/null || echo '')" \
'{
rule: $rule, severity: $severity, message: $message,
file: $file, line: 1, column: 1, snippet: $snippet
}'
fi
done < "$FILE_LIST"
}
{
rule_use_client
emit_findings \
"rhf-audit-11-onchange-mode" \
"MEDIUM" \
"useForm uses mode: 'onChange' — re-validates on every keystroke. Confirm real-time feedback is genuinely needed; consider 'onSubmit' or 'onBlur' otherwise." \
"mode:\s*['\"]onChange['\"]"
emit_findings \
"rhf-audit-14-revalidate-onblur" \
"LOW" \
"reValidateMode: 'onBlur' overrides the recommended default. Verify the form has expensive validation that justifies switching from onChange." \
"reValidateMode:\s*['\"]onBlur['\"]"
} | jq -s .
#!/usr/bin/env bash
# detect-project.sh — Verify project is a Next.js codebase using react-hook-form.
# Part of: react-hook-form-audit
set -euo pipefail
if [[ $# -ne 1 ]]; then
echo "Usage: $0 <project-root>" >&2
exit 2
fi
PROJECT_ROOT="$1"
PKG_JSON="$PROJECT_ROOT/package.json"
if [[ ! -f "$PKG_JSON" ]]; then
echo " ERROR: $PKG_JSON not found. Is this a Node.js project root?" >&2
exit 2
fi
# Check for Next.js
if ! jq -e '
(.dependencies // {}) + (.devDependencies // {})
| has("next")
' "$PKG_JSON" >/dev/null; then
echo " ERROR: 'next' not found in package.json dependencies." >&2
echo " This skill audits Next.js App Router projects specifically." >&2
echo " For non-Next.js React projects, run the detectors against your source manually." >&2
exit 2
fi
# Check for react-hook-form
if ! jq -e '
(.dependencies // {}) + (.devDependencies // {})
| has("react-hook-form")
' "$PKG_JSON" >/dev/null; then
echo " ERROR: 'react-hook-form' not found in package.json dependencies." >&2
echo " Install with: npm install react-hook-form" >&2
exit 2
fi
NEXT_VER="$(jq -r '(.dependencies.next // .devDependencies.next // "unknown")' "$PKG_JSON")"
RHF_VER="$(jq -r '(.dependencies."react-hook-form" // .devDependencies."react-hook-form" // "unknown")' "$PKG_JSON")"
echo " Next.js: $NEXT_VER"
echo " react-hook-form: $RHF_VER"
# Detect App Router vs Pages Router
HAS_APP_DIR=0
[[ -d "$PROJECT_ROOT/app" || -d "$PROJECT_ROOT/src/app" ]] && HAS_APP_DIR=1
HAS_PAGES_DIR=0
[[ -d "$PROJECT_ROOT/pages" || -d "$PROJECT_ROOT/src/pages" ]] && HAS_PAGES_DIR=1
if [[ "$HAS_APP_DIR" -eq 1 ]]; then
echo " Router: App Router detected (use-client checks active)"
elif [[ "$HAS_PAGES_DIR" -eq 1 ]]; then
echo " Router: Pages Router detected (use-client checks will be skipped)"
else
echo " Router: unknown (no app/ or pages/ directory found at root or src/)"
fi
{
"name": "react-hook-form-audit-scripts",
"version": "0.1.0",
"private": true,
"type": "module",
"description": "Internal dependencies for the react-hook-form-audit skill scripts. Installed on first run by audit.sh.",
"dependencies": {
"ts-morph": "^23.0.0"
}
}
#!/usr/bin/env node
// render-report.mjs — Render the JSON findings as a markdown audit report.
// Part of: react-hook-form-audit
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
const args = parseArgs(process.argv.slice(2));
if (!args.findings || !args.project) {
process.stderr.write('Usage: render-report.mjs --findings <findings.json> --project <root> [--rule-link-base <url-or-path>]\n');
process.exit(2);
}
function parseArgs(argv) {
const out = {};
for (let i = 0; i < argv.length; i++) {
const a = argv[i];
if (a === '--findings') out.findings = argv[++i];
else if (a === '--project') out.project = argv[++i];
else if (a === '--rule-link-base') out.ruleLinkBase = argv[++i];
}
return out;
}
const findings = JSON.parse(readFileSync(resolve(args.findings), 'utf8'));
const SEVERITY_ORDER = { CRITICAL: 0, HIGH: 1, MEDIUM: 2, LOW: 3 };
// Map audit rule IDs back to the distillation skill's rule filenames.
const RULE_TO_FILE = {
'rhf-audit-01-watch-at-form-root': 'sub-usewatch-over-watch',
'rhf-audit-02-watch-all-fields': 'sub-watch-specific-fields',
'rhf-audit-03-missing-default-values': 'formcfg-default-values',
'rhf-audit-04-useeffect-depends-useform': 'formcfg-useeffect-dependency',
'rhf-audit-05-non-use-client': null, // Next.js-specific; no companion rule
'rhf-audit-06-controller-inlined': 'ctrl-usecontroller-isolation',
'rhf-audit-07-async-submit-no-trycatch': 'formstate-async-submit-lifecycle',
'rhf-audit-08-schema-inside-component': 'valid-resolver-caching',
'rhf-audit-09-no-server-error-setError': 'valid-server-errors',
'rhf-audit-10-rhf-with-useactionstate': null, // Next.js-specific
'rhf-audit-11-onchange-mode': 'formcfg-validation-mode',
'rhf-audit-12-disabled-visual': 'formcfg-disabled-prop',
'rhf-audit-13-fieldarray-no-field-id': 'array-use-field-id-as-key',
'rhf-audit-14-revalidate-onblur': 'formcfg-revalidate-mode',
'rhf-audit-15-useformcontext': 'sub-useformcontext-sparingly',
};
function ruleLink(ruleId) {
const fileSlug = RULE_TO_FILE[ruleId];
if (!fileSlug) return null;
const base = args.ruleLinkBase ?? '';
if (!base) return null;
return `${base.replace(/\/$/, '')}/${fileSlug}.md`;
}
// --- Sort + group ---
findings.sort((a, b) => {
const s = SEVERITY_ORDER[a.severity] - SEVERITY_ORDER[b.severity];
if (s !== 0) return s;
const f = a.file.localeCompare(b.file);
if (f !== 0) return f;
return a.line - b.line;
});
const bySeverity = { CRITICAL: [], HIGH: [], MEDIUM: [], LOW: [] };
for (const f of findings) {
(bySeverity[f.severity] ?? bySeverity.LOW).push(f);
}
// --- Counts by rule, for the summary table ---
const ruleCounts = new Map();
for (const f of findings) {
ruleCounts.set(f.rule, (ruleCounts.get(f.rule) ?? 0) + 1);
}
// --- Render ---
const now = new Date().toISOString().slice(0, 19).replace('T', ' ');
const total = findings.length;
let out = '';
out += `# React Hook Form Audit Report\n\n`;
out += `**Project:** \`${args.project}\` \n`;
out += `**Generated:** ${now} \n`;
out += `**Total findings:** ${total}\n\n`;
out += `## Summary\n\n`;
out += `| Severity | Count |\n|----------|-------|\n`;
for (const sev of ['CRITICAL', 'HIGH', 'MEDIUM', 'LOW']) {
out += `| ${sev} | ${bySeverity[sev].length} |\n`;
}
out += '\n';
if (ruleCounts.size > 0) {
out += `### By Rule\n\n`;
out += `| Rule | Count | Companion rule |\n|------|-------|----------------|\n`;
const sortedRules = [...ruleCounts.entries()].sort((a, b) => b[1] - a[1]);
for (const [rule, count] of sortedRules) {
const link = ruleLink(rule);
const companion = link ? `[${RULE_TO_FILE[rule]}](${link})` : '—';
out += `| \`${rule}\` | ${count} | ${companion} |\n`;
}
out += '\n';
}
for (const sev of ['CRITICAL', 'HIGH', 'MEDIUM', 'LOW']) {
const items = bySeverity[sev];
if (items.length === 0) continue;
out += `## ${sev} (${items.length})\n\n`;
for (const f of items) {
const loc = `\`${f.file}:${f.line}:${f.column}\``;
out += `### ${loc}\n\n`;
out += `**Rule:** \`${f.rule}\``;
const link = ruleLink(f.rule);
if (link) {
out += ` — see [${RULE_TO_FILE[f.rule]}](${link})`;
}
out += `\n\n`;
out += `${f.message}\n\n`;
if (f.snippet) {
out += '```\n' + f.snippet + '\n```\n\n';
}
}
}
if (total === 0) {
out += `_No React Hook Form anti-patterns detected._\n`;
}
process.stdout.write(out);
Related skills
FAQ
What does react-hook-form-audit do?
react-hook-form-audit is a Claude Code skill for frontend development. It helps developers move faster with AI-assisted coding.
When should I use react-hook-form-audit?
When you need to helps with frontend development tasks during ai-assisted development, or when react-hook-form-audit is a claude code skill for frontend development. it helps developers move faster with ai-assisted coding.
What are the main capabilities?
react-hook-form-audit; Frontend Development; AI-coding skill.