rigortype 0.4.5 → 0.4.6

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 (136) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +1 -1
  3. data/docs/handbook/11-sig-gen.md +11 -2
  4. data/docs/manual/02-cli-reference.md +46 -7
  5. data/docs/manual/03-configuration.md +2 -1
  6. data/docs/manual/04-diagnostics.md +1 -1
  7. data/docs/manual/10-mcp-server.md +9 -1
  8. data/docs/manual/12-caching.md +8 -1
  9. data/docs/manual/plugins/rigor-actionmailer.md +30 -0
  10. data/docs/manual/plugins/rigor-actionpack.md +30 -0
  11. data/docs/manual/plugins/rigor-devise.md +15 -0
  12. data/docs/manual/plugins/rigor-rspec.md +2 -1
  13. data/lib/rigor/analysis/buffer_binding.rb +14 -0
  14. data/lib/rigor/analysis/check_rules/inferred_param_guard.rb +30 -9
  15. data/lib/rigor/analysis/check_rules/ivar_write_collector.rb +142 -15
  16. data/lib/rigor/analysis/check_rules/lexical_method_sites.rb +4 -0
  17. data/lib/rigor/analysis/check_rules/rbs_receiver_surface.rb +66 -3
  18. data/lib/rigor/analysis/check_rules/rebinding_block_callees/dispatch.rb +112 -0
  19. data/lib/rigor/analysis/check_rules/rebinding_block_callees.rb +394 -0
  20. data/lib/rigor/analysis/check_rules/ruby_deprecations.rb +7 -0
  21. data/lib/rigor/analysis/check_rules/unreachable_clause_collector.rb +51 -0
  22. data/lib/rigor/analysis/check_rules.rb +93 -8
  23. data/lib/rigor/analysis/dependency_recorder.rb +11 -0
  24. data/lib/rigor/analysis/edit_replay.rb +67 -0
  25. data/lib/rigor/analysis/incremental.rb +37 -0
  26. data/lib/rigor/analysis/incremental_session.rb +175 -27
  27. data/lib/rigor/analysis/path_expansion.rb +41 -0
  28. data/lib/rigor/analysis/require_relative_closure.rb +100 -0
  29. data/lib/rigor/analysis/rule_catalog.rb +5 -1
  30. data/lib/rigor/analysis/run_disclosure_credit.rb +80 -0
  31. data/lib/rigor/analysis/run_disclosure_ledger.rb +155 -0
  32. data/lib/rigor/analysis/runner/buffer_pool_dispatcher.rb +6 -2
  33. data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +11 -3
  34. data/lib/rigor/analysis/runner/pool_coordinator.rb +17 -4
  35. data/lib/rigor/analysis/runner/project_pre_passes.rb +45 -3
  36. data/lib/rigor/analysis/runner/scan_environment.rb +51 -0
  37. data/lib/rigor/analysis/runner.rb +156 -49
  38. data/lib/rigor/analysis/worker_session.rb +14 -2
  39. data/lib/rigor/cache/incremental_snapshot.rb +30 -7
  40. data/lib/rigor/cli/check_command.rb +93 -4
  41. data/lib/rigor/cli/diff_command.rb +2 -1
  42. data/lib/rigor/cli/lens_command.rb +5 -5
  43. data/lib/rigor/cli/mcp_command.rb +2 -1
  44. data/lib/rigor/cli/options.rb +20 -3
  45. data/lib/rigor/cli/plugins_command.rb +5 -0
  46. data/lib/rigor/cli/plugins_renderer.rb +24 -2
  47. data/lib/rigor/cli/show_bleedingedge_command.rb +2 -1
  48. data/lib/rigor/cli/sig_gen_command.rb +46 -7
  49. data/lib/rigor/cli/type_of_command.rb +2 -1
  50. data/lib/rigor/cli/type_of_project_scope.rb +27 -0
  51. data/lib/rigor/config_audit.rb +16 -5
  52. data/lib/rigor/configuration.rb +14 -4
  53. data/lib/rigor/environment/rbs_loader.rb +115 -6
  54. data/lib/rigor/environment/required_features.rb +136 -29
  55. data/lib/rigor/environment/ruby_library_roots.rb +122 -0
  56. data/lib/rigor/environment.rb +4 -2
  57. data/lib/rigor/inference/callee_mutation_chain.rb +115 -0
  58. data/lib/rigor/inference/deep_erasure.rb +138 -0
  59. data/lib/rigor/inference/def_return_typer.rb +159 -9
  60. data/lib/rigor/inference/definer_resolution.rb +17 -3
  61. data/lib/rigor/inference/error_info.rb +17 -3
  62. data/lib/rigor/inference/expression_typer.rb +62 -13
  63. data/lib/rigor/inference/global_write_census.rb +36 -1
  64. data/lib/rigor/inference/ivar_writer_census.rb +195 -0
  65. data/lib/rigor/inference/loop_value_record.rb +53 -0
  66. data/lib/rigor/inference/match_rebinding/frame.rb +19 -0
  67. data/lib/rigor/inference/method_dispatcher/constant_folding.rb +110 -9
  68. data/lib/rigor/inference/method_dispatcher/kernel_dispatch.rb +6 -2
  69. data/lib/rigor/inference/method_dispatcher/set_folding.rb +53 -20
  70. data/lib/rigor/inference/method_dispatcher.rb +46 -16
  71. data/lib/rigor/inference/module_function_state/spellings.rb +64 -0
  72. data/lib/rigor/inference/module_function_state.rb +12 -0
  73. data/lib/rigor/inference/mutation_widening.rb +5 -2
  74. data/lib/rigor/inference/narrowing.rb +66 -5
  75. data/lib/rigor/inference/object_mixins.rb +13 -1
  76. data/lib/rigor/inference/project_seed_tables.rb +2 -1
  77. data/lib/rigor/inference/project_yielder.rb +128 -0
  78. data/lib/rigor/inference/scope_indexer/certainty.rb +9 -0
  79. data/lib/rigor/inference/scope_indexer/declared_ivar_types.rb +64 -0
  80. data/lib/rigor/inference/scope_indexer/declared_param_writes.rb +162 -0
  81. data/lib/rigor/inference/scope_indexer/unseen_ivar_writers.rb +133 -0
  82. data/lib/rigor/inference/scope_indexer.rb +480 -103
  83. data/lib/rigor/inference/self_call_ivar_writes.rb +18 -6
  84. data/lib/rigor/inference/statement_evaluator.rb +658 -90
  85. data/lib/rigor/inference/synthetic_method_index.rb +23 -0
  86. data/lib/rigor/inference/synthetic_method_scanner/project_members.rb +130 -0
  87. data/lib/rigor/inference/synthetic_method_scanner.rb +72 -12
  88. data/lib/rigor/language_server/buffer_table.rb +6 -0
  89. data/lib/rigor/language_server/diagnostic_publisher.rb +20 -6
  90. data/lib/rigor/language_server/project_context.rb +59 -3
  91. data/lib/rigor/language_server/server.rb +5 -3
  92. data/lib/rigor/mcp/server.rb +9 -1
  93. data/lib/rigor/plugin/base.rb +26 -11
  94. data/lib/rigor/plugin/manifest.rb +29 -3
  95. data/lib/rigor/plugin/registry.rb +38 -0
  96. data/lib/rigor/protection/discovery_seed.rb +57 -2
  97. data/lib/rigor/rooted_glob.rb +10 -1
  98. data/lib/rigor/scope.rb +14 -5
  99. data/lib/rigor/sig_gen/generator.rb +308 -37
  100. data/lib/rigor/sig_gen/observation_collector.rb +4 -1
  101. data/lib/rigor/sig_gen/rbs_validity.rb +1 -1
  102. data/lib/rigor/sig_gen/renderer.rb +6 -3
  103. data/lib/rigor/sig_gen/type_elaborator.rb +21 -5
  104. data/lib/rigor/type/combinator.rb +4 -3
  105. data/lib/rigor/type/constant.rb +45 -3
  106. data/lib/rigor/type/hash_shape.rb +14 -9
  107. data/lib/rigor/type/struct_instance.rb +3 -2
  108. data/lib/rigor/version.rb +1 -1
  109. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_discoverer.rb +60 -43
  110. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer.rb +20 -1
  111. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +13 -0
  112. data/plugins/rigor-activejob/lib/rigor/plugin/activejob.rb +4 -2
  113. data/plugins/rigor-devise/lib/rigor/plugin/devise.rb +5 -3
  114. data/plugins/rigor-dry-types/lib/rigor/plugin/dry_types.rb +6 -5
  115. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/matcher_analyzer.rb +102 -11
  116. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq.rb +4 -2
  117. data/plugins/rigor-typelizer/lib/rigor/plugin/typelizer.rb +1 -1
  118. data/sig/rigor/analysis/baseline.rbs +3 -2
  119. data/sig/rigor/effects/file_collection.rbs +5 -6
  120. data/sig/rigor/effects/registry.rbs +8 -6
  121. data/sig/rigor/effects/summary.rbs +6 -5
  122. data/sig/rigor/environment.rbs +5 -3
  123. data/sig/rigor/inference/optimistic_origin.rbs +11 -0
  124. data/sig/rigor/inference.rbs +6 -0
  125. data/sig/rigor/plugin/additional_initializer.rbs +5 -5
  126. data/sig/rigor/plugin/effect_ancestry.rbs +6 -4
  127. data/sig/rigor/plugin/effect_attribution.rbs +25 -10
  128. data/sig/rigor/plugin/effect_edge.rbs +12 -5
  129. data/sig/rigor/plugin/effect_entry_points.rbs +8 -3
  130. data/sig/rigor/plugin/io_boundary.rbs +4 -4
  131. data/sig/rigor/plugin/manifest.rbs +16 -18
  132. data/sig/rigor/plugin/protocol_contract.rbs +10 -9
  133. data/sig/rigor/plugin/registry.rbs +1 -1
  134. data/sig/rigor/scope.rbs +31 -50
  135. data/sig/rigor/type.rbs +0 -6
  136. metadata +20 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 4da9b991a97cd586806b416facb6dbf6c3a3b938c7c9433e330bc44c0757fdaf
