diff --git a/.gitignore b/.gitignore
index a5f4abc..f9581d7 100644
--- a/.gitignore
+++ b/.gitignore
@@ -33,3 +33,6 @@ htmlcov/
# mypy
.mypy_cache/
+
+# gdcruiser incremental parse cache
+.gdcruiser_cache.json
diff --git a/README.md b/README.md
index 44c7081..25c5381 100644
--- a/README.md
+++ b/README.md
@@ -43,8 +43,16 @@ gdcruiser [-h] [-f {text,json,dot,mermaid}] [-o FILE] [--no-cycles] [-v] [path]
| `--validate-config` | Validate config file and exit |
| `--ignore-rules` | Skip rule evaluation |
| `--exclude PATTERN` | Regex pattern to exclude paths (can be repeated) |
+| `--cache` | Enable incremental parse caching (default file: `.gdcruiser_cache.json`) |
+| `--cache-file FILE` | Path to the incremental parse cache (implies `--cache`) |
| `-v, --verbose` | Verbose output |
+With caching enabled, unchanged files (matched by modification time and size)
+are restored from the cache instead of being re-parsed — useful for large
+projects run repeatedly in CI or a pre-commit hook. Symbol resolution and cycle
+detection always run fresh, so results are identical to an uncached run. Add
+the cache file to your `.gitignore`.
+
### Examples
Analyze the current directory:
diff --git a/src/gdcruiser/analyzer.py b/src/gdcruiser/analyzer.py
index f3e5ef5..3f34fbe 100644
--- a/src/gdcruiser/analyzer.py
+++ b/src/gdcruiser/analyzer.py
@@ -1,6 +1,7 @@
from dataclasses import dataclass, field
from pathlib import Path
+from .cache import ParseCache
from .scanner import Scanner
from .parser.gdscript import GDScriptParser
from .parser.tscn import TscnParser
@@ -8,6 +9,7 @@
from .parser.project_godot import parse_autoloads
from .graph.dependency import DependencyGraph
from .graph.cycles import CycleDetector
+from .graph.node import Module
from .symbols.table import SymbolTable
@@ -19,6 +21,7 @@ class AnalysisResult:
cycles: list[list[str]] = field(default_factory=list)
symbol_table: SymbolTable = field(default_factory=SymbolTable)
errors: list[str] = field(default_factory=list)
+ warnings: list[str] = field(default_factory=list)
def to_dict(self) -> dict:
return {
@@ -26,6 +29,7 @@ def to_dict(self) -> dict:
"cycles": self.cycles,
"symbols": self.symbol_table.all_classes(),
"errors": self.errors,
+ "warnings": self.warnings,
}
@@ -37,6 +41,7 @@ def __init__(
project_path: Path,
verbose: bool = False,
exclude: list[str] | None = None,
+ cache: ParseCache | None = None,
) -> None:
self._scanner = Scanner(project_path, exclude=exclude)
self._symbol_table = SymbolTable()
@@ -45,7 +50,9 @@ def __init__(
self._tres_parser = TresParser(self._symbol_table)
self._graph = DependencyGraph()
self._verbose = verbose
+ self._cache = cache
self._errors: list[str] = []
+ self._warnings: list[str] = []
def analyze(self, detect_cycles: bool = True) -> AnalysisResult:
"""Analyze the project and return results."""
@@ -54,8 +61,13 @@ def analyze(self, detect_cycles: bool = True) -> AnalysisResult:
# Register project.godot [autoload] singletons before any class-ref
# resolution runs — `TurnManager.foo` then resolves the same way as
- # any other class_name reference.
- autoloads = parse_autoloads(root)
+ # any other class_name reference. A malformed/unreadable project.godot
+ # must not abort the whole analysis.
+ try:
+ autoloads = parse_autoloads(root)
+ except Exception as e:
+ autoloads = {}
+ self._errors.append(f"Error parsing project.godot: {e}")
for identifier, path in autoloads.items():
self._symbol_table.register(identifier, path)
@@ -69,12 +81,10 @@ def analyze(self, detect_cycles: bool = True) -> AnalysisResult:
# First pass: parse all GDScript files to build symbol table
modules = []
for gd_file in gd_files:
- try:
- module = self._gd_parser.parse(gd_file, root)
+ module = self._parse_file(gd_file, self._gd_parser, root)
+ if module is not None:
modules.append(module)
self._graph.add_module(module)
- except Exception as e:
- self._errors.append(f"Error parsing {gd_file}: {e}")
# Second pass: resolve class name dependencies
for module in modules:
@@ -82,19 +92,22 @@ def analyze(self, detect_cycles: bool = True) -> AnalysisResult:
# Parse scene files
for tscn_file in tscn_files:
- try:
- module = self._tscn_parser.parse(tscn_file, root)
+ module = self._parse_file(tscn_file, self._tscn_parser, root)
+ if module is not None:
self._graph.add_module(module)
- except Exception as e:
- self._errors.append(f"Error parsing {tscn_file}: {e}")
# Parse resource (.tres) files
for tres_file in tres_files:
- try:
- module = self._tres_parser.parse(tres_file, root)
+ module = self._parse_file(tres_file, self._tres_parser, root)
+ if module is not None:
self._graph.add_module(module)
- except Exception as e:
- self._errors.append(f"Error parsing {tres_file}: {e}")
+
+ if self._cache is not None:
+ self._cache.save()
+ if self._verbose:
+ print(
+ f"Parse cache: {self._cache.hits} hits, {self._cache.misses} misses"
+ )
# Detect cycles
cycles: list[list[str]] = []
@@ -104,9 +117,40 @@ def analyze(self, detect_cycles: bool = True) -> AnalysisResult:
if self._verbose:
print(f"Found {len(cycles)} cycles")
+ # Surface duplicate class_name / autoload registrations as warnings.
+ for name, existing, new in self._symbol_table.collisions():
+ self._warnings.append(
+ f"Duplicate symbol '{name}': registered by both "
+ f"{existing} and {new} (using {new})"
+ )
+
return AnalysisResult(
graph=self._graph,
cycles=cycles,
symbol_table=self._symbol_table,
errors=self._errors,
+ warnings=self._warnings,
)
+
+ def _parse_file(self, file_path: Path, parser, root: Path) -> Module | None:
+ """Parse a file, restoring it from the cache when unchanged.
+
+ Cached modules are returned verbatim, but their declared class_name is
+ re-registered in the symbol table so cross-file resolution still works
+ without re-parsing the file body.
+ """
+ try:
+ if self._cache is not None:
+ key = self._cache.stat_key(file_path)
+ cached = self._cache.get(file_path, key)
+ if cached is not None:
+ if cached.class_name:
+ self._symbol_table.register(cached.class_name, cached.path)
+ return cached
+ module = parser.parse(file_path, root)
+ self._cache.put(file_path, key, module)
+ return module
+ return parser.parse(file_path, root)
+ except Exception as e:
+ self._errors.append(f"Error parsing {file_path}: {e}")
+ return None
diff --git a/src/gdcruiser/cache.py b/src/gdcruiser/cache.py
new file mode 100644
index 0000000..d08f38c
--- /dev/null
+++ b/src/gdcruiser/cache.py
@@ -0,0 +1,71 @@
+"""Incremental parse cache.
+
+Persists the *raw* parsed :class:`Module` for each source file keyed by the
+file's modification time and size. On a subsequent run, unchanged files are
+restored from the cache instead of being re-read and re-parsed.
+
+Only per-file parsing is cached. Cross-file work (symbol resolution and cycle
+detection) always runs fresh, because a change in one file can alter how an
+unchanged file's class references resolve.
+"""
+
+import json
+from pathlib import Path
+
+from .graph.node import Module
+
+# Bump when the cached representation or parsing semantics change, so stale
+# caches from older versions are transparently ignored.
+CACHE_VERSION = 1
+
+
+class ParseCache:
+ """A file-backed cache of parsed modules keyed by (mtime_ns, size)."""
+
+ def __init__(self, cache_path: Path) -> None:
+ self._path = cache_path
+ self._old: dict[str, dict] = {}
+ self._new: dict[str, dict] = {}
+ self.hits = 0
+ self.misses = 0
+
+ def load(self) -> None:
+ """Load an existing cache file, ignoring anything unreadable or stale."""
+ try:
+ data = json.loads(self._path.read_text(encoding="utf-8"))
+ except (OSError, ValueError):
+ return
+ if isinstance(data, dict) and data.get("version") == CACHE_VERSION:
+ entries = data.get("entries")
+ if isinstance(entries, dict):
+ self._old = entries
+
+ @staticmethod
+ def stat_key(file_path: Path) -> str:
+ """Return a cache key capturing the file's current mtime and size."""
+ stat = file_path.stat()
+ return f"{stat.st_mtime_ns}:{stat.st_size}"
+
+ def get(self, file_path: Path, key: str) -> Module | None:
+ """Return the cached module for ``file_path`` if its key still matches."""
+ path_key = str(file_path)
+ entry = self._old.get(path_key)
+ if entry is not None and entry.get("key") == key:
+ self.hits += 1
+ # Carry the still-valid entry forward so it survives the next save.
+ self._new[path_key] = entry
+ return Module.from_dict(entry["module"])
+ self.misses += 1
+ return None
+
+ def put(self, file_path: Path, key: str, module: Module) -> None:
+ """Store a freshly parsed module under its current key."""
+ self._new[str(file_path)] = {"key": key, "module": module.to_dict()}
+
+ def save(self) -> None:
+ """Write the accumulated entries back to disk."""
+ payload = {"version": CACHE_VERSION, "entries": self._new}
+ try:
+ self._path.write_text(json.dumps(payload), encoding="utf-8")
+ except OSError:
+ pass
diff --git a/src/gdcruiser/cli.py b/src/gdcruiser/cli.py
index 063fd78..ea9a3ac 100644
--- a/src/gdcruiser/cli.py
+++ b/src/gdcruiser/cli.py
@@ -3,11 +3,9 @@
from pathlib import Path
from .analyzer import Analyzer
+from .cache import ParseCache
from .config import ConfigError, ConfigLoader, ConfigValidator
-from .output.dot import DotFormatter
-from .output.json import JsonFormatter
-from .output.mermaid import MermaidFormatter
-from .output.text import TextFormatter
+from .output import FORMATTERS
from .rules import RuleEngine
@@ -40,7 +38,7 @@ def create_parser() -> argparse.ArgumentParser:
parser.add_argument(
"-f",
"--format",
- choices=["text", "json", "dot", "mermaid"],
+ choices=list(FORMATTERS),
default="text",
help="Output format (default: text)",
)
@@ -91,6 +89,18 @@ def create_parser() -> argparse.ArgumentParser:
help="Regex pattern to exclude paths from analysis (can be repeated)",
)
+ parser.add_argument(
+ "--cache",
+ action="store_true",
+ help="Enable incremental parse caching (default file: .gdcruiser_cache.json)",
+ )
+
+ parser.add_argument(
+ "--cache-file",
+ metavar="FILE",
+ help="Path to the incremental parse cache (implies --cache)",
+ )
+
return parser
@@ -115,6 +125,11 @@ def run(args: argparse.Namespace) -> int:
print(f"Error: {e}", file=sys.stderr)
return 1
+ # Surface non-fatal config issues (unknown keys, bad severity values) even
+ # when they leave no valid rules behind (e.g. a mistyped section name).
+ for w in config.warnings:
+ print(f"Warning: {w}", file=sys.stderr)
+
# Validate config
if config.has_rules() or args.validate_config:
validator = ConfigValidator()
@@ -150,7 +165,22 @@ def run(args: argparse.Namespace) -> int:
if args.exclude:
exclude.extend(args.exclude)
- analyzer = Analyzer(project_path, verbose=args.verbose, exclude=exclude or None)
+ cache = None
+ if args.cache or args.cache_file:
+ cache_path = (
+ Path(args.cache_file)
+ if args.cache_file
+ else project_path / ".gdcruiser_cache.json"
+ )
+ cache = ParseCache(cache_path)
+ cache.load()
+
+ analyzer = Analyzer(
+ project_path,
+ verbose=args.verbose,
+ exclude=exclude or None,
+ cache=cache,
+ )
result = analyzer.analyze(detect_cycles=not args.no_cycles)
# Evaluate rules
@@ -166,18 +196,8 @@ def run(args: argparse.Namespace) -> int:
)
# Format output
- if args.format == "json":
- formatter = JsonFormatter()
- output = formatter.format(result, rule_result)
- elif args.format == "dot":
- formatter = DotFormatter()
- output = formatter.format(result)
- elif args.format == "mermaid":
- formatter = MermaidFormatter()
- output = formatter.format(result)
- else:
- formatter = TextFormatter()
- output = formatter.format(result, rule_result)
+ formatter = FORMATTERS[args.format]()
+ output = formatter.format(result, rule_result)
# Write output
if args.output:
diff --git a/src/gdcruiser/config/loader.py b/src/gdcruiser/config/loader.py
index c85621c..9e3a2b3 100644
--- a/src/gdcruiser/config/loader.py
+++ b/src/gdcruiser/config/loader.py
@@ -34,7 +34,7 @@ def discover(self) -> Path | None:
content = pyproject_path.read_text(encoding="utf-8")
if "[tool.gdcruiser]" in content or "[[tool.gdcruiser." in content:
return pyproject_path
- except Exception:
+ except (OSError, UnicodeDecodeError):
pass
return None
@@ -90,32 +90,68 @@ def _load_pyproject(self, path: Path) -> Config:
return self._parse_config(tool_config)
+ _TOP_LEVEL_KEYS = frozenset({"forbidden", "allowed", "required", "options"})
+ _RULE_KEYS = frozenset(
+ {"name", "severity", "comment", "from", "to", "circular", "orphan"}
+ )
+ _MATCHER_KEYS = frozenset({"path", "pathNot"})
+ _OPTION_KEYS = frozenset({"exclude"})
+
def _parse_config(self, data: dict) -> Config:
"""Parse configuration dictionary into Config object."""
- forbidden = [self._parse_rule(r) for r in data.get("forbidden", [])]
- allowed = [self._parse_rule(r) for r in data.get("allowed", [])]
- required = [self._parse_rule(r) for r in data.get("required", [])]
- options = self._parse_options(data.get("options", {}))
+ warnings: list[str] = []
+ self._warn_unknown_keys(data, self._TOP_LEVEL_KEYS, "config", warnings)
+
+ forbidden = [
+ self._parse_rule(r, f"forbidden[{i}]", warnings)
+ for i, r in enumerate(data.get("forbidden", []))
+ ]
+ allowed = [
+ self._parse_rule(r, f"allowed[{i}]", warnings)
+ for i, r in enumerate(data.get("allowed", []))
+ ]
+ required = [
+ self._parse_rule(r, f"required[{i}]", warnings)
+ for i, r in enumerate(data.get("required", []))
+ ]
+ options = self._parse_options(data.get("options", {}), warnings)
return Config(
forbidden=forbidden,
allowed=allowed,
required=required,
options=options,
+ warnings=warnings,
)
- def _parse_rule(self, data: dict) -> Rule:
+ def _warn_unknown_keys(
+ self, data: dict, known: frozenset[str], path: str, warnings: list[str]
+ ) -> None:
+ """Record a warning for each key in ``data`` not in ``known``."""
+ for key in data:
+ if key not in known:
+ warnings.append(f"{path}: unknown key '{key}' (ignored)")
+
+ def _parse_rule(self, data: dict, path: str, warnings: list[str]) -> Rule:
"""Parse a rule dictionary into a Rule object."""
+ self._warn_unknown_keys(data, self._RULE_KEYS, path, warnings)
name = data.get("name", "unnamed")
- severity_str = data.get("severity", "error").lower()
+ severity_str = str(data.get("severity", "error")).lower()
try:
severity = Severity(severity_str)
except ValueError:
severity = Severity.ERROR
+ valid = ", ".join(s.value for s in Severity)
+ warnings.append(
+ f"{path}.severity: unknown value '{severity_str}', "
+ f"defaulting to 'error' (valid: {valid})"
+ )
from_data = data.get("from", {})
to_data = data.get("to", {})
+ self._warn_unknown_keys(from_data, self._MATCHER_KEYS, f"{path}.from", warnings)
+ self._warn_unknown_keys(to_data, self._MATCHER_KEYS, f"{path}.to", warnings)
return Rule(
name=name,
@@ -133,8 +169,9 @@ def _parse_rule(self, data: dict) -> Rule:
orphan=data.get("orphan", False),
)
- def _parse_options(self, data: dict) -> ConfigOptions:
+ def _parse_options(self, data: dict, warnings: list[str]) -> ConfigOptions:
"""Parse options dictionary into ConfigOptions object."""
+ self._warn_unknown_keys(data, self._OPTION_KEYS, "options", warnings)
return ConfigOptions(
exclude=data.get("exclude", []),
)
diff --git a/src/gdcruiser/config/models.py b/src/gdcruiser/config/models.py
index 71e3a1b..0701523 100644
--- a/src/gdcruiser/config/models.py
+++ b/src/gdcruiser/config/models.py
@@ -55,6 +55,8 @@ class Config:
allowed: list[Rule] = field(default_factory=list)
required: list[Rule] = field(default_factory=list)
options: ConfigOptions = field(default_factory=ConfigOptions)
+ # Non-fatal issues found while loading (unknown keys, bad enum values).
+ warnings: list[str] = field(default_factory=list)
def has_rules(self) -> bool:
"""Check if any rules are defined."""
diff --git a/src/gdcruiser/graph/cycles.py b/src/gdcruiser/graph/cycles.py
index 4cdaf0a..4d39baf 100644
--- a/src/gdcruiser/graph/cycles.py
+++ b/src/gdcruiser/graph/cycles.py
@@ -28,31 +28,68 @@ def find_cycles(self) -> list[list[str]]:
return [scc for scc in self._sccs if len(scc) > 1]
- def _strongconnect(self, path: str) -> None:
- """Tarjan's algorithm recursive helper."""
- self._indices[path] = self._index
- self._lowlinks[path] = self._index
- self._index += 1
- self._stack.append(path)
- self._on_stack.add(path)
-
+ def _successors(self, path: str) -> list[str]:
+ """Return in-graph dependency targets for a node (deduplicated)."""
+ seen: set[str] = set()
+ targets: list[str] = []
for dep in self._graph.get_dependencies(path):
target = dep.target
- if not self._graph.has_module(target):
+ if target in seen or not self._graph.has_module(target):
continue
+ seen.add(target)
+ targets.append(target)
+ return targets
+
+ def _strongconnect(self, start: str) -> None:
+ """Iterative Tarjan's algorithm (explicit stack avoids RecursionError)."""
+ successors: dict[str, list[str]] = {}
+ # Each work-stack frame is [node, next-successor-index].
+ work: list[list] = [[start, 0]]
+
+ while work:
+ frame = work[-1]
+ node, next_i = frame
+
+ if next_i == 0:
+ self._indices[node] = self._index
+ self._lowlinks[node] = self._index
+ self._index += 1
+ self._stack.append(node)
+ self._on_stack.add(node)
+ successors[node] = self._successors(node)
- if target not in self._indices:
- self._strongconnect(target)
- self._lowlinks[path] = min(self._lowlinks[path], self._lowlinks[target])
- elif target in self._on_stack:
- self._lowlinks[path] = min(self._lowlinks[path], self._indices[target])
-
- if self._lowlinks[path] == self._indices[path]:
- scc: list[str] = []
- while True:
- w = self._stack.pop()
- self._on_stack.remove(w)
- scc.append(w)
- if w == path:
+ recursed = False
+ node_successors = successors[node]
+ while next_i < len(node_successors):
+ target = node_successors[next_i]
+ next_i += 1
+ if target not in self._indices:
+ frame[1] = next_i
+ work.append([target, 0])
+ recursed = True
break
- self._sccs.append(scc)
+ elif target in self._on_stack:
+ self._lowlinks[node] = min(
+ self._lowlinks[node], self._indices[target]
+ )
+
+ if recursed:
+ continue
+
+ # All successors processed — close out this node.
+ if self._lowlinks[node] == self._indices[node]:
+ scc: list[str] = []
+ while True:
+ w = self._stack.pop()
+ self._on_stack.remove(w)
+ scc.append(w)
+ if w == node:
+ break
+ self._sccs.append(scc)
+
+ work.pop()
+ if work:
+ parent = work[-1][0]
+ self._lowlinks[parent] = min(
+ self._lowlinks[parent], self._lowlinks[node]
+ )
diff --git a/src/gdcruiser/graph/dependency.py b/src/gdcruiser/graph/dependency.py
index 626dee7..c8065df 100644
--- a/src/gdcruiser/graph/dependency.py
+++ b/src/gdcruiser/graph/dependency.py
@@ -6,10 +6,15 @@ class DependencyGraph:
def __init__(self) -> None:
self._modules: dict[str, Module] = {}
+ # Reverse adjacency (target path -> list of (source path, dep)), built
+ # lazily on first `get_dependents` call and invalidated whenever a
+ # module is added. Avoids re-scanning every module on each lookup.
+ self._dependents_index: dict[str, list[tuple[str, Dependency]]] | None = None
def add_module(self, module: Module) -> None:
"""Add a module to the graph."""
self._modules[module.path] = module
+ self._dependents_index = None
def get_module(self, path: str) -> Module | None:
"""Get a module by path."""
@@ -30,12 +35,17 @@ def get_dependencies(self, path: str) -> list[Dependency]:
def get_dependents(self, path: str) -> list[tuple[str, Dependency]]:
"""Get all modules that depend on the given path."""
- dependents = []
+ if self._dependents_index is None:
+ self._dependents_index = self._build_dependents_index()
+ return self._dependents_index.get(path, [])
+
+ def _build_dependents_index(self) -> dict[str, list[tuple[str, Dependency]]]:
+ """Build the reverse adjacency index in a single pass over all edges."""
+ index: dict[str, list[tuple[str, Dependency]]] = {}
for module in self._modules.values():
for dep in module.dependencies:
- if dep.target == path:
- dependents.append((module.path, dep))
- return dependents
+ index.setdefault(dep.target, []).append((module.path, dep))
+ return index
def module_count(self) -> int:
"""Return the number of modules in the graph."""
diff --git a/src/gdcruiser/graph/node.py b/src/gdcruiser/graph/node.py
index 2d8c5ad..2c667c0 100644
--- a/src/gdcruiser/graph/node.py
+++ b/src/gdcruiser/graph/node.py
@@ -31,6 +31,15 @@ def to_dict(self) -> dict:
"resolved": self.resolved,
}
+ @classmethod
+ def from_dict(cls, data: dict) -> "Dependency":
+ return cls(
+ target=data["target"],
+ dep_type=DependencyType(data["type"]),
+ line=data.get("line"),
+ resolved=data.get("resolved", True),
+ )
+
@dataclass
class Module:
@@ -46,3 +55,13 @@ def to_dict(self) -> dict:
"class_name": self.class_name,
"dependencies": [d.to_dict() for d in self.dependencies],
}
+
+ @classmethod
+ def from_dict(cls, data: dict) -> "Module":
+ return cls(
+ path=data["path"],
+ class_name=data.get("class_name"),
+ dependencies=[
+ Dependency.from_dict(d) for d in data.get("dependencies", [])
+ ],
+ )
diff --git a/src/gdcruiser/output/__init__.py b/src/gdcruiser/output/__init__.py
index 4c44fac..ed459af 100644
--- a/src/gdcruiser/output/__init__.py
+++ b/src/gdcruiser/output/__init__.py
@@ -3,4 +3,19 @@
from .dot import DotFormatter
from .mermaid import MermaidFormatter
-__all__ = ["TextFormatter", "JsonFormatter", "DotFormatter", "MermaidFormatter"]
+# Registry keyed by the CLI `--format` value. Every formatter exposes the same
+# ``format(result, rule_result=None)`` signature so the CLI can dispatch by name.
+FORMATTERS = {
+ "text": TextFormatter,
+ "json": JsonFormatter,
+ "dot": DotFormatter,
+ "mermaid": MermaidFormatter,
+}
+
+__all__ = [
+ "TextFormatter",
+ "JsonFormatter",
+ "DotFormatter",
+ "MermaidFormatter",
+ "FORMATTERS",
+]
diff --git a/src/gdcruiser/output/dot.py b/src/gdcruiser/output/dot.py
index a1e14a5..f7f4b40 100644
--- a/src/gdcruiser/output/dot.py
+++ b/src/gdcruiser/output/dot.py
@@ -1,5 +1,6 @@
from ..analyzer import AnalysisResult
-from ..graph.node import DependencyType
+from ..rules.models import RuleCheckResult
+from .labels import cycle_node_set, escape_dot_label, short_path, type_label
class DotFormatter:
@@ -8,7 +9,9 @@ class DotFormatter:
def __init__(self, show_type: bool = True) -> None:
self._show_type = show_type
- def format(self, result: AnalysisResult) -> str:
+ def format(
+ self, result: AnalysisResult, rule_result: RuleCheckResult | None = None
+ ) -> str:
lines: list[str] = []
lines.append("digraph dependencies {")
lines.append(" rankdir=LR;")
@@ -16,15 +19,12 @@ def format(self, result: AnalysisResult) -> str:
lines.append(' edge [fontname="monospace", fontsize=10];')
lines.append("")
- # Find cycle nodes for highlighting
- cycle_nodes: set[str] = set()
- for cycle in result.cycles:
- cycle_nodes.update(cycle)
+ cycle_nodes = cycle_node_set(result)
# Node declarations
for module in result.graph.all_modules():
node_id = self._node_id(module.path)
- label = self._short_path(module.path)
+ label = short_path(module.path)
if module.class_name:
label = f"{module.class_name}\\n{label}"
@@ -32,7 +32,7 @@ def format(self, result: AnalysisResult) -> str:
if module.path in cycle_nodes:
style = ', style=filled, fillcolor="#ffcccc"'
- lines.append(f' {node_id} [label="{label}"{style}];')
+ lines.append(f' {node_id} [label="{escape_dot_label(label)}"{style}];')
lines.append("")
@@ -44,7 +44,9 @@ def format(self, result: AnalysisResult) -> str:
attrs = []
if self._show_type:
- attrs.append(f'label="{self._type_label(dep.dep_type)}"')
+ attrs.append(
+ f'label="{escape_dot_label(type_label(dep.dep_type))}"'
+ )
if not dep.resolved:
attrs.append("style=dashed")
@@ -63,23 +65,4 @@ def format(self, result: AnalysisResult) -> str:
def _node_id(self, path: str) -> str:
"""Convert a path to a valid DOT node ID."""
- return f'"{path}"'
-
- def _short_path(self, path: str) -> str:
- """Shorten path for display."""
- if path.startswith("res://"):
- return path[6:]
- return path
-
- def _type_label(self, dep_type: DependencyType) -> str:
- """Get a short label for dependency type."""
- labels = {
- DependencyType.EXTENDS_PATH: "extends",
- DependencyType.EXTENDS_CLASS: "extends",
- DependencyType.PRELOAD: "preload",
- DependencyType.LOAD: "load",
- DependencyType.SCENE_SCRIPT: "script",
- DependencyType.CLASS_REF: "uses",
- DependencyType.RESOURCE_REF: "resource",
- }
- return labels.get(dep_type, "")
+ return f'"{escape_dot_label(path)}"'
diff --git a/src/gdcruiser/output/labels.py b/src/gdcruiser/output/labels.py
new file mode 100644
index 0000000..8196ac4
--- /dev/null
+++ b/src/gdcruiser/output/labels.py
@@ -0,0 +1,49 @@
+"""Shared helpers for graph output formatters (DOT, Mermaid)."""
+
+from ..analyzer import AnalysisResult
+from ..graph.node import DependencyType
+
+_TYPE_LABELS: dict[DependencyType, str] = {
+ DependencyType.EXTENDS_PATH: "extends",
+ DependencyType.EXTENDS_CLASS: "extends",
+ DependencyType.PRELOAD: "preload",
+ DependencyType.LOAD: "load",
+ DependencyType.SCENE_SCRIPT: "script",
+ DependencyType.CLASS_REF: "uses",
+ DependencyType.RESOURCE_REF: "resource",
+}
+
+
+def type_label(dep_type: DependencyType) -> str:
+ """Return a short human-readable label for a dependency type."""
+ return _TYPE_LABELS.get(dep_type, "")
+
+
+def short_path(path: str) -> str:
+ """Strip the ``res://`` prefix for display."""
+ if path.startswith("res://"):
+ return path[6:]
+ return path
+
+
+def cycle_node_set(result: AnalysisResult) -> set[str]:
+ """Return the set of all module paths that participate in a cycle."""
+ nodes: set[str] = set()
+ for cycle in result.cycles:
+ nodes.update(cycle)
+ return nodes
+
+
+def escape_dot_label(label: str) -> str:
+ """Escape a string for use inside a DOT double-quoted label."""
+ return label.replace("\\", "\\\\").replace('"', '\\"')
+
+
+def escape_mermaid_label(label: str) -> str:
+ """Escape a string for use inside a Mermaid ``["..."]`` label.
+
+ Mermaid has no backslash escape; double quotes are encoded as the HTML
+ entity ``"`` (already-safe ``
`` separators are inserted by the
+ caller and must survive, so only quotes are touched here).
+ """
+ return label.replace('"', """)
diff --git a/src/gdcruiser/output/mermaid.py b/src/gdcruiser/output/mermaid.py
index 8589743..8276de7 100644
--- a/src/gdcruiser/output/mermaid.py
+++ b/src/gdcruiser/output/mermaid.py
@@ -1,5 +1,6 @@
from ..analyzer import AnalysisResult
-from ..graph.node import DependencyType
+from ..rules.models import RuleCheckResult
+from .labels import cycle_node_set, escape_mermaid_label, short_path, type_label
class MermaidFormatter:
@@ -8,22 +9,21 @@ class MermaidFormatter:
def __init__(self, show_type: bool = True) -> None:
self._show_type = show_type
- def format(self, result: AnalysisResult) -> str:
+ def format(
+ self, result: AnalysisResult, rule_result: RuleCheckResult | None = None
+ ) -> str:
lines: list[str] = []
lines.append("graph LR")
- # Find cycle nodes for highlighting
- cycle_nodes: set[str] = set()
- for cycle in result.cycles:
- cycle_nodes.update(cycle)
+ cycle_nodes = cycle_node_set(result)
# Node declarations
cycle_node_ids: list[str] = []
for module in result.graph.all_modules():
node_id = self._node_id(module.path)
- label = self._short_path(module.path)
+ label = escape_mermaid_label(short_path(module.path))
if module.class_name:
- label = f"{module.class_name}
{label}"
+ label = f"{escape_mermaid_label(module.class_name)}
{label}"
lines.append(f' {node_id}["{label}"]')
@@ -39,16 +39,16 @@ def format(self, result: AnalysisResult) -> str:
source_id = self._node_id(module.path)
for dep in module.dependencies:
target_id = self._node_id(dep.target)
- type_label = self._type_label(dep.dep_type) if self._show_type else ""
+ label = type_label(dep.dep_type) if self._show_type else ""
is_cycle_edge = module.path in cycle_nodes and dep.target in cycle_nodes
if is_cycle_edge:
- if type_label:
- lines.append(f" {source_id} == {type_label} ==> {target_id}")
+ if label:
+ lines.append(f" {source_id} == {label} ==> {target_id}")
else:
lines.append(f" {source_id} ==> {target_id}")
- elif type_label:
- lines.append(f" {source_id} -- {type_label} --> {target_id}")
+ elif label:
+ lines.append(f" {source_id} -- {label} --> {target_id}")
else:
lines.append(f" {source_id} --> {target_id}")
@@ -71,22 +71,3 @@ def format(self, result: AnalysisResult) -> str:
def _node_id(self, path: str) -> str:
"""Convert a res:// path to a valid Mermaid node ID."""
return path.replace("://", "_").replace("/", "_").replace(".", "_")
-
- def _short_path(self, path: str) -> str:
- """Shorten path for display."""
- if path.startswith("res://"):
- return path[6:]
- return path
-
- def _type_label(self, dep_type: DependencyType) -> str:
- """Get a short label for dependency type."""
- labels = {
- DependencyType.EXTENDS_PATH: "extends",
- DependencyType.EXTENDS_CLASS: "extends",
- DependencyType.PRELOAD: "preload",
- DependencyType.LOAD: "load",
- DependencyType.SCENE_SCRIPT: "script",
- DependencyType.CLASS_REF: "uses",
- DependencyType.RESOURCE_REF: "resource",
- }
- return labels.get(dep_type, "")
diff --git a/src/gdcruiser/output/text.py b/src/gdcruiser/output/text.py
index a83735c..341821f 100644
--- a/src/gdcruiser/output/text.py
+++ b/src/gdcruiser/output/text.py
@@ -63,6 +63,15 @@ def format(
lines.append("")
lines.append(self._violation_formatter.format(rule_result))
+ # Warnings
+ if result.warnings:
+ lines.append("")
+ lines.append("-" * 40)
+ lines.append("WARNINGS")
+ lines.append("-" * 40)
+ for warning in result.warnings:
+ lines.append(f" {warning}")
+
# Errors
if result.errors:
lines.append("")
diff --git a/src/gdcruiser/output/violations.py b/src/gdcruiser/output/violations.py
index bba7cf0..2e96238 100644
--- a/src/gdcruiser/output/violations.py
+++ b/src/gdcruiser/output/violations.py
@@ -1,7 +1,9 @@
"""Violation output formatters."""
+from collections import defaultdict
+
from ..config.models import Severity
-from ..rules.models import RuleCheckResult
+from ..rules.models import RuleCheckResult, Violation
class ViolationTextFormatter:
@@ -28,12 +30,9 @@ def format(self, result: RuleCheckResult) -> str:
lines.append("-" * 40)
# Group violations by rule
- by_rule: dict[str, list] = {}
+ by_rule: dict[str, list[Violation]] = defaultdict(list)
for v in result.violations:
- rule_name = v.rule.name
- if rule_name not in by_rule:
- by_rule[rule_name] = []
- by_rule[rule_name].append(v)
+ by_rule[v.rule.name].append(v)
for rule_name, violations in by_rule.items():
first = violations[0]
diff --git a/src/gdcruiser/parser/__init__.py b/src/gdcruiser/parser/__init__.py
index 27bb363..51f2b69 100644
--- a/src/gdcruiser/parser/__init__.py
+++ b/src/gdcruiser/parser/__init__.py
@@ -1,11 +1,13 @@
-from .patterns import Patterns
+from . import patterns
from .gdscript import GDScriptParser
+from .paths import to_res_path
from .tscn import TscnParser
from .tres import TresParser
from .project_godot import parse_autoloads
__all__ = [
- "Patterns",
+ "patterns",
+ "to_res_path",
"GDScriptParser",
"TscnParser",
"TresParser",
diff --git a/src/gdcruiser/parser/gdscript.py b/src/gdcruiser/parser/gdscript.py
index 5d4ae85..d428adb 100644
--- a/src/gdcruiser/parser/gdscript.py
+++ b/src/gdcruiser/parser/gdscript.py
@@ -3,7 +3,8 @@
from ..graph.node import Module, Dependency, DependencyType
from ..symbols.table import SymbolTable
-from .patterns import Patterns
+from . import patterns
+from .paths import to_res_path
class GDScriptParser:
@@ -14,17 +15,93 @@ def __init__(self, symbol_table: SymbolTable) -> None:
def parse(self, file_path: Path, project_root: Path) -> Module:
"""Parse a GDScript file and return a Module."""
- rel_path = self._to_res_path(file_path, project_root)
+ rel_path = to_res_path(file_path, project_root)
content = file_path.read_text(encoding="utf-8")
- class_name = self._extract_class_name(content)
+ # Blank out comments and triple-quoted strings up front so that
+ # `preload`/`load`/`extends`/`ClassName` text living inside them is
+ # never mistaken for real code. Single-line string literals are kept
+ # because preload/load/extends paths live inside them.
+ code = self._preprocess(content)
+
+ class_name = self._extract_class_name(code)
if class_name:
self._symbol_table.register(class_name, rel_path)
- dependencies = self._extract_dependencies(content)
+ dependencies = self._extract_dependencies(code)
return Module(path=rel_path, class_name=class_name, dependencies=dependencies)
+ @staticmethod
+ def _preprocess(content: str) -> str:
+ """Return a "code view" of the source for pattern matching.
+
+ Comments (``# ...``) and triple-quoted strings are replaced with
+ spaces, preserving newlines so line numbers stay accurate. Single-line
+ string literals are left intact because ``preload("res://...")`` and
+ ``extends "res://..."`` keep their target inside a quoted string.
+ """
+ out: list[str] = []
+ i = 0
+ n = len(content)
+ state = "code" # code | comment | line_str | triple_str
+ quote = ""
+ while i < n:
+ ch = content[i]
+ if state == "code":
+ if ch == "#":
+ state = "comment"
+ out.append(" ")
+ i += 1
+ elif content.startswith('"""', i) or content.startswith("'''", i):
+ quote = content[i : i + 3]
+ state = "triple_str"
+ out.append(" ")
+ i += 3
+ elif ch in ('"', "'"):
+ quote = ch
+ state = "line_str"
+ out.append(ch)
+ i += 1
+ else:
+ out.append(ch)
+ i += 1
+ elif state == "comment":
+ if ch == "\n":
+ state = "code"
+ out.append("\n")
+ else:
+ out.append(" ")
+ i += 1
+ elif state == "line_str":
+ if ch == "\\" and i + 1 < n:
+ out.append(content[i : i + 2])
+ i += 2
+ elif ch == quote:
+ out.append(ch)
+ state = "code"
+ i += 1
+ elif ch == "\n":
+ # Unterminated single-line string — bail back to code.
+ out.append("\n")
+ state = "code"
+ i += 1
+ else:
+ out.append(ch)
+ i += 1
+ else: # triple_str
+ if content.startswith(quote, i):
+ out.append(" ")
+ state = "code"
+ i += 3
+ elif ch == "\n":
+ out.append("\n")
+ i += 1
+ else:
+ out.append(" ")
+ i += 1
+ return "".join(out)
+
def resolve_class_dependencies(self, module: Module) -> None:
"""Resolve class-name dependencies using the symbol table.
@@ -55,32 +132,22 @@ def resolve_class_dependencies(self, module: Module) -> None:
kept.append(dep)
module.dependencies = kept
- def _to_res_path(self, file_path: Path, project_root: Path) -> str:
- """Convert absolute path to res:// path."""
- rel = file_path.resolve().relative_to(project_root.resolve())
- return f"res://{rel.as_posix()}"
-
def _extract_class_name(self, content: str) -> str | None:
"""Extract class_name declaration from content."""
for line in content.splitlines():
- match = Patterns.CLASS_NAME.match(line)
+ match = patterns.CLASS_NAME.match(line)
if match:
return match.group(1)
return None
def _extract_dependencies(self, content: str) -> list[Dependency]:
- """Extract all dependencies from content."""
+ """Extract all dependencies from content (already comment/string-stripped)."""
dependencies: list[Dependency] = []
seen_class_refs: set[str] = set()
for line_num, line in enumerate(content.splitlines(), start=1):
- # Skip comments
- stripped = line.lstrip()
- if stripped.startswith("#"):
- continue
-
# extends "res://..."
- match = Patterns.EXTENDS_PATH.match(line)
+ match = patterns.EXTENDS_PATH.match(line)
if match:
dependencies.append(
Dependency(
@@ -92,7 +159,7 @@ def _extract_dependencies(self, content: str) -> list[Dependency]:
continue
# extends ClassName
- match = Patterns.EXTENDS_CLASS.match(line)
+ match = patterns.EXTENDS_CLASS.match(line)
if match:
class_ref = match.group(1)
# Skip built-in classes (Node, Resource, etc.)
@@ -108,11 +175,11 @@ def _extract_dependencies(self, content: str) -> list[Dependency]:
continue
# class_name declaration — never a dependency.
- if Patterns.CLASS_NAME.match(line):
+ if patterns.CLASS_NAME.match(line):
continue
# preload("res://...")
- for match in Patterns.PRELOAD.finditer(line):
+ for match in patterns.PRELOAD.finditer(line):
dependencies.append(
Dependency(
target=match.group(1),
@@ -122,7 +189,7 @@ def _extract_dependencies(self, content: str) -> list[Dependency]:
)
# load("res://...")
- for match in Patterns.LOAD.finditer(line):
+ for match in patterns.LOAD.finditer(line):
dependencies.append(
Dependency(
target=match.group(1),
@@ -169,11 +236,11 @@ def _find_class_refs(line: str) -> list[str]:
"""Extract class-name references from a sanitized line."""
refs: list[str] = []
for pattern in (
- Patterns.TYPED_REF,
- Patterns.RETURN_TYPE,
- Patterns.IS_AS_REF,
- Patterns.MEMBER_ACCESS,
- Patterns.GENERIC_PARAM,
+ patterns.TYPED_REF,
+ patterns.RETURN_TYPE,
+ patterns.IS_AS_REF,
+ patterns.MEMBER_ACCESS,
+ patterns.GENERIC_PARAM,
):
for match in pattern.finditer(line):
refs.append(match.group(1))
@@ -482,5 +549,41 @@ def _is_builtin_class(self, name: str) -> bool:
"EditorImportPlugin",
"EditorExportPlugin",
"EditorFileSystem",
+ # Abstract physics/visual bases and other commonly-extended node
+ # types that are easy to omit above (an `extends` of any of these is
+ # a built-in, not a broken project dependency).
+ "PhysicsBody2D",
+ "PhysicsBody3D",
+ "CollisionObject2D",
+ "GeometryInstance3D",
+ "VisualInstance3D",
+ "VisibleOnScreenNotifier2D",
+ "VisibleOnScreenNotifier3D",
+ "VisibleOnScreenEnabler2D",
+ "VisibleOnScreenEnabler3D",
+ "Parallax2D",
+ "ParallaxBackground",
+ "ParallaxLayer",
+ "RemoteTransform2D",
+ "RemoteTransform3D",
+ "Line2D",
+ "Polygon2D",
+ "PointLight2D",
+ "CanvasModulate",
+ "GridMap",
+ "WorldEnvironment",
+ "SpringArm3D",
+ "PhysicalBone3D",
+ "SoftBody3D",
+ "VehicleBody3D",
+ "CSGShape3D",
+ "CSGBox3D",
+ "CSGCombiner3D",
+ "ReflectionProbe",
+ "VoxelGI",
+ "LightmapGI",
+ "OccluderInstance3D",
+ "FogVolume",
+ "Decal",
}
)
diff --git a/src/gdcruiser/parser/paths.py b/src/gdcruiser/parser/paths.py
new file mode 100644
index 0000000..2cb0c59
--- /dev/null
+++ b/src/gdcruiser/parser/paths.py
@@ -0,0 +1,7 @@
+from pathlib import Path
+
+
+def to_res_path(file_path: Path, project_root: Path) -> str:
+ """Convert an absolute path to a ``res://`` path relative to the project root."""
+ rel = file_path.resolve().relative_to(project_root.resolve())
+ return f"res://{rel.as_posix()}"
diff --git a/src/gdcruiser/parser/patterns.py b/src/gdcruiser/parser/patterns.py
index 2542bcb..2ba27fa 100644
--- a/src/gdcruiser/parser/patterns.py
+++ b/src/gdcruiser/parser/patterns.py
@@ -46,23 +46,3 @@
# project.godot [autoload] entry: `Identifier="*res://path/to/script.gd"`.
# The leading `*` marks an enabled singleton and is stripped from the captured path.
AUTOLOAD_ENTRY = re.compile(r'^([A-Za-z_][A-Za-z0-9_]*)\s*=\s*"\*?(res://[^"]+)"\s*$')
-
-
-class Patterns:
- """Container for all regex patterns."""
-
- EXTENDS_PATH = EXTENDS_PATH
- EXTENDS_CLASS = EXTENDS_CLASS
- CLASS_NAME = CLASS_NAME
- PRELOAD = PRELOAD
- LOAD = LOAD
- TYPED_REF = TYPED_REF
- RETURN_TYPE = RETURN_TYPE
- IS_AS_REF = IS_AS_REF
- MEMBER_ACCESS = MEMBER_ACCESS
- GENERIC_PARAM = GENERIC_PARAM
- TSCN_EXT_RESOURCE = TSCN_EXT_RESOURCE
- TSCN_SCRIPT_ATTACH = TSCN_SCRIPT_ATTACH
- RESOURCE_PATH = RESOURCE_PATH
- RESOURCE_SCRIPT_CLASS = RESOURCE_SCRIPT_CLASS
- AUTOLOAD_ENTRY = AUTOLOAD_ENTRY
diff --git a/src/gdcruiser/parser/project_godot.py b/src/gdcruiser/parser/project_godot.py
index 4264460..566d23b 100644
--- a/src/gdcruiser/parser/project_godot.py
+++ b/src/gdcruiser/parser/project_godot.py
@@ -1,6 +1,6 @@
from pathlib import Path
-from .patterns import Patterns
+from . import patterns
def parse_autoloads(project_root: Path) -> dict[str, str]:
@@ -31,7 +31,7 @@ def parse_autoloads(project_root: Path) -> dict[str, str]:
if not in_section:
continue
- match = Patterns.AUTOLOAD_ENTRY.match(stripped)
+ match = patterns.AUTOLOAD_ENTRY.match(stripped)
if match:
identifier, path = match.group(1), match.group(2)
autoloads[identifier] = path
diff --git a/src/gdcruiser/parser/tres.py b/src/gdcruiser/parser/tres.py
index 11b63ad..0e16c97 100644
--- a/src/gdcruiser/parser/tres.py
+++ b/src/gdcruiser/parser/tres.py
@@ -2,7 +2,8 @@
from ..graph.node import Module, Dependency, DependencyType
from ..symbols.table import SymbolTable
-from .patterns import Patterns
+from . import patterns
+from .paths import to_res_path
class TresParser:
@@ -22,7 +23,7 @@ def __init__(self, symbol_table: SymbolTable | None = None) -> None:
def parse(self, file_path: Path, project_root: Path) -> Module:
"""Parse a .tres file and return a Module."""
- rel_path = self._to_res_path(file_path, project_root)
+ rel_path = to_res_path(file_path, project_root)
content = file_path.read_text(encoding="utf-8")
class_name = self._extract_script_class(content)
@@ -33,12 +34,8 @@ def parse(self, file_path: Path, project_root: Path) -> Module:
return Module(path=rel_path, class_name=class_name, dependencies=dependencies)
- def _to_res_path(self, file_path: Path, project_root: Path) -> str:
- rel = file_path.resolve().relative_to(project_root.resolve())
- return f"res://{rel.as_posix()}"
-
def _extract_script_class(self, content: str) -> str | None:
- match = Patterns.RESOURCE_SCRIPT_CLASS.search(content)
+ match = patterns.RESOURCE_SCRIPT_CLASS.search(content)
return match.group(1) if match else None
def _extract_dependencies(self, content: str) -> list[Dependency]:
@@ -46,7 +43,7 @@ def _extract_dependencies(self, content: str) -> list[Dependency]:
seen: set[str] = set()
for line_num, line in enumerate(content.splitlines(), start=1):
- for match in Patterns.RESOURCE_PATH.finditer(line):
+ for match in patterns.RESOURCE_PATH.finditer(line):
target = match.group(1)
if target in seen:
continue
diff --git a/src/gdcruiser/parser/tscn.py b/src/gdcruiser/parser/tscn.py
index 3880f18..93f83ac 100644
--- a/src/gdcruiser/parser/tscn.py
+++ b/src/gdcruiser/parser/tscn.py
@@ -1,7 +1,8 @@
from pathlib import Path
from ..graph.node import Module, Dependency, DependencyType
-from .patterns import Patterns
+from . import patterns
+from .paths import to_res_path
class TscnParser:
@@ -9,18 +10,13 @@ class TscnParser:
def parse(self, file_path: Path, project_root: Path) -> Module:
"""Parse a TSCN file and return a Module."""
- rel_path = self._to_res_path(file_path, project_root)
+ rel_path = to_res_path(file_path, project_root)
content = file_path.read_text(encoding="utf-8")
dependencies = self._extract_dependencies(content)
return Module(path=rel_path, class_name=None, dependencies=dependencies)
- def _to_res_path(self, file_path: Path, project_root: Path) -> str:
- """Convert absolute path to res:// path."""
- rel = file_path.resolve().relative_to(project_root.resolve())
- return f"res://{rel.as_posix()}"
-
def _extract_dependencies(self, content: str) -> list[Dependency]:
"""Extract script dependencies from TSCN content."""
dependencies: list[Dependency] = []
@@ -28,7 +24,7 @@ def _extract_dependencies(self, content: str) -> list[Dependency]:
for line_num, line in enumerate(content.splitlines(), start=1):
# Find external resource declarations for .gd files
- match = Patterns.TSCN_EXT_RESOURCE.search(line)
+ match = patterns.TSCN_EXT_RESOURCE.search(line)
if match:
script_path = match.group(1)
if script_path not in seen_scripts:
diff --git a/src/gdcruiser/scanner.py b/src/gdcruiser/scanner.py
index 0f700fc..d04ebc3 100644
--- a/src/gdcruiser/scanner.py
+++ b/src/gdcruiser/scanner.py
@@ -9,6 +9,9 @@ def __init__(self, project_root: Path, exclude: list[str] | None = None) -> None
self._root = project_root.resolve()
self._exclude_patterns = [re.compile(p) for p in exclude] if exclude else []
+ # Suffixes bucketed by a single directory walk.
+ _SUFFIXES = (".gd", ".tscn", ".tres")
+
def _filter(self, files: list[Path]) -> list[Path]:
"""Remove files whose res:// path matches any exclude pattern."""
if not self._exclude_patterns:
@@ -20,25 +23,33 @@ def _filter(self, files: list[Path]) -> list[Path]:
result.append(f)
return result
+ def _scan(self) -> dict[str, list[Path]]:
+ """Walk the project tree once, bucketing files by suffix."""
+ buckets: dict[str, list[Path]] = {suffix: [] for suffix in self._SUFFIXES}
+ for path in self._root.rglob("*"):
+ files = buckets.get(path.suffix)
+ if files is not None and path.is_file():
+ files.append(path)
+ return {
+ suffix: self._filter(sorted(paths)) for suffix, paths in buckets.items()
+ }
+
def find_gdscript_files(self) -> list[Path]:
"""Find all .gd files in the project."""
- return self._filter(sorted(self._root.rglob("*.gd")))
+ return self._scan()[".gd"]
def find_scene_files(self) -> list[Path]:
"""Find all .tscn files in the project."""
- return self._filter(sorted(self._root.rglob("*.tscn")))
+ return self._scan()[".tscn"]
def find_resource_files(self) -> list[Path]:
"""Find all .tres files in the project."""
- return self._filter(sorted(self._root.rglob("*.tres")))
+ return self._scan()[".tres"]
def find_all_files(self) -> tuple[list[Path], list[Path], list[Path]]:
- """Find all .gd, .tscn, and .tres files."""
- return (
- self.find_gdscript_files(),
- self.find_scene_files(),
- self.find_resource_files(),
- )
+ """Find all .gd, .tscn, and .tres files in a single directory walk."""
+ buckets = self._scan()
+ return (buckets[".gd"], buckets[".tscn"], buckets[".tres"])
def is_godot_project(self) -> bool:
"""Check if the directory is a Godot project (has project.godot)."""
diff --git a/src/gdcruiser/symbols/table.py b/src/gdcruiser/symbols/table.py
index e27c185..c5f631d 100644
--- a/src/gdcruiser/symbols/table.py
+++ b/src/gdcruiser/symbols/table.py
@@ -4,12 +4,23 @@ class SymbolTable:
def __init__(self) -> None:
self._class_to_path: dict[str, str] = {}
self._path_to_class: dict[str, str] = {}
+ # (class_name, existing_path, new_path) for each name registered more
+ # than once to a *different* path — Godot itself rejects duplicate
+ # global class names, so these are surfaced as warnings.
+ self._collisions: list[tuple[str, str, str]] = []
def register(self, class_name: str, path: str) -> None:
"""Register a class_name declaration."""
+ existing = self._class_to_path.get(class_name)
+ if existing is not None and existing != path:
+ self._collisions.append((class_name, existing, path))
self._class_to_path[class_name] = path
self._path_to_class[path] = class_name
+ def collisions(self) -> list[tuple[str, str, str]]:
+ """Return recorded duplicate-registration collisions."""
+ return list(self._collisions)
+
def resolve(self, class_name: str) -> str | None:
"""Resolve a class_name to its file path."""
return self._class_to_path.get(class_name)
diff --git a/tests/test_cache.py b/tests/test_cache.py
new file mode 100644
index 0000000..da6c5fa
--- /dev/null
+++ b/tests/test_cache.py
@@ -0,0 +1,95 @@
+"""Tests for the incremental parse cache."""
+
+from gdcruiser.analyzer import Analyzer
+from gdcruiser.cache import ParseCache
+
+
+def _make_project(root):
+ (root / "project.godot").write_text("[application]\n", encoding="utf-8")
+ (root / "a.gd").write_text(
+ 'class_name A\nvar b = preload("res://b.gd")\n', encoding="utf-8"
+ )
+ (root / "b.gd").write_text("class_name B\n", encoding="utf-8")
+
+
+def test_cold_run_all_misses(tmp_path):
+ _make_project(tmp_path)
+ cache = ParseCache(tmp_path / "cache.json")
+ cache.load()
+ Analyzer(tmp_path, cache=cache).analyze()
+ assert cache.hits == 0
+ assert cache.misses == 2
+
+
+def test_warm_run_all_hits(tmp_path):
+ _make_project(tmp_path)
+ cache_path = tmp_path / "cache.json"
+
+ cold = ParseCache(cache_path)
+ cold.load()
+ Analyzer(tmp_path, cache=cold).analyze()
+
+ warm = ParseCache(cache_path)
+ warm.load()
+ Analyzer(tmp_path, cache=warm).analyze()
+ assert warm.hits == 2
+ assert warm.misses == 0
+
+
+def test_changed_file_invalidated(tmp_path):
+ _make_project(tmp_path)
+ cache_path = tmp_path / "cache.json"
+
+ cold = ParseCache(cache_path)
+ cold.load()
+ Analyzer(tmp_path, cache=cold).analyze()
+
+ # Rewrite one file with a newer mtime/size.
+ (tmp_path / "a.gd").write_text(
+ 'class_name A\nvar b = preload("res://b.gd")\n# changed\n', encoding="utf-8"
+ )
+
+ warm = ParseCache(cache_path)
+ warm.load()
+ Analyzer(tmp_path, cache=warm).analyze()
+ assert warm.hits == 1
+ assert warm.misses == 1
+
+
+def test_cache_produces_identical_graph(tmp_path):
+ _make_project(tmp_path)
+ cache_path = tmp_path / "cache.json"
+
+ no_cache = Analyzer(tmp_path).analyze().to_dict()["graph"]
+
+ cold = ParseCache(cache_path)
+ cold.load()
+ Analyzer(tmp_path, cache=cold).analyze()
+ warm = ParseCache(cache_path)
+ warm.load()
+ cached = Analyzer(tmp_path, cache=warm).analyze().to_dict()["graph"]
+
+ assert cached == no_cache
+
+
+def test_stale_version_ignored(tmp_path):
+ _make_project(tmp_path)
+ cache_path = tmp_path / "cache.json"
+ cache_path.write_text('{"version": 0, "entries": {}}', encoding="utf-8")
+
+ cache = ParseCache(cache_path)
+ cache.load()
+ Analyzer(tmp_path, cache=cache).analyze()
+ # Old version dropped -> everything reparsed.
+ assert cache.misses == 2
+
+
+def test_corrupt_cache_ignored(tmp_path):
+ _make_project(tmp_path)
+ cache_path = tmp_path / "cache.json"
+ cache_path.write_text("not json{{{", encoding="utf-8")
+
+ cache = ParseCache(cache_path)
+ cache.load() # must not raise
+ Analyzer(tmp_path, cache=cache).analyze()
+ assert cache.misses == 2
diff --git a/tests/test_output_escaping.py b/tests/test_output_escaping.py
new file mode 100644
index 0000000..6cdb277
--- /dev/null
+++ b/tests/test_output_escaping.py
@@ -0,0 +1,36 @@
+"""Tests that DOT/Mermaid labels escape adversarial characters."""
+
+from gdcruiser.analyzer import AnalysisResult
+from gdcruiser.graph.dependency import DependencyGraph
+from gdcruiser.graph.node import Module
+from gdcruiser.output.dot import DotFormatter
+from gdcruiser.output.mermaid import MermaidFormatter
+from gdcruiser.output.labels import escape_dot_label, escape_mermaid_label
+
+
+def test_escape_dot_label():
+ assert escape_dot_label('a"b') == 'a\\"b'
+ assert escape_dot_label("a\\b") == "a\\\\b"
+
+
+def test_escape_mermaid_label():
+ assert escape_mermaid_label('a"b') == "a"b"
+
+
+def _result_with_quote():
+ g = DependencyGraph()
+ g.add_module(Module(path='res://a"b.gd', class_name='Wei"rd'))
+ return AnalysisResult(graph=g)
+
+
+def test_dot_label_has_no_bare_quote():
+ out = DotFormatter().format(_result_with_quote())
+ label_line = next(line for line in out.splitlines() if "label=" in line)
+ # The class name's inner quote must be backslash-escaped.
+ assert '\\"' in label_line
+
+
+def test_mermaid_label_encodes_quote():
+ out = MermaidFormatter().format(_result_with_quote())
+ assert """ in out
+ assert 'Wei"rd' not in out
diff --git a/tests/test_parser_edgecases.py b/tests/test_parser_edgecases.py
new file mode 100644
index 0000000..348108a
--- /dev/null
+++ b/tests/test_parser_edgecases.py
@@ -0,0 +1,106 @@
+"""Regression tests for comment/string stripping and builtin handling."""
+
+from pathlib import Path
+
+from gdcruiser.parser.gdscript import GDScriptParser
+from gdcruiser.graph.node import DependencyType
+from gdcruiser.symbols.table import SymbolTable
+
+
+def _parse(tmp_path: Path, source: str):
+ parser = GDScriptParser(SymbolTable())
+ f = tmp_path / "script.gd"
+ f.write_text(source, encoding="utf-8")
+ return parser.parse(f, tmp_path)
+
+
+def _targets(module, dep_type):
+ return [d.target for d in module.dependencies if d.dep_type == dep_type]
+
+
+class TestCommentStripping:
+ def test_preload_in_inline_comment_ignored(self, tmp_path):
+ module = _parse(
+ tmp_path,
+ 'extends Node\nvar x = 5 # preload("res://old.gd")\n',
+ )
+ assert _targets(module, DependencyType.PRELOAD) == []
+
+ def test_load_in_inline_comment_ignored(self, tmp_path):
+ module = _parse(
+ tmp_path,
+ 'extends Node\nvar y = 1 # load("res://old.gd")\n',
+ )
+ assert _targets(module, DependencyType.LOAD) == []
+
+ def test_real_preload_after_comment_kept(self, tmp_path):
+ module = _parse(
+ tmp_path,
+ 'var real = preload("res://real.gd") # loads the thing\n',
+ )
+ assert _targets(module, DependencyType.PRELOAD) == ["res://real.gd"]
+
+ def test_hash_inside_string_is_not_a_comment(self, tmp_path):
+ module = _parse(
+ tmp_path,
+ 'var real = preload("res://a.gd")\nvar s = "# not a comment"\n',
+ )
+ assert _targets(module, DependencyType.PRELOAD) == ["res://a.gd"]
+
+
+class TestStringLiteralStripping:
+ def test_preload_text_inside_string_ignored(self, tmp_path):
+ module = _parse(
+ tmp_path,
+ 'var doc = "call preload(\\"res://phantom.gd\\") somewhere"\n',
+ )
+ assert _targets(module, DependencyType.PRELOAD) == []
+
+
+class TestMultilineStrings:
+ def test_triple_quoted_block_ignored(self, tmp_path):
+ source = (
+ "extends Node\n"
+ 'var doc = """\n'
+ 'preload("res://in_triple.gd")\n'
+ "extends InTripleClass\n"
+ "ClassRef.method()\n"
+ '"""\n'
+ 'var real = preload("res://real.gd")\n'
+ )
+ module = _parse(tmp_path, source)
+ assert _targets(module, DependencyType.PRELOAD) == ["res://real.gd"]
+ assert _targets(module, DependencyType.EXTENDS_CLASS) == []
+
+ def test_single_quoted_triple_block_ignored(self, tmp_path):
+ source = "var doc = '''\npreload(\"res://x.gd\")\n'''\n"
+ module = _parse(tmp_path, source)
+ assert _targets(module, DependencyType.PRELOAD) == []
+
+
+class TestLineNumbersPreserved:
+ def test_line_numbers_survive_preprocessing(self, tmp_path):
+ source = (
+ "extends Node\n" # 1
+ "# a comment line\n" # 2
+ 'var doc = """\n' # 3
+ "hidden\n" # 4
+ '"""\n' # 5
+ 'var real = preload("res://real.gd")\n' # 6
+ )
+ module = _parse(tmp_path, source)
+ preload = [
+ d for d in module.dependencies if d.dep_type == DependencyType.PRELOAD
+ ]
+ assert len(preload) == 1
+ assert preload[0].line == 6
+
+
+class TestBuiltinExtends:
+ def test_extends_known_builtin_not_a_dependency(self, tmp_path):
+ module = _parse(tmp_path, "extends PhysicsBody2D\n")
+ assert _targets(module, DependencyType.EXTENDS_CLASS) == []
+
+ def test_extends_unknown_class_is_dependency(self, tmp_path):
+ module = _parse(tmp_path, "extends SomeProjectClass\n")
+ assert _targets(module, DependencyType.EXTENDS_CLASS) == ["SomeProjectClass"]
diff --git a/tests/test_patterns.py b/tests/test_patterns.py
index c2244f9..4d47056 100644
--- a/tests/test_patterns.py
+++ b/tests/test_patterns.py
@@ -1,4 +1,4 @@
-from gdcruiser.parser.patterns import Patterns
+from gdcruiser.parser import patterns as Patterns
class TestExtendsPath:
diff --git a/tests/test_performance.py b/tests/test_performance.py
new file mode 100644
index 0000000..9c0751b
--- /dev/null
+++ b/tests/test_performance.py
@@ -0,0 +1,78 @@
+"""Tests for the reverse-dependents index, deep-graph cycles, and scanner."""
+
+from gdcruiser.graph.cycles import CycleDetector
+from gdcruiser.graph.dependency import DependencyGraph
+from gdcruiser.graph.node import Dependency, DependencyType, Module
+from gdcruiser.scanner import Scanner
+
+
+def _dep(target):
+ return Dependency(target=target, dep_type=DependencyType.PRELOAD)
+
+
+class TestDependentsIndex:
+ def test_dependents_found(self):
+ g = DependencyGraph()
+ g.add_module(Module(path="a", dependencies=[_dep("c")]))
+ g.add_module(Module(path="b", dependencies=[_dep("c")]))
+ g.add_module(Module(path="c"))
+ sources = sorted(p for p, _ in g.get_dependents("c"))
+ assert sources == ["a", "b"]
+
+ def test_no_dependents(self):
+ g = DependencyGraph()
+ g.add_module(Module(path="a"))
+ assert g.get_dependents("a") == []
+
+ def test_index_invalidated_on_add(self):
+ g = DependencyGraph()
+ g.add_module(Module(path="a", dependencies=[_dep("c")]))
+ assert len(g.get_dependents("c")) == 1 # builds index
+ g.add_module(Module(path="b", dependencies=[_dep("c")]))
+ assert len(g.get_dependents("c")) == 2 # index rebuilt
+
+
+class TestDeepCycles:
+ def test_deep_chain_no_recursion_error(self):
+ g = DependencyGraph()
+ n = 3000
+ for i in range(n):
+ deps = [_dep(f"m{i + 1}")] if i < n - 1 else []
+ g.add_module(Module(path=f"m{i}", dependencies=deps))
+ # A linear chain has no cycles and must not raise RecursionError.
+ assert CycleDetector(g).find_cycles() == []
+
+ def test_deep_chain_with_cycle(self):
+ g = DependencyGraph()
+ n = 2000
+ for i in range(n):
+ target = f"m{(i + 1) % n}" # wrap-around -> one big cycle
+ g.add_module(Module(path=f"m{i}", dependencies=[_dep(target)]))
+ cycles = CycleDetector(g).find_cycles()
+ assert len(cycles) == 1
+ assert len(cycles[0]) == n
+
+
+class TestScannerSinglePass:
+ def test_buckets_by_suffix(self, tmp_path):
+ (tmp_path / "a.gd").write_text("", encoding="utf-8")
+ (tmp_path / "s.tscn").write_text("", encoding="utf-8")
+ (tmp_path / "r.tres").write_text("", encoding="utf-8")
+ (tmp_path / "ignore.txt").write_text("", encoding="utf-8")
+ sub = tmp_path / "nested"
+ sub.mkdir()
+ (sub / "b.gd").write_text("", encoding="utf-8")
+
+ gd, tscn, tres = Scanner(tmp_path).find_all_files()
+ assert [p.name for p in gd] == ["a.gd", "b.gd"]
+ assert [p.name for p in tscn] == ["s.tscn"]
+ assert [p.name for p in tres] == ["r.tres"]
+
+ def test_exclude_pattern(self, tmp_path):
+ (tmp_path / "keep.gd").write_text("", encoding="utf-8")
+ addons = tmp_path / "addons"
+ addons.mkdir()
+ (addons / "plugin.gd").write_text("", encoding="utf-8")
+
+ gd = Scanner(tmp_path, exclude=["addons"]).find_gdscript_files()
+ assert [p.name for p in gd] == ["keep.gd"]
diff --git a/tests/test_robustness.py b/tests/test_robustness.py
new file mode 100644
index 0000000..71edbb2
--- /dev/null
+++ b/tests/test_robustness.py
@@ -0,0 +1,86 @@
+"""Tests for malformed-input handling, symbol collisions, and config semantics."""
+
+from gdcruiser.analyzer import Analyzer
+from gdcruiser.config.loader import ConfigLoader
+from gdcruiser.symbols.table import SymbolTable
+
+
+class TestSymbolCollisions:
+ def test_duplicate_class_name_recorded(self):
+ table = SymbolTable()
+ table.register("Foo", "res://a.gd")
+ table.register("Foo", "res://b.gd")
+ collisions = table.collisions()
+ assert collisions == [("Foo", "res://a.gd", "res://b.gd")]
+
+ def test_same_path_not_a_collision(self):
+ table = SymbolTable()
+ table.register("Foo", "res://a.gd")
+ table.register("Foo", "res://a.gd")
+ assert table.collisions() == []
+
+ def test_analyzer_surfaces_collision_warning(self, tmp_path):
+ (tmp_path / "project.godot").write_text("[application]\n", encoding="utf-8")
+ (tmp_path / "a.gd").write_text("class_name Dup\n", encoding="utf-8")
+ (tmp_path / "b.gd").write_text("class_name Dup\n", encoding="utf-8")
+ result = Analyzer(tmp_path).analyze()
+ assert any("Duplicate symbol 'Dup'" in w for w in result.warnings)
+
+
+class TestMalformedInput:
+ def test_bad_utf8_gdscript_recorded_not_fatal(self, tmp_path):
+ (tmp_path / "project.godot").write_text("[application]\n", encoding="utf-8")
+ (tmp_path / "good.gd").write_text("class_name Good\n", encoding="utf-8")
+ (tmp_path / "bad.gd").write_bytes(b"\xff\xfe invalid utf8 \x80\x81")
+
+ result = Analyzer(tmp_path).analyze()
+ # Good file still analyzed; bad one reported as an error.
+ assert result.graph.has_module("res://good.gd")
+ assert any("bad.gd" in e for e in result.errors)
+
+ def test_malformed_project_godot_not_fatal(self, tmp_path):
+ (tmp_path / "project.godot").write_bytes(b"\xff\xfe not utf8")
+ (tmp_path / "good.gd").write_text("class_name Good\n", encoding="utf-8")
+
+ result = Analyzer(tmp_path).analyze()
+ assert result.graph.has_module("res://good.gd")
+ assert any("project.godot" in e for e in result.errors)
+
+
+class TestConfigSemantics:
+ def test_unknown_top_level_key_warns(self, tmp_path):
+ cfg = tmp_path / ".gdcruiser.json"
+ cfg.write_text('{"forbiden": []}', encoding="utf-8")
+ config = ConfigLoader(tmp_path).load(cfg)
+ assert any("forbiden" in w for w in config.warnings)
+
+ def test_bad_severity_warns_and_defaults_to_error(self, tmp_path):
+ cfg = tmp_path / ".gdcruiser.json"
+ cfg.write_text(
+ '{"forbidden": [{"name": "r", "severity": "warning", '
+ '"from": {"path": "a"}, "to": {"path": "b"}}]}',
+ encoding="utf-8",
+ )
+ config = ConfigLoader(tmp_path).load(cfg)
+ assert config.forbidden[0].severity.value == "error"
+ assert any("severity" in w and "warning" in w for w in config.warnings)
+
+ def test_unknown_rule_key_warns(self, tmp_path):
+ cfg = tmp_path / ".gdcruiser.json"
+ cfg.write_text(
+ '{"forbidden": [{"name": "r", "frm": {"path": "a"}}]}',
+ encoding="utf-8",
+ )
+ config = ConfigLoader(tmp_path).load(cfg)
+ assert any("frm" in w for w in config.warnings)
+
+ def test_valid_config_has_no_warnings(self, tmp_path):
+ cfg = tmp_path / ".gdcruiser.json"
+ cfg.write_text(
+ '{"forbidden": [{"name": "r", "severity": "warn", '
+ '"from": {"path": "a"}, "to": {"path": "b"}}], '
+ '"options": {"exclude": ["addons"]}}',
+ encoding="utf-8",
+ )
+ config = ConfigLoader(tmp_path).load(cfg)
+ assert config.warnings == []