rubocop-legion 0.1.7 → 0.1.8

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: cd43ed49ee3f7013316b5650cfb288d60fd6e1807532acb1f8978865a39a519e
4
- data.tar.gz: a620ac1fc4debec154a69eb72a75b5df1c0a2fb8f7974e5a29fbefed46604549
3
+ metadata.gz: '0668cc51aab49685f7ecb368113a7103253ec4d3aca22de2970c88ff4ae351f3'
4
+ data.tar.gz: abc9644c2e563387778df1459aa7abd81f49e79c14e7cc13e038546ceab40a56
5
5
  SHA512:
6
- metadata.gz: bf447d4891c6c75429b9f0fe92a3f3fc441c11782d6bf97d9b57cc45c1ac77fbbf5314319f9aa2af9681701d4af99a609a8d56cddd63415a01db17bc6ae35ff3
7
- data.tar.gz: 239ebbc2294b1ac2b366894ea17d7699a3c3532d672677d34cf8c562497bc2a51367ca6f01984af291d955d310df7fbb95bf257085bb5a37531fa10fb038ae7f
6
+ metadata.gz: 43d9efec4fb9f8fc5af8eced138ed626c29211017382b4c71b84f3191b2b5316624e64821e657cf4e8a61426f51404c147c32522d623c3cce3c50d51f9738afe
7
+ data.tar.gz: 17807228e3e91cd2e4ac0be778a469256adbe691e7b0f1b6399344cb6d1ecffe6e699a5db7ffcf274a42d1a0df531842b32e218f89230d1ae1ced828ef733fbd
data/.rubocop.yml CHANGED
@@ -9,6 +9,15 @@ Style/Documentation:
9
9
  Metrics/MethodLength:
10
10
  Max: 20
11
11
 
12
+ Metrics/AbcSize:
13
+ Max: 35
14
+
15
+ Metrics/CyclomaticComplexity:
16
+ Max: 20
17
+
18
+ Metrics/PerceivedComplexity:
19
+ Max: 20
20
+
12
21
  Naming/FileName:
13
22
  Exclude:
14
23
  - 'lib/rubocop-legion.rb'
data/CHANGELOG.md CHANGED
@@ -1,5 +1,13 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.1.8] - 2026-06-16
4
+
5
+ ### Added
6
+ - New cop `Legion/Framework/NoUnderscorePrefixedKwargs`: ban `_foo:` keyword arguments and `**_rest` splats; autocorrects to plain names / `**opts`
7
+ - New cop `Legion/Framework/NoInlineSettingDefaults`: flag `Legion::Settings[...] || <literal>` and shadow-default patterns (tunables belong in `settings.rb`)
8
+ - New cop `Legion/Framework/NoDirectDispatch`: ban `*_direct` methods on `Legion::LLM` modules (every pipeline exit must route through the governed pipeline)
9
+ - New cop `Legion/Framework/NoShapeDuckTyping` (disabled by default): flag `respond_to?` on canonical shape methods and string-key fallback access on canonical bodies
10
+
3
11
  ## [0.1.7] - 2026-03-29
4
12
 
5
13
  ### Added
data/CLAUDE.md CHANGED
@@ -1,98 +1,52 @@
1
1
  # rubocop-legion
2
2
 
3
- **Parent**: `/Users/miverso2/rubymine/legion/CLAUDE.md`
4
-
5
- ## What is This?
6
-
7
- Custom RuboCop plugin gem for the LegionIO ecosystem. Provides 47 AST-based cops across 6 departments. Uses the new RuboCop Plugin API (1.72+, lint_roller-based) with auto-discovery via gemspec metadata.
8
-
9
- **GitHub**: https://github.com/LegionIO/rubocop-legion
10
- **RubyGems**: https://rubygems.org/gems/rubocop-legion
11
- **License**: MIT
3
+ Custom RuboCop plugin gem for the LegionIO ecosystem. Provides 47 AST-based cops across 6 departments. Uses the RuboCop Plugin API (1.72+, lint_roller-based) with auto-discovery via gemspec metadata.
12
4
 
