/* Unified VisibilityExpr grammar and evaluator (DNA-26, RFC-0026). One grammar, two consumers: block-level visibility in page content and feature-graph visibility in RFC-0018. VisibilityExpr is a CLOSED discriminated union (DNA-19). Adding a new case requires a superseding RFC. Do not add new VisibilityExpr cases (e.g. abTest, experiment) without a superseding RFC. Do not implement runtime (edge) evaluation here — evalVisibility is build-time only. Do not import from app-local code. RFC-1097: step 6 — compass.migrate codemod run Mechanical v1 to v2 header migration across the workspace: 942 files rewritten — CHANGE_SUMMARY windows collapsed into history, forbidden v1 blocks stripped, KEY_DECISIONS seeded from @ai-invariant comments (5 files) or TODO placeholders (103 files), blocks reordered to canonical order. RFC-1097: sweep — werkstatt-engine clean Sweep batch 4: 73 Compass headers on headerless engine files (certification, component-runtime, isolation, evolution, testing), real KEY_DECISIONS on 75 files (kernel, cache, dht, swim, gitmesh, runtime), ~80 purpose expansions (CONTRACT-02/PURPOSE-02), non-goals on 13 CONTRACT-03 files, CS-07 history literal fix repo-wide (253 files). Policy: .template.ts/.template.astro excludedPaths. werkstatt-engine now 0 diagnostics. */ import { z } from "zod"; import type { RuntimeContext } from "../runtime/runtime-context.ts"; // --------------------------------------------------------------------------- // VisibilityExpr — closed discriminated union (DNA-19, RFC-0026) // --------------------------------------------------------------------------- /** * A closed discriminated union describing when a block or feature is visible. * * MVP semantics: * { feature } — delegates to featureGraph.isEnabled(key) * { locale } — checks ctx.locale against the given code(s) * { segment } — ALWAYS returns false at MVP (ctx.segment === null). RFC-0027 activates. * { flag } — ALWAYS returns false at MVP (ctx.flags === {}). RFC-0027 activates. * { all } — logical AND of sub-expressions * { any } — logical OR of sub-expressions * { not } — logical NOT of a sub-expression * * Content authors may write segment/flag expressions today. They evaluate false * at MVP but will become live without content changes when RFC-0027 ships. * * Do NOT add a new case without a superseding RFC (DNA-19). */ export type VisibilityExpr = | { feature: string } | { locale: string | string[] } | { segment: string | string[] } | { flag: string } | { all: VisibilityExpr[] } | { any: VisibilityExpr[] } | { not: VisibilityExpr }; // --------------------------------------------------------------------------- // VisibilityExprSchema — Zod validator (recursive via z.lazy) // --------------------------------------------------------------------------- const visibilityExprBase: z.ZodType = z.lazy(() => z.union([ z.object({ feature: z.string().min(1) }), z.object({ locale: z.union([z.string().min(1), z.array(z.string().min(1))]) }), z.object({ segment: z.union([z.string().min(1), z.array(z.string().min(1))]) }), z.object({ flag: z.string().min(1) }), z.object({ all: z.array(visibilityExprBase).min(1) }), z.object({ any: z.array(visibilityExprBase).min(1) }), z.object({ not: visibilityExprBase }), ]), ); /** * Zod schema for VisibilityExpr. * Used by page.block.validate and visibility.expr.validate to parse expressions * found in page content and feature-graph YAML files. */ export const VisibilityExprSchema: z.ZodType = visibilityExprBase; // --------------------------------------------------------------------------- // ResolvedFeatureGraph — minimal interface for evalVisibility // --------------------------------------------------------------------------- /** * Minimal interface that evalVisibility depends on for { feature: "..." } clauses. * Consumers provide their own implementation (e.g. from getFeatureGraph() in RFC-0018). * An empty/no-op implementation returns false for every key — safe for offline/preview. */ export interface ResolvedFeatureGraph { /** Returns true when the feature with `key` is enabled in the current graph. */ isEnabled(key: string): boolean; } /** A no-op feature graph where every feature evaluates as disabled. */ export const EMPTY_FEATURE_GRAPH: ResolvedFeatureGraph = { isEnabled: () => true, // MVP: features default to enabled (graph opt-outs via explicit false) }; // --------------------------------------------------------------------------- // exhaustive — compile-time union exhaustion helper // --------------------------------------------------------------------------- function exhaustive(x: never): never { throw new Error(`[visibility] Unhandled VisibilityExpr case: ${JSON.stringify(x)}`); } // --------------------------------------------------------------------------- // evalVisibility — build-time evaluator // --------------------------------------------------------------------------- /** * Evaluates a VisibilityExpr at build time. * * Returns true → block/feature should be rendered in the output. * Returns false → block/feature should be dropped from the output. * * A null expression (no `visibility:` key) is treated as unconditionally visible. * * MVP guarantees (enforced by runtime.context.shape): * - ctx.segment === null → all { segment } clauses return false. * - ctx.flags === {} → all { flag } clauses return false. * * @param expr The parsed visibility expression (or null if absent). * @param ctx The RuntimeContext for this build locale. * @param featureGraph The resolved feature graph from RFC-0018. */ export function evalVisibility( expr: VisibilityExpr | null | undefined, ctx: RuntimeContext, featureGraph: ResolvedFeatureGraph, ): boolean { if (expr == null) return true; if ("feature" in expr) { return featureGraph.isEnabled(expr.feature); } if ("locale" in expr) { const locales = Array.isArray(expr.locale) ? expr.locale : [expr.locale]; return locales.includes(ctx.locale); } if ("segment" in expr) { // MVP contract: ctx.segment is always null → segment clauses always return false. // RFC-0027 lifts this when persona detection lands. if (ctx.segment === null) return false; const segments = Array.isArray(expr.segment) ? expr.segment : [expr.segment]; return segments.includes(ctx.segment); } if ("flag" in expr) { // MVP contract: ctx.flags is always {} → flag clauses always return false. return ctx.flags[expr.flag] === true; } if ("all" in expr) { return expr.all.every((e) => evalVisibility(e, ctx, featureGraph)); } if ("any" in expr) { return expr.any.some((e) => evalVisibility(e, ctx, featureGraph)); } if ("not" in expr) { return !evalVisibility(expr.not, ctx, featureGraph); } return exhaustive(expr); }