Skip to content

perf(hooks): optimize formatter hooks(x52 faster) — local binary, merged invocations, direct require()#359

Open
pythonstrup wants to merge 6 commits intoaffaan-m:mainfrom
pythonstrup:feat/optimize-biome-hooks
Open

perf(hooks): optimize formatter hooks(x52 faster) — local binary, merged invocations, direct require()#359
pythonstrup wants to merge 6 commits intoaffaan-m:mainfrom
pythonstrup:feat/optimize-biome-hooks

Conversation

@pythonstrup
Copy link
Contributor

@pythonstrup pythonstrup commented Mar 8, 2026

Description

Optimize the post-edit-format and quality-gate hooks for significantly faster execution by eliminating npx overhead, merging redundant Biome invocations, and reducing the process spawn chain.

Benchmark Results

Metric Before (npx) After (local binary) Improvement
Biome format per Edit ~3,300ms ~63ms ~52x faster
Biome duplicate check (quality-gate) ~3,300ms 0ms (skipped) eliminated
Process spawn chain 3 (run-with-flags → node → npx → biome) 1 (run-with-flags → biome) -2 spawns

Changes

  1. Shared resolve-formatter.js utility (scripts/lib/resolve-formatter.js — new)

    • Extracts findProjectRoot(), detectFormatter(), resolveFormatterBin() into a shared module
    • Caches results per-process to avoid redundant filesystem lookups
    • Resolves local node_modules/.bin/biome or node_modules/.bin/prettier binary first, falls back to npx
  2. Eliminate npx overhead (scripts/hooks/post-edit-format.js)

    • Uses local binary directly instead of npx (~200-500ms savings per invocation)
    • Changes biome format --writebiome check --write (format + lint in one pass)
    • Exports run() function for direct invocation
  3. Skip redundant quality-gate checks (scripts/hooks/quality-gate.js)

    • Skips Biome check for JS/TS files already handled by post-edit-format
    • Still runs Biome for .json/.md files
    • Fixes bug: uses findProjectRoot() instead of process.cwd() for config detection
    • Adds timeout: 15000 to prevent hanging
  4. Direct require() invocation (scripts/hooks/run-with-flags.js)

    • When hook exports run(), calls it directly via require() instead of spawnSync('node', ...)
    • Falls back to legacy spawnSync for hooks without run() export
    • Adds path traversal guard before require()
    • Adds timeout: 30000 to legacy spawnSync path
    • Logs require() errors to stderr instead of silently swallowing

Type of Change

  • feat: New feature
  • fix: Bug fix

Testing

Unit tests:

  • 15 new tests for resolve-formatter.js — all pass
  • 190 existing hook tests — all pass
  • 23 integration tests — all pass

Live E2E tests with real projects:

Project Formatter Quotes Semicolons Indent Result
Biome (tab + double quote + semi) Biome local " yes ✓ tab ✓ PASS
Prettier (4-space + single quote + no semi) Prettier local ' no ✓ 4-space ✓ PASS
No formatter none unchanged ✓ unchanged ✓ unchanged ✓ PASS

26 comprehensive test cases covering:

  • Happy paths: Biome/Prettier local binary, npx fallback, 3 prettier config formats
  • Edge cases: non-JS/TS files, missing files, empty files, paths with spaces, monorepo nested package.json, syntax errors, malformed JSON input
  • Security: path traversal, shell metacharacter injection

Checklist

  • Tests pass locally (node tests/run-all.js)
  • Validation scripts pass
  • Follows conventional commits format
  • Backwards compatible — Prettier fallback and stdin-based entry points preserved

Summary by cubic

Speed up formatter hooks by using local binaries, merging biome runs, and calling hooks via direct require(). Format+lint after edits now runs ~52x faster (≈3300ms → ~63ms), and the duplicate biome check in the quality gate is removed.

  • Refactors

    • Added scripts/lib/resolve-formatter.js with caching to find project root, detect biome/prettier (incl. package.json “prettier” key), and resolve local node_modules/.bin before npx.
    • Updated post-edit-format and quality-gate: use local bins, merge biome into a single check --write post-edit; skip JS/TS in quality gate; still check .json/.md; normalize file paths to absolute; both export run() for direct invocation.
    • run-with-flags: directly invokes hooks that export run(); legacy hooks fall back to spawn.
  • Bug Fixes

    • quality-gate: correct root detection with findProjectRoot(), resolve file paths to absolute, add a 15s timeout, and improve strict-mode gofmt logging with failure detection.
    • run-with-flags: add path traversal guard; only require() hooks that export run(); separate require() and run() error handling, log require() errors to stderr, write input back on run() errors, and add a 30s timeout for the legacy spawn path.

