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.
Files changed (91) hide show
  1. yuho/__init__.py +16 -0
  2. yuho/ast/__init__.py +196 -0
  3. yuho/ast/builder.py +926 -0
  4. yuho/ast/constant_folder.py +280 -0
  5. yuho/ast/dead_code.py +199 -0
  6. yuho/ast/exhaustiveness.py +503 -0
  7. yuho/ast/nodes.py +907 -0
  8. yuho/ast/overlap.py +291 -0
  9. yuho/ast/reachability.py +293 -0
  10. yuho/ast/scope_analysis.py +490 -0
  11. yuho/ast/transformer.py +490 -0
  12. yuho/ast/type_check.py +471 -0
  13. yuho/ast/type_inference.py +425 -0
  14. yuho/ast/visitor.py +239 -0
  15. yuho/cli/__init__.py +14 -0
  16. yuho/cli/commands/__init__.py +1 -0
  17. yuho/cli/commands/api.py +431 -0
  18. yuho/cli/commands/ast_viz.py +334 -0
  19. yuho/cli/commands/check.py +218 -0
  20. yuho/cli/commands/config.py +311 -0
  21. yuho/cli/commands/contribute.py +122 -0
  22. yuho/cli/commands/diff.py +487 -0
  23. yuho/cli/commands/explain.py +240 -0
  24. yuho/cli/commands/fmt.py +253 -0
  25. yuho/cli/commands/generate.py +316 -0
  26. yuho/cli/commands/graph.py +410 -0
  27. yuho/cli/commands/init.py +120 -0
  28. yuho/cli/commands/library.py +656 -0
  29. yuho/cli/commands/lint.py +503 -0
  30. yuho/cli/commands/lsp.py +36 -0
  31. yuho/cli/commands/preview.py +377 -0
  32. yuho/cli/commands/repl.py +444 -0
  33. yuho/cli/commands/serve.py +44 -0
  34. yuho/cli/commands/test.py +528 -0
  35. yuho/cli/commands/transpile.py +121 -0
  36. yuho/cli/commands/wizard.py +370 -0
  37. yuho/cli/completions.py +182 -0
  38. yuho/cli/error_formatter.py +193 -0
  39. yuho/cli/main.py +1064 -0
  40. yuho/config/__init__.py +46 -0
  41. yuho/config/loader.py +235 -0
  42. yuho/config/mask.py +194 -0
  43. yuho/config/schema.py +147 -0
  44. yuho/library/__init__.py +84 -0
  45. yuho/library/index.py +328 -0
  46. yuho/library/install.py +699 -0
  47. yuho/library/lockfile.py +330 -0
  48. yuho/library/package.py +421 -0
  49. yuho/library/resolver.py +791 -0
  50. yuho/library/signature.py +335 -0
  51. yuho/llm/__init__.py +45 -0
  52. yuho/llm/config.py +75 -0
  53. yuho/llm/factory.py +123 -0
  54. yuho/llm/prompts.py +146 -0
  55. yuho/llm/providers.py +383 -0
  56. yuho/llm/utils.py +470 -0
  57. yuho/lsp/__init__.py +14 -0
  58. yuho/lsp/code_action_handler.py +518 -0
  59. yuho/lsp/completion_handler.py +85 -0
  60. yuho/lsp/diagnostics.py +100 -0
  61. yuho/lsp/hover_handler.py +130 -0
  62. yuho/lsp/server.py +1425 -0
  63. yuho/mcp/__init__.py +10 -0
  64. yuho/mcp/server.py +1452 -0
  65. yuho/parser/__init__.py +8 -0
  66. yuho/parser/source_location.py +108 -0
  67. yuho/parser/wrapper.py +311 -0
  68. yuho/testing/__init__.py +48 -0
  69. yuho/testing/coverage.py +274 -0
  70. yuho/testing/fixtures.py +263 -0
  71. yuho/transpile/__init__.py +52 -0
  72. yuho/transpile/alloy_transpiler.py +546 -0
  73. yuho/transpile/base.py +100 -0
  74. yuho/transpile/blocks_transpiler.py +338 -0
  75. yuho/transpile/english_transpiler.py +470 -0
  76. yuho/transpile/graphql_transpiler.py +404 -0
  77. yuho/transpile/json_transpiler.py +217 -0
  78. yuho/transpile/jsonld_transpiler.py +250 -0
  79. yuho/transpile/latex_preamble.py +161 -0
  80. yuho/transpile/latex_transpiler.py +406 -0
  81. yuho/transpile/latex_utils.py +206 -0
  82. yuho/transpile/mermaid_transpiler.py +357 -0
  83. yuho/transpile/registry.py +275 -0
  84. yuho/verify/__init__.py +43 -0
  85. yuho/verify/alloy.py +352 -0
  86. yuho/verify/combined.py +218 -0
  87. yuho/verify/z3_solver.py +1155 -0
  88. yuho-5.0.0.dist-info/METADATA +186 -0
  89. yuho-5.0.0.dist-info/RECORD +91 -0
  90. yuho-5.0.0.dist-info/WHEEL +4 -0
  91. yuho-5.0.0.dist-info/entry_points.txt +2 -0
