igniter_lang 0.1.0.alpha.1

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.
@@ -0,0 +1,362 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "json"
5
+ require "pathname"
6
+
7
+ require_relative "assembler"
8
+ require_relative "classifier"
9
+ require_relative "compilation_report"
10
+ require_relative "compiler_profile_contract_validator"
11
+ require_relative "compiler_result"
12
+ require_relative "parser"
13
+ require_relative "semanticir_emitter"
14
+ require_relative "typechecker"
15
+
16
+ module IgniterLang
17
+ class CompilerOrchestrator
18
+ FORMAT_VERSION = SemanticIREmitter::FORMAT_VERSION
19
+ STRICT_REQUIREMENT_KIND = "compiler_profile_contract_strict_requirement"
20
+ STRICT_REQUIREMENT_MODE = "strict_contract_digest"
21
+ CONTRACT_DIGEST_MISMATCH_CODE =
22
+ "compiler_profile_contract.contract_digest_mismatch"
23
+ CONTRACT_DIGEST_REFUSAL_CODE =
24
+ "compiler_profile_contract_refusal.contract_digest_mismatch"
25
+ STRICT_REQUIREMENT_MALFORMED_CODE =
26
+ "compiler_profile_contract_refusal.strict_requirement_malformed"
27
+ STRICT_REQUIREMENT_SOURCES = ["proof_local_gate", "internal_test_seam"].freeze
28
+
29
+ def initialize(
30
+ classifier: Classifier.new,
31
+ typechecker: TypeChecker.new,
32
+ emitter: SemanticIREmitter.new,
33
+ assembler: Assembler.new,
34
+ compiler_profile_contract_provider: nil,
35
+ compiler_profile_contract_strict_requirement: nil
36
+ )
37
+ @classifier = classifier
38
+ @typechecker = typechecker
39
+ @emitter = emitter
40
+ @assembler = assembler
41
+ @compiler_profile_contract_provider = compiler_profile_contract_provider
42
+ @compiler_profile_contract_strict_requirement = compiler_profile_contract_strict_requirement
43
+ end
44
+
45
+ def compile(
46
+ source_path:,
47
+ out_path:,
48
+ sample_input: nil,
49
+ sample_input_resolver: nil,
50
+ runtime_smoke: nil,
51
+ compiler_profile_source: nil
52
+ )
53
+ source_path = Pathname.new(source_path)
54
+ out_path = Pathname.new(out_path)
55
+ parsed = ParsedProgram.parse(File.read(source_path), source_path: source_path.to_s).to_h
56
+ return parse_failure(parsed, source_path, out_path) unless parsed.fetch("parse_errors").empty?
57
+
58
+ resolved_sample_input = sample_input || resolve_sample_input(parsed, sample_input_resolver)
59
+ classified = @classifier.classify(parsed, sample_input: resolved_sample_input)
60
+ typed = @typechecker.typecheck(classified)
61
+ compilation = @emitter.emit_typed(typed)
62
+ report = CompilationReport.enrich(
63
+ report: compilation.fetch("compilation_report"),
64
+ parsed: parsed
65
+ )
66
+ semantic_ir = compilation.fetch("semantic_ir")
67
+ report_for_assembly = report
68
+
69
+ if report.fetch("pass_result") == "ok"
70
+ validation = compiler_profile_contract_validation(
71
+ source_path: source_path,
72
+ out_path: out_path,
73
+ parsed_program: parsed,
74
+ compiler_profile_source: compiler_profile_source
75
+ )
76
+ report = CompilationReport.with_compiler_profile_contract_validation(
77
+ report: report,
78
+ validation: validation
79
+ )
80
+ end
81
+
82
+ return refusal(report, source_path, out_path) unless report.fetch("pass_result") == "ok"
83
+
84
+ strict_terminal = compiler_profile_contract_strict_terminal(
85
+ report: report,
86
+ source_path: source_path
87
+ )
88
+ return strict_terminal if strict_terminal
89
+
90
+ # PROP-036: compiler_profile_source is passed unchanged to the assembler.
91
+ # The orchestrator is a transport boundary only — it does not derive, load,
92
+ # discover, default, finalize, or validate profiles. Assembler validation
93
+ # remains authoritative. Nil preserves legacy_optional behavior.
94
+ assembled = @assembler.assemble_artifacts(
95
+ case_name: case_name_for(source_path, parsed),
96
+ report: report_for_assembly,
97
+ semantic_ir: semantic_ir,
98
+ target_dir: out_path,
99
+ compiler_profile_source: compiler_profile_source
100
+ )
101
+ smoke = runtime_smoke&.call(out_path: out_path, sample_input: resolved_sample_input)
102
+
103
+ if smoke && !smoke.fetch("trusted")
104
+ smoke_report = CompilationReport.runtime_smoke_failure(
105
+ report: report,
106
+ smoke: smoke,
107
+ source_path: source_path
108
+ )
109
+ return refusal(smoke_report, source_path, out_path, status: "runtime_smoke_failed")
110
+ end
111
+
112
+ {
113
+ "status" => "ok",
114
+ "result" => CompilerResult.ok(
115
+ format_version: FORMAT_VERSION,
116
+ semantic_ir: semantic_ir,
117
+ source_path: source_path,
118
+ report: report,
119
+ igapp_path: out_path,
120
+ contracts: assembled.fetch("contracts"),
121
+ runtime_smoke: smoke
122
+ ),
123
+ "parsed_program" => parsed,
124
+ "classified_program" => classified,
125
+ "typed_program" => typed,
126
+ "semantic_ir" => semantic_ir,
127
+ "compilation_report" => report,
128
+ "assembled" => assembled,
129
+ "sample_input" => resolved_sample_input
130
+ }
131
+ rescue AssemblyRefused => e
132
+ report = CompilationReport.internal_error(
133
+ format_version: FORMAT_VERSION,
134
+ source_path: source_path,
135
+ rule: "assembler_refused",
136
+ error: e
137
+ )
138
+ refusal(report, source_path, out_path, status: "assembler_refused")
139
+ rescue => e
140
+ report = CompilationReport.internal_error(
141
+ format_version: FORMAT_VERSION,
142
+ source_path: source_path,
143
+ rule: "compiler_error",
144
+ error: e
145
+ )
146
+ refusal(report, source_path, out_path, status: "error")
147
+ end
148
+
149
+ private
150
+
151
+ def parse_failure(parsed, source_path, out_path)
152
+ report = CompilationReport.parse_failure(
153
+ format_version: FORMAT_VERSION,
154
+ parsed: parsed,
155
+ source_path: source_path
156
+ )
157
+ refusal(report, source_path, out_path, status: "error")
158
+ end
159
+
160
+ def refusal(report, source_path, out_path, status: "oof")
161
+ report_path = report_path_for(out_path)
162
+ write_json(report_path, report)
163
+ {
164
+ "status" => status,
165
+ "result" => CompilerResult.refusal(
166
+ format_version: FORMAT_VERSION,
167
+ status: status,
168
+ report: report,
169
+ source_path: source_path,
170
+ report_path: report_path
171
+ ),
172
+ "compilation_report" => report,
173
+ "report_path" => report_path.to_s
174
+ }
175
+ end
176
+
177
+ def report_path_for(out_path)
178
+ raw = out_path.to_s
179
+ if raw.end_with?(".igapp")
180
+ Pathname.new(raw.delete_suffix(".igapp") + ".compilation_report.json")
181
+ else
182
+ Pathname.new("#{raw}.compilation_report.json")
183
+ end
184
+ end
185
+
186
+ def write_json(path, value)
187
+ FileUtils.mkdir_p(Pathname.new(path).dirname)
188
+ File.write(path, "#{JSON.pretty_generate(value)}\n")
189
+ end
190
+
191
+ def compiler_profile_contract_validation(source_path:, out_path:, parsed_program:, compiler_profile_source:)
192
+ return nil unless @compiler_profile_contract_provider.respond_to?(:call)
193
+
194
+ contract = @compiler_profile_contract_provider.call(
195
+ source_path: source_path,
196
+ out_path: out_path,
197
+ parsed_program: parsed_program,
198
+ compiler_profile_source: compiler_profile_source
199
+ )
200
+ return nil unless contract.is_a?(Hash)
201
+
202
+ CompilerProfileContractValidator.validate(contract)
203
+ rescue
204
+ nil
205
+ end
206
+
207
+ def compiler_profile_contract_strict_terminal(report:, source_path:)
208
+ return nil if @compiler_profile_contract_strict_requirement.nil?
209
+
210
+ requirement = validate_compiler_profile_contract_strict_requirement(
211
+ @compiler_profile_contract_strict_requirement
212
+ )
213
+ unless requirement.fetch("valid")
214
+ diagnostic = strict_requirement_malformed_diagnostic(
215
+ requirement.fetch("reason")
216
+ )
217
+ return strict_configuration_error(
218
+ report: report,
219
+ source_path: source_path,
220
+ diagnostic: diagnostic
221
+ )
222
+ end
223
+
224
+ validation = report.fetch("compiler_profile_contract_validation", nil)
225
+ return nil unless validation.is_a?(Hash)
226
+
227
+ diagnostic_codes = Array(validation["diagnostic_codes"])
228
+ return nil unless diagnostic_codes.include?(CONTRACT_DIGEST_MISMATCH_CODE)
229
+
230
+ strict_refusal(
231
+ report: report,
232
+ source_path: source_path,
233
+ diagnostic: contract_digest_mismatch_refusal_diagnostic
234
+ )
235
+ end
236
+
237
+ def validate_compiler_profile_contract_strict_requirement(requirement)
238
+ unless requirement.is_a?(Hash)
239
+ return invalid_strict_requirement(
240
+ "expected compiler_profile_contract_strict_requirement hash"
241
+ )
242
+ end
243
+
244
+ unless requirement["kind"] == STRICT_REQUIREMENT_KIND
245
+ return invalid_strict_requirement(
246
+ "expected compiler_profile_contract_strict_requirement kind"
247
+ )
248
+ end
249
+ unless requirement["mode"] == STRICT_REQUIREMENT_MODE
250
+ return invalid_strict_requirement("unsupported strict requirement mode")
251
+ end
252
+
253
+ source = requirement["source"]
254
+ unless STRICT_REQUIREMENT_SOURCES.include?(source)
255
+ return invalid_strict_requirement("unsupported strict validation source")
256
+ end
257
+
258
+ candidates = Array(requirement["refusal_candidates"])
259
+ unless candidates.include?(CONTRACT_DIGEST_MISMATCH_CODE)
260
+ return invalid_strict_requirement("missing contract_digest_mismatch refusal candidate")
261
+ end
262
+
263
+ unless requirement["recompute_unavailable_policy"] == "fail_open_report_only"
264
+ return invalid_strict_requirement("unsupported recompute_unavailable_policy")
265
+ end
266
+
267
+ unless requirement["compile_refusal_authorized"] == false
268
+ return invalid_strict_requirement("compile_refusal_authorized marker must remain false")
269
+ end
270
+
271
+ { "valid" => true }
272
+ end
273
+
274
+ def invalid_strict_requirement(reason)
275
+ { "valid" => false, "reason" => reason }
276
+ end
277
+
278
+ def strict_refusal(report:, source_path:, diagnostic:)
279
+ {
280
+ "status" => "refused",
281
+ "result" => CompilerResult.strict_terminal(
282
+ format_version: FORMAT_VERSION,
283
+ status: "refused",
284
+ report: report,
285
+ source_path: source_path,
286
+ diagnostics: [diagnostic]
287
+ ),
288
+ "compilation_report" => report
289
+ }
290
+ end
291
+
292
+ def strict_configuration_error(report:, source_path:, diagnostic:)
293
+ {
294
+ "status" => "configuration_error",
295
+ "result" => CompilerResult.strict_terminal(
296
+ format_version: FORMAT_VERSION,
297
+ status: "configuration_error",
298
+ report: report,
299
+ source_path: source_path,
300
+ diagnostics: [diagnostic]
301
+ ),
302
+ "compilation_report" => report
303
+ }
304
+ end
305
+
306
+ def contract_digest_mismatch_refusal_diagnostic
307
+ {
308
+ "code" => CONTRACT_DIGEST_REFUSAL_CODE,
309
+ "message" => "Strict compiler profile contract validation refused compilation " \
310
+ "because contract_digest does not match canonical contract material.",
311
+ "path" => "compiler_profile_contract_validation.contract_digest",
312
+ "evidence_code" => CONTRACT_DIGEST_MISMATCH_CODE
313
+ }
314
+ end
315
+
316
+ def strict_requirement_malformed_diagnostic(_reason)
317
+ {
318
+ "code" => STRICT_REQUIREMENT_MALFORMED_CODE,
319
+ "message" => "Malformed strict compiler profile contract requirement produced " \
320
+ "configuration_error before assembly.",
321
+ "path" => "compiler_profile_contract_strict_requirement",
322
+ "evidence_code" => nil
323
+ }
324
+ end
325
+
326
+ def resolve_sample_input(parsed, sample_input_resolver)
327
+ return sample_input_resolver.call(parsed) if sample_input_resolver
328
+
329
+ default_sample_input(parsed.fetch("contracts").fetch(0, {}))
330
+ end
331
+
332
+ def default_sample_input(contract)
333
+ contract.fetch("body", []).each_with_object({}) do |node, inputs|
334
+ next unless node.fetch("kind") == "input"
335
+
336
+ inputs[node.fetch("name")] = sample_value_for(node.fetch("type_annotation"))
337
+ end
338
+ end
339
+
340
+ def sample_value_for(type_annotation)
341
+ type_name = if type_annotation.is_a?(Hash)
342
+ type_annotation.fetch("name", "Unknown")
343
+ else
344
+ type_annotation.to_s
345
+ end
346
+ case type_name
347
+ when "Integer" then 1
348
+ when "Float" then 1.0
349
+ when "Bool" then true
350
+ when "String" then "synthetic"
351
+ else {}
352
+ end
353
+ end
354
+
355
+ def case_name_for(source_path, parsed)
356
+ basename = File.basename(source_path.to_s, ".ig")
357
+ return basename unless basename.empty?
358
+
359
+ parsed.fetch("contracts").fetch(0).fetch("name").downcase
360
+ end
361
+ end
362
+ end
@@ -0,0 +1,286 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "json"
5
+ require "set"
6
+
7
+ module IgniterLang
8
+ module CompilerProfileContractValidator
9
+ RESULT_KIND = "compiler_profile_contract_validation_result"
10
+ FORMAT_VERSION = "0.1.0"
11
+ DEFAULT_DIGEST_REFERENCE_POLICY = :prop038_24_plus
12
+
13
+ REQUIRED_SLOTS = %w[core oof_registry fragment_registry escape_boundary].freeze
14
+ OPTIONAL_SLOTS = %w[
15
+ contract_modifiers temporal stream olap invariant assumptions evidence_observation pipeline
16
+ ].freeze
17
+ ALL_SLOTS = (REQUIRED_SLOTS + OPTIONAL_SLOTS).freeze
18
+
19
+ DESCRIPTOR_DIGEST_PATTERN = /\Acompiler_profile_descriptor\/sha256:[0-9a-f]{24,}\z/
20
+ FINALIZATION_PAYLOAD_DIGEST_PATTERN = /\Asha256:[0-9a-f]{64}\z/
21
+ CONTRACT_DIGEST_PREFIX = "compiler_profile_contract/sha256:"
22
+ CONTRACT_DIGEST_PATTERN = /\Acompiler_profile_contract\/sha256:[0-9a-f]{24,}\z/
23
+ SUPPORTED_DIGEST_REFERENCE_POLICIES = %w[prop038_24_plus].freeze
24
+ CANONICAL_CONTRACT_FIELDS = %w[
25
+ kind
26
+ format_version
27
+ profile_namespace
28
+ profile_kind
29
+ compiler_profile_id
30
+ descriptor_digest
31
+ finalization_payload_digest
32
+ required_slot_schema
33
+ slot_order
34
+ slot_assignments
35
+ strict_registries
36
+ ordered_rule_graph
37
+ non_authority
38
+ ].freeze
39
+
40
+ def self.validate(contract, digest_reference_policy: DEFAULT_DIGEST_REFERENCE_POLICY)
41
+ policy = digest_reference_policy.to_s
42
+ diagnostics = []
43
+
44
+ unless contract.is_a?(Hash)
45
+ diagnostics << diagnostic("wrong_kind", "expected compiler_profile_contract", "kind")
46
+ return result(diagnostics, policy)
47
+ end
48
+
49
+ diagnostics << diagnostic("wrong_kind", "expected compiler_profile_contract", "kind") unless contract["kind"] == "compiler_profile_contract"
50
+ diagnostics << diagnostic("unsupported_format_version", "expected format_version 0.1.0", "format_version") unless contract["format_version"] == FORMAT_VERSION
51
+ diagnostics << diagnostic("descriptor_digest_invalid", "descriptor_digest must be compiler_profile_descriptor/sha256:<hex>", "descriptor_digest") unless contract["descriptor_digest"].to_s.match?(DESCRIPTOR_DIGEST_PATTERN)
52
+ diagnostics << diagnostic("finalization_payload_digest_invalid", "finalization_payload_digest must be sha256:<64 hex>", "finalization_payload_digest") unless contract["finalization_payload_digest"].to_s.match?(FINALIZATION_PAYLOAD_DIGEST_PATTERN)
53
+
54
+ contract_digest_recomputable = validate_contract_digest_shape(diagnostics, contract, policy)
55
+
56
+ slot_order = Array(contract["slot_order"])
57
+ slot_assignments = contract["slot_assignments"] || {}
58
+ Array(contract.dig("required_slot_schema", "required_slots")).each do |slot|
59
+ unless slot_order.include?(slot) && slot_assignments.key?(slot)
60
+ diagnostics << diagnostic("missing_required_slot", "required slot #{slot.inspect} is missing from slot_order or slot_assignments", "slot_assignments.#{slot}")
61
+ end
62
+ end
63
+
64
+ strict_registries = contract["strict_registries"] || {}
65
+ strict_registries.each do |registry_name, entries|
66
+ seen = {}
67
+ Array(entries).each do |entry|
68
+ key = entry["key"]
69
+ if seen.key?(key)
70
+ diagnostics << diagnostic("duplicate_strict_key", "strict registry #{registry_name} has duplicate key #{key.inspect}", "strict_registries.#{registry_name}.#{key}")
71
+ end
72
+ seen[key] = true
73
+ end
74
+ end
75
+
76
+ rules = Array(contract.dig("ordered_rule_graph", "rules"))
77
+ rule_ids = rules.map { |rule| rule["rule_id"] }
78
+ rules.each do |rule|
79
+ (Array(rule["before"]) + Array(rule["after"])).each do |ref|
80
+ unless rule_ids.include?(ref)
81
+ diagnostics << diagnostic("missing_rule_reference", "ordered rule #{rule["rule_id"]} references missing rule #{ref.inspect}", "ordered_rule_graph.rules.#{rule["rule_id"]}")
82
+ end
83
+ end
84
+ end
85
+
86
+ cycle = find_rule_cycle(rules)
87
+ diagnostics << diagnostic("rule_cycle", "ordered rule graph contains cycle: #{cycle.join(" -> ")}", "ordered_rule_graph.rules") if cycle
88
+
89
+ non_authority = contract["non_authority"] || {}
90
+ diagnostics << diagnostic("runtime_authority_forbidden", "compiler profile contract cannot grant runtime authority", "non_authority.runtime_authority_granted") if non_authority["runtime_authority_granted"]
91
+ diagnostics << diagnostic("dispatch_migration_forbidden", "compiler profile contract cannot authorize dispatch migration", "non_authority.dispatch_migration_authorized") if non_authority["dispatch_migration_authorized"]
92
+
93
+ validate_contract_digest_match(diagnostics, contract) if contract_digest_recomputable
94
+
95
+ result(diagnostics, policy)
96
+ end
97
+
98
+ class << self
99
+ private
100
+
101
+ def validate_contract_digest_shape(diagnostics, contract, policy)
102
+ unless SUPPORTED_DIGEST_REFERENCE_POLICIES.include?(policy)
103
+ diagnostics << diagnostic(
104
+ "contract_digest_policy_unsupported",
105
+ "unsupported contract_digest policy #{policy.inspect}",
106
+ "digest_reference_policy"
107
+ )
108
+ return false
109
+ end
110
+
111
+ unless contract["contract_digest"].to_s.match?(CONTRACT_DIGEST_PATTERN)
112
+ diagnostics << diagnostic(
113
+ "contract_digest_invalid",
114
+ "contract_digest must be compiler_profile_contract/sha256:<24+ lowercase hex>",
115
+ "contract_digest"
116
+ )
117
+ return false
118
+ end
119
+
120
+ true
121
+ end
122
+
123
+ def validate_contract_digest_match(diagnostics, contract)
124
+ declared_hex = contract["contract_digest"].to_s.delete_prefix(CONTRACT_DIGEST_PREFIX)
125
+ computed_hex = recomputed_contract_digest_hex(contract)
126
+ return if computed_hex.start_with?(declared_hex)
127
+
128
+ diagnostics << diagnostic(
129
+ "contract_digest_mismatch",
130
+ "declared contract_digest does not match recomputed canonical contract digest",
131
+ "contract_digest"
132
+ )
133
+ rescue
134
+ diagnostics << diagnostic(
135
+ "contract_digest_recompute_unavailable",
136
+ "contract digest recompute requested but canonicalization is unavailable",
137
+ "contract_digest"
138
+ )
139
+ end
140
+
141
+ def diagnostic(code, message, path = nil)
142
+ {
143
+ "code" => "compiler_profile_contract.#{code}",
144
+ "message" => message,
145
+ "path" => path
146
+ }
147
+ end
148
+
149
+ def result(diagnostics, policy)
150
+ {
151
+ "kind" => RESULT_KIND,
152
+ "format_version" => FORMAT_VERSION,
153
+ "valid" => diagnostics.empty?,
154
+ "diagnostics" => diagnostics,
155
+ "diagnostic_codes" => diagnostics.map { |diagnostic| diagnostic.fetch("code") },
156
+ "digest_reference_policy" => policy,
157
+ "compiler_integrated" => false,
158
+ "compile_refusal_authorized" => false
159
+ }
160
+ end
161
+
162
+ def recomputed_contract_digest_hex(contract)
163
+ Digest::SHA256.hexdigest(canonical_contract_json(contract))
164
+ end
165
+
166
+ def canonical_contract_json(contract)
167
+ JSON.generate(canonical_contract_material(contract))
168
+ end
169
+
170
+ def canonical_contract_material(contract)
171
+ material = CANONICAL_CONTRACT_FIELDS.to_h do |field|
172
+ [field, canonicalize_for_digest(contract[field])]
173
+ end
174
+ material["strict_registries"] = canonical_strict_registries(contract["strict_registries"] || {})
175
+ material["ordered_rule_graph"] = canonical_ordered_rule_graph(contract["ordered_rule_graph"] || {})
176
+ canonicalize_for_digest(material)
177
+ end
178
+
179
+ def canonical_strict_registries(registries)
180
+ unless registries.is_a?(Hash)
181
+ raise TypeError, "strict_registries must be a Hash for canonicalization"
182
+ end
183
+
184
+ registries.keys.sort_by(&:to_s).to_h do |key|
185
+ registry_name = key.to_s
186
+ entries = Array(registries[key]).map { |entry| canonicalize_for_digest(entry) }
187
+ [
188
+ registry_name,
189
+ entries.sort_by do |entry|
190
+ [
191
+ entry.fetch("key", "").to_s,
192
+ entry.fetch("owner_slot", "").to_s,
193
+ entry.fetch("rule_ref", "").to_s
194
+ ]
195
+ end
196
+ ]
197
+ end
198
+ end
199
+
200
+ def canonical_ordered_rule_graph(graph)
201
+ unless graph.is_a?(Hash)
202
+ raise TypeError, "ordered_rule_graph must be a Hash for canonicalization"
203
+ end
204
+
205
+ rules = Array(graph["rules"]).map do |rule|
206
+ canonical_rule(rule)
207
+ end
208
+ { "rules" => rules.sort_by { |rule| rule.fetch("rule_id", "").to_s } }
209
+ end
210
+
211
+ def canonical_rule(rule)
212
+ unless rule.is_a?(Hash)
213
+ raise TypeError, "ordered rule must be a Hash for canonicalization"
214
+ end
215
+
216
+ normalized = rule.keys.sort_by(&:to_s).to_h do |key|
217
+ key_name = key.to_s
218
+ value = if key_name == "before" || key_name == "after"
219
+ Array(rule[key]).map(&:to_s).uniq.sort
220
+ else
221
+ canonicalize_for_digest(rule[key])
222
+ end
223
+ [key_name, value]
224
+ end
225
+ normalized["before"] = [] unless normalized.key?("before")
226
+ normalized["after"] = [] unless normalized.key?("after")
227
+ normalized
228
+ end
229
+
230
+ def canonicalize_for_digest(value)
231
+ case value
232
+ when Hash
233
+ value.keys.sort_by(&:to_s).to_h do |key|
234
+ [key.to_s, canonicalize_for_digest(value[key])]
235
+ end
236
+ when Array
237
+ value.map { |entry| canonicalize_for_digest(entry) }
238
+ when String, Integer, Float, TrueClass, FalseClass, NilClass
239
+ value
240
+ else
241
+ raise TypeError, "unsupported canonical contract value #{value.class}"
242
+ end
243
+ end
244
+
245
+ def find_rule_cycle(rules)
246
+ ids = rules.map { |rule| rule.fetch("rule_id") }
247
+ edges = Hash.new { |hash, key| hash[key] = [] }
248
+ rules.each do |rule|
249
+ rule.fetch("before", []).each { |target| edges[rule.fetch("rule_id")] << target }
250
+ rule.fetch("after", []).each { |source| edges[source] << rule.fetch("rule_id") }
251
+ end
252
+
253
+ visiting = Set.new
254
+ visited = Set.new
255
+ stack = []
256
+
257
+ visit = lambda do |id|
258
+ return nil if visited.include?(id)
259
+ if visiting.include?(id)
260
+ cycle_start = stack.index(id) || 0
261
+ return stack[cycle_start..] + [id]
262
+ end
263
+
264
+ visiting << id
265
+ stack << id
266
+ edges[id].each do |target|
267
+ next unless ids.include?(target)
268
+
269
+ cycle = visit.call(target)
270
+ return cycle if cycle
271
+ end
272
+ stack.pop
273
+ visiting.delete(id)
274
+ visited << id
275
+ nil
276
+ end
277
+
278
+ ids.each do |id|
279
+ cycle = visit.call(id)
280
+ return cycle if cycle
281
+ end
282
+ nil
283
+ end
284
+ end
285
+ end
286
+ end