From d6b140b40c2db2530db5443b3f82c85ad176296e Mon Sep 17 00:00:00 2001 From: Maximilian Wedekind Date: Fri, 14 Aug 2026 14:20:25 +0200 Subject: [PATCH 1/2] fix(excludes): skip .worktrees and .phpstan when building the CPG MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two directory kinds slipped past DEFAULT_EXCLUDES/IGNORE_DIRS and broke the build on a Laravel monorepo: - `.worktrees/` holds full second checkouts (the layout `git worktree add` gets pointed at most often). Every symbol was indexed once per worktree — in the repo this was measured on, 13,344 of 18,630 indexed files came from three worktrees, so `lmc find` returned every hit four times and `impact` overstated the blast radius by the same factor, mixing in stale branch states that no longer exist anywhere. - `.phpstan/` is PHPStan's cache. Its `resultCache.php` reaches double-digit MB (23 MB here); php2cpg reads the parser output into memory in one go (IOUtils.contentFromBufferedSource) and dies with java.lang.OutOfMemoryError. Measured on the same repo, `lmc build` on 4,493 files: previously it aborted with `joern-parse exit 1` and fell back to the tree-sitter index, now the Joern CPG completes in 18 s. --- lmc/config.py | 2 +- lmc/joern.py | 2 ++ lmc/server/graph.py | 2 +- 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/lmc/config.py b/lmc/config.py index d79e9fd..241a189 100644 --- a/lmc/config.py +++ b/lmc/config.py @@ -37,7 +37,7 @@ IGNORE_DIRS = { ".git", "node_modules", "vendor", ".venv", "venv", "dist", "build", "__pycache__", ".idea", ".vscode", "target", "bower_components", - "storage", "var", "cache", + "storage", "var", "cache", ".worktrees", ".phpstan", } CONFIG_NAME = "lumos.yml" diff --git a/lmc/joern.py b/lmc/joern.py index e338545..3b5ae6a 100644 --- a/lmc/joern.py +++ b/lmc/joern.py @@ -31,6 +31,8 @@ DEFAULT_EXCLUDES = ( "vendor", "node_modules", ".venv", "venv", "__pycache__", "storage", "public", "dist", "build", "target", ".git", + ".worktrees", # git worktrees: ganze Zweit-Checkouts, sonst kommt jeder Treffer n-fach + ".phpstan", # PHPStan-Cache: resultCache.php wird zweistellig MB gross -> OOM beim Einlesen ) # ponytail: joern-parse kennt kein --exclude, und was nach --frontend-args kommt, diff --git a/lmc/server/graph.py b/lmc/server/graph.py index ad99e29..c89e26d 100644 --- a/lmc/server/graph.py +++ b/lmc/server/graph.py @@ -337,7 +337,7 @@ def _walk(node, src, file, idx, def_stack): _IGNORE = { ".git", "node_modules", "vendor", ".venv", "venv", "dist", "build", "__pycache__", ".idea", ".vscode", "target", "bower_components", - "storage", "var", "cache", "__pycache__", + "storage", "var", "cache", "__pycache__", ".worktrees", ".phpstan", } _LANG_EXTS = { From 9906f685bee383b9e50fdba92a1e91ab61e67520 Mon Sep 17 00:00:00 2001 From: Maximilian Wedekind Date: Fri, 14 Aug 2026 14:20:59 +0200 Subject: [PATCH 2/2] feat(config): per-project `exclude:` key in lumos.yml MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DEFAULT_EXCLUDES/IGNORE_DIRS cover dependency and build directories, which are the same everywhere. What isn't the same everywhere is a directory that only hurts in one repo — a vendored legacy tree, a generated-code drop. Putting such a name into the shared lists means every other project carries it too. # lumos.yml language: php codebase_hash: d870a6000cbcd0ae exclude: - legacy_app The key is read once per build and applies to both engines: `build_index()` adds it to _IGNORE, `joern_parse()` passes it to the frontend as `--exclude`, de-duplicated against the defaults. No config, no key, or an empty list keeps the previous behaviour exactly. Known limitation, documented in `joern_parse`: with `--scope` the mounted path is the subtree, so a lumos.yml at the worktree root is not seen and only DEFAULT_EXCLUDES apply. tests/test_config_excludes.py covers reading the key, the frontend command, the de-duplication, and — with a real tree-sitter parse — that a configured directory actually disappears from the index. --- README.md | 12 +++++ lmc/config.py | 16 ++++++ lmc/joern.py | 20 +++++--- lmc/server/graph.py | 7 ++- tests/test_config_excludes.py | 97 +++++++++++++++++++++++++++++++++++ 5 files changed, 145 insertions(+), 7 deletions(-) create mode 100644 tests/test_config_excludes.py diff --git a/README.md b/README.md index f69802d..89618d5 100644 --- a/README.md +++ b/README.md @@ -214,6 +214,7 @@ questions — ask `lmc`.** The agent then runs this loop: lmc init --auto --path . walk tree, count file extensions → dominant language → write lumos.yml {language, codebase_hash = sha1(abspath)[:16]} + optional, by hand: exclude: [, ...] → project-specific dirs to skip lmc build --path . gateway: graph.build_index() → in-memory Index (methods + call edges) @@ -353,6 +354,17 @@ Eigenes Image `lmc-joern:latest` aus `docker/Dockerfile` (basiert auf pysrc2cpg, … je nach Sprache — keine Excludes mehr im generic `joern-parse`); `lmc query` lädt sie per `importCpg` und führt CPGQL aus. +Ausgeschlossen werden Dependency- und Build-Ordner (`vendor`, `node_modules`, +`.worktrees`, …). Was nur in *einem* Projekt stört — ein mitgeschleppter +Legacy-Baum etwa —, kommt in die `lumos.yml` und gilt dann für beide Engines: + +```yaml +language: php +codebase_hash: d870a6000cbcd0ae +exclude: + - legacy_app +``` + Manuell bauen (optional, vorab): ```bash docker build -t lmc-joern:latest docker/ diff --git a/lmc/config.py b/lmc/config.py index 241a189..28fb14c 100644 --- a/lmc/config.py +++ b/lmc/config.py @@ -90,6 +90,22 @@ def load_config(path) -> dict: return yaml.safe_load(cfg.read_text(encoding="utf-8")) or {} +def excludes_from_config(path) -> set: + """Projekteigene Ausschluesse aus dem `exclude:`-Key der lumos.yml. + + Fuer Verzeichnisse, die nur in *diesem* Projekt stoeren und darum nichts in + IGNORE_DIRS/DEFAULT_EXCLUDES zu suchen haben — etwa ein mitgeschleppter + Legacy-Baum. Erwartet Top-Level-Namen relativ zur Worktree-Wurzel: + + exclude: + - legacy_app + + Liefert eine leere Menge, wenn es keine lumos.yml oder keinen Key gibt. + """ + raw = load_config(path).get("exclude") or [] + return {str(d).strip("/") for d in raw if str(d).strip("/")} + + def save_config(path, language: str, extra: dict | None = None) -> Path: """Schreibt lumos.yml mit Sprache + optionalem extra (z.B. codebase_hash).""" cfg_path = Path(path).resolve() / CONFIG_NAME diff --git a/lmc/joern.py b/lmc/joern.py index 3b5ae6a..f8b092d 100644 --- a/lmc/joern.py +++ b/lmc/joern.py @@ -14,10 +14,12 @@ import re import subprocess import time -from typing import Optional +from typing import Iterable, Optional import httpx +from lmc.config import excludes_from_config + JOERN_HOST = "127.0.0.1" JOERN_PORT = 8085 IMAGE = "lmc-joern:latest" @@ -139,11 +141,13 @@ def cpg_path_in_container(codebase_hash: str) -> str: return f"{CPG_DIR_IN_CONTAINER}/{codebase_hash}.bin" -def parse_command(worktree: str, out: str, language: str | None = None) -> list[str]: +def parse_command(worktree: str, out: str, language: str | None = None, + extra_excludes: Iterable[str] = ()) -> list[str]: """Baut das docker-Kommando fuer den CPG-Lauf. Mit bekannter Sprache das Frontend direkt (dann greifen die Excludes), - sonst unveraendert `joern-parse` ueber den ganzen Baum. + sonst unveraendert `joern-parse` ueber den ganzen Baum. `extra_excludes` + kommt aus dem `exclude:`-Key der lumos.yml und ergaenzt DEFAULT_EXCLUDES. """ base = [ "docker", "run", "--rm", @@ -155,15 +159,19 @@ def parse_command(worktree: str, out: str, language: str | None = None) -> list[ if frontend is None: return base + ["joern-parse", "/code", "-o", out] excludes = [] - for d in DEFAULT_EXCLUDES: + for d in dict.fromkeys((*DEFAULT_EXCLUDES, *extra_excludes)): excludes += ["--exclude", f"/code/{d}"] return base + [frontend, "/code", *excludes, "-o", out] def joern_parse(worktree: str, codebase_hash: str, language: str | None = None) -> dict: - """Baut .bin im Volume aus dem Worktree im Container.""" + """Baut .bin im Volume aus dem Worktree im Container. + + Die lumos.yml wird im gemounteten Baum gesucht; bei `--scope` zeigt + `worktree` auf den Teilbaum, dann greifen nur DEFAULT_EXCLUDES. + """ out = cpg_path_in_container(codebase_hash) - cmd = parse_command(worktree, out, language) + cmd = parse_command(worktree, out, language, excludes_from_config(worktree)) try: r = subprocess.run(cmd, capture_output=True, text=True, timeout=600) except FileNotFoundError: diff --git a/lmc/server/graph.py b/lmc/server/graph.py index c89e26d..eb5f8f0 100644 --- a/lmc/server/graph.py +++ b/lmc/server/graph.py @@ -16,6 +16,8 @@ from tree_sitter_language_pack import get_parser +from lmc.config import excludes_from_config + #Unsere Sprachen -> tree-sitter-Sprachname. TS_LANG = { "php": "php", "python": "python", "javascript": "javascript", @@ -292,11 +294,14 @@ def build_index(codebase_hash: str, language: str, root_path) -> Index: root = Path(root_path).resolve() idx = Index(codebase_hash=codebase_hash, language=language) exts = _exts_for(language) + # Projekteigene Ausschluesse aus der lumos.yml im Baum; bei --scope liegt dort + # keine, dann bleibt es bei _IGNORE. + ignore = _IGNORE | excludes_from_config(root) for p in root.rglob("*"): if not p.is_file() or p.suffix.lower() not in exts: continue - if any(part in _IGNORE for part in p.relative_to(root).parts[:-1]): + if any(part in ignore for part in p.relative_to(root).parts[:-1]): continue try: src_str = p.read_text(encoding="utf-8", errors="replace") # parse braucht str (tslp 1.11.0) diff --git a/tests/test_config_excludes.py b/tests/test_config_excludes.py new file mode 100644 index 0000000..3d3e852 --- /dev/null +++ b/tests/test_config_excludes.py @@ -0,0 +1,97 @@ +"""Projekteigene Ausschluesse aus dem `exclude:`-Key der lumos.yml. + +Motivation 2026-08-14: ein Laravel-Repo schleppte einen Legacy-Baum mit +1,5-MB-Font-Arrays mit, an dem php2cpg mit ``java.lang.OutOfMemoryError`` starb. +So ein Ordnername ist projektspezifisch und hat in DEFAULT_EXCLUDES nichts zu +suchen — dafuer gibt es jetzt ``exclude:`` in der lumos.yml. + +Generische Dependency-/Cache-Ordner bleiben dagegen in den Default-Listen; der +Test haelt beide Ebenen auseinander. +""" +import os +import sys +import tempfile +from pathlib import Path + +# Repo-Root zum Pfad hinzufuegen (laeuft ohne Install). +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) + +from lmc.config import excludes_from_config, save_config # noqa: E402 +from lmc.joern import DEFAULT_EXCLUDES, parse_command # noqa: E402 + +OUT = "/cpgs/deadbeef.bin" + + +def _worktree(tmp: str, exclude=None) -> str: + """Minimaler Worktree mit lumos.yml — genau das, was `lmc init` schreibt.""" + extra = {"codebase_hash": "deadbeef"} + if exclude is not None: + extra["exclude"] = exclude + save_config(tmp, "php", extra=extra) + return tmp + + +def test_no_config_means_no_extra_excludes(): + """Ohne lumos.yml darf nichts explodieren — leere Menge, kein Fehler.""" + with tempfile.TemporaryDirectory() as d: + assert excludes_from_config(d) == set() + + +def test_missing_key_means_no_extra_excludes(): + """lumos.yml ohne `exclude:` ist der Normalfall (so schreibt `lmc init` sie).""" + with tempfile.TemporaryDirectory() as d: + assert excludes_from_config(_worktree(d)) == set() + + +def test_exclude_key_is_read(): + """Der eigentliche Zweck: Namen aus der Config kommen an, Slashes normalisiert.""" + with tempfile.TemporaryDirectory() as d: + got = excludes_from_config(_worktree(d, ["legacy", "third_party/"])) + assert got == {"legacy", "third_party"}, got + + +def test_config_excludes_reach_the_frontend_command(): + """Ohne diesen Schritt liest die Config zwar, der CPG-Bau ignoriert sie aber.""" + cmd = parse_command("/w", OUT, "php", extra_excludes={"legacy"}) + assert "/code/legacy" in cmd, cmd + assert "/code/vendor" in cmd, "Defaults duerfen nicht verdraengt werden" + + +def test_config_excludes_are_not_duplicated(): + """`vendor` doppelt (Default + Config) wuerde php2cpg zwei gleiche Flags geben.""" + cmd = parse_command("/w", OUT, "php", extra_excludes={"vendor"}) + assert cmd.count("/code/vendor") == 1 + assert cmd.count("--exclude") == len(DEFAULT_EXCLUDES) + + +def test_index_skips_configured_dir(): + """Der Beweis am Ergebnis: die Datei taucht nicht im Index auf.""" + from lmc.server.graph import build_index + + with tempfile.TemporaryDirectory() as d: + (Path(d) / "app").mkdir() + (Path(d) / "app" / "Keep.php").write_text("