rigortype 0.2.9 → 0.3.1

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 (222) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +2 -2
  3. data/data/builtins/ruby_core/array.yml +416 -392
  4. data/data/builtins/ruby_core/file.yml +42 -42
  5. data/data/builtins/ruby_core/hash.yml +302 -302
  6. data/data/builtins/ruby_core/io.yml +191 -191
  7. data/data/builtins/ruby_core/numeric.yml +321 -366
  8. data/data/builtins/ruby_core/proc.yml +124 -124
  9. data/data/builtins/ruby_core/range.yml +21 -21
  10. data/data/builtins/ruby_core/rational.yml +39 -39
  11. data/data/builtins/ruby_core/re.yml +65 -65
  12. data/data/builtins/ruby_core/set.yml +106 -106
  13. data/data/builtins/ruby_core/struct.yml +14 -14
  14. data/data/core_overlay/string_scanner.rbs +6 -5
  15. data/docs/handbook/01-getting-started.md +22 -34
  16. data/docs/handbook/03-narrowing.md +2 -1
  17. data/docs/handbook/04-tuples-and-shapes.md +7 -3
  18. data/docs/handbook/06-classes.md +19 -10
  19. data/docs/handbook/07-rbs-and-extended.md +76 -101
  20. data/docs/handbook/08-understanding-errors.md +114 -246
  21. data/docs/handbook/09-plugins.md +54 -144
  22. data/docs/handbook/11-sig-gen.md +11 -1
  23. data/docs/handbook/README.md +5 -3
  24. data/docs/handbook/appendix-liskov.md +4 -2
  25. data/docs/handbook/appendix-mypy.md +4 -3
  26. data/docs/handbook/appendix-phpstan.md +14 -7
  27. data/docs/handbook/appendix-steep.md +4 -2
  28. data/docs/handbook/appendix-type-theory.md +3 -1
  29. data/docs/install.md +14 -6
  30. data/docs/manual/01-installation.md +98 -4
  31. data/docs/manual/02-cli-reference.md +81 -12
  32. data/docs/manual/03-configuration.md +34 -2
  33. data/docs/manual/04-diagnostics.md +59 -4
  34. data/docs/manual/06-baseline.md +35 -1
  35. data/docs/manual/07-plugins.md +4 -4
  36. data/docs/manual/08-skills.md +6 -1
  37. data/docs/manual/09-editor-integration.md +7 -6
  38. data/docs/manual/11-ci.md +51 -0
  39. data/docs/manual/12-caching.md +65 -0
  40. data/docs/manual/plugins/rigor-actioncable.md +32 -0
  41. data/docs/manual/plugins/rigor-devise.md +4 -2
  42. data/lib/rigor/analysis/check_rules/always_truthy_condition_collector.rb +9 -1
  43. data/lib/rigor/analysis/check_rules/dead_assignment_collector.rb +5 -3
  44. data/lib/rigor/analysis/check_rules/duplicate_hash_key_collector.rb +128 -0
  45. data/lib/rigor/analysis/check_rules/inferred_param_guard.rb +60 -0
  46. data/lib/rigor/analysis/check_rules/ivar_write_collector.rb +3 -2
  47. data/lib/rigor/analysis/check_rules/return_in_ensure_collector.rb +117 -0
  48. data/lib/rigor/analysis/check_rules/rule_ids.rb +125 -0
  49. data/lib/rigor/analysis/check_rules/rule_walk.rb +2 -1
  50. data/lib/rigor/analysis/check_rules/self_closedness_scanner.rb +2 -1
  51. data/lib/rigor/analysis/check_rules/shadowed_rescue_collector.rb +283 -0
  52. data/lib/rigor/analysis/check_rules/unreachable_clause_collector.rb +10 -1
  53. data/lib/rigor/analysis/check_rules/void_value_use_collector.rb +112 -0
  54. data/lib/rigor/analysis/check_rules.rb +581 -98
  55. data/lib/rigor/analysis/dependency_recorder.rb +93 -9
  56. data/lib/rigor/analysis/dependency_source_inference/walker.rb +2 -1
  57. data/lib/rigor/analysis/incremental_session.rb +456 -51
  58. data/lib/rigor/analysis/path_expansion.rb +42 -0
  59. data/lib/rigor/analysis/plugin_fact_fingerprint.rb +188 -0
  60. data/lib/rigor/analysis/rule_catalog.rb +226 -1
  61. data/lib/rigor/analysis/run_cache_key.rb +78 -0
  62. data/lib/rigor/analysis/run_cache_probe.rb +72 -0
  63. data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +181 -28
  64. data/lib/rigor/analysis/runner/pool_coordinator.rb +77 -15
  65. data/lib/rigor/analysis/runner/project_pre_passes.rb +95 -49
  66. data/lib/rigor/analysis/runner/run_snapshots.rb +9 -2
  67. data/lib/rigor/analysis/runner.rb +344 -68
  68. data/lib/rigor/analysis/severity_stamp.rb +43 -0
  69. data/lib/rigor/analysis/worker_session.rb +30 -2
  70. data/lib/rigor/bleeding_edge.rb +37 -8
  71. data/lib/rigor/builtins/regex_refinement.rb +36 -1
  72. data/lib/rigor/cache/descriptor.rb +88 -28
  73. data/lib/rigor/cache/file_digest.rb +158 -0
  74. data/lib/rigor/cache/incremental_snapshot.rb +41 -5
  75. data/lib/rigor/cache/rbs_cache_producer.rb +11 -1
  76. data/lib/rigor/cache/rbs_descriptor.rb +56 -10
  77. data/lib/rigor/cache/rbs_environment_marshal_patch.rb +38 -0
  78. data/lib/rigor/cache/store.rb +132 -27
  79. data/lib/rigor/ci_detector.rb +90 -0
  80. data/lib/rigor/cli/annotate_command.rb +15 -9
  81. data/lib/rigor/cli/baseline_command.rb +4 -1
  82. data/lib/rigor/cli/check_command.rb +223 -32
  83. data/lib/rigor/cli/check_invocation.rb +84 -0
  84. data/lib/rigor/cli/coverage_command.rb +6 -1
  85. data/lib/rigor/cli/docs_command.rb +0 -30
  86. data/lib/rigor/cli/doctor_command.rb +169 -31
  87. data/lib/rigor/cli/lsp_command.rb +5 -0
  88. data/lib/rigor/cli/mcp_command.rb +5 -0
  89. data/lib/rigor/cli/plugins_command.rb +36 -6
  90. data/lib/rigor/cli/plugins_renderer.rb +44 -12
  91. data/lib/rigor/cli/probe_environment.rb +85 -0
  92. data/lib/rigor/cli/sig_gen_command.rb +36 -9
  93. data/lib/rigor/cli/skill_command.rb +22 -33
  94. data/lib/rigor/cli/skill_deep_probe.rb +172 -0
  95. data/lib/rigor/cli/skill_describe.rb +75 -9
  96. data/lib/rigor/cli/trace_command.rb +5 -6
  97. data/lib/rigor/cli/type_of_command.rb +9 -8
  98. data/lib/rigor/cli/type_scan_command.rb +8 -8
  99. data/lib/rigor/cli.rb +15 -4
  100. data/lib/rigor/config_audit.rb +48 -2
  101. data/lib/rigor/configuration/severity_profile.rb +31 -0
  102. data/lib/rigor/configuration.rb +156 -8
  103. data/lib/rigor/environment/default_libraries.rb +36 -0
  104. data/lib/rigor/environment/rbs_loader.rb +164 -5
  105. data/lib/rigor/environment.rb +29 -28
  106. data/lib/rigor/inference/budget_trace.rb +77 -2
  107. data/lib/rigor/inference/closure_escape_analyzer.rb +14 -1
  108. data/lib/rigor/inference/def_handle.rb +23 -0
  109. data/lib/rigor/inference/def_node_resolver.rb +90 -0
  110. data/lib/rigor/inference/def_return_typer.rb +2 -1
  111. data/lib/rigor/inference/expression_typer.rb +361 -113
  112. data/lib/rigor/inference/method_dispatcher/data_folding.rb +30 -5
  113. data/lib/rigor/inference/method_dispatcher/kernel_dispatch.rb +200 -0
  114. data/lib/rigor/inference/method_dispatcher/literal_string_folding.rb +22 -16
  115. data/lib/rigor/inference/method_dispatcher/member_shape_projection.rb +33 -0
  116. data/lib/rigor/inference/method_dispatcher/overload_selector.rb +17 -1
  117. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +42 -9
  118. data/lib/rigor/inference/method_dispatcher/receiver_affinity.rb +5 -0
  119. data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +28 -35
  120. data/lib/rigor/inference/method_dispatcher/struct_folding.rb +40 -8
  121. data/lib/rigor/inference/method_dispatcher.rb +57 -1
  122. data/lib/rigor/inference/mutation_widening.rb +42 -8
  123. data/lib/rigor/inference/narrowing.rb +127 -17
  124. data/lib/rigor/inference/parameter_inference_collector.rb +15 -0
  125. data/lib/rigor/inference/project_patched_scanner.rb +2 -1
  126. data/lib/rigor/inference/rbs_type_translator.rb +8 -1
  127. data/lib/rigor/inference/scope_indexer.rb +451 -36
  128. data/lib/rigor/inference/statement_evaluator.rb +64 -15
  129. data/lib/rigor/inference/struct_fold_safety.rb +48 -11
  130. data/lib/rigor/inference/synthetic_method_scanner.rb +15 -14
  131. data/lib/rigor/inference/void_origin.rb +25 -0
  132. data/lib/rigor/inference/void_tail_summary.rb +220 -0
  133. data/lib/rigor/language_server/buffer_resolution.rb +6 -3
  134. data/lib/rigor/language_server/buffer_table.rb +46 -6
  135. data/lib/rigor/language_server/completion_provider.rb +2 -1
  136. data/lib/rigor/language_server/diagnostic_publisher.rb +4 -0
  137. data/lib/rigor/language_server/document_symbol_provider.rb +2 -1
  138. data/lib/rigor/language_server/folding_range_provider.rb +2 -1
  139. data/lib/rigor/language_server/incremental_sync.rb +159 -0
  140. data/lib/rigor/language_server/selection_range_provider.rb +2 -1
  141. data/lib/rigor/language_server/server.rb +19 -9
  142. data/lib/rigor/language_server/signature_help_provider.rb +2 -1
  143. data/lib/rigor/language_server.rb +1 -0
  144. data/lib/rigor/plugin/base.rb +38 -24
  145. data/lib/rigor/plugin/inflector.rb +12 -3
  146. data/lib/rigor/plugin/io_boundary.rb +5 -1
  147. data/lib/rigor/plugin/isolation.rb +81 -11
  148. data/lib/rigor/plugin/load_error.rb +10 -1
  149. data/lib/rigor/plugin/loader.rb +96 -14
  150. data/lib/rigor/plugin/node_rule_walk.rb +5 -3
  151. data/lib/rigor/plugin/registry.rb +32 -23
  152. data/lib/rigor/plugin.rb +26 -0
  153. data/lib/rigor/protection/mutator.rb +3 -2
  154. data/lib/rigor/reflection.rb +64 -0
  155. data/lib/rigor/runtime/jit.rb +128 -0
  156. data/lib/rigor/scope/discovery_index.rb +12 -2
  157. data/lib/rigor/scope.rb +122 -28
  158. data/lib/rigor/sig_gen/classification.rb +5 -1
  159. data/lib/rigor/sig_gen/generator.rb +38 -6
  160. data/lib/rigor/sig_gen/observation_collector.rb +4 -3
  161. data/lib/rigor/sig_gen/rbs_validity.rb +44 -0
  162. data/lib/rigor/sig_gen/renderer.rb +10 -0
  163. data/lib/rigor/sig_gen/write_result.rb +9 -4
  164. data/lib/rigor/sig_gen/writer.rb +207 -49
  165. data/lib/rigor/source/node_children.rb +116 -0
  166. data/lib/rigor/source/node_locator.rb +3 -1
  167. data/lib/rigor/source/node_walker.rb +4 -2
  168. data/lib/rigor/source.rb +1 -0
  169. data/lib/rigor/type/difference.rb +28 -24
  170. data/lib/rigor/type/hash_shape.rb +34 -10
  171. data/lib/rigor/version.rb +1 -1
  172. data/lib/rigortype.rb +24 -0
  173. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_discoverer.rb +4 -2
  174. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable.rb +51 -1
  175. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_discoverer.rb +6 -4
  176. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_discoverer.rb +5 -3
  177. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/job_discoverer.rb +4 -2
  178. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/analyzer.rb +3 -1
  179. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_discoverer.rb +4 -2
  180. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/schema_parser.rb +4 -2
  181. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/analyzer.rb +3 -1
  182. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/attachment_discoverer.rb +4 -2
  183. data/plugins/rigor-dry-types/lib/rigor/plugin/dry_types.rb +38 -6
  184. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_discoverer.rb +3 -1
  185. data/plugins/rigor-hanami/lib/rigor/plugin/hanami/action_checker.rb +3 -1
  186. data/plugins/rigor-mangrove/lib/rigor/plugin/mangrove.rb +10 -0
  187. data/plugins/rigor-minitest/lib/rigor/plugin/minitest.rb +11 -0
  188. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/policy_discoverer.rb +3 -1
  189. data/plugins/rigor-rails/lib/rigor-rails.rb +11 -12
  190. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/grape_api_discoverer.rb +3 -1
  191. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/helper_discoverer.rb +3 -1
  192. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/routes_parser.rb +7 -5
  193. data/plugins/rigor-rbs-inline/lib/rigor/plugin/rbs_inline.rb +83 -9
  194. data/plugins/rigor-rbs-inline/lib/rigor-rbs-inline.rb +9 -5
  195. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/let_scope_index.rb +3 -1
  196. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/scope_walker.rb +3 -1
  197. data/plugins/rigor-rspec/lib/rigor/plugin/rspec.rb +11 -0
  198. data/plugins/rigor-rspec-rails/lib/rigor/plugin/rspec_rails/http_status_codes.rb +4 -1
  199. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/worker_discoverer.rb +4 -2
  200. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/catalog_walker.rb +3 -1
  201. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +74 -14
  202. data/sig/prism_node_children.rbs +9 -0
  203. data/sig/rigor/cache.rbs +6 -0
  204. data/sig/rigor/inference/void_origin.rbs +18 -0
  205. data/sig/rigor/inference.rbs +7 -0
  206. data/sig/rigor/plugin/base.rbs +6 -6
  207. data/sig/rigor/reflection.rbs +1 -0
  208. data/sig/rigor/scope.rbs +12 -1
  209. data/skills/rigor-ci-setup/SKILL.md +10 -0
  210. data/skills/rigor-editor-setup/SKILL.md +11 -0
  211. data/skills/rigor-next-steps/SKILL.md +23 -2
  212. data/skills/rigor-plugin-author/SKILL.md +3 -3
  213. data/skills/rigor-plugin-author/references/02-walker-and-types.md +2 -2
  214. data/skills/rigor-plugin-review/SKILL.md +2 -2
  215. data/skills/rigor-plugin-review/references/01-best-practices-checklist.md +5 -4
  216. data/skills/rigor-project-init/SKILL.md +29 -3
  217. data/skills/rigor-project-init/references/01-detect.md +1 -1
  218. data/skills/rigor-project-init/references/02-configure.md +39 -5
  219. data/skills/rigor-project-init/references/03-baseline-and-bugs.md +20 -0
  220. data/skills/rigor-project-init/references/05-jit-performance.md +125 -0
  221. metadata +48 -26
  222. data/lib/rigor/cli/ci_detector.rb +0 -89