Written for commit 3abd3bf. Summary will update on new commits.

Summary by CodeRabbit

  • New Features

    • Formatter-aware flows: automatic detection and resolution of Biome or Prettier with check/write modes; quality gate now supports Go (gofmt) and Python (Ruff) tooling.
    • Hooks can run in-process for faster execution while retaining legacy CLI behavior.
  • Refactor

    • Centralized formatter resolution and unified command execution for hooks.
    • Added path traversal guard for secure script loading.
  • Tests

    • Comprehensive tests for formatter detection, resolution, caching, and fallbacks.

Extract project-root discovery, formatter detection, and binary
resolution into a reusable module. Caches results per-process to
avoid redundant filesystem lookups on every Edit hook invocation.

This is the foundation for eliminating npx overhead in format hooks.
- Use local node_modules/.bin/biome binary instead of npx (~200-500ms savings)
- Change post-edit-format from `biome format --write` to `biome check --write`
  (format + lint in one pass)
- Skip redundant biome check in quality-gate for JS/TS files already
  handled by post-edit-format
- Fix quality-gate to use findProjectRoot instead of process.cwd()
- Export run() function from both hooks for direct invocation
- Update tests to match shared resolve-formatter module usage
When a hook script exports a run(rawInput) function, invoke it
directly via require() instead of spawnSync('node', ...). This
eliminates one Node.js process spawn per hook invocation (~50-100ms).

Hooks without a run() export fall back to the legacy spawnSync path,
maintaining full backwards compatibility.
Address code review findings:
- Add path containment check before require() in run-with-flags.js
  to prevent path traversal outside plugin root
- Add timeout (15s) to quality-gate.js exec() wrapper
- Add timeout (30s) to run-with-flags.js legacy spawnSync path
- Log require() errors to stderr instead of silently swallowing
@coderabbitai
Copy link
Contributor

coderabbitai bot commented Mar 8, 2026

📝 Walkthrough

Walkthrough

Centralizes formatter detection and binary resolution into a new utility and refactors hook scripts to export a run(rawInput) API; the runner tries to require and invoke run(), falls back to spawning the legacy process, and adds path guards and per-process caching for formatter resolution.

Changes

Cohort / File(s) Summary
Formatter Resolution Utility
scripts/lib/resolve-formatter.js
New module exposing findProjectRoot(), detectFormatter(), resolveFormatterBin(), and clearCaches() with per-process caches and cross-platform binary resolution logic.
Hook Refactoring — post-edit-format
scripts/hooks/post-edit-format.js
Replaced inline discovery with exported run(rawInput) that parses input, finds project root, detects formatter (biome/prettier), resolves binary, builds args (Biome: check --write, Prettier: --write), executes formatter, preserves legacy stdin/stdout CLI path and 1MB cap.
Hook Refactoring — quality-gate
scripts/hooks/quality-gate.js
Adds exported run(rawInput); uses resolver to choose Biome/Prettier flows, supports check vs fix modes, adds exec wrapper with timeout, language-specific handling (JS/TS/JSON/MD, Go, Python), and explicit skip when no formatter detected.
Hook Runner Update
scripts/hooks/run-with-flags.js
Adds path traversal guard, attempts to require() the hook and call run(raw) (falling back to legacy spawn on errors), and tightens spawn timeout/stream handling.
Tests & Test Runner
tests/lib/resolve-formatter.test.js, tests/run-all.js
Adds comprehensive tests for resolver behavior (root discovery, formatter detection precedence, local vs npx binary resolution, caching/clearCaches) and registers the test; minor test-runner cleanup.

Sequence Diagram(s)

sequenceDiagram
  participant Runner as "run-with-flags.js"
  participant Hook as "hook script (post-edit-format / quality-gate)"
  participant Resolver as "resolve-formatter.js"
  participant FS as "Filesystem"
  participant Formatter as "Formatter binary"

  Runner->>Hook: require(scriptPath).run(rawInput)
  Hook->>Resolver: findProjectRoot(startDir)
  Resolver->>FS: walk parent dirs for package.json
  FS-->>Resolver: package.json path / not found
  Resolver-->>Hook: projectRoot
  Hook->>Resolver: detectFormatter(projectRoot)
  Resolver->>FS: check biome/prettier config files
  FS-->>Resolver: config presence (biome|prettier|null)
  Resolver-->>Hook: formatter type
  Hook->>Resolver: resolveFormatterBin(projectRoot, formatter)
  Resolver->>FS: look for node_modules/.bin/<bin>
  FS-->>Resolver: local bin path / none
  Resolver-->>Hook: resolved.bin (local or npx prefix)
  Hook->>Formatter: execFileSync(resolved.bin, args)
  Formatter-->>Hook: exit code / output
  Hook-->>Runner: return/write rawInput (or log error)
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~50 minutes

