rigortype 0.1.0 → 0.1.2

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 (52) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +7 -2
  3. data/data/builtins/ruby_core/array.yml +6 -6
  4. data/data/builtins/ruby_core/hash.yml +1 -1
  5. data/data/builtins/ruby_core/io.yml +3 -3
  6. data/data/builtins/ruby_core/numeric.yml +1 -1
  7. data/data/builtins/ruby_core/pathname.yml +100 -100
  8. data/data/builtins/ruby_core/proc.yml +1 -1
  9. data/data/builtins/ruby_core/range.yml +6 -4
  10. data/data/builtins/ruby_core/string.yml +15 -10
  11. data/data/builtins/ruby_core/time.yml +3 -3
  12. data/lib/rigor/analysis/check_rules/always_truthy_condition_collector.rb +116 -0
  13. data/lib/rigor/analysis/check_rules/dead_assignment_collector.rb +123 -0
  14. data/lib/rigor/analysis/check_rules/ivar_write_collector.rb +118 -0
  15. data/lib/rigor/analysis/check_rules.rb +346 -18
  16. data/lib/rigor/analysis/rule_catalog.rb +343 -0
  17. data/lib/rigor/analysis/runner.rb +90 -6
  18. data/lib/rigor/builtins/regex_refinement.rb +104 -0
  19. data/lib/rigor/cli/diff_command.rb +169 -0
  20. data/lib/rigor/cli/explain_command.rb +129 -0
  21. data/lib/rigor/cli/type_of_command.rb +3 -3
  22. data/lib/rigor/cli/type_scan_command.rb +4 -4
  23. data/lib/rigor/cli.rb +29 -5
  24. data/lib/rigor/configuration/severity_profile.rb +18 -3
  25. data/lib/rigor/configuration.rb +186 -13
  26. data/lib/rigor/environment.rb +12 -4
  27. data/lib/rigor/inference/expression_typer.rb +3 -1
  28. data/lib/rigor/inference/method_dispatcher/kernel_dispatch.rb +31 -0
  29. data/lib/rigor/inference/method_dispatcher/literal_string_folding.rb +43 -2
  30. data/lib/rigor/inference/method_dispatcher/overload_selector.rb +104 -12
  31. data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +68 -2
  32. data/lib/rigor/inference/method_dispatcher.rb +50 -1
  33. data/lib/rigor/inference/narrowing.rb +150 -6
  34. data/lib/rigor/inference/scope_indexer.rb +220 -17
  35. data/lib/rigor/inference/statement_evaluator.rb +29 -0
  36. data/lib/rigor/plugin/base.rb +43 -0
  37. data/lib/rigor/plugin/fact_store.rb +92 -0
  38. data/lib/rigor/plugin/io_boundary.rb +92 -19
  39. data/lib/rigor/plugin/load_error.rb +14 -2
  40. data/lib/rigor/plugin/loader.rb +116 -0
  41. data/lib/rigor/plugin/manifest.rb +75 -6
  42. data/lib/rigor/plugin/services.rb +14 -2
  43. data/lib/rigor/plugin/trust_policy.rb +30 -7
  44. data/lib/rigor/plugin.rb +1 -0
  45. data/lib/rigor/scope.rb +30 -5
  46. data/lib/rigor/trinary.rb +1 -1
  47. data/lib/rigor/type/integer_range.rb +6 -2
  48. data/lib/rigor/version.rb +1 -1
  49. data/sig/rigor/environment.rbs +3 -2
  50. data/sig/rigor/scope.rbs +3 -0
  51. data/sig/rigor.rbs +8 -2
  52. metadata +9 -1