4
- data.tar.gz: 1167e8d91ccbc8740ac4094f67db82937bd3b374d2488a43e514f7922c7a98b1
3
+ metadata.gz: 608dc6b03dfc9267330c24c6493f3f4ee2d025ac5ce01a044202828fc282e258
4
+ data.tar.gz: 395692680ea16ec6dc42d4187fc57c10ce00731eea4cd1d03176fcc2daa07c14
5
5
  SHA512:
6
- metadata.gz: db2047e49879c399812704702d4064304bf4ac9be8fe25132cc65ac441e2962f7b8c45cf59371727dffaf52caab9accbfb7c8ea0abe1976f264a7a4506e46e6f
7
- data.tar.gz: e426da15aeb714ab906df710946ae3b2c96b35509632b9c35efba289d303da519f58e3cd362a8b47c4ba8a9f883e54e657edeb3e09c1934b4cd542f119440678
6
+ metadata.gz: c7145aafeaf9fd22fe3759970c9b4eaafd8720796048641a387ec7a02d4e579689fde37540db686da9904b7d8419a6338b9db7caa38997d7184960c346253f5a
7
+ data.tar.gz: bff814da943de361c7c1e5bee99fd0e4847d16bb8a9bd3e36609e69246e8eb0e141c6c6e87b425720d709e2277195d9a14100a3018fef75670361fe2acd2160b
data/README.md CHANGED
@@ -232,7 +232,7 @@ rigor docs --list # list every bundled page
232
232
 
