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>
93 lines
5.3 KiB
JavaScript
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);
|