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 == []