rubocop-rspec-unused-let 1.1.0 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (31) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/hooks/pre-commit-check.sh +42 -2
  3. data/.claude/hooks/protect-sig-files.sh +32 -17
  4. data/.claude/hooks/self-review.sh +66 -35
  5. data/.claude/settings.json +10 -10
  6. data/CHANGELOG.md +43 -0
  7. data/README.md +326 -28
  8. data/Rakefile +13 -4
  9. data/config/default.yml +7 -1
  10. data/lib/rubocop/cop/rspec/unused_let/external_definitions.rb +120 -0
  11. data/lib/rubocop/cop/rspec/unused_let/matchers.rb +211 -0
  12. data/lib/rubocop/cop/rspec/unused_let/references.rb +48 -0
  13. data/lib/rubocop/cop/rspec/unused_let/scope.rb +126 -0
  14. data/lib/rubocop/cop/rspec/unused_let/scope_builder.rb +267 -0
  15. data/lib/rubocop/cop/rspec/unused_let/scope_stack.rb +116 -0
  16. data/lib/rubocop/cop/rspec/unused_let/shared_example_registry.rb +265 -0
  17. data/lib/rubocop/cop/rspec/unused_let.rb +194 -123
  18. data/lib/rubocop/rspec/unused/let/version.rb +1 -1
  19. data/rbs_collection.lock.yaml +20 -16
  20. data/rbs_collection.yaml +3 -0
  21. data/sig/gems/rubocop_rspec_stubs.rbs +70 -0
  22. data/sig/rubocop/cop/rspec/unused_let/external_definitions.rbs +78 -0
  23. data/sig/rubocop/cop/rspec/unused_let/matchers.rbs +129 -0
  24. data/sig/rubocop/cop/rspec/unused_let/references.rbs +30 -0
  25. data/sig/rubocop/cop/rspec/unused_let/scope.rbs +100 -0
  26. data/sig/rubocop/cop/rspec/unused_let/scope_builder.rbs +162 -0
  27. data/sig/rubocop/cop/rspec/unused_let/scope_stack.rbs +84 -0
  28. data/sig/rubocop/cop/rspec/unused_let/shared_example_registry.rbs +171 -0
  29. data/sig/rubocop/cop/rspec/unused_let.rbs +134 -61
  30. metadata +23 -6
  31. data/sig/rubocop_rspec_stubs.rbs +0 -30
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 518f64b5db264aa442ab1465330d15603ccf157676f2d1d4f5f6805fa78e7e07
4
- data.tar.gz: fefc8a161e2c03d21fd0c7ba9d8757a76e97344e7413628608b4f34ede4f74f1
3
+ metadata.gz: 724c612abc3f71f86027ff074ddd0c543b58200c7ddc8bfbb717e57308bc6a6d
4
+ data.tar.gz: a70d24faab3cd03e5c85ea53896b784b19a3527ce440809de887696fba2107b0
5
5
  SHA512:
6
- metadata.gz: '02875504a5c1781e28d2eca15670a82b2cafa2f3c1538462dcfac3201a412c57b0aa3f2584fea635151e537b19b6ed623081a64663b77d4926a4997563dd09a1'
7
- data.tar.gz: 42db99b7c7c5f9a904ce554ce49370a3d70daceabf3515e9797fa1951434cfd87f39f213d0a780b3183dab2427fdb9a3e1f7867327223a6287585a0b66aa691b
6
+ metadata.gz: 0eac33bde343620b5648ac00bc61cfb8e48a95692b16fe6f135d679646bb8f07c6f51ab6271da0aacf90b3d2fecd087763ea1bf1c4133cc172a4bff3b2d426fe
7
+ data.tar.gz: 69545def1558284cd78eccd6933edf5fbb1a890065b4b5f959d1736e693f7bac37ee83e70fe65a169f3a2f1bb01e456dd9293cd19b6ff7e49326b4043996fa3e
@@ -18,12 +18,52 @@ fi
18
18
 
19
19
  echo "Running pre-commit checks..." >&2
20
20
 
21
- # Generate RBS and run all checks
22
- if ! bundle exec rbs-inline --opt-out --output=sig/ lib/ >&2; then
21
+ # Regenerate RBS from scratch so that files orphaned by deleted lib/ sources are dropped too
22
+ if ! bundle exec rake rbs:regenerate >&2; then
23
23
  echo "Error: RBS generation failed" >&2