@@ -0,0 +1,343 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "check_rules"
4
+
5
+ module Rigor
6
+ module Analysis
7
+ # Single-source-of-truth metadata table for every CheckRule
8
+ # the analyzer ships. Consumed by `rigor explain <rule>` so
9
+ # users can read the same information the docs site eventually
10
+ # publishes without leaving the terminal.
11
+ #
12
+ # Each entry carries:
13
+ #
14
+ # - `id` — canonical rule id (`call.undefined-method`).
15
+ # - `summary` — single-line headline (≤ 80 chars).
16
+ # - `fires_when` — bullet-shaped list of conditions that
17
+ # trigger the rule, in the order a reader can scan
18
+ # top-to-bottom.
19
+ # - `does_not_fire_when` — explicit list of cases the rule
20
+ # intentionally skips. Useful for "why am I NOT seeing
21
+ # this diagnostic?" questions.
22
+ # - `suppression` — short note on how to suppress (in-source
23
+ # `# rigor:disable` and the v0.1.2 file-scope variant
24
+ # `# rigor:disable-file`, plus `.rigor.yml` `disable:`,
25
+ # apply to every rule, so the note covers any rule-specific
26
+ # nuance — e.g. unreachable-branch lives on the dead-branch
27
+ # line, not the predicate line).
28
+ # - `severity_authored` — Symbol the rule emits with.
29
+ # - `severity_by_profile` — Hash of `:lenient` / `:balanced`
30
+ # / `:strict` to the configured severity per profile, taken
31
+ # from `Configuration::SeverityProfile::PROFILES`.
32
+ # - `since` — first version the rule shipped in.
33
+ module RuleCatalog # rubocop:disable Metrics/ModuleLength
34
+ Entry = Data.define(:id, :summary, :fires_when, :does_not_fire_when,
35
+ :suppression, :severity_authored, :severity_by_profile, :since) do
36
+ def aliases
37
+ CheckRules::LEGACY_RULE_ALIASES.select { |_legacy, canonical| canonical == id }.keys
38
+ end
39
+
40
+ # Hash-shaped form for `--format=json` consumers. Keys are
41
+ # Strings so the payload is JSON-stable without a transform
42
+ # pass.
43
+ def to_h
44
+ {
45
+ "id" => id,
46
+ "aliases" => aliases,
47
+ "summary" => summary,
48
+ "fires_when" => fires_when,
49
+ "does_not_fire_when" => does_not_fire_when,
50
+ "suppression" => suppression,
51
+ "severity_authored" => severity_authored.to_s,
52
+ "severity_by_profile" => severity_by_profile.transform_keys(&:to_s).transform_values(&:to_s),
53
+ "since" => since
54
+ }
55
+ end
56
+ end
57
+
58
+ ENTRIES = {
59
+ CheckRules::RULE_UNDEFINED_METHOD => Entry.new(
60
+ id: CheckRules::RULE_UNDEFINED_METHOD,
61
+ summary: "Method does not exist on the receiver's statically-known class.",
62
+ fires_when: [
63
+ "The call is `receiver.method(...)` with an explicit receiver.",
64
+ "The receiver type resolves to `Type::Nominal` / `Singleton` / `Constant` / `Tuple` / `HashShape`.",
65
+ "The receiver class is RBS-known (declared in the loaded environment).",
66
+ "The user has not declared the method via `def` or recognised `define_method`.",
67
+ "Neither the receiver class nor an ancestor's RBS sig declares the method."
68
+ ],
69
+ does_not_fire_when: [
70
+ "Implicit-self calls (no receiver) — too noisy without per-method RBS for every helper.",
71
+ "Receiver is `Dynamic[T]` / `Top` / `Union` — by definition the method set isn't enumerable.",
72
+ "Receiver class is in the loader but its RBS definition cannot be built (constant aliases)."
73
+ ],
74
+ suppression: "`# rigor:disable call.undefined-method` on the call line, " \
75
+ "or `disable: [\"call.undefined-method\"]` in `.rigor.yml`.",
76
+ severity_authored: :error,
77
+ severity_by_profile: { lenient: :error, balanced: :error, strict: :error },
78
+ since: "0.0.1"
79
+ ),
80
+
81
+ CheckRules::RULE_WRONG_ARITY => Entry.new(
82
+ id: CheckRules::RULE_WRONG_ARITY,
83
+ summary: "Call's positional argument count is outside the declared overloads' envelope.",
84
+ fires_when: [
85
+ "Call is `receiver.method(args...)` with explicit receiver + plain positional args.",
86
+ "Receiver class is RBS-known and the method has a definition.",
87
+ "Actual positional count is below the min or above the max across all overloads."
88
+ ],
89
+ does_not_fire_when: [
90
+ "Call uses `*splat`, keyword arguments, block-pass, or forwarded arguments.",
91
+ "Method declares required keyword arguments (caller must pass kwargs the rule doesn't model).",
92
+ "Method has a `*rest` positional parameter (max arity is unbounded)."
93
+ ],
94
+ suppression: "`# rigor:disable call.wrong-arity`.",
95
+ severity_authored: :error,
96
+ severity_by_profile: { lenient: :error, balanced: :error, strict: :error },
97
+ since: "0.0.1"
98
+ ),
99
+
100
+ CheckRules::RULE_ARGUMENT_TYPE => Entry.new(
101
+ id: CheckRules::RULE_ARGUMENT_TYPE,
102
+ summary: "Call passes an argument whose type the parameter cannot accept.",
103
+ fires_when: [
104
+ "The parameter type rejects the argument under `accepts(arg, mode: :gradual)`.",
105
+ "Method has a single overload (multi-overload checking is deferred).",
106
+ "Both sides have a non-Dynamic concrete type."
107
+ ],
108
+ does_not_fire_when: [
109
+ "Either the parameter or the argument is `Dynamic[T]`.",
110
+ "Method has multiple overloads.",
111
+ "Method has `*rest_positionals`, required keywords, or trailing positionals."
112
+ ],
113
+ suppression: "`# rigor:disable call.argument-type-mismatch`.",
114
+ severity_authored: :error,
115
+ severity_by_profile: { lenient: :warning, balanced: :error, strict: :error },
116
+ since: "0.0.2"
117
+ ),
118
+
119
+ CheckRules::RULE_NIL_RECEIVER => Entry.new(
120
+ id: CheckRules::RULE_NIL_RECEIVER,
121
+ summary: "Receiver may be nil and the method is not defined on NilClass.",
122
+ fires_when: [
123
+ "Receiver type is `Type::Union` containing `Constant<nil>` (or `nil` from the RBS Optional).",
124
+ "The non-nil branch has the method, but `NilClass` does not.",
125
+ "Call is not safe-navigation (`x&.method`)."
126
+ ],
127
+ does_not_fire_when: [
128
+ "Method exists on every member of the union (including NilClass).",
129
+ "Receiver was narrowed via `return if x.nil?` / similar early-return guard.",
130
+ "Call uses safe-navigation (`x&.method`)."
131
+ ],
132
+ suppression: "`# rigor:disable call.possible-nil-receiver`.",
133
+ severity_authored: :error,
134
+ severity_by_profile: { lenient: :warning, balanced: :error, strict: :error },
135
+ since: "0.0.2"
136
+ ),
137
+
138
+ CheckRules::RULE_DUMP_TYPE => Entry.new(
139
+ id: CheckRules::RULE_DUMP_TYPE,
140
+ summary: "`dump_type(expr)` from Rigor::Testing — informational type print.",
141
+ fires_when: [
142
+ "Top-level / DSL-block call to `dump_type(expr)` after `include Rigor::Testing`."
143
+ ],
144
+ does_not_fire_when: [
145
+ "Outside a context that includes Rigor::Testing.",
146
+ "Argument is not a single expression."
147
+ ],
148
+ suppression: "Remove the `dump_type` call (it's a debug helper, not a real diagnostic).",
149
+ severity_authored: :info,
150
+ severity_by_profile: { lenient: :info, balanced: :info, strict: :error },
151
+ since: "0.0.1"
152
+ ),
153
+
154
+ CheckRules::RULE_ASSERT_TYPE => Entry.new(
155
+ id: CheckRules::RULE_ASSERT_TYPE,
156
+ summary: "`assert_type(\"<expected>\", expr)` from Rigor::Testing — type-equality check.",
157
+ fires_when: [
158
+ "Inferred type's display does not match the asserted string.",
159
+ "Useful in fixture self-assertions (every `spec/integration/fixtures/*.rb` uses it)."
160
+ ],
161
+ does_not_fire_when: [
162
+ "Inferred type matches the assertion exactly."
163
+ ],
164
+ suppression: "Update the assertion to the actual inferred type, or correct the source.",
165
+ severity_authored: :error,
166
+ severity_by_profile: { lenient: :error, balanced: :error, strict: :error },
167
+ since: "0.0.1"
168
+ ),
169
+
170
+ CheckRules::RULE_ALWAYS_RAISES => Entry.new(
171
+ id: CheckRules::RULE_ALWAYS_RAISES,
172
+ summary: "Call provably raises (today: Integer division-by-zero).",
173
+ fires_when: [
174
+ "Receiver is `Integer` / `IntegerRange` / `Constant<Integer>`.",
175
+ "Operator is `/` / `%` / `div` / `modulo` / `divmod`.",
176
+ "Argument is a `Constant<Integer>` whose value is exactly zero."
177
+ ],
178
+ does_not_fire_when: [
179
+ "Receiver is Float / Rational (those return Infinity / NaN, not an exception).",
180
+ "Argument is a Union containing zero (\"may raise\" not \"always raises\")."
181
+ ],
182
+ suppression: "`# rigor:disable flow.always-raises`.",
183
+ severity_authored: :error,
184
+ severity_by_profile: { lenient: :warning, balanced: :error, strict: :error },
185
+ since: "0.0.3"
186
+ ),
187
+
188
+ CheckRules::RULE_UNREACHABLE_BRANCH => Entry.new(
189
+ id: CheckRules::RULE_UNREACHABLE_BRANCH,
190
+ summary: "An if / unless / ternary's literal predicate makes one branch dead.",
191
+ fires_when: [
192
+ "Predicate is a syntactic literal: `true` / `false` / `nil` / Integer / Float / String / Symbol / Regexp.",
193
+ "The corresponding dead branch carries a non-empty body."
194
+ ],
195
+ does_not_fire_when: [
196
+ "Predicate is an inferred-constant expression (not a literal). The literal-only envelope avoids " \
197
+ "false positives from Rigor's incomplete loop / mutation / RBS-strictness modelling.",
198
+ "The dead branch is empty (no useful location to point at)."
199
+ ],
200
+ suppression: "`# rigor:disable unreachable-branch` on the dead-branch line (the diagnostic " \
201
+ "points at the dead branch, not the predicate, so the suppression goes there).",
202
+ severity_authored: :warning,
203
+ severity_by_profile: { lenient: :info, balanced: :warning, strict: :error },
204
+ since: "0.1.2"
205
+ ),
206
+
207
+ CheckRules::RULE_ALWAYS_TRUTHY_CONDITION => Entry.new(
208
+ id: CheckRules::RULE_ALWAYS_TRUTHY_CONDITION,
209
+ summary: "An if / unless / ternary predicate's inferred type folds to a constant.",
210
+ fires_when: [
211
+ "Predicate's inferred type is `Type::Constant<true | false | nil | ...>`.",
212
+ "Predicate is NOT a syntactic literal (the literal-only `flow.unreachable-branch` rule covers those)."
213
+ ],
214
+ does_not_fire_when: [
215
+ "Predicate sits inside a `WhileNode` / `UntilNode` / `ForNode` / `BlockNode` ancestor — " \
216
+ "Rigor's mutation tracking through loop bodies is incomplete enough that an inferred " \
217
+ "`Constant<bool>` can be a false positive.",
218
+ "Predicate is a defensive `.nil?` / `.empty?` / `.zero?` / `.any?` / `.none?` / `.all?` / " \
219
+ "`.respond_to?` call — these typically fire when the user is being more cautious than the " \
220
+ "RBS strict-on-returns sig admits.",
221
+ "Predicate folds to a non-Constant type (Union / Nominal / Dynamic / etc.)."
222
+ ],
223
+ suppression: "`# rigor:disable always-truthy-condition` on the predicate line.",
224
+ severity_authored: :warning,
225
+ severity_by_profile: { lenient: :info, balanced: :warning, strict: :error },
226
+ since: "0.1.2"
227
+ ),
228
+
229
+ CheckRules::RULE_DEAD_ASSIGNMENT => Entry.new(
230
+ id: CheckRules::RULE_DEAD_ASSIGNMENT,
231
+ summary: "Local variable assigned in a method body but never read.",
232
+ fires_when: [
233
+ "Plain `LocalVariableWriteNode` (not `+=` / `||=` / multi-assign) inside a `DefNode` body.",
234
+ "The target name does not appear as a `LocalVariableReadNode` anywhere in the same body, " \
235
+ "including nested blocks / lambdas.",
236
+ "The write is not the last statement of the body (Ruby's implicit return)."
237
+ ],
238
+ does_not_fire_when: [
239
+ "Top-level / class-body assignments (their reachability spans the file's introspection / require surface).",
240
+ "The target name starts with `_` (Ruby convention for intentionally-unused).",
241
+ "The write is a destructure (`a, b = foo`) or operator-write (`x += 1` / `x ||= 1`).",
242
+ "The write is the last statement of the method body (assignments return their rvalue)."
243
+ ],
244
+ suppression: "`# rigor:disable dead-assignment` on the offending line, or rename the local to `_<name>`.",
245
+ severity_authored: :warning,
246
+ severity_by_profile: { lenient: :info, balanced: :warning, strict: :error },
247
+ since: "0.1.2"
248
+ ),
249
+
250
+ CheckRules::RULE_RETURN_TYPE => Entry.new(
251
+ id: CheckRules::RULE_RETURN_TYPE,
252
+ summary: "Method body's last-expression type is incompatible with the declared return type.",
253
+ fires_when: [
254
+ "Method has a `def` body the engine can re-type.",
255
+ "Method's RBS sig declares a non-`untyped` return type.",
256
+ "Body's inferred return type does not flow into the declared type under gradual acceptance.",
257
+ "When the RBS sig carries `%a{rigor:v1:return: <refinement>}` (v0.1.2), the refinement " \
258
+ "carrier — `non-empty-string`, `positive-int`, etc. — replaces the bare RBS class for the " \
259
+ "comparison, so a body the bare class would accept may still fail the refinement."
260
+ ],
261
+ does_not_fire_when: [
262
+ "Method's declared return is `untyped` / `void`.",
263
+ "Body's last expression is `Dynamic[T]` (the engine cannot rule out the declared type)."
264
+ ],
265
+ suppression: "`# rigor:disable def.return-type-mismatch`.",
266
+ severity_authored: :warning,
267
+ severity_by_profile: { lenient: :warning, balanced: :warning, strict: :error },
268
+ since: "0.1.0"
269
+ ),
270
+
271
+ CheckRules::RULE_VISIBILITY_MISMATCH => Entry.new(
272
+ id: CheckRules::RULE_VISIBILITY_MISMATCH,
273
+ summary: "Explicit-receiver call to a method declared `private` in source.",
274
+ fires_when: [
275
+ "Call is `receiver.method(...)` with explicit non-self receiver.",
276
+ "Receiver type resolves to `Type::Nominal[X]`.",
277
+ "X is a user-defined class whose source carries the method under `private`."
278
+ ],
279
+ does_not_fire_when: [
280
+ "Implicit-self call (no receiver) — always allowed for private.",
281
+ "Receiver is `self` (Ruby 2.7+ permits `self.private_method`).",
282
+ "Receiver class is RBS-known but not user-source-defined (RBS-side visibility is deferred).",
283
+ "Method is `:protected` (subclass tracking is deferred)."
284
+ ],
285
+ suppression: "`# rigor:disable method-visibility-mismatch`.",
286
+ severity_authored: :error,
287
+ severity_by_profile: { lenient: :warning, balanced: :error, strict: :error },
288
+ since: "0.1.2"
289
+ ),
290
+
291
+ CheckRules::RULE_IVAR_WRITE_MISMATCH => Entry.new(
292
+ id: CheckRules::RULE_IVAR_WRITE_MISMATCH,
293
+ summary: "Same instance variable assigned a different concrete class within one class.",
294
+ fires_when: [
295
+ "Two or more `@var = ...` writes occur in instance methods of the same class.",
296
+ "First write's rvalue resolves to a concrete class (Nominal / Singleton / Constant / Tuple → " \
297
+ "\"Array\" / HashShape → \"Hash\").",
298
+ "A later write's rvalue resolves to a different concrete class."
299
+ ],
300
+ does_not_fire_when: [
301
+ "Later write is `nil` — the `@cache = nil` clear-idiom is allowlisted.",
302
+ "Either side is Union / Dynamic / IntegerRange / a shape-varied carrier.",
303
+ "Writes live in different classes that happen to share an ivar name.",
304
+ "Writes are in `def self.foo` (singleton) bodies — those track separately."
305
+ ],
306
+ suppression: "`# rigor:disable ivar-write-mismatch` on the offending write.",
307
+ severity_authored: :error,
308
+ severity_by_profile: { lenient: :warning, balanced: :warning, strict: :error },
309
+ since: "0.1.2"
310
+ )
311
+ }.freeze
312
+
313
+ module_function
314
+
315
+ # Looks up a rule by canonical id, legacy alias, or family
316
+ # wildcard. Returns an Array<Entry>:
317
+ #
318
+ # - canonical id → 1-element array,
319
+ # - legacy alias → 1-element array (resolved to canonical),
320
+ # - family token (`call`) → every entry under that family,
321
+ # - unknown token → empty array.
322
+ def resolve(token)
323
+ token = token.to_s
324
+ return [ENTRIES.fetch(token)] if ENTRIES.key?(token)
325
+
326
+ if CheckRules::LEGACY_RULE_ALIASES.key?(token)
327
+ canonical = CheckRules::LEGACY_RULE_ALIASES.fetch(token)
328
+ return [ENTRIES.fetch(canonical)]
329
+ end
330
+
331
+ if CheckRules::RULE_FAMILIES.include?(token)
332
+ return ENTRIES.values.select { |entry| entry.id.start_with?("#{token}.") }.sort_by(&:id)
333
+ end
334
+
335
+ []
336
+ end
337
+
338
+ def all
339
+ ENTRIES.values.sort_by(&:id)
340
+ end
341
+ end
342
+ end
343
+ end
@@ -54,22 +54,45 @@ module Rigor
54
54
  Inference::MethodDispatcher::FileFolding.fold_platform_specific_paths =
