rigortype 0.4.0 → 0.4.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (237) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +4 -3
  3. data/data/builtins/ruby_core/io.yml +1 -0
  4. data/data/builtins/ruby_core/proc.yml +4 -0
  5. data/data/builtins/ruby_core/random.yml +4 -0
  6. data/data/builtins/ruby_core/struct.yml +3 -0
  7. data/data/core_overlay/env.rbs +34 -0
  8. data/data/core_overlay/gc.rbs +50 -0
  9. data/data/core_overlay/hash.rbs +45 -0
  10. data/data/core_overlay/integer.rbs +31 -0
  11. data/data/core_overlay/io_buffer.rbs +27 -0
  12. data/data/core_overlay/kernel.rbs +40 -0
  13. data/data/core_overlay/match_data.rbs +28 -0
  14. data/data/core_overlay/module.rbs +51 -0
  15. data/data/core_overlay/object_space.rbs +29 -0
  16. data/data/core_overlay/proc.rbs +45 -0
  17. data/data/core_overlay/range.rbs +40 -0
  18. data/data/core_overlay/source_range.rbs +78 -0
  19. data/data/core_overlay/string.rbs +86 -0
  20. data/data/vendored_gem_sigs/prime/integer-extension.rbs +41 -0
  21. data/data/vendored_gem_sigs/prime/prime.rbs +372 -0
  22. data/docs/handbook/01-getting-started.md +3 -3
  23. data/docs/handbook/06-classes.md +3 -1
  24. data/docs/handbook/08-understanding-errors.md +3 -2
  25. data/docs/handbook/11-sig-gen.md +6 -0
  26. data/docs/handbook/appendix-liskov.md +5 -0
  27. data/docs/install.md +87 -15
  28. data/docs/manual/02-cli-reference.md +2 -2
  29. data/docs/manual/03-configuration.md +6 -1
  30. data/docs/manual/04-diagnostics.md +2 -1
  31. data/docs/manual/06-baseline.md +2 -0
  32. data/docs/manual/07-plugins.md +1 -1
  33. data/docs/manual/08-skills.md +4 -3
  34. data/docs/manual/12-caching.md +43 -4
  35. data/docs/manual/14-rails-quickstart.md +19 -11
  36. data/docs/manual/18-removing-dead-code.md +32 -2
  37. data/docs/manual/plugins/README.md +6 -1
  38. data/docs/manual/plugins/rigor-activerecord.md +21 -0
  39. data/docs/manual/plugins/rigor-alba.md +60 -0
  40. data/docs/manual/plugins/rigor-minitest.md +22 -4
  41. data/docs/manual/plugins/rigor-typelizer.md +71 -0
  42. data/lib/rigor/analysis/check_rules/ivar_write_collector.rb +27 -3
  43. data/lib/rigor/analysis/check_rules/lexical_method_sites.rb +21 -92
  44. data/lib/rigor/analysis/check_rules/ruby_deprecations.rb +275 -0
  45. data/lib/rigor/analysis/check_rules/rule_ids.rb +4 -0
  46. data/lib/rigor/analysis/check_rules/source_arity.rb +184 -61
  47. data/lib/rigor/analysis/check_rules.rb +407 -144
  48. data/lib/rigor/analysis/dependency_recorder.rb +215 -18
  49. data/lib/rigor/analysis/dependency_source_inference/builder.rb +8 -1
  50. data/lib/rigor/analysis/dependency_source_inference/gem_resolver.rb +21 -0
  51. data/lib/rigor/analysis/dependency_source_inference/index.rb +36 -11
  52. data/lib/rigor/analysis/dependency_source_inference/walker.rb +196 -27
  53. data/lib/rigor/analysis/erb_template_detector.rb +2 -1
  54. data/lib/rigor/analysis/incremental.rb +29 -9
  55. data/lib/rigor/analysis/incremental_run_slot/write_guard.rb +291 -0
  56. data/lib/rigor/analysis/incremental_run_slot.rb +173 -0
  57. data/lib/rigor/analysis/incremental_session.rb +287 -50
  58. data/lib/rigor/analysis/plugin_fact_fingerprint.rb +24 -13
  59. data/lib/rigor/analysis/reachability/graph.rb +123 -23
  60. data/lib/rigor/analysis/reachability/scan.rb +290 -36
  61. data/lib/rigor/analysis/reachability/scan_cache.rb +2 -1
  62. data/lib/rigor/analysis/rule_catalog.rb +43 -0
  63. data/lib/rigor/analysis/run_cache_key.rb +43 -9
  64. data/lib/rigor/analysis/run_stats.rb +2 -2
  65. data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +88 -6
  66. data/lib/rigor/analysis/runner/pool_coordinator.rb +52 -13
  67. data/lib/rigor/analysis/runner/project_pre_passes.rb +21 -13
  68. data/lib/rigor/analysis/runner/run_snapshots.rb +6 -1
  69. data/lib/rigor/analysis/runner.rb +371 -49
  70. data/lib/rigor/analysis/shadow_harness.rb +27 -0
  71. data/lib/rigor/analysis/source_rbs_gate.rb +157 -0
  72. data/lib/rigor/analysis/template_unit_paths.rb +6 -4
  73. data/lib/rigor/analysis/template_units.rb +15 -5
  74. data/lib/rigor/analysis/worker_session.rb +11 -7
  75. data/lib/rigor/cache/descriptor.rb +40 -1
  76. data/lib/rigor/cache/file_digest.rb +28 -0
  77. data/lib/rigor/cache/incremental_snapshot.rb +92 -8
  78. data/lib/rigor/cache/store.rb +63 -3
  79. data/lib/rigor/cache/value_digest.rb +325 -0
  80. data/lib/rigor/cli/annotate_command.rb +5 -0
  81. data/lib/rigor/cli/baseline_command.rb +7 -18
  82. data/lib/rigor/cli/check_command.rb +85 -14
  83. data/lib/rigor/cli/doctor_command.rb +1 -1
  84. data/lib/rigor/cli/effects_report.rb +2 -2
  85. data/lib/rigor/cli/trace_command.rb +5 -0
  86. data/lib/rigor/cli/type_of_command.rb +5 -3
  87. data/lib/rigor/cli/unused_command.rb +16 -10
  88. data/lib/rigor/cli.rb +11 -3
  89. data/lib/rigor/config_audit.rb +3 -2
  90. data/lib/rigor/config_path.rb +29 -0
  91. data/lib/rigor/configuration/severity_profile.rb +11 -0
  92. data/lib/rigor/configuration.rb +52 -7
  93. data/lib/rigor/effects/plugin_facts.rb +6 -6
  94. data/lib/rigor/environment/bundle_sig_discovery.rb +5 -3
  95. data/lib/rigor/environment/gated_signature_guard.rb +43 -0
  96. data/lib/rigor/environment/gated_signature_plan.rb +339 -0
  97. data/lib/rigor/environment/lockfile_resolver.rb +10 -1
  98. data/lib/rigor/environment/rbs_collection_discovery.rb +10 -1
  99. data/lib/rigor/environment/rbs_coverage_report.rb +1 -1
  100. data/lib/rigor/environment/rbs_loader.rb +284 -15
  101. data/lib/rigor/environment/required_features.rb +206 -0
  102. data/lib/rigor/environment/source_rbs_synthesis.rb +147 -0
  103. data/lib/rigor/environment.rb +34 -73
  104. data/lib/rigor/inference/acceptance.rb +28 -3
  105. data/lib/rigor/inference/block_parameter_binder.rb +29 -13
  106. data/lib/rigor/inference/builtins/cruby_definers.rb +61 -0
  107. data/lib/rigor/inference/builtins/method_catalog.rb +3 -0
  108. data/lib/rigor/inference/captured_locals.rb +7 -1
  109. data/lib/rigor/inference/declaration_walk/context.rb +195 -0
  110. data/lib/rigor/inference/declaration_walk/errors.rb +18 -0
  111. data/lib/rigor/inference/declaration_walk/shadow.rb +147 -0
  112. data/lib/rigor/inference/declaration_walk/traversal.rb +441 -0
  113. data/lib/rigor/inference/declaration_walk.rb +7 -0
  114. data/lib/rigor/inference/def_node_resolver.rb +26 -3
  115. data/lib/rigor/inference/definer_resolution.rb +266 -0
  116. data/lib/rigor/inference/expression_typer.rb +351 -61
  117. data/lib/rigor/inference/external_ancestor_resolution.rb +13 -16
  118. data/lib/rigor/inference/global_write_census.rb +7 -4
  119. data/lib/rigor/inference/guard_rebinding.rb +54 -13
  120. data/lib/rigor/inference/in_effect_refinements/proc_literals.rb +83 -0
  121. data/lib/rigor/inference/in_effect_refinements/refine_defs.rb +54 -0
  122. data/lib/rigor/inference/in_effect_refinements/scope_reads.rb +282 -0
  123. data/lib/rigor/inference/in_effect_refinements/walk.rb +276 -0
  124. data/lib/rigor/inference/in_effect_refinements.rb +256 -0
  125. data/lib/rigor/inference/key_presence_guard/scan.rb +281 -0
  126. data/lib/rigor/inference/key_presence_guard.rb +303 -0
  127. data/lib/rigor/inference/macro_block_self_type.rb +73 -14
  128. data/lib/rigor/inference/method_dispatcher/alias_strict_nominals.rb +36 -0
  129. data/lib/rigor/inference/method_dispatcher/arithmetic_sequence_elements.rb +73 -0
  130. data/lib/rigor/inference/method_dispatcher/imprecise_argument.rb +68 -0
  131. data/lib/rigor/inference/method_dispatcher/integer_step_block_params.rb +81 -0
  132. data/lib/rigor/inference/method_dispatcher/iterator_dispatch.rb +6 -0
  133. data/lib/rigor/inference/method_dispatcher/keyword_arguments.rb +267 -0
  134. data/lib/rigor/inference/method_dispatcher/method_folding.rb +2 -1
  135. data/lib/rigor/inference/method_dispatcher/overload_selector.rb +122 -105
  136. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +305 -54
  137. data/lib/rigor/inference/method_dispatcher/splat_arity.rb +71 -0
  138. data/lib/rigor/inference/method_dispatcher.rb +44 -11
  139. data/lib/rigor/inference/module_function_state.rb +214 -0
  140. data/lib/rigor/inference/multi_target_binder.rb +21 -16
  141. data/lib/rigor/inference/narrowing.rb +166 -22
  142. data/lib/rigor/inference/object_mixins.rb +180 -0
  143. data/lib/rigor/inference/parameter_inference_collector.rb +10 -9
  144. data/lib/rigor/inference/project_patched_methods.rb +10 -5
  145. data/lib/rigor/inference/project_patched_scanner.rb +71 -3
  146. data/lib/rigor/inference/refine_census.rb +239 -0
  147. data/lib/rigor/inference/refine_self.rb +317 -0
  148. data/lib/rigor/inference/refined_dispatch/own_method.rb +403 -0
  149. data/lib/rigor/inference/refined_dispatch.rb +272 -0
  150. data/lib/rigor/inference/scope_indexer/certainty.rb +176 -0
  151. data/lib/rigor/inference/scope_indexer/class_cvars_collector.rb +35 -0
  152. data/lib/rigor/inference/scope_indexer/def_nestings_collector.rb +47 -0
  153. data/lib/rigor/inference/scope_indexer/layered_def_nestings.rb +36 -0
  154. data/lib/rigor/inference/scope_indexer/member_layouts_collector.rb +61 -0
  155. data/lib/rigor/inference/scope_indexer/mixin_accumulator.rb +143 -0
  156. data/lib/rigor/inference/scope_indexer/superclasses_collector.rb +64 -0
  157. data/lib/rigor/inference/scope_indexer.rb +2129 -354
  158. data/lib/rigor/inference/singleton_hook_decline.rb +253 -0
  159. data/lib/rigor/inference/statement_evaluator.rb +149 -32
  160. data/lib/rigor/inference/string_mutation.rb +9 -3
  161. data/lib/rigor/inference/synthetic_method_scanner.rb +2 -1
  162. data/lib/rigor/inference/version_guard.rb +19 -10
  163. data/lib/rigor/language_server/project_context.rb +31 -0
  164. data/lib/rigor/language_server/server.rb +18 -0
  165. data/lib/rigor/plugin/base.rb +28 -8
  166. data/lib/rigor/plugin/inflector.rb +1 -1
  167. data/lib/rigor/plugin/io_boundary.rb +124 -7
  168. data/lib/rigor/plugin/isolation.rb +1 -1
  169. data/lib/rigor/plugin/macro/block_as_method.rb +51 -8
  170. data/lib/rigor/plugin/trust_policy.rb +3 -3
  171. data/lib/rigor/project_environment.rb +29 -6
  172. data/lib/rigor/protection/discovery_seed.rb +18 -9
  173. data/lib/rigor/protection/mutation_cache.rb +4 -4
  174. data/lib/rigor/protection/mutator.rb +1 -1
  175. data/lib/rigor/reflection/constant_ancestors.rb +211 -46
  176. data/lib/rigor/reflection/constant_path.rb +33 -7
  177. data/lib/rigor/reflection.rb +41 -10
  178. data/lib/rigor/scope/discovery_index.rb +258 -3
  179. data/lib/rigor/scope/resolution_chain/relevance.rb +165 -0
  180. data/lib/rigor/scope/resolution_chain.rb +1032 -0
  181. data/lib/rigor/scope.rb +480 -328
  182. data/lib/rigor/sig_gen/alias_index.rb +1 -1
  183. data/lib/rigor/sig_gen/classification.rb +4 -1
  184. data/lib/rigor/sig_gen/effect_annotation.rb +2 -2
  185. data/lib/rigor/sig_gen/generator.rb +77 -21
  186. data/lib/rigor/sig_gen/method_candidate.rb +2 -2
  187. data/lib/rigor/sig_gen/skip_reason_catalog.rb +12 -0
  188. data/lib/rigor/source/alias_names.rb +52 -0
  189. data/lib/rigor/source/literals.rb +3 -2
  190. data/lib/rigor/source/symbol_name.rb +20 -0
  191. data/lib/rigor/type/combinator.rb +59 -3
  192. data/lib/rigor/type/float_range.rb +4 -1
  193. data/lib/rigor/type/nominal.rb +6 -1
  194. data/lib/rigor/version.rb +1 -1
  195. data/plugins/rigor-ac-library-rb/lib/rigor/plugin/ac_library_rb.rb +28 -0
  196. data/plugins/rigor-ac-library-rb/lib/rigor-ac-library-rb.rb +3 -0
  197. data/plugins/rigor-ac-library-rb/sig/ac_library_rb/convolution.rbs +7 -0
  198. data/plugins/rigor-ac-library-rb/sig/ac_library_rb/data_structures.rbs +160 -0
  199. data/plugins/rigor-ac-library-rb/sig/ac_library_rb/graph.rbs +94 -0
  200. data/plugins/rigor-ac-library-rb/sig/ac_library_rb/math.rbs +42 -0
  201. data/plugins/rigor-ac-library-rb/sig/ac_library_rb/modint.rbs +71 -0
  202. data/plugins/rigor-ac-library-rb/sig/ac_library_rb/object.rbs +37 -0
  203. data/plugins/rigor-ac-library-rb/sig/ac_library_rb/segtree.rbs +83 -0
  204. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/analyzer.rb +2 -1
  205. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/association_roots.rb +100 -0
  206. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_discoverer.rb +94 -10
  207. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +43 -2
  208. data/plugins/rigor-activerecord/sig/active_record/relation.rbs +3 -1
  209. data/plugins/rigor-alba/lib/rigor/plugin/alba/resource_collector.rb +149 -0
  210. data/plugins/rigor-alba/lib/rigor/plugin/alba/resource_discoverer.rb +74 -0
  211. data/plugins/rigor-alba/lib/rigor/plugin/alba/resource_index.rb +104 -0
  212. data/plugins/rigor-alba/lib/rigor/plugin/alba.rb +85 -0
  213. data/plugins/rigor-alba/lib/rigor-alba.rb +3 -0
  214. data/plugins/rigor-alba/sig/alba.rbs +4 -0
  215. data/plugins/rigor-ffi/lib/rigor/plugin/ffi/analyzer.rb +2 -2
  216. data/plugins/rigor-ffi/lib/rigor/plugin/ffi/target_detector.rb +29 -12
  217. data/plugins/rigor-ffi/lib/rigor/plugin/ffi.rb +13 -2
  218. data/plugins/rigor-minitest/lib/rigor/plugin/minitest/assertion_analyzer.rb +130 -21
  219. data/plugins/rigor-minitest/lib/rigor/plugin/minitest.rb +6 -4
  220. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/analyzer.rb +4 -4
  221. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/analyzer.rb +1 -1
  222. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/routes_parser.rb +1 -1
  223. data/plugins/rigor-rbs-inline/lib/rigor/plugin/rbs_inline.rb +31 -0
  224. data/plugins/rigor-typelizer/lib/rigor/plugin/typelizer/serializer_collector.rb +102 -0
  225. data/plugins/rigor-typelizer/lib/rigor/plugin/typelizer/serializer_discoverer.rb +87 -0
  226. data/plugins/rigor-typelizer/lib/rigor/plugin/typelizer/serializer_index.rb +76 -0
  227. data/plugins/rigor-typelizer/lib/rigor/plugin/typelizer.rb +73 -0
  228. data/plugins/rigor-typelizer/lib/rigor-typelizer.rb +3 -0
  229. data/sig/rigor/analysis/dependency_source_inference/index.rbs +1 -1
  230. data/sig/rigor/environment.rbs +2 -0
  231. data/sig/rigor/plugin/io_boundary.rbs +4 -0
  232. data/sig/rigor/scope.rbs +69 -4
  233. data/sig/rigor/source.rbs +4 -0
  234. data/sig/rigor.rbs +3 -1
  235. data/skills/rigor-project-init/SKILL.md +37 -11
  236. data/skills/rigor-project-init/references/02-configure.md +7 -2
  237. metadata +90 -1
@@ -0,0 +1,291 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "securerandom"
5
+
6
+ require_relative "../incremental_run_slot"
7
+ require_relative "../dependency_source_inference/gem_resolver"
8
+ require_relative "../../cache/file_digest"
9
+ require_relative "../../cache/incremental_snapshot"
10
+
11
+ module Rigor
12
+ module Analysis
13
+ module IncrementalRunSlot
14
+ # ADR-45 WD2 (#1507) — whether a run may record its answer at all. Some of a slot's rows are built when the
15
+ # run ENDS rather than when it reads, and each of those must still describe the tree the run analysed: a
16
+ # save that lands while the run reads — an editor's, a `bundle install`'s, a `mkdir` — would otherwise leave
17
+ # the slot vouching for a tree the analysis never saw, and the probe would serve the pre-save answer against
18
+ # the post-save tree.
19
+ #
20
+ # So the session takes a mark before the run reads anything ({.start}) and asks {#admits?} about those rows
21
+ # before it writes. A row recorded as the run read (a plugin's read, a listing it took) needs no guard: a
22
+ # later save leaves it stale, and validation says so.
23
+ #
24
+ # The mark is the filesystem's own clock, read off the change time of a file written for the purpose: a
25
+ # timestamp compared across clocks misses an edit that lands inside a coarse filesystem's tick, and change
26
+ # times (unlike modification times) cannot be set back by `cp -p` or `touch -d`. One clock means one
27
+ # filesystem, so the mark is taken only when the store is on the project's filesystem, and a row the key
28
+ # does not pin refuses the write when it is on any other. Declining to write is always safe; it leaves the
29
+ # next null run on the full path.
30
+ #
31
+ # A filesystem's change times tick coarsely (a millisecond or more on ext4 and tmpfs), so a file saved just
32
+ # before the stamp can carry the stamp's own change time. {#admits?} refuses a change time at or after the
33
+ # mark, since a save after the stamp in the same tick reads the same; taking the first stamp as the mark
34
+ # would therefore refuse every save that landed shortly before the run, and no slot would be written. So the
35
+ # mark is taken on a tick boundary: stamps are written until one carries a later change time than the first,
36
+ # and that later change time is the mark. Everything saved before the run started carries a change time no
37
+ # later than the first stamp, hence before the mark; everything saved after the mark carries at least the
38
+ # mark. The wait is bounded ({TICK_WAIT_LIMIT}); a filesystem that does not tick within it takes no mark,
39
+ # which is the safe answer. A filesystem whose first stamp lands on a whole second (HFS+, FAT, ext3) ticks too
40
+ # coarsely to wait out and takes no mark at once.
41
+ #
42
+ # Where the change time is not the time of the write, the mark says nothing, and the guard takes none or
43
+ # cannot tell: native Windows, where a change time is the creation time, takes no mark; a network or FUSE
44
+ # filesystem (NFS with its attribute cache, virtiofs, sshfs) may report a change time from before a save,
45
+ # which nothing here detects. A clock that steps backwards (an NTP correction) inside the run would date a
46
+ # save before the mark, so {#admits?} takes one more stamp and refuses when it reads earlier than the mark.
47
+ # A step undone before the run ends is not seen.
48
+ class WriteGuard
49
+ STAMP_DIR = "incremental"
50
+ private_constant :STAMP_DIR
51
+
52
+ # How long to wait, in seconds, for the filesystem's change time to tick past the first stamp.
53
+ TICK_WAIT_LIMIT = 0.05
54
+ # The pause between stamps.
55
+ TICK_RETRY_DELAY = 0.0005
56
+ private_constant :TICK_RETRY_DELAY
57
+
58
+ # Takes the mark, or nil when the slot must not be written: no stamp can be written, it is not on the
59
+ # project's filesystem, or the snapshot fingerprint the run was given no longer describes the tree.
60
+ #
61
+ # The fingerprint is recomputed AFTER the stamp: the caller computed it earlier, and a lockfile or a
62
+ # signature file under the configured or auto-detected `sig/` roots, or a `pre_eval:` file, changed in
63
+ # between would leave the snapshot the run restores keyed by one tree
64
+ # and the run reading another. From the stamp on, the lockfiles are the guard's to watch ({#admits?}), which
65
+ # is also what lets the slot's key, whose only file inputs they are, be computed when the run ends. The
66
+ # opted-in gems' resolved versions, the key's one input that is neither a file nor the configuration (issue
67
+ # #1814), are read at the mark and watched the same way.
68
+ #
69
+ # The mark also records which of the paths an existence row can name are present: the lockfiles, the
70
+ # analysis roots, the `pre_eval:` entries and the signature roots.
71
+ def self.start(configuration:, roots:, cache_root:, fingerprint:)
72
+ return nil if Gem.win_platform?
73
+
74
+ started_ns, device = stamp(cache_root)
75
+ return nil if started_ns.nil? || device_of(Dir.pwd) != device
76
+ return nil unless Cache::IncrementalSnapshot.fingerprint(configuration: configuration, roots: roots) ==
77
+ fingerprint
78
+
79
+ lockfiles = lockfile_paths(configuration)
80
+ named = Array(roots) + configuration.pre_eval + (configuration.signature_paths || ["sig"])
81
+ presence = (lockfiles + named.map { |path| File.absolute_path(path.to_s) }).to_h do |path|
82
+ [path, File.exist?(path)]
83
+ end
84
+ rows = dependency_rows(configuration)
85
+ return nil if rows.nil?
86
+
87
+ new(started_ns: started_ns, device: device, lockfiles: lockfiles, presence: presence,
88
+ stamp_dir: stamp_dir(cache_root), configuration: configuration, dependency_rows: rows)
89
+ end
90
+
91
+ # Issue #1814 — the opted-in gems' `(name, version, mode)` rows as the key carries them; nil when they cannot
92
+ # be resolved, which takes no mark (the key could not be computed either).
93
+ def self.dependency_rows(configuration)
94
+ DependencySourceInference::GemResolver.dependency_entries(configuration.dependencies)
95
+ rescue StandardError
96
+ nil
97
+ end
98
+ private_class_method :dependency_rows
99
+
100
+ # The mark: the change time and device of the first stamp whose change time is later than that of a
101
+ # stamp taken before it, on the same device; nil when none is written within {TICK_WAIT_LIMIT}.
102
+ def self.stamp(cache_root)
103
+ dir = stamp_dir(cache_root)
104
+ FileUtils.mkdir_p(dir)
105
+ first_ns, device = write_stamp(dir)
106
+ # A change time on a whole second is a filesystem that ticks by the second: waiting for it would burn the
107
+ # whole bound. One that ticks finer lands there once in a billion stamps, and declining is safe.
108
+ return nil if first_ns.nil? || (first_ns % 1_000_000_000).zero?
109
+
110
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + TICK_WAIT_LIMIT
111
+ loop do
112
+ ns, dev = write_stamp(dir)
113
+ return nil if ns.nil? || dev != device
114
+ return [ns, device] if ns > first_ns
115
+ return nil if Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
116
+
117
+ sleep(TICK_RETRY_DELAY)
118
+ end
119
+ rescue SystemCallError, IOError
120
+ nil
121
+ end
122
+ private_class_method :stamp
123
+
124
+ def self.stamp_dir(cache_root)
125
+ File.join(cache_root.to_s, STAMP_DIR)
126
+ end
127
+ private_class_method :stamp_dir
128
+
129
+ # One stamp's change time and device; the file is removed again.
130
+ def self.write_stamp(dir)
131
+ path = File.join(dir, "run-#{Process.pid}-#{SecureRandom.hex(4)}.stamp")
132
+ File.write(path, "")
133
+ stat = File.stat(path)
134
+ [Cache::FileDigest.ns_of(stat.ctime), stat.dev]
135
+ rescue SystemCallError, IOError
136
+ nil
137
+ ensure
138
+ FileUtils.rm_f(path) if path
139
+ end
140
+ private_class_method :write_stamp
141
+
142
+ def self.device_of(path)
143
+ File.stat(path).dev
144
+ rescue SystemCallError
145
+ nil
146
+ end
147
+ private_class_method :device_of
148
+
149
+ # Every lockfile the key or the snapshot fingerprint may digest, present or not: a lockfile that appears
150
+ # while the run reads moves the key as surely as one rewritten, and one rewritten and restored digests as it
151
+ # did while the run may have read the other bytes.
152
+ def self.lockfile_paths(configuration)
153
+ configured = [
154
+ [configuration.bundler_lockfile, Environment::LockfileResolver],
155
+ [configuration.rbs_collection_lockfile, Environment::RbsCollectionDiscovery]
156
+ ].filter_map { |path, resolver| resolver.configured_lockfile_path(path) if path }
157
+ (configured + %w[Gemfile.lock rbs_collection.lock.yaml].map { |path| File.absolute_path(path) }).uniq
158
+ end
159
+ private_class_method :lockfile_paths
160
+
161
+ def initialize(started_ns:, device:, lockfiles:, presence: {}, stamp_dir: nil, configuration: nil,
162
+ dependency_rows: [])
163
+ @stamp_dir = stamp_dir
164
+ @started_ns = started_ns
165
+ @device = device
166
+ @lockfiles = lockfiles
167
+ @presence = presence
168
+ @configuration = configuration
169
+ @dependency_rows = dependency_rows
170
+ end
171
+
172
+ # True when nothing the guard watches moved since the mark:
173
+ #
174
+ # - no lockfile the key or the fingerprint digests was written, created or removed, and no opted-in gem
175
+ # resolves to another version than at the mark (issue #1814);
176
+ # - no file `rows` or `pinned` names changed. A content row's file changed if its change time moved. An
177
+ # existence row asks only whether its path is there, so a path the mark recorded changed if it came or
178
+ # went, and an editor's lock file created and removed beside it, which moves its directory's change
179
+ # time, does not count. That needs no clock, so it holds on any filesystem; it cannot see a path that
180
+ # comes and goes again within the run. Any other existence row falls back to change times: its own when
181
+ # present, its nearest existing ancestor's when absent;
182
+ # - no directory a glob row lists gained or lost an entry, nor, for a stat-mode glob, did any file it
183
+ # matches change.
184
+ #
185
+ # A path on another filesystem than the mark refuses the write when it is one of `rows`, and is passed
186
+ # over when it is one of `pinned`: the rows the key pins (the engine's own signature files, a gem's
187
+ # signatures, which the engine slot and the lockfiles identify) live wherever the engine and the gems are
188
+ # installed — a container image's layer, a Nix store — and refusing them would turn the fast path off for
189
+ # every such installation. On the mark's filesystem they are checked like any other row.
190
+ #
191
+ # A content row whose file is gone passes: the row is stale already, and validation says so.
192
+ #
193
+ # @param rows — a {Cache::Descriptor} of the rows the key does not pin
194
+ # @param pinned — a {Cache::Descriptor} of the rows it does
195
+ def admits?(rows, pinned: Cache::Descriptor.new)
196
+ !clock_stepped_back? && @lockfiles.none? { |path| lockfile_changed?(path) } && dependency_rows_unchanged? &&
197
+ files_unchanged?(rows.files, strict: true) && globs_unchanged?(rows.globs, strict: true) &&
198
+ files_unchanged?(pinned.files, strict: false) && globs_unchanged?(pinned.globs, strict: false)
199
+ rescue StandardError
200
+ false
201
+ end
202
+
203
+ # `descriptor` with every `:stat` row's recording instant no later than the mark. A row recorded as the run
204
+ # read carries the instant the run began, and validation trusts a stat tuple whose mtime is before it. On a
205
+ # coarse clock a save that lands after the read, in the tick the file's mtime already has and with the same
206
+ # size, leaves the tuple unmoved, so it would be trusted. With the instant at the mark, every row whose mtime is
207
+ # at or after the mark is racy, and validation re-hashes it.
208
+ def mark_racy(descriptor)
209
+ Cache::Descriptor.new(
210
+ files: descriptor.files.map { |entry| entry.with_recording_instant_at_most(@started_ns) },
211
+ gems: descriptor.gems, plugins: descriptor.plugins, configs: descriptor.configs,
212
+ dependencies: descriptor.dependencies, globs: descriptor.globs
213
+ )
214
+ end
215
+
216
+ private
217
+
218
+ # Issue #1814 — the opted-in gems resolve to the versions they did at the mark. A guard built without a
219
+ # configuration (the specs' bare constructor) watches none.
220
+ def dependency_rows_unchanged?
221
+ @configuration.nil? || self.class.send(:dependency_rows, @configuration) == @dependency_rows
222
+ end
223
+
224
+ # One more stamp: a change time earlier than the mark means the clock stepped back since it was taken, so
225
+ # a save since then may carry a change time the guard reads as before it. A stamp that cannot be written,
226
+ # or lands on another device, cannot vouch for the clock either.
227
+ def clock_stepped_back?
228
+ return false if @stamp_dir.nil?
229
+
230
+ ns, device = self.class.__send__(:write_stamp, @stamp_dir)
231
+ ns.nil? || device != @device || ns < @started_ns
232
+ end
233
+
234
+ # An existence row and a content row for one path are different questions (the first asks only whether it
235
+ # is there, the second whether it changed), so the row that comes first must not hide the other.
236
+ def files_unchanged?(entries, strict:)
237
+ seen = Set.new
238
+ entries.none? do |entry|
239
+ next false unless seen.add?([entry.path, entry.comparator == :exists])
240
+
241
+ if entry.comparator == :exists
242
+ existence_changed?(entry.path, strict: strict)
243
+ else
244
+ changed?(entry.path, strict: strict)
245
+ end
246
+ end
247
+ end
248
+
249
+ def globs_unchanged?(entries, strict:)
250
+ entries.none? do |entry|
251
+ listed = Dir.glob(File.join(entry.root, File.dirname(entry.pattern), ""))
252
+ matched = entry.mode == :stat ? Dir.glob(File.join(entry.root, entry.pattern)) : []
253
+ (listed + matched).any? { |path| changed?(path, strict: strict) }
254
+ end
255
+ end
256
+
257
+ # A lockfile is read for its bytes: one absent at the mark must still be absent, one present must still be
258
+ # there with its change time before the mark.
259
+ def lockfile_changed?(path)
260
+ return File.exist?(path) unless @presence[path]
261
+
262
+ changed?(path, strict: true, missing: true)
263
+ end
264
+
265
+ def existence_changed?(path, strict:)
266
+ absolute = File.absolute_path(path)
267
+ return File.exist?(absolute) != @presence[absolute] if @presence.key?(absolute)
268
+
269
+ nearest = absolute
270
+ nearest = File.dirname(nearest) until File.exist?(nearest) || File.dirname(nearest) == nearest
271
+ moved?(File.stat(nearest), strict: strict)
272
+ rescue SystemCallError
273
+ true
274
+ end
275
+
276
+ def changed?(path, strict:, missing: false)
277
+ moved?(File.stat(path), strict: strict)
278
+ rescue SystemCallError
279
+ missing
280
+ end
281
+
282
+ # A change time is comparable with the mark only on the stamp's filesystem.
283
+ def moved?(stat, strict:)
284
+ return strict if stat.dev != @device
285
+
286
+ Cache::FileDigest.ns_of(stat.ctime) >= @started_ns
287
+ end
288
+ end
289
+ end
290
+ end
291
+ end
@@ -0,0 +1,173 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "diagnostic"
4
+ require_relative "path_expansion"
5
+ require_relative "result"
6
+ require_relative "run_cache_key"
7
+ require_relative "../cache/descriptor"
8
+ require_relative "../cache/file_digest"
9
+ require_relative "../cache/store"
10
+
11
+ module Rigor
12
+ module Analysis
13
+ # ADR-45 WD2 (#1507) — the run-result slot `rigor check --incremental` writes, and the engine-free probe that
14
+ # serves a null run from it. The writer is {IncrementalSession#run_incremental}; the reader is the CLI, before
15
+ # it loads the inference engine. Both go through this module, so the key, the producer id and the stored shape
16
+ # have one definition.
17
+ #
18
+ # It is ADR-45's record-and-validate slot with a second writer. The value is the diagnostics the session's run
19
+ # printed (before the baseline filter, which the CLI applies to every answer), and the dependency descriptor
20
+ # validates everything that answer was computed from:
21
+ #
22
+ # - one `:stat` row per analysed file, carrying the digest the SESSION holds for it — the bytes its cached rows
23
+ # were computed from — rather than a re-digest taken after the run;
24
+ # - the rows the run re-read itself ({Runner#incremental_slot_rows}' `observed` and `derived` rows);
25
+ # - the chain the {Entry} carries forward: the rows the last full run recorded for the inputs the incremental
26
+ # path does not re-derive on a recheck, and the plugin reads credited to each file's analysis
27
+ # ({IncrementalSession#write_run_slot}).
28
+ #
29
+ # A hit is at least as careful as the full incremental path — where that path would notice a change, a row
30
+ # notices it first — but it is not a cold run: a stale answer that path serves for a read reaching a file
31
+ # through a plugin's memo (#1553) the probe may serve too. ADR-45 WD2 states the guarantee.
32
+ #
33
+ # Kept apart from the plain run's `analysis.run-diagnostics` by its own producer id and by the roots entry its
34
+ # key adds, either enough alone, so neither probe can ever read the other's entry, and an incremental defect
35
+ # cannot leak into a default run.
36
+ #
37
+ # The key is the one {RunCacheProbe} reconstructs from configuration alone — the library list without a
38
+ # `rbs.virtual_rbs` slot, and no `template-units` slot — plus the analysis roots ({Target}). Neither left-out
39
+ # slot is needed. A virtual RBS buffer is a function of an analysed file's bytes (a row) and of the
40
+ # synthesising plugin's identity and configuration (the key's `configuration`, lockfile and engine slots). A
41
+ # template unit is a function of its template's bytes and of what its transform read (rows) and of the
42
+ # plugin's identity and the engine's (key slots), so a project whose plugins claim template globs is served
43
+ # too. The ADR-87 probe misses on one: the plain slot's key carries the `template-units` slot.
44
+ #
45
+ # `explain:` is always false: the session's runners never set it (#1533), and the CLI declines the probe under
46
+ # `--explain`.
47
+ module IncrementalRunSlot
48
+ module_function
49
+
50
+ PRODUCER_ID = "analysis.incremental-run-diagnostics"
51
+
52
+ # One live generation per analysed-path set, as for the plain slot. A writer that moves the path set discards
53
+ # the entry it carried from (see {IncrementalSession}), so the churn a file addition makes is bounded without
54
+ # a compaction pass — the incremental path never runs one.
55
+ GENERATION_CAP = RunCacheKey::GENERATION_CAP
56
+
57
+ # The stored value. `baseline` and `pinned` (each a {Cache::Descriptor}) and `reads` (`{path =>
58
+ # Cache::Descriptor}`) are the chain the next writer carries forward, kept apart from the rest of the
59
+ # dependency descriptor so it knows which rows are which; their entries are the same objects the descriptor
60
+ # holds, so `Marshal` writes them once. `baseline` and `pinned` hold the rows the last full run recorded for
61
+ # inputs the incremental path does not re-derive on a recheck (the signature tree, the
62
+ # discovered-not-analysed files, the `pre_eval:` files outside the analysed set), `pinned` the signature
63
+ # files the key identifies already ({Runner::BaselineRows}); `reads` the plugin reads credited to each
64
+ # analysed file. `snapshot` is the identity of
65
+ # the incremental snapshot file the writing run left behind, which the next writer compares against the one
66
+ # it restored before trusting the chain. `roots` are the analysis roots as the run was given them
67
+ # ({.as_written}): the key holds them as a set of paths, and the probe serves the entry only to a run that
68
+ # names them the same way, in the same order.
69
+ Entry = Data.define(:diagnostics, :roots, :baseline, :pinned, :reads, :snapshot)
70
+
71
+ # What a slot is keyed by: the analysed-path set and the analysis roots the run was given. The roots are
72
+ # not implied by the files — `rigor check --incremental lib extra` with `extra` missing analyses the same
73
+ # files as `rigor check --incremental lib`, and only the first reports `extra` as missing.
74
+ Target = Data.define(:files, :roots)
75
+
76
+ # What a probe hit hands the CLI: the answer, and the analysed-file count its banner reports.
77
+ Hit = Data.define(:result, :file_count)
78
+
79
+ def key(configuration:, target:)
80
+ base = RunCacheKey.descriptor(
81
+ configuration: configuration, files: target.files, explain: false,
82
+ rbs_config_entries: RunCacheKey.libraries_config_entries(configuration)
83
+ )
84
+ return nil if base.nil?
85
+
86
+ roots = RunCacheKey.config_entry("incremental.roots", normalize_roots(target.roots).sort.join("\n"))
87
+ # Issue #1814 — the opted-in gems' `(name, version, mode)` rows travel with the key, as they do in the plain
88
+ # probe's: the gem's walked source reaches every file's answer and no analysed file records it.
89
+ Cache::Descriptor.new(gems: base.gems, dependencies: base.dependencies, configs: base.configs + [roots])
90
+ end
91
+
92
+ # The roots as absolute paths, which the KEY holds sorted: `lib`, `./lib` and `lib/` are one root there. A
93
+ # run that reorders its roots still finds the previous slot's chain (the snapshot fingerprint sorts them
94
+ # too), and one that respells them replaces the slot rather than adding one beside it.
95
+ def normalize_roots(roots)
96
+ Array(roots).map { |root| File.absolute_path(root.to_s) }
97
+ end
98
+
99
+ # The roots as the run was given them, which {Entry#roots} holds and {.serve} compares. A missing root is
100
+ # reported as written (`./extra: no such file or directory`), and `a b` lists `a`'s files before `b`'s, so
101
+ # neither a respelling nor a reordering may be served another's answer.
102
+ def as_written(roots)
103
+ Array(roots).map(&:to_s)
104
+ end
105
+
106
+ # The engine-free probe. Returns a {Hit} when the slot for this run's analysed-path set validates, or nil to
107
+ # decline: a miss, a stale row, an unreadable entry, a project with effect collection on (see below), or any
108
+ # failure at all — declining hands the run to the full path, which is always safe.
109
+ #
110
+ # Effect collection is declined here and never written by the session. Its configuration (`effects:`) is
111
+ # deliberately absent from `Configuration#to_h` and so from the key, and two of its rows are judged each run
112
+ # from state no engine-free path can rebuild (the plain probe's `envelope_lane_live?`, #428). With it off,
113
+ # the only effect row a run can print is `effect.annotations-unchecked`, which is a function of the
114
+ # signature tree and the analysed files' annotations — both validated rows — so it is served as stored.
115
+ def serve(configuration:, cache_root:, paths:)
116
+ return nil if configuration.effects_enabled?
117
+
118
+ files = PathExpansion.ruby_files(paths, configuration.exclude_patterns)
119
+ slot_key = key(configuration: configuration, target: Target.new(files: files, roots: paths))
120
+ return nil if slot_key.nil?
121
+
122
+ entry = validated_entry(configuration, cache_root, slot_key)
123
+ return nil unless entry.is_a?(Entry) && entry.diagnostics.is_a?(Array)
124
+ return nil unless entry.roots == as_written(paths)
125
+
126
+ Hit.new(result: Result.new(diagnostics: entry.diagnostics, stats: nil), file_count: files.size)
127
+ rescue StandardError
128
+ nil
129
+ end
130
+
131
+ # The previous slot's {Entry}, read WITHOUT validating it: it is stale by construction, since the run that
132
+ # is asking changed something. nil when there is no such slot, or it is not an {Entry} — the writer then
133
+ # skips its own write rather than guess what the files it served from cache depended on.
134
+ def previous_entry(store:, configuration:, target:)
135
+ slot_key = key(configuration: configuration, target: target)
136
+ return nil if slot_key.nil?
137
+
138
+ entry = store.peek_unvalidated(producer_id: PRODUCER_ID, key_descriptor: slot_key)
139
+ return nil unless entry.is_a?(Entry) && entry.reads.is_a?(Hash)
140
+ return nil unless entry.baseline.is_a?(Cache::Descriptor) && entry.pinned.is_a?(Cache::Descriptor)
141
+
142
+ entry
143
+ end
144
+
145
+ # Writes `entry` as the slot for `target`, validated by `dependencies` (the whole descriptor, of which the
146
+ # entry's chain is a part). Returns whether it wrote.
147
+ def write(store:, configuration:, target:, entry:, dependencies:)
148
+ slot_key = key(configuration: configuration, target: target)
149
+ return false if slot_key.nil?
150
+
151
+ store.store_validated(
152
+ producer_id: PRODUCER_ID, key_descriptor: slot_key, generation_cap: GENERATION_CAP,
153
+ value: entry, dependencies: dependencies
154
+ )
155
+ end
156
+
157
+ def discard(store:, configuration:, target:)
158
+ slot_key = key(configuration: configuration, target: target)
159
+ slot_key && store.discard(producer_id: PRODUCER_ID, key_descriptor: slot_key)
160
+ end
161
+
162
+ # Validation runs in its own per-run digest scope, with the configuration's `cache.validation` mode, exactly
163
+ # as the plain probe's does.
164
+ def validated_entry(configuration, cache_root, slot_key)
165
+ store = Cache::Store.new(root: cache_root, max_bytes: configuration.cache_max_bytes)
166
+ Cache::FileDigest.with_run(strict: configuration.cache_validation_strict?) do
167
+ store.peek_validated(producer_id: PRODUCER_ID, key_descriptor: slot_key)
168
+ end
169
+ end
170
+ private_class_method :validated_entry
171
+ end
172
+ end
173
+ end