Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions code2docs/analyzers/project_scanner.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions code2docs/generators/code2llm_gen.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
48 changes: 48 additions & 0 deletions tests/test_code2llm_exclusions.py
Original file line number Diff line number Diff line change
@@ -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"]
105 changes: 105 additions & 0 deletions tests/test_project_scanner.py
Original file line number Diff line number Diff line change
@@ -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()
Loading