55
55
  @configuration.fold_platform_specific_paths
56
56
 
57
+ target_ruby_error = validate_target_ruby
58
+ return Result.new(diagnostics: [target_ruby_error]) if target_ruby_error
59
+
60
+ @plugin_registry = load_plugins
57
61
  environment = Environment.for_project(
58
62
  libraries: @configuration.libraries,
59
63
  signature_paths: @configuration.signature_paths,
60
- cache_store: @cache_store
64
+ cache_store: @cache_store,
65
+ plugin_registry: @plugin_registry
61
66
  )
62
-
63
- @plugin_registry = load_plugins
64
67
  expansion = expand_paths(paths)
65
68
 
66
69
  diagnostics = plugin_load_diagnostics
70
+ diagnostics += plugin_prepare_diagnostics
67
71
  diagnostics += expansion.fetch(:errors)
68
72
  diagnostics += expansion.fetch(:files).flat_map { |path| analyze_file(path, environment) }
69
73
 
70
74
  Result.new(diagnostics: apply_severity_profile(diagnostics))
71
75
  end
72
76
 
77
+ # `target_ruby` flows through to Prism's `version:` option.
78
+ # Prism enforces the supported range and raises
79
+ # `ArgumentError` for versions it does not recognise. Run a
80
+ # one-time smoke parse here so a misconfigured target_ruby
81
+ # surfaces as a single project-level diagnostic instead of
82
+ # crashing the whole run on the first file.
83
+ def validate_target_ruby
84
+ Prism.parse("nil", version: @configuration.target_ruby)
85
+ nil
86
+ rescue ArgumentError => e
87
+ Diagnostic.new(
88
+ path: ".rigor.yml", line: 1, column: 1,
89
+ message: "target_ruby #{@configuration.target_ruby.inspect} is not accepted by Prism: #{e.message}",
90
+ severity: :error,
91
+ rule: "configuration-error",
92
+ source_family: :builtin
93
+ )
94
+ end
95
+
73
96
  private
