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.
- checksums.yaml +4 -4
- data/CLAUDE.md +160 -8
- data/README.md +278 -200
- data/{documents → docs}/AST.md +29 -29
- data/{documents → docs}/DSL.md +3 -3
- data/{documents → docs}/SYNTAX.md +107 -24
- data/docs/features/README.md +45 -0
- data/docs/features/analysis-cascade-mutual-exclusion.md +89 -0
- data/docs/features/analysis-type-inference.md +42 -0
- data/docs/features/analysis-unsat-detection.md +71 -0
- data/docs/features/array-broadcasting.md +170 -0
- data/docs/features/input-declaration-system.md +42 -0
- data/docs/features/performance.md +16 -0
- data/examples/federal_tax_calculator_2024.rb +43 -40
- data/examples/game_of_life.rb +97 -0
- data/examples/simple_rpg_game.rb +1000 -0
- data/examples/static_analysis_errors.rb +178 -0
- data/examples/wide_schema_compilation_and_evaluation_benchmark.rb +1 -1
- data/lib/kumi/analyzer/analysis_state.rb +37 -0
- data/lib/kumi/analyzer/constant_evaluator.rb +22 -16
- data/lib/kumi/analyzer/passes/broadcast_detector.rb +251 -0
- data/lib/kumi/analyzer/passes/{definition_validator.rb → declaration_validator.rb} +8 -7
- data/lib/kumi/analyzer/passes/dependency_resolver.rb +106 -26
- data/lib/kumi/analyzer/passes/input_collector.rb +105 -23
- data/lib/kumi/analyzer/passes/name_indexer.rb +2 -2
- data/lib/kumi/analyzer/passes/pass_base.rb +11 -28
- data/lib/kumi/analyzer/passes/semantic_constraint_validator.rb +110 -0
- data/lib/kumi/analyzer/passes/toposorter.rb +45 -9
- data/lib/kumi/analyzer/passes/type_checker.rb +34 -11
- data/lib/kumi/analyzer/passes/type_consistency_checker.rb +2 -1
- data/lib/kumi/analyzer/passes/type_inferencer.rb +128 -21
- data/lib/kumi/analyzer/passes/unsat_detector.rb +312 -13
- data/lib/kumi/analyzer/passes/visitor_pass.rb +4 -3
- data/lib/kumi/analyzer.rb +41 -24
- data/lib/kumi/atom_unsat_solver.rb +45 -0
- data/lib/kumi/cli.rb +449 -0
- data/lib/kumi/compiler.rb +194 -16
- data/lib/kumi/constraint_relationship_solver.rb +638 -0
- data/lib/kumi/domain/validator.rb +0 -4
- data/lib/kumi/error_reporter.rb +6 -6
- data/lib/kumi/evaluation_wrapper.rb +20 -4
- data/lib/kumi/explain.rb +28 -28
- data/lib/kumi/export/node_registry.rb +26 -12
- data/lib/kumi/export/node_serializers.rb +1 -1
- data/lib/kumi/function_registry/collection_functions.rb +117 -9
- data/lib/kumi/function_registry/function_builder.rb +4 -3
- data/lib/kumi/function_registry.rb +8 -2
- data/lib/kumi/input/type_matcher.rb +3 -0
- data/lib/kumi/input/validator.rb +0 -3
- data/lib/kumi/parser/declaration_reference_proxy.rb +36 -0
- data/lib/kumi/parser/dsl_cascade_builder.rb +19 -8
- data/lib/kumi/parser/expression_converter.rb +80 -12
- data/lib/kumi/parser/input_builder.rb +40 -9
- data/lib/kumi/parser/input_field_proxy.rb +46 -0
- data/lib/kumi/parser/input_proxy.rb +3 -3
- data/lib/kumi/parser/nested_input.rb +15 -0
- data/lib/kumi/parser/parser.rb +2 -0
- data/lib/kumi/parser/schema_builder.rb +10 -9
- data/lib/kumi/parser/sugar.rb +171 -18
- data/lib/kumi/schema.rb +3 -1
- data/lib/kumi/schema_instance.rb +69 -3
- data/lib/kumi/syntax/array_expression.rb +15 -0
- data/lib/kumi/syntax/call_expression.rb +11 -0
- data/lib/kumi/syntax/cascade_expression.rb +11 -0
- data/lib/kumi/syntax/case_expression.rb +11 -0
- data/lib/kumi/syntax/declaration_reference.rb +11 -0
- data/lib/kumi/syntax/hash_expression.rb +11 -0
- data/lib/kumi/syntax/input_declaration.rb +12 -0
- data/lib/kumi/syntax/input_element_reference.rb +12 -0
- data/lib/kumi/syntax/input_reference.rb +12 -0
- data/lib/kumi/syntax/literal.rb +11 -0
- data/lib/kumi/syntax/root.rb +1 -0
- data/lib/kumi/syntax/trait_declaration.rb +11 -0
- data/lib/kumi/syntax/value_declaration.rb +11 -0
- data/lib/kumi/types/compatibility.rb +8 -0
- data/lib/kumi/types/validator.rb +1 -1
- data/lib/kumi/vectorization_metadata.rb +108 -0
- data/lib/kumi/version.rb +1 -1
- data/scripts/generate_function_docs.rb +22 -10
- metadata +38 -17
- data/CHANGELOG.md +0 -25
- data/lib/kumi/domain.rb +0 -8
- data/lib/kumi/input.rb +0 -8
- data/lib/kumi/syntax/declarations.rb +0 -23
- data/lib/kumi/syntax/expressions.rb +0 -30
- data/lib/kumi/syntax/terminal_expressions.rb +0 -27
- data/lib/kumi/syntax.rb +0 -9
- data/test_impossible_cascade.rb +0 -51
- /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
|
7
|
-
# DEPENDENCIES: :definitions from NameIndexer
|
8
|
-
# PRODUCES: :dependency_graph
|
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
|
-
#
|
12
|
-
DependencyEdge
|
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
|
-
|
30
|
-
|
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
|
38
|
-
|
39
|
-
|
40
|
-
|
41
|
-
|
42
|
-
|
43
|
-
|
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(
|
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
|
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
|
-
|
61
|
-
|
62
|
-
|
63
|
-
|
64
|
-
|
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
|
-
|
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:
|
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?(
|
18
|
-
|
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
|
-
|
28
|
-
|
29
|
-
|
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
|
-
|
33
|
-
|
34
|
-
|
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
|
-
|
38
|
-
|
39
|
-
|
40
|
-
|
41
|
-
|
42
|
-
|
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
|
-
|
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
|
-
|
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
|
-
|
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
|
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 [
|
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
|
18
|
+
# Main pass execution - subclasses implement this
|
20
19
|
# @param errors [Array] Error accumulator array
|
21
|
-
# @
|
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::
|
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
|
-
#
|
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
|
-
|
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
|
-
|
40
|
+
state[key]
|
56
41
|
end
|
57
42
|
|
58
|
-
#
|
59
|
-
|
60
|
-
|
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
|
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
|
-
|
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
|
-
|
31
|
-
|
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
|
-
|
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
|
-
|
96
|
+
report_error(errors, "cycle detected: #{cycle_path}", location: location)
|
61
97
|
end
|
62
98
|
|
63
99
|
def find_declaration_by_name(name)
|