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:** 1Robust extraction
- **Next step:** 1.6Legacy patterns & graceful degradation
- **Done:** 0.1–0.4, 1.1–1.5
- **Gates passed:** Gate 0 (CI green + red-path verified on PRs #5/#6)
- **Current phase:** 2Instance graph & cross-file data flow
- **Next step:** 2.1Instance tree construction
- **Done:** 0.1–0.4, 1.1–1.6
- **Gates passed:** Gate 0 (CI + red-path, PRs #5/#6) · Gate 1 (precision 1.000 ≥ 0.90, recall 0.895 ≥ 0.80 across C2/C3/C5/A2/A7/A8/D4, zero forbidden hits)

## What CodeRadar is

Expand Down Expand Up @@ -126,7 +126,7 @@ follow-one-reference; the cross-file instance/prop-flow machinery is Phase 2.
- Normalization utility in core (shared with the Phase 4 matcher): lowercase, collapse whitespace, strip punctuation, naive singular/plural folding.
**Accept:** fixtures `a7-transformed-text`, `a8-conditional-text` green (error-state text findable and tagged with its branch).

### [ ] 1.6 Legacy patterns & graceful degradation
### [x] 1.6 Legacy patterns & graceful degradation
**Failure modes:** D4
**Build:**
- Class components: `render()` treated as the body; `this.state`/`setState` → state nodes; lifecycle fetches detected.
Expand Down
8 changes: 8 additions & 0 deletions eval/fixtures/d4-class-components/app/BrokenPanel.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { Component } from "react";

/** No render() — must degrade to an `incomplete`-flagged node, never vanish. */
export class BrokenPanel extends Component {
helper() {
return "not a render method";
}
}
27 changes: 27 additions & 0 deletions eval/fixtures/d4-class-components/app/ConnectedPanel.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
declare function connect(mapState: unknown): <T>(component: T) => T;

function PanelInner({ items }: { items: string[] }) {
return (
<div>
<h3>Connected panel body</h3>
<ul>
{items.map((i) => (
<li key={i}>{i}</li>
))}
</ul>
</div>
);
}

const mapState = (state: { items: string[] }) => ({ items: state.items });

export const Panel = connect(mapState)(PanelInner);

export function Dashboard() {
return (
<main>
<h1>Legacy dashboard</h1>
<Panel />
</main>
);
}
38 changes: 38 additions & 0 deletions eval/fixtures/d4-class-components/app/OrdersBoard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import React from "react";

interface OrdersBoardState {
orders: string[];
failed: boolean;
}

export class OrdersBoard extends React.Component<Record<string, never>, OrdersBoardState> {
state: OrdersBoardState = { orders: [], failed: false };

componentDidMount() {
fetch("/api/orders")
.then((res) => res.json())
.then((orders: string[]) => this.setState({ orders }))
.catch(() => this.setState({ failed: true }));
}

refresh = () => {
fetch("/api/orders")
.then((res) => res.json())
.then((orders: string[]) => this.setState({ orders }));
};

render() {
if (this.state.failed) return <p>Orders unavailable</p>;
return (
<section>
<h2>Orders board</h2>
<ul>
{this.state.orders.map((o) => (
<li key={o}>{o}</li>
))}
</ul>
<button onClick={this.refresh}>Refresh orders</button>
</section>
);
}
}
11 changes: 11 additions & 0 deletions eval/fixtures/d4-class-components/app/legacy/UserBadge.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { Component } from "react";

export class UserBadge extends Component<{ name: string }> {
render() {
return (
<span title="Current user">
Signed in as <strong>{this.props.name}</strong>
</span>
);
}
}
32 changes: 32 additions & 0 deletions eval/fixtures/d4-class-components/golden.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
{
"failureMode": "D4",
"note": "Legacy patterns: class components (React.Component and bare Component), lifecycle fetches, this.state, HOC-wrapped components (connect(map)(Inner)), and a class with no render() that must degrade to an incomplete-flagged node instead of vanishing.",
"expect": {
"components": [
{ "name": "OrdersBoard", "instances": 0 },
{ "name": "UserBadge", "instances": 0 },
{ "name": "PanelInner", "instances": 1 },
{ "name": "Dashboard", "instances": 0 },
{ "name": "BrokenPanel", "instances": 0 }
],
"attributions": [
{ "component": "OrdersBoard", "endpoints": ["/api/orders"] },
{ "component": "UserBadge", "endpoints": [] },
{ "component": "Dashboard", "endpoints": [] }
],
"forbidden": [
{
"component": "OrdersBoard",
"endpoint": "<dynamic>",
"note": "lifecycle fetch collapsed to dynamic means class scanning regressed"
}
],
"queries": [
{ "terms": ["Orders board"], "status": "ok", "top": "OrdersBoard" },
{ "terms": ["Orders unavailable"], "status": "ok", "top": "OrdersBoard" },
{ "terms": ["Signed in as"], "status": "ok", "top": "UserBadge" },
{ "terms": ["Connected panel body"], "status": "ok", "top": "PanelInner" },
{ "terms": ["Legacy dashboard"], "status": "ok", "top": "Dashboard" }
]
}
}
1 change: 1 addition & 0 deletions eval/history.jsonl
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,4 @@
{"generatedAt":"2026-07-13T09:56:46.891Z","commitSha":"44e439834950a42645eb659bb8c387cb5469d03e","pass":60,"fail":0,"xfail":2,"unexpectedPass":0,"lineagePrecision":1,"lineageRecall":0.889,"matchAccuracy":1}
{"generatedAt":"2026-07-13T10:02:55.141Z","commitSha":"67411c5662523fd633383013f6a0591ac3faea3c","pass":67,"fail":0,"xfail":2,"unexpectedPass":0,"lineagePrecision":1,"lineageRecall":0.889,"matchAccuracy":1}
{"generatedAt":"2026-07-13T10:09:02.527Z","commitSha":"4b4fd9e72204c47fa8ad523ff9b68fee41fc3a26","pass":77,"fail":0,"xfail":2,"unexpectedPass":0,"lineagePrecision":1,"lineageRecall":0.889,"matchAccuracy":1}
{"generatedAt":"2026-07-13T11:04:05.130Z","commitSha":"d13b90dd99c993889f57218997984e3e2e601cfd","pass":91,"fail":0,"xfail":2,"unexpectedPass":0,"lineagePrecision":1,"lineageRecall":0.895,"matchAccuracy":1}
4 changes: 3 additions & 1 deletion eval/src/checks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,8 +35,10 @@ export function runChecks(fixture: string, golden: Golden, graph: LineageGraph):
const definition = graph.nodes.find(
(n) => n.kind === "component" && n.name === expected.name,
);
// Count by definition, not by tag name: <Panel/> (an HOC alias) is an
// instance OF PanelInner even though the JSX never says "PanelInner".
const instances = graph.nodes.filter(
(n) => n.kind === "instance" && n.name === expected.name,
(n) => n.kind === "instance" && definition !== undefined && n.definitionId === definition.id,
);
const passed = definition !== undefined && instances.length === expected.instances;
finalize(
Expand Down
4 changes: 4 additions & 0 deletions packages/cli/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,10 @@ program
);
for (const [kind, count] of [...counts].sort()) console.log(` ${kind}: ${count}`);
console.log(` edges: ${graph.edges.length}`);
const incomplete = graph.nodes.filter((n) => n.flags?.includes("incomplete")).length;
if (incomplete > 0) {
console.log(` incomplete: ${incomplete} node(s) could not be fully parsed`);
}
console.log(`Graph written to ${opts.out}`);
});

