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:** 3Journey graph
- **Next step:** 3.5Flag / role conditions (closes Gate 3)
- **Done:** 0.1–0.4, 1.1–1.6, 2.1–2.5, 3.1–3.4
- **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)
- **Current phase:** 4Matching engine
- **Next step:** 4.1Term matching v2
- **Done:** 0.1–0.4, 1.1–1.6, 2.1–2.5, 3.1–3.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) · 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)

## What CodeRadar is

Expand Down Expand Up @@ -209,7 +209,7 @@ The heart of the project. C1 and B1 live here.
**Build:** react-hook-form / Formik adapters (`handleSubmit(onSubmit)` → real handler); `addEventListener` in `useEffect` → EventNode (`source: "effect"`); adapter list for hotkey libs. Unknown patterns → file-level `flags: ["unscanned-events"]`.
**Accept:** fixtures `b8-react-hook-form`, `b7-effect-listeners` green.

### [ ] 3.5 Flag / role conditions
### [x] 3.5 Flag / role conditions
**Failure modes:** G5, B5
**Build:** feature-flag detection (configurable call names: `useFlag`, `useFeature`, `isEnabled`) and role checks in render branches → `EdgeCondition{kind:"flag"|"role"}` on the enclosed `renders`/`handles` edges.
**Accept:** fixture `g5-feature-flag` green: flag-gated UI's journey step carries the flag name. **Gate 3 passes.**
Expand Down
3 changes: 3 additions & 0 deletions eval/fixtures/g5-feature-flag/app/BetaBanner.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export function BetaBanner() {
return <aside>You're in the beta program</aside>;
}
29 changes: 29 additions & 0 deletions eval/fixtures/g5-feature-flag/app/BillingPage.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { useNavigate } from "react-router-dom";

import { BetaBanner } from "./BetaBanner";
import { isEnabled } from "./flags";

export function BillingPage({ role }: { role: string }) {
const navigate = useNavigate();

return (
<section>
<h1>Billing</h1>

{/* Flag-gated child component → renders edge carries the flag condition. */}
{isEnabled("beta-banner") && <BetaBanner />}

{/* Flag-gated action → handles edge (and its journey step) carries the flag. */}
{isEnabled("new-billing") && (
<button onClick={() => navigate("/billing/new")}>Try new billing</button>
)}

{/* Role-gated action → handles edge carries a role condition. */}
{role === "admin" && (
<button onClick={() => fetch("/api/billing/reset", { method: "POST" })}>
Reset billing
</button>
)}
</section>
);
}
7 changes: 7 additions & 0 deletions eval/fixtures/g5-feature-flag/app/NewBillingPage.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
export function NewBillingPage() {
return (
<section>
<h1>New billing experience</h1>
</section>
);
}
5 changes: 5 additions & 0 deletions eval/fixtures/g5-feature-flag/app/flags.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
// A tiny feature-flag helper. The scanner classifies `isEnabled(...)` guards
// as `flag` conditions (it's one of the default flag callees).
export function isEnabled(flag: string): boolean {
return flag.length > 0;
}
9 changes: 9 additions & 0 deletions eval/fixtures/g5-feature-flag/app/routes.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { createBrowserRouter } from "react-router-dom";

import { BillingPage } from "./BillingPage";
import { NewBillingPage } from "./NewBillingPage";

export const router = createBrowserRouter([
{ path: "/billing", element: <BillingPage role="admin" /> },
{ path: "/billing/new", element: <NewBillingPage /> },
]);
30 changes: 30 additions & 0 deletions eval/fixtures/g5-feature-flag/golden.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
{
"failureMode": "G5",
"note": "Feature-flag and role guards become EdgeConditions on the enclosed renders/handles edges, and journeys surface them: the flag-gated 'Try new billing' navigation and the role-gated 'Reset billing' fetch each carry their guard, so a journey step reads with the flag/role name. isEnabled(...) is a default flag callee; role === \"admin\" matches the role heuristic.",
"expect": {
"components": [
{ "name": "BillingPage", "instances": 0 },
{ "name": "NewBillingPage", "instances": 0 },
{ "name": "BetaBanner", "instances": 1 }
],
"routes": [
{ "path": "/billing", "component": "BillingPage" },
{ "path": "/billing/new", "component": "NewBillingPage" }
],
"conditions": [
{ "component": "BillingPage", "edge": "handles", "kind": "flag", "expression": "new-billing" },
{ "component": "BillingPage", "edge": "handles", "kind": "role", "expression": "admin" },
{ "component": "BillingPage", "edge": "renders", "kind": "flag", "expression": "beta-banner" }
],
"journeys": [
{
"start": "/billing",
"depth": 3,
"expect": [
{ "pages": ["/billing", "/billing/new"], "end": "terminal" },
{ "pages": ["/billing"], "end": "terminal" }
]
}
]
}
}
24 changes: 24 additions & 0 deletions eval/src/checks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,30 @@ export function runChecks(fixture: string, golden: Golden, graph: LineageGraph):
}
}

for (const expected of golden.expect.conditions ?? []) {
const id = `condition:${expected.component}.${expected.edge}:${expected.kind}~${expected.expression}`;
const owner = graph.nodes.find((n) => n.kind === "component" && n.name === expected.component);
const matched = graph.edges.some(
(e) =>
e.kind === expected.edge &&
owner !== undefined &&
e.from === owner.id &&
e.condition?.kind === expected.kind &&
e.condition.expression.includes(expected.expression),
);
finalize(
"conditions",
id,
matched,
expected.expectedFail,
matched
? undefined
: owner === undefined
? "component not found"
: `no ${expected.edge} edge from ${expected.component} with a ${expected.kind} condition containing "${expected.expression}"`,
);
}