233
233
  ## Status
234
234
 
235
- Current release: **`v0.4.5`** (2026-10-19) — on the
235
+ Current release: **`v0.4.6`** (2026-10-23) — on the
236
236
  `0.4.x` line, continuing the evaluation era opened by `v0.2.0`, the first
237
237
  publicly-announced (general / evaluation) release. The line publishes
238
238
  an enumerated [compatibility surface](docs/compatibility.md) as a
@@ -538,8 +538,17 @@ overrides the method with the other one is not reported as
538
538
  widening the return. A body that returns only `nil` keeps
539
539
  the `untyped`: it is the shape of a template method a
540
540
  subclass fills in, and `-> nil` would make every override
541
- that returns a value a widening. `nil` inside a union and a
542
- computed `Float` are proposed as they are. An `untyped` that
541
+ that returns a value a widening. A literal inside a tuple, a
542
+ record or a type argument is widened the same way while the
543
+ shape stays (`-> [1, "a"]` becomes `-> [Integer, String]`,
544
+ `-> { a: 1 }` becomes `-> { a: Integer }`); of the generic
545
+ types, only the arguments of `Array`, `Hash`, `Set`, `Range`
546
+ and `Enumerator` are widened. `nil` inside a union or a shape
547
+ and a computed `Float` are proposed as they are. When the
548
+ method also carries an inline `# @rbs` annotation for its
549
+ parameters only, a return holding a literal that would be
550
+ widened, nested or not, is not proposed at all and the
551
+ `untyped` stays. An `untyped` that
543
552
  only the inline reader synthesizes for a `def` you did not
544
553
  annotate keeps the literal, as does a method no `.rbs`
545
554
  declares — the literal is still the strictest thing the body
@@ -28,15 +28,25 @@ rigor check [paths...]
28
28
  `paths` are files or directories; when omitted, Rigor checks
29
29
  the `paths:` list from the configuration file.
30
30
 
31
+ Only the files you name are reported on, but Rigor reads the
32
+ declarations in the configured `paths:` too, so a file resolves a
33
+ class or constant that another file declares. A file outside
34
+ `paths:` also reads the files it loads with a literal
35
+ `require_relative "..."`, and the files those load in turn. Their
36
+ classes, modules and constants resolve and their methods count as
37
+ defined, but Rigor does not infer what those methods return or
38
+ check their arity, since another file may redefine them.
39
+
31
40
  | Option | Effect |
32
41
  | --- | --- |
33
- | `--config=PATH` | Use a specific config file instead of auto-discovery. |
42
+ | `--config=PATH` | Use a specific config file instead of auto-discovery. A path that does not exist is an error (exit 64), not a run with the defaults. |
34
43
  | `--format=FORMAT` | Output format. Default `text`. Also `json` (structured stream), plus the CI-native renderings `sarif`, `github`, `gitlab`, `checkstyle`, `junit`, and `teamcity` — see [Running Rigor in CI](11-ci.md). |
35
44
  | `--no-ci-detect` | Disable CI auto-detection — by default `text` output also emits the running CI's native annotations / hint (`RIGOR_CI_DETECT=0` does the same). See [Running Rigor in CI § auto-detection](11-ci.md). |
36
45
  | `--explain` | Surface fail-soft fallback events as `info` diagnostics. |
37
46
  | `--no-cache` | Skip the persistent cache for this run. With `--incremental` it is a cold full analysis: the incremental snapshot and the run-result cache are neither read nor written, so the next plain `--incremental` run still uses the snapshot it had before. |
38
47
  | `--incremental` | Re-analyse only the files changed since the last run plus the files that depend on them, serving the rest from a cross-process disk snapshot (ADR-46). Diagnostics are identical to a full run; a config / gem / version change (or a file added or removed) transparently forces a full re-analysis. A run with nothing changed is answered from the cache without starting the analyzer. See [Caching](12-caching.md). |
39
- | `--verify-incremental` | Acceptance gate: run the incremental analyzer against a full `--no-cache` run and assert the diagnostics agree as a set and in the same order, then exit (0 on match, 1 on mismatch: the differing diagnostics for a set mismatch, or the first index where the order diverges for a same-set, different-order mismatch). Used in CI to guarantee `--incremental` never serves a stale result. |
48
+ | `--verify-incremental` | Acceptance gate: run the incremental analyzer against a full `--no-cache` run and assert the diagnostics agree as a set and in the same order, then exit (0 on match, 1 on mismatch: the differing diagnostics for a set mismatch, or the first index where the order diverges for a same-set, different-order mismatch). It re-analyses half of an unchanged tree and serves the other half from cache, so it checks the cache-merge path but not that an edit re-checks every file it affects; `--verify-incremental-edits` adds that. |
49
+ | `--verify-incremental-edits=N` | With `--verify-incremental` (and implying it): after the check above, replay two edits on each of `N` files spread over the analysed list, through an editor buffer, never on disk: the file emptied, then the file reduced to its `class` / `module` declarations with every body dropped (skipped when that is the file itself, or holds no declaration). After each edit, the warm recheck of the baseline must equal a full analysis of the edited tree, or the run fails naming the edit and the file. Each replay costs a recheck and a full analysis. A replay the recheck declines (it would have been a full analysis) is counted in the OK line, and the run fails if every replay declined. Only these two removals are replayed, never an addition. Default 0. |
40
50
  | `--clear-cache` | Delete the cache directory before running. |
