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:** 2Instance graph & cross-file data flow
- **Next step:** 2.5Portals, modals, toasts
- **Done:** 0.1–0.4, 1.1–1.6, 2.1–2.4
- **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)
- **Current phase:** 3Journey graph
- **Next step:** 3.1Router adapters
- **Done:** 0.1–0.4, 1.1–1.6, 2.1–2.5
- **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)

## What CodeRadar is

Expand Down Expand Up @@ -173,7 +173,7 @@ The heart of the project. C1 and B1 live here.
- `traceLineage` through a StateNode now continues to its writers: reader component → slice → populating API.
**Accept:** fixture `c6-store-decoupled` green — component with **no fetch of its own** correctly attributed to the API called on a different page at login.

### [ ] 2.5 Portals, modals, toasts
### [x] 2.5 Portals, modals, toasts
**Failure modes:** A9
**Build:** `createPortal` detection + adapter list for common modal/toast libs (`react-modal`, radix `Dialog`, `react-hot-toast`): the triggering instance gets a `triggers-render` edge to the portal-content instance.
**Accept:** fixture `a9-modal-portal` green: matching the modal's text surfaces both the modal component and its trigger site. **Gate 2 passes.**
Expand Down
19 changes: 19 additions & 0 deletions eval/fixtures/a9-modal-portal/app/ConfirmDialog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { createPortal } from "react-dom";

export function ConfirmDialog({
open,
onConfirm,
}: {
open: boolean;
onConfirm: () => void;
}) {
if (!open) return null;
return createPortal(
<div role="dialog">
<h2>Delete this order?</h2>
<p>This cannot be undone.</p>
<button onClick={onConfirm}>Confirm delete</button>
</div>,
document.body,
);
}
21 changes: 21 additions & 0 deletions eval/fixtures/a9-modal-portal/app/OrdersToolbar.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { useState } from "react";
import { toast } from "react-hot-toast";

import { ConfirmDialog } from "./ConfirmDialog";

export function OrdersToolbar() {
const [confirming, setConfirming] = useState(false);

const handleConfirm = () => {
fetch("/api/orders/selected", { method: "DELETE" });
setConfirming(false);
toast("Order deleted");
};

return (
<div role="toolbar">
<button onClick={() => setConfirming(true)}>Delete order</button>
<ConfirmDialog open={confirming} onConfirm={handleConfirm} />
</div>
);
}
25 changes: 25 additions & 0 deletions eval/fixtures/a9-modal-portal/golden.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
{
"failureMode": "A9",
"note": "Portals & toasts: the dialog renders into document.body via createPortal — a screenshot of it shows nothing from its trigger's DOM subtree. Matching the modal text must surface ConfirmDialog (whose instance list points at the OrdersToolbar trigger site). Toast text ('Order deleted') exists only as a call argument; it must match the CALLING component.",
"expect": {
"components": [
{ "name": "ConfirmDialog", "instances": 1 },
{ "name": "OrdersToolbar", "instances": 0 }
],
"attributions": [
{ "component": "OrdersToolbar", "endpoints": ["/api/orders/selected"] },
{
"component": "ConfirmDialog",
"instanceAt": "OrdersToolbar.tsx",
"endpoints": ["/api/orders/selected"],
"_note": "not data-in: the dialog's confirm button TRIGGERS the DELETE via the resolved handler chain (2.3) — correct effect lineage"
}
],
"queries": [
{ "terms": ["Delete this order?"], "status": "ok", "top": "ConfirmDialog" },
{ "terms": ["Confirm delete"], "status": "ok", "top": "ConfirmDialog" },
{ "terms": ["Order deleted"], "status": "ok", "top": "OrdersToolbar" },
{ "terms": ["Delete order"], "status": "ok", "top": "OrdersToolbar" }
]
}
}
1 change: 1 addition & 0 deletions eval/history.jsonl
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,4 @@
{"generatedAt":"2026-07-13T11:16:56.457Z","commitSha":"ce7a3bcbf95481114cd60ef62f8e840874ef7453","pass":108,"fail":0,"xfail":0,"unexpectedPass":0,"lineagePrecision":1,"lineageRecall":1,"matchAccuracy":1}
{"generatedAt":"2026-07-13T11:25:04.854Z","commitSha":"cec11f7aab21ef6ec2d6dd0bb247e2cd29981ed9","pass":119,"fail":0,"xfail":0,"unexpectedPass":0,"lineagePrecision":1,"lineageRecall":1,"matchAccuracy":1}
{"generatedAt":"2026-07-14T16:11:46.552Z","commitSha":"4c01687263ff4ed2d656970a2532754636714604","pass":129,"fail":0,"xfail":0,"unexpectedPass":0,"lineagePrecision":1,"lineageRecall":1,"matchAccuracy":1}
{"generatedAt":"2026-07-14T16:19:16.873Z","commitSha":"acbf574f777e001b553ece6ea206ddbe1f1a3892","pass":137,"fail":0,"xfail":0,"unexpectedPass":0,"lineagePrecision":1,"lineageRecall":1,"matchAccuracy":1}
8 changes: 5 additions & 3 deletions packages/core/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,10 +47,12 @@ export interface RenderedText {
text: string;
/**
* Where the text comes from: JSX children, a string attribute
* (placeholder/label/title/alt/aria-label), or an i18n key resolved
* against locale files.
* (placeholder/label/title/alt/aria-label), an i18n key resolved against
* locale files, or portal-rendered content (toast("Order deleted") — the
* text appears far from the caller in the DOM, but the CALLER is the
* component a screenshot of it should match).
*/
source: "jsx" | "attribute" | "i18n";
source: "jsx" | "attribute" | "i18n" | "portal";
/** i18n entries only: the translation key, e.g. "team.title". */
key?: string;
/** i18n entries only: which locale this text belongs to. */
Expand Down
40 changes: 40 additions & 0 deletions packages/parser-react/src/portals.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import path from "node:path";
import { fileURLToPath } from "node:url";

import { matchComponentsByText } 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/a9-modal-portal/app",
);

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

