rigortype 0.3.7 → 0.3.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 8e6c542d01ee79f9cb65e22a91aa5a87cd9dd43a842ffc815b94ded244fe9cc0
4
- data.tar.gz: 2a4cf5b015559a12d1ede16f414d9884df09aab01ce0bc61da0b1d683b1e11a0
3
+ metadata.gz: 30b66e3d183999cbd85557c5af6b770ac78bf82507a2e587e5878c1a411b4326
4
+ data.tar.gz: 2c3cee80d75ea9769d7b965dd84ac60eb4af89c27fc63d3ec35a1cdce3b45338
5
5
  SHA512:
6
- metadata.gz: '032268117a2430a52a7f8505196c8f5e6760cd29551aa95af5c3fe55185450d569d21f97a6379281951b6ef01b34a30522178f25701dfbed8ff680c6a6c80eaa'
7
- data.tar.gz: d39c177c2c8d9a9b255a1e60ffcbc3c101a8d9e63fb6213bb846d47ae13dcdd0e37acd366055b6ede65836939ac12b026b43c9b03b0774de60f52a2e09de2890
6
+ metadata.gz: 9986a78b908e05fd017f772b0c26d6bc3d654e665cbf59d85cdf0176b76735403d644238ca7072268f9e47ba3937073002de47a308deda6cab77311459f6f008
7
+ data.tar.gz: 66df68005471dc8df33c9a1881659644fca80c4e406830b7ab9c1fe5b641e3872d13a70ab39cccdf66fba3d256e46026ab13963a70a76be1362ac86b8e823f1d
data/README.md CHANGED
@@ -231,7 +231,7 @@ rigor docs --list # list every bundled page
231
231
 
232
232
  ## Status
233
233
 
234
- Current release: **`v0.3.7`** (2026-09-05) — on the
234
+ Current release: **`v0.3.8`** (2026-09-08) — on the
235
235
  `0.3.x` line, continuing the evaluation era opened by `v0.2.0`, the first
236
236
  publicly-announced (general / evaluation) release. The line publishes
237
237
  an enumerated [compatibility surface](docs/compatibility.md) as a
@@ -95,10 +95,13 @@ states:
95
95
 
96
96
  The `sig.skipped.*` reasons are:
97
97
 
98
- - `sig.skipped.complex-shape` — the method has optional, rest,
99
- keyword, block, or forwarding parameters. The MVP's
100
- body-typing path only handles required positional
101
- parameters; complex shapes need a future slice.
98
+ - `sig.skipped.complex-shape` — reserved for a parameter
99
+ shape the renderer cannot spell. Every shape a `def` can
100
+ declare renders today (optional, rest, trailing, keyword,
101
+ keyword-rest, `...` forwarding, `&block`), so the
102
+ generator does not produce this reason; it stayed
103
+ reserved when the gate that used to fire it for every
104
+ such method was retired (#778).
102
105
  - `sig.skipped.untyped-return` — the method body's last
103
106
  expression types as `Dynamic[top]`. Emitting `untyped` as
104
107
  a tightening would be noise rather than help.
@@ -119,16 +122,25 @@ The `sig.skipped.*` reasons are:
119
122
  The three `sig.generated.*` identifiers
120
123
  (`sig.generated.new-file` / `new-method` / `tighter-return`)
121
124
  are emitted as JSON fields under `--format=json` so CI
122
- gating consumers can route them.
125
+ gating consumers can route them. Every `skipped` row is part
126
+ of the same payload, carrying its `sig.skipped.*` identifier
127
+ as `skip_reason`, so a method missing from your `sig/` has
128
+ its reason next to the rows that did emit. In text mode a
129
+ one-line stderr summary counts the skipped methods per
130
+ reason instead; stdout stays paste-clean.
123
131
 
124
132
  ## What method shapes the generator covers
125
133
 
126
134
  Slice-by-slice (each shipped via a CHANGELOG entry — this
127
135
  list is the current state):
128
136
 
129
- - **Plain instance `def foo`** with required positional
130
- parameters. Both new-method and tighter-return paths
131
- apply.
137
+ - **Plain instance `def foo`** of any parameter shape:
138
+ required, optional, rest, trailing, keyword, keyword-rest,
139
+ `...` forwarding and `&block`. The parameter list mirrors
140
+ the runtime shape with `untyped` in every position (the
141
+ observed union under `--params=observed`), and a block
142
+ renders as `?{ (*untyped) -> untyped }`. Both new-method
143
+ and tighter-return paths apply.
132
144
  - **Singleton-side `def self.foo`** and
133
145
  `class << self; def foo; end`. Rendered as
134
146
  `def self.foo: ...`; matched against
@@ -143,10 +155,8 @@ list is the current state):
143
155
  are recognised as user-authored and never produce a
144
156
  duplicate `def` insertion.
145
157
 