74
97
 
75
98
  # Loads project-configured plugins through {Rigor::Plugin::Loader}
@@ -117,7 +140,8 @@ module Rigor
117
140
  Plugin::TrustPolicy.new(
118
141
  trusted_gems: trusted_gems,
119
142
  allowed_read_roots: roots,
120
- network_policy: @configuration.plugins_io_network
143
+ network_policy: @configuration.plugins_io_network,
144
+ allowed_url_hosts: @configuration.plugins_io_allowed_url_hosts
121
145
  )
122
146
  end
123
147
 
@@ -183,6 +207,47 @@ module Rigor
183
207
  end
184
208
  end
185
209
 
210
+ # ADR-9 slice 3 — invokes every loaded plugin's `#prepare`
211
+ # hook once per run, after the loader's `#init` pass and
212
+ # before per-file iteration. Plugins publish facts here
213
+ # for cross-plugin consumption via the shared
214
+ # `services.fact_store`. Failures isolate as
215
+ # `:plugin_loader runtime-error` diagnostics, mirroring the
216
+ # `#diagnostics_for_file` raise envelope in
217
+ # `plugin_runtime_error_diagnostic`.
218
+ #
219
+ # Slice 3 visits plugins in registration order. Slice 5
220
+ # introduces topological ordering by `manifest(consumes:)`
221
+ # so producers always run before consumers; until then,
222
+ # `Configuration#plugins` order MUST be producer-first if
223
+ # cross-plugin dependencies exist.
224
+ def plugin_prepare_diagnostics
225
+ return [] if @plugin_registry.empty?
226
+
227
+ @plugin_registry.plugins.flat_map { |plugin| invoke_plugin_prepare(plugin) }
228
+ end
229
+
230
+ def invoke_plugin_prepare(plugin)
231
+ plugin.prepare(plugin.services)
232
+ []
233
+ rescue StandardError => e
234
+ [plugin_prepare_error_diagnostic(plugin, e)]
235
+ end
236
+
237
+ def plugin_prepare_error_diagnostic(plugin, error)
238
+ plugin_id = safe_plugin_id(plugin)
239
+ Diagnostic.new(
240
+ path: ".rigor.yml",
241
+ line: 1,
242
+ column: 1,
243
+ message: "plugin #{plugin_id.inspect} raised during prepare: " \
244
+ "#{error.class}: #{error.message}",
245
+ severity: :error,
246
+ rule: "runtime-error",
247
+ source_family: :plugin_loader
248
+ )
249
+ end
250
+
186
251
  # ADR-7 § "Slice 5-A/5-B" — invokes every loaded plugin's
