asecli 0.6.2__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 (98) hide show
  1. asecli/__init__.py +3 -0
  2. asecli/bridge/__init__.py +40 -0
  3. asecli/bridge/_editor_port_contract.py +131 -0
  4. asecli/bridge/_editor_primitives.py +205 -0
  5. asecli/bridge/_editor_spec_grammar.py +245 -0
  6. asecli/bridge/_editor_spec_io.py +26 -0
  7. asecli/bridge/_editor_spec_validation.py +218 -0
  8. asecli/bridge/_gui_handoff_store.py +78 -0
  9. asecli/bridge/_gui_project.py +35 -0
  10. asecli/bridge/_gui_resource_store.py +159 -0
  11. asecli/bridge/_gui_resource_upgrade.py +122 -0
  12. asecli/bridge/editor_create.py +173 -0
  13. asecli/bridge/editor_spec.py +250 -0
  14. asecli/bridge/graph_geometry.py +249 -0
  15. asecli/bridge/graph_geometry_parser.py +57 -0
  16. asecli/bridge/graph_inspect.py +136 -0
  17. asecli/bridge/gui_handoff.py +100 -0
  18. asecli/bridge/gui_presentation.py +70 -0
  19. asecli/bridge/gui_provider_detection.py +197 -0
  20. asecli/bridge/gui_runtime_probe.py +25 -0
  21. asecli/bridge/gui_support.py +240 -0
  22. asecli/bridge/gui_support_resource.py +58 -0
  23. asecli/bridge/mcp_client.py +231 -0
  24. asecli/bridge/recompile.py +107 -0
  25. asecli/bridge/resource_text.py +12 -0
  26. asecli/bridge/resources/asecli_material_gui.authoring.part00.cs.txt +238 -0
  27. asecli/bridge/resources/asecli_material_gui.authoring.part01.cs.txt +235 -0
  28. asecli/bridge/resources/asecli_material_gui.authoring.store.cs.txt +125 -0
  29. asecli/bridge/resources/asecli_material_gui.condition.cs.txt +99 -0
  30. asecli/bridge/resources/asecli_material_gui.hydration.cs.txt +105 -0
  31. asecli/bridge/resources/asecli_material_gui.part00.cs.txt +300 -0
  32. asecli/bridge/resources/asecli_material_gui.part01.cs.txt +290 -0
  33. asecli/bridge/resources/asecli_material_gui.reconciliation.cs.txt +174 -0
  34. asecli/bridge/resources/asecli_material_gui.transaction.cs.txt +108 -0
  35. asecli/bridge/resources/editor_create.part00.cs.txt +176 -0
  36. asecli/bridge/resources/editor_create.part01.cs.txt +145 -0
  37. asecli/bridge/resources/editor_create.part02.cs.txt +139 -0
  38. asecli/bridge/resources/editor_create.part03.cs.txt +161 -0
  39. asecli/bridge/resources/editor_create.part04.cs.txt +114 -0
  40. asecli/bridge/resources/wire_route.transaction.cs.txt +140 -0
  41. asecli/bridge/wire_route.py +127 -0
  42. asecli/checks/__init__.py +10 -0
  43. asecli/checks/checksum.py +53 -0
  44. asecli/checks/local_vars.py +135 -0
  45. asecli/checks/usage.py +141 -0
  46. asecli/checks/validate.py +129 -0
  47. asecli/cli/__init__.py +1 -0
  48. asecli/cli/commands.py +237 -0
  49. asecli/cli/commentary_command.py +158 -0
  50. asecli/cli/create_command.py +250 -0
  51. asecli/cli/custom_gui_command.py +170 -0
  52. asecli/cli/gui_provider.py +30 -0
  53. asecli/cli/gui_support_command.py +35 -0
  54. asecli/cli/io.py +214 -0
  55. asecli/cli/layout_command.py +213 -0
  56. asecli/cli/main.py +245 -0
  57. asecli/cli/recompile_metadata.py +53 -0
  58. asecli/cli/skill_command.py +121 -0
  59. asecli/cli/usage_command.py +42 -0
  60. asecli/core/__init__.py +84 -0
  61. asecli/core/comment_bounds.py +166 -0
  62. asecli/core/comment_layout.py +167 -0
  63. asecli/core/comment_metrics.py +132 -0
  64. asecli/core/comment_purpose.py +140 -0
  65. asecli/core/commentary.py +225 -0
  66. asecli/core/compiled_metadata.py +135 -0
  67. asecli/core/compiled_properties.py +190 -0
  68. asecli/core/custom_gui.py +250 -0
  69. asecli/core/custom_gui_versions.py +35 -0
  70. asecli/core/fishbone_placement.py +159 -0
  71. asecli/core/fishbone_topology.py +186 -0
  72. asecli/core/graph_ops.py +122 -0
  73. asecli/core/layout.py +174 -0
  74. asecli/core/layout_audit.py +237 -0
  75. asecli/core/layout_audit_geometry.py +247 -0
  76. asecli/core/layout_audit_repeated.py +44 -0
  77. asecli/core/layout_graph.py +113 -0
  78. asecli/core/local_vars.py +54 -0
  79. asecli/core/material_gui_condition.py +68 -0
  80. asecli/core/material_gui_protocol.py +35 -0
  81. asecli/core/material_gui_spec.py +191 -0
  82. asecli/core/meticulous_layout.py +114 -0
  83. asecli/core/model.py +237 -0
  84. asecli/core/property_presentation.py +233 -0
  85. asecli/core/wire_geometry.py +83 -0
  86. asecli/core/wire_router.py +248 -0
  87. asecli/schema/__init__.py +49 -0
  88. asecli/schema/data/observed.json +16 -0
  89. asecli/schema/data/schemas.json +14106 -0
  90. asecli/skills/asecli/SKILL.md +275 -0
  91. asecli/skills/asecli/references/layout-standard.md +99 -0
  92. asecli/skills/asecli/references/master-output-settings-standard.md +51 -0
  93. asecli/skills/asecli/references/material-property-standard.md +108 -0
  94. asecli-0.6.2.dist-info/METADATA +594 -0
  95. asecli-0.6.2.dist-info/RECORD +98 -0
  96. asecli-0.6.2.dist-info/WHEEL +4 -0
  97. asecli-0.6.2.dist-info/entry_points.txt +2 -0
  98. asecli-0.6.2.dist-info/licenses/LICENSE +21 -0
