kumi 0.0.4 → 0.0.6

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 (89) hide show
  1. checksums.yaml +4 -4
  2. data/CLAUDE.md +160 -8
  3. data/README.md +278 -200
  4. data/{documents → docs}/AST.md +29 -29
  5. data/{documents → docs}/DSL.md +3 -3
  6. data/{documents → docs}/SYNTAX.md +107 -24
  7. data/docs/features/README.md +45 -0
  8. data/docs/features/analysis-cascade-mutual-exclusion.md +89 -0
  9. data/docs/features/analysis-type-inference.md +42 -0
  10. data/docs/features/analysis-unsat-detection.md +71 -0
  11. data/docs/features/array-broadcasting.md +170 -0
  12. data/docs/features/input-declaration-system.md +42 -0
  13. data/docs/features/performance.md +16 -0
  14. data/examples/federal_tax_calculator_2024.rb +43 -40
  15. data/examples/game_of_life.rb +97 -0
  16. data/examples/simple_rpg_game.rb +1000 -0
  17. data/examples/static_analysis_errors.rb +178 -0
  18. data/examples/wide_schema_compilation_and_evaluation_benchmark.rb +1 -1
  19. data/lib/kumi/analyzer/analysis_state.rb +37 -0
  20. data/lib/kumi/analyzer/constant_evaluator.rb +22 -16
  21. data/lib/kumi/analyzer/passes/broadcast_detector.rb +251 -0
  22. data/lib/kumi/analyzer/passes/{definition_validator.rb → declaration_validator.rb} +8 -7
  23. data/lib/kumi/analyzer/passes/dependency_resolver.rb +106 -26
  24. data/lib/kumi/analyzer/passes/input_collector.rb +105 -23
  25. data/lib/kumi/analyzer/passes/name_indexer.rb +2 -2
  26. data/lib/kumi/analyzer/passes/pass_base.rb +11 -28
  27. data/lib/kumi/analyzer/passes/semantic_constraint_validator.rb +110 -0
  28. data/lib/kumi/analyzer/passes/toposorter.rb +45 -9
  29. data/lib/kumi/analyzer/passes/type_checker.rb +34 -11
  30. data/lib/kumi/analyzer/passes/type_consistency_checker.rb +2 -1
  31. data/lib/kumi/analyzer/passes/type_inferencer.rb +128 -21
  32. data/lib/kumi/analyzer/passes/unsat_detector.rb +312 -13
  33. data/lib/kumi/analyzer/passes/visitor_pass.rb +4 -3
  34. data/lib/kumi/analyzer.rb +41 -24
  35. data/lib/kumi/atom_unsat_solver.rb +45 -0
  36. data/lib/kumi/cli.rb +449 -0
  37. data/lib/kumi/compiler.rb +194 -16
  38. data/lib/kumi/constraint_relationship_solver.rb +638 -0
  39. data/lib/kumi/domain/validator.rb +0 -4
  40. data/lib/kumi/error_reporter.rb +6 -6
  41. data/lib/kumi/evaluation_wrapper.rb +20 -4
  42. data/lib/kumi/explain.rb +28 -28
  43. data/lib/kumi/export/node_registry.rb +26 -12
  44. data/lib/kumi/export/node_serializers.rb +1 -1
  45. data/lib/kumi/function_registry/collection_functions.rb +117 -9
  46. data/lib/kumi/function_registry/function_builder.rb +4 -3
  47. data/lib/kumi/function_registry.rb +8 -2
  48. data/lib/kumi/input/type_matcher.rb +3 -0
  49. data/lib/kumi/input/validator.rb +0 -3
  50. data/lib/kumi/parser/declaration_reference_proxy.rb +36 -0
  51. data/lib/kumi/parser/dsl_cascade_builder.rb +19 -8
  52. data/lib/kumi/parser/expression_converter.rb +80 -12
  53. data/lib/kumi/parser/input_builder.rb +40 -9
  54. data/lib/kumi/parser/input_field_proxy.rb +46 -0
  55. data/lib/kumi/parser/input_proxy.rb +3 -3
  56. data/lib/kumi/parser/nested_input.rb +15 -0
  57. data/lib/kumi/parser/parser.rb +2 -0
  58. data/lib/kumi/parser/schema_builder.rb +10 -9
  59. data/lib/kumi/parser/sugar.rb +171 -18
  60. data/lib/kumi/schema.rb +3 -1
  61. data/lib/kumi/schema_instance.rb +69 -3
  62. data/lib/kumi/syntax/array_expression.rb +15 -0
  63. data/lib/kumi/syntax/call_expression.rb +11 -0
  64. data/lib/kumi/syntax/cascade_expression.rb +11 -0
  65. data/lib/kumi/syntax/case_expression.rb +11 -0
  66. data/lib/kumi/syntax/declaration_reference.rb +11 -0
  67. data/lib/kumi/syntax/hash_expression.rb +11 -0
  68. data/lib/kumi/syntax/input_declaration.rb +12 -0
  69. data/lib/kumi/syntax/input_element_reference.rb +12 -0
  70. data/lib/kumi/syntax/input_reference.rb +12 -0
  71. data/lib/kumi/syntax/literal.rb +11 -0
  72. data/lib/kumi/syntax/root.rb +1 -0
  73. data/lib/kumi/syntax/trait_declaration.rb +11 -0
  74. data/lib/kumi/syntax/value_declaration.rb +11 -0
  75. data/lib/kumi/types/compatibility.rb +8 -0
  76. data/lib/kumi/types/validator.rb +1 -1
  77. data/lib/kumi/vectorization_metadata.rb +108 -0
  78. data/lib/kumi/version.rb +1 -1
  79. data/scripts/generate_function_docs.rb +22 -10
  80. metadata +38 -17
  81. data/CHANGELOG.md +0 -25
  82. data/lib/kumi/domain.rb +0 -8
  83. data/lib/kumi/input.rb +0 -8
  84. data/lib/kumi/syntax/declarations.rb +0 -23
  85. data/lib/kumi/syntax/expressions.rb +0 -30
  86. data/lib/kumi/syntax/terminal_expressions.rb +0 -27
  87. data/lib/kumi/syntax.rb +0 -9
  88. data/test_impossible_cascade.rb +0 -51
  89. /data/{documents → docs}/FUNCTIONS.md +0 -0
