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
@@ -0,0 +1,114 @@
1
+
2
+ string moveError = UnityEditor.AssetDatabase.MoveAsset(temporaryAssetPath, assetPath);
3
+ if (!string.IsNullOrEmpty(moveError)) throw new System.Exception("asset commit failed: " + moveError);
4
+ committed = true;
5
+ var targetShader = UnityEditor.AssetDatabase.LoadAssetAtPath<UnityEngine.Shader>(assetPath);
6
+ if (targetShader == null || targetShader.name != shaderName)
7
+ throw new System.Exception("shader identity changed after asset commit");
8
+
9
+ var result = new Newtonsoft.Json.Linq.JObject();
10
+ result["protocol"] = "ASECLI_EDITOR_CREATE_V1";
11
+ result["ase_version"] = aseVersion;
12
+ result["asset_path"] = assetPath;
13
+ result["template_guid"] = reloadedMaster.CurrentTemplate.GUID;
14
+ result["shader_name"] = targetShader.name;
15
+ result["saved"] = saved;
16
+ result["reloaded"] = reloaded;
17
+ result["committed"] = committed;
18
+ var manifest = new Newtonsoft.Json.Linq.JObject();
19
+ var manifestTemplate = new Newtonsoft.Json.Linq.JObject();
20
+ manifestTemplate["guid"] = reloadedMaster.CurrentTemplate.GUID;
21
+ manifestTemplate["shader_name"] = targetShader.name;
22
+ manifest["template"] = manifestTemplate;
23
+ manifest["nodes"] = manifestNodes;
24
+ manifest["connections"] = manifestConnections;
25
+ result["manifest"] = manifest;
26
+
27
+ UnityEditor.Selection.activeObject = previousSelection;
28
+ AmplifyShaderEditor.UIUtils.CurrentWindow = previousWindow;
29
+ stateRestored = true;
30
+ if (win != null && win != previousWindow)
31
+ {
32
+ var createdWindow = win;
33
+ createdWindow.Close();
34
+ UnityEngine.Object.DestroyImmediate(createdWindow);
35
+ win = null;
36
+ }
37
+ success = true;
38
+ return "ASECLI_EDITOR_CREATE_V1:" + result.ToString(Newtonsoft.Json.Formatting.None);
39
+ }
40
+ catch (System.Exception ex)
41
+ {
42
+ failure = ex;
43
+ throw;
44
+ }
45
+ finally
46
+ {
47
+ var cleanupErrors = new System.Collections.Generic.List<string>();
48
+ if (win == null && AmplifyShaderEditor.UIUtils.CurrentWindow != previousWindow)
49
+ win = AmplifyShaderEditor.UIUtils.CurrentWindow as AmplifyShaderEditor.AmplifyShaderEditorWindow;
50
+ if (!success)
51
+ {
52
+ try
53
+ {
54
+ if (committed && !UnityEditor.AssetDatabase.DeleteAsset(assetPath) &&
55
+ System.IO.File.Exists(System.IO.Path.GetFullPath(assetPath)))
56
+ cleanupErrors.Add("failed to roll back committed target asset");
57
+ }
58
+ catch (System.Exception cleanupEx)
59
+ {
60
+ cleanupErrors.Add("target rollback threw: " + cleanupEx.Message);
61
+ }
62
+ try
63
+ {
64
+ if (!UnityEditor.AssetDatabase.DeleteAsset(temporaryAssetPath) &&
65
+ System.IO.File.Exists(System.IO.Path.GetFullPath(temporaryAssetPath)))
66
+ cleanupErrors.Add("failed to delete temporary asset");
67
+ }
68
+ catch (System.Exception cleanupEx)
69
+ {
70
+ cleanupErrors.Add("temporary cleanup threw: " + cleanupEx.Message);
71
+ }
72
+ try { UnityEditor.AssetDatabase.Refresh(); }
73
+ catch (System.Exception cleanupEx) { cleanupErrors.Add("AssetDatabase.Refresh threw: " + cleanupEx.Message); }
74
+ }
75
+ if (!stateRestored)
76
+ {
77
+ try
78
+ {
79
+ UnityEditor.Selection.activeObject = previousSelection;
80
+ AmplifyShaderEditor.UIUtils.CurrentWindow = previousWindow;
81
+ stateRestored = true;
82
+ }
83
+ catch (System.Exception cleanupEx)
84
+ {
85
+ cleanupErrors.Add("Editor state restore threw: " + cleanupEx.Message);
86
+ }
87
+ }
88
+ if (win != null && win != previousWindow)
89
+ {
90
+ try
91
+ {
92
+ win.Close();
93
+ UnityEngine.Object.DestroyImmediate(win);
94
+ win = null;
95
+ }
96
+ catch (System.Exception cleanupEx)
97
+ {
98
+ cleanupErrors.Add("window cleanup threw: " + cleanupEx.Message);
99
+ }
100
+ }
101
+ if (cleanupErrors.Count > 0)
102
+ {
103
+ string cleanupMessage = string.Join(" | ", cleanupErrors.ToArray());
104
+ if (failure != null)
105
+ {
106
+ failure.Data["ASECLI cleanup failures"] = cleanupMessage;
107
+ UnityEngine.Debug.LogError("ASECLI Editor create cleanup failures: " + cleanupMessage);
108
+ }
109
+ else
110
+ {
111
+ throw new System.Exception("ASECLI Editor create cleanup failed: " + cleanupMessage);
112
+ }
113
+ }
114
+ }
@@ -0,0 +1,140 @@
1
+ var payloadJson = System.Text.Encoding.UTF8.GetString(System.Convert.FromBase64String("{payload_base64}"));
2
+ var payload = Newtonsoft.Json.Linq.JObject.Parse(payloadJson);
3
+ string assetPath = (string)payload["asset_path"];
4
+ var routes = (Newtonsoft.Json.Linq.JArray)payload["routes"];
5
+ var previousWindow = AmplifyShaderEditor.UIUtils.CurrentWindow;
6
+ var previousSelection = UnityEditor.Selection.activeObject;
7
+ AmplifyShaderEditor.AmplifyShaderEditorWindow win = null;
8
+ byte[] diskSnapshot = null;
9
+ bool saved = false;
10
+ try
11
+ {
12
+ if ((int)payload["version"] != 1)
13
+ throw new System.Exception("unsupported WireNode route version");
14
+ if (string.IsNullOrEmpty(assetPath) || !assetPath.StartsWith("Assets/") ||
15
+ !assetPath.EndsWith(".shader") || assetPath.Contains("..") || assetPath.Contains("\\"))
16
+ throw new System.Exception("unsafe target asset path");
17
+ string diskPath = System.IO.Path.GetFullPath(assetPath);
18
+ diskSnapshot = System.IO.File.ReadAllBytes(diskPath);
19
+ var shader = UnityEditor.AssetDatabase.LoadAssetAtPath<UnityEngine.Shader>(assetPath);
20
+ if (shader == null) throw new System.Exception("shader not found: " + assetPath);
21
+
22
+ var publicInstance = System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Instance;
23
+ var windowCreate = typeof(AmplifyShaderEditor.AmplifyShaderEditorWindow).GetMethod(
24
+ "CreateNode", publicInstance, null,
25
+ new System.Type[] { typeof(System.Type), typeof(UnityEngine.Vector2), typeof(AmplifyShaderFunction), typeof(bool) }, null);
26
+ var graphCreateConnection = typeof(AmplifyShaderEditor.ParentGraph).GetMethod(
27
+ "CreateConnection", publicInstance, null,
28
+ new System.Type[] { typeof(int), typeof(int), typeof(int), typeof(int), typeof(bool) }, null);
29
+ var graphDeleteConnection = typeof(AmplifyShaderEditor.ParentGraph).GetMethod(
30
+ "DeleteConnection", publicInstance, null,
31
+ new System.Type[] { typeof(bool), typeof(int), typeof(int), typeof(bool), typeof(bool), typeof(bool) }, null);
32
+ var saveMethod = typeof(AmplifyShaderEditor.AmplifyShaderEditorWindow).GetMethod(
33
+ "SaveToDisk", publicInstance, null, new System.Type[] { typeof(bool) }, null);
34
+ if (windowCreate == null || graphCreateConnection == null || graphDeleteConnection == null || saveMethod == null)
35
+ throw new System.Exception("required WireNode routing API is unavailable in this ASE version");
36
+
37
+ win = UnityEditor.EditorWindow.CreateInstance<AmplifyShaderEditor.AmplifyShaderEditorWindow>();
38
+ AmplifyShaderEditor.UIUtils.CurrentWindow = win;
39
+ var delayedLoad = typeof(AmplifyShaderEditor.AmplifyShaderEditorWindow).GetField(
40
+ "m_delayedLoadObject", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic);
41
+ if (delayedLoad == null) throw new System.Exception("ASE delayed-load field not found");
42
+ delayedLoad.SetValue(win, shader);
43
+ win.Show();
44
+ var layoutEvent = new UnityEngine.Event(); layoutEvent.type = UnityEngine.EventType.Layout; win.SendEvent(layoutEvent);
45
+ var repaintEvent = new UnityEngine.Event(); repaintEvent.type = UnityEngine.EventType.Repaint; win.SendEvent(repaintEvent);
46
+ var graph = win.CurrentGraph;
47
+ if (graph == null || graph.CurrentMasterNode == null)
48
+ throw new System.Exception("ASE graph did not load in GUI event");
49
+
50
+ var createdIds = new Newtonsoft.Json.Linq.JArray();
51
+ foreach (Newtonsoft.Json.Linq.JObject route in routes)
52
+ {
53
+ string action = (string)route["action"];
54
+ var logical = (Newtonsoft.Json.Linq.JObject)route["logical_wire"];
55
+ int sourceNodeId = (int)logical["from_node"];
56
+ int sourcePortId = (int)logical["from_port"];
57
+ int targetNodeId = (int)logical["to_node"];
58
+ int targetPortId = (int)logical["to_port"];
59
+ var sourceNode = graph.GetNode(sourceNodeId);
60
+ var targetNode = graph.GetNode(targetNodeId);
61
+ if (sourceNode == null || targetNode == null ||
62
+ sourceNode.GetOutputPortByUniqueId(sourcePortId) == null ||
63
+ targetNode.GetInputPortByUniqueId(targetPortId) == null)
64
+ throw new System.Exception("WireNode route endpoint not found");
65
+
66
+ var anchors = (Newtonsoft.Json.Linq.JArray)route["anchors"];
67
+ if (action == "move")
68
+ {
69
+ foreach (Newtonsoft.Json.Linq.JObject anchor in anchors)
70
+ {
71
+ int wireNodeId = (int)anchor["wire_node_id"];
72
+ var node = graph.GetNode(wireNodeId);
73
+ if (node == null || node.GetType() != typeof(AmplifyShaderEditor.WireNode))
74
+ throw new System.Exception("existing WireNode route anchor not found");
75
+ var point = (Newtonsoft.Json.Linq.JArray)anchor["to"];
76
+ node.Vec2Position = new UnityEngine.Vector2((float)point[0], (float)point[1]);
77
+ }
78
+ }
79
+ else if (action == "add")
80
+ {
81
+ var targetPort = targetNode.GetInputPortByUniqueId(targetPortId);
82
+ if (!targetPort.IsConnectedTo(sourceNodeId, sourcePortId))
83
+ throw new System.Exception("logical wire is no longer a direct connection");
84
+ graphDeleteConnection.Invoke(
85
+ graph, new object[] { true, targetNodeId, targetPortId, false, true, false });
86
+ int previousNodeId = sourceNodeId;
87
+ int previousPortId = sourcePortId;
88
+ foreach (Newtonsoft.Json.Linq.JArray point in anchors)
89
+ {
90
+ var created = (AmplifyShaderEditor.ParentNode)windowCreate.Invoke(
91
+ win, new object[] {
92
+ typeof(AmplifyShaderEditor.WireNode),
93
+ new UnityEngine.Vector2((float)point[0], (float)point[1]), null, false
94
+ });
95
+ if (created == null || created.GetType() != typeof(AmplifyShaderEditor.WireNode))
96
+ throw new System.Exception("ASE failed to create WireNode");
97
+ graphCreateConnection.Invoke(
98
+ graph, new object[] {
99
+ created.UniqueId, created.InputPorts[0].PortId,
100
+ previousNodeId, previousPortId, false
101
+ });
102
+ if (!created.InputPorts[0].IsConnectedTo(previousNodeId, previousPortId))
103
+ throw new System.Exception("ASE rejected source-to-WireNode connection");
104
+ previousNodeId = created.UniqueId;
105
+ previousPortId = created.OutputPorts[0].PortId;
106
+ createdIds.Add(created.UniqueId);
107
+ }
108
+ graphCreateConnection.Invoke(
109
+ graph, new object[] { targetNodeId, targetPortId, previousNodeId, previousPortId, false });
110
+ if (!targetPort.IsConnectedTo(previousNodeId, previousPortId))
111
+ throw new System.Exception("ASE rejected WireNode-to-target connection");
112
+ }
113
+ else throw new System.Exception("unknown WireNode route action");
114
+ }
115
+ saved = (bool)saveMethod.Invoke(win, new object[] { false });
116
+ if (!saved) throw new System.Exception("ASE SaveToDisk returned false");
117
+ var response = new Newtonsoft.Json.Linq.JObject();
118
+ response["protocol"] = "ASECLI_WIRE_ROUTE_V1";
119
+ response["asset_path"] = assetPath;
120
+ response["ase_version"] = AmplifyShaderEditor.VersionInfo.StaticToString();
121
+ response["saved"] = true;
122
+ response["applied_routes"] = routes.Count;
123
+ response["created_node_ids"] = createdIds;
124
+ return "ASECLI_WIRE_ROUTE_V1:" + response.ToString(Newtonsoft.Json.Formatting.None);
125
+ }
126
+ catch
127
+ {
128
+ if (diskSnapshot != null)
129
+ {
130
+ System.IO.File.WriteAllBytes(System.IO.Path.GetFullPath(assetPath), diskSnapshot);
131
+ UnityEditor.AssetDatabase.ImportAsset(assetPath, UnityEditor.ImportAssetOptions.ForceUpdate);
132
+ }
133
+ throw;
134
+ }
135
+ finally
136
+ {
137
+ UnityEditor.Selection.activeObject = previousSelection;
138
+ AmplifyShaderEditor.UIUtils.CurrentWindow = previousWindow;
139
+ if (win != null) { win.Close(); UnityEngine.Object.DestroyImmediate(win); }
140
+ }
@@ -0,0 +1,127 @@
1
+ """Apply a fixed WireNode routing plan through the currently installed ASE API."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import base64
6
+ import json
7
+ from pathlib import Path
8
+
9
+ from .mcp_client import McpClient, McpError
10
+ from .editor_create import _execute_code_result_text
11
+ from .recompile import _detect_project_root
12
+ from .resource_text import compose_resource_text
13
+
14
+ WIRE_ROUTE_SNIPPET = compose_resource_text(
15
+ "asecli.bridge", "resources", ("wire_route.transaction.cs.txt",),
16
+ )
17
+ _RESULT_MARKER = "ASECLI_WIRE_ROUTE_V1:"
18
+
19
+
20
+ def apply_wire_routes_via_mcp(
21
+ shader_path: str | Path,
22
+ changes: tuple[dict, ...],
23
+ *,
24
+ mcp_url: str = "http://127.0.0.1:8080/mcp",
25
+ instance_token: str | None = None,
26
+ unity_instance: str | None = None,
27
+ allow_remote_mcp: bool = False,
28
+ ) -> dict:
29
+ target = Path(shader_path).resolve()
30
+ if not target.is_file():
31
+ raise FileNotFoundError(shader_path)
32
+ root = _detect_project_root(target)
33
+ asset_path = target.as_posix().removeprefix(root.as_posix() + "/")
34
+ routes = _validated_routes(changes)
35
+ payload = {"version": 1, "asset_path": asset_path, "routes": routes}
36
+ encoded = base64.b64encode(
37
+ json.dumps(payload, separators=(",", ":"), sort_keys=True).encode("utf-8")
38
+ ).decode("ascii")
39
+ code = WIRE_ROUTE_SNIPPET.replace("{payload_base64}", encoded)
40
+ client = McpClient(mcp_url, instance_token=instance_token, allow_remote=allow_remote_mcp)
41
+ client.connect()
42
+ arguments = {"action": "execute", "code": code}
43
+ if unity_instance is not None:
44
+ arguments["unity_instance"] = unity_instance
45
+ result = client.call_tool("execute_code", arguments)
46
+ response = _parse_result(_execute_code_result_text(result, instance_token))
47
+ if response.get("protocol") != "ASECLI_WIRE_ROUTE_V1":
48
+ raise McpError("WireNode route protocol version is missing or unsupported")
49
+ if response.get("asset_path") != asset_path:
50
+ raise McpError("WireNode route returned a different asset path")
51
+ if response.get("saved") is not True:
52
+ raise McpError("WireNode route did not confirm saved=True")
53
+ if response.get("applied_routes") != len(routes):
54
+ raise McpError("WireNode route applied-count does not match the plan")
55
+ created = response.get("created_node_ids")
56
+ expected_created = sum(len(item["anchors"]) for item in routes if item["action"] == "add")
57
+ if not isinstance(created, list) or len(created) != expected_created:
58
+ raise McpError("WireNode route returned an invalid created-node manifest")
59
+ return response
60
+
61
+
62
+ def _validated_routes(changes: tuple[dict, ...]) -> list[dict]:
63
+ routes = []
64
+ seen = set()
65
+ for change in changes:
66
+ action = change.get("action")
67
+ logical = change.get("logical_wire")
68
+ if action not in {"move", "add"} or not isinstance(logical, dict):
69
+ raise ValueError("invalid WireNode route change")
70
+ normalized = {}
71
+ for source, target in (
72
+ ("from_node", "from_node"), ("from_port", "from_port"),
73
+ ("to_node", "to_node"), ("to_port", "to_port"),
74
+ ):
75
+ value = str(logical.get(source, ""))
76
+ if not value.lstrip("-").isdigit():
77
+ raise ValueError(f"WireNode route {source} must be numeric")
78
+ normalized[target] = int(value)
79
+ key = tuple(normalized.values())
80
+ if key in seen:
81
+ raise ValueError("duplicate WireNode logical route")
82
+ seen.add(key)
83
+ if action == "add":
84
+ anchors = change.get("anchors")
85
+ if not isinstance(anchors, list) or not 1 <= len(anchors) <= 2:
86
+ raise ValueError("a logical wire may add one or two WireNode anchors")
87
+ normalized_anchors = [_point(item) for item in anchors]
88
+ else:
89
+ anchors = change.get("anchors")
90
+ if not isinstance(anchors, list) or not anchors:
91
+ raise ValueError("WireNode move requires at least one existing anchor")
92
+ normalized_anchors = []
93
+ ids = set()
94
+ for item in anchors:
95
+ node_id = str(item.get("wire_node_id", ""))
96
+ if not node_id.lstrip("-").isdigit() or node_id in ids:
97
+ raise ValueError("WireNode move ids must be unique numeric values")
98
+ ids.add(node_id)
99
+ normalized_anchors.append({"wire_node_id": int(node_id), "to": _point(item.get("to"))})
100
+ routes.append({"action": action, "logical_wire": normalized, "anchors": normalized_anchors})
101
+ return routes
102
+
103
+
104
+ def _point(value) -> list[float]:
105
+ if not isinstance(value, (list, tuple)) or len(value) != 2:
106
+ raise ValueError("WireNode anchor must be an x,y pair")
107
+ try:
108
+ point = [float(value[0]), float(value[1])]
109
+ except (TypeError, ValueError) as exc:
110
+ raise ValueError("WireNode anchor coordinates must be numeric") from exc
111
+ if not all(abs(item) <= 10_000_000 for item in point):
112
+ raise ValueError("WireNode anchor coordinate is outside the supported canvas range")
113
+ return point
114
+
115
+
116
+ def _parse_result(text: str) -> dict:
117
+ marker = text.find(_RESULT_MARKER)
118
+ if marker < 0:
119
+ raise McpError("MCP tool result did not contain the WireNode route marker")
120
+ raw = text[marker + len(_RESULT_MARKER):].lstrip()
121
+ try:
122
+ value, _ = json.JSONDecoder().raw_decode(raw)
123
+ except json.JSONDecodeError as exc:
124
+ raise McpError("MCP tool returned malformed WireNode route JSON") from exc
125
+ if not isinstance(value, dict):
126
+ raise McpError("MCP WireNode route result must be a JSON object")
127
+ return value
@@ -0,0 +1,10 @@
1
+ """Validation & checksum (TASK-0008/0009)."""
2
+
3
+ from .checksum import ChecksumFormatError, compute_checksum, fix_checksum, verify_checksum
4
+ from .usage import external_references_for_node, live_node_ids, usage_audit
5
+ from .validate import validate_file
6
+
7
+ __all__ = [
8
+ "ChecksumFormatError", "compute_checksum", "external_references_for_node", "fix_checksum",
9
+ "live_node_ids", "usage_audit", "verify_checksum", "validate_file",
10
+ ]
@@ -0,0 +1,53 @@
1
+ """ASE checksum: SHA1 (uppercase hex) of the full text before ``//CHKSM=``.
2
+
3
+ Replicates ``IOUtils.CreateChecksum``; verified against real files.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import hashlib
9
+ import re
10
+
11
+ MARKER = "//CHKSM="
12
+ _TRAILER = re.compile(r"(?m)^//CHKSM=([^\r\n]*)(\r?\n)?\Z")
13
+ _LINE_MARKER = re.compile(r"(?m)^//CHKSM=")
14
+
15
+
16
+ class ChecksumFormatError(ValueError):
17
+ """Raised when checksum-like lines are ambiguous or not a trailer."""
18
+
19
+
20
+ def compute_checksum(text_before: str) -> str:
21
+ return hashlib.sha1(text_before.encode("utf-8")).hexdigest().upper()
22
+
23
+
24
+ def verify_checksum(text: str) -> tuple[bool, str | None, str | None]:
25
+ match = _checksum_trailer(text)
26
+ if match is None:
27
+ return False, None, None
28
+ idx = match.start()
29
+ stored = match.group(1).strip()
30
+ actual = compute_checksum(text[:idx])
31
+ return bool(re.fullmatch(r"[0-9A-Fa-f]{40}", stored)) and stored.upper() == actual, stored, actual
32
+
33
+
34
+ def fix_checksum(text: str) -> str:
35
+ match = _checksum_trailer(text)
36
+ if match is None:
37
+ sep = "" if text.endswith("\n") else "\n"
38
+ return text + sep + MARKER + compute_checksum(text)
39
+ idx = match.start()
40
+ trailing_eol = match.group(2) or ""
41
+ return text[:idx] + MARKER + compute_checksum(text[:idx]) + trailing_eol
42
+
43
+
44
+ def _checksum_trailer(text: str) -> re.Match[str] | None:
45
+ markers = list(_LINE_MARKER.finditer(text))
46
+ match = _TRAILER.search(text)
47
+ if match is None:
48
+ if markers:
49
+ raise ChecksumFormatError("//CHKSM= must be the final standalone line")
50
+ return None
51
+ if len(markers) != 1 or markers[0].start() != match.start():
52
+ raise ChecksumFormatError("multiple //CHKSM= lines are ambiguous")
53
+ return match
@@ -0,0 +1,135 @@
1
+ """Structural checks for ASE Register/Get Local Var semantics."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from ..core import (
6
+ AseGraph,
7
+ GET_LOCAL_VAR_TYPE,
8
+ REGISTER_LOCAL_VAR_TYPE,
9
+ parse_get_local_var,
10
+ parse_register_local_var,
11
+ )
12
+
13
+
14
+ def append_local_var_issues(graph: AseGraph, issues: list[dict]) -> None:
15
+ nodes_by_id = {node.node_id: node for node in graph.nodes}
16
+ semantic_edges: list[tuple[str, str]] = []
17
+ registers = {}
18
+ for node in graph.nodes:
19
+ if node.type_name != REGISTER_LOCAL_VAR_TYPE:
20
+ continue
21
+ try:
22
+ item = parse_register_local_var(node)
23
+ except ValueError as exc:
24
+ issues.append(_issue("MALFORMED_LOCAL_VAR", node.node_id, str(exc)))
25
+ continue
26
+ registers[node.node_id] = item
27
+ if not item["name"].strip():
28
+ issues.append(_issue(
29
+ "EMPTY_LOCAL_VAR_NAME", node.node_id,
30
+ f"RegisterLocalVarNode {node.node_id} has an empty name",
31
+ ))
32
+ if not item["data_type"].strip():
33
+ issues.append(_issue(
34
+ "EMPTY_LOCAL_VAR_TYPE", node.node_id,
35
+ f"RegisterLocalVarNode {node.node_id} has an empty data type",
36
+ ))
37
+
38
+ for node in graph.nodes:
39
+ if node.type_name != GET_LOCAL_VAR_TYPE:
40
+ continue
41
+ try:
42
+ item = parse_get_local_var(node)
43
+ except ValueError as exc:
44
+ issues.append(_issue("MALFORMED_LOCAL_VAR", node.node_id, str(exc)))
45
+ continue
46
+ target = nodes_by_id.get(item["register_id"])
47
+ if target is None:
48
+ issues.append(_issue(
49
+ "DANGLING_LOCAL_VAR", node.node_id,
50
+ f"GetLocalVarNode {node.node_id} references missing Register node {item['register_id']}",
51
+ ))
52
+ continue
53
+ if target.type_name != REGISTER_LOCAL_VAR_TYPE:
54
+ issues.append(_issue(
55
+ "INVALID_LOCAL_VAR_TARGET", node.node_id,
56
+ f"GetLocalVarNode {node.node_id} references non-Register node {item['register_id']}",
57
+ ))
58
+ continue
59
+ register = registers.get(item["register_id"])
60
+ if register is None:
61
+ continue
62
+ semantic_edges.append((item["register_id"], node.node_id))
63
+ if item["name"] != register["name"]:
64
+ issues.append(_issue(
65
+ "LOCAL_VAR_NAME_MISMATCH", node.node_id,
66
+ f"GetLocalVarNode {node.node_id} name {item['name']!r} does not match "
67
+ f"Register {item['register_id']} name {register['name']!r}",
68
+ ))
69
+ if item["data_type"] != register["data_type"]:
70
+ issues.append(_issue(
71
+ "LOCAL_VAR_TYPE_MISMATCH", node.node_id,
72
+ f"GetLocalVarNode {node.node_id} type {item['data_type']!r} does not match "
73
+ f"Register {item['register_id']} type {register['data_type']!r}",
74
+ ))
75
+
76
+ _append_cycle_issues(graph, semantic_edges, issues)
77
+
78
+
79
+ def _issue(code: str, node_id: str, message: str) -> dict:
80
+ return {"code": code, "severity": "error", "node_id": node_id, "message": message}
81
+
82
+
83
+ def _append_cycle_issues(graph: AseGraph, semantic_edges: list[tuple[str, str]], issues: list[dict]) -> None:
84
+ adjacency: dict[str, set[str]] = {node.node_id: set() for node in graph.nodes}
85
+ for wire in graph.wires:
86
+ if wire.out_node in adjacency and wire.in_node in adjacency:
87
+ adjacency[wire.out_node].add(wire.in_node)
88
+ for source, target in semantic_edges:
89
+ adjacency[source].add(target)
90
+
91
+ index = 0
92
+ indices: dict[str, int] = {}
93
+ lowlinks: dict[str, int] = {}
94
+ stack: list[str] = []
95
+ on_stack: set[str] = set()
96
+ components: list[set[str]] = []
97
+
98
+ def visit(node_id: str) -> None:
99
+ nonlocal index
100
+ indices[node_id] = lowlinks[node_id] = index
101
+ index += 1
102
+ stack.append(node_id)
103
+ on_stack.add(node_id)
104
+ for target in adjacency[node_id]:
105
+ if target not in indices:
106
+ visit(target)
107
+ lowlinks[node_id] = min(lowlinks[node_id], lowlinks[target])
108
+ elif target in on_stack:
109
+ lowlinks[node_id] = min(lowlinks[node_id], indices[target])
110
+ if lowlinks[node_id] == indices[node_id]:
111
+ component = set()
112
+ while True:
113
+ member = stack.pop()
114
+ on_stack.remove(member)
115
+ component.add(member)
116
+ if member == node_id:
117
+ break
118
+ components.append(component)
119
+
120
+ for node_id in adjacency:
121
+ if node_id not in indices:
122
+ visit(node_id)
123
+
124
+ semantic = set(semantic_edges)
125
+ for component in components:
126
+ cyclic = len(component) > 1 or any(node in adjacency[node] for node in component)
127
+ uses_local_var = any(source in component and target in component for source, target in semantic)
128
+ if cyclic and uses_local_var:
129
+ members = sorted(component, key=lambda value: (not value.lstrip("-").isdigit(), value))
130
+ issue = _issue(
131
+ "LOCAL_VAR_REFERENCE_CYCLE", members[0],
132
+ f"Local Var semantic references participate in a cycle: {', '.join(members)}",
133
+ )
134
+ issue["members"] = members
135
+ issues.append(issue)