Skip to content

Commit b24e9ec

Browse files
Bordaclaude[bot]codex
committed
feat(codemap-py): extract package, add .pyi scan
- Extract the scanner, graph, query, and CLI dispatcher plus the schema/index-path/read-write-gate/logging/telemetry cores out of the two monolithic bin/ executables into an importable src/codemap_py/ package; bin/scan-index and bin/scan-query become thin launchers and the legacy bin/_*.py modules become sys.modules-aliasing shims, so old bare-name imports and private-attribute monkeypatches still resolve. python -m codemap_py reaches the same dispatcher. CLI output bytes and exit codes are unchanged, proven by monolith-vs-package parity tests. - Add .pyi type-stub analysis: scanner discovers .py and .pyi; a sibling .py stays authoritative and its .pyi is recorded as a shadowed stub, a lone .pyi is indexed once as stub-only with no call edges, __init__.py takes precedence over __init__.pyi, and case-fold path collisions fail closed identically on every OS. Editing a .pyi now invalidates the index; the first scan after upgrade rebuilds once via file-hash drift with no index-schema version change. - Derive the package build's executable-mode map from the real repository index (git ls-files --stage, supplied via --mode-map) and draw payload membership from that same tracked set, so an untracked working-tree file is excluded from both and a tracked file missing a mode entry raises instead of silently shipping non-executable. - Reorganize the test suite into subsystem subfolders with fixtures under tests/data/; add monolith-vs-package parity tests (guarded golden fixtures under tests/data/parity_golden/ that fail loudly if the golden is not the pre-extraction monolith) plus a .pyi collision matrix. - Bump both plugin manifests 0.25.0 to 0.25.1, record the changes in CHANGELOG.md, extend the README scanner-scope line to include .pyi, and add --cov=plugins/codemap-py/src so coverage tracks the extracted package. --- Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com> Co-authored-by: OpenAI Codex <codex@openai.com>
1 parent 79d5ab2 commit b24e9ec

106 files changed

Lines changed: 21647 additions & 10075 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

plugins/codemap-py/.claude-plugin/plugin.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,5 +19,5 @@
1919
"name": "codemap-py",
2020
"repository": "https://github.com/Borda/AI-Rig",
2121
"skills": "./claude-skills/",
22-
"version": "0.25.0"
22+
"version": "0.25.1"
2323
}

plugins/codemap-py/.codex-plugin/plugin.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,5 +8,5 @@
88
"license": "Apache-2.0",
99
"name": "codemap-py",
1010
"repository": "https://github.com/Borda/AI-Rig",
11-
"version": "0.25.0"
11+
"version": "0.25.1"
1212
}

plugins/codemap-py/CHANGELOG.md

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,30 @@ repository directory, and skill namespace change. Pre-`0.25.0` history was recor
66
`codemap` under `plugins/codemap/` — see the repository git history for that line; it is
77
not reproduced here.
88

9+
## 0.25.1
10+
11+
- Extracted the two monolithic `bin/` executables into an importable
12+
`src/codemap_py/` package: `scanner` (Python-file discovery and single-file AST
13+
parsing), `graph` (import/call/test/fixture/docstring graph, coverage, and
14+
test-impact construction plus scan orchestration), `query` (query dispatch and
15+
rendering), `cli` (the shared dispatcher), and the schema/index-path/read-write-gate/
16+
logging/telemetry cores. `bin/scan-index` and `bin/scan-query` are now thin launchers
17+
over the package; `python -m codemap_py` reaches the same dispatcher. The legacy
18+
`bin/_*.py` module names remain as compatibility shims, so existing imports and
19+
monkeypatches keep working unchanged. No CLI behavior, output bytes, or exit codes
20+
changed — the move is byte-for-byte parity-tested against the pre-extraction bytes.
21+
- **`.pyi` type stubs now participate in analysis** (plan §2.1 scope extension). A
22+
sibling `module.py` stays authoritative and its `module.pyi` is recorded as a
23+
shadowed stub rather than indexed twice; a `module.pyi` with no implementation is
24+
indexed once as a stub-only module contributing declarations and imports but no call
25+
edges; `package/__init__.pyi` follows the same precedence rule; case-fold path
26+
collisions fail closed identically on every OS. Editing a `.pyi` now invalidates the
27+
index. The first scan after upgrading rebuilds each index once (new discovery set),
28+
then reuse is stable; the on-disk index schema and `.cache/codemap/` path are
29+
unchanged.
30+
- Reorganized the test suite into subsystem subfolders with shared fixtures under
31+
`tests/data/` (test-only; nothing ships in the package).
32+
933
## 0.25.0
1034

1135
- **Renamed the product and plugin identity**: `codemap``codemap-py`, directory

plugins/codemap-py/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ codemap-py builds structural index of Python project — import graph, blast-rad
66

77
No direct querying. Wire in once, let other skills pick it up.
88

9-
**Python first.** Scanner uses `ast.parse` to index `.py` files. `.rst` and `docs/**/*.md` also scanned for Sphinx/MkDocs cross-refs, included in cache-invalidation hashing — doc-only edits trigger incremental re-scans. Non-Python symbol indexing (TypeScript, Go, Rust) planned.
9+
**Python first.** Scanner uses `ast.parse` to index `.py` files and `.pyi` type stubs (a sibling `.py` stays authoritative and its `.pyi` is recorded as a shadowed stub; a stub with no implementation is indexed once as stub-only, contributing declarations and imports but no call edges). `.rst` and `docs/**/*.md` also scanned for Sphinx/MkDocs cross-refs, included in cache-invalidation hashing — doc-only edits trigger incremental re-scans. Non-Python symbol indexing (TypeScript, Go, Rust) planned.
1010

1111
______________________________________________________________________
1212

Lines changed: 20 additions & 286 deletions
Original file line numberDiff line numberDiff line change
@@ -1,294 +1,28 @@
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
1415
"""
1516

1617
from __future__ import annotations
1718

18-
from dataclasses import dataclass
19-
import fnmatch
19+
import sys
2020
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.
26421

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))
26825

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)
27227

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

Comments
 (0)