@@ -24,8 +24,22 @@ module Rigor
24
24
  # Bump when the on-disk shape changes so stale snapshots are ignored rather than mis-deserialized. 5:
25
25
  # the blob is zlib-deflated (ADR-54 WD2 parity with `Store` entries — the snapshot is the one cache
26
26
  # artefact that does not go through `Store`); a raw pre-5 blob fails the inflate and loads as nil, the
27
- # usual fault-tolerant cold-run path.
28
- SCHEMA = 5
27
+ # usual fault-tolerant cold-run path. 6: adds the ADR-85 WD2 `seed_bundles` section (per-file discovery
28
+ # contributions with `(node_id, name, fingerprint)` def-node handles); a pre-6 blob mismatches the
29
+ # SCHEMA gate and loads as nil (a clean cold rebuild — no migration). 7: the seed bundle gains a
30
+ # `singleton_def_sources` table (ADR-46 slice 4 extended to class/singleton methods) AND `digests`
31
+ # switches to ADR-87 packed stat entries; a pre-7 blob mismatches the gate and loads as nil (clean cold
32
+ # rebuild). 8: each seed bundle additionally gains a comment-stripped `code_fingerprint` for the B1
33
+ # bundle-equality gate; a pre-8 blob mismatches and loads as nil (clean cold rebuild). 9: adds the ADR-88
34
+ # WD1 `plugin_fact_digest` (a fingerprint of the plugin fact SURFACE — ADR-9 facts, ADR-60 producer
35
+ # values, and `incremental_state_fingerprint` hooks — that a cached diagnostic can depend on but the
36
+ # global fingerprint does not capture); a pre-9 blob mismatches the SCHEMA gate and loads as nil (a clean
37
+ # cold rebuild — no migration). 10: ADR-89 WD1 adds a per-file `declaration_signature` to each seed
38
+ # bundle (the per-def parameter-shape / visibility / ancestry surface the declaration-stability gate
39
+ # compares) and WD2 adds `return_summaries` (per-def observed-key return descriptors + mutation-effect
40
+ # sets the behavioural-stability gate compares); a pre-10 blob mismatches the SCHEMA gate and loads as
41
+ # nil (a clean cold rebuild — no migration).
42
+ SCHEMA = 10
29
43
 
