diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 9d7ff7d72..ac0c552cf 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -699,11 +699,14 @@ lc tools call grep --args '{"path":".","content_regex":"TODO"}' ### Excluding files from indexing -`lc code index` walks git-visible files (`git ls-files`, so `.gitignore` is -already honored) plus a built-in skip list for caches/build output. To -exclude specific files -- data dumps, fixtures, generated JSON -- that are -tracked in git and therefore not covered by `.gitignore`, add gitignore-syntax -patterns to `.lemoncrow/.ignore`: +`lc code index` indexes every git-tracked file in a supported language, +whatever its directory is called. Files git does not track -- new files not +yet added, and every file when the directory is not a git repository -- are +indexed too, unless `.gitignore` excludes them or they sit under a built-in +skip list of cache, dependency, and build-output directories (`node_modules`, +`.venv`, `dist`, `build`, `data`, `results`, and similar). To exclude specific +tracked files -- data dumps, fixtures, generated JSON -- that `.gitignore` +cannot cover, add gitignore-syntax patterns to `.lemoncrow/.ignore`: ```gitignore # .lemoncrow/.ignore diff --git a/src/lemoncrow/infra/code_intel/coverage.py b/src/lemoncrow/infra/code_intel/coverage.py index b5e72f1b9..454285330 100644 --- a/src/lemoncrow/infra/code_intel/coverage.py +++ b/src/lemoncrow/infra/code_intel/coverage.py @@ -55,6 +55,7 @@ exclusion_rule, free_tier_selection, git_ignored, + git_tracked, load_lemoncrow_ignore_spec, run_git, source_file_patterns, @@ -214,6 +215,7 @@ def _exclusion( rel: str, selection: _IndexSelection, ignored: frozenset[str], + tracked: frozenset[str], patterns: Sequence[str], ) -> tuple[str, str]: """``(rule, reason)`` for an on-disk path the indexer's scan passed over. @@ -223,7 +225,9 @@ def _exclusion( when the scan passed a path over for a reason no rule sees -- an untracked file inside a submodule, which ``git ls-files --others`` does not recurse. """ - return exclusion_rule(rel, ignore_spec=selection.ignore_spec, ignored=ignored, patterns=patterns) or ( + return exclusion_rule( + rel, ignore_spec=selection.ignore_spec, ignored=ignored, tracked=tracked, patterns=patterns + ) or ( RULE_SOURCE_FILE_SCAN, REASON_SOURCE_FILE_SCAN, ) @@ -290,7 +294,9 @@ def check_coverage(paths: list[str] | None = None, repo_root: Path | str = ".") unindexed_on_disk = [rel for rel in candidates if rel not in indexed and (root / rel).exists()] selection = _index_selection(root) if unindexed_on_disk else _NO_SELECTION - ignored = git_ignored(root, [rel for rel in unindexed_on_disk if rel not in selection.selected]) + passed_over = [rel for rel in unindexed_on_disk if rel not in selection.selected] + ignored = git_ignored(root, passed_over) + tracked = git_tracked(root, passed_over) patterns = source_file_patterns() if unindexed_on_disk else [] entries: list[PathCoverage] = [] @@ -321,7 +327,7 @@ def check_coverage(paths: list[str] | None = None, repo_root: Path | str = ".") elif not exists: entry = PathCoverage(rel, "missing", "not on disk and not indexed", language_name, 0) elif rel not in selection.selected: - rule, reason = _exclusion(rel, selection, ignored, patterns) + rule, reason = _exclusion(rel, selection, ignored, tracked, patterns) entry = PathCoverage(rel, "excluded", reason, language_name, 0, rule) elif rel not in selection.kept: entry = PathCoverage(rel, "excluded", "free-tier file cap", language_name, 0, RULE_FREE_TIER_FILE_CAP) diff --git a/src/lemoncrow/infra/code_intel/inclusion.py b/src/lemoncrow/infra/code_intel/inclusion.py index cb24ce0c2..5f4356c67 100644 --- a/src/lemoncrow/infra/code_intel/inclusion.py +++ b/src/lemoncrow/infra/code_intel/inclusion.py @@ -15,6 +15,13 @@ *down* into this module, which is why nothing here imports from :mod:`lemoncrow.pro` at any scope -- the dependency runs one way. +A file git tracks is the owner's word that it belongs to the project, so it is +indexed whatever its directory is called: the skipped-directory list judges only +files git does not track -- untracked, not-ignored files and every file of the +non-git fallback walk -- where nothing else tells build output or a dependency +tree apart from source. ``.lemoncrow/.ignore`` is an explicit opt-out and binds +tracked files too; it is the way to keep a tracked data dump out. + Fail-open by design: a rule that cannot be evaluated -- git missing, the directory not a repository, ``pathspec`` not installed -- admits the path, which is what the scan's own non-git fallback (``_iter_glob_source_files``) does. @@ -44,6 +51,7 @@ "exclusion_rule", "free_tier_selection", "git_ignored", + "git_tracked", "indexable_paths", "load_lemoncrow_ignore_spec", "run_git", @@ -64,7 +72,8 @@ #: The indexer's file-selection rules, first match wins. The free-tier cap only #: ever applies to a path the scan selected, and every other rule only to a path -#: it did not. ``source-file-scan`` closes the ladder: the scan's own glob gate +#: it did not. ``skipped-directory`` and ``git-ignore`` never fire for a tracked +#: file. ``source-file-scan`` closes the ladder: the scan's own glob gate #: does not take the path, and no narrower rule says why (an extension in the #: wrong case, an untracked file inside a submodule). EXCLUSION_RULES: tuple[str, ...] = ( @@ -76,6 +85,9 @@ RULE_SOURCE_FILE_SCAN, ) +#: Directory names whose files are never indexed unless git tracks them. Only +#: untracked files meet this list: a tracked file under ``build/`` or ``data/`` +#: is source its owner committed (a Next.js ``build/[id]/page.tsx`` route). _SKIP_PARTS = { ".git", ".lemoncrow", @@ -242,11 +254,57 @@ def git_ignored(root: Path, candidates: Sequence[str]) -> frozenset[str]: return frozenset(entry for entry in output.split("\0") if entry) +#: Pathspecs per ``git ls-files`` call in :func:`git_tracked`, so a batch of +#: thousands of touched files stays well under the OS argument-length limit. +_TRACKED_QUERY_CHUNK = 1000 + + +def git_tracked(root: Path, candidates: Sequence[str]) -> frozenset[str]: + """The repo-relative *candidates* git tracks in this checkout, submodules included. + + The answer the scan's ``ls-files --cached --recurse-submodules`` pass gives, + asked only about *candidates* so an edit's re-index does not list the whole + repository. Pathspecs are literal: ``app/[id]/page.tsx`` is a path, not a glob. + + Fail-open to the empty set: every candidate is then judged as untracked, by + the full rule ladder -- what the scan's own non-git fallback applies. + """ + tracked: set[str] = set() + for start in range(0, len(candidates), _TRACKED_QUERY_CHUNK): + chunk = candidates[start : start + _TRACKED_QUERY_CHUNK] + try: + completed = subprocess.run( + [ + "git", + "--literal-pathspecs", + "-C", + str(root), + "ls-files", + "-z", + "--cached", + "--recurse-submodules", + "--", + *chunk, + ], + capture_output=True, + check=False, + timeout=30, + ) + except (OSError, subprocess.SubprocessError): + return frozenset() + if completed.returncode != 0: + return frozenset() + # Bytes, not text: a non-UTF-8 name must not raise out of an edit's re-index. + tracked.update(entry.decode("utf-8", errors="replace") for entry in completed.stdout.split(b"\0") if entry) + return frozenset(tracked) + + def exclusion_rule( rel: str, *, ignore_spec: Any | None, ignored: Container[str], + tracked: Container[str], patterns: Sequence[str] | None = None, ) -> tuple[str, str] | None: """``(rule, reason)`` for the first rule that passes *rel* over, else ``None``. @@ -258,12 +316,14 @@ def exclusion_rule( scan drops on its patterns alone -- ``src/SHOUT.PY`` -- was admitted by the incremental entry point while coverage called it ``excluded``. - *patterns* defaults to the scan's own :data:`SOURCE_FILE_PATTERNS`; pass the - list once when classifying a batch. + A path in *tracked* (see :func:`git_tracked`) skips the skipped-directory + rung: git vouches for it. *patterns* defaults to the scan's own + :data:`SOURCE_FILE_PATTERNS`; pass the list once when classifying a batch. """ - for part in Path(rel).parts: - if should_skip_relative_path(part): - return RULE_SKIPPED_DIRECTORY, f"skipped directory: {part}" + if rel not in tracked: + for part in Path(rel).parts: + if should_skip_relative_path(part): + return RULE_SKIPPED_DIRECTORY, f"skipped directory: {part}" if ignore_spec is not None and ignore_spec.match_file(rel): return RULE_LEMONCROW_IGNORE, ".lemoncrow/.ignore" if rel in ignored: @@ -280,9 +340,10 @@ def indexable_paths(repo_root: Path, paths: Iterable[Path]) -> list[Path]: For the incremental entry point, where the candidates are the handful of files an edit touched rather than a whole scan: the ``.lemoncrow/.ignore`` - spec is loaded once and git is asked once for the batch, so the cost is one - subprocess per reindex, not one per file. A path outside *repo_root* is - dropped -- no scan of this checkout would ever list it. + spec is loaded once and git is asked about the batch as a whole -- which + candidates it ignores, which it tracks -- so the cost is two subprocesses + per reindex, not per file. A path outside *repo_root* is dropped -- no scan + of this checkout would ever list it. """ root = repo_root.resolve() pairs: list[tuple[Path, str]] = [] @@ -294,10 +355,12 @@ def indexable_paths(repo_root: Path, paths: Iterable[Path]) -> list[Path]: if not pairs: return [] ignore_spec = load_lemoncrow_ignore_spec(root) - ignored = git_ignored(root, [rel for _path, rel in pairs]) + rels = [rel for _path, rel in pairs] + ignored = git_ignored(root, rels) + tracked = git_tracked(root, rels) patterns = source_file_patterns() return [ path for path, rel in pairs - if exclusion_rule(rel, ignore_spec=ignore_spec, ignored=ignored, patterns=patterns) is None + if exclusion_rule(rel, ignore_spec=ignore_spec, ignored=ignored, tracked=tracked, patterns=patterns) is None ] diff --git a/src/lemoncrow/pro/capabilities/code_context/engine.py b/src/lemoncrow/pro/capabilities/code_context/engine.py index 5379d9bcd..e10b5dc59 100644 --- a/src/lemoncrow/pro/capabilities/code_context/engine.py +++ b/src/lemoncrow/pro/capabilities/code_context/engine.py @@ -8409,7 +8409,14 @@ def _fetch_cf() -> None: ) if file_glob: hits = [hit for hit in hits if _matches_file_glob(hit.file_path, file_glob)] - hits = [hit for hit in hits if not should_skip_relative_path(hit.file_path)] + # Repo symbols already passed the index's file-selection rules, which take a + # tracked file whatever its directory is called; the directory-name filter + # still judges commit-history and external-dependency hits. + hits = [ + hit + for hit in hits + if (scope == "repo" and hit.provenance != "commit") or not should_skip_relative_path(hit.file_path) + ] if provenance_filter is not None: hits = [h for h in hits if h.provenance == provenance_filter] if _is_precise_symbol_query(query): diff --git a/src/lemoncrow/pro/capabilities/repo_map/graph.py b/src/lemoncrow/pro/capabilities/repo_map/graph.py index 5072e99d6..8297ab5e9 100644 --- a/src/lemoncrow/pro/capabilities/repo_map/graph.py +++ b/src/lemoncrow/pro/capabilities/repo_map/graph.py @@ -51,11 +51,12 @@ def _iter_git_visible_source_files( ) -> list[Path]: # ``--recurse-submodules`` lists tracked files inside submodules too, but git # rejects it combined with ``--others``, so untracked files are fetched in a - # separate top-level-only call. - entries: list[bytes] = [] - for extra_args in ( - ("--cached", "--recurse-submodules"), - ("--others", "--exclude-standard"), + # separate top-level-only call. Which call listed an entry is kept: only an + # untracked file meets the skipped-directory list (see inclusion._SKIP_PARTS). + entries: list[tuple[bytes, bool]] = [] + for extra_args, tracked in ( + (("--cached", "--recurse-submodules"), True), + (("--others", "--exclude-standard"), False), ): try: completed = subprocess.run( @@ -69,10 +70,10 @@ def _iter_git_visible_source_files( return [] if completed.returncode != 0: return [] - entries.extend(entry for entry in completed.stdout.split(b"\x00") if entry) + entries.extend((entry, tracked) for entry in completed.stdout.split(b"\x00") if entry) files: list[Path] = [] total_raw = len(entries) - for i, raw_entry in enumerate(entries): + for i, (raw_entry, tracked) in enumerate(entries): if progress_callback is not None: progress_callback(i, total_raw) rel = raw_entry.decode("utf-8", errors="replace") @@ -81,7 +82,7 @@ def _iter_git_visible_source_files( path = (repo_root / rel).resolve() if not path.is_file(): continue - if should_skip_path(path, repo_root=repo_root): + if not tracked and should_skip_path(path, repo_root=repo_root): continue if ignore_spec is not None and ignore_spec.match_file(rel): continue diff --git a/tests/core/test_code_context.py b/tests/core/test_code_context.py index f837eb4ea..46af4677a 100644 --- a/tests/core/test_code_context.py +++ b/tests/core/test_code_context.py @@ -1666,6 +1666,21 @@ def test_auto_mode_keeps_identifier_queries_on_exact_lexical_order(tmp_path: Pat assert payload["mode"] == "lexical" +def test_search_symbols_keeps_tracked_files_under_skip_list_directory_names(tmp_path: Path) -> None: + """A tracked file under ``build/`` is indexed, so repo search must not drop it by directory name.""" + _init_git_fixture_repo(tmp_path / "repo") + root = tmp_path / "repo" + (root / "build").mkdir() + (root / "build" / "pipeline.py").write_text("def assemble_release_bundle() -> int:\n return 1\n") + _git(["add", "build/pipeline.py"], root) + engine = CodeContextEngine(root, db_path=tmp_path / "code.sqlite") + engine.index_repo() + + hits = engine.search_symbols("assemble_release_bundle", limit=5, mode="lexical") + + assert [(hit.file_path, hit.symbol_name) for hit in hits][:1] == [("build/pipeline.py", "assemble_release_bundle")] + + def test_search_symbols_lexical_planner_prioritizes_exact_and_case_insensitive_matches( tmp_path: Path, ) -> None: diff --git a/tests/core/test_repo_map.py b/tests/core/test_repo_map.py index af299c926..28c95a1c6 100644 --- a/tests/core/test_repo_map.py +++ b/tests/core/test_repo_map.py @@ -210,3 +210,27 @@ def test_iter_source_files_respects_lemoncrow_ignore_git_tracked(tmp_path: Path) files = {path.relative_to(tmp_path).as_posix() for path in iter_source_files(tmp_path)} assert files == {"src/keep.py"} + + +def test_iter_source_files_takes_tracked_files_whatever_their_directory_is_called(tmp_path: Path) -> None: + """A tracked file is indexed under any directory name; the skip list judges only untracked files.""" + import subprocess + + subprocess.run(["git", "init", "-q"], cwd=tmp_path, check=True) + tracked = [ + "src/keep.py", + "app/[orgSlug]/build/[id]/page.ts", + "insights/data/source.ts", + "compute/results/route.ts", + "node_modules/vendored/index.js", + "dist/action.js", + ] + untracked = ["build/scratch.py", "data/dump.py", ".venv/lib/site.py", "node_modules/dep/index.js"] + for rel in [*tracked, *untracked]: + (tmp_path / rel).parent.mkdir(parents=True, exist_ok=True) + (tmp_path / rel).write_text("export const x = 1;\n" if rel.endswith((".ts", ".js")) else "X = 1\n") + subprocess.run(["git", "--literal-pathspecs", "add", "--", *tracked], cwd=tmp_path, check=True) + + files = {path.relative_to(tmp_path).as_posix() for path in iter_source_files(tmp_path)} + + assert files == set(tracked) diff --git a/tests/infra/code_intel/test_coverage.py b/tests/infra/code_intel/test_coverage.py index 7edd98f07..5158c40f2 100644 --- a/tests/infra/code_intel/test_coverage.py +++ b/tests/infra/code_intel/test_coverage.py @@ -177,12 +177,13 @@ def test_exclusion_source_names_only_the_rules_that_fired( It was a fixed string, so a report whose only exclusion was a skipped directory still named git-ignore, which no path used, and never named the - skipped directory, which did. + skipped directory, which did. ``data/fixture.py`` stays untracked: the + skipped directory never passes over a tracked file. """ _write(workspace_root, "data/fixture.py", "def rows():\n return []\n") _write(workspace_root, "prompts/prompt.txt", "Review this diff.\n") root = _index_one_file(workspace_root, make_workspace) - _git_init(root, "data/fixture.py", "prompts/prompt.txt") + _git_init(root, "prompts/prompt.txt") report = check_coverage(paths=["prompts/prompt.txt", "src/a.py", "data/fixture.py"], repo_root=root) @@ -212,7 +213,7 @@ def _index_one_file(workspace_root: Path, make_workspace: WorkspaceFactory) -> P def test_skipped_directory_reports_excluded_with_rule(workspace_root: Path, make_workspace: WorkspaceFactory) -> None: - """A supported language under ``data/`` is still never indexed: excluded, not missing.""" + """An untracked file in a supported language under ``data/`` is never indexed: excluded, not missing.""" _write(workspace_root, "data/fixture.py", "def rows():\n return []\n") root = _index_one_file(workspace_root, make_workspace) @@ -402,3 +403,64 @@ def test_on_demand_indexing_still_takes_a_new_source_file(workspace_root: Path) assert "src/added.py" in files assert "src/added.py" in symbols assert _state_of(check_coverage(paths=["src/added.py"], repo_root=root), "src/added.py") == "indexed" + + +_TRACKED_IN_SKIP_DIRS = ("build/[id]/page.py", "data/rows.py", "results/route.py") + + +def _symbol_names(db_path: Path, rel: str) -> set[str]: + with sqlite3.connect(db_path) as conn: + return {str(row[0]) for row in conn.execute("SELECT symbol_name FROM symbols WHERE file_path = ?", (rel,))} + + +def test_a_tracked_file_is_indexed_whatever_its_directory_is_called(workspace_root: Path) -> None: + """Both entry points and the verdict agree: git tracking outranks the skipped-directory list. + + A Next.js route under ``build/[id]/`` is source its owner committed. The + untracked file beside it is still passed over by the directory name, and an + edit's re-index admits the one and not the other. + """ + from lemoncrow.pro.capabilities.code_context import CodeContextEngine + + root = workspace_root.resolve() + _write(root, "src/app.py", "def app():\n return 1\n") + for rel in _TRACKED_IN_SKIP_DIRS: + _write(root, rel, "def tracked_fn():\n return 1\n") + _write(root, "build/scratch.py", "def scratch():\n return 1\n") + subprocess.run(["git", "init", "-q"], cwd=root, check=True, capture_output=True, timeout=30) + subprocess.run( + ["git", "--literal-pathspecs", "add", "--", "src/app.py", *_TRACKED_IN_SKIP_DIRS], + cwd=root, + check=True, + capture_output=True, + timeout=30, + ) + engine = CodeContextEngine(root, autosync_enabled=False) + engine.index_repo() + + report = check_coverage(paths=[*_TRACKED_IN_SKIP_DIRS, "build/scratch.py"], repo_root=root) + assert {entry.path: (entry.state, entry.rule) for entry in report.paths} == { + **{rel: ("indexed", None) for rel in _TRACKED_IN_SKIP_DIRS}, + "build/scratch.py": ("excluded", "skipped-directory"), + } + + # What an edit to each does: the tracked route is re-extracted, the untracked file stays out. + _write(root, "build/[id]/page.py", "def page_v2():\n return 2\n") + engine._reindex_files(["build/[id]/page.py", "build/scratch.py"]) + + files, _symbols = _indexed_paths(engine.db_path) + assert _symbol_names(engine.db_path, "build/[id]/page.py") == {"page_v2"} + assert "build/scratch.py" not in files + assert _state_of(check_coverage(paths=["build/[id]/page.py"], repo_root=root), "build/[id]/page.py") == "indexed" + + +def test_lemoncrow_ignore_still_binds_a_tracked_file(workspace_root: Path, make_workspace: WorkspaceFactory) -> None: + """``.lemoncrow/.ignore`` is the explicit opt-out for tracked files; tracking does not override it.""" + _write(workspace_root, ".lemoncrow/.ignore", "fixtures/\n") + _write(workspace_root, "fixtures/dump.py", "def dump():\n return 1\n") + root = _index_one_file(workspace_root, make_workspace) + _git_init(root, "fixtures/dump.py") + + (entry,) = check_coverage(paths=["fixtures/dump.py"], repo_root=root).paths + + assert (entry.state, entry.rule) == ("excluded", "lemoncrow-ignore") diff --git a/tests/infra/code_intel/test_inclusion.py b/tests/infra/code_intel/test_inclusion.py new file mode 100644 index 000000000..4cfcdf86d --- /dev/null +++ b/tests/infra/code_intel/test_inclusion.py @@ -0,0 +1,85 @@ +"""git_tracked: which candidates git tracks, asked without listing the repository.""" + +from __future__ import annotations + +import os +import subprocess +from pathlib import Path + +import pytest + +from lemoncrow.infra.code_intel import inclusion +from lemoncrow.infra.code_intel.inclusion import exclusion_rule, git_tracked + + +def _git(root: Path, *args: str) -> None: + env = { + **os.environ, + "GIT_AUTHOR_NAME": "t", + "GIT_AUTHOR_EMAIL": "t@e", + "GIT_COMMITTER_NAME": "t", + "GIT_COMMITTER_EMAIL": "t@e", + } + subprocess.run(["git", *args], cwd=root, check=True, capture_output=True, timeout=30, env=env) + + +def _touch(root: Path, rel: str) -> None: + (root / rel).parent.mkdir(parents=True, exist_ok=True) + (root / rel).write_text("X = 1\n", encoding="utf-8") + + +def test_git_tracked_reads_bracketed_paths_literally(tmp_path: Path) -> None: + """``[id]`` is a Next.js route segment, not a glob: only the literal path is tracked.""" + _git(tmp_path, "init", "-q") + for rel in ("app/[id]/page.py", "app/i/page.py", "app/new.py"): + _touch(tmp_path, rel) + _git(tmp_path, "--literal-pathspecs", "add", "--", "app/[id]/page.py") + + assert git_tracked(tmp_path, ["app/[id]/page.py", "app/i/page.py", "app/new.py"]) == {"app/[id]/page.py"} + + +def test_git_tracked_answers_across_chunk_boundaries(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """Chunking bounds the argument list, never the answer: at, and one past, the chunk size.""" + _git(tmp_path, "init", "-q") + rels = [f"build/m{index}.py" for index in range(5)] + for rel in rels: + _touch(tmp_path, rel) + _git(tmp_path, "add", "--", *rels[:4]) + monkeypatch.setattr(inclusion, "_TRACKED_QUERY_CHUNK", 2) + + assert git_tracked(tmp_path, rels[:4]) == set(rels[:4]) + assert git_tracked(tmp_path, rels) == set(rels[:4]) + + +def test_git_tracked_sees_into_submodules(tmp_path: Path) -> None: + """The scan lists submodule files as tracked (``--recurse-submodules``); so must the per-edit check.""" + sub = tmp_path / "sub" + top = tmp_path / "top" + sub.mkdir() + top.mkdir() + _git(sub, "init", "-q") + _touch(sub, "build/page.py") + _git(sub, "add", "-A") + _git(sub, "commit", "-qm", "sub") + _git(top, "init", "-q") + _git(top, "-c", "protocol.file.allow=always", "submodule", "add", "-q", str(sub), "vendor/sub") + + assert git_tracked(top, ["vendor/sub/build/page.py", "vendor/sub/build/other.py"]) == {"vendor/sub/build/page.py"} + + +def test_git_tracked_outside_a_repository_is_empty(tmp_path: Path) -> None: + """Fail-open: nothing counts as tracked, so every candidate meets the full rule ladder.""" + _touch(tmp_path, "build/page.py") + + assert git_tracked(tmp_path, ["build/page.py"]) == frozenset() + assert git_tracked(tmp_path, []) == frozenset() + + +def test_exclusion_rule_skips_the_directory_list_only_for_tracked_paths() -> None: + rel = "apps/api/lib/build/prisma-cli-tracing.ts" + + assert exclusion_rule(rel, ignore_spec=None, ignored=set(), tracked={rel}) is None + assert exclusion_rule(rel, ignore_spec=None, ignored=set(), tracked=set()) == ( + "skipped-directory", + "skipped directory: build", + )