187
252
  # per-file diagnostic emission hook
188
253
  # (`Plugin::Base#diagnostics_for_file`) and re-stamps the
@@ -254,7 +319,7 @@ module Rigor
254
319
  errors = []
255
320
  Array(paths).each do |path|
256
321
  if File.directory?(path)
257
- files.concat(Dir.glob(File.join(path, RUBY_GLOB)))
322
+ files.concat(reject_excluded(Dir.glob(File.join(path, RUBY_GLOB))))
258
323
  elsif File.file?(path) && path.end_with?(".rb")
259
324
  files << path
260
325
  elsif File.exist?(path)
@@ -266,6 +331,25 @@ module Rigor
266
331
  { files: files, errors: errors }
267
332
  end
268
333
 
334
+ # `Configuration#exclude_patterns` is a list of glob patterns
335
+ # checked against each globbed path via `File.fnmatch?` (without
336
+ # `FNM_PATHNAME`, so `**` and `*` both span path separators —
337
+ # the patterns behave like substring globs). Built-in defaults
338
+ # exclude `vendor/bundle`, `.bundle`, `node_modules`, and `tmp`
339
+ # so the analyser never walks into vendored deps or build
340
+ # artefacts. User-supplied entries (`.rigor.yml` `exclude:`)
341
+ # layer on top. Explicit file arguments to the CLI bypass this
342
+ # filter — only the directory-glob expansion is filtered.
343
+ def reject_excluded(file_list)
344
+ return file_list if @configuration.exclude_patterns.empty?
345
+
346
+ file_list.reject { |path| excluded?(path) }
347
+ end
348
+
349
+ def excluded?(path)
350
+ @configuration.exclude_patterns.any? { |pattern| File.fnmatch?(pattern, path) }
351
+ end
352
+
269
353
  def path_error(path, message)
270
354
  Diagnostic.new(
271
355
  path: path,
@@ -277,7 +361,7 @@ module Rigor
277
361
  end
278
362
 
279
363
  def analyze_file(path, environment) # rubocop:disable Metrics/MethodLength
280
- parse_result = Prism.parse_file(path)
364
+ parse_result = Prism.parse_file(path, version: @configuration.target_ruby)
281
365
  return parse_diagnostics(path, parse_result) unless parse_result.errors.empty?
282
366
 
283
367
  scope = Scope.empty(environment: environment)
@@ -0,0 +1,104 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../type"
4
+
5
+ module Rigor
6
+ module Builtins
7
+ # Maps a curated table of canonical regex sub-patterns onto the
8
+ # imported refinement carriers Rigor already ships
9
+ # (`decimal-int-string`, `hex-int-string`, `octal-int-string`,
10
+ # `lowercase-string`, `uppercase-string`, `numeric-string`).
11
+ # See `docs/type-specification/imported-built-in-types.md` for
12
+ # the registry the refinements come from and `docs/MILESTONES.md`
13
+ # § "v0.1.1 — Planned" Track 1 slice 1 for the binding scope of
14
+ # this recogniser.
15
+ #
16
+ # The intended consumer is `Inference::Narrowing.analyse_match_write`:
17
+ # given `if /(?<year>\d+)/ =~ str; year; end`, the v0.1.0
18
+ # baseline narrows `year` to plain `String`; v0.1.1 introspects
19
+ # the regex source and narrows further to
20
+ # `decimal-int-string` whenever the named-capture body matches
21
+ # one of the rows in {RULES}.
22
+ #
23
+ # Recognised body shapes (each row admits the `+` quantifier
24
+ # and the bounded `{n}` / `{n,m}` forms with `n >= 1`):
25
+ #
26
+ # - `\d` -> decimal-int-string
27
+ # - `\h` -> hex-int-string
28
+ # - `[0-9a-fA-F]` -> hex-int-string
29
+ # - `[0-9a-f]`, `[0-9A-F]` -> hex-int-string
30
+ # - `[0-7]` -> octal-int-string
31
+ # - `[a-z]` -> lowercase-string
32
+ # - `[A-Z]` -> uppercase-string
33
+ # - `[[:digit:]]` -> numeric-string
34
+ #
35
+ # Anything outside the table returns `nil` so the calling
36
+ # narrowing site falls back to its previous behaviour
37
+ # (plain `String`). Arbitrary regex semantic equivalence is
38
+ # undecidable, so the table is intentionally a small audited
39
+ # set of canonical shapes rather than a general equivalence
40
+ # checker.
41
+ module RegexRefinement
42
+ # `+` (one-or-more) or `{n}` / `{n,m}` (n >= 1, m >= n).
43
+ # The bound check is enforced separately by
44
+ # {valid_bounds?} after the structural match succeeds, so
45
+ # forms like `\d{0,5}` or `\d{5,3}` reject even though they
46
+ # parse syntactically.
47
+ QUANTIFIER_SOURCE = '(?:\+|\{\d+(?:,\d+)?\})'
48
+ private_constant :QUANTIFIER_SOURCE
49
+
50
+ RULES = [
51
+ [/\A\\d#{QUANTIFIER_SOURCE}\z/, :decimal_int_string],
52
+ [/\A\\h#{QUANTIFIER_SOURCE}\z/, :hex_int_string],
53
+ [/\A\[0-9a-fA-F\]#{QUANTIFIER_SOURCE}\z/, :hex_int_string],
54
+ [/\A\[0-9a-f\]#{QUANTIFIER_SOURCE}\z/, :hex_int_string],
55
+ [/\A\[0-9A-F\]#{QUANTIFIER_SOURCE}\z/, :hex_int_string],
56
+ [/\A\[0-7\]#{QUANTIFIER_SOURCE}\z/, :octal_int_string],
57
+ [/\A\[a-z\]#{QUANTIFIER_SOURCE}\z/, :lowercase_string],
58
+ [/\A\[A-Z\]#{QUANTIFIER_SOURCE}\z/, :uppercase_string],
59
+ [/\A\[\[:digit:\]\]#{QUANTIFIER_SOURCE}\z/, :numeric_string]
60
+ ].freeze
61
+ private_constant :RULES
62
+
63
+ BOUND_RE = /\{(\d+)(?:,(\d+))?\}\z/
64
+ private_constant :BOUND_RE
65
+
66
+ module_function
67
+
68
+ # @param body [String, nil] a regex sub-pattern, typically the
69
+ # inner body of a `(?<name>body)` named capture. Anchors
70
+ # (`\A`, `\z`, `^`, `$`) are not stripped — the recogniser
71
+ # table targets bodies that the regex engine treats as
72
+ # anchored to the capture group bounds.
73
+ # @return [Rigor::Type, nil] the matching imported refinement
74
+ # carrier, or `nil` if `body` is not a recognised shape.
75
+ def for_capture_body(body)
76
+ return nil if body.nil? || body.empty?
77
+
78
+ rule = RULES.find { |pattern, _| pattern.match?(body) }
79
+ return nil if rule.nil?
80
+ return nil unless valid_bounds?(body)
81
+
82
+ Type::Combinator.public_send(rule.last)
83
+ end
84
+
85
+ # Filters the bounded-quantifier forms to ones whose lower
86
+ # bound is at least 1 and whose upper bound (if any) is at
87
+ # least the lower bound. Without this, `\d{0,5}` would be
88
+ # accepted even though it admits the empty string, which is
89
+ # not a valid `decimal-int-string`.
90
+ def valid_bounds?(body)
91
+ m = BOUND_RE.match(body)
92
+ return true if m.nil?
93
+
94
+ low = Integer(m[1])
95
+ return false if low < 1
96
+
97
+ high = m[2] && Integer(m[2])
98
+ return true if high.nil?
99
+
100
+ low <= high
101
+ end
102
+ end
103
+ end
104
+ end