146
- Method shapes the generator does **not** cover yet (and
147
- silently skips):
158
+ Method shapes the generator does **not** cover yet:
148
159
 
149
- - Optional / rest / keyword / block / forwarding parameters.
150
160
  - `define_method(:name) { ... }`.
151
161
  - Methods whose body types as `Dynamic[top]` (the body
152
162
  inference cannot prove a useful return type).
@@ -303,9 +313,9 @@ inference, not a separate analysis.
303
313
 
304
314
  ## Limits today
305
315
 
306
- - Methods with optional / rest / keyword / block /
307
- forwarding parameters silently skip
308
- (`sig.skipped.complex-shape`).
316
+ - A block parameter always renders as the lenient
317
+ `?{ (*untyped) -> untyped }`; a typed block signature
318
+ waits on the engine tracking yield shapes end-to-end.
309
319
  - `define_method` and `Data.define`-specific emission are
310
320
  deferred follow-ups (`Data.define`-derived readers come
311
321
  through if a method body exists).
@@ -475,6 +475,13 @@ exits `1`: you asked for a write and did not get one. Such a skip
475
475
  is a bug in Rigor's RBS rendering, not in your code — please
476
476
  report it.
477
477
 
478
+ A method the generator declined for any other reason (a body
479
+ that proves only `untyped`, a user-authored declaration it will
480
+ not overwrite) is never a silent absence: under `--format=json`
481
+ it is a `skipped` row of the `candidates` array with its
482
+ `sig.skipped.*` identifier in `skip_reason`, and in text mode a
483
+ one-line stderr summary counts the skipped methods per reason.
484
+
478
485
  ## `rigor lsp`
479
486
 
480
487
  Run the Language Server over stdio. See
@@ -74,7 +74,7 @@ carries no `documentation_url`.
74
74
  | <a id="rule-effect-envelope-exceeded"></a>`effect.envelope-exceeded` | A method performs an effect its declared envelope does not admit — its proven effect labels (its own body plus everything it calls) are not covered by the `%a{pure}` or `%a{rigor:v1:effect …}` bound written on it or on its class. Opt-in twice over: it needs an `effects:` block in `.rigor.yml` and an envelope you wrote. Positioned at the Ruby `def`. Unproven ("and possibly more") effects never fire, and `mutate.local` is tolerated by every envelope. | high |
75
75
  | <a id="rule-effect-liskov-widened"></a>`effect.liskov-widened` | An override escapes the envelope written on the method it overrides. A `PgRepo` is usable wherever a `Repo` is, so a `%a{rigor:v1:effect io.db}` on `Repo#find` binds `PgRepo#find` too: an implementation may be purer than the bound it inherits, never less pure. Either what the override *does* exceeds the inherited bound, or the envelope the override *declares for itself* is wider than it. Both sides must be authored — nothing fires unless someone wrote an envelope on the ancestor — and only subclassing counts, not `include`. Positioned at the override's `def`. Needs an `effects:` block. | high |
76
76
  | <a id="rule-effect-unknown-label"></a>`effect.unknown-label` | An effect declaration names a label the registry does not know — a typo in an envelope (`%a{rigor:v1:effect io.bd}`), or a member of `effects.tolerated:`. The whole tag then reads as unbounded, so the declaration quietly stops doing anything; this says so. Positioned at the declaration: the `.rbs` line, the `.rb` line for an rbs-inline annotation, or `.rigor.yml` for a config value. `# rigor:disable` comments are not read out of `.rbs` or `.rigor.yml`, so use `disable:` or the baseline there. Only fires where the spelling is evidently meant to be a label (close to a known one, next to a known one, dotted, or retired) — a word nothing resembles stays silent, because you may be opening your own vocabulary. Needs an `effects:` block. | high |
77
- | <a id="rule-effect-annotations-unchecked"></a>`effect.annotations-unchecked` | Your signatures carry `%a{pure}` / `%a{rigor:v1:effect …}` but `.rigor.yml` has no `effects:` block, so nothing checks them. One `:info` per run, positioned at the first annotation. An annotation never turns effect collection on by itself — that would make one line in one file more expensive for every run — so this is how it tells you instead. Add `effects: {}` to opt in, or `disable:` it to keep the annotations documentary. Reads both annotation lanes — `sig/*.rbs` and rbs-inline comments — except on a run that analyses no file at all (a warm `--incremental` with nothing changed), where only the `.rbs` lane is visible. | — |
77
+ | <a id="rule-effect-annotations-unchecked"></a>`effect.annotations-unchecked` | Your signatures carry `%a{pure}` / `%a{rigor:v1:effect …}` but `.rigor.yml` has no `effects:` block, so nothing checks them. One `:info` per run, positioned at the first annotation. An annotation never turns effect collection on by itself — that would make one line in one file more expensive for every run — so this is how it tells you instead. Add `effects: {}` to opt in, or `disable:` it to keep the annotations documentary. Reads both annotation lanes — `sig/*.rbs` and rbs-inline comments — on every run, a warm `--incremental` with nothing changed included. | — |
78
78
  | <a id="rule-suppression-unknown-rule"></a>`suppression.unknown-rule` | A `# rigor:disable[-file]` comment names a rule that does not exist (typically a typo), so the suppression silently does nothing. `plugin.`-prefixed tokens are never flagged. | high |
