fuzzyworld

when-am-i / commit

feat: build-time calendar data validation

30de6fafb2e8222ba1e36f409755ad79a663e718 · Pavel Safronov · 2026-06-20 11:51:56 -0700 · parents 6b2790fab4b3

feat: build-time calendar data validation

Add `npm run validate:data` (scripts/validate-data.mts): validates every region
file — required fields, valid GeoJSON rings (closed, in-range [lng,lat]),
parseable proleptic-Gregorian dates, and [start,end) ordering. It reuses the
engine's own date parser so validation can't drift from runtime behavior.

Wired into `prebuild` and CI so malformed data can't build or deploy — making
the README's long-standing "the build validates data" claim actually true.
Updates README, SCHEMA.md, and plan.md to match.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
 .gitlab-ci.yml               |   1 +
 README.md                    |  17 ++--
 package.json                 |   2 +
 plan.md                      |  21 ++++-
 scripts/validate-data.mts    | 198 +++++++++++++++++++++++++++++++++++++++++++
 src/data/calendars/SCHEMA.md |  11 +++
 6 files changed, 242 insertions(+), 8 deletions(-)

diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml
index 5767c1f..cd4ae5d 100644
--- a/.gitlab-ci.yml
+++ b/.gitlab-ci.yml
@@ -11,6 +11,7 @@ test:
   stage: test
   script:
     - npm ci
+    - npm run validate:data
     - npm run lint
     - npm run typecheck
     - npm test
diff --git a/README.md b/README.md
index 1834ac2..5c0370f 100644
--- a/README.md
+++ b/README.md
@@ -57,12 +57,17 @@ Imagine you're a time traveler who ends up in a random place and time in history
 Quality checks (also run in CI before any deploy):
 
 ```bash
-npm run lint        # ESLint (flat config)
-npm run typecheck   # tsc --noEmit
-npm test            # Vitest unit tests
-npm run test:watch  # Vitest in watch mode
+npm run lint           # ESLint (flat config)
+npm run typecheck      # tsc --noEmit
+npm test               # Vitest unit tests
+npm run test:watch     # Vitest in watch mode
+npm run validate:data  # Validate every calendar data file against the schema
 ```
 