41
51
  | `--cache-stats` | Print the on-disk cache inventory when finished — on stdout under `--format text`, on stderr under every other format, so machine-readable output stays a parseable document. `--clear-cache`'s and `--verify-incremental`'s notes follow the same rule. |
42
52
  | `--[no-]stats` | Print a run summary (files, classes, memory, wall time) to stderr. Default on. |
@@ -60,7 +70,9 @@ that want the stricter reading.
60
70
  ### Editor mode scope
61
71
 
62
72
  `--tmp-file` / `--instead-of` on their own analyse **only** the
63
- buffer's file: fast, and blind to what the unsaved edit does to the
73
+ buffer's file. It still reads the rest of the project's declarations
74
+ (the classes and methods other files define), as `rigor check FILE`
75
+ does, but it reports nothing about what the unsaved edit does to the
64
76
  rest of the project.
65
77
 
66
78
  Adding `--incremental` analyses the **whole project with the buffer
@@ -138,6 +150,19 @@ truncation explicit. `--trace` records fail-soft fallbacks,
138
150
  after the rows of a line table in text output. The editor-mode
139
151
  `--tmp-file` / `--instead-of` pair is accepted as on `check`.
140
152
 
153
+ A class or constant that another project file declares reads as
154
+ `rigor check` types it (`singleton(User)`) when a bare
155
+ `rigor check --incremental` has left a snapshot for the project:
156
+ `type-of` folds the cross-file declarations from that snapshot's
157
+ per-file bundles, re-reading any file whose bytes changed since,
158
+ and never writes it. With no reusable snapshot (none yet, or one a
159
+ change made unusable, for example a changed configuration, lockfile, `sig/`, `pre_eval:`
160
+ file or required feature, an engine upgrade, or a snapshot written
161
+ for other roots) the probe types the one file on its own, so such a name reads
162
+ `Dynamic[top]`, and it prints nothing about it. Only those
163
+ declarations are shared: the types `check` infers for an unannotated
164
+ parameter are not.
165
+
141
166
  A template a plugin compiles into Ruby (an ERB view under
142
167
  `rigor-actionpack`) is probed the way `rigor check` analyses
143
168
  it: the compiled Ruby is typed under the view's declared
@@ -162,7 +187,9 @@ and is probed as plain Ruby, parse error included.
162
187
 
163
188
  The four probe commands — `type-of`, `type-scan`, `trace` and
164
189
  `annotate` — build their environment fresh on every invocation
165
- and never read or write the persistent cache. That is why none
190
+ and never read or write the persistent cache (apart from the
191
+ read-only snapshot lookup `type-of` makes for the project's
192
+ declarations, above). That is why none
166
193
  of them takes `--no-cache`: the flag would have nothing to skip.
167
194
  A probe therefore types against the environment `rigor check
168
195
  --no-cache` analyses with. The environment a cached (default)
@@ -532,6 +559,12 @@ rigor sig-gen [paths]
532
559
  | `--no-cache` | Do not read or write the analysis cache. Only effect collection uses it. |
533
560
  | `--format=text\|json` | Output format. |
534
561
 
562
+ A `paths` argument that does not exist, or is neither a directory nor a `.rb` file, is reported the way
563
+ `rigor check` reports it: on stderr as `path:1:1: error: …`, or under `diagnostics` in `--print` and `--diff`
564
+ JSON output. When no argument is readable this exits `1`, under every mode, `--write` and `--check` included.
565
+ The configured default `paths:` count too, so `paths: [lib]` with no `lib/` directory exits `1`. A `--config`
566
+ that names no file, is a directory, or is empty is a usage error and exits `64`.
567
+
535
568
  `--print`, `--diff`, `--write` and `--check` are mutually exclusive.
536
569
  `--check` fails exactly when `--write` would create, change or
537
570
  refuse a file, so a tighter return `--write` declines without
@@ -640,7 +673,7 @@ rigor triage [paths]
640
673
 
641
674
  `--top=N` sets the hotspot count (default 10); `--hints-only`,
642
675
  `--selectors-only`, and `--no-hints` select which sections print.
643
- `triage` is advisory and always exits `0` — it never gates a build.
676
+ `triage` is advisory and exits `0` once it runs — it never gates a build; a usage error exits `64`.
644
677
 
645
678
  By default the distribution, selectors, and hotspots sections count
646
679
  only the **actionable** diagnostics (`error` + `warning`). `info`
@@ -946,6 +979,11 @@ loaded from (a `path:` line in text, a `"path"` key in JSON), so
946
979
  if a stale installed gem is shadowing a newer checkout's bundled
947
980
  plugin copy the mismatch is visible at a glance.
948
981
 