79
79
  | <a id="rule-suppression-empty"></a>`suppression.empty` | A `# rigor:disable[-file]` comment lists no rules, so it suppresses nothing. | high |
80
80
  | <a id="rule-suppression-unknown-marker"></a>`suppression.unknown-marker` | A comment uses a suppression marker Rigor does not recognise — typically the RuboCop reflex `# rigor:disable-next-line <rule>` or `# rigor:enable <rule>`. Rigor's only markers are `# rigor:disable <rules>` (suppresses on its own line) and `# rigor:disable-file <rules>`, so the comment suppresses nothing. | high |
@@ -42,9 +42,16 @@ module Rigor
42
42
  # prepare row is appended to every sequential run, it would refuse every run for the life of the
43
43
  # process.
44
44
  # - `:rbs_build` — the analysis ran to completion and every rule fired, over a type universe missing one
45
- # class (`rbs.coverage.definition-build-failed`) or all of them
46
- # (`rbs.coverage.environment-build-failed`). A degradation the user causes and the diagnostic itself
47
- # reports; a project can sit on it for a release while it fixes its `sig/`.
45
+ # class (`rbs.coverage.definition-build-failed`), all of them
46
+ # (`rbs.coverage.environment-build-failed`), or only the HKT registrations the `type` aliases and the
47
+ # loaded plugins' manifests would have contributed (`rbs.coverage.hkt-scan-failed`, issues #784 and
48
+ # #791). The first two are degradations
49
+ # the user causes and the diagnostic itself reports; a project can sit on one for a release while it
50
+ # fixes its `sig/`. The third is NOT the user's: post-#783 the scan raising is an analyzer defect, and
51
+ # the overlay stage is a defect in Rigor or in a plugin the user installed, never in their `sig/`. It
52
+ # still belongs here rather than under `:check_rule`, because the question this classification
53
+ # answers is "may a consumer still read the run's diagnostics?" — and it may: every rule fired. What
54
+ # differs is whether the run is a valid measurement OF RIGOR, which is {.analyzer_defect?}'s question.
48
55
  #
49
56
  # The two consumer tiers therefore differ on purpose. The ADR-69 kill oracles arm `:check_rule` only:
50
57
  # refusing a run they could still have measured is the same "manufactures work" error as scoring an
@@ -63,10 +70,11 @@ module Rigor
63
70
  PLUGIN_SOURCE_FAMILY = :plugin_loader
64
71
  PLUGIN_RULE = "runtime-error"
65
72
 
66
- # The `rbs.coverage.*` rules that mean declared types went missing from this run — an env-wide
67
- # collapse and its per-class sibling (#696). Ordered widest consequence last, the way the two rows sit
68
- # in `docs/type-specification/diagnostic-policy.md`.
73
+ # The `rbs.coverage.*` rules that mean declared types went missing from this run — the implicit-HKT
74
+ # scan over `type` aliases (#784), a per-class definition build (#696), and the env-wide collapse.
75
+ # Ordered widest consequence last, the way the rows sit in `docs/type-specification/diagnostic-policy.md`.
69
76
  RBS_BUILD_FAILURE_RULES = %w[
77
+ rbs.coverage.hkt-scan-failed
70
78
  rbs.coverage.definition-build-failed
71
79
  rbs.coverage.environment-build-failed
72
80
  ].freeze
@@ -75,8 +83,62 @@ module Rigor
75
83
  # `:rbs_build` both leave a readable run behind — see the class doc.
76
84
  DISCARDS_FILE_ANALYSIS_REASON = :check_rule
77
85
 
86
+ # The `:rbs_build` rules whose cause is Rigor, not the user's `sig/` (issue #784). Readable for the
87
+ # user — every rule fired — but NOT a valid measurement of Rigor itself, so a harness that scores
88
+ # Rigor's behaviour should treat one of these as a crash finding the way it treats `:check_rule`, or
89
+ # a mutant that re-breaks the HKT scan (#776 was one) scores as a measurement over a silently degraded
90
+ # universe. What consults it today: `tool/mutation`'s fuzz crash detector — on the severity-resolved
91
+ # stream, so a `severity_overrides: rbs: off` hides the row from it. What does NOT yet: the ADR-69
92
+ # kill oracle (`Protection::AnalysisGuard` reads `Result#crashed?`, which excludes every
93
+ # `:rbs_build` row), and both harnesses still reuse one Environment across mutants, which memoises
94
+ # the degraded registry after the first defect. Arming the oracle, inspecting the pre-severity row,
95
+ # and resetting the Environment are issue #790.
96
+ ANALYZER_DEFECT_RULES = %w[
97
+ rbs.coverage.hkt-scan-failed
98
+ ].freeze
99
+
78
100
  module_function
