rigortype 0.4.0 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (102) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +4 -3
  3. data/docs/handbook/08-understanding-errors.md +3 -2
  4. data/docs/install.md +87 -15
  5. data/docs/manual/02-cli-reference.md +2 -2
  6. data/docs/manual/03-configuration.md +5 -0
  7. data/docs/manual/08-skills.md +4 -3
  8. data/docs/manual/12-caching.md +43 -4
  9. data/docs/manual/14-rails-quickstart.md +19 -11
  10. data/lib/rigor/analysis/check_rules/source_arity.rb +163 -59
  11. data/lib/rigor/analysis/check_rules.rb +118 -87
  12. data/lib/rigor/analysis/dependency_recorder.rb +200 -18
  13. data/lib/rigor/analysis/incremental_run_slot/write_guard.rb +266 -0
  14. data/lib/rigor/analysis/incremental_run_slot.rb +171 -0
  15. data/lib/rigor/analysis/incremental_session.rb +273 -49
  16. data/lib/rigor/analysis/plugin_fact_fingerprint.rb +24 -13
  17. data/lib/rigor/analysis/reachability/scan_cache.rb +2 -1
  18. data/lib/rigor/analysis/run_cache_key.rb +27 -8
  19. data/lib/rigor/analysis/run_stats.rb +2 -2
  20. data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +1 -1
  21. data/lib/rigor/analysis/runner/pool_coordinator.rb +2 -2
  22. data/lib/rigor/analysis/runner/project_pre_passes.rb +21 -13
  23. data/lib/rigor/analysis/runner.rb +337 -46
  24. data/lib/rigor/analysis/shadow_harness.rb +27 -0
  25. data/lib/rigor/analysis/source_rbs_gate.rb +157 -0
  26. data/lib/rigor/analysis/template_unit_paths.rb +6 -4
  27. data/lib/rigor/analysis/template_units.rb +15 -5
  28. data/lib/rigor/analysis/worker_session.rb +4 -3
  29. data/lib/rigor/cache/descriptor.rb +40 -1
  30. data/lib/rigor/cache/file_digest.rb +28 -0
  31. data/lib/rigor/cache/incremental_snapshot.rb +70 -8
  32. data/lib/rigor/cache/store.rb +63 -3
  33. data/lib/rigor/cache/value_digest.rb +325 -0
  34. data/lib/rigor/cli/check_command.rb +83 -13
  35. data/lib/rigor/cli/doctor_command.rb +1 -1
  36. data/lib/rigor/cli/effects_report.rb +2 -2
  37. data/lib/rigor/cli/unused_command.rb +3 -3
  38. data/lib/rigor/cli.rb +1 -1
  39. data/lib/rigor/config_audit.rb +3 -2
  40. data/lib/rigor/config_path.rb +29 -0
  41. data/lib/rigor/configuration/severity_profile.rb +5 -0
  42. data/lib/rigor/configuration.rb +6 -5
  43. data/lib/rigor/effects/plugin_facts.rb +6 -6
  44. data/lib/rigor/environment/bundle_sig_discovery.rb +5 -3
  45. data/lib/rigor/environment/lockfile_resolver.rb +10 -1
  46. data/lib/rigor/environment/rbs_collection_discovery.rb +10 -1
  47. data/lib/rigor/environment/rbs_loader.rb +1 -1
  48. data/lib/rigor/environment/source_rbs_synthesis.rb +147 -0
  49. data/lib/rigor/environment.rb +13 -70
  50. data/lib/rigor/inference/block_parameter_binder.rb +29 -13
  51. data/lib/rigor/inference/captured_locals.rb +7 -1
  52. data/lib/rigor/inference/declaration_walk/context.rb +195 -0
  53. data/lib/rigor/inference/declaration_walk/errors.rb +18 -0
  54. data/lib/rigor/inference/declaration_walk/shadow.rb +147 -0
  55. data/lib/rigor/inference/declaration_walk/traversal.rb +441 -0
  56. data/lib/rigor/inference/declaration_walk.rb +7 -0
  57. data/lib/rigor/inference/def_node_resolver.rb +3 -2
  58. data/lib/rigor/inference/definer_resolution.rb +266 -0
  59. data/lib/rigor/inference/expression_typer.rb +136 -45
  60. data/lib/rigor/inference/external_ancestor_resolution.rb +13 -16
  61. data/lib/rigor/inference/guard_rebinding.rb +53 -12
  62. data/lib/rigor/inference/macro_block_self_type.rb +14 -2
  63. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +46 -26
  64. data/lib/rigor/inference/module_function_state.rb +213 -0
  65. data/lib/rigor/inference/multi_target_binder.rb +21 -16
  66. data/lib/rigor/inference/narrowing.rb +147 -17
  67. data/lib/rigor/inference/parameter_inference_collector.rb +10 -9
  68. data/lib/rigor/inference/scope_indexer/certainty.rb +176 -0
  69. data/lib/rigor/inference/scope_indexer/class_cvars_collector.rb +35 -0
  70. data/lib/rigor/inference/scope_indexer/def_nestings_collector.rb +47 -0
  71. data/lib/rigor/inference/scope_indexer/layered_def_nestings.rb +36 -0
  72. data/lib/rigor/inference/scope_indexer/member_layouts_collector.rb +61 -0
  73. data/lib/rigor/inference/scope_indexer/mixin_accumulator.rb +130 -0
  74. data/lib/rigor/inference/scope_indexer/superclasses_collector.rb +64 -0
  75. data/lib/rigor/inference/scope_indexer.rb +1443 -295
  76. data/lib/rigor/inference/singleton_hook_decline.rb +253 -0
  77. data/lib/rigor/inference/statement_evaluator.rb +80 -14
  78. data/lib/rigor/plugin/base.rb +28 -8
  79. data/lib/rigor/plugin/io_boundary.rb +82 -4
  80. data/lib/rigor/plugin/isolation.rb +1 -1
  81. data/lib/rigor/plugin/trust_policy.rb +3 -3
  82. data/lib/rigor/protection/discovery_seed.rb +18 -9
  83. data/lib/rigor/protection/mutation_cache.rb +3 -3
  84. data/lib/rigor/reflection/constant_ancestors.rb +96 -47
  85. data/lib/rigor/reflection/constant_path.rb +22 -7
  86. data/lib/rigor/reflection.rb +2 -3
  87. data/lib/rigor/scope/discovery_index.rb +218 -2
  88. data/lib/rigor/scope/resolution_chain/relevance.rb +165 -0
  89. data/lib/rigor/scope/resolution_chain.rb +884 -0
  90. data/lib/rigor/scope.rb +295 -322
  91. data/lib/rigor/sig_gen/alias_index.rb +1 -1
  92. data/lib/rigor/sig_gen/effect_annotation.rb +2 -2
  93. data/lib/rigor/sig_gen/generator.rb +22 -16
  94. data/lib/rigor/type/combinator.rb +25 -1
  95. data/lib/rigor/version.rb +1 -1
  96. data/plugins/rigor-ffi/lib/rigor/plugin/ffi.rb +10 -0
  97. data/sig/rigor/environment.rbs +2 -0
  98. data/sig/rigor/plugin/io_boundary.rbs +1 -0
  99. data/sig/rigor/scope.rbs +43 -1
  100. data/skills/rigor-project-init/SKILL.md +37 -11
  101. data/skills/rigor-project-init/references/02-configure.md +7 -2
  102. metadata +25 -1