@@ -3,13 +3,24 @@
3
3
  module Kumi
4
4
  module Analyzer
5
5
  module Passes
6
- # RESPONSIBILITY: Build dependency graph and leaf map, validate references
7
- # DEPENDENCIES: :definitions from NameIndexer
8
- # PRODUCES: :dependency_graph - Hash of name → [DependencyEdge], :leaf_map - Hash of name → Set[nodes]
6
+ # RESPONSIBILITY: Build dependency graph and detect conditional dependencies in cascades
7
+ # DEPENDENCIES: :definitions from NameIndexer, :input_meta from InputCollector
8
+ # PRODUCES: :dependency_graph, :transitive_dependents, :leaf_map - Dependency analysis results
9
9
  # INTERFACE: new(schema, state).run(errors)
10
10
  class DependencyResolver < PassBase
11
- # A Struct to hold rich dependency information
12
- DependencyEdge = Struct.new(:to, :type, :via, keyword_init: true)
11
+ # Enhanced edge with conditional flag and cascade metadata
12
+ class DependencyEdge
13
+ attr_reader :to, :type, :via, :conditional, :cascade_owner
14
+
15
+ def initialize(to:, type:, via:, conditional: false, cascade_owner: nil)
16
+ @to = to
17
+ @type = type
18
+ @via = via
19
+ @conditional = conditional
20
+ @cascade_owner = cascade_owner
21
+ end
22
+ end
23
+
13
24
  include Syntax
14
25
 
15
26
  def run(errors)
@@ -17,53 +28,122 @@ module Kumi
17
28
  input_meta = get_state(:input_meta)
18
29
 
19
30
  dependency_graph = Hash.new { |h, k| h[k] = [] }