79
101
 
102
+ # The message `Runner#analyze_file_body` / `WorkerSession#analyze_body` fold a raised `StandardError`
103
+ # into. Built here, not at either rescue site, so the two twins cannot drift (issue #665) — and so
104
+ # the appended crash frame is derived identically on the sequential and pooled paths. Keeps the
105
+ # {CHECK_RULE_MESSAGE_PREFIX} prefix every consumer matches on; the frame is a trailing hint.
106
+ #
107
+ # @param error [StandardError]
108
+ # @return [String]
109
+ def check_rule_message(error)
110
+ base = "#{CHECK_RULE_MESSAGE_PREFIX}: #{error.class}: #{error.message}"
111
+ frame = crash_frame(error)
112
+ frame ? "#{base} (#{frame})" : base
113
+ end
114
+
115
+ # The first `lib/rigor/` backtrace frame — the raise site, path made repo-relative so it reads the
116
+ # same whether Rigor runs from a checkout or an installed gem. A LOCATION, not an attribution: a
117
+ # bundled or third-party plugin also lives under `lib/rigor/<plugin>/`, so a frame here says where
118
+ # the raise was, never whose defect it is. Falls back to the raw top frame when the crash is entirely
119
+ # inside a dependency, and to nil when there is no backtrace at all.
120
+ #
121
+ # @param error [Exception]
122
+ # @return [String, nil]
123
+ def crash_frame(error)
124
+ frames = error.backtrace
125
+ return nil if frames.nil? || frames.empty?
126
+
127
+ relativize_frame(frames.find { |f| f.include?("/lib/rigor/") } || frames.first)
128
+ end
129
+
130
+ # The repo-relative half of {.crash_frame}, pulled out on its own so a second caller — the #784
131
+ # `rbs.coverage.hkt-scan-failed` diagnostic, which stores its raw frame across a `Marshal` boundary
132
+ # (the fork pool) rather than deriving it fresh from a live `Exception` — can relativize the frame it
133
+ # already has without re-deriving `.crash_frame`'s "which frame" choice. Nil-safe, and a no-op (`sub`
134
+ # never matches) on a frame with no `lib/rigor/` segment at all.
135
+ #
136
+ # @param frame [String, nil]
137
+ # @return [String, nil]
138
+ def relativize_frame(frame)
139
+ frame&.sub(%r{\A.*/(lib/rigor/)}, '\1')
140
+ end
141
+
80
142
  # @param diagnostic [Rigor::Analysis::Diagnostic]
81
143
  # @return [Symbol, nil] `:check_rule`, `:plugin`, `:rbs_build`, or nil for an ordinary diagnostic.
82
144
  def reason(diagnostic)
@@ -96,6 +158,16 @@ module Rigor
96
158
  reason(diagnostic) == DISCARDS_FILE_ANALYSIS_REASON
97
159
  end
98
160
 
161
+ # True when `diagnostic` reports a failure inside Rigor that left the run readable but invalid as a
162
+ # measurement of Rigor — see {ANALYZER_DEFECT_RULES}. Orthogonal to {.discards_file_analysis?}: the
163
+ # user-facing tier reads the run; the Rigor-measuring tier should refuse it (the mutation fuzz does,
164
+ # the ADR-69 kill oracle does not yet — #790).
165
+ #
166
+ # @param diagnostic [Rigor::Analysis::Diagnostic]
167
+ def analyzer_defect?(diagnostic)
168
+ ANALYZER_DEFECT_RULES.include?(diagnostic.rule)
169
+ end
170
+
99
171
  # A one-line "<reason> at <path>:<line>: <message>" for a failure message, so whoever reads the raise
100
172
  # sees which shape fired and where without re-deriving it.
101
173
  #
@@ -172,7 +172,7 @@ module Rigor
172
172
  # ADR-103 WD13 / #382 — a baseline collects every file, so its collections are the whole world.
173
173
  @effect_collections = runner.effect_collections_by_path
174
174
  @effects_identity = current_effects_identity
175
- @cache = per_file(diagnostics)
175
+ @cache = per_file(runner.per_file_diagnostics)
176
176
  @digests = @analyzed.to_h { |path| [path, pack_digest(path)] }
177
177
  diagnostics
178
178
  end
@@ -202,7 +202,7 @@ module Rigor
202
202
  @last_runner = runner # ADR-88 WD1 — the post-hoc fact-surface fingerprint reads this prepared registry.
203
203
  reused = (current & previous) - affected.to_a
204
204
  merged = fresh + reused.flat_map { |path| @cache[path] || [] }