@@ -297,11 +297,15 @@ module Rigor
297
297
  record_dependencies: false, record_self_calls: false, analyze_only: nil,
298
298
  seed_bundles: nil, collect_seed_bundles: false, param_inferred_types: nil,
299
299
  discovery_seed: nil, no_tolerated_effects: false,
300
- restored_run_level_rows: nil)
300
+ restored_run_level_rows: nil, served_per_file_diagnostics: nil)
301
301
  @configuration = configuration
302
302
  # Issues #796 / #794 — the previous full run's run-level rows, supplied only by
303
303
  # {IncrementalSession} on a narrowed run; see {PoolCoordinator#replay_restored_run_level_rows}.
304
304
  @restored_run_level_rows = restored_run_level_rows
305
+ # Issue #1524 — `{path => rows}` the {IncrementalSession} serves from its per-file cache for the files a
306
+ # narrowed run does not analyse, spliced into this run's per-file stream by
307
+ # {#interleave_served_diagnostics}. Empty on every other run.
308
+ @served_per_file_diagnostics = served_per_file_diagnostics || {}
305
309
  @explain = explain
306
310
  @cache_store = enforce_read_only_cache(cache_store, buffer)
307
311
  @plugin_requirer = plugin_requirer
@@ -353,6 +357,12 @@ module Rigor
353
357
  @cached_effect_ancestry = nil
354
358
  @cached_collections_loader = nil
355
359
  @run_dependency_descriptor = nil
360
+ # ADR-45 WD2 (#1507) — the plugin reads each file's analysis made (`{path => [row]}`), and the boundary
361
+ # rows as they stood around the per-file loop; see {#incremental_slot_rows}. Recorded only by a
362
+ # dependency-recording (incremental) run.
363
+ @analysis_reads = {}
364
+ @pre_analysis_plugin_rows = nil
365
+ @post_analysis_plugin_rows = nil
356
366
  # Memoised activation decision for the `call.self-undefined-method` rule (nil = not yet computed).
357
367
  # See `self_undefined_rule_active?`.
358
368
  @self_undefined_rule_active = nil
@@ -414,6 +424,7 @@ module Rigor
414
424
  @project_discovered_prepends = {}.freeze
415
425
  # Issue #898 — the singleton-side twin of the include table (`extend M` / `extend self`).
416
426
  @project_discovered_extends = {}.freeze
427
+ @project_unpositioned_mixins = {}.freeze
417
428
  @project_discovered_deferred_ranges = {}.freeze
418
429
  # Issue #1120 — `{refined class => {method => [refining modules]}}`, every `refine X do … end` body.
419
430
  @project_discovered_refinements = {}.freeze
@@ -433,6 +444,9 @@ module Rigor
433
444
  @constant_shadower_index = nil
434
445
  @project_discovered_method_visibilities = {}.freeze
435
446
  @project_discovered_methods = {}.freeze
447
+ # ADR-119 WD1 — the `possible_*` / `contested_*` siblings of the paired members, ONE Hash keyed by sibling
448
+ # name so no adoption or seed path names a sibling and none can drop one.
449
+ @project_discovery_siblings = Scope::DiscoveryIndex::EMPTY_SIBLINGS
436
450
  # Issue #992 — the cross-file parameter-envelope table `call.wrong-arity` reads for an undeclared `def`.
437
451
  @project_discovered_parameter_envelopes = {}.freeze
438
452
  @project_data_member_layouts = {}.freeze
@@ -508,6 +522,9 @@ module Rigor
508
522
  # #788 — the per-file reader is assigned only on the analysis (miss) path; reset it here so a run the
509
523
  # ADR-45 result cache serves does not answer with the previous run's rows.
510
524
  @per_file_diagnostics = [].freeze
525
+ @analysis_reads = {}
526
+ @pre_analysis_plugin_rows = nil
527
+ @post_analysis_plugin_rows = nil
511
528
  # ADR-84 WD2 — roll the return-memo bucket: a fresh frozen token per run makes every per-file scope
512
529
  # of THIS run share one memo bucket while entries from any earlier run in this process (stale after
513
530
  # an edit) become unreachable.
@@ -828,6 +845,11 @@ module Rigor
828
845
  # then hit — the stats it would otherwise suppress were all gathered.
829
846
  @run_served_from_cache = !computed && analysis.nil?
830
847
  diagnostics
848
+ rescue Inference::DeclarationWalk::ContractError
849
+ # A broken walk contract in the project pre-pass (a shadow-harness divergence, an unknown variant) is
850
+ # not a cache failure: the fallback below would re-run the analysis with the pre-pass marked done and
851
+ # no project index, and report the resulting false positives instead of the failure.
852
+ raise
831
853
  rescue StandardError
832
854
  # The result cache must never break a run. If anything in the cache path fails, fall back to a
833
855
  # direct, uncached analysis.
@@ -1175,19 +1197,55 @@ module Rigor
1175
1197
  # `#per_file_diagnostics`: the `analyze_files` return, stamped with the same severity profile the run's
1176
1198
  # own stream gets (`SeverityStamp` drops `:off` rows and re-stamps overrides, and the per-file cache never
1177
1199
  # re-stamps), sliced to the rows positioned at the targets. Returns the RAW return for the run's stream,
1178
- # which is stamped once, at the end of `#run_analysis`.
1200
+ # which is stamped once, at the end of `#run_analysis` — on a narrowed incremental run with the served
1201
+ # files' rows spliced in (#1524), which `#per_file_diagnostics` never carries.
1179
1202
  def analyze_targets(targets, environment:, project_files:)
1180
1203
  raw = template_units.remap(
1181
1204
  @pool_coordinator.analyze_files(targets, environment: environment, project_files: project_files)
1182
1205
  )
1206
+ # ADR-45 WD2 (#1507) — the boundaries as the per-file loop left them; with the snapshot the first
1207
+ # analysed file took, the rows {#incremental_slot_rows} checks every analysis-time read against.
1208
+ @post_analysis_plugin_rows = plugin_boundary_rows if @record_dependencies
1183
1209
  analysed = targets.to_set
1184
1210
  @per_file_diagnostics = @diagnostic_aggregator.apply_severity_profile(raw)
1185
1211
  .select { |diagnostic| analysed.include?(diagnostic.path) }
1186
1212
  .freeze
1187
- raw
1213
+ interleave_served_diagnostics(raw, analysed, project_files | targets)
1188
1214
  end
1189
1215
  private :analyze_targets
1190
1216
 
1217
+ # Issue #1524 — splices the rows {IncrementalSession} serves from its per-file cache into this narrowed
1218
+ # run's per-file stream, each file's rows where a full run of the same tree lists them. A full run
1219
+ # analyses `order` (the project's files, then any buffer and template-unit targets) one file after
1220
+ # another and a narrowed run a subsequence of it, so a served file's rows go just before the first row
1221
+ # positioned at an analysed file later in `order`, or at the end when there is none. Only such a row
1222
+ # marks a place, so the rows this run produced keep their relative order and a pool backend's
1223
+ # `.rigor.yml` rows stay at the head. Splicing here rather than into the finished result keeps the
1224
+ # run-level streams on either side of the per-file stream, where a full run has them. The served rows
1225
+ # are already severity-resolved and the run's final stamp leaves them unchanged: a resolved severity
1226
+ # resolves to itself, and a row the profile turns `:off` is never cached.
1227
+ def interleave_served_diagnostics(raw, analysed, order)
1228
+ served = @served_per_file_diagnostics
1229
+ return raw if served.empty?
1230
+
1231
+ rank = order.each_with_index.to_h
1232
+ # A served path the expansion no longer lists (a file deleted between the session's listing and this
1233
+ # run) keeps its rows, after every other file's, as the concatenation this replaced did.
1234
+ known, unknown = served.keys.partition { |path| rank.key?(path) }
1235
+ pending = known.sort_by { |path| rank.fetch(path) }
1236
+ merged = []
1237
+ raw.each do |diagnostic|
1238
+ # `order` lists every target, so an analysed path always has a rank; one that did not would only
1239
+ # mark no place, since a misplaced row must never abort the run (ADR-5).
1240
+ position = analysed.include?(diagnostic.path) ? rank.fetch(diagnostic.path, -1) : -1
1241
+ merged.concat(served.fetch(pending.shift)) while pending.any? && rank.fetch(pending.first) < position
1242
+ merged << diagnostic
1243
+ end
1244
+ (pending + unknown).each { |path| merged.concat(served.fetch(path)) }
1245
+ merged
1246
+ end
1247
+ private :interleave_served_diagnostics
1248
+
1191
1249
  # ADR-67 WD6a — the check-walk parameter-inference pre-pass. Populates `@project_param_inferred_types`
1192
1250
  # (read by `project_scope_seed_tables`) with the call-site union of every undeclared parameter, running
1193
1251
  # ONE round (a single hop of call-site → param typing; the protection scan's three-round fixpoint stays a
@@ -1310,25 +1368,205 @@ module Rigor
1310
1368
  # `.rbs` files that existed while the run read them, so without these a signature file written after
1311
1369
  # the run left the slot validating fresh and the warm run answered without it.
1312
1370
  globs = rbs_descriptor.globs + template_unit_glob_entries
1371
+ boundary = plugin_boundary_rows
1372
+ entries.concat(boundary.files)
1373
+ globs.concat(boundary.globs).concat(producer_watch_glob_entries)
1374
+ Cache::Descriptor.new(files: entries, globs: globs.uniq)
1375
+ end
1376
+
1377
+ # Every file a plugin read or probed through its `IoBoundary` (#577 / #613), and every directory it listed
1378
+ # (#954), as the boundaries stand when asked.
1379
+ def plugin_boundary_rows
1380
+ files = []
1381
+ globs = []
1313
1382
  @plugin_registry.plugins.each do |plugin|
1314
1383
  # Read the boundary WITHOUT triggering its lazy `@io_boundary ||=` initializer: plugin instances
1315
1384
  # are frozen after the run, and a plugin that never built a boundary read no files through it,
1316
1385
  # so it contributes no dependencies.
1317
1386
  boundary = plugin.instance_variable_get(:@io_boundary)
1318
- if boundary
1319
- # #629 — a boundary carries GlobEntry listing rows too (`IoBoundary#list_directory`), and they
1320
- # are the only edge back to a file the run decided was ABSENT from a directory it consulted.
1321
- boundary_descriptor = boundary.cache_descriptor
1322
- entries.concat(boundary_descriptor.files)
1323
- globs.concat(boundary_descriptor.globs)
1387
+ next unless boundary
1388
+
1389
+ # #629 — a boundary carries GlobEntry listing rows too (`IoBoundary#list_directory`), and they
1390
+ # are the only edge back to a file the run decided was ABSENT from a directory it consulted.
1391
+ boundary_descriptor = boundary.cache_descriptor
1392
+ files.concat(boundary_descriptor.files)
1393
+ globs.concat(boundary_descriptor.globs)
1394
+ end
1395
+ Cache::Descriptor.new(files: files, globs: globs)
1396
+ end
1397
+
1398
+ # One glob row per producer `watch:` pattern (ADR-60 WD3), recomputed against the tree as it stands.
1399
+ def producer_watch_glob_entries
1400
+ @plugin_registry.plugins.flat_map do |plugin|
1401
+ plugin.class.producers.each_value.flat_map do |prod|
1402
+ prod[:watch] ? plugin.send(:watch_glob_entries, prod[:watch]) : []
1324
1403
  end
1325
- plugin.class.producers.each_value do |prod|
1326
- globs.concat(plugin.send(:watch_glob_entries, prod[:watch])) if prod[:watch]
1404
+ end
1405
+ end
1406
+
1407
+ # ADR-45 WD2 (#1507) — the rows `rigor check --incremental`'s run-result slot records beside the analysed
1408
+ # files (whose digests {IncrementalSession} holds itself). Every run reads these inputs again and re-derives
1409
+ # its answer from them; they are split by when the row was taken, which decides whether the session's
1410
+ # {IncrementalRunSlot::WriteGuard} must vouch for it:
1411
+ #
1412
+ # - `observed` — every plugin row (`#prepare`, producers, template synthesis, and this run's per-file reads
1413
+ # too), recorded by the {Plugin::IoBoundary} as the plugin read. It includes each producer's `watch:`
1414
+ # globs, which a producer replays into its boundary whether it computed or was served (#1558). A save
1415
+ # after the read leaves the row stale, so no guard is needed.
1416
+ # - `derived` — rows taken when the run ENDS: the template files and their claimed globs (#392, re-analysed
1417
+ # every run), and one existence row per analysis root and per `pre_eval:` entry. A run's path errors (`no
1418
+ # such file or directory`, `pre-eval.file-not-found`) answer to those last rows; the plain slot records
1419
+ # neither, and the incremental path regenerates the errors every run, so a slot that served one past the
1420
+ # edit that retracts it would print what no run of that tree prints. Existence rows carry ADR-45 WD1b's
1421
+ # bound: a directory replaced by a file of the same name stays fresh.
1422
+ # - `by_file` — `{path => Descriptor}`, the plugin reads each file's analysis made
1423
+ # ({Plugin::IoBoundary.attributing}). A narrowed run analyses only its closure, so the session carries the
1424
+ # other files' reads forward from the previous slot.
1425
+ #
1426
+ # The template rows stand in for the `template-units` slot the plain key carries, which only a run can
1427
+ # compute. That digest is a function of each unit's compiled Ruby (the template's bytes, and whatever else
1428
+ # the transform read, through its boundary), its transform (the plugin, whose identity and configuration
1429
+ # the key carries) and the synthesis version (the engine's): each of those has a row or a key slot.
1430
+ #
1431
+ # The inputs a recheck does NOT re-derive are {#baseline_dependency_rows}. nil when this run cannot vouch for
1432
+ # its rows — a pool run, whose workers' reads never reach this process; a run that recorded no dependencies,
1433
+ # whose analysis no session owns; or a run in which a boundary row changed during the per-file loop that no
1434
+ # file's analysis was credited with, such as a read from a thread the plugin started.
1435
+ IncrementalSlotRows = Data.define(:observed, :derived, :by_file)
1436
+
1437
+ # @param roots — the analysis roots the run was given.
1438
+ def incremental_slot_rows(roots:)
1439
+ return nil if pool_mode? || !@record_dependencies
1440
+ return nil unless analysis_reads_attributed?
1441
+
1442
+ IncrementalSlotRows.new(
1443
+ observed: plugin_boundary_rows,
1444
+ derived: Cache::Descriptor.new(
1445
+ files: template_unit_file_entries + existence_entries(Array(roots) + @configuration.pre_eval),
1446
+ globs: template_unit_glob_entries
1447
+ ),
1448
+ by_file: @analysis_reads.transform_values do |reads|
1449
+ Cache::Descriptor.new(
1450
+ files: reads.grep(Cache::Descriptor::FileEntry).uniq,
1451
+ globs: reads.grep(Cache::Descriptor::GlobEntry).uniq
1452
+ )
1327
1453
  end
1454
+ )
1455
+ end
1456
+
1457
+ # Whether every boundary row that appeared or changed during the per-file loop is one some file's analysis
1458
+ # was credited with. A row outside every file's attribution would ride this run's `run` rows only, and the
1459
+ # next narrowed run — which does not repeat the read — would drop it.
1460
+ def analysis_reads_attributed?
1461
+ post = @post_analysis_plugin_rows || plugin_boundary_rows
1462
+ pre = @pre_analysis_plugin_rows || post
1463
+ credited = @analysis_reads.each_value.flat_map(&:itself).to_set
1464
+ (post.files - pre.files).all? { |row| credited.include?(row) } &&
1465
+ (post.globs - pre.globs).all? { |row| credited.include?(row) }
1466
+ end
1467
+
1468
+ # ADR-45 WD2 (#1507) — the rows a FULL run records for the inputs an incremental recheck does not re-derive,
1469
+ # which the session therefore carries unchanged from the full run that recorded them to every later slot:
1470
+ #
1471
+ # - the signature tree the plain slot records (`RbsDescriptor.file_entries` / `.glob_entries` over the loader
1472
+ # roots this run's analysis resolved), and an existence row per signature root, configured or the
1473
+ # auto-detected `sig` — a recheck rebuilds no environment unless its closure is non-empty, and the snapshot
1474
+ # fingerprint digests the same roots (configured, or the auto-detected `sig`), so a change to one drops the
1475
+ # snapshot and forces a full run rather than reaching a recheck;
1476
+ # - the discovered-not-analysed files (#684) and a listing row per discovery root — a narrowed recheck does
1477
+ # not widen discovery at all;
1478
+ # - every `pre_eval:` file outside `files` (a stat row, or an absence row) — the session tracks only analysed
1479
+ # files, and one that is analysed is guarded by its own row.
1480
+ #
1481
+ # Recomputing any of these on a recheck would vouch for the tree the recheck saw while its answer still
1482
+ # carries rows computed before the change, so a change to one keeps the probe declining until the next full
1483
+ # run. nil when this process analysed no file itself — a pool run, or a run with no files.
1484
+ #
1485
+ # Split by what identifies the input. `pinned` holds the signature files outside the project's own signature
1486
+ # roots, which the slot's key identifies already: the engine's own (`data/`, a bundled plugin's `sig/`, a
1487
+ # gem overlay) by the engine slot, a gem's `sig/` and an `rbs collection` directory by the lockfiles. `owned`
1488
+ # holds the rest, which only their rows identify. The session's write guard refuses an `owned` row whose
1489
+ # change time it cannot read, and passes over a `pinned` one.
1490
+ BaselineRows = Data.define(:owned, :pinned)
1491
+
1492
+ SignatureRoots = Struct.new(:signature_paths)
1493
+ private_constant :SignatureRoots
1494
+
1495
+ # @param files — the run's analysed-path set.
1496
+ def baseline_dependency_rows(files:)
1497
+ return nil if pool_mode? || !@collect_stats || @analyzed_files.empty?
1498
+
1499
+ own, pinned = signature_rows
1500
+ BaselineRows.new(
1501
+ owned: Cache::Descriptor.new(
1502
+ files: own.files + existence_entries(signature_root_candidates) +
1503
+ discovery_file_entries({ files: files }) + unanalysed_pre_eval_entries(files),
1504
+ globs: (own.globs + discovery_glob_entries).uniq
1505
+ ),
1506
+ pinned: pinned
1507
+ )
1508
+ end
1509
+
1510
+ # The signature tree's rows, the project's own and the rest.
1511
+ def signature_rows
1512
+ roots = SignatureRoots.new(@snapshots.signature_paths)
1513
+ own = signature_root_candidates.map { |root| File.join(File.absolute_path(root.to_s), "") }
1514
+ own_files, other_files = Cache::RbsDescriptor.file_entries(roots, comparator: :stat)
1515
+ .partition { |row| within?(row.path, own) }
1516
+ own_globs, other_globs = Cache::RbsDescriptor.glob_entries(roots).partition { |row| within?(row.root, own) }
1517
+ [Cache::Descriptor.new(files: own_files, globs: own_globs.uniq),
1518
+ Cache::Descriptor.new(files: other_files, globs: other_globs.uniq)]
1519
+ end
1520
+
1521
+ def within?(path, roots)
1522
+ directory = File.join(File.absolute_path(path.to_s), "")
1523
+ roots.any? { |root| directory.start_with?(root) }
1524
+ end
1525
+
1526
+ # The project's own signature roots, whether or not they exist: the configured `signature_paths:`, or the
1527
+ # `sig` directory `Environment.default_signature_paths` auto-detects when none are configured. The
1528
+ # signature rows' listing rows cover a file appearing under a root that existed; only an existence row
1529
+ # covers the root itself appearing.
1530
+ def signature_root_candidates
1531
+ @configuration.signature_paths || ["sig"]
1532
+ end
1533
+
1534
+ # One names-mode listing row per directory the discovery widening globbed, so a file appearing there is seen
1535
+ # as well as an edit to one that existed.
1536
+ def discovery_glob_entries
1537
+ return [] if @project_discovery_expansion.nil?
1538
+
1539
+ @configuration.paths.select { |root| File.directory?(root) }.map do |root|
1540
+ Cache::Descriptor::GlobEntry.compute(root: root, pattern: PathExpansion::RUBY_GLOB, mode: :names)
1328
1541
  end
1329
- Cache::Descriptor.new(files: entries, globs: globs.uniq)
1330
1542
  end
1331
1543
 
1544
+ def unanalysed_pre_eval_entries(files)
1545
+ analysed = files.to_set { |path| File.absolute_path(path) }
1546
+ outside = @configuration.pre_eval.reject { |path| analysed.include?(File.absolute_path(path)) }
1547
+ outside.map do |path|
1548
+ if File.file?(path)
1549
+ Cache::Descriptor::FileEntry.stat(path: path, digest: Cache::FileDigest.hexdigest(path))
1550
+ else
1551
+ existence_entries([path]).first
1552
+ end
1553
+ end
1554
+ end
1555
+
1556
+ def existence_entries(paths)
1557
+ paths.uniq.map do |path|
1558
+ physical = @buffer ? @buffer.resolve(path) : path
1559
+ if File.exist?(physical)
1560
+ Cache::Descriptor::FileEntry.present(path: physical)
1561
+ else
1562
+ Cache::Descriptor::FileEntry.absent(path: physical)
1563
+ end
1564
+ end
1565
+ end
1566
+ private :plugin_boundary_rows, :producer_watch_glob_entries, :existence_entries,
1567
+ :analysis_reads_attributed?, :signature_root_candidates, :discovery_glob_entries,
1568
+ :unanalysed_pre_eval_entries, :signature_rows, :within?
1569
+
1332
1570
  # Issue #684 — the project files this run DISCOVERED but did not analyse. A widened run's diagnostics
1333
1571
  # are a function of them (that is the entire point: the method whose absence would have been reported
1334
1572
  # is defined in one of them), so the run-result cache must invalidate when one changes. Empty on every
@@ -1483,8 +1721,10 @@ module Rigor
1483
1721
  end
1484
1722
 
1485
1723
  # The tables only check rules read beyond dispatch — the issue #1120 refinements and the issue #1367
1486
- # global-write census. Extracted to keep {#apply_discovery_result} under its ABC budget.
1724
+ # global-write census — and the ADR-119 WD1 siblings of the paired members. Extracted to keep
1725
+ # {#apply_discovery_result} under its ABC budget.
1487
1726
  def apply_discovery_call_surface_tables(discovery)
1727
+ @project_discovery_siblings = discovery.siblings
1488
1728
  @project_discovered_refinements = discovery.discovered_refinements
1489
1729
  @project_discovered_global_write_census = discovery.discovered_global_write_census
1490
1730
  end
@@ -1496,6 +1736,7 @@ module Rigor
1496
1736
  @project_discovered_includes = discovery.discovered_includes
1497
1737
  @project_discovered_prepends = discovery.discovered_prepends
1498
1738
  @project_discovered_extends = discovery.discovered_extends
1739
+ @project_unpositioned_mixins = discovery.unpositioned_mixins
1499
1740
  end
1500
1741
 
1501
1742
  # Internal: builds the deferred cross-file discovery tables at most once per run and adopts them.
@@ -1512,7 +1753,7 @@ module Rigor
1512
1753
 
1513
1754
  @project_discovery_done = true
1514
1755
  # Issue #684 — the cross-file index spans the PROJECT even when the analysis targets a file list.
1515
- expansion = @project_discovery_expansion || expansion
1756
+ expansion = @project_discovery_expansion || once_each(expansion)
1516
1757
  if @collect_seed_bundles
1517
1758
  # ADR-85 WD2 — rebuild discovery from the prior run's bundles (re-walking only changed files) and
1518
1759
  # capture the refreshed bundle set for the session to persist.
@@ -1546,11 +1787,42 @@ module Rigor
1546
1787
  # Excluded, each because its expansion is already the one it means: the LSP `prebuilt:` path (which
1547
1788
  # seeds discovery from a snapshot), editor `buffer:` mode, `run_source`'s in-memory single file, and
1548
1789
  # ADR-46's `analyze_only` subset mode (which already passes the full expansion and filters later).
1790
+ #
1791
+ # Issue #1556 — the widened set holds each FILE once. `.rigor.yml`'s `paths:` load as absolute paths and a
1792
+ # path argument usually does not, so `rigor check lib` widened discovery over both spellings of every file:
1793
+ # the pre-pass then saw two writers for every constant a file declares and withheld its publication (#644),
1794
+ # and every file was a discovered-not-analysed dependency of the run's cache entry.
1795
+ #
1796
+ # The widening decision itself still compares the paths as spelled. `exclude:` patterns match a path as it
1797
+ # is spelled, so until #1576 is fixed the configured absolute `lib` can discover files the argument `lib`
1798
+ # excludes (a relative `lib/gen/**` pattern never matches `/abs/lib/gen/x.rb`), and treating the two as the
1799
+ # same root would drop those files from discovery.
1549
1800
  def project_discovery_expansion(paths, expansion)
1550
1801
  return nil unless widen_discovery_to_project?(paths)
1551
1802
 
1552
- widened = expand_paths(@configuration.paths | paths)
1553
- widened.fetch(:files).size > expansion.fetch(:files).size ? widened : nil
1803
+ analysed = expansion.fetch(:files)
1804
+ files = discovery_files(expand_paths(@configuration.paths | paths).fetch(:files), analysed)
1805
+ # Overlapping arguments (`lib lib/user.rb`) name an analysed file twice; the widened set names it once.
1806
+ files.size > analysed.uniq { |path| File.absolute_path(path) }.size ? { files: files, errors: [] } : nil
1807
+ end
1808
+
1809
+ # The expansion with each file once, first spelling kept: overlapping arguments (`lib ./lib`) that do not
1810
+ # widen discovery still name a file twice, and the pre-pass would take every constant in it for one written
1811
+ # twice (#1556). Symlinked aliases (`src -> lib`) are not folded; that needs a realpath and stays out of scope.
1812
+ def once_each(expansion)
1813
+ files = expansion.fetch(:files)
1814
+ unique = files.uniq { |path| File.absolute_path(path) }
1815
+ unique.size == files.size ? expansion : expansion.merge(files: unique)
1816
+ end
1817
+
1818
+ # Each file once, in project order, under the spelling the analysis uses when it is analysed.
1819
+ def discovery_files(project_files, analysed_files)
1820
+ spelling = analysed_files.to_h { |path| [File.absolute_path(path), path] }
1821
+ seen = Set.new
1822
+ project_files.filter_map do |path|
1823
+ absolute = File.absolute_path(path)
1824
+ spelling.fetch(absolute, path) if seen.add?(absolute)
1825
+ end
1554
1826
  end
1555
1827
 
1556
1828
  def widen_discovery_to_project?(paths)
@@ -1569,7 +1841,7 @@ module Rigor
1569
1841
 
1570
1842
  private :run_project_pre_passes, :adopt_prebuilt_project_scan, :apply_pre_passes_result,
1571
1843
  :apply_discovery_result, :ensure_project_discovery, :force_eager_discovery?,
