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,405 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+
5
+ module IgniterLang
6
+ class Classifier
7
+ DEFAULT_VERSION = "classifier-pass-executable-proof-v0"
8
+
9
+ def initialize(classifier_version: DEFAULT_VERSION)
10
+ @classifier_version = classifier_version
11
+ end
12
+
13
+ def classify(parsed_program, sample_input:)
14
+ assumption_registry = assumption_registry(parsed_program)
15
+ contracts = parsed_program.fetch("contracts").map do |contract|
16
+ classify_contract(parsed_program, contract, sample_input, assumption_registry)
17
+ end
18
+
19
+ result = {
20
+ "kind" => "classified_program",
21
+ "classifier_version" => @classifier_version,
22
+ "program_id" => program_id(parsed_program),
23
+ "source_path" => parsed_program.fetch("source_path"),
24
+ "source_hash" => parsed_program.fetch("source_hash"),
25
+ "grammar_version" => parsed_program.fetch("grammar_version"),
26
+ "module" => parsed_program.fetch("module"),
27
+ "type_declarations" => type_declarations(parsed_program),
28
+ "contracts" => contracts,
29
+ "oof_log" => contracts.flat_map { |contract| contract.fetch("oof_log") },
30
+ "semantic_ir_ref" => nil
31
+ }
32
+ result["assumption_registry"] = assumption_registry.values unless assumption_registry.empty?
33
+ olap_points = parsed_program.fetch("olap_points", [])
34
+ result["olap_points"] = olap_points unless olap_points.empty?
35
+ result
36
+ end
37
+
38
+ def type_declarations(parsed_program)
39
+ parsed_program.fetch("types", []).map do |type|
40
+ {
41
+ "kind" => "type",
42
+ "name" => type.fetch("name"),
43
+ "fields" => type.fetch("fields", []).map do |field|
44
+ {
45
+ "name" => field.fetch("name"),
46
+ "type_annotation" => normalize_type(field.fetch("type_annotation")),
47
+ "optional" => field.fetch("optional", false)
48
+ }
49
+ end
50
+ }
51
+ end
52
+ end
53
+
54
+ private
55
+
56
+ def program_id(parsed_program)
57
+ seed = [
58
+ parsed_program.fetch("source_path"),
59
+ parsed_program.fetch("grammar_version"),
60
+ parsed_program.fetch("source_hash"),
61
+ @classifier_version
62
+ ].join("|")
63
+ "classifier_pass/#{Digest::SHA256.hexdigest(seed)[0, 16]}"
64
+ end
65
+
66
+ def classify_contract(parsed_program, contract, sample_input, assumption_registry)
67
+ diagnostics = []
68
+ declarations = []
69
+ assumption_refs = []
70
+ symbol_fragments = {}
71
+ symbol_kinds = {}
72
+ compute_exprs = {}
73
+ window_declarations = []
74
+ fold_stream_stream_refs = Hash.new { |refs, stream_name| refs[stream_name] = [] }
75
+ parsed_program.fetch("olap_points", []).each do |point|
76
+ symbol_fragments[point.fetch("name")] = "escape"
77
+ symbol_kinds[point.fetch("name")] = "olap_point"
78
+ end
79
+
80
+ contract.fetch("body").each do |node|
81
+ case node.fetch("kind")
82
+ when "input"
83
+ symbol_fragments[node.fetch("name")] = "core"
84
+ symbol_kinds[node.fetch("name")] = "input"
85
+ declarations << classified_decl(node, "core", [], [])
86
+ when "escape"
87
+ declarations << classified_decl(node, "escape", [], [])
88
+ when "stream"
89
+ symbol_fragments[node.fetch("name")] = "escape"
90
+ symbol_kinds[node.fetch("name")] = "stream"
91
+ declarations << classified_decl(node, "escape", [], [])
92
+ when "read"
93
+ fragment = temporal_type?(node["type_annotation"]) ? "temporal" : "escape"
94
+ symbol_fragments[node.fetch("name")] = fragment == "temporal" ? "core" : "escape"
95
+ symbol_kinds[node.fetch("name")] = fragment == "temporal" ? "temporal_read" : "read"
96
+ declarations << classified_decl(node, fragment, [], []).merge(value_fragment_metadata(fragment, node["type_annotation"]))
97
+ when "window"
98
+ window_declarations << node
99
+ declarations << classified_decl(node.merge("name" => node.fetch("label", "_window")), "escape", [], [])
100
+ when "uses_assumptions"
101
+ name = node.fetch("name")
102
+ assumption_refs << name
103
+ symbol_fragments[name] = "core"
104
+ symbol_kinds[name] = "assumption"
105
+ missing = assumption_registry.key?(name) ? [] : [name]
106
+ unless missing.empty?
107
+ diagnostics << oof(
108
+ "OOF-A1",
109
+ "contract '#{contract.fetch("name")}' uses assumptions '#{name}' but no " \
110
+ "assumption named '#{name}' is declared in this module",
111
+ "uses_assumptions:#{name}"
112
+ )
113
+ end
114
+ declarations << classified_decl(node, "epistemic", [], missing)
115
+ when "fold_stream"
116
+ bound = node.fetch("bound", nil)
117
+ result_fragment = bound ? "core" : "oof"
118
+ deps = expr_refs(node.fetch("expr", { "kind" => "literal", "value" => nil }))
119
+ deps.select { |dep| symbol_kinds[dep] == "stream" }.each do |stream_name|
120
+ fold_stream_stream_refs[stream_name] << node.fetch("name")
121
+ end
122
+ symbol_fragments[node.fetch("name")] = result_fragment
123
+ symbol_kinds[node.fetch("name")] = "fold_stream"
124
+ declarations << classified_decl(node, result_fragment, deps, [])
125
+ when "invariant"
126
+ deps = [node.fetch("predicate_ref", nil)].compact
127
+ missing = deps.reject { |dep| symbol_fragments.key?(dep) }
128
+ missing.each do |name|
129
+ diagnostics << oof("OOF-P1", "Unresolved symbol: #{name}", node.fetch("name"))
130
+ end
131
+ declarations << classified_decl(node, missing.empty? ? "core" : "oof", deps, missing)
132
+ .merge(invariant_author_fields(node))
133
+ .merge("source_metadata" => invariant_source_metadata(parsed_program, node))
134
+ when "compute"
135
+ deps = expr_refs(node.fetch("expr"))
136
+ missing = deps.reject { |dep| symbol_fragments.key?(dep) }
137
+ missing.each do |name|
138
+ diagnostics << oof("OOF-P1", "Unresolved symbol: #{name}", node.fetch("name"))
139
+ end
140
+ stream_deps = deps.select { |dep| symbol_kinds[dep] == "stream" }
141
+ stream_deps.each do |stream_name|
142
+ diagnostics << oof("OOF-S4", "Direct use of stream '#{stream_name}' is OOF - use fold_stream instead", node.fetch("name"))
143
+ end
144
+ upstream_oof = deps.any? { |dep| symbol_fragments[dep] == "oof" }
145
+ fragment = missing.empty? && stream_deps.empty? && !upstream_oof ? "core" : "oof"
146
+ symbol_fragments[node.fetch("name")] = fragment
147
+ symbol_kinds[node.fetch("name")] = "compute"
148
+ compute_exprs[node.fetch("name")] = node.fetch("expr")
149
+ declarations << classified_decl(node, fragment, deps, missing)
150
+ when "output"
151
+ name = node.fetch("name")
152
+ missing = symbol_fragments.key?(name) ? [] : [name]
153
+ diagnostics << oof("OOF-P1", "Unresolved output source: #{name}", name) unless missing.empty?
154
+ src_fragment = symbol_fragments.fetch(name, "oof")
155
+ fragment = missing.empty? && src_fragment == "core" ? "core" : "oof"
156
+ confidence_oof = confidence_as_bool_oof(node, compute_exprs[name])
157
+ diagnostics << confidence_oof if confidence_oof
158
+ fragment = "oof" if confidence_oof
159
+ declarations << classified_decl(node, fragment, [name], missing)
160
+ end
161
+ end
162
+
163
+ diagnostics.concat(stream_missing_window_oofs(fold_stream_stream_refs, window_declarations))
164
+ diagnostics.concat(evidence_gate_oofs(contract, sample_input))
165
+
166
+ modifier = contract.fetch("modifier", "pure")
167
+ if modifier == "pure"
168
+ escape_decl = declarations.find { |decl| decl.fetch("fragment_class") == "escape" }
169
+ if escape_decl
170
+ diagnostics << oof(
171
+ "OOF-M1",
172
+ "pure contract '#{contract.fetch("name")}' cannot declare escape capabilities; " \
173
+ "use 'observed' for read-only external access",
174
+ contract.fetch("name")
175
+ )
176
+ end
177
+ end
178
+
179
+ contract_fragment = contract_fragment_for(declarations, diagnostics, modifier: modifier)
180
+
181
+ result = {
182
+ "kind" => "classified_contract",
183
+ "contract_id" => contract_id(parsed_program, contract),
184
+ "name" => contract.fetch("name"),
185
+ "modifier" => modifier,
186
+ "fragment_class" => contract_fragment,
187
+ "symbols" => symbol_table(symbol_kinds, symbol_fragments),
188
+ "declarations" => declarations,
189
+ "dependency_graph" => dependency_graph(declarations),
190
+ "oof_log" => diagnostics
191
+ }
192
+ result["assumption_refs"] = assumption_refs.uniq unless assumption_refs.empty?
193
+ result
194
+ end
195
+
196
+ def contract_fragment_for(declarations, diagnostics, modifier: "pure")
197
+ return "oof" unless diagnostics.empty?
198
+ return "core" if declarations.all? { |decl| decl.fetch("fragment_class") == "core" }
199
+ return "temporal" if declarations.any? { |decl| decl.fetch("fragment_class") == "temporal" } &&
200
+ declarations.none? { |decl| decl.fetch("fragment_class") == "oof" }
201
+ return "escape" if (modifier != "pure" || declarations.any? { |decl| decl.fetch("fragment_class") == "escape" }) &&
202
+ declarations.none? { |decl| decl.fetch("fragment_class") == "oof" }
203
+ return "epistemic" if declarations.any? { |decl| decl.fetch("fragment_class") == "epistemic" } &&
204
+ declarations.none? { |decl| decl.fetch("fragment_class") == "oof" }
205
+
206
+ "oof"
207
+ end
208
+
209
+ def assumption_registry(parsed_program)
210
+ parsed_program.fetch("assumptions", []).each_with_object({}) do |assumption, registry|
211
+ name = assumption.fetch("name")
212
+ registry[name] = {
213
+ "kind" => "assumption_entry",
214
+ "name" => name,
215
+ "fields" => assumption.fetch("fields", {}),
216
+ "declared_in_module" => parsed_program.fetch("module")
217
+ }
218
+ end
219
+ end
220
+
221
+ def stream_missing_window_oofs(fold_stream_stream_refs, window_declarations)
222
+ return [] unless window_declarations.empty?
223
+
224
+ fold_stream_stream_refs.keys.sort.map do |stream_name|
225
+ oof("OOF-S2", "stream '#{stream_name}' has no window - every stream must declare a window", stream_name)
226
+ end
227
+ end
228
+
229
+ def contract_id(parsed_program, contract)
230
+ [parsed_program.fetch("module"), contract.fetch("name")].compact.join(".")
231
+ end
232
+
233
+ def classified_decl(node, fragment, deps, missing)
234
+ result = {
235
+ "decl_id" => decl_id(node),
236
+ "kind" => node.fetch("kind"),
237
+ "name" => node.fetch("name"),
238
+ "fragment_class" => fragment,
239
+ "deps" => deps,
240
+ "missing_refs" => missing
241
+ }
242
+ result["type_annotation"] = normalized_type_annotation(node["type_annotation"]) if node.key?("type_annotation")
243
+ if node.key?("expr")
244
+ result["expr_kind"] = node.fetch("expr").fetch("kind")
245
+ result["expr"] = node.fetch("expr")
246
+ end
247
+ %w[bound options].each do |key|
248
+ result[key] = node.fetch(key) if node.key?(key)
249
+ end
250
+ result
251
+ end
252
+
253
+ def invariant_author_fields(node)
254
+ %w[predicate_ref severity label message overridable_with source_span threshold threshold_ms].each_with_object({}) do |key, result|
255
+ result[key] = node.fetch(key) if node.key?(key)
256
+ end
257
+ end
258
+
259
+ def invariant_source_metadata(parsed_program, node)
260
+ {
261
+ "kind" => "invariant",
262
+ "source_path" => parsed_program.fetch("source_path", nil),
263
+ "source_span" => node.fetch("source_span", nil),
264
+ "name" => node.fetch("name"),
265
+ "severity" => node.fetch("severity", "error"),
266
+ "label" => node.fetch("label", nil),
267
+ "message" => node.fetch("message", nil)
268
+ }
269
+ end
270
+
271
+ def value_fragment_metadata(fragment, type)
272
+ return {} unless fragment == "temporal"
273
+
274
+ type_name = normalize_type(type)
275
+ {
276
+ "node_fragment_class" => "temporal",
277
+ "value_fragment_class" => "core",
278
+ "required_capability" => temporal_capability(type_name),
279
+ "temporal_axis" => temporal_axis(type_name)
280
+ }
281
+ end
282
+
283
+ def temporal_capability(type_name)
284
+ type_name == "BiHistory" ? "bihistory_read" : "history_read"
285
+ end
286
+
287
+ def temporal_axis(type_name)
288
+ type_name == "BiHistory" ? "bitemporal" : "valid_time"
289
+ end
290
+
291
+ def decl_id(node)
292
+ "#{node.fetch("kind")}:#{node.fetch("name")}"
293
+ end
294
+
295
+ def symbol_table(symbol_kinds, symbol_fragments)
296
+ symbol_kinds.keys.sort.map do |name|
297
+ {
298
+ "name" => name,
299
+ "kind" => symbol_kinds.fetch(name),
300
+ "fragment_class" => symbol_fragments.fetch(name)
301
+ }
302
+ end
303
+ end
304
+
305
+ def dependency_graph(declarations)
306
+ declaration_ids = declarations.map { |decl| decl.fetch("decl_id") }
307
+ symbol_producers = declarations.each_with_object({}) do |decl, index|
308
+ next unless %w[input compute].include?(decl.fetch("kind"))
309
+
310
+ index[decl.fetch("name")] = decl.fetch("decl_id")
311
+ end
312
+ edges = declarations.flat_map do |decl|
313
+ decl.fetch("deps").filter_map do |dep|
314
+ from = symbol_producers[dep]
315
+ next unless from
316
+
317
+ { "from" => from, "to" => decl.fetch("decl_id"), "kind" => "symbol" }
318
+ end
319
+ end
320
+ { "nodes" => declaration_ids, "edges" => edges }
321
+ end
322
+
323
+ def expr_refs(expr)
324
+ return [] unless expr.is_a?(Hash)
325
+ unless expr.key?("kind")
326
+ return expr.values.flat_map do |value|
327
+ case value
328
+ when Hash then expr_refs(value)
329
+ when Array then value.flat_map { |item| expr_refs(item) }
330
+ else []
331
+ end
332
+ end.uniq
333
+ end
334
+
335
+ case expr.fetch("kind")
336
+ when "ref"
337
+ [expr.fetch("name")]
338
+ when "field_access"
339
+ expr_refs(expr.fetch("object"))
340
+ when "binary_op"
341
+ expr_refs(expr.fetch("left")) + expr_refs(expr.fetch("right"))
342
+ when "call"
343
+ expr.fetch("args", []).flat_map { |arg| expr_refs(arg) }
344
+ when "literal", "symbol"
345
+ []
346
+ else
347
+ expr.values.flat_map { |value| value.is_a?(Hash) ? expr_refs(value) : [] }
348
+ end.uniq
349
+ end
350
+
351
+ def confidence_as_bool_oof(output_node, expr)
352
+ return nil unless normalize_type(output_node.fetch("type_annotation")) == "Bool"
353
+ return nil unless confidence_label_expr?(expr)
354
+
355
+ oof("OOF-CE4", "ConfidenceLabel cannot be used as Bool", output_node.fetch("name"))
356
+ end
357
+
358
+ def confidence_label_expr?(expr)
359
+ return false unless expr
360
+ return true if expr.fetch("kind") == "field_access" && expr.fetch("field") == "confidence_label"
361
+
362
+ false
363
+ end
364
+
365
+ def evidence_gate_oofs(contract, sample_input)
366
+ return [] unless evidence_alert_contract?(contract)
367
+
368
+ alert = sample_input.fetch("alert", {})
369
+ diagnostics = []
370
+ if alert.fetch("signal_count", 0) < 1 || alert.fetch("claim_count", 0) < 1
371
+ diagnostics << oof(
372
+ "OOF-OS2",
373
+ "EvidenceLinkedAlert requires non-empty signal_refs and claim_refs",
374
+ contract.fetch("name")
375
+ )
376
+ end
377
+ diagnostics
378
+ end
379
+
380
+ def evidence_alert_contract?(contract)
381
+ contract.fetch("body").any? do |node|
382
+ node.fetch("kind") == "input" &&
383
+ normalize_type(node.fetch("type_annotation")) == "EvidenceLinkedAlertInput"
384
+ end
385
+ end
386
+
387
+ def normalize_type(type)
388
+ type.is_a?(Hash) ? type.fetch("name") : type.to_s
389
+ end
390
+
391
+ def normalized_type_annotation(type)
392
+ return type unless type.is_a?(Hash)
393
+
394
+ type
395
+ end
396
+
397
+ def temporal_type?(type)
398
+ %w[History BiHistory].include?(normalize_type(type))
399
+ end
400
+
401
+ def oof(rule, message, node_name)
402
+ { "rule" => rule, "message" => message, "node" => node_name, "line" => nil }
403
+ end
404
+ end
405
+ end
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "pathname"
5
+
6
+ require_relative "../igniter_lang"
7
+
8
+ module IgniterLang
9
+ module CLI
10
+ module_function
11
+
12
+ USAGE = "Usage: igc compile SOURCE --out OUT.igapp " \
13
+ "[--compiler-profile-source PATH.json]"
14
+
15
+ def run(argv)
16
+ command = argv.shift
17
+ unless command == "compile"
18
+ warn USAGE
19
+ return false
20
+ end
21
+
22
+ source_path, out_path, profile_source_path = parse_compile_args(argv)
23
+ compiler_profile_source = load_profile_source(profile_source_path) if profile_source_path
24
+ orchestration = IgniterLang.compile(
25
+ source_path: source_path,
26
+ out_path: out_path,
27
+ compiler_profile_source: compiler_profile_source
28
+ )
29
+ puts JSON.pretty_generate(CompilerResult.public_result(orchestration.fetch("result")))
30
+ orchestration.fetch("status") == "ok"
31
+ rescue ArgumentError => e
32
+ warn e.message
33
+ false
34
+ end
35
+
36
+ def parse_compile_args(argv)
37
+ source = argv.shift
38
+ raise ArgumentError, USAGE unless source
39
+
40
+ out_flag = argv.shift
41
+ out = argv.shift
42
+ raise ArgumentError, USAGE unless out_flag == "--out" && out
43
+
44
+ profile_source_path = nil
45
+ until argv.empty?
46
+ flag = argv.shift
47
+ case flag
48
+ when "--compiler-profile-source"
49
+ path = argv.shift
50
+ raise ArgumentError, "--compiler-profile-source requires PATH.json" unless path
51
+ raise ArgumentError, "unsupported argument for igc compile" if profile_source_path
52
+
53
+ profile_source_path = Pathname.new(path)
54
+ else
55
+ raise ArgumentError, "unsupported argument for igc compile"
56
+ end
57
+ end
58
+
59
+ [Pathname.new(source), Pathname.new(out), profile_source_path]
60
+ end
61
+
62
+ def load_profile_source(path)
63
+ raise ArgumentError, "compiler profile source path not found" unless path.exist?
64
+ raise ArgumentError, "compiler profile source path must be a regular file" unless path.file?
65
+
66
+ parsed = JSON.parse(path.read)
67
+ raise ArgumentError, "compiler profile source JSON must be an object" unless parsed.is_a?(Hash)
68
+
69
+ parsed
70
+ rescue Errno::EACCES
71
+ raise ArgumentError, "compiler profile source path is not readable"
72
+ rescue JSON::ParserError
73
+ raise ArgumentError, "compiler profile source file must contain valid JSON"
74
+ end
75
+ end
76
+ end
@@ -0,0 +1,99 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "diagnostics"
4
+
5
+ module IgniterLang
6
+ module CompilationReport
7
+ module_function
8
+
9
+ def parse_failure(format_version:, parsed:, source_path:)
10
+ {
11
+ "kind" => "compilation_report",
12
+ "format_version" => format_version,
13
+ "program_id" => "compilation_report/parse_error",
14
+ "grammar_version" => parsed.fetch("grammar_version"),
15
+ "source_hash" => parsed.fetch("source_hash"),
16
+ "source_path" => source_path.to_s,
17
+ "pass_result" => "error",
18
+ "stages" => {
19
+ "parse" => "error",
20
+ "classify" => "skipped",
21
+ "typecheck" => "skipped",
22
+ "emit" => "skipped"
23
+ },
24
+ "diagnostics" => Diagnostics.from_parse_errors(parsed.fetch("parse_errors")),
25
+ "semantic_ir_ref" => nil
26
+ }
27
+ end
28
+
29
+ def runtime_smoke_failure(report:, smoke:, source_path:)
30
+ report.merge(
31
+ "pass_result" => "error",
32
+ "source_path" => source_path.to_s,
33
+ "diagnostics" => report.fetch("diagnostics", []) + Diagnostics.from_runtime_smoke(smoke)
34
+ )
35
+ end
36
+
37
+ def internal_error(format_version:, source_path:, rule:, error:)
38
+ {
39
+ "kind" => "compilation_report",
40
+ "format_version" => format_version,
41
+ "program_id" => "compilation_report/#{rule}",
42
+ "grammar_version" => "unknown",
43
+ "source_hash" => nil,
44
+ "source_path" => source_path.to_s,
45
+ "pass_result" => "error",
46
+ "stages" => {
47
+ "parse" => "unknown",
48
+ "classify" => "unknown",
49
+ "typecheck" => "unknown",
50
+ "emit" => "unknown"
51
+ },
52
+ "diagnostics" => internal_error_diagnostics(rule, error),
53
+ "semantic_ir_ref" => nil
54
+ }
55
+ end
56
+
57
+ def enrich(report:, parsed:)
58
+ contract_name = parsed.fetch("contracts", []).fetch(0, {}).fetch("name", nil)
59
+ report.merge(
60
+ "diagnostics" => Diagnostics.enrich(
61
+ report.fetch("diagnostics", []),
62
+ category: diagnostic_category_for(report),
63
+ contract: contract_name
64
+ )
65
+ )
66
+ end
67
+
68
+ def with_compiler_profile_contract_validation(report:, validation:)
69
+ return report unless validation
70
+
71
+ report.merge(
72
+ "compiler_profile_contract_validation" => validation.merge("report_only" => true)
73
+ )
74
+ end
75
+
76
+ def diagnostic_category_for(report)
77
+ stages = report.fetch("stages", {})
78
+ return "typechecker_oof" if stages.fetch("typecheck", nil) == "oof"
79
+ return "emitter_error" if stages.fetch("emit", nil) == "error"
80
+
81
+ "classifier_oof"
82
+ end
83
+
84
+ def internal_error_diagnostics(rule, error)
85
+ return Diagnostics.from_assembler_refusal(error) if rule == "assembler_refused"
86
+
87
+ Diagnostics.enrich(
88
+ [
89
+ {
90
+ "rule" => rule,
91
+ "severity" => "error",
92
+ "message" => "#{error.class}: #{error.message}"
93
+ }
94
+ ],
95
+ category: "emitter_error"
96
+ )
97
+ end
98
+ end
99
+ end