diff --git a/graphify/cache.py b/graphify/cache.py index c00eaa557..02782f6ff 100644 --- a/graphify/cache.py +++ b/graphify/cache.py @@ -30,6 +30,13 @@ except Exception: _EXTRACTOR_VERSION = "unknown" +# Local extractor-logic salt: the package version alone does not change when the +# extractor source is edited in place (forks/dev), so AST cache entries would be +# served stale. Bump this whenever extractor output changes (e.g. the GraphQL SDL +# @key/federation work, or the gql call-site extraction) to invalidate the AST +# cache namespace. +_EXTRACTOR_VERSION = f"{_EXTRACTOR_VERSION}+sdlfed1+gqlcalls1" + # Version dirs already swept this process — cleanup runs once per (base, version). _cleaned_ast_dirs: set[str] = set() diff --git a/graphify/detect.py b/graphify/detect.py index e898e5358..2f7ef7e22 100644 --- a/graphify/detect.py +++ b/graphify/detect.py @@ -27,7 +27,7 @@ class FileType(str, Enum): _MANIFEST_PATH = str(out_path("manifest.json")) -CODE_EXTENSIONS = {'.py', '.ts', '.tsx', '.js', '.jsx', '.mjs', '.ejs', '.ets', '.go', '.rs', '.java', '.groovy', '.gradle', '.cpp', '.cc', '.cxx', '.c', '.h', '.hpp', '.cu', '.cuh', '.rb', '.swift', '.kt', '.kts', '.cs', '.scala', '.php', '.lua', '.luau', '.toc', '.zig', '.ps1', '.psm1', '.psd1', '.ex', '.exs', '.m', '.mm', '.jl', '.vue', '.svelte', '.astro', '.dart', '.v', '.sv', '.svh', '.sql', '.r', '.f', '.F', '.f90', '.F90', '.f95', '.F95', '.f03', '.F03', '.f08', '.F08', '.pas', '.pp', '.dpr', '.dpk', '.lpr', '.inc', '.dfm', '.lfm', '.lpk', '.sh', '.bash', '.json', '.tf', '.tfvars', '.hcl', '.dm', '.dme', '.dmi', '.dmm', '.dmf', '.sln', '.slnx', '.csproj', '.fsproj', '.vbproj', '.razor', '.cshtml', '.cls', '.trigger'} +CODE_EXTENSIONS = {'.py', '.ts', '.tsx', '.js', '.jsx', '.mjs', '.ejs', '.ets', '.go', '.rs', '.java', '.groovy', '.gradle', '.cpp', '.cc', '.cxx', '.c', '.h', '.hpp', '.cu', '.cuh', '.rb', '.swift', '.kt', '.kts', '.cs', '.scala', '.php', '.lua', '.luau', '.toc', '.zig', '.ps1', '.psm1', '.psd1', '.ex', '.exs', '.m', '.mm', '.jl', '.vue', '.svelte', '.astro', '.dart', '.v', '.sv', '.svh', '.sql', '.r', '.f', '.F', '.f90', '.F90', '.f95', '.F95', '.f03', '.F03', '.f08', '.F08', '.pas', '.pp', '.dpr', '.dpk', '.lpr', '.inc', '.dfm', '.lfm', '.lpk', '.sh', '.bash', '.json', '.tf', '.tfvars', '.hcl', '.dm', '.dme', '.dmi', '.dmm', '.dmf', '.sln', '.slnx', '.csproj', '.fsproj', '.vbproj', '.razor', '.cshtml', '.cls', '.trigger', '.graphqls', '.graphql'} DOC_EXTENSIONS = {'.md', '.mdx', '.qmd', '.txt', '.rst', '.html', '.yaml', '.yml'} PAPER_EXTENSIONS = {'.pdf'} IMAGE_EXTENSIONS = {'.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg'} diff --git a/graphify/extract.py b/graphify/extract.py index 968c0c508..867a412e9 100644 --- a/graphify/extract.py +++ b/graphify/extract.py @@ -7651,6 +7651,11 @@ def _disambiguate_colliding_node_ids( for node in nodes: if node.get("type") == "module": continue + # GraphQL SDL nodes use name-keyed ids (gql_) deliberately: the same + # type declared in a repo's private AND public schema is the SAME federated + # entity, so it must collapse to one node, not split per file (#dup-entity). + if str(node.get("type", "")).startswith("gql"): + continue nid = node.get("id") if isinstance(nid, str) and nid: by_id.setdefault(nid, []).append(node) @@ -12566,6 +12571,11 @@ def _body_of(block): } +# Code suffixes that can embed GraphQL operation call sites in string literals +# (gql`...` tagged templates in TS/JS, graphql:"..." struct tags in Go). +_GQL_CALL_SUFFIXES = {".go", ".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".mts", ".cts"} + + def _get_extractor(path: Path) -> Any | None: """Return the correct extractor function for a file, or None if unsupported.""" if path.name.endswith(".blade.php"): @@ -12580,7 +12590,15 @@ def _get_extractor(path: Path) -> Any | None: # (#1377). apm.yml would otherwise be a .yml document handled by the LLM. if is_package_manifest_path(path): return extract_package_manifest - return _DISPATCH.get(path.suffix) + if path.suffix in (".graphqls", ".graphql"): + from graphify.graphql_sdl import extract_graphql_sdl + return extract_graphql_sdl + base = _DISPATCH.get(path.suffix) + # TS/JS/Go can embed GraphQL operation call sites in string literals; fold + # their extraction into the per-file result so it caches like the AST nodes. + if base is not None and path.suffix in _GQL_CALL_SUFFIXES: + return _compose_with_gql_calls(base) + return base def _extract_single_file(args: tuple) -> tuple[int, dict]: @@ -12746,6 +12764,191 @@ def _extract_sequential( _PARALLEL_THRESHOLD = 20 +def _consolidate_gql_duplicates(all_nodes: list[dict]) -> int: + """Collapse same-id GraphQL SDL nodes (a type declared in both the private and + public schema of one repo) into a single node. An ``@key`` entity marking and + its key fields win over a plain ``gql_type`` so the federation owner is never + lost to merge order. Mutates ``all_nodes`` in place; returns nodes removed. + """ + first: dict[str, dict] = {} + dropped = 0 + keep: list[dict] = [] + for n in all_nodes: + if not str(n.get("type", "")).startswith("gql"): + keep.append(n) + continue + nid = n.get("id") + prev = first.get(nid) + if prev is None: + first[nid] = n + keep.append(n) + continue + # merge into the already-kept node, preferring the entity marking + dropped += 1 + if n.get("type") == "gql_entity": + if prev.get("type") != "gql_entity": + prev["type"] = "gql_entity" + if n.get("federation") == "entity" or prev.get("federation") is None: + prev["federation"] = n.get("federation", prev.get("federation")) + keys = sorted(set(prev.get("key_fields", []) or []) | set(n.get("key_fields", []) or [])) + if keys: + prev["key_fields"] = keys + if dropped: + all_nodes[:] = keep + return dropped + + +def _anchor_gql_calls(base_nodes: list[dict], call_nodes: list[dict]) -> list[dict]: + """Anchor each ``gql_call`` site to the nearest code symbol defined above it + in the same file (so the node isn't an island within its repo). Returns + ``references`` edges from that enclosing symbol to the call node. + """ + anchors: list[tuple[int, str]] = [] + for n in base_nodes: + if n.get("file_type") != "code" or str(n.get("type", "")).startswith("gql"): + continue + loc = str(n.get("source_location") or "") + if not loc.startswith("L"): + continue + try: + anchors.append((int(loc[1:].split("-")[0]), n["id"])) + except ValueError: + continue + anchors.sort() + edges: list[dict] = [] + for cn in call_nodes: + try: + ln = int(str(cn.get("source_location"))[1:]) + except (ValueError, TypeError): + continue + prev_id = None + for a_line, a_id in anchors: + if a_line <= ln: + prev_id = a_id + else: + break + if prev_id and prev_id != cn["id"]: + edges.append({ + "source": prev_id, + "target": cn["id"], + "relation": "references", + "confidence": "EXTRACTED", + "confidence_score": 1.0, + "source_file": cn.get("source_file"), + "source_location": cn.get("source_location"), + "weight": 1.0, + }) + return edges + + +def _compose_with_gql_calls(base_extractor): + """Wrap a code extractor so each file's result also carries the GraphQL + operation *call sites* it embeds (``gql`...` `` / ``graphql:"..."`` literals, + which tree-sitter sees as opaque text). Folded into the per-file result so it + is cached and incremental like the AST nodes. No-op for files without a + GraphQL literal. + """ + from graphify.graphql_calls import extract_gql_calls + + def _composed(path: Path) -> dict: + result = base_extractor(path) + if "error" in result: + return result + call_nodes = extract_gql_calls(path).get("nodes", []) + if call_nodes: + base_nodes = result.get("nodes", []) + result["edges"] = result.get("edges", []) + _anchor_gql_calls(base_nodes, call_nodes) + result["nodes"] = base_nodes + call_nodes + return result + + return _composed + + +def _link_gql_calls_to_operations(all_nodes: list[dict], all_edges: list[dict]) -> int: + """Link ``gql_call`` call sites to the ``gql_operation`` they invoke, by name, + *within one repo* (e.g. a service calling its own operation). Cross-repo + links are added by the global stitch. Name-based, so edges are INFERRED. + """ + ops_by_name: dict[str, str] = {} + for n in all_nodes: + if n.get("type") == "gql_operation": + ops_by_name.setdefault(str(n.get("label", "")), n["id"]) + if not ops_by_name: + return 0 + existing = {(e.get("source"), e.get("target")) for e in all_edges} + added = 0 + for n in all_nodes: + if n.get("type") != "gql_call": + continue + tgt = ops_by_name.get(str(n.get("op_name", ""))) + if tgt and tgt != n["id"] and (n["id"], tgt) not in existing: + existing.add((n["id"], tgt)) + all_edges.append({ + "source": n["id"], + "target": tgt, + "relation": "calls", + "confidence": "INFERRED", + "confidence_score": 0.8, + "source_file": n.get("source_file", ""), + "source_location": n.get("source_location"), + "weight": 1.0, + }) + added += 1 + return added + + +def _link_gql_operations_to_resolvers(all_nodes: list[dict], all_edges: list[dict]) -> int: + """Connect GraphQL operations (from the SDL extractor) to the code functions + that implement them, so the contract layer isn't an island floating off the + AST. Matches a ``gql_operation`` node's name to a callable code node by + normalized identifier — e.g. operation ``createPilotDistro`` -> resolver + ``.CreatePilotDistro()``. Name-based, so edges are INFERRED. Returns count. + """ + ops = [n for n in all_nodes if n.get("type") == "gql_operation"] + if not ops: + return 0 + + def _core(label: str) -> str: + s = str(label).strip() + if s.startswith("."): + s = s[1:] + if s.endswith("()"): + s = s[:-2] + return s.lower() + + # Index callable (function/method) code nodes by normalized name. Restrict to + # labels ending in ')' so we target resolvers/functions, not types or fields. + callable_by_core: dict[str, str] = {} + for n in all_nodes: + if str(n.get("type", "")).startswith("gql"): + continue + if n.get("file_type") != "code": + continue + lbl = str(n.get("label", "")) + if not lbl.endswith(")"): + continue + callable_by_core.setdefault(_core(lbl), n["id"]) + + existing = {(e.get("source"), e.get("target")) for e in all_edges} + added = 0 + for op in ops: + tgt = callable_by_core.get(str(op.get("label", "")).lower()) + if tgt and tgt != op["id"] and (op["id"], tgt) not in existing: + existing.add((op["id"], tgt)) + all_edges.append({ + "source": op["id"], + "target": tgt, + "relation": "implemented_by", + "confidence": "INFERRED", + "confidence_score": 0.85, + "source_file": op.get("source_file", ""), + "source_location": op.get("source_location"), + "weight": 1.0, + }) + added += 1 + return added + + def extract( paths: list[Path], cache_root: Path | None = None, @@ -13118,6 +13321,13 @@ def _has_import_evidence(candidate_id: str) -> bool: for n in all_nodes: n["_origin"] = "ast" + # Collapse private/public duplicates of the same GraphQL type into one node + # (entity marking wins), then bridge the SDL contract to its implementing code + # by linking each operation to the resolver function that implements it. + _consolidate_gql_duplicates(all_nodes) + _link_gql_operations_to_resolvers(all_nodes, all_edges) + _link_gql_calls_to_operations(all_nodes, all_edges) + return { "nodes": all_nodes, "edges": all_edges, diff --git a/graphify/global_graph.py b/graphify/global_graph.py index 4597268d1..778af636e 100644 --- a/graphify/global_graph.py +++ b/graphify/global_graph.py @@ -1,4 +1,5 @@ from __future__ import annotations +import collections import json import hashlib import sys @@ -74,6 +75,91 @@ def _file_hash(path: Path) -> str: return h.hexdigest()[:16] +def _stitch_federation(G: nx.Graph) -> int: + """Link Apollo Federation entities across repos in the global graph. + + The SDL extractor tags entity types with ``federation='entity'`` (the service + that owns ``type X @key``) or ``federation='extends'`` (a service that + ``extend type X @key`` references it). Same entity name in two repos = the + same federated entity, so each reference gets a ``federation_key`` edge to the + owner. Idempotent: prior ``federation_key`` edges are dropped first, so it is + safe to re-run after every ``global_add``. + """ + stale = [(u, v) for u, v, d in G.edges(data=True) + if d.get("relation") == "federation_key"] + G.remove_edges_from(stale) + + origins: dict[str, list[str]] = collections.defaultdict(list) + refs: dict[str, list[str]] = collections.defaultdict(list) + for nid, d in G.nodes(data=True): + if d.get("type") != "gql_entity": + continue + name = str(d.get("label", "")).split(" ", 1)[0] + if not name: + continue + if d.get("federation") == "entity": + origins[name].append(nid) + elif d.get("federation") == "extends": + refs[name].append(nid) + + added = 0 + for name, ref_ids in refs.items(): + for ref in ref_ids: + ref_repo = G.nodes[ref].get("repo") + for origin in origins.get(name, []): + if G.nodes[origin].get("repo") == ref_repo: + continue + G.add_edge(ref, origin, relation="federation_key", confidence="EXTRACTED", + confidence_score=1.0, source_file="", weight=1.0) + added += 1 + return added + + +def _stitch_gql_calls(G: nx.Graph) -> int: + """Link GraphQL operation *call sites* to the operations they invoke, across + repos, in the global graph. + + The call-site extractor tags each ``gql`...` `` / ``graphql:"..."`` usage as a + ``gql_call`` node carrying ``op_name``; the SDL extractor owns the matching + ``gql_operation`` in whatever service defines the schema. A frontend calling + a backend mutation is the common cross-repo case, so each ``gql_call`` gets a + ``calls`` edge to the operation node of the same name. With this edge, + ``graphify affected ""`` reverse-traverses to every consumer a + backend change would affect. Idempotent: prior ``calls`` edges are dropped + first, so it is safe to re-run after every ``global_add``. + """ + stale = [(u, v) for u, v, d in G.edges(data=True) + if d.get("relation") == "calls"] + G.remove_edges_from(stale) + + ops_by_name: dict[str, list[str]] = collections.defaultdict(list) + calls_by_name: dict[str, list[str]] = collections.defaultdict(list) + for nid, d in G.nodes(data=True): + t = d.get("type") + if t == "gql_operation": + name = str(d.get("label", "")).split(" ", 1)[0] + if name: + ops_by_name[name].append(nid) + elif t == "gql_call": + name = str(d.get("op_name") or d.get("label", "")) + if name: + calls_by_name[name].append(nid) + + added = 0 + for name, call_ids in calls_by_name.items(): + targets = ops_by_name.get(name) + if not targets: + continue + for call in call_ids: + for op in targets: + if call == op: + continue + G.add_edge(call, op, relation="calls", confidence="INFERRED", + confidence_score=0.8, source_file="", weight=1.0) + added += 1 + return added + + def global_add(source_path: Path, repo_tag: str) -> dict: """Add or update a project graph in the global graph. @@ -142,6 +228,14 @@ def global_add(source_path: Path, repo_tag: str) -> dict: G.add_edge(u, v, **data) added = prefixed.number_of_nodes() - len(remap) + + # Re-stitch cross-repo federation @key links now that this repo's entities + # are present (idempotent — recomputed over the whole graph each add). + _stitch_federation(G) + # Link GraphQL call sites to the operations they invoke across repos + # (frontend -> backend mutation); idempotent, same rationale. + _stitch_gql_calls(G) + _save_global_graph(G) manifest["repos"][repo_tag] = { diff --git a/graphify/graphql_calls.py b/graphify/graphql_calls.py new file mode 100644 index 000000000..065e9fa4b --- /dev/null +++ b/graphify/graphql_calls.py @@ -0,0 +1,176 @@ +"""GraphQL operation *call-site* extractor for graphify. + +The SDL extractor (``graphql_sdl.py``) captures the operations a service +*defines* in its ``.graphqls`` schema. This module captures the other side: the +places that *call* those operations from application code — GraphQL documents +embedded in TS/JS as ``gql`...` `` / ``graphql`...` `` tagged template literals, +and Go client structs that name an operation in a ``graphql:"..."`` struct tag. + +Those call sites live inside *string literals*, which tree-sitter indexes as +opaque text — so without this pass the contract layer (SDL operations) is an +island with no link to its real consumers. Each call site becomes a ``gql_call`` +node; a later stitch pass (per-repo in ``extract.py``, cross-repo in +``global_graph.py``) links ``gql_call --calls--> gql_operation`` by operation +name, so reverse traversal (``graphify affected ""``) surfaces every +frontend/service that a backend change would affect. + +Pure-text and dependency-free: a codebase with no GraphQL literals yields no +nodes, so there is zero behavior change unless GraphQL is actually present. +""" +from __future__ import annotations + +import re +from pathlib import Path +from typing import Any + +# Suffixes whose source can embed gql`...` tagged template literals. +_TS_SUFFIXES = {".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".mts", ".cts"} + +# A gql/graphql tagged template literal: the tag identifier then a backtick. +# GraphQL document bodies don't contain raw backticks, so a non-greedy scan to +# the next backtick safely bounds the document. +_GQL_TAG = re.compile(r"\b(?:gql|graphql)\s*`", re.IGNORECASE) + +# An operation block opener inside a document: `query|mutation|subscription ... {`. +_OP_OPEN = re.compile(r"\b(query|mutation|subscription)\b[^{}]*\{") + +# Go client operation tag: `graphql:"storeProductVersion(id: $id)"`. +_GO_TAG = re.compile(r'graphql:"([^"]*)"') + +# Leading operation identifier of a Go tag / GraphQL field head. +_IDENT_CALL = re.compile(r"\s*([A-Za-z_][A-Za-z0-9_]*)\s*\(") + +_IDENT_CHARS = set("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_") + + +def _root_fields(block: str) -> list[tuple[str, int]]: + """Yield ``(field_name, offset_in_block)`` for each *root* selection of every + operation in a GraphQL document body. + + Root selections of a ``query``/``mutation``/``subscription`` are exactly the + operation calls (a mutation's root field is the mutation it invokes). Nested + fields, fragment spreads, aliases and inline-object arguments are skipped, so + the result is the set of operations this document actually calls. + """ + out: list[tuple[str, int]] = [] + for opener in _OP_OPEN.finditer(block): + i = opener.end() - 1 # position of the operation's opening '{' + depth = 0 + paren = 0 + n = len(block) + while i < n: + c = block[i] + if paren > 0: + # Inside (...) arguments — ignore braces (inline object values). + if c == "(": + paren += 1 + elif c == ")": + paren -= 1 + i += 1 + continue + if c == "(": + paren += 1 + i += 1 + continue + if c == "{": + depth += 1 + i += 1 + continue + if c == "}": + depth -= 1 + i += 1 + if depth == 0: + break # end of this operation + continue + if depth == 1 and (c.isalpha() or c == "_"): + # Skip fragment spreads: "...Name". + if block[max(0, i - 3):i] == "...": + i += 1 + continue + j = i + while j < n and block[j] in _IDENT_CHARS: + j += 1 + ident = block[i:j] + k = j + while k < n and block[k] in " \t\r\n": + k += 1 + nxt = block[k] if k < n else "" + if nxt == ":": + # "alias: field" — skip the alias, the real field follows. + i = k + 1 + continue + if ident not in ("on",): + out.append((ident, i)) + i = j + continue + i += 1 + return out + + +def find_gql_operation_calls(text: str, suffix: str) -> list[tuple[str, int]]: + """Find GraphQL operation call sites in one source file. + + Returns ``(operation_name, line_number)`` pairs. Covers TS/JS ``gql`...` `` + template literals (root selections) and Go ``graphql:"..."`` struct tags. + """ + results: list[tuple[str, int]] = [] + + def line_at(pos: int) -> int: + return text.count("\n", 0, pos) + 1 + + if suffix == ".go": + for m in _GO_TAG.finditer(text): + body = m.group(1) + mm = _IDENT_CALL.match(body) + if mm: + results.append((mm.group(1), line_at(m.start(1)))) + return results + + if suffix in _TS_SUFFIXES: + for m in _GQL_TAG.finditer(text): + start = m.end() # just past the opening backtick + end = text.find("`", start) + if end == -1: + continue + block = text[start:end] + for name, off in _root_fields(block): + results.append((name, line_at(start + off))) + return results + + +def extract_gql_calls(path: Path) -> dict[str, Any]: + """Per-file extractor: emit a ``gql_call`` node per operation call site. + + Node shape mirrors the SDL extractor's: ``file_type='code'``, a + ``gql_call`` ``type``, and an ``op_name`` carrying the called operation so the + stitch passes can match it to the owning ``gql_operation`` by name. + """ + suffix = path.suffix + if suffix != ".go" and suffix not in _TS_SUFFIXES: + return {"nodes": [], "edges": []} + try: + text = path.read_text(encoding="utf-8", errors="replace") + except OSError: + return {"nodes": [], "edges": []} + # Cheap reject: skip files with no GraphQL marker at all. + if "graphql" not in text.lower() and "gql`" not in text.replace(" ", ""): + return {"nodes": [], "edges": []} + + sp = str(path) + nodes: list[dict] = [] + seen: set[str] = set() + for name, line in find_gql_operation_calls(text, suffix): + nid = f"gqlcall_{name}_{sp}:{line}" + if nid in seen: + continue + seen.add(nid) + nodes.append({ + "id": nid, + "label": name, + "file_type": "code", + "type": "gql_call", + "op_name": name, + "source_file": sp, + "source_location": f"L{line}", + }) + return {"nodes": nodes, "edges": []} diff --git a/graphify/graphql_sdl.py b/graphify/graphql_sdl.py new file mode 100644 index 000000000..6434f984b --- /dev/null +++ b/graphify/graphql_sdl.py @@ -0,0 +1,180 @@ +"""GraphQL SDL extractor for graphify. + +Parses a single ``.graphqls`` / ``.graphql`` schema file into graphify's +per-file ``{"nodes": [...], "edges": [...]}`` shape, using graphql-core as the +parser (tree-sitter has no GraphQL grammar). + +Extracted node kinds (stored in the ``type`` field): + gql_type, gql_input, gql_interface, gql_enum, gql_scalar, gql_union, + gql_field, gql_input_field, gql_enum_value, gql_operation + +Edges: + --contains--> + --references--> + --references--> + --returns--> + +Wired into extract.py via ``_get_extractor`` for the .graphqls/.graphql suffixes. +""" +from __future__ import annotations + +from pathlib import Path +from typing import Any + +try: + from graphql import parse + from graphql.language import ast as _A + _HAVE_GRAPHQL = True +except Exception: # graphql-core not installed — degrade to no-op + _HAVE_GRAPHQL = False + +ROOT_OPS = {"Mutation", "Query", "Subscription"} + + +def _nid(*parts: str) -> str: + return "gql_" + "_".join(p.lower() for p in parts if p) + + +def _unwrap(t) -> str | None: + """NonNull/List wrapper -> underlying named type name.""" + while isinstance(t, (_A.NonNullTypeNode, _A.ListTypeNode)): + t = t.type + return t.name.value if isinstance(t, _A.NamedTypeNode) else None + + +def _line(node) -> int: + try: + return node.loc.start_token.line + except Exception: + return 1 + + +def _key_directives(d) -> list[str]: + """Apollo Federation @key(fields: "...") directives on a type definition. + + Returns one entry per @key directive (a type can have several composite keys). + Empty list means the type is not a federated entity. + """ + out: list[str] = [] + for directive in getattr(d, "directives", []) or []: + if getattr(directive.name, "value", None) != "key": + continue + for arg in getattr(directive, "arguments", []) or []: + if arg.name.value == "fields" and isinstance(arg.value, _A.StringValueNode): + out.append(arg.value.value) + return out + + +def extract_graphql_sdl(path: Path) -> dict[str, Any]: + """Per-file extractor: parse one SDL file into graphify nodes/edges.""" + if not _HAVE_GRAPHQL: + return {"nodes": [], "edges": [], "error": "graphql-core not installed"} + try: + text = path.read_text(encoding="utf-8", errors="replace") + except OSError as exc: + return {"nodes": [], "edges": [], "error": f"read error: {exc}"} + try: + doc = parse(text) + except Exception as exc: # malformed SDL must not abort extraction + return {"nodes": [], "edges": [], "error": f"graphql parse error: {exc}"} + + sp = str(path) + nodes: list[dict] = [] + edges: list[dict] = [] + seen: set[str] = set() + + by_id: dict[str, dict] = {} + + def node(_id: str, label: str, line: int, kind: str, **extra) -> str: + if _id not in seen: + seen.add(_id) + n = { + "id": _id, + "label": label, + "file_type": "code", + "type": kind, + "source_file": sp, + "source_location": f"L{line}", + } + n.update(extra) + nodes.append(n) + by_id[_id] = n + elif extra.get("federation") == "entity": + # An owning `type X @key` definition outranks a prior `extend type X` + # stub seen earlier in the same file: the origin marker wins so the + # cross-repo stitch points references at the real owner. + existing = by_id.get(_id) + if existing is not None: + existing["type"] = kind + existing.update(extra) + return _id + + def edge(src: str, dst: str | None, relation: str, line: int) -> None: + if dst is None: + return + edges.append({ + "source": src, + "target": dst, + "relation": relation, + "confidence": "EXTRACTED", + "confidence_score": 1.0, + "source_file": sp, + "source_location": f"L{line}", + "weight": 1.0, + }) + + def fields(type_id: str, type_name: str, field_nodes, kind: str) -> None: + for fld in field_nodes or []: + fname = fld.name.value + fid = node(_nid(type_name, fname), f"{type_name}.{fname}", _line(fld), kind + "_field") + edge(type_id, fid, "contains", _line(fld)) + edge(fid, _maybe(_unwrap(fld.type)), "references", _line(fld)) + + def _maybe(name: str | None) -> str | None: + return _nid(name) if name else None + + def operations(field_nodes) -> None: + for op in field_nodes or []: + oid = node(_nid("op", op.name.value), op.name.value, _line(op), "gql_operation") + for arg in getattr(op, "arguments", []) or []: + edge(oid, _maybe(_unwrap(arg.type)), "references", _line(op)) + edge(oid, _maybe(_unwrap(op.type)), "returns", _line(op)) + + for d in doc.definitions: + if isinstance(d, (_A.ObjectTypeDefinitionNode, _A.ObjectTypeExtensionNode, + _A.InterfaceTypeDefinitionNode)): + name = d.name.value + if name in ROOT_OPS: + operations(d.fields) + else: + keys = _key_directives(d) + if keys: + # Apollo Federation entity. `type X @key` = this service owns + # the entity (origin); `extend type X @key` = it references an + # entity owned elsewhere. The cross-repo stitch links the two. + is_extend = isinstance(d, _A.ObjectTypeExtensionNode) + tid = node(_nid(name), name, _line(d), "gql_entity", + federation="extends" if is_extend else "entity", + key_fields=keys) + else: + tid = node(_nid(name), name, _line(d), "gql_type") + fields(tid, name, d.fields, "gql") + elif isinstance(d, (_A.InputObjectTypeDefinitionNode, _A.InputObjectTypeExtensionNode)): + name = d.name.value + tid = node(_nid(name), name, _line(d), "gql_input") + fields(tid, name, d.fields, "gql_input") + elif isinstance(d, _A.EnumTypeDefinitionNode): + name = d.name.value + tid = node(_nid(name), name, _line(d), "gql_enum") + for v in d.values or []: + vid = node(_nid(name, v.name.value), f"{name}.{v.name.value}", _line(v), "gql_enum_value") + edge(tid, vid, "contains", _line(v)) + elif isinstance(d, _A.UnionTypeDefinitionNode): + name = d.name.value + tid = node(_nid(name), name, _line(d), "gql_union") + for m in d.types or []: + edge(tid, _nid(m.name.value), "references", _line(d)) + elif isinstance(d, _A.ScalarTypeDefinitionNode): + node(_nid(d.name.value), d.name.value, _line(d), "gql_scalar") + + return {"nodes": nodes, "edges": edges} diff --git a/pyproject.toml b/pyproject.toml index f99bf1dec..dc28d3aec 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,6 +14,7 @@ dependencies = [ "networkx>=3.4", "numpy>=1.21", "rapidfuzz>=3.0", + "graphql-core>=3.2,<4", "tree-sitter>=0.23.0,<0.26", "tree-sitter-python>=0.23,<0.26", "tree-sitter-javascript>=0.23,<0.26", diff --git a/tests/test_gql_calls.py b/tests/test_gql_calls.py new file mode 100644 index 000000000..5e452a8d7 --- /dev/null +++ b/tests/test_gql_calls.py @@ -0,0 +1,160 @@ +"""Tests for GraphQL operation call-site extraction and linking: + - graphql_calls.find_gql_operation_calls (gql`...` root selections, Go tags) + - graphql_calls.extract_gql_calls (gql_call node shape) + - extract._link_gql_calls_to_operations (per-repo call -> operation) + - global_graph._stitch_gql_calls (cross-repo call -> operation) +""" +import networkx as nx + +from graphify.graphql_calls import find_gql_operation_calls, extract_gql_calls +from graphify.extract import _link_gql_calls_to_operations +from graphify.global_graph import _stitch_gql_calls + + +TS_DOC = """ +import { gql } from '@apollo/client' + +export const ADD_EDUCATION_BOOKING = gql` + mutation CreateEducationBooking($input: CreateEducationBookingInput!) { + createEducationBooking(input: $input) { + id + status + course { + id + name + } + } + } +` +""" + + +def test_ts_root_mutation_call_is_found(): + """The root field of a mutation document is the operation it calls; nested + fields (id/status/course/name) are not.""" + calls = find_gql_operation_calls(TS_DOC, ".ts") + names = [n for n, _ in calls] + assert names == ["createEducationBooking"] + # line points at the root selection, not the `mutation` keyword line + (_, line), = calls + assert TS_DOC.splitlines()[line - 1].strip().startswith("createEducationBooking") + + +def test_ts_query_with_args_and_alias(): + """Args (with a variable) don't break brace tracking; aliases resolve to the + real field name.""" + doc = """ + const Q = gql` + query StoreProductVersion($id: ID!) { + ver: storeProductVersion(id: $id) { + id + versions { id } + } + } + ` + """ + names = [n for n, _ in find_gql_operation_calls(doc, ".ts")] + assert names == ["storeProductVersion"] + + +def test_ts_inline_object_argument_does_not_leak_depth(): + """An inline object value in arguments must not be counted as a subselection.""" + doc = 'const Q = gql`mutation M { doThing(input: {a: 1, b: 2}) { ok } }`' + names = [n for n, _ in find_gql_operation_calls(doc, ".ts")] + assert names == ["doThing"] + + +def test_ts_fragment_spread_ignored(): + doc = """ + const Q = gql` + query Q { + storeProductVersions { + ...VersionFields + } + } + ` + """ + names = [n for n, _ in find_gql_operation_calls(doc, ".ts")] + assert names == ["storeProductVersions"] + + +def test_go_graphql_tag_call(): + """A Go client struct tag names the operation it queries.""" + go = ''' + type q struct { + ProductVersion `graphql:"storeProductVersionByProductIDAndNameOrDate(productID: $productID, name: $name, date: $date)"` + } + ''' + calls = find_gql_operation_calls(go, ".go") + assert calls and calls[0][0] == "storeProductVersionByProductIDAndNameOrDate" + + +def test_non_graphql_file_yields_nothing(): + assert find_gql_operation_calls("const x = `just a template ${y}`", ".ts") == [] + + +def test_extract_gql_calls_node_shape(tmp_path): + f = tmp_path / "booking.ts" + f.write_text(TS_DOC) + out = extract_gql_calls(f) + assert len(out["nodes"]) == 1 + n = out["nodes"][0] + assert n["type"] == "gql_call" + assert n["label"] == "createEducationBooking" + assert n["op_name"] == "createEducationBooking" + assert n["file_type"] == "code" + assert n["source_location"].startswith("L") + + +def test_link_call_to_operation_same_repo(): + """A gql_call links to the gql_operation of the same name (per-repo pass).""" + nodes = [ + {"id": "gql_op_createeducationbooking", "label": "createEducationBooking", + "type": "gql_operation", "file_type": "code"}, + {"id": "gqlcall_x", "label": "createEducationBooking", "op_name": "createEducationBooking", + "type": "gql_call", "file_type": "code", "source_file": "f.ts", "source_location": "L5"}, + ] + edges = [] + assert _link_gql_calls_to_operations(nodes, edges) == 1 + e, = edges + assert e["relation"] == "calls" + assert e["source"] == "gqlcall_x" + assert e["target"] == "gql_op_createeducationbooking" + + +def test_link_call_no_matching_operation_adds_nothing(): + nodes = [ + {"id": "gqlcall_x", "label": "ghostOp", "op_name": "ghostOp", + "type": "gql_call", "file_type": "code"}, + ] + edges = [] + assert _link_gql_calls_to_operations(nodes, edges) == 0 + assert edges == [] + + +def test_stitch_gql_calls_cross_repo(): + """A frontend gql_call (repo adsw) links to the backend gql_operation (repo + education) of the same name.""" + G = nx.Graph() + G.add_node("education::gql_op_createeducationbooking", type="gql_operation", + label="createEducationBooking", repo="education") + G.add_node("adsw::gqlcall_1", type="gql_call", op_name="createEducationBooking", + label="createEducationBooking", repo="adsw") + G.add_node("network::gqlcall_2", type="gql_call", op_name="createEducationBooking", + label="createEducationBooking", repo="network") + added = _stitch_gql_calls(G) + + assert added == 2 + assert G.has_edge("adsw::gqlcall_1", "education::gql_op_createeducationbooking") + assert G.has_edge("network::gqlcall_2", "education::gql_op_createeducationbooking") + assert G["adsw::gqlcall_1"]["education::gql_op_createeducationbooking"]["relation"] == "calls" + + +def test_stitch_gql_calls_idempotent(): + G = nx.Graph() + G.add_node("education::gql_op_x", type="gql_operation", label="x", repo="education") + G.add_node("adsw::gqlcall_1", type="gql_call", op_name="x", label="x", repo="adsw") + assert _stitch_gql_calls(G) == 1 + assert _stitch_gql_calls(G) == 1 + calls = [d for _, _, d in G.edges(data=True) if d.get("relation") == "calls"] + assert len(calls) == 1 diff --git a/tests/test_gql_federation.py b/tests/test_gql_federation.py new file mode 100644 index 000000000..55fe69ea5 --- /dev/null +++ b/tests/test_gql_federation.py @@ -0,0 +1,108 @@ +"""Tests for the GraphQL federation / contract-to-code wiring: + - extract._consolidate_gql_duplicates (private+public dedup, entity wins) + - extract._link_gql_operations_to_resolvers (operation -> resolver) + - global_graph._stitch_federation (cross-repo @key linking) +""" +import networkx as nx + +from graphify.extract import ( + _consolidate_gql_duplicates, + _link_gql_operations_to_resolvers, +) +from graphify.global_graph import _stitch_federation + + +def test_consolidate_keeps_one_node_and_entity_wins(): + """A type declared in both private (entity) and public (plain) schema of one + repo collapses to a single node; the @key entity marking + key fields win.""" + nodes = [ + {"id": "gql_deal", "label": "Deal", "type": "gql_type", "file_type": "code"}, + {"id": "gql_deal", "label": "Deal", "type": "gql_entity", "file_type": "code", + "federation": "entity", "key_fields": ["id"]}, + {"id": "code_foo", "label": ".Foo()", "file_type": "code"}, + ] + dropped = _consolidate_gql_duplicates(nodes) + + assert dropped == 1 + deals = [n for n in nodes if n["id"] == "gql_deal"] + assert len(deals) == 1 + assert deals[0]["type"] == "gql_entity" + assert deals[0]["federation"] == "entity" + assert deals[0]["key_fields"] == ["id"] + # non-gql nodes are untouched + assert any(n["id"] == "code_foo" for n in nodes) + + +def test_consolidate_entity_first_then_plain_stays_entity(): + """Order-independent: entity marking survives even if the plain type comes last.""" + nodes = [ + {"id": "gql_deal", "label": "Deal", "type": "gql_entity", "file_type": "code", + "federation": "entity", "key_fields": ["id"]}, + {"id": "gql_deal", "label": "Deal", "type": "gql_type", "file_type": "code"}, + ] + assert _consolidate_gql_duplicates(nodes) == 1 + assert nodes[0]["type"] == "gql_entity" + + +def test_link_operation_to_resolver_by_name(): + """gql_operation `createDeal` links to the Go resolver labelled `.CreateDeal()`.""" + nodes = [ + {"id": "gql_op_createdeal", "label": "createDeal", "type": "gql_operation", + "file_type": "code", "source_file": "schema.graphqls", "source_location": "L1"}, + {"id": "graph_resolver_createdeal", "label": ".CreateDeal()", "file_type": "code"}, + ] + edges = [] + added = _link_gql_operations_to_resolvers(nodes, edges) + + assert added == 1 + assert any(e["relation"] == "implemented_by" + and e["source"] == "gql_op_createdeal" + and e["target"] == "graph_resolver_createdeal" + for e in edges) + + +def test_link_operation_no_match_adds_nothing(): + """No callable with a matching name -> no edge (no false positives).""" + nodes = [ + {"id": "gql_op_createdeal", "label": "createDeal", "type": "gql_operation", + "file_type": "code"}, + {"id": "gql_deal", "label": "Deal", "type": "gql_type", "file_type": "code"}, # a type, not callable + ] + edges = [] + assert _link_gql_operations_to_resolvers(nodes, edges) == 0 + assert edges == [] + + +def _entity(nid, repo, federation, label="Deal"): + return (nid, {"type": "gql_entity", "federation": federation, "label": label, "repo": repo}) + + +def test_stitch_federation_links_extends_to_owner(): + """An entity owned in repo A (`type X @key`) and referenced in repo B + (`extend type X @key`) gets a cross-repo federation_key edge B -> A.""" + G = nx.Graph() + G.add_nodes_from([ + _entity("a::gql_deal", "a", "entity"), + _entity("b::gql_deal", "b", "extends"), + _entity("a::gql_deal_dup", "a", "extends"), # same repo as owner -> must be skipped + ]) + added = _stitch_federation(G) + + assert added == 1 + assert G.has_edge("b::gql_deal", "a::gql_deal") + assert G["b::gql_deal"]["a::gql_deal"]["relation"] == "federation_key" + # no self-repo link + assert not G.has_edge("a::gql_deal_dup", "a::gql_deal") + + +def test_stitch_federation_is_idempotent(): + """Re-running drops prior federation_key edges first, so the count is stable.""" + G = nx.Graph() + G.add_nodes_from([ + _entity("a::gql_deal", "a", "entity"), + _entity("b::gql_deal", "b", "extends"), + ]) + assert _stitch_federation(G) == 1 + assert _stitch_federation(G) == 1 + fed = [d for _, _, d in G.edges(data=True) if d.get("relation") == "federation_key"] + assert len(fed) == 1 diff --git a/tests/test_graphql_sdl.py b/tests/test_graphql_sdl.py new file mode 100644 index 000000000..f06bda8cb --- /dev/null +++ b/tests/test_graphql_sdl.py @@ -0,0 +1,121 @@ +import pytest + +from graphify.graphql_sdl import extract_graphql_sdl + +_SCHEMA = """ +enum DealStatus { + OPEN + CLOSED +} + +input CreateDealInput { + name: String! + status: DealStatus! +} + +type Deal { + id: ID! + name: String! +} + +type Mutation { + createDeal(input: CreateDealInput!): Deal! +} +""" + + +def _write(path, content): + path.write_text(content.lstrip(), encoding="utf-8") + return path + + +def test_graphql_sdl_extracts_types_inputs_enums_and_operations(tmp_path): + """SDL types, inputs, enums and Mutation fields become first-class nodes.""" + schema = _write(tmp_path / "schema.graphqls", _SCHEMA) + + result = extract_graphql_sdl(schema) + nodes = {n["id"]: n for n in result["nodes"]} + kinds = {n["id"]: n["type"] for n in result["nodes"]} + + # object type, input, enum + assert kinds.get("gql_deal") == "gql_type" + assert kinds.get("gql_createdealinput") == "gql_input" + assert kinds.get("gql_dealstatus") == "gql_enum" + # enum values + assert kinds.get("gql_dealstatus_open") == "gql_enum_value" + # Mutation field is an operation, not a plain type + assert kinds.get("gql_op_createdeal") == "gql_operation" + assert nodes["gql_op_createdeal"]["label"] == "createDeal" + + # every node carries a valid schema file_type + source location + assert all(n["file_type"] == "code" for n in result["nodes"]) + assert nodes["gql_op_createdeal"]["source_location"].startswith("L") + + +def test_graphql_sdl_links_operation_to_input_and_return_type(tmp_path): + """createDeal --references--> CreateDealInput and --returns--> Deal.""" + schema = _write(tmp_path / "schema.graphqls", _SCHEMA) + + result = extract_graphql_sdl(schema) + edges = {(e["source"], e["relation"], e["target"]) for e in result["edges"]} + + assert ("gql_op_createdeal", "references", "gql_createdealinput") in edges + assert ("gql_op_createdeal", "returns", "gql_deal") in edges + # object type contains its fields + assert ("gql_deal", "contains", "gql_deal_name") in edges + + +def test_graphql_sdl_malformed_schema_does_not_raise(tmp_path): + """A broken schema yields an error marker, never an exception.""" + schema = _write(tmp_path / "bad.graphqls", "type Deal { id: ID!") # missing brace + + result = extract_graphql_sdl(schema) + assert result["nodes"] == [] + assert result["edges"] == [] + assert "error" in result + + +_FEDERATED = """ +type Deal @key(fields: "id") { + id: ID! + name: String! +} + +extend type Account @key(fields: "userID") { + userID: ID! +} +""" + + +def test_graphql_sdl_tags_key_entities(tmp_path): + """`type X @key` is a federation origin entity; `extend type X @key` is a reference.""" + schema = _write(tmp_path / "schema.graphqls", _FEDERATED) + + nodes = {n["id"]: n for n in extract_graphql_sdl(schema)["nodes"]} + + deal = nodes["gql_deal"] + assert deal["type"] == "gql_entity" + assert deal["federation"] == "entity" + assert deal["key_fields"] == ["id"] + + account = nodes["gql_account"] + assert account["type"] == "gql_entity" + assert account["federation"] == "extends" + assert account["key_fields"] == ["userID"] + + +def test_graphql_sdl_non_key_type_is_not_an_entity(tmp_path): + """A plain object type without @key stays gql_type, with no federation marker.""" + nodes = {n["id"]: n for n in extract_graphql_sdl(_write(tmp_path / "s.graphqls", _SCHEMA))["nodes"]} + assert nodes["gql_deal"]["type"] == "gql_type" + assert "federation" not in nodes["gql_deal"] + + +def test_graphql_sdl_edges_carry_confidence(tmp_path): + """Every SDL edge has confidence metadata so the graph validator stays quiet.""" + edges = extract_graphql_sdl(_write(tmp_path / "s.graphqls", _SCHEMA))["edges"] + assert edges + for e in edges: + assert e["confidence"] == "EXTRACTED" + assert e["confidence_score"] == 1.0 + assert e["weight"] == 1.0