Initial Void Bastion 0.20 prototype
This commit is contained in:
commit
7362ca0f02
251 files changed
+29688
No files matched your search
@@ -0,0 +1,371 @@
|
||||
@tool
|
||||
extends RefCounted
|
||||
|
||||
# refs.validate_project — project-wide sweep. No params.
|
||||
# Returns {checked: int, issues: [{path, kind, ...}]}.
|
||||
# Kinds: script_parse_error, scene_load_failed, scene_instantiate_failed,
|
||||
# resource_load_failed, missing_ext_resource, missing_uid,
|
||||
# signal_method_missing, signal_target_missing.
|
||||
static func validate_project(_params: Dictionary) -> Dictionary:
|
||||
var issues: Array = []
|
||||
var files: Array = []
|
||||
_walk("res://", ["gd", "tscn", "tres"], files)
|
||||
|
||||
for path in files:
|
||||
if path.begins_with("res://addons/agent_tools/"):
|
||||
continue # don't validate ourselves
|
||||
if path.ends_with(".gd"):
|
||||
_check_script(path, issues)
|
||||
elif path.ends_with(".tscn"):
|
||||
_check_scene(path, issues)
|
||||
elif path.ends_with(".tres"):
|
||||
_check_resource(path, issues)
|
||||
|
||||
return {"data": {"checked": files.size(), "issues": issues}}
|
||||
|
||||
|
||||
static func _walk(dir_path: String, exts: Array, out: Array) -> void:
|
||||
var d := DirAccess.open(dir_path)
|
||||
if d == null:
|
||||
return
|
||||
d.list_dir_begin()
|
||||
while true:
|
||||
var name := d.get_next()
|
||||
if name == "":
|
||||
break
|
||||
if name.begins_with("."):
|
||||
continue
|
||||
var full := dir_path.path_join(name)
|
||||
if d.current_is_dir():
|
||||
_walk(full, exts, out)
|
||||
else:
|
||||
for e in exts:
|
||||
if name.ends_with("." + e):
|
||||
out.append(full)
|
||||
break
|
||||
d.list_dir_end()
|
||||
|
||||
|
||||
static func _check_script(path: String, issues: Array) -> void:
|
||||
# Go through the normal loader so class_name / preload chains resolve against
|
||||
# the real global registry. A detached GDScript.new() + reload() would false-positive
|
||||
# on any script with class_name (duplicate global class registration).
|
||||
var script := ResourceLoader.load(path, "Script")
|
||||
if script == null:
|
||||
issues.append({"path": path, "kind": "script_load_failed"})
|
||||
|
||||
|
||||
static func _check_scene(path: String, issues: Array) -> void:
|
||||
_check_ext_resources_text(path, issues)
|
||||
|
||||
var packed := ResourceLoader.load(path, "PackedScene") as PackedScene
|
||||
if packed == null:
|
||||
issues.append({"path": path, "kind": "scene_load_failed"})
|
||||
return
|
||||
var root := packed.instantiate(PackedScene.GEN_EDIT_STATE_DISABLED)
|
||||
if root == null:
|
||||
issues.append({"path": path, "kind": "scene_instantiate_failed"})
|
||||
return
|
||||
_walk_scene_signals(root, root, path, issues)
|
||||
root.free()
|
||||
|
||||
|
||||
static func _walk_scene_signals(node: Node, scene_root: Node, scene_path: String, issues: Array) -> void:
|
||||
for sig in node.get_signal_list():
|
||||
var sig_name: String = sig.name
|
||||
for c in node.get_signal_connection_list(sig_name):
|
||||
var flags: int = c.flags
|
||||
if not (flags & CONNECT_PERSIST):
|
||||
continue # ignore runtime-only connections
|
||||
var cb: Callable = c.callable
|
||||
var target: Object = cb.get_object()
|
||||
var method: String = cb.get_method()
|
||||
if target == null:
|
||||
issues.append({
|
||||
"path": scene_path,
|
||||
"kind": "signal_target_missing",
|
||||
"node": String(scene_root.get_path_to(node)),
|
||||
"signal": sig_name,
|
||||
})
|
||||
elif not target.has_method(method):
|
||||
issues.append({
|
||||
"path": scene_path,
|
||||
"kind": "signal_method_missing",
|
||||
"node": String(scene_root.get_path_to(node)),
|
||||
"signal": sig_name,
|
||||
"method": method,
|
||||
})
|
||||
for child in node.get_children():
|
||||
if child.owner == scene_root or child == scene_root:
|
||||
_walk_scene_signals(child, scene_root, scene_path, issues)
|
||||
|
||||
|
||||
static func _check_resource(path: String, issues: Array) -> void:
|
||||
var res := ResourceLoader.load(path)
|
||||
if res == null:
|
||||
issues.append({"path": path, "kind": "resource_load_failed"})
|
||||
|
||||
|
||||
# refs.find_usages — find every file that references a given resource.
|
||||
# Target may be either a path ("res://player.gd") or a uid ("uid://abc123").
|
||||
# Searches for both forms so you catch uid-indirected references too.
|
||||
# Params: {target: "res://..." | "uid://..."}
|
||||
static func find_usages(params: Dictionary) -> Dictionary:
|
||||
var target: String = params.get("target", "")
|
||||
if target == "":
|
||||
return _err(-32602, "missing 'target'")
|
||||
|
||||
var terms: Array = [target]
|
||||
if target.begins_with("uid://"):
|
||||
var id := ResourceUID.text_to_id(target)
|
||||
if id != -1 and ResourceUID.has_id(id):
|
||||
var p := ResourceUID.get_id_path(id)
|
||||
if p != "" and p != target:
|
||||
terms.append(p)
|
||||
else:
|
||||
var id := ResourceLoader.get_resource_uid(target)
|
||||
if id != -1:
|
||||
terms.append(ResourceUID.id_to_text(id))
|
||||
|
||||
var files: Array = []
|
||||
_walk("res://", ["gd", "tscn", "tres", "cfg", "gdshader", "gdshaderinc", "cs", "godot"], files)
|
||||
|
||||
var matches: Array = []
|
||||
for path in files:
|
||||
if path.begins_with("res://addons/agent_tools/"):
|
||||
continue
|
||||
if path == target:
|
||||
continue # skip the file itself
|
||||
var f := FileAccess.open(path, FileAccess.READ)
|
||||
if f == null:
|
||||
continue
|
||||
var line_num := 0
|
||||
while not f.eof_reached():
|
||||
line_num += 1
|
||||
var line := f.get_line()
|
||||
for term in terms:
|
||||
if term in line:
|
||||
matches.append({
|
||||
"path": path,
|
||||
"line": line_num,
|
||||
"matched": term,
|
||||
"text": line.strip_edges(),
|
||||
})
|
||||
break
|
||||
|
||||
return _ok({"target": target, "terms_searched": terms, "matches": matches})
|
||||
|
||||
|
||||
# refs.rename — move a file and rewrite all references to it.
|
||||
# Path-form references are text-replaced; uid-form references keep working because
|
||||
# the .uid sidecar moves with the file (preserving the UID → new path mapping).
|
||||
# Params: {from, to, overwrite?: false, dry_run?: false}
|
||||
static func rename(params: Dictionary) -> Dictionary:
|
||||
var from_path: String = params.get("from", "")
|
||||
var to_path: String = params.get("to", "")
|
||||
var overwrite: bool = params.get("overwrite", false)
|
||||
var dry_run: bool = params.get("dry_run", false)
|
||||
|
||||
if from_path == "" or to_path == "":
|
||||
return _err(-32602, "missing 'from' or 'to'")
|
||||
if not (from_path.begins_with("res://") and to_path.begins_with("res://")):
|
||||
return _err(-32602, "paths must be 'res://...'")
|
||||
if from_path == to_path:
|
||||
return _err(-32602, "'from' and 'to' are the same")
|
||||
if not FileAccess.file_exists(from_path):
|
||||
return _err(-32001, "source not found: %s" % from_path)
|
||||
if FileAccess.file_exists(to_path) and not overwrite:
|
||||
return _err(-32602, "destination exists (pass overwrite:true): %s" % to_path)
|
||||
|
||||
# Collect path-form usages. UID-form references are preserved automatically when
|
||||
# the .uid sidecar moves with the file, so we don't need to rewrite them.
|
||||
var usage := find_usages({"target": from_path})
|
||||
if usage.has("error"):
|
||||
return usage
|
||||
var edits: Dictionary = {}
|
||||
for m in usage.data.matches:
|
||||
if m.matched != from_path:
|
||||
continue # uid-form match — no rewrite needed
|
||||
if not edits.has(m.path):
|
||||
edits[m.path] = true
|
||||
|
||||
if dry_run:
|
||||
return _ok({
|
||||
"would_move": {"from": from_path, "to": to_path},
|
||||
"would_update": edits.keys(),
|
||||
})
|
||||
|
||||
var dir_path := to_path.get_base_dir()
|
||||
if not DirAccess.dir_exists_absolute(dir_path):
|
||||
var derr := DirAccess.make_dir_recursive_absolute(dir_path)
|
||||
if derr != OK:
|
||||
return _err(-32001, "mkdir failed (%d): %s" % [derr, dir_path])
|
||||
|
||||
var move_err := DirAccess.rename_absolute(from_path, to_path)
|
||||
if move_err != OK:
|
||||
return _err(-32001, "move failed (%d)" % move_err)
|
||||
|
||||
# Move .uid sidecar so UIDs continue resolving to the new path. Best-effort —
|
||||
# sidecar failures don't block the main rename that already succeeded.
|
||||
var from_uid := from_path + ".uid"
|
||||
if FileAccess.file_exists(from_uid):
|
||||
@warning_ignore("return_value_discarded")
|
||||
DirAccess.rename_absolute(from_uid, to_path + ".uid")
|
||||
# Move .import sidecar for imported assets (textures, audio, etc.).
|
||||
var from_import := from_path + ".import"
|
||||
if FileAccess.file_exists(from_import):
|
||||
@warning_ignore("return_value_discarded")
|
||||
DirAccess.rename_absolute(from_import, to_path + ".import")
|
||||
|
||||
var files_updated: Array = []
|
||||
for ref_path in edits.keys():
|
||||
var rf := FileAccess.open(ref_path, FileAccess.READ)
|
||||
if rf == null:
|
||||
continue
|
||||
var content := rf.get_as_text()
|
||||
rf.close()
|
||||
var new_content := content.replace(from_path, to_path)
|
||||
if new_content == content:
|
||||
continue
|
||||
var wf := FileAccess.open(ref_path, FileAccess.WRITE)
|
||||
if wf == null:
|
||||
continue
|
||||
wf.store_string(new_content)
|
||||
wf.close()
|
||||
files_updated.append(ref_path)
|
||||
|
||||
EditorInterface.get_resource_filesystem().scan()
|
||||
return _ok({
|
||||
"moved": {"from": from_path, "to": to_path},
|
||||
"files_updated": files_updated,
|
||||
})
|
||||
|
||||
|
||||
# refs.rename_class — rename `class_name X` to `class_name Y` and rewrite every
|
||||
# word-boundary reference to X across .gd / .tscn / .tres files. Best-effort:
|
||||
# won't rewrite an X that happens to be a local variable name (word boundary
|
||||
# alone isn't semantic analysis). Use dry_run first on anything non-trivial.
|
||||
# Params: {from, to, dry_run?: false}
|
||||
static func rename_class(params: Dictionary) -> Dictionary:
|
||||
var from_name: String = params.get("from", "")
|
||||
var to_name: String = params.get("to", "")
|
||||
var dry_run: bool = params.get("dry_run", false)
|
||||
|
||||
if from_name == "" or to_name == "":
|
||||
return _err(-32602, "missing 'from' or 'to'")
|
||||
if not _is_valid_identifier(from_name):
|
||||
return _err(-32602, "'from' is not a valid identifier: %s" % from_name)
|
||||
if not _is_valid_identifier(to_name):
|
||||
return _err(-32602, "'to' is not a valid identifier: %s" % to_name)
|
||||
if from_name == to_name:
|
||||
return _err(-32602, "'from' and 'to' are identical")
|
||||
|
||||
var defining_script := _find_class_definition(from_name)
|
||||
if defining_script == "":
|
||||
return _err(-32001, "no script found declaring class_name %s" % from_name)
|
||||
if _find_class_definition(to_name) != "":
|
||||
return _err(-32602, "class_name %s already exists" % to_name)
|
||||
|
||||
var files: Array = []
|
||||
_walk("res://", ["gd", "tscn", "tres"], files)
|
||||
|
||||
var regex := RegEx.new()
|
||||
regex.compile("\\b%s\\b" % from_name)
|
||||
|
||||
var edits: Array = []
|
||||
for path in files:
|
||||
if path.begins_with("res://addons/agent_tools/"):
|
||||
continue
|
||||
var f := FileAccess.open(path, FileAccess.READ)
|
||||
if f == null:
|
||||
continue
|
||||
var text := f.get_as_text()
|
||||
f.close()
|
||||
if regex.search(text) != null:
|
||||
edits.append(path)
|
||||
|
||||
if dry_run:
|
||||
return _ok({
|
||||
"from": from_name,
|
||||
"to": to_name,
|
||||
"defining_script": defining_script,
|
||||
"would_update": edits,
|
||||
})
|
||||
|
||||
var updated: Array = []
|
||||
for path in edits:
|
||||
var f := FileAccess.open(path, FileAccess.READ)
|
||||
if f == null:
|
||||
continue
|
||||
var text := f.get_as_text()
|
||||
f.close()
|
||||
var new_text := regex.sub(text, to_name, true)
|
||||
if new_text == text:
|
||||
continue
|
||||
var wf := FileAccess.open(path, FileAccess.WRITE)
|
||||
if wf == null:
|
||||
continue
|
||||
wf.store_string(new_text)
|
||||
wf.close()
|
||||
updated.append(path)
|
||||
|
||||
EditorInterface.get_resource_filesystem().scan()
|
||||
return _ok({
|
||||
"from": from_name,
|
||||
"to": to_name,
|
||||
"defining_script": defining_script,
|
||||
"updated": updated,
|
||||
})
|
||||
|
||||
|
||||
static func _is_valid_identifier(s: String) -> bool:
|
||||
var re := RegEx.new()
|
||||
re.compile("^[A-Za-z_][A-Za-z0-9_]*$")
|
||||
return re.search(s) != null
|
||||
|
||||
|
||||
static func _find_class_definition(class_name_val: String) -> String:
|
||||
var files: Array = []
|
||||
_walk("res://", ["gd"], files)
|
||||
var re := RegEx.new()
|
||||
re.compile("^\\s*class_name\\s+%s\\b" % class_name_val)
|
||||
for path in files:
|
||||
if path.begins_with("res://addons/agent_tools/"):
|
||||
continue
|
||||
var f := FileAccess.open(path, FileAccess.READ)
|
||||
if f == null:
|
||||
continue
|
||||
var text := f.get_as_text()
|
||||
f.close()
|
||||
for line in text.split("\n"):
|
||||
if re.search(line) != null:
|
||||
return path
|
||||
return ""
|
||||
|
||||
|
||||
static func _ok(data) -> Dictionary:
|
||||
return {"data": data}
|
||||
|
||||
|
||||
static func _err(code: int, msg: String) -> Dictionary:
|
||||
return {"error": {"code": code, "message": msg}}
|
||||
|
||||
|
||||
# Parse the raw .tscn/.tres text for [ext_resource ... path="..."] and verify each target resolves.
|
||||
# Catches references to files that were deleted or moved without updating the scene.
|
||||
static func _check_ext_resources_text(path: String, issues: Array) -> void:
|
||||
var f := FileAccess.open(path, FileAccess.READ)
|
||||
if f == null:
|
||||
return
|
||||
var text := f.get_as_text()
|
||||
var regex := RegEx.new()
|
||||
regex.compile("\\[ext_resource[^\\]]*path=\"([^\"]+)\"")
|
||||
for m in regex.search_all(text):
|
||||
var ref: String = m.get_string(1)
|
||||
if ref.begins_with("uid://"):
|
||||
var id := ResourceUID.text_to_id(ref)
|
||||
if id == -1 or not ResourceUID.has_id(id):
|
||||
issues.append({"path": path, "kind": "missing_uid", "uid": ref})
|
||||
continue
|
||||
if not ResourceLoader.exists(ref):
|
||||
issues.append({"path": path, "kind": "missing_ext_resource", "target": ref})
|
||||
Reference in new issue
Block a user