Repository navigation
refactor(languages): step 3 — per-pack index facts and classification - #600
Conversation
…cation Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…t wired yet) cgis.core.index holds the contract (IndexFacts, ClassifyContext). The Python pack's facts and classification chain are moved from cgis.resolver.indices with their docstrings; the resolver still uses its own copies until step 3 Task 3. JS_BUILTINS_ROOT and the Node builtin names move to cgis.core.js_globals so the TypeScript pack never imports the engine (ruling C6). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… (not wired yet) TypeScriptIndexFacts carries the declared npm packages and each file's bare imports; package_namespace is resolver.indices.bare_import_namespace, moved. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…language packs SymbolIndex.classify_fqn keeps the two language-free rules and hands the rest to the source file's pack (no source -> Python, the spec's preserved quirk; a source no pack reads -> the old generic tail). IndexBuilder asks every pack for its index facts; SymbolIndex loses external_roots, dependency_roots/heads, npm_dependencies and file_bare_imports, now in SymbolIndex.facts by pack name. Resolution-only facts stay until step 4 (ruling C3). symbols._package_call reads the TypeScript pack's package_namespace directly: a TS rule inside the engine, recorded under a new forbidden-edge rule engine-packs with exactly that one edge allowlisted, for step 4 to empty (C5). The Python pack subtracts its own local roots from layout_prefixes, as the old builder did: subtracting the final internal roots would empty the set, since corroborated prefixes are first-party roots by construction. Annotation calibration re-centred 775/238 -> 841/258: main already held 814/252 after steps 1-2; this step adds 27/6, all in the signatures it adds or moves. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Measured on the branch (fresh ingest of src/, main 804a398 alongside): - extraction drift 0.250 -> 0.251, unresolved 0.334 -> 0.328 (lock 0.40/0.37) - resolution drift 0.385 -> 0.386, unresolved 0.433 -> 0.442 (lock 0.40/0.45; 71 nodes, down from 76 — the moved classification code left the domain) - owner-api ingest, median of three: main 22.25 s, branch 22.57 s (+1.5%) Review-path inventory gains core/index.py and the two packs' index modules. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
There was a problem hiding this comment.
Code Review
This pull request implements Step 3 of the Language Packs migration, refactoring the symbol index and classification logic to be language-free by delegating language-specific index facts extraction and classification to the respective language packs (Python and TypeScript). It introduces the IndexFacts protocol and ClassifyContext in cgis.core.index, moves JS-related constants to cgis.core.js_globals, and implements index_facts and classify methods for both packs. Feedback on the changes highlights a critical runtime error in src/cgis/resolver/indices.py where a generic alias frozenset[str] is instantiated directly, and suggests a defensive programming improvement to guard against potential KeyErrors when accessing pack facts.
| } | ||
| facts = pack_index_facts(MappingProxyType(nodes_by_id), state) | ||
| roots = frozenset(internal_roots.union(*(f.internal_roots for f in facts.values()))) | ||
| prefixes = frozenset[str]().union(*(f.layout_prefixes for f in facts.values())) |
There was a problem hiding this comment.
Instantiating a generic alias directly using frozenset[str]() is invalid at runtime in Python and will raise a TypeError: 'types.GenericAlias' object is not callable. To instantiate an empty frozenset, use frozenset() instead.
| prefixes = frozenset[str]().union(*(f.layout_prefixes for f in facts.values())) | |
| prefixes = frozenset().union(*(f.layout_prefixes for f in facts.values())) |
There was a problem hiding this comment.
Declining — frozenset[str]() is valid at runtime. Since Python 3.9 a types.GenericAlias is callable and constructs its origin (list[int]() → []), so frozenset[str]() is an empty frozenset:
$ uv run python -c "x = frozenset[str]().union({'a'}, {'b'}); print(type(x).__name__, sorted(x))"
frozenset ['a', 'b']
The project requires Python 3.12, and this line runs on every ingest — the full suite (3206 passed) and the three identity ingests go through it. The type argument only documents the element type; a bare frozenset() would type-check too.
(Edited: an earlier version claimed mypy needed the type argument. I checked — mypy --strict accepts the bare form — so that claim was wrong and is removed.)
| context = ClassifyContext( | ||
| source_file=source_file, | ||
| internal_roots=self.internal_roots, | ||
| nodes=self.nodes, | ||
| facts=self.facts[pack.name], | ||
| ) | ||
| return pack.classify(fqn, context) |
There was a problem hiding this comment.
To ensure robust defensive programming, guard against cases where self.facts might be missing the expected pack name (e.g., if the index was constructed manually or mocked in tests). Accessing self.facts[pack.name] directly will raise a KeyError in such scenarios. Using .get() with a fallback to the generic classification tail is safer.
facts = self.facts.get(pack.name)\n if facts is None:\n root = fqn.split('.', maxsplit=1)[0]\n if root in self.internal_roots:\n return NodeNamespace.INTERNAL\n return NodeNamespace.STDLIB if root == JS_BUILTINS_ROOT else NodeNamespace.UNKNOWN\n context = ClassifyContext(\n source_file=source_file,\n internal_roots=self.internal_roots,\n nodes=self.nodes,\n facts=facts,\n )\n return pack.classify(fqn, context)There was a problem hiding this comment.
Agreed it was a hazard — fixed in e8b0fb8, but the other way round. A .get() with a fallback to the generic tail would make a mis-built index silently classify a Python reference by the generic rules (no stdlib, no declared-dependency check, no external roots) — a wrong answer instead of an error. Instead SymbolIndex.facts is now a required field with no default, so an index without its packs' facts fails where it is constructed. Pinned by test_an_index_cannot_be_built_without_its_packs_facts, which failed before the change. IndexBuilder.build is the only constructor and always supplies every pack's facts.
Classification reads facts[pack]; with an empty default an index made without them raised a KeyError at the first classification, far from the cause. A guarded fallback (Gemini's suggestion) would be worse: a Python reference would silently take the generic tail. Required, the mistake fails where the index is made (final review minor, Gemini). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|



Step 3 of the language-packs migration (spec
docs/specs/2026-10-14-language-packs-design.md"Index and classification", plandocs/plans/2026-10-14-language-packs-step3.md, part of #146). Each pack computes the index facts it classifies with and classifies FQNs itself; the resolver'sclassify_fqnholds no Python or TypeScript rule. No graph changes.What moved
cgis/resolver/indices.py_build_external_roots,_strips_to_a_node,_heads_under,_is_declared_dependency,_LAYOUT_PREFIX_EVIDENCE,_BUILTINS, src/lib second-segment roots, the Python half ofclassify_fqnlanguages/python/index.py→PythonIndexFacts,index_facts,classifybare_import_namespace, per-file bare imports, npm deps, the TS half ofclassify_fqnlanguages/typescript/index.py→TypeScriptIndexFacts,index_facts,package_namespace,classifyJS_BUILTINS_ROOT,NODE_SCHEME,NODE_BUILTIN_MODULES(resolver/js_builtins.py)core/js_globals.pycore/index.py→IndexFacts,ClassifyContext;LanguagePack.index_facts/.classify; registryindex_facts,classifying_pack(lru-cached)SymbolIndexlosesexternal_roots,dependency_roots,dependency_heads,npm_dependencies,file_bare_imports→SymbolIndex.facts[pack].classify_fqnkeeps the two language-free rules (.-relative,self.) and hands the rest to the source file's pack; no source → Python (the spec's preserved quirk); a source no pack reads → the old generic tail.Identity report
graph_identity.py diff, main's cgis 804a398 vs this branch over the same tree:src/@ 715cd9a — identical (2019 / 7858)ui/— identical (286 / 497)SymbolIndexfrom the same captured nodes for cgissrc,ui/, owner-api, owner-web and a synthetic project withapp/json/+app/alembic/andalembicdeclared, and compared every index field plusclassify_fqn/resolve_import_targetover all node ids, import values, bare specifiers and synthetic FQNs, from every real source, no source, and.js/.pyi/Foo.PY/.jsx/.mts/.d.ts: ~2.7 M checks, 0 mismatches.Measurements
classify_fqn≈0.9 µs vs 0.45 µs).extractiondrift 0.250 → 0.251, unresolved 0.334 → 0.328;resolutiondrift 0.385 → 0.386, unresolved 0.433 → 0.442 (baseline 0.45; 76 → 71 nodes as the classification code left it).Rulings
classifyreturnsNodeNamespace, not| None— today's order interleaves languages (a project package namedjsonmust stay INTERNAL), so each pack runs its whole chain.index_facts(nodes, state)takes the merged project state keyed byingest_statekey; the resolver receives facts as values.layout_prefixes, star expansion, workspace packages, path aliases, bare bindings) stay onSymbolIndexuntil step 4 moves their consumers.resolve_layout/map_to_node_fqngated by pack" dropped — gating changes TypeScript resolution.symbols._package_call→ TSpackage_namespace, recorded under a new forbidden-edge ruleengine-packswith exactly that edge allowlisted (4); step 4 empties it.layout_prefixes, which would have emptied the set (corroborated ⊆ first-party); the Python pack subtracts its local roots, as the old builder did — confirmed equal by the reviewer ({'app'}on owner-api).Deferred minors (final review)
classify_fqnper-call overhead (ClassifyContextallocation, lru keyed on the full path with maxsize 4096) — key on suffix or prebuild a context per pack.SymbolIndex.factsdefaults to{}: aSymbolIndexnot built byIndexBuilderwouldKeyError(none exist)..pyclassifies via the generic tail (suffix""), not Python._heads_under(src/lib rule now inindex_facts),classify_fqn(external_roots); one test name overstates (test_only_typescript_files_carry_bare_imports).Gate: format, lint, mypy strict, 3205 passed, docstring 99%. Independent reviewer (execution tools) ran before this PR.
🤖 Generated with Claude Code