13
5
  ## Shared Config Profiles
14
6
 
15
- The gem ships shared `.rubocop.yml` profiles so repos don't duplicate config:
16
-
17
7
  - `config/base.yml` — all shared settings (AllCops, Layout, Metrics, Style, Naming, Performance, ThreadSafety)
18
- - `config/lex.yml` — inherits base, adds plugins (rubocop-legion + performance + thread_safety) + `ParameterLists Max: 8`
19
- - `config/core.yml` — inherits base, adds plugins (rubocop-legion + performance + thread_safety) + `ParameterLists Max: 10, CountKeywordArgs: false`
20
-
21
- ### Bundled Plugins
8
+ - `config/lex.yml` — inherits base, adds plugins + `ParameterLists Max: 8`
9
+ - `config/core.yml` — inherits base, adds plugins + `ParameterLists Max: 10, CountKeywordArgs: false`
22
10
 
23
- Both profiles load `rubocop-performance` (52 cops) and `rubocop-thread_safety` (6 cops) as runtime dependencies. ThreadSafety defaults tuned for LegionIO: `NewThread` excludes service/connection files, `ClassInstanceVariable` excludes singletons, `RackMiddlewareInstanceVariable` disabled, `DirChdir` allows block form.
11
+ Both profiles load `rubocop-performance` (52 cops) and `rubocop-thread_safety` (6 cops) as runtime dependencies.
24
12
 
25
13
  **LEX repos**: `inherit_gem: { rubocop-legion: config/lex.yml }`
26
14
  **Core repos**: `inherit_gem: { rubocop-legion: config/core.yml }`
27
15
 
28
- Repo-specific overrides go below the `inherit_gem` directive. Version-locked with the gem — bump gem version = all repos get updated config.
29
-
30
- ## Cop Scoping
31
-
32
- Cops are scoped by gem type — no per-repo configuration needed:
33
-
34
- - **Universal** (9 cops): Fire on all LegionIO gems
35
- - **Library-specific** (6 cops): Fire on all gems but only trigger when using Sequel, Sinatra, Thor, Faraday, or cache
36
- - **LEX-only** (31 cops): Scoped to `lib/legion/extensions/**/*.rb` via Include directive — never fire on core `legion-*` libraries
37
-
38
- ## Departments and Cops
39
-
40
- ### Universal — 10 cops
16
+ ## Cop Departments
41
17
 
42
- - **ConstantSafety** (4): `BareDataDefine`, `BareProcess`, `BareJson` (all error, auto-fix) — prefix with `::` inside `module Legion`. `InheritParam` (convention, auto-fix) — pass `false` to `const_defined?`/`const_get`.
43
- - **RescueLogging** (3): `BareRescue` (warning, auto-fix) — capture with `=> e`. `NoCapture` (convention, no auto-fix) — exception class without capture. `SilentCapture` (warning, no auto-fix) — captured but never logged/re-raised. Skips `_`-prefixed vars. All skip inline rescue modifiers.
44
- - **Singleton** (1): `UseInstance` (error, auto-fix) — `.instance` not `.new` for configurable singleton classes.
45
- - **Framework/MutexNestedSync** (1): Nested `synchronize` blocks risk deadlock.
46
- - **Framework/ModuleFunctionPrivate** (1): `private` after `module_function` resets visibility.
18
+ | Department | Count | Scope |
19
+ |------------|-------|-------|
20
+ | ConstantSafety | 4 | Universal |
21
+ | RescueLogging | 3 | Universal |
22
+ | Singleton | 1 | Universal |
23
+ | Framework | 8 | Universal + Library-specific |
24
+ | HelperMigration | 13 | LEX-only |
25
+ | Extension | 18 | LEX-only |
47
26
 