30
44
  # The persisted per-file state.
31
45
  # `cache` maps an analyzed file to its diagnostics.
@@ -39,9 +53,25 @@ module Rigor
39
53
  # ADR-46 slice 3:
40
54
  # `missing` maps a consumer to Set<"kind:name"> it looked up and missed.
41
55
  # `class_decls` maps a path to Set<qualified class name> it declares.
56
+ # ADR-85 WD2:
57
+ # `seed_bundles` maps an analyzed path to its per-file discovery contribution (plain-data tables +
58
+ # `(node_id, name, fingerprint)` def-node handles + content digest), so a warm recheck rebuilds the
59
+ # cross-file index by folding bundles instead of parsing every file.
60
+ # ADR-88 WD1:
61
+ # `plugin_fact_digest` is a SHA-256 hex fingerprint of the plugin fact surface at the run that wrote the
62
+ # snapshot (or nil for a plugin-free project). A warm recheck recomputes it and, on a mismatch, discards
63
+ # the snapshot and runs a full analysis — the guard for a plugin sig/catalog edit that the global
64
+ # fingerprint cannot see.
65
+ # ADR-89 WD2:
66
+ # `return_summaries` maps a `[path, "Class#method" | "Class.method"]` to the callee's persisted
67
+ # behavioural surface `{ keys:, returns:, effects: }` — the observed `[receiver, arg_types]` call keys
68
+ # (Marshal-clean type tuples), their `describe(:short)` return descriptors, and the content-mutated
69
+ # parameter positions. A recheck re-evaluates a declaration-stable changed callee at these keys and,
70
+ # when every return + the effects are unchanged, skips its symbol dependents.
42
71
  Payload = Data.define(:cache, :sources, :digests, :analyzed,
43
72
  :symbol_sources, :ancestry_sources, :symbol_fingerprints,
44
- :missing, :class_decls)
73
+ :missing, :class_decls, :seed_bundles, :plugin_fact_digest,
74
+ :return_summaries)
45
75
 