Possibly related PRs

Poem

🐰
I found the root beneath the sod,
Sniffed configs where the formatters nod;
Biome or Prettier, path now clear,
Caches hum and guards appear.
Hop — the hooks all run with cheer.

🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Docstring Coverage ✅ Passed Docstring coverage is 80.00% which is sufficient. The required threshold is 80.00%.
Title check ✅ Passed The title accurately summarizes the main performance optimization changes: using local binaries, merging invocations, and direct require() calls, with the specific 52x speedup metric highlighting the key improvement.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment

Tip

Try Coding Plans. Let us write the prompt for your AI agent so you can ship faster (with fewer bugs).
Share your feedback on Discord.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link

@cubic-dev-ai cubic-dev-ai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

3 issues found across 6 files

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="scripts/hooks/quality-gate.js">

<violation number="1" location="scripts/hooks/quality-gate.js:59">
P2: Relative `filePath` will not resolve correctly when `projectRoot` differs from `process.cwd()`. The exec call uses `projectRoot` as cwd, but `filePath` may be relative to `process.cwd()`. Resolve it to an absolute path before passing it to the formatter.

The same issue applies to the Prettier branch below.</violation>
</file>

<file name="scripts/hooks/run-with-flags.js">

<violation number="1" location="scripts/hooks/run-with-flags.js:73">
P1: The single try/catch wraps both `require()` and `run()`, so a `run()` error is misreported as "require() failed" and silently falls through to the legacy spawnSync path, executing the hook a second time.

Separate the two so that only a `require()` failure falls through to the legacy path, while a `run()` failure is surfaced without double-execution.</violation>
</file>

<file name="scripts/lib/resolve-formatter.js">

<violation number="1" location="scripts/lib/resolve-formatter.js:62">
P2: Missing detection of `"prettier"` key in `package.json`. This is a standard and commonly-used Prettier configuration method. Projects relying solely on this key won't have Prettier detected, silently skipping formatting.</violation>
</file>

Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (3)
scripts/hooks/run-with-flags.js (1)

71-83: Consider handling empty string output from run().

Line 77's if (output) check will skip writing when run() returns an empty string "", which is falsy in JavaScript. If a hook legitimately needs to return empty output, this would silently drop it. Use explicit output != null if empty strings should be written.

