kumi 0.0.0 → 0.0.4
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/.rubocop.yml +113 -3
- data/CHANGELOG.md +21 -1
- data/CLAUDE.md +387 -0
- data/README.md +270 -20
- data/docs/development/README.md +120 -0
- data/docs/development/error-reporting.md +361 -0
- data/documents/AST.md +126 -0
- data/documents/DSL.md +154 -0
- data/documents/FUNCTIONS.md +132 -0
- data/documents/SYNTAX.md +367 -0
- data/examples/deep_schema_compilation_and_evaluation_benchmark.rb +106 -0
- data/examples/federal_tax_calculator_2024.rb +112 -0
- data/examples/wide_schema_compilation_and_evaluation_benchmark.rb +80 -0
- data/lib/generators/trait_engine/templates/schema_spec.rb.erb +27 -0
- data/lib/kumi/analyzer/constant_evaluator.rb +51 -0
- data/lib/kumi/analyzer/passes/definition_validator.rb +42 -0
- data/lib/kumi/analyzer/passes/dependency_resolver.rb +71 -0
- data/lib/kumi/analyzer/passes/input_collector.rb +55 -0
- data/lib/kumi/analyzer/passes/name_indexer.rb +24 -0
- data/lib/kumi/analyzer/passes/pass_base.rb +67 -0
- data/lib/kumi/analyzer/passes/toposorter.rb +72 -0
- data/lib/kumi/analyzer/passes/type_checker.rb +139 -0
- data/lib/kumi/analyzer/passes/type_consistency_checker.rb +45 -0
- data/lib/kumi/analyzer/passes/type_inferencer.rb +125 -0
- data/lib/kumi/analyzer/passes/unsat_detector.rb +107 -0
- data/lib/kumi/analyzer/passes/visitor_pass.rb +41 -0
- data/lib/kumi/analyzer.rb +54 -0
- data/lib/kumi/atom_unsat_solver.rb +349 -0
- data/lib/kumi/compiled_schema.rb +41 -0
- data/lib/kumi/compiler.rb +127 -0
- data/lib/kumi/domain/enum_analyzer.rb +53 -0
- data/lib/kumi/domain/range_analyzer.rb +83 -0
- data/lib/kumi/domain/validator.rb +84 -0
- data/lib/kumi/domain/violation_formatter.rb +40 -0
- data/lib/kumi/domain.rb +8 -0
- data/lib/kumi/error_reporter.rb +164 -0
- data/lib/kumi/error_reporting.rb +95 -0
- data/lib/kumi/errors.rb +116 -0
- data/lib/kumi/evaluation_wrapper.rb +22 -0
- data/lib/kumi/explain.rb +281 -0
- data/lib/kumi/export/deserializer.rb +39 -0
- data/lib/kumi/export/errors.rb +12 -0
- data/lib/kumi/export/node_builders.rb +140 -0
- data/lib/kumi/export/node_registry.rb +38 -0
- data/lib/kumi/export/node_serializers.rb +156 -0
- data/lib/kumi/export/serializer.rb +23 -0
- data/lib/kumi/export.rb +33 -0
- data/lib/kumi/function_registry/collection_functions.rb +92 -0
- data/lib/kumi/function_registry/comparison_functions.rb +31 -0
- data/lib/kumi/function_registry/conditional_functions.rb +36 -0
- data/lib/kumi/function_registry/function_builder.rb +92 -0
- data/lib/kumi/function_registry/logical_functions.rb +42 -0
- data/lib/kumi/function_registry/math_functions.rb +72 -0
- data/lib/kumi/function_registry/string_functions.rb +54 -0
- data/lib/kumi/function_registry/type_functions.rb +51 -0
- data/lib/kumi/function_registry.rb +138 -0
- data/lib/kumi/input/type_matcher.rb +92 -0
- data/lib/kumi/input/validator.rb +52 -0
- data/lib/kumi/input/violation_creator.rb +50 -0
- data/lib/kumi/input.rb +8 -0
- data/lib/kumi/parser/build_context.rb +25 -0
- data/lib/kumi/parser/dsl.rb +12 -0
- data/lib/kumi/parser/dsl_cascade_builder.rb +125 -0
- data/lib/kumi/parser/expression_converter.rb +58 -0
- data/lib/kumi/parser/guard_rails.rb +43 -0
- data/lib/kumi/parser/input_builder.rb +94 -0
- data/lib/kumi/parser/input_proxy.rb +29 -0
- data/lib/kumi/parser/parser.rb +66 -0
- data/lib/kumi/parser/schema_builder.rb +172 -0
- data/lib/kumi/parser/sugar.rb +108 -0
- data/lib/kumi/schema.rb +49 -0
- data/lib/kumi/schema_instance.rb +43 -0
- data/lib/kumi/syntax/declarations.rb +23 -0
- data/lib/kumi/syntax/expressions.rb +30 -0
- data/lib/kumi/syntax/node.rb +46 -0
- data/lib/kumi/syntax/root.rb +12 -0
- data/lib/kumi/syntax/terminal_expressions.rb +27 -0
- data/lib/kumi/syntax.rb +9 -0
- data/lib/kumi/types/builder.rb +21 -0
- data/lib/kumi/types/compatibility.rb +86 -0
- data/lib/kumi/types/formatter.rb +24 -0
- data/lib/kumi/types/inference.rb +40 -0
- data/lib/kumi/types/normalizer.rb +70 -0
- data/lib/kumi/types/validator.rb +35 -0
- data/lib/kumi/types.rb +64 -0
- data/lib/kumi/version.rb +1 -1
- data/lib/kumi.rb +7 -3
- data/scripts/generate_function_docs.rb +59 -0
- data/test_impossible_cascade.rb +51 -0
- metadata +93 -10
- data/sig/kumi.rbs +0 -4
@@ -0,0 +1,112 @@
|
|
1
|
+
# U.S. federal income‑tax plus FICA
|
2
|
+
|
3
|
+
require_relative "../lib/kumi"
|
4
|
+
|
5
|
+
module CompositeTax2024
|
6
|
+
extend Kumi::Schema
|
7
|
+
FED_BREAKS_SINGLE = [11_600, 47_150, 100_525, 191_950,
|
8
|
+
243_725, 609_350, Float::INFINITY]
|
9
|
+
|
10
|
+
FED_BREAKS_MARRIED = [23_200, 94_300, 201_050, 383_900,
|
11
|
+
487_450, 731_200, Float::INFINITY]
|
12
|
+
|
13
|
+
FED_BREAKS_SEPARATE = [11_600, 47_150, 100_525, 191_950,
|
14
|
+
243_725, 365_600, Float::INFINITY]
|
15
|
+
|
16
|
+
FED_BREAKS_HOH = [16_550, 63_100, 100_500, 191_950,
|
17
|
+
243_700, 609_350, Float::INFINITY]
|
18
|
+
|
19
|
+
FED_RATES = [0.10, 0.12, 0.22, 0.24,
|
20
|
+
0.32, 0.35, 0.37]
|
21
|
+
|
22
|
+
schema do
|
23
|
+
input do
|
24
|
+
float :income
|
25
|
+
string :filing_status
|
26
|
+
end
|
27
|
+
|
28
|
+
# ── standard deduction table ───────────────────────────────────────
|
29
|
+
trait :single, input.filing_status == "single"
|
30
|
+
trait :married, input.filing_status == "married_joint"
|
31
|
+
trait :separate, input.filing_status == "married_separate"
|
32
|
+
trait :hoh, input.filing_status == "head_of_household"
|
33
|
+
|
34
|
+
value :std_deduction do
|
35
|
+
on :single, 14_600
|
36
|
+
on :married, 29_200
|
37
|
+
on :separate, 14_600
|
38
|
+
base 21_900 # HOH default
|
39
|
+
end
|
40
|
+
|
41
|
+
value :taxable_income,
|
42
|
+
fn(:max, [input.income - std_deduction, 0])
|
43
|
+
|
44
|
+
# ── FEDERAL brackets (single shown; others similar if needed) ──────
|
45
|
+
value :fed_breaks do
|
46
|
+
on :single, FED_BREAKS_SINGLE
|
47
|
+
on :married, FED_BREAKS_MARRIED
|
48
|
+
on :separate, FED_BREAKS_SEPARATE
|
49
|
+
on :hoh, FED_BREAKS_HOH
|
50
|
+
end
|
51
|
+
|
52
|
+
value :fed_rates, FED_RATES
|
53
|
+
value :fed_calc,
|
54
|
+
fn(:piecewise_sum, taxable_income, fed_breaks, fed_rates)
|
55
|
+
|
56
|
+
value :fed_tax, fed_calc[0]
|
57
|
+
value :fed_marginal, fed_calc[1]
|
58
|
+
value :fed_eff, fed_tax / fn(:max, [input.income, 1.0])
|
59
|
+
|
60
|
+
# ── FICA (employee share) ─────────────────────────────────────────────
|
61
|
+
value :ss_wage_base, 168_600.0
|
62
|
+
value :ss_rate, 0.062
|
63
|
+
|
64
|
+
value :med_base_rate, 0.0145
|
65
|
+
value :addl_med_rate, 0.009
|
66
|
+
|
67
|
+
# additional‑Medicare threshold depends on filing status
|
68
|
+
value :addl_threshold do
|
69
|
+
on :single, 200_000
|
70
|
+
on :married, 250_000
|
71
|
+
on :separate, 125_000
|
72
|
+
base 200_000 # HOH same as single
|
73
|
+
end
|
74
|
+
|
75
|
+
# social‑security portion (capped)
|
76
|
+
value :ss_tax,
|
77
|
+
fn(:min, [input.income, ss_wage_base]) * ss_rate
|
78
|
+
|
79
|
+
# medicare (1.45 % on everything)
|
80
|
+
value :med_tax, input.income * med_base_rate
|
81
|
+
|
82
|
+
# additional medicare on income above threshold
|
83
|
+
value :addl_med_tax,
|
84
|
+
fn(:max, [input.income - addl_threshold, 0]) * addl_med_rate
|
85
|
+
|
86
|
+
value :fica_tax, ss_tax + med_tax + addl_med_tax
|
87
|
+
value :fica_eff, fica_tax / fn(:max, [input.income, 1.0])
|
88
|
+
|
89
|
+
# ── Totals ─────────────────────────────────────────────────────────
|
90
|
+
value :total_tax,
|
91
|
+
fed_tax + fica_tax
|
92
|
+
|
93
|
+
value :total_eff, total_tax / fn(:max, [input.income, 1.0])
|
94
|
+
value :after_tax, input.income - total_tax
|
95
|
+
end
|
96
|
+
end
|
97
|
+
|
98
|
+
def example(income: 1_000_000, status: "single")
|
99
|
+
# Create a runner for the schema
|
100
|
+
r = CompositeTax2024.from(income: income, filing_status: status)
|
101
|
+
# puts r.inspect
|
102
|
+
puts "\n=== 2024 U.S. Income‑Tax Example ==="
|
103
|
+
printf "Income: $%0.2f\n", income
|
104
|
+
puts "Filing status: #{status}\n\n"
|
105
|
+
|
106
|
+
puts "Federal tax: $#{r[:fed_tax].round(2)} (#{(r[:fed_eff] * 100).round(2)}% effective)"
|
107
|
+
puts "FICA tax: $#{r[:fica_tax].round(2)} (#{(r[:fica_eff] * 100).round(2)}% effective)"
|
108
|
+
puts "Total tax: $#{r[:total_tax].round(2)} (#{(r[:total_eff] * 100).round(2)}% effective)"
|
109
|
+
puts "After-tax income: $#{r[:after_tax].round(2)}"
|
110
|
+
end
|
111
|
+
|
112
|
+
example
|
@@ -0,0 +1,80 @@
|
|
1
|
+
# Wide Schema Compilation and Evaluation Benchmark
|
2
|
+
#
|
3
|
+
# This benchmark measures Kumi's performance with increasingly wide schemas
|
4
|
+
# to understand how compilation and evaluation times scale with schema complexity.
|
5
|
+
#
|
6
|
+
# What it tests:
|
7
|
+
# - Compilation time for schemas with 1k, 5k, and 10k value declarations
|
8
|
+
# - Evaluation performance for computing aggregated results from many values
|
9
|
+
# - Memory efficiency through memoized schema compilation
|
10
|
+
#
|
11
|
+
# Schema structure:
|
12
|
+
# - input: single integer seed
|
13
|
+
# - values: v1 = seed + 1, v2 = seed + 2, ..., v_n = seed + n
|
14
|
+
# - aggregations: sum_all, avg_all
|
15
|
+
# - trait: large_total (conditional logic)
|
16
|
+
# - cascade: final_total (depends on trait evaluation)
|
17
|
+
#
|
18
|
+
# Usage: bundle exec ruby examples/wide_schema_compilation_and_evaluation_benchmark.rb
|
19
|
+
require "benchmark"
|
20
|
+
require "benchmark/ips"
|
21
|
+
require_relative "../lib/kumi"
|
22
|
+
|
23
|
+
# ------------------------------------------------------------------
|
24
|
+
# 1. Helper that builds a *sugar‑free* wide‑but‑shallow schema
|
25
|
+
# ------------------------------------------------------------------
|
26
|
+
def build_wide_schema(width)
|
27
|
+
Class.new do
|
28
|
+
extend Kumi::Schema
|
29
|
+
|
30
|
+
schema do
|
31
|
+
input { integer :seed }
|
32
|
+
|
33
|
+
# width independent leaf nodes: v_i = seed + i
|
34
|
+
1.upto(width) { |i| value :"v#{i}", fn(:add, input.seed, i) }
|
35
|
+
|
36
|
+
# Aggregations
|
37
|
+
value :sum_all, fn(:sum, (1..width).map { |i| ref(:"v#{i}") })
|
38
|
+
value :avg_all, fn(:divide, ref(:sum_all), width)
|
39
|
+
|
40
|
+
trait :large_total,
|
41
|
+
ref(:sum_all), :>, (width * (width + 1) / 2)
|
42
|
+
|
43
|
+
value :final_total do
|
44
|
+
on :large_total, fn(:add, ref(:sum_all), ref(:avg_all))
|
45
|
+
base ref(:sum_all)
|
46
|
+
end
|
47
|
+
end
|
48
|
+
end
|
49
|
+
end
|
50
|
+
|
51
|
+
WIDTHS = [1_000, 5_000, 10_000]
|
52
|
+
|
53
|
+
# ------------------------------------------------------------------
|
54
|
+
# 2. Measure compilation once per width
|
55
|
+
# ------------------------------------------------------------------
|
56
|
+
compile_times = {}
|
57
|
+
schemas = {}
|
58
|
+
|
59
|
+
WIDTHS.each do |w|
|
60
|
+
t0 = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
61
|
+
schemas[w] = build_wide_schema(w)
|
62
|
+
compile_times[w] = Process.clock_gettime(Process::CLOCK_MONOTONIC) - t0
|
63
|
+
end
|
64
|
+
|
65
|
+
puts "=== compilation times ==="
|
66
|
+
compile_times.each do |w, t|
|
67
|
+
puts format("compile %5d‑wide: %6.1f ms", w, t * 1_000)
|
68
|
+
end
|
69
|
+
puts
|
70
|
+
|
71
|
+
# ------------------------------------------------------------------
|
72
|
+
# 3. Pure evaluation benchmark – no compilation inside the loop
|
73
|
+
# ------------------------------------------------------------------
|
74
|
+
Benchmark.ips do |x|
|
75
|
+
schemas.each do |w, schema|
|
76
|
+
runner = schema.from(seed: 0) # memoised runner
|
77
|
+
x.report("eval #{w}-wide") { runner[:final_total] }
|
78
|
+
end
|
79
|
+
x.compare!
|
80
|
+
end
|
@@ -0,0 +1,27 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
require "rails_helper"
|
3
|
+
|
4
|
+
RSpec.describe <%= schema_constant %> do
|
5
|
+
# Single shared instance for every example
|
6
|
+
let(:schema) { described_class }
|
7
|
+
|
8
|
+
# Minimal dummy context so every binding can run
|
9
|
+
let(:ctx) do
|
10
|
+
{
|
11
|
+
<% leaf_keys.each do |k| -%>
|
12
|
+
<%= "#{k}:" %> nil,
|
13
|
+
<% end -%>
|
14
|
+
}
|
15
|
+
end
|
16
|
+
|
17
|
+
<% expose_names.each do |name| -%>
|
18
|
+
describe "<%= name %>" do
|
19
|
+
it "evaluates without raising" do
|
20
|
+
expect {
|
21
|
+
schema.evaluate_binding(:<%= name %>, ctx)
|
22
|
+
}.not_to raise_error
|
23
|
+
end
|
24
|
+
end
|
25
|
+
|
26
|
+
<% end -%>
|
27
|
+
end
|
@@ -0,0 +1,51 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Kumi
|
4
|
+
module Analyzer
|
5
|
+
class ConstantEvaluator
|
6
|
+
include Syntax
|
7
|
+
|
8
|
+
def initialize(definitions)
|
9
|
+
@definitions = definitions
|
10
|
+
@memo = {}
|
11
|
+
end
|
12
|
+
|
13
|
+
OPERATORS = {
|
14
|
+
add: :+,
|
15
|
+
subtract: :-,
|
16
|
+
multiply: :*,
|
17
|
+
divide: :/
|
18
|
+
}.freeze
|
19
|
+
|
20
|
+
def evaluate(node, visited = Set.new)
|
21
|
+
return :unknown unless node
|
22
|
+
return @memo[node] if @memo.key?(node)
|
23
|
+
return node.value if node.is_a?(Literal)
|
24
|
+
|
25
|
+
if node.is_a?(Binding)
|
26
|
+
return :unknown if visited.include?(node.name)
|
27
|
+
|
28
|
+
visited << node.name
|
29
|
+
|
30
|
+
definition = @definitions[node.name]
|
31
|
+
return :unknown unless definition
|
32
|
+
|
33
|
+
@memo[node] = evaluate(definition.expression, visited)
|
34
|
+
return @memo[node]
|
35
|
+
end
|
36
|
+
|
37
|
+
if node.is_a?(CallExpression)
|
38
|
+
return :unknown unless OPERATORS.key?(node.fn_name)
|
39
|
+
|
40
|
+
args = node.args.map { |arg| evaluate(arg, visited) }
|
41
|
+
return :unknown if args.any?(:unknown)
|
42
|
+
|
43
|
+
@memo[node] = args.reduce(OPERATORS[node.fn_name])
|
44
|
+
return @memo[node]
|
45
|
+
end
|
46
|
+
|
47
|
+
:unknown
|
48
|
+
end
|
49
|
+
end
|
50
|
+
end
|
51
|
+
end
|
@@ -0,0 +1,42 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Kumi
|
4
|
+
module Analyzer
|
5
|
+
module Passes
|
6
|
+
# RESPONSIBILITY: Perform local structural validation on each declaration
|
7
|
+
# DEPENDENCIES: None (can run independently)
|
8
|
+
# PRODUCES: None (validation only)
|
9
|
+
# INTERFACE: new(schema, state).run(errors)
|
10
|
+
class DefinitionValidator < VisitorPass
|
11
|
+
def run(errors)
|
12
|
+
each_decl do |decl|
|
13
|
+
visit(decl) { |node| validate_node(node, errors) }
|
14
|
+
end
|
15
|
+
end
|
16
|
+
|
17
|
+
private
|
18
|
+
|
19
|
+
def validate_node(node, errors)
|
20
|
+
case node
|
21
|
+
when Declarations::Attribute
|
22
|
+
validate_attribute(node, errors)
|
23
|
+
when Declarations::Trait
|
24
|
+
validate_trait(node, errors)
|
25
|
+
end
|
26
|
+
end
|
27
|
+
|
28
|
+
def validate_attribute(node, errors)
|
29
|
+
return unless node.expression.nil?
|
30
|
+
|
31
|
+
add_error(errors, node.loc, "attribute `#{node.name}` requires an expression")
|
32
|
+
end
|
33
|
+
|
34
|
+
def validate_trait(node, errors)
|
35
|
+
return if node.expression.is_a?(Expressions::CallExpression)
|
36
|
+
|
37
|
+
add_error(errors, node.loc, "trait `#{node.name}` must wrap a CallExpression")
|
38
|
+
end
|
39
|
+
end
|
40
|
+
end
|
41
|
+
end
|
42
|
+
end
|
@@ -0,0 +1,71 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Kumi
|
4
|
+
module Analyzer
|
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]
|
9
|
+
# INTERFACE: new(schema, state).run(errors)
|
10
|
+
class DependencyResolver < PassBase
|
11
|
+
# A Struct to hold rich dependency information
|
12
|
+
DependencyEdge = Struct.new(:to, :type, :via, keyword_init: true)
|
13
|
+
include Syntax
|
14
|
+
|
15
|
+
def run(errors)
|
16
|
+
definitions = get_state(:definitions)
|
17
|
+
input_meta = get_state(:input_meta)
|
18
|
+
|
19
|
+
dependency_graph = Hash.new { |h, k| h[k] = [] }
|
20
|
+
leaf_map = Hash.new { |h, k| h[k] = Set.new }
|
21
|
+
|
22
|
+
each_decl do |decl|
|
23
|
+
# 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)
|
26
|
+
end
|
27
|
+
end
|
28
|
+
|
29
|
+
set_state(:dependency_graph, dependency_graph.transform_values(&:freeze).freeze)
|
30
|
+
set_state(:leaf_map, leaf_map.transform_values(&:freeze).freeze)
|
31
|
+
end
|
32
|
+
|
33
|
+
private
|
34
|
+
|
35
|
+
def process_node(node, decl, graph, leaves, definitions, input_meta, errors, context)
|
36
|
+
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
|
44
|
+
when Literal
|
45
|
+
leaves[decl.name] << node
|
46
|
+
end
|
47
|
+
end
|
48
|
+
|
49
|
+
def add_dependency_edge(graph, from, to, type, via)
|
50
|
+
edge = DependencyEdge.new(to: to, type: type, via: via)
|
51
|
+
graph[from] << edge
|
52
|
+
end
|
53
|
+
|
54
|
+
# Custom visitor that passes context (like function name) down the tree
|
55
|
+
def visit_with_context(node, context = {}, &block)
|
56
|
+
return unless node
|
57
|
+
|
58
|
+
yield(node, context)
|
59
|
+
|
60
|
+
new_context = if node.is_a?(Expressions::CallExpression)
|
61
|
+
{ via: node.fn_name }
|
62
|
+
else
|
63
|
+
context
|
64
|
+
end
|
65
|
+
|
66
|
+
node.children.each { |child| visit_with_context(child, new_context, &block) }
|
67
|
+
end
|
68
|
+
end
|
69
|
+
end
|
70
|
+
end
|
71
|
+
end
|
@@ -0,0 +1,55 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Kumi
|
4
|
+
module Analyzer
|
5
|
+
module Passes
|
6
|
+
# RESPONSIBILITY: Collect field metadata from input declarations and validate consistency
|
7
|
+
# DEPENDENCIES: None
|
8
|
+
# PRODUCES: :input_meta - Hash mapping field names to {type:, domain:} metadata
|
9
|
+
# INTERFACE: new(schema, state).run(errors)
|
10
|
+
class InputCollector < PassBase
|
11
|
+
include Syntax::TerminalExpressions
|
12
|
+
|
13
|
+
def run(errors)
|
14
|
+
input_meta = {}
|
15
|
+
|
16
|
+
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}")
|
19
|
+
next
|
20
|
+
end
|
21
|
+
|
22
|
+
name = field_decl.name
|
23
|
+
existing = input_meta[name]
|
24
|
+
|
25
|
+
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}")
|
30
|
+
end
|
31
|
+
|
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}")
|
35
|
+
end
|
36
|
+
|
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
|
+
}
|
47
|
+
end
|
48
|
+
end
|
49
|
+
|
50
|
+
set_state(:input_meta, input_meta.freeze)
|
51
|
+
end
|
52
|
+
end
|
53
|
+
end
|
54
|
+
end
|
55
|
+
end
|
@@ -0,0 +1,24 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Kumi
|
4
|
+
module Analyzer
|
5
|
+
module Passes
|
6
|
+
# RESPONSIBILITY: Build definitions index and detect duplicate names
|
7
|
+
# DEPENDENCIES: None (first pass in pipeline)
|
8
|
+
# PRODUCES: :definitions - Hash mapping names to declaration nodes
|
9
|
+
# INTERFACE: new(schema, state).run(errors)
|
10
|
+
class NameIndexer < PassBase
|
11
|
+
def run(errors)
|
12
|
+
definitions = {}
|
13
|
+
|
14
|
+
each_decl do |decl|
|
15
|
+
add_error(errors, decl.loc, "duplicated definition `#{decl.name}`") if definitions.key?(decl.name)
|
16
|
+
definitions[decl.name] = decl
|
17
|
+
end
|
18
|
+
|
19
|
+
set_state(:definitions, definitions.freeze)
|
20
|
+
end
|
21
|
+
end
|
22
|
+
end
|
23
|
+
end
|
24
|
+
end
|
@@ -0,0 +1,67 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Kumi
|
4
|
+
module Analyzer
|
5
|
+
module Passes
|
6
|
+
# Base class for all analyzer passes providing common functionality
|
7
|
+
# and enforcing consistent interface patterns.
|
8
|
+
class PassBase
|
9
|
+
include Kumi::Syntax
|
10
|
+
include Kumi::ErrorReporting
|
11
|
+
|
12
|
+
# @param schema [Syntax::Root] The schema to analyze
|
13
|
+
# @param state [Hash] Shared analysis state accumulator
|
14
|
+
def initialize(schema, state)
|
15
|
+
@schema = schema
|
16
|
+
@state = state
|
17
|
+
end
|
18
|
+
|
19
|
+
# Main entry point for pass execution
|
20
|
+
# @param errors [Array] Error accumulator array
|
21
|
+
# @abstract Subclasses must implement this method
|
22
|
+
def run(errors)
|
23
|
+
raise NotImplementedError, "#{self.class.name} must implement #run"
|
24
|
+
end
|
25
|
+
|
26
|
+
protected
|
27
|
+
|
28
|
+
attr_reader :schema, :state
|
29
|
+
|
30
|
+
# Iterate over all declarations (attributes and traits) in the schema
|
31
|
+
# @yield [Syntax::Declarations::Attribute|Syntax::Declarations::Trait] Each declaration
|
32
|
+
def each_decl(&block)
|
33
|
+
schema.attributes.each(&block)
|
34
|
+
schema.traits.each(&block)
|
35
|
+
end
|
36
|
+
|
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
|
51
|
+
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?
|
54
|
+
|
55
|
+
value
|
56
|
+
end
|
57
|
+
|
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
|
63
|
+
end
|
64
|
+
end
|
65
|
+
end
|
66
|
+
end
|
67
|
+
end
|
@@ -0,0 +1,72 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Kumi
|
4
|
+
module Analyzer
|
5
|
+
module Passes
|
6
|
+
# RESPONSIBILITY: Compute topological ordering of declarations from dependency graph
|
7
|
+
# DEPENDENCIES: :dependency_graph from DependencyResolver, :definitions from NameIndexer
|
8
|
+
# PRODUCES: :topo_order - Array of declaration names in evaluation order
|
9
|
+
# INTERFACE: new(schema, state).run(errors)
|
10
|
+
class Toposorter < PassBase
|
11
|
+
def run(errors)
|
12
|
+
dependency_graph = get_state(:dependency_graph, required: false) || {}
|
13
|
+
definitions = get_state(:definitions, required: false) || {}
|
14
|
+
|
15
|
+
order = compute_topological_order(dependency_graph, definitions, errors)
|
16
|
+
set_state(:topo_order, order)
|
17
|
+
end
|
18
|
+
|
19
|
+
private
|
20
|
+
|
21
|
+
def compute_topological_order(graph, definitions, errors)
|
22
|
+
temp_marks = Set.new
|
23
|
+
perm_marks = Set.new
|
24
|
+
order = []
|
25
|
+
|
26
|
+
visit_node = lambda do |node|
|
27
|
+
return if perm_marks.include?(node)
|
28
|
+
|
29
|
+
if temp_marks.include?(node)
|
30
|
+
report_unexpected_cycle(temp_marks, node, errors)
|
31
|
+
return
|
32
|
+
end
|
33
|
+
|
34
|
+
temp_marks << node
|
35
|
+
Array(graph[node]).each { |edge| visit_node.call(edge.to) }
|
36
|
+
temp_marks.delete(node)
|
37
|
+
perm_marks << node
|
38
|
+
|
39
|
+
# Only include declaration nodes in the final order
|
40
|
+
order << node if definitions.key?(node)
|
41
|
+
end
|
42
|
+
|
43
|
+
# Visit all nodes in the graph
|
44
|
+
graph.each_key { |node| visit_node.call(node) }
|
45
|
+
|
46
|
+
# Also visit any definitions that aren't in the dependency graph
|
47
|
+
# (i.e., declarations with no dependencies)
|
48
|
+
definitions.each_key { |node| visit_node.call(node) }
|
49
|
+
|
50
|
+
order
|
51
|
+
end
|
52
|
+
|
53
|
+
def report_unexpected_cycle(temp_marks, current_node, errors)
|
54
|
+
cycle_path = temp_marks.to_a.join(" → ") + " → #{current_node}"
|
55
|
+
|
56
|
+
# Try to find the first declaration in the cycle for location info
|
57
|
+
first_decl = find_declaration_by_name(temp_marks.first || current_node)
|
58
|
+
location = first_decl&.loc
|
59
|
+
|
60
|
+
add_error(errors, location, "cycle detected: #{cycle_path}")
|
61
|
+
end
|
62
|
+
|
63
|
+
def find_declaration_by_name(name)
|
64
|
+
return nil unless schema
|
65
|
+
|
66
|
+
schema.attributes.find { |attr| attr.name == name } ||
|
67
|
+
schema.traits.find { |trait| trait.name == name }
|
68
|
+
end
|
69
|
+
end
|
70
|
+
end
|
71
|
+
end
|
72
|
+
end
|