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" }); }