for (const query of golden.expect.queries ?? []) {
const id = `query:${query.terms.join("+")}`;
const result = matchComponentsByText(graph, query.terms);
Expand Down
23 changes: 22 additions & 1 deletion eval/src/golden.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,17 @@ export interface GoldenJourney {
expectedFail?: string;
}

export interface GoldenCondition {
/** Component the gated edge originates from. */
component: string;
/** Which edge kind carries the condition. */
edge: "handles" | "renders";
kind: "flag" | "role";
/** Substring the condition expression must contain (e.g. "new-billing"). */
expression: string;
expectedFail?: string;
}

export interface Golden {
failureMode: string;
note?: string;
Expand All @@ -105,6 +116,8 @@ export interface Golden {
effects?: GoldenEffect[];
/** Journey paths (step 3.3): page → event → effect → page, lazily expanded. */
journeys?: GoldenJourney[];
/** Flag/role conditions on renders/handles edges (step 3.5). */
conditions?: GoldenCondition[];
};
}

Expand All @@ -113,7 +126,15 @@ export type CheckStatus = "pass" | "fail" | "xfail" | "unexpected-pass";
export interface CheckResult {
/** e.g. "components:DataTable", "attribution:DataTable@pages/UsersPage.tsx" */
id: string;
kind: "components" | "attributions" | "forbidden" | "queries" | "routes" | "effects" | "journeys";
kind:
| "components"
| "attributions"
| "forbidden"
| "queries"
| "routes"
| "effects"
| "journeys"
| "conditions";
status: CheckStatus;
detail?: string;
}
Expand Down
10 changes: 8 additions & 2 deletions packages/core/src/query.ts
Original file line number Diff line number Diff line change
Expand Up @@ -316,10 +316,12 @@ export function journeys(
const maxPaths = options.maxPaths ?? 256;
const byId = new Map<string, LineageNode>(graph.nodes.map((n) => [n.id, n]));
const out = new Map<string, LineageEdge[]>();
const handlesConditionByEvent = new Map<string, EdgeCondition>();
for (const e of graph.edges) {
const list = out.get(e.from);
if (list) list.push(e);
else out.set(e.from, [e]);
if (e.kind === "handles" && e.condition !== undefined) handlesConditionByEvent.set(e.to, e.condition);
}
const outEdges = (id: string): LineageEdge[] => out.get(id) ?? [];

Expand Down Expand Up @@ -422,13 +424,17 @@ export function journeys(
for (const eventId of screenEvents(componentId)) {
const event = byId.get(eventId);
if (event === undefined || event.kind !== "event") continue;
// A flag/role guard on the handles edge (3.5) gates the whole step; an
// effect-edge condition (rarer) refines the specific effect.
const gate = handlesConditionByEvent.get(eventId);
for (const effect of outEdges(eventId)) {
const stepCondition = effect.condition ?? gate;
const eventStep: JourneyStep = {
kind: "event",
nodeId: eventId,
label: event.event,
loc: event.loc,
...(effect.condition ? { condition: effect.condition } : {}),
...(stepCondition ? { condition: stepCondition } : {}),
};
if (effect.kind === "navigates-to") {
const page = pageOfRoute(effect.to);
Expand All @@ -439,7 +445,7 @@ export function journeys(
nodeId: effect.to,
label: byId.get(effect.to)?.name ?? effect.to,
...(byId.get(effect.to) ? { loc: byId.get(effect.to)!.loc } : {}),
...(effect.condition ? { condition: effect.condition } : {}),
...(stepCondition ? { condition: stepCondition } : {}),
};
expand(page.componentId, page.label, [...pagePath, eventStep, navStep], nextVisited);
} else if (effect.kind === "triggers" || effect.kind === "writes-state") {
Expand Down
50 changes: 50 additions & 0 deletions packages/parser-react/src/conditions.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 { journeys } from "@coderadar/core";
import { describe, expect, it } from "vitest";

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

const g5 = resolveHookEdges(
scanReact({
root: path.resolve(
path.dirname(fileURLToPath(import.meta.url)),
"../../../eval/fixtures/g5-feature-flag/app",
),
}),
);

const conditioned = (kind: string) =>
g5.edges.filter((e) => e.kind === kind && e.condition !== undefined);

describe("flag / role conditions (g5 fixture, TRACKER 3.5)", () => {
it("gates a handles edge with the feature flag guarding the action", () => {
const flagged = conditioned("handles").find((e) => e.condition?.kind === "flag");
expect(flagged?.condition?.expression).toContain("new-billing");
});

it("classifies a role guard as a role condition", () => {
const role = conditioned("handles").find((e) => e.condition?.kind === "role");
expect(role?.condition?.expression).toContain("admin");
});

it("gates a renders edge to a flag-gated child component", () => {
const rendered = conditioned("renders").find((e) => e.condition?.kind === "flag");
expect(rendered?.condition?.expression).toContain("beta-banner");
});

it("keeps the flag- and role-gated actions on separate events (no id collision)", () => {
const events = g5.nodes.filter((n) => n.kind === "event" && n.event === "onClick");
expect(events.length).toBe(2);
});

it("surfaces the guard on the journey step it gates", () => {
const paths = journeys(g5, "/billing", { depth: 3 }).candidates[0]?.value ?? [];
const conditions = paths.flatMap((p) =>
p.steps.flatMap((s) => (s.condition !== undefined ? [`${s.condition.kind}:${s.condition.expression}`] : [])),
);
expect(conditions.some((c) => c.startsWith("flag:") && c.includes("new-billing"))).toBe(true);
expect(conditions.some((c) => c.startsWith("role:") && c.includes("admin"))).toBe(true);
});
});
Loading
Loading