Files
server-deploy/claude-dev-stack/godot-mcp-pro-v1.14.1/addons/godot_mcp/commands/analysis_commands.gd

519 lines
17 KiB
GDScript

@tool
extends "res://addons/godot_mcp/commands/base_command.gd"
func get_commands() -> Dictionary:
return {
"find_unused_resources": _find_unused_resources,
"analyze_signal_flow": _analyze_signal_flow,
"analyze_scene_complexity": _analyze_scene_complexity,
"find_script_references": _find_script_references,
"detect_circular_dependencies": _detect_circular_dependencies,
"get_project_statistics": _get_project_statistics,
}
# =============================================================================
# find_unused_resources
# =============================================================================
## Scan project for resources not referenced by any .tscn, .gd, or .tres file.
func _find_unused_resources(params: Dictionary) -> Dictionary:
var path: String = optional_string(params, "path", "res://")
var include_addons: bool = optional_bool(params, "include_addons", false)
# Step 1: Collect all resource files
var resource_extensions: Array = ["tres", "tscn", "png", "jpg", "jpeg", "svg",
"wav", "ogg", "mp3", "ttf", "otf", "gdshader", "material",
"theme", "stylebox", "font", "anim"]
var all_resources: Array = []
_collect_files_by_ext(path, resource_extensions, all_resources, include_addons)
# Step 2: Collect all referencing files (.tscn, .gd, .tres)
var ref_extensions: Array = ["tscn", "gd", "tres", "cfg", "godot"]
var ref_files: Array = []
_collect_files_by_ext(path, ref_extensions, ref_files, include_addons)
# Step 3: Build a set of all referenced paths
var referenced: Dictionary = {} # path -> true
for ref_file in ref_files:
var content := _read_file_text(ref_file as String)
if content.is_empty():
continue
# Find res:// paths in file content
var idx := 0
while idx < content.length():
var found := content.find("res://", idx)
if found == -1:
break
# Extract the path (up to quote, space, or end of line)
var end := found + 6
while end < content.length():
var c := content[end]
if c == '"' or c == "'" or c == ' ' or c == '\n' or c == '\r' or c == ')' or c == ']' or c == '}':
break
end += 1
var ref_path := content.substr(found, end - found)
referenced[ref_path] = true
idx = end
# Step 4: Find unreferenced resources
var unused: Array = []
for res_path in all_resources:
var p: String = res_path
if not referenced.has(p):
# Also check without uid:// prefix variants — some references use uid
unused.append(p)
return success({
"unused_resources": unused,
"unused_count": unused.size(),
"total_resources_scanned": all_resources.size(),
"total_files_checked": ref_files.size(),
})
# =============================================================================
# analyze_signal_flow
# =============================================================================
## Map all signal connections in the currently edited scene.
func _analyze_signal_flow(params: Dictionary) -> Dictionary:
var root := get_edited_root()
if root == null:
return error_no_scene()
var nodes_data: Array = []
_collect_signal_data(root, root, nodes_data)
return success({
"scene": root.scene_file_path,
"nodes": nodes_data,
"total_nodes": nodes_data.size(),
})
func _collect_signal_data(node: Node, root: Node, out: Array) -> void:
var node_path := str(root.get_path_to(node))
var signals_emitted: Array = []
var signals_connected_to: Array = []
# Get all signals this node defines
for sig in node.get_signal_list():
var sig_name: String = sig["name"]
var connections := node.get_signal_connection_list(sig_name)
if connections.size() > 0:
var targets: Array = []
for conn in connections:
var callable: Callable = conn["callable"]
var target_node: Node = callable.get_object() as Node
var target_path := ""
if target_node != null:
target_path = str(root.get_path_to(target_node))
targets.append({
"target_node": target_path,
"method": callable.get_method(),
})
# Also record on the target side
signals_connected_to.append({
"from_node": node_path,
"signal": sig_name,
"method": callable.get_method(),
})
signals_emitted.append({
"signal": sig_name,
"targets": targets,
})
# Only include nodes that have signal activity
if signals_emitted.size() > 0 or signals_connected_to.size() > 0:
out.append({
"name": node.name,
"path": node_path,
"type": node.get_class(),
"signals_emitted": signals_emitted,
"signals_connected_to": signals_connected_to,
})
for child in node.get_children():
_collect_signal_data(child, root, out)
# =============================================================================
# analyze_scene_complexity
# =============================================================================
## Analyze a scene's complexity: node count, depth, types, scripts, potential issues.
func _analyze_scene_complexity(params: Dictionary) -> Dictionary:
var scene_path: String = optional_string(params, "path", "")
var root: Node = null
if scene_path.is_empty():
root = get_edited_root()
if root == null:
return error_no_scene()
scene_path = root.scene_file_path
else:
if not ResourceLoader.exists(scene_path):
return error_not_found("Scene '%s'" % scene_path)
var packed := ResourceLoader.load(scene_path) as PackedScene
if packed == null:
return error_internal("Failed to load scene: %s" % scene_path)
root = packed.instantiate()
var total_nodes := 0
var max_depth := 0
var types: Dictionary = {} # class_name -> count
var scripts_attached: Array = []
var resources_used: Dictionary = {} # resource path -> count
var issues: Array = []
_analyze_node(root, root, 0, total_nodes, max_depth, types, scripts_attached, resources_used)
# Count totals from recursive walk
total_nodes = _count_nodes_recursive(root)
max_depth = _get_max_depth(root, 0)
# Detect potential issues
if total_nodes > 1000:
issues.append({"severity": "warning", "message": "Scene has %d nodes (>1000). Consider splitting into sub-scenes." % total_nodes})
elif total_nodes > 500:
issues.append({"severity": "info", "message": "Scene has %d nodes (>500). Monitor performance." % total_nodes})
if max_depth > 15:
issues.append({"severity": "warning", "message": "Max nesting depth is %d (>15). Deep hierarchies can be hard to maintain." % max_depth})
elif max_depth > 10:
issues.append({"severity": "info", "message": "Max nesting depth is %d (>10)." % max_depth})
# If we instantiated the scene ourselves, free it
if not scene_path.is_empty() and root != get_edited_root():
root.queue_free()
return success({
"scene_path": scene_path,
"total_nodes": total_nodes,
"max_depth": max_depth,
"nodes_by_type": types,
"scripts_attached": scripts_attached,
"unique_resource_count": resources_used.size(),
"issues": issues,
})
func _analyze_node(node: Node, root: Node, depth: int,
total_nodes: int, max_depth: int,
types: Dictionary, scripts: Array, resources: Dictionary) -> void:
var type_name := node.get_class()
types[type_name] = types.get(type_name, 0) + 1
if node.get_script() != null:
var script: Script = node.get_script()
var script_path := script.resource_path
if not script_path.is_empty():
scripts.append({
"node": str(root.get_path_to(node)),
"script": script_path,
})
for child in node.get_children():
_analyze_node(child, root, depth + 1, total_nodes, max_depth, types, scripts, resources)
func _count_nodes_recursive(node: Node) -> int:
var count := 1
for child in node.get_children():
count += _count_nodes_recursive(child)
return count
func _get_max_depth(node: Node, current_depth: int) -> int:
var max_d := current_depth
for child in node.get_children():
var child_depth := _get_max_depth(child, current_depth + 1)
if child_depth > max_d:
max_d = child_depth
return max_d
# =============================================================================
# find_script_references
# =============================================================================
## Find all places where a given script, class_name, or resource path is used.
func _find_script_references(params: Dictionary) -> Dictionary:
var result := require_string(params, "query")
if result[1] != null:
return result[1]
var query: String = result[0]
var path: String = optional_string(params, "path", "res://")
var include_addons: bool = optional_bool(params, "include_addons", false)
var search_extensions: Array = ["tscn", "gd", "tres", "cfg", "godot"]
var search_files: Array = []
_collect_files_by_ext(path, search_extensions, search_files, include_addons)
var references: Array = []
for file_path in search_files:
var fp: String = file_path
var content := _read_file_text(fp)
if content.is_empty():
continue
var lines := content.split("\n")
var line_num := 0
for line in lines:
line_num += 1
var l: String = line
if l.contains(query):
references.append({
"file": fp,
"line": line_num,
"content": l.strip_edges(),
})
return success({
"query": query,
"references": references,
"reference_count": references.size(),
"files_searched": search_files.size(),
})
# =============================================================================
# detect_circular_dependencies
# =============================================================================
## Check for circular scene dependencies (.tscn files referencing each other).
func _detect_circular_dependencies(params: Dictionary) -> Dictionary:
var path: String = optional_string(params, "path", "res://")
var include_addons: bool = optional_bool(params, "include_addons", false)
# Step 1: Collect all .tscn files
var tscn_files: Array = []
_collect_files_by_ext(path, ["tscn"], tscn_files, include_addons)
# Step 2: Build dependency graph: scene_path -> [referenced_scene_paths]
var dep_graph: Dictionary = {} # String -> Array[String]
for tscn_path in tscn_files:
var tp: String = tscn_path
var content := _read_file_text(tp)
if content.is_empty():
continue
var deps: Array = []
for line in content.split("\n"):
var l: String = line
# Match [ext_resource ... path="res://..." ...] lines that reference .tscn
if l.begins_with("[ext_resource") and ".tscn" in l:
var path_start := l.find('path="')
if path_start == -1:
continue
path_start += 6 # len('path="')
var path_end := l.find('"', path_start)
if path_end == -1:
continue
var ref_path := l.substr(path_start, path_end - path_start)
if ref_path.ends_with(".tscn"):
deps.append(ref_path)
dep_graph[tp] = deps
# Step 3: Detect cycles using DFS
var cycles: Array = []
var visited: Dictionary = {} # path -> "unvisited" | "visiting" | "visited"
for scene in dep_graph:
visited[scene] = "unvisited"
for scene in dep_graph:
if visited[scene] == "unvisited":
var path_stack: Array = []
_dfs_detect_cycle(scene as String, dep_graph, visited, path_stack, cycles)
return success({
"scenes_checked": tscn_files.size(),
"circular_dependencies": cycles,
"has_circular": cycles.size() > 0,
"dependency_graph": dep_graph,
})
func _dfs_detect_cycle(node: String, graph: Dictionary, visited: Dictionary,
path_stack: Array, cycles: Array) -> void:
visited[node] = "visiting"
path_stack.append(node)
if graph.has(node):
var deps: Array = graph[node]
for dep in deps:
var d: String = dep
if not visited.has(d):
# Scene referenced but not in our graph (might not exist or outside scope)
continue
if visited[d] == "visiting":
# Found a cycle — extract it from the stack
var cycle_start := path_stack.find(d)
var cycle: Array = path_stack.slice(cycle_start)
cycle.append(d) # Close the cycle
cycles.append(cycle)
elif visited[d] == "unvisited":
_dfs_detect_cycle(d, graph, visited, path_stack, cycles)
path_stack.pop_back()
visited[node] = "visited"
# =============================================================================
# get_project_statistics
# =============================================================================
## Overall project stats: file counts, script lines, scenes, resources, autoloads, plugins.
func _get_project_statistics(params: Dictionary) -> Dictionary:
var path: String = optional_string(params, "path", "res://")
var include_addons: bool = optional_bool(params, "include_addons", false)
var file_counts: Dictionary = {} # extension -> count
var total_script_lines := 0
var scene_count := 0
var resource_count := 0
var total_files := 0
_collect_statistics(path, include_addons, file_counts)
# Extract internal counters and remove them from the visible dict
total_script_lines = int(file_counts.get("_total_script_lines", 0))
scene_count = int(file_counts.get("_scene_count", 0))
resource_count = int(file_counts.get("_resource_count", 0))
total_files = int(file_counts.get("_total_files", 0))
file_counts.erase("_total_script_lines")
file_counts.erase("_scene_count")
file_counts.erase("_resource_count")
file_counts.erase("_total_files")
# Autoloads
var autoloads: Dictionary = {}
for prop in ProjectSettings.get_property_list():
var prop_name: String = prop["name"]
if prop_name.begins_with("autoload/"):
autoloads[prop_name.substr(9)] = str(ProjectSettings.get_setting(prop_name))
# Enabled plugins
var plugins: Array = []
var plugin_cfg_path := "res://addons"
var enabled_plugins: PackedStringArray = ProjectSettings.get_setting(
"editor_plugins/enabled", PackedStringArray()
)
var plugin_dir := DirAccess.open(plugin_cfg_path)
if plugin_dir != null:
plugin_dir.list_dir_begin()
var dir_name := plugin_dir.get_next()
while not dir_name.is_empty():
if plugin_dir.current_is_dir() and not dir_name.begins_with("."):
var cfg_path := plugin_cfg_path.path_join(dir_name).path_join("plugin.cfg")
if FileAccess.file_exists(cfg_path):
var plugin_path := "res://addons/%s/plugin.cfg" % dir_name
plugins.append({
"name": dir_name,
"enabled": plugin_path in enabled_plugins,
})
dir_name = plugin_dir.get_next()
plugin_dir.list_dir_end()
return success({
"file_counts_by_extension": file_counts,
"total_files": total_files,
"total_script_lines": total_script_lines,
"scene_count": scene_count,
"resource_count": resource_count,
"autoloads": autoloads,
"plugins": plugins,
})
func _collect_statistics(path: String, include_addons: bool,
file_counts: Dictionary) -> void:
var dir := DirAccess.open(path)
if dir == null:
return
dir.list_dir_begin()
var file_name := dir.get_next()
while not file_name.is_empty():
if file_name.begins_with("."):
file_name = dir.get_next()
continue
var full_path := path.path_join(file_name)
if dir.current_is_dir():
if file_name == "addons" and not include_addons:
file_name = dir.get_next()
continue
_collect_statistics(full_path, include_addons, file_counts)
else:
var ext := file_name.get_extension().to_lower()
file_counts[ext] = file_counts.get(ext, 0) + 1
if ext == "gd":
var content := _read_file_text(full_path)
var line_count := content.count("\n") + 1 if not content.is_empty() else 0
# We can't modify int params, so we store in the dict
file_counts["_total_script_lines"] = file_counts.get("_total_script_lines", 0) + line_count
if ext == "tscn":
file_counts["_scene_count"] = file_counts.get("_scene_count", 0) + 1
if ext in ["tres", "material", "theme", "stylebox", "font"]:
file_counts["_resource_count"] = file_counts.get("_resource_count", 0) + 1
file_counts["_total_files"] = file_counts.get("_total_files", 0) + 1
file_name = dir.get_next()
dir.list_dir_end()
# =============================================================================
# Shared helpers
# =============================================================================
## Recursively collect files matching given extensions.
func _collect_files_by_ext(path: String, extensions: Array, out: Array, include_addons: bool) -> void:
var dir := DirAccess.open(path)
if dir == null:
return
dir.list_dir_begin()
var file_name := dir.get_next()
while not file_name.is_empty():
if file_name.begins_with("."):
file_name = dir.get_next()
continue
var full_path := path.path_join(file_name)
if dir.current_is_dir():
if file_name == "addons" and not include_addons:
file_name = dir.get_next()
continue
_collect_files_by_ext(full_path, extensions, out, include_addons)
else:
var ext := file_name.get_extension().to_lower()
if ext in extensions:
out.append(full_path)
file_name = dir.get_next()
dir.list_dir_end()
## Read a file's text content. Returns empty string on failure.
func _read_file_text(file_path: String) -> String:
var file := FileAccess.open(file_path, FileAccess.READ)
if file == null:
return ""
var content := file.get_as_text()
file.close()
return content