rigortype 0.3.1 → 0.3.3
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 +4 -4
- data/README.md +10 -10
- data/data/core_overlay/pathname.rbs +5 -0
- data/data/core_overlay/resolv.rbs +31 -0
- data/data/core_overlay/string_scanner.rbs +5 -0
- data/data/gem_overlay/activesupport/core_ext.rbs +10 -6
- data/data/vendored_gem_sigs/bundler/bundler.rbs +39 -27
- data/data/vendored_gem_sigs/cgi/cgi_extras.rbs +6 -0
- data/data/vendored_gem_sigs/prism/prism_supplement.rbs +7 -0
- data/data/vendored_gem_sigs/racc/racc.rbs +67 -0
- data/data/vendored_gem_sigs/rubygems/rubygems_extras.rbs +20 -9
- data/docs/manual/02-cli-reference.md +71 -11
- data/docs/manual/03-configuration.md +16 -1
- data/docs/manual/04-diagnostics.md +8 -0
- data/docs/manual/12-caching.md +17 -5
- data/docs/manual/15-type-protection-coverage.md +122 -1
- data/docs/manual/plugins/rigor-rbs-inline.md +22 -0
- data/lib/rigor/analysis/check_rules/always_truthy_condition_collector.rb +8 -0
- data/lib/rigor/analysis/check_rules/declaration_sourced_guard.rb +53 -0
- data/lib/rigor/analysis/check_rules/rule_ids.rb +1 -0
- data/lib/rigor/analysis/check_rules/rule_walk.rb +7 -0
- data/lib/rigor/analysis/check_rules.rb +107 -28
- data/lib/rigor/analysis/incremental_session.rb +156 -11
- data/lib/rigor/analysis/plugin_fact_fingerprint.rb +14 -0
- data/lib/rigor/analysis/rule_catalog.rb +5 -1
- data/lib/rigor/analysis/run_cache_key.rb +17 -1
- data/lib/rigor/analysis/runner/buffer_pool_dispatcher.rb +201 -0
- data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +28 -10
- data/lib/rigor/analysis/runner/pool_coordinator.rb +7 -10
- data/lib/rigor/analysis/runner/project_pre_passes.rb +3 -1
- data/lib/rigor/analysis/runner.rb +78 -7
- data/lib/rigor/analysis/severity_stamp.rb +2 -1
- data/lib/rigor/bleeding_edge.rb +132 -7
- data/lib/rigor/cache/descriptor.rb +6 -1
- data/lib/rigor/cache/engine_source.rb +162 -0
- data/lib/rigor/cache/incremental_snapshot.rb +87 -18
- data/lib/rigor/cli/check_command.rb +49 -28
- data/lib/rigor/cli/coverage_command.rb +15 -2
- data/lib/rigor/cli/coverage_mutation.rb +242 -11
- data/lib/rigor/cli/fused_protection_renderer.rb +10 -0
- data/lib/rigor/cli/fused_protection_report.rb +12 -3
- data/lib/rigor/cli/mutation_fork_scan.rb +64 -0
- data/lib/rigor/cli/mutation_protection_renderer.rb +12 -0
- data/lib/rigor/cli/mutation_protection_report.rb +26 -5
- data/lib/rigor/cli/protection_renderer.rb +13 -0
- data/lib/rigor/cli/protection_report.rb +11 -3
- data/lib/rigor/cli/show_bleedingedge_command.rb +17 -4
- data/lib/rigor/cli/sig_gen_command.rb +5 -4
- data/lib/rigor/configuration.rb +45 -6
- data/lib/rigor/environment/bundle_sig_discovery.rb +2 -1
- data/lib/rigor/environment/rbs_coverage_report.rb +1 -1
- data/lib/rigor/environment/rbs_loader.rb +434 -75
- data/lib/rigor/environment.rb +28 -13
- data/lib/rigor/inference/anonymous_meta_class.rb +81 -0
- data/lib/rigor/inference/expression_typer.rb +62 -4
- data/lib/rigor/inference/fork_map.rb +47 -4
- data/lib/rigor/inference/method_dispatcher/constant_folding.rb +50 -2
- data/lib/rigor/inference/method_dispatcher/data_folding.rb +1 -1
- data/lib/rigor/inference/method_dispatcher/member_shape_projection.rb +35 -1
- data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +158 -12
- data/lib/rigor/inference/method_dispatcher/regexp_folding.rb +64 -5
- data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +234 -11
- data/lib/rigor/inference/method_dispatcher/struct_folding.rb +3 -2
- data/lib/rigor/inference/method_dispatcher/uri_folding.rb +124 -1
- data/lib/rigor/inference/method_dispatcher.rb +41 -10
- data/lib/rigor/inference/mutation_widening.rb +30 -25
- data/lib/rigor/inference/narrowing.rb +3 -1
- data/lib/rigor/inference/optimistic_origin.rb +123 -0
- data/lib/rigor/inference/protection_scanner.rb +18 -2
- data/lib/rigor/inference/receiver_alias.rb +57 -0
- data/lib/rigor/inference/scope_indexer.rb +197 -55
- data/lib/rigor/inference/singleton_object_constant.rb +63 -0
- data/lib/rigor/inference/statement_evaluator.rb +55 -4
- data/lib/rigor/language_server/buffer_table.rb +22 -0
- data/lib/rigor/language_server/diagnostic_publisher.rb +203 -4
- data/lib/rigor/language_server/project_context.rb +45 -0
- data/lib/rigor/language_server/publish_batcher.rb +82 -0
- data/lib/rigor/language_server/server.rb +20 -1
- data/lib/rigor/language_server.rb +1 -0
- data/lib/rigor/plugin/node_rule_walk.rb +6 -0
- data/lib/rigor/plugin/source_rbs_synthesis_reporter.rb +9 -3
- data/lib/rigor/protection/closure_kill_oracle.rb +190 -0
- data/lib/rigor/protection/dependency_closure.rb +59 -0
- data/lib/rigor/protection/diagnostic_oracle.rb +12 -8
- data/lib/rigor/protection/discovery_seed.rb +116 -0
- data/lib/rigor/protection/kill_signature.rb +31 -0
- data/lib/rigor/protection/mutation_cache.rb +355 -0
- data/lib/rigor/protection/mutation_scanner.rb +55 -11
- data/lib/rigor/protection/mutator.rb +26 -4
- data/lib/rigor/runtime/jit.rb +63 -1
- data/lib/rigor/scope/discovery_index.rb +9 -0
- data/lib/rigor/scope.rb +114 -10
- data/lib/rigor/sig_gen/generator.rb +130 -31
- data/lib/rigor/sig_gen/layout_index.rb +6 -0
- data/lib/rigor/sig_gen/meta_class_shape.rb +84 -0
- data/lib/rigor/sig_gen/renderer.rb +20 -1
- data/lib/rigor/sig_gen/write_result.rb +6 -4
- data/lib/rigor/sig_gen/writer.rb +12 -0
- data/lib/rigor/sig_gen.rb +1 -0
- data/lib/rigor/source/node_walker.rb +12 -0
- data/lib/rigor/type/anonymous_class_name.rb +40 -0
- data/lib/rigor/type/nominal.rb +5 -2
- data/lib/rigor/type/singleton.rb +4 -1
- data/lib/rigor/version.rb +1 -1
- data/plugins/rigor-activesupport-core-ext/sig/active_support/core_ext.rbs +6 -6
- data/plugins/rigor-dry-schema/lib/rigor/plugin/dry_schema/result_shape.rb +146 -0
- data/plugins/rigor-dry-schema/lib/rigor/plugin/dry_schema/schema_scanner.rb +177 -12
- data/plugins/rigor-dry-schema/lib/rigor/plugin/dry_schema.rb +74 -7
- data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation/contract_scanner.rb +257 -0
- data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation/params_shape.rb +69 -0
- data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation.rb +87 -11
- data/plugins/rigor-rbs-inline/lib/rigor/plugin/rbs_inline.rb +34 -4
- data/sig/rigor/inference/void_origin.rbs +6 -3
- data/sig/rigor/scope.rbs +12 -0
- data/sig/rigor.rbs +4 -0
- metadata +21 -1
|
@@ -389,16 +389,13 @@ module Rigor
|
|
|
389
389
|
# copy-on-write-inherited session and writes the Marshal'd payload to `out_path`. `exit!` skips
|
|
390
390
|
# `at_exit` / stdio flush — the payload is already durable on disk by then.
|
|
391
391
|
def run_fork_worker(session, slice, out_path)
|
|
392
|
-
# Re-arm deferred YJIT in the child
|
|
393
|
-
#
|
|
394
|
-
#
|
|
395
|
-
#
|
|
396
|
-
#
|
|
397
|
-
#
|
|
398
|
-
|
|
399
|
-
# projects, so a worker slice reliably outlasts the deadline; a small-project worker finishes first and
|
|
400
|
-
# stays at no-YJIT parity, exactly as the sequential path would.
|
|
401
|
-
Runtime::Jit.enable_after(Runtime::Jit.deadline_seconds)
|
|
392
|
+
# Re-arm deferred YJIT in the child: the parent's deadline thread does not survive `fork`, so a worker
|
|
393
|
+
# forked before the deadline fires would otherwise run its whole (dominant) analysis slice un-JITted
|
|
394
|
+
# no matter how long it takes. {Runtime::Jit.rearm_after_fork} carries what is *left* of the parent's
|
|
395
|
+
# window, so the run's amortization deadline stays one deadline rather than one per worker. Parallel
|
|
396
|
+
# mode is opted into for large projects, so a slice reliably outlasts the remainder; a small-project
|
|
397
|
+
# worker finishes first and stays at no-YJIT parity, exactly as the sequential path would.
|
|
398
|
+
Runtime::Jit.rearm_after_fork
|
|
402
399
|
results = slice.to_h { |path| [path, session.analyze(path)] }
|
|
403
400
|
payload = { results: results, reporters: session.drain_reporters,
|
|
404
401
|
dependencies: session.drain_dependencies }
|
|
@@ -186,7 +186,9 @@ module Rigor
|
|
|
186
186
|
# Translates a prebuilt {ProjectScan} snapshot supplied to `Runner.new(prebuilt: ...)` into a
|
|
187
187
|
# {Result} the runner adopts the same way it adopts a fresh pre-pass run. The discovery tables are
|
|
188
188
|
# not part of the snapshot (the LSP path seeds an empty project scope), and `Runner#ensure_project_discovery`
|
|
189
|
-
# is a no-op under `prebuilt`, so they stay at their frozen-empty constructor defaults
|
|
189
|
+
# is a no-op under `prebuilt`, so they stay at their frozen-empty constructor defaults — unless the
|
|
190
|
+
# caller opted into `Runner.new(discovery_seed:)` (issue #260), which seeds the per-file scopes
|
|
191
|
+
# directly and leaves these ivars alone.
|
|
190
192
|
def adopt_prebuilt(scan)
|
|
191
193
|
Result.new(
|
|
192
194
|
plugin_registry: scan.plugin_registry,
|
|
@@ -40,6 +40,7 @@ require_relative "runner/run_snapshots"
|
|
|
40
40
|
require_relative "runner/project_pre_passes"
|
|
41
41
|
require_relative "runner/pool_coordinator"
|
|
42
42
|
require_relative "runner/diagnostic_aggregator"
|
|
43
|
+
require_relative "runner/buffer_pool_dispatcher"
|
|
43
44
|
|
|
44
45
|
module Rigor
|
|
45
46
|
module Analysis
|
|
@@ -95,12 +96,19 @@ module Rigor
|
|
|
95
96
|
# pass a shared env so per-publish work doesn't repeat the `Environment.for_project` build (bundler
|
|
96
97
|
# / lockfile / collection discovery, RbsLoader construction). Pool mode ignores the override — each
|
|
97
98
|
# worker continues to build its own Environment.
|
|
99
|
+
# @param discovery_seed [Hash, nil] issue #260 — opt-in cross-file discovery tables, keyed by
|
|
100
|
+
# {Scope::DiscoveryIndex} slot name, seeded onto every per-file scope through
|
|
101
|
+
# `project_scope_seed_tables`. The ONE deliberate exception to "a `prebuilt:` runner carries no
|
|
102
|
+
# discovery tables": {Protection::DiagnosticOracle} threads the table set Tier 2's site filter already
|
|
103
|
+
# judges anchors against, so a site admitted because a sibling-file class resolved is also a site the
|
|
104
|
+
# oracle can kill at. nil (the default) leaves the prebuilt/LSP contract byte-identical.
|
|
98
105
|
def initialize(configuration:, explain: false, # rubocop:disable Metrics/ParameterLists,Metrics/AbcSize,Metrics/MethodLength
|
|
99
106
|
cache_store: Cache::Store.new(root: DEFAULT_CACHE_ROOT),
|
|
100
107
|
plugin_requirer: nil, workers: 0, collect_stats: true,
|
|
101
108
|
buffer: nil, prebuilt: nil, environment: nil,
|
|
102
109
|
record_dependencies: false, record_self_calls: false, analyze_only: nil,
|
|
103
|
-
seed_bundles: nil, collect_seed_bundles: false
|
|
110
|
+
seed_bundles: nil, collect_seed_bundles: false, param_inferred_types: nil,
|
|
111
|
+
discovery_seed: nil)
|
|
104
112
|
@configuration = configuration
|
|
105
113
|
@explain = explain
|
|
106
114
|
@cache_store = enforce_read_only_cache(cache_store, buffer)
|
|
@@ -140,6 +148,16 @@ module Rigor
|
|
|
140
148
|
@collect_seed_bundles = collect_seed_bundles
|
|
141
149
|
@restored_seed_bundles = seed_bundles || {}
|
|
142
150
|
@seed_bundles = {}.freeze
|
|
151
|
+
# ADR-67 WD6c lift — a precomputed inferred-param table. When the incremental session already ran the
|
|
152
|
+
# collector (it must, to diff the table against its snapshot BEFORE deciding the re-analyse closure),
|
|
153
|
+
# it hands the result here so `seed_parameter_inference` seeds without a second whole-project collect
|
|
154
|
+
# — and so the table the diff was decided on and the table this run seeds from are the SAME object,
|
|
155
|
+
# not merely an equal recomputation. nil (the default) keeps the runner self-sufficient.
|
|
156
|
+
@param_inferred_types_override = param_inferred_types
|
|
157
|
+
# Issue #260 — the opt-in cross-file discovery seed (see the `discovery_seed:` doc above). Frozen and
|
|
158
|
+
# never mutated; `project_scope_seed_tables` starts from a copy of it and lets any table this run
|
|
159
|
+
# actually computed win.
|
|
160
|
+
@discovery_seed = discovery_seed&.freeze
|
|
143
161
|
@file_dependencies = {}
|
|
144
162
|
@plugin_registry = Plugin::Registry::EMPTY
|
|
145
163
|
@dependency_source_index = DependencySourceInference::Index::EMPTY
|
|
@@ -324,6 +342,32 @@ module Rigor
|
|
|
324
342
|
result.transform_values(&:freeze).freeze
|
|
325
343
|
end
|
|
326
344
|
|
|
345
|
+
# ADR-67 WD6c lift — the inferred-param table this run seeded from (frozen; empty when
|
|
346
|
+
# `parameter_inference:` is off or the pre-pass failed soft). The incremental session reads it back
|
|
347
|
+
# after a baseline so the snapshot records the seeds the cached diagnostics were computed under.
|
|
348
|
+
def param_inferred_types
|
|
349
|
+
@project_param_inferred_types
|
|
350
|
+
end
|
|
351
|
+
|
|
352
|
+
# ADR-67 WD6c lift — computes the whole-project inferred-param table without running an analysis.
|
|
353
|
+
# The incremental session calls this BEFORE deciding its re-analyse closure: the table's diff against
|
|
354
|
+
# the snapshot's stored table is what invalidates a callee whose seeds moved because a *caller* file
|
|
355
|
+
# changed. Same collector invocation as {#seed_parameter_inference} (one round, same workers), so the
|
|
356
|
+
# session-computed table and an in-run collect are byte-identical by construction. Fails soft to the
|
|
357
|
+
# empty table — which the caller's diff then treats as "every stored entry removed", the conservative
|
|
358
|
+
# direction (those callees re-check).
|
|
359
|
+
def collect_param_inference_table(files)
|
|
360
|
+
return {}.freeze unless @configuration.parameter_inference
|
|
361
|
+
|
|
362
|
+
environment = @pool_coordinator.resolve_sequential_environment(source_files: files)
|
|
363
|
+
Inference::ParameterInferenceCollector.collect(
|
|
364
|
+
files: files, environment: environment,
|
|
365
|
+
target_ruby: @configuration.target_ruby, max_rounds: 1, workers: @workers
|
|
366
|
+
)
|
|
367
|
+
rescue StandardError
|
|
368
|
+
{}.freeze
|
|
369
|
+
end
|
|
370
|
+
|
|
327
371
|
# ADR-89 WD2 — the per-method observed-key return summaries the run's ADR-84 return memo just captured.
|
|
328
372
|
# For each project method with live memo entries, a bounded `{ keys:, returns:, effects: }` summary the
|
|
329
373
|
# incremental session persists: `keys` the observed `[receiver, arg_types]` type tuples, `returns` their
|
|
@@ -511,6 +555,11 @@ module Rigor
|
|
|
511
555
|
def seed_parameter_inference(expansion, environment)
|
|
512
556
|
return environment unless @configuration.parameter_inference
|
|
513
557
|
|
|
558
|
+
if @param_inferred_types_override
|
|
559
|
+
@project_param_inferred_types = @param_inferred_types_override
|
|
560
|
+
return environment
|
|
561
|
+
end
|
|
562
|
+
|
|
514
563
|
files = expansion.fetch(:files)
|
|
515
564
|
environment ||= @pool_coordinator.resolve_sequential_environment(source_files: files)
|
|
516
565
|
@project_param_inferred_types = Inference::ParameterInferenceCollector.collect(
|
|
@@ -638,7 +687,9 @@ module Rigor
|
|
|
638
687
|
# surface. The cross-file discovery tables are NOT carried here — `#run` (prebuilt-less) and
|
|
639
688
|
# `adopt_prebuilt` both leave them at their frozen-empty constructor defaults, and the analysis path
|
|
640
689
|
# fills them lazily via {#ensure_project_discovery}. The prebuilt (LSP) path never fills them, matching
|
|
641
|
-
# the original adopt behaviour that seeded an empty project scope
|
|
690
|
+
# the original adopt behaviour that seeded an empty project scope — with ONE deliberate exception, the
|
|
691
|
+
# opt-in `discovery_seed:` constructor seam (issue #260), which bypasses the ivars entirely and rides
|
|
692
|
+
# `project_scope_seed_tables`. A prebuilt runner constructed without it is unchanged.
|
|
642
693
|
def apply_pre_passes_result(result)
|
|
643
694
|
@plugin_registry = result.plugin_registry
|
|
644
695
|
@dependency_source_index = result.dependency_source_index
|
|
@@ -669,7 +720,9 @@ module Rigor
|
|
|
669
720
|
# Internal: builds the deferred cross-file discovery tables at most once per run and adopts them.
|
|
670
721
|
# Memoised on `@project_discovery_done` (reset at the start of `#run`). No-op under `@prebuilt` — the
|
|
671
722
|
# LSP path deliberately seeds an empty project scope from a snapshot that carries no discovery tables,
|
|
672
|
-
# so forcing a build there would change that contract.
|
|
723
|
+
# so forcing a build there would change that contract. A caller that DOES want cross-file knowledge under
|
|
724
|
+
# `prebuilt:` supplies it explicitly through `discovery_seed:` (issue #260) rather than by re-walking the
|
|
725
|
+
# project here. Called eagerly from `#run` for the recording /
|
|
673
726
|
# subset (ADR-46) modes and lazily from `#assemble_run_diagnostics` on the analysis path, so a warm
|
|
674
727
|
# cache HIT (which never assembles) never pays the double parse.
|
|
675
728
|
def ensure_project_discovery(expansion)
|
|
@@ -757,11 +810,18 @@ module Rigor
|
|
|
757
810
|
def target_files(expansion)
|
|
758
811
|
files = expansion.fetch(:files)
|
|
759
812
|
# ADR-46 slice 2 — restrict the analyzed set to the affected closure while the pre-pass (run
|
|
760
|
-
# separately over `expansion`'s full file list) keeps the cross-file index complete.
|
|
761
|
-
|
|
762
|
-
|
|
813
|
+
# separately over `expansion`'s full file list) keeps the cross-file index complete.
|
|
814
|
+
if @analyze_only
|
|
815
|
+
# Editor mode option B (#146) — with BOTH set, the closure wins and the buffer is one member of it.
|
|
816
|
+
# The logical path joins even when it is not on disk under `paths:` (design § "Failure envelope"),
|
|
817
|
+
# the same allowance option A makes below.
|
|
818
|
+
files = files.select { |path| @analyze_only.include?(path) }
|
|
819
|
+
files |= [@buffer.logical_path] if @buffer && @analyze_only.include?(@buffer.logical_path)
|
|
820
|
+
return files
|
|
821
|
+
end
|
|
763
822
|
return files if @buffer.nil?
|
|
764
823
|
|
|
824
|
+
# Editor mode option A — no closure, so the buffer's single logical path IS the analyzed set.
|
|
765
825
|
[@buffer.logical_path]
|
|
766
826
|
end
|
|
767
827
|
|
|
@@ -1023,8 +1083,12 @@ module Rigor
|
|
|
1023
1083
|
# The cross-file pre-pass tables {#seed_project_scope} applies, as a plain Hash so the fork-pool path
|
|
1024
1084
|
# can hand the same seed to its {WorkerSession} (whose per-file scopes would otherwise miss every
|
|
1025
1085
|
# cross-file def — ADR-15 sequential-equivalence contract).
|
|
1086
|
+
#
|
|
1087
|
+
# Issue #260 — an opt-in `discovery_seed:` is the BASE of the result, so every table this run computed
|
|
1088
|
+
# for itself still wins. Under `prebuilt:` (the only caller that passes one today) the run computes
|
|
1089
|
+
# none, so the seed applies wholesale.
|
|
1026
1090
|
def project_scope_seed_tables
|
|
1027
|
-
tables =
|
|
1091
|
+
tables = discovery_seed_base
|
|
1028
1092
|
# ADR-84 WD2 — the run-scope token rides the same seed so the fork/Ractor `WorkerSession` scopes
|
|
1029
1093
|
# bucket identically to the sequential path.
|
|
1030
1094
|
tables[:run_generation] = @run_generation if @run_generation
|
|
@@ -1055,6 +1119,13 @@ module Rigor
|
|
|
1055
1119
|
tables
|
|
1056
1120
|
end
|
|
1057
1121
|
|
|
1122
|
+
# Issue #260 — the mutable starting point {#project_scope_seed_tables} fills in: a copy of the opt-in
|
|
1123
|
+
# `discovery_seed:` when one was supplied, otherwise the empty Hash every other run has always started
|
|
1124
|
+
# from. Extracted so the seed costs {#project_scope_seed_tables} no branch of its complexity budget.
|
|
1125
|
+
def discovery_seed_base
|
|
1126
|
+
@discovery_seed ? @discovery_seed.dup : {}
|
|
1127
|
+
end
|
|
1128
|
+
|
|
1058
1129
|
# ADR-46 — seed the instance + singleton `"path:line"` def-source tables (each only when non-empty).
|
|
1059
1130
|
# Extracted to keep {#project_scope_seed_tables} under the complexity budget. The singleton table (slice 4
|
|
1060
1131
|
# extension) rides the same seed so a pooled `WorkerSession` records singleton symbol edges identically.
|
|
@@ -35,7 +35,8 @@ module Rigor
|
|
|
35
35
|
Diagnostic.new(
|
|
36
36
|
path: diagnostic.path, line: diagnostic.line, column: diagnostic.column,
|
|
37
37
|
message: diagnostic.message, severity: resolved, rule: diagnostic.rule,
|
|
38
|
-
source_family: diagnostic.source_family
|
|
38
|
+
source_family: diagnostic.source_family, receiver_type: diagnostic.receiver_type,
|
|
39
|
+
method_name: diagnostic.method_name, project_definition_site: diagnostic.project_definition_site
|
|
39
40
|
)
|
|
40
41
|
end
|
|
41
42
|
end
|
data/lib/rigor/bleeding_edge.rb
CHANGED
|
@@ -11,29 +11,86 @@ module Rigor
|
|
|
11
11
|
#
|
|
12
12
|
# The WD2 foundation slice wired the surface end-to-end (`bleeding_edge:` config, the
|
|
13
13
|
# `rigor show-bleedingedge` command, the severity-composition hook in
|
|
14
|
-
# {Configuration::SeverityProfile.resolve}), so a discipline lands as a single
|
|
15
|
-
# entry with no engine plumbing — as the first one,
|
|
14
|
+
# {Configuration::SeverityProfile.resolve}), so a *severity* discipline lands as a single
|
|
15
|
+
# {FEATURES} entry with no engine plumbing — as the first one,
|
|
16
|
+
# `reject-unparseable-signatures`, does.
|
|
17
|
+
#
|
|
18
|
+
# A queued change whose effect is not a severity move — a measurement, an algorithm, or a
|
|
19
|
+
# default that changes while every rule keeps its severity — is a `:behaviour` feature
|
|
20
|
+
# instead. It carries no severity map; its call sites ask
|
|
21
|
+
# {Configuration#bleeding_edge_active?} whether the id is adopted for the run.
|
|
22
|
+
#
|
|
23
|
+
# A `:behaviour` feature MUST NOT change the output of `rigor check` analysis unless its
|
|
24
|
+
# feature id is folded into the analysis-cache identity. Rationale, verified 2026-08-01:
|
|
25
|
+
# severity features are safe because severity is stamped POST-cache — {Analysis::SeverityStamp}
|
|
26
|
+
# (ADR-87 WD4) stores the authored severity and applies the profile + bleeding-edge overrides
|
|
27
|
+
# identically on the miss path and the warm-hit path, so a warm HIT re-resolves under the
|
|
28
|
+
# current selector. A behaviour feature that altered analysis results themselves would poison
|
|
29
|
+
# warm caches across selector changes, because the selector is not part of the cache key. The
|
|
30
|
+
# two queued consumers (#253, #254) change what a separate command measures, not what `check`
|
|
31
|
+
# analyses, so neither is affected.
|
|
16
32
|
#
|
|
17
33
|
# Each feature carries a **stable feature id** — part of the ADR-50 WD1 contract
|
|
18
34
|
# vocabulary: the config, the `show` command, and the eventual CHANGELOG migration note all
|
|
19
|
-
# name the same id, and a feature graduates to default-on at a major
|
|
20
|
-
# {FEATURES}.
|
|
35
|
+
# name the same id, and a feature graduates to default-on at a major (ADR-50 § WD7) by
|
|
36
|
+
# moving from {FEATURES} to {GRADUATED}.
|
|
21
37
|
module BleedingEdge
|
|
38
|
+
# The two kinds a queued change can take. `:severity` composes through
|
|
39
|
+
# {Configuration::SeverityProfile.resolve}; `:behaviour` is read at a call site through
|
|
40
|
+
# {Configuration#bleeding_edge_active?}.
|
|
41
|
+
KINDS = %i[severity behaviour].freeze
|
|
42
|
+
|
|
43
|
+
# The severity map a `:behaviour` feature carries: none.
|
|
44
|
+
NO_SEVERITY_OVERRIDES = {}.freeze
|
|
45
|
+
|
|
22
46
|
# One queued change.
|
|
23
47
|
#
|
|
48
|
+
# The two kinds are exclusive by construction: a `:severity` feature MUST name at least
|
|
49
|
+
# one rule, and a `:behaviour` feature MUST name none — a behaviour switch that also
|
|
50
|
+
# moved a severity would be two changes wearing one id, and the id is what a CHANGELOG
|
|
51
|
+
# migration note and a user's `bleeding_edge:` list both key on.
|
|
52
|
+
#
|
|
24
53
|
# @!attribute id
|
|
25
54
|
# @return [String] the stable feature id (contract vocabulary).
|
|
26
55
|
# @!attribute summary
|
|
27
|
-
# @return [String] a one-line description of what it changes.
|
|
56
|
+
# @return [String] a one-line description of what it changes. For a `:behaviour`
|
|
57
|
+
# feature this is the *whole* explanation — there is no severity diff to read.
|
|
58
|
+
# @!attribute kind
|
|
59
|
+
# @return [Symbol] one of {KINDS}.
|
|
28
60
|
# @!attribute severity_overrides
|
|
29
61
|
# @return [Hash{String => Symbol}] canonical rule id → the severity this feature
|
|
30
62
|
# imposes. Composed *below* the user's own `severity_overrides:` and *above* the
|
|
31
|
-
# active `severity_profile` (see {Configuration::SeverityProfile.resolve}).
|
|
32
|
-
|
|
63
|
+
# active `severity_profile` (see {Configuration::SeverityProfile.resolve}). Empty for
|
|
64
|
+
# a `:behaviour` feature.
|
|
65
|
+
Feature = Data.define(:id, :summary, :kind, :severity_overrides) do
|
|
66
|
+
def initialize(id:, summary:, kind:, severity_overrides: NO_SEVERITY_OVERRIDES)
|
|
67
|
+
raise ArgumentError, "kind must be one of #{KINDS.inspect}, got #{kind.inspect}" unless KINDS.include?(kind)
|
|
68
|
+
|
|
69
|
+
if kind == :severity && severity_overrides.empty?
|
|
70
|
+
raise ArgumentError, "bleeding-edge feature #{id.inspect} is :severity but overrides no rule"
|
|
71
|
+
end
|
|
72
|
+
if kind == :behaviour && !severity_overrides.empty?
|
|
73
|
+
raise ArgumentError, "bleeding-edge feature #{id.inspect} is :behaviour but carries severity_overrides"
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
super
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# @return [Boolean]
|
|
80
|
+
def severity?
|
|
81
|
+
kind == :severity
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# @return [Boolean]
|
|
85
|
+
def behaviour?
|
|
86
|
+
kind == :behaviour
|
|
87
|
+
end
|
|
88
|
+
|
|
33
89
|
def to_h
|
|
34
90
|
{
|
|
35
91
|
"id" => id,
|
|
36
92
|
"summary" => summary,
|
|
93
|
+
"kind" => kind.to_s,
|
|
37
94
|
"severity_overrides" => severity_overrides.transform_values(&:to_s)
|
|
38
95
|
}
|
|
39
96
|
end
|
|
@@ -48,6 +105,7 @@ module Rigor
|
|
|
48
105
|
FEATURES = [
|
|
49
106
|
Feature.new(
|
|
50
107
|
id: "reject-unparseable-signatures",
|
|
108
|
+
kind: :severity,
|
|
51
109
|
summary: "A broken `signature_paths:` RBS set fails the run instead of degrading it silently. An " \
|
|
52
110
|
"unparseable `.rbs` is otherwise skipped with a warning, and a duplicate-declaration " \
|
|
53
111
|
"conflict (a file that parses fine but collides on resolve — typically against Rigor's " \
|
|
@@ -61,6 +119,7 @@ module Rigor
|
|
|
61
119
|
),
|
|
62
120
|
Feature.new(
|
|
63
121
|
id: "use-of-void-value",
|
|
122
|
+
kind: :severity,
|
|
64
123
|
summary: "Using a value recovered from an author-declared `-> void` return in value context (an " \
|
|
65
124
|
"assignment right-hand side, a call receiver, or an argument) becomes a `:warning`. An " \
|
|
66
125
|
"explicit `-> void` is the strongest possible \"do not rely on this return\" signal, so " \
|
|
@@ -70,9 +129,51 @@ module Rigor
|
|
|
70
129
|
severity_overrides: {
|
|
71
130
|
"static.value-use.void" => :warning
|
|
72
131
|
}.freeze
|
|
132
|
+
),
|
|
133
|
+
Feature.new(
|
|
134
|
+
id: "discovery-seeded-mutation-sites",
|
|
135
|
+
kind: :behaviour,
|
|
136
|
+
summary: "`rigor coverage --protection --mutation` (Tier 2) measures against the same cross-file " \
|
|
137
|
+
"project discovery Tier 1 already seeds, instead of an empty scope — both when picking the " \
|
|
138
|
+
"sites and when re-analysing each breakage to decide whether it was caught. A call whose " \
|
|
139
|
+
"receiver is a project class declared in a *sibling* file (`Post.where`, " \
|
|
140
|
+
"`Rigor::Protection::Mutator.new`) then resolves to the type it really has rather than " \
|
|
141
|
+
"`Dynamic`, so the site is measured instead of dropped — and a breakage there can actually " \
|
|
142
|
+
"be caught. This makes the two tiers judge a site " \
|
|
143
|
+
"by one standard, but it ADDS sites to the denominator, so the reported effectiveness ratio " \
|
|
144
|
+
"goes DOWN on the same code — and `--threshold=RATIO` exits 1 when that ratio falls below a " \
|
|
145
|
+
"number pinned in CI. Off by default for that reason: it is a queued change for the next " \
|
|
146
|
+
"major, not a fix you should be opted into mid-release."
|
|
147
|
+
),
|
|
148
|
+
Feature.new(
|
|
149
|
+
id: "dependent-closure-kill-oracle",
|
|
150
|
+
kind: :behaviour,
|
|
151
|
+
summary: "`rigor coverage --protection --mutation` (Tier 2) decides a breakage was caught when the " \
|
|
152
|
+
"diagnostic appears anywhere in the mutated file OR the files that depend on it, instead of " \
|
|
153
|
+
"in the mutated file alone. Changing what a method returns is caught in its *callers* — the " \
|
|
154
|
+
"cross-file reach the analyzer exists for — and that catch is scored as a miss today. The " \
|
|
155
|
+
"measurement re-analyses the dependent closure (ADR-46's dependency graph) against the " \
|
|
156
|
+
"mutated bytes, so those catches count. It can only ADD kills, never remove one, so the " \
|
|
157
|
+
"reported ratio moves up or not at all; a recorded ratio nonetheless stops being comparable " \
|
|
158
|
+
"with one measured without it. It costs roughly a third more wall time per mutant, and on " \
|
|
159
|
+
"the two corpora measured so far (Rigor's own `lib`, redmine `app/models`) it added no " \
|
|
160
|
+
"kills — every surviving breakage there is one the analyzer reports nowhere at all, not one " \
|
|
161
|
+
"it reports in a caller."
|
|
73
162
|
)
|
|
74
163
|
].freeze
|
|
75
164
|
|
|
165
|
+
# ADR-50 § WD7 — the ids that have already graduated to default-on.
|
|
166
|
+
#
|
|
167
|
+
# A feature graduates at a major by moving *here* from {FEATURES} rather than by simply
|
|
168
|
+
# disappearing: {Configuration#bleeding_edge_active?} then answers an unconditional `true`
|
|
169
|
+
# for the id, so a call site still asking about it keeps the graduated behaviour and gate
|
|
170
|
+
# cleanup can lag graduation by as many releases as it takes. The id also stays in the
|
|
171
|
+
# contract vocabulary the CHANGELOG migration note keys on. Entries are removed only once
|
|
172
|
+
# no call site names them.
|
|
173
|
+
#
|
|
174
|
+
# @return [Array<String>]
|
|
175
|
+
GRADUATED = [].freeze
|
|
176
|
+
|
|
76
177
|
module_function
|
|
77
178
|
|
|
78
179
|
# @return [Array<Feature>] the whole overlay.
|
|
@@ -91,6 +192,19 @@ module Rigor
|
|
|
91
192
|
FEATURES.find { |f| f.id == id }
|
|
92
193
|
end
|
|
93
194
|
|
|
195
|
+
# @param id [String]
|
|
196
|
+
# @return [Boolean] whether the id has graduated to default-on ({GRADUATED}).
|
|
197
|
+
def graduated?(id)
|
|
198
|
+
GRADUATED.include?(id)
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
# @param id [String]
|
|
202
|
+
# @return [Boolean] whether the id names a feature this gem knows at all — queued or
|
|
203
|
+
# graduated. Distinct from "adopted"; see {Configuration#bleeding_edge_active?}.
|
|
204
|
+
def known_id?(id)
|
|
205
|
+
graduated?(id) || FEATURES.any? { |f| f.id == id }
|
|
206
|
+
end
|
|
207
|
+
|
|
94
208
|
# Resolves a normalized `bleeding_edge:` selector (see {Configuration#bleeding_edge}) to
|
|
95
209
|
# the active {Feature} list. Unknown ids in a `list` / `except` selector are simply
|
|
96
210
|
# absent from the overlay and contribute nothing — symmetric with how
|
|
@@ -125,6 +239,17 @@ module Rigor
|
|
|
125
239
|
end.freeze
|
|
126
240
|
end
|
|
127
241
|
|
|
242
|
+
# The ids the active features expose to {Configuration#bleeding_edge_active?}, as a frozen
|
|
243
|
+
# `Set` so a call site on the hot path pays a hash lookup rather than an Array scan.
|
|
244
|
+
# Precomputed once per Configuration; frozen (with frozen members) so the carrier stays
|
|
245
|
+
# `Ractor.shareable?` across the worker boundary.
|
|
246
|
+
#
|
|
247
|
+
# @param selector [Hash] see {#active_features}.
|
|
248
|
+
# @return [Set<String>]
|
|
249
|
+
def active_ids_for(selector)
|
|
250
|
+
Set.new(active_features(selector).map(&:id)).freeze
|
|
251
|
+
end
|
|
252
|
+
|
|
128
253
|
# Feature ids named by a selector that are NOT in the overlay (typo / graduated / from a
|
|
129
254
|
# newer gem). Surfaced by `rigor show-bleedingedge` as a hint; never an error.
|
|
130
255
|
#
|
|
@@ -33,7 +33,12 @@ module Rigor
|
|
|
33
33
|
# content digests. Old entries must read as misses so the first writable run rebuilds them in the new
|
|
34
34
|
# format for a clean one-shot migration (the #57 marker discipline: the bump clears the root and
|
|
35
35
|
# reclaims the unreadable bytes).
|
|
36
|
-
|
|
36
|
+
# v6: #237 — `append_stub_declarations` now emits the declaration KIND each referenced-type stub needs
|
|
37
|
+
# (`interface` / `type` / `module` / `class`) and validates each declaration on its own, so a project
|
|
38
|
+
# whose RBS dangles an interface or type-alias reference gets stubs where an older Rigor discarded the
|
|
39
|
+
# whole batch and cached an env in which those signatures are inert. Same reasoning as v3: the
|
|
40
|
+
# marshalled env is the cached value, so it MUST be rebuilt for the fix to take effect.
|
|
41
|
+
SCHEMA_VERSION = 6
|
|
37
42
|
|
|
38
43
|
# Per-slot entry value objects. Constructors validate enums / required fields and freeze the resulting
|
|
39
44
|
# struct so no caller can mutate after the entry is in a Descriptor.
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
|
|
5
|
+
require_relative "../version"
|
|
6
|
+
|
|
7
|
+
module Rigor
|
|
8
|
+
module Cache
|
|
9
|
+
# Issue #285 — the identity of the ENGINE'S OWN SOURCE, as a cache-key slot.
|
|
10
|
+
#
|
|
11
|
+
# Every Rigor cache whose value is a function of what the analyzer COMPUTES (the ADR-45 run-result
|
|
12
|
+
# cache, the #134 mutation-result cache) keys on `Rigor::VERSION`. For a gem installed from RubyGems
|
|
13
|
+
# that is exact — the version pins the bytes. For anyone running an edited working tree it is not, and
|
|
14
|
+
# the failure is invisible: a warm `rigor check` replays the pre-edit diagnostics verbatim, so a
|
|
15
|
+
# before/after measurement of an engine change reports `0 new, 0 gone` no matter what the change does.
|
|
16
|
+
# That shape of false zero survived into #152's FP evaluation before it was caught.
|
|
17
|
+
#
|
|
18
|
+
# ## The two regimes
|
|
19
|
+
#
|
|
20
|
+
# {.identity} answers `nil` for a tree whose identity `Rigor::VERSION` already fixes — a RubyGems
|
|
21
|
+
# install, recognised by its `<gem_home>/gems/rigortype-<VERSION>` directory layout. The caller then
|
|
22
|
+
# adds NO slot, so a released gem's cache key is byte-identical to the one it had before this module
|
|
23
|
+
# existed and pays not one syscall for it. Everything else — a contributor's checkout, a `bundle add
|
|
24
|
+
# rigor, github:` clone (where two commits share one `Rigor::VERSION`), a `path:` gem — is treated as
|
|
25
|
+
# mutable and gets a content digest of the engine's source tree.
|
|
26
|
+
#
|
|
27
|
+
# The predicate is deliberately POSITIVE about being pinned, so every case it fails to recognise lands
|
|
28
|
+
# on the safe side: an unrecognised release pays one directory walk and one extra cache generation,
|
|
29
|
+
# while an unrecognised checkout would have served a stale answer.
|
|
30
|
+
#
|
|
31
|
+
# ## Why a content digest and not a stat tuple
|
|
32
|
+
#
|
|
33
|
+
# A `(size, mtime, ctime, inode)` walk is ~3× cheaper (measured: 5.4 ms vs 17 ms over this repo's 539
|
|
34
|
+
# engine files), and ADR-87 WD1 trusts exactly that tuple for FRESHNESS. It cannot be used here:
|
|
35
|
+
# {Descriptor::FileEntry} states the rule directly — a stat tuple carries machine-local, per-run
|
|
36
|
+
# nondeterministic data and MUST NOT enter a descriptor used as a cache KEY. On the freshness side a
|
|
37
|
+
# moved tuple falls back to the recorded digest, so a fresh checkout of unchanged content still
|
|
38
|
+
# validates; a KEY has no such fallback, so keying on stat would make every CI run and every branch
|
|
39
|
+
# switch a total miss for the `github:`-tracking users this module exists to protect.
|
|
40
|
+
#
|
|
41
|
+
# ## Never a wrong hit
|
|
42
|
+
#
|
|
43
|
+
# A mutable tree whose digest cannot be computed raises {Unavailable} rather than answering `nil`.
|
|
44
|
+
# Both callers already rescue a malformed key into "no cache for this run", which is the only sound
|
|
45
|
+
# reading: falling back to the version-only key would restore precisely the blind spot being fixed.
|
|
46
|
+
module EngineSource
|
|
47
|
+
# Raised when the tree is not version-pinned AND its source cannot be digested. Deliberately not
|
|
48
|
+
# rescued in this module — see the class doc's last paragraph.
|
|
49
|
+
class Unavailable < StandardError; end
|
|
50
|
+
|
|
51
|
+
# The RubyGems package name. `<name>-<version>` is the install directory layout {.version_pinned?}
|
|
52
|
+
# recognises; the constant is not read from the gemspec because this file loads on the boot-slimming
|
|
53
|
+
# probe path, which must not touch RubyGems' specification machinery.
|
|
54
|
+
GEM_NAME = "rigortype"
|
|
55
|
+
|
|
56
|
+
# Engine source, relative to the gem root. `plugins/` ships inside the same gem and its recognisers
|
|
57
|
+
# move diagnostics exactly as `lib/` does, so an edit there must invalidate too; `examples/`,
|
|
58
|
+
# `apps/` and `tool/` are not loaded by an analysis and stay out.
|
|
59
|
+
SOURCE_DIRECTORIES = %w[lib plugins].freeze
|
|
60
|
+
|
|
61
|
+
# The one directory whose absence means "this is not an engine tree". `plugins/` may legitimately be
|
|
62
|
+
# missing (a slimmed install contributes nothing); a missing `lib/` would silently shrink the digest
|
|
63
|
+
# to whatever else happened to be there, which is exactly the weakening this module forbids.
|
|
64
|
+
REQUIRED_DIRECTORY = "lib"
|
|
65
|
+
|
|
66
|
+
module_function
|
|
67
|
+
|
|
68
|
+
# @return [String] the gem root — the directory holding `lib/`, three levels above this file.
|
|
69
|
+
def root
|
|
70
|
+
@root ||= File.expand_path("../../..", __dir__)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# @param root [String] the gem root, defaulted through {.root} so a spec can relocate the tree.
|
|
74
|
+
# @return [String, nil] a digest identifying the engine's current source, or nil when the tree is
|
|
75
|
+
# version-pinned and the caller should add no slot at all.
|
|
76
|
+
# @raise [Unavailable] when a mutable tree's source cannot be read.
|
|
77
|
+
def identity(root = self.root)
|
|
78
|
+
return nil if version_pinned?(root)
|
|
79
|
+
|
|
80
|
+
digest_tree(root)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# {.identity} for THIS process's engine, computed once. Every production caller wants this; the
|
|
84
|
+
# uncached {.identity} stays the computation, and the seam a spec relocates.
|
|
85
|
+
#
|
|
86
|
+
# A memo, not a per-call walk, on two grounds.
|
|
87
|
+
#
|
|
88
|
+
# Cost. #285 could afford the walk because the two callers it wired reached it at most twice per
|
|
89
|
+
# process. Adding {IncrementalSnapshot.fingerprint} (#289) breaks that: {Protection::MutationCache}
|
|
90
|
+
# builds a fingerprint per snapshot-root candidate, so one `rigor coverage --protection --mutation
|
|
91
|
+
# PATH` reaches this five times — 90 ms of walking for a value that cannot differ between the calls.
|
|
92
|
+
#
|
|
93
|
+
# Correctness, which is the stronger reason. The digest exists to identify the engine that COMPUTED a
|
|
94
|
+
# cached value, and that engine is fixed when the process finishes requiring — nothing an edit does to
|
|
95
|
+
# `lib/` mid-run changes the code already running. Re-reading the tree would eventually key values
|
|
96
|
+
# against source that never produced them, so the memo is the more faithful answer, not merely the
|
|
97
|
+
# cheaper one. That is also why a fork-pool worker inheriting it is right: `PoolCoordinator` builds one
|
|
98
|
+
# session on the parent and forks children that copy-on-write inherit its image, so parent and child
|
|
99
|
+
# run the same engine by construction — and the fingerprints are computed on the parent before a pool
|
|
100
|
+
# exists at all. The same reading covers the long-running `rigor lsp` process.
|
|
101
|
+
#
|
|
102
|
+
# {Unavailable} propagates and is deliberately NOT memoised: the ivar is only assigned on success.
|
|
103
|
+
def process_identity
|
|
104
|
+
return @process_identity if defined?(@process_identity)
|
|
105
|
+
|
|
106
|
+
@process_identity = identity
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# Discards the {.process_identity} memo; production code MUST NOT call this — a run that recomputed
|
|
110
|
+
# mid-flight would key cached values against source that did not compute them, which is the whole
|
|
111
|
+
# argument for the memo. It exists because a spec process is many logical "processes", and a memo that
|
|
112
|
+
# outlived one example would silently ignore the next one's stub of {.root} / {.identity} and pass for
|
|
113
|
+
# the wrong reason. `spec_helper` calls it before every example so no spec has to know it is here.
|
|
114
|
+
def reset_process_identity!
|
|
115
|
+
remove_instance_variable(:@process_identity) if defined?(@process_identity)
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# True when `Rigor::VERSION` already pins this tree's bytes: an immutable RubyGems install, laid out
|
|
119
|
+
# as `…/gems/rigortype-<VERSION>`. The `.git` probe is the belt to that braces — a working tree that
|
|
120
|
+
# somehow occupies a release-shaped path is still a working tree.
|
|
121
|
+
def version_pinned?(root)
|
|
122
|
+
return false unless File.basename(root) == "#{GEM_NAME}-#{Rigor::VERSION}"
|
|
123
|
+
return false unless File.basename(File.dirname(root)) == "gems"
|
|
124
|
+
|
|
125
|
+
!File.exist?(File.join(root, ".git"))
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# A SHA-256 over every engine `.rb` file: its ROOT-RELATIVE path (so the digest survives moving or
|
|
129
|
+
# re-cloning the checkout) followed by its bytes, in sorted path order.
|
|
130
|
+
#
|
|
131
|
+
# The walk itself is not memoised — {.process_identity} is where a production caller gets the
|
|
132
|
+
# once-per-process value, and this stays the computation so a spec can point it at another tree.
|
|
133
|
+
def digest_tree(root)
|
|
134
|
+
unless File.directory?(File.join(root, REQUIRED_DIRECTORY))
|
|
135
|
+
raise Unavailable, "#{root} has no #{REQUIRED_DIRECTORY}/ to identify the engine by"
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
digest = Digest::SHA256.new
|
|
139
|
+
prefix = "#{root}#{File::SEPARATOR}"
|
|
140
|
+
count = 0
|
|
141
|
+
source_files(root).each do |path|
|
|
142
|
+
digest << path.delete_prefix(prefix) << "\0"
|
|
143
|
+
digest.file(path)
|
|
144
|
+
count += 1
|
|
145
|
+
end
|
|
146
|
+
raise Unavailable, "no engine source found under #{root}" if count.zero?
|
|
147
|
+
|
|
148
|
+
digest.hexdigest
|
|
149
|
+
rescue SystemCallError, IOError => e
|
|
150
|
+
raise Unavailable, "engine source under #{root} could not be read: #{e.message}"
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# Sorted absolute paths of every engine `.rb` file, across the directories that exist.
|
|
154
|
+
def source_files(root)
|
|
155
|
+
SOURCE_DIRECTORIES.flat_map do |relative|
|
|
156
|
+
directory = File.join(root, relative)
|
|
157
|
+
File.directory?(directory) ? Dir.glob(File.join(directory, "**", "*.rb")) : []
|
|
158
|
+
end.sort
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
end
|