48
- ### Library-Specific6 cops
49
-
50
- - `EagerSequelModel` — `Sequel::Model(:table)` at require time
51
- - `SinatraHostAuth` — Sinatra 4.0+ `set :host_authorization`
52
- - `ThorReservedRun` — Thor 1.5+ reserves `run`
53
- - `FaradayXmlMiddleware` — Faraday 2.0+ removed `:xml`
54
- - `CacheTimeCoercion` — Time→String after cache round-trip
55
- - `ApiStringKeys` — `Legion::JSON.load` returns symbol keys (scoped to `lib/legion/extensions/**/*.rb`)
56
-
57
- ### LEX-Only — 29 cops
58
-
59
- - **HelperMigration** (13): `DirectLogging`, `OldLoggingMethods`, `DirectJson`, `DirectCache`, `DirectLocalCache`, `DirectCrypt`, `DirectData`, `DirectLlm`, `DirectTransport`, `DirectKnowledge`, `RequireDefinedGuard` (all auto-fix) — use per-extension helpers, not global singletons. `LoggingGuard` (no auto-fix) — remove unnecessary `respond_to?(:log_warn)` / `defined?(Legion::Logging)` guards. `DefinedTransportGuard` (no auto-fix) — use `transport_connected?` instead of `defined?(Legion::Transport)`.
60
- - **Extension** (18): `ActorSingularModule` (auto-fix), `RunnerPluralModule` (auto-fix), `CoreExtendGuard` (auto-fix), `RunnerMustBeModule`, `RunnerIncludeHelpers`, `ActorInheritance`, `EveryActorRequiresTime`, `SelfContainedActorRunnerClass`, `HookMissingRunnerClass`, `AbsorberMissingPattern`, `AbsorberMissingAbsorbMethod`, `DefinitionCallMismatched`, `RunnerReturnHash`, `SettingsKeyMethod` (auto-fix), `SettingsBracketMultiArg` (auto-fix), `LlmAskKwargs`, `ActorEnabledSideEffects`, `DataRequiredWithoutMigrations`.
27
+ Cops are scoped by gem type no per-repo configuration needed. LEX-only cops fire on `lib/legion/extensions/**/*.rb` via Include directive.
61
28
 
62
29
  ## Architecture
63
30
 
64
31
  ```
65
32
  rubocop-legion/
66
33
  ├── lib/
67
- │ ├── rubocop-legion.rb # Entry point, requires all cops
34
+ │ ├── rubocop-legion.rb # Entry point
68
35
  │ └── rubocop/
69
- │ ├── legion.rb # Namespace declarations
70
36
  │ ├── legion/
71
- │ │ ├── version.rb
72
37
  │ │ └── plugin.rb # LintRoller::Plugin (auto-discovery)
73
38
  │ └── cop/legion/
74
39
  │ ├── helper_migration/ # 13 cops (lex-only)
75
40
  │ ├── constant_safety/ # 4 cops (universal)
76
41
  │ ├── singleton/ # 1 cop (universal)
77
42
  │ ├── rescue_logging/ # 3 cops (universal)
78
- │ ├── framework/ # 7 cops (universal + library-specific)
79
- │ └── extension/ # 17 cops (lex-only)
43
+ │ ├── framework/ # 8 cops (universal + library-specific)
44
+ │ └── extension/ # 18 cops (lex-only)
80
45
  ├── config/
81
46
  │ └── default.yml # All cop defaults, Include/Exclude scoping
82
47
  └── spec/ # mirrors lib/ structure
83
48
  ```
84
49
 
85
- ## Key Implementation Details
86
-
87
- - Plugin entry point: `RuboCop::Legion::Plugin` (LintRoller-based, registered via gemspec metadata `default_lint_roller_plugin`)
88
- - All cops inherit from `RuboCop::Cop::Base`
89
- - Auto-correctable cops use `extend AutoCorrector`
90
- - AST matching via `def_node_matcher` and `def_node_search`
91
- - Specs use `RuboCop::RSpec::Support` with `:config` shared context and `expect_offense`/`expect_correction`
92
- - `BareRescue` and `NoCapture` skip rescue modifiers (inline `rescue`) to avoid syntax corruption
93
- - `NoCapture` has no auto-correct to prevent correction loop with `Lint/UselessAssignment`
94
- - `SilentCapture` skips `_`-prefixed variables (Ruby unused convention)
95
-
96
50
  ## Development
