From fb53c105222702f874598e8f45a1f4d51768bff1 Mon Sep 17 00:00:00 2001 From: zaebee Date: Sat, 17 Oct 2026 01:40:35 +0000 Subject: [PATCH 1/4] fix(resolver): an unresolved call never joins a real node (#661) An unresolved CALLS or EXTENDS edge targets its bare raw name. When that text equalled a real node's id (a Go local q shadowing package q, a Python parameter named like a module) the resolver minted no virtual node and the edge joined the real one, read as a resolved call: a false caller in impact, counted resolved by validate and coverage. Such a name now keeps the raw_call: prefix (no real id holds one) and gets its own UNKNOWN node named by the bare tail; coverage shows the bare name. Consumers already read the prefix as unresolved. Go builtins resolve to builtin. (STDLIB), as TS uses js_builtins., so a bare len is no longer shared by a resolved builtin call and an unresolved call to a local closure named len. Measured by the consumer map: 1 collision on owner-api, 11 on kafka-go (all accidentally correct; #680), 0 on cgis, raft, crypto, lima, client_golang, owner-web and both snapshots, which are unchanged. Docs: ONTOLOGY.md no longer describes the pre-#49 shape (closes #682). Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 2 +- docs/architecture/HOW_IT_WORKS.md | 2 +- docs/architecture/ONTOLOGY.md | 6 +- src/cgis/languages/go/_syntax.py | 3 + src/cgis/languages/go/index.py | 4 +- src/cgis/languages/go/resolution.py | 16 ++- src/cgis/query/engine.py | 12 +- src/cgis/resolver/engine.py | 16 ++- tests/unit/test_go_resolution.py | 9 +- tests/unit/test_unresolved_collision.py | 143 ++++++++++++++++++++++++ ui/src/utils/edgeMapper.ts | 6 +- 11 files changed, 197 insertions(+), 22 deletions(-) create mode 100644 tests/unit/test_unresolved_collision.py diff --git a/AGENTS.md b/AGENTS.md index a05f1e69..216e3492 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -59,7 +59,7 @@ IngestionPipeline (pipeline/__init__.py) **Node FQN format**: `module.ClassName.method_name` — fully dot-separated, derived from the file path (e.g., `src/cgis/pipeline/__init__.py` → `src.cgis.pipeline.IngestionPipeline.run`). Use `cgis.languages.scope_of(path, source_root)` to convert paths (it picks the file's own pack; the Python rule lives in `languages/python/extractor.py`). `__init__.py` strips the `/__init__` suffix. -**Raw call convention**: Extractors emit edges with `target = "raw_call:"`. The `ResolverEngine` then resolves these to actual FQNs, or leaves them unresolved: the target becomes the bare raw name (or a pack fallback such as `js_builtins.`), a virtual boundary node classified STDLIB, EXTERNAL or UNKNOWN (#661). +**Raw call convention**: Extractors emit edges with `target = "raw_call:"`. The `ResolverEngine` then resolves these to actual FQNs, or leaves them unresolved: the target becomes the bare raw name (or a pack fallback such as `js_builtins.`), a virtual boundary node classified STDLIB, EXTERNAL or UNKNOWN. A raw name equal to the id of a real node keeps the `raw_call:` prefix, so the edge never joins that node (#661). **`ResolverEngine` resolution order** for a call to `name`: 1. `self.method` → look up `method` on the enclosing class via `_class_methods` index diff --git a/docs/architecture/HOW_IT_WORKS.md b/docs/architecture/HOW_IT_WORKS.md index 176cb0bc..e67e283e 100644 --- a/docs/architecture/HOW_IT_WORKS.md +++ b/docs/architecture/HOW_IT_WORKS.md @@ -59,7 +59,7 @@ The resolver operates on the complete set of raw edges emitted by Phase 1. It bu 1. If `name` starts with `self.`, look up the method on the enclosing class via `_class_methods` 2. Look up `name` in `_global_symbols`, among the caller's own language only; if ambiguous, prefer the same-scope candidate (a file, or a Go package) via `_file_global_symbols` -3. A resolved call scores `confidence=1.0` in every language. If unresolved, the target becomes the bare `` (or a pack fallback such as `js_builtins.`), a virtual boundary node classified STDLIB, EXTERNAL or UNKNOWN, at `confidence=0.8` +3. A resolved call scores `confidence=1.0` in every language. If unresolved, the target becomes the bare `` (or a pack fallback such as `js_builtins.`, or Go's `builtin.`), a virtual boundary node classified STDLIB, EXTERNAL or UNKNOWN, at `confidence=0.8`. A name equal to the id of a real node keeps the `raw_call:` prefix instead, so the edge cannot join that node (#661) Unresolved calls are **first-class citizens** — they appear in the graph explicitly rather than being silently discarded. diff --git a/docs/architecture/ONTOLOGY.md b/docs/architecture/ONTOLOGY.md index 0f4692f6..7879836a 100644 --- a/docs/architecture/ONTOLOGY.md +++ b/docs/architecture/ONTOLOGY.md @@ -54,7 +54,7 @@ Produced by `ResolverEngine` after FQN resolution. These edges represent runtime | `CALLS` | caller → callee | A resolved function/method invocation | | `REFERENCES` | node → node | A non-call symbolic reference | -**Unresolved calls** keep `target = "raw_call:"` with `confidence = 0.1` and are stored explicitly so gaps in resolution are visible rather than hidden. +**Unresolved calls** are stored explicitly, so gaps in resolution are visible rather than hidden. The target becomes the bare `` (or a pack fallback such as `js_builtins.`, or Go's `builtin.`), a virtual boundary node classified STDLIB, EXTERNAL or UNKNOWN, at `confidence=0.8`. A name equal to the id of a real node keeps the `raw_call:` prefix instead, so the edge cannot join that node (#661). A resolved call scores 1.0 in every language (#674). --- @@ -104,10 +104,10 @@ Node Edge ├── id str — unique edge identifier (typically "source->target:type") ├── source str — FQN of origin node -├── target str — FQN of target node (or "raw_call:" if unresolved) +├── target str — FQN of target node; unresolved: a virtual boundary node (see above) ├── type EdgeType — L1/L2/L3 classification ├── weight float — traversal weight (default 1.0) -└── confidence float — resolution confidence; 0.1 for unresolved raw calls +└── confidence float — CALLS: 1.0 resolved, 0.8 unresolved; DISPATCHES 0.5 ``` --- diff --git a/src/cgis/languages/go/_syntax.py b/src/cgis/languages/go/_syntax.py index 5056cec6..63daeb86 100644 --- a/src/cgis/languages/go/_syntax.py +++ b/src/cgis/languages/go/_syntax.py @@ -57,6 +57,9 @@ } ) BUILTINS = _BUILTIN_FUNCTIONS | PREDECLARED +#: Go's pseudo-package for its predeclared identifiers: a resolved builtin call +#: targets `builtin.`, never the bare name (#661). +BUILTIN_PACKAGE = "builtin" _MAJOR_VERSION = re.compile(r"v\d+") #: Import names that bring a package in without a name to call it by. UNNAMED_IMPORTS = frozenset({"_", "."}) diff --git a/src/cgis/languages/go/index.py b/src/cgis/languages/go/index.py index 5c717514..2ec49f2b 100644 --- a/src/cgis/languages/go/index.py +++ b/src/cgis/languages/go/index.py @@ -17,7 +17,7 @@ from cgis.core.index import ClassifyContext from cgis.core.models import Node, NodeNamespace, NodeType -from cgis.languages.go._syntax import BUILTINS, PREDECLARED, dotted +from cgis.languages.go._syntax import BUILTIN_PACKAGE, BUILTINS, PREDECLARED, dotted from cgis.languages.go.naming import GO_LANG from cgis.languages.go.project import GO_MODULE_KEY @@ -140,7 +140,7 @@ def classify(fqn: str, ctx: ClassifyContext) -> NodeNamespace: facts = cast("GoIndexFacts", ctx.facts) if _under(fqn, facts.module) or any(_under(fqn, m) for m in facts.replaces): return NodeNamespace.INTERNAL - if fqn in BUILTINS: + if fqn in BUILTINS or fqn.removeprefix(f"{BUILTIN_PACKAGE}.") in BUILTINS: return NodeNamespace.STDLIB imported = facts.file_imports.get(ctx.source_file or "", {}) under = [d for d in imported if _under(fqn, d)] diff --git a/src/cgis/languages/go/resolution.py b/src/cgis/languages/go/resolution.py index 8ba681e7..7559d68f 100644 --- a/src/cgis/languages/go/resolution.py +++ b/src/cgis/languages/go/resolution.py @@ -11,7 +11,16 @@ from cgis.core.models import Node from cgis.core.resolution import AFTER_IMPORT_MAP, ResolutionRules, ResolutionView -from cgis.languages.go._syntax import ANY_OF, BUILTINS, CALL_RESULT, ELEM, FIELD, GLOBAL, TAKE +from cgis.languages.go._syntax import ( + ANY_OF, + BUILTIN_PACKAGE, + BUILTINS, + CALL_RESULT, + ELEM, + FIELD, + GLOBAL, + TAKE, +) from cgis.languages.go.dispatch import interface_dispatch from cgis.languages.go.index import GoIndexFacts from cgis.languages.go.naming import GO_LANG @@ -70,12 +79,15 @@ def builtin_call(name: str, file_path: str, view: ResolutionView) -> str | None: name shadows it, as in Go: left to the same-scope tiebreak. Checked for shadowing, it is a resolved call and scores 1.0, unlike a Python or TS builtin, which is matched by name alone and keeps the unresolved 0.8 (#674 review). + The target is qualified, `builtin.len`, after Go's own pseudo-package, as a TS + builtin is `js_builtins.`: a bare `len` would be shared with an unresolved + call to a local closure named `len`, and the two edges would merge (#661). """ if name not in BUILTINS: return None facts = view.facts.get(GO_LANG) scope = cast("GoIndexFacts", facts).file_scopes.get(file_path, "") if facts else "" - return None if f"{scope}.{name}" in view.nodes else name + return None if f"{scope}.{name}" in view.nodes else f"{BUILTIN_PACKAGE}.{name}" #: How deep one marker may expand: they nest only as far as the extractor bounds diff --git a/src/cgis/query/engine.py b/src/cgis/query/engine.py index 7b6c77ab..e3147ca7 100644 --- a/src/cgis/query/engine.py +++ b/src/cgis/query/engine.py @@ -101,9 +101,15 @@ def _admit( Screen = Callable[[list[str]], _Verdict] +def _shown(target: str) -> str: + """An unresolved target as a reader names it: without the `raw_call:` prefix a + name colliding with a real node keeps in storage (#661).""" + return target.removeprefix(RAW_CALL_PREFIX) + + def _call_sites(calls: list[Edge]) -> list[tuple[str, str, str | None, int | None]]: """(target, caller, file, line) of each call, as `unresolved_sites` takes them.""" - return [(e.target, e.source, e.file_path, e.line_number) for e in calls] + return [(_shown(e.target), e.source, e.file_path, e.line_number) for e in calls] def agent_screen( @@ -354,7 +360,7 @@ def get_impact_result( left_out = screen(callers).dropped examined = [e for e in walk.calls.values() if e.source not in left_out] hidden = [e for e in hidden_calls if e.source not in left_out] - top = rank_unresolved(Counter(e.target for e in hidden)) + top = rank_unresolved(Counter(_shown(e.target) for e in hidden)) coverage = TraversalCoverage( basis="unresolved_calls_by_name", calls_examined=len(examined) + len(hidden), @@ -428,7 +434,7 @@ def get_flow_result( known = {n.id: n for n in self.store.get_nodes([*walk.discovered, *call_targets])} nodes = [n for n in known.values() if n.id in walk.discovered] unresolved_calls = [e for e in walk.calls.values() if is_unresolved(e.target, known)] - top = rank_unresolved(Counter(e.target for e in unresolved_calls)) + top = rank_unresolved(Counter(_shown(e.target) for e in unresolved_calls)) coverage = TraversalCoverage( basis="unresolved_calls_made", calls_examined=len(call_targets), diff --git a/src/cgis/resolver/engine.py b/src/cgis/resolver/engine.py index 2b998d53..a730e33b 100644 --- a/src/cgis/resolver/engine.py +++ b/src/cgis/resolver/engine.py @@ -187,10 +187,17 @@ def _resolved_class_edge(self, edge: Edge) -> Edge: """ raw = edge.target.removeprefix(RAW_CLASS_PREFIX) resolved = self._resolver.resolve_class_ref(raw, edge.source, edge.file_path) - final_target = resolved or raw + final_target = resolved or self._unresolved_target(raw) confidence = 1.0 if resolved else 0.5 return edge.model_copy(update={"target": final_target, "confidence": confidence}) + def _unresolved_target(self, raw_name: str) -> str: + """What an unresolved reference targets: its bare raw name, unless that is + the id of a real node, which the edge would join and read as a resolved call + to (#661). Such a name keeps the `raw_call:` prefix, which no real id holds; + consumers already read that prefix as unresolved.""" + return f"{RAW_CALL_PREFIX}{raw_name}" if self._index.has_node(raw_name) else raw_name + def _resolved_call_edge(self, edge: Edge) -> Edge: """Resolve a raw_call: edge to its final call target FQN. @@ -218,7 +225,7 @@ def _resolved_call_edge(self, edge: Edge) -> Edge: hit = _fallback(self._index, raw_name, file_path) if hit is not None: return edge.model_copy(update={"target": hit[0], "confidence": hit[1]}) - final_target = new_target or raw_name + final_target = new_target or self._unresolved_target(raw_name) confidence = RESOLVED_CALL_CONFIDENCE if new_target else _UNRESOLVED_CONFIDENCE return edge.model_copy(update={"target": final_target, "confidence": confidence}) @@ -294,7 +301,8 @@ def _ensure_virtual_node(self, edge: Edge, virtual_nodes: dict[str, Node]) -> No language = source.language if source is not None else _pack_name(source_file) if existing is not None and _virtual_rank(existing) <= (False, language): return # nothing this caller can classify would outrank it - namespace = self._index.classify_fqn(target, source_file) + # A collision kept its `raw_call:` prefix (#661); the name itself is classified. + namespace = self._index.classify_fqn(target.removeprefix(RAW_CALL_PREFIX), source_file) if namespace is NodeNamespace.INTERNAL: # An internal-root FQN with no node is one of four things, measured on # owner-api's 681 such nodes (#459, and the review of #493): @@ -321,7 +329,7 @@ def _make_virtual_node(self, fqn: str, namespace: NodeNamespace, language: str) return Node( id=fqn, type=NodeType.FUNCTION, - name=fqn.rsplit(".", maxsplit=1)[-1], + name=fqn.removeprefix(RAW_CALL_PREFIX).rsplit(".", maxsplit=1)[-1], file_path=VIRTUAL_FILE_PATH, start_line=0, end_line=0, diff --git a/tests/unit/test_go_resolution.py b/tests/unit/test_go_resolution.py index 0604e30e..b4214def 100644 --- a/tests/unit/test_go_resolution.py +++ b/tests/unit/test_go_resolution.py @@ -164,12 +164,15 @@ def _resolve_with( def test_go_builtins_are_stdlib_and_a_package_function_shadows_one() -> None: - """I4: `len`/`append`/conversions are the language's; `max` here is the package's own.""" + """I4: `len`/`append`/conversions are the language's; `max` here is the package's own. + + A builtin is `builtin.`, Go's pseudo-package, never the bare name (#661).""" edges, nodes = _resolve_with({"internal/server/use.go": (".go", BUILTINS)}) calls = _calls_from(edges, "internal.server.Use") for builtin in ("len", "append", "make", "string", "rune"): - assert builtin in calls - assert nodes[builtin].namespace is NodeNamespace.STDLIB + assert f"builtin.{builtin}" in calls + assert builtin not in calls + assert nodes[f"builtin.{builtin}"].namespace is NodeNamespace.STDLIB assert "internal.server.max" in calls diff --git a/tests/unit/test_unresolved_collision.py b/tests/unit/test_unresolved_collision.py new file mode 100644 index 00000000..188eda66 --- /dev/null +++ b/tests/unit/test_unresolved_collision.py @@ -0,0 +1,143 @@ +"""#661: an unresolved call never joins a real node whose id equals its raw name. + +An unresolved CALLS edge targets its bare raw name, minted as a virtual boundary +node. When that text equals a real node's id (a Go local `q` shadowing package `q`, +a Python parameter named like a module), the edge used to join the real node and +read as a resolved call to it. +""" + +import json +import sqlite3 +from pathlib import Path + +from cgis.api.mcp_server import cgis_analyze_impact, cgis_ingest +from cgis.core.models import VIRTUAL_FILE_PATH, Edge, EdgeType, Node, NodeNamespace, NodeType +from cgis.resolver.engine import ResolverEngine +from cgis.storage.sqlite_store import SQLiteStore + + +def _ingest(root: Path) -> Path: + db = root / "graph.db" + cgis_ingest(str(root), str(db)) + return db + + +def _calls_from(db: Path, source: str) -> list[Edge]: + with SQLiteStore(str(db)) as store: + return [e for e in store.get_outgoing_edges(source) if e.type is EdgeType.CALLS] + + +def _go_module(root: Path, files: dict[str, str]) -> None: + (root / "go.mod").write_text("module example.com/m\n\ngo 1.22\n", encoding="utf-8") + for rel, text in files.items(): + (root / rel).parent.mkdir(parents=True, exist_ok=True) + (root / rel).write_text(text, encoding="utf-8") + + +def test_go_a_local_shadowing_a_package_does_not_call_into_it(tmp_path: Path) -> None: + _go_module( + tmp_path, + { + "q/q.go": "package q\n\nfunc Get() int { return 1 }\n", + "p/p.go": "package p\n\nfunc viaAny(q interface{ Get() int }) int { return q.Get() }\n", + }, + ) + db = _ingest(tmp_path) + (call,) = _calls_from(db, "p.viaAny") + assert call.target != "q.Get" + answer = json.loads(cgis_analyze_impact("q.Get", str(db), output_format="json")) + assert "p.viaAny" not in {n["fqn"] for n in answer["nodes"]} + # Still named as a possible caller, by its bare name: the gap is a place to read. + assert [name for name, _ in answer["coverage"]["top_unresolved"]] == ["q.Get"] + + +def test_go_a_local_closure_named_len_is_not_the_builtin(tmp_path: Path) -> None: + _go_module( + tmp_path, + { + "p/p.go": "package p\n\nfunc f(xs []int) int {\n\tlen := func(int) int { return 0 }\n" + "\treturn len(1)\n}\n\nfunc g(ys []int) int { return len(ys) }\n", + }, + ) + db = _ingest(tmp_path) + (closure,) = _calls_from(db, "p.f") + (builtin,) = _calls_from(db, "p.g") + assert builtin.target == "builtin.len" + assert builtin.confidence == 1.0 + assert closure.target != builtin.target + + +def test_python_a_parameter_named_like_a_module_does_not_call_the_module(tmp_path: Path) -> None: + # The owner-api shape: `vocabulary(1)` on a parameter landed on the FILE `vocabulary`. + (tmp_path / "vocabulary.py").write_text("WORDS = []\n", encoding="utf-8") + (tmp_path / "main.py").write_text( + "def f(vocabulary):\n return vocabulary(1)\n", encoding="utf-8" + ) + db = _ingest(tmp_path) + (call,) = _calls_from(db, "main.f") + assert call.target != "vocabulary" + with SQLiteStore(str(db)) as store: + node = store.get_node(call.target) + assert node is not None + assert node.namespace is NodeNamespace.UNKNOWN + assert node.name == "vocabulary" + + +def test_an_unresolved_class_reference_does_not_join_a_real_node() -> None: + nodes = [ + Node( + id="Widget", + type=NodeType.FILE, + name="Widget.py", + file_path="Widget.py", + start_line=1, + end_line=1, + language="python", + ), + Node( + id="pkg.Frame", + type=NodeType.CLASS, + name="Frame", + file_path="pkg/frame.x", + start_line=1, + end_line=2, + language="go", + ), + ] + edges = [ + Edge( + id="pkg/frame.x:extends:Widget", + source="pkg.Frame", + target="raw_class:Widget", + type=EdgeType.EXTENDS, + confidence=1.0, + ), + ] + resolved, virtual = ResolverEngine(nodes, edges).resolve() + (extends,) = [e for e in resolved if e.type is EdgeType.EXTENDS] + assert extends.target != "Widget" + assert extends.target in {v.id for v in virtual} + + +def test_no_unresolved_edge_joins_a_real_node(tmp_path: Path) -> None: + # The store-level invariant, over every shape above at once. + _go_module( + tmp_path, + { + "q/q.go": "package q\n\nfunc Get() int { return 1 }\n", + "p/p.go": "package p\n\nfunc viaAny(q interface{ Get() int }) int { return q.Get() }\n", + }, + ) + (tmp_path / "vocabulary.py").write_text("WORDS = []\n", encoding="utf-8") + (tmp_path / "main.py").write_text( + "def f(vocabulary):\n return vocabulary(1)\n", encoding="utf-8" + ) + db = _ingest(tmp_path) + with sqlite3.connect(db) as conn: + joined = conn.execute( + "SELECT e.source, e.target FROM edges e JOIN nodes n ON e.target = n.id" + " WHERE e.type IN ('CALLS', 'REFERENCES', 'EXTENDS') AND e.confidence < 1.0" + " AND n.file_path != ?", + (VIRTUAL_FILE_PATH,), + ).fetchall() + assert joined == [] diff --git a/ui/src/utils/edgeMapper.ts b/ui/src/utils/edgeMapper.ts index bbb00446..aaf64af5 100644 --- a/ui/src/utils/edgeMapper.ts +++ b/ui/src/utils/edgeMapper.ts @@ -9,9 +9,9 @@ for (const [type, stroke] of Object.entries(EDGE_COLORS)) { } FULL_EDGE_STYLES.DECLARES = { stroke: EDGE_COLORS.DECLARES, strokeWidth: 1, opacity: 0.6 }; -// confidence=1.0 → fully resolved call (thick) -// confidence=0.8 → resolved to external/builtin (medium) -// confidence<0.5 → unresolved raw_call (thin) +// confidence=1.0 → resolved call (thick) +// confidence=0.8 → unresolved call into a virtual STDLIB/EXTERNAL/UNKNOWN node (medium) +// confidence<0.7 → DISPATCHES (0.5) or an unresolved class reference (thin) function confidenceToWidth(confidence: number | undefined): number { if (confidence == null || confidence >= 0.9) return 2.2; if (confidence >= 0.7) return 1.2; From 1cd097d77ab661cd350b541097d513d9899cd3dc Mon Sep 17 00:00:00 2001 From: zaebee Date: Sat, 17 Oct 2026 01:52:49 +0000 Subject: [PATCH 2/4] fix(pipeline): a real node taking a once-virtual id re-resolves its callers Review of #684: - _cross_file_inputs_changed treated a stored virtual row with the new node's id as another file's row, so no rebuild ran and the stale bare edge joined the new node (full and incremental ingest disagreed). Virtual rows are no file's. - a bare Go builtin name is an unresolved call to a local; it classifies UNKNOWN, only builtin. is STDLIB. - cohesion no longer reconciles a raw_call: target to a file by suffix. - _unresolved_target is a module function (god-object gate); RAW_CALL_PREFIX lives once, in core.models. - docs: a Go builtin is resolved (1.0), not an unresolved fallback. Co-Authored-By: Claude Opus 5.5 --- docs/architecture/HOW_IT_WORKS.md | 2 +- docs/architecture/ONTOLOGY.md | 2 +- src/cgis/api/mcp_server.py | 4 ++-- src/cgis/cli.py | 12 +++++++++-- src/cgis/core/models.py | 3 +++ src/cgis/guardian/chunker.py | 4 ++-- src/cgis/languages/go/index.py | 7 +++++- src/cgis/languages/go/resolution.py | 2 ++ src/cgis/pipeline/__init__.py | 12 ++++++++--- src/cgis/query/analysis/cohesion.py | 4 +++- src/cgis/query/context/context_service.py | 4 ++-- src/cgis/query/engine.py | 4 ++-- src/cgis/resolver/engine.py | 22 ++++++++++--------- src/cgis/storage/sqlite_store.py | 2 +- tests/unit/test_cohesion.py | 15 +++++++++++++ tests/unit/test_query_coverage.py | 12 +++++++++-- tests/unit/test_unresolved_collision.py | 26 +++++++++++++++++++++++ 17 files changed, 107 insertions(+), 30 deletions(-) diff --git a/docs/architecture/HOW_IT_WORKS.md b/docs/architecture/HOW_IT_WORKS.md index e67e283e..70a26bf1 100644 --- a/docs/architecture/HOW_IT_WORKS.md +++ b/docs/architecture/HOW_IT_WORKS.md @@ -59,7 +59,7 @@ The resolver operates on the complete set of raw edges emitted by Phase 1. It bu 1. If `name` starts with `self.`, look up the method on the enclosing class via `_class_methods` 2. Look up `name` in `_global_symbols`, among the caller's own language only; if ambiguous, prefer the same-scope candidate (a file, or a Go package) via `_file_global_symbols` -3. A resolved call scores `confidence=1.0` in every language. If unresolved, the target becomes the bare `` (or a pack fallback such as `js_builtins.`, or Go's `builtin.`), a virtual boundary node classified STDLIB, EXTERNAL or UNKNOWN, at `confidence=0.8`. A name equal to the id of a real node keeps the `raw_call:` prefix instead, so the edge cannot join that node (#661) +3. A resolved call scores `confidence=1.0` in every language (a Go builtin resolves to `builtin.`), capped at 0.8 when its target is an UNKNOWN boundary node. If unresolved, the target becomes the bare `` (or a pack fallback such as `js_builtins.`), a virtual boundary node classified STDLIB, EXTERNAL or UNKNOWN, at `confidence=0.8`. A name equal to the id of a real node keeps the `raw_call:` prefix instead, so the edge cannot join that node (#661) Unresolved calls are **first-class citizens** — they appear in the graph explicitly rather than being silently discarded. diff --git a/docs/architecture/ONTOLOGY.md b/docs/architecture/ONTOLOGY.md index 7879836a..c251d299 100644 --- a/docs/architecture/ONTOLOGY.md +++ b/docs/architecture/ONTOLOGY.md @@ -54,7 +54,7 @@ Produced by `ResolverEngine` after FQN resolution. These edges represent runtime | `CALLS` | caller → callee | A resolved function/method invocation | | `REFERENCES` | node → node | A non-call symbolic reference | -**Unresolved calls** are stored explicitly, so gaps in resolution are visible rather than hidden. The target becomes the bare `` (or a pack fallback such as `js_builtins.`, or Go's `builtin.`), a virtual boundary node classified STDLIB, EXTERNAL or UNKNOWN, at `confidence=0.8`. A name equal to the id of a real node keeps the `raw_call:` prefix instead, so the edge cannot join that node (#661). A resolved call scores 1.0 in every language (#674). +**Unresolved calls** are stored explicitly, so gaps in resolution are visible rather than hidden. The target becomes the bare `` (or a pack fallback such as `js_builtins.`), a virtual boundary node classified STDLIB, EXTERNAL or UNKNOWN, at `confidence=0.8`. A name equal to the id of a real node keeps the `raw_call:` prefix instead, so the edge cannot join that node (#661). A resolved call scores 1.0 in every language (#674), capped at 0.8 when its target is an UNKNOWN boundary node (#493); a Go builtin resolves to `builtin.`. --- diff --git a/src/cgis/api/mcp_server.py b/src/cgis/api/mcp_server.py index 164072ee..0da8101e 100644 --- a/src/cgis/api/mcp_server.py +++ b/src/cgis/api/mcp_server.py @@ -20,7 +20,7 @@ from cgis.api.tool_allowlist import TOOLS_ENV, CgisServer from cgis.core.coverage import TraversalCoverage, UnresolvedSite from cgis.core.freshness import Freshness, FreshnessState -from cgis.core.models import Edge, EdgeType, Node, NodeType +from cgis.core.models import RAW_CALL_PREFIX, Edge, EdgeType, Node, NodeType from cgis.languages import extractors, impact_caveat from cgis.pipeline import IngestionPipeline from cgis.query.analysis.overview import DEFAULT_DEPTH, DEFAULT_LIMIT, build_overview @@ -48,7 +48,7 @@ from cgis.query.render.mermaid import MermaidCompiler from cgis.query.render.metrics import DuckDBAnalyzer from cgis.query.render.text import graph_to_text -from cgis.storage.sqlite_store import RAW_CALL_PREFIX, SQLiteStore +from cgis.storage.sqlite_store import SQLiteStore print("CGIS MCP Server starting…", file=sys.stderr) # stdout is the JSON-RPC channel; the SDK diverts fd 1 only during a stdio session (#619). diff --git a/src/cgis/cli.py b/src/cgis/cli.py index bedd2940..3eb887b6 100644 --- a/src/cgis/cli.py +++ b/src/cgis/cli.py @@ -35,7 +35,15 @@ from cgis import __app_name__, __version__, log_to_stderr from cgis.core.coverage import TraversalCoverage from cgis.core.freshness import FreshnessState -from cgis.core.models import VIRTUAL_FILE_PATH, Edge, EdgeType, Node, NodeNamespace, NodeType +from cgis.core.models import ( + RAW_CALL_PREFIX, + VIRTUAL_FILE_PATH, + Edge, + EdgeType, + Node, + NodeNamespace, + NodeType, +) from cgis.guardian.metrics import load_reviews, rate_review from cgis.languages import extractors, file_node_id, is_supported from cgis.pipeline import IngestionPipeline @@ -56,7 +64,7 @@ from cgis.query.render.graph_json import graph_to_json from cgis.query.render.mermaid import MermaidCompiler from cgis.query.render.metrics import ArchitectureReport, DuckDBAnalyzer -from cgis.storage.sqlite_store import RAW_CALL_PREFIX, SQLiteStore +from cgis.storage.sqlite_store import SQLiteStore _DEFAULT_DB = "graph.db" _DEFAULT_DB_HELP = "Path to the SQLite database" diff --git a/src/cgis/core/models.py b/src/cgis/core/models.py index 66d5ef21..8ad1496d 100644 --- a/src/cgis/core/models.py +++ b/src/cgis/core/models.py @@ -23,6 +23,9 @@ class NodeNamespace(StrEnum): # These mirror the raw_call: / raw_dep: prefixes used by extractors and the # resolver, but apply to self-method dispatch and class-inheritance edges. SELF_PREFIX = "self." +#: What an extractor puts before a call it could not resolve; after resolution, kept +#: only on a raw name that equals a real node's id, which no real id can (#661). +RAW_CALL_PREFIX = "raw_call:" RAW_CLASS_PREFIX = "raw_class:" diff --git a/src/cgis/guardian/chunker.py b/src/cgis/guardian/chunker.py index c82c3d35..38a694fb 100644 --- a/src/cgis/guardian/chunker.py +++ b/src/cgis/guardian/chunker.py @@ -8,12 +8,12 @@ import structlog from pydantic import BaseModel -from cgis.core.models import EdgeType +from cgis.core.models import RAW_CALL_PREFIX, EdgeType # Re-exported: the per-file split moved to the pure diff leaf next to the other # unified-diff parsers, since the skeptic pass needs it too (#246 plan T3). from cgis.guardian.diff_index import split_diff_by_file as split_diff_by_file # noqa: PLC0414 -from cgis.storage.sqlite_store import RAW_CALL_PREFIX, SQLiteStore +from cgis.storage.sqlite_store import SQLiteStore log = structlog.getLogger(__name__) diff --git a/src/cgis/languages/go/index.py b/src/cgis/languages/go/index.py index 2ec49f2b..ac6171c6 100644 --- a/src/cgis/languages/go/index.py +++ b/src/cgis/languages/go/index.py @@ -140,7 +140,12 @@ def classify(fqn: str, ctx: ClassifyContext) -> NodeNamespace: facts = cast("GoIndexFacts", ctx.facts) if _under(fqn, facts.module) or any(_under(fqn, m) for m in facts.replaces): return NodeNamespace.INTERNAL - if fqn in BUILTINS or fqn.removeprefix(f"{BUILTIN_PACKAGE}.") in BUILTINS: + if ( + fqn.startswith(f"{BUILTIN_PACKAGE}.") + and fqn.removeprefix(f"{BUILTIN_PACKAGE}.") in BUILTINS + ): + # Only the qualified form: a resolved builtin is `builtin.len` (#661). A bare + # `len` is an unresolved call, to a local named like a builtin, and stays UNKNOWN. return NodeNamespace.STDLIB imported = facts.file_imports.get(ctx.source_file or "", {}) under = [d for d in imported if _under(fqn, d)] diff --git a/src/cgis/languages/go/resolution.py b/src/cgis/languages/go/resolution.py index 7559d68f..f0ed95e2 100644 --- a/src/cgis/languages/go/resolution.py +++ b/src/cgis/languages/go/resolution.py @@ -82,6 +82,8 @@ def builtin_call(name: str, file_path: str, view: ResolutionView) -> str | None: The target is qualified, `builtin.len`, after Go's own pseudo-package, as a TS builtin is `js_builtins.`: a bare `len` would be shared with an unresolved call to a local closure named `len`, and the two edges would merge (#661). + A module with a real top-level package `builtin` (GOROOT's own stubs) would see + these calls join it; accepted, as TS accepts it for `js_builtins`. """ if name not in BUILTINS: return None diff --git a/src/cgis/pipeline/__init__.py b/src/cgis/pipeline/__init__.py index c4ad56f4..f02942dd 100644 --- a/src/cgis/pipeline/__init__.py +++ b/src/cgis/pipeline/__init__.py @@ -13,7 +13,7 @@ from cgis.core.extractor import BaseExtractor from cgis.core.generated import is_generated_source -from cgis.core.models import Edge, EdgeType, Node, NodeType +from cgis.core.models import VIRTUAL_FILE_PATH, Edge, EdgeType, Node, NodeType from cgis.core.paths import EXCLUDED_DIRS from cgis.import_names import ImportNameCollector from cgis.languages import cross_file_metadata, skips @@ -445,8 +445,14 @@ def _cross_file_inputs_changed( old = _resolution_signature(old_nodes, store.get_outgoing_edges_batch(old_ids)) fresh = new_by_file.get(file_path, {}) # An id another file's row holds (`m.py` beside `m/__init__.py`) was - # never this file's in the store, so it cannot be in `old` either. - owner = {n.id: n.file_path for n in store.get_nodes(list(fresh))} + # never this file's in the store, so it cannot be in `old` either. A + # virtual boundary row is no file's: a real node taking a once-virtual + # id is new, and unresolved edges that named it must re-resolve (#661). + owner = { + n.id: n.file_path + for n in store.get_nodes(list(fresh)) + if n.file_path != VIRTUAL_FILE_PATH + } new_nodes = [n for i, n in fresh.items() if owner.get(i, file_path) == file_path] new_edges = [e for n in new_nodes for e in new_extends.get(n.id, {}).values()] if _resolution_signature(new_nodes, new_edges) != old: diff --git a/src/cgis/query/analysis/cohesion.py b/src/cgis/query/analysis/cohesion.py index ff811a27..fe4184ec 100644 --- a/src/cgis/query/analysis/cohesion.py +++ b/src/cgis/query/analysis/cohesion.py @@ -12,7 +12,7 @@ from dataclasses import dataclass from typing import TYPE_CHECKING, Literal -from cgis.core.models import Edge, EdgeType, Node, NodeType +from cgis.core.models import RAW_CALL_PREFIX, Edge, EdgeType, Node, NodeType if TYPE_CHECKING: from collections.abc import Hashable, Mapping @@ -67,6 +67,8 @@ def _resolve_to_file(node_id: str, file_set: set[str], suffix_index: dict[str, s """ if node_id in file_set: return node_id + if node_id.startswith(RAW_CALL_PREFIX): + return None # an unresolved call whose name collided with a real id (#661) parts = node_id.split(".") for i in range(len(parts)): hit = suffix_index.get(".".join(parts[i:])) diff --git a/src/cgis/query/context/context_service.py b/src/cgis/query/context/context_service.py index 3d7cd9af..a447d883 100644 --- a/src/cgis/query/context/context_service.py +++ b/src/cgis/query/context/context_service.py @@ -11,11 +11,11 @@ enclosing class is recovered separately from the structural layer. """ -from cgis.core.models import EdgeType, Node, NodeNamespace, NodeType +from cgis.core.models import RAW_CALL_PREFIX, EdgeType, Node, NodeNamespace, NodeType from cgis.query.context.prompt import compile_context from cgis.query.context.snippet import extract_snippet, resolve_source_path from cgis.query.engine import QueryEngine, is_unresolved -from cgis.storage.sqlite_store import RAW_CALL_PREFIX, SQLiteStore +from cgis.storage.sqlite_store import SQLiteStore _CALLS: frozenset[EdgeType] = frozenset({EdgeType.CALLS}) _STRUCTURAL: frozenset[EdgeType] = frozenset({EdgeType.CONTAINS, EdgeType.DECLARES}) diff --git a/src/cgis/query/engine.py b/src/cgis/query/engine.py index e3147ca7..f07698c4 100644 --- a/src/cgis/query/engine.py +++ b/src/cgis/query/engine.py @@ -5,8 +5,8 @@ from typing import NamedTuple from cgis.core.coverage import TraversalCoverage, rank_unresolved, unresolved_sites -from cgis.core.models import Edge, EdgeType, Node, NodeNamespace, NodeType -from cgis.storage.sqlite_store import RAW_CALL_PREFIX, SQLiteStore +from cgis.core.models import RAW_CALL_PREFIX, Edge, EdgeType, Node, NodeNamespace, NodeType +from cgis.storage.sqlite_store import SQLiteStore STRUCTURAL_EDGE_TYPES: frozenset[EdgeType] = frozenset({EdgeType.CONTAINS, EdgeType.DECLARES}) BEHAVIORAL_EDGE_TYPES: frozenset[EdgeType] = frozenset( diff --git a/src/cgis/resolver/engine.py b/src/cgis/resolver/engine.py index a730e33b..38599e12 100644 --- a/src/cgis/resolver/engine.py +++ b/src/cgis/resolver/engine.py @@ -4,6 +4,7 @@ from cgis.core.models import ( DEFAULT_LANGUAGE, + RAW_CALL_PREFIX, RAW_CLASS_PREFIX, SELF_PREFIX, VIRTUAL_FILE_PATH, @@ -15,9 +16,9 @@ ) from cgis.core.resolution import ResolutionView from cgis.languages import pack_for_path, rules_for +from cgis.resolver.indices import SymbolIndex from cgis.resolver.symbols import SymbolResolver, dispatch_edges -RAW_CALL_PREFIX = "raw_call:" RAW_DEP_PREFIX = "raw_dep:" RAW_IMPORT_PREFIX = "raw_import:" @@ -29,6 +30,14 @@ RESOLVED_CALL_CONFIDENCE = 1.0 +def _unresolved_target(index: SymbolIndex, raw_name: str) -> str: + """What an unresolved call or class reference targets: its bare raw name, unless + that is the id of a real node, which the edge would join and read as resolved + (#661). Such a name takes the `raw_call:` prefix, which no real id holds and + consumers already read as unresolved; a class reference gives up `raw_class:`.""" + return f"{RAW_CALL_PREFIX}{raw_name}" if index.has_node(raw_name) else raw_name + + def _virtual_rank(node: Node) -> tuple[bool, str]: """Which of two readings of one virtual id wins: a known namespace, then the language that sorts first — never the order edges happen to arrive in.""" @@ -187,17 +196,10 @@ def _resolved_class_edge(self, edge: Edge) -> Edge: """ raw = edge.target.removeprefix(RAW_CLASS_PREFIX) resolved = self._resolver.resolve_class_ref(raw, edge.source, edge.file_path) - final_target = resolved or self._unresolved_target(raw) + final_target = resolved or _unresolved_target(self._index, raw) confidence = 1.0 if resolved else 0.5 return edge.model_copy(update={"target": final_target, "confidence": confidence}) - def _unresolved_target(self, raw_name: str) -> str: - """What an unresolved reference targets: its bare raw name, unless that is - the id of a real node, which the edge would join and read as a resolved call - to (#661). Such a name keeps the `raw_call:` prefix, which no real id holds; - consumers already read that prefix as unresolved.""" - return f"{RAW_CALL_PREFIX}{raw_name}" if self._index.has_node(raw_name) else raw_name - def _resolved_call_edge(self, edge: Edge) -> Edge: """Resolve a raw_call: edge to its final call target FQN. @@ -225,7 +227,7 @@ def _resolved_call_edge(self, edge: Edge) -> Edge: hit = _fallback(self._index, raw_name, file_path) if hit is not None: return edge.model_copy(update={"target": hit[0], "confidence": hit[1]}) - final_target = new_target or self._unresolved_target(raw_name) + final_target = new_target or _unresolved_target(self._index, raw_name) confidence = RESOLVED_CALL_CONFIDENCE if new_target else _UNRESOLVED_CONFIDENCE return edge.model_copy(update={"target": final_target, "confidence": confidence}) diff --git a/src/cgis/storage/sqlite_store.py b/src/cgis/storage/sqlite_store.py index a317c866..9881d8fb 100644 --- a/src/cgis/storage/sqlite_store.py +++ b/src/cgis/storage/sqlite_store.py @@ -13,6 +13,7 @@ from cgis.core.freshness import Freshness, FreshnessState from cgis.core.models import ( + RAW_CALL_PREFIX, VIRTUAL_FILE_PATH, Edge, EdgeType, @@ -48,7 +49,6 @@ } ) -RAW_CALL_PREFIX = "raw_call:" _DELETE_ALL_NODES = "DELETE FROM nodes" _DELETE_ALL_EDGES = "DELETE FROM edges" diff --git a/tests/unit/test_cohesion.py b/tests/unit/test_cohesion.py index b0da3347..40774911 100644 --- a/tests/unit/test_cohesion.py +++ b/tests/unit/test_cohesion.py @@ -80,6 +80,21 @@ def test_build_file_graph_with_calls_adds_calls_layer() -> None: assert g_imports_only.adj == {} +def test_build_file_graph_ignores_an_unresolved_call_that_collided_with_a_file() -> None: + # #684 review: `raw_call:p.b` is an unresolved call whose name equals a file id (#661); + # the suffix match reconciled it to that file and counted a call edge. + nodes = [make_file_node("p.a", "p/a.py"), make_file_node("p.b", "p/b.py")] + call = Edge( + id="p.a:raw", + source="p.a", + target="raw_call:p.b", + type=EdgeType.CALLS, + weight=1.0, + confidence=0.8, + ) + assert build_file_graph(nodes, [call], prefix="p", with_calls=True).adj == {} + + def _clique(prefix: str, names: list[str]) -> dict[str, dict[str, float]]: adj: dict[str, dict[str, float]] = {} ids = [f"{prefix}.{n}" for n in names] diff --git a/tests/unit/test_query_coverage.py b/tests/unit/test_query_coverage.py index ee494a63..c170cc46 100644 --- a/tests/unit/test_query_coverage.py +++ b/tests/unit/test_query_coverage.py @@ -19,9 +19,17 @@ import pytest -from cgis.core.models import VIRTUAL_FILE_PATH, Edge, EdgeType, Node, NodeNamespace, NodeType +from cgis.core.models import ( + RAW_CALL_PREFIX, + VIRTUAL_FILE_PATH, + Edge, + EdgeType, + Node, + NodeNamespace, + NodeType, +) from cgis.query.engine import BEHAVIORAL_EDGE_TYPES, QueryEngine -from cgis.storage.sqlite_store import RAW_CALL_PREFIX, SQLiteStore +from cgis.storage.sqlite_store import SQLiteStore @pytest.fixture diff --git a/tests/unit/test_unresolved_collision.py b/tests/unit/test_unresolved_collision.py index 188eda66..fcd8d5d6 100644 --- a/tests/unit/test_unresolved_collision.py +++ b/tests/unit/test_unresolved_collision.py @@ -65,6 +65,10 @@ def test_go_a_local_closure_named_len_is_not_the_builtin(tmp_path: Path) -> None assert builtin.target == "builtin.len" assert builtin.confidence == 1.0 assert closure.target != builtin.target + with SQLiteStore(str(db)) as store: + closure_node = store.get_node(closure.target) + assert closure_node is not None + assert closure_node.namespace is NodeNamespace.UNKNOWN # unresolved, not the builtin def test_python_a_parameter_named_like_a_module_does_not_call_the_module(tmp_path: Path) -> None: @@ -141,3 +145,25 @@ def test_no_unresolved_edge_joins_a_real_node(tmp_path: Path) -> None: (VIRTUAL_FILE_PATH,), ).fetchall() assert joined == [] + + +def test_an_incremental_run_detaches_an_edge_when_its_target_becomes_a_real_node( + tmp_path: Path, +) -> None: + # #684 review: a stored virtual `q.Get` made the new real `q.Get` look like another + # file's row, so no rebuild ran and the stale bare edge joined the new node. + _go_module( + tmp_path, + { + "q/q.go": "package q\n\nfunc Other() int { return 1 }\n", + "p/p.go": "package p\n\nfunc viaAny(q interface{ Get() int }) int { return q.Get() }\n", + }, + ) + db = _ingest(tmp_path) + (tmp_path / "q" / "q.go").write_text( + "package q\n\nfunc Other() int { return 1 }\n\nfunc Get() int { return 2 }\n", + encoding="utf-8", + ) + cgis_ingest(str(tmp_path), str(db)) + (call,) = _calls_from(db, "p.viaAny") + assert call.target != "q.Get" From 5b542df1413a0780fe5755573c81ca6abc9cb302 Mon Sep 17 00:00:00 2001 From: zaebee Date: Sat, 17 Oct 2026 01:58:12 +0000 Subject: [PATCH 3/4] fix(resolver): no new intra-domain import for the collision check The SymbolIndex import added in the review fix changed the resolver domain's call triads, and self-drift scored 0.58 against pipeline_stage (tolerance 0.40). _unresolved_target takes the ResolutionView engine.py already imports. Co-Authored-By: Claude Opus 5.5 --- src/cgis/resolver/engine.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/cgis/resolver/engine.py b/src/cgis/resolver/engine.py index 38599e12..637cda5b 100644 --- a/src/cgis/resolver/engine.py +++ b/src/cgis/resolver/engine.py @@ -16,7 +16,6 @@ ) from cgis.core.resolution import ResolutionView from cgis.languages import pack_for_path, rules_for -from cgis.resolver.indices import SymbolIndex from cgis.resolver.symbols import SymbolResolver, dispatch_edges RAW_DEP_PREFIX = "raw_dep:" @@ -30,12 +29,12 @@ RESOLVED_CALL_CONFIDENCE = 1.0 -def _unresolved_target(index: SymbolIndex, raw_name: str) -> str: +def _unresolved_target(view: ResolutionView, raw_name: str) -> str: """What an unresolved call or class reference targets: its bare raw name, unless that is the id of a real node, which the edge would join and read as resolved (#661). Such a name takes the `raw_call:` prefix, which no real id holds and consumers already read as unresolved; a class reference gives up `raw_class:`.""" - return f"{RAW_CALL_PREFIX}{raw_name}" if index.has_node(raw_name) else raw_name + return f"{RAW_CALL_PREFIX}{raw_name}" if raw_name in view.nodes else raw_name def _virtual_rank(node: Node) -> tuple[bool, str]: From 84f8c2726fa39957bd3e690613a4ac4d73d7be9b Mon Sep 17 00:00:00 2001 From: zaebee Date: Sat, 17 Oct 2026 01:59:31 +0000 Subject: [PATCH 4/4] fix(go): a local closure named like a predeclared type is unresolved Delta review of #684: the predeclared-type head check (meant for methods such as error.Error) also caught a bare string(x) on a local closure and called it STDLIB. It now needs a dot. Co-Authored-By: Claude Opus 5.5 --- src/cgis/core/models.py | 5 +++-- src/cgis/languages/go/index.py | 2 +- tests/unit/test_unresolved_collision.py | 18 ++++++++++++++++++ 3 files changed, 22 insertions(+), 3 deletions(-) diff --git a/src/cgis/core/models.py b/src/cgis/core/models.py index 8ad1496d..cd502ca8 100644 --- a/src/cgis/core/models.py +++ b/src/cgis/core/models.py @@ -23,8 +23,9 @@ class NodeNamespace(StrEnum): # These mirror the raw_call: / raw_dep: prefixes used by extractors and the # resolver, but apply to self-method dispatch and class-inheritance edges. SELF_PREFIX = "self." -#: What an extractor puts before a call it could not resolve; after resolution, kept -#: only on a raw name that equals a real node's id, which no real id can (#661). +#: What an extractor puts before every call's raw name, for the resolver to replace. +#: After resolution it stays only on an unresolved name that equals a real node's +#: id, which no real id can hold (#661). RAW_CALL_PREFIX = "raw_call:" RAW_CLASS_PREFIX = "raw_class:" diff --git a/src/cgis/languages/go/index.py b/src/cgis/languages/go/index.py index ac6171c6..a3faa965 100644 --- a/src/cgis/languages/go/index.py +++ b/src/cgis/languages/go/index.py @@ -151,7 +151,7 @@ def classify(fqn: str, ctx: ClassifyContext) -> NodeNamespace: under = [d for d in imported if _under(fqn, d)] if under: return import_namespace(imported[max(under, key=len)], facts) - if fqn.split(".", maxsplit=1)[0] in PREDECLARED: + if "." in fqn and fqn.split(".", maxsplit=1)[0] in PREDECLARED: return NodeNamespace.STDLIB # a method of a predeclared type: `error.Error` (#608) if fqn.split(".", maxsplit=1)[0] in ctx.internal_roots: return NodeNamespace.INTERNAL diff --git a/tests/unit/test_unresolved_collision.py b/tests/unit/test_unresolved_collision.py index fcd8d5d6..8044a8da 100644 --- a/tests/unit/test_unresolved_collision.py +++ b/tests/unit/test_unresolved_collision.py @@ -167,3 +167,21 @@ def test_an_incremental_run_detaches_an_edge_when_its_target_becomes_a_real_node cgis_ingest(str(tmp_path), str(db)) (call,) = _calls_from(db, "p.viaAny") assert call.target != "q.Get" + + +def test_go_a_local_closure_named_like_a_predeclared_type_is_unresolved(tmp_path: Path) -> None: + # #684 delta review: `string := func…; string(x)` classified STDLIB through the + # predeclared-type head check, which is meant for methods like `error.Error`. + _go_module( + tmp_path, + { + "p/p.go": "package p\n\nfunc f(x int) int {\n\tstring := func(int) int { return 0 }\n" + "\treturn string(x)\n}\n", + }, + ) + db = _ingest(tmp_path) + (call,) = _calls_from(db, "p.f") + with SQLiteStore(str(db)) as store: + node = store.get_node(call.target) + assert node is not None + assert node.namespace is NodeNamespace.UNKNOWN