46
76
  # The global fingerprint that gates a snapshot load: a digest of the inputs whose change requires a full
47
77
  # rebuild — the engine version + schema, the resolved configuration, the analysis **roots** (the path
@@ -104,7 +134,10 @@ module Rigor
104
134
  ancestry_sources: data[:ancestry_sources] || {},
105
135
  symbol_fingerprints: data[:symbol_fingerprints] || {},
106
136
  missing: data[:missing] || {},
107
- class_decls: data[:class_decls] || {}
137
+ class_decls: data[:class_decls] || {},
138
+ seed_bundles: data[:seed_bundles] || {},
139
+ plugin_fact_digest: data[:plugin_fact_digest],
140
+ return_summaries: data[:return_summaries] || {}
108
141
  )
109
142
  rescue StandardError
110
143
  nil
@@ -122,7 +155,10 @@ module Rigor
122
155
  ancestry_sources: payload.ancestry_sources,
123
156
  symbol_fingerprints: payload.symbol_fingerprints,
124
157
  missing: payload.missing,
125
- class_decls: payload.class_decls
158
+ class_decls: payload.class_decls,
159
+ seed_bundles: payload.seed_bundles,
160
+ plugin_fact_digest: payload.plugin_fact_digest,
161
+ return_summaries: payload.return_summaries
126
162
  )
127
163
  blob = Zlib::Deflate.deflate(raw)
128
164
  tmp = "#{@path}.#{Process.pid}.tmp"
@@ -15,12 +15,22 @@ module Rigor
15
15
  # `self::PRODUCER_ID` resolves the constant on the concrete subclass, and `compute(loader)` dispatches to
16
16
  # its private class method. See the `_CacheProducer` RBS interface for the structural contract.
17
17
  class RbsCacheProducer
18
+ # Every RBS producer is whole-project and content-keyed: one entry is live and any older generation is
19
+ # unreachable, so `Cache::Store#evict!` keeps a small number of them (one spare for the still-warm
20
+ # previous signature state — the RBS environment blob alone runs to ~1.8 MB, ADR-54 WD3). Declared on
21
+ # the base, so a producer added by subclassing inherits a cap instead of being silently uncapped; a
22
+ # subclass with different economics overrides this method.
23
+ def self.generation_cap
24
+ 2
25
+ end
26
+
18
27
  def self.fetch(loader:, store:)
19
28
  # ADR-54 WD4 — the descriptor is identical for every producer consulting the same loader (same sig
20
29
  # files, same libraries), so the loader memoises one build per process instead of re-digesting every
21
30
  # .rbs file once per producer.
22
31
  descriptor = loader.rbs_cache_descriptor
23
- store.fetch_or_compute(producer_id: self::PRODUCER_ID, params: {}, descriptor: descriptor) do
32
+ store.fetch_or_compute(producer_id: self::PRODUCER_ID, params: {}, descriptor: descriptor,
33
+ generation_cap: generation_cap) do
24
34
  compute(loader)
25
35
  end
26
36
  end
@@ -3,6 +3,7 @@
3
3
  require "digest"
4
4
 
5
5
  require_relative "descriptor"
6
+ require_relative "file_digest"
6
7
 
7
8
  module Rigor
8
9
  module Cache
@@ -17,15 +18,36 @@ module Rigor
17
18
  Descriptor.new(
18
19
  gems: [rbs_gem_entry],
19
20
  files: file_entries(loader),
20
- configs: [libraries_entry(loader), virtual_rbs_entry(loader)].compact
21
+ configs: config_entries(loader)
21
22
  )
22
23
  end
23
24
 
25
+ # Lazy-files variant for the ADR-45 run-diagnostics record-and-validate cache. The cache KEY reads only
26
+ # `gems` + `configs` ({Runner#run_key_descriptor}); the RBS signature-tree `files` are read solely on a
27
+ # MISS, by the dependency descriptor ({Runner#run_dependency_descriptor}). So a warm HIT never digests
28
+ # the (large, vendored) RBS tree. {RunDescriptor} is NOT a {Descriptor} — it is never composed, hashed,
29
+ # or `==`'d, only its three readers are consulted — so deferring `files` costs no soundness, and `gems`
30
+ # / `configs` are byte-identical to {.build} (the key is unchanged).
31
+ def self.build_run(loader)
32
+ RunDescriptor.new(loader: loader, gems: [rbs_gem_entry], configs: config_entries(loader))
33
+ end
34
+
35
+ # The `gems` + `configs` slots the run cache key reads. Cheap — no RBS env load, no file digesting
36
+ # (only the configured library names + any plugin-synthesised virtual RBS are hashed).
37
+ def self.config_entries(loader)
38
+ [libraries_entry(loader.libraries), virtual_rbs_entry(loader)].compact
39
+ end
40
+
41
+ # Public (ADR-87 WD4) so the boot-slimming run-cache probe reconstructs the identical `gems` +
42
+ # `rbs.libraries` key slots the runner writes, from the config-derived library list, without a loader.
24
43
  def self.rbs_gem_entry
25
44
  Descriptor::GemEntry.new(name: "rbs", requirement: ">= 0", locked: ::RBS::VERSION.to_s)
26
45
  end
27
46
 
28
- def self.file_entries(loader)
47
+ # @param comparator [Symbol] `:digest` (default) for the env-cache KEY descriptor ({.build}), where the
48
+ # value must be deterministic; `:stat` (ADR-87 WD1) for the validation-only run-dependency descriptor
49
+ # ({RunDescriptor#files}), where the stat tier short-circuits the SHA-256 on an unmoved file.
50
+ def self.file_entries(loader, comparator: :digest)
29
51
  roots = loader.signature_paths +
