
Nuqs Codemod Runner
- 80 installs
- 191 repo stars
- Updated July 24, 2026
- pproenca/dot-skills
nuqs-codemod-runner is a Claude Code skill for ai & agent building. It helps solo builders move faster with AI-assisted coding.
Key points
- nuqs-codemod-runner
- AI & Agent Building
- AI-coding skill
Nuqs Codemod Runner by the numbers
- 80 all-time installs (skills.sh)
- +7 installs in the week ending Aug 4, 2026 (Skillselion tracking)
- Ranked #5,222 of 16,546 AI & Agent Building 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 nuqs-codemod-runnerAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 80 |
|---|---|
| repo stars | ★ 191 |
| Last updated | July 24, 2026 |
| Repository | pproenca/dot-skills ↗ |
How do I helps with ai & agent building tasks during ai-assisted development?
Helps with ai & agent building tasks during AI-assisted development.
Who is it for?
Best when you're working on ai & agent building and need structured help with nuqs-codemod-runner.
Skip if: Teams with no ai & agent building needs, or anyone wanting a generic chat assistant without this specific workflow.
When should I use this skill?
When you need to helps with ai & agent building tasks during ai-assisted development, or when nuqs-codemod-runner is a claude code skill for ai & agent building. it helps solo builders move faster with ai-assisted coding.
What you get
Structured output aligned to nuqs-codemod-runner: nuqs-codemod-runner; AI & Agent Building; AI-coding skill.
Files
nuqs Codemod Runner
Migrate a codebase from pre-v2.5 nuqs patterns to current v2.5–v2.8 idioms. The skill is read-only by default — it never modifies files without an explicit confirmation step, and refuses to run on a dirty git working tree.
When to Apply
Trigger this skill when:
- A user asks to "upgrade nuqs" or "migrate to nuqs 2.x" in a project that already uses nuqs
- You spot any of the 5 legacy patterns during a review (see the codemod table below)
- A
nuqsversion bump inpackage.jsonfrom<2.5to>=2.5is part of the diff - The user has just installed/updated the `nuqs` skill and wants to bring existing code into line with it
This skill is the automation companion to skills/.curated/nuqs/ — that skill teaches what each correct pattern looks like; this skill rewrites legacy code to match.
Workflow Overview
┌──────────┐ ┌────────────┐ ┌──────────────────┐ ┌───────────┐ ┌────────────┐
│ scan.sh │──▶ │ scan.json │──▶ │ report.sh │──▶ │ apply.sh │──▶ │ verify.sh │
└──────────┘ └────────────┘ │ (dry-run table) │ │ (codemods)│ │ (tsc+lint) │
└──────────────────┘ └───────────┘ └────────────┘
│ ▲ │
▼ │ ▼
user reviews ──────────── confirms on fail: revert1. `scripts/scan.sh <repo-root>` — ripgreps every .ts/.tsx/.js/.jsx file for the 5 legacy patterns, emits scan.json with {path, line, matchedPattern, snippet, suggestion}. 2. `scripts/report.sh` — renders scan.json as a markdown table grouped by codemod, with file paths and before/after pairs. Show this output to the user verbatim and ask for explicit confirmation before continuing. 3. `scripts/apply.sh [--filter <codemod-id>]` — runs the jscodeshift transforms in scripts/transforms/. Refuses to run unless a fresh scan.json exists for the current git HEAD and the working tree is clean (--allow-dirty overrides). 4. `scripts/verify.sh` — runs tsc --noEmit and npm run lint (or the equivalent commands from config.json). If anything fails, git restore reverts every file the codemod touched.
Run all four sequentially. If the user only wants to scan, stop at step 2.
The Five Codemods
| ID | Detects | Rewrites to |
|---|---|---|
throttle-ms | withOptions({ throttleMs: N }) or setX(v, { throttleMs: N }) | withOptions({ limitUrlUpdates: throttle(N) }) + adds throttle to the nuqs import |
manual-debounce | useState mirror + useEffect + setTimeout debounce around a useQueryState setter | withOptions({ limitUrlUpdates: debounce(N) }) + adds debounce to the nuqs import; deletes the mirror state and effect |
unchecked-json-cast | parseAsJson<T>() (no validator) or parseAsJson((v) => v as T) | Inserts a // TODO: validate type-guard stub or, if Zod is detected in package.json, a parseAsJson(SchemaName.parse) form |
react-router-unversioned | from 'nuqs/adapters/react-router' | from 'nuqs/adapters/react-router/v6' (the alias the unversioned import historically pointed at) |
parser-builder-type | type references to ParserBuilder<T> from nuqs | SingleParserBuilder<T> |
See `references/workflow.md` for the exact AST shapes each codemod matches and the edge cases it deliberately skips.
Setup
On first run, config.json is populated with the user's lint/typecheck commands and a default min_node_version. See _setup_instructions in config.json for what to fill in.
Risk & Guardrails
This skill is write-risk (it modifies source files). Guardrails:
apply.shaborts if the working tree is dirty (usegit stashfirst, or pass--allow-dirtyafter reading `gotchas.md`)apply.shaborts ifscan.jsonis older than 60 minutes or was generated against a differentgit HEAD— re-runscan.sh- The
hooks/hooks.jsonPreToolUse matcher blocks any directnode scripts/transforms/*.jsinvocation that bypasses the orchestrator verify.shauto-reverts on TS or lint failure viagit restoreover the touched files (recorded inlast-run.json)
Related Skills
- `nuqs` — Best-practice reference for nuqs v2.5+. This skill rewrites legacy code; that skill defines the target.
Gotchas
See `gotchas.md` for failure modes discovered during use.
nuqs Codemod Runner
This curated skill mirrors SKILL.md. When maintaining it, keep the workflow centered on scanning, dry-run reporting, confirmation, AST codemods, and TypeScript/lint verification.
{
"typecheck_command": "npx tsc --noEmit",
"lint_command": "npm run lint",
"package_manager": "npm",
"min_node_version": "18",
"include_globs": ["src/**/*.{ts,tsx,js,jsx}", "app/**/*.{ts,tsx,js,jsx}"],
"exclude_globs": ["**/node_modules/**", "**/.next/**", "**/dist/**", "**/build/**"],
"scan_max_age_minutes": 60,
"_setup_instructions": {
"typecheck_command": "Command that exits non-zero when types break. Default works for most repos; override for monorepos (e.g., `pnpm -r typecheck`).",
"lint_command": "Command that exits non-zero when lint fails. Use `:` (no-op) if the repo has no lint script.",
"package_manager": "One of: npm, pnpm, yarn, bun. Used to detect Zod/typescript availability.",
"min_node_version": "Minimum Node version your codemods will run under. jscodeshift needs ≥18.",
"include_globs": "File patterns to scan. Restrict to source directories for big monorepos.",
"exclude_globs": "Patterns to skip. Always include node_modules and build output dirs.",
"scan_max_age_minutes": "How fresh scan.json must be before apply.sh will run. Lower for fast-moving branches."
}
}
Gotchas
Failure modes discovered while using this skill. Append-only, with dates.
---
No known gotchas yet — this is a fresh skill. Add entries here as real-world runs surface edge cases.
Template
### {one-line title of the failure}
{What goes wrong. Be specific — name the file pattern, the codemod, the symptom.}
Fix: {What to do instead, or how to recover.}
Added: {YYYY-MM-DD}{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "bash ${CLAUDE_PLUGIN_ROOT}/scripts/guardrail.sh",
"timeout": 5
}
]
}
]
}
}
{
"version": "1.0.5",
"organization": "Community",
"technology": "nuqs",
"discipline": "composition",
"type": "automation",
"date": "May 2026",
"abstract": "Read-first codemod runner that scans a repository for pre-nuqs-2.5 patterns (deprecated `throttleMs`, manual `setTimeout` debounce, unchecked `parseAsJson` casts, unversioned `react-router` adapter import, `ParserBuilder<T>` type references), produces a dry-run report for user review, then applies five jscodeshift transforms gated by `tsc --noEmit` + the project's lint. Auto-reverts on verification failure.",
"references": [
"https://nuqs.dev/blog/nuqs-2.5",
"https://nuqs.dev/docs/options",
"https://github.com/facebook/jscodeshift",
"https://ts-morph.com"
]
}
nuqs-codemod-runner Workflow
Detailed step-by-step reference for the four-stage pipeline (scan → report → apply → verify).
---
Stage 1: scan.sh <repo-root>
Reads config.json for include/exclude globs, runs ripgrep with the five pattern regexes, and emits scan.json.
Inputs:
<repo-root>(absolute path; the repo must containpackage.jsonwithnuqsdeclared)
Outputs:
$CLAUDE_PLUGIN_DATA/scan.json(falls back to skill root ifCLAUDE_PLUGIN_DATAis unset)- Shape:
{
"meta": {
"repoRoot": "/abs/path",
"gitHead": "sha",
"scannedAt": "2026-05-11T12:34:56Z",
"nuqsVersion": "^2.4.0"
},
"matches": [
{ "codemod": "throttle-ms", "path": "src/Foo.tsx", "line": 42, "snippet": "..." }
]
}Failure modes:
ripgreporjqnot installed → exits 1 with install hintnuqsnot inpackage.json→ exits 2 (the agent should treat this as "nothing to do")
The `manual-debounce` heuristic: the ripgrep pattern matches setTimeout(() => setX(...)) across many shapes, including legitimate non-nuqs uses. After the initial scan, we re-filter the candidate file list to only files that ALSO contain useQueryState(, dropping any false-positive file. The transform itself does a second, stricter AST check.
---
Stage 2: report.sh [scan-file]
Renders scan.json as markdown. The agent should show the output to the user verbatim and explicitly ask "Apply these changes?" before continuing.
Shape of the report:
- Header with repo, git HEAD, scan time, declared nuqs version, total match count
- One section per codemod, each with:
- A one-line description of what the codemod does (and which nuqs version made the old pattern obsolete)
- A table of
(file, line, snippet)rows, snippet truncated to 120 chars
Zero-match case: report prints "Nothing to migrate" and exits 0.
---
Stage 3: apply.sh [--filter <codemod-id>] [--allow-dirty]
Refuses to run unless: 1. scan.json exists at the expected path 2. meta.gitHead matches the current git rev-parse HEAD 3. meta.scannedAt is younger than scan_max_age_minutes (default 60) 4. Working tree is clean (git diff --quiet HEAD) — overridden by --allow-dirty
Then, for each codemod with matches, runs:
npx --yes jscodeshift@latest \
--transform scripts/transforms/<codemod>.js \
--parser tsx --extensions=ts,tsx,js,jsx --no-babel --print=false --run-in-band \
<every absolute file path with a match for this codemod>Order of transforms: apply.sh runs them in jq sort order (alphabetical by codemod ID). This is intentional — the transforms are independent: each one touches a different AST shape, so order doesn't matter. If a future transform overlaps, sequence them explicitly here.
Output: $CLAUDE_PLUGIN_DATA/last-run.json
{
"repoRoot": "/abs/path",
"gitHeadAtApply": "sha",
"appliedAt": "2026-05-11T12:40:00Z",
"touchedFiles": ["src/Foo.tsx", "src/Bar.ts"]
}`CODEMOD_HAS_ZOD` environment variable: the unchecked-json-cast transform produces a friendlier output if Zod is already a dep. apply.sh doesn't set this automatically yet — to opt in, run:
CODEMOD_HAS_ZOD=$(jq -e '(.dependencies.zod // .devDependencies.zod)' "$REPO_ROOT/package.json" >/dev/null && echo 1 || echo 0) \
scripts/apply.sh---
Stage 4: verify.sh
Runs the two commands from config.json: 1. typecheck_command (default: npx tsc --noEmit) 2. lint_command (default: npm run lint)
On success: prints "All checks passed" and exits 0. The user reviews the diff and commits manually.
On failure: 1. Logs are preserved at $CLAUDE_PLUGIN_DATA/verify-logs/<label>-<timestamp>.log 2. git restore is run against every file in last-run.json:touchedFiles 3. Exits 1 with a pointer to the logs
The auto-revert assumes the working tree was clean at apply time. If --allow-dirty was used, the user's prior uncommitted changes to a touched file will also be reverted — that's why the dirty-tree guard exists.
---
Per-Codemod Transform Notes
throttle-ms
- Replaces every
throttleMs: <number>key in an object literal passed to.withOptions({...})or asetX(value, {...})setter call. throttleMs: 0becomeslimitUrlUpdates: defaultRateLimit(the documented opt-out value).- Adds
throttleand/ordefaultRateLimitto the existingnuqsimport. If there is nonuqsimport in the file (e.g. it re-exports from a barrel), the transform logs a warning to stderr and leaves the import alone —tscwill flag the missing symbol duringverify.sh.
manual-debounce
- Pattern-matches the exact three-statement trio (mirror
useState+ syncuseEffect+ timeruseEffect). If your codebase uses a different shape — e.g. lodashdebouncein auseMemo, or a custom hook — the transform skips the file and leaves the match inscan.jsonfor manual review. - Extracts the debounce delay from the
setTimeout(_, N)literal. Defaults to 300 if the delay is non-literal.
unchecked-json-cast
- Does NOT generate a working validator (we can't know your shape). Instead it inserts a TODO marker and a placeholder that fails type-checking, so
verify.shimmediately flags every spot that needs your attention. - This is intentional — silent rewrites of validation code are dangerous. The transform's job is to land you in a failing
tscstate with clear TODOs, not to ship working code.
react-router-unversioned
- Always rewrites to
/v6(the alias the unversioned import historically pointed at). If your project is actually on React Router v7,verify.shwill fail on the adapter type mismatch — re-run with the v7 path by hand.
parser-builder-type
- Only renames identifiers that came from the
nuqsimport. If the import is aliased (import { ParserBuilder as PB } from 'nuqs'), the transform renames the imported name but leaves the local alias untouched.
---
Recovery & Rollback
- If
apply.shruns but you change your mind beforeverify.sh:git restore -- <files from last-run.json> - If
verify.shsucceeds but you spot a bad rewrite:git restorethe specific file, then re-runapply.sh --filter <codemod-id>to retry without that file's pattern. - If something goes badly wrong:
git reset --hard HEADwill restore to the apply-time HEAD recorded inlast-run.json.
#!/usr/bin/env bash
# apply.sh — Apply jscodeshift codemods to files flagged in scan.json.
# Part of: nuqs-codemod-runner
#
# Refuses to run if:
# - scan.json is missing or older than $scan_max_age_minutes (from config.json)
# - working tree is dirty (override with --allow-dirty)
# - scan.json's gitHead differs from current HEAD
#
# On success: writes last-run.json with the list of touched files (used by verify.sh for rollback).
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
SKILL_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
CONFIG_FILE="$SKILL_ROOT/config.json"
DATA_DIR="${CLAUDE_PLUGIN_DATA:-$SKILL_ROOT}"
SCAN_FILE="$DATA_DIR/scan.json"
LAST_RUN_FILE="$DATA_DIR/last-run.json"
ALLOW_DIRTY=0
FILTER=""
while [[ $# -gt 0 ]]; do
case "$1" in
--allow-dirty) ALLOW_DIRTY=1; shift ;;
--filter) FILTER="$2"; shift 2 ;;
-h|--help)
echo "Usage: $0 [--filter <codemod-id>] [--allow-dirty]"
exit 0
;;
*) echo "Unknown arg: $1" >&2; exit 1 ;;
esac
done
# --- Pre-flight ---
for tool in jq npx git; do
if ! command -v "$tool" >/dev/null 2>&1; then
echo "Error: '$tool' is not installed." >&2
exit 1
fi
done
if [[ ! -f "$SCAN_FILE" ]]; then
echo "Error: $SCAN_FILE not found. Run 'scripts/scan.sh <repo>' first." >&2
exit 1
fi
REPO_ROOT=$(jq -r '.meta.repoRoot' "$SCAN_FILE")
SCAN_HEAD=$(jq -r '.meta.gitHead' "$SCAN_FILE")
SCAN_AT=$(jq -r '.meta.scannedAt' "$SCAN_FILE")
MAX_AGE_MIN=$(jq -r '.scan_max_age_minutes' "$CONFIG_FILE")
cd "$REPO_ROOT"
CURRENT_HEAD=$(git rev-parse HEAD 2>/dev/null || echo "no-git")
if [[ "$SCAN_HEAD" != "$CURRENT_HEAD" ]]; then
echo "Error: scan.json was generated against git HEAD $SCAN_HEAD," >&2
echo "but current HEAD is $CURRENT_HEAD. Re-run scan.sh." >&2
exit 1
fi
# Staleness check (portable: use python for date math since `date -d` is GNU-only)
AGE_MIN=$(python3 -c "
import sys, datetime
scan = datetime.datetime.fromisoformat('$SCAN_AT'.replace('Z', '+00:00'))
now = datetime.datetime.now(datetime.timezone.utc)
print(int((now - scan).total_seconds() // 60))
")
if (( AGE_MIN > MAX_AGE_MIN )); then
echo "Error: scan.json is $AGE_MIN min old (max: $MAX_AGE_MIN). Re-run scan.sh." >&2
exit 1
fi
# Dirty tree guard
if [[ $ALLOW_DIRTY -eq 0 ]] && ! git diff --quiet HEAD 2>/dev/null; then
echo "Error: working tree is dirty. Commit or stash first, or pass --allow-dirty." >&2
echo "See gotchas.md before using --allow-dirty." >&2
exit 1
fi
# --- Collect target files per codemod ---
get_files_for() {
local codemod="$1"
jq -r --arg c "$codemod" '[.matches[] | select(.codemod == $c) | .path] | unique | .[]' "$SCAN_FILE"
}
mapfile -t CODEMODS < <(jq -r '[.matches[].codemod] | unique | .[]' "$SCAN_FILE")
if [[ -n "$FILTER" ]]; then
# shellcheck disable=SC2076
if [[ ! " ${CODEMODS[*]} " =~ " $FILTER " ]]; then
echo "Error: --filter '$FILTER' has no matches in scan.json. Available: ${CODEMODS[*]}" >&2
exit 1
fi
CODEMODS=("$FILTER")
fi
# --- Locate jscodeshift (npx will install if missing) ---
JSCODESHIFT="npx --yes jscodeshift@latest"
# --- Apply each transform ---
TOUCHED=()
for codemod in "${CODEMODS[@]}"; do
TRANSFORM="$SKILL_ROOT/scripts/transforms/$codemod.js"
if [[ ! -f "$TRANSFORM" ]]; then
echo "Skipping '$codemod': no transform at $TRANSFORM" >&2
continue
fi
mapfile -t FILES < <(get_files_for "$codemod")
if [[ ${#FILES[@]} -eq 0 ]]; then
continue
fi
echo
echo "→ $codemod (${#FILES[@]} file(s))"
# Make file paths absolute so jscodeshift can find them regardless of cwd
ABS_FILES=()
for f in "${FILES[@]}"; do ABS_FILES+=("$REPO_ROOT/$f"); done
$JSCODESHIFT \
--transform "$TRANSFORM" \
--parser tsx \
--extensions=ts,tsx,js,jsx \
--no-babel \
--print=false \
--run-in-band \
"${ABS_FILES[@]}"
TOUCHED+=("${FILES[@]}")
done
# --- Record what was touched, for verify.sh rollback ---
TOUCHED_UNIQUE=$(printf "%s\n" "${TOUCHED[@]}" | sort -u | jq -R . | jq -s .)
jq -n --argjson files "$TOUCHED_UNIQUE" \
--arg repo "$REPO_ROOT" \
--arg head "$CURRENT_HEAD" \
--arg ts "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
'{ repoRoot: $repo, gitHeadAtApply: $head, appliedAt: $ts, touchedFiles: $files }' \
> "$LAST_RUN_FILE"
echo
echo "Applied. $(echo "$TOUCHED_UNIQUE" | jq 'length') file(s) modified."
echo "Run 'scripts/verify.sh' to typecheck + lint."
#!/usr/bin/env bash
# guardrail.sh — PreToolUse hook for nuqs-codemod-runner.
# Blocks direct jscodeshift invocations against transforms/ that bypass apply.sh.
#
# Reads $TOOL_INPUT (the bash command being run). Exits 0 to allow, non-zero with a
# message on stderr to block.
set -euo pipefail
CMD="${TOOL_INPUT:-}"
# Allow apply.sh and scan.sh to run jscodeshift internally
if [[ "$CMD" == *"scripts/apply.sh"* ]] || [[ "$CMD" == *"scripts/scan.sh"* ]]; then
exit 0
fi
# Block bare `jscodeshift ... transforms/*.js` invocations
if [[ "$CMD" == *"jscodeshift"* ]] && [[ "$CMD" == *"transforms/"* ]]; then
echo "Blocked: run codemods via scripts/apply.sh, not jscodeshift directly." >&2
echo " apply.sh enforces the pre-flight checks (clean tree, fresh scan, matching git HEAD)" >&2
echo " that protect you from losing work." >&2
exit 2
fi
exit 0
#!/usr/bin/env bash
# report.sh — Render scan.json as a human-readable dry-run report.
# Part of: nuqs-codemod-runner
#
# Output: markdown to stdout. The orchestrating agent shows this to the user verbatim
# and asks for explicit confirmation before invoking apply.sh.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
SKILL_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
DATA_DIR="${CLAUDE_PLUGIN_DATA:-$SKILL_ROOT}"
SCAN_FILE="${1:-$DATA_DIR/scan.json}"
if [[ ! -f "$SCAN_FILE" ]]; then
echo "Error: scan file '$SCAN_FILE' not found." >&2
echo "Run 'scripts/scan.sh <repo-root>' first." >&2
exit 1
fi
if ! command -v jq >/dev/null 2>&1; then
echo "Error: 'jq' is not installed." >&2
exit 1
fi
# --- Header ---
REPO=$(jq -r '.meta.repoRoot' "$SCAN_FILE")
HEAD=$(jq -r '.meta.gitHead' "$SCAN_FILE")
SCANNED=$(jq -r '.meta.scannedAt' "$SCAN_FILE")
NUQS_VER=$(jq -r '.meta.nuqsVersion' "$SCAN_FILE")
TOTAL=$(jq '.matches | length' "$SCAN_FILE")
cat <<HEADER
# nuqs Codemod — Dry-Run Report
- **Repo:** \`$REPO\`
- **Git HEAD:** \`$HEAD\`
- **Scanned at:** \`$SCANNED\`
- **Declared nuqs version:** \`$NUQS_VER\`
- **Total matches:** **$TOTAL**
HEADER
if [[ "$TOTAL" -eq 0 ]]; then
echo "Nothing to migrate. You're already on current nuqs idioms."
exit 0
fi
# --- Per-codemod sections ---
mapfile -t CODEMODS < <(jq -r '[.matches[].codemod] | unique | .[]' "$SCAN_FILE")
declare -A DESCRIPTIONS=(
[throttle-ms]="Replace deprecated \`throttleMs: N\` with \`limitUrlUpdates: throttle(N)\` and add the \`throttle\` import. (nuqs v2.5)"
[manual-debounce]="Replace hand-rolled setTimeout/useState debounce around a nuqs setter with built-in \`limitUrlUpdates: debounce(N)\`. (nuqs v2.5)"
[unchecked-json-cast]="\`parseAsJson\` requires a runtime validator. Unchecked casts (or no argument) let attacker-controlled URLs into your app. Inserts a type-guard stub or Standard Schema bridge."
[react-router-unversioned]="Pin the React Router adapter version explicitly. The unversioned \`nuqs/adapters/react-router\` import is removed in nuqs v3."
[parser-builder-type]="\`ParserBuilder<T>\` was renamed to \`SingleParserBuilder<T>\` in nuqs v2.7. The old name is deprecated and will be removed."
)
for codemod in "${CODEMODS[@]}"; do
COUNT=$(jq --arg c "$codemod" '[.matches[] | select(.codemod == $c)] | length' "$SCAN_FILE")
echo
echo "## \`$codemod\` — $COUNT match(es)"
echo
echo "${DESCRIPTIONS[$codemod]:-No description.}"
echo
echo "| File | Line | Snippet |"
echo "|------|------|---------|"
jq -r --arg c "$codemod" '
.matches[]
| select(.codemod == $c)
| "| `\(.path)` | \(.line) | `\(.snippet | gsub("\\|"; "\\|") | .[0:120])` |"
' "$SCAN_FILE"
done
cat <<FOOTER
---
## Next Steps
1. Review the table(s) above.
2. To apply ALL codemods, run: \`scripts/apply.sh\`
3. To apply just one, run: \`scripts/apply.sh --filter <codemod-id>\` (e.g. \`--filter throttle-ms\`)
4. \`apply.sh\` will refuse to run if the working tree is dirty. Commit or stash first.
After \`apply.sh\` succeeds, \`verify.sh\` runs typecheck + lint automatically and reverts on failure.
FOOTER
#!/usr/bin/env bash
# scan.sh — Scan a repository for pre-nuqs-2.5 patterns.
# Part of: nuqs-codemod-runner
#
# Output: scan.json (in $SKILL_DATA_DIR or pwd) — array of {codemod, path, line, snippet}.
# Exit codes: 0 = scan complete (any number of matches), 1 = scan error, 2 = no nuqs in package.json.
set -euo pipefail
# --- Configuration ---
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
SKILL_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
CONFIG_FILE="$SKILL_ROOT/config.json"
DATA_DIR="${CLAUDE_PLUGIN_DATA:-$SKILL_ROOT}"
OUT_FILE="$DATA_DIR/scan.json"
# --- Input validation ---
if [[ $# -lt 1 ]]; then
echo "Usage: $0 <repo-root>" >&2
echo "Scans <repo-root> for pre-nuqs-2.5 patterns and writes scan.json." >&2
exit 1
fi
REPO_ROOT="$1"
if [[ ! -d "$REPO_ROOT" ]]; then
echo "Error: <repo-root> '$REPO_ROOT' is not a directory." >&2
exit 1
fi
if ! command -v rg >/dev/null 2>&1; then
echo "Error: ripgrep ('rg') is not installed. Install it (brew install ripgrep) and rerun." >&2
exit 1
fi
if ! command -v jq >/dev/null 2>&1; then
echo "Error: 'jq' is not installed. Install it (brew install jq) and rerun." >&2
exit 1
fi
# --- Verify nuqs is a declared dep before doing anything ---
PKG_JSON="$REPO_ROOT/package.json"
if [[ ! -f "$PKG_JSON" ]] || ! jq -e '(.dependencies.nuqs // .devDependencies.nuqs) != null' "$PKG_JSON" >/dev/null 2>&1; then
echo "No 'nuqs' dependency found in $PKG_JSON — nothing to migrate." >&2
exit 2
fi
NUQS_VERSION=$(jq -r '.dependencies.nuqs // .devDependencies.nuqs // "unknown"' "$PKG_JSON")
# --- Build ripgrep --glob args from config ---
mapfile -t INCLUDE_GLOBS < <(jq -r '.include_globs[]' "$CONFIG_FILE")
mapfile -t EXCLUDE_GLOBS < <(jq -r '.exclude_globs[]' "$CONFIG_FILE")
RG_ARGS=(--json --multiline)
for g in "${INCLUDE_GLOBS[@]}"; do RG_ARGS+=(--glob "$g"); done
for g in "${EXCLUDE_GLOBS[@]}"; do RG_ARGS+=(--glob "!$g"); done
# --- Pattern catalog ---
# Keyed by codemod ID → regex (PCRE2). Multiline patterns enabled.
declare -A PATTERNS=(
[throttle-ms]='\bthrottleMs\s*:\s*\d+'
[react-router-unversioned]="from\\s*['\"]nuqs/adapters/react-router['\"]"
[parser-builder-type]='\bParserBuilder\s*<'
[unchecked-json-cast]='parseAsJson\s*(?:<[^>]+>)?\s*\(\s*(?:\)|\([a-zA-Z_$][\w$]*\)\s*=>\s*\w+\s+as\s+)'
# The manual-debounce regex is intentionally loose — final classification happens in the transform.
# Heuristic: a useState mirror near a useQueryState setter + a setTimeout call in the same file.
[manual-debounce]='setTimeout\s*\(\s*\(\s*\)\s*=>\s*set[A-Z]\w+\s*\('
)
# --- Run ripgrep for each pattern, build JSON ---
TMP_JSON=$(mktemp)
trap 'rm -f "$TMP_JSON"' EXIT
echo '[]' > "$TMP_JSON"
for codemod in "${!PATTERNS[@]}"; do
pattern="${PATTERNS[$codemod]}"
# ripgrep --json emits one JSON object per match (type:"match"); we reshape with jq.
rg "${RG_ARGS[@]}" -e "$pattern" "$REPO_ROOT" 2>/dev/null \
| jq --arg codemod "$codemod" --arg root "$REPO_ROOT" -c '
select(.type == "match")
| {
codemod: $codemod,
path: (.data.path.text | sub("^" + $root + "/"; "")),
line: .data.line_number,
snippet: (.data.lines.text | rtrimstr("\n"))
}
' \
| jq -s --slurpfile prev "$TMP_JSON" '. as $new | ($prev[0] + $new)' > "${TMP_JSON}.next"
mv "${TMP_JSON}.next" "$TMP_JSON"
done
# --- For manual-debounce, the regex over-matches: filter to files that ALSO contain a useQueryState call.
# We do this in jq by listing the candidate paths and reading them.
CANDIDATES=$(jq -r '.[] | select(.codemod == "manual-debounce") | .path' "$TMP_JSON" | sort -u)
KEEP_PATHS=()
while IFS= read -r rel_path; do
[[ -z "$rel_path" ]] && continue
if rg -q 'useQueryState\s*\(' "$REPO_ROOT/$rel_path" 2>/dev/null; then
KEEP_PATHS+=("$rel_path")
fi
done <<< "$CANDIDATES"
# Pass the keep list to jq as a JSON array (--argjson). Writing plain-text paths and
# reading them with --slurpfile is invalid JSON and crashes jq whenever a path is kept.
KEEP_JSON=$(printf '%s\n' "${KEEP_PATHS[@]:-}" | jq -R . | jq -s 'map(select(length > 0))')
jq --argjson keep "$KEEP_JSON" '
map(
if .codemod == "manual-debounce"
then (if (.path as $p | $keep | index($p)) then . else empty end)
else . end
)
' "$TMP_JSON" > "${TMP_JSON}.filtered"
mv "${TMP_JSON}.filtered" "$TMP_JSON"
# --- Attach metadata for staleness check in apply.sh ---
GIT_HEAD=$(cd "$REPO_ROOT" && git rev-parse HEAD 2>/dev/null || echo "no-git")
TIMESTAMP=$(date -u +%Y-%m-%dT%H:%M:%SZ)
jq --arg head "$GIT_HEAD" \
--arg ts "$TIMESTAMP" \
--arg root "$REPO_ROOT" \
--arg nuqs "$NUQS_VERSION" \
'{ meta: { repoRoot: $root, gitHead: $head, scannedAt: $ts, nuqsVersion: $nuqs }, matches: . }' \
"$TMP_JSON" > "$OUT_FILE"
TOTAL=$(jq '.matches | length' "$OUT_FILE")
echo "Scan complete: $TOTAL match(es) across $(jq '[.matches[].codemod] | unique | length' "$OUT_FILE") codemod(s)"
echo "Wrote: $OUT_FILE"
echo "Run 'scripts/report.sh' to render a human-readable dry-run."
/**
* manual-debounce.js — Replace hand-rolled setTimeout/useState debounce around a nuqs setter.
*
* Recognised shape (the version produced by Copilot/ChatGPT before nuqs v2.5 debounce existed):
*
* const [inputValue, setInputValue] = useState(query)
* useEffect(() => { setInputValue(query) }, [query])
* useEffect(() => {
* const t = setTimeout(() => { if (inputValue !== query) setQuery(inputValue || null) }, 300)
* return () => clearTimeout(t)
* }, [inputValue, query, setQuery])
*
* Rewrites to: add `limitUrlUpdates: debounce(<ms>)` to the existing `withOptions(...)` (or
* insert one) on the matching `useQueryState` call. Removes the mirror state and both
* effects. Adds `debounce` to the `nuqs` import.
*
* This transform is intentionally conservative: it skips any file where the mirror/effects
* don't match exactly. Skipped files remain in scan.json so the user can review and adjust by hand.
*/
module.exports = function (file, api) {
const j = api.jscodeshift;
const root = j(file.source);
let touched = false;
let needDebounce = false;
// For each useQueryState call we find, look at the surrounding scope for the debounce pattern.
root
.find(j.CallExpression, { callee: { name: 'useQueryState' } })
.forEach((useQSPath) => {
const fnPath = findEnclosingFunctionBody(useQSPath);
if (!fnPath) return;
const body = fnPath.node.body;
if (!body || !Array.isArray(body)) return;
// Find the destructured setter name from `const [x, setX] = useQueryState(...)`
const varDecl = useQSPath.parent.parent.node; // VariableDeclarator → VariableDeclaration
if (varDecl.type !== 'VariableDeclaration') return;
const declarator = varDecl.declarations.find(
(d) => d.init === useQSPath.node
);
if (!declarator || declarator.id.type !== 'ArrayPattern') return;
const setterEl = declarator.id.elements[1];
const stateEl = declarator.id.elements[0];
if (!setterEl || !stateEl || setterEl.type !== 'Identifier') return;
const setterName = setterEl.name;
const stateName = stateEl.type === 'Identifier' ? stateEl.name : null;
if (!stateName) return;
// Locate the debounce trio in the same block.
const trio = matchDebounceTrio(j, body, setterName, stateName);
if (!trio) return;
// Splice the trio out of the body.
const indices = new Set([trio.mirrorIdx, trio.syncEffectIdx, trio.timerEffectIdx]);
fnPath.node.body = body.filter((_, i) => !indices.has(i));
// Add `limitUrlUpdates: debounce(<ms>)` to the parser chain.
addDebounceOption(j, useQSPath, trio.delayMs);
needDebounce = true;
touched = true;
});
if (!touched) return null;
// Ensure `debounce` is imported from nuqs.
root
.find(j.ImportDeclaration, { source: { value: 'nuqs' } })
.forEach((p) => {
const already = p.node.specifiers.some(
(s) => s.type === 'ImportSpecifier' && s.imported.name === 'debounce'
);
if (!already && needDebounce) {
p.node.specifiers.push(j.importSpecifier(j.identifier('debounce')));
}
});
return root.toSource({ quote: 'single' });
};
function findEnclosingFunctionBody(path) {
let cur = path;
while (cur) {
const t = cur.node && cur.node.type;
if (
t === 'FunctionDeclaration' ||
t === 'FunctionExpression' ||
t === 'ArrowFunctionExpression'
) {
if (cur.node.body && cur.node.body.type === 'BlockStatement') {
return { node: cur.node.body };
}
return null;
}
cur = cur.parent;
}
return null;
}
function matchDebounceTrio(j, statements, setterName, stateName) {
// Find the mirror useState declaration: const [inputValue, setInputValue] = useState(<state>)
let mirrorIdx = -1;
let mirrorVarName = null;
let mirrorSetterName = null;
for (let i = 0; i < statements.length; i++) {
const stmt = statements[i];
if (stmt.type !== 'VariableDeclaration') continue;
const d = stmt.declarations[0];
if (!d || d.id.type !== 'ArrayPattern' || !d.init) continue;
if (
d.init.type !== 'CallExpression' ||
d.init.callee.name !== 'useState'
)
continue;
const initArg = d.init.arguments[0];
if (!initArg || initArg.type !== 'Identifier' || initArg.name !== stateName)
continue;
mirrorIdx = i;
mirrorVarName = d.id.elements[0].name;
mirrorSetterName = d.id.elements[1].name;
break;
}
if (mirrorIdx < 0) return null;
// Find the sync useEffect: useEffect(() => { setInputValue(<state>) }, [<state>])
let syncEffectIdx = -1;
for (let i = 0; i < statements.length; i++) {
if (i === mirrorIdx) continue;
const stmt = statements[i];
if (
stmt.type !== 'ExpressionStatement' ||
stmt.expression.type !== 'CallExpression' ||
stmt.expression.callee.name !== 'useEffect'
)
continue;
const [cb, deps] = stmt.expression.arguments;
if (!cb || cb.type !== 'ArrowFunctionExpression') continue;
const cbBody = cb.body.type === 'BlockStatement' ? cb.body.body : [];
const ok = cbBody.some(
(s) =>
s.type === 'ExpressionStatement' &&
s.expression.type === 'CallExpression' &&
s.expression.callee.name === mirrorSetterName &&
s.expression.arguments[0] &&
s.expression.arguments[0].type === 'Identifier' &&
s.expression.arguments[0].name === stateName
);
if (ok) {
syncEffectIdx = i;
break;
}
}
// Find the timer useEffect: contains setTimeout(() => ..setterName(<mirrorVar>)..)
let timerEffectIdx = -1;
let delayMs = 300;
for (let i = 0; i < statements.length; i++) {
if (i === mirrorIdx || i === syncEffectIdx) continue;
const stmt = statements[i];
if (
stmt.type !== 'ExpressionStatement' ||
stmt.expression.type !== 'CallExpression' ||
stmt.expression.callee.name !== 'useEffect'
)
continue;
const cb = stmt.expression.arguments[0];
if (!cb || cb.type !== 'ArrowFunctionExpression') continue;
const cbBody = cb.body.type === 'BlockStatement' ? cb.body.body : [];
let foundSetTimeout = false;
cbBody.forEach((s) => {
if (
s.type === 'VariableDeclaration' &&
s.declarations[0] &&
s.declarations[0].init &&
s.declarations[0].init.type === 'CallExpression' &&
s.declarations[0].init.callee.name === 'setTimeout'
) {
const stArgs = s.declarations[0].init.arguments;
const stCb = stArgs[0];
const delayArg = stArgs[1];
if (delayArg && delayArg.type === 'Literal' && typeof delayArg.value === 'number') {
delayMs = delayArg.value;
}
// Verify the callback inside setTimeout calls the nuqs setter
if (stCb && stCb.body) {
const inner = stCb.body.type === 'BlockStatement' ? stCb.body.body : [stCb.body];
const calls = j(inner).find(j.CallExpression, {
callee: { name: setterName },
});
if (calls.size() > 0) foundSetTimeout = true;
}
}
});
if (foundSetTimeout) {
timerEffectIdx = i;
break;
}
}
if (syncEffectIdx < 0 || timerEffectIdx < 0) return null;
return { mirrorIdx, syncEffectIdx, timerEffectIdx, delayMs };
}
function addDebounceOption(j, useQSPath, ms) {
// useQueryState('q', parser.withOptions({ ... }))
// We want to add { limitUrlUpdates: debounce(ms) } to the existing withOptions object,
// or wrap the second arg with a fresh .withOptions({...}) if absent.
const args = useQSPath.node.arguments;
if (args.length < 2) return;
const parserArg = args[1];
if (
parserArg.type === 'CallExpression' &&
parserArg.callee.type === 'MemberExpression' &&
parserArg.callee.property.name === 'withOptions' &&
parserArg.arguments[0] &&
parserArg.arguments[0].type === 'ObjectExpression'
) {
parserArg.arguments[0].properties.push(
j.property(
'init',
j.identifier('limitUrlUpdates'),
j.callExpression(j.identifier('debounce'), [j.literal(ms)])
)
);
} else {
args[1] = j.callExpression(
j.memberExpression(parserArg, j.identifier('withOptions')),
[
j.objectExpression([
j.property(
'init',
j.identifier('limitUrlUpdates'),
j.callExpression(j.identifier('debounce'), [j.literal(ms)])
),
]),
]
);
}
}
module.exports.parser = 'tsx';
/**
* parser-builder-type.js — Rename ParserBuilder<T> imports/refs to SingleParserBuilder<T>.
*
* Detects:
* import { ParserBuilder, type ParserBuilder } from 'nuqs'
* const p: ParserBuilder<number> = ...
* function make(): ParserBuilder<T> { ... }
*
* Rewrites to SingleParserBuilder in all positions. Only touches identifiers that came
* from a 'nuqs' import (we ignore unrelated ParserBuilder names elsewhere).
*/
module.exports = function (file, api) {
const j = api.jscodeshift;
const root = j(file.source);
// 1. Find the 'nuqs' import and check whether it brings in ParserBuilder.
const nuqsImports = root.find(j.ImportDeclaration, {
source: { value: 'nuqs' },
});
let importedAs = null; // local name (handles `import { ParserBuilder as PB } from 'nuqs'`)
nuqsImports.forEach((path) => {
path.node.specifiers.forEach((spec) => {
if (
spec.type === 'ImportSpecifier' &&
spec.imported.name === 'ParserBuilder'
) {
importedAs = spec.local ? spec.local.name : spec.imported.name;
// Rename the imported name itself
spec.imported = j.identifier('SingleParserBuilder');
if (spec.local && spec.local.name === 'ParserBuilder') {
spec.local = j.identifier('SingleParserBuilder');
}
}
});
});
if (!importedAs) return null;
// 2. Rename every TS reference to the imported local name within this file.
const localName = importedAs;
const targetName =
localName === 'ParserBuilder' ? 'SingleParserBuilder' : localName;
if (localName === 'ParserBuilder') {
// Identifier rename — covers TSTypeReference, generic args, etc.
root.find(j.Identifier, { name: 'ParserBuilder' }).forEach((path) => {
// Skip the ImportSpecifier we already rewrote above
if (
path.parent.node.type === 'ImportSpecifier' &&
path.parent.node.imported &&
path.parent.node.imported.name === 'SingleParserBuilder'
)
return;
path.node.name = 'SingleParserBuilder';
});
}
// If it was aliased (`import { ParserBuilder as PB } from 'nuqs'`), the local
// name PB is already correct in the rest of the file — no further rename needed.
return root.toSource({ quote: 'single' });
};
module.exports.parser = 'tsx';
/**
* react-router-unversioned.js — Pin nuqs/adapters/react-router to /v6.
*
* Detects:
* import { NuqsAdapter } from 'nuqs/adapters/react-router'
*
* Rewrites to:
* import { NuqsAdapter } from 'nuqs/adapters/react-router/v6'
*
* Rationale: the unversioned alias historically pointed at v6 and is removed in nuqs v3.
* If the project is actually on React Router v7, the typecheck will catch the wrong adapter
* and we'll re-run with /v7 by hand.
*/
module.exports = function (file, api) {
const j = api.jscodeshift;
const root = j(file.source);
let touched = false;
root
.find(j.ImportDeclaration, {
source: { value: 'nuqs/adapters/react-router' },
})
.forEach((path) => {
path.node.source = j.literal('nuqs/adapters/react-router/v6');
touched = true;
});
return touched ? root.toSource({ quote: 'single' }) : null;
};
module.exports.parser = 'tsx';
/**
* throttle-ms.js — Replace deprecated `throttleMs: N` with `limitUrlUpdates: throttle(N)`.
*
* Detects:
* .withOptions({ throttleMs: 300 })
* useQueryState('q', parser.withOptions({ throttleMs: 100 }))
* setQuery('v', { throttleMs: 0 }) // per-call override
*
* Rewrites to:
* .withOptions({ limitUrlUpdates: throttle(300) })
* setQuery('v', { limitUrlUpdates: defaultRateLimit }) // when throttleMs was 0
*
* Also ensures `throttle` (or `defaultRateLimit`) is imported from `nuqs`.
*
* Intentionally skipped:
* - throttleMs keys outside of an object passed to withOptions/setter call
* (validated via parent-shape check)
*/
module.exports = function (file, api) {
const j = api.jscodeshift;
const root = j(file.source);
let touched = false;
let needThrottle = false;
let needDefaultRateLimit = false;
// --- Find Property nodes with key.name === 'throttleMs' inside an ObjectExpression
// that is itself an argument to .withOptions(...) or to a setter call.
root
.find(j.Property, { key: { name: 'throttleMs' } })
.forEach((path) => {
const objExpr = path.parent.node;
if (objExpr.type !== 'ObjectExpression') return;
const objExprPath = path.parent;
const callExpr = objExprPath.parent.node;
if (!callExpr || callExpr.type !== 'CallExpression') return;
// Accept: foo.withOptions({...}) OR setX('v', { throttleMs: 0 })
const isWithOptions =
callExpr.callee &&
callExpr.callee.type === 'MemberExpression' &&
callExpr.callee.property &&
callExpr.callee.property.name === 'withOptions';
const isSetterCall =
callExpr.callee &&
callExpr.callee.type === 'Identifier' &&
/^set[A-Z]/.test(callExpr.callee.name) &&
callExpr.arguments.length >= 2 &&
callExpr.arguments[callExpr.arguments.length - 1] === objExpr;
if (!isWithOptions && !isSetterCall) return;
const valueNode = path.node.value;
const isZero =
valueNode.type === 'Literal' && valueNode.value === 0;
path.node.key = j.identifier('limitUrlUpdates');
if (isZero) {
path.node.value = j.identifier('defaultRateLimit');
needDefaultRateLimit = true;
} else {
path.node.value = j.callExpression(j.identifier('throttle'), [valueNode]);
needThrottle = true;
}
touched = true;
});
if (!touched) return null;
// --- Ensure imports from 'nuqs' include the new helpers.
const nuqsImport = root.find(j.ImportDeclaration, {
source: { value: 'nuqs' },
});
const addSpecifier = (decl, name) => {
const already = decl.specifiers.some(
(s) => s.type === 'ImportSpecifier' && s.imported.name === name
);
if (!already) {
decl.specifiers.push(j.importSpecifier(j.identifier(name)));
}
};
if (nuqsImport.size() > 0) {
nuqsImport.forEach((p) => {
if (needThrottle) addSpecifier(p.node, 'throttle');
if (needDefaultRateLimit) addSpecifier(p.node, 'defaultRateLimit');
});
} else {
// No existing 'nuqs' import — bail rather than guess where to insert.
// The user's lint/typecheck will flag this clearly.
console.warn(
`[throttle-ms] ${file.path}: rewrote throttleMs but found no 'nuqs' import to extend. Add: import { throttle } from 'nuqs'`
);
}
return root.toSource({ quote: 'single' });
};
module.exports.parser = 'tsx';
/**
* unchecked-json-cast.js — Flag parseAsJson() calls that lack a real validator.
*
* Two forms are unsafe:
* (a) parseAsJson<T>() — no argument; in nuqs v2 this is a TS error,
* but some codebases @ts-ignore it.
* (b) parseAsJson((v) => v as T) — unchecked cast disguised as a validator.
*
* This transform doesn't generate a complete validator (we can't know the user's intent),
* but it inserts a TODO marker and either:
* - replaces the body with a `null` placeholder so the file stops compiling cleanly
* (forcing the user to write a real guard), OR
* - if Zod is detected in the project's package.json (passed via env CODEMOD_HAS_ZOD=1),
* inserts a `__TODO_ZodSchema__.parse` placeholder for the user to wire up.
*
* Either way, the resulting file fails typecheck, surfacing the change clearly during
* verify.sh and pinpointing where the user must intervene.
*/
module.exports = function (file, api) {
const j = api.jscodeshift;
const root = j(file.source);
const hasZod = process.env.CODEMOD_HAS_ZOD === '1';
let touched = false;
root
.find(j.CallExpression, { callee: { name: 'parseAsJson' } })
.forEach((path) => {
const node = path.node;
const args = node.arguments;
// Case (a): no arguments
if (args.length === 0) {
node.arguments = [
hasZod
? j.memberExpression(
j.identifier('__TODO_ZodSchema__'),
j.identifier('parse')
)
: buildTodoGuard(j),
];
addLeadingComment(
j,
path,
' TODO(nuqs-codemod): parseAsJson requires a runtime validator. Replace this stub.'
);
touched = true;
return;
}
// Case (b): a single arrow `(v) => v as T`
const first = args[0];
if (
first &&
first.type === 'ArrowFunctionExpression' &&
first.body &&
first.body.type === 'TSAsExpression'
) {
node.arguments[0] = hasZod
? j.memberExpression(
j.identifier('__TODO_ZodSchema__'),
j.identifier('parse')
)
: buildTodoGuard(j);
addLeadingComment(
j,
path,
' TODO(nuqs-codemod): replaced unchecked cast — write a real type guard or Standard Schema.'
);
touched = true;
}
});
return touched ? root.toSource({ quote: 'single' }) : null;
};
function buildTodoGuard(j) {
// (value: unknown) => (null as never) — placeholder that won't compile, forcing attention.
return j.arrowFunctionExpression(
[
Object.assign(j.identifier('value'), {
typeAnnotation: j.tsTypeAnnotation(j.tsUnknownKeyword()),
}),
],
j.tsAsExpression(j.nullLiteral(), j.tsNeverKeyword())
);
}
function addLeadingComment(j, path, text) {
const stmt = findEnclosingStatement(path);
if (!stmt || !stmt.node) return;
const comment = j.commentLine(text, true, false);
stmt.node.comments = (stmt.node.comments || []).concat([comment]);
}
function findEnclosingStatement(path) {
let cur = path;
while (cur && cur.node && !/Statement$|^Program$/.test(cur.node.type)) {
cur = cur.parent;
}
return cur;
}
module.exports.parser = 'tsx';
#!/usr/bin/env bash
# verify.sh — Run typecheck + lint against the codemodded tree, revert on failure.
# Part of: nuqs-codemod-runner
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
SKILL_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
CONFIG_FILE="$SKILL_ROOT/config.json"
DATA_DIR="${CLAUDE_PLUGIN_DATA:-$SKILL_ROOT}"
LAST_RUN_FILE="$DATA_DIR/last-run.json"
if [[ ! -f "$LAST_RUN_FILE" ]]; then
echo "Error: $LAST_RUN_FILE not found. Run 'scripts/apply.sh' first." >&2
exit 1
fi
REPO_ROOT=$(jq -r '.repoRoot' "$LAST_RUN_FILE")
TYPECHECK=$(jq -r '.typecheck_command' "$CONFIG_FILE")
LINT=$(jq -r '.lint_command' "$CONFIG_FILE")
cd "$REPO_ROOT"
PASS=0
FAIL=0
LOG_DIR="$DATA_DIR/verify-logs"
mkdir -p "$LOG_DIR"
TS=$(date -u +%Y%m%dT%H%M%SZ)
run_check() {
local label="$1" cmd="$2" log="$LOG_DIR/${label}-${TS}.log"
echo "→ $label: $cmd"
if eval "$cmd" >"$log" 2>&1; then
echo " PASS: $label"
PASS=$((PASS + 1))
else
echo " FAIL: $label (see $log)"
FAIL=$((FAIL + 1))
fi
}
run_check "typecheck" "$TYPECHECK"
run_check "lint" "$LINT"
echo
echo "Results: $PASS passed, $FAIL failed"
if (( FAIL > 0 )); then
echo
echo "Verification failed — reverting touched files via 'git restore'." >&2
mapfile -t TOUCHED < <(jq -r '.touchedFiles[]' "$LAST_RUN_FILE")
if [[ ${#TOUCHED[@]} -gt 0 ]]; then
git restore -- "${TOUCHED[@]}"
echo "Reverted ${#TOUCHED[@]} file(s)." >&2
fi
echo "Logs preserved in $LOG_DIR." >&2
exit 1
fi
echo "All checks passed. Review the diff and commit when ready."
Related skills
FAQ
What does nuqs-codemod-runner do?
nuqs-codemod-runner is a Claude Code skill for ai & agent building. It helps developers move faster with AI-assisted coding.
When should I use nuqs-codemod-runner?
When you need to helps with ai & agent building tasks during ai-assisted development, or when nuqs-codemod-runner is a claude code skill for ai & agent building. it helps developers move faster with ai-assisted coding.
What are the main capabilities?
nuqs-codemod-runner; AI & Agent Building; AI-coding skill.