when-am-i / files / scripts / validate-data.mts
validate-data.mts
| 1 | // Build-time validation for the calendar data files. |
| 2 | // |
| 3 | // npm run validate:data |
| 4 | // |
| 5 | // Reads every region file in src/data/calendars/ (except the SCHEMA doc) and |
| 6 | // checks it against the schema (SCHEMA.json / SCHEMA.md): required fields, valid |
| 7 | // GeoJSON geometry, and parseable proleptic-Gregorian start/end dates. It reuses |
| 8 | // the engine's own date parser (src/utils/julianDay.ts) so validation can never |
| 9 | // drift from how the app actually reads dates. |
| 10 | // |
| 11 | // Overlapping eras — in time or space — are allowed by design (see plan.md, Q4): |
| 12 | // parallel calendars coexisting at one place and time is expected, not an error. |
| 13 | // |
| 14 | // Exits non-zero (failing the build / CI) if any file is invalid, listing every |
| 15 | // problem found across all files rather than stopping at the first. |
| 16 | |
| 17 | import { readFile, readdir } from 'node:fs/promises'; |
| 18 | import path from 'node:path'; |
| 19 | import { fileURLToPath } from 'node:url'; |
| 20 | import { parseGregorianDate, gregorianToJulianDay } from '../src/utils/julianDay.ts'; |
| 21 | import { daysInMonth } from '../src/utils/historicalDate.ts'; |
| 22 | |
| 23 | const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); |
| 24 | const CALENDARS_DIR = path.join(REPO_ROOT, 'src/data/calendars'); |
| 25 | |
| 26 | /** Validate a single "YYYY-MM-DD" string; returns its Julian Day Number. */ |
| 27 | function checkDate(value: string, label: string, errors: string[]): number | null { |
| 28 | let date; |
| 29 | try { |
| 30 | date = parseGregorianDate(value); |
| 31 | } catch (err) { |
| 32 | errors.push(`${label}: ${(err as Error).message}`); |
| 33 | return null; |
| 34 | } |
| 35 | if (date.month < 1 || date.month > 12) { |
| 36 | errors.push(`${label}: month ${date.month} out of range (1–12) in "${value}"`); |
| 37 | return null; |
| 38 | } |
| 39 | const maxDay = daysInMonth(date.year, date.month); |
| 40 | if (date.day < 1 || date.day > maxDay) { |
| 41 | errors.push(`${label}: day ${date.day} out of range (1–${maxDay}) in "${value}"`); |
| 42 | return null; |
| 43 | } |
| 44 | return gregorianToJulianDay(date); |
| 45 | } |
| 46 | |
| 47 | function isPosition(value: unknown): value is [number, number] { |
| 48 | return ( |
| 49 | Array.isArray(value) && |
| 50 | value.length >= 2 && |
| 51 | typeof value[0] === 'number' && value[0] >= -180 && value[0] <= 180 && |
| 52 | typeof value[1] === 'number' && value[1] >= -90 && value[1] <= 90 |
| 53 | ); |
| 54 | } |
| 55 | |
| 56 | /** Validate one linear ring: ≥4 positions, all valid [lng, lat], and closed. */ |
| 57 | function checkRing(ring: unknown, label: string, errors: string[]): void { |
| 58 | if (!Array.isArray(ring) || ring.length < 4) { |
| 59 | errors.push(`${label}: a polygon ring needs at least 4 positions`); |
| 60 | return; |
| 61 | } |
| 62 | ring.forEach((pos, i) => { |
| 63 | if (!isPosition(pos)) { |
| 64 | errors.push(`${label}[${i}]: not a valid [lng, lat] position (lng −180..180, lat −90..90)`); |
| 65 | } |
| 66 | }); |
| 67 | const first = ring[0]; |
| 68 | const last = ring[ring.length - 1]; |
| 69 | if (Array.isArray(first) && Array.isArray(last) && (first[0] !== last[0] || first[1] !== last[1])) { |
| 70 | errors.push(`${label}: ring is not closed (first and last position must match)`); |
| 71 | } |
| 72 | } |
| 73 | |
| 74 | function checkGeometry(geo: unknown, errors: string[]): void { |
| 75 | if (typeof geo !== 'object' || geo === null) { |
| 76 | errors.push('region.geo: missing or not an object'); |
| 77 | return; |
| 78 | } |
| 79 | const g = geo as { type?: unknown; coordinates?: unknown }; |
| 80 | if (g.type === 'Polygon') { |
| 81 | if (!Array.isArray(g.coordinates)) { |
| 82 | errors.push('region.geo.coordinates: Polygon coordinates must be an array of rings'); |
| 83 | return; |
| 84 | } |
| 85 | g.coordinates.forEach((ring, i) => checkRing(ring, `region.geo.coordinates[${i}]`, errors)); |
| 86 | } else if (g.type === 'MultiPolygon') { |
| 87 | if (!Array.isArray(g.coordinates)) { |
| 88 | errors.push('region.geo.coordinates: MultiPolygon coordinates must be an array of polygons'); |
| 89 | return; |
| 90 | } |
| 91 | g.coordinates.forEach((poly, p) => { |
| 92 | if (!Array.isArray(poly)) { |
| 93 | errors.push(`region.geo.coordinates[${p}]: each polygon must be an array of rings`); |
| 94 | return; |
| 95 | } |
| 96 | poly.forEach((ring, i) => checkRing(ring, `region.geo.coordinates[${p}][${i}]`, errors)); |
| 97 | }); |
| 98 | } else { |
| 99 | errors.push(`region.geo.type: must be "Polygon" or "MultiPolygon" (got ${JSON.stringify(g.type)})`); |
| 100 | } |
| 101 | } |
| 102 | |
| 103 | function checkEra(era: unknown, i: number, errors: string[]): void { |
| 104 | const label = `eras[${i}]`; |
| 105 | if (typeof era !== 'object' || era === null) { |
| 106 | errors.push(`${label}: not an object`); |
| 107 | return; |
| 108 | } |
| 109 | const e = era as Record<string, unknown>; |
| 110 | if (typeof e.name !== 'string' || e.name.trim() === '') errors.push(`${label}.name: required non-empty string`); |
| 111 | if (typeof e.calendar !== 'string' || e.calendar.trim() === '') errors.push(`${label}.calendar: required non-empty string`); |
| 112 | |
| 113 | let startJdn: number | null = null; |
| 114 | let endJdn: number | null = null; |
| 115 | if (e.start !== null) { |
| 116 | if (typeof e.start !== 'string') errors.push(`${label}.start: must be "YYYY-MM-DD" or null`); |
| 117 | else startJdn = checkDate(e.start, `${label}.start`, errors); |
| 118 | } |
| 119 | if (e.end !== null) { |
| 120 | if (typeof e.end !== 'string') errors.push(`${label}.end: must be "YYYY-MM-DD" or null`); |
| 121 | else endJdn = checkDate(e.end, `${label}.end`, errors); |
| 122 | } |
| 123 | // Half-open interval [start, end): start must precede end when both are set. |
| 124 | if (startJdn !== null && endJdn !== null && startJdn >= endJdn) { |
| 125 | errors.push(`${label}: start (${e.start}) must be before end (${e.end})`); |
| 126 | } |
| 127 | |
| 128 | if (e.usedBy !== undefined && typeof e.usedBy !== 'string') errors.push(`${label}.usedBy: must be a string when present`); |
| 129 | if (e.description !== undefined && typeof e.description !== 'string') errors.push(`${label}.description: must be a string when present`); |
| 130 | if (e.sources !== undefined) { |
| 131 | if (!Array.isArray(e.sources) || !e.sources.every((s) => typeof s === 'string')) { |
| 132 | errors.push(`${label}.sources: must be an array of strings when present`); |
| 133 | } |
| 134 | } |
| 135 | if (e.anchorEvents !== undefined) { |
| 136 | if (!Array.isArray(e.anchorEvents)) { |
| 137 | errors.push(`${label}.anchorEvents: must be an array when present`); |
| 138 | } else { |
| 139 | e.anchorEvents.forEach((ev, j) => checkAnchorEvent(ev, `${label}.anchorEvents[${j}]`, errors)); |
| 140 | } |
| 141 | } |
| 142 | if (e.suppressModernDefault !== undefined && typeof e.suppressModernDefault !== 'boolean') { |
| 143 | errors.push(`${label}.suppressModernDefault: must be a boolean when present`); |
| 144 | } |
| 145 | } |
| 146 | |
| 147 | // Anchor events are sourced, dateable reckoning points (see SCHEMA.md): each |
| 148 | // needs a name, a parseable proleptic-Gregorian date, and at least one source — |
| 149 | // the project never reckons "winters since" an undocumented event. |
| 150 | function checkAnchorEvent(ev: unknown, label: string, errors: string[]): void { |
| 151 | if (typeof ev !== 'object' || ev === null) { |
| 152 | errors.push(`${label}: not an object`); |
| 153 | return; |
| 154 | } |
| 155 | const a = ev as Record<string, unknown>; |
| 156 | if (typeof a.name !== 'string' || a.name.trim() === '') errors.push(`${label}.name: required non-empty string`); |
| 157 | if (typeof a.date !== 'string') errors.push(`${label}.date: required "YYYY-MM-DD" string`); |
| 158 | else checkDate(a.date, `${label}.date`, errors); |
| 159 | if (!Array.isArray(a.sources) || a.sources.length === 0 || !a.sources.every((s) => typeof s === 'string')) { |
| 160 | errors.push(`${label}.sources: required non-empty array of strings (anchor events must be sourced)`); |
| 161 | } |
| 162 | } |
| 163 | |
| 164 | function validateFile(data: unknown, errors: string[]): void { |
| 165 | if (typeof data !== 'object' || data === null) { |
| 166 | errors.push('file: top level must be an object'); |
| 167 | return; |
| 168 | } |
| 169 | const v = data as { region?: unknown; eras?: unknown }; |
| 170 | if (typeof v.region !== 'object' || v.region === null) { |
| 171 | errors.push('region: missing or not an object'); |
| 172 | } else { |
| 173 | const r = v.region as { name?: unknown; geo?: unknown }; |
| 174 | if (typeof r.name !== 'string' || r.name.trim() === '') errors.push('region.name: required non-empty string'); |
| 175 | checkGeometry(r.geo, errors); |
| 176 | } |
| 177 | if (!Array.isArray(v.eras)) { |
| 178 | errors.push('eras: required array'); |
| 179 | } else if (v.eras.length === 0) { |
| 180 | errors.push('eras: must contain at least one era'); |
| 181 | } else { |
| 182 | v.eras.forEach((era, i) => checkEra(era, i, errors)); |
| 183 | } |
| 184 | } |
| 185 | |
| 186 | async function run(): Promise<void> { |
| 187 | const files = (await readdir(CALENDARS_DIR)) |
| 188 | .filter((f) => f.endsWith('.json') && !f.startsWith('SCHEMA')) |
| 189 | .sort(); |
| 190 | |
| 191 | if (files.length === 0) { |
| 192 | console.error('❌ No calendar data files found in src/data/calendars/'); |
| 193 | process.exit(1); |
| 194 | } |
| 195 | |
| 196 | let totalErrors = 0; |
| 197 | for (const file of files) { |
| 198 | const errors: string[] = []; |
| 199 | const abs = path.join(CALENDARS_DIR, file); |
| 200 | try { |
| 201 | const data = JSON.parse(await readFile(abs, 'utf-8')); |
| 202 | validateFile(data, errors); |
| 203 | } catch (err) { |
| 204 | errors.push(`not valid JSON: ${(err as Error).message}`); |
| 205 | } |
| 206 | if (errors.length === 0) { |
| 207 | console.log(` ✅ ${file}`); |
| 208 | } else { |
| 209 | totalErrors += errors.length; |
| 210 | console.log(` ❌ ${file}`); |
| 211 | for (const e of errors) console.log(` • ${e}`); |
| 212 | } |
| 213 | } |
| 214 | |
| 215 | if (totalErrors > 0) { |
| 216 | console.error(`\n❌ Data validation failed: ${totalErrors} problem(s) across ${files.length} file(s).\n`); |
| 217 | process.exit(1); |
| 218 | } |
| 219 | console.log(`\n✨ All ${files.length} calendar file(s) valid.\n`); |
| 220 | } |
| 221 | |
| 222 | run().catch((err) => { |
| 223 | console.error(err); |
| 224 | process.exit(1); |
| 225 | }); |