Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,10 @@ IngestionPipeline (pipeline.py)
`target="raw_call:<name>"`, set `Node.language = "<name>"`).
2. In its `__init__.py`, define `PACK`, subclassing `cgis.core.extractor.LanguagePack` (mark methods `@override`)
(`name`, `extensions`, `code_fence`, `is_test`, `scope_of`, `extractor`, `project_reader`,
`index_facts`, `classify`). `classify` answers for references from the pack's own files and
`index_facts`, `classify`, `resolution`). `resolution()` returns `ResolutionRules`
(`cgis.core.resolution`): type normalizers, steps at a position (`after:import_map`), a
fallback with its confidence, `reconcile_import` — all reading the index as `ResolutionView`.
Metadata other files resolve against goes in the manifest's `cross_file_metadata`. `classify` answers for references from the pack's own files and
runs the whole chain; a source with no pack gets the generic tail in `SymbolIndex.classify_fqn`.
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
Expand Down
681 changes: 681 additions & 0 deletions docs/plans/2026-10-14-language-packs-step4.md

Large diffs are not rendered by default.

19 changes: 19 additions & 0 deletions docs/specs/2026-10-14-language-packs-design.md
Original file line number Diff line number Diff line change
Expand Up @@ -263,6 +263,25 @@ The generic chain, with today's positions of language logic:
`reconcile_import` takes the language-split half of `resolve_import_target`
(indices.py:141–146). Step 4 of the migration extracts these one at a time.

