rbs_activerecord 1.3.0 → 1.4.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 (40) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/hooks/claude-code-web-session-start.sh +14 -0
  3. data/.claude/hooks/pre-commit-check.sh +73 -0
  4. data/.claude/hooks/protect-sig-files.sh +45 -0
  5. data/.claude/hooks/rbs-inline.sh +95 -0
  6. data/.claude/hooks/self-review.sh +93 -0
  7. data/.claude/settings.json +58 -0
  8. data/.rubocop.yml +42 -5
  9. data/.vscode/settings.json +3 -1
  10. data/Rakefile +32 -4
  11. data/lib/generators/rbs_activerecord/install_generator.rb +1 -1
  12. data/lib/rbs_activerecord/generator/active_storage/instance_methods.rb +2 -1
  13. data/lib/rbs_activerecord/generator/active_storage/scopes.rb +1 -1
  14. data/lib/rbs_activerecord/generator/associations.rb +4 -4
  15. data/lib/rbs_activerecord/generator/attributes.rb +25 -4
  16. data/lib/rbs_activerecord/generator/delegated_type/instance_methods.rb +5 -5
  17. data/lib/rbs_activerecord/generator/delegated_type/scopes.rb +3 -3
  18. data/lib/rbs_activerecord/generator/enum/base.rb +2 -2
  19. data/lib/rbs_activerecord/generator/enum/instance_methods.rb +3 -3
  20. data/lib/rbs_activerecord/generator/enum/mappings.rb +2 -2
  21. data/lib/rbs_activerecord/generator/enum/scopes.rb +2 -2
  22. data/lib/rbs_activerecord/generator/scopes.rb +2 -2
  23. data/lib/rbs_activerecord/generator/secure_password.rb +1 -1
  24. data/lib/rbs_activerecord/generator.rb +1 -1
  25. data/lib/rbs_activerecord/model.rb +2 -1
  26. data/lib/rbs_activerecord/parser/evaluator.rb +1 -1
  27. data/lib/rbs_activerecord/parser/include_expander/module.rb +1 -1
  28. data/lib/rbs_activerecord/parser/include_expander.rb +4 -4
  29. data/lib/rbs_activerecord/parser/visitor.rb +1 -1
  30. data/lib/rbs_activerecord/parser.rb +2 -1
  31. data/lib/rbs_activerecord/utils.rb +6 -6
  32. data/lib/rbs_activerecord/version.rb +1 -1
  33. data/rbs_collection.lock.yaml +131 -63
  34. data/rbs_collection.yaml +1 -0
  35. data/sig/generators/rbs_activerecord/install_generator.rbs +1 -1
  36. data/sig/rbs_activerecord/generator/active_storage/instance_methods.rbs +1 -0
  37. data/sig/rbs_activerecord/generator/attributes.rbs +10 -0
  38. data/sig/rbs_activerecord/parser.rbs +2 -1
  39. data/sig/rbs_activerecord/utils.rbs +2 -2
  40. metadata +10 -7
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b56f18a5772a23292e3ae455e90f9e6d4deff43a1f1d89b0df9f80d67489218a
4
- data.tar.gz: 698af6fa420f68d4068effe5aa65fb474656b89afa18118c0dc76d70f83d937f
3
+ metadata.gz: 78a25f0014350c5542d7b2ed8eeb226c736c8f3416432261413e26f84b7d5877
4
+ data.tar.gz: 92684a1a6ac24afc2bcf1fe160ab6bf391f0ada8bd75ae337360c01d36891114
5
5
  SHA512:
6
- metadata.gz: 0e50032631a3a6f95d34de4decb79d49d673409f3d1196c670c364307ecf1f2506d05edc1fe3ee321fc80e1f643853d0d895276b75bec4545808f1788f1019da
7
- data.tar.gz: 23152212c926367241ad8e29b38ebe2b8861b8b046df7b1dff3314b49e5a84bacc1aaa70529094e8b7767e4e61d178e829dbe5b14b93ed7f7656d209eea229cd
6
+ metadata.gz: 680a2e88d185d9a7c26d124ed5b6f0240233f4199006f4eb85e1639b25abb2925fa6ac6dd623c4d25f7777eea947a137357c05e274f3d8ac02c88ee9a01e2acc
7
+ data.tar.gz: db219887c854a4f412e0d5c1ba7713dc2425c45d985e9bb9fd5872424d51a84c17d8b843db400273fe7c47baa35e690fc2fe8ca2d45bfce2cdfeaf3e0aaf13b7
@@ -0,0 +1,14 @@
1
+ #!/bin/bash
2
+ set -eu
3
+
4
+ if [ "${CLAUDE_CODE_REMOTE:-}" = "true" ]; then
5
+ eval "$(rbenv init - bash)"
6
+
7
+ # Set the latest installed Ruby version as the global default
8
+ rbenv global `rbenv versions --bare | sort -rV | head -1`
9
+
10
+ echo 'eval "$(rbenv init - bash)"' >> "$CLAUDE_ENV_FILE"
11
+ echo 'export RUBYOPT="-rcgi"' >> "$CLAUDE_ENV_FILE"
12
+ RUBYOPT="-rcgi" bundle install
13
+ bundle exec rbs collection install --frozen
14
+ fi
@@ -0,0 +1,73 @@
1
+ #!/bin/bash
2
+
3
+ # Hook input is JSON from stdin
4
+ input=$(cat)
5
+ tool_name=$(echo "$input" | jq -r '.tool_name')
6
+ command=$(echo "$input" | jq -r '.tool_input.command // ""')
7
+
8
+ # Only run for git commit commands
9
+ if [[ "$tool_name" != "Bash" ]] || [[ ! "$command" =~ git[[:space:]]+(commit|cherry-pick|merge|rebase) ]]; then
10
+ exit 0
11
+ fi
12
+
13
+ cd "$CLAUDE_PROJECT_DIR" || exit 1
14
+
15
+ if [ "${CLAUDE_CODE_REMOTE:-}" = "true" ]; then
16
+ eval "$(rbenv init - bash)"
17
+ fi
18
+
19
+ echo "Running pre-commit checks..." >&2
20
+
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
+ echo "Error: RBS generation failed" >&2
24
+ exit 2
25
+ fi
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
+
67
+ if ! bundle exec rake >&2; then
68
+ echo "Error: rake checks failed" >&2
69
+ exit 2
70
+ fi
71
+
72
+ echo "All checks passed!" >&2
73
+ exit 0
@@ -0,0 +1,45 @@
1
+ #!/bin/bash
2
+ # Prevents direct editing of auto-generated RBS type definition files.
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.
5
+
6
+ # Hook input is JSON from stdin
7
+ input=$(cat)
8
+ tool_name=$(echo "$input" | jq -r '.tool_name // ""')
9
+
10
+ # Block Write/Edit operations on sig/ files
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
28
+ fi
29
+ fi
30
+
31
+ # Block manual rbs-inline execution
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
42
+ fi
43
+ fi
44
+
45
+ exit 0
@@ -0,0 +1,95 @@
1
+ #!/bin/bash
2
+
3
+ # Hook input is JSON from stdin
4
+ input=$(cat)
5
+ tool_name=$(echo "$input" | jq -r '.tool_name')
6
+
7
+ cd "$CLAUDE_PROJECT_DIR" || exit 1
8
+
9
+ if [ "${CLAUDE_CODE_REMOTE:-}" = "true" ]; then
10
+ eval "$(rbenv init - bash)"
11
+ fi
12
+
13
+ # Handle Edit or Write tools
14
+ if [[ "$tool_name" == "Edit" || "$tool_name" == "Write" ]]; then
15
+ file_path=$(echo "$input" | jq -r '.tool_input.file_path // ""')
16
+
17
+ if [[ ! "$file_path" =~ \.rb$ ]]; then
18
+ exit 0
19
+ fi
20
+
21
+ if [[ ! "$file_path" =~ /lib/ ]]; then
22
+ exit 0
23
+ fi
24
+
25
+ echo "Running rbs-inline for $file_path..." >&2
26
+
27
+ if ! bundle exec rbs-inline --opt-out --output=sig/ "$file_path" >&2; then
28
+ echo "Warning: RBS generation failed for $file_path" >&2
29
+ exit 0
30
+ fi
31
+
32
+ echo "RBS generation completed." >&2
33
+ exit 0
34
+ fi
35
+
36
+ # Handle Bash tool for mv command
37
+ if [[ "$tool_name" == "Bash" ]]; then
38
+ command=$(echo "$input" | jq -r '.tool_input.command // ""')
39
+
40
+ # Check if this is a mv command
41
+ if [[ ! "$command" =~ ^[[:space:]]*(mv|git\ mv)[[:space:]] ]]; then
42
+ exit 0
43
+ fi
44
+
45
+ # Extract source and destination paths from mv command
46
+ paths=$(echo "$command" | sed -E 's/^[[:space:]]*(git[[:space:]]+)?mv[[:space:]]+//')
47
+
48
+ source_path=$(echo "$paths" | awk '{print $1}')
49
+ dest_path=$(echo "$paths" | awk '{print $2}')
50
+
51
+ # Check if source was a .rb file in lib/
52
+ if [[ ! "$source_path" =~ \.rb$ ]] || [[ ! "$source_path" =~ lib/ ]]; then
53
+ exit 0
54
+ fi
55
+
56
+ echo "Detected mv of Ruby file in lib/: $source_path -> $dest_path" >&2
57
+
58
+ # Calculate the corresponding .rbs file path for the source
59
+ source_rbs=$(echo "$source_path" | sed -E 's|^(.*/)?lib/|sig/|; s|\.rb$|.rbs|')
60
+
61
+ # Remove the old .rbs file if it exists
62
+ if [[ -f "$source_rbs" ]]; then
63
+ echo "Removing old RBS file: $source_rbs" >&2
64
+ rm -f "$source_rbs"
65
+ fi
66
+
67
+ # Determine the new .rb file path
68
+ if [[ -d "$dest_path" ]]; then
69
+ dest_file="$dest_path/$(basename "$source_path")"
70
+ else
71
+ dest_file="$dest_path"
72
+ fi
73
+
74
+ # Check if the destination is in lib/
75
+ if [[ ! "$dest_file" =~ lib/ ]]; then
76
+ echo "Destination is not in lib/, skipping RBS generation" >&2
77
+ exit 0
78
+ fi
79
+
80
+ # Generate RBS for the new file
81
+ if [[ -f "$dest_file" ]]; then
82
+ echo "Running rbs-inline for $dest_file..." >&2
83
+
84
+ if ! bundle exec rbs-inline --opt-out --output=sig/ "$dest_file" >&2; then
85
+ echo "Warning: RBS generation failed for $dest_file" >&2
86
+ exit 0
87
+ fi
88
+
89
+ echo "RBS generation completed." >&2
90
+ fi
91
+
92
+ exit 0
93
+ fi
94
+
95
+ exit 0
@@ -0,0 +1,93 @@
1
+ #!/bin/bash
2
+ #
3
+ # PreToolUse hook: review code before `git commit`.
4
+ #
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.
10
+ #
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.
15
+ #
16
+
17
+ set -euo pipefail
18
+
19
+ # Read JSON input from stdin
20
+ input=$(cat)
21
+
22
+ tool_name=$(echo "$input" | jq -r '.tool_name // empty')
23
+ command=$(echo "$input" | jq -r '.tool_input.command // empty')
24
+
25
+ # Only gate the Bash tool running a git commit.
26
+ if [[ "$tool_name" != "Bash" ]]; then
27
+ exit 0
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
46
+
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
58
+ exit 0
59
+ fi
60
+
61
+ hash=$(printf '%s' "$changeset" | git hash-object --stdin)
62
+ state_file="$git_dir/self-review-reviewed"
63
+
64
+ # Already surfaced for review → allow the commit.
65
+ if [[ -f "$state_file" ]] && grep -qxF "$hash" "$state_file"; then
66
+ exit 0
67
+ fi
68
+
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:
74
+
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.
77
+
78
+ Judge each finding the review returns rather than fixing it on sight. Weigh what the review
79
+ attached to it — how it graded the point, the basis it gave — and decide whether acting on it
80
+ is worth it. Where you skip a finding, say so; do not drop it silently. Where the choice needs
81
+ a requirement or an intent you do not have, leave it to the user. Once every fix you judged
82
+ necessary is in, review again; when nothing you judged worth fixing is left, run the same git
83
+ commit command again to proceed — it will be allowed through.'
84
+
85
+ jq -n --arg reason "$review_prompt" '{
86
+ "hookSpecificOutput": {
87
+ "hookEventName": "PreToolUse",
88
+ "permissionDecision": "deny",
89
+ "permissionDecisionReason": $reason
90
+ }
91
+ }'
92
+
93
+ exit 0
@@ -0,0 +1,58 @@
1
+ {
2
+ "hooks": {
3
+ "PreToolUse": [
4
+ {
5
+ "matcher": "Edit|Write|Bash",
6
+ "hooks": [
7
+ {
8
+ "type": "command",
9
+ "command": "$CLAUDE_PROJECT_DIR/.claude/hooks/protect-sig-files.sh",
10
+ "timeout": 5000
11
+ }
12
+ ]
13
+ },
14
+ {
15
+ "matcher": "Bash",
16
+ "hooks": [
17
+ {
18
+ "type": "command",
19
+ "command": "$CLAUDE_PROJECT_DIR/.claude/hooks/pre-commit-check.sh",
20
+ "timeout": 300000
21
+ }
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
+ ]
33
+ }
34
+ ],
35
+ "SessionStart": [
36
+ {
37
+ "hooks": [
38
+ {
39
+ "type": "command",
40
+ "command": "$CLAUDE_PROJECT_DIR/.claude/hooks/claude-code-web-session-start.sh"
41
+ }
42
+ ]
43
+ }
44
+ ],
45
+ "PostToolUse": [
46
+ {
47
+ "matcher": "Edit|Write|Bash",
48
+ "hooks": [
49
+ {
50
+ "type": "command",
51
+ "command": "$CLAUDE_PROJECT_DIR/.claude/hooks/rbs-inline.sh",
52
+ "timeout": 60000
53
+ }
54
+ ]
55
+ }
56
+ ]
57
+ }
58
+ }
data/.rubocop.yml CHANGED
@@ -1,24 +1,48 @@
1
+ # NOTE: Rules are sorted in ASCII order.
2
+
1
3
  AllCops:
2
4
  NewCops: enable
3
- TargetRubyVersion: 3.2
5
+ TargetRubyVersion: 3.3
4
6
 
5
7
  plugins:
8
+ - rubocop-instance_variable_access
9
+ - rubocop-numbered-params
6
10
  - rubocop-rake
11
+ - rubocop-rbs_inline
7
12
  - rubocop-rspec
13
+ - rubocop-rspec-unused-let
8
14
 
15
+ # Layout
9
16
  Layout/LeadingCommentSpace:
10
17
  AllowRBSInlineAnnotation: true
11
18
  AllowSteepAnnotation: true
12
19
 
20
+ Layout/LineLength:
21
+ Max: 120
22
+
23
+ # Metrics
24
+ Metrics/AbcSize:
25
+ Max: 20
26
+
13
27
  Metrics/BlockLength:
14
28
  Exclude:
15
29
  - "spec/**/*.rb"
16
30
 
31
+ Metrics/ClassLength:
32
+ Max: 200
33
+
17
34
  Metrics/MethodLength:
18
35
  Max: 30
19
36
 
37
+ # RSpec
20
38
  RSpec/ExampleLength:
21
- Max: 20
39
+ Enabled: false
40
+
41
+ RSpec/MultipleExpectations:
42
+ Enabled: false
43
+
44
+ RSpec/MultipleMemoizedHelpers:
45
+ Max: 10
22
46
 
