Skip to content

refactor(languages): step 3 — per-pack index facts and classification - #600

Merged
zaebee merged 6 commits into
mainfrom
feat/language-packs-step3
Oct 14, 2026
Merged

zaebee merged 6 commits into
mainfrom
feat/language-packs-step3

Conversation

@zaebee

@zaebee zaebee commented Oct 14, 2026

Copy link
Copy Markdown
Owner

Step 3 of the language-packs migration (spec docs/specs/2026-10-14-language-packs-design.md "Index and classification", plan docs/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's classify_fqn holds no Python or TypeScript rule. No graph changes.

What moved

from cgis/resolver/indices.py to
_build_external_roots, _strips_to_a_node, _heads_under, _is_declared_dependency, _LAYOUT_PREFIX_EVIDENCE, _BUILTINS, src/lib second-segment roots, the Python half of classify_fqn languages/python/index.py → PythonIndexFacts, index_facts, classify
bare_import_namespace, per-file bare imports, npm deps, the TS half of classify_fqn languages/typescript/index.py → TypeScriptIndexFacts, index_facts, package_namespace, classify
JS_BUILTINS_ROOT, NODE_SCHEME, NODE_BUILTIN_MODULES (resolver/js_builtins.py) core/js_globals.py
— core/index.py → IndexFacts, ClassifyContext; LanguagePack.index_facts / .classify; registry index_facts, classifying_pack (lru-cached)

SymbolIndex loses external_roots, dependency_roots, dependency_heads, npm_dependencies, file_bare_imports → SymbolIndex.facts[pack]. classify_fqn keeps 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:

  • cgis src/ @ 715cd9a — identical (2019 / 7858)
  • owner-api @ 8de2245 — identical (19115 / 96258)
  • this repo's ui/ — identical (286 / 497)
  • The final reviewer built old (804a398) and new SymbolIndex from the same captured nodes for cgis src, ui/, owner-api, owner-web and a synthetic project with app/json/ + app/alembic/ and alembic declared, and compared every index field plus classify_fqn/resolve_import_target over 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

  • owner-api ingest, median of three: main 22.25 s, branch 22.57 s (+1.5%; per call classify_fqn ≈0.9 µs vs 0.45 µs).
  • extraction drift 0.250 → 0.251, unresolved 0.334 → 0.328; resolution drift 0.385 → 0.386, unresolved 0.433 → 0.442 (baseline 0.45; 76 → 71 nodes as the classification code left it).
  • Annotation calibration re-centred 775/238 → 841/258: main already held 814/252 after steps 1–2; this PR adds 27/6, every one in a signature it adds or moves (attributed file by file in the test's comment).

Rulings

  • C1 classify returns NodeNamespace, not | None — today's order interleaves languages (a project package named json must stay INTERNAL), so each pack runs its whole chain.
  • C2 index_facts(nodes, state) takes the merged project state keyed by ingest_state key; the resolver receives facts as values.
  • C3 Resolution-only facts (layout_prefixes, star expansion, workspace packages, path aliases, bare bindings) stay on SymbolIndex until step 4 moves their consumers.
  • C4 The outline's "resolve_layout/map_to_node_fqn gated by pack" dropped — gating changes TypeScript resolution.
  • C5 One engine→pack edge: symbols._package_call → TS package_namespace, recorded under a new forbidden-edge rule engine-packs with exactly that edge allowlisted (4); step 4 empties it.
  • C6 JS constants moved to core so the TS pack never imports the engine.
  • Executor: the plan said subtract the final internal roots from 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_fqn per-call overhead (ClassifyContext allocation, lru keyed on the full path with maxsize 4096) — key on suffix or prebuild a context per pack.
  • SymbolIndex.facts defaults to {}: a SymbolIndex not built by IndexBuilder would KeyError (none exist).
  • A file literally named .py classifies via the generic tail (suffix ""), not Python.
  • Stale docstrings: _heads_under (src/lib rule now in index_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

zaebee and others added 5 commits October 14, 2026 11:54
…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>

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

critical

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.

Suggested change
prefixes = frozenset[str]().union(*(f.layout_prefixes for f in facts.values()))
prefixes = frozenset().union(*(f.layout_prefixes for f in facts.values()))

@zaebee zaebee Oct 14, 2026 •

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Comment on lines +292 to +298
context = ClassifyContext(
source_file=source_file,
internal_roots=self.internal_roots,
nodes=self.nodes,
facts=self.facts[pack.name],
)
return pack.classify(fqn, context)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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)

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>
@sonarqubecloud

Copy link
Copy Markdown

@zaebee
zaebee merged commit f0367de into main Oct 14, 2026
4 checks passed
@zaebee
zaebee deleted the feat/language-packs-step3 branch October 14, 2026 13:32
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant