diff --git a/TRACKER.md b/TRACKER.md index 640cb43..aafe4f4 100644 --- a/TRACKER.md +++ b/TRACKER.md @@ -4,9 +4,9 @@ ## Status -- **Current phase:** 6 — Lifecycle, scale, hardening **COMPLETE** (Gate 6 passed, M6 reached). 6F field-hardening done bar the data-blocked 6F.6 detection half. -- **Next step:** publish 0.5.0 to npm (needs the user's creds — `npm publish`), then the tester re-validates via `ui-lineage-mcp`. After that, Phase 7 (backend parsers & federation) — detail the sketch after v1 feedback. 6F.6 detection half still blocked on a real failing test file from the tester. -- **Done:** 0.1–0.4, 1.1–1.6, 2.1–2.5, 3.1–3.6, 4.1–4.6, 5.1–5.7, 6F.1–6F.5, 6F.7–6F.10 + 6F.6 (defensive half), **6.1–6.5 (Phase 6 complete)** · **0.5.0 prepared** (all packages + generator/CLI/MCP version strings bumped, README changelog; `npm pack` verified; publish pending user's npm creds) (all packages bumped, changelog + version strings, `npm pack` → ui-lineage-0.4.1.tgz verified: both bins, no @coderadar leak; publish to npm pending). 0.4.0 tagged/merged earlier. **Self-validated on Grafana frontend** (6,461 files, 15,334 nodes / 18,367 edges in 72 s): 55 RTK-query data sources, 32 routes, 1,009 coverage edges, gibberish declines — all previously 0/1 in the field run. +- **Current phase:** 7 — Backend parsers & federation (v2 horizon). Phase 6 complete (Gate 6, M6). **0.5.0 published to npm.** +- **Next step:** 7.2 Next.js server data, or hold the rest of Phase 7 for 0.5.0 tester feedback (7.2–7.6 are still sketch-level — detail each before building). 7.1 GraphQL adapter landed. 6F.6 detection half still blocked on a real failing test file from the tester. +- **Done:** 0.1–0.4, 1.1–1.6, 2.1–2.5, 3.1–3.6, 4.1–4.6, 5.1–5.7, 6F.1–6F.5, 6F.7–6F.10 + 6F.6 (defensive half), **6.1–6.5 (Phase 6 complete)**, **7.1 (GraphQL adapter)** · **0.5.0 released & published to npm** (tag v0.5.0; both bins verified via `npm pack`). **Self-validated on Grafana frontend** (6,461 files, 15,334 nodes / 18,367 edges in 72 s): 55 RTK-query data sources, 32 routes, 1,009 coverage edges, gibberish declines — all previously 0/1 in the field run. - **Gates passed:** Gate 0 (CI + red-path, #5/#6) · Gate 1 (precision 1.000, recall 0.895, zero poison) · Gate 2 (C1 instance attribution 1.000 · B1 4-level handler chains · C6 store writers↔readers · A9 portals — scorecard 137/0/0, precision & recall 1.000) · Gate 3 (B3 action effects · B4 routers · B6 cyclic journeys terminate · B7/B8 form & non-JSX events · G5 flag/role conditions — precision & recall 1.000) · Gate 4 (A4 rarity · A10 fuzzy/OCR · A1 structural · A6 subtree · E3 vision annotations · E2 aliases · G4 corrections — high-conf correct 1.000, ambiguity honesty 1.000, poison rate 0.000) · Gate 5 (F1 context bundle · F2 blast radius · F3 test coverage · F4 response schema · F5 git history · MCP server over stdio — scorecard 265/0/0, all honesty metrics 1.000; **M5 reached** — ticket in → budgeted context bundle out, over MCP) ## What CodeRadar is @@ -705,10 +705,14 @@ formally stamped: 6F.6 test-coverage hardening (blocked on a real failing sample ## Phase 7 — Backend parsers & federation (v2 horizon) -Sketch level — detail before starting the phase, after v1 feedback. +Sketch level for 7.2–7.6 — detail each before starting, after v1 feedback. -- **7.1 GraphQL adapter** (C4): operations/fragments as data sources; codegen types feed 5.5. -- **7.2 Next.js server data** (C9): RSC async components, `getServerSideProps`, server actions as first-class data sources. +### [x] 7.1 GraphQL adapter +**Failure modes:** C4 +**Build:** recognize GraphQL operations a component runs (Apollo/urql/graphql-request `useQuery`/`useMutation`/`useSubscription`/`useLazyQuery` with a `gql`/`graphql` tagged-template argument, inline or a resolved const) and emit them as data sources: operation NAME as identity, operation TYPE as method. +**Accept:** fixture with query/mutation/subscription operations (imported const + inline tag) → one `graphql` data source each with the right method and a `fetches-from` edge; react-query `useQuery`/`useMutation` (non-gql arg) still classify as react-query. +**Done:** new `graphql.ts` — `parseGraphqlOperation` reads the operation type/name (+ root selection fields as an anonymous-op fallback) from a gql document, ignoring `#` comments and handling shorthand `{ … }`; `graphqlOperationFromArg` resolves a hook's first argument to an operation (inline tagged template, or an identifier followed via `getDefinitionNodes()` so an imported/co-located gql const resolves cross-file). `detectDataSource` gains a GraphQL branch **before** the react-query branch (Apollo/urql reuse `useQuery`/`useMutation`, so it only claims the call when the argument is an actual gql document, else falls through). Emits `sourceKind: "graphql"`, `method` = query/mutation/subscription, `endpoint` = operation name (the value federation/attribution join on) — reusing the existing data-source node + `fetches-from` wiring, no schema change (`graphql` was already a `DataSourceKind`). New fixture `c4-graphql` (imported-const `useQuery`/`useMutation` + inline `useSubscription`); 9 unit tests incl. the react-query disambiguation on the c5 fixture. eval 326/0/0/0, determinism 1.000, all metrics 1.000; typecheck + lint clean. **Codegen response types (feeding 5.5) deferred** — needs a `.graphql`/codegen fixture; the operation→data-source spine is what unblocks federation. +### [ ] 7.2 Next.js server data (C9): RSC async components, `getServerSideProps`, server actions as first-class data sources. - **7.3 Push channels** (C8): WebSocket/SSE subscription nodes. - **7.4 Python backend parser** (C10, G6): FastAPI/Django route decorators → `ServesNode{ method, pattern }`; tree-sitter based, emits the same LineageGraph JSON. - **7.5 Go backend parser**: mux/gin/echo handler registration → `ServesNode`. diff --git a/eval/fixtures/c4-graphql/app/InviteForm.tsx b/eval/fixtures/c4-graphql/app/InviteForm.tsx new file mode 100644 index 0000000..3960d33 --- /dev/null +++ b/eval/fixtures/c4-graphql/app/InviteForm.tsx @@ -0,0 +1,16 @@ +import { useMutation } from "@apollo/client"; + +import { CREATE_USER } from "./queries"; + +// Apollo useMutation with an imported gql const → graphql data source +// "CreateUser" (method: mutation). Shares the useMutation name with react-query, +// but the gql argument makes it a GraphQL source, not a react-query one. +export function InviteForm() { + const [createUser] = useMutation(CREATE_USER); + return ( +
createUser({ variables: { input: {} } })}> +

