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.
- asecli/__init__.py +3 -0
- asecli/bridge/__init__.py +40 -0
- asecli/bridge/_editor_port_contract.py +131 -0
- asecli/bridge/_editor_primitives.py +205 -0
- asecli/bridge/_editor_spec_grammar.py +245 -0
- asecli/bridge/_editor_spec_io.py +26 -0
- asecli/bridge/_editor_spec_validation.py +218 -0
- asecli/bridge/_gui_handoff_store.py +78 -0
- asecli/bridge/_gui_project.py +35 -0
- asecli/bridge/_gui_resource_store.py +159 -0
- asecli/bridge/_gui_resource_upgrade.py +122 -0
- asecli/bridge/editor_create.py +173 -0
- asecli/bridge/editor_spec.py +250 -0
- asecli/bridge/graph_geometry.py +249 -0
- asecli/bridge/graph_geometry_parser.py +57 -0
- asecli/bridge/graph_inspect.py +136 -0
- asecli/bridge/gui_handoff.py +100 -0
- asecli/bridge/gui_presentation.py +70 -0
- asecli/bridge/gui_provider_detection.py +197 -0
- asecli/bridge/gui_runtime_probe.py +25 -0
- asecli/bridge/gui_support.py +240 -0
- asecli/bridge/gui_support_resource.py +58 -0
- asecli/bridge/mcp_client.py +231 -0
- asecli/bridge/recompile.py +107 -0
- asecli/bridge/resource_text.py +12 -0
- asecli/bridge/resources/asecli_material_gui.authoring.part00.cs.txt +238 -0
- asecli/bridge/resources/asecli_material_gui.authoring.part01.cs.txt +235 -0
- asecli/bridge/resources/asecli_material_gui.authoring.store.cs.txt +125 -0
- asecli/bridge/resources/asecli_material_gui.condition.cs.txt +99 -0
- asecli/bridge/resources/asecli_material_gui.hydration.cs.txt +105 -0
- asecli/bridge/resources/asecli_material_gui.part00.cs.txt +300 -0
- asecli/bridge/resources/asecli_material_gui.part01.cs.txt +290 -0
- asecli/bridge/resources/asecli_material_gui.reconciliation.cs.txt +174 -0
- asecli/bridge/resources/asecli_material_gui.transaction.cs.txt +108 -0
- asecli/bridge/resources/editor_create.part00.cs.txt +176 -0
- asecli/bridge/resources/editor_create.part01.cs.txt +145 -0
- asecli/bridge/resources/editor_create.part02.cs.txt +139 -0
- asecli/bridge/resources/editor_create.part03.cs.txt +161 -0
- asecli/bridge/resources/editor_create.part04.cs.txt +114 -0
- asecli/bridge/resources/wire_route.transaction.cs.txt +140 -0
- asecli/bridge/wire_route.py +127 -0
- asecli/checks/__init__.py +10 -0
- asecli/checks/checksum.py +53 -0
- asecli/checks/local_vars.py +135 -0
- asecli/checks/usage.py +141 -0
- asecli/checks/validate.py +129 -0
- asecli/cli/__init__.py +1 -0
- asecli/cli/commands.py +237 -0
- asecli/cli/commentary_command.py +158 -0
- asecli/cli/create_command.py +250 -0
- asecli/cli/custom_gui_command.py +170 -0
- asecli/cli/gui_provider.py +30 -0
- asecli/cli/gui_support_command.py +35 -0
- asecli/cli/io.py +214 -0
- asecli/cli/layout_command.py +213 -0
- asecli/cli/main.py +245 -0
- asecli/cli/recompile_metadata.py +53 -0
- asecli/cli/skill_command.py +121 -0
- asecli/cli/usage_command.py +42 -0
- asecli/core/__init__.py +84 -0
- asecli/core/comment_bounds.py +166 -0
- asecli/core/comment_layout.py +167 -0
- asecli/core/comment_metrics.py +132 -0
- asecli/core/comment_purpose.py +140 -0
- asecli/core/commentary.py +225 -0
- asecli/core/compiled_metadata.py +135 -0
- asecli/core/compiled_properties.py +190 -0
- asecli/core/custom_gui.py +250 -0
- asecli/core/custom_gui_versions.py +35 -0
- asecli/core/fishbone_placement.py +159 -0
- asecli/core/fishbone_topology.py +186 -0
- asecli/core/graph_ops.py +122 -0
- asecli/core/layout.py +174 -0
- asecli/core/layout_audit.py +237 -0
- asecli/core/layout_audit_geometry.py +247 -0
- asecli/core/layout_audit_repeated.py +44 -0
- asecli/core/layout_graph.py +113 -0
- asecli/core/local_vars.py +54 -0
- asecli/core/material_gui_condition.py +68 -0
- asecli/core/material_gui_protocol.py +35 -0
- asecli/core/material_gui_spec.py +191 -0
- asecli/core/meticulous_layout.py +114 -0
- asecli/core/model.py +237 -0
- asecli/core/property_presentation.py +233 -0
- asecli/core/wire_geometry.py +83 -0
- asecli/core/wire_router.py +248 -0
- asecli/schema/__init__.py +49 -0
- asecli/schema/data/observed.json +16 -0
- asecli/schema/data/schemas.json +14106 -0
- asecli/skills/asecli/SKILL.md +275 -0
- asecli/skills/asecli/references/layout-standard.md +99 -0
- asecli/skills/asecli/references/master-output-settings-standard.md +51 -0
- asecli/skills/asecli/references/material-property-standard.md +108 -0
- asecli-0.6.2.dist-info/METADATA +594 -0
- asecli-0.6.2.dist-info/RECORD +98 -0
- asecli-0.6.2.dist-info/WHEEL +4 -0
- asecli-0.6.2.dist-info/entry_points.txt +2 -0
- 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())
|