/** * uat/cli/lib/plan-io.ts — Locate + load a `.plantest.yml` from disk. * * The execution CLIs (provision / api / ui / report / run) all start from a plan * uat-plan wrote. They accept either an explicit `planFile` or the same * `path` scope uat-plan took — in which case the artifact is found at the * uat-plan default location `.application-test/uat/{application}/{name}.plantest.yml` * (name = last path segment). Loading re-validates schema + invariants so a * hand-edited plan fails loudly before any run. */ import { existsSync, readFileSync } from 'node:fs'; import { isAbsolute, join, resolve } from 'node:path'; import { load } from 'js-yaml'; import { parsePlan, type PlanTest } from './plantest-schema.js'; export interface PlanLocation { /** Absolute path of the plan file. */ absPath: string; /** Path relative to projectRoot (POSIX) when under it, else the absolute path. */ relPath: string; application: string; name: string; } /** Derive the default plan location from a `path` scope. PURE. */ export function defaultPlanLocation(projectRoot: string, path: string, name?: string): PlanLocation { const segments = path .split('/') .map((s) => s.trim()) .filter(Boolean); const application = segments[0] ?? path; const baseName = name ?? (segments.length > 0 ? segments[segments.length - 1] : 'all'); const rel = `.application-test/uat/${application}/${baseName}.plantest.yml`; return { absPath: resolve(join(projectRoot, rel)), relPath: rel, application, name: baseName }; } export interface LocatePlanInput { projectRoot: string; planFile?: string; path?: string; name?: string; } export type LocatePlanResult = { ok: true; location: PlanLocation } | { ok: false; error: string }; /** Resolve the plan file from a spec (`planFile` wins; else `path` + default layout). */ export function locatePlan(input: LocatePlanInput): LocatePlanResult { if (input.planFile) { const abs = isAbsolute(input.planFile) ? input.planFile : resolve(join(input.projectRoot, input.planFile)); if (!existsSync(abs)) return { ok: false, error: `planFile not found: ${abs}` }; const m = /([^/\\]+)\.plantest\.yml$/.exec(abs); const name = m ? m[1] : 'plan'; return { ok: true, location: { absPath: abs, relPath: input.planFile.replace(/\\/g, '/'), application: name, name }, }; } if (!input.path) return { ok: false, error: 'spec needs either "planFile" or "path"' }; const location = defaultPlanLocation(input.projectRoot, input.path, input.name); if (!existsSync(location.absPath)) { return { ok: false, error: `No plan at ${location.relPath} — generate it first (\`/uat plan\` on path "${input.path}").`, }; } return { ok: true, location }; } export type LoadPlanResult = | { ok: true; plan: PlanTest; violations: string[] } | { ok: false; errors: string[] }; /** Read + schema-validate a plan file. Invariant violations are advisory strings. */ export function loadPlan(absPath: string): LoadPlanResult { let raw: unknown; try { raw = load(readFileSync(absPath, 'utf-8')); } catch (e) { return { ok: false, errors: [`Cannot read plan ${absPath}: ${(e as Error).message}`] }; } const parsed = parsePlan(raw); if (!parsed.ok) return { ok: false, errors: parsed.errors }; return { ok: true, plan: parsed.plan, violations: parsed.violations.map((v) => `[inv ${v.invariant}] ${v.where}: ${v.message}`), }; } /** Application code a loaded plan declares (meta.application). */ export function planApplication(plan: PlanTest): string { return plan.meta.application; }