rubocop-legion 0.1.7 → 0.1.9

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: 88034e8168ad8e5634a580f3a07ca2f030bd46be31b6c9607dbc64a28935bcd0
4
+ data.tar.gz: 353647b902a3271fe17809769674778b528eb3e97dda8704934addf80b69b397
5
5
  SHA512:
6
- metadata.gz: bf447d4891c6c75429b9f0fe92a3f3fc441c11782d6bf97d9b57cc45c1ac77fbbf5314319f9aa2af9681701d4af99a609a8d56cddd63415a01db17bc6ae35ff3
7
- data.tar.gz: 239ebbc2294b1ac2b366894ea17d7699a3c3532d672677d34cf8c562497bc2a51367ca6f01984af291d955d310df7fbb95bf257085bb5a37531fa10fb038ae7f
6
+ metadata.gz: 0b7d05703e9bd6762628d25f082a738fba9972f66eda2b650ab5e3c0dace6b3569df16c754f935f8f62caf861eaf3984018c3a4f3f73de01cc0871fcf91cfebd
7
+ data.tar.gz: d598e5090c4ba1242ec371fd2d3b935412f9fe8c4e24fe907a9e18bd6052999568bed0c252a983559e4b33e1c0502a724e02df5244db46b98c5cdad899f62e93
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
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RuboCop
4
+ module Cop
5
+ module Legion
6
+ module Llm
7
+ # Flags `loop do` in legion-llm production code. Unbounded loops have caused
8
+ # runaway threads and hard-to-debug hangs. Use `N.times`, `each`, or a
9
+ # `while` with an explicit counter instead.
10
+ #
11
+ # No auto-correct is provided — the fix requires choosing the correct bound.
12
+ #
13
+ # @example
14
+ # # bad
15
+ # loop do
16
+ # attempt = dispatch_request
17
+ # break if attempt.success?
18
+ # end
19
+ #
20
+ # # good
21
+ # MAX_ATTEMPTS.times do
22
+ # attempt = dispatch_request
23
+ # break if attempt.success?
24
+ # end
25
+ class NoLoopDo < Base
26
+ MSG = '`loop do` is prohibited — use bounded iteration (`N.times`, `each`, ' \
27
+ 'or `while` with an explicit decrement) instead.'
28
+
29
+ def on_block(node)
30
+ return unless node.send_node.method_name == :loop
31
+ return unless node.send_node.receiver.nil?
32
+
33
+ add_offense(node.send_node)
34
+ end
35
+
36
+ alias on_numblock on_block
37
+ end
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RuboCop
4
+ module Cop
5
+ module Legion
6
+ module Llm
7
+ # Flags `handle_exception(e, level: :debug)` in rescue blocks. Exception
8
+ # handlers must use at least `:warn` so that errors are visible in
9
+ # production logs without enabling debug mode.
10
+ #
11
+ # @example
12
+ # # bad
13
+ # rescue StandardError => e
14
+ # handle_exception(e, level: :debug, operation: 'foo')
15
+ # end
16
+ #
17
+ # # good
18
+ # rescue StandardError => e
19
+ # handle_exception(e, level: :warn, operation: 'foo')
20
+ # end
21
+ class RescueLogLevel < Base
22
+ MSG = '`handle_exception` called with `level: :debug` in a rescue. ' \
23
+ 'Minimum log level for exceptions is `:warn`.'
24
+
25
+ DEBUG_LEVELS = %i[debug trace verbose].freeze
26
+
27
+ def on_send(node)
28
+ return unless node.method_name == :handle_exception
29
+ return unless inside_rescue?(node)
30
+ return unless debug_level?(node)
31
+
32
+ add_offense(node)
33
+ end
34
+
35
+ private
36
+
37
+ def debug_level?(node)
38
+ pairs = node.arguments.flat_map do |arg|
39
+ if arg.hash_type?
40
+ arg.children
41
+ elsif arg.pair_type?
42
+ [arg]
43
+ else
44
+ []
45
+ end
46
+ end
47
+ pairs.any? do |pair|
48
+ key, val = pair.children
49
+ key.sym_type? && key.value == :level &&
50
+ val.sym_type? && DEBUG_LEVELS.include?(val.value)
51
+ end
52
+ end
53
+
54
+ def inside_rescue?(node)
55
+ node.each_ancestor(:resbody).any?
56
+ end
57
+ end
58
+ end
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RuboCop
4
+ module Cop
5
+ module Legion
6
+ module Llm
7
+ # Flags direct write-path access to `Legion::Settings.loader.settings[...][...]`
8
+ # outside test files. Production code must use `Legion::Settings.dig(...)` so all
9
+ # reads go through the typed, validated accessor.
10
+ #
11
+ # @example
12
+ # # bad
13
+ # Legion::Settings.loader.settings[:extensions][:llm][:default_model] = 'gemma-12b'
14
+ # Legion::Settings.loader.settings[:llm][:timeout]
15
+ #
16
+ # # good
17
+ # Legion::Settings.dig(:extensions, :llm, :default_model)
18
+ class SettingsAccessPath < Base
19
+ MSG = 'Use `Legion::Settings.dig(...)` instead of ' \
20
+ '`Legion::Settings.loader.settings[...][...]`.'
21
+
22
+ def on_send(node)
23
+ return unless loader_settings_chain?(node)
24
+
25
+ add_offense(node)
26
+ end
27
+
28
+ private
29
+
30
+ def loader_settings_chain?(node)
31
+ return false unless %i[[] []=].include?(node.method_name)
32
+
33
+ # For []=, the second index arg and the value are children[1] and [2]
34
+ # The receiver of the outer []= is the inner [] node
35
+ receiver = node.receiver
36
+ return false unless receiver&.send_type? && receiver.method_name == :[]
37
+
38
+ # receiver.receiver should be Legion::Settings.loader.settings
39
+ inner = receiver.receiver
40
+ return false unless inner&.send_type? && inner.method_name == :settings
41
+
42
+ loader_recv = inner.receiver
43
+ return false unless loader_recv&.send_type? && loader_recv.method_name == :loader
44
+
45
+ settings_const?(loader_recv.receiver)
46
+ end
47
+
48
+ def settings_const?(node)
49
+ return false unless node&.const_type?
50
+ return false unless node.children.last == :Settings
51
+
52
+ parent = node.children.first
53
+ return true if parent.nil?
54
+ return false unless parent.const_type? && parent.children.last == :Legion
55
+
56
+ parent.children.first.nil?
57
+ end
58
+ end
59
+ end
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RuboCop
4
+ module Cop
5
+ module Legion
6
+ module Llm
7
+ # Validates lane field literals against the LegionIO taxonomy enums.
8
+ # Flags `:tier`, `:type`, and `:circuit_state` literals that are not in
9
+ # the known-valid sets, catching typos at lint time.
10
+ #
11
+ # Valid values (from Inventory::DEFAULT_PROVIDER_TIERS and the SSOT design):
12
+ # tier: :local, :fleet, :cloud, :frontier, :direct
13
+ # type: :inference, :embedding, :moderation, :rerank, :image
14
+ # circuit_state: :closed, :open, :half_open
15
+ #
16
+ # @example
17
+ # # bad (typo)
18
+ # { tier: :fronteir }
19
+ # health[:circuit_state] = :half_opened
20
+ #
21
+ # # good
22
+ # { tier: :frontier }
23
+ # health[:circuit_state] = :half_open
24
+ class TaxonomyEnum < Base
25
+ VALID_TIERS = %i[local fleet cloud frontier direct].freeze
26
+ VALID_TYPES = %i[inference embedding moderation rerank image].freeze
27
+ VALID_CIRCUIT_STATES = %i[closed open half_open].freeze
28
+
29
+ MSG_TIER = 'Unknown tier `%<val>s`. Valid: %<valid>s.'
30
+ MSG_TYPE = 'Unknown type `%<val>s`. Valid: %<valid>s.'
31
+ MSG_CIRCUIT = 'Unknown circuit_state `%<val>s`. Valid: %<valid>s.'
32
+
33
+ def on_pair(node)
34
+ key, value = node.children
35
+ return unless key.sym_type? && value.sym_type?
36
+
37
+ check_taxonomy(key.value, value, node)
38
+ end
39
+
40
+ def on_send(node)
41
+ # health[:circuit_state] = :bad_value
42
+ return unless node.method_name == :[]=
43
+ return unless node.arguments.size == 2
44
+
45
+ key_node, val_node = node.arguments
46
+ return unless key_node.sym_type? && val_node.sym_type?
47
+
48
+ check_taxonomy(key_node.value, val_node, node)
49
+ end
50
+
51
+ private
52
+
53
+ def check_taxonomy(key, val_node, parent_node)
54
+ val = val_node.value
55
+ case key
56
+ when :tier
57
+ unless VALID_TIERS.include?(val)
58
+ add_offense(parent_node,
59
+ message: format(MSG_TIER, val: val, valid: VALID_TIERS.join(', ')))
60
+ end
61
+ when :type
62
+ unless VALID_TYPES.include?(val)
63
+ add_offense(parent_node,
64
+ message: format(MSG_TYPE, val: val, valid: VALID_TYPES.join(', ')))
65
+ end
66
+ when :circuit_state
67
+ unless VALID_CIRCUIT_STATES.include?(val)
68
+ msg = format(MSG_CIRCUIT, val: val, valid: VALID_CIRCUIT_STATES.join(', '))
69
+ add_offense(parent_node, message: msg)
70
+ end
71
+ end
72
+ end
73
+ end
74
+ end
75
+ end
76
+ end
77
+ end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module RuboCop
4
4
  module Legion
