From f06eaf756d12e0e378e92985a97bafd0cc28e002 Mon Sep 17 00:00:00 2001 From: Deep Kumar Singh Kushwah Date: Fri, 17 Jul 2026 16:45:00 +0530 Subject: [PATCH] feat(push-channels): WebSocket/SSE as data sources (7.3, C8) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Server-push channels are long-lived data sources, not request/response fetches. - New `sse` DataSourceKind (schema regenerated); new pushchannels.ts: detectPushChannel matches `new WebSocket(url)` / `new EventSource(url)` by constructor name (allowing a window./self. qualifier — low false-positive set) and resolves the URL through the existing resolveEndpoint. - extractBodyFacts gains a NewExpression pass emitting the data-source node + fetches-from edge, mirroring the fetch path (skipped inside API wrappers). websocket for WebSocket, sse for EventSource; transport in method (WS/SSE). - New fixture c8-push-channels (WebSocket wss:// + SSE /api/.../stream); 4 unit tests. eval 332/0/0/0, determinism 1.000, all metrics 1.000; parser-react 164 tests, schema drift gate green, typecheck + lint clean. socket.io deferred (needs import-aware detection). Co-Authored-By: Claude Opus 4.8 --- TRACKER.md | 10 ++-- .../c8-push-channels/app/ChatPanel.tsx | 17 +++++++ .../app/NotificationsFeed.tsx | 17 +++++++ eval/fixtures/c8-push-channels/golden.json | 18 +++++++ packages/core/src/types.ts | 1 + .../parser-react/src/pushchannels.test.ts | 50 +++++++++++++++++++ packages/parser-react/src/pushchannels.ts | 42 ++++++++++++++++ packages/parser-react/src/scan.ts | 26 ++++++++++ schemas/lineage-graph.schema.json | 1 + 9 files changed, 179 insertions(+), 3 deletions(-) create mode 100644 eval/fixtures/c8-push-channels/app/ChatPanel.tsx create mode 100644 eval/fixtures/c8-push-channels/app/NotificationsFeed.tsx create mode 100644 eval/fixtures/c8-push-channels/golden.json create mode 100644 packages/parser-react/src/pushchannels.test.ts create mode 100644 packages/parser-react/src/pushchannels.ts diff --git a/TRACKER.md b/TRACKER.md index aafe4f4..c72a61c 100644 --- a/TRACKER.md +++ b/TRACKER.md @@ -5,8 +5,8 @@ ## Status - **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. +- **Next step:** 7.2 Next.js server data, or hold the backend-parser steps (7.4–7.6) for 0.5.0 tester feedback (still sketch-level — detail each before building). 7.1 GraphQL adapter + 7.3 push channels landed (both frontend, low-regret). 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), 7.3 (push channels)** · **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 @@ -713,7 +713,11 @@ Sketch level for 7.2–7.6 — detail each before starting, after v1 feedback. **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. +### [x] 7.3 Push channels +**Failure modes:** C8 +**Build:** recognize server-push channels (`new WebSocket(url)`, `new EventSource(url)`) as data sources — long-lived connections, not request/response fetches — with a `fetches-from` edge from the component that opens them. +**Accept:** fixture with a WebSocket and an SSE channel → one `websocket` / one `sse` data source with the URL as endpoint and a `fetches-from` edge each; no stray sources from unrelated constructors. +**Done:** new `sse` `DataSourceKind` (schema regenerated, drift gate green); new `pushchannels.ts` — `detectPushChannel` matches a `new WebSocket(...)` / `new EventSource(...)` expression (by constructor name, allowing a `window.`/`self.` qualifier — a very low false-positive set) and returns a data source with the URL resolved through the existing `resolveEndpoint` (constant-folded, `:param`-normalized). `extractBodyFacts` gains a `NewExpression` pass that emits the node + `fetches-from` edge, mirroring the fetch path (skipped inside API wrappers). `websocket` for WebSocket, `sse` for EventSource, transport in `method` (`WS`/`SSE`). New fixture `c8-push-channels` (WebSocket `wss://…` + SSE `/api/…/stream`); 4 unit tests. eval 332/0/0/0, determinism 1.000, all metrics 1.000; parser-react 164 tests, typecheck + lint clean. **socket.io deferred** (`io()` needs import-aware detection to avoid false positives on the common `io` identifier). - **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`. - **7.6 Federation** (G6): endpoint-pattern join across graphs (`fetches-from: /api/users` ↔ `serves: /api/users`); multi-graph loader in agent-sdk; bundle lineage extends to the owning service + handler file. diff --git a/eval/fixtures/c8-push-channels/app/ChatPanel.tsx b/eval/fixtures/c8-push-channels/app/ChatPanel.tsx new file mode 100644 index 0000000..d1adc5f --- /dev/null +++ b/eval/fixtures/c8-push-channels/app/ChatPanel.tsx @@ -0,0 +1,17 @@ +import { useEffect, useState } from "react"; + +// WebSocket push channel → websocket data source. +export function ChatPanel() { + const [messages, setMessages] = useState([]); + useEffect(() => { + const socket = new WebSocket("wss://chat.example.com/socket"); + socket.onmessage = (e) => setMessages((m) => [...m, e.data]); + return () => socket.close(); + }, []); + return ( +
+

Team chat

+
    {messages.map((m, i) =>
  • {m}
  • )}
+
+ ); +} diff --git a/eval/fixtures/c8-push-channels/app/NotificationsFeed.tsx b/eval/fixtures/c8-push-channels/app/NotificationsFeed.tsx new file mode 100644 index 0000000..0a3aadd --- /dev/null +++ b/eval/fixtures/c8-push-channels/app/NotificationsFeed.tsx @@ -0,0 +1,17 @@ +import { useEffect, useState } from "react"; + +// Server-Sent Events push channel → sse data source. +export function NotificationsFeed() { + const [count, setCount] = useState(0); + useEffect(() => { + const source = new EventSource("/api/notifications/stream"); + source.onmessage = () => setCount((c) => c + 1); + return () => source.close(); + }, []); + return ( + + ); +} diff --git a/eval/fixtures/c8-push-channels/golden.json b/eval/fixtures/c8-push-channels/golden.json new file mode 100644 index 0000000..6267afb --- /dev/null +++ b/eval/fixtures/c8-push-channels/golden.json @@ -0,0 +1,18 @@ +{ + "failureMode": "C8", + "note": "Push channels (7.3): server-push data arrives over a long-lived connection, not a request/response fetch. `new WebSocket(url)` becomes a websocket data source and `new EventSource(url)` an sse data source, each with the URL as its endpoint and a fetches-from edge from the component that opens it — so a component fed by a live channel shows that channel in its lineage.", + "expect": { + "components": [ + { "name": "ChatPanel", "instances": 0 }, + { "name": "NotificationsFeed", "instances": 0 } + ], + "attributions": [ + { "component": "ChatPanel", "endpoints": ["wss://chat.example.com/socket"] }, + { "component": "NotificationsFeed", "endpoints": ["/api/notifications/stream"] } + ], + "queries": [ + { "terms": ["Team chat"], "status": "ok", "top": "ChatPanel" }, + { "terms": ["Live notifications"], "status": "ok", "top": "NotificationsFeed" } + ] + } +} diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 3398dba..98484ef 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -162,6 +162,7 @@ export type DataSourceKind = | "swr" | "graphql" | "websocket" + | "sse" | "unknown"; /** How much of an endpoint was statically resolvable. */ diff --git a/packages/parser-react/src/pushchannels.test.ts b/packages/parser-react/src/pushchannels.test.ts new file mode 100644 index 0000000..7b881cc --- /dev/null +++ b/packages/parser-react/src/pushchannels.test.ts @@ -0,0 +1,50 @@ +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +import type { DataSourceNode } from "@coderadar/core"; +import { describe, expect, it } from "vitest"; + +import { resolveHookEdges, scanReact } from "./scan.js"; + +const fixtures = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../../eval/fixtures"); +const graph = resolveHookEdges(scanReact({ root: path.join(fixtures, "c8-push-channels/app") })); + +const source = (endpoint: string): DataSourceNode | undefined => + graph.nodes.find((n): n is DataSourceNode => n.kind === "data-source" && n.endpoint === endpoint); + +describe("push-channel extraction (7.3, C8)", () => { + it("emits a websocket data source for new WebSocket(url)", () => { + expect(source("wss://chat.example.com/socket")).toMatchObject({ + sourceKind: "websocket", + method: "WS", + resolved: "full", + }); + }); + + it("emits an sse data source for new EventSource(url)", () => { + expect(source("/api/notifications/stream")).toMatchObject({ + sourceKind: "sse", + method: "SSE", + resolved: "full", + }); + }); + + it("wires a fetches-from edge from the component that opens the channel", () => { + const has = (comp: string, ds: string): boolean => + graph.edges.some( + (e) => e.kind === "fetches-from" && e.from === `component:${comp}.tsx#${comp}` && e.to === ds, + ); + expect(has("ChatPanel", "data-source:ChatPanel.tsx#websocket:wss://chat.example.com/socket")).toBe( + true, + ); + expect(has("NotificationsFeed", "data-source:NotificationsFeed.tsx#sse:/api/notifications/stream")).toBe( + true, + ); + }); + + it("does not treat an unrelated constructor as a data source", () => { + // Sanity: only WebSocket/EventSource are push channels — no stray sources. + const kinds = graph.nodes.flatMap((n) => (n.kind === "data-source" ? [n.sourceKind] : [])); + expect(new Set(kinds)).toStrictEqual(new Set(["websocket", "sse"])); + }); +}); diff --git a/packages/parser-react/src/pushchannels.ts b/packages/parser-react/src/pushchannels.ts new file mode 100644 index 0000000..20a43f7 --- /dev/null +++ b/packages/parser-react/src/pushchannels.ts @@ -0,0 +1,42 @@ +/** + * Push-channel extraction (TRACKER step 7.3, failure mode C8). + * + * Server-push data arrives over a long-lived connection, not a request/response + * fetch: the browser `WebSocket` and Server-Sent-Events `EventSource` + * constructors. Each `new WebSocket(url)` / `new EventSource(url)` becomes a data + * source — `websocket` / `sse` sourceKind, the URL as its endpoint — so a + * component fed by a live channel shows that channel in its lineage. + */ +import type { DataSourceKind } from "@coderadar/core"; +import { Node } from "ts-morph"; + +import { resolveEndpoint, type ResolvedEndpoint } from "./endpoint.js"; + +export interface PushChannel extends ResolvedEndpoint { + sourceKind: DataSourceKind; + /** Transport label: "WS" for WebSocket, "SSE" for EventSource. */ + method: string; +} + +/** The last identifier in a (possibly namespaced) callee, e.g. `window.WebSocket` → `WebSocket`. */ +function calleeName(expr: string): string { + return expr.split(".").pop() ?? expr; +} + +/** + * A push-channel data source for a `new WebSocket(url)` / `new EventSource(url)` + * expression, or null. Only the standard global constructors are matched (by + * name, including a `window.`/`self.` qualifier) — a very low false-positive set. + */ +export function detectPushChannel(node: Node, baseUrls: string[]): PushChannel | null { + if (!Node.isNewExpression(node)) return null; + const name = calleeName(node.getExpression().getText()); + const urlArg = node.getArguments()?.[0]; + if (name === "WebSocket") { + return { sourceKind: "websocket", method: "WS", ...resolveEndpoint(urlArg, baseUrls) }; + } + if (name === "EventSource") { + return { sourceKind: "sse", method: "SSE", ...resolveEndpoint(urlArg, baseUrls) }; + } + return null; +} diff --git a/packages/parser-react/src/scan.ts b/packages/parser-react/src/scan.ts index 75b9a7f..c3122ba 100644 --- a/packages/parser-react/src/scan.ts +++ b/packages/parser-react/src/scan.ts @@ -34,6 +34,7 @@ import { import { fetchMethod, resolveEndpoint, type ResolvedEndpoint, resolveStringValue } from "./endpoint.js"; import { decodeEntities } from "./entities.js"; import { GRAPHQL_HOOKS, graphqlOperationFromArg } from "./graphql.js"; +import { detectPushChannel } from "./pushchannels.js"; import { i18nRenderedText, type I18nOptions, loadLocaleTable, type LocaleTable } from "./i18n.js"; import { linkOpenApiResponses, loadOpenApi, responseFromCall } from "./response.js"; import { detectRoutes } from "./routes.js"; @@ -893,6 +894,31 @@ function extractBodyFacts( } } + // Push channels (7.3, C8): new WebSocket(url) / new EventSource(url) are + // long-lived server-push data sources, not request/response fetches. A + // wrapper's own body carries a placeholder URL, so skip it like fetch sources. + if (!declIsWrapper) { + for (const expr of body.getDescendantsOfKind(SyntaxKind.NewExpression)) { + const channel = detectPushChannel(expr, baseUrls); + if (channel === null) continue; + const dsId = nodeId("data-source", file, `${channel.sourceKind}:${channel.endpoint}`); + if (!nodes.has(dsId)) { + nodes.set(dsId, { + id: dsId, + kind: "data-source", + name: channel.endpoint, + loc: locOf(expr, file), + sourceKind: channel.sourceKind, + method: channel.method, + endpoint: channel.endpoint, + raw: channel.raw, + resolved: channel.resolved, + }); + } + addEdge({ from: ownerId, to: dsId, kind: "fetches-from" }); + } + } + for (const attr of body.getDescendantsOfKind(SyntaxKind.JsxAttribute)) { const attrName = attr.getNameNode().getText(); // / — a link that leaves the app (B9). diff --git a/schemas/lineage-graph.schema.json b/schemas/lineage-graph.schema.json index a9bdd51..49aba57 100644 --- a/schemas/lineage-graph.schema.json +++ b/schemas/lineage-graph.schema.json @@ -467,6 +467,7 @@ "swr", "graphql", "websocket", + "sse", "unknown" ] },