rigortype 0.1.0 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +7 -2
  3. data/data/builtins/ruby_core/array.yml +6 -6
  4. data/data/builtins/ruby_core/hash.yml +1 -1
  5. data/data/builtins/ruby_core/io.yml +3 -3
  6. data/data/builtins/ruby_core/numeric.yml +1 -1
  7. data/data/builtins/ruby_core/pathname.yml +100 -100
  8. data/data/builtins/ruby_core/proc.yml +1 -1
  9. data/data/builtins/ruby_core/range.yml +6 -4
  10. data/data/builtins/ruby_core/string.yml +15 -10
  11. data/data/builtins/ruby_core/time.yml +3 -3
  12. data/lib/rigor/analysis/check_rules/always_truthy_condition_collector.rb +116 -0
  13. data/lib/rigor/analysis/check_rules/dead_assignment_collector.rb +123 -0
  14. data/lib/rigor/analysis/check_rules/ivar_write_collector.rb +118 -0
  15. data/lib/rigor/analysis/check_rules.rb +346 -18
  16. data/lib/rigor/analysis/rule_catalog.rb +343 -0
  17. data/lib/rigor/analysis/runner.rb +90 -6
  18. data/lib/rigor/builtins/regex_refinement.rb +104 -0
  19. data/lib/rigor/cli/diff_command.rb +169 -0
  20. data/lib/rigor/cli/explain_command.rb +129 -0
  21. data/lib/rigor/cli/type_of_command.rb +3 -3
  22. data/lib/rigor/cli/type_scan_command.rb +4 -4
  23. data/lib/rigor/cli.rb +29 -5
  24. data/lib/rigor/configuration/severity_profile.rb +18 -3
  25. data/lib/rigor/configuration.rb +186 -13
  26. data/lib/rigor/environment.rb +12 -4
  27. data/lib/rigor/inference/expression_typer.rb +3 -1
  28. data/lib/rigor/inference/method_dispatcher/kernel_dispatch.rb +31 -0
  29. data/lib/rigor/inference/method_dispatcher/literal_string_folding.rb +43 -2
  30. data/lib/rigor/inference/method_dispatcher/overload_selector.rb +104 -12
  31. data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +68 -2
  32. data/lib/rigor/inference/method_dispatcher.rb +50 -1
  33. data/lib/rigor/inference/narrowing.rb +150 -6
  34. data/lib/rigor/inference/scope_indexer.rb +220 -17
  35. data/lib/rigor/inference/statement_evaluator.rb +29 -0
  36. data/lib/rigor/plugin/base.rb +43 -0
  37. data/lib/rigor/plugin/fact_store.rb +92 -0
  38. data/lib/rigor/plugin/io_boundary.rb +92 -19
  39. data/lib/rigor/plugin/load_error.rb +14 -2
  40. data/lib/rigor/plugin/loader.rb +116 -0
  41. data/lib/rigor/plugin/manifest.rb +75 -6
  42. data/lib/rigor/plugin/services.rb +14 -2
  43. data/lib/rigor/plugin/trust_policy.rb +30 -7
  44. data/lib/rigor/plugin.rb +1 -0
  45. data/lib/rigor/scope.rb +30 -5
  46. data/lib/rigor/trinary.rb +1 -1
  47. data/lib/rigor/type/integer_range.rb +6 -2
  48. data/lib/rigor/version.rb +1 -1
  49. data/sig/rigor/environment.rbs +3 -2
  50. data/sig/rigor/scope.rbs +3 -0
  51. data/sig/rigor.rbs +8 -2
  52. metadata +9 -1
@@ -106,6 +106,14 @@ module Rigor
106
106
  discovered_def_nodes = build_discovered_def_nodes(root)
107
107
  seeded_scope = seeded_scope.with_discovered_def_nodes(discovered_def_nodes)
108
108
 
109
+ # v0.1.2 — per-class table of method visibilities
110
+ # (`:public` / `:private` / `:protected`). The
111
+ # `def.method-visibility-mismatch` CheckRule consults
112
+ # the table to flag explicit-non-self calls to a
113
+ # private user method.
114
+ discovered_method_visibilities = build_discovered_method_visibilities(root)
115
+ seeded_scope = seeded_scope.with_discovered_method_visibilities(discovered_method_visibilities)
116
+
109
117
  table = {}.compare_by_identity
110
118
  table.default = seeded_scope
111
119
 
@@ -340,7 +348,8 @@ module Rigor
340
348
  accumulator.transform_values(&:freeze).freeze
341
349
  end
342
350
 
343
- def walk_methods(node, qualified_prefix, in_singleton_class, accumulator) # rubocop:disable Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
351
+ # rubocop:disable Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity, Metrics/AbcSize
352
+ def walk_methods(node, qualified_prefix, in_singleton_class, accumulator)
344
353
  return unless node.is_a?(Prism::Node)
345
354
 
346
355
  case node
@@ -356,6 +365,12 @@ module Rigor
356
365
  walk_methods(node.body, qualified_prefix, true, accumulator)
357
366
  return
358
367
  end
368
+ when Prism::ConstantWriteNode
369
+ if meta_new_block_body(node)
370
+ child_prefix = qualified_prefix + [node.name.to_s]
371
+ walk_methods(meta_new_block_body(node), child_prefix, false, accumulator)
372
+ return
373
+ end
359
374
  when Prism::DefNode
360
375
  record_def_method(node, qualified_prefix, in_singleton_class, accumulator)
361
376
  return
@@ -370,6 +385,24 @@ module Rigor
370
385
  walk_methods(child, qualified_prefix, in_singleton_class, accumulator)
371
386
  end
372
387
  end
388
+ # rubocop:enable Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity, Metrics/AbcSize
389
+
390
+ # v0.1.2 — when a `Const = Data.define(*sym) do ... end`
391
+ # / `Const = Struct.new(*sym) do ... end` constant write
392
+ # carries a block, the block body holds method overrides
393
+ # whose canonical class is `Const`. Returns the block body
394
+ # node (a `Prism::StatementsNode`) when the rvalue
395
+ # matches; nil otherwise. Used by `walk_methods` /
396
+ # `walk_def_nodes` to push `Const` onto the qualified
397
+ # prefix before recursing.
398
+ def meta_new_block_body(node)
399
+ return nil unless node.is_a?(Prism::ConstantWriteNode)
400
+
401
+ rvalue = node.value
402
+ return nil unless data_define_call?(rvalue) || struct_new_call?(rvalue)
403
+
404
+ rvalue.block&.body
405
+ end
373
406
 
374
407
  def record_def_method(def_node, qualified_prefix, in_singleton_class, accumulator)
375
408
  return if qualified_prefix.empty?
@@ -397,7 +430,8 @@ module Rigor
397
430
  accumulator.transform_values(&:freeze).freeze
398
431
  end
399
432
 
400
- def walk_def_nodes(node, qualified_prefix, in_singleton_class, accumulator) # rubocop:disable Metrics/CyclomaticComplexity
433
+ # rubocop:disable Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
434
+ def walk_def_nodes(node, qualified_prefix, in_singleton_class, accumulator)
401
435
  return unless node.is_a?(Prism::Node)
402
436
 
403
437
  case node
@@ -413,6 +447,12 @@ module Rigor
413
447
  walk_def_nodes(node.body, qualified_prefix, true, accumulator)
414
448
  return
415
449
  end
450
+ when Prism::ConstantWriteNode
451
+ if meta_new_block_body(node)
452
+ child_prefix = qualified_prefix + [node.name.to_s]
453
+ walk_def_nodes(meta_new_block_body(node), child_prefix, false, accumulator)
454
+ return
455
+ end
416
456
  when Prism::DefNode
417
457
  record_def_node(node, qualified_prefix, in_singleton_class, accumulator)
418
458
  return
@@ -422,6 +462,7 @@ module Rigor
422
462
  walk_def_nodes(child, qualified_prefix, in_singleton_class, accumulator)
423
463
  end
424
464
  end
465
+ # rubocop:enable Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
425
466
 
426
467
  # v0.0.3 A — sentinel key under which `record_def_node`
427
468
  # files DefNodes that live outside any class / module
@@ -440,6 +481,134 @@ module Rigor
440
481
  accumulator[class_name][def_node.name] = def_node
441
482
  end
442
483
 
484
+ VISIBILITY_MODIFIERS = %i[public private protected].freeze
485
+
486
+ # v0.1.2 — per-class method-visibility table for the
487
+ # `def.method-visibility-mismatch` CheckRule.
488
+ #
489
+ # Tracks two visibility-changing forms:
490
+ #
491
+ # - **Modifier blocks**: a bare `private` / `protected` /
492
+ # `public` call inside a class body switches the
493
+ # "current default" visibility for every subsequent
494
+ # `def` until another modifier flips it again.
495
+ # - **Named-argument form**: `private :foo, :bar` (or
496
+ # the same with `protected` / `public`) marks specific
497
+ # names already-recorded under the class. Symbol-only
498
+ # args are recognised; `private def foo; end` (the
499
+ # wrap-around form) is not yet — it would need
500
+ # tracking the def-call's return-value visibility,
501
+ # which is a separate slice.
502
+ #
503
+ # Top-level (no surrounding class) defs do not contribute
504
+ # — Ruby's top-level visibility nuances (private at
505
+ # top-level marks the method on `Object`) are out of
506
+ # scope for v0.1.2.
507
+ def build_discovered_method_visibilities(root)
508
+ accumulator = {}
509
+ walk_method_visibilities(root, [], false, :public, accumulator)
510
+ accumulator.transform_values(&:freeze).freeze
511
+ end
512
+
513
+ # rubocop:disable Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity, Metrics/AbcSize
514
+ def walk_method_visibilities(node, qualified_prefix, in_singleton_class, current_visibility, accumulator)
515
+ return current_visibility unless node.is_a?(Prism::Node)
516
+
517
+ case node
518
+ when Prism::ClassNode, Prism::ModuleNode
519
+ name = qualified_name_for(node.constant_path)
520
+ if name
521
+ child_prefix = qualified_prefix + [name]
522
+ walk_method_visibilities(node.body, child_prefix, false, :public, accumulator) if node.body
523
+ return current_visibility
524
+ end
525
+ when Prism::SingletonClassNode
526
+ if node.expression.is_a?(Prism::SelfNode) && node.body
527
+ walk_method_visibilities(node.body, qualified_prefix, true, :public, accumulator)
528
+ return current_visibility
529
+ end
530
+ when Prism::ConstantWriteNode
531
+ if meta_new_block_body(node)
532
+ child_prefix = qualified_prefix + [node.name.to_s]
533
+ walk_method_visibilities(meta_new_block_body(node), child_prefix, false, :public, accumulator)
534
+ return current_visibility
535
+ end
536
+ when Prism::DefNode
537
+ record_def_visibility(node, qualified_prefix, in_singleton_class, current_visibility, accumulator)
538
+ return current_visibility
539
+ when Prism::CallNode
540
+ updated = apply_visibility_call(node, qualified_prefix, current_visibility, accumulator)
541
+ return updated unless updated.equal?(current_visibility)
542
+ end
543
+
544
+ # Statement-position StatementsNode preserves
545
+ # left-to-right visibility flow; everything else
546
+ # recurses with the entry visibility unchanged.
547
+ if node.is_a?(Prism::StatementsNode)
548
+ local_visibility = current_visibility
549
+ node.compact_child_nodes.each do |child|
550
+ local_visibility = walk_method_visibilities(child, qualified_prefix, in_singleton_class,
551
+ local_visibility, accumulator)
552
+ end
553
+ else
554
+ node.compact_child_nodes.each do |child|
555
+ walk_method_visibilities(child, qualified_prefix, in_singleton_class, current_visibility, accumulator)
556
+ end
557
+ end
558
+ current_visibility
559
+ end
560
+ # rubocop:enable Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity, Metrics/AbcSize
561
+
562
+ def record_def_visibility(def_node, qualified_prefix, in_singleton_class, current_visibility, accumulator)
563
+ return if def_node.receiver.is_a?(Prism::SelfNode) || in_singleton_class
564
+ return if qualified_prefix.empty?
565
+
566
+ class_name = qualified_prefix.join("::")
567
+ accumulator[class_name] ||= {}
568
+ accumulator[class_name][def_node.name] = current_visibility
569
+ end
570
+
571
+ # Recognises modifier calls on the implicit-self receiver
572
+ # inside a class body. Returns the (possibly updated)
573
+ # current visibility:
574
+ #
575
+ # - `private` / `public` / `protected` (no args) —
576
+ # switch the running default for subsequent defs.
577
+ # - `private :foo, :bar` — back-patch the named methods
578
+ # in the accumulator. Returns `current_visibility`
579
+ # unchanged because the running default does NOT
580
+ # change for this form.
581
+ def apply_visibility_call(call_node, qualified_prefix, current_visibility, accumulator)
582
+ return current_visibility unless call_node.receiver.nil?
583
+ return current_visibility unless VISIBILITY_MODIFIERS.include?(call_node.name)
584
+ return current_visibility if qualified_prefix.empty?
585
+
586
+ args = call_node.arguments&.arguments || []
587
+ if args.empty?
588
+ call_node.name
589
+ else
590
+ apply_named_visibility(args, qualified_prefix, call_node.name, accumulator)
591
+ current_visibility
592
+ end
593
+ end
594
+
595
+ def apply_named_visibility(args, qualified_prefix, visibility, accumulator)
596
+ class_name = qualified_prefix.join("::")
597
+ args.each do |arg|
598
+ name = visibility_target_name(arg)
599
+ next if name.nil?
600
+
601
+ accumulator[class_name] ||= {}
602
+ accumulator[class_name][name] = visibility
603
+ end
604
+ end
605
+
606
+ def visibility_target_name(arg)
607
+ return arg.unescaped.to_sym if arg.is_a?(Prism::SymbolNode) || arg.is_a?(Prism::StringNode)
608
+
609
+ nil
610
+ end
611
+
443
612
  # Registers the alias name in the `discovered_methods` table so
444
613
  # `undefined-method` diagnostics are not emitted for calls to the
445
614
  # aliased name. The kind mirrors the surrounding class context
@@ -552,7 +721,7 @@ module Rigor
552
721
  when Prism::ModuleNode, Prism::ClassNode
553
722
  return if record_class_or_module?(node, qualified_prefix, identity_table, discovered)
554
723
  when Prism::ConstantWriteNode
555
- return if record_data_define_constant?(node, qualified_prefix, identity_table, discovered)
724
+ return if record_meta_new_constant?(node, qualified_prefix, identity_table, discovered)
556
725
  end
557
726
 
558
727
  node.compact_child_nodes.each do |child|
@@ -573,17 +742,23 @@ module Rigor
573
742
  true
574
743
  end
575
744
 
576
- # Recognises `Const = Data.define(*Symbol) [do ... end]` and registers
577
- # `Const` (qualified by the surrounding class/module path) as a
578
- # discovered class. `Const.new(...)` then resolves to a fresh
579
- # `Nominal[Const]` via `meta_new`, instead of the un-narrowed
580
- # `Dynamic[top]` returned by the default `Class#new` envelope.
745
+ # Recognises class-creating meta calls at constant-write rvalue
746
+ # position and registers `Const` (qualified by the surrounding
747
+ # class/module path) as a discovered class. `Const.new(...)`
748
+ # then resolves to a fresh `Nominal[Const]` via `meta_new`,
749
+ # instead of the un-narrowed `Dynamic[top]` returned by the
750
+ # default `Class#new` envelope.
581
751
  #
582
- # The Data.define block body, if present, is recursed into so any
583
- # nested class/module declarations in the override block (rare but
584
- # legal) still feed the discovered table.
585
- def record_data_define_constant?(node, qualified_prefix, identity_table, discovered)
586
- return false unless data_define_call?(node.value)
752
+ # Two recognised meta forms:
753
+ #
754
+ # - `Const = Data.define(*Symbol) [do ... end]`
755
+ # - `Const = Struct.new(*Symbol [, keyword_init: ...]) [do ... end]`
756
+ #
757
+ # The block body, if present, is recursed into so any nested
758
+ # class/module declarations in the override block (rare but legal)
759
+ # still feed the discovered table.
760
+ def record_meta_new_constant?(node, qualified_prefix, identity_table, discovered)
761
+ return false unless data_define_call?(node.value) || struct_new_call?(node.value)
587
762
 
588
763
  full = (qualified_prefix + [node.name.to_s]).join("::")
589
764
  discovered[full] = Type::Combinator.singleton_of(full)
@@ -599,18 +774,46 @@ module Rigor
599
774
  def data_define_call?(node)
600
775
  return false unless node.is_a?(Prism::CallNode)
601
776
  return false unless node.name == :define
602
- return false unless data_constant_receiver?(node.receiver)
777
+ return false unless meta_constant_receiver?(node.receiver, :Data)
603
778
 
604
779
  args = node.arguments&.arguments || []
605
780
  args.all?(Prism::SymbolNode)
606
781
  end
607
782
 
608
- def data_constant_receiver?(node)
783
+ # Recognises `Struct.new(*Symbol)` and
784
+ # `Struct.new(*Symbol, keyword_init: <expr>)` at constant-write
785
+ # rvalue position. A trailing `KeywordHashNode` (the
786
+ # `keyword_init: ...` form) is accepted but does not contribute
787
+ # to member discovery; every other argument MUST be a
788
+ # `Prism::SymbolNode`. At least one Symbol member is required —
789
+ # `Struct.new()` is a degenerate form callers don't typically use.
790
+ def struct_new_call?(node)
791
+ return false unless meta_call_with_name?(node, :Struct, :new)
792
+
793
+ args = node.arguments&.arguments || []
794
+ positional = struct_new_positionals(args)
795
+ return false if positional.nil? || positional.empty?
796
+
797
+ positional.all?(Prism::SymbolNode)
798
+ end
799
+
800
+ def meta_call_with_name?(node, receiver_name, method_name)
801
+ return false unless node.is_a?(Prism::CallNode)
802
+ return false unless node.name == method_name
803
+
804
+ meta_constant_receiver?(node.receiver, receiver_name)
805
+ end
806
+
807
+ def struct_new_positionals(args)
808
+ args.last.is_a?(Prism::KeywordHashNode) ? args[0..-2] : args
809
+ end
810
+
811
+ def meta_constant_receiver?(node, expected_name)
609
812
  case node
610
813
  when Prism::ConstantReadNode
611
- node.name == :Data
814
+ node.name == expected_name
612
815
  when Prism::ConstantPathNode
613
- node.parent.nil? && node.name == :Data
816
+ node.parent.nil? && node.name == expected_name
614
817
  end
615
818
  end
616
819
 
@@ -1015,6 +1015,7 @@ module Rigor
1015
1015
  # same hierarchy-aware narrowing rules.
1016
1016
  def apply_post_return_fact(fact, call_node, current_scope, method_def)
1017
1017
  target_node = fact_target_node(fact, call_node, method_def)
1018
+ return apply_self_post_return_fact(fact, target_node, current_scope) if fact.target_kind == :self
1018
1019
  return current_scope unless target_node.is_a?(Prism::LocalVariableReadNode)
1019
1020
 
1020
1021
  local_name = target_node.name
@@ -1025,6 +1026,34 @@ module Rigor
1025
1026
  current_scope.with_local(local_name, narrowed)
1026
1027
  end
1027
1028
 
1029
+ # v0.1.1 Track 1 slice 3 — `assert self is T` post-return
1030
+ # narrowing for the four supported receiver shapes (mirrors
1031
+ # `Narrowing#apply_self_fact`).
1032
+ def apply_self_post_return_fact(fact, receiver_node, current_scope)
1033
+ case receiver_node
1034
+ when nil, Prism::SelfNode
1035
+ current = current_scope.self_type
1036
+ return current_scope if current.nil?
1037
+
1038
+ narrowed = Narrowing.narrow_for_fact(current, fact, current_scope.environment)
1039
+ current_scope.with_self_type(narrowed)
1040
+ when Prism::LocalVariableReadNode
1041
+ current = current_scope.local(receiver_node.name)
1042
+ return current_scope if current.nil?
1043
+
1044
+ narrowed = Narrowing.narrow_for_fact(current, fact, current_scope.environment)
1045
+ current_scope.with_local(receiver_node.name, narrowed)
1046
+ when Prism::InstanceVariableReadNode
1047
+ current = current_scope.ivar(receiver_node.name)
1048
+ return current_scope if current.nil?
1049
+
1050
+ narrowed = Narrowing.narrow_for_fact(current, fact, current_scope.environment)
1051
+ current_scope.with_ivar(receiver_node.name, narrowed)
1052
+ else
1053
+ current_scope
1054
+ end
1055
+ end
1056
+
1028
1057
  # `:self` routes to the call receiver; otherwise we look
1029
1058
  # up the matching positional argument by parameter name.
1030
1059
  def fact_target_node(fact, call_node, method_def)
@@ -111,6 +111,49 @@ module Rigor
111
111
  nil
112
112
  end
113
113
 
114
+ # ADR-2 § "Flow Contribution Bundle" / v0.1.1 Track 2
115
+ # slice 7 — per-call return-type contribution hook. When
116
+ # the inference engine dispatches a `Prism::CallNode` and
117
+ # neither the precision tiers nor RBS resolve a result,
118
+ # `MethodDispatcher` consults each loaded plugin via this
119
+ # hook ahead of `RbsDispatch`. Plugins that override the
120
+ # default return a {Rigor::FlowContribution} bundle whose
121
+ # `return_type` slot pins the call site's result type.
122
+ #
123
+ # Default returns nil — plugins that don't refine return
124
+ # types skip the override. Failures are isolated: a hook
125
+ # that raises gets its contribution dropped silently for
126
+ # this call so the rest of the dispatch chain continues.
127
+ def flow_contribution_for(call_node:, scope:) # rubocop:disable Lint/UnusedMethodArgument
128
+ nil
129
+ end
130
+
131
+ # ADR-9 slice 3 — per-run preparation hook. The runner
132
+ # invokes `#prepare(services)` on every loaded plugin once
133
+ # per `Analysis::Runner.run`, after `#init` has run on every
134
+ # plugin and before any `#diagnostics_for_file` call.
135
+ # Plugins use this hook to compute and publish facts other
136
+ # plugins consume:
137
+ #
138
+ # def prepare(services)
139
+ # services.fact_store.publish(
140
+ # plugin_id: manifest.id, name: :model_index, value: model_index
141
+ # )
142
+ # end
143
+ #
144
+ # Default no-op so plugins without facts to publish leave
145
+ # `#prepare` unimplemented. Failures isolate as
146
+ # `:plugin_loader runtime-error` diagnostics; a plugin that
147
+ # raises in `#prepare` has its facts considered un-published
148
+ # and downstream consumers see `nil` from `fact_store.read`.
149
+ #
150
+ # Slice 3 calls plugins in registration order. ADR-9 slice 5
151
+ # introduces topological ordering by `consumes:` so producers
152
+ # always run before consumers.
153
+ def prepare(services) # rubocop:disable Lint/UnusedMethodArgument
154
+ nil
155
+ end
156
+
114
157
  # ADR-7 § "Slice 5-A" — per-file diagnostic emission hook.
115
158
  # Override in plugin subclasses to return an array of
116
159
  # `Rigor::Analysis::Diagnostic` rows for the analysed file.
@@ -0,0 +1,92 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rigor
4
+ module Plugin
5
+ # Per-run cross-plugin fact store. ADR-9 § "Plugin::FactStore".
6
+ #
7
+ # A plugin publishes typed `(plugin_id, name) -> value` tuples
8
+ # in its `Plugin::Base#prepare(services)` hook (slice 3); other
9
+ # plugins read them in `#diagnostics_for_file` via
10
+ # `services.fact_store.read(plugin_id:, name:)`. The store is
11
+ # constructed fresh at the start of every `Analysis::Runner.run`
12
+ # and discarded at the end — caching the underlying expensive
13
+ # computation is the producer's job (`Plugin::Base.producer`);
14
+ # the FactStore just publishes a *reference* to that
15
+ # already-cached result.
16
+ #
17
+ # `(plugin_id, name)` is a unique key. A second `publish` with
18
+ # the same value is a no-op (`==` comparison); a second
19
+ # `publish` with a different value raises {Conflict}. Since
20
+ # `plugin_id` namespaces the key, a real conflict only happens
21
+ # when a single plugin publishes twice with differing values —
22
+ # the conflict signals a plugin-author bug, never a load-time
23
+ # interaction between unrelated plugins.
24
+ class FactStore
25
+ Fact = Data.define(:plugin_id, :name, :value)
26
+
27
+ class Conflict < StandardError
28
+ attr_reader :plugin_id, :name, :existing, :incoming
29
+
30
+ def initialize(plugin_id:, name:, existing:, incoming:)
31
+ @plugin_id = plugin_id
32
+ @name = name
33
+ @existing = existing
34
+ @incoming = incoming
35
+ super(
36
+ "fact store conflict: plugin #{plugin_id.inspect} published " \
37
+ "two different values for #{name.inspect} " \
38
+ "(existing: #{existing.inspect}, incoming: #{incoming.inspect})"
39
+ )
40
+ end
41
+ end
42
+
43
+ def initialize
44
+ @facts = {}
45
+ @mutex = Mutex.new
46
+ end
47
+
48
+ # Writes a `(plugin_id, name) -> value` triple. Idempotent if
49
+ # the same value is published twice (`==`); raises
50
+ # {Conflict} if the values differ.
51
+ #
52
+ # @param plugin_id [String] producing plugin's manifest id.
53
+ # @param name [Symbol, String] fact name (canonicalised to
54
+ # Symbol for lookup).
55
+ # @param value [Object] frozen-shape value object the
56
+ # producer chose to publish. The value is stored as-is.
57
+ def publish(plugin_id:, name:, value:)
58
+ plugin_id = plugin_id.to_s
59
+ name = name.to_sym
60
+ @mutex.synchronize do
61
+ existing = @facts[[plugin_id, name]]
62
+ if existing && existing.value != value
63
+ raise Conflict.new(plugin_id: plugin_id, name: name, existing: existing.value, incoming: value)
64
+ end
65
+
66
+ @facts[[plugin_id, name]] = Fact.new(plugin_id: plugin_id, name: name, value: value)
67
+ end
68
+ nil
69
+ end
70
+
71
+ # @return [Object, nil] the published value, or `nil` when no
72
+ # fact is registered. Reads do NOT establish a dependency —
73
+ # `manifest(consumes:)` (slice 4) is the dependency
74
+ # declaration mechanism.
75
+ def read(plugin_id:, name:)
76
+ fact = @mutex.synchronize { @facts[[plugin_id.to_s, name.to_sym]] }
77
+ fact&.value
78
+ end
79
+
80
+ # @return [Boolean] whether a fact is registered.
81
+ def published?(plugin_id:, name:)
82
+ @mutex.synchronize { @facts.key?([plugin_id.to_s, name.to_sym]) }
83
+ end
84
+
85
+ # @yield [Fact] every published fact in publication order.
86
+ def each_fact(&)
87
+ snapshot = @mutex.synchronize { @facts.values }
88
+ snapshot.each(&)
89
+ end
90
+ end
91
+ end
92
+ end
@@ -27,20 +27,37 @@ module Rigor
27
27
  # the file's contents, and adds a digest-keyed
