AT Mot — a bilingual (EN/FR) daily word game native to the AT Protocol.
1/**
2 * validate-lexicons.ts — lint AT Mot's lexicon schemas against the official
3 * AT Protocol Lexicon Style Guide ("Lexinomicon") and the project's hard
4 * constraints (spec §6 / §11.12).
5 *
6 * Enforced rules:
7 * - lowerCamelCase for every NSID segment, def id, and field name
8 * - `format` validators on language / datetime / uri fields
9 * - NO arrays of bare scalars (array items must be ref/union/object)
10 * - records and ambiguous fields carry descriptions
11 * - id matches the file path
12 * - record `key` is one of the allowed kinds
13 *
14 * Exits non-zero on any error.
15 */
16import { readdirSync, readFileSync, statSync } from 'node:fs';
17import { fileURLToPath } from 'node:url';
18import { dirname, resolve, relative, sep } from 'node:path';
19
20const HERE = dirname(fileURLToPath(import.meta.url));
21const LEX_DIR = resolve(HERE, '../lexicons');
22
23const errors: string[] = [];
24const lowerCamel = /^[a-z][a-zA-Z0-9]*$/;
25
26function walk(dir: string): string[] {
27 const out: string[] = [];
28 for (const name of readdirSync(dir)) {
29 const p = resolve(dir, name);
30 if (statSync(p).isDirectory()) out.push(...walk(p));
31 else if (name.endsWith('.json')) out.push(p);
32 }
33 return out;
34}
35
36function err(file: string, msg: string): void {
37 errors.push(`${relative(LEX_DIR, file)}: ${msg}`);
38}
39
40/** Walk an object recursively, validating field names and array/format rules. */
41function checkProperties(file: string, where: string, props: Record<string, any>): void {
42 for (const [name, def] of Object.entries(props)) {
43 if (!lowerCamel.test(name)) err(file, `${where}.${name}: field name is not lowerCamelCase`);
44 checkDef(file, `${where}.${name}`, def);
45 }
46}
47
48function checkDef(file: string, where: string, def: any): void {
49 if (!def || typeof def !== 'object') return;
50
51 // No arrays of bare scalars (style-guide evolution rule).
52 if (def.type === 'array' && def.items) {
53 const itemType = def.items.type;
54 if (['string', 'integer', 'boolean', 'number'].includes(itemType)) {
55 err(file, `${where}: array of bare ${itemType} — wrap each element in an object/ref`);
56 }
57 checkDef(file, `${where}[]`, def.items);
58 }
59
60 // Format validators on the well-known fields.
61 if (def.type === 'string') {
62 const base = where.split('.').pop() ?? '';
63 if (base === 'lang' && def.format !== 'language') {
64 err(file, `${where}: lang must use "format": "language"`);
65 }
66 if (/(^|[a-z])At$/.test(base) && def.format !== 'datetime') {
67 err(file, `${where}: ${base} must use "format": "datetime"`);
68 }
69 if (base === 'puzzleDate' && def.format !== 'datetime') {
70 err(file, `${where}: puzzleDate must use "format": "datetime"`);
71 }
72 if (base === 'puzzleTarget' && def.format !== 'uri') {
73 err(file, `${where}: puzzleTarget must use "format": "uri"`);
74 }
75 }
76
77 if (def.type === 'object' && def.properties) {
78 checkProperties(file, where, def.properties);
79 }
80}
81
82const allowedKeys = ['tid', 'nsid', 'any', 'literal'];
83
84for (const file of walk(LEX_DIR)) {
85 let doc: any;
86 try {
87 doc = JSON.parse(readFileSync(file, 'utf8'));
88 } catch (e) {
89 err(file, `invalid JSON: ${(e as Error).message}`);
90 continue;
91 }
92
93 if (doc.lexicon !== 1) err(file, `missing or wrong "lexicon": 1`);
94
95 // id must be a valid NSID (all segments lowerCamelCase) and match the path.
96 const id: string = doc.id ?? '';
97 const segs = id.split('.');
98 if (segs.length < 3) err(file, `id "${id}" is not a valid NSID`);
99 for (const s of segs) {
100 if (!lowerCamel.test(s)) err(file, `id segment "${s}" is not lowerCamelCase`);
101 }
102 const expectedPath = segs.join(sep) + '.json';
103 if (!file.endsWith(expectedPath)) err(file, `id "${id}" does not match file path`);
104
105 for (const [defName, def] of Object.entries<any>(doc.defs ?? {})) {
106 if (defName !== 'main' && !lowerCamel.test(defName)) {
107 err(file, `def "${defName}" is not lowerCamelCase`);
108 }
109
110 if (def.type === 'record') {
111 if (!def.description) err(file, `record ${defName} is missing a description`);
112 if (!allowedKeys.includes(String(def.key).split(':')[0] ?? '')) {
113 err(file, `record ${defName} has invalid key "${def.key}"`);
114 }
115 if (def.record?.properties) checkProperties(file, defName, def.record.properties);
116 } else if (def.type === 'object') {
117 if (def.properties) checkProperties(file, defName, def.properties);
118 } else {
119 checkDef(file, defName, def);
120 }
121 }
122}
123
124if (errors.length) {
125 console.error(`✗ ${errors.length} lexicon issue(s):`);
126 for (const e of errors) console.error(' - ' + e);
127 process.exit(1);
128}
129console.log('✓ all lexicons conform to the Style Guide rules');