From 5bc6e00192bbfa38f87b514fee9028fe4b6f7d93 Mon Sep 17 00:00:00 2001 From: Deep Kumar Singh Kushwah Date: Mon, 13 Jul 2026 16:34:05 +0530 Subject: [PATCH] =?UTF-8?q?feat(parser-react):=20class=20components,=20HOC?= =?UTF-8?q?=20unwrapping,=20graceful=20degradation=20=E2=80=94=20Gate=201?= =?UTF-8?q?=20complete?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Step 1.6 (TRACKER). Failure mode D4; closes Phase 1: - Class components: React.Component/PureComponent/bare Component subclasses scanned — render() is the text/instance/event body, the whole class body yields lifecycle fetches (componentDidMount), this.state keys become class-state nodes, this.props.X accesses become props, and method- reference handlers (onClick={this.refresh}) resolve. - HOC unwrapping: const Panel = connect(map)(PanelInner) records an alias; call sites materialize as instances OF PanelInner (alias chain bounded at 3). Eval components check now counts instances by definitionId, not tag name — the semantically correct reading the HOC case exposed. - Graceful degradation: a class with no render() emits an incomplete-flagged node instead of vanishing; coderadar scan prints the incomplete count. - Body-walking helpers genericized from FunctionLike to Node so classes and functions share extraction (extractRenderedText/BodyFacts/InstanceSites). - Scorecard: 91 pass / 0 fail / 2 xfail; precision 1.000, recall 0.895. 67 unit tests. GATE 1 PASSES. Co-Authored-By: Claude Fable 5 --- TRACKER.md | 10 +- .../d4-class-components/app/BrokenPanel.tsx | 8 + .../app/ConnectedPanel.tsx | 27 +++ .../d4-class-components/app/OrdersBoard.tsx | 38 ++++ .../app/legacy/UserBadge.tsx | 11 + eval/fixtures/d4-class-components/golden.json | 32 +++ eval/history.jsonl | 1 + eval/src/checks.ts | 4 +- packages/cli/src/index.ts | 4 + packages/core/src/types.ts | 9 +- packages/parser-react/src/legacy.test.ts | 70 +++++++ packages/parser-react/src/scan.ts | 196 ++++++++++++++++-- schemas/lineage-graph.schema.json | 1 + 13 files changed, 387 insertions(+), 24 deletions(-) create mode 100644 eval/fixtures/d4-class-components/app/BrokenPanel.tsx create mode 100644 eval/fixtures/d4-class-components/app/ConnectedPanel.tsx create mode 100644 eval/fixtures/d4-class-components/app/OrdersBoard.tsx create mode 100644 eval/fixtures/d4-class-components/app/legacy/UserBadge.tsx create mode 100644 eval/fixtures/d4-class-components/golden.json create mode 100644 packages/parser-react/src/legacy.test.ts diff --git a/TRACKER.md b/TRACKER.md index 6cddfc1..4845520 100644 --- a/TRACKER.md +++ b/TRACKER.md @@ -4,10 +4,10 @@ ## Status -- **Current phase:** 1 — Robust extraction -- **Next step:** 1.6 — Legacy 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:** 2 — Instance graph & cross-file data flow +- **Next step:** 2.1 — Instance 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 @@ -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. diff --git a/eval/fixtures/d4-class-components/app/BrokenPanel.tsx b/eval/fixtures/d4-class-components/app/BrokenPanel.tsx new file mode 100644 index 0000000..f1e10b8 --- /dev/null +++ b/eval/fixtures/d4-class-components/app/BrokenPanel.tsx @@ -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"; + } +} diff --git a/eval/fixtures/d4-class-components/app/ConnectedPanel.tsx b/eval/fixtures/d4-class-components/app/ConnectedPanel.tsx new file mode 100644 index 0000000..9c5c4a7 --- /dev/null +++ b/eval/fixtures/d4-class-components/app/ConnectedPanel.tsx @@ -0,0 +1,27 @@ +declare function connect(mapState: unknown): (component: T) => T; + +function PanelInner({ items }: { items: string[] }) { + return ( +
+

Connected panel body

+
    + {items.map((i) => ( +
  • {i}
  • + ))} +
+
+ ); +} + +const mapState = (state: { items: string[] }) => ({ items: state.items }); + +export const Panel = connect(mapState)(PanelInner); + +export function Dashboard() { + return ( +
+

Legacy dashboard

+ +
+ ); +} diff --git a/eval/fixtures/d4-class-components/app/OrdersBoard.tsx b/eval/fixtures/d4-class-components/app/OrdersBoard.tsx new file mode 100644 index 0000000..e1f917b --- /dev/null +++ b/eval/fixtures/d4-class-components/app/OrdersBoard.tsx @@ -0,0 +1,38 @@ +import React from "react"; + +interface OrdersBoardState { + orders: string[]; + failed: boolean; +} + +export class OrdersBoard extends React.Component, 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