205
- absorb(runner, fresh, current, analyze_set, removed)
205
+ absorb(runner, current, analyze_set, removed)
206
206
  @param_table = fresh_params
207
207
  Recheck.new(diagnostics: merged, changed: changed.to_set, added: added.to_set,
208
208
  removed: removed.to_set, affected: affected, reused: reused.to_set)
@@ -684,13 +684,13 @@ module Rigor
684
684
  # Fold a #recheck's fresh results back into the cache + graph so the session is correct across
685
685
  # multiple edits: the analyzed set gets fresh diagnostics + digests + dependency edges, removed files
686
686
  # are evicted from every map, and the analyzed-file list advances to `current`.
687
- def absorb(runner, fresh, current, analyze_set, removed)
687
+ def absorb(runner, current, analyze_set, removed)
688
688
  removed.each { |path| forget(path) }
689
689
  @analyzed = current
690
690
  # ADR-85 WD2 — the recheck's discovery folded the restored bundles and refreshed them (changed files
691
691
  # re-walked, removed files dropped, added files built), so adopt the runner's current set wholesale.
692
692
  @seed_bundles = runner.seed_bundles
693
- fresh_by_file = per_file(fresh)
693
+ fresh_by_file = per_file(runner.per_file_diagnostics)
694
694
  analyze_set.each do |path|
695
695
  @cache[path] = fresh_by_file[path] || []
696
696
  @digests[path] = pack_digest(path)
@@ -984,9 +984,21 @@ module Rigor
984
984
  @paths ? runner.run(@paths) : runner.run
985
985
  end
986
986
 
987
- # Group diagnostics by their file path, keeping only those whose path is an analyzed project file —
988
- # run-level streams (the gem-RBS info diagnostic, keyed on `.rigor.yml`) are recomputed fresh every
989
- # run and must not be served from the per-file cache.
987
+ # Group the runner's PER-FILE diagnostics by path, keeping only analyzed project files.
988
+ #
989
+ # The argument is `Runner#per_file_diagnostics` what `PoolCoordinator#analyze_files` returned, already
990
+ # severity-resolved as the run's stream is (this cache serves reused files without re-stamping, so a
991
+ # raw row would resurrect an `:off` rule and lose every override) — never the run's full stream. Every
992
+ # run-level row is regenerated by every run and must not be served
993
+ # from this cache, and "run-level" is a property of where a row was PRODUCED, not of where it is
994
+ # positioned: the `.rigor.yml` rows the slice below drops are the common case, but
995
+ # `effect.annotations-unchecked` sits at the first annotated file and the two `source-rbs-*` rows sit
996
+ # at the source file that carried the annotation, and all three are produced once per run off the
997
+ # environment and its reporters. Slicing the full stream cached them, and once a narrowed run's
998
+ # environment carried the whole project's synthesized RBS (#793) each was regenerated for a file the
999
+ # recheck did not analyse and met its cached twin in the merge — two rows, `--verify-incremental` red,
1000
+ # on the default configuration with the bundled rbs-inline plugin (#788 rounds 5 and 6; a rule-id
1001
+ # denylist missed the second pair). Feeding only the per-file stream makes the split structural.
990
1002
  def per_file(diagnostics)
991
1003
  diagnostics.group_by(&:path).slice(*@analyzed)
992
1004
  end
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "../crash_signature"
3
4
  require_relative "../diagnostic"
4
5
  require_relative "../severity_stamp"
5
6
 
@@ -39,13 +40,18 @@ module Rigor
39
40
  # @param definition_build_failures_snapshot [#call] issue #696 — reader returning the per-class
40
41
  # `RBS::DefinitionBuilder` failures the run observed, as `[class_name, error_class, member,
41
42
  # conflicting_buffer_names]` tuples. Empty for a healthy sig set.
43
+ # @param hkt_scan_failure_snapshot [#call] issue #784 — reader returning the `[error_class_name,
44
+ # first_message_line, raw_frame_or_nil, stage]` tuple whichever stage of the HKT-registry build
45
+ # raised, or nil when both built (or were never demanded). `stage` is `:scan` (the RBS `type`-alias
46
+ # scan) or `:overlay` (the plugin-manifest aggregation, #791), and picks the row's wording.
42
47
  # @param conformance_results_snapshot [#call] reader.
43
48
  def initialize(configuration:, rbs_extended_reporter:, boundary_cross_reporter:, # rubocop:disable Metrics/ParameterLists
44
49
  source_rbs_synthesis_reporter:, plugin_registry:, dependency_source_index:,
45
50
  pool_mode:, cached_plugin_prepare_diagnostics:,
46
51
  pre_eval_diagnostics_from_scanner:, synthesized_namespaces_snapshot:,
47
52
  quarantined_signatures_snapshot:, env_build_failure_snapshot:,
48
- definition_build_failures_snapshot:, conformance_results_snapshot:)
53
+ definition_build_failures_snapshot:, hkt_scan_failure_snapshot:,
54
+ conformance_results_snapshot:)
49
55
  @configuration = configuration