97
51
 
98
52
  ```bash
@@ -100,15 +54,3 @@ bundle install
100
54
  bundle exec rspec # 350 specs
101
55
  bundle exec rubocop # Self-linting
102
56
  ```
103
-
104
- ## Common Per-Repo Overrides
105
-
106
- ```yaml
107
- # Repos using Faraday JSON middleware (string keys, not Legion::JSON symbol keys)
108
- Legion/Framework/ApiStringKeys:
109
- Enabled: false
110
- ```
111
-
112
- ---
113
-
114
- **Maintained By**: Matthew Iverson (@Esity)
data/config/default.yml CHANGED
@@ -336,3 +336,30 @@ Legion/Extension/DefinitionCallMismatched:
336
336
  Enabled: true
337
337
  Severity: error
338
338
  VersionAdded: '0.1.4'
339
+
340
+ # Legion/Framework — N×N routing guard cops (Phase 6 enforcement)
341
+
342
+ Legion/Framework/NoUnderscorePrefixedKwargs:
343
+ Description: 'Ban underscore-prefixed kwargs and `**_rest` splats. Use plain kwarg, defaulted kwarg, or `**opts`.'
344
+ Enabled: true
345
+ Severity: warning
346
+ VersionAdded: '0.1.8'
347
+
348
+ Legion/Framework/NoInlineSettingDefaults:
349
+ Description: 'Flag `Legion::Settings[...] || <literal>` and shadow-default patterns. Defaults belong in `settings.rb`.'
350
+ Enabled: true
351
+ Severity: warning
352
+ VersionAdded: '0.1.8'
353
+
354
+ Legion/Framework/NoDirectDispatch:
355
+ Description: 'Ban `_direct` methods on `Legion::LLM` modules. All inference routes through the governed pipeline.'
356
+ Enabled: true
357
+ Severity: error
358
+ VersionAdded: '0.1.8'
359
+
360
+ Legion/Framework/NoShapeDuckTyping:
361
+ Description: 'Flag `respond_to?` on canonical shape methods and string-key fallback access. Disabled by default.'
362
+ Enabled: false
363
+ Severity: convention
364
+ VersionAdded: '0.1.8'
365
+ Safe: false
@@ -59,7 +59,11 @@ module RuboCop
59
59
 
60
60
  node.body.each_node(:send).any? do |send_node|
61
61
  send_node.method_name == :time && send_node.receiver.nil?
62
- end
62
+ end || defines_time_method?(node)
63
+ end
64
+
65
+ def defines_time_method?(node)
66
+ node.body.each_node(:def).any? { |def_node| def_node.method_name == :time }
63
67
  end
64
68
  end
65
69
  end
