|
1 | | -"""Index exclusion rules shared between scan-index (writer) and scan-query (reader). |
2 | | -
|
3 | | -scan-index drops built-in ``SKIP_DIRS`` and user-configured (``pyproject.toml`` / |
4 | | -``.codemapignore``) paths from the index. scan-query's staleness diff must apply the |
5 | | -SAME rules — otherwise a git-tracked-but-excluded ``.py`` (e.g. a vendored tree) is |
6 | | -re-listed unfiltered, shows as "added" against the filtered index ``file_shas``, and |
7 | | -forces the index permanently stale (the 1.2 ↔ 1.1 integration gap). Keeping the rules |
8 | | -in one module guarantees writer and reader never diverge. |
9 | | -
|
10 | | -Both scripts import via ``sys.path.insert`` on ``__file__``'s directory — this file |
11 | | -must live alongside them in ``bin/``. |
12 | | -
|
13 | | -consumers: bin/scan-index, bin/scan-query — imported as Python module; not a standalone executable |
| 1 | +#!/usr/bin/env python3 |
| 2 | +"""bin/_exclusions.py — compatibility shim for :mod:`codemap_py.scanner` (Phase 3 slice 3). |
| 3 | +
|
| 4 | +``scan-query`` imports this bare module name after inserting ``bin/`` onto its own |
| 5 | +``sys.path``; this shim prepends ``<plugin-root>/src`` to the process import path, then |
| 6 | +replaces its own entry in ``sys.modules`` with the real package module so every |
| 7 | +attribute access — including the exclusion parsers/matchers a test monkeypatches — |
| 8 | +reaches the one authoritative implementation. The exclusion rules (``SKIP_DIRS``, |
| 9 | +``Exclusions``, ``_load_exclusions``, ``_match_exclusion``, ``is_excluded``, |
| 10 | +``load_src_roots``) moved into :mod:`codemap_py.scanner` alongside the rest of the |
| 11 | +file-discovery/parsing code that scan-index's writer side owns; the reader (scan-query) |
| 12 | +must apply the SAME rules, so this shim keeps it pointed at the one implementation. |
| 13 | +
|
| 14 | +consumers: bin/scan-query — imported as bare ``_exclusions``; not a standalone executable |
14 | 15 | """ |
15 | 16 |
|
16 | 17 | from __future__ import annotations |
17 | 18 |
|
18 | | -from dataclasses import dataclass |
19 | | -import fnmatch |
| 19 | +import sys |
20 | 20 | from pathlib import Path |
21 | | -import re |
22 | | - |
23 | | -# Built-in directory names pruned from every scan. Never project source, but can hold |
24 | | -# worktree copies of the whole repo (.claude/, .codex/) that would otherwise inflate the |
25 | | -# index and create qualname collisions, plus the usual build/cache/venv dirs. |
26 | | -SKIP_DIRS = { |
27 | | - ".git", |
28 | | - ".venv", |
29 | | - "venv", |
30 | | - "__pycache__", |
31 | | - ".tox", |
32 | | - "dist", |
33 | | - "build", |
34 | | - ".eggs", |
35 | | - "node_modules", |
36 | | - ".mypy_cache", |
37 | | - ".pytest_cache", |
38 | | - ".ruff_cache", |
39 | | - "htmlcov", |
40 | | - ".claude", |
41 | | - ".codex", |
42 | | - ".experiments", |
43 | | - ".temp", |
44 | | - ".developments", |
45 | | - ".cache", |
46 | | - ".plans", |
47 | | - ".reports", |
48 | | - ".notes", |
49 | | - ".reference", |
50 | | - "site", |
51 | | - "_site", |
52 | | -} |
53 | | - |
54 | | -# Glob metacharacters — an exclusion entry containing any of these (or a "/") is |
55 | | -# treated as a path glob matched against the posix relpath; otherwise it is a bare |
56 | | -# directory name pruned during the walk (like SKIP_DIRS). |
57 | | -_GLOB_META_RE = re.compile(r"[*?\[\]/]") |
58 | | - |
59 | | - |
60 | | -@dataclass(frozen=True) |
61 | | -class Exclusions: |
62 | | - """User-configurable index exclusions layered on top of :data:`SKIP_DIRS`. |
63 | | -
|
64 | | - ``dirs`` are bare directory names pruned during ``os.walk`` (like ``SKIP_DIRS``). |
65 | | - ``globs`` are ``fnmatch`` patterns tested against each file's posix path relative |
66 | | - to the project root. ``sources`` records where each entry came from for meta output. |
67 | | -
|
68 | | - Args: |
69 | | - dirs: extra directory names to prune. |
70 | | - globs: glob patterns to skip individual files. |
71 | | - sources: mapping of raw entry → origin label (``"pyproject.toml"`` / ``".codemapignore"``). |
72 | | - """ |
73 | | - |
74 | | - dirs: frozenset[str] |
75 | | - globs: tuple[str, ...] |
76 | | - sources: dict[str, str] |
77 | | - |
78 | | - |
79 | | -def _parse_codemap_exclude_toml(text: str) -> list[str]: |
80 | | - """Extract the ``[tool.codemap] exclude = [...]`` string array from pyproject text. |
81 | | -
|
82 | | - Uses a targeted regex rather than a TOML parser: ``tomllib`` is 3.11+ and this |
83 | | - project runs on 3.10, and the existing ``detect_src_root`` config reader already |
84 | | - relies on regex TOML matching. Handles single-line and multi-line array forms. |
85 | | -
|
86 | | - Args: |
87 | | - text: full contents of a ``pyproject.toml`` file. |
88 | | -
|
89 | | - Returns: |
90 | | - List of raw exclude entries (empty if the section or key is absent). |
91 | | -
|
92 | | - Examples: |
93 | | - >>> _parse_codemap_exclude_toml('[tool.codemap]\\nexclude = ["a", "b/*"]\\n') |
94 | | - ['a', 'b/*'] |
95 | | - >>> _parse_codemap_exclude_toml('[tool.other]\\nexclude = ["x"]\\n') |
96 | | - [] |
97 | | - """ |
98 | | - section = re.search(r"^\[tool\.codemap\](.*?)(?=^\[|\Z)", text, re.MULTILINE | re.DOTALL) |
99 | | - if not section: |
100 | | - return [] |
101 | | - m = re.search(r"exclude\s*=\s*\[(.*?)\]", section.group(1), re.DOTALL) |
102 | | - if not m: |
103 | | - return [] |
104 | | - return re.findall(r'["\']([^"\']+)["\']', m.group(1)) |
105 | | - |
106 | | - |
107 | | -def _parse_codemap_src_roots_toml(text: str) -> list[str]: |
108 | | - """Extract the ``[tool.codemap] src_roots = [...]`` string array from pyproject text. |
109 | | -
|
110 | | - Uses the same targeted-regex strategy as :func:`_parse_codemap_exclude_toml` |
111 | | - (``tomllib`` is 3.11+; this project runs on 3.10). Handles single-line and |
112 | | - multi-line array forms. Declaration order is preserved — the array order is the |
113 | | - source-root priority applied by module naming and collision resolution. |
114 | | -
|
115 | | - Args: |
116 | | - text: full contents of a ``pyproject.toml`` file. |
117 | | -
|
118 | | - Returns: |
119 | | - List of raw source-root entries in declaration order (empty if the section |
120 | | - or key is absent). |
121 | | -
|
122 | | - Examples: |
123 | | - >>> _parse_codemap_src_roots_toml('[tool.codemap]\\nsrc_roots = ["a/src", "b/src"]\\n') |
124 | | - ['a/src', 'b/src'] |
125 | | - >>> _parse_codemap_src_roots_toml('[tool.other]\\nsrc_roots = ["x"]\\n') |
126 | | - [] |
127 | | - """ |
128 | | - section = re.search(r"^\[tool\.codemap\](.*?)(?=^\[|\Z)", text, re.MULTILINE | re.DOTALL) |
129 | | - if not section: |
130 | | - return [] |
131 | | - m = re.search(r"src_roots\s*=\s*\[(.*?)\]", section.group(1), re.DOTALL) |
132 | | - if not m: |
133 | | - return [] |
134 | | - return re.findall(r'["\']([^"\']+)["\']', m.group(1)) |
135 | | - |
136 | | - |
137 | | -def load_src_roots(root: Path) -> list[Path]: |
138 | | - """Load explicit source roots from ``pyproject.toml`` ``[tool.codemap] src_roots``. |
139 | | -
|
140 | | - Joins each declared entry to *root* (without ``resolve()`` — kept in the same |
141 | | - unresolved path space as the ``os.walk`` file paths so relative-path derivation |
142 | | - never trips over ``/var`` → ``/private/var`` symlink canonicalisation on macOS), |
143 | | - keeping only entries that exist as directories, in declaration order — which is the |
144 | | - priority order applied by module naming and collision resolution (earlier entries |
145 | | - win). Duplicate roots (by relative posix form) are dropped, preserving the first |
146 | | - occurrence. Returns an empty list when the key is absent, so callers fall back to |
147 | | - single-root ``detect_src_root`` detection with no behaviour change. |
148 | | -
|
149 | | - Args: |
150 | | - root: project root to read ``pyproject.toml`` from. |
151 | | -
|
152 | | - Returns: |
153 | | - List of existing source-root directories under *root*, in priority order. |
154 | | - """ |
155 | | - pyproject = root / "pyproject.toml" |
156 | | - if not pyproject.exists(): |
157 | | - return [] |
158 | | - entries = _parse_codemap_src_roots_toml(pyproject.read_text(errors="replace")) |
159 | | - roots: list[Path] = [] |
160 | | - seen: set[str] = set() |
161 | | - for entry in entries: |
162 | | - candidate = root / entry |
163 | | - try: |
164 | | - rel = candidate.relative_to(root).as_posix() |
165 | | - except ValueError: |
166 | | - continue # entry escapes the project root (e.g. "../x") — ignore |
167 | | - if rel and rel not in seen and candidate.is_dir(): |
168 | | - seen.add(rel) |
169 | | - roots.append(candidate) |
170 | | - return roots |
171 | | - |
172 | | - |
173 | | -def _parse_codemapignore(text: str) -> list[str]: |
174 | | - """Extract patterns from a ``.codemapignore`` file (one per line, ``#`` comments). |
175 | | -
|
176 | | - Args: |
177 | | - text: full contents of a ``.codemapignore`` file. |
178 | | -
|
179 | | - Returns: |
180 | | - List of non-empty, non-comment patterns with surrounding whitespace stripped. |
181 | | -
|
182 | | - Examples: |
183 | | - >>> _parse_codemapignore("# comment\\nvendored/\\n\\n foo.py \\n") |
184 | | - ['vendored', 'foo.py'] |
185 | | - """ |
186 | | - entries = [] |
187 | | - for raw in text.splitlines(): |
188 | | - line = raw.split("#", 1)[0].strip().rstrip("/") |
189 | | - if line: |
190 | | - entries.append(line) |
191 | | - return entries |
192 | | - |
193 | | - |
194 | | -def _load_exclusions(root: Path) -> Exclusions: |
195 | | - """Load extra dir-name and glob exclusions from pyproject.toml and .codemapignore. |
196 | | -
|
197 | | - Bare names (no ``/`` or glob metacharacter) become pruned directory names; anything |
198 | | - with a path separator or glob character becomes an ``fnmatch`` pattern. |
199 | | -
|
200 | | - Args: |
201 | | - root: project root to read config from. |
202 | | -
|
203 | | - Returns: |
204 | | - :class:`Exclusions` combining both config sources (empty when neither exists). |
205 | | - """ |
206 | | - dirs: set[str] = set() |
207 | | - globs: list[str] = [] |
208 | | - sources: dict[str, str] = {} |
209 | | - |
210 | | - def _ingest(entries: list[str], origin: str) -> None: |
211 | | - for entry in entries: |
212 | | - sources.setdefault(entry, origin) |
213 | | - if _GLOB_META_RE.search(entry): |
214 | | - globs.append(entry) |
215 | | - else: |
216 | | - dirs.add(entry) |
217 | | - |
218 | | - pyproject = root / "pyproject.toml" |
219 | | - if pyproject.exists(): |
220 | | - _ingest(_parse_codemap_exclude_toml(pyproject.read_text(errors="replace")), "pyproject.toml") |
221 | | - ignore = root / ".codemapignore" |
222 | | - if ignore.exists(): |
223 | | - _ingest(_parse_codemapignore(ignore.read_text(errors="replace")), ".codemapignore") |
224 | | - |
225 | | - return Exclusions(dirs=frozenset(dirs), globs=tuple(dict.fromkeys(globs)), sources=sources) |
226 | | - |
227 | | - |
228 | | -def _match_exclusion(rel_posix: str, exclusions: Exclusions) -> str | None: |
229 | | - """Return the exclusion entry that excludes *rel_posix*, or ``None``. |
230 | | -
|
231 | | - Matches a bare dir-name entry if it appears as any path component, or a glob entry |
232 | | - via ``fnmatch``. Used to keep git-tracked hashes consistent with the walked module |
233 | | - list so an excluded path never appears in the index. |
234 | | -
|
235 | | - Args: |
236 | | - rel_posix: file path relative to root, posix separators. |
237 | | - exclusions: loaded exclusions. |
238 | | -
|
239 | | - Returns: |
240 | | - The matching raw entry, or ``None`` if not excluded. |
241 | | -
|
242 | | - Examples: |
243 | | - >>> ex = Exclusions(frozenset({"vendor"}), ("gen/*.py",), {}) |
244 | | - >>> _match_exclusion("a/vendor/b.py", ex) |
245 | | - 'vendor' |
246 | | - >>> _match_exclusion("gen/x.py", ex) |
247 | | - 'gen/*.py' |
248 | | - >>> _match_exclusion("src/app.py", ex) is None |
249 | | - True |
250 | | - """ |
251 | | - parts = set(rel_posix.split("/")[:-1]) |
252 | | - hit = parts & exclusions.dirs |
253 | | - if hit: |
254 | | - return next(iter(hit)) |
255 | | - return next((g for g in exclusions.globs if fnmatch.fnmatch(rel_posix, g)), None) |
256 | | - |
257 | | - |
258 | | -def is_excluded(rel_posix: str, exclusions: Exclusions) -> bool: |
259 | | - """Return True if *rel_posix* is excluded by SKIP_DIRS or by *exclusions*. |
260 | | -
|
261 | | - Combines the built-in directory prune list with the user config so a single call |
262 | | - answers "would scan-index have dropped this tracked file?" — used by scan-query's |
263 | | - staleness diff to filter git-tracked paths the same way the index was filtered. |
264 | 21 |
|
265 | | - Args: |
266 | | - rel_posix: file path relative to root, posix separators. |
267 | | - exclusions: user-configured exclusions from :func:`_load_exclusions`. |
| 22 | +_SRC = Path(__file__).resolve().parent.parent / "src" |
| 23 | +if str(_SRC) not in sys.path: |
| 24 | + sys.path.insert(0, str(_SRC)) |
268 | 25 |
|
269 | | - Returns: |
270 | | - True when any path component is a built-in SKIP_DIR, or the path matches a |
271 | | - user dir-name / glob exclusion. |
| 26 | +from codemap_py import scanner as _impl # noqa: E402 (needs the sys.path insert above) |
272 | 27 |
|
273 | | - Examples: |
274 | | - >>> ex = Exclusions(frozenset({"vendor"}), (), {}) |
275 | | - >>> is_excluded(".claude/worktrees/x/pkg/a.py", ex) |
276 | | - True |
277 | | - >>> is_excluded("vendor/lib.py", ex) |
278 | | - True |
279 | | - >>> is_excluded(".sandbox/proj/src/app.py", ex) |
280 | | - True |
281 | | - >>> is_excluded("src/app.py", ex) |
282 | | - False |
283 | | - """ |
284 | | - parts = rel_posix.split("/")[:-1] |
285 | | - # Any dot-directory component prunes: dot-dirs are never part of a project's |
286 | | - # import space, but they can hold whole vendored checkouts — the 2026-07 usage |
287 | | - # audit found a `.sandbox/` tree contributing 646 of 928 indexed modules and |
288 | | - # dominating centrality. Must mirror the scan-index walk prune exactly, or |
289 | | - # excluded files reappear in the staleness diff as permanently "added". |
290 | | - if any(part.startswith(".") for part in parts): |
291 | | - return True |
292 | | - if SKIP_DIRS.intersection(parts): |
293 | | - return True |
294 | | - return _match_exclusion(rel_posix, exclusions) is not None |
| 28 | +sys.modules[__name__] = _impl |
0 commit comments