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.
634 lines
21 KiB
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();
|