when-am-i / commit
feat: build-time calendar data validation
.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