Orders unavailable

; + return ( +
+

Orders board

+
    + {this.state.orders.map((o) => ( +
  • {o}
  • + ))} +
+ +
+ ); + } +} diff --git a/eval/fixtures/d4-class-components/app/legacy/UserBadge.tsx b/eval/fixtures/d4-class-components/app/legacy/UserBadge.tsx new file mode 100644 index 0000000..54cfc66 --- /dev/null +++ b/eval/fixtures/d4-class-components/app/legacy/UserBadge.tsx @@ -0,0 +1,11 @@ +import { Component } from "react"; + +export class UserBadge extends Component<{ name: string }> { + render() { + return ( + + Signed in as {this.props.name} + + ); + } +} diff --git a/eval/fixtures/d4-class-components/golden.json b/eval/fixtures/d4-class-components/golden.json new file mode 100644 index 0000000..2734564 --- /dev/null +++ b/eval/fixtures/d4-class-components/golden.json @@ -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": "", + "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" } + ] + } +} diff --git a/eval/history.jsonl b/eval/history.jsonl index b7967de..c5b77c8 100644 --- a/eval/history.jsonl +++ b/eval/history.jsonl @@ -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} diff --git a/eval/src/checks.ts b/eval/src/checks.ts index f23e443..3bc0f89 100644 --- a/eval/src/checks.ts +++ b/eval/src/checks.ts @@ -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: (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( diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 563bfc9..68aa15e 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -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}`); }); diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 060189b..826f348 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -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 { diff --git a/packages/parser-react/src/legacy.test.ts b/packages/parser-react/src/legacy.test.ts new file mode 100644 index 0000000..28b2a1d --- /dev/null +++ b/packages/parser-react/src/legacy.test.ts @@ -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 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([]); + }); +}); diff --git a/packages/parser-react/src/scan.ts b/packages/parser-react/src/scan.ts index 1cd969b..e05228a 100644 --- a/packages/parser-react/src/scan.ts +++ b/packages/parser-react/src/scan.ts @@ -109,6 +109,8 @@ export function scanReact(options: ScanOptions): LineageGraph { const nodes = new Map(); const edges: LineageEdge[] = []; const pendingInstances: PendingInstance[] = []; + /** HOC-wrapped alias → inner component name, e.g. Panel → PanelInner (connect). */ + const hocAliases = new Map(); const addEdge = (edge: LineageEdge): void => { if (!edges.some((e) => e.from === edge.from && e.to === edge.to && e.kind === edge.kind)) { edges.push(edge); @@ -139,16 +141,19 @@ export function scanReact(options: ScanOptions): LineageGraph { ], rendersComponents: extractRenderedComponents(decl.fn), }); - collectInstanceSites(decl, id, file, pendingInstances); + collectInstanceSites(decl.fn, id, file, pendingInstances); } else { nodes.set(id, { id, kind: "hook", name: decl.name, loc: decl.loc, exportName: decl.exportName }); } - extractBodyFacts(decl, id, file, nodes, addEdge, baseUrls, wrappers); + extractBodyFacts(decl.name, decl.fn, id, file, nodes, addEdge, baseUrls, wrappers); } + + scanClassComponents(sourceFile, file, nodes, addEdge, baseUrls, wrappers, localeTable, pendingInstances); + collectHocAliases(sourceFile, hocAliases); } - materializeInstances(pendingInstances, nodes, addEdge); + materializeInstances(pendingInstances, nodes, addEdge, hocAliases); return { version: 2, @@ -241,7 +246,7 @@ function extractProps(fn: FunctionLike): string[] { return [first.getName()]; } -function extractRenderedText(fn: FunctionLike): RenderedText[] { +function extractRenderedText(fn: Node): RenderedText[] { const entries = new Map(); const add = (entry: RenderedText): void => { entries.set(`${entry.source}:${entry.text}:${entry.branch ?? ""}`, entry); @@ -301,7 +306,7 @@ function extractRenderedText(fn: FunctionLike): RenderedText[] { * `cond && ` gate, or an enclosing if-statement (early-return pattern). * Ternary else-branches are negated. Empty object when unconditional. */ -function branchTag(node: Node, boundary: FunctionLike): { branch?: string } { +function branchTag(node: Node, boundary: Node): { branch?: string } { let current: Node | undefined = node; while (current !== undefined && current !== boundary) { const parent: Node | undefined = current.getParent(); @@ -326,7 +331,7 @@ function branchTag(node: Node, boundary: FunctionLike): { branch?: string } { return {}; } -function extractRenderedComponents(fn: FunctionLike): string[] { +function extractRenderedComponents(fn: Node): string[] { const names = new Set(); const record = (tagName: string): void => { const head = tagName.split(".")[0]; @@ -347,7 +352,8 @@ function extractRenderedComponents(fn: FunctionLike): string[] { * and same-file hook calls. */ function extractBodyFacts( - decl: Declaration, + declName: string, + body: Node, ownerId: string, file: string, nodes: Map, @@ -358,9 +364,9 @@ function extractBodyFacts( // A wrapper's own body is plumbing: its URL is a parameter placeholder, so a // data source emitted here would attribute ":path" to every consumer. Call // sites get the real, substituted endpoint instead. - const declIsWrapper = wrappers.has(decl.name); + const declIsWrapper = wrappers.has(declName); - for (const call of decl.fn.getDescendantsOfKind(SyntaxKind.CallExpression)) { + for (const call of body.getDescendantsOfKind(SyntaxKind.CallExpression)) { const callee = call.getExpression().getText(); const dataSource = declIsWrapper ? null : detectDataSource(call, callee, baseUrls, wrappers); @@ -387,7 +393,7 @@ function extractBodyFacts( const stateKind = detectState(callee); if (stateKind !== null) { const stateName = stateVariableName(call) ?? callee; - const stId = nodeId("state", file, `${decl.name}.${stateName}`); + const stId = nodeId("state", file, `${declName}.${stateName}`); if (!nodes.has(stId)) { nodes.set(stId, { id: stId, @@ -407,16 +413,22 @@ function extractBodyFacts( } } - for (const attr of decl.fn.getDescendantsOfKind(SyntaxKind.JsxAttribute)) { + for (const attr of body.getDescendantsOfKind(SyntaxKind.JsxAttribute)) { const attrName = attr.getNameNode().getText(); if (!/^on[A-Z]/.test(attrName)) continue; const init = attr.getInitializer(); let handler: string | null = null; if (init !== undefined && Node.isJsxExpression(init)) { const expr = init.getExpression(); - if (expr !== undefined && Node.isIdentifier(expr)) handler = expr.getText(); + // Plain references (handleDelete) and method references (this.refresh). + if ( + expr !== undefined && + (Node.isIdentifier(expr) || Node.isPropertyAccessExpression(expr)) + ) { + handler = expr.getText(); + } } - const evId = nodeId("event", file, `${decl.name}.${attrName}${handler !== null ? `:${handler}` : ""}`); + const evId = nodeId("event", file, `${declName}.${attrName}${handler !== null ? `:${handler}` : ""}`); if (!nodes.has(evId)) { nodes.set(evId, { id: evId, @@ -616,9 +628,146 @@ function stateVariableName(call: CallExpression): string | null { return null; } +const CLASS_COMPONENT_BASE = /(React\.)?(Pure)?Component/; + +/** Legacy class components: render() is the body, this.state is state, lifecycle fetches count. */ +function scanClassComponents( + sourceFile: SourceFile, + file: string, + nodes: Map, + addEdge: (edge: LineageEdge) => void, + baseUrls: string[], + wrappers: WrapperRegistry, + localeTable: LocaleTable | null, + pendingInstances: PendingInstance[], +): void { + for (const cls of sourceFile.getClasses()) { + const name = cls.getName(); + if (name === undefined || !COMPONENT_NAME.test(name)) continue; + const heritage = cls.getExtends()?.getText() ?? ""; + if (!CLASS_COMPONENT_BASE.test(heritage)) continue; + + const id = nodeId("component", file, name); + const exportName = cls.isDefaultExport() ? "default" : cls.isExported() ? name : null; + const render = cls.getMethod("render"); + + if (render === undefined) { + nodes.set(id, { + id, + kind: "component", + name, + loc: locOf(cls, file), + exportName, + props: [], + renderedText: [], + rendersComponents: [], + flags: ["incomplete"], + }); + continue; + } + + nodes.set(id, { + id, + kind: "component", + name, + loc: locOf(cls, file), + exportName, + props: classProps(cls), + renderedText: [ + ...extractRenderedText(render), + ...(localeTable !== null ? i18nRenderedText(render, localeTable) : []), + ], + rendersComponents: extractRenderedComponents(render), + }); + collectInstanceSites(render, id, file, pendingInstances); + // Whole class body: lifecycle fetches (componentDidMount etc.) + render events. + extractBodyFacts(name, cls, id, file, nodes, addEdge, baseUrls, wrappers); + extractClassState(cls, name, id, file, nodes, addEdge); + } +} + +/** `this.props.` accesses stand in for destructured props. */ +function classProps(cls: Node): string[] { + const props = new Set(); + for (const access of cls.getDescendantsOfKind(SyntaxKind.PropertyAccessExpression)) { + if (access.getExpression().getText() === "this.props") props.add(access.getName()); + } + return [...props]; +} + +/** State keys from `state = {...}` property or `this.state = {...}` in the constructor. */ +function extractClassState( + cls: Node, + componentName: string, + ownerId: string, + file: string, + nodes: Map, + addEdge: (edge: LineageEdge) => void, +): void { + const stateKeys = new Map(); + const collectKeys = (objectLiteral: Node): void => { + if (!Node.isObjectLiteralExpression(objectLiteral)) return; + for (const member of objectLiteral.getProperties()) { + if (Node.isPropertyAssignment(member) || Node.isShorthandPropertyAssignment(member)) { + stateKeys.set(member.getName(), member); + } + } + }; + for (const property of cls.getDescendantsOfKind(SyntaxKind.PropertyDeclaration)) { + if (property.getName() !== "state") continue; + const init = property.getInitializer(); + if (init !== undefined) collectKeys(init); + } + for (const assignment of cls.getDescendantsOfKind(SyntaxKind.BinaryExpression)) { + if (assignment.getLeft().getText() === "this.state") collectKeys(assignment.getRight()); + } + for (const [key, node] of stateKeys) { + const stId = nodeId("state", file, `${componentName}.${key}`); + if (!nodes.has(stId)) { + nodes.set(stId, { + id: stId, + kind: "state", + name: key, + loc: locOf(node, file), + stateKind: "class-state", + }); + } + addEdge({ from: ownerId, to: stId, kind: "reads-state" }); + } +} + +const HOC_NAMES = /^(connect|withRouter|memo|forwardRef|observer|inject|withTranslation|styled)\b/; + +/** + * `const Panel = connect(mapState)(PanelInner)` — record Panel → PanelInner so + * call sites resolve to the inner component's definition. + */ +function collectHocAliases(sourceFile: SourceFile, hocAliases: Map): void { + for (const variable of sourceFile.getVariableDeclarations()) { + const name = variable.getName(); + if (!COMPONENT_NAME.test(name)) continue; + const init = variable.getInitializer(); + if (init === undefined || !Node.isCallExpression(init)) continue; + if (unwrapFunction(init) !== undefined) continue; // inline-fn HOCs are handled as declarations + const outermostCallee = init.getExpression().getText(); + if (!HOC_NAMES.test(outermostCallee)) continue; + const inner = findComponentArgument(init); + if (inner !== null && inner !== name) hocAliases.set(name, inner); + } +} + +/** First capitalized identifier argument anywhere in a (possibly curried) call chain. */ +function findComponentArgument(call: Node): string | null { + if (!Node.isCallExpression(call)) return null; + for (const arg of call.getArguments()) { + if (Node.isIdentifier(arg) && COMPONENT_NAME.test(arg.getText())) return arg.getText(); + } + return findComponentArgument(call.getExpression()); +} + /** Record every JSX call site of a capitalized component within a declaration body. */ function collectInstanceSites( - decl: Declaration, + body: Node, ownerId: string, file: string, pendingInstances: PendingInstance[], @@ -636,8 +785,8 @@ function collectInstanceSites( } pendingInstances.push({ tagName: head, loc: locOf(el, file), staticProps, ownerId, file }); }; - for (const el of decl.fn.getDescendantsOfKind(SyntaxKind.JsxOpeningElement)) record(el); - for (const el of decl.fn.getDescendantsOfKind(SyntaxKind.JsxSelfClosingElement)) record(el); + for (const el of body.getDescendantsOfKind(SyntaxKind.JsxOpeningElement)) record(el); + for (const el of body.getDescendantsOfKind(SyntaxKind.JsxSelfClosingElement)) record(el); } /** @@ -652,14 +801,27 @@ function materializeInstances( pendingInstances: PendingInstance[], nodes: Map, addEdge: (edge: LineageEdge) => void, + hocAliases: ReadonlyMap, ): void { const definitionsByName = new Map(); for (const node of nodes.values()) { if (node.kind === "component") definitionsByName.set(node.name, node.id); } + // where Panel = connect(...)(PanelInner): resolve through the alias + // chain (bounded — alias graphs are tiny and could in principle cycle). + const resolveAlias = (tagName: string): string => { + let name = tagName; + for (let hop = 0; hop < 3; hop += 1) { + const target = hocAliases.get(name); + if (target === undefined) break; + name = target; + } + return name; + }; + for (const pending of pendingInstances) { - const definitionId = definitionsByName.get(pending.tagName); + const definitionId = definitionsByName.get(resolveAlias(pending.tagName)); if (definitionId === undefined || definitionId === pending.ownerId) continue; let id = instanceId(pending.file, pending.loc.line, pending.tagName); diff --git a/schemas/lineage-graph.schema.json b/schemas/lineage-graph.schema.json index 4eec9e8..15cca29 100644 --- a/schemas/lineage-graph.schema.json +++ b/schemas/lineage-graph.schema.json @@ -446,6 +446,7 @@ "context", "redux", "zustand", + "class-state", "unknown" ] },