31
+ reverse_dependencies = Hash.new { |h, k| h[k] = [] }
20
32
  leaf_map = Hash.new { |h, k| h[k] = Set.new }
21
33
 
22
34
  each_decl do |decl|
23
35
  # Traverse the expression for each declaration, passing context down
24
- visit_with_context(decl.expression) do |node, context|
25
- process_node(node, decl, dependency_graph, leaf_map, definitions, input_meta, errors, context)
36
+ visit_with_context(decl.expression, { decl_name: decl.name }) do |node, context|
37
+ process_node(node, decl, dependency_graph, reverse_dependencies, leaf_map, definitions, input_meta, errors, context)
26
38
  end
27
39
  end
28
40
 
29
- set_state(:dependency_graph, dependency_graph.transform_values(&:freeze).freeze)
30
- set_state(:leaf_map, leaf_map.transform_values(&:freeze).freeze)
41
+ # Compute transitive closure of reverse dependencies
42
+ transitive_dependents = compute_transitive_closure(reverse_dependencies)
43
+
44
+ state.with(:dependency_graph, dependency_graph.transform_values(&:freeze).freeze)
45
+ .with(:transitive_dependents, transitive_dependents.freeze)
46
+ .with(:leaf_map, leaf_map.transform_values(&:freeze).freeze)
31
47
  end
32
48
 
33
49
  private
34
50
 
35
- def process_node(node, decl, graph, leaves, definitions, input_meta, errors, context)
51
+ def process_node(node, decl, graph, reverse_deps, leaves, definitions, input_meta, errors, context)
36
52
  case node
37
- when Binding
38
- add_error(errors, node.loc, "undefined reference to `#{node.name}`") unless definitions.key?(node.name)
39
- add_dependency_edge(graph, decl.name, node.name, :ref, context[:via])
40
- when FieldRef
41
- add_error(errors, node.loc, "undeclared input `#{node.name}`") unless input_meta.key?(node.name)
42
- add_dependency_edge(graph, decl.name, node.name, :key, context[:via])
43
- leaves[decl.name] << node # put it back
53
+ when DeclarationReference
54
+ report_error(errors, "undefined reference to `#{node.name}`", location: node.loc) unless definitions.key?(node.name)
55
+
56
+ # Determine if this is a conditional dependency
57
+ conditional = context[:in_cascade_branch] || context[:in_cascade_base] || false
58
+ cascade_owner = conditional ? (context[:cascade_owner] || context[:decl_name]) : nil
59
+
60
+ add_dependency_edge(graph, reverse_deps, decl.name, node.name, :ref, context[:via],
61
+ conditional: conditional,
62
+ cascade_owner: cascade_owner)
63
+ when InputReference
64
+ add_dependency_edge(graph, reverse_deps, decl.name, node.name, :key, context[:via])
65
+ leaves[decl.name] << node
66
+ when InputElementReference
67
+ # adds the root input declaration as a dependency
68
+ root_input_declr_name = node.path.first
69
+ add_dependency_edge(graph, reverse_deps, decl.name, root_input_declr_name, :key, context[:via])
44
70
  when Literal
45
71
  leaves[decl.name] << node
46
72
  end
47
73
  end
48
74
 
49
- def add_dependency_edge(graph, from, to, type, via)
50
- edge = DependencyEdge.new(to: to, type: type, via: via)
75
+ def add_dependency_edge(graph, reverse_deps, from, to, type, via, conditional: false, cascade_owner: nil)
76
+ edge = DependencyEdge.new(
77
+ to: to,
78
+ type: type,
79
+ via: via,
80
+ conditional: conditional,
81
+ cascade_owner: cascade_owner
82
+ )
51
83
  graph[from] << edge
84
+ reverse_deps[to] << from
52
85
  end
53
86
 
54
- # Custom visitor that passes context (like function name) down the tree
87
+ # Custom visitor that understands cascade structure
55
88
  def visit_with_context(node, context = {}, &block)
56
89
  return unless node
57
90
 
58
91
  yield(node, context)
59
92
 