24
24
  exit 2
25
25
  fi
26
26
 
27
+ # True when $command stages the whole of sig/ itself, which the check below cannot see because
28
+ # this hook runs first. Staging only part of sig/ does not count - the rest still drops out.
29
+ stages_sig() {
30
+ local segment tokens token stages_everything pathspec_given
31
+
32
+ while IFS= read -r segment; do
33
+ segment=${segment//[\"\']/}
34
+ read -ra tokens <<<"${segment#*add}"
35
+ stages_everything=0
36
+ pathspec_given=0
37
+
38
+ for token in "${tokens[@]}"; do
39
+ case "$token" in
40
+ --) ;;
41
+ -*A*|--all) stages_everything=1 ;;
42
+ -*) ;;
43
+ .|./|sig|sig/|./sig|./sig/) return 0 ;;
44
+ *) pathspec_given=1 ;;
45
+ esac
46
+ done
47
+
48
+ # -A alone stages every path; given a pathspec it stages that path only
49
+ [ "$stages_everything" = 1 ] && [ "$pathspec_given" = 0 ] && return 0
50
+ done < <(grep -oE '(^|[;&|(])[[:space:]]*git[[:space:]]+add[^;&|(]*' <<<"$command")
51
+
52
+ return 1
53
+ }
54
+
55
+ # Stop rather than let the RBS just regenerated above drop out of the commit unnoticed
56
+ if [[ "$command" =~ git[[:space:]]+commit([[:space:]]|$) ]] && ! stages_sig; then
57
+ unstaged_sig=$(git status --porcelain --untracked-files=all -- sig/ | awk 'substr($0, 2, 1) != " "')
58
+
59
+ if [ -n "$unstaged_sig" ]; then
60
+ echo "Error: sig/ has changes that are not staged:" >&2
61
+ echo "$unstaged_sig" >&2
62
+ echo "Run 'git add sig/' and commit again." >&2
63
+ exit 2
64
+ fi
65
+ fi
66
+
27
67
  if ! bundle exec rake >&2; then
28
68
  echo "Error: rake checks failed" >&2
29
69
  exit 2
@@ -1,29 +1,44 @@
1
1
  #!/bin/bash
2
- # .claude/hooks/protect-sig-files.sh
3
- #
4
2
  # Prevents direct editing of auto-generated RBS type definition files.
5
- # Type definitions in */sig/ directories are managed by PostToolUse hooks.
3
+ # Type definitions in the project's own sig/ are managed by PostToolUse hooks.
4
+ # Hand-written stubs for third-party gems under sig/gems/ are exempt.
6
5
 
7
- TOOL_NAME="$CLAUDE_TOOL_NAME"
8
- FILE_PATH="$CLAUDE_TOOL_PARAM_file_path"
9
- COMMAND="$CLAUDE_TOOL_PARAM_command"
6
+ # Hook input is JSON from stdin
7
+ input=$(cat)
8
+ tool_name=$(echo "$input" | jq -r '.tool_name // ""')
10
9
 
11
10
  # Block Write/Edit operations on sig/ files