23
47
  RSpec/NamedSubject:
24
48
  Enabled: false
@@ -26,15 +50,28 @@ RSpec/NamedSubject:
26
50
  RSpec/NestedGroups:
27
51
  Max: 5
28
52
 
29
- RSpec/MultipleExpectations:
53
+ # Style
54
+ Style/AccessorGrouping:
30
55
  Enabled: false
31
56
 
32
- RSpec/MultipleMemoizedHelpers:
33
- Max: 10
57
+ Style/CommentedKeyword:
58
+ Enabled: false
34
59
 
35
60
  Style/Documentation:
36
61
  Enabled: false
37
62
 
63
+ Style/HashSyntax:
64
+ EnforcedShorthandSyntax: always
65
+
66
+ Style/RbsInline:
67
+ Mode: opt_out
68
+
69
+ Style/RbsInline/MissingTypeAnnotation:
70
+ EnforcedStyle: doc_style_and_return_annotation
71
+
72
+ Style/RbsInline/RedundantTypeAnnotation:
73
+ EnforcedStyle: doc_style_and_return_annotation
74
+
38
75
  Style/RedundantFormat:
39
76
  Enabled: false
40
77
 
@@ -25,7 +25,9 @@
25
25
  "requireds",
26
26
  "rmtree",
27
27
  "rubocop",
28
+ "rubygems",
28
29
  "Takeshi",
