Files
Hankan/validate.mjs
MechaCat02 b80deb2f6f chore: scaffold, CI, and the export bundle verbatim
The export bundle is the input to this port, not a sketch: the curriculum,
the tutor prompt and the five logic modules are finished and tested. They
land here byte-identical and stay that way.

  diff -r export/data data && diff -r export/lib lib
  diff -r export/prompt prompt && diff export/validate.mjs validate.mjs

data/, lib/, prompt/ and validate.mjs sit at the repo root so validate.mjs
runs verbatim with no path edits. All four are excluded from lint and
formatting — they are not ours to restyle. Types for lib/ live alongside in
types/ rather than as sibling .d.ts files, so the verbatim check stays a
plain directory diff.

CI runs the curriculum gate first, before anything else can pass:

  node validate.mjs   PASS — 0 blocking, 0 advisory

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 19:11:40 +02:00

93 lines
5.3 KiB
JavaScript

/* 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);