> **Implementation note (step 4):** the contract is `cgis.core.resolution` —
> `ResolutionRules` (normalizers, steps at validated positions, a fallback
> returning `(target, confidence)`, `reconcile_import`) and `ResolutionView`, the
> protocol a rule reads the index through, so packs never import the resolver
> (ruling D4). `layout_prefixes`, `reexports` and `suffix_map` stay generic on
> `SymbolIndex` — `map_to_node_fqn`/`follow_reexport` serve every language; the
> packs compute `layout_prefixes` and `reexports` (Python's star expansion), while
> `suffix_map` is still built generically. Only the TS-only facts left it (D1). Every pack's type
> normalizers apply, in manifest order, because `receiver_type_fqn` has no source
> (D2). The `engine-packs` allowlist is empty: `cgis.resolver` imports no language
> pack. Some language knowledge remains in its text, for step 5 to weigh: the
> Python `__init__` root rule inside `strip_layout_prefixes` (reached from the
> generic `resolve_layout`), the JS-builtins root in `classify_fqn`'s generic tail,
> and `_is_dunder` in DISPATCHES.
> The owner-api ingest moved +2.2% (median of three). One measured lesson: typing
> an engine helper with `SymbolIndex` instead of `ResolutionView` added an
> engine→indices import that turned the domain's chain into a triangle and its
> self-drift from 0.38 to 0.59; through the protocol it is 0.18.

**Incremental ingest.** `cross_file_metadata` lists, per node type, the
metadata keys other files resolve against. It is declared in the pack's
registry manifest, not read from the pack module, so collecting it imports
Expand Down
5 changes: 5 additions & 0 deletions src/cgis/core/extractor.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
from cgis.core.index import ClassifyContext, IndexFacts
from cgis.core.models import Edge, Node, NodeNamespace
from cgis.core.project import ProjectReader
from cgis.core.resolution import ResolutionRules


@runtime_checkable
Expand Down Expand Up @@ -99,3 +100,7 @@ def index_facts(self, nodes: Mapping[str, Node], state: Mapping[str, object]) ->
def classify(self, fqn: str, ctx: ClassifyContext) -> NodeNamespace:
"""The namespace of `fqn` referenced from one of this pack's files: the whole chain (C1)."""
...

def resolution(self) -> ResolutionRules:
"""This pack's resolution rules (spec: Resolution)."""
...
5 changes: 5 additions & 0 deletions src/cgis/core/index.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,11 @@ def internal_roots(self) -> frozenset[str]:
"""Roots this pack adds to the language-free set (first segment of every node id)."""
...

@property
def reexports(self) -> Mapping[str, Mapping[str, str]]:
"""module_fqn -> {name: definer_fqn}: the names a module forwards (#417)."""
...

@property
def layout_prefixes(self) -> frozenset[str]:
"""Prefixes sources write that node ids do not carry (#494): stripped off a target."""
Expand Down
73 changes: 73 additions & 0 deletions src/cgis/core/resolution.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
"""What a language pack contributes to resolution (spec: Resolution).

The engine runs a fixed chain — local type (with type normalizers), import map,
steps positioned `after:import_map`, global symbols, fallback — and asks the
source file's pack at each of those points. A rule reads the index through
`ResolutionView`, never the resolver itself, so `cgis.languages` stays clear of
`cgis.resolver` (language-packs step 4).
"""

from collections.abc import Callable, Mapping
from dataclasses import dataclass
from typing import Protocol

from cgis.core.index import IndexFacts
from cgis.core.models import Node

#: The only position the engine runs pack steps at today: after the import map,
#: before the global symbol index (TS `_package_call`).
AFTER_IMPORT_MAP = "after:import_map"
_POSITIONS = frozenset({AFTER_IMPORT_MAP})


class ResolutionView(Protocol):
"""What a resolution rule may read; `SymbolIndex` satisfies it."""

@property
def nodes(self) -> Mapping[str, Node]:
"""Every node, by id."""
...

@property
def facts(self) -> Mapping[str, IndexFacts]:
"""Each pack's index facts, by pack name."""
...

def map_to_node_fqn(self, imported_fqn: str) -> str | None:
"""An imported FQN reconciled to a node id, or None."""
...

def strip_layout_prefixes(self, fqn: str) -> str | None:
"""The node `fqn` names once corroborated layout prefixes are dropped, or None."""
...

def is_variable_node(self, fqn: str) -> bool:
"""Whether `fqn` is a VARIABLE node."""
...


TypeNormalizer = Callable[[str, ResolutionView], str]
CallStep = Callable[[str, str, ResolutionView], str | None]
Fallback = Callable[[str, str, ResolutionView], tuple[str, float] | None]
ImportReconciler = Callable[[str, str | None, ResolutionView], str | None]


@dataclass(frozen=True)
class ResolutionRules:
"""One pack's contribution to the resolver's chain."""

#: Applied to a declared type before its methods are looked up (e.g. Annotated alias → T).
type_normalizers: tuple[TypeNormalizer, ...] = ()
#: (position, step): a call step run at that point of the chain.
steps: tuple[tuple[str, CallStep], ...] = ()
#: When nothing resolved a call: (target, confidence), or None.
fallback: Fallback | None = None
#: An import target with no node of that name: the node it means, or None.
reconcile_import: ImportReconciler | None = None

def __post_init__(self) -> None:
"""Refuse a step at a position the engine never runs."""
unknown = sorted({position for position, _ in self.steps} - _POSITIONS)
if unknown:
msg = f"unknown resolution step position(s): {unknown}"
raise ValueError(msg)
46 changes: 44 additions & 2 deletions src/cgis/languages/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,14 +19,15 @@

import re
from collections.abc import Callable, Iterator, Mapping
from dataclasses import dataclass
from dataclasses import dataclass, field
from functools import cache, lru_cache
from pathlib import Path, PurePosixPath

from cgis.core.extractor import BaseExtractor, LanguagePack
from cgis.core.index import IndexFacts
from cgis.core.models import Node
from cgis.core.models import Node, NodeType
from cgis.core.project import ProjectReader
from cgis.core.resolution import ResolutionRules, TypeNormalizer


@dataclass(frozen=True)
Expand All @@ -37,6 +38,10 @@ class Manifest:
load: Callable[[], LanguagePack] #: imports the pack; called once, on first use
extensions: tuple[str, ...] #: matched against the path suffix, dot included
never_ingested: re.Pattern[str] #: this language's test files the walk skips outright
#: Per node type, the metadata keys other files resolve against; a change to
#: one forces a rebuild of an incremental run. Declared here so collecting the
#: union imports no pack (spec, Incremental ingest).
cross_file_metadata: Mapping[NodeType, tuple[str, ...]] = field(default_factory=dict)


def _python() -> LanguagePack:
Expand All @@ -57,6 +62,12 @@ def _typescript() -> LanguagePack:
load=_python,
extensions=(".py",),
never_ingested=re.compile(r"\.(test|spec)\.py$", re.IGNORECASE),
cross_file_metadata={
NodeType.FILE: ("import_map", "reexports", "star_imports"),
NodeType.CLASS: ("self_types",),
# Another file's parameter typed with the alias reads the type behind it (#550).
NodeType.VARIABLE: ("annotated_type",),
},
),
Manifest(
name="typescript",
Expand Down Expand Up @@ -136,6 +147,37 @@ def classifying_pack(source_file: str | None) -> LanguagePack | None:
return _load("python") if source_file is None else pack_for_path(source_file)


def cross_file_metadata() -> dict[NodeType, tuple[str, ...]]:
"""The union over every manifest, keys in manifest order; imports no pack."""
merged: dict[NodeType, tuple[str, ...]] = {}
for manifest in MANIFESTS:
for node_type, keys in manifest.cross_file_metadata.items():
known = merged.get(node_type, ())
merged[node_type] = known + tuple(k for k in keys if k not in known)
return merged


@cache
def _rules(name: str) -> ResolutionRules:
return _load(name).resolution()


def rules_for(source_file: str | None) -> ResolutionRules | None:
"""The resolution rules of the pack that classifies `source_file`, or None."""
pack = classifying_pack(source_file)
return None if pack is None else _rules(pack.name)


@cache
def type_normalizers() -> tuple[TypeNormalizer, ...]:
"""Every pack's type normalizers, in manifest order (step 4, ruling D2).

Cached: the engine asks for them on every declared type, and the packs'
rules never change once loaded.
"""
return tuple(n for m in MANIFESTS for n in _rules(m.name).type_normalizers)
Comment on lines +172 to +178

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

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.

Suggested change
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)

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.

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 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)
Expand Down
8 changes: 7 additions & 1 deletion src/cgis/languages/python/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@
from cgis.core.models import Node, NodeNamespace
from cgis.core.paths import is_test_path
from cgis.core.project import ProjectReader
from cgis.languages.python import index
from cgis.core.resolution import ResolutionRules
from cgis.languages.python import index, resolution
from cgis.languages.python._syntax import PYTHON_LANG, file_path_to_module_fqn
from cgis.languages.python.project import PythonProjectReader

Expand Down Expand Up @@ -57,5 +58,10 @@ def classify(self, fqn: str, ctx: ClassifyContext) -> NodeNamespace:
"""See `cgis.languages.python.index.classify`."""
return index.classify(fqn, ctx)

@override
def resolution(self) -> ResolutionRules:
"""See `cgis.languages.python.resolution`."""
return resolution.RULES


PACK = PythonPack()
101 changes: 101 additions & 0 deletions src/cgis/languages/python/index.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import sys
from collections.abc import Iterable, Mapping
from dataclasses import dataclass
from types import MappingProxyType
from typing import cast

from cgis.core.index import ClassifyContext
Expand Down Expand Up @@ -141,6 +142,9 @@ class PythonIndexFacts:
dependency_roots: frozenset[str]
#: `root.second` heads our own nodes have under those roots.
dependency_heads: frozenset[str]
#: module_fqn -> {name: definer_fqn}: names a module forwards rather than defines,
#: from explicit re-exports and expanded star imports (#417).
reexports: Mapping[str, Mapping[str, str]]


def index_facts(nodes: Mapping[str, Node], state: Mapping[str, object]) -> PythonIndexFacts:
Expand All @@ -149,14 +153,21 @@ def index_facts(nodes: Mapping[str, Node], state: Mapping[str, object]) -> Pytho
local_roots: set[str] = set()
layout_roots: set[str] = set()
file_imports: dict[str, dict[str, str]] = {}
reexports: dict[str, dict[str, str]] = {}
star_imports: dict[str, list[str]] = {}
for node in nodes.values():
parts = node.id.split(".")
local_roots.add(parts[0])
if len(parts) > 1 and parts[0] in _LAYOUT_DIRS:
layout_roots.add(parts[1])
if node.type == NodeType.FILE:
file_imports[os.path.normpath(node.file_path)] = node.metadata.get("import_map") or {}
if node.metadata.get("reexports"):
reexports[node.id] = dict(node.metadata["reexports"])
if node.metadata.get("star_imports"):
star_imports[node.id] = list(node.metadata["star_imports"])
external, first_party, corroborated = _split_import_roots(file_imports, node_ids)
_expand_star_imports(star_imports, reexports, node_ids)
declared = set(cast("list[str]", state.get("python_dependencies", [])))
# Before first-party prefixes join: the set the old builder intersected (#495).
colliding = (local_roots | layout_roots) & declared
Expand All @@ -167,6 +178,7 @@ def index_facts(nodes: Mapping[str, Node], state: Mapping[str, object]) -> Pytho
external_roots=frozenset(external),
dependency_roots=frozenset(colliding),
dependency_heads=_heads_under(colliding, node_ids),
reexports=MappingProxyType(reexports),
)


Expand All @@ -185,3 +197,92 @@ def classify(fqn: str, ctx: ClassifyContext) -> NodeNamespace:
if root in facts.external_roots:
return NodeNamespace.EXTERNAL
return NodeNamespace.UNKNOWN


def _public_names_under(module_fqn: str, node_ids: set[str]) -> dict[str, str]:
"""The symbols `from <module_fqn> import *` would bring in directly.

Only names defined immediately under the module: a dotted remainder is a
symbol inside one of its children, and a leading underscore is private —
`import *` takes neither.

The module is tried verbatim and then with leading segments stripped. A
star import written absolutely (`from app.tests.fixtures import *`)
records the path as the author typed it, while node ids are
`tests.fixtures` when the project was ingested at app/ — the same prefix
mismatch that made the first version of this feature resolve nothing, and
it reaches the star sources as well as the map keys.
"""
parts = module_fqn.split(".")
for i in range(len(parts)):
prefix = ".".join(parts[i:]) + "."
names: dict[str, str] = {}
for node_id in node_ids:
if not node_id.startswith(prefix):
continue
name = node_id[len(prefix) :]
if "." not in name and not name.startswith("_"):
names.setdefault(name, node_id)
if names:
return names
return {}


def _close_over_sources(
by_module: dict[str, dict[str, str]],
star_imports: dict[str, list[str]],
reexports: dict[str, dict[str, str]],
) -> None:
"""Pull each source's own forwarded names in, repeatedly, until stable.

A star import of a package that itself re-exports needs those names too:
`app/__init__` starring `app.models`, which stars `app.models.rating`,
must reach Rating. Repeating terminates because names are only ever
added, never removed.
"""
changed = True
while changed:
changed = False
for module_fqn, sources in star_imports.items():
target = by_module[module_fqn]
for source in sources:
inherited = {**reexports.get(source, {}), **by_module.get(source, {})}
for name, definer in inherited.items():
if name not in target:
target[name] = definer
changed = True


def _expand_star_imports(
star_imports: dict[str, list[str]],
reexports: dict[str, dict[str, str]],
node_ids: set[str],
) -> None:
"""Turn `from X import *` into the names X actually exports.

The extractor cannot do this: it sees one file, and the names belong to
the *exporting* module. Here every node is known.

`__all__` is not read. Where a module has one, the difference is a module
deliberately hiding a public name — rare, and the extra entry is inert:
it only ever fires if some file actually imports that name.

An explicit re-export already recorded wins over a starred one: a module
that both stars a submodule and names a symbol explicitly meant the
explicit one.
"""
by_module = {
module_fqn: {
name: definer
for source in sources
for name, definer in _public_names_under(source, node_ids).items()
}
for module_fqn, sources in star_imports.items()
}
_close_over_sources(by_module, star_imports, reexports)
for module_fqn, names in by_module.items():
if not names:
continue
existing = reexports.setdefault(module_fqn, {})
for name, definer in names.items():
existing.setdefault(name, definer)
Loading
Loading