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: 5 additions & 5 deletions TRACKER.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,10 @@

## Status

- **Current phase:** 0Foundations
- **Next step:** 1.1Endpoint resolution (constants, templates, patterns)
- **Done:** 0.1, 0.2, 0.3, 0.4
- **Gates passed:** none yet (Gate 0 completes with 0.4)
- **Current phase:** 1Robust extraction
- **Next step:** 1.2API-client wrapper adapter
- **Done:** 0.1–0.4, 1.1
- **Gates passed:** Gate 0 (CI green + red-path verified on PRs #5/#6)

## What CodeRadar is

Expand Down Expand Up @@ -88,7 +88,7 @@ The v0.1 schema is definition-only, which is *wrong* for C1 — fix before build
Make the parser survive real code instead of demo code. All work is within-file or
follow-one-reference; the cross-file instance/prop-flow machinery is Phase 2.

### [ ] 1.1 Endpoint resolution — constants, templates, patterns
### [x] 1.1 Endpoint resolution — constants, templates, patterns
**Failure modes:** C2 (constants half), C3
**Build:** in `parser-react`:
- Resolve identifier arguments to fetch/axios through imports to their declarations (constant folding: string literals, `const` object members like `ENDPOINTS.USERS`, simple concatenation).
Expand Down
15 changes: 15 additions & 0 deletions eval/fixtures/c2-endpoint-constants/app/HealthBadge.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { useEffect, useState } from "react";

import { HEALTH_URL } from "./api/endpoints";

export function HealthBadge() {
const [status, setStatus] = useState("unknown");

useEffect(() => {
fetch(HEALTH_URL)
.then((res) => res.json())
.then((body: { status: string }) => setStatus(body.status));
}, []);

return <span title="System status">{status}</span>;
}
24 changes: 24 additions & 0 deletions eval/fixtures/c2-endpoint-constants/app/ReportsPanel.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { useEffect, useState } from "react";

import { API_PREFIX } from "./api/endpoints";

export function ReportsPanel() {
const [reports, setReports] = useState<string[]>([]);

useEffect(() => {
fetch(API_PREFIX + "/reports")
.then((res) => res.json())
.then(setReports);
}, []);

return (
<section>
<h2>Weekly reports</h2>
<ul>
{reports.map((r) => (
<li key={r}>{r}</li>
))}
</ul>
</section>
);
}
24 changes: 24 additions & 0 deletions eval/fixtures/c2-endpoint-constants/app/UsersPanel.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { useEffect, useState } from "react";

import { ENDPOINTS } from "./api/endpoints";

export function UsersPanel() {
const [users, setUsers] = useState<string[]>([]);

useEffect(() => {
fetch(ENDPOINTS.USERS)
.then((res) => res.json())
.then(setUsers);
}, []);

return (
<section>
<h2>User Directory</h2>
<ul>
{users.map((u) => (
<li key={u}>{u}</li>
))}
</ul>
</section>
);
}
8 changes: 8 additions & 0 deletions eval/fixtures/c2-endpoint-constants/app/api/endpoints.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
export const ENDPOINTS = {
USERS: "/api/users",
INVOICES: "/api/invoices",
};

export const HEALTH_URL = "/api/health";

export const API_PREFIX = "/api";
27 changes: 27 additions & 0 deletions eval/fixtures/c2-endpoint-constants/golden.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
{
"failureMode": "C2",
"note": "Endpoints hidden behind constants: an object member (ENDPOINTS.USERS), a plain named constant (HEALTH_URL), and a concatenation (API_PREFIX + '/reports') — all declared in a different file than the fetch. Constant folding must recover the literal endpoint.",
"expect": {
"components": [
{ "name": "UsersPanel", "instances": 0 },
{ "name": "HealthBadge", "instances": 0 },
{ "name": "ReportsPanel", "instances": 0 }
],
"attributions": [
{ "component": "UsersPanel", "endpoints": ["/api/users"] },
{ "component": "HealthBadge", "endpoints": ["/api/health"] },
{ "component": "ReportsPanel", "endpoints": ["/api/reports"] }
],
"forbidden": [
{
"component": "UsersPanel",
"endpoint": "<dynamic>",
"note": "constant-hidden endpoint reported as dynamic means folding regressed"
}
],
"queries": [
{ "terms": ["User Directory"], "status": "ok", "top": "UsersPanel" },
{ "terms": ["Weekly reports"], "status": "ok", "top": "ReportsPanel" }
]
}
}
27 changes: 27 additions & 0 deletions eval/fixtures/c3-dynamic-endpoints/app/OrderDetail.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { useEffect, useState } from "react";

export function OrderDetail({ orderId, entity }: { orderId: string; entity: string }) {
const [order, setOrder] = useState<Record<string, string> | null>(null);
const [related, setRelated] = useState<string[]>([]);

useEffect(() => {
fetch(`/api/orders/${orderId}`)
.then((res) => res.json())
.then(setOrder);
fetch(`/api/${entity}/list`)
.then((res) => res.json())
.then(setRelated);
}, [orderId, entity]);

return (
<article>
<h1>Order detail</h1>
<p>{order?.status}</p>
<ul>
{related.map((r) => (
<li key={r}>{r}</li>
))}
</ul>
</article>
);
}
21 changes: 21 additions & 0 deletions eval/fixtures/c3-dynamic-endpoints/golden.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
{
"failureMode": "C3",
"note": "Runtime-valued endpoints: `/api/orders/${orderId}` must resolve to the pattern /api/orders/:orderId (partial), and `/api/${entity}/list` — where even the resource segment is dynamic — to /api/:entity/list. The shape is preserved, the unknowns are flagged, and nothing collapses to <dynamic>.",
"expect": {
"components": [{ "name": "OrderDetail", "instances": 0 }],
"attributions": [
{
"component": "OrderDetail",
"endpoints": ["/api/orders/:orderId", "/api/:entity/list"]
}
],
"forbidden": [
{
"component": "OrderDetail",
"endpoint": "<dynamic>",
"note": "template endpoints with known shape must not collapse to dynamic"
}
],
"queries": [{ "terms": ["Order detail"], "status": "ok", "top": "OrderDetail" }]
}
}
4 changes: 2 additions & 2 deletions eval/fixtures/demo-app/golden.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,11 @@
"attributions": [
{
"component": "UserList",
"endpoints": ["/api/users", "/api/users/${user.id}"]
"endpoints": ["/api/users", "/api/users/:id"]
},
{
"component": "UserCard",
"endpoints": ["/api/users/${user.id}"]
"endpoints": ["/api/users/:id"]
}
],
"queries": [
Expand Down
1 change: 1 addition & 0 deletions eval/history.jsonl
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
{"generatedAt":"2026-07-12T20:53:38.653Z","commitSha":"25e035d50ddddb72c9e8c02febc9785e7a64bb3c","pass":25,"fail":0,"xfail":2,"unexpectedPass":0,"lineagePrecision":1,"lineageRecall":0.714,"matchAccuracy":1}
{"generatedAt":"2026-07-13T09:45:35.873Z","commitSha":"d59ef32a575e12295e2fcc309d44dd9538a458e1","pass":38,"fail":0,"xfail":2,"unexpectedPass":0,"lineagePrecision":1,"lineageRecall":0.833,"matchAccuracy":1}
4 changes: 3 additions & 1 deletion eval/thresholds.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
{
"maxFail": 0,
"maxUnexpectedPass": 0,
"minMatchAccuracy": 1.0
"minMatchAccuracy": 1.0,
"minLineagePrecision": 0.9,
"minLineageRecall": 0.8
}
14 changes: 12 additions & 2 deletions packages/core/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,17 +95,27 @@ export type DataSourceKind =
| "websocket"
| "unknown";

/** How much of an endpoint was statically resolvable. */
export type EndpointResolution =
| "full" // every segment is a known string
| "partial" // known shape with :param placeholders, e.g. "/api/users/:id"
| "none"; // nothing statically known ("<dynamic>")

/** An external data origin: an HTTP endpoint, GraphQL operation, or socket. */
export interface DataSourceNode extends BaseNode {
kind: "data-source";
sourceKind: DataSourceKind;
/** HTTP method when statically determinable. */
method: string | null;
/**
* The endpoint as written in source — may contain template placeholders,
* e.g. "/api/users/${id}".
* Canonical endpoint pattern: constants folded, template placeholders
* normalized to :param form — e.g. "/api/users/:id". This is the value
* attribution and cross-graph joins match on.
*/
endpoint: string;
/** The endpoint expression exactly as written in source. */
raw: string;
resolved: EndpointResolution;
}

export type StateKind = "useState" | "useReducer" | "context" | "redux" | "zustand" | "unknown";
Expand Down
73 changes: 73 additions & 0 deletions packages/parser-react/src/endpoint.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import { type Node, Project, SyntaxKind } from "ts-morph";
import { describe, expect, it } from "vitest";

import { resolveEndpoint } from "./endpoint.js";

/** Parse a snippet and return the first argument of the fetch(...) call in it. */
function fetchArg(code: string): Node | undefined {
const project = new Project({ useInMemoryFileSystem: true, compilerOptions: { allowJs: true } });
const sourceFile = project.createSourceFile("test.ts", code);
const call = sourceFile
.getDescendantsOfKind(SyntaxKind.CallExpression)
.find((c) => c.getExpression().getText() === "fetch");
return call?.getArguments()[0];
}

describe("resolveEndpoint", () => {
it("resolves plain literals as full", () => {
expect(resolveEndpoint(fetchArg(`fetch("/api/a");`), [])).toEqual({
endpoint: "/api/a",
raw: `"/api/a"`,
resolved: "full",
});
});

it("folds a named constant", () => {
const arg = fetchArg(`const USERS = "/api/users"; fetch(USERS);`);
expect(resolveEndpoint(arg, [])).toMatchObject({ endpoint: "/api/users", resolved: "full" });
});

it("folds an object member (ENDPOINTS.USERS)", () => {
const arg = fetchArg(`const E = { USERS: "/api/users" }; fetch(E.USERS);`);
expect(resolveEndpoint(arg, [])).toMatchObject({ endpoint: "/api/users", resolved: "full" });
});

it("folds concatenation of constant + literal", () => {
const arg = fetchArg(`const P = "/api"; fetch(P + "/reports");`);
expect(resolveEndpoint(arg, [])).toMatchObject({ endpoint: "/api/reports", resolved: "full" });
});

it("folds a fully-resolvable template", () => {
const arg = fetchArg(`const V = "v2"; fetch(\`/api/\${V}/users\`);`);
expect(resolveEndpoint(arg, [])).toMatchObject({ endpoint: "/api/v2/users", resolved: "full" });
});

it("turns unknown template parts into :param placeholders", () => {
const arg = fetchArg(`declare const user: { id: string }; fetch(\`/api/users/\${user.id}\`);`);
expect(resolveEndpoint(arg, [])).toMatchObject({
endpoint: "/api/users/:id",
resolved: "partial",
});
});

it("keeps the shape when the resource segment itself is dynamic", () => {
const arg = fetchArg(`declare const entity: string; fetch(\`/api/\${entity}/list\`);`);
expect(resolveEndpoint(arg, [])).toMatchObject({
endpoint: "/api/:entity/list",
resolved: "partial",
});
});

it("reports none for fully-opaque expressions", () => {
const arg = fetchArg(`declare function buildUrl(): string; fetch(buildUrl());`);
expect(resolveEndpoint(arg, [])).toMatchObject({ endpoint: "<dynamic>", resolved: "none" });
});

it("strips configured base URLs", () => {
const arg = fetchArg(`fetch("https://api.example.com/users");`);
expect(resolveEndpoint(arg, ["https://api.example.com"])).toMatchObject({
endpoint: "/users",
resolved: "full",
});
});
});
Loading
Loading