982
+ A plugin that declares `per_invocation_classes:` or
983
+ `per_invocation_non_action_macros:` ([ADR-125](../adr/125-per-invocation-classes.md))
984
+ also gets a line for each in text, and a key in JSON (an empty
985
+ array when it declares none).
986
+
949
987
  ```sh
950
988
  rigor plugins [--format=text|json] [--strict] [--capabilities] [--config=PATH]
951
989
  ```
@@ -1268,8 +1306,9 @@ diagnostics about Rigor's own inference cutoffs and memory — see
1268
1306
  | `64` | Usage error — unknown command, bad flag, malformed argument, or a value in `.rigor.yml` the loader cannot proceed on. |
1269
1307
  | `70` | Internal fatal error — `SystemStackError` or `NoMemoryError` inside the analysis; Rigor could not finish the run, so its report on disk is empty rather than clean. |
1270
1308
 
1271
- `rigor triage` is the exception: it is advisory and always
1272
- exits `0`.
1309
+ `rigor triage` is the exception: it is advisory and exits `0`
1310
+ whenever it runs to completion. A usage error, such as a
1311
+ `--config` that names no file, still exits `64`.
1273
1312
 
1274
1313
  A configuration mistake prints one `rigor:` line naming the key
1275
1314
  and nothing else — no backtrace, on every command:
@@ -67,7 +67,7 @@ cache:
67
67
  | `target_ruby` | String | `"4.0"` | The lowest Ruby version *your* project runs on — `"X.Y"`, `"X.Y.Z"`, or `"latest"`. Independent of the Ruby Rigor itself runs on. Every value selects the syntax Rigor parses. A version you set explicitly also turns on the Ruby-deprecation rules for that version ([`call.deprecated-ruby2-keywords`](04-diagnostics.md#rule-call-deprecated-ruby2-keywords) from `"4.1"`); the default and `"latest"` never do, which is why `rigor init` writes the key commented out. |
68
68
  | `paths` | Array | `["lib"]` | Directories or files to analyse. |
69
69
  | `exclude` | Array | `[]` | Glob patterns to skip. A relative pattern is matched against the file's path relative to the directory of the config file in use (the working directory when no config file is used), so it excludes the same files whether the path came from `paths:` or from a command-line argument, and it never matches on a directory above that base (a file outside it is seen as `../…`, so `../shared/lib/gen/**` reaches a sibling directory that `paths:` names). A leading `./` is ignored, and a pattern starting with `.` (such as `.*`) also matches files outside the base, which appear as `../…`. A symlink to a file is matched only under its own name: the realpath pass resolves directories, not files. `*` and `**` also cross `/`, so `lib/*.rb` matches nested files too. An absolute pattern is matched against the absolute path. Patterns from an `includes:` file resolve against the top-level config's directory, not the included file's. `vendor/bundle`, `.bundle`, and `node_modules` are always excluded. |
70
- | `test_paths` | Array | `nil` | The project's test roots: the directories (or files) holding its tests. Relative entries resolve against the config file's directory. Unset auto-detects whichever of `spec/` and `test/` exist; `[]` declares none. `rigor sig-gen --params=observed` reads call sites there to type parameters, and names on stderr a declared root that does not exist. Test roots are not analysed unless `paths:` also lists them, and changing them invalidates no cache. |
70
+ | `test_paths` | Array | `nil` | The project's test roots: the directories (or files) holding its tests. Relative entries resolve against the config file's directory. Unset auto-detects whichever of `spec/` and `test/` exist in the config file's directory, or in the working directory when the config file's directory has neither or no config file is used; `[]` declares none. `rigor sig-gen --params=observed` reads call sites there to type parameters, and names on stderr a declared root that does not exist. Test roots are not analysed unless `paths:` also lists them, and changing them invalidates no cache. |
71
71
  | `includes` | Array | `[]` | Other config files to layer underneath this one. |
72
72
  | `fold_platform_specific_paths` | Boolean | `false` | Resolve Ruby-version-conditional load paths when discovering sources. |
73
73
  | `parameter_inference` | Boolean | `false` | Opt-in call-site parameter type inference on the `check` walk ([ADR-67](../adr/67-parameter-type-inference.md) WD6). When `true`, an undeclared `def` / `initialize` / setter parameter is typed to the union of its resolved call-site argument types, sharpening downstream ivar reads, folds, and protection coverage. Precision-additive only — the negative rules never fire against an inferred parameter. Cannot be combined with `--incremental`. |
@@ -106,6 +106,7 @@ rigor: disable: "call.undefined-methdo" is not a recognized rule id; the suppres
106
106
  rigor: disable: "plugin_loader.load-error" names a run-level row, which `disable:` does not reach; use `severity_overrides:` or a baseline
107
107
  rigor: disable: "plugin.shoulda-matchers.unknown-column" names no plugin `plugins:` configures; the suppression has no effect
108
108
  rigor: severity_overrides: "flow.bogus" is not a recognized rule id; the override has no effect
109
+ rigor: severity_overrides: "undefined-method" is a legacy alias of `call.undefined-method`; the override has no effect, so write `call.undefined-method`
109
110
  rigor: bundler.lockfile: "./missing/Gemfile.lock" does not exist
110
111
  ```
111
112
 
@@ -67,7 +67,7 @@ carries no `documentation_url`.
67
67
  | <a id="rule-flow-return-in-ensure"></a>`flow.return-in-ensure` | An explicit `return` inside an `ensure` clause — it overrides the method's in-flight return value and silently swallows any in-flight exception. A `return` in a nested `def`, lambda, or `define_method` block inside the `ensure` does not fire (it exits that inner frame). | high |
68
68
  | <a id="rule-flow-shadowed-rescue-clause"></a>`flow.shadowed-rescue-clause` | A `rescue` clause can never run because an earlier clause of the same chain already catches a superclass (or the same class) of every exception class it names. | high |
69
69
  | <a id="rule-def-return-type-mismatch"></a>`def.return-type-mismatch` | The method body's result violates its declared RBS return type. | medium |
70
- | <a id="rule-def-ivar-write-mismatch"></a>`def.ivar-write-mismatch` | An instance variable is written with a type disagreeing with its first write, unless its RBS declaration (`@x: T` or an `attr_*` member) admits both writes. | high |
70
+ | <a id="rule-def-ivar-write-mismatch"></a>`def.ivar-write-mismatch` | An instance variable is written with a type disagreeing with its first write, unless its RBS declaration (`@x: T` or an `attr_*` member) admits both writes. With `rigor-actionpack` or `rigor-actionmailer` loaded, writes in two different actions of a controller or mailer are not compared, since Rails runs one action per instance. A write in a private or protected helper, `initialize` or the same action still is, and so is one in a callback target or `helper_method` that a macro in the same file names. A public method named only by a macro in another file or a concern, or reached through a lambda callback, counts as an action, and so does an action another action calls. | high |
71
71
  | <a id="rule-def-method-visibility-mismatch"></a>`def.method-visibility-mismatch` | An explicit-receiver call reaches a private method. | high |
72
72
  | <a id="rule-def-override-visibility-reduced"></a>`def.override-visibility-reduced` | An override reduces the visibility it inherits from a project-defined ancestor. | high |
73
73
  | <a id="rule-def-override-return-widened"></a>`def.override-return-widened` | An override's declared return type widens the inherited return (covariance). | high |
@@ -66,7 +66,9 @@ rigor mcp [--transport=stdio] [--config=PATH]
66
66
  transport is queued for v2.
67
67
  - `--config=PATH` — session-level default config path. Individual tool
68
68
  calls may supply their own `config` argument, which takes precedence.
69
- Without either, `Configuration.discover` walks `.rigor.yml` /
69
+ A path that names no file is an error, not a silent switch to the
70
+ defaults, for the session default and for a per-call `config` alike:
71
+ the call returns `isError: true`. Without either, `Configuration.discover` walks `.rigor.yml` /
70
72
  `.rigor.dist.yml` from the working directory.
71
73
 
72
74
  Exit codes: 0 (clean shutdown — stdin EOF), 64 (unknown
@@ -417,6 +419,12 @@ The key is absent under the default `untyped` policy.
417
419
  Classifications: `new-file`, `new-method`, `tighter-return`,
418
420
  `equivalent`, `skipped`.
419
421
 
422
+ A `paths` entry that is neither a directory nor a `.rb` file is reported
423
+ under a top-level `diagnostics` array, in the shape `rigor_check` uses
424
+ (`"message": "no such file or directory"`). It is a warning beside
425
+ paths that were read, and an error when none was. An empty `candidates`
426
+ list with no `diagnostics` means nothing was found to generate.
427
+
420
428
  **Typical agent use:** call `rigor_sig_gen` to see which methods have
421
429
  precise enough returns to be worth writing into `sig/`, then generate
422
430
  and insert the RBS. The agent should NOT write signatures
@@ -185,7 +185,14 @@ exactly which files an edit can affect. A continuous-integration
185
185
  gate, `rigor check --verify-incremental`, asserts this on every
186
186
  build: it runs the incremental analyzer and a full analysis and
187
187
  fails if they disagree on a single diagnostic, or list the same
188
- diagnostics in a different order.
188
+ diagnostics in a different order. That gate re-analyses an
189
+ unchanged tree, so it cannot see an edit that fails to re-check a
190
+ file it affects. `--verify-incremental-edits=N` also edits `N`
191
+ files one at a time, in memory and not on disk, and compares each
192
+ warm recheck with a full analysis of the edited tree. Each file is
193
+ edited twice: once emptied, and once reduced to its `class` /
194
+ `module` declarations with every body dropped. It replays only
195
+ those two removals, never an addition or any other edit.
189
196
 
190
197
  Rigor is also precise about *what* an edit changed. Re-checking a
191
198
  file that many others depend on — a base class, a widely-used
@@ -77,6 +77,36 @@ plugins:
77
77
  `--fail-on=warning` run — regenerate with `rigor baseline
78
78
  regenerate`.
79
79
 
80
+ ## Instance variable writes across actions
81
+
82
+ The plugin tells the engine that a mailer runs one action per instance
83
+ ([ADR-125](../../adr/125-per-invocation-classes.md)). Its manifest lists
84
+ `ActionMailer::Base` in `per_invocation_classes:`, and the callback macros
85
+ (`before_action` and the rest) and `helper_method` in
86
+ `per_invocation_non_action_macros:`, so a method those name is not an action.
87
+
88
+ So [`def.ivar-write-mismatch`](../04-diagnostics.md#rule-def-ivar-write-mismatch)
89
+ stays silent when `welcome` assigns `@items = [1, 2]` and `digest` assigns
90
+ `@items = { a: 1 }`. It still reports two divergent writes in the same action,
91
+ and a divergence that involves a private or protected helper, `initialize`, or a
92
+ callback target or `helper_method` that a macro in the same file names.
93
+
94
+ The callback macros include `before_deliver`, `after_deliver` and
95
+ `around_deliver`, which run on the action's own instance.
96
+
97
+ Two shapes stay silent because their macros are not listed: a public
98
+ method named by `rescue_from X, with: :handler` or by `layout :method_name`
99
+ counts as an action, as does one a lambda callback
100
+ (`before_action -> { load_roles }`) reaches ([#2320](https://github.com/rigortype/rigor/issues/2320) tracks the fix).
101
+
102
+ Visibility is read in source order, as Rails builds `action_methods` from the
103
+ public instance methods. A method that ends up protected or private is not an
104
+ action: it follows a bare `protected` or `private`, is named by `private :x` or
105
+ `protected :x`, or is defined inline (`private def x`). `public :x` or
106
+ `public def x` makes it one again. So it is no longer checked for a view
107
+ template, and calling it (`UserMailer.helper`) reports `unknown-action` at
108
+ error severity, as Rails raises `NoMethodError` for it.
109
+
80
110
  ## Plugin internals
81
111
 
82
112
  The mailer/concern discoverer, the cached `:mailer_index` producer,
@@ -41,6 +41,36 @@ no-op rather than erroring.
41
41
  | `plugin.actionpack.permit-call` | info | a `params.require(:m).permit(:key, …)` chain resolved to a known model; keys matched against its columns |
42
42
  | `plugin.actionpack.unknown-permit-key` | error | a literal `permit(:key)` is a near-miss (edit distance ≤ 2) of a real column but not one — a likely typo (with a did-you-mean). A key nothing like any column (a legitimate virtual attribute) does not fire |
43
43
 
44
+ The plugin also tells the engine that a controller runs one action
45
+ per instance ([ADR-125](../../adr/125-per-invocation-classes.md)).
46
+ Its manifest lists `ActionController::Base` and
47
+ `ActionController::API` in `per_invocation_classes:`. It also lists
48
+ the callback macros (`before_action` and the rest) and
49
+ `helper_method` in `per_invocation_non_action_macros:`, so a method
50
+ those name is not an action.
51
+
52
+ So `def.ivar-write-mismatch` stays silent when `index` assigns
53
+ `@roles = Role.sorted.to_a` and `permissions` assigns
54
+ `@roles = Role.sorted`. It still reports a divergence that involves
55
+ a private or protected helper, `initialize`, or two writes in the
56
+ same action. It also still reports one that involves a callback
57
+ target or `helper_method`, when the macro naming it sits in the same
58
+ file as the method. Actions defined in a concern module and
59
+ `ActionController::Metal` controllers keep reporting too.
60
+
61
+ Two shapes stay silent because their macros are not listed: a public
62
+ method named by `rescue_from X, with: :handler` or by `layout :method_name`
63
+ counts as an action, as does one a lambda callback
64
+ (`before_action -> { load_roles }`) reaches ([#2320](https://github.com/rigortype/rigor/issues/2320) tracks the fix).
65
+
66
+ These shapes stay silent:
67
+
68
+ - a public method named only by a macro in another file or a
69
+ concern, such as `ApplicationController`;
70
+ - a public method a lambda callback reaches
71
+ (`before_action -> { load_roles }`);
72
+ - an action that calls another action.
73
+
44
74
  Filter and render resolution honours nested-module controller
45
75
  qualification (`module Admin; class WidgetsController` resolves
46
76
  views under `admin/widgets/…`) and silences gem-shipped parent
@@ -48,6 +48,21 @@ whatever class includes the concern.
48
48
 
49
49
  ## Limitations
50
50
 
51
+ - **Needs RBS for the Devise modules.** The methods come from the
52
+ strategy modules' signatures (`Devise::Models::DatabaseAuthenticatable`
53
+ and the rest), which Rigor does not bundle and the `devise` gem does
54
+ not ship. Supply them through `signature_paths:` (or a
55
+ `rbs collection`); a strategy module with no signature contributes
56
+ nothing, and the model's `valid_password?` and friends stay as they
57
+ were. `plugins/rigor-devise/demo/sig/devise.rbs` is a minimal
58
+ example.
59
+ - **Your own definitions win.** A method the model defines itself
60
+ (`def`, `alias_method`, `attr_*`, `define_method`, `delegate`), or
61
+ that a module of your project it includes or prepends defines, is
62
+ left to that definition rather than typed from the Devise module.
63
+ This holds even for a module included before the `devise` call,
64
+ which Devise's module would override at runtime. Only the module's
65
+ own public methods are contributed.
51
66
  - **Instance methods only.** Per-module `ClassMethods` (e.g.
52
67
  `User.reset_password_by_token`) are not yet synthesised.
53
68
  - **Controller helpers deferred.** `current_user` /
@@ -73,10 +73,11 @@ end
73
73
  | `eq(literal)` / `eql(literal)` | meet with `Constant<literal>` (see below) |
74
74
  | `be_a(T)` / `be_kind_of(T)` / `be_instance_of(T)` / `be_an_instance_of(T)` | meet with `T` |
75
75
  | `be_nil` | narrow to `Constant<nil>` |
76
- | `match(/regex/)` | narrow to `String` |
76
+ | `match(/regex/)` | keep the `String` and `Symbol` members, read an `Object`, `BasicObject` or module member as `String \| Symbol`, and remove `nil`, `true`, `false` and numeric members (unless the project defines `match`, `to_str` or `==` on them); unchanged when a member may be anything else |
77
77
  | `not_to be_nil` / `to_not be_nil` | narrow away from `nil` |
78
78
  | `not_to be_a(T)` / `not_to be_kind_of(T)` / `not_to be_instance_of(T)` / `not_to be_an_instance_of(T)` | narrow away from `T` |
79
79
  | `not_to eq(literal)` / `not_to eql(literal)` (and `to_not`) | unchanged (see below) |
80
+ | `not_to match(/regex/)` (and `to_not`) | unchanged: a `String` that does not match passes |
80
81
 
81
82
  Other matchers emit nothing. `be_truthy` is not narrowed, nor is
82
83
  `expect { ... }.to raise_error(...)` (the block form), nor an
@@ -24,6 +24,20 @@ module Rigor
24
24
  def display_path(path)
25
25
  path == physical_path ? logical_path : path
26
26
  end
27
+
28
+ # The file's identity for comparing two spellings of one path: its real path, resolved through its directory
29
+ # when the file is absent from disk (a new buffer's file), so a symlinked root or parent spells it as it spells
30
+ # an existing file. Two accepted limits: a missing name is compared as written, so a name that differs only in
31
+ # case is not matched; and a `..` in a missing path is resolved textually, not through the filesystem.
32
+ def self.real_path(path)
33
+ File.realpath(path)
34
+ rescue SystemCallError
35
+ absolute = File.absolute_path(path)
36
+ parent = File.dirname(absolute)
37
+ return absolute if parent == absolute
38
+
39
+ File.join(real_path(parent), File.basename(absolute))
40
+ end
27
41
  end
28
42
  end
29
43
  end
@@ -21,6 +21,14 @@ module Rigor
21
21
  # - the direct receiver / subject: `param.foo`, `case param`
22
22
  # - an indexed element: `param[i] - x` (the `-` receiver is the `[]` call whose root is `param`)
23
23
  # - any method chain: `param.foo.bar`, `param.foo(arg).baz`, `param.key?(:x)`
24
+ # - an array or hash literal holding the parameter (issue #2189): `[param].first.upcase`,
25
+ # `{a: param}[:a].upcase`. The engine reads such an element back precisely, so the read is the
26
+ # parameter's lower bound again; any other call on the literal declines too, the FP-safe direction.
27
+ # - an instance variable the same method wrote from such a value (issue #2189): `@x = param; @x.upcase`.
28
+ # This one IS a scope mark — {Inference::StatementEvaluator#eval_ivar_write} stamps `:inferred_param`
29
+ # under the ivar's `@`-prefixed name, which no local can spell — because the write and the read are
30
+ # separate statements. The mark is method-local: the class-ivar census that seeds a sibling method's
31
+ # ivars types the write's RHS without the inference seed, so a cross-method read is not a lower bound.
24
32
  #
25
33
  # Every value derived from an inferred parameter by dispatch is itself a lower bound, so firing against it
26
34
  # is an FP by the same construction. No-op on a normal `check` run — `Scope#inferred_param?` is false
@@ -36,17 +44,30 @@ module Rigor
36
44
  return false if node.nil? || depth > MAX_DEPTH
37
45
 
38
46
  case node
39
- when Prism::LocalVariableReadNode then scope.inferred_param?(node.name)
47
+ when Prism::LocalVariableReadNode, Prism::InstanceVariableReadNode then scope.inferred_param?(node.name)
40
48
  when Prism::CallNode then rooted?(node.receiver, scope, depth + 1)
41
- # Value-combining forms: the produced value is a union of the operands, so it is a lower bound when
42
- # ANY operand is inferred-parameter-rooted (`cps[i] - x rescue -1`, `a && param.foo`, `param || b`).
43
- # Declining on the whole expression is FP-safe by the same lower-bound argument.
49
+ when Prism::ArrayNode, Prism::HashNode, Prism::KeywordHashNode, Prism::AssocNode
50
+ literal_rooted?(node, scope, depth + 1)
51
+ else combining_rooted?(node, scope, depth + 1)
52
+ end
53
+ end
54
+
55
+ # An array or hash literal is rooted when any element is: the engine reads the element back precisely.
56
+ def literal_rooted?(node, scope, depth)
57
+ parts = node.is_a?(Prism::AssocNode) ? [node.key, node.value] : node.elements
58
+ parts.any? { |part| rooted?(part, scope, depth) }
59
+ end
60
+
61
+ # Value-combining forms: the produced value is a union of the operands, so it is a lower bound when ANY
62
+ # operand is inferred-parameter-rooted (`cps[i] - x rescue -1`, `a && param.foo`, `param || b`). Declining
63
+ # on the whole expression is FP-safe by the same lower-bound argument.
64
+ def combining_rooted?(node, scope, depth)
65
+ case node
44
66
  when Prism::RescueModifierNode
45
- rooted?(node.expression, scope, depth + 1) || rooted?(node.rescue_expression, scope, depth + 1)
46
- when Prism::AndNode, Prism::OrNode
47
- rooted?(node.left, scope, depth + 1) || rooted?(node.right, scope, depth + 1)
48
- when Prism::ParenthesesNode then rooted?(last_statement(node.body), scope, depth + 1)
49
- when Prism::StatementsNode then rooted?(node.body.last, scope, depth + 1)
67
+ rooted?(node.expression, scope, depth) || rooted?(node.rescue_expression, scope, depth)
68
+ when Prism::AndNode, Prism::OrNode then rooted?(node.left, scope, depth) || rooted?(node.right, scope, depth)
69
+ when Prism::ParenthesesNode then rooted?(last_statement(node.body), scope, depth)
70
+ when Prism::StatementsNode then rooted?(node.body.last, scope, depth)
50
71
  else false
51
72
  end
52
73
  end