|
|
/**
|
|
|
* 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. `ethereal` is the one known gap (it only
|
|
|
* ships PERF.md); it is exempt until batch A3 authors its README. The
|
|
|
* exemption is self-cleaning: once the file lands, the guard demands the
|
|
|
* art be removed from README_PENDING.
|
|
|
* 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 (warn) — the `src/arts/README.md` Map lists every art. WARN (not
|
|
|
* error) because closing the four current gaps (bus, orca, prefs, ethereal)
|
|
|
* is batch B3's job; the warnings are B3's worklist and drive to zero there.
|
|
|
*
|
|
|
* 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 ─────────────────────────────
|
|
|
|
|
|
/** ethereal only ships PERF.md today; batch A3 authors its README. */
|
|
|
const README_PENDING = new Set(['ethereal']);
|
|
|
|
|
|
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(
|
|
|
'warn',
|
|
|
'A-index',
|
|
|
join(ARTS, 'README.md'),
|
|
|
1,
|
|
|
`art '${art}' is not listed in the arts index Map table — batch B3 adds 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}`
|
|
|
);
|
|
|
}
|
|
|
|
|
|
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);
|