Invite teammate

+ +
+ ); +} diff --git a/eval/fixtures/c4-graphql/app/LiveTicker.tsx b/eval/fixtures/c4-graphql/app/LiveTicker.tsx new file mode 100644 index 0000000..29b79e5 --- /dev/null +++ b/eval/fixtures/c4-graphql/app/LiveTicker.tsx @@ -0,0 +1,21 @@ +import { gql, useSubscription } from "@apollo/client"; + +// Inline gql tagged template (not a const) with a subscription operation → +// graphql data source "OnTick" (method: subscription). +export function LiveTicker() { + const { data } = useSubscription( + gql` + subscription OnTick { + tick { + at + } + } + `, + ); + return ( +
+

Live updates

+ {data?.tick?.at ?? "waiting"} +
+ ); +} diff --git a/eval/fixtures/c4-graphql/app/UsersPanel.tsx b/eval/fixtures/c4-graphql/app/UsersPanel.tsx new file mode 100644 index 0000000..a4cdc20 --- /dev/null +++ b/eval/fixtures/c4-graphql/app/UsersPanel.tsx @@ -0,0 +1,14 @@ +import { useQuery } from "@apollo/client"; + +import { GET_USERS } from "./queries"; + +// Apollo useQuery with an imported gql const → graphql data source "GetUsers". +export function UsersPanel() { + const { data } = useQuery(GET_USERS); + return ( +
+

User directory