Expand Down
9 changes: 8 additions & 1 deletion packages/core/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -145,7 +145,14 @@ export interface DataSourceNode extends BaseNode {
queryKey?: string;
}

export type StateKind = "useState" | "useReducer" | "context" | "redux" | "zustand" | "unknown";
export type StateKind =
| "useState"
| "useReducer"
| "context"
| "redux"
| "zustand"
| "class-state"
| "unknown";

/** Local or global state a component reads. */
export interface StateNode extends BaseNode {
Expand Down
70 changes: 70 additions & 0 deletions packages/parser-react/src/legacy.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import path from "node:path";
import { fileURLToPath } from "node:url";

import { traceLineage } from "@coderadar/core";
import { describe, expect, it } from "vitest";

import { scanReact } from "./scan.js";

const fixture = path.resolve(
path.dirname(fileURLToPath(import.meta.url)),
"../../../eval/fixtures/d4-class-components/app",
);

const graph = scanReact({ root: fixture });

function componentNode(name: string) {
const node = graph.nodes.find((n) => n.kind === "component" && n.name === name);
if (node?.kind !== "component") throw new Error(`${name} not found`);
return node;
}

describe("class components (d4 fixture)", () => {
it("detects React.Component and bare Component subclasses", () => {
expect(componentNode("OrdersBoard").exportName).toBe("OrdersBoard");
expect(componentNode("UserBadge").renderedText.some((e) => e.text.includes("Signed in as"))).toBe(
true,
);
});

it("attributes lifecycle fetches to the class component", () => {
const result = traceLineage(graph, componentNode("OrdersBoard").id);
const lineage = result.candidates[0]?.value;
expect(lineage?.dataSources.map((d) => d.endpoint)).toEqual(["/api/orders"]);
});

it("extracts this.state keys as class-state nodes", () => {
const result = traceLineage(graph, componentNode("OrdersBoard").id);
const state = result.candidates[0]?.value.state;
expect(state?.map((s) => `${s.stateKind}:${s.name}`).sort()).toEqual([
"class-state:failed",
"class-state:orders",
]);
});

it("captures method-reference event handlers (this.refresh)", () => {
const result = traceLineage(graph, componentNode("OrdersBoard").id);
const events = result.candidates[0]?.value.events;
expect(events?.map((e) => `${e.event}:${e.handler}`)).toEqual(["onClick:this.refresh"]);
});

it("collects this.props accesses as props", () => {
expect(componentNode("UserBadge").props).toEqual(["name"]);
});
});

describe("HOC unwrapping (d4 fixture)", () => {
it("resolves <Panel/> through connect() to the inner component's definition", () => {
const instance = graph.nodes.find((n) => n.kind === "instance" && n.name === "Panel");
if (instance?.kind !== "instance") throw new Error("Panel instance not found");
expect(instance.definitionId).toBe(componentNode("PanelInner").id);
});
});

describe("graceful degradation (d4 fixture)", () => {
it("emits an incomplete-flagged node for a class with no render()", () => {
const broken = componentNode("BrokenPanel");
expect(broken.flags).toContain("incomplete");
expect(broken.renderedText).toEqual([]);
});
});
Loading
Loading