/** * arts-check — guard for the `src/arts/` ecosystem invariants (arts docs * reconciliation, batch A0). Ships the net that batches A1–A3 / B3 land * against, so the drift-fix and language passes cannot silently regress the * structural promises the arts corpus makes. * * The canon these invariants encode is `src/arts/README.md` §"naming * conventions" + the "Map" index: * * A-readme (error) — every `src/arts/{name}/` has a README.md. A missing * README is a hole in the corpus. README_PENDING is a self-cleaning * exemption set (empty now — every art is documented, ethereal included): * if an exempted art later gains a README the guard demands it be removed * from the set, so the exemption can never outlive the gap it covers. * A-naming (error) — the `Engine*` / `Active*` split holds: * · No `Engine*` root is DECLARED in a `.svelte.ts` file. Engines are * pure factories over private/no state (README §7-9) — they never own * `$state`, so they never need the runes extension. (A `.svelte.ts` * that merely *wraps* an engine is fine; only a declaration trips it.) * · Every reactive `class Active*` root lives in a `.svelte.ts` file * (README §12) — a plain `.ts` cannot own module `$state`. Error * classes (`Active…Error`) are not reactive roots and are exempt. * A-index (error) — the `src/arts/README.md` Map lists every art. An art * that never reaches the index is invisible to readers (ethereal was: a * full positioning engine absent from the Map, the aliases and the deps). * * Mirrors scripts/docs-check.ts (textual invariants, no Svelte-graph imports). * Exit code 1 when any error-level finding exists. */ import { readFileSync, readdirSync, statSync, existsSync } from 'node:fs'; import { join, resolve, relative, sep } from 'node:path'; const REPO = resolve(import.meta.dirname, '..'); const ARTS = join(REPO, 'src', 'arts'); interface Finding { severity: 'error' | 'warn'; invariant: string; file: string; line: number; message: string; } const findings: Finding[] = []; const notes: string[] = []; 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 }); } function read(file: string): string { return readFileSync(file, 'utf-8'); } /** The arts = the immediate subdirectories of `src/arts/`. */ const arts = readdirSync(ARTS) .filter((entry) => statSync(join(ARTS, entry)).isDirectory()) .sort(); /** Collect `.ts` sources under an art, excluding tests. */ function walkTs(dir: string, out: string[] = []): string[] { for (const entry of readdirSync(dir)) { const full = join(dir, entry); if (statSync(full).isDirectory()) { if (entry !== 'node_modules') walkTs(full, out); } else if (/\.ts$/.test(entry) && !/\.(test|spec)\./.test(entry)) { out.push(full); } } return out; } const tsFiles = walkTs(ARTS); // ── Invariant A-readme — every art has a README ───────────────────────────── /** Self-cleaning exemption for arts whose README is not authored yet. Empty: * every art is documented (ethereal's README landed with its Map/alias wiring). */ const README_PENDING = new Set(); for (const art of arts) { const readme = join(ARTS, art, 'README.md'); const exists = existsSync(readme); if (!exists) { if (README_PENDING.has(art)) { notes.push(`A-readme: '${art}/README.md' absent — exempt pending batch A3`); } else { report('error', 'A-readme', readme, 1, `art '${art}' has no README.md`); } } else if (README_PENDING.has(art)) { // Self-cleaning: the exemption must not outlive the gap it covers. report( 'error', 'A-readme', readme, 1, `art '${art}' now has a README — remove '${art}' from README_PENDING in scripts/arts-check.ts` ); } } // ── Invariant A-naming — the Engine*/Active* split holds ──────────────────── /** An EXPORTED declaration of an `Engine*` root (class or create/define factory). */ const ENGINE_DECL = /^\s*export\s+(?:async\s+)?(?:abstract\s+)?(?:class\s+Engine\w+|function\s+(?:create|define)Engine\w+|const\s+(?:create|define)Engine\w+\s*=)/; /** An EXPORTED reactive `class Active*` root (not an `Active…Error`). */ const ACTIVE_CLASS = /^\s*export\s+(?:abstract\s+)?class\s+(Active\w+)/; for (const file of tsFiles) { const isSvelte = file.endsWith('.svelte.ts'); const lines = read(file).split('\n'); lines.forEach((line, i) => { if (isSvelte && ENGINE_DECL.test(line)) { report( 'error', 'A-naming', file, i + 1, 'an Engine* root is declared in a .svelte.ts file — engines are stateless (arts/README §naming); reactive roots are Active* only' ); } if (!isSvelte) { const m = line.match(ACTIVE_CLASS); if (m && !/Error$/.test(m[1])) { report( 'error', 'A-naming', file, i + 1, `reactive root class ${m[1]} is declared in a plain .ts — Active* roots own $state and must live in a .svelte.ts (arts/README §naming)` ); } } }); } // ── Invariant A-index — the arts index Map lists every art (WARN) ─────────── const indexSrc = read(join(ARTS, 'README.md')); const indexed = new Set([...indexSrc.matchAll(/\.\/([a-z][\w-]*)\/README\.md/g)].map((m) => m[1])); for (const art of arts) { if (!indexed.has(art)) { report( 'error', 'A-index', join(ARTS, 'README.md'), 1, `art '${art}' is not listed in the arts index Map table (src/arts/README.md)` ); } } // ── 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}` ); } for (const n of notes) console.log(`NOTE ${n}`); console.log( `\narts-check: ${errors.length} error(s), ${warns.length} warning(s) across ${arts.length} arts` ); process.exit(errors.length > 0 ? 1 : 0);