30
52
  Rigor::Environment::RbsLoader.vendored_gem_sig_paths +
31
53
  Rigor::Environment::RbsLoader.core_overlay_sig_paths
@@ -33,17 +55,20 @@ module Rigor
33
55
  next [] unless root.directory?
34
56
 
35
57
  Dir.glob(root.join("**", "*.rbs")).map do |path|
36
- Descriptor::FileEntry.new(
37
- path: path,
38
- comparator: :digest,
39
- value: Digest::SHA256.file(path).hexdigest
40
- )
58
+ digest = FileDigest.hexdigest(path)
59
+ if comparator == :stat
60
+ Descriptor::FileEntry.stat(path: path, digest: digest)
61
+ else
62
+ Descriptor::FileEntry.new(path: path, comparator: :digest, value: digest)
63
+ end
41
64
  end
42
65
  end
43
66
  end
44
67
 
45
- def self.libraries_entry(loader)
46
- sorted = loader.libraries.map(&:to_s).sort
68
+ # @param library_names [Array<String, Symbol>] the loader's merged library list (or, on the WD4 probe
69
+ # path, `Environment::DEFAULT_LIBRARIES + config.libraries` reconstructed without a loader).
70
+ def self.libraries_entry(library_names)
71
+ sorted = library_names.map(&:to_s).sort
47
72
  Descriptor::ConfigEntry.new(
48
73
  key: "rbs.libraries",
49
74
  value_hash: Digest::SHA256.hexdigest(sorted.join("\n"))
@@ -66,7 +91,28 @@ module Rigor
66
91
  )
67
92
  end
68
93
 
69
- private_class_method :rbs_gem_entry, :file_entries, :libraries_entry, :virtual_rbs_entry
94
+ private_class_method :virtual_rbs_entry
95
+
96
+ # The lazy-files run descriptor {RbsDescriptor.build_run} returns. Exposes the three readers the
97
+ # run-diagnostics cache consults — `gems` + `configs` are supplied eagerly (they feed the cache KEY,
98
+ # and are cheap); `files` (the RBS signature-tree digests, read only on a MISS) is computed once on
99
+ # first access and memoised, so a warm HIT never pays for it.
100
+ class RunDescriptor
101
+ attr_reader :gems, :configs
102
+
103
+ def initialize(loader:, gems:, configs:)
104
+ @loader = loader
105
+ @gems = gems
106
+ @configs = configs
107
+ end
108
+
109
+ def files
110
+ # ADR-87 WD1 — this descriptor is validated (never a cache key), so the RBS signature tree rides the
111
+ # stat-then-digest `:stat` tier: a warm run stat-checks the (large, vendored) tree instead of
112
+ # re-hashing it.
113
+ @files ||= RbsDescriptor.file_entries(@loader, comparator: :stat)
114
+ end
115
+ end
70
116
  end
71
117
  end
72
118
  end
@@ -17,6 +17,20 @@ require "rbs"
17
17
  #
18
18
  # Idempotent: the guard checks `method_defined?(:_dump)` so requiring this file twice (or against an upstream
19
19
  # rbs that adds Marshal hooks itself) is a no-op.
20
+ #
21
+ # `RBS::TypeName` / `RBS::Namespace` carry a second, subtler Marshal hazard, introduced by rbs 4.1's
22
+ # flyweight interning (ruby/rbs#2957): both memoise their `#hash` into an `@hash` ivar, and that value is
23
+ # derived from `Array#hash` / `Symbol#hash`, which Ruby seeds PER PROCESS. Marshal round-trips the ivar
24
+ # verbatim, so a cached `TypeName` loaded in a later process answers `hash` with the *writing* process's
25
+ # value while a freshly parsed `TypeName.parse("::String")` answers with this process's. The two are still
26
+ # `eql?`, so nothing raises — every Hash keyed by a TypeName simply misses. `RBS::Environment#class_decls`
27
+ # is exactly such a Hash, so a warm cache silently reported every core class as unknown.
28
+ #
29
+ # `_dump` / `_load` on both classes fixes it at the representation: the name is dumped as its source string
30
+ # and reconstructed through `.parse`, which routes back through the flyweight interner. That drops the stale
31
+ # `@hash` (it is re-memoised on demand, under this process's seed), and restores the flyweight identity the
32
+ # interner exists to provide — a cached env now shares one object per distinct name rather than one per
33
+ # reference. Both classes are value objects fully described by `to_s`, so the round-trip is lossless.
20
34
  module RBS
21
35
  class Location
22
36
  unless method_defined?(:_dump)
@@ -29,4 +43,28 @@ module RBS
29
43
  end
30
44
  end
31
45
  end
46
+
47
+ class Namespace
48
+ unless method_defined?(:_dump)
49
+ def _dump(_)
50
+ to_s
51
+ end
52
+
53
+ def self._load(string)
54
+ parse(string)
55
+ end
56
+ end
57
+ end
58
+
59
+ class TypeName
60
+ unless method_defined?(:_dump)
61
+ def _dump(_)
62
+ to_s
63
+ end
64
+
65
+ def self._load(string)
66
+ parse(string)
67
+ end
68
+ end
69
+ end
32
70
  end
@@ -37,18 +37,10 @@ module Rigor
37
37
  # blob whose class layout still happens to unmarshal.
38
38
  PAYLOAD_ABI_VERSION = Rigor::VERSION
39
39
 
40
- # Whole-project producers are content-keyed, so dependency / signature churn writes a new entry and leaves
41
- # the old generation unreachable. The global 256 MB cap is intentionally generous and often never fires on
42
- # one project, so these producers get a small generation cap as a second compaction axis. Per-file / plugin
43
- # producers are deliberately absent from this table: many current entries under one producer id can be live.
44
- GENERATION_CAP_BY_PRODUCER = {
45
- "analysis.run-diagnostics" => 16,
46
- "rbs.class_ancestor_table" => 2,
47
- "rbs.class_type_param_names" => 2,
48
- "rbs.constant_type_table" => 2,
49
- "rbs.environment" => 2,
50
- "rbs.known_class_names" => 2
51
- }.freeze
40
+ # The `generation_cap:` value declaring that a producer keeps MANY live entries under one id at once
41
+ # (per-file and per-plugin producers): a generation count is not a staleness proxy there, so the
42
+ # compaction pass in {#evict!} leaves the producer alone and only the size-based LRU pass can touch it.
43
+ UNBOUNDED_GENERATIONS = :unbounded
52
44
 
