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

323 lines
10 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-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`.
*
* 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');
// B-10 declared exception: the shells compose F1 pieces and blocks by design.
const SHELL_ALLOWLIST = new Set(['app-shell', 'docs-shell']);
// 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, '/');
}
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')) {
lines.forEach((line, i) => {
const code = line.replace(/<!--.*?-->/g, '');
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'
});
}
}
for (const match of src.matchAll(IMPORT_RE)) {
const spec = match[1];
const line = src.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`
});
}
}
}
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']
);
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')));
}
for (const dir of blockDirs) {
const hasCode = walk(join(BLOCKS_DIR, dir), /\.(ts|svelte)$/).length > 0;
if (!hasCode) continue;
if (!existsSync(join(BLOCKS_DIR, dir, 'README.md'))) {
violations.push({
rule: 'B-9',
file: `src/uix/blocks/${dir}`,
line: 1,
detail: 'block folder without README.md (Function · Composition map · Decisions · Gaps)'
});
}
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)`
});
}
}
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.