AT Mot — a bilingual (EN/FR) daily word game native to the AT Protocol.
0

Configure Feed

Select the types of activity you want to include in your feed.

at trunk 4.7 kB View raw
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');