53
45
  STALE_TEMP_FILE_AGE_SECONDS = 60 * 60
54
46
 
@@ -76,6 +68,14 @@ module Rigor
76
68
  @misses = 0
77
69
  @writes = 0
78
70
  @by_producer = Hash.new { |h, k| h[k] = { hits: 0, misses: 0, writes: 0 } }
71
+ # `producer_id => generation cap`, populated from the `generation_cap:` every fetch call declares (see
72
+ # {#declare_generation_cap}). This is the bridge between the id STRINGS the fetch API takes and the
73
+ # directory names {#evict!} walks: a producer id the Store never saw declared stays uncapped, which
74
+ # errs toward under-evicting. Per-instance rather than process-global on purpose — a global mutable
75
+ # registry would be a `Ractor` shareability hazard for pool mode, and the Store instance that wrote a
76
+ # generation is the one whose `evict!` compacts it (`CLI::CheckCommand` calls
77
+ # `runner.cache_store&.evict!`).
78
+ @generation_caps = {}
79
79
  # Process-level in-memory layer keyed by `(producer_id, cache_key)`. Avoids the disk read +
80
80
  # `Marshal.load` cost (the dominant share of repeated cache-hit calls per stackprof) when many
81
81
  # short-lived `Analysis::Runner` instances share one `Store` — the spec process, the LSP daemon's
@@ -161,6 +161,12 @@ module Rigor
161
161
  private_class_method :collect_producers
162
162
 
163
163
  # @param producer_id [String] stable cache namespace; only `[a-z][a-z0-9._-]*` is accepted.
164
+ # @param generation_cap [Integer, Symbol] how many generations of this producer survive a compaction
165
+ # pass — a positive `Integer` for a whole-project producer (one live entry, older ones unreachable),
166
+ # or {UNBOUNDED_GENERATIONS} for a producer with many simultaneously-live entries. REQUIRED, and
167
+ # sourced from the producer's own declaration (`RbsCacheProducer.generation_cap`,
168
+ # `RunCacheKey::GENERATION_CAP`, `Plugin::Base.producer generation_cap:`) rather than invented at the
169
+ # call site. See {#evict!}.
164
170
  # @param params [Hash] producer inputs; mixed into the cache key via {Descriptor#cache_key_for}.
165
171
  # @param descriptor [Rigor::Cache::Descriptor] the invalidation descriptor for the value being cached.
166
172
  # @param serialize [#call, nil] optional callable that turns the producer's return value into a binary
@@ -175,9 +181,10 @@ module Rigor
175
181
  # path.
176
182
  # @yieldreturn the value to cache.
177
183
  # @return the cached value (loaded from disk on hit; produced by the block on miss).