60
- new_context = if node.is_a?(Expressions::CallExpression)
61
- { via: node.fn_name }
62
- else
63
- context
64
- end
93
+ case node
94
+ when CascadeExpression
95
+ # Visit condition nodes and result expressions (non-base cases)
96
+ node.cases[0...-1].each do |when_case|
97
+ if when_case.condition
98
+ # Visit condition normally
99
+ visit_with_context(when_case.condition, context, &block)
100
+ end
101
+ # Visit result expressions as conditional dependencies
102
+ conditional_context = context.merge(in_cascade_branch: true, cascade_owner: context[:decl_name])
103
+ visit_with_context(when_case.result, conditional_context, &block)
104
+ end
105
+
106
+ # Visit base case with conditional flag
107
+ if node.cases.last
108
+ base_context = context.merge(in_cascade_base: true)
109
+ visit_with_context(node.cases.last.result, base_context, &block)
110
+ end
111
+ when CallExpression
112
+ new_context = context.merge(via: node.fn_name)
113
+ node.children.each { |child| visit_with_context(child, new_context, &block) }
114
+ else
115
+ node.children.each { |child| visit_with_context(child, context, &block) } if node.respond_to?(:children)
116
+ end
117
+ end
118
+
119
+ def compute_transitive_closure(reverse_dependencies)
120
+ transitive = {}
121
+ all_keys = reverse_dependencies.keys
122
+
123
+ all_keys.each do |key|
124
+ visited = Set.new
125
+ to_visit = [key]
126
+ dependents = Set.new
127
+
128
+ while to_visit.any?
129
+ current = to_visit.shift
130
+ next if visited.include?(current)
131
+
132
+ visited.add(current)
133
+
134
+ direct_dependents = reverse_dependencies[current] || []
135
+ direct_dependents.each do |dependent|
136
+ next if visited.include?(dependent)
137
+
138
+ dependents << dependent
139
+ to_visit << dependent
140
+ end
141
+ end
142
+
143
+ transitive[key] = dependents.to_a
144
+ end
65
145
 
66
- node.children.each { |child| visit_with_context(child, new_context, &block) }
146
+ transitive
67
147
  end
68
148
  end
69
149
  end
@@ -4,18 +4,16 @@ module Kumi
4
4
  module Analyzer
5
5
  module Passes
6
6
  # RESPONSIBILITY: Collect field metadata from input declarations and validate consistency
7
- # DEPENDENCIES: None
7
+ # DEPENDENCIES: :definitions
8
8
  # PRODUCES: :input_meta - Hash mapping field names to {type:, domain:} metadata
9
9
  # INTERFACE: new(schema, state).run(errors)
10
10
  class InputCollector < PassBase
11
- include Syntax::TerminalExpressions
12
-
13
11
  def run(errors)
14
12
  input_meta = {}
15
13
 
16
14
  schema.inputs.each do |field_decl|
17
- unless field_decl.is_a?(FieldDecl)
18
- add_error(errors, field_decl.loc, "Expected FieldDecl node, got #{field_decl.class}")
15
+ unless field_decl.is_a?(Kumi::Syntax::InputDeclaration)
16
+ report_error(errors, "Expected InputDeclaration node, got #{field_decl.class}", location: field_decl.loc)
19
17
  next
20
18
  end
21
19
 
@@ -23,31 +21,115 @@ module Kumi
23
21
  existing = input_meta[name]
24
22
 
25
23
  if existing
26
- # Check for compatibility
27
- if existing[:type] != field_decl.type && field_decl.type && existing[:type]
28
- add_error(errors, field_decl.loc,
29
- "Field :#{name} declared with conflicting types: #{existing[:type]} vs #{field_decl.type}")
24
+ # Check for compatibility and merge
25
+ merged_meta = merge_field_metadata(existing, field_decl, errors)
26
+ input_meta[name] = merged_meta if merged_meta
27
+ else
28
+ # New field - collect its metadata
29
+ input_meta[name] = collect_field_metadata(field_decl, errors)
30
+ end
31
+ end
32
+
33
+ state.with(:input_meta, freeze_nested_hash(input_meta))
34
+ end
35
+
36
+ private
37
+
38
+ def collect_field_metadata(field_decl, errors)
39
+ validate_domain_type(field_decl, errors) if field_decl.domain
40
+
41
+ metadata = {
42
+ type: field_decl.type,
43
+ domain: field_decl.domain
44
+ }
45
+
46
+ # Process children if present
47
+ if field_decl.children && !field_decl.children.empty?
48
+ children_meta = {}
49
+ field_decl.children.each do |child_decl|
50
+ unless child_decl.is_a?(Kumi::Syntax::InputDeclaration)
51
+ report_error(errors, "Expected InputDeclaration node in children, got #{child_decl.class}", location: child_decl.loc)
52
+ next
30
53
  end
54
+ children_meta[child_decl.name] = collect_field_metadata(child_decl, errors)
55
+ end
56
+ metadata[:children] = children_meta
57
+ end
58
+
59
+ metadata
60
+ end
61
+
62
+ def merge_field_metadata(existing, field_decl, errors)
63
+ name = field_decl.name
64
+
65
+ # Check for type compatibility
66
+ if existing[:type] != field_decl.type && field_decl.type && existing[:type]
67
+ report_error(errors,
68
+ "Field :#{name} declared with conflicting types: #{existing[:type]} vs #{field_decl.type}",
69
+ location: field_decl.loc)
70
+ end
71
+
72
+ # Check for domain compatibility
73
+ if existing[:domain] != field_decl.domain && field_decl.domain && existing[:domain]
74
+ report_error(errors,
75
+ "Field :#{name} declared with conflicting domains: #{existing[:domain].inspect} vs #{field_decl.domain.inspect}",
76
+ location: field_decl.loc)
77
+ end
78
+
79
+ # Validate domain type if provided
80
+ validate_domain_type(field_decl, errors) if field_decl.domain
31
81
 
32
- if existing[:domain] != field_decl.domain && field_decl.domain && existing[:domain]
33
- add_error(errors, field_decl.loc,
34
- "Field :#{name} declared with conflicting domains: #{existing[:domain].inspect} vs #{field_decl.domain.inspect}")
82
+ # Merge metadata (later declarations override nil values)
83
+ merged = {
84
+ type: field_decl.type || existing[:type],
85
+ domain: field_decl.domain || existing[:domain]
86
+ }
87
+
88
+ # Merge children if present
89
+ if field_decl.children && !field_decl.children.empty?
90
+ existing_children = existing[:children] || {}
91
+ new_children = {}
92
+
93
+ field_decl.children.each do |child_decl|
94
+ unless child_decl.is_a?(Kumi::Syntax::InputDeclaration)
95
+ report_error(errors, "Expected InputDeclaration node in children, got #{child_decl.class}", location: child_decl.loc)
96
+ next
35
97
  end
36
98
 
37
- # Merge metadata (later declarations override nil values)
38
- input_meta[name] = {
39
- type: field_decl.type || existing[:type],
40
- domain: field_decl.domain || existing[:domain]
41
- }
42
- else
43
- input_meta[name] = {
44
- type: field_decl.type,
45
- domain: field_decl.domain
46
- }
99
+ child_name = child_decl.name
100
+ new_children[child_name] = if existing_children[child_name]
101
+ merge_field_metadata(existing_children[child_name], child_decl, errors)
102
+ else
103
+ collect_field_metadata(child_decl, errors)
104
+ end
47
105
  end
106
+
107
+ merged[:children] = new_children
108
+ elsif existing[:children]
109
+ merged[:children] = existing[:children]
48
110
  end
49
111
 
50
- set_state(:input_meta, input_meta.freeze)
112
+ merged
113
+ end
114
+
115
+ def freeze_nested_hash(hash)
116
+ hash.each_value do |value|
117
+ freeze_nested_hash(value) if value.is_a?(Hash)
118
+ end
119
+ hash.freeze
120
+ end
121
+
122
+ def validate_domain_type(field_decl, errors)
123
+ domain = field_decl.domain
124
+ return if valid_domain_type?(domain)
125
+
126
+ report_error(errors,
127
+ "Field :#{field_decl.name} has invalid domain constraint: #{domain.inspect}. Domain must be a Range, Array, or Proc",
128
+ location: field_decl.loc)
129
+ end
130
+
131
+ def valid_domain_type?(domain)
132
+ domain.is_a?(Range) || domain.is_a?(Array) || domain.is_a?(Proc)
51
133
  end