@@ -0,0 +1,109 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RuboCop
4
+ module Cop
5
+ module Legion
6
+ module Framework
7
+ # Bans defining methods matching `/_direct\z/` on `Legion::LLM` modules.
8
+ # Per G16, all pipeline bypasses (`chat_direct`, `embed_direct`,
9
+ # `structured_direct`) are removed — internal callers route through the
10
+ # governed pipeline with an appropriate profile.
11
+ #
12
+ # No auto-correct is provided because the fix requires routing through
13
+ # the inference pipeline.
14
+ #
15
+ # @example
16
+ # # bad
17
+ # module Legion::LLM
18
+ # def chat_direct(message:)
19
+ # # bypasses metering, audit, ledger
20
+ # end
21
+ # end
22
+ #
23
+ # # bad
24
+ # module Legion::LLM
25
+ # def self.embed_direct(text:)
26
+ # end
27
+ # end
28
+ #
29
+ # # good
30
+ # module Legion::LLM
31
+ # def chat(message:)
32
+ # # routes through pipeline
33
+ # end
34
+ # end
35
+ class NoDirectDispatch < Base
36
+ MSG = 'Method `%<name>s` matches `/_direct\\z/` on a `Legion::LLM` module. ' \
37
+ 'Pipeline bypasses are not allowed — route through the governed pipeline.'
38
+
39
+ DIRECT_PATTERN = /_direct\z/
40
+
41
+ def on_def(node)
42
+ return unless node.method_name.to_s.match?(DIRECT_PATTERN)
43
+ return unless inside_legion_llm?(node)
44
+
45
+ add_offense(node.loc.name, message: format(MSG, name: node.method_name))
46
+ end
47
+
48
+ def on_defs(node)
49
+ return unless node.method_name.to_s.match?(DIRECT_PATTERN)
50
+ return unless inside_legion_llm?(node)
51
+
52
+ add_offense(node.loc.name, message: format(MSG, name: node.method_name))
53
+ end
54
+
55
+ private
56
+
57
+ def inside_legion_llm?(node)
58
+ node.each_ancestor(:module, :class).any? do |ancestor|
59
+ legion_llm_module?(ancestor)
60
+ end
61
+ end
62
+
63
+ def legion_llm_module?(mod_node)
64
+ return false unless mod_node.module_type?
65
+
66
+ # Check compact form: Legion::LLM or Legion::LLM::Something
67
+ ident = mod_node.identifier
68
+ return true if compact_legion_llm?(ident)
69
+
70
+ # Check nested form: module Legion; module LLM; ...
71
+ # The identifier is just `LLM` but parent is `module Legion`
72
+ return true if nested_legion_llm?(mod_node)
73
+
74
+ false
75
+ end
76
+
77
+ def compact_legion_llm?(ident)
78
+ parts = const_parts(ident)
79
+ parts.length >= 2 && parts[0] == :Legion && parts[1] == :LLM
80
+ end
81
+
82
+ def nested_legion_llm?(mod_node)
83
+ return false unless mod_node.identifier.const_type?
84
+ return false unless mod_node.identifier.children.last == :LLM
85
+
86
+ # Check if parent module is `Legion`
87
+ parent = mod_node.parent
88
+ return false unless parent&.module_type?
89
+ return false unless parent&.identifier&.const_type?
90
+
91
+ parent.identifier.children.last == :Legion
92
+ end
93
+
94
+ def const_parts(node)
95
+ return [] unless node&.const_type?
96
+
97
+ child = node.children.first
98
+ name = node.children.last
99
+ if child&.const_type?
100
+ const_parts(child) + [name]
101
+ else
102
+ [name]
103
+ end
104
+ end
105
+ end
106
+ end
107
+ end
108
+ end
109
+ end
@@ -0,0 +1,110 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RuboCop
4
+ module Cop
5
+ module Legion
6
+ module Framework
7
+ # Detects `Legion::Settings[...]` reads combined with inline literal
8
+ # fallbacks (`|| <literal>`) or shadow-default patterns
9
+ # (`x = N unless x.positive?`). Per G13, all defaults belong in
10
+ # `settings.rb` — inline fallbacks are dead code after
11
+ # `register_defaults!` merge.
12
+ #
13
+ # No auto-correct is provided because the fix requires moving the
14
+ # literal into the appropriate `*_defaults` group in settings.rb.
15
+ #
16
+ # @example
17
+ # # bad
18
+ # max_retries = Legion::Settings[:llm][:max_retries] || 3
19
+ # timeout = Legion::Settings[:timeout] || 30
20
+ #
21
+ # # bad (shadow default)
22
+ # max_retries = Legion::Settings[:llm][:max_retries]
23
+ # max_retries = 200 unless max_retries.positive?
24
+ #
25
+ # # good
26
+ # max_retries = Legion::Settings[:llm][:max_retries]
27
+ class NoInlineSettingDefaults < Base
28
+ MSG_OR = 'Inline default `%<default>s` after `Legion::Settings` read. ' \
29
+ 'Move the default into `settings.rb` instead.'
30
+ MSG_SHADOW = 'Shadow-default pattern after `Legion::Settings` read. ' \
31
+ 'Move the default into `settings.rb` instead.'
32
+
33
+ def on_or(node)
34
+ left = node.children.first
35
+ right = node.children.last
36
+ return unless settings_read?(left)
37
+ return unless right && literal?(right)
38
+
39
+ add_offense(node, message: format(MSG_OR, default: right.source))
40
+ end
41
+
42
+ def on_if(node)
43
+ cond = node.condition
44
+ return unless cond&.send_type?
45
+
46
+ receiver = cond.receiver
47
+ return unless receiver&.lvar_type?
48
+
49
+ method_name = cond.method_name
50
+ return unless %i[positive? nil? zero? empty? blank?].include?(method_name)
51
+
52
+ var_name = receiver.children.first
53
+ return unless preceded_by_settings_read?(node, var_name)
54
+
55
+ add_offense(node, message: MSG_SHADOW)
56
+ end
57
+
58
+ private
59
+
60
+ def settings_read?(node)
61
+ return false unless node&.send_type?
62
+ return true if direct_settings_access?(node)
63
+ return true if nested_settings_access?(node)
64
+
65
+ false
66
+ end
67
+
68
+ def direct_settings_access?(node)
69
+ node.method_name == :[] &&
70
+ node.receiver&.const_type? &&
71
+ node.receiver.children.last == :Settings &&
72
+ legion_const?(node.receiver.children.first)
73
+ end
74
+
75
+ def nested_settings_access?(node)
76
+ node.method_name == :[] &&
77
+ node.receiver&.send_type? &&
78
+ node.receiver.method_name == :[] &&
79
+ direct_settings_access?(node.receiver)
80
+ end
81
+
82
+ def legion_const?(node)
83
+ return true if node.nil?
84
+ return false unless node.const_type?
85
+
86
+ node.children.last == :Legion
87
+ end
88
+
89
+ def literal?(node)
90
+ %i[str int float sym].include?(node.type)
91
+ end
92
+
93
+ def preceded_by_settings_read?(if_node, var_name)
94
+ parent = if_node.parent
95
+ return false unless parent&.begin_type?
96
+
97
+ idx = parent.children.index(if_node)
98
+ return false unless idx&.positive?
99
+
100
+ prev = parent.children[idx - 1]
101
+ return false unless prev&.lvasgn_type?
102
+ return false unless prev.children.first == var_name
103
+
104
+ settings_read?(prev.children.last)
105
+ end
106
+ end
107
+ end
108
+ end
109
+ end
110
+ end
@@ -0,0 +1,100 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RuboCop
4
+ module Cop
5
+ module Legion
6
+ module Framework
7
+ # Flags `respond_to?` checks for canonical response shape methods
8
+ # (`thinking`, `tool_calls`, `content`, `stop_reason`) and string-key
9
+ # fallback access (`x[:k] || x['k']`). Per R10, downstream consumers
10
+ # of canonical types should rely on the struct interface, not duck-type.
11
+ #
12
+ # Ships **disabled by default**. Enable per-repo or per-directory once
13
+ # the canonical migration is complete.
14
+ #
15
+ # @example
16
+ # # bad
17
+ # response.respond_to?(:thinking)
18
+ # response.respond_to?(:tool_calls)
19
+ # tc[:name] || tc['name']
20
+ #
21
+ # # good
22
+ # response.thinking
23
+ # response.tool_calls
24
+ # tc[:name]
25
+ class NoShapeDuckTyping < Base
26
+ MSG_RESPOND = 'Use canonical struct access instead of `respond_to?(:%<method>s)`. ' \
27
+ 'Downstream of translators, the shape is guaranteed.'
28
+ MSG_FALLBACK = 'String-key fallback `%<key>s` suggests duck-typed access. ' \
29
+ 'Use canonical struct access or symbol keys consistently.'
30
+
31
+ CANONICAL_METHODS = %w[thinking tool_calls content stop_reason].freeze
32
+
33
+ def on_send(node)
34
+ check_respond_to(node)
35
+ end
36
+
37
+ def on_or(node)
38
+ check_string_key_fallback(node)
39
+ end
40
+
41
+ private
42
+
43
+ def check_respond_to(node)
44
+ return unless node.method_name == :respond_to?
45
+ return unless node.arguments.length == 1
46
+
47
+ arg = node.arguments.first
48
+ method_name = case arg.type
49
+ when :sym then arg.value.to_s
50
+ when :str then arg.value
51
+ else return
52
+ end
53
+
54
+ return unless CANONICAL_METHODS.include?(method_name)
55
+
56
+ add_offense(node, message: format(MSG_RESPOND, method: method_name))
57
+ end
58
+
59
+ def check_string_key_fallback(node)
60
+ left = node.children.first
61
+ right = node.children.last
62
+ return unless left&.send_type? && right&.send_type?
63
+ return unless left.method_name == :[] && right.method_name == :[]
64
+
65
+ # Both must access the same receiver
66
+ left_recv = left.receiver
67
+ right_recv = right.receiver
68
+ return unless left_recv && right_recv
69
+ return unless same_receiver?(left_recv, right_recv)
70
+
71
+ # One must be sym, the other str
72
+ left_key = left.arguments.first
73
+ right_key = right.arguments.first
74
+ return unless left_key && right_key
75
+
76
+ has_sym = left_key.sym_type? || right_key.sym_type?
77
+ has_str = left_key.str_type? || right_key.str_type?
78
+ return unless has_sym && has_str
79
+
80
+ # Extract the key name from the symbol side
81
+ key_node = left_key.sym_type? ? left_key : right_key
82
+ return unless key_node
83
+
84
+ add_offense(node, message: format(MSG_FALLBACK, key: key_node.value))
85
+ end
86
+
87
+ def same_receiver?(left, right)
88
+ # Both lvars with same name
89
+ return true if left.lvar_type? && right.lvar_type? && left.children.first == right.children.first
90
+
91
+ # Both sends with same method name and no receiver (bare identifier)
92
+ left.send_type? && right.send_type? &&
93
+ left.receiver.nil? && right.receiver.nil? &&
94
+ left.method_name == right.method_name
95
+ end
96
+ end
97
+ end
98
+ end
99
+ end
100
+ end
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RuboCop
4
+ module Cop
5
+ module Legion
6
+ module Framework
7
+ # Bans underscore-prefixed keyword arguments and `**_rest`/`**_` splat
8
+ # parameters in method definitions. The N×N routing design requires
9
+ # explicit kwarg signatures: required → plain kwarg, optional → defaulted
10
+ # kwarg, passthrough → `**opts` at the end.
11
+ #
12
+ # @example
13
+ # # bad
14
+ # def foo(_bar:); end
15
+ # def foo(**_rest); end
16
+ # def foo(**_); end
17
+ #
18
+ # # good
19
+ # def foo(bar:); end
20
+ # def foo(bar: nil); end
21
+ # def foo(**opts); end
22
+ class NoUnderscorePrefixedKwargs < Base
23
+ extend AutoCorrector
24
+
25
+ MSG_KWARG = 'Underscore-prefixed kwarg `%<name>s` is not allowed. ' \
26
+ 'Use plain kwarg (required), defaulted kwarg (optional), ' \
27
+ 'or `**opts` for passthrough.'
28
+ MSG_SPLAT = 'Underscore-prefixed kwarg splat `%<name>s` is not allowed. ' \
29
+ 'Use `**opts` for passthrough.'
30
+
31
+ def on_def(node)
32
+ check_params(node)
33
+ end
34
+
35
+ def on_defs(node)
36
+ check_params(node)
37
+ end
38
+
39
+ private
40
+
41
+ def check_params(method_node)
42
+ args = method_node.arguments
43
+ return unless args
44
+
45
+ args.each_child_node do |arg|
46
+ case arg.type
47
+ when :kwarg, :kwoptarg
48
+ check_kwarg(arg)
49
+ when :kwrestarg
50
+ check_kwrestarg(arg)
51
+ end
52
+ end
53
+ end
54
+
55
+ def check_kwarg(arg)
56
+ name = arg.children.first
57
+ return unless name.is_a?(Symbol)
58
+ return unless name.to_s.start_with?('_')
59
+
60
+ add_offense(arg, message: format(MSG_KWARG, name: name)) do |corrector|
61
+ corrected = name.to_s.sub(/\A_+/, '')
62
+ corrected = 'arg' if corrected.empty?
63
+ corrector.replace(arg.source_range, kwarg_replacement(corrected, arg))
64
+ end
65
+ end
66
+
67
+ def check_kwrestarg(arg)
68
+ name = arg.children.first
69
+ return unless name.is_a?(Symbol)
70
+ return unless name.to_s.start_with?('_')
71
+
72
+ add_offense(arg, message: format(MSG_SPLAT, name: name)) do |corrector|
73
+ corrector.replace(arg.source_range, '**opts')
74
+ end
75
+ end
76
+
77
+ def kwarg_replacement(name, arg)
78
+ if arg.kwoptarg_type?
79
+ default = arg.children.last
80
+ "#{name}: #{default.source}"
81
+ else
82
+ "#{name}:"
83
+ end
84
+ end
85
+ end
86
+ end
87
+ end
88
+ end
89
+ end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module RuboCop
4
4
  module Legion
