yuho 5.0.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.
- yuho/__init__.py +16 -0
- yuho/ast/__init__.py +196 -0
- yuho/ast/builder.py +926 -0
- yuho/ast/constant_folder.py +280 -0
- yuho/ast/dead_code.py +199 -0
- yuho/ast/exhaustiveness.py +503 -0
- yuho/ast/nodes.py +907 -0
- yuho/ast/overlap.py +291 -0
- yuho/ast/reachability.py +293 -0
- yuho/ast/scope_analysis.py +490 -0
- yuho/ast/transformer.py +490 -0
- yuho/ast/type_check.py +471 -0
- yuho/ast/type_inference.py +425 -0
- yuho/ast/visitor.py +239 -0
- yuho/cli/__init__.py +14 -0
- yuho/cli/commands/__init__.py +1 -0
- yuho/cli/commands/api.py +431 -0
- yuho/cli/commands/ast_viz.py +334 -0
- yuho/cli/commands/check.py +218 -0
- yuho/cli/commands/config.py +311 -0
- yuho/cli/commands/contribute.py +122 -0
- yuho/cli/commands/diff.py +487 -0
- yuho/cli/commands/explain.py +240 -0
- yuho/cli/commands/fmt.py +253 -0
- yuho/cli/commands/generate.py +316 -0
- yuho/cli/commands/graph.py +410 -0
- yuho/cli/commands/init.py +120 -0
- yuho/cli/commands/library.py +656 -0
- yuho/cli/commands/lint.py +503 -0
- yuho/cli/commands/lsp.py +36 -0
- yuho/cli/commands/preview.py +377 -0
- yuho/cli/commands/repl.py +444 -0
- yuho/cli/commands/serve.py +44 -0
- yuho/cli/commands/test.py +528 -0
- yuho/cli/commands/transpile.py +121 -0
- yuho/cli/commands/wizard.py +370 -0
- yuho/cli/completions.py +182 -0
- yuho/cli/error_formatter.py +193 -0
- yuho/cli/main.py +1064 -0
- yuho/config/__init__.py +46 -0
- yuho/config/loader.py +235 -0
- yuho/config/mask.py +194 -0
- yuho/config/schema.py +147 -0
- yuho/library/__init__.py +84 -0
- yuho/library/index.py +328 -0
- yuho/library/install.py +699 -0
- yuho/library/lockfile.py +330 -0
- yuho/library/package.py +421 -0
- yuho/library/resolver.py +791 -0
- yuho/library/signature.py +335 -0
- yuho/llm/__init__.py +45 -0
- yuho/llm/config.py +75 -0
- yuho/llm/factory.py +123 -0
- yuho/llm/prompts.py +146 -0
- yuho/llm/providers.py +383 -0
- yuho/llm/utils.py +470 -0
- yuho/lsp/__init__.py +14 -0
- yuho/lsp/code_action_handler.py +518 -0
- yuho/lsp/completion_handler.py +85 -0
- yuho/lsp/diagnostics.py +100 -0
- yuho/lsp/hover_handler.py +130 -0
- yuho/lsp/server.py +1425 -0
- yuho/mcp/__init__.py +10 -0
- yuho/mcp/server.py +1452 -0
- yuho/parser/__init__.py +8 -0
- yuho/parser/source_location.py +108 -0
- yuho/parser/wrapper.py +311 -0
- yuho/testing/__init__.py +48 -0
- yuho/testing/coverage.py +274 -0
- yuho/testing/fixtures.py +263 -0
- yuho/transpile/__init__.py +52 -0
- yuho/transpile/alloy_transpiler.py +546 -0
- yuho/transpile/base.py +100 -0
- yuho/transpile/blocks_transpiler.py +338 -0
- yuho/transpile/english_transpiler.py +470 -0
- yuho/transpile/graphql_transpiler.py +404 -0
- yuho/transpile/json_transpiler.py +217 -0
- yuho/transpile/jsonld_transpiler.py +250 -0
- yuho/transpile/latex_preamble.py +161 -0
- yuho/transpile/latex_transpiler.py +406 -0
- yuho/transpile/latex_utils.py +206 -0
- yuho/transpile/mermaid_transpiler.py +357 -0
- yuho/transpile/registry.py +275 -0
- yuho/verify/__init__.py +43 -0
- yuho/verify/alloy.py +352 -0
- yuho/verify/combined.py +218 -0
- yuho/verify/z3_solver.py +1155 -0
- yuho-5.0.0.dist-info/METADATA +186 -0
- yuho-5.0.0.dist-info/RECORD +91 -0
- yuho-5.0.0.dist-info/WHEEL +4 -0
- yuho-5.0.0.dist-info/entry_points.txt +2 -0
yuho/testing/coverage.py
ADDED
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Coverage tracking for Yuho statute test implementations.
|
|
3
|
+
|
|
4
|
+
Tracks which elements, conditions, and branches are exercised by tests.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from typing import Dict, List, Set, Optional, Any
|
|
8
|
+
from dataclasses import dataclass, field
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
import json
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass
|
|
14
|
+
class ElementCoverage:
|
|
15
|
+
"""Coverage tracking for a single statute element."""
|
|
16
|
+
element_type: str # actus_reus, mens_rea, circumstance
|
|
17
|
+
name: str
|
|
18
|
+
covered: bool = False
|
|
19
|
+
test_count: int = 0
|
|
20
|
+
test_files: List[str] = field(default_factory=list)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass
|
|
24
|
+
class StatuteCoverage:
|
|
25
|
+
"""Coverage tracking for a single statute."""
|
|
26
|
+
section_number: str
|
|
27
|
+
title: str = ""
|
|
28
|
+
elements: Dict[str, ElementCoverage] = field(default_factory=dict)
|
|
29
|
+
penalty_covered: bool = False
|
|
30
|
+
illustrations_covered: Set[str] = field(default_factory=set)
|
|
31
|
+
total_illustrations: int = 0
|
|
32
|
+
|
|
33
|
+
@property
|
|
34
|
+
def element_coverage_percent(self) -> float:
|
|
35
|
+
"""Calculate percentage of elements covered."""
|
|
36
|
+
if not self.elements:
|
|
37
|
+
return 0.0
|
|
38
|
+
covered = sum(1 for e in self.elements.values() if e.covered)
|
|
39
|
+
return (covered / len(self.elements)) * 100
|
|
40
|
+
|
|
41
|
+
@property
|
|
42
|
+
def overall_coverage_percent(self) -> float:
|
|
43
|
+
"""Calculate overall coverage percentage."""
|
|
44
|
+
total_items = len(self.elements) + 1 # +1 for penalty
|
|
45
|
+
if self.total_illustrations > 0:
|
|
46
|
+
total_items += self.total_illustrations
|
|
47
|
+
|
|
48
|
+
covered_items = sum(1 for e in self.elements.values() if e.covered)
|
|
49
|
+
if self.penalty_covered:
|
|
50
|
+
covered_items += 1
|
|
51
|
+
covered_items += len(self.illustrations_covered)
|
|
52
|
+
|
|
53
|
+
return (covered_items / total_items) * 100 if total_items > 0 else 0.0
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass
|
|
57
|
+
class CoverageReport:
|
|
58
|
+
"""Complete coverage report for a test run."""
|
|
59
|
+
statutes: Dict[str, StatuteCoverage] = field(default_factory=dict)
|
|
60
|
+
total_tests: int = 0
|
|
61
|
+
passed_tests: int = 0
|
|
62
|
+
failed_tests: int = 0
|
|
63
|
+
|
|
64
|
+
@property
|
|
65
|
+
def overall_coverage_percent(self) -> float:
|
|
66
|
+
"""Calculate overall coverage across all statutes."""
|
|
67
|
+
if not self.statutes:
|
|
68
|
+
return 0.0
|
|
69
|
+
total = sum(s.overall_coverage_percent for s in self.statutes.values())
|
|
70
|
+
return total / len(self.statutes)
|
|
71
|
+
|
|
72
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
73
|
+
"""Convert to dictionary for JSON serialization."""
|
|
74
|
+
return {
|
|
75
|
+
"summary": {
|
|
76
|
+
"total_statutes": len(self.statutes),
|
|
77
|
+
"overall_coverage": f"{self.overall_coverage_percent:.1f}%",
|
|
78
|
+
"total_tests": self.total_tests,
|
|
79
|
+
"passed_tests": self.passed_tests,
|
|
80
|
+
"failed_tests": self.failed_tests,
|
|
81
|
+
},
|
|
82
|
+
"statutes": {
|
|
83
|
+
section: {
|
|
84
|
+
"title": cov.title,
|
|
85
|
+
"element_coverage": f"{cov.element_coverage_percent:.1f}%",
|
|
86
|
+
"overall_coverage": f"{cov.overall_coverage_percent:.1f}%",
|
|
87
|
+
"elements": {
|
|
88
|
+
name: {
|
|
89
|
+
"type": elem.element_type,
|
|
90
|
+
"covered": elem.covered,
|
|
91
|
+
"test_count": elem.test_count,
|
|
92
|
+
}
|
|
93
|
+
for name, elem in cov.elements.items()
|
|
94
|
+
},
|
|
95
|
+
"penalty_covered": cov.penalty_covered,
|
|
96
|
+
"illustrations_covered": len(cov.illustrations_covered),
|
|
97
|
+
"total_illustrations": cov.total_illustrations,
|
|
98
|
+
}
|
|
99
|
+
for section, cov in self.statutes.items()
|
|
100
|
+
},
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
def to_json(self, indent: int = 2) -> str:
|
|
104
|
+
"""Convert to JSON string."""
|
|
105
|
+
return json.dumps(self.to_dict(), indent=indent)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
class CoverageTracker:
|
|
109
|
+
"""
|
|
110
|
+
Tracks test coverage for Yuho statute implementations.
|
|
111
|
+
|
|
112
|
+
Usage:
|
|
113
|
+
tracker = CoverageTracker()
|
|
114
|
+
tracker.load_statutes_from_ast(ast)
|
|
115
|
+
tracker.mark_element_covered("378", "actus_reus", "taking", "test_theft.yh")
|
|
116
|
+
report = tracker.generate_report()
|
|
117
|
+
"""
|
|
118
|
+
|
|
119
|
+
def __init__(self):
|
|
120
|
+
self.report = CoverageReport()
|
|
121
|
+
|
|
122
|
+
def load_statutes_from_ast(self, ast) -> None:
|
|
123
|
+
"""Load statute structure from AST for coverage tracking."""
|
|
124
|
+
for statute in ast.statutes:
|
|
125
|
+
section = statute.section_number
|
|
126
|
+
title = statute.title.value if statute.title else ""
|
|
127
|
+
|
|
128
|
+
cov = StatuteCoverage(section_number=section, title=title)
|
|
129
|
+
|
|
130
|
+
# Track elements
|
|
131
|
+
for element in (statute.elements or []):
|
|
132
|
+
elem_key = f"{element.element_type}:{element.name}"
|
|
133
|
+
cov.elements[elem_key] = ElementCoverage(
|
|
134
|
+
element_type=element.element_type,
|
|
135
|
+
name=element.name,
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
# Track illustrations
|
|
139
|
+
if hasattr(statute, 'illustrations') and statute.illustrations:
|
|
140
|
+
cov.total_illustrations = len(statute.illustrations)
|
|
141
|
+
|
|
142
|
+
self.report.statutes[section] = cov
|
|
143
|
+
|
|
144
|
+
def mark_element_covered(
|
|
145
|
+
self,
|
|
146
|
+
section: str,
|
|
147
|
+
element_type: str,
|
|
148
|
+
element_name: str,
|
|
149
|
+
test_file: str,
|
|
150
|
+
) -> None:
|
|
151
|
+
"""Mark an element as covered by a test."""
|
|
152
|
+
if section not in self.report.statutes:
|
|
153
|
+
return
|
|
154
|
+
|
|
155
|
+
elem_key = f"{element_type}:{element_name}"
|
|
156
|
+
cov = self.report.statutes[section]
|
|
157
|
+
|
|
158
|
+
if elem_key in cov.elements:
|
|
159
|
+
cov.elements[elem_key].covered = True
|
|
160
|
+
cov.elements[elem_key].test_count += 1
|
|
161
|
+
if test_file not in cov.elements[elem_key].test_files:
|
|
162
|
+
cov.elements[elem_key].test_files.append(test_file)
|
|
163
|
+
|
|
164
|
+
def mark_penalty_covered(self, section: str) -> None:
|
|
165
|
+
"""Mark penalty section as covered."""
|
|
166
|
+
if section in self.report.statutes:
|
|
167
|
+
self.report.statutes[section].penalty_covered = True
|
|
168
|
+
|
|
169
|
+
def mark_illustration_covered(self, section: str, illustration_label: str) -> None:
|
|
170
|
+
"""Mark an illustration as covered."""
|
|
171
|
+
if section in self.report.statutes:
|
|
172
|
+
self.report.statutes[section].illustrations_covered.add(illustration_label)
|
|
173
|
+
|
|
174
|
+
def add_test_result(self, passed: bool) -> None:
|
|
175
|
+
"""Record a test result."""
|
|
176
|
+
self.report.total_tests += 1
|
|
177
|
+
if passed:
|
|
178
|
+
self.report.passed_tests += 1
|
|
179
|
+
else:
|
|
180
|
+
self.report.failed_tests += 1
|
|
181
|
+
|
|
182
|
+
def generate_report(self) -> CoverageReport:
|
|
183
|
+
"""Generate the coverage report."""
|
|
184
|
+
return self.report
|
|
185
|
+
|
|
186
|
+
def print_summary(self) -> None:
|
|
187
|
+
"""Print a human-readable coverage summary."""
|
|
188
|
+
print("\n" + "=" * 60)
|
|
189
|
+
print("STATUTE COVERAGE REPORT")
|
|
190
|
+
print("=" * 60)
|
|
191
|
+
|
|
192
|
+
for section, cov in self.report.statutes.items():
|
|
193
|
+
print(f"\n{section}: {cov.title}")
|
|
194
|
+
print(f" Element coverage: {cov.element_coverage_percent:.1f}%")
|
|
195
|
+
|
|
196
|
+
for elem_key, elem in cov.elements.items():
|
|
197
|
+
status = "COVERED" if elem.covered else "NOT COVERED"
|
|
198
|
+
print(f" [{status}] {elem.element_type}: {elem.name}")
|
|
199
|
+
|
|
200
|
+
penalty_status = "COVERED" if cov.penalty_covered else "NOT COVERED"
|
|
201
|
+
print(f" [{penalty_status}] penalty")
|
|
202
|
+
|
|
203
|
+
if cov.total_illustrations > 0:
|
|
204
|
+
ill_cov = len(cov.illustrations_covered)
|
|
205
|
+
print(f" Illustrations: {ill_cov}/{cov.total_illustrations}")
|
|
206
|
+
|
|
207
|
+
print("\n" + "-" * 60)
|
|
208
|
+
print(f"Overall coverage: {self.report.overall_coverage_percent:.1f}%")
|
|
209
|
+
print(f"Tests: {self.report.passed_tests}/{self.report.total_tests} passed")
|
|
210
|
+
print("=" * 60)
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def analyze_test_coverage(
|
|
214
|
+
statute_files: List[Path],
|
|
215
|
+
test_files: List[Path],
|
|
216
|
+
) -> CoverageReport:
|
|
217
|
+
"""
|
|
218
|
+
Analyze test coverage for a set of statute and test files.
|
|
219
|
+
|
|
220
|
+
Args:
|
|
221
|
+
statute_files: List of .yh statute files
|
|
222
|
+
test_files: List of test files
|
|
223
|
+
|
|
224
|
+
Returns:
|
|
225
|
+
CoverageReport with coverage analysis
|
|
226
|
+
"""
|
|
227
|
+
from yuho.parser import Parser
|
|
228
|
+
from yuho.ast import ASTBuilder
|
|
229
|
+
|
|
230
|
+
tracker = CoverageTracker()
|
|
231
|
+
parser = Parser()
|
|
232
|
+
|
|
233
|
+
# Load statute structure
|
|
234
|
+
for statute_file in statute_files:
|
|
235
|
+
try:
|
|
236
|
+
result = parser.parse_file(statute_file)
|
|
237
|
+
if result.is_valid:
|
|
238
|
+
builder = ASTBuilder(result.source, str(statute_file))
|
|
239
|
+
ast = builder.build(result.root_node)
|
|
240
|
+
tracker.load_statutes_from_ast(ast)
|
|
241
|
+
except Exception:
|
|
242
|
+
continue
|
|
243
|
+
|
|
244
|
+
# Analyze test files for coverage
|
|
245
|
+
for test_file in test_files:
|
|
246
|
+
try:
|
|
247
|
+
result = parser.parse_file(test_file)
|
|
248
|
+
if result.is_valid:
|
|
249
|
+
builder = ASTBuilder(result.source, str(test_file))
|
|
250
|
+
ast = builder.build(result.root_node)
|
|
251
|
+
|
|
252
|
+
# Check what elements are referenced in tests
|
|
253
|
+
for statute in ast.statutes:
|
|
254
|
+
section = statute.section_number
|
|
255
|
+
|
|
256
|
+
# Mark elements as covered if they appear in test assertions
|
|
257
|
+
for elem in (statute.elements or []):
|
|
258
|
+
tracker.mark_element_covered(
|
|
259
|
+
section,
|
|
260
|
+
elem.element_type,
|
|
261
|
+
elem.name,
|
|
262
|
+
str(test_file),
|
|
263
|
+
)
|
|
264
|
+
|
|
265
|
+
if statute.penalty:
|
|
266
|
+
tracker.mark_penalty_covered(section)
|
|
267
|
+
|
|
268
|
+
tracker.add_test_result(passed=True)
|
|
269
|
+
else:
|
|
270
|
+
tracker.add_test_result(passed=False)
|
|
271
|
+
except Exception:
|
|
272
|
+
tracker.add_test_result(passed=False)
|
|
273
|
+
|
|
274
|
+
return tracker.generate_report()
|
yuho/testing/fixtures.py
ADDED
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Pytest fixtures for testing Yuho statute implementations.
|
|
3
|
+
|
|
4
|
+
Provides ready-to-use fixtures for parsing, AST building, and validating
|
|
5
|
+
Yuho code in user tests.
|
|
6
|
+
|
|
7
|
+
Usage:
|
|
8
|
+
# In conftest.py
|
|
9
|
+
pytest_plugins = ["yuho.testing.fixtures"]
|
|
10
|
+
|
|
11
|
+
# In test files
|
|
12
|
+
def test_my_statute(yuho_parser, yuho_ast, parse_statute):
|
|
13
|
+
ast = parse_statute("statute 299 {...}")
|
|
14
|
+
assert len(ast.statutes) == 1
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from typing import Optional, List, Dict, Any, Callable
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from dataclasses import dataclass, field
|
|
20
|
+
import pytest
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@pytest.fixture
|
|
24
|
+
def yuho_parser():
|
|
25
|
+
"""
|
|
26
|
+
Fixture providing a Yuho parser instance.
|
|
27
|
+
|
|
28
|
+
Usage:
|
|
29
|
+
def test_parse(yuho_parser):
|
|
30
|
+
result = yuho_parser.parse("statute 299 {...}")
|
|
31
|
+
assert result.is_valid
|
|
32
|
+
"""
|
|
33
|
+
from yuho.parser import Parser
|
|
34
|
+
return Parser()
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@pytest.fixture
|
|
38
|
+
def yuho_ast(yuho_parser):
|
|
39
|
+
"""
|
|
40
|
+
Fixture providing an AST builder function.
|
|
41
|
+
|
|
42
|
+
Usage:
|
|
43
|
+
def test_ast(yuho_ast):
|
|
44
|
+
ast = yuho_ast("statute 299 {...}")
|
|
45
|
+
assert len(ast.statutes) == 1
|
|
46
|
+
"""
|
|
47
|
+
from yuho.ast import ASTBuilder
|
|
48
|
+
|
|
49
|
+
def build_ast(source: str):
|
|
50
|
+
result = yuho_parser.parse(source)
|
|
51
|
+
if not result.is_valid:
|
|
52
|
+
raise ValueError(f"Parse error: {result.errors[0].message}")
|
|
53
|
+
builder = ASTBuilder(source)
|
|
54
|
+
return builder.build(result.root_node)
|
|
55
|
+
|
|
56
|
+
return build_ast
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@pytest.fixture
|
|
60
|
+
def parse_statute(yuho_ast):
|
|
61
|
+
"""
|
|
62
|
+
Fixture providing a function to parse statute code and return the first statute.
|
|
63
|
+
|
|
64
|
+
Usage:
|
|
65
|
+
def test_theft(parse_statute):
|
|
66
|
+
statute = parse_statute('''
|
|
67
|
+
statute 378 "Theft" {
|
|
68
|
+
elements {
|
|
69
|
+
actus_reus taking: "takes property"
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
''')
|
|
73
|
+
assert statute.section_number == "378"
|
|
74
|
+
"""
|
|
75
|
+
def _parse(source: str):
|
|
76
|
+
ast = yuho_ast(source)
|
|
77
|
+
if not ast.statutes:
|
|
78
|
+
raise ValueError("No statute found in source")
|
|
79
|
+
return ast.statutes[0]
|
|
80
|
+
|
|
81
|
+
return _parse
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@pytest.fixture
|
|
85
|
+
def parse_file(yuho_ast):
|
|
86
|
+
"""
|
|
87
|
+
Fixture providing a function to parse a .yh file.
|
|
88
|
+
|
|
89
|
+
Usage:
|
|
90
|
+
def test_file(parse_file):
|
|
91
|
+
ast = parse_file("path/to/statute.yh")
|
|
92
|
+
assert len(ast.statutes) >= 1
|
|
93
|
+
"""
|
|
94
|
+
def _parse_file(path: str):
|
|
95
|
+
file_path = Path(path)
|
|
96
|
+
if not file_path.exists():
|
|
97
|
+
raise FileNotFoundError(f"File not found: {path}")
|
|
98
|
+
source = file_path.read_text()
|
|
99
|
+
return yuho_ast(source)
|
|
100
|
+
|
|
101
|
+
return _parse_file
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
@pytest.fixture
|
|
105
|
+
def statute_validator():
|
|
106
|
+
"""
|
|
107
|
+
Fixture providing a statute validator for checking statute completeness.
|
|
108
|
+
|
|
109
|
+
Usage:
|
|
110
|
+
def test_valid(statute_validator, parse_statute):
|
|
111
|
+
statute = parse_statute("...")
|
|
112
|
+
result = statute_validator(statute)
|
|
113
|
+
assert result.valid
|
|
114
|
+
assert not result.errors
|
|
115
|
+
"""
|
|
116
|
+
@dataclass
|
|
117
|
+
class ValidationResult:
|
|
118
|
+
valid: bool
|
|
119
|
+
errors: List[str] = field(default_factory=list)
|
|
120
|
+
warnings: List[str] = field(default_factory=list)
|
|
121
|
+
|
|
122
|
+
def _validate(statute) -> ValidationResult:
|
|
123
|
+
errors = []
|
|
124
|
+
warnings = []
|
|
125
|
+
|
|
126
|
+
# Check required fields
|
|
127
|
+
if not statute.section_number:
|
|
128
|
+
errors.append("Missing section number")
|
|
129
|
+
|
|
130
|
+
if not statute.title:
|
|
131
|
+
warnings.append("Missing title")
|
|
132
|
+
|
|
133
|
+
if not statute.elements:
|
|
134
|
+
errors.append("No elements defined")
|
|
135
|
+
else:
|
|
136
|
+
# Check for at least one actus_reus
|
|
137
|
+
has_actus = any(
|
|
138
|
+
e.element_type == "actus_reus"
|
|
139
|
+
for e in statute.elements
|
|
140
|
+
)
|
|
141
|
+
if not has_actus:
|
|
142
|
+
warnings.append("No actus_reus element defined")
|
|
143
|
+
|
|
144
|
+
# Check for mens_rea
|
|
145
|
+
has_mens = any(
|
|
146
|
+
e.element_type == "mens_rea"
|
|
147
|
+
for e in statute.elements
|
|
148
|
+
)
|
|
149
|
+
if not has_mens:
|
|
150
|
+
warnings.append("No mens_rea element defined")
|
|
151
|
+
|
|
152
|
+
if not statute.penalty:
|
|
153
|
+
warnings.append("No penalty section defined")
|
|
154
|
+
|
|
155
|
+
return ValidationResult(
|
|
156
|
+
valid=len(errors) == 0,
|
|
157
|
+
errors=errors,
|
|
158
|
+
warnings=warnings,
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
return _validate
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
@dataclass
|
|
165
|
+
class StatuteTestCase:
|
|
166
|
+
"""
|
|
167
|
+
Helper class for defining statute test cases.
|
|
168
|
+
|
|
169
|
+
Usage:
|
|
170
|
+
cases = [
|
|
171
|
+
StatuteTestCase(
|
|
172
|
+
name="theft_basic",
|
|
173
|
+
source='''statute 378 "Theft" {...}''',
|
|
174
|
+
expected_elements=["actus_reus:taking"],
|
|
175
|
+
expected_penalty_max=7, # years
|
|
176
|
+
),
|
|
177
|
+
]
|
|
178
|
+
|
|
179
|
+
@pytest.mark.parametrize("case", cases, ids=lambda c: c.name)
|
|
180
|
+
def test_statute(case, parse_statute):
|
|
181
|
+
statute = parse_statute(case.source)
|
|
182
|
+
case.verify(statute)
|
|
183
|
+
"""
|
|
184
|
+
name: str
|
|
185
|
+
source: str
|
|
186
|
+
expected_section: Optional[str] = None
|
|
187
|
+
expected_title: Optional[str] = None
|
|
188
|
+
expected_elements: List[str] = field(default_factory=list)
|
|
189
|
+
expected_penalty_max: Optional[int] = None # years
|
|
190
|
+
expected_fine_max: Optional[float] = None
|
|
191
|
+
|
|
192
|
+
def verify(self, statute) -> None:
|
|
193
|
+
"""Verify statute against expected values. Raises AssertionError on mismatch."""
|
|
194
|
+
if self.expected_section:
|
|
195
|
+
assert statute.section_number == self.expected_section, \
|
|
196
|
+
f"Section mismatch: expected {self.expected_section}, got {statute.section_number}"
|
|
197
|
+
|
|
198
|
+
if self.expected_title:
|
|
199
|
+
title = statute.title.value if statute.title else None
|
|
200
|
+
assert title == self.expected_title, \
|
|
201
|
+
f"Title mismatch: expected {self.expected_title}, got {title}"
|
|
202
|
+
|
|
203
|
+
if self.expected_elements:
|
|
204
|
+
actual_elements = [
|
|
205
|
+
f"{e.element_type}:{e.name}"
|
|
206
|
+
for e in (statute.elements or [])
|
|
207
|
+
]
|
|
208
|
+
for expected in self.expected_elements:
|
|
209
|
+
assert expected in actual_elements, \
|
|
210
|
+
f"Missing element: {expected}. Found: {actual_elements}"
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def element_check(statute, element_type: str, name: str) -> bool:
|
|
214
|
+
"""
|
|
215
|
+
Check if a statute has a specific element.
|
|
216
|
+
|
|
217
|
+
Usage:
|
|
218
|
+
def test_has_actus_reus(parse_statute):
|
|
219
|
+
statute = parse_statute("...")
|
|
220
|
+
assert element_check(statute, "actus_reus", "taking")
|
|
221
|
+
"""
|
|
222
|
+
if not statute.elements:
|
|
223
|
+
return False
|
|
224
|
+
return any(
|
|
225
|
+
e.element_type == element_type and e.name == name
|
|
226
|
+
for e in statute.elements
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def penalty_check(statute, penalty_type: str, max_value: Any = None) -> bool:
|
|
231
|
+
"""
|
|
232
|
+
Check if a statute has a specific penalty type.
|
|
233
|
+
|
|
234
|
+
Usage:
|
|
235
|
+
def test_has_imprisonment(parse_statute):
|
|
236
|
+
statute = parse_statute("...")
|
|
237
|
+
assert penalty_check(statute, "imprisonment")
|
|
238
|
+
"""
|
|
239
|
+
if not statute.penalty:
|
|
240
|
+
return False
|
|
241
|
+
|
|
242
|
+
# Check penalty structure
|
|
243
|
+
penalty = statute.penalty
|
|
244
|
+
if hasattr(penalty, penalty_type):
|
|
245
|
+
penalty_val = getattr(penalty, penalty_type)
|
|
246
|
+
if penalty_val is None:
|
|
247
|
+
return False
|
|
248
|
+
if max_value is not None and hasattr(penalty_val, 'max'):
|
|
249
|
+
return penalty_val.max is not None
|
|
250
|
+
return True
|
|
251
|
+
|
|
252
|
+
return False
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
# Register as pytest plugin
|
|
256
|
+
def pytest_configure(config):
|
|
257
|
+
"""Register Yuho markers."""
|
|
258
|
+
config.addinivalue_line(
|
|
259
|
+
"markers", "statute(section): mark test as testing a specific statute section"
|
|
260
|
+
)
|
|
261
|
+
config.addinivalue_line(
|
|
262
|
+
"markers", "slow: mark test as slow running"
|
|
263
|
+
)
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Yuho transpilation module - multi-target code generation.
|
|
3
|
+
|
|
4
|
+
Supports transpilation to:
|
|
5
|
+
- JSON: Structured AST representation
|
|
6
|
+
- JSON-LD: Linked data with legal ontology
|
|
7
|
+
- English: Controlled natural language
|
|
8
|
+
- LaTeX: Legal document formatting
|
|
9
|
+
- Mermaid: Decision tree flowcharts
|
|
10
|
+
- Alloy: Formal verification models
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from yuho.transpile.base import TranspileTarget, TranspilerBase
|
|
14
|
+
from yuho.transpile.json_transpiler import JSONTranspiler
|
|
15
|
+
from yuho.transpile.jsonld_transpiler import JSONLDTranspiler
|
|
16
|
+
from yuho.transpile.english_transpiler import EnglishTranspiler
|
|
17
|
+
from yuho.transpile.mermaid_transpiler import MermaidTranspiler
|
|
18
|
+
from yuho.transpile.alloy_transpiler import AlloyTranspiler
|
|
19
|
+
from yuho.transpile.latex_transpiler import LaTeXTranspiler, compile_to_pdf
|
|
20
|
+
from yuho.transpile.registry import TranspilerRegistry
|
|
21
|
+
|
|
22
|
+
__all__ = [
|
|
23
|
+
"TranspileTarget",
|
|
24
|
+
"TranspilerBase",
|
|
25
|
+
"TranspilerRegistry",
|
|
26
|
+
"JSONTranspiler",
|
|
27
|
+
"JSONLDTranspiler",
|
|
28
|
+
"EnglishTranspiler",
|
|
29
|
+
"LaTeXTranspiler",
|
|
30
|
+
"compile_to_pdf",
|
|
31
|
+
"MermaidTranspiler",
|
|
32
|
+
"AlloyTranspiler",
|
|
33
|
+
"get_transpiler",
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def get_transpiler(target: TranspileTarget) -> TranspilerBase:
|
|
38
|
+
"""
|
|
39
|
+
Get a transpiler instance for the given target.
|
|
40
|
+
|
|
41
|
+
This is a convenience function that uses the TranspilerRegistry singleton.
|
|
42
|
+
|
|
43
|
+
Args:
|
|
44
|
+
target: The transpilation target
|
|
45
|
+
|
|
46
|
+
Returns:
|
|
47
|
+
A transpiler instance for the target
|
|
48
|
+
|
|
49
|
+
Raises:
|
|
50
|
+
KeyError: If no transpiler is registered for the target
|
|
51
|
+
"""
|
|
52
|
+
return TranspilerRegistry.instance().get(target)
|