52
134
  end
53
135
  end
@@ -12,11 +12,11 @@ module Kumi
12
12
  definitions = {}
13
13
 
14
14
  each_decl do |decl|
15
- add_error(errors, decl.loc, "duplicated definition `#{decl.name}`") if definitions.key?(decl.name)
15
+ report_error(errors, "duplicated definition `#{decl.name}`", location: decl.loc) if definitions.key?(decl.name)
16
16
  definitions[decl.name] = decl
17
17
  end
18
18
 
19
- set_state(:definitions, definitions.freeze)
19
+ state.with(:definitions, definitions.freeze)
20
20
  end
21
21
  end
22
22
  end
@@ -3,22 +3,21 @@
3
3
  module Kumi
4
4
  module Analyzer
5
5
  module Passes
6
- # Base class for all analyzer passes providing common functionality
7
- # and enforcing consistent interface patterns.
6
+ # Base class for analyzer passes with simple immutable state
8
7
  class PassBase
9
8
  include Kumi::Syntax
10
9
  include Kumi::ErrorReporting
11
10
 
12
11
  # @param schema [Syntax::Root] The schema to analyze
13
- # @param state [Hash] Shared analysis state accumulator
12
+ # @param state [AnalysisState] Current analysis state
14
13
  def initialize(schema, state)
15
14
  @schema = schema
16
15
  @state = state
17
16
  end
18
17
 
19
- # Main entry point for pass execution
18
+ # Main pass execution - subclasses implement this
20
19
  # @param errors [Array] Error accumulator array
21
- # @abstract Subclasses must implement this method
20
+ # @return [AnalysisState] New state after pass execution
22
21
  def run(errors)
23
22
  raise NotImplementedError, "#{self.class.name} must implement #run"
24
23
  end
@@ -28,38 +27,22 @@ module Kumi
28
27
  attr_reader :schema, :state
29
28
 
30
29
  # Iterate over all declarations (attributes and traits) in the schema
31
- # @yield [Syntax::Declarations::Attribute|Syntax::Declarations::Trait] Each declaration
30
+ # @yield [Syntax::Attribute|Syntax::Trait] Each declaration
32
31
  def each_decl(&block)
33
32
  schema.attributes.each(&block)
34
33
  schema.traits.each(&block)
35
34
  end
36
35
 
37
- # DEPRECATED: Use report_error instead for consistent error handling
38
- # Helper to add standardized error messages
39
- # @param errors [Array] Error accumulator
40
- # @param location [Syntax::Location] Error location
41
- # @param message [String] Error message
42
- def add_error(errors, location, message)
43
- errors << ErrorReporter.create_error(message, location: location, type: :semantic)
44
- end
45
-
46
- # Helper to get required state from previous passes
47
- # @param key [Symbol] State key to retrieve
48
- # @param required [Boolean] Whether this state is required
49
- # @return [Object] The state value
50
- # @raise [StandardError] If required state is missing
36
+ # Get state value - compatible with old interface
51
37
  def get_state(key, required: true)
52
- value = state[key]
53
- raise "Pass #{self.class.name} requires #{key} from previous passes, but it was not found" if required && value.nil?
38
+ raise StandardError, "Required state key '#{key}' not found" if required && !state.key?(key)
54
39
 
55
- value
40
+ state[key]
56
41
  end
57
42
 
58
- # Helper to set state for subsequent passes
59
- # @param key [Symbol] State key to set
60
- # @param value [Object] Value to store
61
- def set_state(key, value)
62
- state[key] = value
43
+ # Add error to the error list
44
+ def add_error(errors, location, message)
45
+ errors << ErrorReporter.create_error(message, location: location, type: :semantic)
63
46
  end
64
47
  end
65
48
  end