5
- VERSION = '0.1.7'
5
+ VERSION = '0.1.8'
6
6
  end
7
7
  end
@@ -44,6 +44,10 @@ require 'rubocop/cop/legion/framework/module_function_private'
44
44
  require 'rubocop/cop/legion/framework/cache_time_coercion'
45
45
  require 'rubocop/cop/legion/framework/api_string_keys'
46
46
  require 'rubocop/cop/legion/framework/mutex_nested_sync'
47
+ require 'rubocop/cop/legion/framework/no_underscore_prefixed_kwargs'
48
+ require 'rubocop/cop/legion/framework/no_inline_setting_defaults'
49
+ require 'rubocop/cop/legion/framework/no_direct_dispatch'
50
+ require 'rubocop/cop/legion/framework/no_shape_duck_typing'
47
51
 
48
52
  # Legion/Extension
49
53
  require 'rubocop/cop/legion/extension/actor_singular_module'
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rubocop-legion
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.7
4
+ version: 0.1.8
5
5
  platform: ruby
6
6
  authors:
7
7
  - Esity
@@ -144,6 +144,10 @@ files:
144
144
  - lib/rubocop/cop/legion/framework/faraday_xml_middleware.rb
145
145
  - lib/rubocop/cop/legion/framework/module_function_private.rb
146
146
  - lib/rubocop/cop/legion/framework/mutex_nested_sync.rb
147
+ - lib/rubocop/cop/legion/framework/no_direct_dispatch.rb
148
+ - lib/rubocop/cop/legion/framework/no_inline_setting_defaults.rb
149
+ - lib/rubocop/cop/legion/framework/no_shape_duck_typing.rb
150
+ - lib/rubocop/cop/legion/framework/no_underscore_prefixed_kwargs.rb
147
151
  - lib/rubocop/cop/legion/framework/sinatra_host_auth.rb
148
152
  - lib/rubocop/cop/legion/framework/thor_reserved_run.rb
149
153
  - lib/rubocop/cop/legion/helper_migration/defined_transport_guard.rb
@@ -191,7 +195,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
191
195
  - !ruby/object:Gem::Version
192
196
  version: '0'
193
197
  requirements: []
194
- rubygems_version: 4.0.8
198
+ rubygems_version: 3.6.9
195
199
  specification_version: 4
196
200
  summary: LegionIO code quality cops for RuboCop
197
201
  test_files: []