kumi 0.0.3 → 0.0.5
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 +109 -2
- data/README.md +174 -205
- data/documents/DSL.md +3 -3
- data/documents/SYNTAX.md +17 -26
- data/examples/federal_tax_calculator_2024.rb +36 -38
- 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/definition_validator.rb +4 -3
- data/lib/kumi/analyzer/passes/dependency_resolver.rb +50 -10
- data/lib/kumi/analyzer/passes/input_collector.rb +28 -7
- data/lib/kumi/analyzer/passes/name_indexer.rb +2 -2
- data/lib/kumi/analyzer/passes/pass_base.rb +10 -27
- data/lib/kumi/analyzer/passes/semantic_constraint_validator.rb +110 -0
- data/lib/kumi/analyzer/passes/toposorter.rb +3 -3
- data/lib/kumi/analyzer/passes/type_checker.rb +2 -1
- data/lib/kumi/analyzer/passes/type_consistency_checker.rb +2 -1
- data/lib/kumi/analyzer/passes/type_inferencer.rb +2 -4
- data/lib/kumi/analyzer/passes/unsat_detector.rb +233 -14
- data/lib/kumi/analyzer/passes/visitor_pass.rb +2 -1
- data/lib/kumi/analyzer.rb +42 -24
- data/lib/kumi/atom_unsat_solver.rb +45 -0
- data/lib/kumi/cli.rb +449 -0
- data/lib/kumi/constraint_relationship_solver.rb +638 -0
- data/lib/kumi/error_reporter.rb +6 -6
- data/lib/kumi/evaluation_wrapper.rb +22 -4
- data/lib/kumi/explain.rb +9 -10
- data/lib/kumi/function_registry/collection_functions.rb +103 -0
- data/lib/kumi/function_registry/string_functions.rb +1 -1
- data/lib/kumi/parser/dsl_cascade_builder.rb +17 -6
- data/lib/kumi/parser/expression_converter.rb +80 -12
- data/lib/kumi/parser/guard_rails.rb +2 -2
- data/lib/kumi/parser/parser.rb +2 -0
- data/lib/kumi/parser/schema_builder.rb +1 -1
- data/lib/kumi/parser/sugar.rb +117 -16
- data/lib/kumi/schema.rb +3 -1
- data/lib/kumi/schema_instance.rb +69 -3
- data/lib/kumi/syntax/declarations.rb +3 -0
- data/lib/kumi/syntax/expressions.rb +4 -0
- data/lib/kumi/syntax/root.rb +1 -0
- data/lib/kumi/syntax/terminal_expressions.rb +3 -0
- data/lib/kumi/types/compatibility.rb +8 -0
- data/lib/kumi/types/validator.rb +1 -1
- data/lib/kumi/version.rb +1 -1
- data/scripts/generate_function_docs.rb +22 -10
- metadata +10 -6
- data/CHANGELOG.md +0 -25
- data/test_impossible_cascade.rb +0 -51
@@ -0,0 +1,178 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
# Static Analysis Error Examples
|
4
|
+
# This file demonstrates various errors that Kumi catches during schema definition
|
5
|
+
|
6
|
+
require_relative "../lib/kumi"
|
7
|
+
|
8
|
+
puts "=== Kumi Static Analysis Examples ===\n"
|
9
|
+
puts "All errors caught during schema definition, before any data processing!\n\n"
|
10
|
+
|
11
|
+
# Example 1: Circular Dependency Detection
|
12
|
+
puts "1. Circular Dependency Detection:"
|
13
|
+
puts " Code with circular references between values..."
|
14
|
+
begin
|
15
|
+
module CircularDependency
|
16
|
+
extend Kumi::Schema
|
17
|
+
|
18
|
+
schema do
|
19
|
+
input { float :base }
|
20
|
+
|
21
|
+
value :monthly_rate, yearly_rate / 12
|
22
|
+
value :yearly_rate, monthly_rate * 12
|
23
|
+
end
|
24
|
+
end
|
25
|
+
rescue Kumi::Errors::SemanticError => e
|
26
|
+
puts " → #{e.message}"
|
27
|
+
end
|
28
|
+
|
29
|
+
puts "\n" + "="*60 + "\n"
|
30
|
+
|
31
|
+
# Example 2: Impossible Logic Detection (UnsatDetector)
|
32
|
+
puts "2. Impossible Logic Detection:"
|
33
|
+
puts " Code with contradictory conditions..."
|
34
|
+
begin
|
35
|
+
module ImpossibleLogic
|
36
|
+
extend Kumi::Schema
|
37
|
+
|
38
|
+
schema do
|
39
|
+
input { integer :age }
|
40
|
+
|
41
|
+
trait :child, input.age < 13
|
42
|
+
trait :adult, input.age >= 18
|
43
|
+
|
44
|
+
# This combination can never be true
|
45
|
+
value :status do
|
46
|
+
on child & adult, "Impossible!"
|
47
|
+
base "Normal"
|
48
|
+
end
|
49
|
+
end
|
50
|
+
end
|
51
|
+
rescue Kumi::Errors::SemanticError => e
|
52
|
+
puts " → #{e.message}"
|
53
|
+
end
|
54
|
+
|
55
|
+
puts "\n" + "="*60 + "\n"
|
56
|
+
|
57
|
+
# Example 3: Type System Validation
|
58
|
+
puts "3. Type Mismatch Detection:"
|
59
|
+
puts " Code trying to add incompatible types..."
|
60
|
+
begin
|
61
|
+
module TypeMismatch
|
62
|
+
extend Kumi::Schema
|
63
|
+
|
64
|
+
schema do
|
65
|
+
input do
|
66
|
+
string :name
|
67
|
+
integer :age
|
68
|
+
end
|
69
|
+
|
70
|
+
# String + Integer type mismatch
|
71
|
+
value :invalid_sum, input.name + input.age
|
72
|
+
end
|
73
|
+
end
|
74
|
+
rescue Kumi::Errors::TypeError => e
|
75
|
+
puts " → #{e.message}"
|
76
|
+
end
|
77
|
+
|
78
|
+
puts "\n" + "="*60 + "\n"
|
79
|
+
|
80
|
+
# Example 4: Domain Constraint Analysis
|
81
|
+
puts "4. Domain Constraint Violations:"
|
82
|
+
puts " Code using values outside declared domains..."
|
83
|
+
begin
|
84
|
+
module DomainViolation
|
85
|
+
extend Kumi::Schema
|
86
|
+
|
87
|
+
schema do
|
88
|
+
input do
|
89
|
+
integer :score, domain: 0..100
|
90
|
+
string :grade, domain: %w[A B C D F]
|
91
|
+
end
|
92
|
+
|
93
|
+
# 150 is outside the domain 0..100
|
94
|
+
trait :impossible_score, input.score == 150
|
95
|
+
end
|
96
|
+
end
|
97
|
+
rescue Kumi::Errors::SemanticError => e
|
98
|
+
puts " → #{e.message}"
|
99
|
+
end
|
100
|
+
|
101
|
+
puts "\n" + "="*60 + "\n"
|
102
|
+
|
103
|
+
# Example 5: Undefined Reference Detection
|
104
|
+
puts "5. Undefined Reference Detection:"
|
105
|
+
puts " Code referencing non-existent declarations..."
|
106
|
+
begin
|
107
|
+
module UndefinedReference
|
108
|
+
extend Kumi::Schema
|
109
|
+
|
110
|
+
schema do
|
111
|
+
input { integer :amount }
|
112
|
+
|
113
|
+
# References a trait that doesn't exist
|
114
|
+
value :result, ref(:nonexistent_trait) ? 100 : 0
|
115
|
+
end
|
116
|
+
end
|
117
|
+
rescue Kumi::Errors::SemanticError => e
|
118
|
+
puts " → #{e.message}"
|
119
|
+
end
|
120
|
+
|
121
|
+
puts "\n" + "="*60 + "\n"
|
122
|
+
|
123
|
+
# Example 6: Invalid Function Usage
|
124
|
+
puts "6. Invalid Function Detection:"
|
125
|
+
puts " Code using non-existent functions..."
|
126
|
+
begin
|
127
|
+
module InvalidFunction
|
128
|
+
extend Kumi::Schema
|
129
|
+
|
130
|
+
schema do
|
131
|
+
input { string :text }
|
132
|
+
|
133
|
+
# Function doesn't exist in registry
|
134
|
+
value :result, fn(:nonexistent_function, input.text)
|
135
|
+
end
|
136
|
+
end
|
137
|
+
rescue Kumi::Errors::TypeError => e
|
138
|
+
puts " → #{e.message}"
|
139
|
+
end
|
140
|
+
|
141
|
+
puts "\n" + "="*60 + "\n"
|
142
|
+
|
143
|
+
# Example 7: Complex Schema with Multiple Issues
|
144
|
+
puts "7. Multiple Issues Detected:"
|
145
|
+
puts " Complex schema with several problems..."
|
146
|
+
begin
|
147
|
+
module MultipleIssues
|
148
|
+
extend Kumi::Schema
|
149
|
+
|
150
|
+
schema do
|
151
|
+
input { integer :value, domain: 1..10 }
|
152
|
+
|
153
|
+
# Issue 1: Circular dependency
|
154
|
+
value :a, b + 1
|
155
|
+
value :b, c + 1
|
156
|
+
value :c, a + 1
|
157
|
+
|
158
|
+
# Issue 2: Impossible domain condition
|
159
|
+
trait :impossible, (input.value > 10) & (input.value < 5)
|
160
|
+
|
161
|
+
# Issue 3: Undefined reference
|
162
|
+
value :result, ref(:undefined_declaration)
|
163
|
+
end
|
164
|
+
end
|
165
|
+
rescue Kumi::Errors::SemanticError => e
|
166
|
+
puts " → " + e.message.split("\n").join("\n → ")
|
167
|
+
end
|
168
|
+
|
169
|
+
puts "\n" + "="*60 + "\n"
|
170
|
+
puts "Summary:"
|
171
|
+
puts "• Circular dependencies caught before infinite loops"
|
172
|
+
puts "• Impossible logic detected through constraint analysis"
|
173
|
+
puts "• Type mismatches found during type inference"
|
174
|
+
puts "• Domain violations identified through static analysis"
|
175
|
+
puts "• Undefined references caught during name resolution"
|
176
|
+
puts "• Invalid functions detected during compilation"
|
177
|
+
puts "• Multiple issues reported together with precise locations"
|
178
|
+
puts "\nAll validation happens during schema definition - no runtime surprises!"
|
@@ -0,0 +1,37 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Kumi
|
4
|
+
module Analyzer
|
5
|
+
# Simple immutable state wrapper to prevent accidental mutations between passes
|
6
|
+
class AnalysisState
|
7
|
+
def initialize(data = {})
|
8
|
+
@data = data.dup.freeze
|
9
|
+
end
|
10
|
+
|
11
|
+
# Get a value (same as hash access)
|
12
|
+
def [](key)
|
13
|
+
@data[key]
|
14
|
+
end
|
15
|
+
|
16
|
+
# Check if key exists (same as hash)
|
17
|
+
def key?(key)
|
18
|
+
@data.key?(key)
|
19
|
+
end
|
20
|
+
|
21
|
+
# Get all keys (same as hash)
|
22
|
+
def keys
|
23
|
+
@data.keys
|
24
|
+
end
|
25
|
+
|
26
|
+
# Create new state with additional data (simple and clean)
|
27
|
+
def with(key, value)
|
28
|
+
AnalysisState.new(@data.merge(key => value))
|
29
|
+
end
|
30
|
+
|
31
|
+
# Convert back to hash for final result
|
32
|
+
def to_h
|
33
|
+
@data.dup
|
34
|
+
end
|
35
|
+
end
|
36
|
+
end
|
37
|
+
end
|
@@ -22,29 +22,35 @@ module Kumi
|
|
22
22
|
return @memo[node] if @memo.key?(node)
|
23
23
|
return node.value if node.is_a?(Literal)
|
24
24
|
|
25
|
-
|
26
|
-
|
25
|
+
result = case node
|
26
|
+
when Binding then evaluate_binding(node, visited)
|
27
|
+
when CallExpression then evaluate_call_expression(node, visited)
|
28
|
+
else :unknown
|
29
|
+
end
|
30
|
+
|
31
|
+
@memo[node] = result unless result == :unknown
|
32
|
+
result
|
33
|
+
end
|
27
34
|
|
28
|
-
|
35
|
+
private
|
29
36
|
|
30
|
-
|
31
|
-
|
37
|
+
def evaluate_binding(node, visited)
|
38
|
+
return :unknown if visited.include?(node.name)
|
32
39
|
|
33
|
-
|
34
|
-
|
35
|
-
|
40
|
+
visited << node.name
|
41
|
+
definition = @definitions[node.name]
|
42
|
+
return :unknown unless definition
|
36
43
|
|
37
|
-
|
38
|
-
|
44
|
+
evaluate(definition.expression, visited)
|
45
|
+
end
|
39
46
|
|
40
|
-
|
41
|
-
|
47
|
+
def evaluate_call_expression(node, visited)
|
48
|
+
return :unknown unless OPERATORS.key?(node.fn_name)
|
42
49
|
|
43
|
-
|
44
|
-
|
45
|
-
end
|
50
|
+
args = node.args.map { |arg| evaluate(arg, visited) }
|
51
|
+
return :unknown if args.any?(:unknown)
|
46
52
|
|
47
|
-
|
53
|
+
args.reduce(OPERATORS[node.fn_name])
|
48
54
|
end
|
49
55
|
end
|
50
56
|
end
|
@@ -4,7 +4,7 @@ module Kumi
|
|
4
4
|
module Analyzer
|
5
5
|
module Passes
|
6
6
|
# RESPONSIBILITY: Perform local structural validation on each declaration
|
7
|
-
# DEPENDENCIES:
|
7
|
+
# DEPENDENCIES: :definitions
|
8
8
|
# PRODUCES: None (validation only)
|
9
9
|
# INTERFACE: new(schema, state).run(errors)
|
10
10
|
class DefinitionValidator < VisitorPass
|
@@ -12,6 +12,7 @@ module Kumi
|
|
12
12
|
each_decl do |decl|
|
13
13
|
visit(decl) { |node| validate_node(node, errors) }
|
14
14
|
end
|
15
|
+
state
|
15
16
|
end
|
16
17
|
|
17
18
|
private
|
@@ -28,13 +29,13 @@ module Kumi
|
|
28
29
|
def validate_attribute(node, errors)
|
29
30
|
return unless node.expression.nil?
|
30
31
|
|
31
|
-
|
32
|
+
report_error(errors, "attribute `#{node.name}` requires an expression", location: node.loc)
|
32
33
|
end
|
33
34
|
|
34
35
|
def validate_trait(node, errors)
|
35
36
|
return if node.expression.is_a?(Expressions::CallExpression)
|
36
37
|
|
37
|
-
|
38
|
+
report_error(errors, "trait `#{node.name}` must wrap a CallExpression", location: node.loc)
|
38
39
|
end
|
39
40
|
end
|
40
41
|
end
|
@@ -4,7 +4,7 @@ module Kumi
|
|
4
4
|
module Analyzer
|
5
5
|
module Passes
|
6
6
|
# RESPONSIBILITY: Build dependency graph and leaf map, validate references
|
7
|
-
# DEPENDENCIES: :definitions
|
7
|
+
# DEPENDENCIES: :definitions, :input_meta
|
8
8
|
# PRODUCES: :dependency_graph - Hash of name → [DependencyEdge], :leaf_map - Hash of name → Set[nodes]
|
9
9
|
# INTERFACE: new(schema, state).run(errors)
|
10
10
|
class DependencyResolver < PassBase
|
@@ -17,38 +17,78 @@ module Kumi
|
|
17
17
|
input_meta = get_state(:input_meta)
|
18
18
|
|
19
19
|
dependency_graph = Hash.new { |h, k| h[k] = [] }
|
20
|
+
reverse_dependencies = Hash.new { |h, k| h[k] = [] }
|
20
21
|
leaf_map = Hash.new { |h, k| h[k] = Set.new }
|
21
22
|
|
22
23
|
each_decl do |decl|
|
23
24
|
# Traverse the expression for each declaration, passing context down
|
24
25
|
visit_with_context(decl.expression) do |node, context|
|
25
|
-
process_node(node, decl, dependency_graph, leaf_map, definitions, input_meta, errors, context)
|
26
|
+
process_node(node, decl, dependency_graph, reverse_dependencies, leaf_map, definitions, input_meta, errors, context)
|
26
27
|
end
|
27
28
|
end
|
28
29
|
|
29
|
-
|
30
|
-
|
30
|
+
# Compute transitive closure of reverse dependencies
|
31
|
+
transitive_dependents = compute_transitive_closure(reverse_dependencies)
|
32
|
+
|
33
|
+
state.with(:dependency_graph, dependency_graph.transform_values(&:freeze).freeze)
|
34
|
+
.with(:transitive_dependents, transitive_dependents.freeze)
|
35
|
+
.with(:leaf_map, leaf_map.transform_values(&:freeze).freeze)
|
31
36
|
end
|
32
37
|
|
33
38
|
private
|
34
39
|
|
35
|
-
def process_node(node, decl, graph, leaves, definitions, input_meta, errors, context)
|
40
|
+
def process_node(node, decl, graph, reverse_deps, leaves, definitions, input_meta, errors, context)
|
36
41
|
case node
|
37
42
|
when Binding
|
38
|
-
|
39
|
-
add_dependency_edge(graph, decl.name, node.name, :ref, context[:via])
|
43
|
+
report_error(errors, "undefined reference to `#{node.name}`", location: node.loc) unless definitions.key?(node.name)
|
44
|
+
add_dependency_edge(graph, reverse_deps, decl.name, node.name, :ref, context[:via])
|
40
45
|
when FieldRef
|
41
|
-
|
42
|
-
add_dependency_edge(graph, decl.name, node.name, :key, context[:via])
|
46
|
+
report_error(errors, "undeclared input `#{node.name}`", location: node.loc) unless input_meta.key?(node.name)
|
47
|
+
add_dependency_edge(graph, reverse_deps, decl.name, node.name, :key, context[:via])
|
43
48
|
leaves[decl.name] << node # put it back
|
44
49
|
when Literal
|
45
50
|
leaves[decl.name] << node
|
46
51
|
end
|
47
52
|
end
|
48
53
|
|
49
|
-
def add_dependency_edge(graph, from, to, type, via)
|
54
|
+
def add_dependency_edge(graph, reverse_deps, from, to, type, via)
|
50
55
|
edge = DependencyEdge.new(to: to, type: type, via: via)
|
51
56
|
graph[from] << edge
|
57
|
+
reverse_deps[to] << from
|
58
|
+
end
|
59
|
+
|
60
|
+
# Compute transitive closure: for each key, find ALL declarations that depend on it
|
61
|
+
def compute_transitive_closure(reverse_dependencies)
|
62
|
+
transitive = {}
|
63
|
+
|
64
|
+
# Collect all keys first to avoid iteration issues
|
65
|
+
all_keys = reverse_dependencies.keys
|
66
|
+
|
67
|
+
all_keys.each do |key|
|
68
|
+
visited = Set.new
|
69
|
+
to_visit = [key]
|
70
|
+
dependents = Set.new
|
71
|
+
|
72
|
+
while to_visit.any?
|
73
|
+
current = to_visit.shift
|
74
|
+
next if visited.include?(current)
|
75
|
+
|
76
|
+
visited.add(current)
|
77
|
+
|
78
|
+
# Get direct dependents
|
79
|
+
direct_dependents = reverse_dependencies[current] || []
|
80
|
+
direct_dependents.each do |dependent|
|
81
|
+
next if visited.include?(dependent)
|
82
|
+
|
83
|
+
dependents << dependent
|
84
|
+
to_visit << dependent
|
85
|
+
end
|
86
|
+
end
|
87
|
+
|
88
|
+
transitive[key] = dependents.to_a
|
89
|
+
end
|
90
|
+
|
91
|
+
transitive
|
52
92
|
end
|
53
93
|
|
54
94
|
# Custom visitor that passes context (like function name) down the tree
|
@@ -4,7 +4,7 @@ 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
|
@@ -15,7 +15,7 @@ module Kumi
|
|
15
15
|
|
16
16
|
schema.inputs.each do |field_decl|
|
17
17
|
unless field_decl.is_a?(FieldDecl)
|
18
|
-
|
18
|
+
report_error(errors, "Expected FieldDecl node, got #{field_decl.class}", location: field_decl.loc)
|
19
19
|
next
|
20
20
|
end
|
21
21
|
|
@@ -25,21 +25,27 @@ module Kumi
|
|
25
25
|
if existing
|
26
26
|
# Check for compatibility
|
27
27
|
if existing[:type] != field_decl.type && field_decl.type && existing[:type]
|
28
|
-
|
29
|
-
|
28
|
+
report_error(errors,
|
29
|
+
"Field :#{name} declared with conflicting types: #{existing[:type]} vs #{field_decl.type}",
|
30
|
+
location: field_decl.loc)
|
30
31
|
end
|
31
32
|
|
32
33
|
if existing[:domain] != field_decl.domain && field_decl.domain && existing[:domain]
|
33
|
-
|
34
|
-
|
34
|
+
report_error(errors,
|
35
|
+
"Field :#{name} declared with conflicting domains: #{existing[:domain].inspect} vs #{field_decl.domain.inspect}",
|
36
|
+
location: field_decl.loc)
|
35
37
|
end
|
36
38
|
|
39
|
+
# Validate domain type if provided
|
40
|
+
validate_domain_type(field_decl, errors) if field_decl.domain
|
41
|
+
|
37
42
|
# Merge metadata (later declarations override nil values)
|
38
43
|
input_meta[name] = {
|
39
44
|
type: field_decl.type || existing[:type],
|
40
45
|
domain: field_decl.domain || existing[:domain]
|
41
46
|
}
|
42
47
|
else
|
48
|
+
validate_domain_type(field_decl, errors) if field_decl.domain
|
43
49
|
input_meta[name] = {
|
44
50
|
type: field_decl.type,
|
45
51
|
domain: field_decl.domain
|
@@ -47,7 +53,22 @@ module Kumi
|
|
47
53
|
end
|
48
54
|
end
|
49
55
|
|
50
|
-
|
56
|
+
state.with(:input_meta, input_meta.freeze)
|
57
|
+
end
|
58
|
+
|
59
|
+
private
|
60
|
+
|
61
|
+
def validate_domain_type(field_decl, errors)
|
62
|
+
domain = field_decl.domain
|
63
|
+
return if valid_domain_type?(domain)
|
64
|
+
|
65
|
+
report_error(errors,
|
66
|
+
"Field :#{field_decl.name} has invalid domain constraint: #{domain.inspect}. Domain must be a Range, Array, or Proc",
|
67
|
+
location: field_decl.loc)
|
68
|
+
end
|
69
|
+
|
70
|
+
def valid_domain_type?(domain)
|
71
|
+
domain.is_a?(Range) || domain.is_a?(Array) || domain.is_a?(Proc)
|
51
72
|
end
|
52
73
|
end
|
53
74
|
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
|
@@ -34,32 +33,16 @@ module Kumi
|
|
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 (Binding 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 Declarations::Trait
|
30
|
+
validate_trait_expression(node, errors)
|
31
|
+
when Expressions::WhenCaseExpression
|
32
|
+
validate_cascade_condition(node, errors)
|
33
|
+
when Expressions::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?(Expressions::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 TerminalExpressions::Binding
|
54
|
+
# Valid: trait reference
|
55
|
+
return
|
56
|
+
when Expressions::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 TerminalExpressions::Literal
|
63
|
+
# Allow literal conditions (like true/false) - they might be valid
|
64
|
+
return
|
65
|
+
else
|
66
|
+
# Only reject truly invalid conditions like FieldRef 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
|