asecli/checks/usage.py ADDED
@@ -0,0 +1,141 @@
1
+ """Conservative graph-liveness and external material-property usage audit."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections import defaultdict, deque
6
+ from pathlib import Path
7
+ import re
8
+
9
+ from ..core import AseGraph, NodeLine, is_material_property_node, local_var_edges
10
+
11
+
12
+ COMMENTARY_TYPE = "AmplifyShaderEditor.CommentaryNode"
13
+ MASTER_TYPES = {
14
+ "AmplifyShaderEditor.TemplateMultiPassMasterNode",
15
+ "AmplifyShaderEditor.TemplateMasterNode",
16
+ "AmplifyShaderEditor.StandardSurfaceOutputNode",
17
+ "AmplifyShaderEditor.FunctionOutput",
18
+ "AmplifyShaderEditor.FunctionOutputNode",
19
+ }
20
+ SOURCE_SUFFIXES = {".cs", ".cginc", ".hlsl", ".compute", ".shader"}
21
+ MAX_SCAN_BYTES = 8 * 1024 * 1024
22
+
23
+
24
+ def live_node_ids(graph: AseGraph) -> set[str]:
25
+ """Return nodes that contribute to an output through wires or Local Vars."""
26
+ reverse: dict[str, set[str]] = defaultdict(set)
27
+ for wire in graph.wires:
28
+ reverse[wire.in_node].add(wire.out_node)
29
+ for source, target in local_var_edges(graph):
30
+ reverse[target].add(source)
31
+
32
+ masters = [node for node in graph.nodes if node.type_name in MASTER_TYPES]
33
+ active = [
34
+ node.node_id
35
+ for node in masters
36
+ if len(node.raw_fields) > 6 and node.raw_fields[6].lower() == "true"
37
+ ]
38
+ sinks = active or [node.node_id for node in masters]
39
+ live = set(sinks)
40
+ queue = deque(sinks)
41
+ while queue:
42
+ node_id = queue.popleft()
43
+ for source in reverse.get(node_id, ()):
44
+ if source in live:
45
+ continue
46
+ live.add(source)
47
+ queue.append(source)
48
+ return live
49
+
50
+
51
+ def usage_audit(shader_path: str, graph: AseGraph) -> dict:
52
+ """Classify dead-graph candidates without calling externally used properties unused."""
53
+ live = live_node_ids(graph)
54
+ connected = {
55
+ node_id
56
+ for wire in graph.wires
57
+ for node_id in (wire.out_node, wire.in_node)
58
+ }
59
+ semantic = {node_id for edge in local_var_edges(graph) for node_id in edge}
60
+ project_root = _detect_project_root(Path(shader_path).resolve())
61
+ unused: list[dict] = []
62
+ external: list[dict] = []
63
+ for node in graph.nodes:
64
+ if node.node_id in live or node.type_name == COMMENTARY_TYPE or node.type_name in MASTER_TYPES:
65
+ continue
66
+ item = {
67
+ "node_id": node.node_id,
68
+ "node_type": node.type_name,
69
+ "position": node.raw_fields[3] if len(node.raw_fields) > 3 else None,
70
+ "reason": "not_reachable_from_graph_output",
71
+ "has_wire_or_local_var_relation": node.node_id in connected or node.node_id in semantic,
72
+ }
73
+ if is_material_property_node(node):
74
+ property_name = node.raw_fields[7]
75
+ item["property_name"] = property_name
76
+ refs = find_external_references(project_root, Path(shader_path).resolve(), property_name)
77
+ if refs:
78
+ item["classification"] = "external_consumer"
79
+ item["external_references"] = refs
80
+ external.append(item)
81
+ continue
82
+ item["classification"] = "unused_candidate"
83
+ unused.append(item)
84
+ return {
85
+ "live_node_count": len(live),
86
+ "live_node_ids": sorted(live, key=_node_id_sort_key),
87
+ "unused_candidates": sorted(unused, key=lambda item: _node_id_sort_key(item["node_id"])),
88
+ "external_consumers": sorted(external, key=lambda item: _node_id_sort_key(item["node_id"])),
89
+ "policy": (
90
+ "unused_candidates are conservative review targets; external_consumers must not be pruned "
91
+ "only because they have no ASE wire"
92
+ ),
93
+ }
94
+
95
+
96
+ def external_references_for_node(shader_path: str, node: NodeLine) -> list[dict]:
97
+ if not is_material_property_node(node):
98
+ return []
99
+ path = Path(shader_path).resolve()
100
+ return find_external_references(_detect_project_root(path), path, node.raw_fields[7])
101
+
102
+
103
+ def find_external_references(project_root: Path, shader_path: Path, token: str) -> list[dict]:
104
+ pattern = re.compile(rf"(?<![A-Za-z0-9_]){re.escape(token)}(?![A-Za-z0-9_])")
105
+ results: list[dict] = []
106
+ assets = project_root / "Assets"
107
+ for path in sorted(assets.rglob("*")):
108
+ if not path.is_file() or path.is_symlink() or path.resolve() == shader_path:
109
+ continue
110
+ if path.suffix.lower() not in SOURCE_SUFFIXES or path.name.endswith(".shader.bak"):
111
+ continue
112
+ try:
113
+ if path.stat().st_size > MAX_SCAN_BYTES:
114
+ continue
115
+ text = path.read_text(encoding="utf-8", errors="replace")
116
+ except OSError:
117
+ continue
118
+ for line_number, line in enumerate(text.splitlines(), 1):
119
+ if pattern.search(line):
120
+ results.append(
121
+ {
122
+ "path": path.relative_to(project_root).as_posix(),
123
+ "line": line_number,
124
+ }
125
+ )
126
+ if len(results) >= 100:
127
+ return results
128
+ return results
129
+
130
+
131
+ def _detect_project_root(path: Path) -> Path:
132
+ cur = path.parent
133
+ while cur != cur.parent:
134
+ if (cur / "Assets").is_dir() and (cur / "ProjectSettings").is_dir():
135
+ return cur
136
+ cur = cur.parent
137
+ raise ValueError(f"cannot locate Unity project root for {path}")
138
+
139
+
140
+ def _node_id_sort_key(value: str) -> tuple[int, int | str]:
141
+ return (0, int(value)) if value.lstrip("-").isdigit() else (1, value)
@@ -0,0 +1,129 @@
1
+ """Structural validation (TASK-0008)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from ..core import (
6
+ AseFile,
7
+ COMMENTARY_TYPE,
8
+ parse_commentary_node,
9
+ )
10
+ from .checksum import ChecksumFormatError, verify_checksum
11
+ from .local_vars import append_local_var_issues
12
+
13
+
14
+ def validate_file(ase_file: AseFile) -> list[dict]:
15
+ """Return a list of issue dicts. Empty list means the graph is structurally sound."""
16
+ issues: list[dict] = []
17
+ graph = ase_file.graph
18
+
19
+ seen: dict[str, int] = {}
20
+ for n in graph.nodes:
21
+ seen[n.node_id] = seen.get(n.node_id, 0) + 1
22
+ for node_id, count in seen.items():
23
+ if count > 1:
24
+ issues.append(
25
+ {"code": "DUPLICATE_NODE_ID", "severity": "error", "node_id": node_id,
26
+ "message": f"node id {node_id} appears {count} times"}
27
+ )
28
+
29
+ node_ids = set(seen)
30
+ input_sources: dict[tuple[str, str], list[tuple[str, str]]] = {}
31
+ for w in graph.wires:
32
+ if w.in_node not in node_ids:
33
+ issues.append(
34
+ {"code": "DANGLING_WIRE", "severity": "error", "node_id": w.in_node,
35
+ "message": f"wire references missing input node {w.in_node}"}
36
+ )
37
+ if w.out_node not in node_ids:
38
+ issues.append(
39
+ {"code": "DANGLING_WIRE", "severity": "error", "node_id": w.out_node,
40
+ "message": f"wire references missing output node {w.out_node}"}
41
+ )
42
+ input_sources.setdefault((w.in_node, w.in_port), []).append((w.out_node, w.out_port))
43
+
44
+ for (node_id, port), sources in input_sources.items():
45
+ unique_sources = sorted(set(sources))
46
+ if len(unique_sources) > 1:
47
+ rendered = ", ".join(f"{node}:{source_port}" for node, source_port in unique_sources)
48
+ issues.append(
49
+ {
50
+ "code": "MULTIPLE_INPUT_CONNECTIONS",
51
+ "severity": "error",
52
+ "node_id": node_id,
53
+ "port": port,
54
+ "message": f"input {node_id}:{port} has multiple sources: {rendered}",
55
+ }
56
+ )
57
+
58
+ for n in graph.nodes:
59
+ if not n.type_name:
60
+ issues.append(
61
+ {"code": "MALFORMED_NODE", "severity": "error", "node_id": n.node_id,
62
+ "message": f"node {n.node_id} has empty type"}
63
+ )
64
+
65
+ append_local_var_issues(graph, issues)
66
+
67
+ comment_children: dict[str, list[str]] = {}
68
+ comment_parent: dict[str, str] = {}
69
+ for n in graph.nodes:
70
+ if n.type_name != COMMENTARY_TYPE:
71
+ continue
72
+ try:
73
+ group = parse_commentary_node(n)
74
+ except ValueError as exc:
75
+ issues.append(
76
+ {"code": "MALFORMED_COMMENT", "severity": "error", "node_id": n.node_id,
77
+ "message": str(exc)}
78
+ )
79
+ continue
80
+ comment_children[n.node_id] = [member for member in group["members"] if member in node_ids]
81
+ for member in group["members"]:
82
+ if member not in node_ids:
83
+ issues.append(
84
+ {"code": "DANGLING_COMMENT_MEMBER", "severity": "error", "node_id": n.node_id,
85
+ "message": f"comment {n.node_id} references missing member node {member}"}
86
+ )
87
+ elif member in comment_parent:
88
+ issues.append(
89
+ {"code": "MULTIPLE_COMMENT_PARENTS", "severity": "error", "node_id": member,
90
+ "message": f"node {member} belongs to comments {comment_parent[member]} and {n.node_id}"}
91
+ )
92
+ else:
93
+ comment_parent[member] = n.node_id
94
+
95
+ visiting: set[str] = set()
96
+ visited: set[str] = set()
97
+
98
+ def visit_comment(node_id: str) -> None:
99
+ if node_id in visiting:
100
+ issues.append(
101
+ {"code": "COMMENT_MEMBERSHIP_CYCLE", "severity": "error", "node_id": node_id,
102
+ "message": f"comment membership contains a cycle at node {node_id}"}
103
+ )
104
+ return
105
+ if node_id in visited:
106
+ return
107
+ visiting.add(node_id)
108
+ for member in comment_children.get(node_id, []):
109
+ if member in comment_children:
110
+ visit_comment(member)
111
+ visiting.remove(node_id)
112
+ visited.add(node_id)
113
+
114
+ for comment_id in comment_children:
115
+ visit_comment(comment_id)
116
+
117
+ try:
118
+ ok, stored, actual = verify_checksum(ase_file.serialize())
119
+ except ChecksumFormatError as exc:
120
+ issues.append(
121
+ {"code": "CHECKSUM_FORMAT", "severity": "error", "message": str(exc)}
122
+ )
123
+ return issues
124
+ if stored is not None and not ok:
125
+ issues.append(
126
+ {"code": "CHECKSUM_MISMATCH", "severity": "warning",
127
+ "message": f"stored {stored} != computed {actual} (non-blocking: ASE loads anyway)"}
128
+ )
129
+ return issues
asecli/cli/__init__.py ADDED
@@ -0,0 +1 @@
1
+ """CLI composition root (TASK-0010 wires the JSON contract here)."""
asecli/cli/commands.py ADDED
@@ -0,0 +1,237 @@
1
+ """Command implementations (TASK-0010). Split from main.py for LOC budget."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+
7
+ from ..bridge import McpError
8
+ from ..bridge import recompile_via_mcp
9
+ from ..checks import (
10
+ ChecksumFormatError,
11
+ fix_checksum,
12
+ validate_file,
13
+ verify_checksum,
14
+ )
15
+ from ..core import (
16
+ AseFile,
17
+ NodeLine,
18
+ connect,
19
+ disconnect,
20
+ node_from_schema,
21
+ parse_node_line,
22
+ require_managed_property_presentation,
23
+ set_node_field,
24
+ )
25
+ from ..schema import allows_mutation, is_version_compatible, schema_for, schema_version
26
+ from .io import (
27
+ UnsafeWritePathError,
28
+ WriteConflictError,
29
+ atomic_write,
30
+ read_text_snapshot,
31
+ )
32
+ from .recompile_metadata import restore_recompile_metadata, snapshot_recompile_metadata
33
+
34
+ EXIT_OK = 0
35
+ EXIT_ERROR = 2
36
+ EXIT_BRIDGE = 3
37
+ class CliError(Exception):
38
+ def __init__(self, code: str, message: str, data: dict | None = None):
39
+ super().__init__(message)
40
+ self.code = code
41
+ self.data = data
42
+ def _load(path: str) -> AseFile:
43
+ try:
44
+ return AseFile.from_path(path)
45
+ except FileNotFoundError:
46
+ raise CliError("NOT_FOUND", f"file not found: {path}")
47
+ except ValueError as e:
48
+ raise CliError("PARSE_ERROR", str(e))
49
+
50
+
51
+ def _save(ase_file: AseFile, path: str, write: bool) -> dict:
52
+ issues = validate_file(ase_file)
53
+ errors = [issue for issue in issues if issue["severity"] == "error"]
54
+ if errors:
55
+ raise CliError(
56
+ "VALIDATION_ERROR",
57
+ f"mutation would leave {len(errors)} structural error(s)",
58
+ {"issues": issues, "error_count": len(errors)},
59
+ )
60
+ if not write:
61
+ return {"written": False, "preview_bytes": len(ase_file.serialize())}
62
+ try:
63
+ require_managed_property_presentation(ase_file)
64
+ except ValueError as exc:
65
+ raise CliError("PROPERTY_PRESENTATION_ERROR", str(exc)) from exc
66
+ _commit_text(path, ase_file.serialize(), ase_file.source_digest)
67
+ return {"written": True, "path": str(path)}
68
+
69
+
70
+ def _commit_text(path: str, text: str, expected_digest: str | None) -> None:
71
+ try:
72
+ atomic_write(path, text, expected_digest=expected_digest)
73
+ except WriteConflictError as exc:
74
+ raise CliError(
75
+ "WRITE_CONFLICT",
76
+ str(exc),
77
+ {
78
+ "path": str(exc.path),
79
+ "expected": exc.expected[:12] if exc.expected else None,
80
+ "actual": exc.actual[:12] if exc.actual else None,
81
+ },
82
+ ) from exc
83
+ except UnsafeWritePathError as exc:
84
+ raise CliError("UNSAFE_PATH", str(exc)) from exc
85
+ except OSError as exc:
86
+ raise CliError("WRITE_ERROR", str(exc)) from exc
87
+
88
+
89
+ def _parse_endpoint(ep: str) -> tuple[str, str]:
90
+ if ":" not in ep:
91
+ raise CliError("USAGE_ERROR", f"endpoint must be node:port, got {ep!r}")
92
+ node, port = ep.split(":", 1)
93
+ return node, port
94
+
95
+
96
+ def cmd_parse(args) -> dict:
97
+ f = _load(args.file)
98
+ g = f.graph
99
+ return {
100
+ "file": args.file,
101
+ "version": g.version,
102
+ "node_count": len(g.nodes),
103
+ "wire_count": len(g.wires),
104
+ "nodes": [{"id": n.node_id, "type": n.type_name, "pos": n.raw_fields[3]} for n in g.nodes],
105
+ }
106
+
107
+
108
+ def cmd_set_field(args) -> dict:
109
+ f = _load(args.file)
110
+ try:
111
+ node = set_node_field(f.graph, args.node, args.field, args.value)
112
+ except (KeyError, IndexError) as e:
113
+ raise CliError("NOT_FOUND", str(e))
114
+ except ValueError as e:
115
+ raise CliError("USAGE_ERROR", str(e))
116
+ saved = _save(f, args.file, args.write)
117
+ return {"node": node.node_id, "field": args.field, "value": args.value, **saved}
118
+
119
+
120
+ def cmd_add_node(args) -> dict:
121
+ f = _load(args.file)
122
+ if args.line is not None:
123
+ parse_node_line(args.line)
124
+ fields = args.line.split(";")
125
+ node = NodeLine(type_name=fields[1], node_id=fields[2], raw_fields=fields)
126
+ else:
127
+ if not args.type:
128
+ raise CliError("USAGE_ERROR", "either --type or --line is required")
129
+ s = schema_for(args.type)
130
+ if s is None:
131
+ raise CliError("SCHEMA_UNAVAILABLE", f"unknown node type: {args.type} (use --line to insert raw)")
132
+ if not allows_mutation(args.type):
133
+ raise CliError("SCHEMA_UNAVAILABLE", f"{args.type} has opaque layout; use --line with a real serialized line")
134
+ if not is_version_compatible(args.type, f.graph.version):
135
+ raise CliError(
136
+ "SCHEMA_VERSION_MISMATCH",
137
+ f"node schema version {schema_version(args.type)} is not compatible with graph version {f.graph.version}",
138
+ )
139
+ node = node_from_schema(f.graph, s, args.id, args.pos, args.type)
140
+ try:
141
+ f.graph.add_node(node)
142
+ except ValueError as e:
143
+ raise CliError("VALIDATION_ERROR", str(e))
144
+ saved = _save(f, args.file, args.write)
145
+ import re as _re
146
+
147
+ warnings = []
148
+ if _re.search(r"[0-9a-f]{32}", node.to_line(), _re.IGNORECASE):
149
+ warnings.append(
150
+ "node line contains a 32-hex identifier copied from the schema placeholder; "
151
+ "verify uniqueness in Unity (duplicate guids may conflict)"
152
+ )
153
+ return {"node_id": node.node_id, "type": node.type_name, "warnings": warnings, **saved}
154
+
155
+
156
+ def cmd_connect(args) -> dict:
157
+ f = _load(args.file)
158
+ src, sport = _parse_endpoint(args.src)
159
+ dst, dport = _parse_endpoint(args.dst)
160
+ try:
161
+ wire = connect(f.graph, src, sport, dst, dport)
162
+ except KeyError as e:
163
+ raise CliError("NOT_FOUND", str(e))
164
+ except ValueError as e:
165
+ raise CliError("USAGE_ERROR", str(e))
166
+ saved = _save(f, args.file, args.write)
167
+ return {"from": args.src, "to": args.dst, "line": wire.to_line(), **saved}
168
+
169
+
170
+ def cmd_disconnect(args) -> dict:
171
+ f = _load(args.file)
172
+ src, sport = _parse_endpoint(args.src)
173
+ dst, dport = _parse_endpoint(args.dst)
174
+ if not disconnect(f.graph, src, sport, dst, dport):
175
+ raise CliError("NOT_FOUND", f"wire {args.src} -> {args.dst} not found")
176
+ saved = _save(f, args.file, args.write)
177
+ return {"removed": True, **saved}
178
+
179
+
180
+ def cmd_validate(args) -> dict:
181
+ f = _load(args.file)
182
+ issues = validate_file(f)
183
+ data = {
184
+ "file": args.file,
185
+ "issues": issues,
186
+ "error_count": sum(1 for i in issues if i["severity"] == "error"),
187
+ }
188
+ if data["error_count"]:
189
+ raise CliError(
190
+ "VALIDATION_ERROR",
191
+ f"graph contains {data['error_count']} structural error(s)",
192
+ data,
193
+ )
194
+ return data
195
+
196
+
197
+ def cmd_fix_checksum(args) -> dict:
198
+ try:
199
+ text, source_digest = read_text_snapshot(args.file)
200
+ except FileNotFoundError:
201
+ raise CliError("NOT_FOUND", f"file not found: {args.file}")
202
+ except UnsafeWritePathError as exc:
203
+ raise CliError("UNSAFE_PATH", str(exc)) from exc
204
+ try:
205
+ ok, stored, actual = verify_checksum(text)
206
+ fixed = fix_checksum(text)
207
+ except ChecksumFormatError as exc:
208
+ raise CliError("CHECKSUM_FORMAT_ERROR", str(exc)) from exc
209
+ if args.write:
210
+ _commit_text(args.file, fixed, source_digest)
211
+ return {"was_valid": ok, "stored": stored, "fixed_to": actual, "written": bool(args.write)}
212
+
213
+
214
+ def cmd_recompile(args) -> dict:
215
+ if args.instance_token_argv is not None:
216
+ raise CliError("USAGE_ERROR", "do not pass MCP tokens via argv; use ASECLI_MCP_INSTANCE_TOKEN")
217
+ instance_token = os.environ.get("ASECLI_MCP_INSTANCE_TOKEN")
218
+ metadata_snapshot = snapshot_recompile_metadata(args.file)
219
+ try:
220
+ result = recompile_via_mcp(
221
+ args.file,
222
+ mcp_url=args.mcp_url,
223
+ instance_token=instance_token,
224
+ allow_remote_mcp=args.allow_remote_mcp,
225
+ )
226
+ if metadata_snapshot:
227
+ ase_file, restored = restore_recompile_metadata(args.file, metadata_snapshot)
228
+ output = fix_checksum(ase_file.serialize())
229
+ _commit_text(args.file, output, ase_file.source_digest)
230
+ result["metadata_restored"] = restored
231
+ return result
232
+ except McpError as e:
233
+ raise CliError("BRIDGE_ERROR", str(e), e.data)
234
+ except FileNotFoundError as e:
235
+ raise CliError("NOT_FOUND", str(e))
236
+ except ValueError as e:
237
+ raise CliError("USAGE_ERROR", str(e))
@@ -0,0 +1,158 @@
1
+ """CLI composition for native ASE Comment-frame grouping."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+
7
+ from ..bridge import McpError, measure_node_bounds_via_mcp
8
+ from ..checks import fix_checksum, validate_file
9
+ from ..core import (
10
+ comment_containment_issues,
11
+ create_comment_group,
12
+ inspect_comment_groups,
13
+ refit_comment_groups,
14
+ )
15
+ from .commands import CliError, _commit_text, _load
16
+
17
+
18
+ def cmd_comment_group(args) -> dict:
19
+ f = _load(args.file)
20
+ if args.fit:
21
+ if args.nodes is not None or args.title is not None or args.note is not None or args.id is not None:
22
+ raise CliError("USAGE_ERROR", "--fit cannot be combined with Comment creation options")
23
+ bounds = _editor_bounds(args)
24
+ try:
25
+ before = comment_containment_issues(f.graph, bounds)
26
+ changes = refit_comment_groups(
27
+ f.graph,
28
+ bounds,
29
+ padding=50.0 if args.padding is None else args.padding,
30
+ )
31
+ after = comment_containment_issues(f.graph, bounds)
32
+ except KeyError as exc:
33
+ raise CliError("NOT_FOUND", str(exc))
34
+ except ValueError as exc:
35
+ raise CliError("COMMENT_GROUP_ERROR", str(exc))
36
+ overlaps = [issue for issue in after if issue["code"] == "COMMENT_GROUP_OVERLAP"]
37
+ if args.write and overlaps:
38
+ raise CliError(
39
+ "COMMENT_GROUP_ERROR",
40
+ f"fit would leave {len(overlaps)} unrelated Comment group overlap(s); move groups apart first",
41
+ {"issues": overlaps, "error_count": len(overlaps)},
42
+ )
43
+ output = _validated_output(f)
44
+ if args.write:
45
+ _commit_text(args.file, output, f.source_digest)
46
+ return {
47
+ "file": args.file,
48
+ "action": "fit",
49
+ "measurement": "live_ase_true_position",
50
+ "padding": 50.0 if args.padding is None else args.padding,
51
+ "changes": changes,
52
+ "changed_count": len(changes),
53
+ "before_issues": before,
54
+ "after_issues": after,
55
+ "written": bool(args.write),
56
+ "path": args.file if args.write else None,
57
+ "preview_bytes": len(output),
58
+ "checksum_recomputed": True,
59
+ "requires_editor_reload": True,
60
+ "structural_validation": "passed",
61
+ "visual_validation": "pending",
62
+ }
63
+
64
+ if args.nodes is None:
65
+ if any((args.title is not None, args.note is not None, args.padding is not None,
66
+ args.id is not None, args.write, args.editor_bounds)):
67
+ raise CliError("USAGE_ERROR", "--nodes and --title are required to create a comment group")
68
+ try:
69
+ groups = inspect_comment_groups(f.graph)
70
+ except ValueError as exc:
71
+ raise CliError("COMMENT_GROUP_ERROR", str(exc))
72
+ data = {
73
+ "file": args.file,
74
+ "action": "inspect",
75
+ "groups": groups,
76
+ "written": False,
77
+ "structural_validation": "passed",
78
+ "visual_validation": "pending",
79
+ }
80
+ if args.check_bounds:
81
+ bounds = _editor_bounds(args)
82
+ try:
83
+ data["measurement"] = "live_ase_true_position"
84
+ data["containment_issues"] = comment_containment_issues(f.graph, bounds)
85
+ except ValueError as exc:
86
+ raise CliError("COMMENT_GROUP_ERROR", str(exc))
87
+ return data
88
+ if args.title is None:
89
+ raise CliError("USAGE_ERROR", "--title is required with --nodes")
90
+ if args.check_bounds:
91
+ raise CliError("USAGE_ERROR", "--check-bounds cannot be combined with Comment creation")
92
+ member_ids = [item.strip() for item in args.nodes.split(",")]
93
+ if any(not item for item in member_ids):
94
+ raise CliError("USAGE_ERROR", "--nodes must be a comma-separated list of node ids")
95
+
96
+ bounds = _editor_bounds(args) if args.editor_bounds else None
97
+ try:
98
+ group = create_comment_group(
99
+ f.graph,
100
+ member_ids,
101
+ args.title,
102
+ note="Comment" if args.note is None else args.note,
103
+ padding=50.0 if args.padding is None else args.padding,
104
+ node_id=args.id,
105
+ node_bounds=bounds,
106
+ )
107
+ except KeyError as exc:
108
+ raise CliError("NOT_FOUND", str(exc))
109
+ except ValueError as exc:
110
+ raise CliError("COMMENT_GROUP_ERROR", str(exc))
111
+
112
+ output = _validated_output(f)
113
+ if args.write:
114
+ _commit_text(args.file, output, f.source_digest)
115
+ return {
116
+ "file": args.file,
117
+ "action": "create",
118
+ "group": group,
119
+ "written": bool(args.write),
120
+ "path": args.file if args.write else None,
121
+ "preview_bytes": len(output),
122
+ "checksum_recomputed": True,
123
+ "requires_editor_reload": True,
124
+ "measurement": "live_ase_true_position" if bounds is not None else "offline_estimate",
125
+ "structural_validation": "passed",
126
+ "visual_validation": "pending",
127
+ }
128
+
129
+
130
+ def _editor_bounds(args) -> dict[str, tuple[float, float, float, float]]:
131
+ if args.instance_token_argv is not None:
132
+ raise CliError("USAGE_ERROR", "do not pass MCP tokens via argv; use ASECLI_MCP_INSTANCE_TOKEN")
133
+ try:
134
+ return measure_node_bounds_via_mcp(
135
+ args.file,
136
+ mcp_url=args.mcp_url,
137
+ instance_token=os.environ.get("ASECLI_MCP_INSTANCE_TOKEN"),
138
+ unity_instance=args.unity_instance,
139
+ allow_remote_mcp=args.allow_remote_mcp,
140
+ )
141
+ except McpError as exc:
142
+ raise CliError("BRIDGE_ERROR", str(exc), exc.data)
143
+ except FileNotFoundError as exc:
144
+ raise CliError("NOT_FOUND", str(exc))
145
+ except ValueError as exc:
146
+ raise CliError("USAGE_ERROR", str(exc))
147
+
148
+
149
+ def _validated_output(f) -> str:
150
+ issues = validate_file(f)
151
+ errors = [issue for issue in issues if issue["severity"] == "error"]
152
+ if errors:
153
+ raise CliError(
154
+ "VALIDATION_ERROR",
155
+ f"mutation would leave {len(errors)} structural error(s)",
156
+ {"issues": issues, "error_count": len(errors)},
157
+ )
158
+ return fix_checksum(f.serialize())