diff --git a/src/data/sample.ts b/src/data/sample.ts index 0d51aa3..8117f76 100644 --- a/src/data/sample.ts +++ b/src/data/sample.ts @@ -1,7 +1,10 @@ // Default Macroplan shown on first load — exercises every state: // on-time delivery, late delivery with slips, in-flight (on-track/at-risk/off-track), // an overdue Feature, and a Milestone with unmet required Features. -export const SAMPLE_PLAN = `title = "Q3 — Checkout revamp" +export const SAMPLE_PLAN = `# Macroplan format version — optional; current is 1. See docs/format.md. +macroplan_version = 1 + +title = "Q3 — Checkout revamp" # Optional plan span: pad the plan with lead-in / trailing weeks. # Rule: start ≤ every Feature's start, and end ≥ every Feature's last week. diff --git a/src/model/parse.ts b/src/model/parse.ts index e2b8a01..31c3560 100644 --- a/src/model/parse.ts +++ b/src/model/parse.ts @@ -45,6 +45,11 @@ const MilestoneSchema = v.object({ ), }) +/** The Macroplan format version this build understands (see docs/format.md). + * Files may declare `macroplan_version`; a newer version is rejected rather + * than silently mis-rendered against a schema we don't know. */ +export const FORMAT_VERSION = 1 + /** Parse + validate a Macroplan TOML source into the raw model. */ export function parseMacroplan(source: string): RawPlan { let data: Record @@ -54,19 +59,51 @@ export function parseMacroplan(source: string): RawPlan { throw new PlanParseError(e instanceof Error ? e.message : String(e)) } + checkVersion(data.macroplan_version) + + const features = asBlocks(data.feature, "feature").map((f, i) => + check(FeatureSchema, f, blockWhere("feature", f, i)), + ) + requireUniqueFeatureNames(features) + return { title: data.title != null ? String(data.title) : "Untitled Macroplan", start: data.start != null ? check(Ymd, data.start, "plan", "start") : undefined, end: data.end != null ? check(Ymd, data.end, "plan", "end") : undefined, - features: asBlocks(data.feature, "feature").map((f, i) => - check(FeatureSchema, f, blockWhere("feature", f, i)), - ), + features, milestones: asBlocks(data.milestone, "milestone").map((m, i) => check(MilestoneSchema, m, blockWhere("milestone", m, i)), ), } } +/** Validate the optional `macroplan_version` marker. Absent means the current + * version; a future version we don't understand is rejected rather than + * silently mis-rendered. */ +function checkVersion(value: unknown): void { + if (value == null) return + if (typeof value !== "number" || !Number.isInteger(value) || value < 1) { + throw new PlanParseError("`macroplan_version` must be a positive integer") + } + if (value > FORMAT_VERSION) { + throw new PlanParseError( + `unsupported macroplan_version ${value} — this build understands up to ${FORMAT_VERSION}`, + ) + } +} + +/** Feature names are the join key for a Milestone's `requires`, so they must be + * unique — otherwise a requirement resolves ambiguously to the first match. */ +function requireUniqueFeatureNames(features: readonly { name: string }[]): void { + const seen = new Set() + for (const f of features) { + if (seen.has(f.name)) { + throw new PlanParseError(`duplicate feature name "${f.name}" — feature names must be unique`) + } + seen.add(f.name) + } +} + function asBlocks(value: unknown, key: string): unknown[] { if (value == null) return [] if (!Array.isArray(value)) {