178
- def fetch_or_compute(producer_id:, params:, descriptor:,
184
+ def fetch_or_compute(producer_id:, params:, descriptor:, generation_cap:,
179
185
  serialize: nil, deserialize: nil, &block)
180
186
  validate_producer_id!(producer_id)
187
+ declare_generation_cap(producer_id, generation_cap)
181
188
  disk = ensure_schema_version!
182
189
 
183
190
  key = descriptor.cache_key_for(producer_id: producer_id, params: params)
@@ -217,17 +224,20 @@ module Rigor
217
224
  #
218
225
  # The block MUST return `[value, dependency_descriptor]`. Disk reads are not in-process-memoised —
219
226
  # validation always re-checks the filesystem — but a single run only looks up once.
220
- def fetch_or_validate(producer_id:, key_descriptor:, params: {}, serialize: nil, deserialize: nil)
227
+ #
228
+ # `generation_cap:` carries the same producer-declared compaction budget as {#fetch_or_compute}.
229
+ def fetch_or_validate(producer_id:, key_descriptor:, generation_cap:, params: {},
230
+ serialize: nil, deserialize: nil)
221
231
  validate_producer_id!(producer_id)
232
+ declare_generation_cap(producer_id, generation_cap)
222
233
  disk = ensure_schema_version!
223
234
 
224
235
  key = key_descriptor.cache_key_for(producer_id: producer_id, params: params)
225
236
  path = disk ? entry_path(producer_id, key) : nil
226
237
  cached = path && read_entry(path, deserialize: deserialize)
227
- if cached && (pair = cached.value).is_a?(Array) && pair.size == 2 &&
228
- pair[1].is_a?(Descriptor) && pair[1].fresh?
238
+ if (validated = fresh_pair_value(cached))
229
239
  @monitor.synchronize { record(:hits, producer_id) }
230
- return pair[0]
240
+ return validated[0]
231
241
  end
232
242
 
233
243
  value, dependency_descriptor = block_given? ? yield : [nil, Descriptor.new]
@@ -239,8 +249,36 @@ module Rigor
239
249
  value
240
250
  end
241
251
 
242
- # ADR-6 § "Eviction" compaction pass over the on-disk cache. No-op when the store is read-only. Stale
243
- # temp file cleanup and the whole-project generation cap run regardless of `max_bytes:` they reclaim
252
+ # ADR-87 WD4the READ half of {#fetch_or_validate} with no compute and no write: returns the cached
253
+ # value on a fresh hit, nil on a miss / stale / unavailable-disk. The boot-slimming hit probe calls this
254
+ # to serve a run's diagnostics WITHOUT loading the inference engine — it never runs a producer block, so
255
+ # there is nothing to write. Records a hit (for `--cache-stats` parity) but never a miss (a probe miss
256
+ # hands off to the full path, which records its own).
257
+ #
258
+ # Takes no `generation_cap:`: a pure read creates no generation, so a run that only ever peeks has
259
+ # nothing to compact. The write path for the same producer declares the cap.
260
+ def peek_validated(producer_id:, key_descriptor:, params: {}, deserialize: nil)
261
+ validate_producer_id!(producer_id)
262
+ return nil unless ensure_schema_version!
263
+
264
+ key = key_descriptor.cache_key_for(producer_id: producer_id, params: params)
265
+ cached = read_entry(entry_path(producer_id, key), deserialize: deserialize)
266
+ validated = fresh_pair_value(cached)
267
+ return nil if validated.nil?
268
+
269
+ @monitor.synchronize { record(:hits, producer_id) }
270
+ validated[0]
271
+ end
272
+
273
+ # ADR-6 § "Eviction" — compaction pass over the on-disk cache. No-op when the store is read-only.
274
+ #
275
+ # The generation cap of pass 2 comes from what the producers themselves declared through this Store's
276
+ # fetch calls (`generation_cap:`), NOT from a maintained table of producer ids: a producer id this
277
+ # Store never saw is left alone. That is deliberately the safe direction — a producer that was not
278
+ # consulted this run also wrote no new generation, so the only entries this can skip are ones that were
279
+ # already sitting there.
280
+ #
281
+ # Stale temp file cleanup and the generation cap run regardless of `max_bytes:` — they reclaim
244
282
  # provably-dead bytes (leaked temp files, unreachable content-keyed generations) rather than enforcing a
245
283
  # size budget, so an explicitly unbounded store (`max_bytes: nil`) still benefits from them. The
246
284
  # size-based LRU pass below stays gated on `max_bytes:` being configured: it walks all remaining
@@ -262,7 +300,7 @@ module Rigor
262
300
  entries.each do |entry|
263
301
  break if total <= @max_bytes
264
302
 
265
- total -= entry[:bytes] if unlink_entry(entry[:path])
303
+ total -= entry[:bytes] if unlink_entry_and_shard?(entry[:path])
266
304
  end
267
305
  nil
268
306
  rescue StandardError
@@ -274,6 +312,19 @@ module Rigor
274
312
  Entry = Data.define(:descriptor_bytes, :value)
275
313
  private_constant :Entry
276
314
 
315
+ # A record-and-validate entry is a fresh hit iff it deserialised to a `[value, dependency_descriptor]`
316
+ # pair whose descriptor still validates against the filesystem. Returns the pair on a fresh hit, nil
317
+ # otherwise. Shared by {#fetch_or_validate} and {#peek_validated} so both apply identical hit criteria.
318
+ def fresh_pair_value(cached)
319
+ return nil if cached.nil?
320
+
321
+ pair = cached.value
322
+ return nil unless pair.is_a?(Array) && pair.size == 2 &&
323
+ pair[1].is_a?(Descriptor) && pair[1].fresh?
324
+
325
+ pair
326
+ end
327
+
277
328
  def record(counter, producer_id)
278
329
  case counter
279
330
  when :hits then @hits += 1
@@ -290,6 +341,37 @@ module Rigor
290
341
  "producer_id must match #{VALID_PRODUCER_ID.inspect}, got #{producer_id.inspect}"
291
342
  end
292
343
 
344
+ # Records the producer's declared compaction budget for {#evict!}. Every fetch call carries it, so a
345
+ # producer cannot reach disk without stating one — the failure mode the previous hardcoded id table
346
+ # had (a new whole-project producer silently uncapped) is not expressible.
347
+ #
348
+ # Two DIFFERENT caps for one producer id in one process is a producer bug, not a policy: whichever the
349
+ # compaction pass picked would be arbitrary. It raises, consistent with the serializer-contract
350
+ # violations {#try_write_entry} deliberately lets through.
351
+ def declare_generation_cap(producer_id, generation_cap)
352
+ validate_generation_cap!(producer_id, generation_cap)
353
+ @monitor.synchronize do
354
+ previous = @generation_caps[producer_id]
355
+ if !previous.nil? && previous != generation_cap
356
+ raise ArgumentError,
357
+ "producer #{producer_id.inspect} declared generation_cap #{generation_cap.inspect} after " \
358
+ "#{previous.inspect}; one producer id must declare one cap"
359
+ end
360
+
361
+ @generation_caps[producer_id] = generation_cap
362
+ end
363
+ end
364
+
365
+ def validate_generation_cap!(producer_id, generation_cap)
366
+ return if generation_cap == UNBOUNDED_GENERATIONS
367
+ return if generation_cap.is_a?(Integer) && generation_cap.positive?
368
+
369
+ raise ArgumentError,
370
+ "producer #{producer_id.inspect} must declare generation_cap as a positive Integer (a " \
371
+ "whole-project producer: how many generations survive compaction) or " \
372
+ "#{UNBOUNDED_GENERATIONS.inspect} (many entries live at once), got #{generation_cap.inspect}"
373
+ end
374
+
293
375
  def entry_path(producer_id, key)
294
376
  File.join(@root, producer_id, key[0, 2], "#{key[2..]}.entry")
295
377
  end
@@ -511,7 +593,7 @@ module Rigor
511
593
  next unless File.file?(path)
512
594
  next if File.mtime(path) > cutoff
513
595
 
514
- unlink_entry(path)
596
+ unlink_entry_and_shard?(path)
515
597
  rescue StandardError
516
598
  next
517
599
  end
@@ -520,16 +602,16 @@ module Rigor
520
602
  end
521
603
 
522
604
  def evict_excess_generations(entries)
605
+ caps = @monitor.synchronize { @generation_caps.dup }
523
606
  removed = {}
524
607
  entries.group_by { |entry| entry[:producer] }.each do |producer, producer_entries|
525
- cap = GENERATION_CAP_BY_PRODUCER[producer]
608
+ cap = caps[producer]
609
+ cap = nil if cap == UNBOUNDED_GENERATIONS
526
610
  next if cap.nil? || producer_entries.size <= cap
527
611
 
528
- producer_entries.sort_by { |entry| [entry[:mtime], entry[:path]] }
529
- .first(producer_entries.size - cap)
530
- .each do |entry|
531
- removed[entry[:path]] = true if unlink_entry(entry[:path])
532
- end
612
+ excess = producer_entries.sort_by { |entry| [entry[:mtime], entry[:path]] }
613
+ .first(producer_entries.size - cap)
614
+ excess.each { |entry| removed[entry[:path]] = true if unlink_entry_and_shard?(entry[:path]) }
533
615
  end
534
616
  return entries if removed.empty?
535
617
 
@@ -543,6 +625,29 @@ module Rigor
543
625
  false
544
626
  end
545
627
 
628
+ # {#unlink_entry} plus a best-effort {#rmdir_if_empty} of the shard directory the unlinked file left
629
+ # behind. Shared by every eviction/sweep pass so an emptied shard is cleaned up wherever an entry (or
630
+ # stale temp file) is removed, per the issue #216 fossil fix.
631
+ def unlink_entry_and_shard?(path)
632
+ return false unless unlink_entry(path)
633
+
634
+ rmdir_if_empty(File.dirname(path))
635
+ true
636
+ end
637
+
638
+ # Removes `dir` — a shard directory (`entry_path`'s `key[0, 2]` component) — when the unlink that
639
+ # just preceded this call was the one to empty it. Best-effort and rescued the same way
640
+ # {#unlink_entry} is: a concurrent writer's `FileUtils.mkdir_p` recreating the shard between the
641
+ # unlink and this call (`Errno::ENOTEMPTY`) or another pass already having removed it
642
+ # (`Errno::ENOENT`) are both benign outcomes, never a reason to break the eviction/sweep pass. This
643
+ # is purely cosmetic (inode reclaim) — it never decides which ENTRIES are evicted, only tidies the
644
+ # directory left behind once they are gone.
645
+ def rmdir_if_empty(dir)
646
+ Dir.rmdir(dir)
647
+ rescue StandardError
648
+ nil
649
+ end
650
+
546
651
  # Returns an array of `{ path:, producer:, mtime:, bytes: }` hashes for every `.entry` file under the
547
652
  # cache root, skipping unreadable entries.
548
653
  def collect_entry_stats
@@ -0,0 +1,90 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rigor
4
+ # Runtime CI-environment detection (ADR-51 WD7), modelled on `OndraM/ci-detector` (the library PHPStan uses). Reads
5
+ # the well-known environment variables a CI provider sets and returns the matching {Platform}, classifying it into a
6
+ # **tier** that decides how `rigor check` surfaces diagnostics there:
7
+ #
8
+ # :native_stdout — Rigor has a native format that renders purely from
9
+ # stdout, so it is auto-emitted on top of the human
10
+ # output (GitHub Actions → `github`, TeamCity →
11
+ # `teamcity`). These are the first-class platforms.
12
+ # :native_artifact — Rigor has a native format but it needs a CI-wired
13
+ # report artifact, not stdout (GitLab CI → `gitlab`).
14
+ # First-class, but Rigor only *hints* the format.
15
+ # :reviewdog — no native Rigor format; second-class, routed through
16
+ # reviewdog (`checkstyle`/`sarif`) or `junit`. Hint
17
+ # only.
18
+ #
19
+ # Lives outside the CLI namespace because it is a pure function of the environment hash with no CLI dependency, and
20
+ # non-CLI consumers exist: {Configuration#cache_validation_strict?} resolves the `auto` cache-validation default
21
+ # through it (a fresh CI checkout regenerates every stat tuple, so ADR-87's stat tier cannot work there).
22
+ # Detection is fully testable; callers pass `ENV`.
23
+ # `RIGOR_CI_DETECT=0` (or `false`/`no`) disables it globally — the seam the spec suite uses for determinism.
24
+ module CiDetector
25
+ Platform = Struct.new(:id, :name, :format, :tier, keyword_init: true) do
26
+ def native_stdout? = tier == :native_stdout
27
+ def native_artifact? = tier == :native_artifact
28
+ def reviewdog? = tier == :reviewdog
29
+ end
30
+
31
+ # The detection table, ordered most-specific first so the generic `CI=true` catch-all is last (a provider that
32
+ # also sets `CI` is still recognised by its own variable). `match` is `:truthy` (value in 1/true/yes/on),
33
+ # `:present` (variable set non-empty), or `:equals`.
34
+ PROVIDERS = [
35
+ { id: "github-actions", name: "GitHub Actions", format: "github", tier: :native_stdout,
36
+ var: "GITHUB_ACTIONS", match: :truthy },
37
+ { id: "gitlab", name: "GitLab CI", format: "gitlab", tier: :native_artifact,
38
+ var: "GITLAB_CI", match: :truthy },
39
+ { id: "teamcity", name: "TeamCity", format: "teamcity", tier: :native_stdout,
40
+ var: "TEAMCITY_VERSION", match: :present },
41
+ { id: "circleci", name: "CircleCI", format: nil, tier: :reviewdog,
42
+ var: "CIRCLECI", match: :truthy },
43
+ { id: "jenkins", name: "Jenkins", format: nil, tier: :reviewdog,
44
+ var: "JENKINS_URL", match: :present },
45
+ { id: "travis", name: "Travis CI", format: nil, tier: :reviewdog,
46
+ var: "TRAVIS", match: :truthy },
47
+ { id: "appveyor", name: "AppVeyor", format: nil, tier: :reviewdog,
48
+ var: "APPVEYOR", match: :truthy },
49
+ { id: "azure-pipelines", name: "Azure Pipelines", format: nil, tier: :reviewdog,
50
+ var: "TF_BUILD", match: :present },
51
+ { id: "bitbucket", name: "Bitbucket Pipelines", format: nil, tier: :reviewdog,
52
+ var: "BITBUCKET_BUILD_NUMBER", match: :present },
53
+ { id: "buildkite", name: "Buildkite", format: nil, tier: :reviewdog,
54
+ var: "BUILDKITE", match: :truthy },
55
+ { id: "drone", name: "Drone CI", format: nil, tier: :reviewdog,
56
+ var: "DRONE", match: :truthy },
57
+ { id: "semaphore", name: "Semaphore", format: nil, tier: :reviewdog,
58
+ var: "SEMAPHORE", match: :truthy },
59
+ { id: "codeship", name: "Codeship", format: nil, tier: :reviewdog,
60
+ var: "CI_NAME", match: :equals, value: "codeship" },
61
+ { id: "ci", name: "CI", format: nil, tier: :reviewdog,
62
+ var: "CI", match: :truthy }
63
+ ].freeze
64
+
65
+ module_function
66
+
67
+ # Returns the detected {Platform}, or nil when no CI is recognised or detection is disabled via `RIGOR_CI_DETECT`.
68
+ def detect(env = ENV)
69
+ return nil if disabled?(env)
70
+
71
+ row = PROVIDERS.find { |provider| matches?(env, provider) }
72
+ return nil if row.nil?
73
+
74
+ Platform.new(id: row[:id], name: row[:name], format: row[:format], tier: row[:tier])
75
+ end
76
+
77
+ def disabled?(env)
78
+ %w[0 false no off].include?(env["RIGOR_CI_DETECT"].to_s.strip.downcase)
79
+ end
80
+
81
+ def matches?(env, provider)
82
+ value = env[provider[:var]].to_s.strip
83
+ case provider[:match]
84
+ when :truthy then %w[1 true yes on].include?(value.downcase)
85
+ when :present then !value.empty?
86
+ when :equals then value.downcase == provider[:value]
87
+ end
88
+ end
89
+ end
90
+ end