29
- "tasklib"
30
+ "tasklib",
31
+ "tk0miya"
30
32
  ]
31
33
  }
data/Rakefile CHANGED
@@ -1,15 +1,43 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "bundler/gem_tasks"
4
+ require "rspec/core/rake_task"
4
5
  require "rubocop/rake_task"
5
6
 
6
7
  RuboCop::RakeTask.new
8
+ RSpec::Core::RakeTask.new(:spec)
7
9
 
8
- task default: :rubocop
10
+ task default: :ci
11
+
12
+ task ci: %i[rubocop spec steep rbs:validate]
9
13
 
10
14
  namespace :rbs do
11
- desc "Generate RBS files"
12
- task :generate do
13
- sh "rbs-inline", "--opt-out", "--output=sig", "lib"
15
+ # sig/gems holds hand-written types for external gems, so it is never regenerated
16
+ kept = ->(path) { path == "sig/gems" || path.start_with?("sig/gems/") }
17
+
18
+ desc "Install RBS collection"
19
+ task :install do
20
+ sh "bundle exec rbs collection install --frozen"
21
+ end
22
+
23
+ desc "Regenerate all RBS files under sig/ from scratch (run manually after editing lib/)"
24
+ task :regenerate do
25
+ Dir.glob("sig/**/*.rbs").reject(&kept).each { rm _1 }
26
+
27
+ sh "bundle exec rbs-inline --opt-out --output=sig lib"
28
+
29
+ # Drop the directories the deletion above left empty, deepest first
30
+ dirs = Dir.glob("sig/**/*").select { File.directory?(_1) }
31
+ dirs.reject(&kept).sort.reverse_each { rmdir _1 if Dir.empty?(_1) }
32
+ end
33
+
34
+ desc "Validate RBS files"
35
+ task validate: "rbs:install" do
36
+ sh "bundle exec rbs -Isig validate"
14
37
  end
15
38
  end
39
+
40
+ desc "Run steep type check"
41
+ task steep: "rbs:install" do
42
+ sh "bundle exec steep check"
43
+ end
@@ -4,7 +4,7 @@ require "rails"
4
4
 
5
5
  module RbsActiverecord
6
6
  class InstallGenerator < Rails::Generators::Base
7
- def create_raketask
7
+ def create_raketask #: void
8
8
  create_file "lib/tasks/rbs_activerecord.rake", <<~RUBY
9
9
  # frozen_string_literal: true
10
10
 
@@ -23,13 +23,14 @@ module RbsActiverecord
23
23
 
24
24
  def attachments #: Hash[String, untyped]
25
25
  if model.klass.respond_to?(:attachment_reflections)
26
- model.klass.attachment_reflections # steep:ignore
26
+ model.klass.attachment_reflections
27
27
  else
28
28
  {}
29
29
  end
30
30
  end
31
31
 
32
32
  # @rbs name: String
33
+ # @rbs reflection: untyped
33
34
  def attachment(name, reflection) #: String
34
35
  case reflection.macro
35
36
  when :has_one_attached
@@ -23,7 +23,7 @@ module RbsActiverecord
23
23
 
24
24
  def attachments #: Hash[String, untyped]
25
25
  if model.klass.respond_to?(:attachment_reflections)
26
- model.klass.attachment_reflections # steep:ignore
26
+ model.klass.attachment_reflections
27
27
  else
28
28
  {}
29
29
  end
@@ -25,7 +25,7 @@ module RbsActiverecord
25
25
 
26
26
  private
27
27
 
28
- def has_many #: String # rubocop:disable Naming/PredicateName
28
+ def has_many #: String # rubocop:disable Naming/PredicatePrefix
29
29
  model.reflect_on_all_associations(:has_many).map do |assoc|
30
30
  assoc_name = assoc.name.to_s
31
31
  klass_name = "::#{assoc.klass.name}"
@@ -41,7 +41,7 @@ module RbsActiverecord
41
41
  end.join("\n")
42
42
  end
43
43
 
44
- def has_one #: String # rubocop:disable Naming/PredicateName
44
+ def has_one #: String # rubocop:disable Naming/PredicatePrefix
45
45
  model.reflect_on_all_associations(:has_one).map do |assoc|
46
46
  type = "::#{assoc.klass.name}"
47
47
  optional = "#{type}?"
