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..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.`), a virtual boundary node classified STDLIB, EXTERNAL or UNKNOWN, at `confidence=0.8` +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 0f4692f6..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** 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.`), 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.`. --- @@ -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/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..cd502ca8 100644 --- a/src/cgis/core/models.py +++ b/src/cgis/core/models.py @@ -23,6 +23,10 @@ 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 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/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/_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..a3faa965 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,13 +140,18 @@ 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.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)] 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/src/cgis/languages/go/resolution.py b/src/cgis/languages/go/resolution.py index 8ba681e7..f0ed95e2 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,17 @@ 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). + 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 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/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 7b6c77ab..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( @@ -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..637cda5b 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, @@ -17,7 +18,6 @@ from cgis.languages import pack_for_path, rules_for 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 +29,14 @@ RESOLVED_CALL_CONFIDENCE = 1.0 +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 raw_name in view.nodes 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,7 +195,7 @@ 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 _unresolved_target(self._index, raw) confidence = 1.0 if resolved else 0.5 return edge.model_copy(update={"target": final_target, "confidence": confidence}) @@ -218,7 +226,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 _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}) @@ -294,7 +302,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 +330,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/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_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_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 new file mode 100644 index 00000000..8044a8da --- /dev/null +++ b/tests/unit/test_unresolved_collision.py @@ -0,0 +1,187 @@ +"""#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 + 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: + # 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 == [] + + +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" + + +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 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;