feat(docs): docs:check guard — copyable-invariant lint for the doc corpus
Phase 3 of PLAN-docs-reconciliation (fable_audit D5: the 'link, don't
copy' law had no mechanical guard). New scripts/docs-check.ts + npm run
docs:check with six invariants, each born from a verified drift:
- I1 vocabulary counts vs SEMA_FAMILIES/INTENTS/ARCHETYPE_VOCABULARY
(context-gated: font/shape families, variant archetypes and evaluative
subsets don't false-positive; section numbers and quoted historical
mentions skipped).
- I2 phantom fields: translations: in morfo/soma docs, defaultSemantic
outside LIBRO_VARIACIONES (rename/rejection mentions allowed).
- I3 dependency claims: soma dep lists must cite real package.json
dependencies; corpus-wide @floating-ui mentions need their devDep-only
context.
- I4 SHARED_VARIANT_VOCAB (component-audit) == EIDOS_VARIANTS (textual
mirror comparison).
- I5 checklist<->audit: every Enforcement:audit rule exists in the
script, every script rule ID is declared.
- I6 relative links resolve (WARN until the known dangling targets get
their user decision: THEMING_AUDIT deleted in worktree, PENDIENTES.md
deleted 2026-06-07, MOTION_SERVICE_RFC demo routes).
Chronicle docs (process/, changelogs, audits, CONTINUEs, status:
historical frontmatter, the book) are exempt from truth-tracking.
Also: THEMING s2 per-layer table realigned to the real entrypoint (the
fragile rule-count column dropped); stale adom link in SOMA_ARCHITECTURE
fixed; validator documented in docs/testing-and-tooling.md.
Current output: 1 error (building-a-component known-traps row — cleared
in the closing pass) + 13 warns.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
3 months ago
|
|
|
|
/**
|
|
|
|
|
|
* 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. (WARN severity until the known dangling targets get
|
|
|
|
|
|
* their user decision.)
|
|
|
|
|
|
*
|
|
|
|
|
|
* Severities: 1–5 are errors (Phase 1 left them green); 6 is a warning.
|
|
|
|
|
|
* 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';
|
|
|
|
|
|
|
|
|
|
|
|
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.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}`);
|
|
|
|
|
|
return [...m[1].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'
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
// ── 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
|
|
|
|
|
|
}
|
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
|
|
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") — number glued to a dot.
|
|
|
|
|
|
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)'
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
for (const file of liveCorpus) {
|
|
|
|
|
|
const rel = relative(REPO, file).split(sep).join('/');
|
docs(book): F7.6 (1/2) — decision logs moved to docs/decisions/ (verbatim Spanish)
LIBRO_VARIACIONES_Y_EXTENSIONES -> decisions/book-deviations.md and
GUIA_IMPLEMENTACION_SEMAUIX -> decisions/guia-semantica-historica.md,
both moved AS-IS in Spanish: the deviations registry is a logbook of the
author's literal decisions ('transcrita literal') and carries proposed
doctrinal text destined for the Spanish book — translating it would
destroy that function (s G calls itself bitacora); the guia was already
status: historical (Fase 6) and the plan exempts it explicitly. English
frontmatter added to both; internal cross-links repointed (CANON,
theming/reference, book-deviations D.11). docs-check's I2 phantom-field
exemption follows the moved file (it matched by the LIBRO_VARIACIONES
filename; now also matches decisions/book-deviations.md). Corpus swept:
CANON x3, README (E2/E3 strata + tables — also fixed the pre-Fase-6
leftover row still calling the guia 'authoritative for any new wrapper'
and the unswept eidos/TSC.md stratum mention), building-a-component D.4,
completion-checklist G-1.1 + header, theming/reference, architecture
x5 (active-architecture, eidos, morfo, overview, sema x2).
docs:check 0 errors, 11-warn baseline.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
3 months ago
|
|
|
|
if (rel.includes('LIBRO_VARIACIONES') || rel.endsWith('decisions/book-deviations.md')) continue; // the decision log that records the rejection
|
feat(docs): docs:check guard — copyable-invariant lint for the doc corpus
Phase 3 of PLAN-docs-reconciliation (fable_audit D5: the 'link, don't
copy' law had no mechanical guard). New scripts/docs-check.ts + npm run
docs:check with six invariants, each born from a verified drift:
- I1 vocabulary counts vs SEMA_FAMILIES/INTENTS/ARCHETYPE_VOCABULARY
(context-gated: font/shape families, variant archetypes and evaluative
subsets don't false-positive; section numbers and quoted historical
mentions skipped).
- I2 phantom fields: translations: in morfo/soma docs, defaultSemantic
outside LIBRO_VARIACIONES (rename/rejection mentions allowed).
- I3 dependency claims: soma dep lists must cite real package.json
dependencies; corpus-wide @floating-ui mentions need their devDep-only
context.
- I4 SHARED_VARIANT_VOCAB (component-audit) == EIDOS_VARIANTS (textual
mirror comparison).
- I5 checklist<->audit: every Enforcement:audit rule exists in the
script, every script rule ID is declared.
- I6 relative links resolve (WARN until the known dangling targets get
their user decision: THEMING_AUDIT deleted in worktree, PENDIENTES.md
deleted 2026-06-07, MOTION_SERVICE_RFC demo routes).
Chronicle docs (process/, changelogs, audits, CONTINUEs, status:
historical frontmatter, the book) are exempt from truth-tracking.
Also: THEMING s2 per-layer table realigned to the real entrypoint (the
fragile rule-count column dropped); stale adom link in SOMA_ARCHITECTURE
fixed; validator documented in docs/testing-and-tooling.md.
Current output: 1 error (building-a-component known-traps row — cleared
in the closing pass) + 13 warns.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
3 months ago
|
|
|
|
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 ──────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
{
|
docs(book): F7.5 (1/2) — checklist + audit guide + demo guide moved to docs/guides/
Three English guides moved verbatim (copy + link touch-ups, no
translation): COMPONENT_COMPLETION_CHECKLIST -> guides/
completion-checklist.md, COMPONENT_AUDIT_GUIDE -> guides/
component-audit.md, DEMO_AUTHORING_GUIDE -> guides/demo-authoring.md.
Tooling moved with its doc in the same pass: docs-check I5 now parses
the checklist at the new path (verified: 0 errors, rule tables found)
and component-audit.ts's two path strings updated. One reconciliation
in the audit guide's s5, applied as link-don't-copy: the copied v1
6-tab template list (stale vs the v2 9-tab guide it links, drawer
canary vs button) is replaced by a pointer to demo-authoring.md; the
domain canaries in s7 stay. demo-authoring's harness links now reach
into web/routes/uix/lib/ (the modules stay with the code); the deleted
pendiente_color_demos.md reference is marked TODO(reconcile). Corpus
swept: building-a-component (frontmatter related: + phases 6/8),
authoring, architecture/soma, theming/reference s5-parity,
testing-and-tooling, getting-started, README (E4 rows + component-audit
row added + two stale F7.3 paths in 'I want to' fixed). Remaining
warns = links to component-guide.md, which lands in F7.5 (2/2).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
3 months ago
|
|
|
|
const checklistPath = join(REPO, 'docs/guides/completion-checklist.md');
|
feat(docs): docs:check guard — copyable-invariant lint for the doc corpus
Phase 3 of PLAN-docs-reconciliation (fable_audit D5: the 'link, don't
copy' law had no mechanical guard). New scripts/docs-check.ts + npm run
docs:check with six invariants, each born from a verified drift:
- I1 vocabulary counts vs SEMA_FAMILIES/INTENTS/ARCHETYPE_VOCABULARY
(context-gated: font/shape families, variant archetypes and evaluative
subsets don't false-positive; section numbers and quoted historical
mentions skipped).
- I2 phantom fields: translations: in morfo/soma docs, defaultSemantic
outside LIBRO_VARIACIONES (rename/rejection mentions allowed).
- I3 dependency claims: soma dep lists must cite real package.json
dependencies; corpus-wide @floating-ui mentions need their devDep-only
context.
- I4 SHARED_VARIANT_VOCAB (component-audit) == EIDOS_VARIANTS (textual
mirror comparison).
- I5 checklist<->audit: every Enforcement:audit rule exists in the
script, every script rule ID is declared.
- I6 relative links resolve (WARN until the known dangling targets get
their user decision: THEMING_AUDIT deleted in worktree, PENDIENTES.md
deleted 2026-06-07, MOTION_SERVICE_RFC demo routes).
Chronicle docs (process/, changelogs, audits, CONTINUEs, status:
historical frontmatter, the book) are exempt from truth-tracking.
Also: THEMING s2 per-layer table realigned to the real entrypoint (the
fragile rule-count column dropped); stale adom link in SOMA_ARCHITECTURE
fixed; validator documented in docs/testing-and-tooling.md.
Current output: 1 error (building-a-component known-traps row — cleared
in the closing pass) + 13 warns.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
3 months ago
|
|
|
|
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 (WARN) ────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
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) {
|
|
|
|
|
|
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('warn', 'I6-links', file, i + 1, `relative link does not resolve: ${target}`);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ── 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})`
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
process.exit(errors.length > 0 ? 1 : 0);
|