12
- if [[ "$TOOL_NAME" == "Write" || "$TOOL_NAME" == "Edit" ]]; then
13
- if [[ "$FILE_PATH" == */sig/* ]]; then
14
- echo "❌ ERROR: Direct editing of sig/ files is not allowed."
15
- echo "📝 Type definitions are auto-generated by PostToolUse hook."
16
- echo "💡 Modify inline RBS annotations in .rb files instead."
17
- exit 1
11
+ if [[ "$tool_name" == "Write" || "$tool_name" == "Edit" ]]; then
12
+ file_path=$(echo "$input" | jq -r '.tool_input.file_path // ""')
13
+
14
+ # Judge the path relative to the project root: only the sig/ there is
15
+ # generated. A sig/ nested elsewhere is hand-written and stays editable.
16
+ project_root="${CLAUDE_PROJECT_DIR:-$PWD}"
17
+ rel_path="${file_path#"${project_root}/"}"
18
+
19
+ if [[ "$rel_path" == sig/gems/* ]]; then
20
+ exit 0
21
+ fi
22
+
23
+ if [[ "$rel_path" == sig/* ]]; then
24
+ echo "❌ ERROR: Direct editing of generated sig/ files is not allowed." >&2
25
+ echo "📝 This repository's own types are generated: fix the inline RBS comments in the .rb file." >&2
26
+ echo "💡 Hand-written files are allowed only under sig/gems/ (stubs for third-party gems)." >&2
27
+ exit 2
18
28
  fi
19
29
  fi
20
30
 
21
31
  # Block manual rbs-inline execution
22
- if [[ "$TOOL_NAME" == "Bash" ]]; then
23
- if [[ "$COMMAND" =~ rbs-inline ]]; then
24
- echo "❌ ERROR: Manual rbs-inline execution is not allowed."
25
- echo "🔄 Type definitions are auto-generated by hooks."
26
- exit 1
32
+ if [[ "$tool_name" == "Bash" ]]; then
33
+ command=$(echo "$input" | jq -r '.tool_input.command // ""')
34
+
35
+ # Match rbs-inline only at a command position, not as an argument.
36
+ rbs_inline_re=$'(^|[;&|(\n])[[:space:]]*(bundle[[:space:]]+exec[[:space:]]+)?([^[:space:]]*/)?rbs-inline([[:space:]]|[;&|)]|$)'
37
+
38
+ if [[ "$command" =~ $rbs_inline_re ]]; then
39
+ echo "❌ ERROR: Manual rbs-inline execution is not allowed." >&2
40
+ echo "🔄 Type definitions are auto-generated by hooks." >&2
41
+ exit 2
27
42
  fi
28
43
  fi
29
44
 
@@ -1,15 +1,17 @@
1
1
  #!/bin/bash
2
2
  #
3
- # PostToolUse hook for self-review after git push
3
+ # PreToolUse hook: review code before `git commit`.
4
4
  #
5
- # This script triggers a self-review process after git push operations.
6
- # The output is fed back to Claude via JSON, prompting it to perform
7
- # a code review.
5
+ # Fires before a Bash `git commit` command and blocks the first commit attempt
6
+ # for a given change-set, asking Claude to review the code before committing.
7
+ # The review uses the `self-review` skill when available, and falls back to the
8
+ # bundled `code-review` skill otherwise — so the hook works even in projects
9
+ # that don't ship the custom self-review skill.
8
10
  #
9
- # NOTE: PostToolUse hooks with exit code 0 only show plain text stdout
10
- # in verbose mode. To ensure Claude receives the message, we output JSON
11
- # with "decision": "block" and "reason", which Claude Code delivers as
12
- # a system-reminder.
11
+ # A per-change-set marker (keyed by a hash of the diff, stored under .git/)
12
+ # prevents an infinite block loop: once a change-set has been surfaced for
13
+ # review, the matching commit is allowed through. If the review leads to fixes,
14
+ # the diff changes, so the new state is reviewed once more before it commits.
13
15
  #
14
16
 
15
17
  set -euo pipefail
@@ -20,49 +22,78 @@ input=$(cat)
20
22
  tool_name=$(echo "$input" | jq -r '.tool_name // empty')
21
23
  command=$(echo "$input" | jq -r '.tool_input.command // empty')
22
24
 
23
- # Only run for Bash tool
25
+ # Only gate the Bash tool running a git commit.
24
26
  if [[ "$tool_name" != "Bash" ]]; then
25
27
  exit 0
26
28
  fi
29
+ # Match `git commit`, or `git -C <path> commit`.
30
+ commit_re='git[[:space:]]+(-C[[:space:]]+[^[:space:]]+[[:space:]]+)?commit'
31
+ if [[ ! "$command" =~ $commit_re ]]; then
32
+ exit 0
33
+ fi
34
+
35
+ # If the command targets a specific repo via `git -C <path>`, operate there too.
36
+ work_dir="."
37
+ if [[ "$command" =~ git[[:space:]]+-C[[:space:]]+([^[:space:]]+) ]]; then
38
+ work_dir="${BASH_REMATCH[1]}"
39
+ fi
40
+
41
+ # Not a git repo → nothing to do.
42
+ git_dir=$(git -C "$work_dir" rev-parse --absolute-git-dir 2>/dev/null || true)
43
+ if [[ -z "$git_dir" ]]; then
44
+ exit 0
45
+ fi
27
46
 
