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
4 changes: 3 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,9 @@ IngestionPipeline (pipeline.py)
(`parse(code, file_path) -> tuple[list[Node], list[Edge]]`; emit `CALLS` edges with
`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`).
(`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
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
else names the pack: CLI, MCP server, guardian and scripts all go through the registry, and the
Expand Down
732 changes: 732 additions & 0 deletions docs/plans/2026-10-14-language-packs-step3.md

Large diffs are not rendered by default.

15 changes: 15 additions & 0 deletions docs/specs/2026-10-14-language-packs-design.md
Original file line number Diff line number Diff line change
Expand Up @@ -224,6 +224,21 @@ aliases. `import_names.py` becomes the collector that asks each pack.
and 294, and indices.py:234). The registry keeps that: with no source, the
Python pack classifies. Removing the quirk is a separate, measured change.

> **Implementation note (step 3):** `classify` returns a `NodeNamespace`, never
> `None`: today's order interleaves the languages (TS bare imports, internal
> roots with Python's declared-dependency rule, the JS root, Python stdlib and
> external roots), so each pack runs its whole chain (ruling C1). A source no
> pack reads gets the old generic tail in `SymbolIndex.classify_fqn`.
> `index_facts` takes the merged project state keyed by `ingest_state` key, not
> the pack's `ProjectFacts` object, since the resolver receives facts as values
> (C2). Resolution-only facts — `layout_prefixes`, star-import expansion,
> workspace packages, path aliases, bare bindings — stay on `SymbolIndex` until
> step 4 moves their consumers (C3). The outline's "`resolve_layout` gated by
> pack" was dropped: both run for every language today, and gating them changes
> TypeScript resolution (C4). One engine-to-pack edge remains, `_package_call`
> → the TS pack's `package_namespace`, allowlisted under the `engine-packs`
> rule for step 4 to empty (C5). The owner-api ingest moved +1.5% (median of three).

**Resolution.** The engine runs named steps in a fixed order. A pack
contributes to them; it does not get free hook points:

Expand Down
11 changes: 10 additions & 1 deletion src/cgis/core/extractor.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@
from pathlib import Path
from typing import Protocol, runtime_checkable

from cgis.core.models import Edge, Node
from cgis.core.index import ClassifyContext, IndexFacts
from cgis.core.models import Edge, Node, NodeNamespace
from cgis.core.project import ProjectReader


Expand Down Expand Up @@ -90,3 +91,11 @@ def extractor(self, extension: str, source_roots: list[str]) -> BaseExtractor:
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)."""
...

def index_facts(self, nodes: Mapping[str, Node], state: Mapping[str, object]) -> IndexFacts:
"""This pack's facts over every node, given the merged project state (step 3, C2)."""
...

def classify(self, fqn: str, ctx: ClassifyContext) -> NodeNamespace:
"""The namespace of `fqn` referenced from one of this pack's files: the whole chain (C1)."""
...
39 changes: 39 additions & 0 deletions src/cgis/core/index.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
"""What a language pack contributes to the symbol index and to classification.

Spec: Index and classification. The resolver keeps the language-free indices;
each pack derives what only its language's nodes and configs can say, and
classifies an FQN referenced from one of its files (language-packs step 3).
"""

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

from cgis.core.models import Node


class IndexFacts(Protocol):
"""One pack's facts over the whole node set; frozen."""

@property
def internal_roots(self) -> frozenset[str]:
"""Roots this pack adds to the language-free set (first segment of every node id)."""
...

@property
def layout_prefixes(self) -> frozenset[str]:
"""Prefixes sources write that node ids do not carry (#494): stripped off a target."""
...


@dataclass(frozen=True)
class ClassifyContext:
"""What a pack's `classify` may read about one reference."""

#: The file the reference comes from; None applies the Python reading (spec quirk).
source_file: str | None
#: Every internal root, all packs' contributions included.
internal_roots: frozenset[str]
nodes: Mapping[str, Node]
#: This pack's own facts.
facts: IndexFacts
57 changes: 57 additions & 0 deletions src/cgis/core/js_globals.py
Original file line number Diff line number Diff line change
Expand Up @@ -120,3 +120,60 @@
def specifier_fqn(specifier: str) -> str:
"""A bare module specifier dotted the way import targets are: `@x/a/b` -> `@x.a.b`."""
return specifier.replace("/", ".")


#: The synthetic root calls on a JS runtime global resolve into (#111).
JS_BUILTINS_ROOT = "js_builtins"

#: The `node:` scheme, which names a Node builtin and nothing else (#556).
NODE_SCHEME = "node:"

#: Node's builtin modules importable without the `node:` scheme — Node's own
#: `module.builtinModules`, minus internals and the scheme-only ones (`node:test`,
#: `node:sqlite`). Matched on the specifier's root, so `fs/promises` counts (#556).
NODE_BUILTIN_MODULES: frozenset[str] = frozenset(
{
"assert",
"async_hooks",
"buffer",
"child_process",
"cluster",
"console",
"constants",
"crypto",
"dgram",
"diagnostics_channel",
"dns",
"domain",
"events",
"fs",
"http",
"http2",
"https",
"inspector",
"module",
"net",
"os",
"path",
"perf_hooks",
"process",
"punycode",
"querystring",
"readline",
"repl",
"stream",
"string_decoder",
"sys",
"timers",
"tls",
"trace_events",
"tty",
"url",
"util",
"v8",
"vm",
"wasi",
"worker_threads",
"zlib",
}
)
20 changes: 19 additions & 1 deletion src/cgis/languages/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,11 @@
import re
from collections.abc import Callable, Iterator, Mapping
from dataclasses import dataclass
from functools import cache
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.project import ProjectReader

Expand Down Expand Up @@ -118,6 +119,23 @@ def project_readers(root: Path, extractors: Mapping[str, BaseExtractor]) -> list
return [_load(m.name).project_reader(root, extractors) for m in MANIFESTS]


def index_facts(nodes: Mapping[str, Node], state: Mapping[str, object]) -> dict[str, IndexFacts]:
"""Every pack's index facts, by pack name."""
return {m.name: _load(m.name).index_facts(nodes, state) for m in MANIFESTS}


@lru_cache(maxsize=4096)
def classifying_pack(source_file: str | None) -> LanguagePack | None:
"""The pack that classifies a reference from `source_file`; None when no pack reads it.

No source means the Python pack: `classify_fqn(fqn)` has always applied the
Python reading, and removing that is a separate, measured change (spec,
Index and classification). Cached: classification is on the resolver's hot
path, and a path's suffix never changes.
"""
return _load("python") if source_file is None else pack_for_path(source_file)


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
13 changes: 13 additions & 0 deletions src/cgis/languages/python/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,11 @@
from typing import override

from cgis.core.extractor import BaseExtractor, LanguagePack
from cgis.core.index import ClassifyContext, IndexFacts
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.languages.python._syntax import PYTHON_LANG, file_path_to_module_fqn
from cgis.languages.python.project import PythonProjectReader

Expand Down Expand Up @@ -44,5 +47,15 @@ def project_reader(self, root: Path, extractors: Mapping[str, BaseExtractor]) ->
"""This pack's config reader for the project at `root`."""
return PythonProjectReader(root)

@override
def index_facts(self, nodes: Mapping[str, Node], state: Mapping[str, object]) -> IndexFacts:
"""See `cgis.languages.python.index.index_facts`."""
return index.index_facts(nodes, state)

@override
def classify(self, fqn: str, ctx: ClassifyContext) -> NodeNamespace:
"""See `cgis.languages.python.index.classify`."""
return index.classify(fqn, ctx)


PACK = PythonPack()
Loading
Loading