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.
323 lines
10 KiB
323 lines
10 KiB
|
3 months ago
|
/**
|
||
|
|
* 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();
|