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

625 lines
21 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.
*
* 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<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)'
);
}
}
// ── 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.