28
- # Check if it's a git push command
29
- if [[ ! "$command" =~ git[[:space:]]+push ]]; then
47
+ # Hash what will be committed. `git status --porcelain` is included so a commit
48
+ # that only adds untracked files isn't mistaken for empty; `git diff HEAD` adds
49
+ # tracked content. On the first commit there is no HEAD, so use the working state.
50
+ if git -C "$work_dir" rev-parse --verify HEAD >/dev/null 2>&1; then
51
+ changeset=$(git -C "$work_dir" status --porcelain; git -C "$work_dir" diff HEAD)
52
+ else
53
+ changeset=$(git -C "$work_dir" status --porcelain; git -C "$work_dir" diff; git -C "$work_dir" diff --cached)
54
+ fi
55
+
56
+ # Nothing to review (e.g. a message-only `--amend`) → let it proceed.
57
+ if [[ -z "$changeset" ]]; then
30
58
  exit 0
31
59
  fi
32
60
 
33
- # Check if push was successful (field may not exist; default to 0)
34
- exit_code=$(echo "$input" | jq -r '.tool_response.exit_code // 0')
61
+ hash=$(printf '%s' "$changeset" | git hash-object --stdin)
62
+ state_file="$git_dir/self-review-reviewed"
35
63
 
36
- if [[ "$exit_code" != "0" ]]; then
37
- # Push failed, no need for review
64
+ # Already surfaced for review → allow the commit.
65
+ if [[ -f "$state_file" ]] && grep -qxF "$hash" "$state_file"; then
38
66
  exit 0
39
67
  fi
40
68
 
41
- # Output self-review prompt as JSON so Claude receives it
42
- review_prompt='SELF-REVIEW REQUIRED: A git push has been completed. Please use the Task tool with subagent_type Explore to perform a self-review of the changes. Use this prompt for the Explore subagent:
69
+ # Record this change-set and block once to force a review first.
70
+ echo "$hash" >> "$state_file"
71
+
72
+ review_prompt='SELF-REVIEW REQUIRED before committing. Review the changes that are
73
+ about to be committed BEFORE running git commit again:
43
74
 
44
- First, read CLAUDE.md to understand the project rules and conventions.
45
- Then review the recent git push. Run `git log origin/main..HEAD --stat` and
46
- `git diff origin/main...HEAD` to examine all changes from the branch point.
47
- Check for:
48
- 1. Correctness: requirements met, logic errors, edge cases
49
- 2. Code Quality: naming, readability, follows project conventions
50
- 3. Testing: test coverage for new functionality
51
- 4. Security: vulnerabilities, input validation
52
- 5. Commit Quality: each commit should be a meaningful unit of work.
53
- If there are minor fix commits (typos, forgotten changes, careless mistakes),
54
- suggest squashing them into the relevant commit before merging.
75
+ - If the `self-review` skill is available, invoke it (Skill tool, skill "self-review").
76
+ - Otherwise, run the bundled `/code-review` skill on the working diff.
55
77
 
56
- Report findings and suggest fixes if needed.
57
- After the subagent review, if issues are found, create follow-up commits to address them.
78
+ If the review finds issues, fix them and review again. Once it is clean, run the same
79
+ git commit command again to proceed — it will be allowed through.
58
80
 
59
- If working in a git worktree (i.e., the working directory is under .claude/worktrees/),
60
- use `git -C <worktree-path>` for all git commands instead of `cd <path> && git`.
61
- Also avoid chaining commands with `&&` where possible — run each command separately.'
81
+ The review runs in an isolated subagent with no memory of earlier rounds, so it cannot
82
+ tell on its own whether it is repeating itself — only you, the caller, have that history.
83
+ Before fixing a finding, check your own conversation: if it asks for the same change as
84
+ a finding you already attempted to satisfy twice before (worded differently still counts),
85
+ and it is still not resolved, do not attempt a third fix. Instead stop and ask the user
86
+ (AskUserQuestion): what the finding wants, how the two prior attempts addressed it, why it
87
+ still was not accepted, and the options (try a different fix, skip this finding and
88
+ commit, or decide the review point does not apply here). Do not run git commit again until
89
+ the user answers — another round of fixes would only repeat the same mismatch.'
62
90
 
63
91
  jq -n --arg reason "$review_prompt" '{
