You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
svelte-kit-vice/scripts/docs-check.ts

1089 lines
38 KiB

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

/**
* 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 (imported from uix.aliases.js, 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 { UIX_ALIASES } from '../uix.aliases.js';
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<string, string[]>();
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<string, string> = {
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<string, string[]>();
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<string, string>();
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;
/** `<!-- docs-check-allow: SymbolA, SymbolB -->` pragma, per doc. */
function allowedSymbols(text: string): Set<string> {
const out = new Set<string>();
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 IMPORTED from `uix.aliases.js`, the one module Vite,
// SvelteKit and the boot compiler also read — never copied, never parsed out
// of a config file.
const ALIASES = new Map<string, string>(Object.entries(UIX_ALIASES));
if (ALIASES.size < 10) {
report(
'error',
'I8-imports',
join(REPO, 'uix.aliases.js'),
1,
`the alias table has only ${ALIASES.size} entries — uix.aliases.js 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 resolvesOnDisk(base: string): boolean {
if (existsSync(base)) return true; // file or directory
return ['.ts', '.svelte', '.svelte.ts', '.js', `${sep}index.ts`].some((suffix) =>
existsSync(base + suffix)
);
}
/**
* Resolve an import spec the way VITE does: the alias map holds MULTI-SEGMENT
* keys (`$svrs/auth/testing` points at a file, not a directory), so matching
* only the first segment is a model of aliases the repo outgrew. Longest key
* first, exactly like the resolver whose truth this instrument claims to
* mirror.
*
* Measured 2026-08-27: with the server-only barrier the auth entries became
* `index.server.ts` / `testing.server.ts` and gained exact alias keys; the
* head-only lookup then declared a VALID import broken and turned the gate
* (and the shared pre-push hook) red. The doc was right and the instrument
* was wrong — the class this whole corpus exists to catch, landing on the
* catcher.
*/
function specResolves(spec: string): boolean {
let longest: string | undefined;
for (const key of ALIASES.keys()) {
if (spec !== key && !spec.startsWith(`${key}/`)) continue;
if (!longest || key.length > longest.length) longest = key;
}
if (!longest) return false;
const rest = spec === longest ? '' : spec.slice(longest.length + 1);
return resolvesOnDisk(join(REPO, ALIASES.get(longest)!, rest));
}
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 (uix.aliases.js)`
);
} else if (!specResolves(spec)) {
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<string>();
{
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<string>();
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<string, Set<string>>();
function identifiersOf(file: string): Set<string> {
const cached = identifierCache.get(file);
if (cached) return cached;
const out = new Set<string>();
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<string>();
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);

Powered by TurnKey Linux.