Repository navigation
refactor(languages): step 4 — resolution rules per pack - #601
Conversation
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The pipeline's incremental signature reads the union over the manifests; the Python manifest carries today's table, TypeScript none, so the union is the old table exactly — and collecting it imports no pack. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ules) cgis.core.resolution: the view a rule reads the index through (SymbolIndex satisfies it; strip_layout_prefixes becomes public for it), and the rules a pack contributes — type normalizers, steps at validated positions, a fallback with its confidence, an import reconciler. Packs return empty rules until the next commits move one language branch each. Registry: rules_for, type_normalizers. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
resolve_import_target keeps the exact-id hit and hands the rest to the source pack's reconcile_import: TypeScript's reads tsconfig aliases, workspace packages and `.d` declarations from its own index facts; Python's strips corroborated layout prefixes. SymbolIndex loses workspace_packages and path_aliases, and the resolver its last suffix checks (_PYTHON_SUFFIXES, _TYPESCRIPT_SUFFIXES). Self-drift: resolution hygiene unresolved_ratio 0.444 -> 0.459 (161 UNKNOWN of 351 calls, from 169 of 381): the moved TS helpers took more resolved calls than unresolved ones out of the domain, and the rule call `rules.reconcile_import` is one new dynamic dispatch. Re-baselined once at the end of step 4 (user decision). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ort_map step resolve_global_call runs the source pack's steps at `after:import_map` through a module-level helper (SymbolResolver is at the god-object threshold). The TS step reads the file's bare bindings from the TS pack's index facts; SymbolIndex loses file_bare_bindings. The resolver no longer imports a pack: the engine-packs allowlist is empty. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
js_builtins.py moves into the TypeScript pack; the engine asks the source pack's fallback for a call nothing resolved and takes its (target, confidence) — 0.8 for a JS runtime global, as before. A missing file still rewrites nothing. The file's shadowed globals are TS index facts; SymbolIndex loses file_shadowed_globals, and js_builtin_target its now-redundant language check. The engine's fallback helper reads the index as core's ResolutionView, not as SymbolIndex: importing cgis.resolver.indices into engine made an engine→symbols→indices triangle that took resolution's self-drift from 0.384 to 0.594. Through the protocol the domain's imports are a single chain and the drift is 0.177. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… are the Python pack's _through_annotated_alias becomes the Python pack's type normalizer; the engine applies every pack's normalizers in manifest order (receiver_type_fqn has no source file; ruling D2). Explicit re-exports and star-import expansion move into PythonIndexFacts.reexports; SymbolIndex.reexports is the merge of every pack's (a module two packs both forward is an error) and stays generic, since follow_reexport serves every language (D1). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Measured on the branch (main f0367de alongside): - resolution self-drift 0.386 -> 0.180, unresolved 0.444 -> 0.440 (lock 0.40/0.45): the domain's imports are now the single chain engine -> symbols -> indices. The mid-step hygiene breach (0.459 after Task 3) closed by itself once the Python rules left; the re-baseline agreed for the end of the step was not needed. - owner-api ingest, median of three: main 21.26 s, branch 21.74 s (+2.2%) - review-path inventory gains core/resolution.py and the two packs' resolution modules and the TS builtins. - `git grep` of src/cgis/resolver for suffixes, pack names and js_builtins: none. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…remains The note claimed the packs compute suffix_map (it is still built generically) and that cgis.resolver "names no language" (true of its imports, not its text: the Python __init__ root rule, the JS-builtins root in the generic classify tail, and _is_dunder remain for step 5). Final review, minor 3. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
There was a problem hiding this comment.
Code Review
This pull request implements Step 4 of the Language Packs migration, successfully moving language-specific resolution rules (including type normalizers, positioned steps, fallbacks, and import reconcilers) out of the core resolver engine and into the TypeScript and Python language packs. This decoupling is achieved by introducing the cgis.core.resolution contract, allowing the engine-packs allowlist to be emptied. Feedback on these changes highlights a performance improvement opportunity to cache the type_normalizers() function on the hot path, as well as several reliability improvements to defensively use .get("typescript") on view.facts to prevent potential KeyErrors in the TypeScript resolution rules.
| def type_normalizers() -> tuple[TypeNormalizer, ...]: | ||
| """Every pack's type normalizers, in manifest order (step 4, ruling D2).""" | ||
| return tuple(n for m in MANIFESTS for n in _rules(m.name).type_normalizers) |
There was a problem hiding this comment.
The type_normalizers() function is called on the hot path of resolution (e.g., inside _normalized in symbols.py). Currently, it rebuilds the tuple of normalizers on every single call, which can be a performance bottleneck (taking ~4.6 ms on large codebases like owner-api as noted in the PR description). Since the manifests and rules are static/cached, we can cache the result of type_normalizers() using @cache to avoid this overhead.
| def type_normalizers() -> tuple[TypeNormalizer, ...]: | |
| """Every pack's type normalizers, in manifest order (step 4, ruling D2).""" | |
| return tuple(n for m in MANIFESTS for n in _rules(m.name).type_normalizers) | |
| @cache | |
| def type_normalizers() -> tuple[TypeNormalizer, ...]: | |
| """Every pack's type normalizers, in manifest order (step 4, ruling D2).""" | |
| return tuple(n for m in MANIFESTS for n in _rules(m.name).type_normalizers) |
There was a problem hiding this comment.
Adopted in 15af381: type_normalizers() is @cached — the packs' rules never change once loaded. Pinned by test_the_normalizers_are_collected_once (type_normalizers() is type_normalizers()), which failed before the change.
| def reconcile_import(fqn: str, source_file: str | None, view: ResolutionView) -> str | None: | ||
| """A TS import's node through tsconfig aliases, then workspace packages (#504, #507, #508).""" | ||
| facts = cast("TypeScriptIndexFacts", view.facts["typescript"]) | ||
| aliased = _alias_candidates(fqn, source_file or "", facts.path_aliases) | ||
| return _resolve_typescript_import(fqn, view.nodes, facts.workspace_packages, aliased) |
There was a problem hiding this comment.
Accessing view.facts["typescript"] directly can raise a KeyError if the ResolutionView / SymbolIndex is hand-built or partially constructed (e.g., in unit tests or minimal environments) without the "typescript" key. It is safer and more defensive to use .get("typescript") and handle the None case gracefully.
| def reconcile_import(fqn: str, source_file: str | None, view: ResolutionView) -> str | None: | |
| """A TS import's node through tsconfig aliases, then workspace packages (#504, #507, #508).""" | |
| facts = cast("TypeScriptIndexFacts", view.facts["typescript"]) | |
| aliased = _alias_candidates(fqn, source_file or "", facts.path_aliases) | |
| return _resolve_typescript_import(fqn, view.nodes, facts.workspace_packages, aliased) | |
| def reconcile_import(fqn: str, source_file: str | None, view: ResolutionView) -> str | None: | |
| """A TS import's node through tsconfig aliases, then workspace packages (#504, #507, #508).""" | |
| facts = view.facts.get("typescript") | |
| if facts is None: | |
| return None | |
| facts = cast("TypeScriptIndexFacts", facts) | |
| aliased = _alias_candidates(fqn, source_file or "", facts.path_aliases) | |
| return _resolve_typescript_import(fqn, view.nodes, facts.workspace_packages, aliased) |
There was a problem hiding this comment.
Declining, deliberately — the same call as on #600. Every SymbolIndex the pipeline builds comes from IndexBuilder.build, which always supplies every registered pack's facts, and since #600 SymbolIndex.facts is a required field so an index without them cannot be constructed by accident. A .get(...) returning None here would turn a mis-built index into silently unresolved TypeScript imports — a wrong graph with no error — which is exactly what the identity and resolver tests exist to rule out. A KeyError names the missing pack at the point of use. The same reasoning applies to package_call and fallback (the two sibling threads).
| def package_call(name: str, file_path: str, view: ResolutionView) -> str | None: | ||
| """A TypeScript call on a name imported from a package: `useState` -> `react.useState`. | ||
|
|
||
| Only when the binding lands in a package (#556). A binding into one of our | ||
| own modules through an alias or workspace name is left to the lookups that | ||
| already resolve those, so internal resolution is unchanged. | ||
|
|
||
| Bindings are per file, not per scope: a parameter that shadows an imported | ||
| name (`function f(map) { map() }` beside `import { map } from "lodash"`) is | ||
| read as the import. Accepted — shadowing an import is rare, and scope | ||
| tracking is a separate piece of work. | ||
| """ | ||
| facts = cast("TypeScriptIndexFacts", view.facts["typescript"]) | ||
| bindings = facts.file_bare_bindings.get(file_path) |
There was a problem hiding this comment.
Similar to reconcile_import, accessing view.facts["typescript"] directly via view.facts["typescript"] can raise a KeyError if the facts dictionary does not contain "typescript". Using .get("typescript") with a fallback or early return is safer.
| def package_call(name: str, file_path: str, view: ResolutionView) -> str | None: | |
| """A TypeScript call on a name imported from a package: `useState` -> `react.useState`. | |
| Only when the binding lands in a package (#556). A binding into one of our | |
| own modules through an alias or workspace name is left to the lookups that | |
| already resolve those, so internal resolution is unchanged. | |
| Bindings are per file, not per scope: a parameter that shadows an imported | |
| name (`function f(map) { map() }` beside `import { map } from "lodash"`) is | |
| read as the import. Accepted — shadowing an import is rare, and scope | |
| tracking is a separate piece of work. | |
| """ | |
| facts = cast("TypeScriptIndexFacts", view.facts["typescript"]) | |
| bindings = facts.file_bare_bindings.get(file_path) | |
| def package_call(name: str, file_path: str, view: ResolutionView) -> str | None: | |
| """A TypeScript call on a name imported from a package: `useState` -> `react.useState`. | |
| Only when the binding lands in a package (#556). A binding into one of our | |
| own modules through an alias or workspace name is left to the lookups that | |
| already resolve those, so internal resolution is unchanged. | |
| Bindings are per file, not per scope: a parameter that shadows an imported | |
| name (`function f(map) { map() }` beside `import { map } from "lodash"`) is | |
| read as the import. Accepted — shadowing an import is rare, and scope | |
| tracking is a separate piece of work. | |
| """ | |
| facts = view.facts.get("typescript") | |
| if facts is None: | |
| return None | |
| facts = cast("TypeScriptIndexFacts", facts) | |
| bindings = facts.file_bare_bindings.get(file_path) |
There was a problem hiding this comment.
Declining — see the reply on reconcile_import above: a missing "typescript" entry can only come from an index built outside IndexBuilder, and failing loudly there is preferable to a silently skipped package call.
| def fallback(name: str, file_path: str, view: ResolutionView) -> tuple[str, float] | None: | ||
| """A call on a JS runtime global the file does not shadow: `js_builtins.<name>` (#111).""" | ||
| facts = cast("TypeScriptIndexFacts", view.facts["typescript"]) | ||
| shadowed = facts.file_shadowed_globals.get(file_path, frozenset()) |
There was a problem hiding this comment.
Similar to the other functions, accessing view.facts["typescript"] directly can raise a KeyError if "typescript" is missing from view.facts.
| def fallback(name: str, file_path: str, view: ResolutionView) -> tuple[str, float] | None: | |
| """A call on a JS runtime global the file does not shadow: `js_builtins.<name>` (#111).""" | |
| facts = cast("TypeScriptIndexFacts", view.facts["typescript"]) | |
| shadowed = facts.file_shadowed_globals.get(file_path, frozenset()) | |
| def fallback(name: str, file_path: str, view: ResolutionView) -> tuple[str, float] | None: | |
| """A call on a JS runtime global the file does not shadow: `js_builtins.<name>` (#111).""" | |
| facts = view.facts.get("typescript") | |
| if facts is None: | |
| return None | |
| facts = cast("TypeScriptIndexFacts", facts) | |
| shadowed = facts.file_shadowed_globals.get(file_path, frozenset()) |
There was a problem hiding this comment.
Declining — see the reply on reconcile_import above: a missing "typescript" entry can only come from an index built outside IndexBuilder, and failing loudly there is preferable to a silently skipped package call.
The engine asks for the normalizers on every declared type, and the packs' rules never change once loaded; the tuple was rebuilt on each call (~4.6 ms on owner-api). Gemini. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|