28
28
  # {Cache::Descriptor::FileEntry} to the boundary's
29
29
  # accumulated descriptor.
30
- # - `#open_url(url)` — always raises {AccessDeniedError} while
31
- # `network_policy` is `:disabled` (the only setting in slice
32
- # 2). The hook exists so slices 3-6 can layer richer access
33
- # policy without re-defining the API.
30
+ # - `#open_url(url)` — fetches the URL when the policy
31
+ # permits it (`network_policy: :allowlist` plus an
32
+ # `allowed_url_hosts` match) and raises
33
+ # {AccessDeniedError} otherwise. v0.1.2 ships the
34
+ # allowlist surface; the default project policy still
35
+ # has `network_policy: :disabled` so plugins that want
36
+ # network access opt in explicitly through
37
+ # `.rigor.yml`'s `plugins_io.network: allowlist` plus
38
+ # `plugins_io.allowed_url_hosts: [...]`. The HTTP fetch
39
+ # is GET-only over HTTPS, capped at {URL_TIMEOUT_SECONDS}
40
+ # wall time and {URL_MAX_BYTES} body size; non-2xx
41
+ # responses raise {AccessDeniedError} so plugin code
42
+ # doesn't have to rescue mid-build.
34
43
  # - `#cache_descriptor` — flushes the accumulated entries into
35
44
  # a fresh {Cache::Descriptor} for the contribution that
36
- # built it.
45
+ # built it. URL fetches contribute `ConfigEntry` rows
46
+ # keyed `"url:#{url}"` with the response body's SHA-256
47
+ # so contributions invalidate when the remote document
48
+ # changes.
37
49
  class IoBoundary
50
+ URL_TIMEOUT_SECONDS = 10
51
+ URL_MAX_BYTES = 10 * 1024 * 1024
52
+
38
53
  attr_reader :policy, :plugin_id
39
54
 
40
- def initialize(policy:, plugin_id:)
55
+ def initialize(policy:, plugin_id:, http_client: DefaultHttpClient.new)
41
56
  @policy = policy
42
57
  @plugin_id = plugin_id.to_s.dup.freeze
43
58
  @file_entries = {}
59
+ @config_entries = {}
60
+ @http_client = http_client
44
61
  @mutex = Mutex.new
45
62
  end
46
63
 
@@ -64,30 +81,39 @@ module Rigor
64
81
  contents
65
82
  end
66
83
 
67
- # Slice 2 stub: every URL access is denied while
68
- # `network_policy` is `:disabled`. Slices that need to relax
69
- # the rule (e.g. for opt-in offline-replay caches) will lift
70
- # the policy gate; the API does not change.
84
+ # Fetches the URL when the policy permits it. Returns the
85
+ # response body. Raises {AccessDeniedError} when the policy
86
+ # is `:disabled`, the URL scheme is not `https`, the host is
87
+ # not on the allowlist, the response is non-2xx, the body
88
+ # exceeds {URL_MAX_BYTES}, or the request times out
89
+ # ({URL_TIMEOUT_SECONDS}). On success, records a
90
+ # `ConfigEntry` keyed `"url:#{url}"` with the body's
91
+ # SHA-256 so the cache descriptor invalidates if the remote
92
+ # document changes.
71
93
  def open_url(url)
