Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 10 additions & 6 deletions TRACKER.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@

## Status

- **Current phase:** 6Lifecycle, 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:** 7Backend 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
Expand Down Expand Up @@ -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`.
Expand Down
16 changes: 16 additions & 0 deletions eval/fixtures/c4-graphql/app/InviteForm.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<form onSubmit={() => createUser({ variables: { input: {} } })}>
<h3>Invite teammate</h3>
<button type="submit">Send invite</button>
</form>
);
}
21 changes: 21 additions & 0 deletions eval/fixtures/c4-graphql/app/LiveTicker.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div>
<h3>Live updates</h3>
<span>{data?.tick?.at ?? "waiting"}</span>
</div>
);
}
14 changes: 14 additions & 0 deletions eval/fixtures/c4-graphql/app/UsersPanel.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<section>
<h2>User directory</h2>
<ul>{data?.users?.map((u: { id: string; name: string }) => <li key={u.id}>{u.name}</li>)}</ul>
</section>
);
}
21 changes: 21 additions & 0 deletions eval/fixtures/c4-graphql/app/queries.ts
Original file line number Diff line number Diff line change
@@ -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
}
}
`;
21 changes: 21 additions & 0 deletions eval/fixtures/c4-graphql/golden.json
Original file line number Diff line number Diff line change
@@ -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" }
]
}
}
84 changes: 84 additions & 0 deletions packages/parser-react/src/graphql.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
134 changes: 134 additions & 0 deletions packages/parser-react/src/graphql.ts
Original file line number Diff line number Diff line change
@@ -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<string> = new Set([
"useQuery",
"useMutation",
"useSubscription",
"useLazyQuery",
]);

/** Tag names that mark a template literal as a GraphQL document. */
const GQL_TAGS: ReadonlySet<string> = 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;
}
18 changes: 18 additions & 0 deletions packages/parser-react/src/scan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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] ?? "<anonymous>";
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;
Expand Down
Loading