From 943ed8b90624b0ce25b0f4d201df81c2b2b40da1 Mon Sep 17 00:00:00 2001 From: zaebee Date: Wed, 14 Oct 2026 10:55:05 +0000 Subject: [PATCH 1/7] =?UTF-8?q?docs(plan):=20language-packs=20step=202=20?= =?UTF-8?q?=E2=80=94=20per-pack=20project=20readers?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- docs/plans/2026-10-14-language-packs-step2.md | 773 ++++++++++++++++++ 1 file changed, 773 insertions(+) create mode 100644 docs/plans/2026-10-14-language-packs-step2.md diff --git a/docs/plans/2026-10-14-language-packs-step2.md b/docs/plans/2026-10-14-language-packs-step2.md new file mode 100644 index 00000000..4b0250f7 --- /dev/null +++ b/docs/plans/2026-10-14-language-packs-step2.md @@ -0,0 +1,773 @@ +# Language Packs — Step 2: Project Readers — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Each language pack reads its own project configs: Python its declared dependencies; TypeScript its npm dependencies, workspace packages and tsconfig aliases. `import_names.py` becomes a language-free collector. No stored graph and no `ingest_state` value changes. + +**Architecture:** `cgis.core.project` defines two protocols, `ProjectReader` and `ProjectFacts`. `LanguagePack` gains `project_reader(root, extractors)`. The four config modules move into their packs: +- `python_dependencies` +- `npm_dependencies` +- `workspaces` +- `tsconfig_paths` + +The collector asks the registry for every pack's reader, routes walk files and the directories above the root to them, and assembles `ImportNames` from the readers' facts. The store keys and the `ImportNames` API stay as they are, so the pipeline and resolver are untouched. Pack modules stop importing their grammar at import time, so readers can exist from the first file without loading tree-sitter grammars. + +**Tech Stack:** Python 3.12, tree-sitter, SQLite, pytest, ruff, mypy strict, interrogate. + +**Spec:** `docs/specs/2026-10-14-language-packs-design.md` (rev 3): "Project facts", Migration row 2. Outline: `docs/plans/2026-10-14-language-packs.md` "Step 2". Previous step: PR #597 (`docs/plans/2026-10-14-language-packs-step1.md`, its Rulings). + +## Global Constraints + +- `make format && make lint && make type-check && make pytest && make doc-coverage` passes before every commit. Chain commands with `&&`: never `;`, never a newline after a heredoc, never a pipe on the command whose exit status gates. +- mypy strict on `src/`. Docstring coverage ≥ 90%. Sonar cognitive complexity ≤ 15 per function. +- **Behaviour unchanged:** + - `tests/fixtures/graph_identity/snapshot.json` is not modified, `ingest_state` included (`git diff --exit-code main -- tests/fixtures/graph_identity/snapshot.json`). + - `test_graph_identity.py` passes under both hash seeds. +- `tests/unit/test_configs_above_root.py` passes, with no change to its fixtures or assertions. Only its `import` lines may change, because modules move. This is how step 1 treated moved imports. +- Moved modules are imported from their new paths. No shims. +- Drift tolerances only go down (`docs/ontology/tolerances.lock`). + - If `extraction` drift exceeds 0.40 or its unresolved ratio exceeds 0.37 after the move, **stop and report**. + - If any other self-drift or god-object gate fails, **stop and report**. +- Test-count floor: raise `.github/test-count-baseline` (currently 3095) if the suite passes 3245. +- Every PR gets an independent reviewer with execution tools before merge. +- Commits end with `Co-Authored-By: Claude Opus 5.5 `. The PR body ends with `🤖 Generated with [Claude Code](https://claude.com/claude-code)`. +- Worktree `/home/zaebee/projects/ownima/cgb-step2`, branch `feat/language-packs-step2`. + +## Rulings (where this plan departs from the spec's text) + +- **S1 — `ProjectFacts` is data, not a writer.** + - **Conflict:** the spec gives `ProjectFacts.record(store)` and `differs_from(store)`. It also says "a pack never touches storage", and the `pack-isolation` gate enforces that: a pack calling `SQLiteStore.record_*` is a `cgis.languages → cgis.storage` edge. + - **Ruling:** `ProjectFacts.state() -> Mapping[str, object]`, keyed by the `ingest_state` key each fact is stored under. The collector's `ImportNames` records and compares facts through the store's existing typed methods, so the store keys stay as they are. + - *Cost if wrong:* moving two methods. +- **S2 — Pack modules are imported early; grammars are not.** + - **Why:** the spec says nothing imports a pack until one of its files turns up. A project reader, though, must exist before the walk, to see configs above the root and `package.json` files met ahead of any `.ts` file. + - **Ruling:** every pack's `__init__` stays light. The extractor, and with it the tree-sitter grammar, is imported inside `extractor()` (a static import, `noqa: PLC0415`). The review-fingerprint walk sees static imports. + - This supersedes step 1's R7. *Cost if wrong:* none found; laziness moves from the module to the grammar. +- **S3 — `note_above` is the TS reader's search; Python keeps its own.** + - `declared_import_roots` looks for the nearest manifest **including the root itself** and stops at `.git`. `_enclosing_directories` starts above the root. + - The Python reader therefore ignores `note_above` and keeps the exact search it has today. + - *Cost if wrong:* the two searches stay separate until someone needs them unified. +- **S4 — Readers keep the walk's injection seam.** `project_reader(root, extractors)` takes the pipeline's extractor mapping. A test pipeline built with only `{".py": PythonExtractor()}` keeps today's TS facts: no namer, so empty workspace packages and aliases, while npm names are still read. + +## Review Focus + +- **TS-only repo:** must not load `tree_sitter_python`. All readers are created up front now (Task 3, `test_a_typescript_only_ingest_never_loads_the_python_grammar`). +- **Python-only repo with a tooling `package.json` at its root** (prettier, commitlint): must not load `tree_sitter_typescript`. Step-1 deferred minor (Task 4). +- **Two packs claiming one `ingest_state` key:** must fail loudly, not let the last writer win (Task 3, `test_two_packs_claiming_one_key_fail`). +- **A graph stored before a fact was recorded:** must read as "had none" and not force a rebuild loop. `ImportNames.differ_from` is unchanged; Task 3's round-trip test records and re-reads all four keys. +- **Configs above the root** (#555, pnpm #562) and an unbounded search with no `.git`: unchanged. `test_configs_above_root.py` runs with only its import lines edited. + +--- + +### Task 1: Light packs — no grammar at import + +**Files:** +- Create: `src/cgis/languages/typescript/naming.py` (holds `TYPESCRIPT_LANG` and `file_path_to_module_fqn`, moved out of `extractor.py`) +- Modify: + - `src/cgis/languages/typescript/extractor.py` + - `src/cgis/languages/typescript/__init__.py` + - `src/cgis/languages/python/__init__.py` + - `tests/self_parsing/test_self_parse_ts.py:13` + - `tests/unit/test_typescript_extractor.py:6` +- Test: `tests/unit/test_languages.py` (append) + +**Interfaces:** +- Produces: `cgis.languages.typescript.naming.TYPESCRIPT_LANG` and `.file_path_to_module_fqn(file_path: str, source_root: str | None = None) -> str`. The body is unchanged. + +- [ ] **Step 1: Write the failing test.** Append to `tests/unit/test_languages.py`: + +```python +def test_importing_the_packs_loads_no_grammar() -> None: + """Ruling S2: readers need every pack from the first file; grammars wait for a parse.""" + script = ( + "import json, sys\n" + "from cgis.languages import pack_named\n" + "for name in ('python', 'typescript'):\n" + " pack = pack_named(name)\n" + " pack.scope_of('src/app/x.tsx', 'src')\n" + " pack.code_fence('a.py')\n" + "print(json.dumps(sorted({m.split('.')[0] for m in sys.modules if m.startswith('tree_sitter_')})))\n" + ) + out = subprocess.run([sys.executable, "-c", script], capture_output=True, text=True, check=False) + assert out.returncode == 0, out.stderr + assert json.loads(out.stdout.strip().splitlines()[-1]) == [] +``` + +- [ ] **Step 2: Run it and watch it fail.** + - Run: `uv run pytest tests/unit/test_languages.py -q -k loads_no_grammar` + - Expected: FAIL, with both `tree_sitter_python` and `tree_sitter_typescript` loaded, because each pack's `__init__` imports its extractor. + +- [ ] **Step 3: Move the TS naming out, and import the extractors lazily.** + - **`src/cgis/languages/typescript/naming.py`:** module docstring `"""TypeScript module FQNs from file paths — no tree-sitter, so a pack can name files without its grammar."""`. Move `TYPESCRIPT_LANG`, with its `#:` comment, and the whole `file_path_to_module_fqn` function verbatim out of `extractor.py`. + - **`extractor.py`:** add `from cgis.languages.typescript.naming import TYPESCRIPT_LANG, file_path_to_module_fqn`. Delete the moved definitions. + - **The two test files:** change their `file_path_to_module_fqn` import to `cgis.languages.typescript.naming`. In `test_typescript_extractor.py`, keep `TypeScriptExtractor` imported from `.extractor` on its own line. + - **`typescript/__init__.py`:** import `TYPESCRIPT_LANG` and `file_path_to_module_fqn` from `.naming`, drop the top-level extractor import, and make `extractor` read: + +```python + @override + def extractor(self, extension: str, source_roots: list[str]) -> BaseExtractor: + """The TSX grammar for `.tsx`, the TypeScript one otherwise — loaded here, on first use.""" + from cgis.languages.typescript.extractor import TypeScriptExtractor # noqa: PLC0415 + + return TypeScriptExtractor(tsx=extension == ".tsx", source_roots=source_roots) +``` + + - **`python/__init__.py`:** import `PYTHON_LANG` and `file_path_to_module_fqn` from `cgis.languages.python._syntax`. The extractor module's `file_path_to_module_fqn` only delegates to that one; check with `sed -n 25,45p src/cgis/languages/python/extractor.py`. Drop the top-level `PythonExtractor` import, and make `extractor` import it inside the method with `# noqa: PLC0415`, the same way. + - Update the module docstring of `src/cgis/languages/__init__.py`. It currently says each manifest's load function imports "the pack". Add: `A pack's own module imports no grammar; its extractor() does (step 2, ruling S2).` + +- [ ] **Step 4: Run the tests.** + - Run: `uv run pytest tests/unit/test_languages.py tests/unit/test_typescript_extractor.py tests/self_parsing/test_self_parse_ts.py tests/unit/test_review_fingerprint_contract.py -q` + - Expected: all pass. If the review-path inventory test reports a module that left the closure, it is `naming.py` showing up as new; that is growth and passes. A *missing* module means the walk lost one: stop and look. + +- [ ] **Step 5: Gate and commit.** + +```bash +cd /home/zaebee/projects/ownima/cgb-step2 && S=/tmp/claude-1001/-home-zaebee-projects-ownima-codegraph-brain/97c475b5-6bc7-4231-a643-ff81f3f651ec/scratchpad/step2 && mkdir -p $S && make format > /dev/null && make lint > $S/lint.log 2>&1 && make type-check > $S/mypy.log 2>&1 && make pytest > $S/pytest.log 2>&1 && make doc-coverage > $S/doc.log 2>&1 && git diff --exit-code main -- tests/fixtures/graph_identity/snapshot.json && git add -A && git commit -q -F - <<'MSG' +refactor(languages): a pack's module loads no grammar; extractor() does + +Readers (step 2) need every pack before the first file; the grammar can wait +for the first parse. TS module naming moves to a tree-sitter-free naming.py. + +Co-Authored-By: Claude Opus 5.5 +MSG +``` + +--- + +### Task 2: Move the config readers into their packs (no behaviour change) + +**Files:** +- Move: `src/cgis/python_dependencies.py` → `src/cgis/languages/python/dependencies.py` +- Move: `src/cgis/npm_dependencies.py` → `src/cgis/languages/typescript/npm.py` +- Move: `src/cgis/workspaces.py` → `src/cgis/languages/typescript/workspaces.py` +- Move: `src/cgis/tsconfig_paths.py` → `src/cgis/languages/typescript/tsconfig.py` +- Modify: importers in `src/`, `scripts/` and `tests/`, which are: + - `import_names.py` + - `tests/unit/test_python_dependencies.py`, `test_npm_dependencies.py`, `test_tsconfig_paths.py` + - the import line of `test_configs_above_root.py` +- Modify (self-graph): + - `docs/ontology/patterns.yaml` (the extraction comment) + - `tests/unit/review_path_inventory.txt` (regenerate) + - anything `tests/self_parsing` names by the old FQNs + +- [ ] **Step 1: Move with git, then rewrite the imports.** Rewrite only `from`/`import` lines, the same way step 1 did. + +```bash +cd /home/zaebee/projects/ownima/cgb-step2 && git mv src/cgis/python_dependencies.py src/cgis/languages/python/dependencies.py && git mv src/cgis/npm_dependencies.py src/cgis/languages/typescript/npm.py && git mv src/cgis/workspaces.py src/cgis/languages/typescript/workspaces.py && git mv src/cgis/tsconfig_paths.py src/cgis/languages/typescript/tsconfig.py && uv run python - <<'PY' +import re +import subprocess +from pathlib import Path + +MOVES = [ + (r"cgis\.python_dependencies\b", "cgis.languages.python.dependencies"), + (r"cgis\.npm_dependencies\b", "cgis.languages.typescript.npm"), + (r"cgis\.workspaces\b", "cgis.languages.typescript.workspaces"), + (r"cgis\.tsconfig_paths\b", "cgis.languages.typescript.tsconfig"), +] +IMPORT = re.compile(r"^\s*(from|import) cgis\.(python_dependencies|npm_dependencies|workspaces|tsconfig_paths)\b") + + +def move(line: str) -> str: + for pattern, new in MOVES: + line = re.sub(pattern, new, line) + return line + +files = subprocess.run(["git", "ls-files", "src", "scripts", "tests"], capture_output=True, text=True, check=True).stdout.split() +changed = 0 +for name in files: + path = Path(name) + if not name.endswith(".py") or name.startswith("tests/fixtures/") or not path.exists(): + continue + old = path.read_text(encoding="utf-8") + new = "".join(move(l) if IMPORT.match(l) else l for l in old.splitlines(keepends=True)) + if new != old: + path.write_text(new, encoding="utf-8") + changed += 1 +print("files changed:", changed) +PY +git grep -nE "cgis\.(python_dependencies|npm_dependencies|workspaces|tsconfig_paths)\b" -- src scripts tests docs/ontology +``` + +Expected: +- `files changed:` prints about 7. +- The final grep prints only non-import mentions. Fix those that describe where code lives now, such as `patch("cgis.workspaces...")` strings or `self_parsing` FQNs. Leave historical issue references alone. +- `git diff tests/unit/test_configs_above_root.py` shows import lines only. + +- [ ] **Step 2: Measure the extraction domain.** The four modules now sit under `cgis.languages`. + +```bash +cd /home/zaebee/projects/ownima/cgb-step2 && S=/tmp/claude-1001/-home-zaebee-projects-ownima-codegraph-brain/97c475b5-6bc7-4231-a643-ff81f3f651ec/scratchpad/step2 && uv run cgis ingest src --output $S/self.db > /dev/null 2>&1 && uv run cgis drift --db $S/self.db --profile python --format json > $S/drift.json 2>/dev/null +python3 -c "import json; d=json.load(open('$S/drift.json')); rs=d if isinstance(d,list) else d.get('reports') or d.get('domains'); [print(r['domain'], round(r['drift_score'],4), round(r['actual']['unresolved_ratio'],4), r['actual']['node_count']) for r in rs if r['domain']=='extraction']" +``` + +Expected: `extraction` drift at or below 0.40 and unresolved at or below 0.37. Main after #597 measured drift 0.315 and unresolved 0.346. Above either bound: **stop and report**. + +- [ ] **Step 3: Regenerate the review-path inventory.** First check that nothing left the closure except the moved names. + +```bash +cd /home/zaebee/projects/ownima/cgb-step2 && uv run python - <<'PY' +import sys; sys.path.insert(0, "tests/unit") +from test_review_fingerprint_contract import current_closure, INVENTORY +pinned = set(INVENTORY.read_text().split()); now = set(current_closure()) +print("gone:", sorted(pinned - now)); print("new:", sorted(now - pinned)) +INVENTORY.write_text("\n".join(sorted(now)) + "\n") +PY +``` + +Expected: +- `gone` lists only the four old paths that were in the closure. +- `new` lists their new paths, plus `naming.py` from Task 1. + +Anything else gone means a module dropped off the review path: stop. + +- [ ] **Step 4: Gate and commit.** Use the same gate line as Task 1 Step 5, with this message: + +``` +refactor(languages): config readers move into their packs + +python_dependencies -> languages/python/dependencies; npm_dependencies, +workspaces, tsconfig_paths -> languages/typescript/{npm,workspaces,tsconfig}. +Pure move plus imports; snapshot and ingest_state unchanged. Extraction domain +/ (lock 0.40/0.37). + +Co-Authored-By: Claude Opus 5.5 +``` + +Replace the placeholders with the numbers measured in Step 2. + +--- + +### Task 3: `ProjectReader` / `ProjectFacts` per pack; a language-free collector + +**Files:** +- Create: + - `src/cgis/core/project.py` + - `src/cgis/languages/python/project.py` + - `src/cgis/languages/typescript/project.py` + - `tests/unit/test_project_facts.py` +- Modify: + - `src/cgis/core/extractor.py` (`LanguagePack.project_reader`) + - `src/cgis/languages/__init__.py` (`project_readers`) + - both pack `__init__.py` + - `src/cgis/import_names.py` +- Test: `tests/unit/test_languages.py` (append the TS-only acceptance test) + +**Interfaces:** +- **`cgis.core.project`:** + - `ProjectFacts` (Protocol): `state() -> Mapping[str, object]`. + - `ProjectReader` (Protocol): `note(path: Path) -> bool`, `note_above(directories: list[Path], bounded: bool) -> None`, `facts() -> ProjectFacts`. +- **`LanguagePack.project_reader(root: Path, extractors: Mapping[str, BaseExtractor]) -> ProjectReader`.** +- **`cgis.languages.project_readers(root: Path, extractors: Mapping[str, BaseExtractor]) -> list[ProjectReader]`**, one reader per manifest, in manifest order. +- **Python pack:** `PythonProjectReader(root)` and `PythonFacts(dependencies: list[str])`; `state()` is `{"python_dependencies": dependencies}`. +- **TS pack:** `TypeScriptProjectReader(root, extractors)` and `TypeScriptFacts(workspace_packages, path_aliases, npm_dependencies)`; `state()` keys are `workspace_packages`, `tsconfig_paths` and `npm_dependencies`. +- **`ImportNames.from_facts(facts: Iterable[ProjectFacts]) -> ImportNames`.** The fields, `differ_from` and `record` are unchanged. + +- [ ] **Step 1: Write the failing tests.** Create `tests/unit/test_project_facts.py`: + +```python +"""Per-pack project readers and the collector that merges their facts (language-packs step 2).""" + +import json +from pathlib import Path + +import pytest + +from cgis.import_names import ImportNameCollector, ImportNames +from cgis.languages import extractors, project_readers +from cgis.languages.python.dependencies import declared_import_roots +from cgis.languages.python.project import PythonFacts, PythonProjectReader +from cgis.languages.typescript.project import TypeScriptFacts, TypeScriptProjectReader +from cgis.storage.sqlite_store import SQLiteStore + +_STORE_KEYS = {"workspace_packages", "tsconfig_paths", "python_dependencies", "npm_dependencies"} + + +def _repo(tmp_path: Path) -> Path: + root = tmp_path / "repo" + (root / ".git").mkdir(parents=True) + (root / "pyproject.toml").write_text( + '[project]\nname = "x"\ndependencies = ["PyYAML>=6", "requests"]\n', encoding="utf-8" + ) + (root / "package.json").write_text( + json.dumps({"name": "mono", "dependencies": {"react": "18"}}), encoding="utf-8" + ) + (root / "packages" / "ui").mkdir(parents=True) + (root / "packages" / "ui" / "package.json").write_text( + json.dumps({"name": "@acme/ui"}), encoding="utf-8" + ) + return root + + +def test_every_pack_contributes_a_reader_in_manifest_order(tmp_path: Path) -> None: + readers = project_readers(tmp_path, extractors([])) + assert [type(r) for r in readers] == [PythonProjectReader, TypeScriptProjectReader] + + +def test_the_packs_own_exactly_the_store_keys() -> None: + """Ruling S1: facts are keyed by the ingest_state key the store has always used.""" + python = set(PythonFacts([]).state()) + typescript = set(TypeScriptFacts({}, {}, []).state()) + assert python | typescript == _STORE_KEYS + assert not python & typescript + + +def test_python_facts_are_the_declared_roots(tmp_path: Path) -> None: + root = _repo(tmp_path) + reader = PythonProjectReader(root) + assert reader.note(root / "pyproject.toml") is False # read at the root, not from the walk + assert reader.facts().state() == {"python_dependencies": sorted(declared_import_roots(root))} + assert reader.facts().state()["python_dependencies"] == ["requests", "yaml"] + + +def test_the_typescript_reader_takes_only_its_files(tmp_path: Path) -> None: + reader = TypeScriptProjectReader(tmp_path, extractors([])) + assert reader.note(tmp_path / "package.json") is True + assert reader.note(tmp_path / "tsconfig.json") is True + assert reader.note(tmp_path / "pyproject.toml") is False + + +def test_two_packs_claiming_one_key_fail() -> None: + """Last-writer-wins would silently drop one pack's facts.""" + with pytest.raises(ValueError, match="python_dependencies"): + ImportNames.from_facts([PythonFacts(["a"]), PythonFacts(["b"])]) + + +def test_a_missing_pack_reads_as_no_facts() -> None: + names = ImportNames.from_facts([PythonFacts(["yaml"])]) + assert names == ImportNames( + workspace_packages={}, path_aliases={}, python_dependencies=["yaml"], npm_dependencies=[] + ) + + +def test_collected_facts_round_trip_through_the_store(tmp_path: Path) -> None: + """Recorded under the old keys, and an unchanged project asks for no rebuild.""" + root = _repo(tmp_path) + collector = ImportNameCollector(root, extractors([])) + for path in sorted(root.rglob("package.json")): + collector.note(path) + names = collector.collect() + assert names.python_dependencies == ["requests", "yaml"] + assert names.npm_dependencies == ["react"] + assert names.workspace_packages == {"@acme.ui": "packages.ui"} + with SQLiteStore(str(tmp_path / "g.db")) as store: + names.record(store) + assert names.differ_from(store) is False + keys = {row[0] for row in store._conn.execute("SELECT key FROM ingest_state")} # noqa: SLF001 + assert keys >= _STORE_KEYS +``` + +Append to `tests/unit/test_languages.py`: + +```python +def test_a_typescript_only_ingest_never_loads_the_python_grammar(tmp_path: Path) -> None: + """Every pack's reader exists from the first file now; the Python grammar must still wait.""" + files = {"app/h.ts": TS_SOURCE, "README.md": "# demo\n"} + assert _cli_ingest_loads(tmp_path, files) == ["tree_sitter_typescript"] +``` + +- [ ] **Step 2: Run them and watch them fail.** + - Run: `uv run pytest tests/unit/test_project_facts.py tests/unit/test_languages.py -q -k "facts or reader or pack or keys or typescript_only"` + - Expected: `test_project_facts.py` fails to collect with `ModuleNotFoundError: cgis.languages.python.project`. The TS-only test passes already: today Python dependencies are read without importing the Python pack. It is a guard for the step that follows, and Step 6 mutation-checks it. + - If `workspace_packages` comes out as something other than `{"@acme.ui": "packages.ui"}` once the code exists, check what `WorkspacePackages.unambiguous()` does today with the same fixture before you change the test. The test must describe today's behaviour. + +- [ ] **Step 3: The contract.** Create `src/cgis/core/project.py`: + +```python +"""What a language pack reads from a project's configs (spec: Project facts). + +Data, not a writer: a pack never touches storage (the `pack-isolation` gate), so +facts say *what* to store under which `ingest_state` key and the build plane's +collector (`cgis.import_names`) records and compares them (step 2, ruling S1). +""" + +from collections.abc import Mapping +from pathlib import Path +from typing import Protocol + + +class ProjectFacts(Protocol): + """Frozen, JSON-serialisable facts one pack read from a project.""" + + def state(self) -> Mapping[str, object]: + """Each fact under the `ingest_state` key it is stored as.""" + ... + + +class ProjectReader(Protocol): + """Collects one pack's configs during a walk, and from the directories above it.""" + + def note(self, path: Path) -> bool: + """Take a file the walk passed, if it is one of this pack's configs.""" + ... + + def note_above(self, directories: list[Path], bounded: bool) -> None: + """Read configs above the ingest root, nearest first (#555). + + `bounded` says a repository boundary ends the list; without one the list + runs to `/`, and only the nearest find should be trusted. + """ + ... + + def facts(self) -> ProjectFacts: + """What the walk's configs say, once it is done.""" + ... +``` + +In `src/cgis/core/extractor.py`: +- add `from collections.abc import Mapping`, `from pathlib import Path` and `from cgis.core.project import ProjectReader`; +- add to `LanguagePack`: + +```python + def project_reader(self, root: Path, extractors: Mapping[str, BaseExtractor]) -> ProjectReader: + """A reader for this pack's configs around `root` (`extractors`: the walk's own).""" + ... +``` + +- [ ] **Step 4: The two readers.** + +Create `src/cgis/languages/python/project.py`: + +```python +"""The Python pack's project reader: the dependencies a project declares (#495).""" + +from dataclasses import dataclass +from pathlib import Path +from typing import override + +from cgis.core.project import ProjectFacts, ProjectReader +from cgis.languages.python.dependencies import declared_import_roots + + +@dataclass(frozen=True) +class PythonFacts(ProjectFacts): + """Import roots of the declared dependencies, sorted.""" + + dependencies: list[str] + + @override + def state(self) -> dict[str, object]: + """Stored as `python_dependencies`.""" + return {"python_dependencies": self.dependencies} + + +class PythonProjectReader(ProjectReader): + """Reads the manifests at or above the root; the walk itself brings nothing.""" + + def __init__(self, root: Path) -> None: + """Read for the project at `root`.""" + self._root = root + + @override + def note(self, path: Path) -> bool: + """Never: the nearest manifest at or above the root is found in `facts` (S3).""" + return False + + @override + def note_above(self, directories: list[Path], bounded: bool) -> None: + """Ignored: `declared_import_roots` searches from the root itself, bounded by `.git` (S3).""" + + @override + def facts(self) -> PythonFacts: + """The declared dependencies' import roots.""" + return PythonFacts(sorted(declared_import_roots(self._root))) +``` + +Create `src/cgis/languages/typescript/project.py`. It holds the TS half of today's `ImportNameCollector`, moved: `_note_configs_above`, `note` and the three TS lines of `collect`. + +```python +"""The TypeScript pack's project reader: workspace packages, npm dependencies, tsconfig aliases. + +Four sources read the same way (#504, #556, #508, #562): `package.json` and +`tsconfig.json` files the walk passes or that sit above the ingest root (#555). +""" + +from collections.abc import Mapping +from dataclasses import dataclass +from pathlib import Path +from typing import override + +import structlog + +from cgis.core.extractor import BaseExtractor +from cgis.core.project import ProjectFacts, ProjectReader +from cgis.languages.typescript.npm import NpmDependencies +from cgis.languages.typescript.tsconfig import TSCONFIG, TsconfigPaths +from cgis.languages.typescript.workspaces import ( + PACKAGE_MANIFEST, + PNPM_WORKSPACE, + WorkspacePackages, + pnpm_member_names, + workspace_member_names, +) + +logger = structlog.getLogger(__name__) + + +@dataclass(frozen=True) +class TypeScriptFacts(ProjectFacts): + """What one walk's configs say TypeScript import names mean.""" + + #: Dotted package name -> the directory FQN it lives at. + workspace_packages: dict[str, str] + #: Project directory -> dotted alias pattern -> dotted target FQNs. + path_aliases: dict[str, dict[str, list[str]]] + #: Declared npm package names as written, less our workspace packages, sorted. + npm_dependencies: list[str] + + @override + def state(self) -> dict[str, object]: + """Stored as `workspace_packages`, `tsconfig_paths` and `npm_dependencies`.""" + return { + "workspace_packages": self.workspace_packages, + "tsconfig_paths": self.path_aliases, + "npm_dependencies": self.npm_dependencies, + } + + +class TypeScriptProjectReader(ProjectReader): + """Takes the `package.json` and `tsconfig.json` files a walk passes, and those above it.""" + + def __init__(self, root: Path, extractors: Mapping[str, BaseExtractor]) -> None: + """Read for `root`, naming modules with the walk's own `.ts` extractor (S4).""" + self._root = root + self._packages = WorkspacePackages(root, extractors) + self._tsconfigs = TsconfigPaths(root, extractors) + self._npm = NpmDependencies() + # Workspace packages named by a manifest above the root, outside the walk. + self._siblings: set[str] = set() + + @override + def note(self, path: Path) -> bool: + """`package.json` feeds packages and npm names; `tsconfig.json` feeds aliases.""" + if path.name == PACKAGE_MANIFEST: + self._packages.note(path) + self._npm.note(path) + return True + if path.name == TSCONFIG: + self._tsconfigs.note(path) + return True + return False + + @override + def note_above(self, directories: list[Path], bounded: bool) -> None: + """Read the configs above the ingest root that its files still belong to (#555). + + `cgis ingest app/src` never walks past `app/tsconfig.json` or + `app/package.json`, yet the compiler gives each file the nearest tsconfig + above it and Node finds packages in every `node_modules` up the tree. So + the nearest tsconfig above counts — unless the root has its own, which is + nearer — and every package.json above declares dependencies, less the + workspace packages a monorepo manifest there names. + """ + manifests = [d / PACKAGE_MANIFEST for d in directories if (d / PACKAGE_MANIFEST).is_file()] + # Without a repository the walk up reaches `$HOME` or `/`: a stray manifest + # there would add packages, so only the nearest is trusted (#560 review). + for manifest in manifests if bounded else manifests[:1]: + self._npm.note(manifest) + self._siblings |= workspace_member_names(manifest) + # pnpm names its workspace in its own file, beside the root manifest (#562). + pnpm = [d / PNPM_WORKSPACE for d in directories if (d / PNPM_WORKSPACE).is_file()] + for workspace_file in pnpm if bounded else pnpm[:1]: + self._siblings |= pnpm_member_names(workspace_file) + if (self._root / TSCONFIG).is_file(): + return + config = next((d / TSCONFIG for d in directories if (d / TSCONFIG).is_file()), None) + if config is not None: + logger.info("Reading tsconfig.json above the ingest root", path=str(config)) + self._tsconfigs.note(config) + + @override + def facts(self) -> TypeScriptFacts: + """Aliases last: a tsconfig can extend a shared config in a workspace package.""" + return TypeScriptFacts( + workspace_packages=self._packages.unambiguous(), + path_aliases=self._tsconfigs.aliases(self._packages.directories()), + npm_dependencies=self._npm.declared(excluding=self._packages.names() | self._siblings), + ) +``` + +Add `project_reader` to both packs. Each needs `@override` and the imports `Mapping` and `Path`. +- Python: `return PythonProjectReader(root)`. `extractors` is unused, and `@override` covers that. +- TS: `return TypeScriptProjectReader(root, extractors)`. + +Import the reader classes at the top of each pack's `__init__`. Neither module imports a grammar. + +In `src/cgis/languages/__init__.py`, add `from pathlib import Path` and `from cgis.core.project import ProjectReader`, then: + +```python +def project_readers(root: Path, extractors: Mapping[str, BaseExtractor]) -> list[ProjectReader]: + """Every pack's project reader, in manifest order — imports each pack, loads no grammar.""" + return [_load(m.name).project_reader(root, extractors) for m in MANIFESTS] +``` + +- [ ] **Step 5: The language-free collector.** In `src/cgis/import_names.py`: + - Replace the module docstring's second sentence with: `Each language pack reads its own (cgis.core.project); this module only routes the walk's files and the directories above the root to every pack's reader, and merges what they read.` + - Delete the imports of `NpmDependencies`, `declared_import_roots`, `TSCONFIG`, `TsconfigPaths` and the workspace names. + - Add `from collections.abc import Iterable, Mapping`, `from typing import TYPE_CHECKING, cast`, `from cgis.core.project import ProjectFacts` and `from cgis.languages import project_readers`. + - Keep `ImportNames` with its fields, `differ_from` and `record` unchanged, and add: + +```python + @classmethod + def from_facts(cls, facts: Iterable[ProjectFacts]) -> "ImportNames": + """Merge every pack's facts; a pack that read nothing contributes empty values. + + A key two packs both claim is an error, not a last-writer-wins: one pack's + facts would vanish from resolution without a trace. + """ + state: dict[str, object] = {} + for fact in facts: + contribution = fact.state() + shared = state.keys() & contribution.keys() + if shared: + msg = f"two packs record the same ingest_state key(s): {sorted(shared)}" + raise ValueError(msg) + state.update(contribution) + return cls( + workspace_packages=cast("dict[str, str]", state.get("workspace_packages", {})), + path_aliases=cast("dict[str, dict[str, list[str]]]", state.get("tsconfig_paths", {})), + python_dependencies=cast("list[str]", state.get("python_dependencies", [])), + npm_dependencies=cast("list[str]", state.get("npm_dependencies", [])), + ) +``` + + - Replace `ImportNameCollector` with: + +```python +class ImportNameCollector: + """Routes the configs a walk passes to every language pack's reader.""" + + def __init__(self, workspace_root: Path, extractors: Mapping[str, BaseExtractor]) -> None: + """Collect for `workspace_root`; `extractors` reach the readers that name modules (S4). + + The root is resolved first: the search above it walks `parents`, which a + relative path does not have past `.` (#560 review). The pipeline already + passes a resolved root, so for it nothing changes. + """ + self._root = workspace_root.resolve() + self._readers = project_readers(self._root, extractors) + directories, bounded = _enclosing_directories(self._root) + for reader in self._readers: + reader.note_above(directories, bounded) + + def note(self, path: Path) -> None: + """Hand a file the walk passed to every reader; each takes only its own.""" + for reader in self._readers: + reader.note(path) + + def collect(self) -> ImportNames: + """The names, once the walk is done.""" + return ImportNames.from_facts(reader.facts() for reader in self._readers) +``` + + - `logger` and `structlog` may now be unused here. Remove them if ruff says so. + +- [ ] **Step 6: Run the tests, then mutation-check the TS-only guard.** + - Run: `uv run pytest tests/unit/test_project_facts.py tests/unit/test_languages.py tests/unit/test_configs_above_root.py tests/unit/test_pipeline_ts_workspaces.py tests/unit/test_tsconfig_paths.py tests/unit/test_python_dependencies.py tests/unit/test_npm_dependencies.py -q` + - Expected: all pass. + - Then put `from cgis.languages.python.extractor import PythonExtractor` back at the top of `languages/python/__init__.py` for one run. `test_a_typescript_only_ingest_never_loads_the_python_grammar` must fail. Restore it, then check that `git diff --stat` lists only intended files. + +- [ ] **Step 7: Gate and commit.** Use Task 1's gate line, with this message: + +``` +feat(languages): per-pack project readers; import_names collects without knowing languages + +ProjectReader/ProjectFacts (cgis.core.project) per pack: Python reads declared +dependencies, TypeScript npm names, workspace packages and tsconfig aliases. +Facts are data keyed by their ingest_state key; the collector records and +compares them through the store, since a pack never touches storage (ruling S1). +Store keys, ImportNames and the snapshot (ingest_state included) unchanged. + +Co-Authored-By: Claude Opus 5.5 +``` + +--- + +### Task 4: A root `package.json` stops loading the TypeScript grammar (step-1 deferred minor) + +**Files:** +- Modify: `src/cgis/languages/typescript/workspaces.py`, `WorkspacePackages.note` +- Test: `tests/unit/test_languages.py` (append) + +- [ ] **Step 1: Write the failing test.** + +```python +def test_a_root_package_json_does_not_load_the_typescript_grammar(tmp_path: Path) -> None: + """A tooling manifest at a Python repo's root (prettier, commitlint) names no package.""" + files = { + "pkg/a.py": PY_SOURCE, + "package.json": '{"name": "tooling", "devDependencies": {"prettier": "3"}}', + } + assert _cli_ingest_loads(tmp_path, files) == ["tree_sitter_python"] +``` + +- [ ] **Step 2: Run it and watch it fail.** + - Run: `uv run pytest tests/unit/test_languages.py -q -k root_package_json` + - Expected: FAIL, with `['tree_sitter_python', 'tree_sitter_typescript']`. `note()` evaluates `self._extractor` before it learns the manifest is the root's. + +- [ ] **Step 3: Check the extractor last.** In `WorkspacePackages.note`: + - move `if self._extractor is None: return` to just before `json.loads`, after the root and outside-root checks; + - add to the docstring: `The extractor is asked for last: a root manifest, which is skipped anyway, must not load a grammar.` + +- [ ] **Step 4: Run it, plus the workspace tests.** + - Run: `uv run pytest tests/unit/test_languages.py tests/unit/test_pipeline_ts_workspaces.py tests/unit/test_configs_above_root.py -q` + - Expected: all pass. + +- [ ] **Step 5: Gate and commit.** Use Task 1's gate line, with message `perf(workspaces): a root package.json no longer loads the TypeScript grammar`, plus a body line and the Co-Authored-By trailer. + +--- + +### Task 5: Documentation + +**Files:** +- `CLAUDE.md`, section "Adding a Language Pack" +- `docs/specs/2026-10-14-language-packs-design.md` +- `docs/plans/2026-10-14-language-packs-step1.md`, a superseded-by note + +- [ ] **Step 1: CLAUDE.md.** + - In step 2 of "Adding a Language Pack", after `extractor`, add `project_reader`. + - Add a step: `A pack's __init__ imports no grammar: import the extractor inside extractor(), and put config reading in a ProjectReader (cgis.core.project) whose facts are data keyed by ingest_state key — the collector, not the pack, writes them.` + +- [ ] **Step 2: Spec notes.** Change no decision. Add a short `> **Implementation note (step N):**` block under each of these: + - **Architecture → Core:** the contract (`BaseExtractor`, `ModuleNamer`, `LanguagePack`, `ProjectReader`, `ProjectFacts`) lives in `cgis.core`. A protocol inside the registry would form a cycle with the packs that subclass it (#597). + - **Registry:** pack modules are imported up front, for their readers; grammars load in `extractor()` (S2). Manifests load packs through a function with a static import, never `importlib`, which the review-fingerprint walk cannot follow (#597). + - **Project facts:** `ProjectFacts` is `state()` data; the collector records and compares it (S1, with the conflict stated). + - **Files and namespaces:** step 1 split "is test" into the walk's skip (`Manifest.never_ingested`) and the node flag (`is_test`) (#597, R1). + +- [ ] **Step 3: Step-1 plan.** Under its Rulings heading, add one line: `> Superseded in places by the code that shipped (#597): LanguagePack lives in cgis.core.extractor, manifests carry load functions instead of module paths, and packs subclass the protocol with @override. The spec's implementation notes record each.` + +- [ ] **Step 4: Gate and commit.** Use Task 1's gate line, with message `docs: implementation notes for language-packs steps 1–2`. + +--- + +### Task 6: Identity report, review, PR + +- [ ] **Step 1: Ingest the same inputs with main's cgis and the branch's cgis.** The inputs are cgis `src` @715cd9a, owner-api @8de2245, and this repo's `ui/`, the tree with package.json and tsconfig that exercises the TS facts. + +```bash +S=/tmp/claude-1001/-home-zaebee-projects-ownima-codegraph-brain/97c475b5-6bc7-4231-a643-ff81f3f651ec/scratchpad/step2/identity && MAIN=/home/zaebee/projects/ownima/codegraph-brain && BR=/home/zaebee/projects/ownima/cgb-step2 && /bin/rm -rf $S && mkdir -p $S/cgis $S/oa $S/ui && echo "main $(git -C $MAIN rev-parse --short HEAD) branch $(git -C $BR rev-parse --short HEAD)" && git -C $MAIN archive 715cd9a src | tar -x -C $S/cgis && git -C /home/zaebee/projects/ownima/owner-api archive 8de2245 ownima-backend | tar -x -C $S/oa && git -C $MAIN archive HEAD ui | tar -x -C $S/ui && for t in cgis/src oa/ownima-backend ui/ui; do n=$(echo $t | cut -d/ -f1); uv run --project $MAIN cgis ingest $S/$t --output $S/$n-main.db > /dev/null 2>&1 && uv run --project $BR cgis ingest $S/$t --output $S/$n-branch.db > /dev/null 2>&1 || exit 1; done && echo INGESTED +``` + +Expected: +- The main line shows a HEAD equal to `main`. +- Then `INGESTED`. +- If `git diff --stat main -- src` is non-empty, main has moved under the branch: rebase first. + +- [ ] **Step 2: Diff them.** + +```bash +S=/tmp/claude-1001/-home-zaebee-projects-ownima-codegraph-brain/97c475b5-6bc7-4231-a643-ff81f3f651ec/scratchpad/step2/identity && cd /home/zaebee/projects/ownima/cgb-step2 && uv run python scripts/graph_identity.py diff $S/cgis-main.db $S/cgis/src $S/cgis-branch.db $S/cgis/src && uv run python scripts/graph_identity.py diff $S/oa-main.db $S/oa/ownima-backend $S/oa-branch.db $S/oa/ownima-backend && uv run python scripts/graph_identity.py diff $S/ui-main.db $S/ui/ui $S/ui-branch.db $S/ui/ui +``` + +Expected: `identical` three times. The dump includes `ingest_state`, which is exactly what this step relocates. + +- [ ] **Step 3: Independent review.** Dispatch a reviewer with execution tools on the most capable model. Give it the branch diff, this plan, the spec, the Rulings and the Review Focus. Fix Critical and Important findings test-first, then rerun the gate and Step 2. + +- [ ] **Step 4: Open the PR.** Push the branch. Title: `refactor(languages): step 2 — per-pack project readers`. The body carries: + - the module map; + - rulings S1–S4; + - the three identity results; + - the extraction-domain numbers; + - the two new acceptance tests (TS-only repo, root package.json) and their mutation checks; + - the reviewer's deferred minors. + + Then check the Gemini comments, Guardian comments and Sonar results, and reply in every thread. From 05956134148650925024f09fccee5e4d733f1866 Mon Sep 17 00:00:00 2001 From: zaebee Date: Wed, 14 Oct 2026 11:03:32 +0000 Subject: [PATCH 2/7] refactor(languages): a pack's module loads no grammar; extractor() does Readers (step 2) need every pack before the first file; the grammar can wait for the first parse. TS module naming moves to a tree-sitter-free naming.py. Co-Authored-By: Claude Opus 5.5 --- src/cgis/languages/__init__.py | 3 +- src/cgis/languages/python/__init__.py | 7 ++-- src/cgis/languages/typescript/__init__.py | 10 +++--- src/cgis/languages/typescript/extractor.py | 36 +-------------------- src/cgis/languages/typescript/naming.py | 37 ++++++++++++++++++++++ tests/self_parsing/test_self_parse_ts.py | 2 +- tests/unit/test_languages.py | 19 +++++++++++ tests/unit/test_typescript_extractor.py | 3 +- 8 files changed, 70 insertions(+), 47 deletions(-) create mode 100644 src/cgis/languages/typescript/naming.py diff --git a/src/cgis/languages/__init__.py b/src/cgis/languages/__init__.py index 95fe94ba..db1b4655 100644 --- a/src/cgis/languages/__init__.py +++ b/src/cgis/languages/__init__.py @@ -8,7 +8,8 @@ Adding a language is a manifest here plus a package beside this file that exposes `PACK`. Each manifest loads its pack with a static import inside a function — lazy, yet visible to the review-fingerprint walk, which cannot follow -`importlib` (`test_no_dynamic_imports_in_the_review_path`). +`importlib` (`test_no_dynamic_imports_in_the_review_path`). A pack's own module +imports no grammar; its `extractor()` does (step 2, ruling S2). Case-sensitive on purpose: `IngestionPipeline._get_extractor` matches `filename.endswith(ext)` against these lowercase extensions, so `Foo.TS` is diff --git a/src/cgis/languages/python/__init__.py b/src/cgis/languages/python/__init__.py index 134422ab..2e69939e 100644 --- a/src/cgis/languages/python/__init__.py +++ b/src/cgis/languages/python/__init__.py @@ -4,8 +4,7 @@ from cgis.core.extractor import BaseExtractor, LanguagePack from cgis.core.paths import is_test_path -from cgis.languages.python._syntax import PYTHON_LANG -from cgis.languages.python.extractor import PythonExtractor, file_path_to_module_fqn +from cgis.languages.python._syntax import PYTHON_LANG, file_path_to_module_fqn class PythonPack(LanguagePack): @@ -31,7 +30,9 @@ def scope_of(self, path: str, source_root: str | None) -> str: @override def extractor(self, extension: str, source_roots: list[str]) -> BaseExtractor: - """One grammar for the one extension.""" + """One grammar for the one extension — loaded here, on first use.""" + from cgis.languages.python.extractor import PythonExtractor # noqa: PLC0415 + return PythonExtractor(source_roots=source_roots) diff --git a/src/cgis/languages/typescript/__init__.py b/src/cgis/languages/typescript/__init__.py index 599c483e..58a73aef 100644 --- a/src/cgis/languages/typescript/__init__.py +++ b/src/cgis/languages/typescript/__init__.py @@ -4,11 +4,7 @@ from cgis.core.extractor import BaseExtractor, LanguagePack from cgis.core.paths import is_test_path -from cgis.languages.typescript.extractor import ( - TYPESCRIPT_LANG, - TypeScriptExtractor, - file_path_to_module_fqn, -) +from cgis.languages.typescript.naming import TYPESCRIPT_LANG, file_path_to_module_fqn class TypeScriptPack(LanguagePack): @@ -34,7 +30,9 @@ def scope_of(self, path: str, source_root: str | None) -> str: @override def extractor(self, extension: str, source_roots: list[str]) -> BaseExtractor: - """The TSX grammar for `.tsx`, the TypeScript one otherwise.""" + """The TSX grammar for `.tsx`, the TypeScript one otherwise — loaded here, on first use.""" + from cgis.languages.typescript.extractor import TypeScriptExtractor # noqa: PLC0415 + return TypeScriptExtractor(tsx=extension == ".tsx", source_roots=source_roots) diff --git a/src/cgis/languages/typescript/extractor.py b/src/cgis/languages/typescript/extractor.py index 7a833645..5d94d8db 100644 --- a/src/cgis/languages/typescript/extractor.py +++ b/src/cgis/languages/typescript/extractor.py @@ -8,11 +8,9 @@ from cgis.core.js_globals import specifier_fqn from cgis.core.models import Edge, EdgeType, Node, NodeNamespace, NodeType from cgis.languages.typescript._bindings import bare_imports, shadowed_globals +from cgis.languages.typescript.naming import TYPESCRIPT_LANG, file_path_to_module_fqn _RAW_CALL_PREFIX = "raw_call:" -#: `Node.language` for everything this extractor emits, `.ts` and `.tsx` alike; -#: the field defaults to "python", which every TS node used to be stored as (#504). -TYPESCRIPT_LANG = "typescript" _EXPORT_UNWRAP_TYPES = frozenset( { "class_declaration", @@ -23,38 +21,6 @@ ) -def file_path_to_module_fqn(file_path: str, source_root: str | None = None) -> str: - """Convert a TS/TSX file path to a dot-separated module namespace. - - Examples: - src/api/handler.ts -> src.api.handler - src/components/index.tsx -> src.components - C:\\path\\to\\mod.ts -> path.to.mod - - With source_root="src": - src/api/handler.ts -> api.handler - """ - clean = file_path - if len(clean) >= 2 and clean[1] == ":" and clean[0].isalpha(): - clean = clean[2:] - clean = clean.replace("\\", "/").lstrip("/") - if source_root: - # removeprefix("./") so CI-style roots ("cgis ingest ./src") strip too, - # matching the Python helper. Without it a collector configured with - # source_root="./src" would leave the prefix on and derive an FQN no - # node carries (#344). - sr = source_root.replace("\\", "/").removeprefix("./").strip("/") + "/" - if clean.startswith(sr): - clean = clean[len(sr) :] - for ext in (".tsx", ".ts", ".jsx", ".js"): - if clean.endswith(ext): - clean = clean[: -len(ext)] - break - if clean.endswith("/index"): - clean = clean[:-6] - return clean.replace("/", ".") - - def _node_text(node: TSNode | None) -> str: """Return decoded text of a tree-sitter node, or empty string.""" if node is None or node.text is None: diff --git a/src/cgis/languages/typescript/naming.py b/src/cgis/languages/typescript/naming.py new file mode 100644 index 00000000..b542e9ea --- /dev/null +++ b/src/cgis/languages/typescript/naming.py @@ -0,0 +1,37 @@ +"""TypeScript module FQNs from file paths — no tree-sitter, so a pack names files grammar-free.""" + +#: `Node.language` for everything this extractor emits, `.ts` and `.tsx` alike; +#: the field defaults to "python", which every TS node used to be stored as (#504). +TYPESCRIPT_LANG = "typescript" + + +def file_path_to_module_fqn(file_path: str, source_root: str | None = None) -> str: + """Convert a TS/TSX file path to a dot-separated module namespace. + + Examples: + src/api/handler.ts -> src.api.handler + src/components/index.tsx -> src.components + C:\\path\\to\\mod.ts -> path.to.mod + + With source_root="src": + src/api/handler.ts -> api.handler + """ + clean = file_path + if len(clean) >= 2 and clean[1] == ":" and clean[0].isalpha(): + clean = clean[2:] + clean = clean.replace("\\", "/").lstrip("/") + if source_root: + # removeprefix("./") so CI-style roots ("cgis ingest ./src") strip too, + # matching the Python helper. Without it a collector configured with + # source_root="./src" would leave the prefix on and derive an FQN no + # node carries (#344). + sr = source_root.replace("\\", "/").removeprefix("./").strip("/") + "/" + if clean.startswith(sr): + clean = clean[len(sr) :] + for ext in (".tsx", ".ts", ".jsx", ".js"): + if clean.endswith(ext): + clean = clean[: -len(ext)] + break + if clean.endswith("/index"): + clean = clean[:-6] + return clean.replace("/", ".") diff --git a/tests/self_parsing/test_self_parse_ts.py b/tests/self_parsing/test_self_parse_ts.py index ba24dc4b..f61cbd5f 100644 --- a/tests/self_parsing/test_self_parse_ts.py +++ b/tests/self_parsing/test_self_parse_ts.py @@ -10,7 +10,7 @@ import pytest from cgis.core.models import Edge, EdgeType, Node, NodeNamespace, NodeType -from cgis.languages.typescript.extractor import file_path_to_module_fqn +from cgis.languages.typescript.naming import file_path_to_module_fqn from cgis.resolver.js_builtins import JS_BUILTINS_ROOT, JS_GLOBALS from cgis.storage.sqlite_store import SQLiteStore diff --git a/tests/unit/test_languages.py b/tests/unit/test_languages.py index 77b2330f..85e38399 100644 --- a/tests/unit/test_languages.py +++ b/tests/unit/test_languages.py @@ -279,3 +279,22 @@ def test_a_typescript_file_does_load_it(tmp_path: Path) -> None: """The control: without it, the test above would pass on a probe that sees nothing.""" loaded = _cli_ingest_loads(tmp_path, {"pkg/a.py": PY_SOURCE, "app/h.ts": TS_SOURCE}) assert "tree_sitter_typescript" in loaded + + +def test_importing_the_packs_loads_no_grammar() -> None: + """Ruling S2: readers need every pack from the first file; grammars wait for a parse.""" + script = ( + "import json, sys\n" + "from cgis.languages import pack_named\n" + "for name in ('python', 'typescript'):\n" + " pack = pack_named(name)\n" + " pack.scope_of('src/app/x.tsx', 'src')\n" + " pack.code_fence('a.py')\n" + "loaded = {m.split('.')[0] for m in sys.modules if m.startswith('tree_sitter_')}\n" + "print(json.dumps(sorted(loaded)))\n" + ) + out = subprocess.run( + [sys.executable, "-c", script], capture_output=True, text=True, check=False + ) + assert out.returncode == 0, out.stderr + assert json.loads(out.stdout.strip().splitlines()[-1]) == [] diff --git a/tests/unit/test_typescript_extractor.py b/tests/unit/test_typescript_extractor.py index 691368a9..d690f965 100644 --- a/tests/unit/test_typescript_extractor.py +++ b/tests/unit/test_typescript_extractor.py @@ -3,7 +3,8 @@ import pytest from cgis.core.models import Edge, EdgeType, Node, NodeType -from cgis.languages.typescript.extractor import TypeScriptExtractor, file_path_to_module_fqn +from cgis.languages.typescript.extractor import TypeScriptExtractor +from cgis.languages.typescript.naming import file_path_to_module_fqn # --------------------------------------------------------------------------- # file_path_to_module_fqn From 974b6859da9d7823da23b595b02fcba5846ac325 Mon Sep 17 00:00:00 2001 From: zaebee Date: Wed, 14 Oct 2026 11:06:15 +0000 Subject: [PATCH 3/7] refactor(languages): config readers move into their packs python_dependencies -> languages/python/dependencies; npm_dependencies, workspaces, tsconfig_paths -> languages/typescript/{npm,workspaces,tsconfig}. Pure move plus imports; snapshot and ingest_state unchanged. Extraction domain drift 0.305, unresolved 0.347 (lock 0.40/0.37). Co-Authored-By: Claude Opus 5.5 --- src/cgis/import_names.py | 8 ++++---- .../python/dependencies.py} | 0 .../{npm_dependencies.py => languages/typescript/npm.py} | 0 .../typescript/tsconfig.py} | 0 src/cgis/{ => languages/typescript}/workspaces.py | 0 tests/unit/review_path_inventory.txt | 1 + tests/unit/test_configs_above_root.py | 2 +- tests/unit/test_npm_dependencies.py | 2 +- tests/unit/test_python_dependencies.py | 2 +- tests/unit/test_tsconfig_paths.py | 2 +- 10 files changed, 9 insertions(+), 8 deletions(-) rename src/cgis/{python_dependencies.py => languages/python/dependencies.py} (100%) rename src/cgis/{npm_dependencies.py => languages/typescript/npm.py} (100%) rename src/cgis/{tsconfig_paths.py => languages/typescript/tsconfig.py} (100%) rename src/cgis/{ => languages/typescript}/workspaces.py (100%) diff --git a/src/cgis/import_names.py b/src/cgis/import_names.py index 661607b0..42ec4980 100644 --- a/src/cgis/import_names.py +++ b/src/cgis/import_names.py @@ -17,10 +17,10 @@ import structlog from cgis.core.extractor import BaseExtractor -from cgis.npm_dependencies import NpmDependencies -from cgis.python_dependencies import declared_import_roots -from cgis.tsconfig_paths import TSCONFIG, TsconfigPaths -from cgis.workspaces import ( +from cgis.languages.python.dependencies import declared_import_roots +from cgis.languages.typescript.npm import NpmDependencies +from cgis.languages.typescript.tsconfig import TSCONFIG, TsconfigPaths +from cgis.languages.typescript.workspaces import ( PACKAGE_MANIFEST, PNPM_WORKSPACE, WorkspacePackages, diff --git a/src/cgis/python_dependencies.py b/src/cgis/languages/python/dependencies.py similarity index 100% rename from src/cgis/python_dependencies.py rename to src/cgis/languages/python/dependencies.py diff --git a/src/cgis/npm_dependencies.py b/src/cgis/languages/typescript/npm.py similarity index 100% rename from src/cgis/npm_dependencies.py rename to src/cgis/languages/typescript/npm.py diff --git a/src/cgis/tsconfig_paths.py b/src/cgis/languages/typescript/tsconfig.py similarity index 100% rename from src/cgis/tsconfig_paths.py rename to src/cgis/languages/typescript/tsconfig.py diff --git a/src/cgis/workspaces.py b/src/cgis/languages/typescript/workspaces.py similarity index 100% rename from src/cgis/workspaces.py rename to src/cgis/languages/typescript/workspaces.py diff --git a/tests/unit/review_path_inventory.txt b/tests/unit/review_path_inventory.txt index 38cd96ca..6d61cab7 100644 --- a/tests/unit/review_path_inventory.txt +++ b/tests/unit/review_path_inventory.txt @@ -38,6 +38,7 @@ src/cgis/languages/python/extractor.py src/cgis/languages/typescript/__init__.py src/cgis/languages/typescript/_bindings.py src/cgis/languages/typescript/extractor.py +src/cgis/languages/typescript/naming.py src/cgis/query/analysis/__init__.py src/cgis/query/analysis/health.py src/cgis/query/drift/__init__.py diff --git a/tests/unit/test_configs_above_root.py b/tests/unit/test_configs_above_root.py index 63a4043f..f4ca0d54 100644 --- a/tests/unit/test_configs_above_root.py +++ b/tests/unit/test_configs_above_root.py @@ -20,8 +20,8 @@ from cgis.core.models import EdgeType, NodeNamespace from cgis.import_names import ImportNameCollector from cgis.languages.typescript.extractor import TypeScriptExtractor +from cgis.languages.typescript.workspaces import pnpm_member_names from cgis.pipeline import IngestionPipeline -from cgis.workspaces import pnpm_member_names APP = """\ import { cn } from "@/lib/utils"; diff --git a/tests/unit/test_npm_dependencies.py b/tests/unit/test_npm_dependencies.py index defb007f..74eb86f6 100644 --- a/tests/unit/test_npm_dependencies.py +++ b/tests/unit/test_npm_dependencies.py @@ -18,7 +18,7 @@ from cgis.core.models import Edge, EdgeType, Node, NodeNamespace from cgis.languages.typescript.extractor import TypeScriptExtractor -from cgis.npm_dependencies import NpmDependencies +from cgis.languages.typescript.npm import NpmDependencies from cgis.pipeline import IngestionPipeline from cgis.storage.sqlite_store import SQLiteStore diff --git a/tests/unit/test_python_dependencies.py b/tests/unit/test_python_dependencies.py index 202d2ca7..d436c4d2 100644 --- a/tests/unit/test_python_dependencies.py +++ b/tests/unit/test_python_dependencies.py @@ -8,9 +8,9 @@ from pathlib import Path from cgis.core.models import Node, NodeNamespace +from cgis.languages.python.dependencies import declared_import_roots, import_name from cgis.languages.python.extractor import PythonExtractor from cgis.pipeline import IngestionPipeline -from cgis.python_dependencies import declared_import_roots, import_name from cgis.storage.sqlite_store import SQLiteStore PYPROJECT = """ diff --git a/tests/unit/test_tsconfig_paths.py b/tests/unit/test_tsconfig_paths.py index f22dc1e9..f3cf53af 100644 --- a/tests/unit/test_tsconfig_paths.py +++ b/tests/unit/test_tsconfig_paths.py @@ -19,9 +19,9 @@ from cgis.core.models import EdgeType from cgis.languages.typescript.extractor import TypeScriptExtractor +from cgis.languages.typescript.tsconfig import TsconfigPaths, load_jsonc from cgis.pipeline import IngestionPipeline from cgis.storage.sqlite_store import SQLiteStore -from cgis.tsconfig_paths import TsconfigPaths, load_jsonc BUTTON = "export function Button() {\n return 1;\n}\n" From fc8e152422f11e538211b4a8881c5dc51b66666d Mon Sep 17 00:00:00 2001 From: zaebee Date: Wed, 14 Oct 2026 11:12:03 +0000 Subject: [PATCH 4/7] feat(languages): per-pack project readers; import_names collects without knowing languages ProjectReader/ProjectFacts (cgis.core.project) per pack: Python reads declared dependencies, TypeScript npm names, workspace packages and tsconfig aliases. Facts are data keyed by their ingest_state key; the collector records and compares them through the store, since a pack never touches storage (ruling S1). Store keys, ImportNames and the snapshot (ingest_state included) unchanged. Co-Authored-By: Claude Opus 5.5 --- src/cgis/core/extractor.py | 7 ++ src/cgis/core/project.py | 38 +++++++ src/cgis/import_names.py | 116 ++++++++-------------- src/cgis/languages/__init__.py | 8 +- src/cgis/languages/python/__init__.py | 9 ++ src/cgis/languages/python/project.py | 42 ++++++++ src/cgis/languages/typescript/__init__.py | 9 ++ src/cgis/languages/typescript/project.py | 109 ++++++++++++++++++++ tests/unit/test_languages.py | 6 ++ tests/unit/test_project_facts.py | 90 +++++++++++++++++ 10 files changed, 357 insertions(+), 77 deletions(-) create mode 100644 src/cgis/core/project.py create mode 100644 src/cgis/languages/python/project.py create mode 100644 src/cgis/languages/typescript/project.py create mode 100644 tests/unit/test_project_facts.py diff --git a/src/cgis/core/extractor.py b/src/cgis/core/extractor.py index 7b589bbd..9bd30446 100644 --- a/src/cgis/core/extractor.py +++ b/src/cgis/core/extractor.py @@ -5,9 +5,12 @@ """ from abc import ABC, abstractmethod +from collections.abc import Mapping +from pathlib import Path from typing import Protocol, runtime_checkable from cgis.core.models import Edge, Node +from cgis.core.project import ProjectReader @runtime_checkable @@ -83,3 +86,7 @@ def scope_of(self, path: str, source_root: str | None) -> str: def extractor(self, extension: str, source_roots: list[str]) -> BaseExtractor: """A new extractor for files of `extension`.""" ... + + def project_reader(self, root: Path, extractors: Mapping[str, BaseExtractor]) -> ProjectReader: + """A reader for this pack's configs around `root` (`extractors`: the walk's own).""" + ... diff --git a/src/cgis/core/project.py b/src/cgis/core/project.py new file mode 100644 index 00000000..82f7717a --- /dev/null +++ b/src/cgis/core/project.py @@ -0,0 +1,38 @@ +"""What a language pack reads from a project's configs (spec: Project facts). + +Data, not a writer: a pack never touches storage (the `pack-isolation` gate), so +facts say *what* to store under which `ingest_state` key and the build plane's +collector (`cgis.import_names`) records and compares them (step 2, ruling S1). +""" + +from collections.abc import Mapping +from pathlib import Path +from typing import Protocol + + +class ProjectFacts(Protocol): + """Frozen, JSON-serialisable facts one pack read from a project.""" + + def state(self) -> Mapping[str, object]: + """Each fact under the `ingest_state` key it is stored as.""" + ... + + +class ProjectReader(Protocol): + """Collects one pack's configs during a walk, and from the directories above it.""" + + def note(self, path: Path) -> bool: + """Take a file the walk passed, if it is one of this pack's configs.""" + ... + + def note_above(self, directories: list[Path], bounded: bool) -> None: + """Read configs above the ingest root, nearest first (#555). + + `bounded` says a repository boundary ends the list; without one the list + runs to `/`, and only the nearest find should be trusted. + """ + ... + + def facts(self) -> ProjectFacts: + """What the walk's configs say, once it is done.""" + ... diff --git a/src/cgis/import_names.py b/src/cgis/import_names.py index 42ec4980..8976d659 100644 --- a/src/cgis/import_names.py +++ b/src/cgis/import_names.py @@ -1,38 +1,23 @@ """The names imports resolve through, collected from the configs around a walk. -Four sources, read the same way and handed to the resolver together: workspace -packages and declared npm dependencies from `package.json` (#504, #556) and -`compilerOptions.paths` aliases from `tsconfig.json` (#508), which the walk passes -or which sit above the ingest root (#555), and the dependencies a Python project -declares (#495), read from the manifests at or above the ingest root. -Kept out of `IngestionPipeline`, which only says which files it walked past and -when the result must be stored. +Each language pack reads its own (`cgis.core.project`); this module only routes +the walk's files and the directories above the root to every pack's reader, and +merges what they read (language-packs step 2). Kept out of `IngestionPipeline`, +which only says which files it walked past and when the result must be stored. """ -from collections.abc import Mapping +from collections.abc import Iterable, Mapping from dataclasses import dataclass from pathlib import Path -from typing import TYPE_CHECKING - -import structlog +from typing import TYPE_CHECKING, cast from cgis.core.extractor import BaseExtractor -from cgis.languages.python.dependencies import declared_import_roots -from cgis.languages.typescript.npm import NpmDependencies -from cgis.languages.typescript.tsconfig import TSCONFIG, TsconfigPaths -from cgis.languages.typescript.workspaces import ( - PACKAGE_MANIFEST, - PNPM_WORKSPACE, - WorkspacePackages, - pnpm_member_names, - workspace_member_names, -) +from cgis.core.project import ProjectFacts +from cgis.languages import project_readers if TYPE_CHECKING: from cgis.storage.sqlite_store import SQLiteStore -logger = structlog.getLogger(__name__) - #: What marks a repository boundary the upward search never crosses. _REPOSITORY_MARKER = ".git" @@ -67,6 +52,28 @@ def differ_from(self, store: "SQLiteStore") -> bool: or (store.get_npm_dependencies() or []) != self.npm_dependencies ) + @classmethod + def from_facts(cls, facts: Iterable[ProjectFacts]) -> "ImportNames": + """Merge every pack's facts; a pack that read nothing contributes empty values. + + A key two packs both claim is an error, not a last-writer-wins: one pack's + facts would vanish from resolution without a trace. + """ + state: dict[str, object] = {} + for fact in facts: + contribution = fact.state() + shared = state.keys() & contribution.keys() + if shared: + msg = f"two packs record the same ingest_state key(s): {sorted(shared)}" + raise ValueError(msg) + state.update(contribution) + return cls( + workspace_packages=cast("dict[str, str]", state.get("workspace_packages", {})), + path_aliases=cast("dict[str, dict[str, list[str]]]", state.get("tsconfig_paths", {})), + python_dependencies=cast("list[str]", state.get("python_dependencies", [])), + npm_dependencies=cast("list[str]", state.get("npm_dependencies", [])), + ) + def record(self, store: "SQLiteStore") -> None: """Record these names on the stored graph, for the next run's `differ_from`.""" store.record_workspace_packages(self.workspace_packages) @@ -94,69 +101,26 @@ def _enclosing_directories(root: Path) -> tuple[list[Path], bool]: class ImportNameCollector: - """Takes the `package.json` and `tsconfig.json` files a walk passes.""" + """Routes the configs a walk passes to every language pack's reader.""" def __init__(self, workspace_root: Path, extractors: Mapping[str, BaseExtractor]) -> None: - """Collect for `workspace_root`, naming modules with the TypeScript extractor's rule. + """Collect for `workspace_root`; `extractors` reach the readers that name modules (S4). The root is resolved first: the search above it walks `parents`, which a relative path does not have past `.` (#560 review). The pipeline already passes a resolved root, so for it nothing changes. """ self._root = workspace_root.resolve() - self._packages = WorkspacePackages(self._root, extractors) - self._tsconfigs = TsconfigPaths(self._root, extractors) - self._npm = NpmDependencies() - # Workspace packages named by a manifest above the root, outside the walk. - self._siblings: set[str] = set() - self._note_configs_above() - - def _note_configs_above(self) -> None: - """Read the configs above the ingest root that its files still belong to (#555). - - `cgis ingest app/src` never walks past `app/tsconfig.json` or - `app/package.json`, yet the compiler gives each file the nearest tsconfig - above it and Node finds packages in every `node_modules` up the tree. So - the nearest tsconfig above counts — unless the root has its own, which is - nearer — and every package.json above declares dependencies, less the - workspace packages a monorepo manifest there names. The search stops at - the repository boundary, as `python_dependencies` does (#495). - """ + self._readers = project_readers(self._root, extractors) directories, bounded = _enclosing_directories(self._root) - manifests = [d / PACKAGE_MANIFEST for d in directories if (d / PACKAGE_MANIFEST).is_file()] - # Without a repository the walk up reaches `$HOME` or `/`: a stray manifest - # there would add packages, so only the nearest is trusted (#560 review). - for manifest in manifests if bounded else manifests[:1]: - self._npm.note(manifest) - self._siblings |= workspace_member_names(manifest) - # pnpm names its workspace in its own file, beside the root manifest (#562). - pnpm = [d / PNPM_WORKSPACE for d in directories if (d / PNPM_WORKSPACE).is_file()] - for workspace_file in pnpm if bounded else pnpm[:1]: - self._siblings |= pnpm_member_names(workspace_file) - if (self._root / TSCONFIG).is_file(): - return - config = next((d / TSCONFIG for d in directories if (d / TSCONFIG).is_file()), None) - if config is not None: - logger.info("Reading tsconfig.json above the ingest root", path=str(config)) - self._tsconfigs.note(config) + for reader in self._readers: + reader.note_above(directories, bounded) def note(self, path: Path) -> None: - """Hand a file the walk passed to whichever reader takes it; any other is ignored.""" - if path.name == PACKAGE_MANIFEST: - self._packages.note(path) - self._npm.note(path) - elif path.name == TSCONFIG: - self._tsconfigs.note(path) + """Hand a file the walk passed to every reader; each takes only its own.""" + for reader in self._readers: + reader.note(path) def collect(self) -> ImportNames: - """The names, once the walk is done. - - Aliases are read last: a tsconfig can extend a shared config that lives in - a workspace package, which only the finished package map can locate. - """ - return ImportNames( - workspace_packages=self._packages.unambiguous(), - path_aliases=self._tsconfigs.aliases(self._packages.directories()), - python_dependencies=sorted(declared_import_roots(self._root)), - npm_dependencies=self._npm.declared(excluding=self._packages.names() | self._siblings), - ) + """The names, once the walk is done.""" + return ImportNames.from_facts(reader.facts() for reader in self._readers) diff --git a/src/cgis/languages/__init__.py b/src/cgis/languages/__init__.py index db1b4655..a8b2f614 100644 --- a/src/cgis/languages/__init__.py +++ b/src/cgis/languages/__init__.py @@ -21,10 +21,11 @@ from collections.abc import Callable, Iterator, Mapping from dataclasses import dataclass from functools import cache -from pathlib import PurePosixPath +from pathlib import Path, PurePosixPath from cgis.core.extractor import BaseExtractor, LanguagePack from cgis.core.models import Node +from cgis.core.project import ProjectReader @dataclass(frozen=True) @@ -112,6 +113,11 @@ def scope_of(path: str, source_root: str | None) -> str: return pack.scope_of(path, source_root) +def project_readers(root: Path, extractors: Mapping[str, BaseExtractor]) -> list[ProjectReader]: + """Every pack's project reader, in manifest order — imports each pack, loads no grammar.""" + return [_load(m.name).project_reader(root, extractors) for m in MANIFESTS] + + def skips(filename: str) -> bool: """Whether the walk never reads this file (`foo.test.ts`, `foo.spec.py`).""" return any(m.never_ingested.search(filename) for m in MANIFESTS) diff --git a/src/cgis/languages/python/__init__.py b/src/cgis/languages/python/__init__.py index 2e69939e..b897d101 100644 --- a/src/cgis/languages/python/__init__.py +++ b/src/cgis/languages/python/__init__.py @@ -1,10 +1,14 @@ """The Python language pack: tree-sitter-python, module FQNs from file paths.""" +from collections.abc import Mapping +from pathlib import Path from typing import override from cgis.core.extractor import BaseExtractor, LanguagePack from cgis.core.paths import is_test_path +from cgis.core.project import ProjectReader from cgis.languages.python._syntax import PYTHON_LANG, file_path_to_module_fqn +from cgis.languages.python.project import PythonProjectReader class PythonPack(LanguagePack): @@ -35,5 +39,10 @@ def extractor(self, extension: str, source_roots: list[str]) -> BaseExtractor: return PythonExtractor(source_roots=source_roots) + @override + def project_reader(self, root: Path, extractors: Mapping[str, BaseExtractor]) -> ProjectReader: + """This pack's config reader for the project at `root`.""" + return PythonProjectReader(root) + PACK = PythonPack() diff --git a/src/cgis/languages/python/project.py b/src/cgis/languages/python/project.py new file mode 100644 index 00000000..ff95db20 --- /dev/null +++ b/src/cgis/languages/python/project.py @@ -0,0 +1,42 @@ +"""The Python pack's project reader: the dependencies a project declares (#495).""" + +from dataclasses import dataclass +from pathlib import Path +from typing import override + +from cgis.core.project import ProjectFacts, ProjectReader +from cgis.languages.python.dependencies import declared_import_roots + + +@dataclass(frozen=True) +class PythonFacts(ProjectFacts): + """Import roots of the declared dependencies, sorted.""" + + dependencies: list[str] + + @override + def state(self) -> dict[str, object]: + """Stored as `python_dependencies`.""" + return {"python_dependencies": self.dependencies} + + +class PythonProjectReader(ProjectReader): + """Reads the manifests at or above the root; the walk itself brings nothing.""" + + def __init__(self, root: Path) -> None: + """Read for the project at `root`.""" + self._root = root + + @override + def note(self, path: Path) -> bool: + """Never: the nearest manifest at or above the root is found in `facts` (S3).""" + return False + + @override + def note_above(self, directories: list[Path], bounded: bool) -> None: + """Ignored: `declared_import_roots` searches from the root itself, up to `.git` (S3).""" + + @override + def facts(self) -> PythonFacts: + """The declared dependencies' import roots.""" + return PythonFacts(sorted(declared_import_roots(self._root))) diff --git a/src/cgis/languages/typescript/__init__.py b/src/cgis/languages/typescript/__init__.py index 58a73aef..3ca22b0e 100644 --- a/src/cgis/languages/typescript/__init__.py +++ b/src/cgis/languages/typescript/__init__.py @@ -1,10 +1,14 @@ """The TypeScript language pack: `.ts` and `.tsx`, one grammar each.""" +from collections.abc import Mapping +from pathlib import Path from typing import override from cgis.core.extractor import BaseExtractor, LanguagePack from cgis.core.paths import is_test_path +from cgis.core.project import ProjectReader from cgis.languages.typescript.naming import TYPESCRIPT_LANG, file_path_to_module_fqn +from cgis.languages.typescript.project import TypeScriptProjectReader class TypeScriptPack(LanguagePack): @@ -35,5 +39,10 @@ def extractor(self, extension: str, source_roots: list[str]) -> BaseExtractor: return TypeScriptExtractor(tsx=extension == ".tsx", source_roots=source_roots) + @override + def project_reader(self, root: Path, extractors: Mapping[str, BaseExtractor]) -> ProjectReader: + """This pack's config reader for the project at `root`.""" + return TypeScriptProjectReader(root, extractors) + PACK = TypeScriptPack() diff --git a/src/cgis/languages/typescript/project.py b/src/cgis/languages/typescript/project.py new file mode 100644 index 00000000..4099e88a --- /dev/null +++ b/src/cgis/languages/typescript/project.py @@ -0,0 +1,109 @@ +"""The TypeScript pack's project reader: workspace packages, npm dependencies, tsconfig aliases. + +Four sources read the same way (#504, #556, #508, #562): `package.json` and +`tsconfig.json` files the walk passes or that sit above the ingest root (#555). +""" + +from collections.abc import Mapping +from dataclasses import dataclass +from pathlib import Path +from typing import override + +import structlog + +from cgis.core.extractor import BaseExtractor +from cgis.core.project import ProjectFacts, ProjectReader +from cgis.languages.typescript.npm import NpmDependencies +from cgis.languages.typescript.tsconfig import TSCONFIG, TsconfigPaths +from cgis.languages.typescript.workspaces import ( + PACKAGE_MANIFEST, + PNPM_WORKSPACE, + WorkspacePackages, + pnpm_member_names, + workspace_member_names, +) + +logger = structlog.getLogger(__name__) + + +@dataclass(frozen=True) +class TypeScriptFacts(ProjectFacts): + """What one walk's configs say TypeScript import names mean.""" + + #: Dotted package name -> the directory FQN it lives at. + workspace_packages: dict[str, str] + #: Project directory -> dotted alias pattern -> dotted target FQNs. + path_aliases: dict[str, dict[str, list[str]]] + #: Declared npm package names as written, less our workspace packages, sorted. + npm_dependencies: list[str] + + @override + def state(self) -> dict[str, object]: + """Stored as `workspace_packages`, `tsconfig_paths` and `npm_dependencies`.""" + return { + "workspace_packages": self.workspace_packages, + "tsconfig_paths": self.path_aliases, + "npm_dependencies": self.npm_dependencies, + } + + +class TypeScriptProjectReader(ProjectReader): + """Takes the `package.json` and `tsconfig.json` files a walk passes, and those above it.""" + + def __init__(self, root: Path, extractors: Mapping[str, BaseExtractor]) -> None: + """Read for `root`, naming modules with the walk's own `.ts` extractor (S4).""" + self._root = root + self._packages = WorkspacePackages(root, extractors) + self._tsconfigs = TsconfigPaths(root, extractors) + self._npm = NpmDependencies() + # Workspace packages named by a manifest above the root, outside the walk. + self._siblings: set[str] = set() + + @override + def note(self, path: Path) -> bool: + """`package.json` feeds packages and npm names; `tsconfig.json` feeds aliases.""" + if path.name == PACKAGE_MANIFEST: + self._packages.note(path) + self._npm.note(path) + return True + if path.name == TSCONFIG: + self._tsconfigs.note(path) + return True + return False + + @override + def note_above(self, directories: list[Path], bounded: bool) -> None: + """Read the configs above the ingest root that its files still belong to (#555). + + `cgis ingest app/src` never walks past `app/tsconfig.json` or + `app/package.json`, yet the compiler gives each file the nearest tsconfig + above it and Node finds packages in every `node_modules` up the tree. So + the nearest tsconfig above counts — unless the root has its own, which is + nearer — and every package.json above declares dependencies, less the + workspace packages a monorepo manifest there names. + """ + manifests = [d / PACKAGE_MANIFEST for d in directories if (d / PACKAGE_MANIFEST).is_file()] + # Without a repository the walk up reaches `$HOME` or `/`: a stray manifest + # there would add packages, so only the nearest is trusted (#560 review). + for manifest in manifests if bounded else manifests[:1]: + self._npm.note(manifest) + self._siblings |= workspace_member_names(manifest) + # pnpm names its workspace in its own file, beside the root manifest (#562). + pnpm = [d / PNPM_WORKSPACE for d in directories if (d / PNPM_WORKSPACE).is_file()] + for workspace_file in pnpm if bounded else pnpm[:1]: + self._siblings |= pnpm_member_names(workspace_file) + if (self._root / TSCONFIG).is_file(): + return + config = next((d / TSCONFIG for d in directories if (d / TSCONFIG).is_file()), None) + if config is not None: + logger.info("Reading tsconfig.json above the ingest root", path=str(config)) + self._tsconfigs.note(config) + + @override + def facts(self) -> TypeScriptFacts: + """Aliases last: a tsconfig can extend a shared config in a workspace package.""" + return TypeScriptFacts( + workspace_packages=self._packages.unambiguous(), + path_aliases=self._tsconfigs.aliases(self._packages.directories()), + npm_dependencies=self._npm.declared(excluding=self._packages.names() | self._siblings), + ) diff --git a/tests/unit/test_languages.py b/tests/unit/test_languages.py index 85e38399..2a3a70da 100644 --- a/tests/unit/test_languages.py +++ b/tests/unit/test_languages.py @@ -298,3 +298,9 @@ def test_importing_the_packs_loads_no_grammar() -> None: ) assert out.returncode == 0, out.stderr assert json.loads(out.stdout.strip().splitlines()[-1]) == [] + + +def test_a_typescript_only_ingest_never_loads_the_python_grammar(tmp_path: Path) -> None: + """Every pack's reader exists from the first file now; the Python grammar must still wait.""" + files = {"app/h.ts": TS_SOURCE, "README.md": "# demo\n"} + assert _cli_ingest_loads(tmp_path, files) == ["tree_sitter_typescript"] diff --git a/tests/unit/test_project_facts.py b/tests/unit/test_project_facts.py new file mode 100644 index 00000000..e7426f4d --- /dev/null +++ b/tests/unit/test_project_facts.py @@ -0,0 +1,90 @@ +"""Per-pack project readers and the collector that merges their facts (language-packs step 2).""" + +import json +from pathlib import Path + +import pytest + +from cgis.import_names import ImportNameCollector, ImportNames +from cgis.languages import extractors, project_readers +from cgis.languages.python.dependencies import declared_import_roots +from cgis.languages.python.project import PythonFacts, PythonProjectReader +from cgis.languages.typescript.project import TypeScriptFacts, TypeScriptProjectReader +from cgis.storage.sqlite_store import SQLiteStore + +_STORE_KEYS = {"workspace_packages", "tsconfig_paths", "python_dependencies", "npm_dependencies"} + + +def _repo(tmp_path: Path) -> Path: + root = tmp_path / "repo" + (root / ".git").mkdir(parents=True) + (root / "pyproject.toml").write_text( + '[project]\nname = "x"\ndependencies = ["PyYAML>=6", "requests"]\n', encoding="utf-8" + ) + (root / "package.json").write_text( + json.dumps({"name": "mono", "dependencies": {"react": "18"}}), encoding="utf-8" + ) + (root / "packages" / "ui").mkdir(parents=True) + (root / "packages" / "ui" / "package.json").write_text( + json.dumps({"name": "@acme/ui"}), encoding="utf-8" + ) + return root + + +def test_every_pack_contributes_a_reader_in_manifest_order(tmp_path: Path) -> None: + readers = project_readers(tmp_path, extractors([])) + assert [type(r) for r in readers] == [PythonProjectReader, TypeScriptProjectReader] + + +def test_the_packs_own_exactly_the_store_keys() -> None: + """Ruling S1: facts are keyed by the ingest_state key the store has always used.""" + python = set(PythonFacts([]).state()) + typescript = set(TypeScriptFacts({}, {}, []).state()) + assert python | typescript == _STORE_KEYS + assert not python & typescript + + +def test_python_facts_are_the_declared_roots(tmp_path: Path) -> None: + root = _repo(tmp_path) + reader = PythonProjectReader(root) + assert reader.note(root / "pyproject.toml") is False # read at the root, not from the walk + assert reader.facts().state() == {"python_dependencies": sorted(declared_import_roots(root))} + assert reader.facts().state()["python_dependencies"] == ["requests", "yaml"] + + +def test_the_typescript_reader_takes_only_its_files(tmp_path: Path) -> None: + reader = TypeScriptProjectReader(tmp_path, extractors([])) + assert reader.note(tmp_path / "package.json") is True + assert reader.note(tmp_path / "tsconfig.json") is True + assert reader.note(tmp_path / "pyproject.toml") is False + + +def test_two_packs_claiming_one_key_fail() -> None: + """Last-writer-wins would silently drop one pack's facts.""" + facts = [PythonFacts(["a"]), PythonFacts(["b"])] + with pytest.raises(ValueError, match="python_dependencies"): + ImportNames.from_facts(facts) + + +def test_a_missing_pack_reads_as_no_facts() -> None: + names = ImportNames.from_facts([PythonFacts(["yaml"])]) + assert names == ImportNames( + workspace_packages={}, path_aliases={}, python_dependencies=["yaml"], npm_dependencies=[] + ) + + +def test_collected_facts_round_trip_through_the_store(tmp_path: Path) -> None: + """Recorded under the old keys, and an unchanged project asks for no rebuild.""" + root = _repo(tmp_path) + collector = ImportNameCollector(root, extractors([])) + for path in sorted(root.rglob("package.json")): + collector.note(path) + names = collector.collect() + assert names.python_dependencies == ["requests", "yaml"] + assert names.npm_dependencies == ["react"] + assert names.workspace_packages == {"@acme.ui": "packages.ui"} + with SQLiteStore(str(tmp_path / "g.db")) as store: + names.record(store) + assert names.differ_from(store) is False + keys = {row[0] for row in store._conn.execute("SELECT key FROM ingest_state")} # noqa: SLF001 + assert keys >= _STORE_KEYS From 2b4092a2c8d95b1851a5dd1e3bc5a35001e80435 Mon Sep 17 00:00:00 2001 From: zaebee Date: Wed, 14 Oct 2026 11:15:01 +0000 Subject: [PATCH 5/7] perf(workspaces): a root package.json no longer loads the TypeScript grammar WorkspacePackages.note asked for the TS extractor before learning the manifest was the repository root's, which it skips anyway; a Python repo with a tooling package.json (prettier, commitlint) loaded the grammar for nothing. Step-1 deferred minor; pinned by a fresh-interpreter CLI ingest. Co-Authored-By: Claude Opus 5.5 --- src/cgis/languages/typescript/workspaces.py | 7 +++---- tests/unit/test_languages.py | 9 +++++++++ 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/src/cgis/languages/typescript/workspaces.py b/src/cgis/languages/typescript/workspaces.py index 1f39cfb1..c33304ca 100644 --- a/src/cgis/languages/typescript/workspaces.py +++ b/src/cgis/languages/typescript/workspaces.py @@ -135,16 +135,15 @@ def note(self, manifest: Path) -> None: Never the repository root: its manifest names the monorepo itself, and mapping it would send an import of that name to the empty directory FQN. An unreadable or nameless manifest is skipped: one bad file must not stop - the ingest. + the ingest. The extractor is asked for last: a root manifest, which is + skipped anyway, must not load a grammar. """ - if self._extractor is None: - return directory = manifest.resolve().parent try: relative = directory.relative_to(self._root).as_posix() except ValueError: return - if relative in ("", "."): + if relative in ("", ".") or self._extractor is None: return try: data = json.loads(manifest.read_text(encoding="utf-8")) diff --git a/tests/unit/test_languages.py b/tests/unit/test_languages.py index 2a3a70da..5ac50a63 100644 --- a/tests/unit/test_languages.py +++ b/tests/unit/test_languages.py @@ -304,3 +304,12 @@ def test_a_typescript_only_ingest_never_loads_the_python_grammar(tmp_path: Path) """Every pack's reader exists from the first file now; the Python grammar must still wait.""" files = {"app/h.ts": TS_SOURCE, "README.md": "# demo\n"} assert _cli_ingest_loads(tmp_path, files) == ["tree_sitter_typescript"] + + +def test_a_root_package_json_does_not_load_the_typescript_grammar(tmp_path: Path) -> None: + """A tooling manifest at a Python repo's root (prettier, commitlint) names no package.""" + files = { + "pkg/a.py": PY_SOURCE, + "package.json": '{"name": "tooling", "devDependencies": {"prettier": "3"}}', + } + assert _cli_ingest_loads(tmp_path, files) == ["tree_sitter_python"] From 5ef2cad4a60bca4f61684eb302ae7e8d2e642021 Mon Sep 17 00:00:00 2001 From: zaebee Date: Wed, 14 Oct 2026 11:17:41 +0000 Subject: [PATCH 6/7] =?UTF-8?q?docs:=20implementation=20notes=20for=20lang?= =?UTF-8?q?uage-packs=20steps=201=E2=80=932?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 5 +++- docs/plans/2026-10-14-language-packs-step1.md | 6 +++++ .../specs/2026-10-14-language-packs-design.md | 23 +++++++++++++++++++ 3 files changed, 33 insertions(+), 1 deletion(-) diff --git a/CLAUDE.md b/CLAUDE.md index d67fb0ad..6ec7389b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -75,12 +75,15 @@ IngestionPipeline (pipeline.py) (`parse(code, file_path) -> tuple[list[Node], list[Edge]]`; emit `CALLS` edges with `target="raw_call:"`, set `Node.language = ""`). 2. In its `__init__.py`, define `PACK`, subclassing `cgis.core.extractor.LanguagePack` (mark methods `@override`) - (`name`, `extensions`, `code_fence`, `is_test`, `scope_of`, `extractor`). + (`name`, `extensions`, `code_fence`, `is_test`, `scope_of`, `extractor`, `project_reader`). 3. Add one `Manifest` to `MANIFESTS` in `src/cgis/languages/__init__.py`, with a `load` function that imports the pack statically inside it (lazy, yet visible to the review-fingerprint walk). Nothing else names the pack: CLI, MCP server, guardian and scripts all go through the registry, and the `surface-packs` forbidden-edge gate fails if a surface imports a pack directly. 4. A pack never imports another pack or `cgis.storage` (`cross-pack`, `pack-isolation` gates). +5. A pack's `__init__` imports no grammar: import the extractor inside `extractor()`. Config reading + goes in a `ProjectReader` (`cgis.core.project`) whose facts are data keyed by `ingest_state` + key — the collector (`cgis.import_names`), not the pack, writes them. ## Type Discipline diff --git a/docs/plans/2026-10-14-language-packs-step1.md b/docs/plans/2026-10-14-language-packs-step1.md index 452da218..5a8e2f68 100644 --- a/docs/plans/2026-10-14-language-packs-step1.md +++ b/docs/plans/2026-10-14-language-packs-step1.md @@ -23,6 +23,12 @@ ## Rulings (where this plan departs from the spec's text, and why) +> Superseded in places by the code that shipped (#597): `LanguagePack` lives in +> `cgis.core.extractor`, manifests carry `load` functions instead of module paths, +> packs subclass the protocol with `@override`, and (step 2) R7 gave way to light +> pack modules that import their grammar inside `extractor()`. The spec's +> implementation notes record each. + - **R1 — Two "is test" rules, not one.** The spec says a pack's `is_test` replaces both `core.paths.is_test_path` and the pipeline's `_TEST_FILE_PATTERN`. They are different rules: - `_TEST_FILE_PATTERN` (`foo.test.ts`, `foo.spec.py`) makes the walk **skip** a file; - `is_test_path` (`tests/`, `test_*.py`) **flags** an ingested node. diff --git a/docs/specs/2026-10-14-language-packs-design.md b/docs/specs/2026-10-14-language-packs-design.md index 410a33b3..a81e1553 100644 --- a/docs/specs/2026-10-14-language-packs-design.md +++ b/docs/specs/2026-10-14-language-packs-design.md @@ -100,6 +100,11 @@ Two planes over a shared core: - **Read plane.** `storage → query → surfaces`. - **Core.** Model types and FQN utilities. It imports nothing else from cgis. + > **Implementation note (steps 1–2):** the pack contract — `BaseExtractor`, + > `ModuleNamer`, `LanguagePack` (`cgis.core.extractor`), `ProjectReader` and + > `ProjectFacts` (`cgis.core.project`) — lives in core too. A protocol inside the + > registry formed a cycle with the packs that subclass it (#597). + ### Registry `cgis/languages/__init__.py` holds one lightweight **manifest** per pack: its @@ -123,6 +128,13 @@ The CLI, MCP server, health script and reviewer all use the registry; none names an extractor class. Surfaces and `query` may import `cgis.languages` (the registry) but not `cgis.languages.`. +> **Implementation note (steps 1–2):** manifests load their pack through a +> function holding a *static* import, never `importlib`, which the +> review-fingerprint walk cannot follow (#597). Pack modules are imported up +> front, because every project reader must see configs before the first file +> of its language; what stays lazy is the grammar, which a pack imports only +> inside `extractor()` (step 2, ruling S2). + ### The `LanguagePack` contract ```python @@ -154,6 +166,11 @@ class LanguagePack(Protocol): - `is_test` replaces the Python-only `is_test_path` and the pipeline's `_TEST_FILE_PATTERN` (pipeline.py:447). For Go it matches `_test.go`. + + > **Implementation note (step 1):** those are two rules, not one — the walk's + > *skip* (`foo.test.ts` is never read) became `Manifest.never_ingested` and the + > registry's `skips()`; the node *flag* is the pack's `is_test`. Merging them + > would have dropped every `tests/` directory from the graph (#597, ruling R1). - `scope_of` returns the namespace unit a file's definitions live in: - Python and TS return today's module FQN for the file, so nothing changes; - Go returns the package directory, so `func (s *Server) Run()` in @@ -182,6 +199,12 @@ class ProjectFacts(Protocol): # frozen, JSON-serialisable def differs_from(self, store: SQLiteStore) -> bool: ... # a change forces a rebuild ``` +> **Implementation note (step 2):** `record`/`differs_from` contradict "a pack +> never touches storage" below, which the `pack-isolation` gate enforces. As +> built, `ProjectFacts.state()` returns the facts keyed by their `ingest_state` +> key, and the collector (`cgis.import_names.ImportNames`) records and compares +> them through the store — same keys, no pack-to-storage edge (ruling S1). + The Python pack's facts are today's dependency roots. The TS pack's are npm dependencies, workspace packages (package.json and pnpm) and tsconfig path aliases. `import_names.py` becomes the collector that asks each pack. From f5df61659e6fb4923e8c0c806e121dacd8ae7cc1 Mon Sep 17 00:00:00 2001 From: zaebee Date: Wed, 14 Oct 2026 11:30:02 +0000 Subject: [PATCH 7/7] fix(import-names): refuse facts the collector cannot store; pin readers on the review path from_facts read only the four keys it knows and cast their values: a new pack's fact under another key vanished without a trace, and a tuple where the store reads back a list rebuilt the graph on every incremental run. Unknown keys now raise ValueError and wrong shapes TypeError (final review I1). Each pack's __init__ imports its project reader, so the seven config-reader modules are now in the review-fingerprint closure (a side effect of ruling S2); the inventory is regenerated so dropping one fails again (final review I2). Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 3 ++- src/cgis/import_names.py | 23 +++++++++++++++++++++++ tests/unit/review_path_inventory.txt | 7 +++++++ tests/unit/test_project_facts.py | 24 ++++++++++++++++++++++++ 4 files changed, 56 insertions(+), 1 deletion(-) diff --git a/CLAUDE.md b/CLAUDE.md index 6ec7389b..f9a96a11 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -83,7 +83,8 @@ IngestionPipeline (pipeline.py) 4. A pack never imports another pack or `cgis.storage` (`cross-pack`, `pack-isolation` gates). 5. A pack's `__init__` imports no grammar: import the extractor inside `extractor()`. Config reading goes in a `ProjectReader` (`cgis.core.project`) whose facts are data keyed by `ingest_state` - key — the collector (`cgis.import_names`), not the pack, writes them. + key — the collector (`cgis.import_names`), not the pack, writes them. A key the collector does not + know (`_FACT_SHAPES`) is refused: a new fact needs store and resolver support first. ## Type Discipline diff --git a/src/cgis/import_names.py b/src/cgis/import_names.py index 8976d659..14fd05f8 100644 --- a/src/cgis/import_names.py +++ b/src/cgis/import_names.py @@ -21,6 +21,16 @@ #: What marks a repository boundary the upward search never crosses. _REPOSITORY_MARKER = ".git" +#: The facts the collector can record, compare and hand the resolver, with the +#: JSON shape each reads back as. A pack contributing any other key needs store +#: and resolver support first; until then its facts would vanish silently. +_FACT_SHAPES: dict[str, type] = { + "workspace_packages": dict, + "tsconfig_paths": dict, + "python_dependencies": list, + "npm_dependencies": list, +} + @dataclass(frozen=True) class ImportNames: @@ -67,6 +77,7 @@ def from_facts(cls, facts: Iterable[ProjectFacts]) -> "ImportNames": msg = f"two packs record the same ingest_state key(s): {sorted(shared)}" raise ValueError(msg) state.update(contribution) + _check_shapes(state) return cls( workspace_packages=cast("dict[str, str]", state.get("workspace_packages", {})), path_aliases=cast("dict[str, dict[str, list[str]]]", state.get("tsconfig_paths", {})), @@ -82,6 +93,18 @@ def record(self, store: "SQLiteStore") -> None: store.record_npm_dependencies(self.npm_dependencies) +def _check_shapes(state: Mapping[str, object]) -> None: + """Refuse a fact the collector cannot store, or one that would never read back equal.""" + unknown = sorted(state.keys() - _FACT_SHAPES.keys()) + if unknown: + msg = f"no collector support for ingest_state key(s): {unknown}" + raise ValueError(msg) + for key, value in state.items(): + if not isinstance(value, _FACT_SHAPES[key]): + msg = f"{key} must be a {_FACT_SHAPES[key].__name__}, not {type(value).__name__}" + raise TypeError(msg) + + def _enclosing_directories(root: Path) -> tuple[list[Path], bool]: """The directories above `root`, nearest first, and whether a repository bounds them. diff --git a/tests/unit/review_path_inventory.txt b/tests/unit/review_path_inventory.txt index 6d61cab7..5dd857c2 100644 --- a/tests/unit/review_path_inventory.txt +++ b/tests/unit/review_path_inventory.txt @@ -5,6 +5,7 @@ src/cgis/core/freshness.py src/cgis/core/js_globals.py src/cgis/core/models.py src/cgis/core/paths.py +src/cgis/core/project.py src/cgis/guardian/__init__.py src/cgis/guardian/axes.py src/cgis/guardian/chunked.py @@ -34,11 +35,17 @@ src/cgis/languages/python/_functions.py src/cgis/languages/python/_imports.py src/cgis/languages/python/_syntax.py src/cgis/languages/python/_types.py +src/cgis/languages/python/dependencies.py src/cgis/languages/python/extractor.py +src/cgis/languages/python/project.py src/cgis/languages/typescript/__init__.py src/cgis/languages/typescript/_bindings.py src/cgis/languages/typescript/extractor.py src/cgis/languages/typescript/naming.py +src/cgis/languages/typescript/npm.py +src/cgis/languages/typescript/project.py +src/cgis/languages/typescript/tsconfig.py +src/cgis/languages/typescript/workspaces.py src/cgis/query/analysis/__init__.py src/cgis/query/analysis/health.py src/cgis/query/drift/__init__.py diff --git a/tests/unit/test_project_facts.py b/tests/unit/test_project_facts.py index e7426f4d..f6b5119a 100644 --- a/tests/unit/test_project_facts.py +++ b/tests/unit/test_project_facts.py @@ -88,3 +88,27 @@ def test_collected_facts_round_trip_through_the_store(tmp_path: Path) -> None: assert names.differ_from(store) is False keys = {row[0] for row in store._conn.execute("SELECT key FROM ingest_state")} # noqa: SLF001 assert keys >= _STORE_KEYS + + +class _Facts: + """A pack's facts as a new pack might write them.""" + + def __init__(self, state: dict[str, object]) -> None: + self._state = state + + def state(self) -> dict[str, object]: + return self._state + + +def test_a_key_the_collector_cannot_store_fails() -> None: + """Accepted and dropped, a new pack's facts would never be stored, compared or resolved.""" + facts = [_Facts({"go_modules": ["example.com/x"]})] + with pytest.raises(ValueError, match="go_modules"): + ImportNames.from_facts(facts) + + +def test_a_fact_of_the_wrong_shape_fails() -> None: + """A tuple never equals the list read back from JSON: every incremental run would rebuild.""" + facts = [_Facts({"npm_dependencies": ("react",)})] + with pytest.raises(TypeError, match="npm_dependencies"): + ImportNames.from_facts(facts)