72
- unless @policy.network_allowed?
94
+ url_string = url.to_s
95
+ unless @policy.allow_url?(url_string)
73
96
  raise AccessDeniedError.new(
74
97
  "plugin #{@plugin_id.inspect} cannot open URL #{url.inspect}: " \
75
- "network access is disabled during analysis",
98
+ "URL is not permitted by the active TrustPolicy " \
99
+ "(network_policy=#{@policy.network_policy} allowed_url_hosts=#{@policy.allowed_url_hosts.inspect})",
76
100
  reason: :network_disabled,
77
- resource: url.to_s
101
+ resource: url_string
78
102
  )
79
103
  end
80
104
 
81
- raise NotImplementedError, "URL fetch surface is reserved; slice 2 only ships the deny path"
105
+ body = @http_client.get(url_string, timeout: URL_TIMEOUT_SECONDS, max_bytes: URL_MAX_BYTES)
106
+ record_url_entry(url_string, body)
107
+ body
82
108
  end
83
109
 
84
110
  # @return [Rigor::Cache::Descriptor] frozen snapshot of every
85
- # file the boundary has read so far. Calling this multiple
86
- # times yields equal descriptors; subsequent reads expand
87
- # the underlying record table.
111
+ # file / URL the boundary has read so far. Calling this
112
+ # multiple times yields equal descriptors; subsequent
113
+ # reads expand the underlying record tables.
88
114
  def cache_descriptor
89
- entries = @mutex.synchronize { @file_entries.values.dup }
90
- Cache::Descriptor.new(files: entries)
115
+ files, configs = @mutex.synchronize { [@file_entries.values.dup, @config_entries.values.dup] }
116
+ Cache::Descriptor.new(files: files, configs: configs)
91
117
  end
92
118
 
93
119
  private
@@ -97,6 +123,53 @@ module Rigor
97
123
  entry = Cache::Descriptor::FileEntry.new(path: path, comparator: :digest, value: digest)
98
124
  @mutex.synchronize { @file_entries[path] = entry }
99
125
  end
126
+
127
+ def record_url_entry(url, body)
128
+ digest = Digest::SHA256.hexdigest(body)
129
+ key = "url:#{url}"
130
+ entry = Cache::Descriptor::ConfigEntry.new(key: key, value_hash: digest)
131
+ @mutex.synchronize { @config_entries[key] = entry }
132
+ end
133
+ end
134
+
135
+ # Default HTTP client wrapping `Net::HTTP`. Wraps a single
136
+ # `GET` over HTTPS. Specs inject a fake client that conforms
137
+ # to the same `#get(url, timeout:, max_bytes:)` shape so the
138
+ # tests don't require network access.
139
+ class DefaultHttpClient
140
+ # rubocop:disable Metrics/MethodLength
141
+ def get(url, timeout:, max_bytes:)
142
+ require "net/http"
143
+ require "uri"
144
+
145
+ uri = URI.parse(url)
146
+ body = +""
147
+ Net::HTTP.start(uri.host, uri.port, use_ssl: true,
148
+ open_timeout: timeout,
149
+ read_timeout: timeout) do |http|
150
+ http.request_get(uri.request_uri) do |response|
151
+ unless response.is_a?(Net::HTTPSuccess)
152
+ raise Plugin::AccessDeniedError.new(
153
+ "URL #{url.inspect} returned non-success status #{response.code}",
154
+ reason: :url_fetch_failed,
155
+ resource: url
156
+ )
157
+ end
158
+ response.read_body do |chunk|
159
+ body << chunk
160
+ if body.bytesize > max_bytes
161
+ raise Plugin::AccessDeniedError.new(
162
+ "URL #{url.inspect} body exceeds #{max_bytes} bytes",
163
+ reason: :url_body_too_large,
164
+ resource: url
165
+ )
166
+ end
167
+ end
168
+ end
169
+ end
170
+ body
171
+ end
172
+ # rubocop:enable Metrics/MethodLength
100
173
  end
101
174
  end
102
175
  end