/** * uat-plan/generate.ts — PURE assembly of a `.plantest.yml` from discovery data. * * Ties the projection (project-roles) to the contract (plantest-schema): assemble * the raw plan, parse it through the Zod schema (fills defaults), check the * structural invariants, serialise to deterministic YAML, and emit the artifact + * its `.signature` sidecar. No I/O, no clock — `generatedAt` is injected so the * plan is byte-stable under a fixture. */ import { createHash } from 'node:crypto'; import { dump } from 'js-yaml'; import { PlanTestSchema, validateInvariants, type PlanTest, type Route, type Endpoint, type InvariantViolation, type RoleCatalogEntry, } from '../lib/plantest-schema.js'; import { canonicalRoleOrder, projectRoutes, projectEndpoints, } from './project-roles.js'; import type { DiscoveryResult, SourceSignature, GeneratedFile, CapsOverride, } from './types.js'; /** Run-time-interpolated screenshot template (literal `${...}` placeholders — F2). */ const SCREENSHOTS_OUT_DIR = '.application-test/runs/${run}/screenshots/${role}/${route_id}'; /** sha256 of three canonical source strings → the plan's drift signature. PURE. */ export function computeSignature(parts: { nav: string; rbac: string; registry: string }): SourceSignature { const sha = (s: string): string => createHash('sha256').update(s, 'utf8').digest('hex'); return { nav_sha: sha(parts.nav), rbac_sha: sha(parts.rbac), registry_sha: sha(parts.registry) }; } export interface AssemblePlanArgs { application: string; path: string; /** Canonically ordered (caller passes canonicalRoleOrder output). */ roles: string[]; /** Role identities keyed by role name (never carries `anonymous`). */ roleCatalog?: Record; routes: Route[]; endpoints: Endpoint[]; signature: SourceSignature; modes?: ('bfs' | 'goto')[]; caps?: CapsOverride; generatedAt?: string; } /** * Assemble + validate a plan. Returns the parsed plan (defaults filled) and any * structural invariant violations. Throws only on an internal assembly bug (a * raw plan that fails the SCHEMA — distinct from invariant violations, which are * returned for the caller to surface). */ export function assemblePlan(args: AssemblePlanArgs): { plan: PlanTest; violations: InvariantViolation[] } { const execution: Record = { screenshots: { out_dir: SCREENSHOTS_OUT_DIR }, }; if (args.modes) execution.modes = args.modes; // A partial caps override is parsed normally by Zod, so omitted fields still get // their per-field defaults (the v4 `.default()` footgun only bites the absent case). if (args.caps) execution.caps = args.caps; if (args.roles.includes('anonymous')) { execution.per_role = { anonymous: { mode: 'goto', expect: 'redirect_login' } }; } const meta: Record = { application: args.application, path: args.path, source_signature: args.signature, }; if (args.generatedAt) meta.generated_at = args.generatedAt; // `anonymous` is synthetic (no auth_Roles row) — never a catalog entry. YAML key // order follows the SCHEMA shape (roles → role_catalog → execution), not this raw. const roleCatalog = args.roleCatalog ? Object.fromEntries(Object.entries(args.roleCatalog).filter(([name]) => name !== 'anonymous')) : undefined; const raw = { schema_version: '1.1.0', meta, roles: args.roles, ...(roleCatalog ? { role_catalog: roleCatalog } : {}), execution, routes: args.routes, endpoints: args.endpoints, }; const parsed = PlanTestSchema.safeParse(raw); if (!parsed.success) { throw new Error( `uat-plan assembled an invalid plan (internal bug): ${parsed.error.issues .map((i) => `${i.path.join('.')}: ${i.message}`) .join('; ')}`, ); } return { plan: parsed.data, violations: validateInvariants(parsed.data) }; } /** Serialise a plan to deterministic YAML (insertion order preserved, no line-wrap). */ export function serializePlan(plan: PlanTest): string { return dump(plan, { lineWidth: -1, noRefs: true, indent: 2 }); } /** Emit the plan + its `.signature` sidecar under `outDir`. */ export function emitArtifacts( name: string, outDir: string, yaml: string, signature: SourceSignature, ): GeneratedFile[] { const dir = outDir.replace(/\/+$/, ''); return [ { path: `${dir}/${name}.plantest.yml`, content: yaml }, { path: `${dir}/${name}.plantest.signature`, content: `${JSON.stringify(signature, null, 2)}\n` }, ]; } export interface GeneratePlanOptions { /** Artifact basename (`{name}.plantest.yml`). */ name: string; /** Artifact directory (relative to projectPath). */ outDir: string; modes?: ('bfs' | 'goto')[]; caps?: CapsOverride; generatedAt?: string; /** Emit the API axis (`endpoints[]`). */ includeApi: boolean; } export interface GeneratePlanResult { plan: PlanTest; yaml: string; violations: InvariantViolation[]; files: GeneratedFile[]; signature: SourceSignature; } /** End-to-end PURE pipeline: discovery → projection → plan → YAML + artifacts. */ export function generatePlan(discovery: DiscoveryResult, opts: GeneratePlanOptions): GeneratePlanResult { const roles = canonicalRoleOrder(discovery.rbac.roles); const routes = projectRoutes(discovery.routes, roles, discovery.rbac.grantsByRole); const endpoints = opts.includeApi ? projectEndpoints(discovery.endpoints, roles, discovery.rbac.grantsByRole) : []; const { plan, violations } = assemblePlan({ application: discovery.application, path: discovery.path, roles, roleCatalog: discovery.roleCatalog, routes, endpoints, signature: discovery.signature, modes: opts.modes, caps: opts.caps, generatedAt: opts.generatedAt, }); const yaml = serializePlan(plan); const files = emitArtifacts(opts.name, opts.outDir, yaml, discovery.signature); return { plan, yaml, violations, files, signature: discovery.signature }; }