1572
- :project_discovery_expansion, :widen_discovery_to_project?
1844
+ :project_discovery_expansion, :discovery_files, :once_each, :widen_discovery_to_project?
1573
1845
 
1574
1846
  # Ruby versions probed (ascending) to discover the lowest one this Prism build accepts for
1575
1847
  # `version:`. Prism exposes no version list, so the floor is found empirically — only when a
@@ -1832,7 +2104,7 @@ module Rigor
1832
2104
  end
1833
2105
 
1834
2106
  def shadow_rule_walk?
1835
- ENV.fetch("RIGOR_SHADOW_RULE_WALK", nil)
2107
+ ShadowHarness.enabled?
1836
2108
  end
1837
2109
 
1838
2110
  def collect_plugin_diagnostics(plugin, path, root, scope, node_result)
@@ -1977,13 +2249,9 @@ module Rigor
1977
2249
  # bucket identically to the sequential path.
1978
2250
  tables[:run_generation] = @run_generation if @run_generation
1979
2251
  tables[:discovered_classes] = @project_discovered_classes unless @project_discovered_classes.empty?
1980
- tables[:discovered_def_nodes] = @project_discovered_def_nodes unless @project_discovered_def_nodes.empty?
1981
2252
  unless @project_discovered_def_nestings.empty?
1982
2253
  tables[:discovered_def_nestings] = @project_discovered_def_nestings
1983
2254
  end
1984
- unless @project_discovered_singleton_def_nodes.empty?
1985
- tables[:discovered_singleton_def_nodes] = @project_discovered_singleton_def_nodes
1986
- end
1987
2255
  seed_def_source_tables(tables)
1988
2256
  unless @project_discovered_superclasses.empty?
1989
2257
  tables[:discovered_superclasses] = @project_discovered_superclasses
@@ -1992,27 +2260,41 @@ module Rigor
1992
2260
  tables[:discovered_header_nestings] = @project_discovered_header_nestings
1993
2261
  end
1994
2262
  seed_mixin_tables(tables)
1995
- unless @project_discovered_method_visibilities.empty?
1996
- tables[:discovered_method_visibilities] = @project_discovered_method_visibilities
1997
- end
1998
- tables[:discovered_methods] = @project_discovered_methods unless @project_discovered_methods.empty?
1999
- unless @project_discovered_deferred_ranges.empty?
2000
- tables[:discovered_deferred_ranges] = @project_discovered_deferred_ranges
2001
- end
2263
+ seed_paired_tables(tables)
2002
2264
  seed_call_surface_tables(tables)
2003
2265
  seed_opt_in_pre_pass_tables(tables)
2004
2266
  seed_member_layout_tables(tables)
2005
2267
  seed_dependency_attribution_tables(tables)
2006
- tables
2268
+ # Only a base seed can bring in half a pair (`seed_paired_tables` writes both halves), so a run without
2269
+ # one skips the copy this costs per file.
2270
+ @discovery_seed ? Scope::DiscoveryIndex.compact_pairs(tables) : tables
2271
+ end
2272
+
2273
+ # ADR-119 WD1 — the members `Scope::DiscoveryIndex::SIBLINGS` pairs, each with its sibling. A pair rides the
2274
+ # seed when either half is non-empty and is left out when both are, which keeps an `discovery_seed:` base's
2275
+ # table for a pair this run computed nothing for. {Scope::DiscoveryIndex#with} takes the two together, and
2276
+ # `compact_pairs` completes a half pair a base seed brought in.
2277
+ PAIRED_MEMBER_IVARS = Scope::DiscoveryIndex::SIBLINGS.keys.to_h do |member|
2278
+ [member, :"@project_#{member}"]
2279
+ end.freeze
2280
+ private_constant :PAIRED_MEMBER_IVARS
2281
+
2282
+ def seed_paired_tables(tables)
2283
+ PAIRED_MEMBER_IVARS.each do |member, ivar|
2284
+ sibling = Scope::DiscoveryIndex::SIBLINGS.fetch(member)
2285
+ table = instance_variable_get(ivar)
2286
+ own = @project_discovery_siblings.fetch(sibling)
2287
+ next if table.empty? && own.empty?
2288
+
2289
+ tables[member] = table
2290
+ tables[sibling] = own
2291
+ end
2007
2292
  end
2008
2293
 
2009
- # The tables only check rules read: the issue #992 parameter envelopes (`call.wrong-arity`), the issue #1120
2010
- # refinements (`call.undefined-method`), and the issue #1367 global-write census (the `global.*` write
2011
- # rules). Split out of {#project_scope_seed_tables} to keep it under the complexity budget.
2294
+ # The tables only check rules read: the issue #1120 refinements (`call.undefined-method`) and the issue #1367
2295
+ # global-write census (the `global.*` write rules); the issue #992 envelopes ride {#seed_paired_tables}. Split
2296
+ # out of {#project_scope_seed_tables} to keep it under the complexity budget.
2012
2297
  def seed_call_surface_tables(tables)
2013
- unless @project_discovered_parameter_envelopes.empty?
2014
- tables[:discovered_parameter_envelopes] = @project_discovered_parameter_envelopes
2015
- end
2016
2298
  tables[:discovered_refinements] = @project_discovered_refinements unless @project_discovered_refinements.empty?
2017
2299
  # Issue #1367 — read by the `global.*` write rules.
2018
2300
  return if @project_discovered_global_write_census.empty?
@@ -2028,12 +2310,14 @@ module Rigor
2028
2310
  tables[:discovered_includes] = @project_discovered_includes unless @project_discovered_includes.empty?
2029
2311
  tables[:discovered_prepends] = @project_discovered_prepends unless @project_discovered_prepends.empty?
2030
2312
  tables[:discovered_extends] = @project_discovered_extends unless @project_discovered_extends.empty?
2313
+ tables[:unpositioned_mixins] = @project_unpositioned_mixins unless @project_unpositioned_mixins.empty?
2031
2314
  end
2032
2315
 
2033
- # ADR-46 slice 1 / issue #644 — the two SOURCE-ATTRIBUTION tables, read only by the recording accessors
2316
+ # ADR-46 slice 1 / issue #644 — the two SOURCE-ATTRIBUTION tables, read by the recording accessors
2034
2317
  # (`Scope#record_class_dependency` for the class-declaration map, `Scope#record_constant_dependency`
2035
- # for the constant-write map). A normal run carries neither of the two. Extracted to keep
2036
- # {#project_scope_seed_tables} under the complexity budget.
2318
+ # for the constant-write map). The class-declaration map rides every run: an ancestor-order reader also
2319
+ # asks whether a class is declared in more than one file. The constant-write map rides only a recording
2320
+ # run. Extracted to keep {#project_scope_seed_tables} under the complexity budget.
2037
2321
  #
2038
2322
  # Issue #617 — the census's binding writes, grouped by last segment, ride every run instead: a constant
2039
2323
  # compound write whose plain read resolves nothing reads its binding off them, a TYPE rather than an edge.
@@ -2043,10 +2327,10 @@ module Rigor
2043
2327
  tables[:constant_writers] = writers unless writers.empty?
2044
2328
  shadowers = constant_shadower_index
2045
2329
  tables[:constant_shadowers] = shadowers unless shadowers.empty?
2046
- return unless @record_dependencies
2047
-
2048
2330
  tables[:discovered_class_sources] = @project_discovered_class_sources unless
2049
2331
  @project_discovered_class_sources.empty?
2332
+ return unless @record_dependencies
2333
+
2050
2334
  tables[:constant_sources] = @project_constant_sources unless @project_constant_sources.empty?
2051
2335
  end
2052
2336
 
@@ -2108,7 +2392,7 @@ module Rigor
2108
2392
  def memo_files_by_segment
2109
2393
  @project_constant_writes.each_with_object({}) do |(name, by_path), files|
2110
2394
  set = (files[name.split("::").last] ||= Set.new)
2111
- by_path.each { |path, descriptor| set << File.expand_path(path) if memo_descriptor?(descriptor) }
2395
+ by_path.each { |path, descriptor| set << File.absolute_path(path) if memo_descriptor?(descriptor) }
2112
2396
  end
2113
2397
  end
2114
2398
 
@@ -2166,14 +2450,14 @@ module Rigor
2166
2450
  writers = @project_constant_sources[name]
2167
2451
  wildcard = @project_constant_sources["#{Inference::ScopeIndexer::DYNAMIC_TARGET_PREFIX}#{segment}"]
2168
2452
  [writers, wildcard].any? do |paths|
2169
- paths&.any? { |path| !listed.include?(File.expand_path(path)) }
2453
+ paths&.any? { |path| !listed.include?(File.absolute_path(path)) }
2170
2454
  end
2171
2455
  end
2172
2456
 
2173
2457
  # The listed `pre_eval:` files, expanded so a census path and a configured one compare as the same
2174
2458
  # file whichever spelling each arrived in.
2175
2459
  def pre_eval_path_set
2176
- @pre_eval_path_set ||= @configuration.pre_eval.to_set { |path| File.expand_path(path) }
2460
+ @pre_eval_path_set ||= @configuration.pre_eval.to_set { |path| File.absolute_path(path) }
2177
2461
  end
2178
2462
 
2179
2463
  # ADR-46 — seed the instance + singleton `"path:line"` def-source tables (each only when non-empty).
@@ -2203,11 +2487,18 @@ module Rigor
2203
2487
  def analyze_file(path, environment)
2204
2488
  return analyze_with_effects(path, environment) unless @record_dependencies
2205
2489
 
2490
+ # ADR-45 WD2 (#1507) — every plugin read this file's analysis makes is attributed to it, so the
2491
+ # incremental session can validate them on a later run that serves this file's answer from cache.
2492
+ @pre_analysis_plugin_rows ||= plugin_boundary_rows
2493
+ reads = []
2206
2494
  diagnostics = nil
2207
- record = DependencyRecorder.record_for(path) do
2208
- diagnostics = analyze_with_effects(path, environment)
2495
+ record = Plugin::IoBoundary.attributing(reads) do
2496
+ DependencyRecorder.record_for(path) do
2497
+ diagnostics = analyze_with_effects(path, environment)
2498
+ end
2209
2499
  end
2210
2500
  @file_dependencies[path] = record
2501
+ @analysis_reads[path] = reads unless reads.empty?
2211
2502
  diagnostics
2212
2503
  end
2213
2504
 
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rigor
4
+ module Analysis
5
+ # The `RIGOR_SHADOW_RULE_WALK` switch, read in one place by both halves of the shadow harness: the rule
6
+ # walk's collectors (ADR-53 Track B, {CheckRules}) and the discovery tables (ADR-116 WD5,
7
+ # {Inference::DeclarationWalk::Shadow}, which also says how a run must be made), and by the cache keys that
8
+ # must not let a warm run answer for either. A leaf file, so the ADR-87 boot-slim probe loads it without
9
+ # any engine code.
10
+ module ShadowHarness
11
+ ENV_KEY = "RIGOR_SHADOW_RULE_WALK"
12
+
13
+ module_function
14
+
15
+ # Set to anything, `0` and the empty string included.
16
+ def enabled?
17
+ !ENV[ENV_KEY].nil?
18
+ end
19
+
20
+ # What the run-result cache and the incremental snapshot key on: nil with the harness off, which adds
21
+ # no key slot, so a run without the variable computes the key it computed before the harness existed.
22
+ def cache_identity
23
+ enabled? ? ENV_KEY : nil
24
+ end
25
+ end
26
+ end
27
+ end