fix(engine-47): make companion addon load and serve on Godot 4.7 (Refs #7)

This commit is contained in:
John Smith committed 2026-10-12 10:08:55 -04:00
1 parent c4cc12233a
commit 759985f57e
8 files changed
+107 -20

No files matched your search

@@ -37,7 +37,10 @@ var dispatcher = null
func _ready() -> void:
set_process(false)
# NOTE: _ready runs AFTER the parent plugin's _enter_tree (which calls
# startup()), so this must not force processing off — derive it from the
# actual listen state instead.
set_process(_server.is_listening())
## Start listening. `capability_path` points at the 9500.json record.
@@ -51,7 +54,7 @@ func startup(capability_path: String) -> int:
if not _is_ws_token(_ws_capability):
_ws_capability = ""
return ERR_FILE_CORRUPT
var err := _server.listen(BRIDGE_PORT, "127.0.0.1")
var err: int = _server.listen(BRIDGE_PORT, "127.0.0.1")
if err != OK:
return err
set_process(true)
@@ -98,7 +101,7 @@ func _poll_peers() -> void:
if not (peer is WebSocketPeer):
continue
peer.poll()
var state := peer.get_ready_state()
var state: int = peer.get_ready_state()
if state == WebSocketPeer.STATE_CLOSED:
_authenticated.erase(peer.get_instance_id())
continue
@@ -151,7 +154,9 @@ func _forward_command_frame(peer_id: int, raw: String) -> void:
## Route one dispatcher response to the peer that issued its request_id.
## Returns false when the peer is gone (response dropped, never requeued).
func send_response(response: Dictionary) -> bool: var peer := _peer_for_request(str(response.get("request_id", "")))
## (Multiline body: Godot 4.7 rejects a same-line suite after the colon here.)
func send_response(response: Dictionary) -> bool:
var peer := _peer_for_request(str(response.get("request_id", "")))
if peer == null:
return false
if peer.get_ready_state() != WebSocketPeer.STATE_OPEN:
@@ -19,19 +19,20 @@ var _pending_queries: Dictionary = {}
func _setup_session(session_id: int) -> void:
var session := get_session(session_id)
if session == null:
return
session.register_message_capture(
CAPTURE_CHANNEL,
func(message: StringName, data: Array) -> bool:
return _on_capture(message, data)
)
_sessions.append(session_id)
if not _sessions.has(session_id):
_sessions.append(session_id)
func _has_capture() -> void:
_sessions.clear()
## Engine capture entry point: the editor routes channel traffic to this
## plugin when _has_capture reports the channel as handled.
func _capture(message: String, data: Array, _session_id: int) -> bool:
return _on_capture(message, data)
## EditorDebuggerPlugin virtual (parent: `_has_capture(String) -> bool`):
## report whether this plugin captures the given channel.
func _has_capture(capture: String) -> bool:
return capture == CAPTURE_CHANNEL
## Engine capture callback. Returns true when the message was consumed.
@@ -62,7 +62,7 @@ func instantiate_scene(params: Dictionary) -> Dictionary:
var node_name := str(params.get("name", ""))
if not node_name.is_empty():
instance.name = node_name
var result := _undo.add_node_by_path(scene_root, str(params.get("parent_path", "/")), instance)
var result: Dictionary = _undo.add_node_by_path(scene_root, str(params.get("parent_path", "/")), instance)
if result.has("data"):
result["data"]["scene_path"] = scene_path
return result
@@ -24,7 +24,7 @@ func search_resources(params: Dictionary) -> Dictionary:
var directory := queue.pop_back()
seen += 1
for entry in DirAccess.get_files_at(directory):
var full := directory.path_join(entry)
var full: String = directory.path_join(entry)
if not full.match("*" + pattern + "*"):
continue
if not type_hint.is_empty() and not _type_matches(full, type_hint):
+8 -1
View File
@@ -23,6 +23,12 @@ const BRIDGE_PORT := 9500
var _connection: Node = null
var _dispatcher: RefCounted = null
var _debugger_plugin: EditorDebuggerPlugin = null
## Retain every RefCounted handler for the plugin's lifetime. Callables bound
## to a RefCounted method do NOT extend its lifetime: once the constructing
## local goes out of scope the object is freed and every dispatch fails with
## `null::<method> (Callable)` (proven live on Godot 4.7). Nodes (_connection)
## are kept alive by the scene tree; RefCounteds need this explicit anchor.
var _handler_refs: Array = []
func _enter_tree() -> void:
@@ -34,6 +40,7 @@ func _enter_tree() -> void:
var shaders := ShaderHandler.new()
var themes := ThemeHandler.new(undo_manager)
var viewport := ViewportHandler.new()
_handler_refs = [undo_manager, nodes, signals, resources, shaders, themes, viewport]
_register_node_commands(nodes)
_register_signal_commands(signals)
_register_resource_commands(resources)
@@ -47,7 +54,7 @@ func _enter_tree() -> void:
_debugger_plugin = DebuggerPlugin.new()
_debugger_plugin.forward_target = _connection
add_debugger_plugin(_debugger_plugin)
var err := _connection.startup(_capability_record_path())
var err: int = _connection.startup(_capability_record_path())
if err != OK:
push_warning("GodotOpenCode: bridge not listening (%s)" % error_string(err))
@@ -66,8 +66,21 @@ func _undo_reinsert_by_path(scene_root: Node, parent_path: String, node: Node, i
## Rename the node at `node_path`. Undo resolves the node by its NEW path
## (post-rename) so a rename-then-reorder sequence still finds it.
## Node-name rule (verified against Godot 4.7): non-empty, and none of the
## characters the engine silently rewrites on add_child (":", "/", ".",
## "@", '"', "%"). Rejected names fail closed with INVALID_PARAMS instead
## of being stored mangled.
static func is_valid_node_name(value: String) -> bool:
if value.is_empty():
return false
for ch in [":", "/", ".", "@", "\"", "%"]:
if ch in value:
return false
return true
func rename_node_by_path(scene_root: Node, node_path: String, new_name: String) -> Dictionary:
if new_name.is_empty() or not new_name.is_valid_node_name():
if not is_valid_node_name(new_name):
return {"error": {"code": "INVALID_PARAMS", "message": "Invalid node name: %s" % new_name}}
var target := _resolve_by_path(scene_root, node_path)
if target == null:
+5 -1
View File
@@ -94,7 +94,11 @@ describe("ticket 2.6: out-of-band debugger and engine bridge", () => {
expect(existsSync(PLUGIN)).toBe(true);
const src = readFileSync(PLUGIN, "utf8");
expect(src).toContain("extends EditorDebuggerPlugin");
expect(src).toContain("register_message_capture(");
// Live-verified on Godot 4.7: captures route via the _has_capture/_capture
// virtuals. (register_message_capture does not exist on
// EditorDebuggerSession — asserting it here pinned the bug.)
expect(src).toContain("func _has_capture(capture: String) -> bool");
expect(src).toContain("func _capture(");
expect(src).toContain('CAPTURE_CHANNEL := "opencode"');
expect(src).toContain("game_exception");
expect(src).toContain("stack");
+57
View File
@@ -0,0 +1,57 @@
#!/usr/bin/env bash
# Engine GDScript parse gate — regression pin for the v1.0.0 finding that the
# companion addon shipped with parse errors no mock harness could catch
# (same-line suite in connection.gd, String.is_valid_node_name, wrong
# _has_capture override, uninferrable :=). Stages companion/ into a throwaway
# Godot project and --check-only compiles every .gd file.
#
# Usage:
# scripts/check-gdscript.sh [--godot-bin "<bin>"]
# GODOT_BIN env is honored when the flag is absent. The binary may be a
# multi-word launcher, e.g.:
# scripts/check-gdscript.sh --godot-bin "flatpak run --branch=stable --arch=x86_64 --command=godot org.godotengine.Godot"
# No binary on PATH -> prints "unavailable" and exits 0 (CI-tolerated,
# mirroring the verify/ unavailable legs). Any SCRIPT ERROR -> exit 1.
set -u
BIN="${GODOT_BIN:-godot}"
if [ "${1:-}" = "--godot-bin" ]; then
BIN="${2:?missing binary after --godot-bin}"
fi
# shellcheck disable=SC2086
if ! command -v ${BIN%% *} >/dev/null 2>&1; then
echo "check-gdscript: unavailable (no Godot binary) — skipped"
exit 0
fi
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
# Stage under $HOME: Flatpak-sandboxed engines cannot see /tmp, so mktemp(1)'s
# default glimpse outside the sandbox. A cache dir in $HOME is visible both
# inside and outside the sandbox.
STAGE_BASE="${XDG_CACHE_HOME:-$HOME/.cache}/godot-parse-gate"
mkdir -p "$STAGE_BASE"
STAGE="$(mktemp -d "$STAGE_BASE/stage.XXXXXX")"
trap 'rm -rf "$STAGE"' EXIT
mkdir -p "$STAGE/addons"
cp -r "$ROOT/companion/addons/godot_opencode" "$STAGE/addons/godot_opencode"
cp "$ROOT/companion/validate_scene.gd" "$STAGE/validate_scene.gd"
printf 'config_version=5\n\n[application]\nconfig/name="ParseGate"\n' > "$STAGE/project.godot"
count=0
fail=0
while IFS= read -r f; do
count=$((count + 1))
rel="res://${f#$STAGE/}"
# shellcheck disable=SC2086
if ! out=$($BIN --headless --path "$STAGE" --check-only --script "$rel" 2>&1); then
echo "FAIL: $rel"
printf '%s\n' "$out" | grep "SCRIPT ERROR" | head -n 3
fail=1
fi
done < <(find "$STAGE" -name "*.gd")
if [ "$fail" -ne 0 ]; then
echo "check-gdscript: FAIL ($count files)"
exit 1
fi
echo "check-gdscript: ok ($count files)"