diff --git a/TRACKER.md b/TRACKER.md
index 8965e4d..c297727 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -4,10 +4,10 @@
## Status
-- **Current phase:** 3 — Journey graph
-- **Next step:** 3.5 — Flag / 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:** 4 — Matching engine
+- **Next step:** 4.1 — Term 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
@@ -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.**
diff --git a/eval/fixtures/g5-feature-flag/app/BetaBanner.tsx b/eval/fixtures/g5-feature-flag/app/BetaBanner.tsx
new file mode 100644
index 0000000..f5afebe
--- /dev/null
+++ b/eval/fixtures/g5-feature-flag/app/BetaBanner.tsx
@@ -0,0 +1,3 @@
+export function BetaBanner() {
+ return ;
+}
diff --git a/eval/fixtures/g5-feature-flag/app/BillingPage.tsx b/eval/fixtures/g5-feature-flag/app/BillingPage.tsx
new file mode 100644
index 0000000..f68bf8c
--- /dev/null
+++ b/eval/fixtures/g5-feature-flag/app/BillingPage.tsx
@@ -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 (
+
+ Billing
+
+ {/* Flag-gated child component → renders edge carries the flag condition. */}
+ {isEnabled("beta-banner") && }
+
+ {/* Flag-gated action → handles edge (and its journey step) carries the flag. */}
+ {isEnabled("new-billing") && (
+
+ )}
+
+ {/* Role-gated action → handles edge carries a role condition. */}
+ {role === "admin" && (
+
+ )}
+
+ );
+}
diff --git a/eval/fixtures/g5-feature-flag/app/NewBillingPage.tsx b/eval/fixtures/g5-feature-flag/app/NewBillingPage.tsx
new file mode 100644
index 0000000..64f8e53
--- /dev/null
+++ b/eval/fixtures/g5-feature-flag/app/NewBillingPage.tsx
@@ -0,0 +1,7 @@
+export function NewBillingPage() {
+ return (
+
+ New billing experience
+
+ );
+}
diff --git a/eval/fixtures/g5-feature-flag/app/flags.ts b/eval/fixtures/g5-feature-flag/app/flags.ts
new file mode 100644
index 0000000..9bee94f
--- /dev/null
+++ b/eval/fixtures/g5-feature-flag/app/flags.ts
@@ -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;
+}
diff --git a/eval/fixtures/g5-feature-flag/app/routes.tsx b/eval/fixtures/g5-feature-flag/app/routes.tsx
new file mode 100644
index 0000000..a0d99ad
--- /dev/null
+++ b/eval/fixtures/g5-feature-flag/app/routes.tsx
@@ -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: },
+ { path: "/billing/new", element: },
+]);
diff --git a/eval/fixtures/g5-feature-flag/golden.json b/eval/fixtures/g5-feature-flag/golden.json
new file mode 100644
index 0000000..60716f7
--- /dev/null
+++ b/eval/fixtures/g5-feature-flag/golden.json
@@ -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" }
+ ]
+ }
+ ]
+ }
+}
diff --git a/eval/src/checks.ts b/eval/src/checks.ts
index c23e757..5e7cf51 100644
--- a/eval/src/checks.ts
+++ b/eval/src/checks.ts
@@ -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);
diff --git a/eval/src/golden.ts b/eval/src/golden.ts
index f46865a..dd511a6 100644
--- a/eval/src/golden.ts
+++ b/eval/src/golden.ts
@@ -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;
@@ -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[];
};
}
@@ -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;
}
diff --git a/packages/core/src/query.ts b/packages/core/src/query.ts
index 41948c4..a433f52 100644
--- a/packages/core/src/query.ts
+++ b/packages/core/src/query.ts
@@ -316,10 +316,12 @@ export function journeys(
const maxPaths = options.maxPaths ?? 256;
const byId = new Map(graph.nodes.map((n) => [n.id, n]));
const out = new Map();
+ const handlesConditionByEvent = new Map();
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) ?? [];
@@ -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);
@@ -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") {
diff --git a/packages/parser-react/src/conditions.test.ts b/packages/parser-react/src/conditions.test.ts
new file mode 100644
index 0000000..47d022c
--- /dev/null
+++ b/packages/parser-react/src/conditions.test.ts
@@ -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);
+ });
+});
diff --git a/packages/parser-react/src/scan.ts b/packages/parser-react/src/scan.ts
index 0940e37..d4f624d 100644
--- a/packages/parser-react/src/scan.ts
+++ b/packages/parser-react/src/scan.ts
@@ -2,6 +2,7 @@ import path from "node:path";
import {
type DataSourceKind,
+ type EdgeCondition,
instanceId,
type InstanceNode,
type LineageEdge,
@@ -59,6 +60,13 @@ export interface ScanOptions {
* ours even when the definition isn't (failure mode A5).
*/
designSystemPackages?: string[];
+ /**
+ * Extra feature-flag call names beyond the defaults (useFlag, useFeature,
+ * isEnabled, …). A `renders`/`handles` edge gated behind one of these — or a
+ * role check — carries an EdgeCondition so journeys read "… [flag] step"
+ * (TRACKER step 3.5, failure modes G5/B5).
+ */
+ featureFlags?: string[];
}
type FunctionLike = FunctionDeclaration | ArrowFunction | FunctionExpression;
@@ -112,12 +120,26 @@ const HOTKEY_HOOKS = new Set([
]);
/** Form components whose `onSubmit` prop is a real submit handler (Formik, 3.4). */
const FORM_TAGS = new Set(["Formik", "Form"]);
+/** Default feature-flag call names classified as `flag` conditions (3.5). */
+const DEFAULT_FLAG_CALLEES = [
+ "useFlag",
+ "useFeature",
+ "useFeatureFlag",
+ "useFlags",
+ "isEnabled",
+ "isFeatureEnabled",
+ "hasFeature",
+ "featureEnabled",
+];
+/** Heuristic for role/permission guards classified as `role` conditions (3.5). */
+const ROLE_PATTERN = /\brole\b|\bisAdmin\b|\bisSuperuser\b|hasRole|hasPermission|\bcan\(|\bpermission|useRole|usePermission/i;
/** Scan a directory of React source and produce a lineage graph. */
export function scanReact(options: ScanOptions): LineageGraph {
const root = path.resolve(options.root);
const include = options.include ?? ["**/*.tsx", "**/*.jsx", "**/*.ts"];
const baseUrls = options.baseUrls ?? [];
+ const flagCallees = new Set([...DEFAULT_FLAG_CALLEES, ...(options.featureFlags ?? [])]);
const project = new Project({
compilerOptions: { allowJs: true, jsx: 4 /* ReactJSX */ },
@@ -183,10 +205,10 @@ export function scanReact(options: ScanOptions): LineageGraph {
nodes.set(id, { id, kind: "hook", name: decl.name, loc: decl.loc, exportName: decl.exportName });
}
- extractBodyFacts(decl.name, decl.fn, id, file, nodes, addEdge, baseUrls, wrappers, stores, handlerExprs);
+ extractBodyFacts(decl.name, decl.fn, id, file, nodes, addEdge, baseUrls, wrappers, stores, handlerExprs, flagCallees);
}
- scanClassComponents(sourceFile, file, nodes, addEdge, baseUrls, wrappers, localeTable, pendingInstances, stores, handlerExprs);
+ scanClassComponents(sourceFile, file, nodes, addEdge, baseUrls, wrappers, localeTable, pendingInstances, stores, handlerExprs, flagCallees);
collectHocAliases(sourceFile, hocAliases);
}
@@ -197,6 +219,7 @@ export function scanReact(options: ScanOptions): LineageGraph {
hocAliases,
options.designSystemPackages ?? [],
root,
+ flagCallees,
);
resolvePropFlow(pendingInstances, instanceIds, nodes, edges, addEdge, baseUrls, wrappers);
// Routes first: navigate() effects (3.2) join to RouteNodes by path shape.
@@ -401,6 +424,74 @@ function branchTag(node: Node, boundary: Node): { branch?: string } {
return {};
}
+/**
+ * The flag/role condition guarding a rendered instance or a wired event
+ * (TRACKER step 3.5). Walks the enclosing branches up to the component
+ * boundary; returns the nearest one that is a feature-flag call or a role
+ * check, so its `renders`/`handles` edge (and any journey step through it)
+ * carries the flag/role. Undefined when the guard is a plain condition.
+ */
+function edgeCondition(node: Node, flagCallees: ReadonlySet): EdgeCondition | undefined {
+ const boundary = node.getFirstAncestor(
+ (a) =>
+ Node.isFunctionDeclaration(a) ||
+ Node.isArrowFunction(a) ||
+ Node.isFunctionExpression(a) ||
+ Node.isMethodDeclaration(a),
+ );
+ let current: Node | undefined = node;
+ while (current !== undefined && current !== boundary) {
+ const parent: Node | undefined = current.getParent();
+ if (parent === undefined) break;
+ let test: Node | undefined;
+ let negate = false;
+ if (Node.isConditionalExpression(parent)) {
+ if (parent.getWhenTrue() === current) test = parent.getCondition();
+ else if (parent.getWhenFalse() === current) {
+ test = parent.getCondition();
+ negate = true;
+ }
+ } else if (
+ Node.isBinaryExpression(parent) &&
+ parent.getOperatorToken().getKind() === SyntaxKind.AmpersandAmpersandToken &&
+ parent.getRight() === current
+ ) {
+ test = parent.getLeft();
+ } else if (Node.isIfStatement(parent) && parent.getThenStatement() === current) {
+ test = parent.getExpression();
+ }
+ if (test !== undefined) {
+ const condition = classifyTest(test, flagCallees);
+ if (condition !== undefined) {
+ return negate ? { ...condition, expression: `!(${condition.expression})` } : condition;
+ }
+ }
+ current = parent;
+ }
+ return undefined;
+}
+
+/** Classify a guard's test expression as a feature-flag or role condition. */
+function classifyTest(test: Node, flagCallees: ReadonlySet): EdgeCondition | undefined {
+ const text = test.getText();
+ for (const callee of flagCallees) {
+ if (new RegExp(`\\b${callee}\\s*\\(`).test(text)) return { kind: "flag", expression: text };
+ }
+ // A flag hook result used as a bare boolean: `const beta = useFlag("beta")`.
+ if (Node.isIdentifier(test)) {
+ for (const definition of test.getDefinitionNodes()) {
+ if (!Node.isVariableDeclaration(definition)) continue;
+ const init = definition.getInitializer();
+ if (init === undefined || !Node.isCallExpression(init)) continue;
+ const callee = init.getExpression().getText();
+ const name = callee.includes(".") ? (callee.split(".").pop() ?? callee) : callee;
+ if (flagCallees.has(name)) return { kind: "flag", expression: `${text} (${init.getText()})` };
+ }
+ }
+ if (ROLE_PATTERN.test(text)) return { kind: "role", expression: text };
+ return undefined;
+}
+
function extractRenderedComponents(fn: Node): string[] {
const names = new Set();
const record = (tagName: string): void => {
@@ -432,6 +523,7 @@ function extractBodyFacts(
wrappers: WrapperRegistry,
stores: StoreRegistry,
handlerExprs: Map,
+ flagCallees: ReadonlySet,
): void {
// 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
@@ -454,14 +546,17 @@ function extractBodyFacts(
) {
handler = handlerNode.getText();
}
- const suffix = `${handler !== null ? `:${handler}` : ""}${source !== "jsx" ? `@${source}` : ""}`;
+ // Inline handlers (no name) at different call sites are distinct events —
+ // disambiguate by line so their conditions and effects don't collapse.
+ const atLoc = locOf(at, file);
+ const suffix = `${handler !== null ? `:${handler}` : `@${atLoc.line}`}${source !== "jsx" ? `@${source}` : ""}`;
const evId = nodeId("event", file, `${declName}.${eventName}${suffix}`);
if (!nodes.has(evId)) {
nodes.set(evId, {
id: evId,
kind: "event",
name: eventName,
- loc: locOf(at, file),
+ loc: atLoc,
event: eventName,
handler,
...(source !== "jsx" ? { source } : {}),
@@ -473,7 +568,8 @@ function extractBodyFacts(
if (list) list.push(handlerNode);
else handlerExprs.set(evId, [handlerNode]);
}
- addEdge({ from: ownerId, to: evId, kind: "handles" });
+ const condition = edgeCondition(at, flagCallees);
+ addEdge({ from: ownerId, to: evId, kind: "handles", ...(condition ? { condition } : {}) });
};
for (const call of body.getDescendantsOfKind(SyntaxKind.CallExpression)) {
@@ -971,6 +1067,7 @@ function scanClassComponents(
pendingInstances: PendingInstance[],
stores: StoreRegistry,
handlerExprs: Map,
+ flagCallees: ReadonlySet,
): void {
for (const cls of sourceFile.getClasses()) {
const name = cls.getName();
@@ -1012,7 +1109,7 @@ function scanClassComponents(
});
collectInstanceSites(render, id, file, pendingInstances);
// Whole class body: lifecycle fetches (componentDidMount etc.) + render events.
- extractBodyFacts(name, cls, id, file, nodes, addEdge, baseUrls, wrappers, stores, handlerExprs);
+ extractBodyFacts(name, cls, id, file, nodes, addEdge, baseUrls, wrappers, stores, handlerExprs, flagCallees);
extractClassState(cls, name, id, file, nodes, addEdge);
}
}
@@ -1164,6 +1261,7 @@ function materializeInstances(
hocAliases: ReadonlyMap,
designSystemPackages: string[],
root: string,
+ flagCallees: ReadonlySet,
): Array {
const definitionsByName = new Map();
for (const node of nodes.values()) {
@@ -1259,7 +1357,8 @@ function materializeInstances(
nodes.set(id, instance);
idByIndex[index] = id;
created.push(instance);
- addEdge({ from: pending.ownerId, to: id, kind: "renders" });
+ const condition = edgeCondition(pending.element, flagCallees);
+ addEdge({ from: pending.ownerId, to: id, kind: "renders", ...(condition ? { condition } : {}) });
if (!resolved.external) {
addEdge({ from: id, to: resolved.definitionId, kind: "instance-of" });
}