rigortype 0.2.9 → 0.3.0
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 +2 -2
- data/docs/handbook/03-narrowing.md +2 -1
- data/docs/handbook/04-tuples-and-shapes.md +7 -3
- data/docs/handbook/06-classes.md +18 -9
- data/docs/handbook/08-understanding-errors.md +1 -0
- data/docs/handbook/11-sig-gen.md +11 -1
- data/docs/handbook/appendix-mypy.md +4 -3
- data/docs/install.md +14 -6
- data/docs/manual/01-installation.md +98 -4
- data/docs/manual/02-cli-reference.md +49 -12
- data/docs/manual/03-configuration.md +34 -2
- data/docs/manual/04-diagnostics.md +23 -0
- data/docs/manual/07-plugins.md +4 -4
- data/docs/manual/09-editor-integration.md +4 -4
- data/docs/manual/11-ci.md +51 -0
- data/docs/manual/12-caching.md +65 -0
- data/lib/rigor/analysis/check_rules/always_truthy_condition_collector.rb +9 -1
- data/lib/rigor/analysis/check_rules/dead_assignment_collector.rb +5 -3
- data/lib/rigor/analysis/check_rules/duplicate_hash_key_collector.rb +128 -0
- data/lib/rigor/analysis/check_rules/inferred_param_guard.rb +60 -0
- data/lib/rigor/analysis/check_rules/ivar_write_collector.rb +3 -2
- data/lib/rigor/analysis/check_rules/return_in_ensure_collector.rb +117 -0
- data/lib/rigor/analysis/check_rules/rule_ids.rb +125 -0
- data/lib/rigor/analysis/check_rules/rule_walk.rb +2 -1
- data/lib/rigor/analysis/check_rules/self_closedness_scanner.rb +2 -1
- data/lib/rigor/analysis/check_rules/shadowed_rescue_collector.rb +283 -0
- data/lib/rigor/analysis/check_rules/unreachable_clause_collector.rb +10 -1
- data/lib/rigor/analysis/check_rules/void_value_use_collector.rb +93 -0
- data/lib/rigor/analysis/check_rules.rb +557 -95
- data/lib/rigor/analysis/dependency_recorder.rb +93 -9
- data/lib/rigor/analysis/dependency_source_inference/walker.rb +2 -1
- data/lib/rigor/analysis/incremental_session.rb +456 -51
- data/lib/rigor/analysis/path_expansion.rb +42 -0
- data/lib/rigor/analysis/plugin_fact_fingerprint.rb +188 -0
- data/lib/rigor/analysis/rule_catalog.rb +226 -1
- data/lib/rigor/analysis/run_cache_key.rb +68 -0
- data/lib/rigor/analysis/run_cache_probe.rb +72 -0
- data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +181 -28
- data/lib/rigor/analysis/runner/pool_coordinator.rb +77 -15
- data/lib/rigor/analysis/runner/project_pre_passes.rb +95 -49
- data/lib/rigor/analysis/runner/run_snapshots.rb +9 -2
- data/lib/rigor/analysis/runner.rb +343 -68
- data/lib/rigor/analysis/severity_stamp.rb +43 -0
- data/lib/rigor/analysis/worker_session.rb +30 -2
- data/lib/rigor/bleeding_edge.rb +37 -8
- data/lib/rigor/builtins/regex_refinement.rb +36 -1
- data/lib/rigor/cache/descriptor.rb +88 -28
- data/lib/rigor/cache/file_digest.rb +158 -0
- data/lib/rigor/cache/incremental_snapshot.rb +41 -5
- data/lib/rigor/cache/rbs_descriptor.rb +56 -10
- data/lib/rigor/cache/store.rb +33 -3
- data/lib/rigor/ci_detector.rb +90 -0
- data/lib/rigor/cli/annotate_command.rb +15 -9
- data/lib/rigor/cli/baseline_command.rb +4 -1
- data/lib/rigor/cli/check_command.rb +212 -27
- data/lib/rigor/cli/coverage_command.rb +6 -1
- data/lib/rigor/cli/docs_command.rb +0 -30
- data/lib/rigor/cli/doctor_command.rb +163 -23
- data/lib/rigor/cli/lsp_command.rb +5 -0
- data/lib/rigor/cli/mcp_command.rb +5 -0
- data/lib/rigor/cli/plugins_command.rb +36 -6
- data/lib/rigor/cli/plugins_renderer.rb +44 -12
- data/lib/rigor/cli/probe_environment.rb +85 -0
- data/lib/rigor/cli/sig_gen_command.rb +36 -9
- data/lib/rigor/cli/skill_command.rb +1 -32
- data/lib/rigor/cli/trace_command.rb +5 -6
- data/lib/rigor/cli/type_of_command.rb +9 -8
- data/lib/rigor/cli/type_scan_command.rb +8 -8
- data/lib/rigor/cli.rb +15 -4
- data/lib/rigor/config_audit.rb +48 -2
- data/lib/rigor/configuration/severity_profile.rb +31 -0
- data/lib/rigor/configuration.rb +156 -8
- data/lib/rigor/environment/default_libraries.rb +35 -0
- data/lib/rigor/environment/rbs_loader.rb +164 -5
- data/lib/rigor/environment.rb +19 -27
- data/lib/rigor/inference/budget_trace.rb +77 -2
- data/lib/rigor/inference/closure_escape_analyzer.rb +14 -1
- data/lib/rigor/inference/def_handle.rb +23 -0
- data/lib/rigor/inference/def_node_resolver.rb +90 -0
- data/lib/rigor/inference/def_return_typer.rb +2 -1
- data/lib/rigor/inference/expression_typer.rb +361 -113
- data/lib/rigor/inference/method_dispatcher/data_folding.rb +30 -5
- data/lib/rigor/inference/method_dispatcher/kernel_dispatch.rb +200 -0
- data/lib/rigor/inference/method_dispatcher/literal_string_folding.rb +22 -16
- data/lib/rigor/inference/method_dispatcher/member_shape_projection.rb +33 -0
- data/lib/rigor/inference/method_dispatcher/overload_selector.rb +11 -0
- data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +42 -9
- data/lib/rigor/inference/method_dispatcher/receiver_affinity.rb +5 -0
- data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +28 -35
- data/lib/rigor/inference/method_dispatcher/struct_folding.rb +40 -8
- data/lib/rigor/inference/method_dispatcher.rb +57 -1
- data/lib/rigor/inference/mutation_widening.rb +42 -8
- data/lib/rigor/inference/narrowing.rb +127 -17
- data/lib/rigor/inference/parameter_inference_collector.rb +15 -0
- data/lib/rigor/inference/project_patched_scanner.rb +2 -1
- data/lib/rigor/inference/rbs_type_translator.rb +8 -1
- data/lib/rigor/inference/scope_indexer.rb +451 -36
- data/lib/rigor/inference/statement_evaluator.rb +64 -15
- data/lib/rigor/inference/struct_fold_safety.rb +48 -11
- data/lib/rigor/inference/synthetic_method_scanner.rb +15 -14
- data/lib/rigor/inference/void_origin.rb +25 -0
- data/lib/rigor/inference/void_tail_summary.rb +220 -0
- data/lib/rigor/language_server/completion_provider.rb +2 -1
- data/lib/rigor/language_server/document_symbol_provider.rb +2 -1
- data/lib/rigor/language_server/folding_range_provider.rb +2 -1
- data/lib/rigor/language_server/selection_range_provider.rb +2 -1
- data/lib/rigor/language_server/signature_help_provider.rb +2 -1
- data/lib/rigor/plugin/base.rb +9 -22
- data/lib/rigor/plugin/inflector.rb +12 -3
- data/lib/rigor/plugin/io_boundary.rb +5 -1
- data/lib/rigor/plugin/isolation.rb +81 -11
- data/lib/rigor/plugin/load_error.rb +10 -1
- data/lib/rigor/plugin/loader.rb +96 -14
- data/lib/rigor/plugin/node_rule_walk.rb +5 -3
- data/lib/rigor/plugin/registry.rb +32 -23
- data/lib/rigor/plugin.rb +26 -0
- data/lib/rigor/protection/mutator.rb +3 -2
- data/lib/rigor/reflection.rb +64 -0
- data/lib/rigor/runtime/jit.rb +128 -0
- data/lib/rigor/scope/discovery_index.rb +12 -2
- data/lib/rigor/scope.rb +122 -28
- data/lib/rigor/sig_gen/classification.rb +5 -1
- data/lib/rigor/sig_gen/generator.rb +38 -6
- data/lib/rigor/sig_gen/observation_collector.rb +4 -3
- data/lib/rigor/sig_gen/rbs_validity.rb +44 -0
- data/lib/rigor/sig_gen/renderer.rb +10 -0
- data/lib/rigor/sig_gen/write_result.rb +9 -4
- data/lib/rigor/sig_gen/writer.rb +24 -2
- data/lib/rigor/source/node_children.rb +116 -0
- data/lib/rigor/source/node_locator.rb +3 -1
- data/lib/rigor/source/node_walker.rb +4 -2
- data/lib/rigor/source.rb +1 -0
- data/lib/rigor/type/difference.rb +28 -24
- data/lib/rigor/type/hash_shape.rb +34 -10
- data/lib/rigor/version.rb +1 -1
- data/lib/rigortype.rb +24 -0
- data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_discoverer.rb +4 -2
- data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_discoverer.rb +6 -4
- data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_discoverer.rb +5 -3
- data/plugins/rigor-activejob/lib/rigor/plugin/activejob/job_discoverer.rb +4 -2
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/analyzer.rb +3 -1
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_discoverer.rb +4 -2
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/schema_parser.rb +4 -2
- data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/analyzer.rb +3 -1
- data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/attachment_discoverer.rb +4 -2
- data/plugins/rigor-dry-types/lib/rigor/plugin/dry_types.rb +38 -6
- data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_discoverer.rb +3 -1
- data/plugins/rigor-hanami/lib/rigor/plugin/hanami/action_checker.rb +3 -1
- data/plugins/rigor-mangrove/lib/rigor/plugin/mangrove.rb +10 -0
- data/plugins/rigor-minitest/lib/rigor/plugin/minitest.rb +11 -0
- data/plugins/rigor-pundit/lib/rigor/plugin/pundit/policy_discoverer.rb +3 -1
- data/plugins/rigor-rails/lib/rigor-rails.rb +11 -12
- data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/grape_api_discoverer.rb +3 -1
- data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/helper_discoverer.rb +3 -1
- data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/routes_parser.rb +7 -5
- data/plugins/rigor-rbs-inline/lib/rigor/plugin/rbs_inline.rb +83 -9
- data/plugins/rigor-rbs-inline/lib/rigor-rbs-inline.rb +9 -5
- data/plugins/rigor-rspec/lib/rigor/plugin/rspec/let_scope_index.rb +3 -1
- data/plugins/rigor-rspec/lib/rigor/plugin/rspec/scope_walker.rb +3 -1
- data/plugins/rigor-rspec/lib/rigor/plugin/rspec.rb +11 -0
- data/plugins/rigor-rspec-rails/lib/rigor/plugin/rspec_rails/http_status_codes.rb +4 -1
- data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/worker_discoverer.rb +4 -2
- data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/catalog_walker.rb +3 -1
- data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +74 -14
- data/sig/prism_node_children.rbs +9 -0
- data/sig/rigor/inference.rbs +7 -0
- data/sig/rigor/plugin/base.rbs +2 -3
- data/sig/rigor/reflection.rbs +1 -0
- data/sig/rigor/scope.rbs +12 -1
- data/skills/rigor-ci-setup/SKILL.md +10 -0
- data/skills/rigor-editor-setup/SKILL.md +11 -0
- data/skills/rigor-next-steps/SKILL.md +23 -2
- data/skills/rigor-plugin-author/SKILL.md +3 -3
- data/skills/rigor-plugin-author/references/02-walker-and-types.md +2 -2
- data/skills/rigor-plugin-review/SKILL.md +2 -2
- data/skills/rigor-plugin-review/references/01-best-practices-checklist.md +5 -4
- data/skills/rigor-project-init/SKILL.md +29 -3
- data/skills/rigor-project-init/references/01-detect.md +1 -1
- data/skills/rigor-project-init/references/02-configure.md +39 -5
- data/skills/rigor-project-init/references/03-baseline-and-bugs.md +20 -0
- data/skills/rigor-project-init/references/05-jit-performance.md +125 -0
- metadata +42 -24
- data/lib/rigor/cli/ci_detector.rb +0 -89
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "diagnostic"
|
|
4
|
+
require_relative "../configuration/severity_profile"
|
|
5
|
+
|
|
6
|
+
module Rigor
|
|
7
|
+
module Analysis
|
|
8
|
+
# ADR-8 § "Severity profile" — re-stamps each diagnostic's severity from the configured profile + per-rule
|
|
9
|
+
# overrides and drops any diagnostic resolving to `:off`. Rules emit with their authored severity; the
|
|
10
|
+
# profile is the final filter.
|
|
11
|
+
#
|
|
12
|
+
# Extracted (ADR-87 WD4) so the miss path ({Runner::DiagnosticAggregator}) and the boot-slimming hit path
|
|
13
|
+
# ({RunCacheProbe}, which never loads the aggregator) apply the IDENTICAL final filter — a warm HIT's
|
|
14
|
+
# served diagnostics are byte-identical to what a full run would print.
|
|
15
|
+
module SeverityStamp
|
|
16
|
+
module_function
|
|
17
|
+
|
|
18
|
+
def apply(diagnostics, configuration)
|
|
19
|
+
diagnostics.filter_map { |diagnostic| stamp(diagnostic, configuration) }
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def stamp(diagnostic, configuration)
|
|
23
|
+
return diagnostic if diagnostic.rule.nil?
|
|
24
|
+
|
|
25
|
+
resolved = Configuration::SeverityProfile.resolve(
|
|
26
|
+
rule: diagnostic.rule,
|
|
27
|
+
authored_severity: diagnostic.severity,
|
|
28
|
+
profile: configuration.severity_profile,
|
|
29
|
+
overrides: configuration.severity_overrides,
|
|
30
|
+
bleeding_edge_overrides: configuration.bleeding_edge_severity_overrides
|
|
31
|
+
)
|
|
32
|
+
return nil if resolved == :off
|
|
33
|
+
return diagnostic if resolved == diagnostic.severity
|
|
34
|
+
|
|
35
|
+
Diagnostic.new(
|
|
36
|
+
path: diagnostic.path, line: diagnostic.line, column: diagnostic.column,
|
|
37
|
+
message: diagnostic.message, severity: resolved, rule: diagnostic.rule,
|
|
38
|
+
source_family: diagnostic.source_family
|
|
39
|
+
)
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -13,6 +13,7 @@ require_relative "../inference/coverage_scanner"
|
|
|
13
13
|
require_relative "../inference/scope_indexer"
|
|
14
14
|
require_relative "../inference/method_dispatcher/file_folding"
|
|
15
15
|
require_relative "check_rules"
|
|
16
|
+
require_relative "dependency_recorder"
|
|
16
17
|
require_relative "dependency_source_inference"
|
|
17
18
|
require_relative "diagnostic"
|
|
18
19
|
require_relative "erb_template_detector"
|
|
@@ -78,7 +79,7 @@ module Rigor
|
|
|
78
79
|
def initialize(configuration:, cache_store: nil, # rubocop:disable Metrics/MethodLength,Metrics/ParameterLists
|
|
79
80
|
plugin_blueprints: [], explain: false, buffer: nil,
|
|
80
81
|
synthetic_method_index: nil, project_patched_methods: nil,
|
|
81
|
-
project_scope_seed: {}, source_files: [])
|
|
82
|
+
project_scope_seed: {}, source_files: [], record_dependencies: false)
|
|
82
83
|
@configuration = configuration
|
|
83
84
|
@cache_store = cache_store
|
|
84
85
|
@explain = explain
|
|
@@ -86,6 +87,12 @@ module Rigor
|
|
|
86
87
|
@synthetic_method_index = synthetic_method_index
|
|
87
88
|
@project_patched_methods = project_patched_methods
|
|
88
89
|
@project_scope_seed = project_scope_seed || {}
|
|
90
|
+
# ADR-46 — when set (the `--incremental` pool path), each `#analyze` is wrapped in a
|
|
91
|
+
# `DependencyRecorder.record_for` so the worker captures the file's cross-file reads, and the records
|
|
92
|
+
# are marshalled back to the coordinator ({#drain_dependencies}). Mirrors `Runner#analyze_file`. A
|
|
93
|
+
# normal `--workers N` run leaves it false and pays nothing (the recorder's disabled fast path).
|
|
94
|
+
@record_dependencies = record_dependencies
|
|
95
|
+
@file_dependencies = {}
|
|
89
96
|
# ADR-32 WD4 — full project file list (frozen Array<String>) for env-build-time invocation of any
|
|
90
97
|
# loaded plugin's `source_rbs_synthesizer` callable.
|
|
91
98
|
@source_files = source_files
|
|
@@ -132,8 +139,20 @@ module Rigor
|
|
|
132
139
|
|
|
133
140
|
# Equivalent of {Rigor::Analysis::Runner#analyze_file} + `plugin_emitted_diagnostics` +
|
|
134
141
|
# `explain_diagnostics`. Returns a flat `Array<Diagnostic>` for the file. Severity profile re-stamping
|
|
135
|
-
# is intentionally NOT applied — that is a per-run aggregate concern handled by the caller.
|
|
142
|
+
# is intentionally NOT applied — that is a per-run aggregate concern handled by the caller. When
|
|
143
|
+
# `record_dependencies:` was set, wraps the analysis in a {DependencyRecorder} window (ADR-46) so the
|
|
144
|
+
# worker captures the file's cross-file reads for {#drain_dependencies}; the recorder's own disabled
|
|
145
|
+
# fast path makes the unwrapped case free.
|
|
136
146
|
def analyze(path)
|
|
147
|
+
return analyze_body(path) unless @record_dependencies
|
|
148
|
+
|
|
149
|
+
diagnostics = nil
|
|
150
|
+
record = DependencyRecorder.record_for(path) { diagnostics = analyze_body(path) }
|
|
151
|
+
@file_dependencies[path] = record
|
|
152
|
+
diagnostics
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def analyze_body(path)
|
|
137
156
|
parse_result = parse_source(path)
|
|
138
157
|
unless parse_result.errors.empty?
|
|
139
158
|
return [] if ErbTemplateDetector.template?(parse_result)
|
|
@@ -161,6 +180,7 @@ module Rigor
|
|
|
161
180
|
rescue StandardError => e
|
|
162
181
|
[analyzer_error(path, "internal analyzer error: #{e.class}: #{e.message}")]
|
|
163
182
|
end
|
|
183
|
+
private :analyze_body
|
|
164
184
|
|
|
165
185
|
# Read-once snapshot of the per-worker reporters so the caller (or the eventual Phase 4b pool
|
|
166
186
|
# aggregator) can merge into a single coordinator-side reporter. Both reporters dedupe at write time,
|
|
@@ -176,6 +196,14 @@ module Rigor
|
|
|
176
196
|
}
|
|
177
197
|
end
|
|
178
198
|
|
|
199
|
+
# ADR-46 — the per-file {DependencyRecorder::Record}s captured by this session's `#analyze` calls (empty
|
|
200
|
+
# unless `record_dependencies:` was set). Marshal-clean (Data of frozen Sets / String Hashes), so the
|
|
201
|
+
# fork pool ships them back to the coordinator, which folds them into the runner's `file_dependencies`
|
|
202
|
+
# exactly as the sequential recording path would.
|
|
203
|
+
def drain_dependencies
|
|
204
|
+
@file_dependencies
|
|
205
|
+
end
|
|
206
|
+
|
|
179
207
|
private
|
|
180
208
|
|
|
181
209
|
# Mirrors {Runner#seed_project_scope}: applies the cross-file pre-pass discovery tables the
|
data/lib/rigor/bleeding_edge.rb
CHANGED
|
@@ -9,11 +9,10 @@ module Rigor
|
|
|
9
9
|
# *today's* rules are) and is versioned with the gem, NOT a user-supplied file: the
|
|
10
10
|
# inspectable counterpart to PHPStan's `bleedingEdge` include.
|
|
11
11
|
#
|
|
12
|
-
# The
|
|
13
|
-
#
|
|
14
|
-
#
|
|
15
|
-
#
|
|
16
|
-
# real feature lands as a single {FEATURES} entry with no engine plumbing.
|
|
12
|
+
# The WD2 foundation slice wired the surface end-to-end (`bleeding_edge:` config, the
|
|
13
|
+
# `rigor show-bleedingedge` command, the severity-composition hook in
|
|
14
|
+
# {Configuration::SeverityProfile.resolve}), so a discipline lands as a single {FEATURES}
|
|
15
|
+
# entry with no engine plumbing — as the first one, `reject-unparseable-signatures`, does.
|
|
17
16
|
#
|
|
18
17
|
# Each feature carries a **stable feature id** — part of the ADR-50 WD1 contract
|
|
19
18
|
# vocabulary: the config, the `show` command, and the eventual CHANGELOG migration note all
|
|
@@ -40,9 +39,39 @@ module Rigor
|
|
|
40
39
|
end
|
|
41
40
|
end
|
|
42
41
|
|
|
43
|
-
# The overlay.
|
|
44
|
-
#
|
|
45
|
-
|
|
42
|
+
# The overlay.
|
|
43
|
+
#
|
|
44
|
+
# Feature ids are **kebab-case, and name the discipline rather than the rule** it happens to
|
|
45
|
+
# promote (`reject-unparseable-signatures`, not `rbs-quarantine-error`): a discipline may grow
|
|
46
|
+
# to cover more rules without its id going stale, and the id is contract vocabulary that
|
|
47
|
+
# outlives the rule set it started with.
|
|
48
|
+
FEATURES = [
|
|
49
|
+
Feature.new(
|
|
50
|
+
id: "reject-unparseable-signatures",
|
|
51
|
+
summary: "A broken `signature_paths:` RBS set fails the run instead of degrading it silently. An " \
|
|
52
|
+
"unparseable `.rbs` is otherwise skipped with a warning, and a duplicate-declaration " \
|
|
53
|
+
"conflict (a file that parses fine but collides on resolve — typically against Rigor's " \
|
|
54
|
+
"own bundled RBS) collapses the whole env with a warning; either way the run gets quieter " \
|
|
55
|
+
"rather than cleaner. This treats both as a build error, the way a broken source file " \
|
|
56
|
+
"already is.",
|
|
57
|
+
severity_overrides: {
|
|
58
|
+
"rbs.coverage.quarantined-signature" => :error,
|
|
59
|
+
"rbs.coverage.environment-build-failed" => :error
|
|
60
|
+
}.freeze
|
|
61
|
+
),
|
|
62
|
+
Feature.new(
|
|
63
|
+
id: "use-of-void-value",
|
|
64
|
+
summary: "Using a value recovered from an author-declared `-> void` return in value context (an " \
|
|
65
|
+
"assignment right-hand side, a call receiver, or an argument) becomes a `:warning`. An " \
|
|
66
|
+
"explicit `-> void` is the strongest possible \"do not rely on this return\" signal, so " \
|
|
67
|
+
"the direct-dispatch case is FP-narrow; a bare-statement `void` result and a legitimate " \
|
|
68
|
+
"`top` value both stay silent. Off by default because a new required diagnostic is an " \
|
|
69
|
+
"ADR-50 WD1 compatibility change (ADR-100 WD2).",
|
|
70
|
+
severity_overrides: {
|
|
71
|
+
"static.value-use.void" => :warning
|
|
72
|
+
}.freeze
|
|
73
|
+
)
|
|
74
|
+
].freeze
|
|
46
75
|
|
|
47
76
|
module_function
|
|
48
77
|
|
|
@@ -19,7 +19,7 @@ module Rigor
|
|
|
19
19
|
# Recognised body shapes (each row admits the `+` quantifier and the bounded `{n}` /
|
|
20
20
|
# `{n,m}` forms with `n >= 1`):
|
|
21
21
|
#
|
|
22
|
-
# - `\d`
|
|
22
|
+
# - `\d`, `[0-9]` -> decimal-int-string
|
|
23
23
|
# - `\h` -> hex-int-string
|
|
24
24
|
# - `[0-9a-fA-F]` -> hex-int-string
|
|
25
25
|
# - `[0-9a-f]`, `[0-9A-F]` -> hex-int-string
|
|
@@ -45,6 +45,7 @@ module Rigor
|
|
|
45
45
|
# `Ractor::IsolationError` on the first row access.
|
|
46
46
|
RULES = Ractor.make_shareable([
|
|
47
47
|
[/\A\\d#{QUANTIFIER_SOURCE}\z/, :decimal_int_string],
|
|
48
|
+
[/\A\[0-9\]#{QUANTIFIER_SOURCE}\z/, :decimal_int_string],
|
|
48
49
|
[/\A\\h#{QUANTIFIER_SOURCE}\z/, :hex_int_string],
|
|
49
50
|
[/\A\[0-9a-fA-F\]#{QUANTIFIER_SOURCE}\z/, :hex_int_string],
|
|
50
51
|
[/\A\[0-9a-f\]#{QUANTIFIER_SOURCE}\z/, :hex_int_string],
|
|
@@ -77,6 +78,40 @@ module Rigor
|
|
|
77
78
|
Type::Combinator.public_send(rule.last)
|
|
78
79
|
end
|
|
79
80
|
|
|
81
|
+
# Whole-receiver regime (#164). Where {for_capture_body} narrows a named-capture body —
|
|
82
|
+
# sound *without* anchors because the capture boundary is itself the anchor — this maps a
|
|
83
|
+
# WHOLE regex `source` (as from `Regexp#source` or a literal `RegularExpressionNode`)
|
|
84
|
+
# matched against an entire string via `String#match?` / `=~`. The predicate proves a
|
|
85
|
+
# property of the whole string only when the pattern is fully anchored, so a required
|
|
86
|
+
# leading `\A` **and** trailing `\z` are load-bearing: both must be present, and the inner
|
|
87
|
+
# body must be one of the curated single-char-class shapes {for_capture_body} already
|
|
88
|
+
# audits.
|
|
89
|
+
#
|
|
90
|
+
# Deliberately rejected (each admits a string the carrier would misdescribe):
|
|
91
|
+
#
|
|
92
|
+
# - bare / one-sided anchoring (`\d+`, `\A\d+`, `\d+\z`) — matches a substring, not the
|
|
93
|
+
# whole string;
|
|
94
|
+
# - `\Z` (capital) — anchors before an optional trailing newline, so `"12\n"` matches
|
|
95
|
+
# `/\A\d+\Z/` yet is not a decimal-int-string;
|
|
96
|
+
# - line anchors `^` / `$` — match per-line, so `"12\nabc"` matches `/^\d+$/`;
|
|
97
|
+
# - any inner body outside the curated table (`\w+`, alternation, multi-class).
|
|
98
|
+
#
|
|
99
|
+
# Extended (`//x`) mode is the caller's concern — the char-class table cannot see the
|
|
100
|
+
# free-whitespace/`#`-comment flag from the `source` alone, so consumers bail on it before
|
|
101
|
+
# calling here.
|
|
102
|
+
#
|
|
103
|
+
# @param source [String, nil] the full regex source string.
|
|
104
|
+
# @return [Rigor::Type, nil] the matching imported refinement carrier, or `nil`.
|
|
105
|
+
def for_whole_pattern(source)
|
|
106
|
+
return nil if source.nil?
|
|
107
|
+
return nil unless source.start_with?('\A') && source.end_with?('\z')
|
|
108
|
+
|
|
109
|
+
inner = source[2...-2]
|
|
110
|
+
return nil if inner.nil? || inner.empty?
|
|
111
|
+
|
|
112
|
+
for_capture_body(inner)
|
|
113
|
+
end
|
|
114
|
+
|
|
80
115
|
# Filters the bounded-quantifier forms to ones whose lower bound is at least 1 and
|
|
81
116
|
# whose upper bound (if any) is at least the lower bound. Without this, `\d{0,5}` would
|
|
82
117
|
# be accepted even though it admits the empty string, which is not a valid
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
require "digest"
|
|
4
4
|
require "json"
|
|
5
5
|
require_relative "../value_semantics"
|
|
6
|
+
require_relative "file_digest"
|
|
6
7
|
|
|
7
8
|
module Rigor
|
|
8
9
|
module Cache
|
|
@@ -26,7 +27,13 @@ module Rigor
|
|
|
26
27
|
# v4: ADR-60 WD3 added the `globs` slot ({GlobEntry}) for the record-and-validate plugin-producer cache;
|
|
27
28
|
# the new slot changes `#to_canonical_hash` (and is Marshal-dumped inside `fetch_or_validate` entry
|
|
28
29
|
# pairs), so entries written by an older Rigor must read as misses.
|
|
29
|
-
|
|
30
|
+
# v5: ADR-87 WD1/WD2 changed the dependency-side freshness format — {FileEntry} gains the `:stat`
|
|
31
|
+
# comparator (a packed `digest + size + mtime_ns + ctime_ns + inode + recording_instant` tuple) and
|
|
32
|
+
# {GlobEntry}'s aggregate signature is now a SHA-256 over per-file STAT tuples rather than per-file
|
|
33
|
+
# content digests. Old entries must read as misses so the first writable run rebuilds them in the new
|
|
34
|
+
# format for a clean one-shot migration (the #57 marker discipline: the bump clears the root and
|
|
35
|
+
# reclaims the unreadable bytes).
|
|
36
|
+
SCHEMA_VERSION = 5
|
|
30
37
|
|
|
31
38
|
# Per-slot entry value objects. Constructors validate enums / required fields and freeze the resulting
|
|
32
39
|
# struct so no caller can mutate after the entry is in a Descriptor.
|
|
@@ -34,7 +41,13 @@ module Rigor
|
|
|
34
41
|
class FileEntry
|
|
35
42
|
include Rigor::ValueSemantics
|
|
36
43
|
|
|
37
|
-
|
|
44
|
+
# `:stat` (ADR-87 WD1) is the stat-then-digest comparator for the validation-only dependency
|
|
45
|
+
# descriptor; its `value` packs the content digest PLUS a `(size, mtime_ns, ctime_ns, inode)` stat
|
|
46
|
+
# tuple and the run's recording instant, so validation can skip the SHA-256 when the stat is unmoved.
|
|
47
|
+
# It MUST NOT be placed in a descriptor used as a cache KEY (its value carries machine-local,
|
|
48
|
+
# per-run-nondeterministic stat data); {RbsDescriptor.build}'s env-cache `files` therefore stay
|
|
49
|
+
# `:digest`, while the {Runner} run-dependency descriptor and plugin {IoBoundary} reads use `:stat`.
|
|
50
|
+
VALID_COMPARATORS = %i[digest stat mtime exists].freeze
|
|
38
51
|
|
|
39
52
|
attr_reader :path, :comparator, :value
|
|
40
53
|
|
|
@@ -52,6 +65,16 @@ module Rigor
|
|
|
52
65
|
freeze
|
|
53
66
|
end
|
|
54
67
|
|
|
68
|
+
# ADR-87 WD1 — builds a `:stat` entry, packing the supplied content `digest` with the file's live stat
|
|
69
|
+
# tuple. Falls back to a plain `:digest` entry when the file cannot be stat-ed (a race between the
|
|
70
|
+
# digest and the stat), so the resulting entry is always valid to re-validate.
|
|
71
|
+
def self.stat(path:, digest:)
|
|
72
|
+
packed = FileDigest.pack_stat(path, digest)
|
|
73
|
+
return new(path: path, comparator: :digest, value: digest) if packed.nil?
|
|
74
|
+
|
|
75
|
+
new(path: path, comparator: :stat, value: packed)
|
|
76
|
+
end
|
|
77
|
+
|
|
55
78
|
def to_h
|
|
56
79
|
{ "path" => path, "comparator" => comparator.to_s, "value" => value }
|
|
57
80
|
end
|
|
@@ -147,14 +170,27 @@ module Rigor
|
|
|
147
170
|
end
|
|
148
171
|
end
|
|
149
172
|
|
|
150
|
-
# ADR-60 WD3 — one glob's-worth of watched files,
|
|
151
|
-
#
|
|
152
|
-
# `"<path>\0<sha256
|
|
153
|
-
#
|
|
154
|
-
#
|
|
173
|
+
# ADR-60 WD3 / ADR-87 WD2 — one glob's-worth of watched files, covering content change, addition, AND
|
|
174
|
+
# removal in a single aggregate `value`. Pre-ADR-87 the value was a SHA-256 over sorted
|
|
175
|
+
# `"<path>\0<content-sha256>\n"` rows — sound but it re-READ + re-hashed every file's CONTENT on every
|
|
176
|
+
# validation (the ~40 MB gitlab plugin-prepass tax). ADR-87 WD2 keeps the identical one-hash shape (small
|
|
177
|
+
# to Marshal, deterministic, composition-safe) but hashes STAT TUPLES instead of content: the value is a
|
|
178
|
+
# SHA-256 over sorted `"<path>\0<size>\0<mtime_ns>\0<ctime_ns>\0<inode>\n"` rows. {.fresh?} re-globs +
|
|
179
|
+
# re-stats and compares — reading ZERO file-content bytes on an unchanged tree — while any content edit
|
|
180
|
+
# (which moves mtime + ctime) still moves the signature. `RIGOR_STRICT_VALIDATION` / `cache.validation:
|
|
181
|
+
# digest` restores the content-hash signature for a filesystem whose stat cannot be trusted. A single
|
|
182
|
+
# aggregate hash is the right granularity here: a watched dependency is all-or-nothing (one changed file
|
|
183
|
+
# invalidates the producer's cache regardless), so per-file partial re-hashing bought nothing but a heavy
|
|
184
|
+
# per-file table to Marshal. The trade vs the old content signature is that a bare `touch` (moved stat,
|
|
185
|
+
# identical content) now invalidates the glob — rare, and only forces the same recompute the old form
|
|
186
|
+
# paid on EVERY run.
|
|
155
187
|
class GlobEntry
|
|
156
188
|
include Rigor::ValueSemantics
|
|
157
189
|
|
|
190
|
+
ROW_SEPARATOR = "\n"
|
|
191
|
+
FIELD_SEPARATOR = "\0"
|
|
192
|
+
private_constant :ROW_SEPARATOR, :FIELD_SEPARATOR
|
|
193
|
+
|
|
158
194
|
attr_reader :root, :pattern, :value
|
|
159
195
|
|
|
160
196
|
value_fields :root, :pattern, :value
|
|
@@ -168,27 +204,45 @@ module Rigor
|
|
|
168
204
|
|
|
169
205
|
# Builds the entry for the glob's CURRENT filesystem state.
|
|
170
206
|
def self.compute(root:, pattern:)
|
|
171
|
-
new(root: root, pattern: pattern, value:
|
|
207
|
+
new(root: root, pattern: pattern, value: signature_for(root: root, pattern: pattern))
|
|
172
208
|
end
|
|
173
209
|
|
|
174
|
-
# The
|
|
175
|
-
#
|
|
176
|
-
#
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
210
|
+
# The aggregate signature the entry's `value` carries: SHA-256 over the sorted per-file rows. In the
|
|
211
|
+
# default (`:stat`) mode a row is the file's `(path, size, mtime_ns, ctime_ns, inode)` tuple — NO
|
|
212
|
+
# content read; in strict mode a row is `(path, content-sha256)`, restoring the pre-ADR-87 authority.
|
|
213
|
+
# Per-file stat failures (a file vanishing between the glob and the stat) drop the row — same race
|
|
214
|
+
# posture as {Descriptor#file_entry_fresh?}. `Dir.glob` returns sorted entries by default so the row
|
|
215
|
+
# order — and therefore the signature — is stable.
|
|
216
|
+
def self.signature_for(root:, pattern:)
|
|
217
|
+
strict = FileDigest.strict_validation?
|
|
180
218
|
rows = Dir.glob(File.join(root, pattern)).filter_map do |path|
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
219
|
+
st = File.stat(path)
|
|
220
|
+
next nil unless st.file?
|
|
221
|
+
|
|
222
|
+
if strict
|
|
223
|
+
"#{path}#{FIELD_SEPARATOR}#{FileDigest.hexdigest(path)}#{ROW_SEPARATOR}"
|
|
224
|
+
else
|
|
225
|
+
"#{path}#{FIELD_SEPARATOR}#{st.size}#{FIELD_SEPARATOR}#{FileDigest.ns_of(st.mtime)}" \
|
|
226
|
+
"#{FIELD_SEPARATOR}#{FileDigest.ns_of(st.ctime)}#{FIELD_SEPARATOR}#{st.ino}#{ROW_SEPARATOR}"
|
|
227
|
+
end
|
|
184
228
|
rescue StandardError
|
|
185
229
|
nil
|
|
186
230
|
end
|
|
187
231
|
Digest::SHA256.hexdigest(rows.join)
|
|
188
232
|
end
|
|
189
233
|
|
|
234
|
+
# ADR-87 WD2 — fresh iff re-globbing + re-stat-ing reproduces the recorded signature. Zero file-content
|
|
235
|
+
# bytes are read in the default mode. Any failure reads as stale (recompute), never a crash. A stored
|
|
236
|
+
# signature recorded under the opposite validation mode simply mismatches and recomputes.
|
|
237
|
+
def self.fresh?(entry)
|
|
238
|
+
signature_for(root: entry.root, pattern: entry.pattern) == entry.value
|
|
239
|
+
rescue StandardError
|
|
240
|
+
false
|
|
241
|
+
end
|
|
242
|
+
|
|
190
243
|
# Composition key — {.compose} unions per (root, pattern) slot; two contributions for the same slot
|
|
191
|
-
# must agree on the
|
|
244
|
+
# must agree on the value or {Conflict} is raised. Within one run the same glob stat-reads identically,
|
|
245
|
+
# so contributions never conflict.
|
|
192
246
|
def slot_key
|
|
193
247
|
"#{root}\0#{pattern}"
|
|
194
248
|
end
|
|
@@ -229,10 +283,12 @@ module Rigor
|
|
|
229
283
|
globs.all? { |entry| glob_entry_fresh?(entry) }
|
|
230
284
|
end
|
|
231
285
|
|
|
232
|
-
# File-comparator strictness ordering. `:
|
|
233
|
-
#
|
|
234
|
-
#
|
|
235
|
-
|
|
286
|
+
# File-comparator strictness ordering. `:stat` is strictest (it carries the content digest AND a stat
|
|
287
|
+
# tuple, so it validates content-authoritatively while short-circuiting on an unmoved stat); `:digest` is
|
|
288
|
+
# deterministic across machines; `:mtime` is cheaper but local; `:exists` is the weakest signal. Ranking
|
|
289
|
+
# `:stat` and `:digest` apart guarantees a path contributed under both never raises a value {Conflict}
|
|
290
|
+
# (their `value` strings differ by construction) — the stricter one wins and its value is used.
|
|
291
|
+
COMPARATOR_STRICTNESS = { stat: 3, digest: 2, mtime: 1, exists: 0 }.freeze
|
|
236
292
|
private_constant :COMPARATOR_STRICTNESS
|
|
237
293
|
|
|
238
294
|
# Composes any number of descriptors into a single descriptor whose slots are the union of the inputs'
|
|
@@ -313,7 +369,13 @@ module Rigor
|
|
|
313
369
|
def file_entry_fresh?(entry)
|
|
314
370
|
case entry.comparator
|
|
315
371
|
when :digest
|
|
316
|
-
|
|
372
|
+
# `FileDigest` serves a per-run memo when a run is active (validation digests overlap the
|
|
373
|
+
# dependency descriptor's), and falls back to a direct digest otherwise — same value either way.
|
|
374
|
+
File.file?(entry.path) && FileDigest.hexdigest(entry.path) == entry.value
|
|
375
|
+
when :stat
|
|
376
|
+
# ADR-87 WD1 — stat-then-digest: stat first, re-hash only when the tuple moved (or the racy window
|
|
377
|
+
# fires, or strict mode forces it). A stat failure raises and the outer rescue reads it as stale.
|
|
378
|
+
FileDigest.stat_fresh?(entry.path, entry.value)
|
|
317
379
|
when :mtime
|
|
318
380
|
File.exist?(entry.path) && File.mtime(entry.path).to_i.to_s == entry.value
|
|
319
381
|
when :exists
|
|
@@ -325,12 +387,10 @@ module Rigor
|
|
|
325
387
|
false
|
|
326
388
|
end
|
|
327
389
|
|
|
328
|
-
# ADR-60 WD3 —
|
|
329
|
-
# reads as stale (recompute), never a crash.
|
|
390
|
+
# ADR-60 WD3 / ADR-87 WD2 — stat-validates the entry's per-file table against the live tree, re-hashing
|
|
391
|
+
# only stat-moved files. Any failure reads as stale (recompute), never a crash.
|
|
330
392
|
def glob_entry_fresh?(entry)
|
|
331
|
-
GlobEntry.
|
|
332
|
-
rescue StandardError
|
|
333
|
-
false
|
|
393
|
+
GlobEntry.fresh?(entry)
|
|
334
394
|
end
|
|
335
395
|
|
|
336
396
|
def sort_entries(entries, key)
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
|
|
5
|
+
module Rigor
|
|
6
|
+
module Cache
|
|
7
|
+
# Per-run content-digest memo AND the ADR-87 stat-then-digest freshness choke point. Within a single
|
|
8
|
+
# `Rigor::Analysis::Runner#run`, the same absolute path is SHA-256'd at most once — across every descriptor
|
|
9
|
+
# that digests files: the run-diagnostics dependency descriptor ({Runner#analyzed_file_entries}), the
|
|
10
|
+
# ADR-45 {Descriptor#fresh?} validation of that descriptor, the RBS signature tree
|
|
11
|
+
# ({RbsDescriptor.file_entries}), and every plugin producer's `watch:`-glob validation
|
|
12
|
+
# ({Descriptor::GlobEntry}). Those sets overlap heavily (an analyzed file is often also inside a plugin's
|
|
13
|
+
# watch glob; the RBS tree is digested by both the runner and the RBS producers), so the memo removes the
|
|
14
|
+
# redundant re-hashing that dominates warm-hit validation.
|
|
15
|
+
#
|
|
16
|
+
# ADR-87 WD1 layers a stat tier ON TOP of the memo: a `:stat` {Descriptor::FileEntry} packs a
|
|
17
|
+
# `(digest, size, mtime_ns, ctime_ns, inode)` tuple plus the run's recording instant, and {.stat_fresh?}
|
|
18
|
+
# validates it by stat-ing the file first and only falling back to {.hexdigest} (this memo) when the tuple
|
|
19
|
+
# moved or the racy window fires. On a true null build an unchanged file is a single `File::Stat` call and
|
|
20
|
+
# zero SHA-256 bytes. The SHA-256 digest remains the sole change AUTHORITY — the stat tier only decides
|
|
21
|
+
# whether the digest needs recomputing.
|
|
22
|
+
#
|
|
23
|
+
# Soundness: the memo + recording-instant + strict flag are process-local (thread-locals, so Ractor-safe
|
|
24
|
+
# and never crossing a fork boundary), never persisted to disk, and scoped to exactly one run via
|
|
25
|
+
# {.with_run} (which installs a fresh table and restores the previous one on exit — a second run, or a run
|
|
26
|
+
# that re-reads a file the user edited between runs, gets a clean table). Within a single run the analyzer
|
|
27
|
+
# already assumes the filesystem is stable — {Descriptor#fresh?} validates the whole dependency set against
|
|
28
|
+
# the live tree in one pass and never re-checks mid-run — so caching a coherent per-run snapshot changes
|
|
29
|
+
# nothing about what a run observes. Outside a run scope (no active table) every call digests directly,
|
|
30
|
+
# identical to a bare `Digest::SHA256.file`.
|
|
31
|
+
module FileDigest
|
|
32
|
+
MEMO_KEY = :rigor_cache_file_digest_memo
|
|
33
|
+
INSTANT_KEY = :rigor_cache_recording_instant
|
|
34
|
+
STRICT_KEY = :rigor_cache_strict_validation
|
|
35
|
+
private_constant :MEMO_KEY, :INSTANT_KEY, :STRICT_KEY
|
|
36
|
+
|
|
37
|
+
# Set in the environment to force the strict digest-always validation path for a single run, regardless
|
|
38
|
+
# of the `cache.validation` config setting (the env wins). The escape hatch for a filesystem whose stat
|
|
39
|
+
# tuples cannot be trusted (a coarse-timestamp mount, a restored-from-backup tree).
|
|
40
|
+
STRICT_ENV = "RIGOR_STRICT_VALIDATION"
|
|
41
|
+
|
|
42
|
+
# Runs `block` with a fresh per-run digest table installed, restoring whatever table was active before
|
|
43
|
+
# (nil on the normal top-level run; a parent table only if a run were somehow nested). Always restores,
|
|
44
|
+
# even on a raise, so a failed run never leaks its table into the next one. The recording instant is
|
|
45
|
+
# captured at run start — a file whose recorded mtime is not strictly older than it is treated as racy
|
|
46
|
+
# (always re-hashed), catching a file edited during the analysis window. `strict:` forces the
|
|
47
|
+
# digest-always path for `cache.validation: digest`.
|
|
48
|
+
def self.with_run(strict: false)
|
|
49
|
+
previous_memo = Thread.current[MEMO_KEY]
|
|
50
|
+
previous_instant = Thread.current[INSTANT_KEY]
|
|
51
|
+
previous_strict = Thread.current[STRICT_KEY]
|
|
52
|
+
Thread.current[MEMO_KEY] = {}
|
|
53
|
+
Thread.current[INSTANT_KEY] = now_ns
|
|
54
|
+
Thread.current[STRICT_KEY] = strict
|
|
55
|
+
yield
|
|
56
|
+
ensure
|
|
57
|
+
Thread.current[MEMO_KEY] = previous_memo
|
|
58
|
+
Thread.current[INSTANT_KEY] = previous_instant
|
|
59
|
+
Thread.current[STRICT_KEY] = previous_strict
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# The SHA-256 hex digest of `path`'s current content. Served from the active per-run table when one is
|
|
63
|
+
# installed (computing + storing it on first request for that path), otherwise computed directly. A
|
|
64
|
+
# read failure (missing / unreadable file) propagates exactly as `Digest::SHA256.file` would and is NOT
|
|
65
|
+
# memoised, so a caller's own rescue path is unchanged.
|
|
66
|
+
def self.hexdigest(path)
|
|
67
|
+
memo = Thread.current[MEMO_KEY]
|
|
68
|
+
return Digest::SHA256.file(path).hexdigest if memo.nil?
|
|
69
|
+
|
|
70
|
+
memo[path] ||= Digest::SHA256.file(path).hexdigest
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# @return [Boolean] whether digest-always validation is forced for this run. The env var wins over the
|
|
74
|
+
# run-scoped config flag so an operator can bypass the stat tier without editing `.rigor.yml`.
|
|
75
|
+
def self.strict_validation?
|
|
76
|
+
return true if ENV[STRICT_ENV] == "1"
|
|
77
|
+
|
|
78
|
+
Thread.current[STRICT_KEY] == true
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# ADR-87 WD1 — packs the `:stat` FileEntry value: `"<digest> <size> <mtime_ns> <ctime_ns> <inode>
|
|
82
|
+
# <recording_instant_ns>"`. The digest is supplied by the caller (already computed via {.hexdigest} or
|
|
83
|
+
# from in-memory content). Returns nil when the file cannot be stat-ed (a race between the digest and the
|
|
84
|
+
# stat), so the caller can fall back to a plain `:digest` entry.
|
|
85
|
+
def self.pack_stat(path, digest)
|
|
86
|
+
st = File.stat(path)
|
|
87
|
+
"#{digest} #{st.size} #{ns(st.mtime)} #{ns(st.ctime)} #{st.ino} #{recording_instant_ns}"
|
|
88
|
+
rescue SystemCallError
|
|
89
|
+
nil
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# ADR-87 WD1 — validates a `:stat` FileEntry value against the live filesystem. Stats the file first;
|
|
93
|
+
# when the `(size, mtime_ns, ctime_ns, inode)` tuple is unchanged AND the entry is not racy, the file is
|
|
94
|
+
# fresh with zero bytes hashed. When the tuple moved or the entry is racy, the recorded digest is the
|
|
95
|
+
# authority: the file is re-hashed and fresh iff the digest still matches (a touch — a moved stat over
|
|
96
|
+
# identical content — therefore validates as fresh). Any stat failure (missing / unreadable file) raises
|
|
97
|
+
# and the caller reads it as stale. In strict mode the stat tier is skipped entirely.
|
|
98
|
+
def self.stat_fresh?(path, packed)
|
|
99
|
+
parsed = parse_stat(packed)
|
|
100
|
+
return false if parsed.nil?
|
|
101
|
+
|
|
102
|
+
digest = parsed[0]
|
|
103
|
+
return hexdigest(path) == digest if strict_validation?
|
|
104
|
+
|
|
105
|
+
st = File.stat(path)
|
|
106
|
+
return true if !racy?(parsed) && tuple_matches?(st, parsed)
|
|
107
|
+
|
|
108
|
+
hexdigest(path) == digest
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def self.recording_instant_ns
|
|
112
|
+
Thread.current[INSTANT_KEY] || now_ns
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# value layout: [digest, size, mtime_ns, ctime_ns, inode, recording_instant_ns]
|
|
116
|
+
def self.parse_stat(packed)
|
|
117
|
+
parts = packed.to_s.split
|
|
118
|
+
return nil unless parts.size == 6
|
|
119
|
+
|
|
120
|
+
[parts[0], *parts[1..].map { |n| Integer(n, 10) }]
|
|
121
|
+
rescue ArgumentError
|
|
122
|
+
nil
|
|
123
|
+
end
|
|
124
|
+
private_class_method :parse_stat
|
|
125
|
+
|
|
126
|
+
def self.tuple_matches?(stat, parsed)
|
|
127
|
+
stat.size == parsed[1] && ns(stat.mtime) == parsed[2] &&
|
|
128
|
+
ns(stat.ctime) == parsed[3] && stat.ino == parsed[4]
|
|
129
|
+
end
|
|
130
|
+
private_class_method :tuple_matches?
|
|
131
|
+
|
|
132
|
+
# An entry is racy when its recorded mtime is not strictly older than the run's recording instant — the
|
|
133
|
+
# file was (or may have been) modified within the recording window, so its stat tuple cannot be trusted
|
|
134
|
+
# to have settled. Such an entry always re-hashes. On a nanosecond-resolution filesystem this fires only
|
|
135
|
+
# for a file edited during the analysis run itself.
|
|
136
|
+
def self.racy?(parsed)
|
|
137
|
+
parsed[2] >= parsed[5]
|
|
138
|
+
end
|
|
139
|
+
private_class_method :racy?
|
|
140
|
+
|
|
141
|
+
# Full-nanosecond integer for a `Time` (from `File::Stat#mtime`/`#ctime`). Public so {GlobEntry} packs
|
|
142
|
+
# its per-file rows with the identical resolution the FileEntry tuple uses.
|
|
143
|
+
def self.ns_of(time)
|
|
144
|
+
(time.tv_sec * 1_000_000_000) + time.tv_nsec
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def self.ns(time)
|
|
148
|
+
ns_of(time)
|
|
149
|
+
end
|
|
150
|
+
private_class_method :ns
|
|
151
|
+
|
|
152
|
+
def self.now_ns
|
|
153
|
+
Process.clock_gettime(Process::CLOCK_REALTIME, :nanosecond)
|
|
154
|
+
end
|
|
155
|
+
private_class_method :now_ns
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
end
|