@@ -0,0 +1,110 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kumi
4
+ module Analyzer
5
+ module Passes
6
+ # RESPONSIBILITY: Validate DSL semantic constraints at the AST level
7
+ # DEPENDENCIES: :definitions
8
+ # PRODUCES: None (validation only)
9
+ # INTERFACE: new(schema, state).run(errors)
10
+ #
11
+ # This pass enforces semantic constraints that must hold regardless of which parser
12
+ # was used to construct the AST. It validates:
13
+ # 1. Cascade conditions are only trait references (DeclarationReference nodes)
14
+ # 2. Trait expressions evaluate to boolean values (CallExpression nodes)
15
+ # 3. Function names exist in the function registry
16
+ # 4. Expression types are valid for their context
17
+ class SemanticConstraintValidator < VisitorPass
18
+ def run(errors)
19
+ each_decl do |decl|
20
+ visit(decl) { |node| validate_semantic_constraints(node, decl, errors) }
21
+ end
22
+ state
23
+ end
24
+
25
+ private
26
+
27
+ def validate_semantic_constraints(node, decl, errors)
28
+ case node
29
+ when Kumi::Syntax::TraitDeclaration
30
+ validate_trait_expression(node, errors)
31
+ when Kumi::Syntax::CaseExpression
32
+ validate_cascade_condition(node, errors)
33
+ when Kumi::Syntax::CallExpression
34
+ validate_function_call(node, errors)
35
+ end
36
+ end
37
+
38
+ def validate_trait_expression(trait, errors)
39
+ return if trait.expression.is_a?(Kumi::Syntax::CallExpression)
40
+
41
+ report_error(
42
+ errors,
43
+ "trait `#{trait.name}` must have a boolean expression",
44
+ location: trait.loc,
45
+ type: :semantic
46
+ )
47
+ end
48
+
49
+ def validate_cascade_condition(when_case, errors)
50
+ condition = when_case.condition
51
+
52
+ case condition
53
+ when Kumi::Syntax::DeclarationReference
54
+ # Valid: trait reference
55
+ return
56
+ when Kumi::Syntax::CallExpression
57
+ # Valid if it's a boolean composition of traits (all?, any?, none?)
58
+ return if boolean_trait_composition?(condition)
59
+
60
+ # For now, allow other CallExpressions - they'll be validated by other passes
61
+ return
62
+ when Kumi::Syntax::Literal
63
+ # Allow literal conditions (like true/false) - they might be valid
64
+ return
65
+ else
66
+ # Only reject truly invalid conditions like InputReference or complex expressions
67
+ report_error(
68
+ errors,
69
+ "cascade condition must be trait reference",
70
+ location: when_case.loc,
71
+ type: :semantic
72
+ )
73
+ end
74
+ end
75
+
76
+ def validate_function_call(call_expr, errors)
77
+ fn_name = call_expr.fn_name
78
+
79
+ # Skip validation if FunctionRegistry is being mocked for testing
80
+ return if function_registry_mocked?
81
+
82
+ return if FunctionRegistry.supported?(fn_name)
83
+
84
+ report_error(
85
+ errors,
86
+ "unknown function `#{fn_name}`",
87
+ location: call_expr.loc,
88
+ type: :semantic
89
+ )
90
+ end
91
+
92
+ def boolean_trait_composition?(call_expr)
93
+ # Allow boolean composition functions that operate on trait collections
94
+ %i[all? any? none?].include?(call_expr.fn_name)
95
+ end
96
+
97
+ def function_registry_mocked?
98
+ # Check if FunctionRegistry is being mocked (for tests)
99
+ begin
100
+ # Try to access a method that doesn't exist in the real registry
101
+ # If it's mocked, this won't raise an error
102
+ FunctionRegistry.respond_to?(:confirm_support!)
103
+ rescue
104
+ false
105
+ end
106
+ end
107
+ end
108
+ end
109
+ end
110
+ end
@@ -3,8 +3,8 @@
3
3
  module Kumi
4
4
  module Analyzer
5
5
  module Passes
