From 936077c305416801a4eadff1b7f5217e195f2f98 Mon Sep 17 00:00:00 2001 From: JeongwooYoo Date: Sat, 14 Mar 2026 16:11:38 -0700 Subject: [PATCH 1/4] feat(core): extend grammar with Actor, Outcome, Scenario blocks and native Field syntax - Add Actor block: first-class user role/persona, referenceable from Policy, Constraint, and Scenario via the actor attribute - Add Outcome block: measurable success criterion (metric/target/timeframe), exclusive to Feature; enforces quantitative definition of done - Add Scenario block: Given/When/Then acceptance criteria linked to a Policy and optional Actor; allowed in Feature and Section - Add native child syntax for Definition blocks, replacing the JSON-encoded fields attribute (legacy JSON still supported for backward compat) Field supports min/max (number) and pattern (string) inline constraints - Add trigger attribute to Policy: semantic event name that fires the policy (e.g. cart.item.add), searchable but not runtime-evaluated - Add actor attribute to Policy and Constraint: space-separated Actor ID list parsed into string[] during MDX transformation - Expand Link relationship enum with: triggers, supersedes, validates, enables, blocks - Update GRAMMAR_TABLE to 10 block types; Outcome is Feature-only, Actor and Scenario are allowed in Feature and Section - Extend link-integrity validator to check Policy/Constraint actor references and Scenario policy/actor references, both per-document and workspace-wide - Update all tests (grammar, schema, parser, validator) and test fixtures - Update checkout.product.mdx example to demonstrate full v2 grammar --- models/examples/checkout.product.mdx | 255 +++++++++++++----- packages/core/src/grammar.ts | 14 +- packages/core/src/parser/mdx-to-pmast.ts | 113 ++++++-- .../core/src/parser/remark-product-model.ts | 35 ++- packages/core/src/schema/blocks.ts | 100 ++++++- packages/core/src/schema/fields.ts | 11 + packages/core/src/schema/index.ts | 3 + packages/core/src/schema/primitives.ts | 3 + packages/core/src/types/ast.ts | 6 + .../src/validator/rules/link-integrity.ts | 89 +++++- .../rules/workspace-link-integrity.ts | 87 +++++- .../tests/fixtures/valid-full.product.mdx | 41 ++- packages/core/tests/grammar.test.ts | 21 +- packages/core/tests/parser.test.ts | 190 ++++++++++++- packages/core/tests/schema.test.ts | 199 +++++++++++++- packages/core/tests/validator.test.ts | 224 +++++++++++++++ 16 files changed, 1242 insertions(+), 149 deletions(-) diff --git a/models/examples/checkout.product.mdx b/models/examples/checkout.product.mdx index 087f429..0058f55 100644 --- a/models/examples/checkout.product.mdx +++ b/models/examples/checkout.product.mdx @@ -5,14 +5,53 @@ Comprehensive checkout flow from cart preparation to order confirmation. + {/* ── Actors ─────────────────────────────────────────────── */} + + + + + + {/* ── Outcomes ───────────────────────────────────────────── */} + + + + + {/* ── Cart ───────────────────────────────────────────────── */} +
Captures line items and shopping constraints. - + + Canonical structure for one line item in checkout. + + + + + @@ -21,7 +60,10 @@ On add-item request, count distinct items; reject with error code CART_LIMIT_EXCEEDED when count exceeds 50. + @@ -30,7 +72,10 @@ Accept quantity only when it is between 1 and 99 inclusive. Return INVALID_QUANTITY if out of range; clamp silently only when triggered by inventory sync. + @@ -45,16 +90,10 @@ On TTL expiry, release the hold and mark the cart item as unconfirmed. On next cart view, re-check availability and notify the user if stock is no longer available. - - Prevents stale cart data from accumulating and ensures inventory holds do not persist indefinitely through background processes. - - A daily batch job scans for carts with lastActivityAt older than 7 days. Expired carts are soft-deleted and a cart_expired event is emitted for analytics. If the user returns, show a "your cart has expired" message with option to restore items if still in stock. - - + @@ -63,19 +102,47 @@ On code submission, validate against promo service: check code exists, is within valid date range, and cart subtotal meets minimumOrderValue. Apply discount as either percentage or fixed amount based on promo type. Return INVALID_PROMO_CODE, PROMO_EXPIRED, or MINIMUM_NOT_MET as appropriate. - When cart contents change after promo application, re-evaluate eligibility. If cart no longer meets minimum, remove promo and notify user. If item removal causes promo to become more favorable (e.g., category-specific discount now applies to higher percentage of cart), recalculate automatically. + When cart contents change after promo application, re-evaluate eligibility. If cart no longer meets minimum, remove promo and notify user. + + + Prevents mixed-currency carts that break tax and payment calculations. + + + +
+ {/* ── Payment ─────────────────────────────────────────────── */} +
Manages payment method capture and gateway constraints. - + + Normalized payment representation for authorization and capture. + + + + @@ -84,7 +151,10 @@ Drop PAN fields and persist only provider-issued tokens. Log a security event if raw card data is detected in any request payload. + @@ -93,138 +163,187 @@ On soft decline (network timeout, 5xx from gateway), wait 2 seconds and retry on the same gateway. If the second attempt also fails with a soft decline, proceed to fallback. - If primary gateway fails twice, route the third attempt through the secondary gateway. If all three attempts fail, present a user-friendly error with the option to try a different payment method. Never retry on hard declines (insufficient funds, stolen card, do-not-honor). + If primary gateway fails twice, route the third attempt through the secondary gateway. If all three attempts fail, present a user-friendly error. Never retry on hard declines. + - Prevents double-charging caused by network retries, user double-clicks, or frontend re-submissions. + Prevents double-charging caused by network retries or frontend re-submissions. - Generate a UUID-based idempotency key on checkout initiation. Attach the key to every authorization request. The gateway must reject duplicate keys within a 24-hour window. On frontend, disable the pay button immediately after first click and re-enable only on confirmed failure. + Generate a UUID-based idempotency key on checkout initiation. Attach the key to every authorization request. The gateway must reject duplicate keys within a 24-hour window. + Reduces chargeback rate and protects against fraudulent high-value orders. - Before authorization, pass order details (amount, shipping address, device fingerprint, email age) to fraud scoring service. Orders with risk score above 80 are auto-declined. Scores between 60-80 trigger step-up verification (3DS or email OTP). + Before authorization, pass order details to fraud scoring service. Orders with risk score above 80 are auto-declined. Scores between 60-80 trigger step-up verification (3DS or email OTP). - Flag accounts that attempt more than 3 failed payments in 10 minutes or more than 5 orders in 24 hours. Temporarily lock checkout for the account and require customer support intervention or a 30-minute cooldown. + Flag accounts that attempt more than 3 failed payments in 10 minutes or more than 5 orders in 24 hours. Temporarily lock checkout for the account. + Protect user flow from long-running authorization calls. + Prevents exchange rate discrepancies between displayed price and charged amount. + + +
+ {/* ── Shipping ─────────────────────────────────────────────── */} +
Shipping destination and fulfillment option data. - + + Address schema for delivery and tax calculation. + + + + + - + + Available shipping methods offered at checkout. + + + + + Reduces failed deliveries and return costs from invalid or incomplete addresses. - On address submission, call postal validation API. If the address is undeliverable, show an error. If the API suggests corrections (e.g., missing apartment number, ZIP+4 completion), present suggestions to the user and require explicit confirmation. Bypass validation only for military (APO/FPO) and PO Box addresses where API coverage is limited. + On address submission, call postal validation API. If undeliverable, show an error. If the API suggests corrections, present suggestions and require explicit user confirmation. + Ensures trade compliance and prevents orders that cannot be legally fulfilled. - Maintain a configurable blocklist of country and region codes. On shipping address entry, check against the blocklist. If restricted, display a message explaining the restriction without disclosing specific compliance details. Log blocked attempts for compliance audit. - - - - Ensures the total displayed at checkout includes all applicable taxes per jurisdiction. - - On shipping address confirmation, call tax service with line items and destination. Apply tax rates per item category (some categories may be tax-exempt). Display tax as a separate line item in the order summary. Recalculate if shipping address or cart contents change. + Maintain a configurable blocklist of country and region codes. On address entry, check against the blocklist. If restricted, display a message without disclosing specific compliance details. Log blocked attempts for compliance audit. + +
+ {/* ── Order Confirmation ───────────────────────────────────── */} +
Final order record and confirmation policies. - + + Immutable order snapshot created after successful payment. + + + + + Ensure users get immediate post-purchase confirmation. - Enqueue confirmation event at order creation. Email service must deliver within 60 seconds. If delivery fails, retry up to 3 times with exponential backoff (5s, 15s, 45s). After 3 failures, alert the operations team and queue for manual follow-up. + Enqueue confirmation event at order creation. Email service must deliver within 60 seconds. If delivery fails, retry up to 3 times with exponential backoff (5s, 15s, 45s). + - Provides a grace period for accidental or impulsive purchases while respecting fulfillment workflow constraints. + Provides a grace period for accidental purchases while respecting fulfillment workflow constraints. - On cancel request, check that createdAt is within 30 minutes of current time AND order status is still pending or confirmed (not shipped). If eligible, transition status to cancelled, initiate payment refund, and release inventory holds. + On cancel request, check that createdAt is within 30 minutes of current time AND order status is still pending or confirmed. If eligible, transition status to cancelled, initiate refund, and release inventory holds. - Initiate a full refund through the original payment method. If refund fails (e.g., payment provider error), create a store credit for the full amount and notify the user. Refund processing should complete within 5-10 business days depending on payment provider. - - - - Prevents the worst-case scenario where a user is charged but no order is recorded. - - After successful payment capture, if the order persistence step fails (database error, timeout), immediately log a critical alert with the payment transaction ID. - - - Trigger an automatic void (if within capture window) or refund on the payment. Send the user an email explaining the issue and confirming the refund. Include a support reference number for follow-up. + Initiate a full refund through the original payment method. If refund fails, create a store credit for the full amount and notify the user. + + +
+ {/* ── Cross-section dependencies ───────────────────────────── */} + - Checkout moves from cart validation to payment authorization. - + description="Cart must be finalized before payment can proceed" + /> - Successful payment unlocks fulfillment choice and pricing. - + /> - After shipping selection, the order is finalized and confirmed. - + /> +
diff --git a/packages/core/src/grammar.ts b/packages/core/src/grammar.ts index e0e0524..52c55da 100644 --- a/packages/core/src/grammar.ts +++ b/packages/core/src/grammar.ts @@ -3,19 +3,23 @@ import type { GrammarTable } from "./types/grammar.js"; /** * Canonical grammar table defining allowed parent → children relationships. * - * - Feature: top-level container. Can hold Section, Definition, Policy, Constraint, Link, Logic. - * - Section: grouping container (recursive). Same children as Feature minus Logic. + * - Feature: top-level container. Can hold all block types except nested Feature. + * Outcome is exclusive to Feature (not allowed in Section). + * - Section: grouping container (recursive). Same children as Feature minus Logic and Outcome. * - Policy: can contain nested Logic blocks for detailed execution rules. - * - Definition, Constraint, Link, Logic: leaf blocks with no children. + * - Actor, Definition, Constraint, Link, Logic, Outcome, Scenario: leaf blocks with no children. */ export const GRAMMAR_TABLE: GrammarTable = { - Feature: ["Section", "Definition", "Policy", "Constraint", "Link", "Logic"], - Section: ["Section", "Definition", "Policy", "Constraint", "Link"], + Feature: ["Section", "Definition", "Policy", "Constraint", "Link", "Logic", "Actor", "Outcome", "Scenario"], + Section: ["Section", "Definition", "Policy", "Constraint", "Link", "Actor", "Scenario"], Definition: [], Policy: ["Logic"], Constraint: [], Link: [], Logic: [], + Actor: [], + Outcome: [], + Scenario: [], } as const; /** diff --git a/packages/core/src/parser/mdx-to-pmast.ts b/packages/core/src/parser/mdx-to-pmast.ts index 6f24bcb..33ea8bd 100644 --- a/packages/core/src/parser/mdx-to-pmast.ts +++ b/packages/core/src/parser/mdx-to-pmast.ts @@ -2,6 +2,63 @@ import { PMDocumentSchema } from "../schema/blocks.js"; import type { Block, PMDocument } from "../types/ast.js"; import type { ExtractedBlock } from "./remark-product-model.js"; +/** + * Coerce a value that may be a stringified number (from MDX expression attributes) + * into a JS number. Returns undefined if the value is not numeric. + */ +function coerceNumber(value: unknown): number | undefined { + if (typeof value === "number") return value; + if (typeof value === "string") { + const n = Number(value); + return Number.isNaN(n) ? undefined : n; + } + return undefined; +} + +/** + * Transform native attributes into a FieldSpec-compatible object. + * + * Handles: + * - `values="USD,EUR"` → `enumValues: ["USD","EUR"]` (comma-separated enum values) + * - `required` (boolean attribute, present = true) + * - `min={1}` / `max={99}` (JSX expression string → number coercion) + */ +function transformFieldAttrs(raw: Record): Record { + const field: Record = { ...raw }; + + // Convert comma-separated `values` attribute → `enumValues` array + if (typeof field.values === "string") { + field.enumValues = field.values + .split(",") + .map((v) => v.trim()) + .filter(Boolean); + delete field.values; + } + + // Coerce numeric constraints + const min = coerceNumber(field.min); + if (min !== undefined) field.min = min; + + const max = coerceNumber(field.max); + if (max !== undefined) field.max = max; + + // `required` is a boolean attribute — already `true` when present without value. + // Handle the edge-case where it arrives as the string "false". + if (field.required === "false") field.required = false; + + return field; +} + +/** + * Parse a space-separated actor string (e.g. "guest member") into an array of IDs. + * Returns undefined if the value is absent or empty. + */ +function parseActorList(value: unknown): string[] | undefined { + if (typeof value !== "string") return undefined; + const ids = value.trim().split(/\s+/).filter(Boolean); + return ids.length > 0 ? ids : undefined; +} + /** * Transform an extracted block into a typed Block. * Handles nested children for Feature and Section blocks. @@ -16,27 +73,51 @@ function transformBlock(extracted: ExtractedBlock): Record { block.content = extracted.content; } - // Parse JSON-encoded fields attribute for Definition blocks - if (extracted.type === "Definition" && typeof block.fields === "string") { - try { - block.fields = JSON.parse(block.fields as string); - } catch { - // Leave as-is; Zod validation will catch the error - } - } + // ── Definition: resolve fields from native children or legacy JSON ── + if (extracted.type === "Definition") { + const fieldChildren = block.fieldChildren as Record[] | undefined; - // Parse JSON-encoded enumValues within fields - if (extracted.type === "Definition" && Array.isArray(block.fields)) { - block.fields = (block.fields as Record[]).map((field) => { - if (typeof field.enumValues === "string") { + if (Array.isArray(fieldChildren) && fieldChildren.length > 0) { + // Native children take priority + block.fields = fieldChildren.map(transformFieldAttrs); + delete block.fieldChildren; + } else { + // Legacy JSON-encoded fields attribute (deprecated but still supported) + if (typeof block.fields === "string") { try { - return { ...field, enumValues: JSON.parse(field.enumValues as string) }; + block.fields = JSON.parse(block.fields as string); } catch { - return field; + // Leave as-is; Zod validation will catch the error } } - return field; - }); + + // Parse JSON-encoded enumValues within legacy fields + if (Array.isArray(block.fields)) { + block.fields = (block.fields as Record[]).map((field) => { + if (typeof field.enumValues === "string") { + try { + return { ...field, enumValues: JSON.parse(field.enumValues as string) }; + } catch { + return field; + } + } + return field; + }); + } + } + + // Always clean up leftover fieldChildren key + delete block.fieldChildren; + } + + // ── Policy / Constraint: parse space-separated actor list → string[] ── + if (extracted.type === "Policy" || extracted.type === "Constraint") { + const actorList = parseActorList(block.actor); + if (actorList !== undefined) { + block.actor = actorList; + } else { + delete block.actor; + } } if (extracted.children.length > 0) { diff --git a/packages/core/src/parser/remark-product-model.ts b/packages/core/src/parser/remark-product-model.ts index 7843598..4d24056 100644 --- a/packages/core/src/parser/remark-product-model.ts +++ b/packages/core/src/parser/remark-product-model.ts @@ -67,8 +67,25 @@ function normalizeContent(raw: string): string | undefined { return normalized.length > 0 ? normalized : undefined; } +/** + * Extract attributes from a raw MdxJsxFlowElement into a plain record. + */ +function extractRawAttributes(node: MdxJsxFlowElement): Record { + const attrs: Record = {}; + for (const attr of node.attributes) { + if ("name" in attr && typeof attr.name === "string") { + attrs[attr.name] = extractAttributeValue(attr); + } + } + return attrs; +} + /** * Extract block data from an MDX JSX element and its children. + * + * Special handling for `` child elements inside ``: + * they are collected into `attributes.fieldChildren` as raw attribute records, + * rather than being treated as child blocks. */ function extractBlock(node: MdxJsxFlowElement): ExtractedBlock | null { const tagName = node.name; @@ -77,17 +94,23 @@ function extractBlock(node: MdxJsxFlowElement): ExtractedBlock | null { const result = BlockTypeSchema.safeParse(tagName); if (!result.success) return null; - const attributes: Record = {}; - for (const attr of node.attributes) { - if ("name" in attr && typeof attr.name === "string") { - attributes[attr.name] = extractAttributeValue(attr); - } - } + const attributes: Record = extractRawAttributes(node); const children: ExtractedBlock[] = []; const contentFragments: string[] = []; + for (const child of node.children) { if (isMdxJsxFlowElement(child)) { + // Collect native children into fieldChildren instead of block children + if (child.name === "Field") { + const fieldAttrs = extractRawAttributes(child); + if (!Array.isArray(attributes.fieldChildren)) { + attributes.fieldChildren = []; + } + (attributes.fieldChildren as Record[]).push(fieldAttrs); + continue; + } + const extracted = extractBlock(child); if (extracted) { children.push(extracted); diff --git a/packages/core/src/schema/blocks.ts b/packages/core/src/schema/blocks.ts index fc8e790..5b25cf3 100644 --- a/packages/core/src/schema/blocks.ts +++ b/packages/core/src/schema/blocks.ts @@ -32,6 +32,16 @@ export const PolicyBlockSchema = z.object({ content: BlockContentSchema, rule: z.string().min(1, "Policy must have a rule"), enforcement: z.enum(["must", "should", "may"]).default("must"), + /** + * Semantic event name that fires this policy (e.g. "cart.item.add", "payment.capture.success"). + * Human-readable and searchable — not evaluated at runtime. + */ + trigger: z.string().optional(), + /** + * Space-separated list of Actor IDs this policy applies to. + * Parsed into an array during MDX transformation (e.g. actor="guest member" → ["guest","member"]). + */ + actor: z.array(BlockIdSchema).optional(), children: z.array(LogicBlockSchema).optional(), }); @@ -42,6 +52,11 @@ export const ConstraintBlockSchema = z.object({ description: z.string().optional(), content: BlockContentSchema, condition: z.string().min(1, "Constraint must have a condition"), + /** + * Space-separated list of Actor IDs this constraint applies to. + * Parsed into an array during MDX transformation. + */ + actor: z.array(BlockIdSchema).optional(), }); export const LinkBlockSchema = z.object({ @@ -49,7 +64,87 @@ export const LinkBlockSchema = z.object({ id: BlockIdSchema.optional(), from: BlockIdSchema, to: BlockIdSchema, - relationship: z.enum(["depends-on", "extends", "conflicts-with", "implements"]), + /** + * Semantic relationship between the two blocks. + * - depends-on : `from` requires `to` to function + * - extends : `from` specialises or builds upon `to` + * - conflicts-with: `from` and `to` cannot be active simultaneously + * - implements : `from` is a concrete realisation of `to` + * - triggers : `from` causes `to` to activate or fire + * - supersedes : `from` replaces `to` (e.g. v2 replacing v1) + * - validates : `from` must pass before `to` can proceed + * - enables : `from` unlocks or gates `to` + * - blocks : `from` prevents `to` while it is active + */ + relationship: z.enum([ + "depends-on", + "extends", + "conflicts-with", + "implements", + "triggers", + "supersedes", + "validates", + "enables", + "blocks", + ]), + description: z.string().optional(), + content: BlockContentSchema, +}); + +/** + * Actor block — a first-class user role or persona. + * Reference an Actor's `id` in the `actor` attribute of Policy, Constraint, or Scenario + * to explicitly scope who a requirement applies to. + */ +export const ActorBlockSchema = z.object({ + type: z.literal("Actor"), + id: BlockIdSchema, + name: z.string().min(1), + description: z.string().optional(), + content: BlockContentSchema, +}); + +/** + * Outcome block — a measurable product success criterion. + * Allowed only inside Feature blocks. Anchors a feature to a concrete, + * quantitative definition of success. + */ +export const OutcomeBlockSchema = z.object({ + type: z.literal("Outcome"), + id: BlockIdSchema, + name: z.string().min(1), + /** The metric being tracked (e.g. "conversion_rate", "p99_latency_ms") */ + metric: z.string().min(1), + /** Target value to reach (e.g. "75%", "200ms") */ + target: z.string().min(1), + /** Measurement window (e.g. "30d", "Q2-2026") */ + timeframe: z.string().min(1), + /** Current baseline value before the feature ships (e.g. "68%") */ + baseline: z.string().optional(), + /** Team or individual accountable for this outcome */ + owner: z.string().optional(), + description: z.string().optional(), + content: BlockContentSchema, +}); + +/** + * Scenario block — a structured acceptance criterion (Given / When / Then). + * Links a human-readable test scenario directly to a Policy and optional Actor. + */ +export const ScenarioBlockSchema = z.object({ + type: z.literal("Scenario"), + id: BlockIdSchema, + name: z.string().min(1), + /** The precondition state (e.g. "A cart with 50 items") */ + given: z.string().min(1), + /** The triggering action (e.g. "The user attempts to add a 51st item") */ + when: z.string().min(1), + /** The expected outcome (e.g. "System returns CART_LIMIT_EXCEEDED") */ + then: z.string().min(1), + /** Optional reference to the Policy block this scenario validates */ + policy: BlockIdSchema.optional(), + /** Optional reference to an Actor block that performs the action */ + actor: BlockIdSchema.optional(), description: z.string().optional(), content: BlockContentSchema, }); @@ -62,6 +157,9 @@ const NonContainerBlockSchema = z.union([ ConstraintBlockSchema, LinkBlockSchema, LogicBlockSchema, + ActorBlockSchema, + OutcomeBlockSchema, + ScenarioBlockSchema, ]); type NonContainerBlock = z.infer; diff --git a/packages/core/src/schema/fields.ts b/packages/core/src/schema/fields.ts index a5cea04..4094255 100644 --- a/packages/core/src/schema/fields.ts +++ b/packages/core/src/schema/fields.ts @@ -4,6 +4,11 @@ import { FieldTypeSchema } from "./primitives.js"; /** * Schema for a field specification within a Definition block. * Defines the name, type, requiredness, and optional constraints of a data field. + * + * Fields can be authored either as native MDX child elements: + * + * + * or as a legacy JSON-encoded `fields` attribute on the Definition block (deprecated). */ export const FieldSpecSchema = z.object({ name: z.string().min(1, "Field name is required"), @@ -12,6 +17,12 @@ export const FieldSpecSchema = z.object({ description: z.string().optional(), enumValues: z.array(z.string()).optional(), default: z.union([z.string(), z.number(), z.boolean()]).optional(), + /** Minimum value — valid for `number` fields */ + min: z.number().optional(), + /** Maximum value — valid for `number` fields */ + max: z.number().optional(), + /** Regex pattern constraint — valid for `string` fields */ + pattern: z.string().optional(), }); /** diff --git a/packages/core/src/schema/index.ts b/packages/core/src/schema/index.ts index f2d651f..7040924 100644 --- a/packages/core/src/schema/index.ts +++ b/packages/core/src/schema/index.ts @@ -9,14 +9,17 @@ export { export { FieldSpecSchema, ValidatedFieldSpecSchema } from "./fields.js"; export { + ActorBlockSchema, BlockSchema, ConstraintBlockSchema, DefinitionBlockSchema, FeatureBlockSchema, LinkBlockSchema, LogicBlockSchema, + OutcomeBlockSchema, PMDocumentSchema, PolicyBlockSchema, + ScenarioBlockSchema, SectionBlockSchema, } from "./blocks.js"; diff --git a/packages/core/src/schema/primitives.ts b/packages/core/src/schema/primitives.ts index 72b9145..6a560af 100644 --- a/packages/core/src/schema/primitives.ts +++ b/packages/core/src/schema/primitives.ts @@ -34,6 +34,9 @@ export const BlockTypeSchema = z.enum([ "Constraint", "Link", "Logic", + "Actor", + "Outcome", + "Scenario", ]); /** diff --git a/packages/core/src/types/ast.ts b/packages/core/src/types/ast.ts index 0e42ad1..d8f8712 100644 --- a/packages/core/src/types/ast.ts +++ b/packages/core/src/types/ast.ts @@ -1,13 +1,16 @@ import type { z } from "zod"; import type { + ActorBlockSchema, BlockSchema, ConstraintBlockSchema, DefinitionBlockSchema, FeatureBlockSchema, LinkBlockSchema, LogicBlockSchema, + OutcomeBlockSchema, PMDocumentSchema, PolicyBlockSchema, + ScenarioBlockSchema, } from "../schema/blocks.js"; import type { FieldSpecSchema } from "../schema/fields.js"; import type { @@ -37,6 +40,9 @@ export type PolicyBlock = z.infer; export type ConstraintBlock = z.infer; export type LinkBlock = z.infer; export type LogicBlock = z.infer; +export type ActorBlock = z.infer; +export type OutcomeBlock = z.infer; +export type ScenarioBlock = z.infer; export type { SectionBlock } from "../schema/blocks.js"; export type FeatureBlock = z.infer; diff --git a/packages/core/src/validator/rules/link-integrity.ts b/packages/core/src/validator/rules/link-integrity.ts index 98e4754..36c4218 100644 --- a/packages/core/src/validator/rules/link-integrity.ts +++ b/packages/core/src/validator/rules/link-integrity.ts @@ -1,16 +1,40 @@ import type { Block, PMDocument } from "../../types/ast.js"; import type { ValidationDiagnostic } from "../../types/validation.js"; +interface LinkTarget { + id?: string; + from: string; + to: string; +} + +interface ActorReference { + blockId?: string; + actorIds: string[]; + context: string; +} + +interface ScenarioReference { + blockId?: string; + policyId?: string; + actorId?: string; +} + /** - * Check that all Link block targets (from/to) reference existing block IDs. + * Check that all reference targets resolve to existing block IDs: + * - Link `from` / `to` + * - Policy / Constraint `actor` list + * - Scenario `policy` and `actor` references */ export function checkLinkIntegrity(document: PMDocument): ValidationDiagnostic[] { const diagnostics: ValidationDiagnostic[] = []; const allIds = new Set(); - const links: Array<{ id?: string; from: string; to: string }> = []; + const links: LinkTarget[] = []; + const actorRefs: ActorReference[] = []; + const scenarioRefs: ScenarioReference[] = []; - collectIdsAndLinks(document.blocks, allIds, links); + collectRefs(document.blocks, allIds, links, actorRefs, scenarioRefs); + // ── Link from/to ────────────────────────────────────────── for (const link of links) { if (!allIds.has(link.from)) { diagnostics.push({ @@ -28,13 +52,46 @@ export function checkLinkIntegrity(document: PMDocument): ValidationDiagnostic[] } } + // ── Actor references (Policy / Constraint actor lists) ──── + for (const ref of actorRefs) { + for (const actorId of ref.actorIds) { + if (!allIds.has(actorId)) { + diagnostics.push({ + severity: "error", + message: `${ref.context} actor "${actorId}" does not reference an existing Actor block ID`, + blockId: ref.blockId, + }); + } + } + } + + // ── Scenario policy / actor references ──────────────────── + for (const ref of scenarioRefs) { + if (ref.policyId && !allIds.has(ref.policyId)) { + diagnostics.push({ + severity: "error", + message: `Scenario "policy" target "${ref.policyId}" does not reference an existing block ID`, + blockId: ref.blockId, + }); + } + if (ref.actorId && !allIds.has(ref.actorId)) { + diagnostics.push({ + severity: "error", + message: `Scenario "actor" target "${ref.actorId}" does not reference an existing Actor block ID`, + blockId: ref.blockId, + }); + } + } + return diagnostics; } -function collectIdsAndLinks( +function collectRefs( blocks: Block[], allIds: Set, - links: Array<{ id?: string; from: string; to: string }>, + links: LinkTarget[], + actorRefs: ActorReference[], + scenarioRefs: ScenarioReference[], ): void { for (const block of blocks) { if ("id" in block && typeof block.id === "string") { @@ -42,15 +99,27 @@ function collectIdsAndLinks( } if (block.type === "Link") { - links.push({ - id: block.id, - from: block.from, - to: block.to, + links.push({ id: block.id, from: block.from, to: block.to }); + } + + if ((block.type === "Policy" || block.type === "Constraint") && Array.isArray(block.actor)) { + actorRefs.push({ + blockId: block.id, + actorIds: block.actor as string[], + context: `${block.type} "${block.id}"`, + }); + } + + if (block.type === "Scenario") { + scenarioRefs.push({ + blockId: block.id, + policyId: block.policy, + actorId: block.actor, }); } if ("children" in block && Array.isArray(block.children)) { - collectIdsAndLinks(block.children as Block[], allIds, links); + collectRefs(block.children as Block[], allIds, links, actorRefs, scenarioRefs); } } } diff --git a/packages/core/src/validator/rules/workspace-link-integrity.ts b/packages/core/src/validator/rules/workspace-link-integrity.ts index 633ff7e..93a6a7a 100644 --- a/packages/core/src/validator/rules/workspace-link-integrity.ts +++ b/packages/core/src/validator/rules/workspace-link-integrity.ts @@ -8,16 +8,35 @@ interface LinkTarget { to: string; } +interface ActorReference { + blockId?: string; + actorIds: string[]; + context: string; +} + +interface ScenarioReference { + blockId?: string; + policyId?: string; + actorId?: string; +} + /** - * Check that all link targets resolve to existing IDs across the workspace. + * Check that all reference targets resolve to existing IDs across the workspace: + * - Link `from` / `to` + * - Policy / Constraint `actor` lists + * - Scenario `policy` and `actor` references */ export function checkWorkspaceLinkIntegrity(workspace: PMWorkspace): ValidationDiagnostic[] { const diagnostics: ValidationDiagnostic[] = []; for (const module of workspace.modules) { const links: LinkTarget[] = []; - collectLinks(module.document.blocks, links); + const actorRefs: ActorReference[] = []; + const scenarioRefs: ScenarioReference[] = []; + collectRefs(module.document.blocks, links, actorRefs, scenarioRefs); + + // ── Link from/to ────────────────────────────────────────── for (const link of links) { if (!(link.from in workspace.idIndex)) { diagnostics.push({ @@ -27,7 +46,6 @@ export function checkWorkspaceLinkIntegrity(workspace: PMWorkspace): ValidationD path: module.filePath, }); } - if (!(link.to in workspace.idIndex)) { diagnostics.push({ severity: "error", @@ -37,23 +55,74 @@ export function checkWorkspaceLinkIntegrity(workspace: PMWorkspace): ValidationD }); } } + + // ── Actor references ────────────────────────────────────── + for (const ref of actorRefs) { + for (const actorId of ref.actorIds) { + if (!(actorId in workspace.idIndex)) { + diagnostics.push({ + severity: "error", + message: `${ref.context} actor "${actorId}" does not reference an existing Actor block ID in workspace`, + blockId: ref.blockId, + path: module.filePath, + }); + } + } + } + + // ── Scenario policy / actor references ──────────────────── + for (const ref of scenarioRefs) { + if (ref.policyId && !(ref.policyId in workspace.idIndex)) { + diagnostics.push({ + severity: "error", + message: `Scenario "policy" target "${ref.policyId}" does not reference an existing block ID in workspace`, + blockId: ref.blockId, + path: module.filePath, + }); + } + if (ref.actorId && !(ref.actorId in workspace.idIndex)) { + diagnostics.push({ + severity: "error", + message: `Scenario "actor" target "${ref.actorId}" does not reference an existing Actor block ID in workspace`, + blockId: ref.blockId, + path: module.filePath, + }); + } + } } return diagnostics; } -function collectLinks(blocks: Block[], links: LinkTarget[]): void { +function collectRefs( + blocks: Block[], + links: LinkTarget[], + actorRefs: ActorReference[], + scenarioRefs: ScenarioReference[], +): void { for (const block of blocks) { if (block.type === "Link") { - links.push({ - id: block.id, - from: block.from, - to: block.to, + links.push({ id: block.id, from: block.from, to: block.to }); + } + + if ((block.type === "Policy" || block.type === "Constraint") && Array.isArray(block.actor)) { + actorRefs.push({ + blockId: block.id, + actorIds: block.actor as string[], + context: `${block.type} "${block.id}"`, + }); + } + + if (block.type === "Scenario") { + scenarioRefs.push({ + blockId: block.id, + policyId: block.policy, + actorId: block.actor, }); } if ("children" in block && Array.isArray(block.children)) { - collectLinks(block.children as Block[], links); + collectRefs(block.children as Block[], links, actorRefs, scenarioRefs); } } } diff --git a/packages/core/tests/fixtures/valid-full.product.mdx b/packages/core/tests/fixtures/valid-full.product.mdx index 2d22c33..14cae8d 100644 --- a/packages/core/tests/fixtures/valid-full.product.mdx +++ b/packages/core/tests/fixtures/valid-full.product.mdx @@ -1,35 +1,62 @@ End-to-end purchase flow from cart through payment confirmation. + + + + +
Manages cart state before a user begins payment. - + Canonical line-item contract for checkout calculations. + + + + Prevent oversized orders that violate fulfillment constraints. - If requested quantity > 99, reject update and return validation error. + If requested quantity exceeds 99, reject the update and return a validation error. Every cart item price must remain positive. +
Payment capture and tokenized authorization workflows. - + Supported payment instruments and token payload. + +
diff --git a/packages/core/tests/grammar.test.ts b/packages/core/tests/grammar.test.ts index 7a3a3ea..eb1d4fe 100644 --- a/packages/core/tests/grammar.test.ts +++ b/packages/core/tests/grammar.test.ts @@ -2,9 +2,9 @@ import { describe, expect, it } from "vitest"; import { GRAMMAR_TABLE, ROOT_BLOCK_TYPES } from "../src/grammar.js"; describe("GRAMMAR_TABLE", () => { - it("defines all 7 block types", () => { + it("defines all 10 block types", () => { const types = Object.keys(GRAMMAR_TABLE); - expect(types).toHaveLength(7); + expect(types).toHaveLength(10); expect(types).toContain("Feature"); expect(types).toContain("Section"); expect(types).toContain("Definition"); @@ -12,6 +12,9 @@ describe("GRAMMAR_TABLE", () => { expect(types).toContain("Constraint"); expect(types).toContain("Link"); expect(types).toContain("Logic"); + expect(types).toContain("Actor"); + expect(types).toContain("Outcome"); + expect(types).toContain("Scenario"); }); it("Feature can hold all non-Feature block types", () => { @@ -21,11 +24,20 @@ describe("GRAMMAR_TABLE", () => { expect(GRAMMAR_TABLE.Feature).toContain("Constraint"); expect(GRAMMAR_TABLE.Feature).toContain("Link"); expect(GRAMMAR_TABLE.Feature).toContain("Logic"); + expect(GRAMMAR_TABLE.Feature).toContain("Actor"); + expect(GRAMMAR_TABLE.Feature).toContain("Outcome"); + expect(GRAMMAR_TABLE.Feature).toContain("Scenario"); expect(GRAMMAR_TABLE.Feature).not.toContain("Feature"); }); - it("Section excludes Logic children", () => { + it("Section excludes Logic and Outcome children", () => { expect(GRAMMAR_TABLE.Section).not.toContain("Logic"); + expect(GRAMMAR_TABLE.Section).not.toContain("Outcome"); + }); + + it("Section allows Actor and Scenario children", () => { + expect(GRAMMAR_TABLE.Section).toContain("Actor"); + expect(GRAMMAR_TABLE.Section).toContain("Scenario"); }); it("Policy allows only Logic children", () => { @@ -37,6 +49,9 @@ describe("GRAMMAR_TABLE", () => { expect(GRAMMAR_TABLE.Constraint).toEqual([]); expect(GRAMMAR_TABLE.Link).toEqual([]); expect(GRAMMAR_TABLE.Logic).toEqual([]); + expect(GRAMMAR_TABLE.Actor).toEqual([]); + expect(GRAMMAR_TABLE.Outcome).toEqual([]); + expect(GRAMMAR_TABLE.Scenario).toEqual([]); }); }); diff --git a/packages/core/tests/parser.test.ts b/packages/core/tests/parser.test.ts index 10e3bbf..a52de63 100644 --- a/packages/core/tests/parser.test.ts +++ b/packages/core/tests/parser.test.ts @@ -1,7 +1,72 @@ import { describe, expect, it } from "vitest"; import { parse } from "../src/parser/index.js"; -describe("parser", () => { +describe("parser — Definition fields", () => { + it("parses native children into fields array", async () => { + const source = ` + + + Cart item schema. + + + + + +`; + const doc = await parse(source, { version: "1.0.0", title: "Checkout" }); + const feature = doc.blocks[0]; + const definition = feature && "children" in feature ? feature.children?.[0] : undefined; + expect(definition?.type).toBe("Definition"); + if (definition?.type !== "Definition") return; + + expect(definition.fields).toHaveLength(3); + expect(definition.fields[0]).toMatchObject({ name: "productId", type: "string", required: true }); + expect(definition.fields[1]).toMatchObject({ name: "quantity", type: "number", min: 1, max: 99 }); + expect(definition.fields[2]).toMatchObject({ + name: "currency", + type: "enum", + enumValues: ["USD", "EUR", "GBP"], + }); + }); + + it("parses legacy JSON-encoded fields attribute (backward compatibility)", async () => { + const source = ` + + + Cart item schema body text. + + +`; + const doc = await parse(source, { version: "1.0.0", title: "Checkout" }); + const feature = doc.blocks[0]; + const definition = feature && "children" in feature ? feature.children?.[0] : undefined; + expect(definition?.type).toBe("Definition"); + if (definition?.type !== "Definition") return; + expect(definition.fields).toHaveLength(1); + expect(definition.fields[0]?.name).toBe("productId"); + }); + + it("prefers native children over JSON fields attribute when both present", async () => { + const source = ` + + + + + +`; + const doc = await parse(source, { version: "1.0.0", title: "Checkout" }); + const feature = doc.blocks[0]; + const definition = feature && "children" in feature ? feature.children?.[0] : undefined; + if (definition?.type !== "Definition") return; + expect(definition.fields[0]?.name).toBe("native"); + }); +}); + +describe("parser — block content", () => { it("parses block body text into content", async () => { const source = ` @@ -13,10 +78,8 @@ describe("parser", () => {
`; - - const document = await parse(source, { version: "1.0.0", title: "Checkout" }); - const feature = document.blocks[0]; - expect(feature?.type).toBe("Feature"); + const doc = await parse(source, { version: "1.0.0", title: "Checkout" }); + const feature = doc.blocks[0]; expect(feature && "content" in feature ? feature.content : undefined).toContain( "Checkout flow summary text.", ); @@ -39,19 +102,122 @@ describe("parser", () => { `; - - const document = await parse(source, { version: "1.0.0", title: "Checkout" }); + const doc = await parse(source, { version: "1.0.0", title: "Checkout" }); const policy = - document.blocks[0] && "children" in document.blocks[0] - ? document.blocks[0].children?.[0] - : undefined; + doc.blocks[0] && "children" in doc.blocks[0] ? doc.blocks[0].children?.[0] : undefined; expect(policy?.type).toBe("Policy"); expect(policy && "children" in policy ? policy.children?.[0]?.type : undefined).toBe("Logic"); }); +}); - it("rejects unsupported status/priority attributes on Feature", async () => { - const source = ``; +describe("parser — Actor block", () => { + it("parses an Actor block", async () => { + const source = ` + + + +`; + const doc = await parse(source, { version: "1.0.0", title: "Checkout" }); + const actor = + doc.blocks[0] && "children" in doc.blocks[0] ? doc.blocks[0].children?.[0] : undefined; + expect(actor?.type).toBe("Actor"); + if (actor?.type !== "Actor") return; + expect(actor.id).toBe("guest"); + expect(actor.description).toBe("Unauthenticated visitor"); + }); +}); +describe("parser — Outcome block", () => { + it("parses an Outcome block", async () => { + const source = ` + + + +`; + const doc = await parse(source, { version: "1.0.0", title: "Checkout" }); + const outcome = + doc.blocks[0] && "children" in doc.blocks[0] ? doc.blocks[0].children?.[0] : undefined; + expect(outcome?.type).toBe("Outcome"); + if (outcome?.type !== "Outcome") return; + expect(outcome.metric).toBe("conversion_rate"); + expect(outcome.target).toBe("75%"); + expect(outcome.baseline).toBe("68%"); + }); +}); + +describe("parser — Scenario block", () => { + it("parses a Scenario block with policy and actor references", async () => { + const source = ` + + + +`; + const doc = await parse(source, { version: "1.0.0", title: "Checkout" }); + const scenario = + doc.blocks[0] && "children" in doc.blocks[0] ? doc.blocks[0].children?.[0] : undefined; + expect(scenario?.type).toBe("Scenario"); + if (scenario?.type !== "Scenario") return; + expect(scenario.given).toBe("A cart with 99 items"); + expect(scenario.policy).toBe("max-qty"); + expect(scenario.actor).toBe("guest"); + }); +}); + +describe("parser — Policy actor and trigger", () => { + it("parses Policy trigger and space-separated actor into array", async () => { + const source = ` + + + +`; + const doc = await parse(source, { version: "1.0.0", title: "Checkout" }); + const policy = + doc.blocks[0] && "children" in doc.blocks[0] ? doc.blocks[0].children?.[0] : undefined; + expect(policy?.type).toBe("Policy"); + if (policy?.type !== "Policy") return; + expect(policy.trigger).toBe("cart.item.add"); + expect(policy.actor).toEqual(["guest", "member"]); + }); +}); + +describe("parser — Link relationships", () => { + it("parses new relationship types", async () => { + const source = ` + +
+
+ + +`; + const doc = await parse(source, { version: "1.0.0", title: "Checkout" }); + const link = + doc.blocks[0] && "children" in doc.blocks[0] ? doc.blocks[0].children?.[2] : undefined; + expect(link?.type).toBe("Link"); + if (link?.type !== "Link") return; + expect(link.relationship).toBe("validates"); + }); +}); + +describe("parser — errors", () => { + it("rejects unsupported attributes on Feature", async () => { + const source = ``; await expect(parse(source, { version: "1.0.0", title: "Checkout" })).rejects.toThrow(); }); }); diff --git a/packages/core/tests/schema.test.ts b/packages/core/tests/schema.test.ts index 2382cd8..bdc2a59 100644 --- a/packages/core/tests/schema.test.ts +++ b/packages/core/tests/schema.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; import { + ActorBlockSchema, BlockIdSchema, ConstraintBlockSchema, DefinitionBlockSchema, @@ -7,8 +8,10 @@ import { FieldTypeSchema, LinkBlockSchema, LogicBlockSchema, + OutcomeBlockSchema, PMDocumentSchema, PolicyBlockSchema, + ScenarioBlockSchema, SemVerSchema, } from "../src/schema/index.js"; @@ -58,7 +61,7 @@ describe("primitives", () => { describe("block schemas", () => { describe("DefinitionBlockSchema", () => { - it("accepts a valid definition", () => { + it("accepts a valid definition with flat fields", () => { const def = DefinitionBlockSchema.parse({ type: "Definition", id: "user", @@ -70,6 +73,29 @@ describe("block schemas", () => { expect(def.fields).toHaveLength(1); }); + it("accepts fields with min/max constraints", () => { + const def = DefinitionBlockSchema.parse({ + type: "Definition", + id: "item", + name: "Item", + version: "1.0.0", + fields: [{ name: "quantity", type: "number", required: true, min: 1, max: 99 }], + }); + expect(def.fields[0]?.min).toBe(1); + expect(def.fields[0]?.max).toBe(99); + }); + + it("accepts fields with pattern constraint", () => { + const def = DefinitionBlockSchema.parse({ + type: "Definition", + id: "order", + name: "Order", + version: "1.0.0", + fields: [{ name: "orderId", type: "string", required: true, pattern: "^ORD-\\d+$" }], + }); + expect(def.fields[0]?.pattern).toBe("^ORD-\\d+$"); + }); + it("rejects a definition without fields", () => { expect(() => DefinitionBlockSchema.parse({ @@ -84,7 +110,7 @@ describe("block schemas", () => { }); describe("PolicyBlockSchema", () => { - it("accepts a valid policy", () => { + it("accepts a valid policy with default enforcement", () => { const policy = PolicyBlockSchema.parse({ type: "Policy", id: "max-qty", @@ -101,6 +127,19 @@ describe("block schemas", () => { }); expect(policy.enforcement).toBe("must"); }); + + it("accepts a policy with trigger and actor list", () => { + const policy = PolicyBlockSchema.parse({ + type: "Policy", + id: "max-qty", + name: "Max Quantity", + rule: "Cannot exceed 99", + trigger: "cart.item.add", + actor: ["guest", "member"], + }); + expect(policy.trigger).toBe("cart.item.add"); + expect(policy.actor).toEqual(["guest", "member"]); + }); }); describe("ConstraintBlockSchema", () => { @@ -113,17 +152,53 @@ describe("block schemas", () => { }); expect(c.condition).toBe("value > 0"); }); + + it("accepts a constraint with actor list", () => { + const c = ConstraintBlockSchema.parse({ + type: "Constraint", + id: "positive", + name: "Positive Value", + condition: "value > 0", + actor: ["guest"], + }); + expect(c.actor).toEqual(["guest"]); + }); }); describe("LinkBlockSchema", () => { - it("accepts a valid link", () => { - const link = LinkBlockSchema.parse({ - type: "Link", - from: "blockA", - to: "blockB", - relationship: "depends-on", - }); - expect(link.relationship).toBe("depends-on"); + it("accepts all original relationship types", () => { + for (const rel of ["depends-on", "extends", "conflicts-with", "implements"]) { + const link = LinkBlockSchema.parse({ + type: "Link", + from: "blockA", + to: "blockB", + relationship: rel, + }); + expect(link.relationship).toBe(rel); + } + }); + + it("accepts new relationship types", () => { + for (const rel of ["triggers", "supersedes", "validates", "enables", "blocks"]) { + const link = LinkBlockSchema.parse({ + type: "Link", + from: "blockA", + to: "blockB", + relationship: rel, + }); + expect(link.relationship).toBe(rel); + } + }); + + it("rejects an unknown relationship type", () => { + expect(() => + LinkBlockSchema.parse({ + type: "Link", + from: "blockA", + to: "blockB", + relationship: "replaces", + }), + ).toThrow(); }); }); @@ -139,8 +214,80 @@ describe("block schemas", () => { }); }); + describe("ActorBlockSchema", () => { + it("accepts a valid actor", () => { + const actor = ActorBlockSchema.parse({ + type: "Actor", + id: "guest-user", + name: "Guest User", + description: "Unauthenticated visitor", + }); + expect(actor.type).toBe("Actor"); + expect(actor.id).toBe("guest-user"); + }); + + it("requires id and name", () => { + expect(() => ActorBlockSchema.parse({ type: "Actor" })).toThrow(); + }); + }); + + describe("OutcomeBlockSchema", () => { + it("accepts a valid outcome", () => { + const outcome = OutcomeBlockSchema.parse({ + type: "Outcome", + id: "checkout-cvr", + name: "Checkout Conversion Rate", + metric: "conversion_rate", + target: "75%", + timeframe: "30d", + baseline: "68%", + owner: "checkout-pm", + }); + expect(outcome.metric).toBe("conversion_rate"); + expect(outcome.target).toBe("75%"); + }); + + it("requires metric, target, timeframe", () => { + expect(() => + OutcomeBlockSchema.parse({ + type: "Outcome", + id: "cvr", + name: "CVR", + }), + ).toThrow(); + }); + }); + + describe("ScenarioBlockSchema", () => { + it("accepts a valid scenario", () => { + const scenario = ScenarioBlockSchema.parse({ + type: "Scenario", + id: "sc-cart-limit", + name: "Cart at capacity", + given: "A cart with 99 items", + when: "User adds a 100th item", + then: "System returns CART_LIMIT_EXCEEDED", + policy: "max-quantity", + actor: "guest-user", + }); + expect(scenario.given).toBe("A cart with 99 items"); + expect(scenario.policy).toBe("max-quantity"); + expect(scenario.actor).toBe("guest-user"); + }); + + it("requires given, when, then", () => { + expect(() => + ScenarioBlockSchema.parse({ + type: "Scenario", + id: "sc-x", + name: "X", + }), + ).toThrow(); + }); + }); + describe("FeatureBlockSchema", () => { - it("accepts a feature with children", () => { + it("accepts a feature with Definition children", () => { const feature = FeatureBlockSchema.parse({ type: "Feature", id: "checkout", @@ -158,7 +305,35 @@ describe("block schemas", () => { expect(feature.children).toHaveLength(1); }); - it("rejects unsupported status/priority fields", () => { + it("accepts a feature with Actor, Outcome and Scenario children", () => { + const feature = FeatureBlockSchema.parse({ + type: "Feature", + id: "checkout", + name: "Checkout", + children: [ + { type: "Actor", id: "guest", name: "Guest User" }, + { + type: "Outcome", + id: "cvr", + name: "Conversion Rate", + metric: "conversion_rate", + target: "75%", + timeframe: "30d", + }, + { + type: "Scenario", + id: "sc-1", + name: "Scenario 1", + given: "State A", + when: "Action B", + then: "Result C", + }, + ], + }); + expect(feature.children).toHaveLength(3); + }); + + it("rejects unsupported top-level attributes on Feature", () => { expect(() => FeatureBlockSchema.parse({ type: "Feature", diff --git a/packages/core/tests/validator.test.ts b/packages/core/tests/validator.test.ts index c0e258b..810c0d4 100644 --- a/packages/core/tests/validator.test.ts +++ b/packages/core/tests/validator.test.ts @@ -77,6 +77,100 @@ describe("grammar-rules", () => { expect(diagnostics).toHaveLength(1); expect(diagnostics[0]?.message).toContain("not allowed at document root"); }); + + it("allows Actor, Outcome, Scenario as Feature children", () => { + const doc = makeDoc({ + blocks: [ + { + type: "Feature", + id: "f1", + name: "Feature 1", + children: [ + { type: "Actor", id: "guest", name: "Guest" }, + { + type: "Outcome", + id: "cvr", + name: "CVR", + metric: "conversion_rate", + target: "75%", + timeframe: "30d", + }, + { + type: "Scenario", + id: "sc-1", + name: "Scenario", + given: "G", + when: "W", + then: "T", + }, + ], + }, + ], + }); + const diagnostics = checkGrammarRules(doc); + expect(diagnostics).toHaveLength(0); + }); + + it("allows Actor and Scenario as Section children", () => { + const doc = makeDoc({ + blocks: [ + { + type: "Feature", + id: "f1", + name: "Feature 1", + children: [ + { + type: "Section", + id: "sec-1", + name: "Section 1", + children: [ + { type: "Actor", id: "guest", name: "Guest" }, + { + type: "Scenario", + id: "sc-1", + name: "Scenario", + given: "G", + when: "W", + then: "T", + }, + ], + }, + ], + }, + ], + }); + const diagnostics = checkGrammarRules(doc); + expect(diagnostics).toHaveLength(0); + }); + + it("rejects Outcome as a Section child", () => { + const doc = makeDoc({ + blocks: [ + { + type: "Feature", + id: "f1", + name: "Feature 1", + children: [ + { + type: "Section", + id: "sec-1", + name: "Section 1", + children: [ + { + type: "Outcome" as "Actor", + id: "cvr", + name: "CVR", + } as PMDocument["blocks"][number], + ], + }, + ], + }, + ], + }); + const diagnostics = checkGrammarRules(doc); + expect(diagnostics).toHaveLength(1); + expect(diagnostics[0]?.message).toContain('"Outcome" is not allowed as a child of "Section"'); + }); }); describe("id-uniqueness", () => { @@ -176,4 +270,134 @@ describe("link-integrity", () => { const diagnostics = checkLinkIntegrity(doc); expect(diagnostics).toHaveLength(0); }); + + it("validates new relationship types in links", () => { + const doc = makeDoc({ + blocks: [ + { + type: "Feature", + id: "f1", + name: "F", + children: [ + { type: "Actor", id: "a1", name: "Actor 1" }, + { + type: "Link", + from: "f1", + to: "a1", + relationship: "enables", + }, + ], + }, + ], + }); + const diagnostics = checkLinkIntegrity(doc); + expect(diagnostics).toHaveLength(0); + }); + + it("detects broken Policy actor references", () => { + const doc = makeDoc({ + blocks: [ + { + type: "Feature", + id: "f1", + name: "F", + children: [ + { + type: "Policy", + id: "p1", + name: "Policy", + rule: "Some rule", + actor: ["nonexistent-actor"], + }, + ], + }, + ], + }); + const diagnostics = checkLinkIntegrity(doc); + expect(diagnostics).toHaveLength(1); + expect(diagnostics[0]?.message).toContain("nonexistent-actor"); + }); + + it("passes when Policy actor references a valid Actor block", () => { + const doc = makeDoc({ + blocks: [ + { + type: "Feature", + id: "f1", + name: "F", + children: [ + { type: "Actor", id: "guest", name: "Guest" }, + { + type: "Policy", + id: "p1", + name: "Policy", + rule: "Some rule", + actor: ["guest"], + }, + ], + }, + ], + }); + const diagnostics = checkLinkIntegrity(doc); + expect(diagnostics).toHaveLength(0); + }); + + it("detects broken Scenario policy reference", () => { + const doc = makeDoc({ + blocks: [ + { + type: "Feature", + id: "f1", + name: "F", + children: [ + { + type: "Scenario", + id: "sc-1", + name: "Scenario", + given: "G", + when: "W", + then: "T", + policy: "nonexistent-policy", + }, + ], + }, + ], + }); + const diagnostics = checkLinkIntegrity(doc); + expect(diagnostics).toHaveLength(1); + expect(diagnostics[0]?.message).toContain("nonexistent-policy"); + }); + + it("passes when Scenario references valid policy and actor", () => { + const doc = makeDoc({ + blocks: [ + { + type: "Feature", + id: "f1", + name: "F", + children: [ + { type: "Actor", id: "guest", name: "Guest" }, + { + type: "Policy", + id: "p1", + name: "Policy", + rule: "Some rule", + }, + { + type: "Scenario", + id: "sc-1", + name: "Scenario", + given: "G", + when: "W", + then: "T", + policy: "p1", + actor: "guest", + }, + ], + }, + ], + }); + const diagnostics = checkLinkIntegrity(doc); + expect(diagnostics).toHaveLength(0); + }); }); From c63782557a6a0bbe6b2f6572639c48118e0f5ef9 Mon Sep 17 00:00:00 2001 From: JeongwooYoo Date: Sat, 14 Mar 2026 16:16:52 -0700 Subject: [PATCH 2/4] fix: remove duplicate pnpm version from CI workflow --- .github/workflows/ci.yml | 2 -- 1 file changed, 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dac407f..6a6259f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,8 +17,6 @@ jobs: - uses: actions/checkout@v4 - uses: pnpm/action-setup@v4 - with: - version: 9 - uses: actions/setup-node@v4 with: From 5143b3f9be0ad9c7656ec4f89cdf5fd36ecd2bc1 Mon Sep 17 00:00:00 2001 From: JeongwooYoo Date: Sat, 14 Mar 2026 16:19:23 -0700 Subject: [PATCH 3/4] fix: resolve all biome lint errors --- apps/studio/src/components/studio/studio-app.tsx | 10 +++++++++- biome.json | 2 +- packages/cli/src/commands/studio.ts | 12 ++++-------- packages/core/src/grammar.ts | 12 +++++++++++- packages/core/src/parser/mdx-to-pmast.ts | 8 ++++---- packages/core/src/schema/blocks.ts | 1 + packages/core/tests/parser.test.ts | 13 +++++++++++-- packages/core/tests/schema.test.ts | 2 ++ packages/core/tests/validator.test.ts | 4 ++++ 9 files changed, 47 insertions(+), 17 deletions(-) diff --git a/apps/studio/src/components/studio/studio-app.tsx b/apps/studio/src/components/studio/studio-app.tsx index 0e8cd12..236b416 100644 --- a/apps/studio/src/components/studio/studio-app.tsx +++ b/apps/studio/src/components/studio/studio-app.tsx @@ -194,6 +194,9 @@ function StudioEditorBlock({
onFocus(focused ? "" : block.uiId)} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") onFocus(focused ? "" : block.uiId); + }} >