rigortype 0.4.2 → 0.4.4
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 +1 -1
- data/data/core_overlay/ostruct.rbs +40 -0
- data/docs/handbook/11-sig-gen.md +28 -5
- data/docs/manual/02-cli-reference.md +23 -4
- data/docs/manual/03-configuration.md +21 -8
- data/docs/manual/04-diagnostics.md +44 -2
- data/docs/manual/05-inspecting-types.md +50 -0
- data/docs/manual/07-plugins.md +1 -1
- data/docs/manual/10-mcp-server.md +32 -3
- data/docs/manual/12-caching.md +18 -1
- data/docs/manual/14-rails-quickstart.md +2 -2
- data/docs/manual/18-removing-dead-code.md +26 -9
- data/docs/manual/plugins/rigor-rails-routes.md +2 -2
- data/docs/manual/plugins/rigor-shoulda-matchers.md +4 -1
- data/exe/rigor +12 -17
- data/lib/rigor/analysis/check_rules/rule_ids.rb +64 -0
- data/lib/rigor/analysis/check_rules.rb +68 -57
- data/lib/rigor/analysis/effects_cache_probe.rb +1 -1
- data/lib/rigor/analysis/incremental.rb +12 -0
- data/lib/rigor/analysis/incremental_run_slot.rb +3 -3
- data/lib/rigor/analysis/incremental_session.rb +207 -40
- data/lib/rigor/analysis/path_expansion.rb +62 -6
- data/lib/rigor/analysis/plugin_fact_fingerprint.rb +124 -37
- data/lib/rigor/analysis/reachability/graph.rb +139 -15
- data/lib/rigor/analysis/reachability/scan.rb +374 -173
- data/lib/rigor/analysis/reachability/scan_cache.rb +4 -2
- data/lib/rigor/analysis/rule_catalog.rb +6 -1
- data/lib/rigor/analysis/run_cache_key.rb +31 -7
- data/lib/rigor/analysis/run_cache_probe.rb +6 -4
- data/lib/rigor/analysis/run_stats.rb +28 -6
- data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +74 -13
- data/lib/rigor/analysis/runner/pool_coordinator.rb +133 -19
- data/lib/rigor/analysis/runner/project_pre_passes.rb +46 -1
- data/lib/rigor/analysis/runner.rb +62 -18
- data/lib/rigor/analysis/severity_stamp.rb +34 -1
- data/lib/rigor/analysis/worker_session.rb +58 -2
- data/lib/rigor/cache/descriptor.rb +40 -6
- data/lib/rigor/cache/engine_source.rb +21 -7
- data/lib/rigor/cache/incremental_snapshot.rb +56 -12
- data/lib/rigor/cli/check_command.rb +138 -39
- data/lib/rigor/cli/docs_command.rb +5 -4
- data/lib/rigor/cli/lens_command.rb +144 -0
- data/lib/rigor/cli/lsp_command.rb +5 -2
- data/lib/rigor/cli/mcp_command.rb +1 -1
- data/lib/rigor/cli/skill_command.rb +3 -3
- data/lib/rigor/cli/skill_describe.rb +1 -1
- data/lib/rigor/cli/type_of_command.rb +2 -1
- data/lib/rigor/cli/unused_command.rb +157 -24
- data/lib/rigor/cli.rb +30 -4
- data/lib/rigor/config_audit.rb +111 -15
- data/lib/rigor/configuration/dependencies.rb +19 -11
- data/lib/rigor/configuration.rb +15 -0
- data/lib/rigor/environment/bundle_sig_discovery.rb +11 -7
- data/lib/rigor/environment/installed_gem_set.rb +38 -11
- data/lib/rigor/environment/lockfile_resolver.rb +37 -6
- data/lib/rigor/environment/missing_gem_constant_index.rb +22 -7
- data/lib/rigor/environment/rbs_loader.rb +79 -10
- data/lib/rigor/environment/required_features.rb +4 -2
- data/lib/rigor/environment.rb +12 -5
- data/lib/rigor/inference/block_call_timing.rb +5 -0
- data/lib/rigor/inference/builtins/cruby_definers.rb +22 -5
- data/lib/rigor/inference/captured_locals.rb +69 -6
- data/lib/rigor/inference/definer_resolution.rb +16 -7
- data/lib/rigor/inference/expression_typer.rb +117 -29
- data/lib/rigor/inference/guard_rebinding.rb +7 -2
- data/lib/rigor/inference/in_effect_refinements/scope_reads.rb +21 -17
- data/lib/rigor/inference/last_line/self_evidence.rb +1 -1
- data/lib/rigor/inference/match_rebinding.rb +1 -1
- data/lib/rigor/inference/method_dispatcher/argument_nodes.rb +25 -0
- data/lib/rigor/inference/method_dispatcher/kernel_dispatch.rb +3 -2
- data/lib/rigor/inference/method_dispatcher/match_data_folding.rb +2 -2
- data/lib/rigor/inference/method_dispatcher/member_shape_projection.rb +3 -3
- data/lib/rigor/inference/method_dispatcher/optional_count.rb +63 -0
- data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +4 -16
- data/lib/rigor/inference/method_dispatcher/struct_folding.rb +4 -7
- data/lib/rigor/inference/method_dispatcher/struct_materialization.rb +8 -26
- data/lib/rigor/inference/method_dispatcher/unit_denominator_rational.rb +67 -0
- data/lib/rigor/inference/method_dispatcher.rb +52 -3
- data/lib/rigor/inference/module_function_state.rb +80 -13
- data/lib/rigor/inference/narrowing.rb +184 -68
- data/lib/rigor/inference/project_patched_definedness_guard.rb +86 -0
- data/lib/rigor/inference/project_patched_methods.rb +2 -2
- data/lib/rigor/inference/project_patched_scanner.rb +38 -24
- data/lib/rigor/inference/refined_dispatch/class_object.rb +350 -0
- data/lib/rigor/inference/refined_dispatch/own_method.rb +112 -40
- data/lib/rigor/inference/refined_dispatch.rb +62 -14
- data/lib/rigor/inference/reflective_ivar_write.rb +116 -0
- data/lib/rigor/inference/repeated_or_writes.rb +416 -1
- data/lib/rigor/inference/scope_indexer.rb +565 -61
- data/lib/rigor/inference/self_call_ivar_writes.rb +341 -0
- data/lib/rigor/inference/statement_evaluator.rb +307 -60
- data/lib/rigor/inference/untyped_rejoin.rb +75 -0
- data/lib/rigor/language_server/project_context.rb +56 -5
- data/lib/rigor/lens/anchor.rb +115 -0
- data/lib/rigor/lens/contract_reader.rb +133 -0
- data/lib/rigor/lens/document.rb +113 -0
- data/lib/rigor/lens/file_reader.rb +802 -0
- data/lib/rigor/lens/members.rb +203 -0
- data/lib/rigor/lens/parameters.rb +91 -0
- data/lib/rigor/lens/plugins.rb +40 -0
- data/lib/rigor/lens/run.rb +322 -0
- data/lib/rigor/lens/slot.rb +79 -0
- data/lib/rigor/lens.rb +41 -0
- data/lib/rigor/mcp/server.rb +61 -20
- data/lib/rigor/plugin/box_probe.rb +154 -7
- data/lib/rigor/project_environment.rb +23 -1
- data/lib/rigor/protection/mutation_cache.rb +2 -1
- data/lib/rigor/scope.rb +33 -17
- data/lib/rigor/sig_gen/generator.rb +45 -5
- data/lib/rigor/sig_gen/layout_index.rb +36 -0
- data/lib/rigor/sig_gen/method_candidate.rb +8 -0
- data/lib/rigor/sig_gen/path_mapper.rb +18 -2
- data/lib/rigor/sig_gen/writer.rb +47 -34
- data/lib/rigor/version.rb +1 -1
- data/plugins/rigor-active-model-serializers/lib/rigor/plugin/active_model_serializers/serializer_discoverer.rb +29 -3
- data/plugins/rigor-active-model-serializers/lib/rigor/plugin/active_model_serializers.rb +5 -1
- data/plugins/rigor-dry-schema/lib/rigor/plugin/dry_schema.rb +5 -1
- data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation.rb +5 -1
- data/plugins/rigor-ffi/lib/rigor/plugin/ffi/discoverer.rb +28 -6
- data/plugins/rigor-ffi/lib/rigor/plugin/ffi.rb +1 -1
- data/plugins/rigor-graphql/lib/rigor/plugin/graphql.rb +5 -1
- data/plugins/rigor-rspec/lib/rigor/plugin/rspec/matcher_analyzer.rb +155 -12
- data/plugins/rigor-rspec/lib/rigor/plugin/rspec.rb +10 -8
- data/sig/rigor/scope.rbs +3 -0
- data/sig/rigor.rbs +6 -3
- data/skills/rigor-project-init/references/02-configure.md +2 -2
- metadata +21 -1
|
@@ -38,7 +38,13 @@ module Rigor
|
|
|
38
38
|
# gate) can report what was re-analyzed versus served from cache. `added` / `removed` carry the
|
|
39
39
|
# structural delta so {#run_incremental} (ADR-87 WD3) can recognise a zero-change recheck and skip the
|
|
40
40
|
# unconditional snapshot rewrite.
|
|
41
|
-
|
|
41
|
+
# `rebaselined` (#1898) is true when the recheck found its served effect collections stale and answered with a
|
|
42
|
+
# full baseline instead; see {#rebaseline_recheck}.
|
|
43
|
+
Recheck = Data.define(:diagnostics, :changed, :added, :removed, :affected, :reused, :templates, :rebaselined) do
|
|
44
|
+
def initialize(rebaselined: false, **rest)
|
|
45
|
+
super
|
|
46
|
+
end
|
|
47
|
+
|
|
42
48
|
# A recheck that changed, added, and removed nothing — the session state is byte-equivalent to the
|
|
43
49
|
# snapshot that was restored, so persisting it again would only rewrite identical bytes.
|
|
44
50
|
def no_change?
|
|
@@ -68,9 +74,14 @@ module Rigor
|
|
|
68
74
|
# `--verify-incremental` gate use) keeps the sequential recording path bit-for-bit unchanged. The fork
|
|
69
75
|
# pool records each worker's cross-file reads and marshals them back (PoolCoordinator), so the
|
|
70
76
|
# dependency graph a pooled recheck rebuilds equals the sequential one.
|
|
71
|
-
def initialize(configuration:, paths: nil, environment: nil, cache_store: nil, plugin_requirer: nil,
|
|
72
|
-
workers: 0, buffer: nil)
|
|
77
|
+
def initialize(configuration:, paths: nil, environment: nil, cache_store: nil, plugin_requirer: nil, # rubocop:disable Metrics/ParameterLists,Metrics/AbcSize,Metrics/MethodLength
|
|
78
|
+
workers: 0, buffer: nil, explain: false, on_file_indexed: nil)
|
|
73
79
|
@configuration = configuration
|
|
80
|
+
# ADR-113 WD2 — `rigor lens`: {#force_targets=} names files a {#recheck} analyses whether or not they changed,
|
|
81
|
+
# in this process, so `on_file_indexed` (see {Runner}) sees their seeded index. Neither moves a diagnostic:
|
|
82
|
+
# a forced file re-analyses under the same seeds its cached rows were computed with.
|
|
83
|
+
@force_targets = Set.new
|
|
84
|
+
@on_file_indexed = on_file_indexed
|
|
74
85
|
@paths = paths
|
|
75
86
|
# Editor mode option B (#146) — the in-flight buffer, threaded into every internal Runner so the
|
|
76
87
|
# pre-passes and the closure re-analysis read the editor's bytes at the logical path. A session
|
|
@@ -81,6 +92,7 @@ module Rigor
|
|
|
81
92
|
@cache_store = cache_store
|
|
82
93
|
@plugin_requirer = plugin_requirer
|
|
83
94
|
@workers = workers
|
|
95
|
+
@explain = explain # #1533: threaded into every Runner; keys the snapshot, and no slot is written
|
|
84
96
|
# ADR-85 WD2 — per-file discovery seed bundles keyed by logical path. A cold baseline builds them; a
|
|
85
97
|
# warm recheck folds them (re-walking only changed files) and refreshes the set. Ride the snapshot.
|
|
86
98
|
@seed_bundles = {}
|
|
@@ -122,12 +134,31 @@ module Rigor
|
|
|
122
134
|
# banner + `--cache-stats`) reads after `#run_incremental`. `@last_runner` is the analysis runner the
|
|
123
135
|
# post-hoc fingerprint reads from; `@plugin_fact_reusable` is the computed decision object.
|
|
124
136
|
@plugin_fact_digest = nil
|
|
125
|
-
@
|
|
137
|
+
@opaque_reasons = {}.freeze
|
|
138
|
+
@fact_store_unreadable = false
|
|
126
139
|
@fact_surface_invalidated = false
|
|
127
140
|
@last_runner = nil
|
|
128
141
|
@plugin_fact_reusable = nil
|
|
129
142
|
end
|
|
130
143
|
|
|
144
|
+
# ADR-113 WD2 — the paths (as {#project_files} spells them) every later {#recheck} analyses in this process.
|
|
145
|
+
def force_targets=(paths)
|
|
146
|
+
@force_targets = paths.to_set
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# The project files a run of this session analyses, as the runner expands the configured paths.
|
|
150
|
+
def project_files
|
|
151
|
+
current_files
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# The per-file discovery bundles of the last run, keyed by path (ADR-85 WD2).
|
|
155
|
+
attr_reader :seed_bundles
|
|
156
|
+
|
|
157
|
+
# The project files `path`'s analysis read from at the last run that analysed it (ADR-46 slice 1).
|
|
158
|
+
def dependency_sources(path)
|
|
159
|
+
@sources.fetch(path, Set.new)
|
|
160
|
+
end
|
|
161
|
+
|
|
131
162
|
# The project files analyzed at the last baseline / recheck — the set a verify pass partitions and the
|
|
132
163
|
# merge subtracts the affected closure from.
|
|
133
164
|
def analyzed_files
|
|
@@ -183,6 +214,7 @@ module Rigor
|
|
|
183
214
|
# ADR-103 WD13 / #382 — a baseline collects every file, so its collections are the whole world.
|
|
184
215
|
@effect_collections = runner.effect_collections_by_path
|
|
185
216
|
@effects_identity = current_effects_identity
|
|
217
|
+
@effect_plugin_digest = effect_plugin_digest_of(runner)
|
|
186
218
|
@cache = per_file(runner.per_file_diagnostics)
|
|
187
219
|
@digests = @analyzed.to_h { |path| [path, pack_digest(path)] }
|
|
188
220
|
forget_unbound_digests
|
|
@@ -206,21 +238,20 @@ module Rigor
|
|
|
206
238
|
fresh_params = fresh_param_table(current, changed, added, removed)
|
|
207
239
|
param_files, param_pairs = param_seed_invalidation(fresh_params)
|
|
208
240
|
affected = affected_closure(changed, added, removed, param_files, param_pairs)
|
|
209
|
-
analyze_set = affected
|
|
210
|
-
reused = (current & previous) -
|
|
241
|
+
forced, analyze_set = forced_analyze_set(affected, current)
|
|
242
|
+
reused = (current & previous) - analyze_set.to_a
|
|
211
243
|
# The freshly collected table is handed to the runner so the run seeds from the SAME table the diff
|
|
212
244
|
# was decided on (and the collector runs once per recheck, not twice). The reused files' cached rows
|
|
213
245
|
# go in too, so the runner lists them in the full run's file order (#1524).
|
|
214
246
|
runner = build_runner(analyze_only: analyze_set, record_dependencies: true,
|
|
215
|
-
param_inferred_types: fresh_params,
|
|
216
|
-
restored_run_level_rows: @run_level_rows,
|
|
217
|
-
served_per_file_diagnostics: @cache.slice(*reused))
|
|
247
|
+
param_inferred_types: fresh_params, **forced_target_options(forced),
|
|
248
|
+
restored_run_level_rows: @run_level_rows, **served_state(reused))
|
|
218
249
|
merged = run_runner(runner).diagnostics
|
|
219
|
-
|
|
250
|
+
return rebaseline_recheck(changed, added, removed) if served_effects_stale?(runner, reused)
|
|
251
|
+
|
|
220
252
|
absorb(runner, current, analyze_set, removed)
|
|
221
253
|
@param_table = fresh_params
|
|
222
|
-
|
|
223
|
-
removed: removed.to_set, affected: affected, reused: reused.to_set)
|
|
254
|
+
recheck_result(merged, [changed, added, removed], affected: affected, reused: reused.to_set, runner: runner)
|
|
224
255
|
end
|
|
225
256
|
|
|
226
257
|
# Editor mode option B (#146) — a whole-project recheck with the editor's buffer substituted for one
|
|
@@ -245,6 +276,9 @@ module Rigor
|
|
|
245
276
|
# latency this mode exists to avoid, so decline and let the caller drop to single-file scope.
|
|
246
277
|
break nil unless @plugin_fact_reusable.reusable_against?(restored.plugin_fact_digest)
|
|
247
278
|
break nil if source_rbs_unverified?(result)
|
|
279
|
+
# #1898 — a recheck that re-baselined over stale plugin effect facts ran its baseline with the buffer set,
|
|
280
|
+
# which analyses the buffer alone, so it is not a whole-project answer. Decline, as above.
|
|
281
|
+
break nil if result.rebaselined
|
|
248
282
|
|
|
249
283
|
result
|
|
250
284
|
end
|
|
@@ -316,6 +350,35 @@ module Rigor
|
|
|
316
350
|
closure.freeze
|
|
317
351
|
end
|
|
318
352
|
|
|
353
|
+
# Issue #1906 — a file that rewrites a constant's singleton names without declaring it
|
|
354
|
+
# (`D.singleton_class.alias_method :own, :keep`) contributes opaque singleton envelopes to the constant's bucket,
|
|
355
|
+
# but a reader's class edge names only the files that declared the constant when the reader last ran, and the
|
|
356
|
+
# class-declaration diff does not move while the constant stays in the file (`:own` becoming `:keep`, or the
|
|
357
|
+
# call becoming a `class D` reopening). So a declaration-unstable edit to such a file fires `class:<last
|
|
358
|
+
# segment>` for each constant that, before the edit, both sat in the file's singleton-source set and held a
|
|
359
|
+
# singleton envelope entry or an unpositioned singleton side the file recorded (a computed name there leaves the
|
|
360
|
+
# side unpositioned, and becoming literal reads it again). A file that only includes into a constant
|
|
361
|
+
# (`ActiveRecord::Base.include(X)`) records neither and fires nothing. A constant the edit makes the file
|
|
362
|
+
# rewrite needs no key: the class-declaration diff fires if it is new to the file, and the reader's class edge
|
|
363
|
+
# names the file if the file declared it.
|
|
364
|
+
def singleton_rewrite_affected(unstable)
|
|
365
|
+
names = unstable.flat_map { |path| singleton_rewritten_constants(@seed_bundles[path]) }.uniq
|
|
366
|
+
return Set.new if names.empty?
|
|
367
|
+
|
|
368
|
+
Incremental.negative_closure(names.map { |name| "class:#{name.split('::').last}" }, @negative_dependents)
|
|
369
|
+
end
|
|
370
|
+
|
|
371
|
+
def singleton_rewritten_constants(bundle)
|
|
372
|
+
return [] if bundle.nil?
|
|
373
|
+
|
|
374
|
+
envelopes = bundle[:parameter_envelopes] || {}
|
|
375
|
+
unpositioned = bundle[:unpositioned_mixins] || {}
|
|
376
|
+
(bundle[:singleton_class_source_names] || []).select do |name|
|
|
377
|
+
(envelopes[name] || {}).each_key.any? { |key| key.is_a?(Array) && key.first == :singleton } ||
|
|
378
|
+
unpositioned.dig(name, :extend)
|
|
379
|
+
end
|
|
380
|
+
end
|
|
381
|
+
|
|
319
382
|
# Issue #1367 — every file whose last result holds a `global.*` diagnostic re-checks on any recheck. What
|
|
320
383
|
# exempts such a write is a fact of the whole program — an alias, or a `write` / `to_str` / `to_int` / hatch
|
|
321
384
|
# defined in any spelling on any receiver, a rewritten ancestor, a mixin — which no name-keyed edge can
|
|
@@ -339,13 +402,15 @@ module Rigor
|
|
|
339
402
|
|
|
340
403
|
# The dependents contributed by the declaration-unstable changed files and the behaviourally-unstable
|
|
341
404
|
# symbol pairs: the ADR-46 slice-4 symbol-granular fan-out when either is present (ancestry deps of the
|
|
342
|
-
# unstable files + symbol deps of the changed pairs), else the coarse file-level fan-out
|
|
405
|
+
# unstable files + symbol deps of the changed pairs), else the coarse file-level fan-out; plus the readers of
|
|
406
|
+
# the constants an unstable file rewrote from outside their bodies ({#singleton_rewrite_affected}).
|
|
343
407
|
def dependents_base(unstable, symbol_pairs)
|
|
344
|
-
if symbol_pairs.any? || unstable.any? { |f| @ancestry_dependents[f] }
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
408
|
+
base = if symbol_pairs.any? || unstable.any? { |f| @ancestry_dependents[f] }
|
|
409
|
+
Incremental.affected_with_symbols(unstable, symbol_pairs, @symbol_dependents, @ancestry_dependents)
|
|
410
|
+
else
|
|
411
|
+
Incremental.affected(unstable, @dependents)
|
|
412
|
+
end
|
|
413
|
+
base | singleton_rewrite_affected(unstable)
|
|
349
414
|
end
|
|
350
415
|
|
|
351
416
|
# ADR-67 WD6c lift — the fresh whole-project inferred-param table for this recheck ({} when the gate
|
|
@@ -440,7 +505,7 @@ module Rigor
|
|
|
440
505
|
def whole_project_closure(changed, added)
|
|
441
506
|
(@analyzed.to_set | changed | added).freeze
|
|
442
507
|
end
|
|
443
|
-
private :gated_closure, :whole_project_closure
|
|
508
|
+
private :gated_closure, :whole_project_closure, :singleton_rewrite_affected, :singleton_rewritten_constants
|
|
444
509
|
|
|
445
510
|
# The current project file set (cheap directory expansion, no analysis), used to detect files added /
|
|
446
511
|
# removed since the last run. Also where an editor buffer's binding is re-spelled onto the set, since
|
|
@@ -463,8 +528,7 @@ module Rigor
|
|
|
463
528
|
# ADR-67 WD6c lift — seed the subset run from the baseline's own table so the verification engine
|
|
464
529
|
# exercises the exact seeds the served cache entries were computed under (and skips a re-collect).
|
|
465
530
|
runner = build_runner(analyze_only: affected, param_inferred_types: @param_table,
|
|
466
|
-
restored_run_level_rows: @run_level_rows,
|
|
467
|
-
served_per_file_diagnostics: @cache.slice(*reused))
|
|
531
|
+
restored_run_level_rows: @run_level_rows, **served_state(reused))
|
|
468
532
|
run_runner(runner).diagnostics
|
|
469
533
|
end
|
|
470
534
|
|
|
@@ -480,6 +544,7 @@ module Rigor
|
|
|
480
544
|
# to. Every soundness gate below — the fingerprint, the ADR-88 fact surface — is unchanged, so the
|
|
481
545
|
# decision to reuse is made identically whether or not the result is saved.
|
|
482
546
|
def run_incremental(snapshot:, fingerprint:, persist: true)
|
|
547
|
+
@last_recheck = nil # #1526 — set only by a warm recheck, see {#last_recheck}
|
|
483
548
|
# ADR-87 WD1 — install the per-run digest table + recording instant + strict flag for the whole
|
|
484
549
|
# invocation so change-detection's stat-then-digest freshness (`#pack_digest` / `#stat_fresh?`) honours
|
|
485
550
|
# `cache.validation: digest` (and `RIGOR_STRICT_VALIDATION`, which the env-only path already sees) and
|
|
@@ -507,8 +572,8 @@ module Rigor
|
|
|
507
572
|
result = recheck
|
|
508
573
|
adopt_plugin_fact_fingerprint
|
|
509
574
|
fact_reusable = @plugin_fact_reusable.reusable_against?(restored.plugin_fact_digest)
|
|
510
|
-
if fact_reusable && effects_reusable && !source_rbs_unverified?(result)
|
|
511
|
-
diagnostics = result
|
|
575
|
+
if fact_reusable && effects_reusable && !result.rebaselined && !source_rbs_unverified?(result)
|
|
576
|
+
diagnostics = keep_recheck(result)
|
|
512
577
|
warm = true
|
|
513
578
|
# ADR-87 WD3 — a warm recheck that changed nothing leaves the session state byte-equivalent to the
|
|
514
579
|
# snapshot it restored, so skip the unconditional rewrite (209ms + 2 MB on gitlab per null recheck).
|
|
@@ -522,7 +587,8 @@ module Rigor
|
|
|
522
587
|
# digest (from the recheck runner) is unchanged by the re-analysis, so it is kept for the save.
|
|
523
588
|
# Only a genuine fact-surface reason sets the reporting flag the CLI banner reads.
|
|
524
589
|
@fact_surface_invalidated = true unless fact_reusable
|
|
525
|
-
|
|
590
|
+
# #1898 — a recheck that already re-baselined over stale plugin effect facts is that re-analysis.
|
|
591
|
+
diagnostics = result.rebaselined ? result.diagnostics : baseline
|
|
526
592
|
warm = false
|
|
527
593
|
skip_save = false
|
|
528
594
|
end
|
|
@@ -539,9 +605,36 @@ module Rigor
|
|
|
539
605
|
|
|
540
606
|
# ADR-88 WD1 — reporting hooks the CLI reads after {#run_incremental}. `fact_surface_invalidated?` is true
|
|
541
607
|
# when a valid snapshot was dropped for a fact-surface reason (a plugin sig/catalog edit, or an opaque
|
|
542
|
-
# plugin) rather than a cold miss. `
|
|
543
|
-
#
|
|
544
|
-
attr_reader :
|
|
608
|
+
# plugin) rather than a cold miss. `opaque_reasons` maps each plugin that forces a full run every invocation
|
|
609
|
+
# to its {PluginFactFingerprint::OpaqueReason}.
|
|
610
|
+
attr_reader :opaque_reasons
|
|
611
|
+
|
|
612
|
+
# #1526 — the {Recheck} of the last {#run_incremental}, or nil unless that run answered warm. A baseline
|
|
613
|
+
# (cold, or a re-baseline after a fact-surface, effects or source-RBS reason) has no recheck to report.
|
|
614
|
+
# The diagnostics are not kept: a long-lived session (the LSP) would otherwise retain every merged list.
|
|
615
|
+
attr_reader :last_recheck
|
|
616
|
+
|
|
617
|
+
# Records the warm recheck's sets for {#last_recheck} and hands back its diagnostics.
|
|
618
|
+
def keep_recheck(result)
|
|
619
|
+
@last_recheck = result.with(diagnostics: [])
|
|
620
|
+
result.diagnostics
|
|
621
|
+
end
|
|
622
|
+
private :keep_recheck
|
|
623
|
+
|
|
624
|
+
# The opaque plugin ids, sorted (the keys of {#opaque_reasons}).
|
|
625
|
+
def opaque_plugin_ids
|
|
626
|
+
@opaque_reasons.keys
|
|
627
|
+
end
|
|
628
|
+
|
|
629
|
+
# True when the fact store could not be read, so no single plugin is named (see {PluginFactFingerprint::Result}).
|
|
630
|
+
def fact_store_unreadable?
|
|
631
|
+
@fact_store_unreadable
|
|
632
|
+
end
|
|
633
|
+
|
|
634
|
+
# True when the fact surface forces a full run: some plugin is opaque, or the fact store is unreadable.
|
|
635
|
+
def opaque_fact_surface?
|
|
636
|
+
!@opaque_reasons.empty? || @fact_store_unreadable
|
|
637
|
+
end
|
|
545
638
|
|
|
546
639
|
def fact_surface_invalidated?
|
|
547
640
|
@fact_surface_invalidated
|
|
@@ -566,7 +659,8 @@ module Rigor
|
|
|
566
659
|
registry = prepared_plugin_registry
|
|
567
660
|
fact = compute_plugin_fact_fingerprint(registry)
|
|
568
661
|
@plugin_fact_digest = fact.digest
|
|
569
|
-
@
|
|
662
|
+
@opaque_reasons = fact.opaque_reasons
|
|
663
|
+
@fact_store_unreadable = fact.fact_store_unreadable
|
|
570
664
|
@plugin_fact_reusable = fact
|
|
571
665
|
@seed_bundles = source_rbs_gate.verify(registry, @seed_bundles)
|
|
572
666
|
end
|
|
@@ -596,10 +690,10 @@ module Rigor
|
|
|
596
690
|
|
|
597
691
|
# ADR-88 WD1 — the plugin fact-surface fingerprint for this invocation. For a SEQUENTIAL run it is read
|
|
598
692
|
# post-hoc from the analysis runner's already-prepared registry (no second `#prepare`, producers already
|
|
599
|
-
# validated during analysis). A POOLED run's main process
|
|
600
|
-
# published facts — there it falls back to the always-sequential probe. Both
|
|
601
|
-
# digest for a given fact surface, so the reuse decision is worker-count-independent
|
|
602
|
-
# asserts this).
|
|
693
|
+
# validated during analysis). A POOLED run's main process does not run `#prepare` on it (its workers do),
|
|
694
|
+
# so its registry carries no published facts — there it falls back to the always-sequential probe. Both
|
|
695
|
+
# paths compute the identical digest for a given fact surface, so the reuse decision is worker-count-independent
|
|
696
|
+
# (the parity spec asserts this).
|
|
603
697
|
def compute_plugin_fact_fingerprint(registry = prepared_plugin_registry)
|
|
604
698
|
PluginFactFingerprint.from_registry(registry)
|
|
605
699
|
end
|
|
@@ -710,7 +804,8 @@ module Rigor
|
|
|
710
804
|
return nil if fingerprint.nil? || @cache_store.nil? || @cache_store.read_only? || !@buffer.nil?
|
|
711
805
|
# Known before the run: none of these can be written afterwards, so the stamp and the recomputed
|
|
712
806
|
# fingerprint would be spent for nothing.
|
|
713
|
-
|
|
807
|
+
# `WriteGuard.start` would also refuse (it recomputes the fingerprint without `explain:`); say so here.
|
|
808
|
+
return nil unless @workers.zero? && !@configuration.effects_enabled? && !@explain
|
|
714
809
|
|
|
715
810
|
IncrementalRunSlot::WriteGuard.start(
|
|
716
811
|
configuration: @configuration, roots: @paths || @configuration.paths, cache_root: @cache_store.root,
|
|
@@ -724,7 +819,7 @@ module Rigor
|
|
|
724
819
|
# cold, not the warm run the probe stands in for.
|
|
725
820
|
def run_slot_writable?
|
|
726
821
|
!@cache_store.nil? && !@cache_store.read_only? && @buffer.nil? && @workers.zero? &&
|
|
727
|
-
!@configuration.effects_enabled? &&
|
|
822
|
+
!@configuration.effects_enabled? && !@explain && !opaque_fact_surface? && !@last_runner.nil?
|
|
728
823
|
end
|
|
729
824
|
|
|
730
825
|
def start_chain(rows)
|
|
@@ -817,6 +912,7 @@ module Rigor
|
|
|
817
912
|
# a partial re-collection cannot be closed into a whole-project fixpoint.
|
|
818
913
|
@effects_identity = payload.effects_identity
|
|
819
914
|
@effect_collections = effect_collections_reusable?(payload) ? payload.effect_collections.dup : {}
|
|
915
|
+
@effect_plugin_digest = payload.effect_plugin_digest
|
|
820
916
|
@symbol_dependents = Incremental.invert_symbols(@symbol_sources)
|
|
821
917
|
@ancestry_dependents = Incremental.invert(@ancestry_sources)
|
|
822
918
|
@negative_dependents = Incremental.invert(@missing)
|
|
@@ -845,7 +941,8 @@ module Rigor
|
|
|
845
941
|
param_table: marshal_safe_param_table,
|
|
846
942
|
effect_collections: marshal_safe_effect_collections,
|
|
847
943
|
effects_identity: @effects_identity,
|
|
848
|
-
run_level_rows: @run_level_rows
|
|
944
|
+
run_level_rows: @run_level_rows,
|
|
945
|
+
effect_plugin_digest: @effect_plugin_digest
|
|
849
946
|
)
|
|
850
947
|
end
|
|
851
948
|
|
|
@@ -857,6 +954,7 @@ module Rigor
|
|
|
857
954
|
def reset_effect_state
|
|
858
955
|
@effect_collections = {}
|
|
859
956
|
@effects_identity = nil
|
|
957
|
+
@effect_plugin_digest = nil
|
|
860
958
|
end
|
|
861
959
|
|
|
862
960
|
# The state this session carries ACROSS processes in the snapshot rather than deriving each run: the
|
|
@@ -947,6 +1045,7 @@ module Rigor
|
|
|
947
1045
|
# multiple edits: the analyzed set gets fresh diagnostics + digests + dependency edges, removed files
|
|
948
1046
|
# are evicted from every map, and the analyzed-file list advances to `current`.
|
|
949
1047
|
def absorb(runner, current, analyze_set, removed)
|
|
1048
|
+
@last_runner = runner # ADR-88 WD1 — the post-hoc fact-surface fingerprint reads this prepared registry.
|
|
950
1049
|
removed.each { |path| forget(path) }
|
|
951
1050
|
@analyzed = current
|
|
952
1051
|
# ADR-85 WD2 — the recheck's discovery folded the restored bundles and refreshed them (changed files
|
|
@@ -980,6 +1079,7 @@ module Rigor
|
|
|
980
1079
|
@effect_collections.reject! { |path, _| analyzed.include?(path) }
|
|
981
1080
|
@effect_collections.merge!(runner.effect_collections_by_path)
|
|
982
1081
|
@effects_identity = current_effects_identity
|
|
1082
|
+
@effect_plugin_digest = effect_plugin_digest_of(runner)
|
|
983
1083
|
end
|
|
984
1084
|
|
|
985
1085
|
# ADR-89 WD2 — replace the behavioural summaries of every re-analyzed file with THIS run's harvest: drop
|
|
@@ -1075,7 +1175,8 @@ module Rigor
|
|
|
1075
1175
|
# appeared `"ClassName#method"` to the negative-dependency key it would satisfy (`toplevel:foo` for a
|
|
1076
1176
|
# top-level def, `method:C#m` otherwise), then unions the recorded negative-dependents of those keys.
|
|
1077
1177
|
# Issue #1120 — plus the consumers of any refinement name the edit moved ({#refinement_affected}). Issue #1367 —
|
|
1078
|
-
# and every file whose last result holds a `global.*` diagnostic ({#global_write_reporters}).
|
|
1178
|
+
# and every file whose last result holds a `global.*` diagnostic ({#global_write_reporters}). Issue #1459 — and
|
|
1179
|
+
# the consumers of any method name a file started or stopped defining ({#defined_name_affected}).
|
|
1079
1180
|
def negative_affected(changed, removed, new_fingerprints, new_class_decls, new_constant_decls, summary = nil)
|
|
1080
1181
|
appeared_methods = Incremental.appeared_symbols(changed, @symbol_fingerprints, new_fingerprints)
|
|
1081
1182
|
# Issue #639 — a class DISAPPEARING satisfies the `class:` kind too, now that a resolved bare
|
|
@@ -1093,7 +1194,7 @@ module Rigor
|
|
|
1093
1194
|
keys.concat(class_move_keys(moved_classes))
|
|
1094
1195
|
keys.concat(moved_constants.map { |name| "constant:#{name.split('::').last}" })
|
|
1095
1196
|
Incremental.negative_closure(keys, @negative_dependents) | refinement_affected(changed + removed, summary) |
|
|
1096
|
-
global_write_reporters
|
|
1197
|
+
defined_name_affected(changed + removed, summary) | global_write_reporters
|
|
1097
1198
|
end
|
|
1098
1199
|
|
|
1099
1200
|
# The negative keys a class-declaration diff fires: `class:<last segment>` for a class name, and (issue #1820)
|
|
@@ -1121,14 +1222,30 @@ module Rigor
|
|
|
1121
1222
|
Incremental.negative_closure(names.map { |name| "refinement:#{name}" }, @negative_dependents)
|
|
1122
1223
|
end
|
|
1123
1224
|
|
|
1225
|
+
# Issue #1459 — the consumers whose answer asked whether the program defines a method name anywhere, for each
|
|
1226
|
+
# name a file in `paths` started or stopped defining. The before-state is each file's seed bundle, the
|
|
1227
|
+
# after-state the scan's per-file name sets. Unlike the appeared-symbol diff, it sees a name an `attr_*`,
|
|
1228
|
+
# `alias` or `define_method` adds, and one that vanishes, which brings a declined reading back.
|
|
1229
|
+
def defined_name_affected(paths, summary)
|
|
1230
|
+
after = (summary && summary[:defined_method_names]) || {}
|
|
1231
|
+
before = paths.to_h do |path|
|
|
1232
|
+
bundle = @seed_bundles[path]
|
|
1233
|
+
[path, bundle && Inference::ScopeIndexer.defined_method_names(bundle)]
|
|
1234
|
+
end
|
|
1235
|
+
names = Incremental.changed_defined_names(paths, before, after)
|
|
1236
|
+
return Set.new if names.empty?
|
|
1237
|
+
|
|
1238
|
+
Incremental.negative_closure(names.map { |name| "defines:#{name}" }, @negative_dependents)
|
|
1239
|
+
end
|
|
1240
|
+
|
|
1124
1241
|
# The qualified class/module names declared in the pre-parsed `index` (shared with
|
|
1125
1242
|
# {#symbol_fingerprints_from_index}, so the changed set is parsed once). `{ path => Set<class name> }`.
|
|
1126
1243
|
def class_declarations_from_index(index)
|
|
1127
1244
|
return {} if index.nil?
|
|
1128
1245
|
|
|
1129
1246
|
result = Hash.new { |hash, key| hash[key] = Set.new }
|
|
1130
|
-
index[:class_sources].each do |
|
|
1131
|
-
files.each { |file| result[file] << class_name }
|
|
1247
|
+
[index[:class_sources], index[:singleton_class_sources] || {}].each do |sources|
|
|
1248
|
+
sources.each { |class_name, files| files.each { |file| result[file] << class_name } }
|
|
1132
1249
|
end
|
|
1133
1250
|
Incremental.add_superclass_edges(result, index[:class_sources], index[:superclasses])
|
|
1134
1251
|
result.transform_values(&:freeze).freeze
|
|
@@ -1269,10 +1386,60 @@ module Rigor
|
|
|
1269
1386
|
Runner.new(
|
|
1270
1387
|
configuration: @configuration, cache_store: @cache_store, environment: @environment,
|
|
1271
1388
|
plugin_requirer: @plugin_requirer, seed_bundles: @seed_bundles, collect_seed_bundles: true,
|
|
1272
|
-
workers: @workers, buffer: @buffer, **
|
|
1389
|
+
workers: @workers, buffer: @buffer, explain: @explain, on_file_indexed: @on_file_indexed, **
|
|
1273
1390
|
)
|
|
1274
1391
|
end
|
|
1275
1392
|
|
|
1393
|
+
# The forced targets present in `current`, and the recheck's analyse set with them joined to the closure.
|
|
1394
|
+
def forced_analyze_set(affected, current)
|
|
1395
|
+
forced = @force_targets & current.to_set
|
|
1396
|
+
[forced, (affected | forced) & current]
|
|
1397
|
+
end
|
|
1398
|
+
|
|
1399
|
+
# What the session serves a narrowed run for the files it does not analyse: their cached per-file rows (#1524),
|
|
1400
|
+
# and (#1898) their effect collections, so the run closes its effect graph over the whole project.
|
|
1401
|
+
def served_state(reused)
|
|
1402
|
+
{ served_per_file_diagnostics: @cache.slice(*reused),
|
|
1403
|
+
served_effect_collections: @effect_collections.slice(*reused) }
|
|
1404
|
+
end
|
|
1405
|
+
|
|
1406
|
+
# Issue #1898 — the plugin effect-fact digest a run's collections were stamped under. A plugin writes its
|
|
1407
|
+
# attributions and edges into a file's collection when the file is collected, and what it contributes can
|
|
1408
|
+
# follow project state no file digest sees (rigor-activejob reads `config.active_job.queue_adapter`), so a
|
|
1409
|
+
# served collection is current only while this digest is. nil when collection is off.
|
|
1410
|
+
def effect_plugin_digest_of(runner)
|
|
1411
|
+
@configuration.effects_enabled? ? runner.effect_plugin_facts.digest : nil
|
|
1412
|
+
end
|
|
1413
|
+
|
|
1414
|
+
# Whether `runner` judged envelopes over served collections stamped under a different plugin effect-fact
|
|
1415
|
+
# digest, or an unknown one. The comparison waits for the run because the plugins load inside it.
|
|
1416
|
+
def served_effects_stale?(runner, reused)
|
|
1417
|
+
return false if reused.empty? || @effect_collections.empty?
|
|
1418
|
+
|
|
1419
|
+
effect_plugin_digest_of(runner) != @effect_plugin_digest
|
|
1420
|
+
end
|
|
1421
|
+
|
|
1422
|
+
# The answer {#recheck} gives when {#served_effects_stale?}: a full baseline, reported as a recheck that
|
|
1423
|
+
# re-analysed every file. {#run_incremental} reads `rebaselined` so it neither runs a second baseline
|
|
1424
|
+
# nor reports the run as warm.
|
|
1425
|
+
def rebaseline_recheck(changed, added, removed)
|
|
1426
|
+
diagnostics = baseline
|
|
1427
|
+
recheck_result(diagnostics, [changed, added, removed],
|
|
1428
|
+
affected: @analyzed.to_set, reused: Set.new, runner: @last_runner, rebaselined: true)
|
|
1429
|
+
end
|
|
1430
|
+
|
|
1431
|
+
# A {Recheck} over `deltas` (`[changed, added, removed]`), counting `runner`'s template units.
|
|
1432
|
+
def recheck_result(diagnostics, deltas, affected:, reused:, runner:, rebaselined: false)
|
|
1433
|
+
changed, added, removed = deltas.map(&:to_set)
|
|
1434
|
+
Recheck.new(diagnostics: diagnostics, changed: changed, added: added, removed: removed, affected: affected,
|
|
1435
|
+
reused: reused, templates: runner.template_unit_paths.size, rebaselined: rebaselined)
|
|
1436
|
+
end
|
|
1437
|
+
|
|
1438
|
+
# A recheck that forces targets analyses in this process, so the index callback reaches them.
|
|
1439
|
+
def forced_target_options(forced)
|
|
1440
|
+
forced.empty? ? {} : { workers: 0 }
|
|
1441
|
+
end
|
|
1442
|
+
|
|
1276
1443
|
# Run the runner over the session's explicit paths (or, when none were given, the configuration's
|
|
1277
1444
|
# `paths:` via `Runner#run`'s default).
|
|
1278
1445
|
def run_runner(runner)
|
|
@@ -14,10 +14,10 @@ module Rigor
|
|
|
14
14
|
module_function
|
|
15
15
|
|
|
16
16
|
# The `.rb` files an ordinary (non-buffer) check over `paths` would analyze.
|
|
17
|
-
def ruby_files(paths, exclude_patterns)
|
|
17
|
+
def ruby_files(paths, exclude_patterns, base: Dir.pwd)
|
|
18
18
|
Array(paths).flat_map do |path|
|
|
19
19
|
if File.directory?(path)
|
|
20
|
-
directory_files(path, exclude_patterns)
|
|
20
|
+
directory_files(path, exclude_patterns, base: base)
|
|
21
21
|
elsif File.file?(path) && path.end_with?(".rb")
|
|
22
22
|
[path]
|
|
23
23
|
else
|
|
@@ -26,16 +26,72 @@ module Rigor
|
|
|
26
26
|
end
|
|
27
27
|
end
|
|
28
28
|
|
|
29
|
-
def directory_files(dir, exclude_patterns)
|
|
30
|
-
reject_excluded(Dir.glob(File.join(dir, RUBY_GLOB)), exclude_patterns)
|
|
29
|
+
def directory_files(dir, exclude_patterns, base: Dir.pwd)
|
|
30
|
+
reject_excluded(Dir.glob(File.join(dir, RUBY_GLOB)), exclude_patterns, base: base)
|
|
31
31
|
end
|
|
32
32
|
|
|
33
33
|
# `exclude_patterns` are matched with `File.fnmatch?` WITHOUT `FNM_PATHNAME`, so `**`/`*` span separators
|
|
34
34
|
# (the patterns behave like substring globs) — the exact semantics the Runner's directory glob applies.
|
|
35
|
-
|
|
35
|
+
#
|
|
36
|
+
# #1576 — a RELATIVE pattern (a leading `./` is ignored) is matched against the file's path relative to
|
|
37
|
+
# `base` (the config file's directory, or the working directory with no config), so the same file is
|
|
38
|
+
# excluded whether `paths:` (absolute) or an argument (relative) named it.
|
|
39
|
+
#
|
|
40
|
+
# Two spellings are tried. The LOGICAL one (the path as given, against the logical base) counts only when
|
|
41
|
+
# the file lies inside the base; it is what lets a symlinked `lib` match `lib/gen/**`. The REALPATH one
|
|
42
|
+
# (realpath of the file against realpath of the base) is the only one used for a file outside the base, and
|
|
43
|
+
# it also catches a base or file spelled through a different symlink root (macOS `/tmp` vs `/private/tmp`).
|
|
44
|
+
# Neither shows a directory name above the base: a file outside it comes out as `../…`, so `**/tmp/**`
|
|
45
|
+
# cannot exclude a project merely located under a `tmp/`, while `**/vendor/bundle/**` still reaches
|
|
46
|
+
# `../other/vendor/bundle/x.rb`. The pattern is also tried against `/<relative>`, because without
|
|
47
|
+
# `FNM_PATHNAME` the unanchored `**/tmp/**` needs a leading `/` to match a top-level `tmp/x.rb` (and `*`
|
|
48
|
+
# never matches a leading `.`, as in `../other`). A pattern starting with `.` therefore also matches files
|
|
49
|
+
# outside the base. An ABSOLUTE pattern is matched only against the absolute path (logical, then real).
|
|
50
|
+
def reject_excluded(file_list, exclude_patterns, base: Dir.pwd)
|
|
36
51
|
return file_list if exclude_patterns.empty?
|
|
37
52
|
|
|
38
|
-
|
|
53
|
+
absolute_patterns, relative_patterns = exclude_patterns.partition { |pattern| pattern.start_with?("/") }
|
|
54
|
+
relative_patterns = relative_patterns.map { |pattern| pattern.sub(%r{\A(?:\./+)+}, "") }
|
|
55
|
+
logical_base = File.absolute_path(base)
|
|
56
|
+
context = { cwd: Dir.pwd, base: logical_base, real_base: real_directory(logical_base), real_dirs: {} }
|
|
57
|
+
file_list.reject { |path| excluded?(path, absolute_patterns, relative_patterns, context) }
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def excluded?(path, absolute_patterns, relative_patterns, context)
|
|
61
|
+
logical = File.absolute_path(path, context.fetch(:cwd))
|
|
62
|
+
base = context.fetch(:base)
|
|
63
|
+
inside = logical.start_with?("#{base}/")
|
|
64
|
+
return true if inside && matches_relative?(relative_patterns, logical.byteslice((base.bytesize + 1)..))
|
|
65
|
+
return true if absolute_patterns.any? { |pattern| File.fnmatch?(pattern, logical) }
|
|
66
|
+
|
|
67
|
+
real = real_file(logical, context.fetch(:real_dirs))
|
|
68
|
+
return false if real == logical && inside && context.fetch(:real_base) == base
|
|
69
|
+
|
|
70
|
+
matches_relative?(relative_patterns, relative_path(real, context.fetch(:real_base))) ||
|
|
71
|
+
(real != logical && absolute_patterns.any? { |pattern| File.fnmatch?(pattern, real) })
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def relative_path(absolute, base)
|
|
75
|
+
return absolute.byteslice((base.bytesize + 1)..) if absolute.start_with?("#{base}/")
|
|
76
|
+
|
|
77
|
+
Pathname.new(absolute).relative_path_from(Pathname.new(base)).to_s
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def matches_relative?(patterns, relative)
|
|
81
|
+
candidates = [relative, "/#{relative}"]
|
|
82
|
+
patterns.any? { |pattern| candidates.any? { |candidate| File.fnmatch?(pattern, candidate) } }
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def real_directory(dir)
|
|
86
|
+
File.realpath(dir)
|
|
87
|
+
rescue SystemCallError
|
|
88
|
+
File.absolute_path(dir)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def real_file(absolute, real_dirs)
|
|
92
|
+
dir = File.dirname(absolute)
|
|
93
|
+
real_dir = real_dirs[dir] ||= real_directory(dir)
|
|
94
|
+
File.join(real_dir, File.basename(absolute))
|
|
39
95
|
end
|
|
40
96
|
end
|
|
41
97
|
end
|