Skip to content

Commit 2ee3a54

Browse files
Merge pull request #24 from officialCodeWork/build/phase-3/step-3.5-flag-role
feat(conditions): flag/role guards on edges (G5) — Gate 3, Phase 3 done
2 parents aa848ab + c03d209 commit 2ee3a54

12 files changed

Lines changed: 298 additions & 15 deletions

File tree

TRACKER.md

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,10 @@
44

55
## Status
66

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

1212
## What CodeRadar is
1313

@@ -209,7 +209,7 @@ The heart of the project. C1 and B1 live here.
209209
**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"]`.
210210
**Accept:** fixtures `b8-react-hook-form`, `b7-effect-listeners` green.
211211

212-
### [ ] 3.5 Flag / role conditions
212+
### [x] 3.5 Flag / role conditions
213213
**Failure modes:** G5, B5
214214
**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.
215215
**Accept:** fixture `g5-feature-flag` green: flag-gated UI's journey step carries the flag name. **Gate 3 passes.**
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
export function BetaBanner() {
2+
return <aside>You're in the beta program</aside>;
3+
}
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import { useNavigate } from "react-router-dom";
2+
3+
import { BetaBanner } from "./BetaBanner";
4+
import { isEnabled } from "./flags";
5+
6+
export function BillingPage({ role }: { role: string }) {
7+
const navigate = useNavigate();
8+
9+
return (
10+
<section>
11+
<h1>Billing</h1>
12+
13+
{/* Flag-gated child component → renders edge carries the flag condition. */}
14+
{isEnabled("beta-banner") && <BetaBanner />}
15+
16+
{/* Flag-gated action → handles edge (and its journey step) carries the flag. */}
17+
{isEnabled("new-billing") && (
18+
<button onClick={() => navigate("/billing/new")}>Try new billing</button>
19+
)}
20+
21+
{/* Role-gated action → handles edge carries a role condition. */}
22+
{role === "admin" && (
23+
<button onClick={() => fetch("/api/billing/reset", { method: "POST" })}>
24+
Reset billing
25+
</button>
26+
)}
27+
</section>
28+
);
29+
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
export function NewBillingPage() {
2+
return (
3+
<section>
4+
<h1>New billing experience</h1>
5+
</section>
6+
);
7+
}
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
// A tiny feature-flag helper. The scanner classifies `isEnabled(...)` guards
2+
// as `flag` conditions (it's one of the default flag callees).
3+
export function isEnabled(flag: string): boolean {
4+
return flag.length > 0;
5+
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import { createBrowserRouter } from "react-router-dom";
2+
3+
import { BillingPage } from "./BillingPage";
4+
import { NewBillingPage } from "./NewBillingPage";
5+
6+
export const router = createBrowserRouter([
7+
{ path: "/billing", element: <BillingPage role="admin" /> },
8+
{ path: "/billing/new", element: <NewBillingPage /> },
9+
]);
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
{
2+
"failureMode": "G5",
3+
"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.",
4+
"expect": {
5+
"components": [
6+
{ "name": "BillingPage", "instances": 0 },
7+
{ "name": "NewBillingPage", "instances": 0 },
8+
{ "name": "BetaBanner", "instances": 1 }
9+
],
10+
"routes": [
11+
{ "path": "/billing", "component": "BillingPage" },
12+
{ "path": "/billing/new", "component": "NewBillingPage" }
13+
],
14+
"conditions": [
15+
{ "component": "BillingPage", "edge": "handles", "kind": "flag", "expression": "new-billing" },
16+
{ "component": "BillingPage", "edge": "handles", "kind": "role", "expression": "admin" },
17+
{ "component": "BillingPage", "edge": "renders", "kind": "flag", "expression": "beta-banner" }
18+
],
19+
"journeys": [
20+
{
21+
"start": "/billing",
22+
"depth": 3,
23+
"expect": [
24+
{ "pages": ["/billing", "/billing/new"], "end": "terminal" },
25+
{ "pages": ["/billing"], "end": "terminal" }
26+
]
27+
}
28+
]
29+
}
30+
}

eval/src/checks.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -192,6 +192,30 @@ export function runChecks(fixture: string, golden: Golden, graph: LineageGraph):
192192
}
193193
}
194194

195+
for (const expected of golden.expect.conditions ?? []) {
196+
const id = `condition:${expected.component}.${expected.edge}:${expected.kind}~${expected.expression}`;
197+
const owner = graph.nodes.find((n) => n.kind === "component" && n.name === expected.component);
198+
const matched = graph.edges.some(
199+
(e) =>
200+
e.kind === expected.edge &&
201+
owner !== undefined &&
202+
e.from === owner.id &&
203+
e.condition?.kind === expected.kind &&
204+
e.condition.expression.includes(expected.expression),
205+
);
206+
finalize(
207+
"conditions",
208+
id,
209+
matched,
210+
expected.expectedFail,
211+
matched
212+
? undefined
213+
: owner === undefined
214+
? "component not found"
215+
: `no ${expected.edge} edge from ${expected.component} with a ${expected.kind} condition containing "${expected.expression}"`,
216+
);
217+
}
218+
195219
for (const query of golden.expect.queries ?? []) {
196220
const id = `query:${query.terms.join("+")}`;
197221
const result = matchComponentsByText(graph, query.terms);

eval/src/golden.ts

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,17 @@ export interface GoldenJourney {
8282
expectedFail?: string;
8383
}
8484

85+
export interface GoldenCondition {
86+
/** Component the gated edge originates from. */
87+
component: string;
88+
/** Which edge kind carries the condition. */
89+
edge: "handles" | "renders";
90+
kind: "flag" | "role";
91+
/** Substring the condition expression must contain (e.g. "new-billing"). */
92+
expression: string;
93+
expectedFail?: string;
94+
}
95+
8596
export interface Golden {
8697
failureMode: string;
8798
note?: string;
@@ -105,6 +116,8 @@ export interface Golden {
105116
effects?: GoldenEffect[];
106117
/** Journey paths (step 3.3): page → event → effect → page, lazily expanded. */
107118
journeys?: GoldenJourney[];
119+
/** Flag/role conditions on renders/handles edges (step 3.5). */
120+
conditions?: GoldenCondition[];
108121
};
109122
}
110123

@@ -113,7 +126,15 @@ export type CheckStatus = "pass" | "fail" | "xfail" | "unexpected-pass";
113126
export interface CheckResult {
114127
/** e.g. "components:DataTable", "attribution:DataTable@pages/UsersPage.tsx" */
115128
id: string;
116-
kind: "components" | "attributions" | "forbidden" | "queries" | "routes" | "effects" | "journeys";
129+
kind:
130+
| "components"
131+
| "attributions"
132+
| "forbidden"
133+
| "queries"
134+
| "routes"
135+
| "effects"
136+
| "journeys"
137+
| "conditions";
117138
status: CheckStatus;
118139
detail?: string;
119140
}

packages/core/src/query.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -316,10 +316,12 @@ export function journeys(
316316
const maxPaths = options.maxPaths ?? 256;
317317
const byId = new Map<string, LineageNode>(graph.nodes.map((n) => [n.id, n]));
318318
const out = new Map<string, LineageEdge[]>();
319+
const handlesConditionByEvent = new Map<string, EdgeCondition>();
319320
for (const e of graph.edges) {
320321
const list = out.get(e.from);
321322
if (list) list.push(e);
322323
else out.set(e.from, [e]);
324+
if (e.kind === "handles" && e.condition !== undefined) handlesConditionByEvent.set(e.to, e.condition);
323325
}
324326
const outEdges = (id: string): LineageEdge[] => out.get(id) ?? [];
325327

@@ -422,13 +424,17 @@ export function journeys(
422424
for (const eventId of screenEvents(componentId)) {
423425
const event = byId.get(eventId);
424426
if (event === undefined || event.kind !== "event") continue;
427+
// A flag/role guard on the handles edge (3.5) gates the whole step; an
428+
// effect-edge condition (rarer) refines the specific effect.
429+
const gate = handlesConditionByEvent.get(eventId);
425430
for (const effect of outEdges(eventId)) {
431+
const stepCondition = effect.condition ?? gate;
426432
const eventStep: JourneyStep = {
427433
kind: "event",
428434
nodeId: eventId,
429435
label: event.event,
430436
loc: event.loc,
431-
...(effect.condition ? { condition: effect.condition } : {}),
437+
...(stepCondition ? { condition: stepCondition } : {}),
432438
};
433439
if (effect.kind === "navigates-to") {
434440
const page = pageOfRoute(effect.to);
@@ -439,7 +445,7 @@ export function journeys(
439445
nodeId: effect.to,
440446
label: byId.get(effect.to)?.name ?? effect.to,
441447
...(byId.get(effect.to) ? { loc: byId.get(effect.to)!.loc } : {}),
442-
...(effect.condition ? { condition: effect.condition } : {}),
448+
...(stepCondition ? { condition: stepCondition } : {}),
443449
};
444450
expand(page.componentId, page.label, [...pagePath, eventStep, navStep], nextVisited);
445451
} else if (effect.kind === "triggers" || effect.kind === "writes-state") {

0 commit comments

Comments
 (0)