50
56
  @rbs_extended_reporter = rbs_extended_reporter
51
57
  @boundary_cross_reporter = boundary_cross_reporter
@@ -59,6 +65,7 @@ module Rigor
59
65
  @quarantined_signatures_snapshot_reader = quarantined_signatures_snapshot
60
66
  @env_build_failure_snapshot_reader = env_build_failure_snapshot
61
67
  @definition_build_failures_snapshot_reader = definition_build_failures_snapshot
68
+ @hkt_scan_failure_snapshot_reader = hkt_scan_failure_snapshot
62
69
  @conformance_results_snapshot_reader = conformance_results_snapshot
63
70
  end
64
71
 
@@ -315,9 +322,11 @@ module Rigor
315
322
  # user knows their sig set is malformed (`rbs validate` rejects it) and can fix it at the source.
316
323
  # Authored `:info`: the analysis already succeeded; this is advisory, never a gate. Empty for a
317
324
  # well-formed sig set.
318
- # An unparseable `.rbs` under `signature_paths:` is QUARANTINED so the rest of the env survives
319
- # (PR #50), which means the types it declares are silently absent calls into them read
320
- # `Dynamic[top]`, and the run gets *quieter*, not louder. The stderr banner alone never reached CI:
325
+ # An unparseable or declaration-colliding `.rbs` under `signature_paths:` is QUARANTINED so the
326
+ # rest of the env survives (PR #50; issue #777 extends this to class-vs-module / constant
327
+ # collisions against bundled RBS), which means the types it declares are silently absent calls
328
+ # into them read `Dynamic[top]`, and the run gets *quieter*, not louder. The stderr banner alone
329
+ # never reached CI:
321
330
  # it is not a diagnostic, so it is absent from `--format json` / SARIF / GitHub annotations / the LSP
322
331
  # and cannot move the exit code. This puts it in the diagnostic stream where every channel sees it.
323
332
  #
@@ -381,6 +390,25 @@ module Rigor
381
390
  [build_rbs_definition_build_failed_diagnostic(failures)]
382
391
  end
383
392
 
393
+ # Issue #784 — the fourth rung, narrowest consequence: one of the two builds behind
394
+ # `Environment#hkt_registry` raised instead of building. The `:scan` stage is the implicit HKT scan
395
+ # over RBS `type` aliases (ADR-20 WD2's `%a{rigor:v1:hkt_register / hkt_define}` overlay AND any
396
+ # recursive `type` alias in the project's own `.rbs` or an installed `rbs collection`); analysis
397
+ # proceeds over the PRE-scan registry — bundled builtins (`json::value`, …) plus the plugin overlay
398
+ # — so a `type` alias that would have registered as a type constructor reads its bound
399
+ # (`Dynamic[top]`) instead. The `:overlay` stage (#791) is the plugin-manifest aggregation, which
400
+ # sat ABOVE the seam until the run-owned demands made a raise there abort the run; it degrades one
401
+ # step further in and one step narrower — the plugin entries are dropped, the `.rbs` scan still
402
+ # runs. Either way everything else this run reports is unaffected: no class loses its method
403
+ # surface, no signature file is skipped, the environment builds. That is why this sits LAST on the
404
+ # ladder, after its two `rbs.coverage.*` siblings above.
405
+ def rbs_hkt_scan_failed_diagnostics
406
+ failure = hkt_scan_failure_snapshot
407
+ return [] if failure.nil?
408
+
409
+ [build_rbs_hkt_scan_failed_diagnostic(failure)]
410
+ end
411
+
384
412
  def rbs_synthesized_namespace_diagnostics
385
413
  synthesized = synthesized_namespaces_snapshot
386
414
  return [] if synthesized.nil? || synthesized.empty?
@@ -458,11 +486,12 @@ module Rigor
458
486
  path: ".rigor.yml",
459
487
  line: 1,
460
488
  column: 1,
461
- message: "#{quarantined.size} RBS file(s) under `signature_paths:` do not parse and were " \
462
- "SKIPPED: #{sample.join(', ')}#{suffix}. The rest of your RBS environment still " \
463
- "loaded, but the types those files declare are absent — calls into them read " \
464
- "`Dynamic[top]`, so this run is quieter than it should be, not cleaner. Fix the " \
465
- "parse error(s) (`rbs validate`) to restore that coverage.",
489
+ message: "#{quarantined.size} RBS file(s) under `signature_paths:` were SKIPPED " \
490
+ "(unparseable, or duplicated against bundled RBS): #{sample.join(', ')}#{suffix}. " \
491
+ "The rest of your RBS environment still loaded, but the types those files declare " \
492
+ "are absent — calls into them read `Dynamic[top]`, so this run is quieter than it " \
493
+ "should be, not cleaner. Fix the parse error(s) or remove the conflicting " \
494
+ "declaration(s) (`rbs validate`) to restore that coverage.",
466
495
  severity: :warning,