@@ -0,0 +1,487 @@
1
+ """
2
+ Diff command - compare statute versions and show changes.
3
+
4
+ Provides semantic diff between two Yuho files or statute versions,
5
+ showing:
6
+ - Added/removed statutes
7
+ - Changed definitions
8
+ - Modified elements
9
+ - Penalty changes
10
+ - Illustration differences
11
+ """
12
+
13
+ import sys
14
+ from pathlib import Path
15
+ from typing import Optional, List, Tuple, Dict, Any
16
+ from dataclasses import dataclass
17
+ from enum import Enum, auto
18
+
19
+ import click
20
+
21
+ from yuho.parser import Parser
22
+ from yuho.ast import ASTBuilder
23
+ from yuho.ast.nodes import (
24
+ ModuleNode, StatuteNode, ElementNode, PenaltyNode,
25
+ DefinitionEntry, IllustrationNode, StringLit
26
+ )
27
+ from yuho.cli.error_formatter import Colors, colorize
28
+
29
+
30
+ class ChangeType(Enum):
31
+ """Type of change detected."""
32
+ ADDED = auto()
33
+ REMOVED = auto()
34
+ MODIFIED = auto()
35
+ UNCHANGED = auto()
36
+
37
+
38
+ @dataclass
39
+ class Change:
40
+ """Represents a single change between versions."""
41
+ change_type: ChangeType
42
+ path: str # e.g., "statute.299.elements.0"
43
+ old_value: Optional[Any] = None
44
+ new_value: Optional[Any] = None
45
+ description: str = ""
46
+
47
+
48
+ class StatuteDiffer:
49
+ """
50
+ Computes semantic diff between two Yuho ASTs.
51
+
52
+ Focuses on meaningful changes rather than syntactic differences.
53
+ """
54
+
55
+ def __init__(self):
56
+ self.changes: List[Change] = []
57
+
58
+ def diff(self, old_ast: ModuleNode, new_ast: ModuleNode) -> List[Change]:
59
+ """
60
+ Compute diff between two ASTs.
61
+
62
+ Args:
63
+ old_ast: The original/baseline AST
64
+ new_ast: The new/modified AST
65
+
66
+ Returns:
67
+ List of changes detected
68
+ """
69
+ self.changes = []
70
+
71
+ # Build lookup maps
72
+ old_statutes = {s.section_number: s for s in old_ast.statutes}
73
+ new_statutes = {s.section_number: s for s in new_ast.statutes}
74
+
75
+ old_types = {t.name: t for t in old_ast.type_defs}
76
+ new_types = {t.name: t for t in new_ast.type_defs}
77
+
78
+ old_funcs = {f.name: f for f in old_ast.function_defs}
79
+ new_funcs = {f.name: f for f in new_ast.function_defs}
80
+
81
+ # Diff statutes
82
+ self._diff_statutes(old_statutes, new_statutes)
83
+
84
+ # Diff type definitions
85
+ self._diff_types(old_types, new_types)
86
+
87
+ # Diff functions
88
+ self._diff_functions(old_funcs, new_funcs)
89
+
90
+ return self.changes
91
+
92
+ def _diff_statutes(
93
+ self,
94
+ old: Dict[str, StatuteNode],
95
+ new: Dict[str, StatuteNode]
96
+ ) -> None:
97
+ """Diff statute nodes."""
98
+ all_sections = set(old.keys()) | set(new.keys())
99
+
100
+ for section in sorted(all_sections):
101
+ if section not in old:
102
+ # Added statute
103
+ title = new[section].title.value if new[section].title else "(untitled)"
104
+ self.changes.append(Change(
105
+ change_type=ChangeType.ADDED,
106
+ path=f"statute.{section}",
107
+ new_value=new[section],
108
+ description=f"Added statute: Section {section} - {title}"
109
+ ))
110
+ elif section not in new:
111
+ # Removed statute
112
+ title = old[section].title.value if old[section].title else "(untitled)"
113
+ self.changes.append(Change(
114
+ change_type=ChangeType.REMOVED,
115
+ path=f"statute.{section}",
116
+ old_value=old[section],
117
+ description=f"Removed statute: Section {section} - {title}"
118
+ ))
119
+ else:
120
+ # Compare statutes
121
+ self._diff_single_statute(section, old[section], new[section])
122
+
123
+ def _diff_single_statute(
124
+ self,
125
+ section: str,
126
+ old: StatuteNode,
127
+ new: StatuteNode
128
+ ) -> None:
129
+ """Diff a single statute's contents."""
130
+ prefix = f"statute.{section}"
131
+
132
+ # Title change
133
+ old_title = old.title.value if old.title else ""
134
+ new_title = new.title.value if new.title else ""
135
+ if old_title != new_title:
136
+ self.changes.append(Change(
137
+ change_type=ChangeType.MODIFIED,
138
+ path=f"{prefix}.title",
139
+ old_value=old_title,
140
+ new_value=new_title,
141
+ description=f"Section {section} title: '{old_title}' → '{new_title}'"
142
+ ))
143
+
144
+ # Definitions
145
+ self._diff_definitions(prefix, old.definitions, new.definitions)
146
+
147
+ # Elements
148
+ self._diff_elements(prefix, old.elements, new.elements)
149
+
150
+ # Penalty
151
+ self._diff_penalty(prefix, old.penalty, new.penalty)
152
+
153
+ # Illustrations
154
+ self._diff_illustrations(prefix, old.illustrations, new.illustrations)
155
+
156
+ def _diff_definitions(
157
+ self,
158
+ prefix: str,
159
+ old: Tuple[DefinitionEntry, ...],
160
+ new: Tuple[DefinitionEntry, ...]
161
+ ) -> None:
162
+ """Diff definition entries."""
163
+ old_defs = {d.term: d.definition.value for d in old}
164
+ new_defs = {d.term: d.definition.value for d in new}
165
+
166
+ all_terms = set(old_defs.keys()) | set(new_defs.keys())
167
+
168
+ for term in sorted(all_terms):
169
+ if term not in old_defs:
170
+ self.changes.append(Change(
171
+ change_type=ChangeType.ADDED,
172
+ path=f"{prefix}.definitions.{term}",
173
+ new_value=new_defs[term],
174
+ description=f"Added definition: '{term}'"
175
+ ))
176
+ elif term not in new_defs:
177
+ self.changes.append(Change(
178
+ change_type=ChangeType.REMOVED,
179
+ path=f"{prefix}.definitions.{term}",
180
+ old_value=old_defs[term],
181
+ description=f"Removed definition: '{term}'"
182
+ ))
183
+ elif old_defs[term] != new_defs[term]:
184
+ self.changes.append(Change(
185
+ change_type=ChangeType.MODIFIED,
186
+ path=f"{prefix}.definitions.{term}",
187
+ old_value=old_defs[term],
188
+ new_value=new_defs[term],
189
+ description=f"Modified definition: '{term}'"
190
+ ))
191
+
192
+ def _diff_elements(
193
+ self,
194
+ prefix: str,
195
+ old: Tuple[ElementNode, ...],
196
+ new: Tuple[ElementNode, ...]
197
+ ) -> None:
198
+ """Diff element nodes."""
199
+ # Compare by name for matching
200
+ old_elems = {e.name: e for e in old}
201
+ new_elems = {e.name: e for e in new}
202
+
203
+ all_names = set(old_elems.keys()) | set(new_elems.keys())
204
+
205
+ for name in sorted(all_names):
206
+ if name not in old_elems:
207
+ self.changes.append(Change(
208
+ change_type=ChangeType.ADDED,
209
+ path=f"{prefix}.elements.{name}",
210
+ new_value=new_elems[name],
211
+ description=f"Added element: {name} ({new_elems[name].element_type})"
212
+ ))
213
+ elif name not in new_elems:
214
+ self.changes.append(Change(
215
+ change_type=ChangeType.REMOVED,
216
+ path=f"{prefix}.elements.{name}",
217
+ old_value=old_elems[name],
218
+ description=f"Removed element: {name}"
219
+ ))
220
+ else:
221
+ old_e, new_e = old_elems[name], new_elems[name]
222
+ if old_e.element_type != new_e.element_type:
223
+ self.changes.append(Change(
224
+ change_type=ChangeType.MODIFIED,
225
+ path=f"{prefix}.elements.{name}.type",
226
+ old_value=old_e.element_type,
227
+ new_value=new_e.element_type,
228
+ description=f"Element {name} type: {old_e.element_type} → {new_e.element_type}"
229
+ ))
230
+ # Note: description comparison would require deeper AST comparison
231
+
232
+ def _diff_penalty(
233
+ self,
234
+ prefix: str,
235
+ old: Optional[PenaltyNode],
236
+ new: Optional[PenaltyNode]
237
+ ) -> None:
238
+ """Diff penalty nodes."""
239
+ if old is None and new is None:
240
+ return
241
+
242
+ if old is None:
243
+ self.changes.append(Change(
244
+ change_type=ChangeType.ADDED,
245
+ path=f"{prefix}.penalty",
246
+ new_value=new,
247
+ description="Added penalty clause"
248
+ ))
249
+ return
250
+
251
+ if new is None:
252
+ self.changes.append(Change(
253
+ change_type=ChangeType.REMOVED,
254
+ path=f"{prefix}.penalty",
255
+ old_value=old,
256
+ description="Removed penalty clause"
257
+ ))
258
+ return
259
+
260
+ # Compare imprisonment
261
+ if self._penalty_field_changed(old.imprisonment_max, new.imprisonment_max):
262
+ self.changes.append(Change(
263
+ change_type=ChangeType.MODIFIED,
264
+ path=f"{prefix}.penalty.imprisonment",
265
+ old_value=old.imprisonment_max,
266
+ new_value=new.imprisonment_max,
267
+ description="Modified imprisonment penalty"
268
+ ))
269
+
270
+ # Compare fine
271
+ if self._penalty_field_changed(old.fine_max, new.fine_max):
272
+ self.changes.append(Change(
273
+ change_type=ChangeType.MODIFIED,
274
+ path=f"{prefix}.penalty.fine",
275
+ old_value=old.fine_max,
276
+ new_value=new.fine_max,
277
+ description="Modified fine penalty"
278
+ ))
279
+
280
+ def _penalty_field_changed(self, old: Any, new: Any) -> bool:
281
+ """Check if a penalty field has changed."""
282
+ if old is None and new is None:
283
+ return False
284
+ if old is None or new is None:
285
+ return True
286
+ # Simple comparison - could be enhanced for Duration/Money nodes
287
+ return str(old) != str(new)
288
+
289
+ def _diff_illustrations(
290
+ self,
291
+ prefix: str,
292
+ old: Tuple[IllustrationNode, ...],
293
+ new: Tuple[IllustrationNode, ...]
294
+ ) -> None:
295
+ """Diff illustration nodes."""
296
+ old_count = len(old)
297
+ new_count = len(new)
298
+
299
+ if old_count != new_count:
300
+ if new_count > old_count:
301
+ self.changes.append(Change(
302
+ change_type=ChangeType.ADDED,
303
+ path=f"{prefix}.illustrations",
304
+ description=f"Added {new_count - old_count} illustration(s)"
305
+ ))
306
+ else:
307
+ self.changes.append(Change(
308
+ change_type=ChangeType.REMOVED,
309
+ path=f"{prefix}.illustrations",
310
+ description=f"Removed {old_count - new_count} illustration(s)"
311
+ ))
312
+
313
+ def _diff_types(self, old: Dict, new: Dict) -> None:
314
+ """Diff type definitions."""
315
+ all_names = set(old.keys()) | set(new.keys())
316
+
317
+ for name in sorted(all_names):
318
+ if name not in old:
319
+ self.changes.append(Change(
320
+ change_type=ChangeType.ADDED,
321
+ path=f"type.{name}",
322
+ description=f"Added type: {name}"
323
+ ))
324
+ elif name not in new:
325
+ self.changes.append(Change(
326
+ change_type=ChangeType.REMOVED,
327
+ path=f"type.{name}",
328
+ description=f"Removed type: {name}"
329
+ ))
330
+
331
+ def _diff_functions(self, old: Dict, new: Dict) -> None:
332
+ """Diff function definitions."""
333
+ all_names = set(old.keys()) | set(new.keys())
334
+
335
+ for name in sorted(all_names):
336
+ if name not in old:
337
+ self.changes.append(Change(
338
+ change_type=ChangeType.ADDED,
339
+ path=f"function.{name}",
340
+ description=f"Added function: {name}"
341
+ ))
342
+ elif name not in new:
343
+ self.changes.append(Change(
344
+ change_type=ChangeType.REMOVED,
345
+ path=f"function.{name}",
346
+ description=f"Removed function: {name}"
347
+ ))
348
+
349
+
350
+ def format_diff(changes: List[Change], color: bool = True) -> str:
351
+ """
352
+ Format diff output for terminal display.
353
+
354
+ Args:
355
+ changes: List of changes to format
356
+ color: Whether to use colors
357
+
358
+ Returns:
359
+ Formatted diff string
360
+ """
361
+ if not changes:
362
+ return "No changes detected."
363
+
364
+ lines: List[str] = []
365
+
366
+ # Group by change type
367
+ added = [c for c in changes if c.change_type == ChangeType.ADDED]
368
+ removed = [c for c in changes if c.change_type == ChangeType.REMOVED]
369
+ modified = [c for c in changes if c.change_type == ChangeType.MODIFIED]
370
+
371
+ def c(text: str, col: str) -> str:
372
+ return colorize(text, col) if color else text
373
+
374
+ # Summary
375
+ summary_parts = []
376
+ if added:
377
+ summary_parts.append(c(f"+{len(added)} added", Colors.GREEN))
378
+ if removed:
379
+ summary_parts.append(c(f"-{len(removed)} removed", Colors.RED))
380
+ if modified:
381
+ summary_parts.append(c(f"~{len(modified)} modified", Colors.YELLOW))
382
+
383
+ lines.append(f"Changes: {', '.join(summary_parts)}")
384
+ lines.append("")
385
+
386
+ # Details
387
+ if added:
388
+ lines.append(c("Added:", Colors.GREEN))
389
+ for change in added:
390
+ lines.append(f" + {change.description}")
391
+ lines.append("")
392
+
393
+ if removed:
394
+ lines.append(c("Removed:", Colors.RED))
395
+ for change in removed:
396
+ lines.append(f" - {change.description}")
397
+ lines.append("")
398
+
399
+ if modified:
400
+ lines.append(c("Modified:", Colors.YELLOW))
401
+ for change in modified:
402
+ lines.append(f" ~ {change.description}")
403
+ lines.append("")
404
+
405
+ return "\n".join(lines)
406
+
407
+
408
+ def run_diff(
409
+ file1: str,
410
+ file2: str,
411
+ json_output: bool = False,
412
+ verbose: bool = False,
413
+ color: bool = True,
414
+ ) -> None:
415
+ """
416
+ Compare two Yuho files and show differences.
417
+
418
+ Args:
419
+ file1: Path to the first (old) file
420
+ file2: Path to the second (new) file
421
+ json_output: Output as JSON
422
+ verbose: Enable verbose output
423
+ color: Use colored output
424
+ """
425
+ import json as json_module
426
+
427
+ path1, path2 = Path(file1), Path(file2)
428
+
429
+ # Validate files exist
430
+ for path in [path1, path2]:
431
+ if not path.exists():
432
+ click.echo(colorize(f"error: File not found: {path}", Colors.RED), err=True)
433
+ sys.exit(1)
434
+
435
+ parser = Parser()
436
+
437
+ # Parse both files
438
+ def parse_file(path: Path) -> Optional[ModuleNode]:
439
+ result = parser.parse_file(path)
440
+ if result.errors:
441
+ click.echo(colorize(f"error: Parse errors in {path}:", Colors.RED), err=True)
442
+ for err in result.errors[:3]:
443
+ click.echo(f" {err.message}", err=True)
444
+ return None
445
+ builder = ASTBuilder()
446
+ return builder.build(result.tree)
447
+
448
+ ast1 = parse_file(path1)
449
+ ast2 = parse_file(path2)
450
+
451
+ if ast1 is None or ast2 is None:
452
+ sys.exit(1)
453
+
454
+ # Compute diff
455
+ differ = StatuteDiffer()
456
+ changes = differ.diff(ast1, ast2)
457
+
458
+ if json_output:
459
+ output = {
460
+ "file1": str(path1),
461
+ "file2": str(path2),
462
+ "changes": [
463
+ {
464
+ "type": c.change_type.name.lower(),
465
+ "path": c.path,
466
+ "description": c.description,
467
+ }
468
+ for c in changes
469
+ ],
470
+ "summary": {
471
+ "added": len([c for c in changes if c.change_type == ChangeType.ADDED]),
472
+ "removed": len([c for c in changes if c.change_type == ChangeType.REMOVED]),
473
+ "modified": len([c for c in changes if c.change_type == ChangeType.MODIFIED]),
474
+ }
475
+ }
476
+ print(json_module.dumps(output, indent=2))
477
+ else:
478
+ if verbose:
479
+ click.echo(f"Comparing: {path1} ↔ {path2}")
480
+ click.echo("")
481
+
482
+ output = format_diff(changes, color=color)
483
+ click.echo(output)
484
+
485
+ # Exit with code 1 if there are changes (useful for CI)
486
+ if changes:
487
+ sys.exit(1)