♻️ Explicit null check
     if (typeof hookModule.run === 'function') {
       const output = hookModule.run(raw);
-      if (output) process.stdout.write(output);
+      if (output != null) process.stdout.write(output);
       process.exit(0);
     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@scripts/hooks/run-with-flags.js` around lines 71 - 83, The current require()
path drops empty-string outputs because the check uses if (output), which treats
"" as falsy; update the condition in the try block around hookModule.run(raw) so
it writes any non-null/undefined result (e.g., use an explicit null/undefined
check like output != null or output !== undefined) before calling
process.stdout.write and process.exit, referencing the hookModule.run, raw,
process.stdout.write, and process.exit locations in the snippet.
tests/lib/resolve-formatter.test.js (1)

32-34: Consider cleaning up temporary directories after tests.

The test creates temporary directories via makeTmpDir() but never cleans them up. While os.tmpdir() directories may be cleared periodically by the OS, explicitly cleaning up in an afterEach or at test completion would prevent accumulation during repeated test runs.

🧹 Optional cleanup helper
 function makeTmpDir() {
   return fs.mkdtempSync(path.join(os.tmpdir(), 'resolve-fmt-'));
 }

+const tmpDirs = [];
+
+function makeTmpDirTracked() {
+  const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'resolve-fmt-'));
+  tmpDirs.push(dir);
+  return dir;
+}
+
+function cleanupTmpDirs() {
+  for (const dir of tmpDirs) {
+    try {
+      fs.rmSync(dir, { recursive: true, force: true });
+    } catch { /* ignore */ }
+  }
+  tmpDirs.length = 0;
+}

Then call cleanupTmpDirs() at the end of runTests().

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/lib/resolve-formatter.test.js` around lines 32 - 34, Tests create temp
dirs via makeTmpDir() but never remove them; implement a small cleanup
mechanism: track created directories in makeTmpDir (e.g., push each returned
path into an array) and add a cleanupTmpDirs function that iterates the array
and removes each directory (fs.rmSync with recursive: true or equivalent). Call
cleanupTmpDirs from an appropriate teardown hook (e.g., afterEach or afterAll)
or at the end of runTests() so temporary directories are removed after tests
complete; update references to makeTmpDir, cleanupTmpDirs, and runTests
accordingly.
scripts/hooks/quality-gate.js (1)

82-95: Go format errors are silently ignored.

For Go files, gofmt errors are not logged even when strict mode is enabled. This differs from Python/Ruff which logs failures in strict mode.

♻️ Consistent strict mode handling for Go
   if (ext === '.go' && fix) {
-    exec('gofmt', ['-w', filePath]);
+    const r = exec('gofmt', ['-w', filePath]);
+    if (r.status !== 0 && strict) {
+      log(`[QualityGate] gofmt failed for ${filePath}`);
+    }
     return;
   }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@scripts/hooks/quality-gate.js` around lines 82 - 95, The Go branch currently
calls exec('gofmt', ['-w', filePath]) and returns without checking the result;
change it to capture the exec result (e.g., const r = exec('gofmt', ['-w',
filePath])) and if r.status !== 0 and strict is true, log a descriptive message
(similar to the Python branch) including filePath and any r.stderr/r.stdout to
surface the formatting error; keep the existing behavior when fix is false
(no-op) but ensure strict-mode failures are reported for the 'gofmt' invocation.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@scripts/hooks/quality-gate.js`:
- Around line 51-55: The hook currently sets async execution for the
quality-gate hook while it intentionally skips JS/TS when formatter === 'biome'
(the ext check for ['.ts', '.tsx', '.js', '.jsx']), which can create a race with
the synchronous post-edit-format hook; to fix, remove the async flag from the
quality-gate hook (making it run synchronously) so its skip behavior reliably
occurs before post-edit-format runs, or if you prefer not to change execution
semantics, add a concise comment inside the block referencing the intentional
skip and the dependency on post-edit-format (mentioning post-edit-format and the
'biome' check) explaining the required ordering guarantee from the hook runner.

In `@scripts/lib/resolve-formatter.js`:
- Around line 96-140: The function resolveFormatterBin currently falls back to {
bin: npxBin, prefix: [] } for unknown formatter values which would invoke npx
with no package; instead validate the formatter at the end of
resolveFormatterBin (function name) and handle unknown values defensively by
caching and returning null (e.g., binCache.set(cacheKey, null); return null) so
callers can detect and skip/raise a clear error; ensure you reference the same
cacheKey and not return an executable object, and update callers to check for
null (binCache, projectRoot, formatter are the unique symbols to locate the
logic).

---

Nitpick comments:
In `@scripts/hooks/quality-gate.js`:
- Around line 82-95: The Go branch currently calls exec('gofmt', ['-w',
filePath]) and returns without checking the result; change it to capture the
exec result (e.g., const r = exec('gofmt', ['-w', filePath])) and if r.status
!== 0 and strict is true, log a descriptive message (similar to the Python
branch) including filePath and any r.stderr/r.stdout to surface the formatting
error; keep the existing behavior when fix is false (no-op) but ensure
strict-mode failures are reported for the 'gofmt' invocation.

In `@scripts/hooks/run-with-flags.js`:
- Around line 71-83: The current require() path drops empty-string outputs
because the check uses if (output), which treats "" as falsy; update the
condition in the try block around hookModule.run(raw) so it writes any
non-null/undefined result (e.g., use an explicit null/undefined check like
output != null or output !== undefined) before calling process.stdout.write and
process.exit, referencing the hookModule.run, raw, process.stdout.write, and
process.exit locations in the snippet.

In `@tests/lib/resolve-formatter.test.js`:
- Around line 32-34: Tests create temp dirs via makeTmpDir() but never remove
them; implement a small cleanup mechanism: track created directories in
makeTmpDir (e.g., push each returned path into an array) and add a
cleanupTmpDirs function that iterates the array and removes each directory
(fs.rmSync with recursive: true or equivalent). Call cleanupTmpDirs from an
appropriate teardown hook (e.g., afterEach or afterAll) or at the end of
runTests() so temporary directories are removed after tests complete; update
references to makeTmpDir, cleanupTmpDirs, and runTests accordingly.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 21e346ed-eb36-4cc4-90bc-00424bea608e

📥 Commits

Reviewing files that changed from the base of the PR and between 6090401 and 34cd7b7.

📒 Files selected for processing (6)
  • scripts/hooks/post-edit-format.js
  • scripts/hooks/quality-gate.js
  • scripts/hooks/run-with-flags.js
  • scripts/lib/resolve-formatter.js
  • tests/hooks/hooks.test.js
  • tests/lib/resolve-formatter.test.js

Address 8 PR review issues from cubic-dev-ai and coderabbitai:

- Separate require()/run() try/catch in run-with-flags to prevent
  double-execution on run() errors (affaan-m#1)
- Resolve filePath to absolute in quality-gate (#2)
- Detect prettier key in package.json (affaan-m#3)
- Use `output != null` instead of truthy check (affaan-m#4)
- Add gofmt strict mode logging (affaan-m#5)
- Return null for unknown formatter in resolveFormatterBin (#6)
- Track and cleanup temp dirs in tests (#7)
- Add JSDoc to exported functions (#8)
Copy link

@cubic-dev-ai cubic-dev-ai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

3 issues found across 6 files (changes from recent commits).

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="scripts/hooks/run-with-flags.js">

<violation number="1" location="scripts/hooks/run-with-flags.js:87">
P1: When `run()` throws, the original input `raw` is never written to stdout and the process exits with code 0. Every other error path in this file passes `raw` through so downstream consumers still receive the data. This silently drops the hook input on any runtime error in the hook module.</violation>
</file>

<file name="scripts/lib/resolve-formatter.js">

<violation number="1" location="scripts/lib/resolve-formatter.js:143">
P2: The JSDoc `@returns` for `resolveFormatterBin` still promises `{{ bin: string, prefix: string[] }}` but this code path now returns `null`. Update the return annotation to `{{ bin: string, prefix: string[] } | null}` so future callers know to handle the null case.</violation>
</file>

<file name="scripts/hooks/quality-gate.js">

<violation number="1" location="scripts/hooks/quality-gate.js:113">
P2: The `gofmt -l` strict-check path doesn't detect command-level failures. If `gofmt` is not installed or otherwise errors out, `r.status` will be non-zero but `r.stdout` will be empty, so this branch silently passes. Add an `r.status` check for parity with the `fix` path above.</violation>
</file>

Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 5

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@scripts/hooks/post-edit-format.js`:
- Around line 36-55: Normalize the input path before invoking the formatter:
compute a resolvedFilePath = path.resolve(filePath) (use the same resolved path
that projectRoot was derived from) and use resolvedFilePath instead of filePath
when building args for Biome/Prettier and when calling execFileSync; update the
args construction (the array built in the formatter === 'biome' ? ... : ...) and
any other references to filePath (e.g., execFileSync invocation) to use
resolvedFilePath so the formatter always runs against the correct absolute path
while still running with cwd = projectRoot.

In `@scripts/hooks/quality-gate.js`:
- Around line 112-116: The gofmt strict branch currently only flags failures
when r.stdout contains filenames, so execution errors (non-zero exit, missing
binary, or syntax errors) with empty stdout are silent; update the handling
around the exec('gofmt', ['-l', filePath]) call (variable r) to treat non-zero
exit codes or presence of r.stderr/r.error as failures in strict mode by logging
a failure for filePath and include r.status (or r.code) and r.stderr in the log
message (and still treat any non-empty r.stdout as a failure as today).

In `@scripts/hooks/run-with-flags.js`:
- Around line 71-91: The current attempt to optimize by calling
require(scriptPath) is unsafe because legacy hooks run module-scope side effects
on require(), causing double execution; stop requiring arbitrary hook files and
always invoke hooks via the child-process fallback (the spawnSync path) instead
of using require(scriptPath) and hookModule.run, or alternatively only use
require() for hooks that explicitly export a safe run() and are marked safe—but
since existing hooks are unsafe, remove or bypass the require(scriptPath) block
and always call the spawnSync execution path for scriptPath/hookId to avoid
module-scope side effects and double-execution races.

In `@scripts/lib/resolve-formatter.js`:
- Around line 28-42: findProjectRoot currently only treats a directory with
package.json as the anchor which causes detectFormatter to miss repo-level
config files (e.g. biome.json, .prettierrc) and fall back to the leaf directory;
update findProjectRoot (and its projectRootCache usage) to walk parent
directories looking for common formatter/config files (at least biome.json and
.prettierrc variants) in addition to package.json, cache the discovered root for
startDir, and return that directory so detectFormatter can find top-level
configs even in repos without package.json.
- Around line 114-137: The code currently hardcodes npxBin (via isWin/npxBin) as
the fallback for both formatter === 'biome' and formatter === 'prettier', which
ignores CLAUDE_PACKAGE_MANAGER and other managers; update resolve-formatter.js
to determine the package-manager runner instead of using npxBin: replace uses of
npxBin when creating the fallback result for both the 'biome' and 'prettier'
branches with a call to the repo package-manager resolution logic (e.g., read
CLAUDE_PACKAGE_MANAGER or call the existing project/package-manager helper) so
the returned result.bin reflects the chosen runner (npm/npx, pnpm, yarn, bun)
while preserving binCache.set(cacheKey, result) and the prefix arrays.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 3e6ff8ca-eea3-405f-8f79-383c8a93bc53

📥 Commits

Reviewing files that changed from the base of the PR and between 34cd7b7 and 3cc66de.

📒 Files selected for processing (6)
  • scripts/hooks/post-edit-format.js
  • scripts/hooks/quality-gate.js
  • scripts/hooks/run-with-flags.js
  • scripts/lib/resolve-formatter.js
  • tests/lib/resolve-formatter.test.js
  • tests/run-all.js

Comment on lines +112 to +116
} else if (strict) {
const r = exec('gofmt', ['-l', filePath]);
if (r.stdout && r.stdout.trim()) {
log(`[QualityGate] gofmt check failed for ${filePath}`);
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Treat gofmt -l execution errors as failures in strict mode.

Syntax errors and missing gofmt binaries return non-zero with empty stdout. This branch only logs when stdout contains filenames, so those failures are currently silent.

💡 Proposed fix
     } else if (strict) {
       const r = exec('gofmt', ['-l', filePath]);
-      if (r.stdout && r.stdout.trim()) {
+      if (r.status !== 0 || (r.stdout && r.stdout.trim())) {
         log(`[QualityGate] gofmt check failed for ${filePath}`);
       }
     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@scripts/hooks/quality-gate.js` around lines 112 - 116, The gofmt strict
branch currently only flags failures when r.stdout contains filenames, so
execution errors (non-zero exit, missing binary, or syntax errors) with empty
stdout are silent; update the handling around the exec('gofmt', ['-l',
filePath]) call (variable r) to treat non-zero exit codes or presence of
r.stderr/r.error as failures in strict mode by logging a failure for filePath
and include r.status (or r.code) and r.stderr in the log message (and still
treat any non-empty r.stdout as a failure as today).

… edge cases

- Only require() hooks that export run() to prevent module-scope side
  effects (stdin listeners, process.exit) from legacy hooks causing
  double execution or parent process termination
- Normalize filePath via path.resolve() before passing to formatter
  args so relative paths work correctly with cwd: projectRoot
- Add process.stdout.write(raw) in run() error catch for pass-through
  consistency with all other error paths
- Add r.status check for gofmt -l strict path to detect command failures
- Update JSDoc @returns to include null for resolveFormatterBin
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
scripts/hooks/run-with-flags.js (1)

101-114: ⚠️ Potential issue | 🟠 Major

Use process.execPath and explicitly handle spawn failures instead of silently defaulting to exit code 0.

Line 102 uses the string 'node' instead of process.execPath, which fails on systems where node is not on PATH. When spawnSync fails to spawn (e.g., command not found) or times out, result.error is set or result.signal is populated while result.status is null. Line 113 then defaults this null to 0, silently treating spawn failures as success.

Use process.execPath to invoke the current Node runtime directly, and check result.error to catch and report spawn failures explicitly rather than silently exiting with code 0.

Suggested fix
-  const result = spawnSync('node', [scriptPath], {
+  const result = spawnSync(process.execPath, [scriptPath], {
     input: raw,
     encoding: 'utf8',
     env: process.env,
     cwd: process.cwd(),
     timeout: 30000
   });
+
+  if (result.error) {
+    process.stderr.write(`[Hook] ${hookId} failed for ${scriptPath}: ${result.error.message}\n`);
+    process.stdout.write(raw);
+    process.exit(0);
+  }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@scripts/hooks/run-with-flags.js` around lines 101 - 114, The hook child
process invocation uses the literal 'node' and silently treats spawn failures as
success; update the spawnSync call in this block to use process.execPath instead
of 'node', then after spawnSync inspect result.error and result.signal (in
addition to result.status): if result.error exists, write the error message to
process.stderr and exit with a non-zero code (e.g., 1); if result.signal is set
(timeout or killed) write a descriptive message to stderr and exit with a
non-zero code; otherwise use result.status as the exit code. Ensure you still
forward result.stdout/result.stderr to the parent before exiting.
♻️ Duplicate comments (3)
scripts/hooks/run-with-flags.js (1)

71-88: ⚠️ Potential issue | 🟠 Major

The source-text probe is not a safe boundary for require().

Lines 78-79 only prove the file mentions module.exports and run; they do not prove that Line 83 can load it without module-scope side effects or that run is actually the exported entrypoint. I’d restrict direct require() to a small allowlist of known-safe hooks (or another explicit safe-to-require contract) instead of gating it with regexes.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@scripts/hooks/run-with-flags.js` around lines 71 - 88, The current regex
probe (hasRunExport) is unsafe to decide require()-ing arbitrary hook files;
change the logic in run-with-flags.js to only require(scriptPath) for hooks on
an explicit allowlist (e.g., a Set or array of known-safe hook IDs) and
otherwise fall back to the legacy spawnSync path; update the condition that
currently uses hasRunExport to also check membership in that allowlist before
attempting require(), and keep the existing try/catch that writes to
process.stderr on require failure (using hookId, scriptPath, hookModule as
needed).
scripts/lib/resolve-formatter.js (2)

114-137: ⚠️ Potential issue | 🟠 Major

The fallback runner still hardcodes npm via npx.

When no local binary exists, both branches return npx/npx.cmd, which bypasses the repo's package-manager selection and can break formatter execution in pnpm, yarn, or bun setups without npm shims. This should resolve the runner from CLAUDE_PACKAGE_MANAGER/project config before building the fallback command.

As per coding guidelines "scripts/**/*.js: Support multiple package managers: npm, pnpm, yarn, bun, with configurable selection via CLAUDE_PACKAGE_MANAGER env var or project config".

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@scripts/lib/resolve-formatter.js` around lines 114 - 137, The code currently
hardcodes npx/npx.cmd (npxBin) for fallback runners in resolve-formatter.js
(branches handling formatter === 'biome' and formatter === 'prettier'); change
this to resolve the package-manager runner from CLAUDE_PACKAGE_MANAGER (or
project config) instead of always using npx: implement or call a helper like
getPackageManagerRunner(projectRoot, process.env.CLAUDE_PACKAGE_MANAGER) that
returns the correct runner binary and invocation prefix for npm/pnpm/yarn/bun
(and handles isWin), then use that runner instead of npxBin when creating the
fallback result objects (the ones assigned to result and cached in binCache with
cacheKey), preserving existing prefix arrays (e.g., ['@biomejs/biome'] or
['prettier']) but applying the resolved runner invocation.

28-42: ⚠️ Potential issue | 🟠 Major

findProjectRoot() still misses config-only repos.

The upward walk stops at package.json, so a repo that only has biome.json or .prettierrc* at the top level falls back to the leaf directory and both hooks quietly skip formatter detection. The root search needs to treat formatter config files as anchors too, or move the parent-directory walk into detectFormatter().

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@scripts/lib/resolve-formatter.js` around lines 28 - 42, findProjectRoot()
only treats package.json as a project anchor so repos that only have formatter
configs (e.g., biome.json, .prettierrc, .prettierrc.json, .prettierrc.js,
.prettierrc.yaml, .prettierrc.yml, .prettierrc.toml, etc.) are misdetected;
update findProjectRoot (and use projectRootCache) to also consider those
formatter config filenames during the upward directory walk and return the first
directory containing any of them, or alternatively move the parent-directory
walk into detectFormatter() and let detectFormatter() identify the nearest
formatter-config-containing ancestor before caching the result in
projectRootCache; ensure you reference/modify findProjectRoot, projectRootCache,
and detectFormatter accordingly.
🧹 Nitpick comments (1)
scripts/hooks/quality-gate.js (1)

57-132: Split maybeRunQualityGate() into per-tool helpers.

This function now owns formatter resolution plus Biome, Prettier, Go, and Ruff execution in one 70+ line block. Extracting small handlers here would make future tool-specific fixes safer and keeps the function within the repo's size/focus guideline.

As per coding guidelines "**/*.{js,ts,tsx,jsx,go,py,java,cs,rb}: Keep functions small (under 50 lines) and focused. Keep files focused (under 800 lines maximum, typical 200-400 lines)".

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@scripts/hooks/quality-gate.js` around lines 57 - 132, maybeRunQualityGate is
too large and mixes formatter resolution with per-tool logic; extract per-tool
helpers (e.g., runBiomeCheck(projectRoot, filePath, ext, fix, strict),
runPrettierCheck(projectRoot, filePath, fix, strict), runGofmtCheck(filePath,
fix, strict), runRuffCheck(filePath, fix, strict)) and move the specific
exec/args/log handling into those functions while keeping maybeRunQualityGate
limited to resolving filePath, ext, projectRoot, formatter (via findProjectRoot
and detectFormatter) and then dispatching to the appropriate helper; reuse
resolveFormatterBin, exec and log inside the helpers and return early from
maybeRunQualityGate after dispatch to keep it under 50 lines.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Outside diff comments:
In `@scripts/hooks/run-with-flags.js`:
- Around line 101-114: The hook child process invocation uses the literal 'node'
and silently treats spawn failures as success; update the spawnSync call in this
block to use process.execPath instead of 'node', then after spawnSync inspect
result.error and result.signal (in addition to result.status): if result.error
exists, write the error message to process.stderr and exit with a non-zero code
(e.g., 1); if result.signal is set (timeout or killed) write a descriptive
message to stderr and exit with a non-zero code; otherwise use result.status as
the exit code. Ensure you still forward result.stdout/result.stderr to the
parent before exiting.

---

Duplicate comments:
In `@scripts/hooks/run-with-flags.js`:
- Around line 71-88: The current regex probe (hasRunExport) is unsafe to decide
require()-ing arbitrary hook files; change the logic in run-with-flags.js to
only require(scriptPath) for hooks on an explicit allowlist (e.g., a Set or
array of known-safe hook IDs) and otherwise fall back to the legacy spawnSync
path; update the condition that currently uses hasRunExport to also check
membership in that allowlist before attempting require(), and keep the existing
try/catch that writes to process.stderr on require failure (using hookId,
scriptPath, hookModule as needed).

In `@scripts/lib/resolve-formatter.js`:
- Around line 114-137: The code currently hardcodes npx/npx.cmd (npxBin) for
fallback runners in resolve-formatter.js (branches handling formatter ===
'biome' and formatter === 'prettier'); change this to resolve the
package-manager runner from CLAUDE_PACKAGE_MANAGER (or project config) instead
of always using npx: implement or call a helper like
getPackageManagerRunner(projectRoot, process.env.CLAUDE_PACKAGE_MANAGER) that
returns the correct runner binary and invocation prefix for npm/pnpm/yarn/bun
(and handles isWin), then use that runner instead of npxBin when creating the
fallback result objects (the ones assigned to result and cached in binCache with
cacheKey), preserving existing prefix arrays (e.g., ['@biomejs/biome'] or
['prettier']) but applying the resolved runner invocation.
- Around line 28-42: findProjectRoot() only treats package.json as a project
anchor so repos that only have formatter configs (e.g., biome.json, .prettierrc,
.prettierrc.json, .prettierrc.js, .prettierrc.yaml, .prettierrc.yml,
.prettierrc.toml, etc.) are misdetected; update findProjectRoot (and use
projectRootCache) to also consider those formatter config filenames during the
upward directory walk and return the first directory containing any of them, or
alternatively move the parent-directory walk into detectFormatter() and let
detectFormatter() identify the nearest formatter-config-containing ancestor
before caching the result in projectRootCache; ensure you reference/modify
findProjectRoot, projectRootCache, and detectFormatter accordingly.

---

Nitpick comments:
In `@scripts/hooks/quality-gate.js`:
- Around line 57-132: maybeRunQualityGate is too large and mixes formatter
resolution with per-tool logic; extract per-tool helpers (e.g.,
runBiomeCheck(projectRoot, filePath, ext, fix, strict),
runPrettierCheck(projectRoot, filePath, fix, strict), runGofmtCheck(filePath,
fix, strict), runRuffCheck(filePath, fix, strict)) and move the specific
exec/args/log handling into those functions while keeping maybeRunQualityGate
limited to resolving filePath, ext, projectRoot, formatter (via findProjectRoot
and detectFormatter) and then dispatching to the appropriate helper; reuse
resolveFormatterBin, exec and log inside the helpers and return early from
maybeRunQualityGate after dispatch to keep it under 50 lines.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 93dc38ca-addc-4ca5-a341-985efdb00a92

📥 Commits

Reviewing files that changed from the base of the PR and between 3cc66de and 3abd3bf.

📒 Files selected for processing (4)
  • scripts/hooks/post-edit-format.js
  • scripts/hooks/quality-gate.js
  • scripts/hooks/run-with-flags.js
  • scripts/lib/resolve-formatter.js

@pythonstrup pythonstrup changed the title perf(hooks): optimize formatter hooks — local binary, merged invocations, direct require() perf(hooks): optimize formatter hooks(x52) — local binary, merged invocations, direct require() Mar 9, 2026
@pythonstrup pythonstrup changed the title perf(hooks): optimize formatter hooks(x52) — local binary, merged invocations, direct require() perf(hooks): optimize formatter hooks(x52 faster) — local binary, merged invocations, direct require() Mar 9, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant