/* Curriculum checks. Run: node validate.mjs Reads only data/ and lib/, so it works as a CI gate in the new repo. */ import fs from "fs"; import { decompose } from "./lib/hangul.js"; import { haeche, surfaceForms } from "./lib/conjugation.js"; import { flatten } from "./lib/gate.js"; const read = f => JSON.parse(fs.readFileSync(new URL(`./data/${f}`, import.meta.url))); const curriculum = read("curriculum.json"), deck = read("deck.json"); const sentences = read("sentences.json"), sfx = read("sfx.json"); const UNITS = flatten(curriculum); const at = id => UNITS.findIndex(u => u.id === id); const TENSE = "ㄲㄸㅃㅆㅉ", ASP = "ㅋㅌㅍㅊ"; const COMPV = "ㅐㅔㅒㅖㅘㅙㅚㅝㅞㅟㅢ", DOUBLE = "ㄳㄵㄶㄺㄻㄼㄽㄾㄿㅀㅄ"; const CHO="ㄱㄲㄴㄷㄸㄹㅁㅂㅃㅅㅆㅇㅈㅉㅊㅋㅌㅍㅎ"; const JUNG="ㅏㅐㅑㅒㅓㅔㅕㅖㅗㅘㅙㅚㅛㅜㅝㅞㅟㅠㅡㅢㅣ"; const JONG=" ㄱㄲㄳㄴㄵㄶㄷㄹㄺㄻㄼㄽㄾㄿㅀㅁㅂㅄㅅㅆㅇㅈㅊㅋㅌㅍㅎ"; const dec = ch => { const d = decompose(ch); return d ? [CHO[d[0]], JUNG[d[1]], JONG[d[2]] === " " ? "" : JONG[d[2]]] : null; }; const FEATURE = { "1.1":"basic","1.2":"compV","1.3":"tense","1.4":"batchim", "1.5":"liaison","1.6":"nasal","1.7":"double","1.8":"allsound" }; const ORDER = ["basic","compV","tense","batchim","liaison","nasal","double","allsound"]; const level = i => Math.max(-1, ...Object.entries(FEATURE).map(([id,f]) => at(id) <= i ? ORDER.indexOf(f) : -1)); const has = (i,f) => level(i) >= ORDER.indexOf(f); const fail = []; const add = (kind, unit, msg) => fail.push({ kind, unit, msg }); /* ── 1. sequencing: no word may use a phenomenon not yet taught ── */ UNITS.forEach((u, i) => (u.words || []).forEach(w => { const syl = [...w].filter(dec); syl.forEach(ch => { const [c, v, f] = dec(ch); if (!has(i,"tense") && (TENSE.includes(c) || ASP.includes(c))) add("seq", u.id, `${w}: tense/aspirated ${c} before 1.3`); if (!has(i,"compV") && COMPV.includes(v)) add("seq", u.id, `${w}: compound vowel ${v} before 1.2`); if (f && !has(i,"batchim")) add("seq", u.id, `${w}: batchim ${f} before 1.4`); if (f && DOUBLE.includes(f) && !has(i,"double")) add("seq", u.id, `${w}: double batchim ${f} before 1.7`); }); for (let k = 0; k < syl.length - 1; k++) { const a = dec(syl[k]), b = dec(syl[k+1]); if (a[2] && b[0] === "ㅇ" && !has(i,"liaison")) add("seq", u.id, `${w}: liaison context before 1.5`); if (a[2] && "ㄱㄷㅂㅅㅈㅊㅌㅍㅋ".includes(a[2]) && "ㄴㅁ".includes(b[0]) && !has(i,"nasal")) add("seq", u.id, `${w}: nasalisation context before 1.6`); } })); /* ── 2. coverage: every roadmap word must be glossable ── */ const lex = new Set(); Object.values(deck.topics).flat().forEach(([ko,,en,pos]) => { lex.add(ko); if (pos === "verb" || pos === "adj") surfaceForms(ko, en).forEach(s => lex.add(s.form)); }); sentences.sentences.forEach(s => s.parts.forEach(p => lex.add(p[0]))); sfx.items.forEach(s => lex.add(s.ko)); try { read("gloss-extra.json").entries.forEach(g => lex.add(g.ko)); } catch {} UNITS.forEach(u => (u.words || []).forEach(w => { if (!lex.has(w)) add("gloss", u.id, w); })); /* ── 3. hygiene ── */ const seen = new Map(); UNITS.forEach(u => (u.words || []).forEach(w => { if (seen.has(w)) add("dupe", u.id, `${w} — already introduced in ${seen.get(w)}`); else if (!seen.has(w)) seen.set(w, u.id); })); UNITS.forEach(u => { if (!(u.teaches || []).length) add("empty", u.id, "no teaches[] — the gate cannot grow"); }); /* ── 4. revisits[] integrity: a spiral target must exist, and earlier ── */ UNITS.forEach((u, i) => (u.revisits || []).forEach(r => { const j = at(r.from); if (j < 0) add("revisit", u.id, `${r.word}: from "${r.from}" — no such unit`); else if (j >= i) add("revisit", u.id, `${r.word}: from ${r.from}, which is not earlier`); else if (!(UNITS[j].words || []).includes(r.word)) add("revisit", u.id, `${r.word}: not in ${r.from}'s words[]`); })); /* ── report ── */ const by = k => fail.filter(f => f.kind === k); const H = (t, n) => console.log(`\n${t} — ${n}`); H("SEQUENCING", by("seq").length ? `${by("seq").length} VIOLATIONS` : "clean"); by("seq").forEach(f => console.log(` ✗ ${f.unit} ${f.msg}`)); H("GLOSS COVERAGE", `${by("gloss").length} of ${UNITS.flatMap(u => u.words || []).length} roadmap words have no lexicon entry`); Object.entries(by("gloss").reduce((a,f) => ((a[f.unit] = a[f.unit] || []).push(f.msg), a), {})) .forEach(([u,w]) => console.log(` ${u}: ${w.join(" · ")}`)); H("DUPLICATES", `${by("dupe").length}`); by("dupe").forEach(f => console.log(` ${f.unit}: ${f.msg}`)); H("EMPTY teaches[]", `${by("empty").length}`); by("empty").forEach(f => console.log(` ${f.unit}`)); H("REVISITS", by("revisit").length ? `${by("revisit").length} BROKEN` : `clean (${UNITS.flatMap(u => u.revisits || []).length} spiral targets)`); by("revisit").forEach(f => console.log(` ✗ ${f.unit}: ${f.msg}`)); const blocking = by("seq").length + by("empty").length + by("revisit").length; console.log(`\n${blocking ? "FAIL" : "PASS"} — ${blocking} blocking, ${by("gloss").length + by("dupe").length} advisory`); process.exit(blocking ? 1 : 0);