467
496
  rule: "rbs.coverage.quarantined-signature",
468
497
  source_family: :builtin
@@ -550,6 +579,66 @@ module Rigor
550
579
  "#{sample.join(', ')}#{suffix}"
551
580
  end
552
581
 
582
+ # Issue #784 — one `:error` row per run, unlike its two `:warning` `rbs.coverage.*` siblings above.
583
+ # Those two are typically a collision between the user's OWN `sig/` and Rigor's bundled RBS, so an
584
+ # `:error` default would let a Rigor release turn a green project red with zero user change (ADR-5
585
+ # / AGENTS.md § FP discipline) — the reason both stay `:warning` by default. This row has no such
586
+ # neighbour: post-#783 the scan itself raising is an ANALYZER defect, never something a user's
587
+ # `sig/` could trigger on its own, so there is no green project this could newly redden. And the
588
+ # run was already non-zero before this row existed — issue #784's seam is what stopped the raise
589
+ # from reaching every file as N identical `internal analyzer error` rows in the first place, and
590
+ # THAT per-file rescue is what `Result#success?` was already reading as a failure; this row only
591
+ # makes the reason legible.
592
+ #
593
+ # One row per RUN, not per file: the scan is one build over the whole `signature_paths:` overlay
594
+ # (memoised — see {Environment#hkt_registry}), so every file that would have demanded it hit the
595
+ # exact same failure, and a row per file would say the same thing N times.
596
+ #
597
+ # `:rbs_build` (via {CrashSignature::RBS_BUILD_FAILURE_RULES}), not `:check_rule`: the analysis ran
598
+ # to completion over a DEGRADED type universe — every rule still fired, unlike `:check_rule`'s
599
+ # whole-file replacement — so a consumer gating on {CrashSignature.discards_file_analysis?} must
600
+ # keep reading this run's diagnostics rather than refuse it as a crash.
601
+ def build_rbs_hkt_scan_failed_diagnostic(failure)
602
+ error_class, first_line, frame, stage = failure
603
+ relative_frame = CrashSignature.relativize_frame(frame)
604
+ frame_clause = relative_frame ? " at #{relative_frame}" : ""
605
+ raised = "(#{error_class}): #{first_line}#{frame_clause}."
606
+ Diagnostic.new(
607
+ path: ".rigor.yml",
608
+ line: 1,
609
+ column: 1,
610
+ message: stage == :overlay ? hkt_overlay_failed_message(raised) : hkt_scan_failed_message(raised),
611
+ severity: :error,
612
+ rule: "rbs.coverage.hkt-scan-failed",
613
+ source_family: :builtin
614
+ )
615
+ end
616
+
617
+ def hkt_scan_failed_message(raised)
618
+ "The implicit HKT scan over RBS `type` aliases raised #{raised} Rigor fell back to the bundled " \
619
+ "and plugin HKT registrations, so a recursive `type` alias in your `.rbs` or an installed " \
620
+ "`rbs collection` no longer registers as a type constructor and reads its bound " \
621
+ "(`Dynamic[top]`) instead — this run is quieter than it should be, not cleaner. " \
622
+ "This is an analyzer defect, not a problem with your signatures; please report it " \
623
+ "with the message above."
624
+ end
625
+
626
+ # Issue #791 — the overlay stage of the same build, worded for the plugin it came from. The scan
627
+ # wording would send a user to their `.rbs` for a defect that is not there: what failed is the
628
+ # aggregation of the loaded plugins' manifest-declared HKT entries, and the message names the
629
+ # plugin whenever the raise was attributable to one (`Plugin::Registry#hkt_overlay_registry` puts
630
+ # the id in the message it re-raises). The degradation is narrower than the scan's, so the fallback
631
+ # sentence differs too: only the plugin entries are missing, and the user's own `.rbs` scan still
632
+ # ran on top of the bundled registrations.
633
+ def hkt_overlay_failed_message(raised)
634
+ "Building the plugin HKT overlay raised #{raised} Rigor skipped every plugin-declared HKT " \
635
+ "registration and analysed with the bundled ones plus your own `.rbs` overlay, so a type " \
636
+ "constructor a plugin declares reads its bound (`Dynamic[top]`) instead — this run is " \
637
+ "quieter than it should be, not cleaner. This is a defect in the named plugin or in Rigor, " \
638
+ "not a problem with your signatures; report it with the message above, or remove the plugin " \
639
+ "from `plugins:` to analyse without it."
640
+ end
641
+
553
642
  # The absolute path is what the loader records; the user thinks in project-relative terms.
554
643
  def relative_signature_path(path)
