/** * 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. * 7. Generated vocabularies appendix — docs/canon/vocabularies.md must match * an in-memory regeneration from the code consts. * 8. Code-block imports — `$`/`@/` import heads in fenced ts/js/svelte blocks * must be real repo aliases (extracted from vite.config.ts, never copied) * and resolve on disk. `$lib` is retired: forbidden in framework-owned * docs (docs/**, src/uix/**); tolerated in consumer-app examples * elsewhere. (Born 2026-08-05: the exhaustive opts audit found phantom * '../../reactive' paths and a `$lib` debounce recipe in live docs.) * 9. Provider/Opts symbols — `XxxProvider` / `XxxOpts` names inside fenced * code blocks must exist in the src symbol index. Placeholders (Xxx…), * negated mentions ("not TerraDialogProvider") and a * `docs-check-allow:` pragma are exempt. (SplitterTriggerProvider * survived in GESTURES.md — the class is SplitterResizeTriggerProvider.) * 10. Prop tables ↔ code — every `prop` documented in a component README's * prop table must be declared somewhere in the component dir OR in the * files its sources import (one hop, types.ts convention included). * The one-hop resolution exists because rest-forwarding (`...rest` to a * soma provider) is how eidos wrappers legitimately expose props they * never declare. * * Severities: all invariants are 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') || /CONTINUE|continuar/i.test(rel) || // hand-offs, any casing (root `continuar-*.md`) /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')), // The other framework layers — arts/libs/svrs/packs READMEs are doctrine // too (widened 2026-08-05: the opts audit found $lib recipes living there // precisely because no guard walked them). ...['arts', 'libs', 'svrs', 'packs'] .map((d) => join(REPO, 'src', d)) .filter((d) => existsSync(d)) .flatMap((d) => walkMd(d)), // Repo-root docs (top level only — no recursion, the trees above own the rest). ...readdirSync(REPO) .filter((f) => f.endsWith('.md')) .map((f) => join(REPO, f)), ...(existsSync(join(REPO, 'web', 'routes', 'uix', 'lib')) ? walkMd(join(REPO, 'web', 'routes', 'uix', 'lib')) : []) ]; const liveCorpus = corpus.filter((f) => !isChronicle(f)); /** Aspirational docs — proposals/backlogs that name APIs which do not exist YET. */ function isAspirational(file: string): boolean { return /IMPROVEMENTS|PROPOSAL|RFC|ROADMAP|IDEAS|WISHLIST/i.test( relative(REPO, file).split(sep).join('/') ); } // ── 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'); /** Files in a directory that ARE the catalog (no tests, no barrel). */ function catalogEntries(dir: string, opts: { skip?: RegExp } = {}): string[] { if (!existsSync(dir)) return []; return readdirSync(dir) .filter((f) => f.endsWith('.ts') && !f.endsWith('.test.ts') && f !== 'index.ts') .filter((f) => !opts.skip?.test(f)) .map((f) => f.replace(/\.ts$/, '')) .sort(); } const MORFO_COMPONENTS = catalogEntries(join(REPO, 'src/uix/morfo/components')); const SERVICE_FACTORIES = catalogEntries(join(REPO, 'src/arts/active-app/service-factories')); // ── 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. { // `colou?r` — the British spelling slipped a "the 8 colour roles" past // this rule while its American sibling on the next line was caught. re: /\b(\d+)\s+(?:colou?r\s+)?roles\b/gi, expected: COLOR_ROLES_COUNT, what: 'COLOR_ROLES', context: /colou?r|role|palet/i, exclude: /accent|hierarch|jerarqu|intent\s+roles|semantic/i, minToFlag: 7 }, // The morfo catalog: "≈140 morfos today" while the tree held 165. An // approximation of OUR OWN catalog is the disease, not an escape hatch — // so no `~`/`≈` exclusion here (unlike the palette rule, whose `~N` refers // to other libraries). { re: /\b(\d+)\s+morfos?\b/gi, expected: MORFO_COMPONENTS.length, what: 'the morfo catalog', context: /morfo|component|catálogo|catalog|matri|cat[aá]logo/i, minToFlag: 20 }, { 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 1.bis — enumerated catalogs vs their source directory ──────── // // A copied NUMBER is one disease ("8 roles"); a hand-enumerated LIST that fell // behind is the other, and no word-counter can see it: `active-app.md` listed // 12 of the 16 service factories without ever writing "12". Same law // (`authoring.md`: a catalog becomes a pointer), so same check — compare the // doc's mentions against the directory that IS the catalog. // // Deliberately conservative: it only asks whether the entry is mentioned AT // ALL, so a doc that names it in prose passes. That under-reports rather than // crying wolf, and it still catches the real failure (an entry absent // entirely, which is how `agent` / `motion` / `scene` / `sound` went missing). interface CatalogCheck { /** Repo-relative doc that enumerates the catalog. */ doc: string; /** The entries that ARE the catalog, read from the tree. */ entries: string[]; /** Where the truth lives, for the message. */ source: string; } const CATALOG_CHECKS: CatalogCheck[] = [ { doc: 'docs/architecture/active-app.md', entries: SERVICE_FACTORIES, source: 'src/arts/active-app/service-factories/' } ]; for (const { doc, entries, source } of CATALOG_CHECKS) { const full = join(REPO, doc); if (!existsSync(full) || entries.length === 0) continue; const text = read(full); const missing = entries.filter((entry) => !new RegExp(`\\b${entry}\\b`, 'i').test(text)); if (missing.length > 0) { report( 'error', 'I1-catalog', full, 1, `enumerates ${source} (${entries.length} entries) but never mentions ${missing.join(', ')} — point at the directory, don't enumerate it` ); } } // ── 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) { // The in-house engine's own docs (src/arts/ethereal) benchmark AGAINST // @floating-ui — comparison is their raison d'être, not a dependency claim. if (relative(REPO, file).split(sep).join('/').startsWith('src/arts/ethereal/')) continue; 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)' ); } } // ── Shared machinery for invariants 8–10 ─────────────────────────────────── // // All three were born 2026-08-05 from the exhaustive opts-axis audit: the // mechanical sweep that found them ran OUTSIDE this guard, which is exactly // the blindness these invariants close. They scan fenced code blocks only — // prose and comparison tables legitimately name foreign-library APIs. interface Fence { lang: string; /** 1-based line of the opening ``` — content lines carry their own number. */ lines: { text: string; line: number }[]; /** The two lines above the opener — anti-example fences announce themselves there. */ intro: string; } function codeFences(rawLines: string[]): Fence[] { const out: Fence[] = []; let cur: Fence | null = null; rawLines.forEach((text, i) => { const open = text.match(/^\s*```([\w-]*)\s*$/); if (open) { if (cur) { out.push(cur); cur = null; } else { cur = { lang: open[1].toLowerCase(), lines: [], intro: `${rawLines[i - 2] ?? ''} ${rawLines[i - 1] ?? ''}` }; } return; } if (cur) cur.lines.push({ text, line: i + 1 }); }); return out; } const CODE_LANGS = new Set(['ts', 'typescript', 'js', 'javascript', 'svelte']); /** Deliberate anti-examples — the doc is SHOWING the mistake. */ const ANTI_EXAMPLE = /❌|✗|\bwrong\b|\bbad\b|\bnever\b|\bnunca\b|\bantes\b|\bbefore\b|\bmal\b/i; /** `` pragma, per doc. */ function allowedSymbols(text: string): Set { const out = new Set(); for (const m of text.matchAll(/docs-check-allow:\s*([^\n>]+)/g)) { for (const s of m[1].split(/[,\s]+/)) if (s) out.add(s); } return out; } const codeCorpus = liveCorpus.filter((f) => !isAspirational(f)); // ── Invariant 8 — code-block imports resolve ─────────────────────────────── // // The alias table is EXTRACTED from vite.config.ts (the declared source of // truth for aliases), never copied — copying it here would recreate the // disease this script exists to catch. const ALIASES = new Map(); { const viteSrc = read(join(REPO, 'vite.config.ts')); for (const m of viteSrc.matchAll( /['"]([$@][\w./-]*)['"]\s*:\s*resolve\(__dirname,\s*['"]([^'"]+)['"]\)/g )) { ALIASES.set(m[1], m[2]); } if (ALIASES.size < 10) { report( 'error', 'I8-imports', join(REPO, 'vite.config.ts'), 1, `alias extraction found only ${ALIASES.size} entries — the aliases const changed shape` ); } } /** SvelteKit's own virtual modules — legal without being repo aliases. */ const SVELTEKIT_HEADS = new Set(['$app', '$env', '$service-worker']); const IMPORT_RE = /(?:from\s+|import\s*\(\s*|import\s+)['"]([^'"\n]+)['"]/g; /** A doc that speaks WITH the framework's voice — consumer-app idioms are drift there. */ function isFrameworkOwned(file: string): boolean { const rel = relative(REPO, file).split(sep).join('/'); return rel.startsWith('docs/') || rel.startsWith('src/uix/'); } function importResolves(head: string, rest: string): boolean { const base = join(REPO, ALIASES.get(head)!, rest); if (existsSync(base)) return true; // file or directory return ['.ts', '.svelte', '.svelte.ts', '.js', `${sep}index.ts`].some((suffix) => existsSync(base + suffix) ); } for (const file of codeCorpus) { const rawLines = read(file).split('\n'); for (const fence of codeFences(rawLines)) { if (!CODE_LANGS.has(fence.lang)) continue; if (ANTI_EXAMPLE.test(fence.intro)) continue; fence.lines.forEach(({ text, line }, idx) => { if (ANTI_EXAMPLE.test(text) || ANTI_EXAMPLE.test(fence.lines[idx - 1]?.text ?? '')) return; IMPORT_RE.lastIndex = 0; for (const m of text.matchAll(IMPORT_RE)) { const spec = m[1]; if (/[{}<>]|\.\.\./.test(spec)) continue; // `{name}` placeholders / elisions if (!spec.startsWith('$') && !spec.startsWith('@/') && spec !== '@') continue; const slash = spec.indexOf('/'); const head = slash === -1 ? spec : spec.slice(0, slash); const rest = slash === -1 ? '' : spec.slice(slash + 1); if (SVELTEKIT_HEADS.has(head)) continue; if (head === '$lib') { if (isFrameworkOwned(file)) { report( 'error', 'I8-imports', file, line, `\`${spec}\` — the \`$lib\` alias was retired; framework docs must use the real repo alias` ); } continue; // consumer-app examples elsewhere may keep their own $lib } if (!ALIASES.has(head)) { report( 'error', 'I8-imports', file, line, `\`${spec}\` — \`${head}\` is not a repo alias (vite.config.ts)` ); } else if (!importResolves(head, rest)) { report( 'error', 'I8-imports', file, line, `\`${spec}\` does not resolve on disk (alias \`${head}\` → ${ALIASES.get(head)})` ); } } }); } } // ── Invariant 9 — Provider/Opts symbols exist in src ─────────────────────── const symbolIndex = new Set(); { const stack = [join(REPO, 'src')]; while (stack.length > 0) { const dir = stack.pop()!; for (const entry of readdirSync(dir)) { const full = join(dir, entry); if (statSync(full).isDirectory()) { if (!SKIP_DIRS.has(entry)) stack.push(full); } else if (/\.(ts|svelte)$/.test(entry) && !/\.(test|spec)\.ts$/.test(entry)) { for (const m of read(full).matchAll( /\b(?:class|interface|type|function|const|let|enum)\s+([A-Z][A-Za-z0-9_]*)/g )) { symbolIndex.add(m[1]); } } } } } /** Placeholder stems used by guides for "your component here". */ const PLACEHOLDER_STEM = /^(X{1,3}[a-z]*|[A-Z]|Foo|Bar|Baz|Example|Sample|Demo|My\w*|Some\w*|Your\w*|Name|Abc|Component)$/; for (const file of codeCorpus) { const text = read(file); const allowed = allowedSymbols(text); for (const fence of codeFences(text.split('\n'))) { if (!CODE_LANGS.has(fence.lang)) continue; if (ANTI_EXAMPLE.test(fence.intro)) continue; // Symbols the block itself imports from an external package (e.g. // `import { LoggerProvider } from '@opentelemetry/sdk-logs'`) carry // their own provenance — they are not ours to index. const externals = new Set(); for (const { text: lineText } of fence.lines) { const im = lineText.match( /import\s+(?:([A-Za-z_$][\w$]*)\s*,?\s*)?(?:\{([^}]*)\})?\s*from\s*['"]([^'"]+)['"]/ ); if (!im) continue; const spec = im[3]; if (spec.startsWith('$') || spec.startsWith('.') || spec.startsWith('@/')) continue; if (im[1]) externals.add(im[1]); for (const part of (im[2] ?? '').split(',')) { const named = part .trim() .split(/\s+as\s+/) .pop() ?.trim(); if (named) externals.add(named); } } for (const { text: lineText, line } of fence.lines) { if (/\bnot\b|\bno\b|\bnever\b|❌|✗/i.test(lineText)) continue; // negated mentions for (const m of lineText.matchAll(/\b([A-Z][A-Za-z0-9_]*?)(Provider|Opts)\b/g)) { const name = m[1] + m[2]; if (m[1] === '' || PLACEHOLDER_STEM.test(m[1])) continue; // `{Name}TriggerProvider` — a placeholder prefix glued to the // symbol; the `}` before the match gives it away. if (lineText[(m.index ?? 0) - 1] === '}') continue; if (allowed.has(name) || externals.has(name)) continue; if (!symbolIndex.has(name)) { report( 'error', 'I9-symbols', file, line, `\`${name}\` does not exist in src — phantom symbol (or add a \`docs-check-allow: ${name}\` pragma if it is deliberate)` ); } } } } } // ── Invariant 10 — README prop tables ↔ component code ───────────────────── // // The truth set is deliberately over-inclusive (any identifier declared as a // property / binding in the dir or its one-hop imports counts): it // under-reports rather than crying wolf, and still catches the real failure — // a documented prop NO source file ever declares (date-range-field's // symmetric `readonlySegments` never existed; the API is per-endpoint). const identifierCache = new Map>(); function identifiersOf(file: string): Set { const cached = identifierCache.get(file); if (cached) return cached; const out = new Set(); if (existsSync(file) && statSync(file).isFile()) { for (const line of read(file).split('\n')) { const m = line.match( /^\s*(?:export\s+)?(?:public\s+|private\s+|protected\s+|readonly\s+)*'?([A-Za-z_$][\w$]*)'?\s*\??\s*[:=,)]/ ); if (m) out.add(m[1]); } } identifierCache.set(file, out); return out; } /** Resolve a one-hop import target to concrete files worth harvesting. */ function hopTargets(fromFile: string, spec: string): string[] { let base: string | null = null; if (spec.startsWith('.')) { base = resolve(dirname(fromFile), spec); } else if (spec.startsWith('$') || spec.startsWith('@/') || spec === '@') { const slash = spec.indexOf('/'); const head = slash === -1 ? spec : spec.slice(0, slash); const target = ALIASES.get(head); if (target) base = join(REPO, target, slash === -1 ? '' : spec.slice(slash + 1)); } if (!base || !base.startsWith(join(REPO, 'src'))) return []; if (existsSync(base) && statSync(base).isDirectory()) { // A directory / barrel — the framework convention keeps the prop // surface in types.ts next to it. return [join(base, 'types.ts'), join(base, 'index.ts')].filter((f) => existsSync(f)); } for (const suffix of ['', '.ts', '.svelte', '.svelte.ts']) { if (existsSync(base + suffix) && statSync(base + suffix).isFile()) { const hits = [base + suffix]; const sibling = join(dirname(base + suffix), 'types.ts'); if (existsSync(sibling)) hits.push(sibling); return hits; } } return []; } const PROP_TABLE_HEADER = /^\|\s*`?(Props?|Property|Nombre)\b/i; const TABLE_SEPARATOR = /^\|[\s:|-]+\|?\s*$/; const componentReadmes = codeCorpus.filter((f) => /^src\/uix\/(soma|eidos)\/components\/[^/]+\/README\.md$/.test( relative(REPO, f).split(sep).join('/') ) ); for (const file of componentReadmes) { const dir = dirname(file); // Truth set: every file in the component dir + one hop through its imports. const truth = new Set(); const dirFiles = readdirSync(dir) .filter((f) => /\.(ts|svelte)$/.test(f)) .map((f) => join(dir, f)); for (const src of dirFiles) { for (const id of identifiersOf(src)) truth.add(id); for (const m of read(src).matchAll(IMPORT_RE)) { for (const hop of hopTargets(src, m[1])) { for (const id of identifiersOf(hop)) truth.add(id); } } } if (truth.size === 0) continue; // markdown-only dir — nothing to check against const lines = read(file).split('\n'); let inPropTable = false; lines.forEach((line, i) => { if (PROP_TABLE_HEADER.test(line)) { inPropTable = true; return; } if (!line.startsWith('|')) { inPropTable = false; return; } if (!inPropTable || TABLE_SEPARATOR.test(line)) return; const firstCell = line.split('|')[1] ?? ''; for (const m of firstCell.matchAll(/`([^`]+)`/g)) { const name = m[1].replace(/\?$/, ''); // Only bare camelCase prop names — methods, data-attrs, css vars, // compound cells and event names live outside this invariant. if (!/^[a-z][A-Za-z0-9]*$/.test(name)) continue; if (!truth.has(name)) { report( 'error', 'I10-props', file, i + 1, `prop table documents \`${name}\` but no file in ${relative(REPO, dir).split(sep).join('/')} (or its one-hop imports) declares it` ); } } }); } // ── 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);