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
|
@@ -0,0 +1,528 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Test command - run tests for Yuho statute files.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import sys
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Optional, List
|
|
9
|
+
from datetime import datetime
|
|
10
|
+
|
|
11
|
+
import click
|
|
12
|
+
|
|
13
|
+
from yuho.parser import Parser
|
|
14
|
+
from yuho.ast import ASTBuilder
|
|
15
|
+
from yuho.cli.error_formatter import Colors, colorize
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def run_test(
|
|
19
|
+
file: Optional[str] = None,
|
|
20
|
+
run_all: bool = False,
|
|
21
|
+
json_output: bool = False,
|
|
22
|
+
verbose: bool = False,
|
|
23
|
+
coverage: bool = False,
|
|
24
|
+
coverage_html: Optional[str] = None,
|
|
25
|
+
) -> None:
|
|
26
|
+
"""
|
|
27
|
+
Run tests for Yuho statute files.
|
|
28
|
+
|
|
29
|
+
Args:
|
|
30
|
+
file: Path to .yh file (looks for associated test file)
|
|
31
|
+
run_all: Run all tests in current directory
|
|
32
|
+
json_output: Output results as JSON
|
|
33
|
+
verbose: Enable verbose output
|
|
34
|
+
coverage: Enable coverage tracking
|
|
35
|
+
coverage_html: Path to write HTML coverage report
|
|
36
|
+
"""
|
|
37
|
+
test_files: List[Path] = []
|
|
38
|
+
statute_files: List[Path] = []
|
|
39
|
+
|
|
40
|
+
if run_all:
|
|
41
|
+
# Find all test files
|
|
42
|
+
cwd = Path.cwd()
|
|
43
|
+
test_files.extend(cwd.glob("test_*.yh"))
|
|
44
|
+
test_files.extend(cwd.glob("tests/*_test.yh"))
|
|
45
|
+
test_files.extend(cwd.glob("**/test_*.yh"))
|
|
46
|
+
# Find statute files for coverage
|
|
47
|
+
if coverage:
|
|
48
|
+
statute_files.extend(f for f in cwd.glob("**/*.yh") if "test" not in f.name.lower())
|
|
49
|
+
elif file:
|
|
50
|
+
file_path = Path(file)
|
|
51
|
+
if coverage:
|
|
52
|
+
statute_files.append(file_path)
|
|
53
|
+
# Look for associated test file
|
|
54
|
+
candidates = [
|
|
55
|
+
file_path.parent / f"test_{file_path.name}",
|
|
56
|
+
file_path.parent / "tests" / f"{file_path.stem}_test.yh",
|
|
57
|
+
file_path.parent / "tests" / f"test_{file_path.name}",
|
|
58
|
+
]
|
|
59
|
+
for candidate in candidates:
|
|
60
|
+
if candidate.exists():
|
|
61
|
+
test_files.append(candidate)
|
|
62
|
+
break
|
|
63
|
+
|
|
64
|
+
if not test_files:
|
|
65
|
+
click.echo(colorize(f"No test file found for {file}", Colors.YELLOW))
|
|
66
|
+
click.echo("Expected:")
|
|
67
|
+
for c in candidates:
|
|
68
|
+
click.echo(f" - {c}")
|
|
69
|
+
sys.exit(1)
|
|
70
|
+
else:
|
|
71
|
+
click.echo(colorize("error: Specify a file or use --all", Colors.RED), err=True)
|
|
72
|
+
sys.exit(1)
|
|
73
|
+
|
|
74
|
+
if not test_files:
|
|
75
|
+
click.echo(colorize("No test files found", Colors.YELLOW))
|
|
76
|
+
sys.exit(0)
|
|
77
|
+
|
|
78
|
+
# Initialize coverage tracker if needed
|
|
79
|
+
coverage_tracker = None
|
|
80
|
+
if coverage:
|
|
81
|
+
from yuho.testing.coverage import CoverageTracker
|
|
82
|
+
coverage_tracker = CoverageTracker()
|
|
83
|
+
|
|
84
|
+
# Load statutes for coverage tracking
|
|
85
|
+
for statute_file in statute_files:
|
|
86
|
+
try:
|
|
87
|
+
parser = Parser()
|
|
88
|
+
result = parser.parse_file(statute_file)
|
|
89
|
+
if result.is_valid:
|
|
90
|
+
builder = ASTBuilder(result.source, str(statute_file))
|
|
91
|
+
ast = builder.build(result.root_node)
|
|
92
|
+
coverage_tracker.load_statutes_from_ast(ast)
|
|
93
|
+
except Exception:
|
|
94
|
+
continue
|
|
95
|
+
|
|
96
|
+
# Run tests
|
|
97
|
+
results = []
|
|
98
|
+
passed = 0
|
|
99
|
+
failed = 0
|
|
100
|
+
|
|
101
|
+
for test_file in test_files:
|
|
102
|
+
if verbose:
|
|
103
|
+
click.echo(f"Running {test_file}...")
|
|
104
|
+
|
|
105
|
+
result = _run_test_file(test_file, verbose, coverage_tracker)
|
|
106
|
+
results.append(result)
|
|
107
|
+
|
|
108
|
+
if result["passed"]:
|
|
109
|
+
passed += 1
|
|
110
|
+
if not json_output:
|
|
111
|
+
click.echo(colorize(f" PASS: {test_file.name}", Colors.CYAN))
|
|
112
|
+
else:
|
|
113
|
+
failed += 1
|
|
114
|
+
if not json_output:
|
|
115
|
+
click.echo(colorize(f" FAIL: {test_file.name}", Colors.RED))
|
|
116
|
+
for err in result.get("errors", []):
|
|
117
|
+
click.echo(f" - {err}")
|
|
118
|
+
|
|
119
|
+
# Generate coverage report
|
|
120
|
+
if coverage and coverage_tracker:
|
|
121
|
+
report = coverage_tracker.generate_report()
|
|
122
|
+
|
|
123
|
+
if coverage_html:
|
|
124
|
+
html_path = Path(coverage_html)
|
|
125
|
+
_generate_html_coverage_report(report, html_path)
|
|
126
|
+
if not json_output:
|
|
127
|
+
click.echo(f"\nCoverage report written to: {html_path}")
|
|
128
|
+
elif not json_output:
|
|
129
|
+
coverage_tracker.print_summary()
|
|
130
|
+
|
|
131
|
+
# Summary
|
|
132
|
+
if json_output:
|
|
133
|
+
output_data = {
|
|
134
|
+
"total": len(test_files),
|
|
135
|
+
"passed": passed,
|
|
136
|
+
"failed": failed,
|
|
137
|
+
"results": results,
|
|
138
|
+
}
|
|
139
|
+
if coverage and coverage_tracker:
|
|
140
|
+
output_data["coverage"] = coverage_tracker.generate_report().to_dict()
|
|
141
|
+
print(json.dumps(output_data, indent=2))
|
|
142
|
+
else:
|
|
143
|
+
click.echo()
|
|
144
|
+
if failed == 0:
|
|
145
|
+
click.echo(colorize(f"All {passed} tests passed", Colors.CYAN + Colors.BOLD))
|
|
146
|
+
else:
|
|
147
|
+
click.echo(colorize(f"{passed} passed, {failed} failed", Colors.RED + Colors.BOLD))
|
|
148
|
+
sys.exit(1)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _run_test_file(test_file: Path, verbose: bool, coverage_tracker=None) -> dict:
|
|
152
|
+
"""Run a single test file and return results."""
|
|
153
|
+
result = {
|
|
154
|
+
"file": str(test_file),
|
|
155
|
+
"passed": False,
|
|
156
|
+
"errors": [],
|
|
157
|
+
"assertions": {"passed": 0, "failed": 0},
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
# Parse test file
|
|
161
|
+
parser = Parser()
|
|
162
|
+
try:
|
|
163
|
+
parse_result = parser.parse_file(test_file)
|
|
164
|
+
except Exception as e:
|
|
165
|
+
result["errors"].append(f"Parse error: {e}")
|
|
166
|
+
if coverage_tracker:
|
|
167
|
+
coverage_tracker.add_test_result(passed=False)
|
|
168
|
+
return result
|
|
169
|
+
|
|
170
|
+
if parse_result.errors:
|
|
171
|
+
result["errors"].extend(f"{e.location}: {e.message}" for e in parse_result.errors)
|
|
172
|
+
if coverage_tracker:
|
|
173
|
+
coverage_tracker.add_test_result(passed=False)
|
|
174
|
+
return result
|
|
175
|
+
|
|
176
|
+
# Build AST
|
|
177
|
+
try:
|
|
178
|
+
builder = ASTBuilder(parse_result.source, str(test_file))
|
|
179
|
+
ast = builder.build(parse_result.root_node)
|
|
180
|
+
except Exception as e:
|
|
181
|
+
result["errors"].append(f"AST error: {e}")
|
|
182
|
+
if coverage_tracker:
|
|
183
|
+
coverage_tracker.add_test_result(passed=False)
|
|
184
|
+
return result
|
|
185
|
+
|
|
186
|
+
# Evaluate assertions if present
|
|
187
|
+
if hasattr(ast, 'assertions') and ast.assertions:
|
|
188
|
+
env = _build_test_environment(ast)
|
|
189
|
+
for assertion in ast.assertions:
|
|
190
|
+
try:
|
|
191
|
+
passed, error_msg = _evaluate_assertion(assertion, env, verbose)
|
|
192
|
+
if passed:
|
|
193
|
+
result["assertions"]["passed"] += 1
|
|
194
|
+
else:
|
|
195
|
+
result["assertions"]["failed"] += 1
|
|
196
|
+
loc = assertion.source_location
|
|
197
|
+
loc_str = f"{loc.line}:{loc.col}" if loc else "?"
|
|
198
|
+
result["errors"].append(f"Assertion failed at {loc_str}: {error_msg}")
|
|
199
|
+
except Exception as e:
|
|
200
|
+
result["assertions"]["failed"] += 1
|
|
201
|
+
result["errors"].append(f"Assertion evaluation error: {e}")
|
|
202
|
+
|
|
203
|
+
# Track coverage if enabled
|
|
204
|
+
if coverage_tracker:
|
|
205
|
+
for statute in ast.statutes:
|
|
206
|
+
section = statute.section_number
|
|
207
|
+
|
|
208
|
+
# Mark elements as covered
|
|
209
|
+
for elem in (statute.elements or []):
|
|
210
|
+
coverage_tracker.mark_element_covered(
|
|
211
|
+
section,
|
|
212
|
+
elem.element_type,
|
|
213
|
+
elem.name,
|
|
214
|
+
str(test_file),
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
# Mark penalty as covered if present
|
|
218
|
+
if statute.penalty:
|
|
219
|
+
coverage_tracker.mark_penalty_covered(section)
|
|
220
|
+
|
|
221
|
+
# Mark illustrations as covered
|
|
222
|
+
if hasattr(statute, 'illustrations') and statute.illustrations:
|
|
223
|
+
for ill in statute.illustrations:
|
|
224
|
+
if hasattr(ill, 'label') and ill.label:
|
|
225
|
+
coverage_tracker.mark_illustration_covered(section, ill.label)
|
|
226
|
+
|
|
227
|
+
coverage_tracker.add_test_result(passed=result["assertions"]["failed"] == 0)
|
|
228
|
+
|
|
229
|
+
# Test passes if no assertion failures and no parse errors
|
|
230
|
+
result["passed"] = result["assertions"]["failed"] == 0 and len(result["errors"]) == 0
|
|
231
|
+
result["stats"] = {
|
|
232
|
+
"statutes": len(ast.statutes),
|
|
233
|
+
"functions": len(ast.function_defs),
|
|
234
|
+
"assertions_total": result["assertions"]["passed"] + result["assertions"]["failed"],
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
return result
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def _build_test_environment(ast) -> dict:
|
|
241
|
+
"""Build an environment mapping variable names to their values."""
|
|
242
|
+
from yuho.ast import nodes
|
|
243
|
+
|
|
244
|
+
env = {}
|
|
245
|
+
|
|
246
|
+
# Add struct types to environment
|
|
247
|
+
for struct_def in ast.type_defs:
|
|
248
|
+
env[struct_def.name] = {"_type": "struct_def", "fields": {f.name: f for f in struct_def.fields}}
|
|
249
|
+
|
|
250
|
+
# Add variables to environment
|
|
251
|
+
for var in ast.variables:
|
|
252
|
+
if var.value:
|
|
253
|
+
env[var.name] = _evaluate_expr(var.value, env)
|
|
254
|
+
|
|
255
|
+
return env
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def _evaluate_expr(expr, env: dict):
|
|
259
|
+
"""Evaluate an expression in the given environment."""
|
|
260
|
+
from yuho.ast import nodes
|
|
261
|
+
|
|
262
|
+
if isinstance(expr, nodes.BoolLit):
|
|
263
|
+
return expr.value
|
|
264
|
+
elif isinstance(expr, nodes.IntLit):
|
|
265
|
+
return expr.value
|
|
266
|
+
elif isinstance(expr, nodes.FloatLit):
|
|
267
|
+
return expr.value
|
|
268
|
+
elif isinstance(expr, nodes.StringLit):
|
|
269
|
+
return expr.value
|
|
270
|
+
elif isinstance(expr, nodes.IdentifierNode):
|
|
271
|
+
return env.get(expr.name, f"<unbound:{expr.name}>")
|
|
272
|
+
elif isinstance(expr, nodes.FieldAccessNode):
|
|
273
|
+
base = _evaluate_expr(expr.base, env)
|
|
274
|
+
if isinstance(base, dict):
|
|
275
|
+
return base.get(expr.field_name, f"<no field:{expr.field_name}>")
|
|
276
|
+
# Handle enum-style access like Party.Accused
|
|
277
|
+
if isinstance(base, str) and base.startswith("<unbound:"):
|
|
278
|
+
# This is an enum reference like ConsequenceDefinition.Murder
|
|
279
|
+
type_name = base.replace("<unbound:", "").replace(">", "")
|
|
280
|
+
return f"{type_name}.{expr.field_name}"
|
|
281
|
+
return f"<field access error>"
|
|
282
|
+
elif isinstance(expr, nodes.StructLiteralNode):
|
|
283
|
+
result = {"_struct_name": expr.struct_name}
|
|
284
|
+
for fa in expr.field_values:
|
|
285
|
+
result[fa.name] = _evaluate_expr(fa.value, env)
|
|
286
|
+
return result
|
|
287
|
+
elif isinstance(expr, nodes.BinaryExprNode):
|
|
288
|
+
left = _evaluate_expr(expr.left, env)
|
|
289
|
+
right = _evaluate_expr(expr.right, env)
|
|
290
|
+
if expr.operator == "==":
|
|
291
|
+
return left == right
|
|
292
|
+
elif expr.operator == "!=":
|
|
293
|
+
return left != right
|
|
294
|
+
elif expr.operator == "&&":
|
|
295
|
+
return left and right
|
|
296
|
+
elif expr.operator == "||":
|
|
297
|
+
return left or right
|
|
298
|
+
return f"<binary:{expr.operator}>"
|
|
299
|
+
elif isinstance(expr, nodes.PassExprNode):
|
|
300
|
+
return None
|
|
301
|
+
else:
|
|
302
|
+
return f"<unevaluated:{type(expr).__name__}>"
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def _evaluate_assertion(assertion, env: dict, verbose: bool) -> tuple:
|
|
306
|
+
"""Evaluate an assertion and return (passed, error_message)."""
|
|
307
|
+
from yuho.ast import nodes
|
|
308
|
+
|
|
309
|
+
condition = assertion.condition
|
|
310
|
+
|
|
311
|
+
# Handle equality assertions like: assert var.field == ExpectedValue
|
|
312
|
+
if isinstance(condition, nodes.BinaryExprNode) and condition.operator == "==":
|
|
313
|
+
left = _evaluate_expr(condition.left, env)
|
|
314
|
+
right = _evaluate_expr(condition.right, env)
|
|
315
|
+
|
|
316
|
+
if left == right:
|
|
317
|
+
return (True, "")
|
|
318
|
+
else:
|
|
319
|
+
return (False, f"expected {right}, got {left}")
|
|
320
|
+
|
|
321
|
+
# Handle simple boolean assertions
|
|
322
|
+
result = _evaluate_expr(condition, env)
|
|
323
|
+
if result is True:
|
|
324
|
+
return (True, "")
|
|
325
|
+
elif result is False:
|
|
326
|
+
return (False, "assertion evaluated to FALSE")
|
|
327
|
+
else:
|
|
328
|
+
# Non-boolean result, check for truthiness
|
|
329
|
+
if result:
|
|
330
|
+
return (True, "")
|
|
331
|
+
return (False, f"assertion evaluated to: {result}")
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def _generate_html_coverage_report(report, output_path: Path) -> None:
|
|
335
|
+
"""Generate an HTML coverage report."""
|
|
336
|
+
report_dict = report.to_dict()
|
|
337
|
+
summary = report_dict["summary"]
|
|
338
|
+
statutes = report_dict["statutes"]
|
|
339
|
+
|
|
340
|
+
html = f"""<!DOCTYPE html>
|
|
341
|
+
<html lang="en">
|
|
342
|
+
<head>
|
|
343
|
+
<meta charset="UTF-8">
|
|
344
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
345
|
+
<title>Yuho Coverage Report</title>
|
|
346
|
+
<style>
|
|
347
|
+
body {{
|
|
348
|
+
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
|
349
|
+
margin: 0;
|
|
350
|
+
padding: 20px;
|
|
351
|
+
background: #f5f5f5;
|
|
352
|
+
}}
|
|
353
|
+
.container {{
|
|
354
|
+
max-width: 1200px;
|
|
355
|
+
margin: 0 auto;
|
|
356
|
+
}}
|
|
357
|
+
h1 {{
|
|
358
|
+
color: #333;
|
|
359
|
+
border-bottom: 2px solid #007bff;
|
|
360
|
+
padding-bottom: 10px;
|
|
361
|
+
}}
|
|
362
|
+
.summary {{
|
|
363
|
+
background: white;
|
|
364
|
+
padding: 20px;
|
|
365
|
+
border-radius: 8px;
|
|
366
|
+
margin-bottom: 20px;
|
|
367
|
+
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
|
368
|
+
}}
|
|
369
|
+
.summary-grid {{
|
|
370
|
+
display: grid;
|
|
371
|
+
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
|
372
|
+
gap: 15px;
|
|
373
|
+
}}
|
|
374
|
+
.metric {{
|
|
375
|
+
text-align: center;
|
|
376
|
+
padding: 15px;
|
|
377
|
+
background: #f8f9fa;
|
|
378
|
+
border-radius: 4px;
|
|
379
|
+
}}
|
|
380
|
+
.metric-value {{
|
|
381
|
+
font-size: 2em;
|
|
382
|
+
font-weight: bold;
|
|
383
|
+
color: #007bff;
|
|
384
|
+
}}
|
|
385
|
+
.metric-label {{
|
|
386
|
+
color: #666;
|
|
387
|
+
font-size: 0.9em;
|
|
388
|
+
}}
|
|
389
|
+
.statute {{
|
|
390
|
+
background: white;
|
|
391
|
+
padding: 20px;
|
|
392
|
+
border-radius: 8px;
|
|
393
|
+
margin-bottom: 15px;
|
|
394
|
+
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
|
395
|
+
}}
|
|
396
|
+
.statute-header {{
|
|
397
|
+
display: flex;
|
|
398
|
+
justify-content: space-between;
|
|
399
|
+
align-items: center;
|
|
400
|
+
margin-bottom: 15px;
|
|
401
|
+
}}
|
|
402
|
+
.statute-title {{
|
|
403
|
+
font-size: 1.2em;
|
|
404
|
+
font-weight: bold;
|
|
405
|
+
color: #333;
|
|
406
|
+
}}
|
|
407
|
+
.coverage-badge {{
|
|
408
|
+
padding: 5px 15px;
|
|
409
|
+
border-radius: 20px;
|
|
410
|
+
font-weight: bold;
|
|
411
|
+
color: white;
|
|
412
|
+
}}
|
|
413
|
+
.coverage-high {{ background: #28a745; }}
|
|
414
|
+
.coverage-medium {{ background: #ffc107; color: #333; }}
|
|
415
|
+
.coverage-low {{ background: #dc3545; }}
|
|
416
|
+
.elements-table {{
|
|
417
|
+
width: 100%;
|
|
418
|
+
border-collapse: collapse;
|
|
419
|
+
}}
|
|
420
|
+
.elements-table th, .elements-table td {{
|
|
421
|
+
padding: 10px;
|
|
422
|
+
text-align: left;
|
|
423
|
+
border-bottom: 1px solid #eee;
|
|
424
|
+
}}
|
|
425
|
+
.elements-table th {{
|
|
426
|
+
background: #f8f9fa;
|
|
427
|
+
font-weight: 600;
|
|
428
|
+
}}
|
|
429
|
+
.status-covered {{
|
|
430
|
+
color: #28a745;
|
|
431
|
+
font-weight: bold;
|
|
432
|
+
}}
|
|
433
|
+
.status-uncovered {{
|
|
434
|
+
color: #dc3545;
|
|
435
|
+
font-weight: bold;
|
|
436
|
+
}}
|
|
437
|
+
.generated {{
|
|
438
|
+
text-align: center;
|
|
439
|
+
color: #999;
|
|
440
|
+
font-size: 0.9em;
|
|
441
|
+
margin-top: 30px;
|
|
442
|
+
}}
|
|
443
|
+
</style>
|
|
444
|
+
</head>
|
|
445
|
+
<body>
|
|
446
|
+
<div class="container">
|
|
447
|
+
<h1>Yuho Coverage Report</h1>
|
|
448
|
+
|
|
449
|
+
<div class="summary">
|
|
450
|
+
<h2>Summary</h2>
|
|
451
|
+
<div class="summary-grid">
|
|
452
|
+
<div class="metric">
|
|
453
|
+
<div class="metric-value">{summary['overall_coverage']}</div>
|
|
454
|
+
<div class="metric-label">Overall Coverage</div>
|
|
455
|
+
</div>
|
|
456
|
+
<div class="metric">
|
|
457
|
+
<div class="metric-value">{summary['total_statutes']}</div>
|
|
458
|
+
<div class="metric-label">Statutes</div>
|
|
459
|
+
</div>
|
|
460
|
+
<div class="metric">
|
|
461
|
+
<div class="metric-value">{summary['passed_tests']}/{summary['total_tests']}</div>
|
|
462
|
+
<div class="metric-label">Tests Passed</div>
|
|
463
|
+
</div>
|
|
464
|
+
</div>
|
|
465
|
+
</div>
|
|
466
|
+
"""
|
|
467
|
+
|
|
468
|
+
for section, data in statutes.items():
|
|
469
|
+
coverage_pct = float(data['overall_coverage'].rstrip('%'))
|
|
470
|
+
if coverage_pct >= 80:
|
|
471
|
+
badge_class = "coverage-high"
|
|
472
|
+
elif coverage_pct >= 50:
|
|
473
|
+
badge_class = "coverage-medium"
|
|
474
|
+
else:
|
|
475
|
+
badge_class = "coverage-low"
|
|
476
|
+
|
|
477
|
+
html += f"""
|
|
478
|
+
<div class="statute">
|
|
479
|
+
<div class="statute-header">
|
|
480
|
+
<span class="statute-title">Section {section}: {data['title']}</span>
|
|
481
|
+
<span class="coverage-badge {badge_class}">{data['overall_coverage']}</span>
|
|
482
|
+
</div>
|
|
483
|
+
<table class="elements-table">
|
|
484
|
+
<thead>
|
|
485
|
+
<tr>
|
|
486
|
+
<th>Element</th>
|
|
487
|
+
<th>Type</th>
|
|
488
|
+
<th>Status</th>
|
|
489
|
+
<th>Tests</th>
|
|
490
|
+
</tr>
|
|
491
|
+
</thead>
|
|
492
|
+
<tbody>
|
|
493
|
+
"""
|
|
494
|
+
for elem_name, elem_data in data['elements'].items():
|
|
495
|
+
status_class = "status-covered" if elem_data['covered'] else "status-uncovered"
|
|
496
|
+
status_text = "COVERED" if elem_data['covered'] else "NOT COVERED"
|
|
497
|
+
html += f"""
|
|
498
|
+
<tr>
|
|
499
|
+
<td>{elem_name.split(':')[1] if ':' in elem_name else elem_name}</td>
|
|
500
|
+
<td>{elem_data['type']}</td>
|
|
501
|
+
<td class="{status_class}">{status_text}</td>
|
|
502
|
+
<td>{elem_data['test_count']}</td>
|
|
503
|
+
</tr>
|
|
504
|
+
"""
|
|
505
|
+
|
|
506
|
+
penalty_status = "COVERED" if data['penalty_covered'] else "NOT COVERED"
|
|
507
|
+
penalty_class = "status-covered" if data['penalty_covered'] else "status-uncovered"
|
|
508
|
+
html += f"""
|
|
509
|
+
<tr>
|
|
510
|
+
<td>Penalty</td>
|
|
511
|
+
<td>penalty</td>
|
|
512
|
+
<td class="{penalty_class}">{penalty_status}</td>
|
|
513
|
+
<td>-</td>
|
|
514
|
+
</tr>
|
|
515
|
+
</tbody>
|
|
516
|
+
</table>
|
|
517
|
+
</div>
|
|
518
|
+
"""
|
|
519
|
+
|
|
520
|
+
html += f"""
|
|
521
|
+
<p class="generated">Generated by Yuho v5 on {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}</p>
|
|
522
|
+
</div>
|
|
523
|
+
</body>
|
|
524
|
+
</html>
|
|
525
|
+
"""
|
|
526
|
+
|
|
527
|
+
output_path.write_text(html)
|
|
528
|
+
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Transpile command - convert Yuho files to other formats.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import sys
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Optional, List
|
|
9
|
+
|
|
10
|
+
import click
|
|
11
|
+
|
|
12
|
+
from yuho.parser import Parser
|
|
13
|
+
from yuho.ast import ASTBuilder
|
|
14
|
+
from yuho.transpile import TranspileTarget, get_transpiler
|
|
15
|
+
from yuho.cli.error_formatter import Colors, colorize
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
ALL_TARGETS = ["json", "jsonld", "english", "mermaid", "alloy"]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def run_transpile(
|
|
22
|
+
file: str,
|
|
23
|
+
target: str = "json",
|
|
24
|
+
output: Optional[str] = None,
|
|
25
|
+
output_dir: Optional[str] = None,
|
|
26
|
+
all_targets: bool = False,
|
|
27
|
+
json_output: bool = False,
|
|
28
|
+
verbose: bool = False
|
|
29
|
+
) -> None:
|
|
30
|
+
"""
|
|
31
|
+
Transpile a Yuho file to another format.
|
|
32
|
+
|
|
33
|
+
Args:
|
|
34
|
+
file: Path to the .yh file
|
|
35
|
+
target: Target format (json, jsonld, english, mermaid, alloy)
|
|
36
|
+
output: Output file path
|
|
37
|
+
output_dir: Output directory for multiple files
|
|
38
|
+
all_targets: Generate all targets
|
|
39
|
+
json_output: Output metadata as JSON
|
|
40
|
+
verbose: Enable verbose output
|
|
41
|
+
"""
|
|
42
|
+
file_path = Path(file)
|
|
43
|
+
|
|
44
|
+
if verbose:
|
|
45
|
+
click.echo(f"Parsing {file_path}...")
|
|
46
|
+
|
|
47
|
+
# Parse and build AST
|
|
48
|
+
parser = Parser()
|
|
49
|
+
try:
|
|
50
|
+
result = parser.parse_file(file_path)
|
|
51
|
+
except FileNotFoundError:
|
|
52
|
+
click.echo(colorize(f"error: File not found: {file}", Colors.RED), err=True)
|
|
53
|
+
sys.exit(1)
|
|
54
|
+
|
|
55
|
+
if result.errors:
|
|
56
|
+
click.echo(colorize(f"error: Parse errors in {file}", Colors.RED), err=True)
|
|
57
|
+
for err in result.errors:
|
|
58
|
+
click.echo(f" {err.location}: {err.message}", err=True)
|
|
59
|
+
sys.exit(1)
|
|
60
|
+
|
|
61
|
+
builder = ASTBuilder(result.source, str(file_path))
|
|
62
|
+
ast = builder.build(result.root_node)
|
|
63
|
+
|
|
64
|
+
# Determine targets
|
|
65
|
+
targets: List[str] = ALL_TARGETS if all_targets else [target]
|
|
66
|
+
|
|
67
|
+
# Determine output directory
|
|
68
|
+
if output_dir:
|
|
69
|
+
out_dir = Path(output_dir)
|
|
70
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
|
71
|
+
else:
|
|
72
|
+
out_dir = None
|
|
73
|
+
|
|
74
|
+
results = []
|
|
75
|
+
|
|
76
|
+
for tgt in targets:
|
|
77
|
+
if verbose:
|
|
78
|
+
click.echo(f"Transpiling to {tgt}...")
|
|
79
|
+
|
|
80
|
+
try:
|
|
81
|
+
transpile_target = TranspileTarget.from_string(tgt)
|
|
82
|
+
transpiler = get_transpiler(transpile_target)
|
|
83
|
+
output_text = transpiler.transpile(ast)
|
|
84
|
+
except ValueError as e:
|
|
85
|
+
click.echo(colorize(f"error: {e}", Colors.RED), err=True)
|
|
86
|
+
sys.exit(1)
|
|
87
|
+
|
|
88
|
+
# Determine output path
|
|
89
|
+
if all_targets or output_dir:
|
|
90
|
+
# Multiple targets -> use directory
|
|
91
|
+
if out_dir:
|
|
92
|
+
out_path = out_dir / f"{file_path.stem}{transpile_target.file_extension}"
|
|
93
|
+
else:
|
|
94
|
+
out_path = file_path.parent / f"{file_path.stem}{transpile_target.file_extension}"
|
|
95
|
+
|
|
96
|
+
out_path.write_text(output_text, encoding="utf-8")
|
|
97
|
+
results.append({"target": tgt, "output": str(out_path)})
|
|
98
|
+
|
|
99
|
+
if not json_output:
|
|
100
|
+
click.echo(f" -> {out_path}")
|
|
101
|
+
|
|
102
|
+
elif output:
|
|
103
|
+
# Single target with explicit output
|
|
104
|
+
out_path = Path(output)
|
|
105
|
+
out_path.parent.mkdir(parents=True, exist_ok=True)
|
|
106
|
+
out_path.write_text(output_text, encoding="utf-8")
|
|
107
|
+
results.append({"target": tgt, "output": str(out_path)})
|
|
108
|
+
|
|
109
|
+
if not json_output:
|
|
110
|
+
click.echo(f" -> {out_path}")
|
|
111
|
+
|
|
112
|
+
else:
|
|
113
|
+
# Single target to stdout
|
|
114
|
+
print(output_text)
|
|
115
|
+
results.append({"target": tgt, "output": "stdout"})
|
|
116
|
+
|
|
117
|
+
if json_output:
|
|
118
|
+
print(json.dumps({
|
|
119
|
+
"source": str(file_path),
|
|
120
|
+
"results": results,
|
|
121
|
+
}, indent=2))
|