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
10 changes: 7 additions & 3 deletions TRACKER.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
17 changes: 17 additions & 0 deletions eval/fixtures/c8-push-channels/app/ChatPanel.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { useEffect, useState } from "react";

// WebSocket push channel → websocket data source.
export function ChatPanel() {
const [messages, setMessages] = useState<string[]>([]);
useEffect(() => {
const socket = new WebSocket("wss://chat.example.com/socket");
socket.onmessage = (e) => setMessages((m) => [...m, e.data]);
return () => socket.close();
}, []);
return (
<section>
<h2>Team chat</h2>
<ul>{messages.map((m, i) => <li key={i}>{m}</li>)}</ul>
</section>
);
}
17 changes: 17 additions & 0 deletions eval/fixtures/c8-push-channels/app/NotificationsFeed.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<aside>
<h3>Live notifications</h3>
<span>{count} unread</span>
</aside>
);
}
18 changes: 18 additions & 0 deletions eval/fixtures/c8-push-channels/golden.json
Original file line number Diff line number Diff line change
@@ -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" }
]
}
}
1 change: 1 addition & 0 deletions packages/core/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,7 @@ export type DataSourceKind =
| "swr"
| "graphql"
| "websocket"
| "sse"
| "unknown";

/** How much of an endpoint was statically resolvable. */
Expand Down
50 changes: 50 additions & 0 deletions packages/parser-react/src/pushchannels.test.ts
Original file line number Diff line number Diff line change
@@ -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"]));
});
});
42 changes: 42 additions & 0 deletions packages/parser-react/src/pushchannels.ts
Original file line number Diff line number Diff line change
@@ -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;
}
26 changes: 26 additions & 0 deletions packages/parser-react/src/scan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 href="https://…"> / <Link to="mailto:…"> — a link that leaves the app (B9).
Expand Down
1 change: 1 addition & 0 deletions schemas/lineage-graph.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -467,6 +467,7 @@
"swr",
"graphql",
"websocket",
"sse",
"unknown"
]
},
Expand Down
Loading