arxo-codegen 0.4.0__py3-none-any.whl

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.
Files changed (152) hide show
  1. arxo_codegen/__init__.py +5 -0
  2. arxo_codegen/__main__.py +7 -0
  3. arxo_codegen/active-outcomes.json +6837 -0
  4. arxo_codegen/check.py +158 -0
  5. arxo_codegen/check_active_canaries.py +262 -0
  6. arxo_codegen/check_api.py +76 -0
  7. arxo_codegen/check_conformance.py +414 -0
  8. arxo_codegen/check_matrix.py +131 -0
  9. arxo_codegen/check_packages.py +281 -0
  10. arxo_codegen/check_semantic_probes.py +293 -0
  11. arxo_codegen/check_xlsx.py +320 -0
  12. arxo_codegen/consumer_runner.py +316 -0
  13. arxo_codegen/core_support.py +203 -0
  14. arxo_codegen/coverage.py +341 -0
  15. arxo_codegen/deliver.py +180 -0
  16. arxo_codegen/examples/xlsx/pricing.lawir.json +409 -0
  17. arxo_codegen/examples/xlsx/pricing.xlsx.json +52 -0
  18. arxo_codegen/excel_plan.py +602 -0
  19. arxo_codegen/harness/verify/ci/gates/backends/check_backends.py +1248 -0
  20. arxo_codegen/harness/verify/ci/gates/backends/l1_fixtures.py +463 -0
  21. arxo_codegen/inert-nodes.json +62 -0
  22. arxo_codegen/inert.py +91 -0
  23. arxo_codegen/inputs.py +59 -0
  24. arxo_codegen/package.py +565 -0
  25. arxo_codegen/plan.py +4053 -0
  26. arxo_codegen/recertify_registry.py +100 -0
  27. arxo_codegen/runtime-cert.json +5259 -0
  28. arxo_codegen/runtime_cert.py +233 -0
  29. arxo_codegen/survey.py +272 -0
  30. arxo_codegen/target_runner.py +101 -0
  31. arxo_codegen/targets/bsl/cert.bsl +113 -0
  32. arxo_codegen/targets/bsl/emit.py +356 -0
  33. arxo_codegen/targets/bsl/json.bsl +243 -0
  34. arxo_codegen/targets/bsl/runner.bsl +210 -0
  35. arxo_codegen/targets/bsl/runtime.bsl +858 -0
  36. arxo_codegen/targets/cs/Cert.cs +90 -0
  37. arxo_codegen/targets/cs/Runner.cs +154 -0
  38. arxo_codegen/targets/cs/Runtime.cs +243 -0
  39. arxo_codegen/targets/cs/emit.py +223 -0
  40. arxo_codegen/targets/dart/cert.dart +99 -0
  41. arxo_codegen/targets/dart/emit.py +206 -0
  42. arxo_codegen/targets/dart/json.dart +166 -0
  43. arxo_codegen/targets/dart/runner.dart +154 -0
  44. arxo_codegen/targets/dart/runtime.dart +293 -0
  45. arxo_codegen/targets/erl/cert.erl +64 -0
  46. arxo_codegen/targets/erl/emit.py +216 -0
  47. arxo_codegen/targets/erl/rt.erl +238 -0
  48. arxo_codegen/targets/erl/runner.erl +130 -0
  49. arxo_codegen/targets/ex/cert.exs +62 -0
  50. arxo_codegen/targets/ex/emit.py +204 -0
  51. arxo_codegen/targets/ex/runner.exs +124 -0
  52. arxo_codegen/targets/ex/runtime.ex +227 -0
  53. arxo_codegen/targets/go/apiBase.go +483 -0
  54. arxo_codegen/targets/go/apicheck.go +97 -0
  55. arxo_codegen/targets/go/cert.go +218 -0
  56. arxo_codegen/targets/go/emit.py +1806 -0
  57. arxo_codegen/targets/go/evaluate.go +1826 -0
  58. arxo_codegen/targets/go/runner.go +38 -0
  59. arxo_codegen/targets/go/runtime.go +2612 -0
  60. arxo_codegen/targets/java/Cert.java +91 -0
  61. arxo_codegen/targets/java/Json.java +148 -0
  62. arxo_codegen/targets/java/Runner.java +151 -0
  63. arxo_codegen/targets/java/Runtime.java +243 -0
  64. arxo_codegen/targets/java/emit.py +198 -0
  65. arxo_codegen/targets/kt/Cert.kt +67 -0
  66. arxo_codegen/targets/kt/Json.kt +103 -0
  67. arxo_codegen/targets/kt/Runner.kt +126 -0
  68. arxo_codegen/targets/kt/Runtime.kt +196 -0
  69. arxo_codegen/targets/kt/emit.py +195 -0
  70. arxo_codegen/targets/lua/cert.lua +82 -0
  71. arxo_codegen/targets/lua/emit.py +250 -0
  72. arxo_codegen/targets/lua/json.lua +133 -0
  73. arxo_codegen/targets/lua/runner.lua +167 -0
  74. arxo_codegen/targets/lua/runtime.lua +541 -0
  75. arxo_codegen/targets/php/cert.php +84 -0
  76. arxo_codegen/targets/php/emit.py +229 -0
  77. arxo_codegen/targets/php/json.php +144 -0
  78. arxo_codegen/targets/php/runner.php +189 -0
  79. arxo_codegen/targets/php/runtime.php +660 -0
  80. arxo_codegen/targets/py/api_base.py +334 -0
  81. arxo_codegen/targets/py/apicheck.py +51 -0
  82. arxo_codegen/targets/py/cert.py +140 -0
  83. arxo_codegen/targets/py/emit.py +1140 -0
  84. arxo_codegen/targets/py/evaluate.py +980 -0
  85. arxo_codegen/targets/py/runner.py +38 -0
  86. arxo_codegen/targets/py/runtime.py +1634 -0
  87. arxo_codegen/targets/rb/cert.rb +73 -0
  88. arxo_codegen/targets/rb/emit.py +203 -0
  89. arxo_codegen/targets/rb/runner.rb +130 -0
  90. arxo_codegen/targets/rb/runtime.rb +255 -0
  91. arxo_codegen/targets/rs/Cargo.lock +142 -0
  92. arxo_codegen/targets/rs/Cargo.toml +16 -0
  93. arxo_codegen/targets/rs/cert.rs +106 -0
  94. arxo_codegen/targets/rs/emit.py +233 -0
  95. arxo_codegen/targets/rs/prefetch.rs +1 -0
  96. arxo_codegen/targets/rs/runner.rs +181 -0
  97. arxo_codegen/targets/rs/runtime.rs +448 -0
  98. arxo_codegen/targets/scala/Cert.scala +77 -0
  99. arxo_codegen/targets/scala/Json.scala +140 -0
  100. arxo_codegen/targets/scala/Runner.scala +121 -0
  101. arxo_codegen/targets/scala/Runtime.scala +269 -0
  102. arxo_codegen/targets/scala/emit.py +193 -0
  103. arxo_codegen/targets/swift/cert.swift +105 -0
  104. arxo_codegen/targets/swift/emit.py +231 -0
  105. arxo_codegen/targets/swift/json.swift +208 -0
  106. arxo_codegen/targets/swift/runner.swift +133 -0
  107. arxo_codegen/targets/swift/runtime.swift +528 -0
  108. arxo_codegen/targets/ts/apiBase.ts +250 -0
  109. arxo_codegen/targets/ts/apicheck.ts +37 -0
  110. arxo_codegen/targets/ts/cert.ts +96 -0
  111. arxo_codegen/targets/ts/emit.py +1214 -0
  112. arxo_codegen/targets/ts/evaluate.ts +894 -0
  113. arxo_codegen/targets/ts/package-lock.json +45 -0
  114. arxo_codegen/targets/ts/package.json +9 -0
  115. arxo_codegen/targets/ts/runner.ts +31 -0
  116. arxo_codegen/targets/ts/runtime.ts +1369 -0
  117. arxo_codegen/targets/xlsx/README.ru.md +176 -0
  118. arxo_codegen/targets/xlsx/emit.py +198 -0
  119. arxo_codegen/temporal.py +59 -0
  120. arxo_codegen/test_alias_types.py +171 -0
  121. arxo_codegen/test_api_contract.py +89 -0
  122. arxo_codegen/test_calendar_policy.py +48 -0
  123. arxo_codegen/test_check.py +49 -0
  124. arxo_codegen/test_check_active_canaries.py +96 -0
  125. arxo_codegen/test_check_conformance.py +132 -0
  126. arxo_codegen/test_check_semantic_probes.py +85 -0
  127. arxo_codegen/test_check_xlsx.py +52 -0
  128. arxo_codegen/test_consumer_runner.py +175 -0
  129. arxo_codegen/test_core_support.py +80 -0
  130. arxo_codegen/test_e0212_decision_time.py +63 -0
  131. arxo_codegen/test_head_aggregate.py +71 -0
  132. arxo_codegen/test_inert.py +143 -0
  133. arxo_codegen/test_instant_types.py +113 -0
  134. arxo_codegen/test_key_conflict_scope.py +92 -0
  135. arxo_codegen/test_key_conflict_targets.py +17 -0
  136. arxo_codegen/test_package_layout.py +163 -0
  137. arxo_codegen/test_plan_language_version.py +44 -0
  138. arxo_codegen/test_positions_refusal.py +43 -0
  139. arxo_codegen/test_recertify_registry.py +77 -0
  140. arxo_codegen/test_ruby_target.py +41 -0
  141. arxo_codegen/test_runtime_cert.py +83 -0
  142. arxo_codegen/test_secondary_key_conflict_scope.py +17 -0
  143. arxo_codegen/test_stage_r.py +108 -0
  144. arxo_codegen/test_standalone_inputs.py +71 -0
  145. arxo_codegen/test_support.py +150 -0
  146. arxo_codegen/test_survey_inputs.py +115 -0
  147. arxo_codegen-0.4.0.dist-info/LICENSE +202 -0
  148. arxo_codegen-0.4.0.dist-info/METADATA +1673 -0
  149. arxo_codegen-0.4.0.dist-info/RECORD +152 -0
  150. arxo_codegen-0.4.0.dist-info/WHEEL +5 -0
  151. arxo_codegen-0.4.0.dist-info/entry_points.txt +13 -0
  152. arxo_codegen-0.4.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,320 @@
1
+ #!/usr/bin/env python3
2
+ """Check exported XLSX fixtures without importing the Law DSL oracle.
3
+
4
+ The publisher-side exporter computes ``expectations.json`` with lawref. This
5
+ consumer verifies fixture hashes and workbook structure, then optionally asks
6
+ LibreOffice to recalculate copies of the exported workbooks. It compares only
7
+ against those pinned expected results; it never imports lawref or implements
8
+ Excel formulae in Python.
9
+ """
10
+ from __future__ import annotations
11
+
12
+ import argparse
13
+ from decimal import Decimal
14
+ import datetime as dt
15
+ import hashlib
16
+ import json
17
+ from pathlib import Path
18
+ import shutil
19
+ import subprocess
20
+ import sys
21
+ import tempfile
22
+ import zipfile
23
+ from xml.etree import ElementTree as ET
24
+
25
+
26
+ FORMAT = "law.codegen-xlsx-fixtures/0.1"
27
+ CURRENT_REFUSAL = "цель xlsx не поддерживает возможности плана: admission:current"
28
+ XMLNS = {"s": "http://schemas.openxmlformats.org/spreadsheetml/2006/main"}
29
+ REL = "{http://schemas.openxmlformats.org/officeDocument/2006/relationships}id"
30
+ EPOCH = dt.date(1899, 12, 30)
31
+ HERE = Path(__file__).resolve().parent
32
+
33
+
34
+ class FixtureError(RuntimeError):
35
+ pass
36
+
37
+
38
+ def sha(data: bytes) -> str:
39
+ return "sha256:" + hashlib.sha256(data).hexdigest()
40
+
41
+
42
+ def load(fixtures: Path) -> tuple[dict, dict]:
43
+ fixtures = Path(fixtures)
44
+ if fixtures.is_symlink() or not fixtures.is_dir():
45
+ raise FixtureError("fixtures are not a directory")
46
+ for path in fixtures.rglob("*"):
47
+ if path.is_symlink():
48
+ raise FixtureError("fixtures contain a symlink")
49
+ try:
50
+ manifest = json.loads((fixtures / "manifest.json").read_bytes())
51
+ expected = json.loads((fixtures / "expectations.json").read_bytes())
52
+ except (OSError, UnicodeDecodeError, json.JSONDecodeError) as error:
53
+ raise FixtureError("invalid fixture JSON") from error
54
+ if manifest.get("format") != FORMAT or expected.get("format") != FORMAT:
55
+ raise FixtureError("unsupported fixture format")
56
+ files = manifest.get("files")
57
+ if not isinstance(files, list):
58
+ raise FixtureError("fixture manifest has no inventory")
59
+ seen = set()
60
+ for row in files:
61
+ if not isinstance(row, dict) or not isinstance(row.get("path"), str):
62
+ raise FixtureError("invalid fixture inventory row")
63
+ rel = row["path"]
64
+ if not rel or rel.startswith("/") or ".." in Path(rel).parts or rel in seen:
65
+ raise FixtureError("invalid fixture inventory path")
66
+ seen.add(rel); path = fixtures / rel
67
+ if path.is_symlink() or not path.is_file() or row.get("sha256") != sha(path.read_bytes()) or row.get("bytes") != path.stat().st_size:
68
+ raise FixtureError("fixture inventory hash mismatch")
69
+ actual = {path.relative_to(fixtures).as_posix() for path in fixtures.rglob("*") if path.is_file()}
70
+ if actual != seen | {"manifest.json"}:
71
+ raise FixtureError("fixture inventory differs from files")
72
+ if expected.get("semanticVersion") != "0.2.11":
73
+ raise FixtureError("fixture does not declare current semanticVersion 0.2.11")
74
+ if not isinstance(expected.get("programs"), dict) or not expected["programs"]:
75
+ raise FixtureError("fixture has no programs")
76
+ return manifest, expected
77
+
78
+
79
+ def cached(path: Path) -> dict[str, object]:
80
+ with zipfile.ZipFile(path) as archive:
81
+ strings = []
82
+ if "xl/sharedStrings.xml" in archive.namelist():
83
+ strings = ["".join(node.itertext()) for node in ET.fromstring(archive.read("xl/sharedStrings.xml")).findall("s:si", XMLNS)]
84
+ relationships = {row.attrib["Id"]: row.attrib["Target"].lstrip("/") for row in ET.fromstring(archive.read("xl/_rels/workbook.xml.rels"))}
85
+ book = ET.fromstring(archive.read("xl/workbook.xml")); values: dict[str, object] = {}
86
+ for sheet in book.findall("s:sheets/s:sheet", XMLNS):
87
+ target = relationships[sheet.attrib[REL]]
88
+ target = target if target.startswith("xl/") else "xl/" + target
89
+ for cell in ET.fromstring(archive.read(target)).findall(".//s:c", XMLNS):
90
+ value = cell.find("s:v", XMLNS)
91
+ data: object = value.text if value is not None else None
92
+ if cell.get("t") == "inlineStr": data = "".join(cell.find("s:is", XMLNS).itertext())
93
+ elif cell.get("t") == "s" and data is not None: data = strings[int(data)]
94
+ elif cell.get("t") == "b": data = data == "1"
95
+ elif cell.get("t") not in ("str", "e") and data is not None: data = Decimal(data)
96
+ values[sheet.attrib["name"] + "!" + cell.attrib["r"]] = data
97
+ return values
98
+
99
+
100
+ def _sheets(archive: zipfile.ZipFile) -> dict[str, str]:
101
+ rels = {row.attrib["Id"]: row.attrib["Target"].lstrip("/") for row in ET.fromstring(archive.read("xl/_rels/workbook.xml.rels"))}
102
+ book = ET.fromstring(archive.read("xl/workbook.xml"))
103
+ return {sheet.attrib["name"]: (rels[sheet.attrib[REL]] if rels[sheet.attrib[REL]].startswith("xl/") else "xl/" + rels[sheet.attrib[REL]]) for sheet in book.findall("s:sheets/s:sheet", XMLNS)}
104
+
105
+
106
+ def edit_copy(source: Path, destination: Path, changes: dict[str, object]) -> None:
107
+ """Edit only declared Inputs cells; formula XML is copied byte-for-byte."""
108
+ with zipfile.ZipFile(source) as original, zipfile.ZipFile(destination, "w", zipfile.ZIP_DEFLATED) as output:
109
+ sheets = _sheets(original)
110
+ for name in original.namelist():
111
+ data = original.read(name)
112
+ for sheet, target in sheets.items():
113
+ if name != target:
114
+ continue
115
+ document = ET.fromstring(data)
116
+ for cell in document.findall(".//s:c", XMLNS):
117
+ key = sheet + "!" + cell.get("r")
118
+ if key not in changes:
119
+ continue
120
+ for child in list(cell): cell.remove(child)
121
+ cell.attrib.pop("t", None); value = changes[key]
122
+ if isinstance(value, str):
123
+ cell.set("t", "inlineStr")
124
+ ET.SubElement(ET.SubElement(cell, "{" + XMLNS["s"] + "}is"), "{" + XMLNS["s"] + "}t").text = value
125
+ elif value is not None:
126
+ ET.SubElement(cell, "{" + XMLNS["s"] + "}v").text = str(value)
127
+ data = ET.tostring(document, encoding="utf-8", xml_declaration=True)
128
+ output.writestr(name, data)
129
+
130
+
131
+ def result_rows(values: dict[str, object], mapping: dict, predicate: str) -> list[list[object]]:
132
+ rows = []
133
+ for row in mapping["tables"][predicate]:
134
+ if values.get(row["status"]) == "PRESENT":
135
+ rows.append([values.get(address) for address in row["args"]])
136
+ return rows
137
+
138
+
139
+ def same_rows(actual: list[list[object]], expected: list[list[str]]) -> bool:
140
+ """Compare Excel numeric cells to oracle decimal strings by value, not scale."""
141
+ if len(actual) != len(expected): return False
142
+ def equivalent(left: object, right: str) -> bool:
143
+ if isinstance(left, Decimal):
144
+ try: return left == Decimal(right)
145
+ except Exception: return False
146
+ return left == right
147
+ unmatched = list(expected)
148
+ for row in actual:
149
+ for index, candidate in enumerate(unmatched):
150
+ if len(row) == len(candidate) and all(equivalent(left, right) for left, right in zip(row, candidate)):
151
+ unmatched.pop(index); break
152
+ else: return False
153
+ return not unmatched
154
+
155
+
156
+ def changes_for(name: str, case: dict, mapping: dict, prefix: str) -> dict[str, object]:
157
+ change = case["changes"]
158
+ if name == "pricing":
159
+ first, second = mapping["inputs"][prefix + "quote"]
160
+ out = {}
161
+ for index, key in enumerate(("ident", "category", "amount")):
162
+ if key in change: out[first["args"][index]] = change[key]
163
+ if "enabled" in change: out[first["enabled"]] = change["enabled"]
164
+ if "legalTime" in change: out["Inputs!B4"] = None if change["legalTime"] is None else (dt.date.fromisoformat(change["legalTime"]) - EPOCH).days
165
+ if "second" in change:
166
+ out[second["enabled"]] = 1
167
+ out.update(dict(zip(second["args"], change["second"])))
168
+ return out
169
+ if name == "arithmetic":
170
+ row = mapping["inputs"][prefix + "operands"][0]
171
+ return {address: Decimal(value) for address, value in zip(row["args"], change.get("operands", []))}
172
+ if name == "discrete":
173
+ out = {}
174
+ for table, values in change.items():
175
+ row = mapping["inputs"][prefix + table][0]
176
+ if table == "dates":
177
+ values = [(dt.date.fromisoformat(value) - EPOCH).days for value in values]
178
+ out.update(dict(zip(row["args"], values)))
179
+ return out
180
+ raise FixtureError("unknown exported workbook")
181
+
182
+
183
+ def structural_tree(fixtures: Path, expected: dict) -> None:
184
+ for name, program in expected["programs"].items():
185
+ directory = fixtures / name
186
+ if program.get("expectedRefusal"):
187
+ if program["expectedRefusal"] != CURRENT_REFUSAL:
188
+ raise FixtureError(f"{name}: unqualified current XLSX refusal")
189
+ ir = json.loads((directory / "program.lawir.json").read_bytes())
190
+ if (ir.get("semanticVersion"), ir.get("schemaVersion")) != ("0.2.11", "law.core.ir/0.7"):
191
+ raise FixtureError(f"{name}: refusal fixture is not current")
192
+ if {p.name for p in directory.iterdir()} != {"program.lawir.json", "config.json"}:
193
+ raise FixtureError(f"{name}: refused workbook must not contain generated output")
194
+ continue
195
+ for required in ("model.xlsx", "cells.json", "law-backend.json", "program.lawir.json", "config.json"):
196
+ if not (directory / required).is_file(): raise FixtureError(f"{name}: missing {required}")
197
+ with zipfile.ZipFile(directory / "model.xlsx") as book:
198
+ names = set(book.namelist())
199
+ if not {"xl/workbook.xml", "xl/_rels/workbook.xml.rels"} <= names or any("vba" in item.lower() or "externallink" in item.lower() for item in names):
200
+ raise FixtureError(f"{name}: unsafe or incomplete workbook")
201
+ passport = json.loads((directory / "law-backend.json").read_bytes())
202
+ if passport.get("profile") != "excel-calculator/0.1": raise FixtureError(f"{name}: unexpected delivery profile")
203
+ for rel, digest in passport.get("files", {}).items():
204
+ if digest != sha((directory / rel).read_bytes()): raise FixtureError(f"{name}: delivery passport hash mismatch")
205
+ program_ir = json.loads((directory / "program.lawir.json").read_bytes())
206
+ if program_ir.get("semanticVersion") != "0.2.11": raise FixtureError(f"{name}: archived program leaked into active fixtures")
207
+
208
+
209
+ def structural(fixtures: Path, expected: dict) -> None:
210
+ structural_tree(fixtures, expected)
211
+
212
+
213
+ def _backend_modules():
214
+ """Support both a direct script and installed ``arxo_codegen`` package."""
215
+ try:
216
+ from . import core_support, deliver
217
+ except ImportError:
218
+ if str(HERE) not in sys.path: sys.path.insert(0, str(HERE))
219
+ import core_support, deliver
220
+ return core_support, deliver
221
+
222
+
223
+ def regenerate(fixtures: Path, expected: dict, core_kit: Path, output: Path) -> None:
224
+ """Regenerate pinned deliveries with the installed backend and core kit."""
225
+ core_support, deliver = _backend_modules()
226
+ core_support.configure_core_kit(core_kit)
227
+ core_support.validate_core_kit(core_kit)
228
+ try:
229
+ for name in expected["programs"]:
230
+ source, target = fixtures / name, output / name
231
+ target.mkdir(parents=True)
232
+ program = json.loads((source / "program.lawir.json").read_bytes())
233
+ config = json.loads((source / "config.json").read_bytes())
234
+ # Resources are deliberately explicit and empty: these fixtures do
235
+ # not rely on a neighbouring corpus or an implicit resolver.
236
+ if expected["programs"][name].get("expectedRefusal"):
237
+ try:
238
+ deliver.deliver(program, "xlsx", target, options=config, resources={})
239
+ except deliver.P.Refused as exc:
240
+ if str(exc) != CURRENT_REFUSAL:
241
+ raise FixtureError(f"{name}: wrong current XLSX refusal") from exc
242
+ else:
243
+ raise FixtureError(f"{name}: uncertified XLSX unexpectedly admitted")
244
+ if any(target.iterdir()):
245
+ raise FixtureError(f"{name}: refusal wrote output")
246
+ for asset in ("program.lawir.json", "config.json"):
247
+ shutil.copyfile(source / asset, target / asset)
248
+ continue
249
+ deliver.deliver(program, "xlsx", target, options=config, resources={})
250
+ for asset in ("program.lawir.json", "config.json"):
251
+ shutil.copyfile(source / asset, target / asset)
252
+ for asset in ("model.xlsx", "plan.json", "cells.json", "law-backend.json"):
253
+ if sha((source / asset).read_bytes()) != sha((target / asset).read_bytes()):
254
+ raise FixtureError(f"{name}: installed backend regenerated different {asset}")
255
+ structural_tree(output, expected)
256
+ finally:
257
+ core_support.configure_core_kit(None)
258
+
259
+
260
+ def recalculate(fixtures: Path, expected: dict, soffice: str) -> int:
261
+ if not Path(soffice).is_file(): raise FixtureError("LibreOffice executable is unavailable")
262
+ checked = 0
263
+ with tempfile.TemporaryDirectory(prefix="arxo-xlsx-check-") as temporary:
264
+ work = Path(temporary); incoming, outgoing = work / "incoming", work / "outgoing"; incoming.mkdir(); outgoing.mkdir()
265
+ pending = []
266
+ for name, program in expected["programs"].items():
267
+ mapping = json.loads((fixtures / name / "cells.json").read_bytes()); prefix = program["predicatePrefix"]
268
+ for case in program["cases"]:
269
+ target = incoming / f"{name}--{case['id']}.xlsx"
270
+ edit_copy(fixtures / name / "model.xlsx", target, changes_for(name, case, mapping, prefix))
271
+ pending.append((name, case, mapping, prefix, target.name))
272
+ command = [soffice, "-env:UserInstallation=" + (work / "profile").as_uri(), "--headless", "--convert-to", "xlsx", "--outdir", str(outgoing)] + [str(incoming / item[-1]) for item in pending]
273
+ result = subprocess.run(command, capture_output=True, text=True, timeout=120)
274
+ if result.returncode or len(list(outgoing.glob("*.xlsx"))) != len(pending):
275
+ raise FixtureError(f"LibreOffice recalculation failed: {result.returncode}\n{result.stdout}\n{result.stderr}")
276
+ for name, case, mapping, prefix, filename in pending:
277
+ values = cached(outgoing / filename)
278
+ if values.get("Results!B3") != case["status"]: raise FixtureError(f"{name}/{case['id']}: status mismatch")
279
+ if case["status"] == "READY" and any(isinstance(value, str) and value.startswith(("#REF!", "#DIV/0!", "#VALUE!", "#NAME?", "#N/A", "#NUM!")) for value in values.values()):
280
+ raise FixtureError(f"{name}/{case['id']}: spreadsheet formula error")
281
+ for table, expected_rows in case["results"].items():
282
+ actual = result_rows(values, mapping, prefix + table)
283
+ if table == "before":
284
+ actual = [[(EPOCH + dt.timedelta(days=int(Decimal(value)))).isoformat() for value in row] for row in actual]
285
+ if not same_rows(actual, expected_rows): raise FixtureError(f"{name}/{case['id']}/{table}: result mismatch: {actual!r} != {expected_rows!r}")
286
+ checked += 1
287
+ return checked
288
+
289
+
290
+ def main() -> None:
291
+ parser = argparse.ArgumentParser(description=__doc__)
292
+ parser.add_argument("--fixtures", required=True, type=Path)
293
+ parser.add_argument("--core-kit", type=Path, help="validate the selected core kit without importing lawref")
294
+ parser.add_argument("--recalculate", action="store_true")
295
+ parser.add_argument("--soffice", default=shutil.which("soffice") or "/Applications/LibreOffice.app/Contents/MacOS/soffice")
296
+ args = parser.parse_args()
297
+ try:
298
+ _, expected = load(args.fixtures); structural(args.fixtures, expected)
299
+ refused = sum(bool(p.get("expectedRefusal")) for p in expected["programs"].values())
300
+ print(f"check_xlsx: fixture integrity — PASS; qualified current refusals {refused}")
301
+ with tempfile.TemporaryDirectory(prefix="arxo-xlsx-regenerated-") as temporary:
302
+ active = args.fixtures
303
+ if args.core_kit:
304
+ active = Path(temporary) / "delivery"
305
+ regenerate(args.fixtures, expected, args.core_kit, active)
306
+ print("check_xlsx: installed backend regenerated pinned XLSX delivery — PASS")
307
+ if refused:
308
+ if not args.core_kit:
309
+ raise FixtureError("--core-kit required to execute qualified refusals")
310
+ print("check_xlsx: current XLSX unsupported; no workbook/recalculation PASS claimed")
311
+ elif args.recalculate:
312
+ print(f"check_xlsx: LibreOffice recalculated {recalculate(active, expected, args.soffice)} cases — PASS")
313
+ else:
314
+ print("check_xlsx: recalculation NOT CHECKED (pass --recalculate with --soffice to verify formulas)")
315
+ except (OSError, ValueError, FixtureError, zipfile.BadZipFile, subprocess.SubprocessError) as error:
316
+ parser.exit(1, f"check_xlsx: REFUSED: {error}\n")
317
+
318
+
319
+ if __name__ == "__main__":
320
+ main()
@@ -0,0 +1,316 @@
1
+ #!/usr/bin/env python3
2
+ """Run an already-lowered consumer bundle without corpus, lawref, or CI code."""
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import hashlib
7
+ import json
8
+ import os
9
+ from pathlib import Path
10
+ import shutil
11
+ import sys
12
+ import tempfile
13
+
14
+ HERE = Path(__file__).resolve().parent
15
+ if str(HERE) not in sys.path:
16
+ sys.path.insert(0, str(HERE))
17
+ import core_support
18
+ import deliver as D
19
+ import inputs
20
+ import plan as P
21
+ import target_runner as T
22
+
23
+
24
+ FORMAT = "law.codegen-consumer/0.1"
25
+ SET_FORMAT = "law.codegen-consumer-set/0.1"
26
+ FIELDS = ("truth_status", "evaluation_status", "result_kind", "normative_status")
27
+ UNSUPPORTED_FIELDS = frozenset(("applicability_status", "trigger_status", "value"))
28
+ POSITION_OBS = ("position", "position_status", "position_count", "positions_present")
29
+ UNSUPPORTED_OBS = frozenset(("applied", "any_applied", "issue", "any_issue", "issues_within", "issues_exactly",
30
+ "certificate", "conflicts", "judgment_requests", "judgment_request", "interpretations",
31
+ "argumentation_class", "refused", "collected", "collected_exactly", "collected_count",
32
+ "any_position", "blocked_by", "blockers", "deadline_trace_present", "deadline_trace",
33
+ "position_proved", "status_support"))
34
+
35
+
36
+ def sha(data: bytes) -> str:
37
+ return "sha256:" + hashlib.sha256(data).hexdigest()
38
+
39
+
40
+ def _file(root: Path, entry: dict, label: str) -> Path:
41
+ if not isinstance(entry, dict) or set(entry) != {"path", "sha256"} or not all(isinstance(value, str) for value in entry.values()):
42
+ raise ValueError(f"{label}: expected {{path, sha256}}")
43
+ path = (root / entry["path"]).resolve()
44
+ if not path.is_relative_to(root) or not path.is_file():
45
+ raise ValueError(f"{label}: file is absent or outside bundle")
46
+ if sha(path.read_bytes()) != entry["sha256"]:
47
+ raise ValueError(f"{label}: SHA-256 differs")
48
+ return path
49
+
50
+
51
+ def load_bundle(path: Path, *, allow_set: bool = True) -> dict:
52
+ root = path.resolve()
53
+ manifest = root / "bundle.json"
54
+ try:
55
+ doc = json.loads(manifest.read_text(encoding="utf-8"))
56
+ except (OSError, json.JSONDecodeError) as error:
57
+ raise ValueError(f"consumer bundle: {error}") from error
58
+ if isinstance(doc, dict) and doc.get("format") == SET_FORMAT and allow_set:
59
+ members = doc.get("groups")
60
+ skipped = doc.get("skipped", [])
61
+ if not isinstance(members, list) or not members:
62
+ raise ValueError("groups: non-empty list required")
63
+ if not isinstance(skipped, list) or not all(isinstance(value, str) for value in skipped):
64
+ raise ValueError("skipped: list of named source skips required")
65
+ manifests = [_file(root, item, "group") for item in members]
66
+ if any(item.name != "bundle.json" for item in manifests) or len(set(manifests)) != len(manifests):
67
+ raise ValueError("groups: distinct bundle.json manifests required")
68
+ return {"groups": [load_bundle(item.parent, allow_set=False) for item in manifests],
69
+ "source_skipped": skipped}
70
+ if not isinstance(doc, dict) or doc.get("format") != FORMAT:
71
+ raise ValueError(f"consumer bundle: expected format {FORMAT}")
72
+ program = _file(root, doc.get("program"), "program")
73
+ world_rows = doc.get("world", [])
74
+ if not isinstance(world_rows, list):
75
+ raise ValueError("world: expected list")
76
+ world = [_file(root, row, "world") for row in world_rows]
77
+ mode = doc.get("programMode", "source")
78
+ if mode not in ("source", "linked") or (mode == "linked" and world):
79
+ raise ValueError("programMode: source or prelinked program with empty world required")
80
+ tests = doc.get("tests")
81
+ if not isinstance(tests, list) or not tests:
82
+ raise ValueError("tests: non-empty list required")
83
+ loaded = []
84
+ for row in tests:
85
+ if not isinstance(row, dict) or not isinstance(row.get("id"), str):
86
+ raise ValueError("test: id required")
87
+ case, query = _file(root, row.get("case"), f"test {row['id']} case"), _file(root, row.get("query"), f"test {row['id']} query")
88
+ expectations = row.get("expectations", [])
89
+ if not isinstance(expectations, list):
90
+ raise ValueError(f"test {row['id']}: expectations must be a list")
91
+ query_doc = json.loads(query.read_text(encoding="utf-8"))
92
+ validate_expectations(row["id"], query_doc.get("kind"), expectations)
93
+ loaded.append(dict(row, case=case, query=query, expectations=expectations))
94
+ resources = doc.get("resources")
95
+ resource_file = _file(root, resources, "resources") if resources is not None else None
96
+ group = doc.get("group")
97
+ if not isinstance(group, dict) or set(group) != {"world", "concat", "resolve", "merge"} or group.get("merge") != "link" \
98
+ or any(not isinstance(group.get(key), list) or not all(isinstance(value, str) for value in group[key])
99
+ for key in ("world", "concat", "resolve")):
100
+ raise ValueError("group: explicit {world, concat, resolve, merge:'link'} required")
101
+ skipped_at_source = doc.get("skipped", [])
102
+ if not isinstance(skipped_at_source, list) or not all(isinstance(value, str) for value in skipped_at_source):
103
+ raise ValueError("skipped: list of named source skips required")
104
+ return {"root": root, "doc": doc, "program": program, "world": world, "tests": loaded,
105
+ "group": group, "source_skipped": skipped_at_source, "program_mode": mode,
106
+ "resource_file": resource_file}
107
+
108
+
109
+ def validate_expectations(identifier: str, query_kind: object, expectations: list) -> None:
110
+ allowed_fields = {"truth": {"truth_status", "evaluation_status", "result_kind"},
111
+ "positions": {"result_kind", "normative_status", "evaluation_status"}}
112
+ for item in expectations:
113
+ if not isinstance(item, dict):
114
+ raise ValueError(f"test {identifier}: expectation must be an object")
115
+ field, observation = item.get("field"), item.get("observation")
116
+ if field is not None:
117
+ if item.get("op") != "eq":
118
+ raise ValueError(f"test {identifier}: unsupported expectation op {item.get('op')!r}")
119
+ if field not in FIELDS and field not in UNSUPPORTED_FIELDS:
120
+ raise ValueError(f"test {identifier}: unknown expectation field {field!r}")
121
+ if query_kind in allowed_fields and field in FIELDS and field not in allowed_fields[query_kind]:
122
+ raise ValueError(f"test {identifier}: field {field!r} is inapplicable to query {query_kind!r}")
123
+ elif observation is not None:
124
+ if observation in POSITION_OBS:
125
+ if query_kind != "positions":
126
+ raise ValueError(f"test {identifier}: observation {observation!r} requires positions query")
127
+ elif observation not in UNSUPPORTED_OBS:
128
+ raise ValueError(f"test {identifier}: unknown unsupported observation {observation!r}")
129
+ else:
130
+ raise ValueError(f"test {identifier}: expectation needs field or observation")
131
+
132
+
133
+ def expected(test: dict) -> tuple[dict, list[str], list[dict]]:
134
+ fields, skipped, positions = {}, [], []
135
+ for item in test["expectations"]:
136
+ if not isinstance(item, dict):
137
+ raise ValueError(f"test {test['id']}: expectation must be an object")
138
+ if item.get("op") == "eq" and item.get("field") in FIELDS:
139
+ fields[item["field"]] = item.get("value")
140
+ elif item.get("observation") in POSITION_OBS:
141
+ positions.append(item)
142
+ else:
143
+ skipped.append(str(item.get("observation") or item.get("field") or "?"))
144
+ return fields, skipped, positions
145
+
146
+
147
+ def observe(item: dict, positions: list[dict]) -> bool:
148
+ value, kind = item.get("value"), item["observation"]
149
+ if kind == "positions_present":
150
+ found = bool(positions)
151
+ elif kind == "position_status":
152
+ found = any(row.get("status") == value for row in positions)
153
+ elif kind == "position_count":
154
+ found = sum(row.get("template") == value and row.get("status") == item.get("status", "") for row in positions) == item.get("count")
155
+ else:
156
+ found = any(row.get("template") == value and row.get("status") == item.get("status", "") for row in positions)
157
+ return found != bool(item.get("negated"))
158
+
159
+
160
+ def compare(answer: dict, test: dict) -> list[str]:
161
+ fields, _skips, position_expectations = expected(test)
162
+ kind = json.loads(test["query"].read_text(encoding="utf-8")).get("kind")
163
+ if kind == "truth":
164
+ actual = {"truth_status": answer.get("truthStatus"), "evaluation_status": answer.get("evaluationStatus"),
165
+ "result_kind": answer.get("resultKind", "PROPOSITION" if answer.get("truthStatus") in
166
+ {"TRUE_ONLY", "FALSE_ONLY", "BOTH", "NEITHER"} else None)}
167
+ elif kind == "positions":
168
+ actual = {"result_kind": answer.get("resultKind"), "normative_status": answer.get("normativeStatus"),
169
+ "evaluation_status": answer.get("evaluationStatus")}
170
+ bad_positions = [item["observation"] for item in position_expectations if not observe(item, answer.get("positions") or [])]
171
+ if bad_positions:
172
+ return ["наблюдения позиций не совпали: " + ", ".join(bad_positions)]
173
+ else:
174
+ return [f"пропуск: запрос {kind}"]
175
+ return [f"{name}: ожидалось {value!r}, получено {actual.get(name)!r}" for name, value in fields.items() if actual.get(name) != value]
176
+
177
+
178
+ def checkable(test: dict) -> bool:
179
+ fields, _skipped, positions = expected(test)
180
+ return bool(fields or positions)
181
+
182
+
183
+ def run(bundle: dict, targets: list[str], work: Path) -> tuple[dict, list[str]]:
184
+ if "groups" in bundle:
185
+ summaries, failures = [], []
186
+ for number, member in enumerate(bundle["groups"]):
187
+ summary, errors = run(member, targets, work / f"group{number:03d}")
188
+ summaries.append(summary)
189
+ failures.extend(f"group {number}: {error}" for error in errors)
190
+ if not errors and summary.get("matched", 0) == 0:
191
+ failures.append(f"group {number}: нет проверенных ожиданий")
192
+ return {"groups": summaries, "tests": sum(s.get("tests", 0) for s in summaries),
193
+ "matched": sum(s.get("matched", 0) for s in summaries),
194
+ "sourceSkipped": bundle["source_skipped"]}, failures
195
+ ir = json.loads(bundle["program"].read_text(encoding="utf-8"))
196
+ world = [json.loads(path.read_text(encoding="utf-8")) for path in bundle["world"]]
197
+ # Validate names/duplicates even though resolve_world receives documents as
198
+ # explicit extras rather than its path-index argument.
199
+ inputs.dependency_index(world)
200
+ resources = inputs.load_resources(bundle["resource_file"]) if bundle["resource_file"] else {}
201
+ negative = any(any((item.get("literal") or {}).get("polarity") == "negative" for item in
202
+ (json.loads(test["case"].read_text(encoding="utf-8")).get("assertions") or [])) for test in bundle["tests"])
203
+ # ``extra`` is documents, not the path index accepted by resolve_world.
204
+ # Retain every explicitly delivered world member afterwards: concat worlds
205
+ # may not be imported by the root but still change its program.
206
+ resolved = P.resolve_world(ir, {}, world) if ir.get("imports") and bundle.get("program_mode") != "linked" else []
207
+ names = {(document.get("package") or {}).get("name") for document in resolved}
208
+ for document in world:
209
+ name = (document.get("package") or {}).get("name")
210
+ if name not in names:
211
+ resolved.append(document)
212
+ names.add(name)
213
+ plan = P.build_plan(ir, resolved, {"negation"} if negative else None, resources)
214
+ known = D.targets(protocol="runner")
215
+ unknown = sorted(set(targets) - set(known))
216
+ if unknown:
217
+ return {}, ["неизвестные цели: " + ", ".join(unknown)]
218
+ emit = {name: D.emitter(name) for name in targets}
219
+ unavailable = [f"{name}: {emit[name].available()[1]}" for name in targets if not emit[name].available()[0]]
220
+ active = [name for name in targets if emit[name].available()[0]]
221
+ if not active:
222
+ return {}, ["нет доступных целей"]
223
+ projects = {}
224
+ for name in active:
225
+ project = work / "module" / name
226
+ D.emit_project(plan, name, project)
227
+ projects[name] = [project]
228
+ environment = dict(os.environ, GOCACHE=str(work.resolve() / "gocache"),
229
+ GOMODCACHE=str(work.resolve() / "gomodcache"),
230
+ CLANG_MODULE_CACHE_PATH=str(work.resolve() / "clang-cache"),
231
+ SWIFT_MODULECACHE_PATH=str(work.resolve() / "swift-cache"))
232
+ failures = T.build_projects(emit, projects, work / "module", environment)
233
+ summary = {"tests": len(bundle["tests"]), "matched": 0, "unchecked": 0, "refused": {},
234
+ "skipped": {}, "sourceSkipped": bundle["source_skipped"], "unavailable": unavailable}
235
+ if failures:
236
+ return summary, failures
237
+ for test in bundle["tests"]:
238
+ query = json.loads(test["query"].read_text(encoding="utf-8"))
239
+ if query.get("kind") not in ("truth", "positions"):
240
+ summary["skipped"][f"запрос {query.get('kind')}"] = summary["skipped"].get(f"запрос {query.get('kind')}", 0) + 1
241
+ continue
242
+ _fields, skipped, _positions = expected(test)
243
+ for name in skipped:
244
+ summary["skipped"][f"наблюдение {name}"] = summary["skipped"].get(f"наблюдение {name}", 0) + 1
245
+ got = {name: T.run_target(emit[name], work / "module" / name, "runner",
246
+ [str(test["case"]), str(test["query"])], environment) for name in active}
247
+ crashes = {name: value for name, (kind, value) in got.items() if kind == "crash"}
248
+ if crashes:
249
+ failures.append(f"{test['id']}: КРАХ {crashes}")
250
+ continue
251
+ refusals = {name: value for name, (kind, value) in got.items() if kind == "refused"}
252
+ if refusals:
253
+ if len(refusals) != len(got):
254
+ failures.append(f"{test['id']}: отказ не единогласен {refusals}")
255
+ elif len(set(refusals.values())) != 1:
256
+ failures.append(f"{test['id']}: тексты отказа различаются {refusals}")
257
+ else:
258
+ text = next(iter(refusals.values()))
259
+ summary["refused"][text] = summary["refused"].get(text, 0) + 1
260
+ continue
261
+ answers = {name: value for name, (_kind, value) in got.items()}
262
+ if len({T.answer_key(value) for value in answers.values()}) != 1:
263
+ failures.append(f"{test['id']}: ответы целей разошлись")
264
+ continue
265
+ if not checkable(test):
266
+ summary["unchecked"] += 1
267
+ continue
268
+ differences = compare(next(iter(answers.values())), test)
269
+ if differences:
270
+ failures.extend(f"{test['id']}: {line}" for line in differences)
271
+ else:
272
+ summary["matched"] += 1
273
+ return summary, failures
274
+
275
+
276
+ def main(argv: list[str] | None = None) -> int:
277
+ parser = argparse.ArgumentParser(description=__doc__)
278
+ parser.add_argument("--bundle", required=True, type=Path)
279
+ parser.add_argument("--core-kit", type=Path, required=True)
280
+ parser.add_argument("--targets", default="ts,py,go")
281
+ parser.add_argument("--keep", type=Path)
282
+ parser.add_argument("--allow-source-skips", action="store_true", help="explicitly accept named source omissions")
283
+ args = parser.parse_args(argv)
284
+ previous_core_kit = core_support.core_kit_path()
285
+ try:
286
+ core_support.validate_core_kit(args.core_kit)
287
+ core_support.configure_core_kit(args.core_kit)
288
+ bundle = load_bundle(args.bundle)
289
+ source_skips = list(bundle["source_skipped"])
290
+ for member in bundle.get("groups", []):
291
+ source_skips.extend(member["source_skipped"])
292
+ if source_skips and not args.allow_source_skips:
293
+ raise ValueError("source omissions require --allow-source-skips: " + "; ".join(source_skips))
294
+ work = args.keep or Path(tempfile.mkdtemp(prefix="arxo-codegen-consumer-"))
295
+ work.mkdir(parents=True, exist_ok=True)
296
+ summary, failures = run(bundle, [name for name in args.targets.split(",") if name], work)
297
+ except (ValueError, P.Refused, core_support.CoreKitError) as error:
298
+ print(f"arxo-codegen-consumer: {error}")
299
+ return 1
300
+ finally:
301
+ if not args.keep and 'work' in locals():
302
+ shutil.rmtree(work, ignore_errors=True)
303
+ # CLI invocations may share an interpreter with other verification
304
+ # modules. Selecting a temporary kit must not poison the next call.
305
+ core_support.configure_core_kit(previous_core_kit)
306
+ print("arxo-codegen-consumer: " + json.dumps(summary, ensure_ascii=False, sort_keys=True))
307
+ for failure in failures:
308
+ print(" " + failure)
309
+ if not failures and summary["matched"] == 0:
310
+ print(" ничего не проверено: нет совпавших проверяемых ожиданий")
311
+ return 1
312
+ return 1 if failures else 0
313
+
314
+
315
+ if __name__ == "__main__":
316
+ raise SystemExit(main())