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/blocks-check.ts

634 lines
21 KiB

/**
* blocks-check — the mechanical guard of the blocks tier's B contract
* (docs/architecture/blocks.md). Small on purpose: blocks are exempt from the
* acceptance matrix, NOT from discipline.
*
* Checks:
* B-2 no raw interactive natives (`<button/input/select/textarea/a>`)
* in block `.svelte` files — every interactive element is a canon
* eidos component.
* B-4/B-1 import direction: blocks never import `$packs` or `$uix/sema`;
* nothing in the canon (`src/uix/{morfo,soma,sema,eidos,
* active-uix,langs}`) nor in `src/{arts,libs,packs}` imports the
* blocks tier.
* B-4 import allowlist (hard boundary 1): a block imports only `$uix`,
* the public arts, `$libs/forms`, `svelte` and its own relatives.
* Anything else is the boundary being crossed, not a new need.
* B-10 a block never imports another block (declared exception: the
* shells allowlist).
* D-BLK.2 no `.css` files under the tier; a scoped `<style>` requires a
* `justified:` comment marker (layout-components-first).
* B-9 every block folder ships `README.md` and a demo route at
* `web/routes/blocks/{kebab}/+page.svelte`.
* B-9 the README carries the template's four sections, and the block
* has a shipped entry in the demo catalog (`_lib/catalog.ts`) —
* the single list the rail and the gallery read.
* B-8 the README declares its landmark + heading hierarchy: which
* sectioning element and which levels the block emits.
*
* Self-testing: the detectors are asserted against inline fixtures on every
* run (a raw `<button>` fixture MUST be flagged, a `<Button>` fixture must
* not, …), so a silently-broken regex fails the guard itself.
*/
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
import { dirname, join, relative, resolve } from 'node:path';
const REPO = process.cwd();
const BLOCKS_DIR = join(REPO, 'src', 'uix', 'blocks');
const DEMO_DIR = join(REPO, 'web', 'routes', 'blocks');
const CATALOG_FILE = join(DEMO_DIR, '_lib', 'catalog.ts');
const ARTS_DIR = join(REPO, 'src', 'arts');
// B-10 declared exception: the shells compose F1 pieces and blocks by design.
const SHELL_ALLOWLIST = new Set(['app-shell', 'docs-shell']);
// The public arts a block may import, DERIVED from the tree (`$name` →
// `src/arts/name`) — a hand-written list falls behind the day an art lands.
const PUBLIC_ARTS = new Set(
existsSync(ARTS_DIR)
? readdirSync(ARTS_DIR).filter((entry) => statSync(join(ARTS_DIR, entry)).isDirectory())
: []
);
// The four sections the B-9 template requires of every block README.
/**
* The B-9 template. `## Equivalencias` joined it when the wave's stretch B
* landed: the tier's claim is «variants as typed props instead of N dumps», and
* that claim is only worth something if each block SHOWS which composition of
* ours reaches each variant of theirs — and names what it does not reach. The
* rule is switched on LAST on purpose: turning it on before the tables existed
* would have painted every README red for the length of the wave.
*/
const README_SECTIONS = [
'## Function',
'## Composition map',
'## Decisions',
'## Equivalencias',
'## Gaps'
];
// Trees that must never import the blocks tier (B-4).
const OUTSIDE_ROOTS = [
join(REPO, 'src', 'arts'),
join(REPO, 'src', 'libs'),
join(REPO, 'src', 'packs'),
join(REPO, 'src', 'uix', 'morfo'),
join(REPO, 'src', 'uix', 'soma'),
join(REPO, 'src', 'uix', 'sema'),
join(REPO, 'src', 'uix', 'eidos'),
join(REPO, 'src', 'uix', 'active-uix'),
join(REPO, 'src', 'uix', 'langs')
];
interface Violation {
rule: string;
file: string;
line: number;
detail: string;
}
const RAW_NATIVE_RE = /<(button|input|select|textarea|a)\b/;
const IMPORT_RE = /(?:from|import)\s*['"]([^'"]+)['"]/g;
function norm(path: string): string {
return path.replace(/\\/g, '/');
}
/**
* Blank out comments, keeping every line break so line numbers survive. The
* entry points document their own usage with an `// import { Cta } from
* '$blocks/cta'` example, and an allowlist that reads prose as code flags it.
*/
function stripComments(src: string): string {
const blank = (match: string): string => match.replace(/[^\n]/g, ' ');
return src
.replace(/\/\*[\s\S]*?\*\//g, blank)
.replace(/<!--[\s\S]*?-->/g, blank)
.replace(/(?<!:)\/\/[^\n]*/g, blank);
}
/**
* Hard boundary 1 (B-4): what a block is allowed to import. `$uix/sema`,
* `$packs` and cross-block imports are rejected earlier with their own rule,
* so this is the catch-all for everything the boundary never named.
*/
function isAllowedSpec(spec: string): boolean {
if (spec.startsWith('./') || spec.startsWith('../')) return true;
if (spec === 'svelte' || spec === 'svelte/elements') return true;
if (spec === '$uix' || spec.startsWith('$uix/')) return true;
if (spec === '$blocks' || spec.startsWith('$blocks/')) return true;
if (spec === '$libs/forms' || spec.startsWith('$libs/forms/')) return true;
const art = /^\$([a-z0-9-]+)(?:\/|$)/.exec(spec);
return art !== null && PUBLIC_ARTS.has(art[1]);
}
function isUnder(abs: string, root: string): boolean {
return norm(abs).startsWith(norm(root) + '/');
}
/** Resolve an import specifier to an absolute path when it targets the repo. */
function resolveSpec(fileAbs: string, spec: string): string | null {
if (spec.startsWith('./') || spec.startsWith('../')) return resolve(dirname(fileAbs), spec);
if (spec === '$blocks' || spec.startsWith('$blocks/'))
return join(BLOCKS_DIR, spec.slice('$blocks'.length + 1));
if (spec === '$packs' || spec.startsWith('$packs/'))
return join(REPO, 'src', 'packs', spec.slice('$packs'.length + 1));
if (spec.startsWith('$uix/')) return join(REPO, 'src', 'uix', spec.slice('$uix/'.length));
if (spec.startsWith('@/')) return join(REPO, 'src', spec.slice(2));
return null;
}
/** The block folder a path belongs to, or null. */
function blockOf(abs: string): string | null {
if (!isUnder(abs, BLOCKS_DIR)) return null;
return norm(relative(BLOCKS_DIR, abs)).split('/')[0] ?? null;
}
/** Scan one file INSIDE the tier. Pure — safe for self-test fixtures. */
export function scanBlockFile(fileAbs: string, rel: string, src: string): Violation[] {
const out: Violation[] = [];
const ownBlock = blockOf(fileAbs);
const lines = src.split('\n');
if (fileAbs.endsWith('.svelte')) {
// B-2 reads CODE, never prose. It used to strip only single-line `<!-- … -->`
// on each line, so a MULTI-line comment explaining why a native is not used
// («`interactive` would turn the card into a `<button>`») was flagged as the
// native itself. `stripComments` already handles every comment shape and
// keeps the line breaks, which is why line numbers still point true — the
// same lesson phase 5 applied to the import allowlist, one rule late.
const codeLines = stripComments(src).split('\n');
codeLines.forEach((code, i) => {
if (RAW_NATIVE_RE.test(code)) {
out.push({
rule: 'B-2',
file: rel,
line: i + 1,
detail: `raw interactive native — compose the canon component (${code.trim().slice(0, 60)}…)`
});
}
});
if (/<style\b/.test(src) && !/justified:/.test(src)) {
out.push({
rule: 'D-BLK.2',
file: rel,
line: lines.findIndex((l) => /<style\b/.test(l)) + 1,
detail: 'scoped <style> without a `/* justified: … */` marker — layout-components-first'
});
}
}
const withoutComments = stripComments(src);
for (const match of withoutComments.matchAll(IMPORT_RE)) {
const spec = match[1];
const line = withoutComments.slice(0, match.index).split('\n').length;
const target = resolveSpec(fileAbs, spec);
if (spec.startsWith('$packs') || (target && isUnder(target, join(REPO, 'src', 'packs')))) {
out.push({
rule: 'B-4',
file: rel,
line,
detail: `a block must not import the packs tier (${spec})`
});
continue;
}
if (
spec.startsWith('$uix/sema') ||
(target && isUnder(target, join(REPO, 'src', 'uix', 'sema')))
) {
out.push({
rule: 'B-1',
file: rel,
line,
detail: `a block must not import sema — perception belongs to the canon (${spec})`
});
continue;
}
if (target) {
const targetBlock = blockOf(target);
if (targetBlock && ownBlock && targetBlock !== ownBlock && !SHELL_ALLOWLIST.has(ownBlock)) {
out.push({
rule: 'B-10',
file: rel,
line,
detail: `block "${ownBlock}" imports block "${targetBlock}" — share via canon or duplicate consciously`
});
continue;
}
}
if (!isAllowedSpec(spec)) {
out.push({
rule: 'B-4',
file: rel,
line,
detail: `import outside hard boundary 1 (${spec}) — a block imports $uix, the public arts, $libs/forms and svelte; anything else is promoted or the app's`
});
}
}
return out;
}
/** Scan a block README against B-9's template and B-8's landmark duty. Pure. */
export function scanReadme(rel: string, src: string): Violation[] {
const out: Violation[] = [];
for (const section of README_SECTIONS) {
if (!src.split('\n').some((line) => line.trim() === section)) {
out.push({
rule: 'B-9',
file: rel,
line: 1,
detail: `README without the "${section}" section of the B-9 template`
});
}
}
if (!/^\*\*Landmark\b/m.test(src)) {
out.push({
rule: 'B-8',
file: rel,
line: 1,
detail:
'README without a `**Landmark + headings**` declaration — which sectioning element and which heading levels the block emits, and how the app adjusts them'
});
}
return out;
}
/** slug → shipped, read from the demo catalog. Pure. */
export function parseCatalog(src: string): Map<string, boolean> {
const out = new Map<string, boolean>();
for (const match of src.matchAll(
/\{\s*slug:\s*'([^']+)'([^}]*?)shipped:\s*(true|false)([^}]*)\}/g
)) {
// `kind: 'page'` is a COMPOSED page, not a block: it has a route and no
// `src/uix/blocks/{slug}`, so it is not this cross-check's business in
// either direction. Everything without the marker is a block.
if (/kind:\s*'page'/.test(match[2] + match[4])) continue;
out.set(match[1], match[3] === 'true');
}
return out;
}
/**
* Cross-check the live tree against the demo catalog, BOTH ways: a block the
* catalog does not ship is invisible to the rail and the gallery; a slug it
* ships without a block behind it is a dead link. Pure.
*/
export function scanCatalog(
blocks: string[],
catalog: Map<string, boolean>,
rel: string
): Violation[] {
const out: Violation[] = [];
const live = new Set(blocks);
for (const block of blocks) {
if (catalog.get(block) === true) continue;
out.push({
rule: 'B-9',
file: rel,
line: 1,
detail: catalog.has(block)
? `block "${block}" ships but its catalog entry says shipped: false`
: `block "${block}" has no entry in the demo catalog`
});
}
for (const [slug, shipped] of catalog) {
if (shipped && !live.has(slug)) {
out.push({
rule: 'B-9',
file: rel,
line: 1,
detail: `the catalog ships "${slug}" but there is no block at src/uix/blocks/${slug}`
});
}
}
return out;
}
/** Scan one file OUTSIDE the tier for imports INTO it. Pure. */
export function scanOutsideFile(fileAbs: string, rel: string, src: string): Violation[] {
const out: Violation[] = [];
for (const match of src.matchAll(IMPORT_RE)) {
const spec = match[1];
const target = resolveSpec(fileAbs, spec);
if (
spec === '$blocks' ||
spec.startsWith('$blocks/') ||
(target && (target === BLOCKS_DIR || isUnder(target, BLOCKS_DIR)))
) {
out.push({
rule: 'B-4',
file: rel,
line: src.slice(0, match.index).split('\n').length,
detail: `the canon must not import the blocks tier (${spec})`
});
}
}
return out;
}
function walk(dir: string, exts: RegExp, out: string[] = []): string[] {
for (const entry of readdirSync(dir)) {
const path = join(dir, entry);
if (statSync(path).isDirectory()) walk(path, exts, out);
else if (exts.test(entry)) out.push(path);
}
return out;
}
/** Assert the detectors against inline fixtures — the permanent negative test. */
function selfTest(): string[] {
const failures: string[] = [];
const inHero = join(BLOCKS_DIR, 'hero', 'hero.svelte');
const inShell = join(BLOCKS_DIR, 'app-shell', 'app-shell.svelte');
const expectRules = (name: string, got: Violation[], want: string[]) => {
const gotRules = got.map((v) => v.rule).sort();
if (JSON.stringify(gotRules) !== JSON.stringify([...want].sort()))
failures.push(`${name}: expected [${want}], got [${gotRules}]`);
};
expectRules(
'raw <button> flagged',
scanBlockFile(inHero, 'fixture', '<button onclick={x}>go</button>'),
['B-2']
);
expectRules(
'canon <Button> + <aside> clean',
scanBlockFile(inHero, 'fixture', '<aside>\n<Button onclick={x}>go</Button>\n</aside>'),
[]
);
expectRules(
'packs import flagged',
scanBlockFile(inHero, 'fixture', "import { mesh } from '$packs/ambient';"),
['B-4']
);
expectRules(
'sema import flagged',
scanBlockFile(inHero, 'fixture', "import { pack } from '$uix/sema/components/dialog';"),
['B-1']
);
expectRules(
'unjustified <style> flagged',
scanBlockFile(inHero, 'fixture', '<div />\n<style>.x{color:red}</style>'),
['D-BLK.2']
);
expectRules(
'justified <style> clean',
scanBlockFile(inHero, 'fixture', '<div />\n<style>/* justified: demo glue */ .x{}</style>'),
[]
);
expectRules(
'cross-block import flagged',
scanBlockFile(inHero, 'fixture', "import Pricing from '$blocks/pricing';"),
['B-10']
);
expectRules(
'shell composing a block allowed',
scanBlockFile(inShell, 'fixture', "import Hero from '../hero/index.ts';"),
[]
);
expectRules(
'canon importing $blocks flagged',
scanOutsideFile(
join(REPO, 'src', 'uix', 'eidos', 'x.ts'),
'fixture',
"import H from '$blocks/hero';"
),
['B-4']
);
expectRules(
'canon relative import into blocks flagged',
scanOutsideFile(
join(REPO, 'src', 'uix', 'eidos', 'x.ts'),
'fixture',
"import H from '../blocks/hero/index.ts';"
),
['B-4']
);
expectRules(
'import outside the boundary flagged',
scanBlockFile(inHero, 'fixture', "import { z } from 'zod';"),
['B-4']
);
expectRules(
'a lib that is not $libs/forms flagged',
scanBlockFile(inHero, 'fixture', "import { toDate } from '$libs/days';"),
['B-4']
);
expectRules(
'the sanctioned imports clean',
scanBlockFile(
inHero,
'fixture',
"import { Stack } from '$uix/eidos/components/stack';\n" +
"import { createForm } from '$libs/forms';\n" +
"import { getWindow } from '$adom';\n" +
"import { object } from '$sium/core';\n" +
"import type { Snippet } from 'svelte';\n" +
"import type { HTMLAttributes } from 'svelte/elements';\n" +
"import type { HeroProps } from './types';"
),
[]
);
expectRules(
'an example import inside a comment is prose, not code',
scanBlockFile(inHero, 'fixture', "// import { Pricing } from '$blocks/pricing';"),
[]
);
expectRules(
'a native named inside a MULTI-line comment is prose, not code',
scanBlockFile(
inHero,
'fixture',
'<!--\n\tinteractive would turn the card into a <button>, and the action already holds one.\n-->\n<Button>go</Button>'
),
[]
);
expectRules(
'a real native BELOW a multi-line comment is still flagged',
scanBlockFile(
inHero,
'fixture',
'<!--\n\ta note about <button> semantics\n-->\n<button onclick={x}>go</button>'
),
['B-2']
);
expectRules(
'README without the landmark declaration flagged',
scanReadme(
'fixture',
'# Hero\n\n## Function\n\n## Composition map\n\n## Decisions\n\n## Equivalencias\n\n## Gaps\n'
),
['B-8']
);
expectRules(
'README without the equivalence matrix flagged',
scanReadme(
'fixture',
'# Hero\n\n## Function\n\n## Composition map\n\n**Landmark + headings**: a section.\n\n## Decisions\n\n## Gaps\n'
),
['B-9']
);
expectRules(
'README missing a template section flagged',
scanReadme(
'fixture',
'# Hero\n\n## Function\n\n**Landmark + headings**: a `<section>`.\n\n## Gaps\n'
),
['B-9', 'B-9', 'B-9']
);
expectRules(
'a complete README clean',
scanReadme(
'fixture',
'# Hero\n\n## Function\n\n## Composition map\n\n**Landmark + headings**: a `<section>`.\n\n## Decisions\n\n## Equivalencias\n\n## Gaps\n'
),
[]
);
const catalog = parseCatalog(
"[{ slug: 'hero', label: 'Hero', shipped: true }, { slug: 'kanban', label: 'Kanban', shipped: false }]"
);
if (catalog.get('hero') !== true || catalog.get('kanban') !== false || catalog.size !== 2)
failures.push(`catalog parse: expected hero=true kanban=false, got ${[...catalog]}`);
if (parseCatalog("[{ label: 'Hero', shipped: true }]").size !== 0)
failures.push('catalog parse: an entry without a slug must not become a row');
// A composed page has a route and NO block, so it must not reach the
// cross-check in either direction — and a block must still parse when other
// fields follow `shipped`, which is the shape the marker introduced.
if (parseCatalog("[{ slug: 'landing', label: 'L', shipped: true, kind: 'page' }]").size !== 0)
failures.push('catalog parse: a kind: page entry must not become a block row');
if (
parseCatalog("[{ slug: 'hero', label: 'H', shipped: true, kind: 'block' }]").get('hero') !==
true
)
failures.push('catalog parse: an explicit kind: block entry must still parse');
expectRules(
'block without a catalog entry flagged',
scanCatalog(['hero'], new Map(), 'fixture'),
['B-9']
);
expectRules(
'block whose entry says shipped: false flagged',
scanCatalog(['hero'], new Map([['hero', false]]), 'fixture'),
['B-9']
);
expectRules(
'catalog shipping a block that does not exist flagged',
scanCatalog([], new Map([['ghost', true]]), 'fixture'),
['B-9']
);
expectRules(
'tree and catalog in agreement clean',
scanCatalog(
['hero'],
new Map([
['hero', true],
['kanban', false]
]),
'fixture'
),
[]
);
return failures;
}
function main(): void {
const selfFailures = selfTest();
if (selfFailures.length > 0) {
for (const f of selfFailures) console.error(`SELF-TEST FAIL — ${f}`);
console.error(`\nblocks-check: the guard's own detectors are broken (${selfFailures.length})`);
process.exit(1);
}
const violations: Violation[] = [];
let blockFiles: string[] = [];
let blockDirs: string[] = [];
if (existsSync(BLOCKS_DIR)) {
blockFiles = walk(BLOCKS_DIR, /\.(ts|svelte|css)$/);
blockDirs = readdirSync(BLOCKS_DIR).filter((e) => statSync(join(BLOCKS_DIR, e)).isDirectory());
}
for (const file of blockFiles) {
const rel = relative(REPO, file);
if (file.endsWith('.css')) {
violations.push({
rule: 'D-BLK.2',
file: rel,
line: 1,
detail: 'a block ships no .css file — layout-components-first'
});
continue;
}
if (/\.test\./.test(file)) continue;
violations.push(...scanBlockFile(file, rel, readFileSync(file, 'utf8')));
}
const catalog = existsSync(CATALOG_FILE)
? parseCatalog(readFileSync(CATALOG_FILE, 'utf8'))
: null;
if (catalog === null) {
violations.push({
rule: 'B-9',
file: relative(REPO, CATALOG_FILE),
line: 1,
detail: 'the demo catalog is missing — the rail and the gallery read it as their single list'
});
}
const shippedBlocks = new Set<string>();
for (const dir of blockDirs) {
const hasCode = walk(join(BLOCKS_DIR, dir), /\.(ts|svelte)$/).length > 0;
if (!hasCode) continue;
shippedBlocks.add(dir);
const readme = join(BLOCKS_DIR, dir, 'README.md');
if (!existsSync(readme)) {
violations.push({
rule: 'B-9',
file: `src/uix/blocks/${dir}`,
line: 1,
detail: 'block folder without README.md (Function · Composition map · Decisions · Gaps)'
});
} else {
violations.push(...scanReadme(relative(REPO, readme), readFileSync(readme, 'utf8')));
}
if (!existsSync(join(DEMO_DIR, dir, '+page.svelte'))) {
violations.push({
rule: 'B-9',
file: `src/uix/blocks/${dir}`,
line: 1,
detail: `block without a demo route (web/routes/blocks/${dir}/+page.svelte)`
});
}
}
if (catalog !== null) {
violations.push(...scanCatalog([...shippedBlocks], catalog, relative(REPO, CATALOG_FILE)));
}
let outsideCount = 0;
for (const root of OUTSIDE_ROOTS) {
if (!existsSync(root)) continue;
for (const file of walk(root, /\.(ts|svelte)$/)) {
outsideCount++;
violations.push(...scanOutsideFile(file, relative(REPO, file), readFileSync(file, 'utf8')));
}
}
if (violations.length > 0) {
for (const v of violations) {
console.error(`ERROR [${v.rule}] ${v.file}:${v.line} — ${v.detail}`);
}
console.error(
`\nblocks-check: ${violations.length} error(s) across ${blockDirs.length} block(s)`
);
process.exit(1);
}
console.log(
`blocks-check: self-test OK · 0 error(s) across ${blockDirs.length} block(s) (${blockFiles.length} files; ${outsideCount} canon/arts/libs/packs files scanned for direction)`
);
}
main();

Powered by TurnKey Linux.