+ +
+ ); +} diff --git a/eval/fixtures/c4-graphql/app/queries.ts b/eval/fixtures/c4-graphql/app/queries.ts new file mode 100644 index 0000000..875b630 --- /dev/null +++ b/eval/fixtures/c4-graphql/app/queries.ts @@ -0,0 +1,21 @@ +import { gql } from "@apollo/client"; + +// GraphQL operations defined as gql consts and imported by the components that +// run them — the common Apollo/urql pattern. The operation NAME is the data +// source identity; a component that runs it gets a fetches-from edge. +export const GET_USERS = gql` + query GetUsers { + users { + id + name + } + } +`; + +export const CREATE_USER = gql` + mutation CreateUser($input: UserInput!) { + createUser(input: $input) { + id + } + } +`; diff --git a/eval/fixtures/c4-graphql/golden.json b/eval/fixtures/c4-graphql/golden.json new file mode 100644 index 0000000..9afadb4 --- /dev/null +++ b/eval/fixtures/c4-graphql/golden.json @@ -0,0 +1,21 @@ +{ + "failureMode": "C4", + "note": "GraphQL adapter (7.1): Apollo/urql operations become data sources keyed by operation NAME, with the operation TYPE (query/mutation/subscription) as the method. Covers an imported gql const via useQuery (GetUsers) and useMutation (CreateUser) — which share their hook names with react-query but are disambiguated by the gql document argument — and an inline gql tagged template via useSubscription (OnTick). Each running component gets a fetches-from edge to its operation.", + "expect": { + "components": [ + { "name": "UsersPanel", "instances": 0 }, + { "name": "InviteForm", "instances": 0 }, + { "name": "LiveTicker", "instances": 0 } + ], + "attributions": [ + { "component": "UsersPanel", "endpoints": ["GetUsers"] }, + { "component": "InviteForm", "endpoints": ["CreateUser"] }, + { "component": "LiveTicker", "endpoints": ["OnTick"] } + ], + "queries": [ + { "terms": ["User directory"], "status": "ok", "top": "UsersPanel" }, + { "terms": ["Invite teammate"], "status": "ok", "top": "InviteForm" }, + { "terms": ["Live updates"], "status": "ok", "top": "LiveTicker" } + ] + } +} diff --git a/packages/parser-react/src/graphql.test.ts b/packages/parser-react/src/graphql.test.ts new file mode 100644 index 0000000..bcbc9b4 --- /dev/null +++ b/packages/parser-react/src/graphql.test.ts @@ -0,0 +1,84 @@ +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +import type { DataSourceNode } from "@coderadar/core"; +import { describe, expect, it } from "vitest"; + +import { parseGraphqlOperation } from "./graphql.js"; +import { resolveHookEdges, scanReact } from "./scan.js"; + +const fixtures = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../../eval/fixtures"); + +describe("parseGraphqlOperation (7.1, C4)", () => { + it("parses a named query with its root field", () => { + expect(parseGraphqlOperation("query GetUsers { users { id name } }")).toStrictEqual({ + type: "query", + name: "GetUsers", + rootFields: ["users"], + }); + }); + + it("parses a named mutation with variables", () => { + expect( + parseGraphqlOperation("mutation CreateUser($input: UserInput!) { createUser(input: $input) { id } }"), + ).toStrictEqual({ type: "mutation", name: "CreateUser", rootFields: ["createUser"] }); + }); + + it("parses a subscription", () => { + const op = parseGraphqlOperation("subscription OnTick { tick { at } }"); + expect(op?.type).toBe("subscription"); + expect(op?.name).toBe("OnTick"); + }); + + it("parses an anonymous shorthand query via its root fields", () => { + expect(parseGraphqlOperation("{ me { id } settings { theme } }")).toStrictEqual({ + type: "query", + name: null, + rootFields: ["me", "settings"], + }); + }); + + it("ignores # comments and leading whitespace", () => { + const op = parseGraphqlOperation("\n # fetch the current user\n query Me { me { id } }\n"); + expect(op).toStrictEqual({ type: "query", name: "Me", rootFields: ["me"] }); + }); + + it("returns null for a fragment-only document (not an operation)", () => { + expect(parseGraphqlOperation("fragment UserFields on User { id name }")).toBeNull(); + }); +}); + +describe("GraphQL data-source extraction (7.1, C4)", () => { + const graph = resolveHookEdges(scanReact({ root: path.join(fixtures, "c4-graphql/app") })); + const source = (endpoint: string): DataSourceNode | undefined => + graph.nodes.find((n): n is DataSourceNode => n.kind === "data-source" && n.endpoint === endpoint); + + it("emits a graphql source per operation, keyed by name, typed by operation", () => { + expect(source("GetUsers")).toMatchObject({ sourceKind: "graphql", method: "query", resolved: "full" }); + expect(source("CreateUser")).toMatchObject({ sourceKind: "graphql", method: "mutation" }); + expect(source("OnTick")).toMatchObject({ sourceKind: "graphql", method: "subscription" }); + }); + + it("wires a fetches-from edge from each running component", () => { + const from = (comp: string, ds: string): boolean => + graph.edges.some( + (e) => + e.kind === "fetches-from" && + e.from === `component:${comp}.tsx#${comp}` && + e.to.endsWith(`#graphql:${ds}`), + ); + expect(from("UsersPanel", "GetUsers")).toBe(true); + expect(from("InviteForm", "CreateUser")).toBe(true); + expect(from("LiveTicker", "OnTick")).toBe(true); + }); + + it("does not misclassify react-query useQuery as graphql (disambiguation)", () => { + // The c5 fixture uses react-query useQuery/useMutation with URL queryFns, no gql. + const rq = resolveHookEdges(scanReact({ root: path.join(fixtures, "c5-queryfn-indirection/app") })); + const graphqlSources = rq.nodes.filter( + (n) => n.kind === "data-source" && n.sourceKind === "graphql", + ); + expect(graphqlSources).toStrictEqual([]); + expect(rq.nodes.some((n) => n.kind === "data-source" && n.sourceKind === "react-query")).toBe(true); + }); +}); diff --git a/packages/parser-react/src/graphql.ts b/packages/parser-react/src/graphql.ts new file mode 100644 index 0000000..cb000d3 --- /dev/null +++ b/packages/parser-react/src/graphql.ts @@ -0,0 +1,134 @@ +/** + * GraphQL operation extraction (TRACKER step 7.1, failure mode C4). + * + * Recognizes GraphQL operations a component runs — Apollo / urql / graphql-request + * hooks (`useQuery`, `useMutation`, `useSubscription`, `useLazyQuery`) whose + * argument is a `gql`/`graphql` tagged template (inline, or a const that resolves + * to one). Each operation becomes a data source: the operation NAME is its + * identity (the value federation and attribution join on), the operation TYPE + * (query/mutation/subscription) is its "method". Anonymous operations fall back + * to their first root selection field. + */ +import { Node } from "ts-morph"; + +/** The GraphQL hook names that take an operation document as their first argument. */ +export const GRAPHQL_HOOKS: ReadonlySet = new Set([ + "useQuery", + "useMutation", + "useSubscription", + "useLazyQuery", +]); + +/** Tag names that mark a template literal as a GraphQL document. */ +const GQL_TAGS: ReadonlySet = new Set(["gql", "graphql"]); + +export interface GraphqlOperation { + type: "query" | "mutation" | "subscription"; + /** Operation name, or null for an anonymous operation. */ + name: string | null; + /** Top-level selection field names (best-effort), a fallback identity. */ + rootFields: string[]; +} + +/** Strip `# ...` line comments and the surrounding backticks from a template's text. */ +function documentText(raw: string): string { + return raw.replace(/^`|`$/g, "").replace(/#[^\n]*/g, " "); +} + +/** + * Parse a GraphQL document's leading operation. Handles named and anonymous + * `query`/`mutation`/`subscription` operations and the shorthand `{ … }` query. + * Returns null for a fragment-only document (a reusable selection, not a fetch). + */ +export function parseGraphqlOperation(rawDocument: string): GraphqlOperation | null { + const text = documentText(rawDocument); + const opMatch = /\b(query|mutation|subscription)\b\s*([A-Za-z_]\w*)?/.exec(text); + let type: GraphqlOperation["type"]; + let name: string | null; + let bodyStart: number; + if (opMatch !== null) { + type = opMatch[1] as GraphqlOperation["type"]; + name = opMatch[2] ?? null; + bodyStart = opMatch.index + opMatch[0].length; + } else if (/^\s*\{/.test(text)) { + // Shorthand anonymous query: `{ field { … } }`. + type = "query"; + name = null; + bodyStart = text.indexOf("{"); + } else { + return null; + } + return { type, name, rootFields: rootSelectionFields(text, bodyStart) }; +} + +/** + * The field names selected directly inside the operation's top-level `{ … }` + * (depth 1). Skips argument lists `( … )` and nested selections, and drops the + * variable-definition list that can precede the body. + */ +function rootSelectionFields(text: string, from: number): string[] { + const open = text.indexOf("{", from); + if (open === -1) return []; + const fields: string[] = []; + let depth = 0; + let parens = 0; + let token = ""; + const flush = (): void => { + // A field is a bareword captured at brace depth 1; an alias `a: b` keeps `a`. + const name = token.split(":")[0]?.trim(); + if (name !== undefined && /^[A-Za-z_]\w*$/.test(name) && name !== "on") fields.push(name); + token = ""; + }; + for (let i = open; i < text.length; i += 1) { + const ch = text.charAt(i); + if (ch === "(") parens += 1; + else if (ch === ")") parens -= 1; + else if (parens > 0) continue; + else if (ch === "{") { + if (depth === 1) flush(); + depth += 1; + token = ""; + } else if (ch === "}") { + if (depth === 1) flush(); + depth -= 1; + if (depth === 0) break; + } else if (depth === 1) { + if (/\s/.test(ch)) flush(); + else token += ch; + } + } + // Dedupe, preserve order. + return [...new Set(fields)]; +} + +/** The GraphQL document text of a `gql`/`graphql` tagged template, or null. */ +function taggedGqlText(node: Node): string | null { + if (!Node.isTaggedTemplateExpression(node)) return null; + const tagName = node.getTag().getText().split(".").pop() ?? ""; + if (!GQL_TAGS.has(tagName)) return null; + return node.getTemplate().getText(); +} + +/** + * Resolve a hook argument to a GraphQL operation: an inline `gql`/`graphql` + * tagged template, or an identifier bound to one (in this file or an imported + * module — ts-morph follows the symbol). Returns null when the argument is not + * a GraphQL document (e.g. a react-query key/options object). + */ +export function graphqlOperationFromArg(arg: Node | undefined): GraphqlOperation | null { + if (arg === undefined) return null; + const inline = taggedGqlText(arg); + if (inline !== null) return parseGraphqlOperation(inline); + if (Node.isIdentifier(arg)) { + // getDefinitionNodes follows imports to the real declaration, so a gql const + // defined in another module (the common codegen/co-located pattern) resolves. + for (const decl of arg.getDefinitionNodes()) { + if (Node.isVariableDeclaration(decl)) { + const init = decl.getInitializer(); + const text = init !== undefined ? taggedGqlText(init) : null; + if (text !== null) return parseGraphqlOperation(text); + } + } + } + return null; +} diff --git a/packages/parser-react/src/scan.ts b/packages/parser-react/src/scan.ts index 819fa53..75b9a7f 100644 --- a/packages/parser-react/src/scan.ts +++ b/packages/parser-react/src/scan.ts @@ -33,6 +33,7 @@ import { import { fetchMethod, resolveEndpoint, type ResolvedEndpoint, resolveStringValue } from "./endpoint.js"; import { decodeEntities } from "./entities.js"; +import { GRAPHQL_HOOKS, graphqlOperationFromArg } from "./graphql.js"; import { i18nRenderedText, type I18nOptions, loadLocaleTable, type LocaleTable } from "./i18n.js"; import { linkOpenApiResponses, loadOpenApi, responseFromCall } from "./response.js"; import { detectRoutes } from "./routes.js"; @@ -1011,6 +1012,23 @@ function detectDataSource( }; } + // GraphQL (7.1, C4): Apollo/urql hooks reuse `useQuery`/`useMutation`, so this + // must run before the react-query branch — it only claims the call when the + // argument is an actual gql document (else it falls through to react-query). + if (GRAPHQL_HOOKS.has(callee)) { + const op = graphqlOperationFromArg(call.getArguments()[0]); + if (op !== null) { + const identity = op.name ?? op.rootFields[0] ?? ""; + return { + sourceKind: "graphql", + method: op.type, + endpoint: identity, + raw: (call.getArguments()[0]?.getText() ?? callee).slice(0, 120), + resolved: op.name !== null ? "full" : op.rootFields.length > 0 ? "partial" : "none", + }; + } + } + if (callee === "useQuery" || callee === "useMutation" || callee === "useInfiniteQuery") { let keyText: string | undefined; let fnExpr: Node | undefined;