Step 4 of the language-packs migration (spec
docs/specs/2026-10-14-language-packs-design.md"Resolution" + "Incremental ingest", plandocs/plans/2026-10-14-language-packs-step4.md, part of #146). Every language branch of the resolver becomes a rule a pack contributes;cgis.resolverimports no language pack — theengine-packsallowlist is empty. No graph changes. One language branch per commit.What moved
pipeline._CROSS_FILE_METADATAManifest.cross_file_metadata(Python declares today's table; the pipeline unions them; collecting it imports no pack)core/resolution.py→ResolutionView(protocol;SymbolIndexsatisfies it, so packs never import the resolver),ResolutionRules(type normalizers, steps at validated positions, fallback →(target, confidence),reconcile_import);LanguagePack.resolution(); registryrules_for,type_normalizersindices.resolve_import_target's TS half,PathAlias,_alias_candidates,_workspace_candidates,_resolve_typescript_import,_ordered_aliaseslanguages/typescript/{index,resolution}.py—reconcile_import; facts gainworkspace_packages,path_aliasessymbols._package_callpackage_call, theafter:import_mapstep; facts gainfile_bare_bindingsresolver/js_builtins.pylanguages/typescript/builtins.py; the TSfallbackat 0.8; facts gainfile_shadowed_globalsindicesPython branch ofresolve_import_targetreconcile_import→strip_layout_prefixes(now public, part of the view)symbols._through_annotated_aliasannotated_aliastype normalizerIndexBuilder._expand_star_imports& co, explicit re-exportsPythonIndexFacts.reexports;SymbolIndex.reexports= merge of the packs'Identity and equivalence
graph_identity.py diff, main's cgis f0367de vs this branch: cgissrc/@715cd9a identical (2019 / 7858), owner-api @8de2245 identical (19115 / 96258),ui/identical (286 / 497).ResolverEngine(...).resolve()— every resolved edge (full dump incl. confidence), every virtual node,reexports,layout_prefixes,suffix_map, workspace/alias/binding/shadowed facts and the inheritance tree — on cgissrc,ui/, owner-api and owner-web, plus a synthetic fixture with a tsconfig alias, a workspace package, a.d.ts, a bare import, a shadowed global and a PythonAnnotatedDI alias used throughself.: identical.Measurements
resolutionself-drift 0.386 → 0.180, unresolved 0.444 → 0.440 (lock 0.40 / 0.45): the domain's imports are now the single chainengine → symbols → indices.Two episodes, both in the commit messages:
SymbolIndeximportedindicesintoengine, turning the chain into a triangle: drift 0.594. Typed with core'sResolutionView(ruling D4) it is 0.180.Rulings
layout_prefixes,reexports,suffix_mapstay generic onSymbolIndex(map_to_node_fqn/follow_reexportserve every language); packs computelayout_prefixesandreexports; only TS-only facts left the index.receiver_type_fqnhas no source).ResolutionView, neverSymbolIndex.Remaining language knowledge in
cgis.resolver(text, not imports) — for step 5The Python
__init__root rule insidestrip_layout_prefixes(reached from genericresolve_layout), the JS-builtins root inclassify_fqn's generic tail,_is_dunderin DISPATCHES. Recorded in the spec's step-4 note.Deferred minors (final review)
symbols.py"beside_package_call",IndexBuilder.buildon workspace ordering).view.facts["typescript"]directly — a hand-builtSymbolIndexwithout it wouldKeyError(IndexBuilderalways supplies it).type_normalizers()rebuilds its tuple per call (~4.6 ms on owner-api).Gate: format, lint, mypy strict, 3226 passed (drift tests included), docstring 99%.
🤖 Generated with Claude Code