describe("portals & toasts (a9 fixture)", () => {
it("flags createPortal components", () => {
const dialog = graph.nodes.find((n) => n.kind === "component" && n.name === "ConfirmDialog");
expect(dialog?.flags).toContain("portal");
});

it("matching modal text surfaces the component AND its trigger site", () => {
const result = matchComponentsByText(graph, ["Delete this order?"]);
expect(result.status).toBe("ok");
const match = result.candidates[0]?.value;
expect(match?.component.name).toBe("ConfirmDialog");
expect(match?.instances.map((i) => i.loc.file)).toEqual(["OrdersToolbar.tsx"]);
});

it("attributes toast text to the calling component with portal provenance", () => {
const toolbar = graph.nodes.find(
(n) => n.kind === "component" && n.name === "OrdersToolbar",
);
if (toolbar?.kind !== "component") throw new Error("OrdersToolbar not found");
const entry = toolbar.renderedText.find((e) => e.text === "Order deleted");
expect(entry?.source).toBe("portal");
const result = matchComponentsByText(graph, ["Order deleted"]);
expect(result.candidates[0]?.value.component.name).toBe("OrdersToolbar");
});
});
25 changes: 25 additions & 0 deletions packages/parser-react/src/scan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,7 @@ const TEXT_ATTRIBUTES = new Set([
"value",
]);
const HTTP_METHODS = new Set(["get", "post", "put", "patch", "delete", "head", "options"]);
const TOAST_CALLEES = /^(toast(\.\w+)?|enqueueSnackbar|message\.(success|error|info|warning))$/;

/** Scan a directory of React source and produce a lineage graph. */
export function scanReact(options: ScanOptions): LineageGraph {
Expand Down Expand Up @@ -144,6 +145,12 @@ export function scanReact(options: ScanOptions): LineageGraph {
const id = nodeId(kind, file, decl.name);

if (isComponent) {
// Portal components (A9): rendered into document.body etc., far from
// where they're triggered — flagged so agents know the screenshot's
// DOM position won't match the render-tree position.
const usesPortal = decl.fn
.getDescendantsOfKind(SyntaxKind.CallExpression)
.some((c) => /^(ReactDOM\.)?createPortal$/.test(c.getExpression().getText()));
nodes.set(id, {
id,
kind: "component",
Expand All @@ -156,6 +163,7 @@ export function scanReact(options: ScanOptions): LineageGraph {
...(localeTable !== null ? i18nRenderedText(decl.fn, localeTable) : []),
],
rendersComponents: extractRenderedComponents(decl.fn),
...(usesPortal ? { flags: ["portal"] } : {}),
});
collectInstanceSites(decl.fn, id, file, pendingInstances);
} else {
Expand Down Expand Up @@ -292,6 +300,17 @@ function extractRenderedText(fn: Node): RenderedText[] {
}
}

// Toast/notification calls (A9): toast("Order deleted") renders via a portal
// mounted elsewhere — the text belongs to the CALLING component for matching.
for (const call of fn.getDescendantsOfKind(SyntaxKind.CallExpression)) {
if (!TOAST_CALLEES.test(call.getExpression().getText())) continue;
const arg = call.getArguments()[0];
if (arg !== undefined && Node.isStringLiteral(arg)) {
const text = arg.getLiteralValue().trim();
if (text.length > 0) add({ text, source: "portal", ...branchTag(call, fn) });
}
}

// Template literals rendered as JSX children: {`${count} items in cart`} →
// "* items in cart" (unknown segments become * wildcards).
for (const expr of fn.getDescendantsOfKind(SyntaxKind.JsxExpression)) {
Expand Down Expand Up @@ -1213,6 +1232,10 @@ function resolvePropFlow(

const init = varDecl.getInitializer();
if (init !== undefined) {
// Function values are callbacks, not data: the fetch inside
// onConfirm={handleConfirm} is an EFFECT the child can trigger
// (handler chains, step 2.3), never data flowing into it.
if (Node.isArrowFunction(init) || Node.isFunctionExpression(init)) continue;
// Direct result: const { data } = useQuery(...) / const r = useApi("/x")
// — including calls wrapped in await / as-casts.
fromCall(init);
Expand Down Expand Up @@ -1264,6 +1287,8 @@ function resolvePropFlow(
if (instanceId === null || instanceId === undefined) continue;
for (const attr of pending.element.getAttributes()) {
if (!Node.isJsxAttribute(attr)) continue;
// Event-handler props (onConfirm, onClick…) carry effects, not data.
if (/^on[A-Z]/.test(attr.getNameNode().getText())) continue;
const init = attr.getInitializer();
if (init === undefined || !Node.isJsxExpression(init)) continue;
const expr = init.getExpression();
Expand Down
5 changes: 3 additions & 2 deletions schemas/lineage-graph.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -191,9 +191,10 @@
"enum": [
"jsx",
"attribute",
"i18n"
"i18n",
"portal"
],
"description": "Where the text comes from: JSX children, a string attribute (placeholder/label/title/alt/aria-label), or an i18n key resolved against locale files."
"description": "Where the text comes from: JSX children, a string attribute (placeholder/label/title/alt/aria-label), an i18n key resolved against locale files, or portal-rendered content (toast(\"Order deleted\") — the text appears far from the caller in the DOM, but the CALLER is the component a screenshot of it should match)."
},
"key": {
"type": "string",
Expand Down
Loading