@@ -85,7 +85,7 @@ module RbsActiverecord
85
85
  def polymorphic_owner_types(assoc) #: String # rubocop:disable Metrics/AbcSize
86
86
  table_name = model.klass.name.to_s.tableize.to_sym
87
87
  owners = ActiveRecord::Base.descendants.select do |klass|
88
- klass.reflect_on_all_associations.any? { |a| a.name == table_name && a.options[:as] == assoc.name }
88
+ klass.reflect_on_all_associations.any? { _1.name == table_name && _1.options[:as] == assoc.name }
89
89
  end
90
90
 
91
91
  if owners.empty?
@@ -98,7 +98,7 @@ module RbsActiverecord
98
98
  end
99
99
  end
100
100
 
101
- def has_and_belongs_to_many #: String # rubocop:disable Naming/PredicateName
101
+ def has_and_belongs_to_many #: String # rubocop:disable Naming/PredicatePrefix
102
102
  model.reflect_on_all_associations(:has_and_belongs_to_many).map do |assoc|
103
103
  assoc_name = assoc.name.to_s
104
104
  klass_name = assoc.klass.name
@@ -18,7 +18,7 @@ module RbsActiverecord
18
18
  def generate #: String
19
19
  <<~RBS
20
20
  module GeneratedAttributeMethods
21
- #{model.columns.map { |c| column(c) }.join("\n")}
21
+ #{model.columns.map { column(_1) }.join("\n")}
22
22
  #{attributes.map { |name, type| attribute(name, type) }.join("\n")}
23
23
  #{model.attribute_aliases.map { |from, to| alias_method(from, to) }.join("\n")}
24
24
  end
@@ -27,15 +27,17 @@ module RbsActiverecord
27
27
 
28
28
  private
29
29
 
30
+ # @rbs col: untyped
30
31
  def column(col) #: String # rubocop:disable Metrics/AbcSize
31
32
  type = sql_type_to_class(col.type)
32
33
  optional = "#{type}?"
33
34
  column_type = col.null ? optional : type
35
+ writer_type = writer_type_for(col.type, nullable: col.null)
34
36
 
35
37
  <<~RBS
36
38
  #{"%a{pure}" if pure_accessors}
37
39
  def #{col.name}: () -> #{column_type}
38
- def #{col.name}=: (#{column_type}) -> #{column_type}
40
+ def #{col.name}=: (#{writer_type}) -> #{writer_type}
39
41
  def #{col.name}?: () -> bool
40
42
  def #{col.name}_changed?: () -> bool
41
43
  def #{col.name}_change: () -> [#{optional}, #{optional}]
@@ -57,7 +59,7 @@ module RbsActiverecord
57
59
 
58
60
  def attributes #: Hash[String, untyped]
59
61
  model.attribute_types.filter_map do |name, type|
60
- [name, type] if model.columns.none? { |col| col.name == name }
62
+ [name, type] if model.columns.none? { _1.name == name }
61
63
  end.to_h
62
64
  end
63
65
 
@@ -65,13 +67,32 @@ module RbsActiverecord
65
67
  # @rbs type: untyped
66
68
  def attribute(name, type) #: String
67
69
  column_type = sql_type_to_class(type.type)
70
+ writer_type = writer_type_for(type.type, nullable: false)
68
71
 
69
72
  <<~RBS
70
73
  def #{name}: () -> #{column_type}
71
- def #{name}=: (#{column_type}) -> #{column_type}
74
+ def #{name}=: (#{writer_type}) -> #{writer_type}
72
75
  RBS
73
76
  end
74
77
 
78
+ # The type accepted by the attribute writer.
79
+ # It is wider than the reader type for the types ActiveRecord casts on assignment.
80
+ #
81
+ # @rbs type: Symbol
82
+ # @rbs nullable: bool
83
+ def writer_type_for(type, nullable:) #: String
84
+ writer_type = case type
85
+ when :datetime
86
+ # A datetime attribute also accepts ::Time and converts it to ::ActiveSupport::TimeWithZone
87
+ "(::ActiveSupport::TimeWithZone | ::Time)"
88
+ else
89
+ sql_type_to_class(type)
90
+ end
91
+ nullable ? "#{writer_type}?" : writer_type
92
+ end
93
+
94
+ # @rbs from: untyped
95
+ # @rbs to: untyped
75
96
  def alias_method(from, to) #: String
76
97
  <<~RBS
77
98
  alias #{from} #{to}