Skip to content

Commit da3118a

Browse files
committed
feat(isc): add specs, deep equivalence checker, and refactor codemod to library
Codemod refactored from exe/ to lib/interscript/isc/codemod.rb for proper autoload. exe/codemod-imp-to-isc is now a thin wrapper. Deep equivalence checker (exe/verify_isc_deep) compares metadata, tests, aliases, rule counts, and stages between .imp (Ruby DSL) and .isc. Specs cover: - Parser: system block, metadata, tests, stages - DocumentBuilder: metadata extraction, notes, tests, stages, noop filtering - Transform: string atoms, escapes, primitives, alias refs, captures - Grammar concerns: items, metadata, stages - Codemod: metadata, tests, aliases, sub rules, modifiers Code quality: - Codemod class lives in lib/ for autoload, not exe/ - No internal require calls — all autoload-driven - No send on private methods in specs - ISC spec_helper avoids bundler dependency
1 parent 3bc3282 commit da3118a

12 files changed

Lines changed: 1874 additions & 761 deletions

File tree

exe/codemod-imp-to-isc

Lines changed: 2 additions & 761 deletions
Large diffs are not rendered by default.

exe/verify_isc_deep

Lines changed: 214 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,214 @@
1+
#!/usr/bin/env ruby
2+
# Deep equivalence checker: compares ISC parser output against Ruby DSL
3+
# at the rule level (from/to/constraints), not just test counts.
4+
5+
require "interscript"
6+
require "set"
7+
require "interscript/isc"
8+
require "json"
9+
10+
class DeepVerifier
11+
Result = Struct.new(:status, :details, :imp_data, :isc_data)
12+
13+
def initialize
14+
@results = []
15+
end
16+
17+
def verify(imp_path, isc_path)
18+
imp_data = extract_imp(imp_path)
19+
isc_data = extract_isc(isc_path)
20+
21+
return Result.new(:both_fail, "Both parsers failed", nil, nil) if imp_data.nil? && isc_data.nil?
22+
return Result.new(:imp_fail, "Ruby DSL parse failed", nil, isc_data) if imp_data.nil?
23+
return Result.new(:isc_fail, "ISC parse failed", imp_data, nil) if isc_data.nil?
24+
25+
details = []
26+
27+
# Compare metadata — skip DSL-internal fields and fields the Ruby DSL
28+
# doesn't store (STANDARD_ARRAY_KEYS like url/notes are parsed but
29+
# silently dropped by the DSL due to a bug in dsl/metadata.rb).
30+
skip_fields = [:nonstandard, :tests, :stages, :aliases, :dependencies,
31+
:url, :notes, :implementation_notes, :original_notes]
32+
imp_meta = imp_data[:metadata].reject { |k, _| skip_fields.include?(k.to_sym) }
33+
isc_meta = isc_data[:metadata].reject { |k, _| skip_fields.include?(k.to_sym) }
34+
(imp_meta.keys | isc_meta.keys).each do |key|
35+
imp_val = normalize_meta(imp_meta[key])
36+
isc_val = normalize_meta(isc_meta[key])
37+
next if imp_val == isc_val
38+
next if imp_val.nil? && isc_val.to_s.strip == ""
39+
next if isc_val.nil? && imp_val.to_s.strip == ""
40+
details << "metadata[#{key}]: imp=#{imp_val.inspect[0..60]} isc=#{isc_val.inspect[0..60]}"
41+
end
42+
43+
# Compare tests
44+
imp_tests = Set.new(imp_data[:tests])
45+
isc_tests = Set.new(isc_data[:tests])
46+
missing = imp_data[:tests].reject { |t| isc_tests.include?(t) }
47+
details << "missing #{missing.size} tests from ISC" if missing.any?
48+
49+
# Compare aliases
50+
imp_aliases = imp_data[:aliases].to_h
51+
isc_aliases = isc_data[:aliases].to_h
52+
if imp_aliases.keys.sort != isc_aliases.keys.sort
53+
details << "alias names differ: imp=#{imp_aliases.keys.sort} isc=#{isc_aliases.keys.sort}"
54+
end
55+
56+
# Compare rule counts per stage
57+
imp_rules = imp_data[:rule_counts]
58+
isc_rules = isc_data[:rule_counts]
59+
if imp_rules != isc_rules
60+
details << "rule counts: imp=#{imp_rules} isc=#{isc_rules}"
61+
end
62+
63+
status = details.empty? ? :equivalent : :differ
64+
Result.new(status, details.join("; "), imp_data, isc_data)
65+
rescue => e
66+
Result.new(:error, "#{e.class}: #{e.message[0..100]}", nil, nil)
67+
end
68+
69+
private
70+
71+
def extract_imp(path)
72+
dsl = Interscript::DSL.parse(File.basename(path, ".imp"))
73+
hash = dsl.to_hash
74+
75+
metadata = hash[:metadata]&.fetch(:data, {}) || {}
76+
tests = (dsl.tests&.data || []).map { |t| [t[0], t[1]] }
77+
aliases = (hash[:aliases] || {}).map { |k, v| [k.to_s, rule_to_s(v)] }
78+
79+
rule_counts = {}
80+
hash[:stages]&.each do |name, stage|
81+
rule_counts[name] = count_rules(stage[:children])
82+
end
83+
84+
{
85+
metadata: metadata,
86+
tests: tests,
87+
aliases: aliases,
88+
rule_counts: rule_counts,
89+
}
90+
rescue => e
91+
warn "IMP fail #{path}: #{e.message[0..80]}"
92+
nil
93+
end
94+
95+
def extract_isc(path)
96+
src = File.read(path)
97+
tree = Interscript::Isc::Parser.parse(src, filename: File.basename(path))
98+
doc = Interscript::Isc::DocumentBuilder.build(tree, filename: File.basename(path))
99+
100+
metadata = doc[:metadata] || {}
101+
tests = doc[:tests].map { |t| [t[:input], t[:expected]] }
102+
aliases = (doc[:aliases] || []).map { |a| [a[:name], item_to_s(a[:value])] }
103+
104+
rule_counts = {}
105+
doc[:stages].each do |stage|
106+
rule_counts[stage[:name].to_sym] = count_stage_rules(stage[:body])
107+
end
108+
109+
{
110+
metadata: metadata,
111+
tests: tests,
112+
aliases: aliases,
113+
rule_counts: rule_counts,
114+
}
115+
rescue => e
116+
warn "ISC fail #{path}: #{e.message[0..80]}"
117+
nil
118+
end
119+
120+
def normalize_meta(val)
121+
case val
122+
when String then val.strip
123+
when Array then val.map { |v| normalize_meta(v) }
124+
when Hash then val.transform_values { |v| normalize_meta(v) }
125+
else val
126+
end
127+
end
128+
129+
def count_rules(children)
130+
return 0 unless children.is_a?(Array)
131+
count = 0
132+
children.each do |child|
133+
case child[:class].to_s
134+
when /Group/
135+
count += count_rules(child[:children])
136+
when /Rule/
137+
count += 1
138+
end
139+
end
140+
count
141+
end
142+
143+
def count_stage_rules(body)
144+
return 0 unless body.is_a?(Array)
145+
count = 0
146+
body.each do |item|
147+
case item[:kind]
148+
when :sequence, :parallel
149+
count += item[:rules].size
150+
when :bare_rule, :sub, :run, :separate, :compose, :string_case
151+
count += 1
152+
end
153+
end
154+
count
155+
end
156+
157+
def rule_to_s(node)
158+
return "" unless node.is_a?(Hash)
159+
data = node[:data]
160+
data ? data.to_s : node.to_s
161+
end
162+
163+
def item_to_s(item)
164+
item.respond_to?(:value) ? item.value : item.to_s
165+
end
166+
end
167+
168+
if $PROGRAM_NAME == __FILE__
169+
maps_dir = "/Users/mulgogi/src/interscript/maps/maps"
170+
isc_dir = "/tmp/isc-verify"
171+
172+
v = DeepVerifier.new
173+
results = {}
174+
counts = Hash.new(0)
175+
176+
Dir.glob("#{maps_dir}/*.imp").sort.each do |imp_path|
177+
base = File.basename(imp_path, ".imp")
178+
isc_path = "#{isc_dir}/#{base}.isc"
179+
next unless File.exist?(isc_path)
180+
181+
r = v.verify(imp_path, isc_path)
182+
results[base] = r
183+
counts[r.status] += 1
184+
end
185+
186+
puts "=" * 60
187+
puts "DEEP EQUIVALENCE REPORT"
188+
puts "=" * 60
189+
puts "Total maps: #{results.size}"
190+
puts "Equivalent: #{counts[:equivalent]}"
191+
puts "Differ: #{counts[:differ]}"
192+
puts "IMP fail: #{counts[:imp_fail]}"
193+
puts "ISC fail: #{counts[:isc_fail]}"
194+
puts "Both fail: #{counts[:both_fail]}"
195+
puts "Errors: #{counts[:error]}"
196+
puts
197+
198+
if counts[:differ] > 0
199+
puts "Differences:"
200+
results.select { |_, r| r.status == :differ }.each do |base, r|
201+
puts " #{base}: #{r.details}"
202+
end
203+
end
204+
205+
File.write("/tmp/deep_report.json", JSON.pretty_generate(
206+
results.transform_values do |r|
207+
{
208+
status: r.status,
209+
details: r.details,
210+
}
211+
end
212+
))
213+
puts "Full report: /tmp/deep_report.json"
214+
end

lib/interscript/isc.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ module Isc
99
autoload :DocumentBuilder, "interscript/isc/document_builder"
1010
autoload :Grammar, "interscript/isc/grammar"
1111
autoload :Items, "interscript/isc/items"
12+
autoload :Codemod, "interscript/isc/codemod"
1213

1314
SCHEMA_VERSION = 1
1415

0 commit comments

Comments
 (0)