5
- VERSION = '0.1.7'
5
+ VERSION = '0.1.9'
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'
@@ -64,3 +68,9 @@ require 'rubocop/cop/legion/extension/absorber_missing_pattern'
64
68
  require 'rubocop/cop/legion/extension/absorber_missing_absorb_method'
65
69
  require 'rubocop/cop/legion/extension/definition_call_mismatched'
66
70
  require 'rubocop/cop/legion/extension/actor_enabled_side_effects'
71
+
72
+ # Legion/Llm
73
+ require 'rubocop/cop/legion/llm/settings_access_path'
74
+ require 'rubocop/cop/legion/llm/no_loop_do'
75
+ require 'rubocop/cop/legion/llm/rescue_log_level'
76
+ require 'rubocop/cop/legion/llm/taxonomy_enum'
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.9
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
@@ -159,6 +163,10 @@ files:
159
163
  - lib/rubocop/cop/legion/helper_migration/logging_guard.rb
160
164
  - lib/rubocop/cop/legion/helper_migration/old_logging_methods.rb
161
165
  - lib/rubocop/cop/legion/helper_migration/require_defined_guard.rb
166
+ - lib/rubocop/cop/legion/llm/no_loop_do.rb
167
+ - lib/rubocop/cop/legion/llm/rescue_log_level.rb
168
+ - lib/rubocop/cop/legion/llm/settings_access_path.rb
169
+ - lib/rubocop/cop/legion/llm/taxonomy_enum.rb
162
170
  - lib/rubocop/cop/legion/rescue_logging/bare_rescue.rb
163
171
  - lib/rubocop/cop/legion/rescue_logging/no_capture.rb
164
172
  - lib/rubocop/cop/legion/rescue_logging/silent_capture.rb
@@ -191,7 +199,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
191
199
  - !ruby/object:Gem::Version
192
200
  version: '0'
193
201
  requirements: []
194
- rubygems_version: 4.0.8
202
+ rubygems_version: 3.6.9
195
203
  specification_version: 4
196
204
  summary: LegionIO code quality cops for RuboCop
197
205
  test_files: []