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,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)
|