555
644
  root = "#{Dir.pwd}#{File::SEPARATOR}"
@@ -603,16 +692,19 @@ module Rigor
603
692
  # enable.
604
693
  # - `dynamic.shape.lossy-projection` for every shape-projection type function (`pick_of`, …) applied
605
694
  # to a carrier that loses precision (anything other than `HashShape` / `Tuple`).
695
+ # - `dynamic.rbs-extended.hkt-directive-invalid` for every malformed ADR-20 `rigor:v1:hkt_register` /
696
+ # `rigor:v1:hkt_define` the directive parser declined (issue #785).
606
697
  #
607
- # Both are authored `:info`; the severity profile re-stamps them per project taste. Path / line /
608
- # column come from the annotation's `RBS::Location` when available, falling back to
609
- # `.rigor.yml`-style file-level attribution otherwise.
698
+ # All three are authored `:info`; the severity profile re-stamps them per project taste. Every stream
699
+ # carries its `(path, line, column)` already flattened off the annotation's `RBS::Location`, because
700
+ # all three cross the pool drain channel (see {RbsExtended::Reporter}); an entry with no position
701
+ # falls back to `.rigor.yml`-style file-level attribution.
610
702
  def rbs_extended_reporter_diagnostics
611
703
  return [] if @rbs_extended_reporter.empty?
612
704
 
613
705
  unresolved = @rbs_extended_reporter.unresolved_payloads.map do |entry|
614
- build_reporter_diagnostic(
615
- entry.source_location,
706
+ build_positioned_reporter_diagnostic(
707
+ entry,
616
708
  rule: "dynamic.rbs-extended.unresolved",
617
709
  message: "`RBS::Extended` directive payload could not be resolved: " \
618
710
  "#{entry.payload.inspect}. Check for typos or enable a plugin " \
@@ -621,8 +713,8 @@ module Rigor
621
713
  end
622
714
 
623
715
  lossy = @rbs_extended_reporter.lossy_projections.map do |entry|
624
- build_reporter_diagnostic(
625
- entry.source_location,
716
+ build_positioned_reporter_diagnostic(
717
+ entry,
626
718
  rule: "dynamic.shape.lossy-projection",
627
719
  message: "Shape projection `#{entry.head}` applied to a carrier without a " \
628
720
  "literal shape; the projection degrades to the input type. Author " \
@@ -630,7 +722,22 @@ module Rigor
630
722
  )
631
723
  end
632
724
 
633
- unresolved + lossy
725
+ unresolved + lossy + hkt_directive_diagnostics
726
+ end
727
+
728
+ # Issue #785 — one row per declined HKT directive. The consequence sentence is the point: the parser
729
+ # is fail-soft, so nothing else in the run tells the author that the constructor they registered is
730
+ # not there and that every `App[…]` naming it silently reads its bound.
731
+ def hkt_directive_diagnostics
732
+ @rbs_extended_reporter.hkt_directive_errors.map do |entry|
733
+ build_positioned_reporter_diagnostic(
734
+ entry,
735
+ rule: "dynamic.rbs-extended.hkt-directive-invalid",
736
+ message: "`RBS::Extended` HKT directive was declined: #{entry.message}. The type " \
737
+ "constructor stays unregistered, so an `App[...]` carrier naming it reads " \
738
+ "its bound (`Dynamic[top]`) instead of the type function."
739
+ )
740
+ end
634
741
  end
635
742
 
636
743
  # ADR-32 WD6 — drains the per-run {Plugin::SourceRbsSynthesisReporter} into
@@ -712,6 +819,20 @@ module Rigor
712
819
  )
713
820
  end
714
821
 
822
+ # The {RbsExtended::Reporter} form of the builder above: its three streams carry the position as
823
+ # `(path, line, column)` primitives rather than as the `RBS::Location` a conformance record holds,
824
+ # because they cross the pool drain channel (#785, #805). A missing component falls back exactly as
825
+ # {#location_fields} does for a missing location.
826
+ def build_positioned_reporter_diagnostic(entry, rule:, message:)
827
+ path = entry.path.to_s
828
+ Diagnostic.new(
829
+ path: path.empty? ? ".rigor.yml" : path,
830
+ line: entry.line || 1,
831
+ column: entry.column || 1,
832
+ message: message, severity: :info, rule: rule, source_family: :builtin
833
+ )
834
+ end
835
+
715
836
  def location_fields(source_location)
716
837
  return [".rigor.yml", 1, 1] if source_location.nil?
717
838
 
@@ -776,6 +897,10 @@ module Rigor
776
897
  @definition_build_failures_snapshot_reader.call
777
898
  end
778
899
 
900
+ def hkt_scan_failure_snapshot
901
+ @hkt_scan_failure_snapshot_reader.call
902
+ end
903
+
779
904
  def conformance_results_snapshot
780
905
  @conformance_results_snapshot_reader.call
781
906
  end