From 4d958ef3012d89d87513a45927d6d7734554bdd2 Mon Sep 17 00:00:00 2001 From: Tom Softreck Date: Fri, 6 Nov 2026 18:59:34 +0100 Subject: [PATCH] fix(scanner): honor configured exclusions in README analysis and exports Co-authored-by: Koru Agent --- code2docs/analyzers/project_scanner.py | 5 ++ code2docs/generators/code2llm_gen.py | 5 ++ tests/test_code2llm_exclusions.py | 48 +++++++++++ tests/test_project_scanner.py | 105 +++++++++++++++++++++++++ 4 files changed, 163 insertions(+) create mode 100644 tests/test_code2llm_exclusions.py create mode 100644 tests/test_project_scanner.py diff --git a/code2docs/analyzers/project_scanner.py b/code2docs/analyzers/project_scanner.py index d19c247..da72a87 100644 --- a/code2docs/analyzers/project_scanner.py +++ b/code2docs/analyzers/project_scanner.py @@ -20,6 +20,11 @@ def _build_llm_config(self) -> Config: mode="static", verbose=self.config.verbose, ) + # README analysis must use the same configured source boundaries as + # the optional export, while retaining code2llm's native defaults. + config.filters.exclude_patterns = list(dict.fromkeys( + config.filters.exclude_patterns + self.config.code2llm.exclude_patterns + )) config.filters.exclude_tests = self.config.exclude_tests config.filters.skip_private = self.config.skip_private # Keep properties and accessors visible for docs diff --git a/code2docs/generators/code2llm_gen.py b/code2docs/generators/code2llm_gen.py index c22eda6..9d958b0 100644 --- a/code2docs/generators/code2llm_gen.py +++ b/code2docs/generators/code2llm_gen.py @@ -183,6 +183,11 @@ def _add_config_options(self, cmd: List[str], cfg) -> None: def _add_exclude_patterns(self, cmd: List[str], cfg, project_path: Path) -> None: """Add exclude patterns from config and .gitignore.""" + # --skip-subprojects accepts directory names, not arbitrary source + # globs. Forward every configured pattern through the filter option. + if cfg.exclude_patterns: + cmd.append("--exclude") + cmd.extend(dict.fromkeys(cfg.exclude_patterns)) skip_dirs = self._get_config_skip_dirs(cfg) gitignore_patterns = parse_gitignore(project_path) diff --git a/tests/test_code2llm_exclusions.py b/tests/test_code2llm_exclusions.py new file mode 100644 index 0000000..1123d52 --- /dev/null +++ b/tests/test_code2llm_exclusions.py @@ -0,0 +1,48 @@ +"""Export filtering retains hidden/glob patterns and entries beyond the tenth.""" + +from types import SimpleNamespace + +from code2llm.cli_analysis import _build_filter_config +from code2llm.cli_parser import create_parser +from code2llm.core.file_filter import FastFileFilter + +from code2docs.config import Code2DocsConfig +from code2docs.generators.code2llm_gen import Code2LlmGenerator + + +def test_export_passes_complete_patterns_through_actual_cli_filter(tmp_path): + config = Code2DocsConfig() + patterns = [f"copy{n}" for n in range(12)] + [ + ".subactor", + "**/generated/**", + "*scratch*", + "copy0", + ] + config.code2llm.exclude_patterns = list(patterns) + generator = Code2LlmGenerator(config, SimpleNamespace(project_path=str(tmp_path))) + command = generator._build_base_cmd(tmp_path, tmp_path / "project", config.code2llm) + generator._add_exclude_patterns(command, config.code2llm, tmp_path) + args = create_parser().parse_args(command[3:]) + assert args.exclude == list(dict.fromkeys(patterns)) + assert config.code2llm.exclude_patterns == patterns + filters = _build_filter_config(args) + file_filter = FastFileFilter(filters, tmp_path) + assert file_filter.should_process(str(tmp_path / "canonical.py")) + for relative in [ + "copy11/mirror.py", + ".subactor/cache/mirror.py", + "pkg/generated/mirror.py", + "scratch_copy.py", + ]: + assert not file_filter.should_process(str(tmp_path / relative)), relative + + +def test_export_preserves_existing_simple_gitignore_subproject_skips(tmp_path): + (tmp_path / ".gitignore").write_text("legacy-cache/\n!keep.py\n") + config = Code2DocsConfig() + config.code2llm.exclude_patterns = ["**/generated/**"] + command = [] + generator = Code2LlmGenerator(config, SimpleNamespace(project_path=str(tmp_path))) + generator._add_exclude_patterns(command, config.code2llm, tmp_path) + assert command[:2] == ["--exclude", "**/generated/**"] + assert command[command.index("--skip-subprojects") + 1 :] == ["legacy-cache"] diff --git a/tests/test_project_scanner.py b/tests/test_project_scanner.py new file mode 100644 index 0000000..8d8f34f --- /dev/null +++ b/tests/test_project_scanner.py @@ -0,0 +1,105 @@ +"""Real collection and README generation respect configured source boundaries.""" + +from copy import deepcopy + +import pytest +import yaml +from click.testing import CliRunner +from code2llm.api import Config + +from code2docs.analyzers.project_scanner import ProjectScanner +from code2docs.cli import main +from code2docs.config import Code2DocsConfig + + +def sample(root): + for relative, name in [ + ("canonical.py", "CanonicalEngine"), + ("artifacts/copy.py", "ArtifactCopy"), + ("scratch_copy.py", "ScratchCopy"), + ("generated/copy.py", "GeneratedCopy"), + (".subactor/cache/copy.py", "CacheCopy"), + (".worktrees/ticket-999/copy.py", "WorktreeCopy"), + ("venv/copy.py", "EnvironmentCopy"), + ]: + path = root / relative + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text( + f'class {name}:\n """{name} API."""\n def run(self):\n return 1\n' + ) + + +@pytest.mark.parametrize("export_enabled", [False, True]) +def test_actual_collection_applies_custom_exclusions_with_or_without_export( + tmp_path, export_enabled +): + sample(tmp_path) + config = Code2DocsConfig() + config.code2llm.enabled = export_enabled + config.code2llm.exclude_patterns = ["artifacts", "*scratch*", "generated"] + scanner = ProjectScanner(config) + result = scanner.analyze(str(tmp_path)) + names = {item.name for item in result.classes.values()} + assert names == {"CanonicalEngine"} + + +def test_scanner_preserves_native_defaults_and_does_not_mutate_configuration(): + defaults = deepcopy(Config().filters.exclude_patterns) + config = Code2DocsConfig() + configured = ["artifacts", "**/generated/**", "artifacts"] + config.code2llm.exclude_patterns = configured + scanner = ProjectScanner(config) + assert config.code2llm.exclude_patterns == configured + assert Config().filters.exclude_patterns == defaults + assert set(defaults) <= set(scanner._llm_config.filters.exclude_patterns) + assert scanner._llm_config.filters.exclude_patterns.count("artifacts") == 1 + scanner._llm_config.filters.exclude_patterns.append("scanner-only") + assert "scanner-only" not in config.code2llm.exclude_patterns + assert "scanner-only" not in Config().filters.exclude_patterns + + +def test_empty_configured_exclusions_keep_native_filter_defaults(): + config = Code2DocsConfig() + config.code2llm.exclude_patterns = [] + assert ( + ProjectScanner(config)._llm_config.filters.exclude_patterns + == Config().filters.exclude_patterns + ) + + +def test_readme_only_cli_uses_yaml_exclusions_and_keeps_sync_markers(tmp_path, monkeypatch): + sample(tmp_path) + path = tmp_path / "code2docs.yaml" + path.write_text( + yaml.safe_dump( + { + "project": {"name": "fixture", "readme_output": "manual/README.md"}, + "llm": {"enabled": False}, + "code2llm": { + "enabled": False, + "exclude_patterns": ["artifacts", "*scratch*", "generated"], + }, + "readme": {"sections": ["api", "structure"], "sync_markers": True}, + } + ) + ) + monkeypatch.setattr("code2docs.autoupdate.check_for_updates", lambda *args: None) + result = CliRunner().invoke( + main, ["generate", str(tmp_path), "--config", str(path), "--readme-only"] + ) + assert result.exit_code == 0, result.output + content = (tmp_path / "manual/README.md").read_text() + assert "CanonicalEngine" in content + for excluded in [ + "ArtifactCopy", + "ScratchCopy", + "GeneratedCopy", + "CacheCopy", + "WorktreeCopy", + "EnvironmentCopy", + ]: + assert excluded not in content + from code2docs.generators.readme_gen import MARKER_START, MARKER_END + + assert MARKER_START in content and MARKER_END in content + assert not (tmp_path / "project").exists()