64
- "decision": "block",
65
- "reason": $reason
92
+ "hookSpecificOutput": {
93
+ "hookEventName": "PreToolUse",
94
+ "permissionDecision": "deny",
95
+ "permissionDecisionReason": $reason
96
+ }
66
97
  }'
67
98
 
68
99
  exit 0
@@ -20,6 +20,16 @@
20
20
  "timeout": 300000
21
21
  }
22
22
  ]
23
+ },
24
+ {
25
+ "matcher": "Bash",
26
+ "hooks": [
27
+ {
28
+ "type": "command",
29
+ "command": "$CLAUDE_PROJECT_DIR/.claude/hooks/self-review.sh",
30
+ "timeout": 10000
31
+ }
32
+ ]
23
33
  }
24
34
  ],
25
35
  "SessionStart": [
@@ -42,16 +52,6 @@
42
52
  "timeout": 60000
43
53
  }
44
54
  ]
45
- },
46
- {
47
- "matcher": "Bash",
48
- "hooks": [
49
- {
50
- "type": "command",
51
- "command": "$CLAUDE_PROJECT_DIR/.claude/hooks/self-review.sh",
52
- "timeout": 10000
53
- }
54
- ]
55
55
  }
56
56
  ]
57
57
  }
data/CHANGELOG.md CHANGED
@@ -1,5 +1,48 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [1.2.0] - 2026-08-08
4
+
5
+ - `RSpec/UnusedLet` now also flags `subject`/`subject!` definitions that
6
+ nothing references. A subject answers to two names: its own, when it has
7
+ one, and the implicit `subject`, which RSpec's one-liner syntax
8
+ (`is_expected`, `are_expected`, `should`, `should_not`) and rspec-its' `its`
9
+ reach. A reference by either name counts, in a shared example block as much
10
+ as in an example group. `CheckLetBang` governs `subject!` alongside `let!`,
11
+ and setting the new `CheckSubject` (default `true`) to `false` turns the
12
+ check off entirely.
13
+ - `RSpec/UnusedLet` now also flags helper methods (`def`) written at an
14
+ example group's level whose name is never referenced. Such a method
15
+ becomes an instance method on the group's example class, so it is checked
16
+ with the same rules as a `let`, and autocorrect removes it.
17
+ - `RSpec/UnusedLet` now understands `shared_examples` / `shared_context`.
18
+ Where it used to silence every `let` an inclusion could reach, it now works
19
+ out which ones the shared block really uses, falling back to the old
20
+ behavior only for an inclusion it cannot resolve, and it no longer flags a
21
+ `let` that overrides a name an inline inclusion (`include_examples` /
22
+ `include_context`) injects and the shared block itself references. It also
23
+ checks the `let`s defined inside a shared block that carries examples —
24
+ expect new offenses on upgrade. `SharedExamplePaths` lists the files,
25
+ besides the one under inspection, whose top-level blocks are resolved, and
26
+ defaults to `spec/support/**/*.rb`; `CheckSharedExamples: false` leaves the
27
+ `let`s inside shared blocks unchecked, as before.
28
+ - `RSpec/UnusedLet` now skips helper specs (rspec-rails `type: :helper`, or
29
+ files under `spec/helpers`) by default, since the auto-included module may
30
+ reference any `let` unseen. Set `CheckHelperSpecs: true` to check them.
31
+ - `RSpec/UnusedLet` now recognizes `let` definitions consumed by
32
+ well-known gems' shared contexts and treats them as used. Currently
33
+ supports [rspec-validator_spec_helper](https://github.com/izumin5210/rspec-validator_spec_helper):
34
+ groups tagged with `type: :validator` may override `value`,
35
+ `attribute_names`, `options` (and the helper's other overridable
36
+ lets) without being flagged.
37
+ - Fix a false positive in `RSpec/UnusedLet` when a `let` defined in a
38
+ nested group was only referenced from an ancestor group's `let`,
39
+ `subject`, or hook block. Those ancestor blocks run in the example's
40
+ scope, so their references resolve to the nested definition.
41
+ - Fix a false positive in `RSpec/UnusedLet` when a `let` was only
42
+ referenced from a plain `def` helper method defined in an ancestor
43
+ example group. Such helpers become instance methods on the example
44
+ class and can reference `let` names visible at the example.
45
+
3
46
  ## [1.1.0] - 2026-07-15
4
47
 
5
48
  - `RSpec/UnusedLet` now supports autocorrect. The correction is marked