6
- # RESPONSIBILITY: Compute topological ordering of declarations from dependency graph
7
- # DEPENDENCIES: :dependency_graph from DependencyResolver, :definitions from NameIndexer
6
+ # RESPONSIBILITY: Compute topological ordering of declarations, allowing safe conditional cycles
7
+ # DEPENDENCIES: :dependency_graph from DependencyResolver, :definitions from NameIndexer, :cascade_metadata from UnsatDetector
8
8
  # PRODUCES: :topo_order - Array of declaration names in evaluation order
9
9
  # INTERFACE: new(schema, state).run(errors)
10
10
  class Toposorter < PassBase
@@ -13,7 +13,7 @@ module Kumi
13
13
  definitions = get_state(:definitions, required: false) || {}
14
14
 
15
15
  order = compute_topological_order(dependency_graph, definitions, errors)
16
- set_state(:topo_order, order)
16
+ state.with(:topo_order, order)
17
17
  end
18
18
 
19
19
  private
@@ -22,17 +22,26 @@ module Kumi
22
22
  temp_marks = Set.new
23
23
  perm_marks = Set.new
24
24
  order = []
25
+ cascade_metadata = get_state(:cascade_metadata) || {}
25
26
 
26
- visit_node = lambda do |node|
27
+ visit_node = lambda do |node, path = []|
27
28
  return if perm_marks.include?(node)
28
29
 
29
30
  if temp_marks.include?(node)
30
- report_unexpected_cycle(temp_marks, node, errors)
31
- return
31
+ # Check if this is a safe conditional cycle
32
+ cycle_path = path + [node]
33
+ if safe_conditional_cycle?(cycle_path, graph, cascade_metadata)
34
+ # Allow this cycle - it's safe due to cascade mutual exclusion
35
+ return
36
+ else
37
+ report_unexpected_cycle(temp_marks, node, errors)
38
+ return
39
+ end
32
40
  end
33
41
 
34
42
  temp_marks << node
35
- Array(graph[node]).each { |edge| visit_node.call(edge.to) }
43
+ current_path = path + [node]
44
+ Array(graph[node]).each { |edge| visit_node.call(edge.to, current_path) }
36
45
  temp_marks.delete(node)
37
46
  perm_marks << node
38
47
 
@@ -47,7 +56,34 @@ module Kumi
47
56
  # (i.e., declarations with no dependencies)
48
57
  definitions.each_key { |node| visit_node.call(node) }
49
58
 
50
- order
59
+ order.freeze
60
+ end
61
+
62
+ def safe_conditional_cycle?(cycle_path, graph, cascade_metadata)
63
+ return false if cycle_path.nil? || cycle_path.size < 2
64
+
65
+ # Find where the cycle starts - look for the first occurrence of the repeated node
66
+ last_node = cycle_path.last
67
+ return false if last_node.nil?
68
+
69
+ cycle_start = cycle_path.index(last_node)
70
+ return false unless cycle_start && cycle_start < cycle_path.size - 1
71
+
72
+ cycle_nodes = cycle_path[cycle_start..-1]
73
+
74
+ # Check if all edges in the cycle are conditional
75
+ cycle_nodes.each_cons(2) do |from, to|
76
+ edges = graph[from] || []
77
+ edge = edges.find { |e| e.to == to }
78
+
79
+ return false unless edge&.conditional
80
+
81
+ # Check if the cascade has mutually exclusive conditions
82
+ cascade_meta = cascade_metadata[edge.cascade_owner]
83
+ return false unless cascade_meta&.dig(:all_mutually_exclusive)
84
+ end
85
+
86
+ true
51
87
  end
52
88
 
53
89
  def report_unexpected_cycle(temp_marks, current_node, errors)
@@ -57,7 +93,7 @@ module Kumi
57
93
  first_decl = find_declaration_by_name(temp_marks.first || current_node)
58
94
  location = first_decl&.loc
59
95
 
60
- add_error(errors, location, "cycle detected: #{cycle_path}")
96
+ report_error(errors, "cycle detected: #{cycle_path}", location: location)
61
97
  end
62
98
 
63
99
  def find_declaration_by_name(name)