/** * docs-check — guard for copyable doc invariants (docs reconciliation, Phase 3). * * Every invariant below was born from a REAL drift found by the 2026-07-01/02 * audits (fable_audit D1–D5, fable-eidos-audit §3.11). The corpus law is * "link the canon, never copy it" (docs/authoring.md §1); this script catches * the copies that survive anyway. * * 1. Vocabulary counts — no doc may assert a count of families / intents / * archetypes different from the source consts (SEMA_FAMILIES, INTENTS, * ARCHETYPE_VOCABULARY). ("7 familias" survived in three docs for weeks.) * 2. Phantom fields — `translations:` (the pre-`texts:` morfo field) must not * appear in morfo/soma docs; `defaultSemantic` (a rejected API shape) must * not appear outside LIBRO_VARIACIONES / chronicle docs. * 3. Dependency claims — the dependency lists in soma/README §3 and * SOMA_ARCHITECTURE §12 may only cite packages that are real runtime * dependencies (catches the stale @floating-ui / clsx claims). * 4. Variant vocab mirror — SHARED_VARIANT_VOCAB (scripts/component-audit.ts) * must equal EIDOS_VARIANTS (src/uix/eidos/lib/types.ts) entry by entry. * 5. Checklist ↔ audit script — every COMPONENT_COMPLETION_CHECKLIST rule * with `Enforcement: audit` must exist in component-audit.ts, and every * rule ID the script emits must be declared in the checklist. * 6. Relative links — markdown links in docs/** and the layer docs must * resolve on disk. ERROR severity since 2026-07-11 (DOC-3, clean-room): * the 15 tolerated dangling targets were repointed or de-linked; a dead * link can never again sit at WARN indefinitely. * * Severities: 1–6 are all errors. Exit code 1 when any error-level finding * exists. * * Chronicle docs (docs/process/**, THEMING_CHANGELOG, audits, CONTINUE * hand-offs, the book) record what WAS true — they are excluded from the * truth-tracking invariants (1–3) and from link checking where noted. */ import { readFileSync, readdirSync, statSync, existsSync } from 'node:fs'; import { join, dirname, resolve, relative, sep } from 'node:path'; import { generateVocabulariesDoc } from './docs-vocabularies'; const REPO = resolve(import.meta.dirname, '..'); interface Finding { severity: 'error' | 'warn'; invariant: string; file: string; line: number; message: string; } const findings: Finding[] = []; function report( severity: 'error' | 'warn', invariant: string, file: string, line: number, message: string ): void { findings.push({ severity, invariant, file: relative(REPO, file).split(sep).join('/'), line, message }); } // ── Corpus walking ───────────────────────────────────────────────────────── /** Dirs never walked: WIP tracks, third parties, build output. */ const SKIP_DIRS = new Set([ 'node_modules', '.git', '.svelte-kit', 'build', 'dist', 'words', 'palabras', 'chronos', '.claude', 'tmp' ]); function walkMd(dir: string, out: string[] = []): string[] { for (const entry of readdirSync(dir)) { const full = join(dir, entry); const st = statSync(full); if (st.isDirectory()) { if (!SKIP_DIRS.has(entry)) walkMd(full, out); } else if (entry.endsWith('.md')) { out.push(full); } } return out; } /** Chronicle docs: record history, exempt from truth-tracking invariants. */ function isChronicle(file: string): boolean { const rel = relative(REPO, file).split(sep).join('/'); if ( rel.startsWith('docs/process/') || rel.startsWith('docs/old-deprecated/') || // superseded fossils — chronicle rel.includes('CHANGELOG') || rel.includes('CONTINUE') || /AUDIT/i.test(rel) || /fable.*audit/i.test(rel) || rel.includes('Disenando_lo_que_ocurre') || // the book — never linted rel.includes('handoffs-') || rel === 'CLAUDE.md' || // own deferred pass rel === 'AGENTS.md' ) { return true; } // Frontmatter `status: historical` (e.g. the degraded GUIA) — the doc // declares itself a seed, not current truth. const head = readFileSync(file, 'utf-8').slice(0, 400); return /^status:\s*(historical|chronicle)\s*$/m.test(head); } const corpus = [ ...walkMd(join(REPO, 'docs')), ...walkMd(join(REPO, 'src', 'uix')), ...walkMd(join(REPO, 'src', 'docs')), ...(existsSync(join(REPO, 'web', 'routes', 'uix', 'lib')) ? walkMd(join(REPO, 'web', 'routes', 'uix', 'lib')) : []) ]; const liveCorpus = corpus.filter((f) => !isChronicle(f)); // ── Source-of-truth extraction (textual, no imports — scripts must not pull // the Svelte graph) ────────────────────────────────────────────────────── function read(file: string): string { return readFileSync(file, 'utf-8'); } /** Extract the string literals of an `as const` array by const name. */ function extractConstArray(source: string, constName: string): string[] { const m = source.match(new RegExp(`${constName}\\s*=\\s*\\[([\\s\\S]*?)\\]`)); if (!m) throw new Error(`Cannot find const ${constName}`); // Strip comments first — a quoted name inside a comment (e.g. the note // documenting the retired 'borderHover' slot) must not count as an entry. const body = m[1].replace(/\/\/[^\n]*/g, '').replace(/\/\*[\s\S]*?\*\//g, ''); return [...body.matchAll(/'([\w-]+)'/g)].map((x) => x[1]); } // SEMA_FAMILIES (event.ts) is a spread of the two partitions — extract both. const semaEventSrc = read(join(REPO, 'src/uix/sema/event.ts')); const SEMA_FAMILIES = [ ...extractConstArray(semaEventSrc, 'SEMA_VALENCED_FAMILIES'), ...extractConstArray(semaEventSrc, 'SEMA_TRANSITIONAL_FAMILIES') ]; const INTENTS = extractConstArray(read(join(REPO, 'src/uix/intent.ts')), 'INTENTS'); const ARCHETYPES = extractConstArray( read(join(REPO, 'src/uix/morfo/types.ts')), 'ARCHETYPE_VOCABULARY' ); const PALETTE = extractConstArray( read(join(REPO, 'src/uix/eidos/lib/types.ts')), 'PALETTE_SCALES' ); const eidosConfigTypesSrc = read(join(REPO, 'src/uix/eidos/lib/config-types.ts')); // COLOR_ROLES is a spread (hierarchy + intents) — count the two parts. const COLOR_ROLES_COUNT = extractConstArray(eidosConfigTypesSrc, 'HIERARCHY_COLOR_ROLES').length + INTENTS.length; const COLOR_ROLE_SLOTS = extractConstArray(eidosConfigTypesSrc, 'COLOR_ROLE_SLOTS'); // ── Invariant 1 — vocabulary counts ──────────────────────────────────────── interface CountPattern { re: RegExp; expected: number; what: string; /** The match only counts when the line (or its neighbour) is about THIS vocabulary. */ context: RegExp; /** Lines about a DIFFERENT vocabulary that shares the word (font families, variant archetypes…). */ exclude?: RegExp; /** Ignore totals below this (subset phrasing like "5 intent roles" / "3 arquetipos de size"). */ minToFlag: number; } const COUNT_PATTERNS: CountPattern[] = [ { re: /\b(\d+)\s+(?:sema\s+)?famil(?:ies|ias)\b/gi, expected: SEMA_FAMILIES.length, what: 'SEMA_FAMILIES', context: /sema|semánt|semant|famil.{0,30}(evento|event)|SEMA_FAMILIES|contact|commit|emerge|delegate/i, exclude: /tipográf|typograph|font|shape|forma|escala|scale|superel/i, minToFlag: 6 }, { re: /\b(\d+)\s+intents?\b/gi, expected: INTENTS.length, what: 'INTENTS', context: /intent/i, exclude: /evaluativ|valenc|roles?\b/i, minToFlag: 6 }, { re: /\b(\d+)\s+archetypes?\b/gi, expected: ARCHETYPES.length, what: 'ARCHETYPE_VOCABULARY', context: /morfo|part|ARCHETYPE_VOCABULARY|archetype/i, exclude: /variant|EIDOS_VARIANTS|size|tamañ/i, minToFlag: 10 }, // Palette scale count — "the 31 palette scales" survived in four docs + // the demo harness after fuchsia/steel grew the library to 33 (2026-07-02). { re: /\b(\d+)[\s-]+(?:palette\s+|donor\s+|color\s+|Radix\s+)?scales?\b/gi, expected: PALETTE.length, what: 'PALETTE_SCALES', context: /palette|paleta|scale|color/i, // ~N = approximations about OTHER libraries (Radix "~30 scales"); type/space // scales and token names (--scale-*, "12-step scale") are different vocab. exclude: /~\s*\d|type\s+scale|font|typo|space|spacing|--scale|scale-\d|step|gray|grey/i, minToFlag: 20 }, { re: /\b(\d+)\s+escalas\b/gi, expected: PALETTE.length, what: 'PALETTE_SCALES', context: /palet|escala|color/i, exclude: /~\s*\d|tipogr|espaci|paso|step|gris/i, minToFlag: 20 }, // Color roles (9 = 3 hierarchy + 6 intents) and role slots (12 since // border-hover was retired, 2026-07-02) — "13 slots" survived in the RFCs. { re: /\b(\d+)\s+(?:color\s+)?roles\b/gi, expected: COLOR_ROLES_COUNT, what: 'COLOR_ROLES', context: /color|role|palet/i, exclude: /accent|hierarch|jerarqu|intent\s+roles|semantic/i, minToFlag: 7 }, { re: /\b(\d+)\s+slots\b/gi, expected: COLOR_ROLE_SLOTS.length, what: 'COLOR_ROLE_SLOTS', context: /color|slot|role|token/i, exclude: /~\s*\d/, minToFlag: 10 } ]; for (const file of liveCorpus) { const lines = read(file).split('\n'); lines.forEach((line, i) => { for (const { re, expected, what, context, exclude, minToFlag } of COUNT_PATTERNS) { re.lastIndex = 0; for (const m of line.matchAll(re)) { const n = Number(m[1]); if (n === expected || n < minToFlag) continue; // Section numbering ("B.7 Intent", "§35 scale canon") — number glued // to a dot / section mark. const before = line.slice(0, m.index ?? 0); if (/[.\d\-§#]$/.test(before)) continue; // Quoted historical mentions ('"7 families" survived…'). if (/["'“”]$/.test(before)) continue; const windowText = `${lines[i - 1] ?? ''} ${line} ${lines[i + 1] ?? ''}`; if (!context.test(windowText)) continue; if (exclude && exclude.test(line)) continue; report( 'error', 'I1-count', file, i + 1, `says "${m[0].trim()}" but ${what}.length is ${expected} — link the const, don't copy the count` ); } } }); } // ── Invariant 2 — phantom fields ─────────────────────────────────────────── const MORFO_SOMA_DOCS = liveCorpus.filter((f) => { const rel = relative(REPO, f).split(sep).join('/'); return ( rel.startsWith('src/uix/morfo/') || rel.startsWith('src/uix/soma/') || /^src\/uix\/[^/]+\.md$/.test(rel) ); }); /** Lines that legitimately DISCUSS the phantom (renames, rejections). */ const PHANTOM_MENTION_OK = /renombr|renamed|legacy|migrat|descart|reject|rechaz|superseded|was removed|se retir/i; for (const file of MORFO_SOMA_DOCS) { const lines = read(file).split('\n'); lines.forEach((line, i) => { if ( (/\btranslations:\s*\{/.test(line) || /\bmorfo\.translations\b/.test(line)) && !PHANTOM_MENTION_OK.test(line) ) { report( 'error', 'I2-phantom', file, i + 1, 'legacy `translations:` field — the Morfo field is `texts:` (idlangrefs)' ); } // `kind: 'internal'` never existed — the MorfoPartKind enum is // 'public' | 'private' | 'virtual' (morfo/types.ts). The phantom value // survived in the completion checklist until the Knob build exercise // tripped over it (STUMBLES.md #2, 2026-07-03). if (/\bkind:\s*'public'\s*(?:\\?\|)\s*'internal'|\bkind:\s*'internal'/.test(line) && !PHANTOM_MENTION_OK.test(line)) { report( 'error', 'I2-phantom', file, i + 1, "phantom part kind 'internal' — MorfoPartKind is 'public' | 'private' | 'virtual' (morfo/types.ts)" ); } }); } for (const file of liveCorpus) { const rel = relative(REPO, file).split(sep).join('/'); if (rel.includes('LIBRO_VARIACIONES') || rel.endsWith('decisions/book-deviations.md')) continue; // the decision log that records the rejection const lines = read(file).split('\n'); lines.forEach((line, i) => { if (/\bdefaultSemantic\b/.test(line) && !PHANTOM_MENTION_OK.test(line)) { report( 'error', 'I2-phantom', file, i + 1, '`defaultSemantic` was rejected (D.11) — the implemented shape is additive `allowedFamilies`' ); } }); } // ── Invariant 3 — dependency claims ⊆ package.json ───────────────────────── const pkg = JSON.parse(read(join(REPO, 'package.json'))); const runtimeDeps = new Set(Object.keys(pkg.dependencies ?? {})); // svelte is the framework (devDep by design); clsx is a KNOWN phantom import // pending decision — documented as such, so a mention is legal only when the // same line admits it is not declared. const DEP_DOCS = [ join(REPO, 'src/uix/soma/README.md'), join(REPO, 'src/uix/soma/SOMA_ARCHITECTURE.md') ]; const DEP_CLAIM = /^\s*[-*]\s+`?(@?[a-z][\w./-]*[\w])`?\s+\(/; for (const file of DEP_DOCS) { if (!existsSync(file)) continue; const lines = read(file).split('\n'); let inDepList = false; lines.forEach((line, i) => { if (/depende de:|dependencias npm declaradas|solo depende/.test(line)) inDepList = true; else if (inDepList && /^\s*$/.test(line) && !DEP_CLAIM.test(lines[i + 1] ?? '')) inDepList = false; if (!inDepList) return; const m = line.match(DEP_CLAIM); if (!m) return; const name = m[1]; if (name === 'svelte') return; if (name.startsWith('$') || name.startsWith('src/')) return; // repo-internal if (!runtimeDeps.has(name)) { report( 'error', 'I3-deps', file, i + 1, `cites \`${name}\` as a dependency but package.json > dependencies does not declare it` ); } }); } // Corpus-wide: no live doc may present @floating-ui as a runtime dep. A // mention is fine when the surrounding lines state its devDep-only / // historical status (±1 line window — markdown wraps sentences). for (const file of liveCorpus) { const lines = read(file).split('\n'); lines.forEach((line, i) => { if (!/@floating-ui/.test(line)) return; const windowText = `${lines[i - 1] ?? ''} ${line} ${lines[i + 1] ?? ''}`; if ( /devDep|dev-only|parity|paridad|histor|superseded|CONTINUE|removal|spec|motor propio|in-house|ya no|dejó de/i.test( windowText ) ) return; report( 'error', 'I3-deps', file, i + 1, '`@floating-ui` presented without its devDep-only status — positioning is the in-house engine (`layers/floating` + `$ethereal`)' ); }); } // ── Invariant 4 — variant vocab mirror ───────────────────────────────────── { const eidosTypes = read(join(REPO, 'src/uix/eidos/lib/types.ts')); const auditSrc = read(join(REPO, 'scripts/component-audit.ts')); const evm = eidosTypes.match(/EIDOS_VARIANTS\s*=\s*\{([\s\S]*?)\}\s*as const/); if (!evm) { report( 'error', 'I4-mirror', join(REPO, 'src/uix/eidos/lib/types.ts'), 1, 'cannot locate EIDOS_VARIANTS' ); } else { const canonical = new Map(); for (const m of evm[1].matchAll(/(\w+):\s*\[([^\]]*)\]/g)) { canonical.set( m[1], [...m[2].matchAll(/'([\w-]+)'/g)].map((x) => x[1]) ); } const aliasFor: Record = { control: 'ControlVariant', selection: 'SelectionVariant', chip: 'ChipVariant', marker: 'MarkerVariant', tabs: 'TabsVariant' }; const mirrorBlock = auditSrc.match(/SHARED_VARIANT_VOCAB\s*=\s*new Map[\s\S]*?\]\);/); if (!mirrorBlock) { report( 'error', 'I4-mirror', join(REPO, 'scripts/component-audit.ts'), 1, 'cannot locate SHARED_VARIANT_VOCAB' ); } else { const mirror = new Map(); for (const m of mirrorBlock[0].matchAll(/\['(\w+)',\s*\[([\s\S]*?)\]\s*\]/g)) { mirror.set( m[1], [...m[2].matchAll(/'([\w-]+)'/g)].map((x) => x[1]) ); } for (const [archetype, values] of canonical) { const alias = aliasFor[archetype]; if (!alias) continue; const mirrored = mirror.get(alias); if (!mirrored) { report( 'error', 'I4-mirror', join(REPO, 'scripts/component-audit.ts'), 1, `SHARED_VARIANT_VOCAB is missing '${alias}' (EIDOS_VARIANTS.${archetype})` ); } else if (mirrored.join('|') !== values.join('|')) { report( 'error', 'I4-mirror', join(REPO, 'scripts/component-audit.ts'), 1, `SHARED_VARIANT_VOCAB['${alias}'] = [${mirrored.join(', ')}] != EIDOS_VARIANTS.${archetype} = [${values.join(', ')}]` ); } } } } } // ── Invariant 5 — checklist ↔ component-audit ────────────────────────────── { const checklistPath = join(REPO, 'docs/guides/completion-checklist.md'); const checklist = read(checklistPath); const auditSrc = read(join(REPO, 'scripts/component-audit.ts')); // Checklist rows: | ID | Rule... | severity | applicability | enforcement | const rowRe = /^\|\s*([A-Z]+-\d+\.\d+[a-z]?)\s*\|.*\|\s*(audit(?:\s*\(via [A-Z]+-\d+\.\d+[a-z]?\))?|tool:[\w:-]+|manual)\s*\|\s*$/; const declared = new Map(); checklist.split('\n').forEach((line, i) => { const m = line.match(rowRe); if (m) declared.set(m[1], m[2]); else { // Row without a recognizable enforcement value? const idOnly = line.match(/^\|\s*([A-Z]+-\d+\.\d+[a-z]?)\s*\|/); if (idOnly) { report( 'error', 'I5-checklist', checklistPath, i + 1, `rule ${idOnly[1]} has no recognizable Enforcement value (audit | tool:X | manual)` ); } } }); const implemented = new Set( [...auditSrc.matchAll(/(?:pass|fail)\(\s*'([A-Z]+-\d+\.\d+[a-z]?)'/g)].map((m) => m[1]) ); for (const [id, enforcement] of declared) { if (enforcement === 'audit' && !implemented.has(id)) { report( 'error', 'I5-checklist', checklistPath, 1, `rule ${id} declares Enforcement: audit but component-audit.ts never emits it` ); } const via = enforcement.match(/audit\s*\(via ([A-Z]+-\d+\.\d+[a-z]?)\)/); if (via && !implemented.has(via[1])) { report( 'error', 'I5-checklist', checklistPath, 1, `rule ${id} claims coverage via ${via[1]} but component-audit.ts never emits ${via[1]}` ); } } for (const id of implemented) { if (!declared.has(id)) { report( 'error', 'I5-checklist', checklistPath, 1, `component-audit.ts emits ${id} but the checklist does not declare it` ); } } } // ── Invariant 6 — relative links resolve (ERROR since 2026-07-11) ────────── const LINK_RE = /\[[^\]]*\]\(([^)\s]+)\)/g; const linkScope = corpus.filter((f) => { const rel = relative(REPO, f).split(sep).join('/'); if (rel.includes('Disenando_lo_que_ocurre')) return false; // docs/** + uix layer-level docs (not per-component READMEs — too many // historical relative paths; promote later if useful). return ( rel.startsWith('docs/') || /^src\/uix\/[^/]+\.md$/.test(rel) || /^src\/uix\/(morfo|soma|sema|eidos|active-uix)\/[^/]+\.md$/.test(rel) || /^src\/docs\/[^/]+\.md$/.test(rel) ); }); for (const file of linkScope) { // Sealed fossils: their internal links reflect the tree at their moment in // time and are allowed to dangle (docs/old-deprecated/README.md). if (relative(REPO, file).split(sep).join('/').startsWith('docs/old-deprecated/')) continue; const lines = read(file).split('\n'); let inFence = false; lines.forEach((line, i) => { if (/^\s*```/.test(line)) inFence = !inFence; if (inFence) return; for (const m of line.matchAll(LINK_RE)) { const target = m[1]; if (/^(https?:|mailto:|#)/.test(target)) continue; const clean = decodeURI(target.split('#')[0]); if (!clean) continue; const abs = resolve(dirname(file), clean); if (!existsSync(abs)) { report('error', 'I6-links', file, i + 1, `relative link does not resolve: ${target}`); } } }); } // ── Invariant 7 — generated vocabularies appendix is fresh ───────────────── // docs/canon/vocabularies.md is generated from the code consts. If it drifts // (a const changed but `npm run docs:vocabularies` wasn't re-run), the appendix // that agents build from is stale. Regenerate in-memory and compare. { const vocPath = join(REPO, 'docs/canon/vocabularies.md'); const expected = generateVocabulariesDoc(); const actual = existsSync(vocPath) ? read(vocPath).replace(/\r\n/g, '\n') : ''; if (actual !== expected) { report( 'error', 'I7-vocab', vocPath, 1, 'docs/canon/vocabularies.md is stale — run `npm run docs:vocabularies` (it is generated from the code consts)' ); } } // ── Report ───────────────────────────────────────────────────────────────── findings.sort( (a, b) => a.invariant.localeCompare(b.invariant) || a.file.localeCompare(b.file) || a.line - b.line ); const errors = findings.filter((f) => f.severity === 'error'); const warns = findings.filter((f) => f.severity === 'warn'); for (const f of findings) { console.log( `${f.severity.toUpperCase().padEnd(5)} [${f.invariant}] ${f.file}:${f.line} — ${f.message}` ); } console.log( `\ndocs-check: ${errors.length} error(s), ${warns.length} warning(s) across ${corpus.length} docs ` + `(families=${SEMA_FAMILIES.length}, intents=${INTENTS.length}, archetypes=${ARCHETYPES.length}, palette=${PALETTE.length})` ); process.exit(errors.length > 0 ? 1 : 0);