-
Notifications
You must be signed in to change notification settings - Fork 2
feat: add pre-commit hooks for cycles, dead exports, signature warnings, and PR body guard #376
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Closed
Closed
Changes from all commits
Commits
Show all changes
27 commits
Select commit
Hold shift + click to select a range
5baf3ad
perf: skip ensureWasmTrees when native engine provides complete data
carlos-alm d411ab6
fix: address PR #344 review comments — TODO for constant exclusion, c…
carlos-alm 4317e0d
fix: add null guard on symbols.definitions in pre-parse check
carlos-alm c2bb5de
fix: treat empty cfg.blocks array as valid native CFG
carlos-alm 7e6b489
style: format cfg check per biome rules
carlos-alm f0a5522
feat: add `sequence` command for Mermaid sequence diagrams
carlos-alm 0201d31
perf: hoist db.prepare() calls and build O(1) lookup map in sequence
carlos-alm 19a990a
fix: address sequence review comments — alias collision, truncation, …
carlos-alm 0db9017
ci: retrigger workflows
carlos-alm ae4af09
Merge remote-tracking branch 'origin/main' into feat/sequence-command
carlos-alm 995c816
fix: address round-3 review — deduplicate findBestMatch, guard trunca…
carlos-alm c97bdee
chore: add hook to block "generated with" in PR bodies
carlos-alm 95c5c66
feat: add pre-commit hooks for cycle detection and dead export checks
carlos-alm f8ff775
fix: scope cycle and dead-export hooks to session-edited files
carlos-alm d535550
feat: add signature change warning hook with role-based risk levels
carlos-alm 5f1dc81
merge main + address greptile review comments
carlos-alm e5580a2
fix: remove duplicate declarations and fix sequence.js indentation
carlos-alm 633bc74
fix: remove trailing whitespace in sequence.js
carlos-alm 544984b
Merge remote-tracking branch 'origin/chore/guard-pr-body' into chore/…
carlos-alm e90d3ec
fix: remove duplicate --unused option in exports CLI command
carlos-alm 386e865
feat: add signature change warning hook with role-based risk levels
carlos-alm 4c924da
fix: resolve merge conflicts and address greptile review comments
carlos-alm c104434
fix: resolve merge conflict in cli.js, hoist db.prepare() in signatur…
carlos-alm 6b8d7cf
merge remote chore/guard-pr-body, resolve conflicts
carlos-alm 2637d42
merge: resolve conflicts with main
carlos-alm 7d17382
fix: address greptile review — flow kind regression and hook consistency
carlos-alm 9e6192f
fix: surface triage query errors instead of misleading "no symbols" m…
carlos-alm File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Some comments aren't visible on the classic Files Changed page.
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,94 @@ | ||
| #!/usr/bin/env bash | ||
| # check-cycles.sh — PreToolUse hook for Bash (git commit) | ||
| # Blocks commits if circular dependencies involve files edited in this session. | ||
|
|
||
| set -euo pipefail | ||
|
|
||
| INPUT=$(cat) | ||
|
|
||
| # Extract the command from tool_input JSON | ||
| COMMAND=$(echo "$INPUT" | node -e " | ||
| let d=''; | ||
| process.stdin.on('data',c=>d+=c); | ||
| process.stdin.on('end',()=>{ | ||
| const p=JSON.parse(d).tool_input?.command||''; | ||
| if(p)process.stdout.write(p); | ||
| }); | ||
| " 2>/dev/null) || true | ||
|
|
||
| if [ -z "$COMMAND" ]; then | ||
| exit 0 | ||
| fi | ||
|
|
||
| # Only trigger on git commit commands | ||
| if ! echo "$COMMAND" | grep -qE '(^|\s|&&\s*)git\s+commit\b'; then | ||
| exit 0 | ||
| fi | ||
|
|
||
| # Guard: codegraph DB must exist | ||
| WORK_ROOT=$(git rev-parse --show-toplevel 2>/dev/null) || WORK_ROOT="${CLAUDE_PROJECT_DIR:-.}" | ||
| if [ ! -f "$WORK_ROOT/.codegraph/graph.db" ]; then | ||
| exit 0 | ||
| fi | ||
|
|
||
| # Guard: must have staged changes | ||
| STAGED=$(git diff --cached --name-only 2>/dev/null) || true | ||
| if [ -z "$STAGED" ]; then | ||
| exit 0 | ||
| fi | ||
|
|
||
| # Load session edit log | ||
| LOG_FILE="$WORK_ROOT/.claude/session-edits.log" | ||
| if [ ! -f "$LOG_FILE" ] || [ ! -s "$LOG_FILE" ]; then | ||
| exit 0 | ||
| fi | ||
| EDITED_FILES=$(awk '{print $2}' "$LOG_FILE" | sort -u) | ||
|
|
||
| # Run check with cycles predicate on staged changes | ||
| RESULT=$(node "$WORK_ROOT/src/cli.js" check --staged --json -T 2>/dev/null) || true | ||
|
|
||
| if [ -z "$RESULT" ]; then | ||
| exit 0 | ||
| fi | ||
|
|
||
| # Check if cycles predicate failed — but only block if a cycle involves | ||
| # a file that was edited in this session | ||
| CYCLES_FAILED=$(echo "$RESULT" | EDITED="$EDITED_FILES" node -e " | ||
| let d=''; | ||
| process.stdin.on('data',c=>d+=c); | ||
| process.stdin.on('end',()=>{ | ||
| try { | ||
| const data=JSON.parse(d); | ||
| const cyclesPred=(data.predicates||[]).find(p=>p.name==='cycles'); | ||
| if(!cyclesPred || cyclesPred.passed) return; | ||
| const edited=new Set(process.env.EDITED.split('\\n').filter(Boolean)); | ||
| // Filter to cycles that involve at least one file we edited | ||
| const relevant=(cyclesPred.cycles||[]).filter( | ||
| cycle=>cycle.some(f=>edited.has(f)) | ||
| ); | ||
| if(relevant.length===0) return; | ||
| const summary=relevant.slice(0,5).map(c=>c.join(' -> ')).join('\\n '); | ||
| const extra=relevant.length>5?'\\n ... and '+(relevant.length-5)+' more':''; | ||
| process.stdout.write(summary+extra); | ||
| }catch{} | ||
| }); | ||
| " 2>/dev/null) || true | ||
|
|
||
| if [ -n "$CYCLES_FAILED" ]; then | ||
| REASON="BLOCKED: Circular dependencies detected involving files you edited: | ||
| $CYCLES_FAILED | ||
| Fix the cycles before committing." | ||
|
|
||
| node -e " | ||
| console.log(JSON.stringify({ | ||
| hookSpecificOutput: { | ||
| hookEventName: 'PreToolUse', | ||
| permissionDecision: 'deny', | ||
| permissionDecisionReason: process.argv[1] | ||
| } | ||
| })); | ||
| " "$REASON" | ||
| exit 0 | ||
| fi | ||
|
|
||
| exit 0 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,103 @@ | ||
| #!/usr/bin/env bash | ||
| # check-dead-exports.sh — PreToolUse hook for Bash (git commit) | ||
| # Blocks commits if any src/ file edited in THIS SESSION has exports with zero consumers. | ||
| # Uses the session edit log to scope checks to files you actually touched. | ||
|
|
||
| set -euo pipefail | ||
|
|
||
| INPUT=$(cat) | ||
|
|
||
| # Extract the command from tool_input JSON | ||
| COMMAND=$(echo "$INPUT" | node -e " | ||
| let d=''; | ||
| process.stdin.on('data',c=>d+=c); | ||
| process.stdin.on('end',()=>{ | ||
| const p=JSON.parse(d).tool_input?.command||''; | ||
| if(p)process.stdout.write(p); | ||
| }); | ||
| " 2>/dev/null) || true | ||
|
|
||
| if [ -z "$COMMAND" ]; then | ||
| exit 0 | ||
| fi | ||
|
|
||
| # Only trigger on git commit commands | ||
| if ! echo "$COMMAND" | grep -qE '(^|\s|&&\s*)git\s+commit\b'; then | ||
| exit 0 | ||
| fi | ||
|
|
||
| # Guard: codegraph DB must exist | ||
| WORK_ROOT=$(git rev-parse --show-toplevel 2>/dev/null) || WORK_ROOT="${CLAUDE_PROJECT_DIR:-.}" | ||
| if [ ! -f "$WORK_ROOT/.codegraph/graph.db" ]; then | ||
| exit 0 | ||
| fi | ||
|
|
||
| # Guard: must have staged changes | ||
| STAGED=$(git diff --cached --name-only 2>/dev/null) || true | ||
| if [ -z "$STAGED" ]; then | ||
| exit 0 | ||
| fi | ||
|
|
||
| # Load session edit log to scope checks to files we actually edited | ||
| LOG_FILE="$WORK_ROOT/.claude/session-edits.log" | ||
| if [ ! -f "$LOG_FILE" ] || [ ! -s "$LOG_FILE" ]; then | ||
| exit 0 | ||
| fi | ||
| EDITED_FILES=$(awk '{print $2}' "$LOG_FILE" | sort -u) | ||
|
|
||
| # Check each staged source file that was edited in this session | ||
| DEAD_EXPORTS="" | ||
|
|
||
| while IFS= read -r file; do | ||
| # Only check source files | ||
| case "$file" in | ||
| src/*.js|src/*.ts|src/*.tsx) ;; | ||
| *) continue ;; | ||
| esac | ||
|
|
||
| # Only check files edited in this session | ||
| if ! echo "$EDITED_FILES" | grep -qxF "$file"; then | ||
| continue | ||
| fi | ||
|
|
||
| RESULT=$(node "$WORK_ROOT/src/cli.js" exports "$file" --unused --json -T 2>/dev/null) || true | ||
| if [ -z "$RESULT" ]; then | ||
| continue | ||
| fi | ||
|
|
||
| # Extract unused export names | ||
| UNUSED=$(echo "$RESULT" | node -e " | ||
| let d=''; | ||
| process.stdin.on('data',c=>d+=c); | ||
| process.stdin.on('end',()=>{ | ||
| try { | ||
| const data=JSON.parse(d); | ||
| const unused=(data.results||[]).filter(r=>r.consumerCount===0); | ||
| if(unused.length>0){ | ||
| process.stdout.write(unused.map(u=>u.name+' ('+data.file+':'+u.line+')').join(', ')); | ||
| } | ||
| }catch{} | ||
| }); | ||
| " 2>/dev/null) || true | ||
|
|
||
| if [ -n "$UNUSED" ]; then | ||
| DEAD_EXPORTS="${DEAD_EXPORTS:+$DEAD_EXPORTS; }$UNUSED" | ||
| fi | ||
| done <<< "$STAGED" | ||
|
|
||
| if [ -n "$DEAD_EXPORTS" ]; then | ||
| REASON="BLOCKED: Dead exports (zero consumers) detected in files you edited: $DEAD_EXPORTS. Either add consumers, remove the exports, or verify these are intentionally public API." | ||
|
|
||
| node -e " | ||
| console.log(JSON.stringify({ | ||
| hookSpecificOutput: { | ||
| hookEventName: 'PreToolUse', | ||
| permissionDecision: 'deny', | ||
| permissionDecisionReason: process.argv[1] | ||
| } | ||
| })); | ||
| " "$REASON" | ||
| exit 0 | ||
| fi | ||
|
|
||
| exit 0 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,24 @@ | ||
| #!/usr/bin/env bash | ||
| # Block PR creation if the body contains "generated with" (case-insensitive) | ||
|
|
||
| set -euo pipefail | ||
|
|
||
| INPUT=$(cat) | ||
|
|
||
| # Extract just the command field to avoid false positives on the description field | ||
| cmd=$(echo "$INPUT" | node -e " | ||
| let d=''; | ||
| process.stdin.on('data',c=>d+=c); | ||
| process.stdin.on('end',()=>{ | ||
| const p=JSON.parse(d).tool_input?.command||''; | ||
| if(p)process.stdout.write(p); | ||
| }); | ||
| " 2>/dev/null) || true | ||
|
|
||
| echo "$cmd" | grep -qi 'gh pr create' || exit 0 | ||
|
|
||
| # Block if body contains "generated with" | ||
| if echo "$cmd" | grep -qi 'generated with'; then | ||
| echo "BLOCK: Remove any 'Generated with ...' line from the PR body." >&2 | ||
| exit 2 | ||
| fi | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,126 @@ | ||
| #!/usr/bin/env bash | ||
| # warn-signature-changes.sh — PreToolUse hook for Bash (git commit) | ||
| # Warns when staged changes modify function signatures, highlighting risk | ||
| # level based on the symbol's role (core > utility > others). | ||
| # Informational only — never blocks. | ||
|
|
||
| set -euo pipefail | ||
|
|
||
| INPUT=$(cat) | ||
|
|
||
| # Extract the command from tool_input JSON | ||
| COMMAND=$(echo "$INPUT" | node -e " | ||
| let d=''; | ||
| process.stdin.on('data',c=>d+=c); | ||
| process.stdin.on('end',()=>{ | ||
| const p=JSON.parse(d).tool_input?.command||''; | ||
| if(p)process.stdout.write(p); | ||
| }); | ||
| " 2>/dev/null) || true | ||
|
|
||
| if [ -z "$COMMAND" ]; then | ||
| exit 0 | ||
| fi | ||
|
|
||
| # Only trigger on git commit commands | ||
| if ! echo "$COMMAND" | grep -qE '(^|\s|&&\s*)git\s+commit\b'; then | ||
| exit 0 | ||
| fi | ||
|
|
||
| # Guard: codegraph DB must exist | ||
| WORK_ROOT=$(git rev-parse --show-toplevel 2>/dev/null) || WORK_ROOT="${CLAUDE_PROJECT_DIR:-.}" | ||
| if [ ! -f "$WORK_ROOT/.codegraph/graph.db" ]; then | ||
| exit 0 | ||
| fi | ||
|
|
||
| # Guard: must have staged changes | ||
| STAGED=$(git diff --cached --name-only 2>/dev/null) || true | ||
| if [ -z "$STAGED" ]; then | ||
| exit 0 | ||
| fi | ||
|
|
||
| # Run check --staged to get signature violations, then enrich with role + caller count | ||
| WARNING=$(node -e " | ||
| const path = require('path'); | ||
| const { checkData } = require(path.join(process.argv[1], 'src/check.js')); | ||
| const { openReadonlyOrFail } = require(path.join(process.argv[1], 'src/db.js')); | ||
|
|
||
| const result = checkData(undefined, { staged: true, noTests: true }); | ||
| if (!result || result.error) process.exit(0); | ||
|
|
||
| const sigPred = (result.predicates || []).find(p => p.name === 'signatures'); | ||
| if (!sigPred || sigPred.passed || !sigPred.violations.length) process.exit(0); | ||
|
|
||
| const db = openReadonlyOrFail(); | ||
| const lines = []; | ||
|
|
||
| const stmtNode = db.prepare( | ||
| 'SELECT id, role FROM nodes WHERE name = ? AND file = ? AND line = ?' | ||
| ); | ||
| const stmtCallers = db.prepare( | ||
| 'SELECT DISTINCT n.id FROM edges e JOIN nodes n ON e.source_id = n.id WHERE e.target_id = ? AND e.kind = \\'calls\\'' | ||
| ); | ||
|
|
||
| for (const v of sigPred.violations) { | ||
| const node = stmtNode.get(v.name, v.file, v.line); | ||
| const role = node?.role || 'unknown'; | ||
|
|
||
| let callerCount = 0; | ||
| if (node) { | ||
| const visited = new Set([node.id]); | ||
| let frontier = [node.id]; | ||
| for (let d = 0; d < 3; d++) { | ||
| const next = []; | ||
| for (const fid of frontier) { | ||
| const callers = stmtCallers.all(fid); | ||
| for (const c of callers) { | ||
| if (!visited.has(c.id)) { | ||
| visited.add(c.id); | ||
| next.push(c.id); | ||
| callerCount++; | ||
| } | ||
| } | ||
| } | ||
| frontier = next; | ||
| if (!frontier.length) break; | ||
| } | ||
| } | ||
|
|
||
| const risk = role === 'core' ? 'HIGH' : role === 'utility' ? 'MEDIUM' : 'low'; | ||
| lines.push(risk + ': ' + v.name + ' (' + v.kind + ') [' + role + '] at ' + v.file + ':' + v.line + ' — ' + callerCount + ' transitive callers'); | ||
| } | ||
|
|
||
| db.close(); | ||
|
|
||
| if (lines.length > 0) { | ||
| process.stdout.write(lines.join('\\n')); | ||
| } | ||
| " "$WORK_ROOT" 2>/dev/null) || true | ||
|
|
||
| if [ -z "$WARNING" ]; then | ||
| exit 0 | ||
| fi | ||
|
|
||
| # Escape for JSON | ||
| ESCAPED=$(printf '%s' "$WARNING" | node -e " | ||
| let d=''; | ||
| process.stdin.on('data',c=>d+=c); | ||
| process.stdin.on('end',()=>process.stdout.write(JSON.stringify(d))); | ||
| " 2>/dev/null) || true | ||
|
|
||
| if [ -z "$ESCAPED" ]; then | ||
| exit 0 | ||
| fi | ||
|
|
||
| # Inject as additionalContext — informational, never blocks | ||
| node -e " | ||
| console.log(JSON.stringify({ | ||
| hookSpecificOutput: { | ||
| hookEventName: 'PreToolUse', | ||
| permissionDecision: 'allow', | ||
| additionalContext: '[codegraph] Signature changes detected in staged files:\\n' + JSON.parse(process.argv[1]) | ||
| } | ||
| })); | ||
| " "$ESCAPED" 2>/dev/null || true | ||
|
|
||
| exit 0 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The guard scans the entire
gh pr createcommand string forgenerated with, not just the--bodyvalue. This means a PR whose title contains that phrase (e.g.--title "Report generated with nightly CI") would be incorrectly blocked, even though the body is clean.Extract just the
--bodyargument before testing:Or more robustly, parse the body via Node:
This avoids false positives from other flag values.