+`validate:data` also runs automatically before every build (`prebuild`) and in
+CI, so malformed calendar data can never be deployed. See
+[Contributing Calendar Data](#contributing-calendar-data).
+
 ### Badges
 
 The README badge block (between the `badges:start`/`badges:end` markers above) is
@@ -169,7 +174,9 @@ See `src/data/calendars/SCHEMA.json` for the schema and field documentation.
   reviewed visually.
 - Run `npm run gen:badges` so the README's calendar-data count reflects your
   addition, and commit the updated README.
-- The build process will validate and compile all data for use in the app.
+- Run `npm run validate:data` to check your file against the schema (required
+  fields, valid geometry, parseable dates). This runs automatically before every
+  build and in CI, so a malformed file will fail the pipeline rather than deploy.
 
 ## License
 
diff --git a/package.json b/package.json
index 88dbc7a..0aa3749 100644
--- a/package.json
+++ b/package.json
@@ -7,10 +7,12 @@
     "license": "MIT",
     "scripts": {
         "dev": "vite --open",
+        "prebuild": "npm run validate:data",
         "build": "vite build",
         "preview": "vite preview",
         "typecheck": "tsc --noEmit",
         "lint": "eslint .",
+        "validate:data": "tsx scripts/validate-data.mts",
         "test": "vitest run",
         "test:watch": "vitest",
         "screenshot": "tsx scripts/generate-screenshots.mts",
diff --git a/plan.md b/plan.md
index 6a7b3f2..9ffd077 100644
--- a/plan.md
+++ b/plan.md
@@ -28,7 +28,11 @@ Missing / debt:
 - **No date picker.**
 - **No resolution engine**: nothing loads the calendar JSON, runs point-in-polygon,
   matches an era, or converts/displays a local year.
-- **No build-time data validation** (README claims it exists — it does not).
+- ~~**No build-time data validation** (README claims it exists — it does not).~~
+  **Resolved 2026-06-20:** `npm run validate:data`
+  ([scripts/validate-data.mts](scripts/validate-data.mts)) now validates every
+  region file and runs in `prebuild` + CI, so the README claim is true. See the
+  data-pipeline note below.
 - **Dead code**: [src/MapPicker.tsx](src/MapPicker.tsx) is an unused, broken duplicate
   (no `center`/`zoom`) of the real component.
 - **Empty stubs**: [src/data/index.ts](src/data/index.ts), [src/hooks/index.ts](src/hooks/index.ts),
@@ -119,8 +123,19 @@ Mirror the JSON schema and add result types:
 
 ## Data pipeline (lightweight, to make the README honest)
 
-- Promote [SCHEMA.json](src/data/calendars/SCHEMA.json) to a real **JSON Schema** (or a
-  zod schema) and add `npm run validate:data` that checks every region file:
+**Shipped 2026-06-20** as [scripts/validate-data.mts](scripts/validate-data.mts)
+(`npm run validate:data`), wired into `prebuild` and CI.
+**Decision:** rather than promote [SCHEMA.json](src/data/calendars/SCHEMA.json)
+to a real JSON Schema / zod schema, the validator itself is the authoritative,
+machine-checkable schema. A hand-rolled validator covers semantic checks a plain
+JSON Schema cannot express — dates that actually parse, geometry rings that are
+closed with in-range `[lng, lat]`, and `[start, end)` ordering — and avoids a
+second spec that could drift from the validator. SCHEMA.json / SCHEMA.md remain
+the human-readable field docs and now point at the validator. Overlapping eras
+are intentionally **not** flagged (Q4).
+
+- ~~Promote [SCHEMA.json](src/data/calendars/SCHEMA.json) to a real **JSON Schema** (or a
+  zod schema) and~~ add `npm run validate:data` that checks every region file:
   required fields, valid geometry, parseable proleptic-Gregorian dates. **Overlapping eras
   are allowed** (Q4) — do not flag them.
 - Add the optional `usedBy` field to the schema (back-compatible).
diff --git a/scripts/validate-data.mts b/scripts/validate-data.mts
new file mode 100644
index 0000000..31ef1f9
--- /dev/null
+++ b/scripts/validate-data.mts
@@ -0,0 +1,198 @@
+// Build-time validation for the calendar data files.
+//
+//   npm run validate:data
+//
+// Reads every region file in src/data/calendars/ (except the SCHEMA doc) and
+// checks it against the schema (SCHEMA.json / SCHEMA.md): required fields, valid
+// GeoJSON geometry, and parseable proleptic-Gregorian start/end dates. It reuses
+// the engine's own date parser (src/utils/julianDay.ts) so validation can never
+// drift from how the app actually reads dates.
+//
+// Overlapping eras — in time or space — are allowed by design (see plan.md, Q4):
+// parallel calendars coexisting at one place and time is expected, not an error.
+//
+// Exits non-zero (failing the build / CI) if any file is invalid, listing every
+// problem found across all files rather than stopping at the first.
+
+import { readFile, readdir } from 'node:fs/promises';
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+import { parseGregorianDate, gregorianToJulianDay } from '../src/utils/julianDay.ts';
+import { daysInMonth } from '../src/utils/historicalDate.ts';
+
+const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
+const CALENDARS_DIR = path.join(REPO_ROOT, 'src/data/calendars');
+
+/** Validate a single "YYYY-MM-DD" string; returns its Julian Day Number. */
+function checkDate(value: string, label: string, errors: string[]): number | null {
+    let date;
+    try {
+        date = parseGregorianDate(value);
+    } catch (err) {
+        errors.push(`${label}: ${(err as Error).message}`);
+        return null;
+    }
+    if (date.month < 1 || date.month > 12) {
+        errors.push(`${label}: month ${date.month} out of range (1–12) in "${value}"`);
+        return null;
+    }
+    const maxDay = daysInMonth(date.year, date.month);
+    if (date.day < 1 || date.day > maxDay) {
+        errors.push(`${label}: day ${date.day} out of range (1–${maxDay}) in "${value}"`);
+        return null;
+    }
+    return gregorianToJulianDay(date);
+}
+
+function isPosition(value: unknown): value is [number, number] {
+    return (
+        Array.isArray(value) &&
+        value.length >= 2 &&
+        typeof value[0] === 'number' && value[0] >= -180 && value[0] <= 180 &&
+        typeof value[1] === 'number' && value[1] >= -90 && value[1] <= 90
+    );
+}
+
+/** Validate one linear ring: ≥4 positions, all valid [lng, lat], and closed. */
+function checkRing(ring: unknown, label: string, errors: string[]): void {
+    if (!Array.isArray(ring) || ring.length < 4) {
+        errors.push(`${label}: a polygon ring needs at least 4 positions`);
+        return;
+    }
+    ring.forEach((pos, i) => {
+        if (!isPosition(pos)) {
+            errors.push(`${label}[${i}]: not a valid [lng, lat] position (lng −180..180, lat −90..90)`);
+        }
+    });
+    const first = ring[0];
+    const last = ring[ring.length - 1];
+    if (Array.isArray(first) && Array.isArray(last) && (first[0] !== last[0] || first[1] !== last[1])) {
+        errors.push(`${label}: ring is not closed (first and last position must match)`);
+    }
+}
+
+function checkGeometry(geo: unknown, errors: string[]): void {
+    if (typeof geo !== 'object' || geo === null) {
+        errors.push('region.geo: missing or not an object');
+        return;
+    }
+    const g = geo as { type?: unknown; coordinates?: unknown };
+    if (g.type === 'Polygon') {
+        if (!Array.isArray(g.coordinates)) {
+            errors.push('region.geo.coordinates: Polygon coordinates must be an array of rings');
+            return;
+        }
+        g.coordinates.forEach((ring, i) => checkRing(ring, `region.geo.coordinates[${i}]`, errors));
+    } else if (g.type === 'MultiPolygon') {
+        if (!Array.isArray(g.coordinates)) {
+            errors.push('region.geo.coordinates: MultiPolygon coordinates must be an array of polygons');
+            return;
+        }
+        g.coordinates.forEach((poly, p) => {
+            if (!Array.isArray(poly)) {
+                errors.push(`region.geo.coordinates[${p}]: each polygon must be an array of rings`);
+                return;
+            }
+            poly.forEach((ring, i) => checkRing(ring, `region.geo.coordinates[${p}][${i}]`, errors));
+        });
+    } else {
+        errors.push(`region.geo.type: must be "Polygon" or "MultiPolygon" (got ${JSON.stringify(g.type)})`);
+    }
+}
+
+function checkEra(era: unknown, i: number, errors: string[]): void {
+    const label = `eras[${i}]`;
+    if (typeof era !== 'object' || era === null) {
+        errors.push(`${label}: not an object`);
+        return;
+    }
+    const e = era as Record<string, unknown>;
+    if (typeof e.name !== 'string' || e.name.trim() === '') errors.push(`${label}.name: required non-empty string`);
+    if (typeof e.calendar !== 'string' || e.calendar.trim() === '') errors.push(`${label}.calendar: required non-empty string`);
+
+    let startJdn: number | null = null;
+    let endJdn: number | null = null;
+    if (e.start !== null) {
+        if (typeof e.start !== 'string') errors.push(`${label}.start: must be "YYYY-MM-DD" or null`);
+        else startJdn = checkDate(e.start, `${label}.start`, errors);
+    }
+    if (e.end !== null) {
+        if (typeof e.end !== 'string') errors.push(`${label}.end: must be "YYYY-MM-DD" or null`);
+        else endJdn = checkDate(e.end, `${label}.end`, errors);
+    }
+    // Half-open interval [start, end): start must precede end when both are set.
+    if (startJdn !== null && endJdn !== null && startJdn >= endJdn) {
+        errors.push(`${label}: start (${e.start}) must be before end (${e.end})`);
+    }
+
+    if (e.usedBy !== undefined && typeof e.usedBy !== 'string') errors.push(`${label}.usedBy: must be a string when present`);
+    if (e.description !== undefined && typeof e.description !== 'string') errors.push(`${label}.description: must be a string when present`);
+    if (e.sources !== undefined) {
+        if (!Array.isArray(e.sources) || !e.sources.every((s) => typeof s === 'string')) {
+            errors.push(`${label}.sources: must be an array of strings when present`);
+        }
+    }
+}
+
+function validateFile(data: unknown, errors: string[]): void {
+    if (typeof data !== 'object' || data === null) {
+        errors.push('file: top level must be an object');
+        return;
+    }
+    const v = data as { region?: unknown; eras?: unknown };
+    if (typeof v.region !== 'object' || v.region === null) {
+        errors.push('region: missing or not an object');
+    } else {
+        const r = v.region as { name?: unknown; geo?: unknown };
+        if (typeof r.name !== 'string' || r.name.trim() === '') errors.push('region.name: required non-empty string');
+        checkGeometry(r.geo, errors);
+    }
+    if (!Array.isArray(v.eras)) {
+        errors.push('eras: required array');
+    } else if (v.eras.length === 0) {
+        errors.push('eras: must contain at least one era');
+    } else {
+        v.eras.forEach((era, i) => checkEra(era, i, errors));
+    }
+}
+
+async function run(): Promise<void> {
+    const files = (await readdir(CALENDARS_DIR))
+        .filter((f) => f.endsWith('.json') && !f.startsWith('SCHEMA'))
+        .sort();
+
+    if (files.length === 0) {
+        console.error('❌  No calendar data files found in src/data/calendars/');
+        process.exit(1);
+    }
+
+    let totalErrors = 0;
+    for (const file of files) {
+        const errors: string[] = [];
+        const abs = path.join(CALENDARS_DIR, file);
+        try {
+            const data = JSON.parse(await readFile(abs, 'utf-8'));
+            validateFile(data, errors);
+        } catch (err) {
+            errors.push(`not valid JSON: ${(err as Error).message}`);
+        }
+        if (errors.length === 0) {
+            console.log(`  ✅  ${file}`);
+        } else {
+            totalErrors += errors.length;
+            console.log(`  ❌  ${file}`);
+            for (const e of errors) console.log(`        • ${e}`);
+        }
+    }
+
+    if (totalErrors > 0) {
+        console.error(`\n❌  Data validation failed: ${totalErrors} problem(s) across ${files.length} file(s).\n`);
+        process.exit(1);
+    }
+    console.log(`\n✨  All ${files.length} calendar file(s) valid.\n`);
+}
+
+run().catch((err) => {
+    console.error(err);
+    process.exit(1);
+});
diff --git a/src/data/calendars/SCHEMA.md b/src/data/calendars/SCHEMA.md
index 5328e99..36b4b88 100644
--- a/src/data/calendars/SCHEMA.md
+++ b/src/data/calendars/SCHEMA.md
@@ -28,6 +28,17 @@ Each file describes a region and its calendar eras.
 }
 ```
 
+## Validation
+
+Every file here is checked by `npm run validate:data`
+([`scripts/validate-data.mts`](../../../scripts/validate-data.mts)) — the
+authoritative, machine-checkable form of this schema. It runs automatically
+before every build (`prebuild`) and in CI, so invalid data can never deploy. It
+verifies required fields, that geometry rings are valid `[lng, lat]` and closed,
+that `start`/`end` are real proleptic-Gregorian dates, and that `start` precedes
+`end`. The block above is the human-readable companion; the script is the source
+of truth.
+
 ## Notes
 
 - Dates (`start`/`end`) are **proleptic Gregorian** `YYYY-MM-DD`. They are