From 53716a13ea9c69c439f8351e2b3ce4a3639d6c7c Mon Sep 17 00:00:00 2001 From: Julien Calixte Date: Thu, 2 Jul 2026 22:04:15 +0200 Subject: [PATCH] feat(format): read and emit macroplan_version, require unique names Reject a macroplan_version newer than this build understands rather than silently mis-rendering it. Feature names are the join key for a milestone's requires, so duplicates (previously resolved to the first match) are now a parse error. The sample plan emits the version marker. --- src/data/sample.ts | 5 ++++- src/model/parse.ts | 43 ++++++++++++++++++++++++++++++++++++++++--- 2 files changed, 44 insertions(+), 4 deletions(-) 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)) {