fuzzyworld

when-am-i / files / scripts / validate-data.mts

validate-data.mts

Blame: the commit that last changed each line · file · history

30de6faf 2026-06-20 Pavel Safronov1// 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
17import { readFile, readdir } from 'node:fs/promises';
18import path from 'node:path';
19import { fileURLToPath } from 'node:url';
20import { parseGregorianDate, gregorianToJulianDay } from '../src/utils/julianDay.ts';
21import { daysInMonth } from '../src/utils/historicalDate.ts';
22
23const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
24const CALENDARS_DIR = path.join(REPO_ROOT, 'src/data/calendars');
25
26/** Validate a single "YYYY-MM-DD" string; returns its Julian Day Number. */
27function 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
47function 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. */
57function 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
74function 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
103function 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 }
5c66fc6a 2026-06-21 Pavel Safronov135 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 }
cedd4e9c 2026-06-23 Pavel Safronov142 if (e.suppressModernDefault !== undefined && typeof e.suppressModernDefault !== 'boolean') {
143 errors.push(`${label}.suppressModernDefault: must be a boolean when present`);
144 }
5c66fc6a 2026-06-21 Pavel Safronov145}
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.
150function 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 }
30de6faf 2026-06-20 Pavel Safronov162}
163
164function 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
186async 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
222run().catch((err) => {
223 console.error(err);
224 process.exit(1);
225});