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,847 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "json"
5
+
6
+ module IgniterLang
7
+ class SemanticIREmitter
8
+ FORMAT_VERSION = "0.1.0"
9
+
10
+ def emit(parsed_program, sample_input:)
11
+ @types = type_shapes(parsed_program)
12
+ semantic_contracts = parsed_program.fetch("contracts").map do |contract|
13
+ emit_contract(parsed_program, contract, sample_input)
14
+ end
15
+ diagnostics = dedupe_oofs(semantic_contracts.flat_map { |contract| contract.fetch("diagnostics") })
16
+ semantic_ir = diagnostics.empty? ? semantic_ir_program(parsed_program, semantic_contracts) : nil
17
+
18
+ {
19
+ "semantic_ir" => semantic_ir,
20
+ "compilation_report" => compilation_report(parsed_program, diagnostics, semantic_ir)
21
+ }
22
+ end
23
+
24
+ alias compile emit
25
+
26
+ def emit_typed(typed_program)
27
+ diagnostics = typed_program.fetch("type_errors", [])
28
+ semantic_ir = diagnostics.empty? ? typed_semantic_ir_program(typed_program) : nil
29
+
30
+ {
31
+ "semantic_ir" => semantic_ir,
32
+ "compilation_report" => typed_compilation_report(typed_program, diagnostics, semantic_ir)
33
+ }
34
+ end
35
+
36
+ private
37
+
38
+ def semantic_ir_program(parsed_program, contracts)
39
+ report_id = compilation_report_id(parsed_program)
40
+ {
41
+ "kind" => "semantic_ir_program",
42
+ "format_version" => FORMAT_VERSION,
43
+ "program_id" => program_id(parsed_program),
44
+ "grammar_version" => parsed_program.fetch("grammar_version"),
45
+ "source_hash" => parsed_program.fetch("source_hash"),
46
+ "source_path" => source_path(parsed_program),
47
+ "module" => parsed_program.fetch("module"),
48
+ "compilation_report_ref" => report_id,
49
+ "contracts" => contracts.map { |contract| contract.reject { |key, _value| key == "diagnostics" } }
50
+ }
51
+ end
52
+
53
+ def compilation_report(parsed_program, diagnostics, semantic_ir)
54
+ ok = diagnostics.empty?
55
+ {
56
+ "kind" => "compilation_report",
57
+ "format_version" => FORMAT_VERSION,
58
+ "program_id" => compilation_report_id(parsed_program),
59
+ "grammar_version" => parsed_program.fetch("grammar_version"),
60
+ "source_hash" => parsed_program.fetch("source_hash"),
61
+ "source_path" => source_path(parsed_program),
62
+ "pass_result" => ok ? "ok" : "oof",
63
+ "stages" => {
64
+ "parse" => "ok",
65
+ "classify" => ok ? "ok" : "oof",
66
+ "typecheck" => ok ? "ok" : "skipped",
67
+ "emit" => ok ? "ok" : "skipped"
68
+ },
69
+ "diagnostics" => diagnostics.map { |entry| diagnostic(entry) },
70
+ "semantic_ir_ref" => semantic_ir&.fetch("program_id")
71
+ }
72
+ end
73
+
74
+ def typed_semantic_ir_program(typed_program)
75
+ report_id = typed_compilation_report_id(typed_program)
76
+ result = {
77
+ "kind" => "semantic_ir_program",
78
+ "format_version" => FORMAT_VERSION,
79
+ "program_id" => typed_program_id(typed_program),
80
+ "grammar_version" => typed_program.fetch("grammar_version"),
81
+ "source_hash" => typed_program.fetch("source_hash"),
82
+ "source_path" => source_path(typed_program),
83
+ "module" => typed_program.fetch("module"),
84
+ "compilation_report_ref" => report_id,
85
+ "contracts" => typed_program.fetch("contracts").map { |contract| typed_contract_ir(contract) }
86
+ }
87
+ assumptions = typed_assumption_registry(typed_program)
88
+ result["assumption_registry"] = assumptions unless assumptions.empty?
89
+ result["olap_points"] = typed_program.fetch("olap_points") if typed_program.key?("olap_points")
90
+ invariants = typed_program_invariants(result.fetch("contracts"))
91
+ result["invariants"] = invariants unless invariants.empty?
92
+ result
93
+ end
94
+
95
+ def typed_compilation_report(typed_program, diagnostics, semantic_ir)
96
+ ok = diagnostics.empty?
97
+ report = {
98
+ "kind" => "compilation_report",
99
+ "format_version" => FORMAT_VERSION,
100
+ "program_id" => typed_compilation_report_id(typed_program),
101
+ "grammar_version" => typed_program.fetch("grammar_version"),
102
+ "source_hash" => typed_program.fetch("source_hash"),
103
+ "source_path" => source_path(typed_program),
104
+ "pass_result" => ok ? "ok" : "oof",
105
+ "stages" => {
106
+ "parse" => "ok",
107
+ "classify" => "ok",
108
+ "typecheck" => ok ? "ok" : "oof",
109
+ "emit" => ok ? "ok" : "skipped"
110
+ },
111
+ "diagnostics" => diagnostics.map { |entry| diagnostic(entry) },
112
+ "semantic_ir_ref" => semantic_ir&.fetch("program_id")
113
+ }
114
+ coverage = typed_invariant_coverage(semantic_ir)
115
+ report["invariant_coverage"] = coverage unless coverage.empty?
116
+ report
117
+ end
118
+
119
+ def program_id(parsed_program)
120
+ "semanticir/#{parsed_program.fetch("source_hash").delete_prefix("sha256:")[0, 16]}"
121
+ end
122
+
123
+ def compilation_report_id(parsed_program)
124
+ "compilation_report/#{parsed_program.fetch("source_hash").delete_prefix("sha256:")[0, 16]}"
125
+ end
126
+
127
+ def typed_program_id(typed_program)
128
+ program_id(typed_program)
129
+ end
130
+
131
+ def typed_compilation_report_id(typed_program)
132
+ compilation_report_id(typed_program)
133
+ end
134
+
135
+ def source_path(parsed_program)
136
+ parsed_program.fetch("source_path").delete_prefix("igniter-lang/")
137
+ end
138
+
139
+ def typed_contract_ir(contract)
140
+ contract_ir = {
141
+ "kind" => "contract_ir",
142
+ "contract_ref" => nil,
143
+ "contract_name" => contract.fetch("name"),
144
+ "modifier" => contract.fetch("modifier", "pure"),
145
+ "specialization_of" => nil,
146
+ "type_args" => {},
147
+ "fragment_class" => contract.fetch("fragment_class"),
148
+ "inputs" => typed_ports(contract, "input"),
149
+ "outputs" => typed_ports(contract, "output"),
150
+ "nodes" => typed_nodes(contract),
151
+ "escape_boundaries" => typed_escape_boundaries(contract)
152
+ }
153
+ assumption_refs = contract.fetch("assumption_refs", [])
154
+ contract_ir["assumption_refs"] = assumption_refs unless assumption_refs.empty?
155
+ contract_ir["contract_ref"] = contract_ref(contract_ir)
156
+ contract_ir
157
+ end
158
+
159
+ def typed_ports(contract, kind)
160
+ contract.fetch("declarations").select { |decl| decl.fetch("kind") == kind }.map do |decl|
161
+ port = {
162
+ "name" => decl.fetch("name"),
163
+ "type" => decl.fetch("type"),
164
+ "lifecycle" => decl.fetch("lifecycle", kind == "input" ? "local" : "session")
165
+ }
166
+ if kind == "output"
167
+ port["warnings_from"] = decl.fetch("warnings_from") if decl.key?("warnings_from")
168
+ port["uncertain_from"] = decl.fetch("uncertain_from") if decl.key?("uncertain_from")
169
+ port["metrics_from"] = decl.fetch("metrics_from") if decl.key?("metrics_from")
170
+ end
171
+ port
172
+ end
173
+ end
174
+
175
+ def typed_nodes(contract)
176
+ declarations = contract.fetch("declarations")
177
+ contract.fetch("declarations").filter_map do |decl|
178
+ next decl.fetch("semantic_node") if decl.key?("semantic_node")
179
+
180
+ case decl.fetch("kind")
181
+ when "stream"
182
+ stream_input_node(decl, declarations)
183
+ when "window"
184
+ window_decl_node(decl)
185
+ when "fold_stream"
186
+ fold_stream_node(decl, declarations)
187
+ when "uses_assumptions"
188
+ assumption_ref_node(decl)
189
+ when "invariant"
190
+ invariant_node(decl)
191
+ when "read"
192
+ temporal_input_node(decl) if decl.fetch("node_fragment_class", nil) == "temporal"
193
+ when "compute"
194
+ temporal_access_node(decl) ||
195
+ {
196
+ "kind" => "compute",
197
+ "name" => decl.fetch("name"),
198
+ "expr" => semantic_expr(decl.fetch("expr")),
199
+ "type" => decl.fetch("type"),
200
+ "deps" => decl.fetch("deps", []),
201
+ "fragment" => decl.fetch("fragment_class")
202
+ }
203
+ end
204
+ end
205
+ end
206
+
207
+ def typed_assumption_registry(typed_program)
208
+ typed_program.fetch("assumption_registry", []).map do |entry|
209
+ {
210
+ "kind" => "assumption_ir",
211
+ "name" => entry.fetch("name"),
212
+ "fields" => entry.fetch("fields", {})
213
+ }.tap do |node|
214
+ node["declared_in_module"] = entry.fetch("declared_in_module") if entry.key?("declared_in_module")
215
+ end
216
+ end
217
+ end
218
+
219
+ def assumption_ref_node(decl)
220
+ {
221
+ "kind" => "assumption_ref_node",
222
+ "name" => decl.fetch("name"),
223
+ "assumption_ref" => decl.fetch("name"),
224
+ "type" => decl.fetch("type"),
225
+ "fragment" => decl.fetch("fragment_class", "epistemic")
226
+ }
227
+ end
228
+
229
+ def typed_program_invariants(contracts)
230
+ contracts.flat_map do |contract|
231
+ contract.fetch("nodes").select { |node| node.fetch("kind") == "invariant_node" }
232
+ end
233
+ end
234
+
235
+ def semantic_expr(expr)
236
+ case expr
237
+ when Hash
238
+ expr.each_with_object({}) do |(key, value), result|
239
+ next if key == "deps"
240
+
241
+ result[key] = semantic_expr(value)
242
+ end
243
+ when Array
244
+ expr.map { |item| semantic_expr(item) }
245
+ else
246
+ expr
247
+ end
248
+ end
249
+
250
+ def typed_invariant_coverage(semantic_ir)
251
+ return [] unless semantic_ir
252
+
253
+ typed_program_invariants(semantic_ir.fetch("contracts")).map do |node|
254
+ coverage = {
255
+ "name" => node.fetch("name"),
256
+ "severity" => node.fetch("severity"),
257
+ "label" => node.fetch("label", nil),
258
+ "message" => node.fetch("message", nil),
259
+ "output_policy" => node.fetch("severity") == "error" ? "blocking" : "non_blocking",
260
+ "output_effect" => node.fetch("output_effect")
261
+ }
262
+ coverage["source_metadata"] = node.fetch("source_metadata") if node.key?("source_metadata")
263
+ coverage
264
+ end
265
+ end
266
+
267
+ def typed_escape_boundaries(contract)
268
+ temporal_caps = contract.fetch("declarations")
269
+ .select { |decl| decl.fetch("node_fragment_class", nil) == "temporal" }
270
+ .map { |decl| decl.fetch("required_capability") }
271
+ .uniq
272
+ .sort
273
+ boundaries = temporal_caps.map do |capability|
274
+ {
275
+ "name" => capability,
276
+ "required_caps" => [capability],
277
+ "produces" => [capability == "bihistory_read" ? "bihistory_access_observation" : "history_access_observation"]
278
+ }
279
+ end
280
+ return boundaries unless contract.fetch("declarations").any? { |decl| decl.fetch("kind") == "stream" }
281
+
282
+ boundaries + [
283
+ {
284
+ "name" => "stream_input",
285
+ "required_caps" => ["stream_input"],
286
+ "produces" => ["stream_window_observation"]
287
+ }
288
+ ]
289
+ end
290
+
291
+ def temporal_input_node(decl)
292
+ {
293
+ "kind" => "temporal_input_node",
294
+ "name" => decl.fetch("name"),
295
+ "type" => temporal_type(decl.fetch("type")),
296
+ "store_ref" => decl.fetch("from", nil),
297
+ "lifecycle" => decl.fetch("lifecycle", "durable"),
298
+ "axis" => decl.fetch("temporal_axis"),
299
+ "node_fragment_class" => decl.fetch("node_fragment_class"),
300
+ "value_fragment_class" => decl.fetch("value_fragment_class"),
301
+ "required_capability" => decl.fetch("required_capability"),
302
+ "required_caps" => [decl.fetch("required_capability")],
303
+ "fragment" => decl.fetch("fragment_class", "temporal")
304
+ }
305
+ end
306
+
307
+ def temporal_access_node(decl)
308
+ expr = decl.fetch("expr", {})
309
+ return nil unless expr.fetch("kind", nil) == "call"
310
+
311
+ case expr.fetch("fn")
312
+ when "history_at"
313
+ history_temporal_access_node(decl, expr)
314
+ when "bihistory_at"
315
+ bihistory_temporal_access_node(decl, expr)
316
+ end
317
+ end
318
+
319
+ def history_temporal_access_node(decl, expr)
320
+ args = expr.fetch("args", [])
321
+ source_ref = ref_name(args[0])
322
+ as_of_ref = ref_name(args[1])
323
+ {
324
+ "kind" => "temporal_access_node",
325
+ "name" => decl.fetch("name"),
326
+ "source_ref" => source_ref,
327
+ "access" => "point",
328
+ "temporal_axis" => "valid_time",
329
+ "axis" => "valid_time",
330
+ "as_of_ref" => as_of_ref,
331
+ "coordinate_refs" => { "as_of" => as_of_ref },
332
+ "result_type" => decl.fetch("type"),
333
+ "node_fragment_class" => "temporal",
334
+ "value_fragment_class" => "core",
335
+ "required_capability" => "history_read",
336
+ "required_caps" => ["history_read"],
337
+ "evidence_policy" => "link_selected_append_observation",
338
+ "fragment" => "temporal"
339
+ }
340
+ end
341
+
342
+ def bihistory_temporal_access_node(decl, expr)
343
+ args = expr.fetch("args", [])
344
+ source_ref = ref_name(args[0])
345
+ vt_ref = ref_name(args[1])
346
+ tt_ref = ref_name(args[2])
347
+ {
348
+ "kind" => "temporal_access_node",
349
+ "name" => decl.fetch("name"),
350
+ "source_ref" => source_ref,
351
+ "access" => "point",
352
+ "temporal_axis" => "bitemporal",
353
+ "axis" => "bitemporal",
354
+ "valid_time_ref" => vt_ref,
355
+ "transaction_time_ref" => tt_ref,
356
+ "coordinate_refs" => {
357
+ "valid_time" => vt_ref,
358
+ "transaction_time" => tt_ref
359
+ },
360
+ "result_type" => decl.fetch("type"),
361
+ "node_fragment_class" => "temporal",
362
+ "value_fragment_class" => "core",
363
+ "required_capability" => "bihistory_read",
364
+ "required_caps" => ["bihistory_read"],
365
+ "evidence_policy" => "link_selected_event_observation",
366
+ "fragment" => "temporal"
367
+ }
368
+ end
369
+
370
+ def temporal_type(type)
371
+ return type unless type.is_a?(Hash)
372
+
373
+ constructor = type.fetch("name", type.fetch("constructor", nil))
374
+ params = type.fetch("params", [])
375
+ element = params.first
376
+ {
377
+ "constructor" => constructor,
378
+ "element_type" => element ? type_display(element) : "Unknown"
379
+ }
380
+ end
381
+
382
+ def invariant_node(decl)
383
+ result = {
384
+ "kind" => "invariant_node",
385
+ "name" => decl.fetch("name"),
386
+ "predicate" => decl.fetch("predicate_ref", nil),
387
+ "predicate_ref" => decl.fetch("predicate_ref", nil),
388
+ "predicate_type" => decl.fetch("predicate_type", nil),
389
+ "severity" => decl.fetch("severity", "error"),
390
+ "label" => decl.fetch("label", nil),
391
+ "message" => decl.fetch("message", nil),
392
+ "overridable_with" => decl.fetch("overridable_with", nil),
393
+ "output_effect" => decl.fetch("output_effect", invariant_output_effect(decl.fetch("severity", "error"))),
394
+ "deps" => decl.fetch("deps", []),
395
+ "fragment" => decl.fetch("fragment_class", "core")
396
+ }
397
+ result["source_span"] = decl.fetch("source_span") if decl.key?("source_span")
398
+ result["source_metadata"] = decl.fetch("source_metadata") if decl.key?("source_metadata")
399
+ result["threshold"] = decl.fetch("threshold") if decl.key?("threshold")
400
+ result["threshold_ms"] = decl.fetch("threshold_ms") if decl.key?("threshold_ms")
401
+ result
402
+ end
403
+
404
+ def invariant_output_effect(severity)
405
+ case severity
406
+ when "error" then "blocks"
407
+ when "warn" then "warns"
408
+ when "soft" then "uncertain"
409
+ when "metric" then "metric"
410
+ else "blocks"
411
+ end
412
+ end
413
+
414
+ def stream_input_node(decl, declarations)
415
+ {
416
+ "kind" => "stream_input_node",
417
+ "name" => decl.fetch("name"),
418
+ "type" => type_display(decl.fetch("type")),
419
+ "window_ref" => decl.fetch("window_ref", first_window_ref(declarations)),
420
+ "escape_capability" => "stream_input",
421
+ "fragment" => decl.fetch("fragment_class", "escape")
422
+ }
423
+ end
424
+
425
+ def window_decl_node(decl)
426
+ result = {
427
+ "kind" => "window_decl_node",
428
+ "ref" => window_ref(decl),
429
+ "key" => decl.fetch("key", decl.fetch("name")),
430
+ "window_kind" => atom_value(decl.fetch("window_kind", decl.dig("options", "kind"))),
431
+ "on_close" => atom_value(decl.fetch("on_close", decl.dig("options", "on_close")))
432
+ }
433
+ result["size"] = decl.fetch("size", decl.dig("options", "size")) if decl.key?("size") || decl.dig("options", "size")
434
+ result["period"] = decl.fetch("period", decl.dig("options", "period")) if decl.key?("period") || decl.dig("options", "period")
435
+ result["idle"] = decl.fetch("idle", decl.dig("options", "idle")) if decl.key?("idle") || decl.dig("options", "idle")
436
+ result["bounded"] = window_bounded?(result)
437
+ result.compact
438
+ end
439
+
440
+ def fold_stream_node(decl, declarations)
441
+ expr = decl.fetch("expr", {})
442
+ args = expr.fetch("args", [])
443
+ {
444
+ "kind" => "fold_stream_node",
445
+ "name" => decl.fetch("name"),
446
+ "stream_ref" => decl.fetch("stream_ref", ref_name(args[0])),
447
+ "init" => decl.fetch("init", literal_node(args[1])),
448
+ "fn_ref" => decl.fetch("fn_ref", lambda_ref(args[2])),
449
+ "bound" => fold_stream_bound(decl, declarations),
450
+ "event_binding" => stream_event_binding(args[2]),
451
+ "result_type" => decl.fetch("type"),
452
+ "escape_capability" => "stream_input",
453
+ "result_fragment" => decl.fetch("fragment_class", "core")
454
+ }
455
+ end
456
+
457
+ def first_window_ref(declarations)
458
+ window_decl = declarations.find { |decl| decl.fetch("kind") == "window" }
459
+ window_decl && window_ref(window_decl)
460
+ end
461
+
462
+ def window_ref(decl)
463
+ decl.fetch("window_ref", decl.fetch("ref", decl.fetch("name")))
464
+ end
465
+
466
+ def stream_bound(decl, declarations)
467
+ {
468
+ "kind" => decl.fetch("bound_kind", "window_bounded"),
469
+ "window_ref" => decl.fetch("window_ref", first_window_ref(declarations))
470
+ }
471
+ end
472
+
473
+ def fold_stream_bound(decl, declarations)
474
+ bound = decl.fetch("bound", stream_bound(decl, declarations)).dup
475
+ bound["window_ref"] ||= decl.fetch("window_ref", first_window_ref(declarations))
476
+ bound
477
+ end
478
+
479
+ def window_bounded?(window)
480
+ %w[size period idle].any? { |key| window.fetch(key, nil) }
481
+ end
482
+
483
+ def stream_event_binding(lambda_expr)
484
+ params = lambda_expr.is_a?(Hash) ? lambda_expr.fetch("params", []) : []
485
+ {
486
+ "event_ref" => "event",
487
+ "value_ref" => params[1],
488
+ "value_path" => ["value"]
489
+ }.compact
490
+ end
491
+
492
+ def ref_name(expr)
493
+ return nil unless expr.is_a?(Hash)
494
+ return expr.fetch("name") if expr.fetch("kind", nil) == "ref"
495
+
496
+ nil
497
+ end
498
+
499
+ def literal_node(expr)
500
+ return expr unless expr.is_a?(Hash) && expr.fetch("kind", nil) == "literal"
501
+
502
+ type_tag = expr.fetch("type_tag", "Unknown")
503
+ {
504
+ "kind" => "#{type_tag.downcase}_literal",
505
+ "value" => expr.fetch("value")
506
+ }
507
+ end
508
+
509
+ def lambda_ref(expr)
510
+ return "integer_sum_lambda" if integer_sum_lambda?(expr)
511
+ return nil unless expr.is_a?(Hash)
512
+
513
+ "lambda/#{Digest::SHA256.hexdigest(canonical_json(expr))[0, 16]}"
514
+ end
515
+
516
+ def integer_sum_lambda?(expr)
517
+ return false unless expr.is_a?(Hash) && expr.fetch("kind", nil) == "lambda"
518
+
519
+ params = expr.fetch("params", [])
520
+ body = expr.fetch("body", {})
521
+ body.fetch("kind", nil) == "binary_op" &&
522
+ body.fetch("op", nil) == "+" &&
523
+ ref_name(body.fetch("left", {})) == params[0]
524
+ end
525
+
526
+ def atom_value(value)
527
+ case value
528
+ when Hash
529
+ if value.fetch("kind", nil) == "symbol"
530
+ value.fetch("value")
531
+ elsif value.fetch("kind", nil) == "literal"
532
+ value.fetch("value")
533
+ else
534
+ value
535
+ end
536
+ when Symbol
537
+ value.to_s
538
+ else
539
+ value
540
+ end
541
+ end
542
+
543
+ def type_display(type)
544
+ return type unless type.is_a?(Hash)
545
+
546
+ params = type.fetch("params", [])
547
+ return type.fetch("name") if params.empty?
548
+
549
+ "#{type.fetch("name")}[#{params.map { |param| type_display(param) }.join(", ")}]"
550
+ end
551
+
552
+ def type_shapes(parsed_program)
553
+ parsed_program.fetch("types").each_with_object({}) do |type, shapes|
554
+ fields = type.fetch("fields", []).each_with_object({}) do |field, index|
555
+ index[field.fetch("name")] = normalize_type(field.fetch("type_annotation"))
556
+ end
557
+ shapes[type.fetch("name")] = fields
558
+ end
559
+ end
560
+
561
+ def emit_contract(_parsed_program, contract, sample_input)
562
+ diagnostics = []
563
+ type_env = {}
564
+ value_env = sample_input.dup
565
+ inputs = []
566
+ outputs = []
567
+ nodes = []
568
+
569
+ contract.fetch("body").each do |node|
570
+ case node.fetch("kind")
571
+ when "input"
572
+ type = normalize_type(node.fetch("type_annotation"))
573
+ type_env[node.fetch("name")] = type
574
+ inputs << { "name" => node.fetch("name"), "type" => type_ir(type), "lifecycle" => "local" }
575
+ when "compute"
576
+ diagnostic_count_before = diagnostics.length
577
+ lowered = lower_expr(node.fetch("expr"), type_env, diagnostics, node.fetch("name"))
578
+ type_env[node.fetch("name")] = lowered.fetch("type")
579
+ value_env[node.fetch("name")] = eval_expr(node.fetch("expr"), value_env)
580
+ fragment = diagnostics.length == diagnostic_count_before ? "core" : "oof"
581
+ nodes << {
582
+ "kind" => "compute",
583
+ "name" => node.fetch("name"),
584
+ "expr" => lowered.fetch("expr"),
585
+ "type" => type_ir(lowered.fetch("type")),
586
+ "deps" => lowered.fetch("deps").uniq,
587
+ "fragment" => fragment
588
+ }
589
+ when "output"
590
+ name = node.fetch("name")
591
+ expected = normalize_type(node.fetch("type_annotation"))
592
+ actual = type_env[name]
593
+ if actual.nil?
594
+ diagnostics << oof("OOF-P1", "Unresolved output source: #{name}", name)
595
+ elsif actual != expected
596
+ diagnostics << type_mismatch_oof(expected, actual, name)
597
+ end
598
+ outputs << {
599
+ "name" => name,
600
+ "type" => type_ir(expected),
601
+ "lifecycle" => node.fetch("lifecycle", "session")
602
+ }
603
+ end
604
+ end
605
+
606
+ diagnostics.concat(evidence_gate_oofs(contract, sample_input, value_env))
607
+ modifier = contract.fetch("modifier", "pure")
608
+ fragment_class = if !diagnostics.empty?
609
+ "oof"
610
+ elsif modifier != "pure"
611
+ "escape"
612
+ else
613
+ "core"
614
+ end
615
+ contract_ir = {
616
+ "kind" => "contract_ir",
617
+ "contract_ref" => nil,
618
+ "contract_name" => contract.fetch("name"),
619
+ "modifier" => modifier,
620
+ "specialization_of" => nil,
621
+ "type_args" => {},
622
+ "fragment_class" => fragment_class,
623
+ "inputs" => inputs,
624
+ "outputs" => outputs,
625
+ "nodes" => nodes,
626
+ "escape_boundaries" => [],
627
+ "diagnostics" => diagnostics
628
+ }
629
+ contract_ir["contract_ref"] = contract_ref(contract_ir)
630
+ contract_ir
631
+ end
632
+
633
+ def contract_ref(contract_ir)
634
+ body = contract_ir.reject { |key, _value| key == "contract_ref" || key == "diagnostics" }
635
+ "contract/#{contract_ir.fetch("contract_name")}/sha256:#{Digest::SHA256.hexdigest(canonical_json(body))[0, 24]}"
636
+ end
637
+
638
+ def lower_expr(expr, type_env, diagnostics, node_name)
639
+ case expr.fetch("kind")
640
+ when "literal"
641
+ type = normalize_type(expr.fetch("type_tag"))
642
+ { "expr" => {
643
+ "kind" => "literal",
644
+ "value" => expr.fetch("value"),
645
+ "type" => literal_type(type),
646
+ "resolved_type" => type_ir(type)
647
+ },
648
+ "type" => type,
649
+ "deps" => [] }
650
+ when "ref"
651
+ name = expr.fetch("name")
652
+ type = type_env[name]
653
+ unless type
654
+ diagnostics << oof("OOF-P1", "Unresolved symbol: #{name}", node_name)
655
+ type = "Unknown"
656
+ end
657
+ { "expr" => { "kind" => "ref", "name" => name, "resolved_type" => type_ir(type) },
658
+ "type" => type,
659
+ "deps" => [name] }
660
+ when "field_access"
661
+ object = lower_expr(expr.fetch("object"), type_env, diagnostics, node_name)
662
+ field = expr.fetch("field")
663
+ field_type = @types.fetch(object.fetch("type"), {})[field]
664
+ unless field_type
665
+ diagnostics << oof("OOF-P1", "Unresolved field: #{object.fetch("type")}.#{field}", node_name)
666
+ field_type = "Unknown"
667
+ end
668
+ { "expr" => {
669
+ "kind" => "field_access",
670
+ "object" => object.fetch("expr"),
671
+ "field" => field,
672
+ "resolved_type" => type_ir(field_type)
673
+ },
674
+ "type" => field_type,
675
+ "deps" => object.fetch("deps") }
676
+ when "binary_op"
677
+ lower_binary(expr, type_env, diagnostics, node_name)
678
+ when "call"
679
+ fn = expr.fetch("fn")
680
+ diagnostics << oof("OOF-P1", "Unresolved function: #{fn}", node_name)
681
+ { "expr" => { "kind" => "call", "fn" => fn, "args" => [], "resolved_type" => type_ir("Unknown") },
682
+ "type" => "Unknown",
683
+ "deps" => [] }
684
+ else
685
+ diagnostics << oof("OOF-P0", "Unsupported expression kind: #{expr.fetch("kind")}", node_name)
686
+ { "expr" => {
687
+ "kind" => "unsupported",
688
+ "source_kind" => expr.fetch("kind"),
689
+ "resolved_type" => type_ir("Unknown")
690
+ },
691
+ "type" => "Unknown",
692
+ "deps" => [] }
693
+ end
694
+ end
695
+
696
+ def lower_binary(expr, type_env, diagnostics, node_name)
697
+ left = lower_expr(expr.fetch("left"), type_env, diagnostics, node_name)
698
+ right = lower_expr(expr.fetch("right"), type_env, diagnostics, node_name)
699
+ operator, result_type = operator_for(expr.fetch("op"), left.fetch("type"), right.fetch("type"), diagnostics, node_name)
700
+
701
+ {
702
+ "expr" => {
703
+ "kind" => "call",
704
+ "fn" => operator,
705
+ "args" => [left.fetch("expr"), right.fetch("expr")],
706
+ "resolved_type" => type_ir(result_type)
707
+ },
708
+ "type" => result_type,
709
+ "deps" => left.fetch("deps") + right.fetch("deps")
710
+ }
711
+ end
712
+
713
+ def operator_for(op, left_type, right_type, diagnostics, node_name)
714
+ case op
715
+ when "+"
716
+ unless unknown_type?(left_type, right_type) || (left_type == "Integer" && right_type == "Integer")
717
+ diagnostics << oof("OOF-TY0", "Integer add requires Integer operands", node_name)
718
+ end
719
+ ["stdlib.integer.add", "Integer"]
720
+ when ">"
721
+ unless unknown_type?(left_type, right_type) || (left_type == "Integer" && right_type == "Integer")
722
+ diagnostics << oof("OOF-TY0", "Integer comparison requires Integer operands", node_name)
723
+ end
724
+ ["stdlib.integer.gt", "Bool"]
725
+ when "&&"
726
+ unless unknown_type?(left_type, right_type) || (left_type == "Bool" && right_type == "Bool")
727
+ diagnostics << oof("OOF-TY0", "Boolean and requires Bool operands", node_name)
728
+ end
729
+ ["stdlib.bool.and", "Bool"]
730
+ else
731
+ diagnostics << oof("OOF-P0", "Unsupported operator: #{op}", node_name)
732
+ ["stdlib.unsupported.#{op}", "Unknown"]
733
+ end
734
+ end
735
+
736
+ def unknown_type?(*types)
737
+ types.any? { |type| type == "Unknown" }
738
+ end
739
+
740
+ def eval_expr(expr, env)
741
+ case expr.fetch("kind")
742
+ when "literal"
743
+ expr.fetch("value")
744
+ when "ref"
745
+ env[expr.fetch("name")]
746
+ when "field_access"
747
+ object = eval_expr(expr.fetch("object"), env)
748
+ object&.fetch(expr.fetch("field"), nil)
749
+ when "binary_op"
750
+ left = eval_expr(expr.fetch("left"), env)
751
+ right = eval_expr(expr.fetch("right"), env)
752
+ return nil if left.nil? || right.nil?
753
+
754
+ case expr.fetch("op")
755
+ when "+" then left + right
756
+ when ">" then left > right
757
+ when "&&" then left && right
758
+ else nil
759
+ end
760
+ else
761
+ nil
762
+ end
763
+ end
764
+
765
+ def evidence_gate_oofs(contract, sample_input, value_env)
766
+ return [] unless contract.fetch("name").include?("EvidenceLinkedAlert") ||
767
+ contract.fetch("name").include?("EvidenceLessAlert")
768
+
769
+ alert = sample_input.fetch("alert", {})
770
+ diagnostics = []
771
+ if alert.fetch("signal_count", 0) < 1 || alert.fetch("claim_count", 0) < 1
772
+ diagnostics << oof(
773
+ "OOF-OS2",
774
+ "EvidenceLinkedAlert requires non-empty signal_refs and claim_refs",
775
+ contract.fetch("name")
776
+ )
777
+ end
778
+ if alert.fetch("valid_until", "").to_s.empty?
779
+ diagnostics << oof("OOF-OS4", "EvidenceLinkedAlert requires valid_until", contract.fetch("name"))
780
+ end
781
+ if value_env.key?("allowed") && value_env.fetch("allowed") != true && diagnostics.empty?
782
+ diagnostics << oof("OOF-OS2", "EvidenceLinkedAlert gate did not pass", contract.fetch("name"))
783
+ end
784
+ diagnostics
785
+ end
786
+
787
+ def type_mismatch_oof(expected, actual, node_name)
788
+ if expected == "Bool" && actual == "ConfidenceLabel"
789
+ oof("OOF-CE4", "ConfidenceLabel cannot be used as Bool", node_name)
790
+ else
791
+ oof("OOF-TY0", "Type mismatch: expected #{expected}, got #{actual}", node_name)
792
+ end
793
+ end
794
+
795
+ def normalize_type(type)
796
+ type.is_a?(Hash) ? type.fetch("name") : type.to_s
797
+ end
798
+
799
+ def type_ir(type)
800
+ { "name" => type, "params" => [] }
801
+ end
802
+
803
+ def literal_type(type)
804
+ {
805
+ "Integer" => "int",
806
+ "Float" => "float",
807
+ "String" => "string",
808
+ "Bool" => "bool",
809
+ "Nil" => "nil"
810
+ }.fetch(type, type.downcase)
811
+ end
812
+
813
+ def dedupe_oofs(entries)
814
+ entries.uniq { |entry| [entry.fetch("rule"), entry.fetch("message"), entry.fetch("node"), entry.fetch("line")] }
815
+ end
816
+
817
+ def canonical_json(value)
818
+ JSON.generate(deep_sort(value))
819
+ end
820
+
821
+ def deep_sort(value)
822
+ case value
823
+ when Hash
824
+ value.keys.sort.each_with_object({}) { |key, sorted| sorted[key] = deep_sort(value[key]) }
825
+ when Array
826
+ value.map { |item| deep_sort(item) }
827
+ else
828
+ value
829
+ end
830
+ end
831
+
832
+ def oof(rule, message, node_name)
833
+ { "rule" => rule, "message" => message, "node" => node_name, "line" => nil }
834
+ end
835
+
836
+ def diagnostic(entry)
837
+ {
838
+ "rule" => entry.fetch("rule"),
839
+ "severity" => "error",
840
+ "message" => entry.fetch("message"),
841
+ "node" => entry.fetch("node"),
842
+ "path" => nil,
843
+ "line" => entry.fetch("line")
844
+ }
845
+ end
846
+ end
847
+ end