146 lines
4.8 KiB
TypeScript
146 lines
4.8 KiB
TypeScript
import { mkdir, readFile, readdir, writeFile } from "node:fs/promises";
|
|
import path from "node:path";
|
|
|
|
import { REQUIRED_COMPONENT_TOKENS, REQUIRED_PRIMITIVE_TOKENS, REQUIRED_SEMANTIC_TOKENS } from "../src/presentation/design-system/tokens/token-contract.ts";
|
|
|
|
const fixtureMode = process.argv.includes("--fixture");
|
|
const failures: string[] = [];
|
|
const tokenFiles = {
|
|
primitive: "src/presentation/design-system/tokens/primitive.css",
|
|
semantic: "src/presentation/design-system/tokens/semantic.css",
|
|
component: "src/presentation/design-system/tokens/component.css",
|
|
};
|
|
const tokenLayers: Array<readonly [string, string, readonly string[]]> = [
|
|
["primitive", tokenFiles.primitive, REQUIRED_PRIMITIVE_TOKENS],
|
|
["semantic", tokenFiles.semantic, REQUIRED_SEMANTIC_TOKENS],
|
|
["component", tokenFiles.component, REQUIRED_COMPONENT_TOKENS],
|
|
];
|
|
|
|
for (const [layer, file, tokens] of tokenLayers) {
|
|
const source = await readFile(file, "utf8");
|
|
for (const token of tokens) {
|
|
if (!source.includes(`${token}:`)) {
|
|
failures.push(`${layer} token is missing: ${token}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
const cssSources = await Promise.all(
|
|
[...Object.values(tokenFiles), "src/presentation/styles/theme.css"].map(
|
|
async (file) => ({ file, source: await readFile(file, "utf8") }),
|
|
),
|
|
);
|
|
const definitions = new Set(
|
|
cssSources.flatMap(({ source }) =>
|
|
[...source.matchAll(/(--[a-z0-9-]+)\s*:/g)].map((match) => match[1]),
|
|
),
|
|
);
|
|
for (const { file, source } of cssSources) {
|
|
for (const match of source.matchAll(/var\((--[a-z0-9-]+)/g)) {
|
|
if (!definitions.has(match[1])) {
|
|
failures.push(`${file} uses undefined token ${match[1]}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
const semanticSource = await readFile(tokenFiles.semantic, "utf8");
|
|
const darkSource =
|
|
semanticSource.match(/:root\[data-theme="dark"\]\s*\{([\s\S]*?)\n\}/)?.[1] ??
|
|
"";
|
|
for (const token of [
|
|
"--color-surface",
|
|
"--color-surface-muted",
|
|
"--color-surface-elevated",
|
|
"--color-content",
|
|
"--color-content-muted",
|
|
"--color-content-inverse",
|
|
"--color-border",
|
|
"--color-border-strong",
|
|
"--color-action",
|
|
"--color-action-hover",
|
|
"--color-action-pressed",
|
|
"--color-danger",
|
|
"--color-focus",
|
|
"--color-disabled-content",
|
|
"--color-disabled-surface",
|
|
]) {
|
|
if (!darkSource.includes(`${token}:`)) {
|
|
failures.push(`dark theme token is missing: ${token}`);
|
|
}
|
|
}
|
|
const componentSource = await readFile(tokenFiles.component, "utf8");
|
|
if (!componentSource.includes("@media (forced-colors: active)")) {
|
|
failures.push("forced-colors token fallback is missing");
|
|
}
|
|
|
|
async function listSourceFiles(directory: string): Promise<string[]> {
|
|
const result: string[] = [];
|
|
for (const entry of await readdir(directory, { withFileTypes: true })) {
|
|
const target = path.join(directory, entry.name);
|
|
if (entry.isDirectory()) result.push(...(await listSourceFiles(target)));
|
|
else if (/\.(?:ts|tsx|mts|cts)$/.test(entry.name)) result.push(target);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
const sources = fixtureMode
|
|
? await listSourceFiles("tests/fixtures/design-system/forbidden")
|
|
: await listSourceFiles("src");
|
|
for (const file of sources) {
|
|
const source = await readFile(file, "utf8");
|
|
const vendorFacade =
|
|
file === "src/presentation/design-system/icons/vendors/lucide.tsx";
|
|
if (!vendorFacade && /from\s+["']lucide-react["']/.test(source)) {
|
|
failures.push(`direct icon vendor import in ${file}`);
|
|
}
|
|
if (
|
|
/from\s+["'](?:react-aria-components|@radix-ui\/[^"']+)["']/.test(source)
|
|
) {
|
|
failures.push(`direct headless vendor import in ${file}`);
|
|
}
|
|
if (
|
|
!file.includes("src/presentation/design-system/") &&
|
|
/presentation\/design-system\/(?!index(?:\.tsx?)?["'])/.test(source)
|
|
) {
|
|
failures.push(`design-system deep import in ${file}`);
|
|
}
|
|
if (
|
|
!file.includes("src/presentation/design-system/tokens/") &&
|
|
/(?:#[0-9a-f]{3,8}\b|oklch\(|rgba?\()/i.test(source)
|
|
) {
|
|
failures.push(`raw palette value in ${file}`);
|
|
}
|
|
if (
|
|
file.includes("tests/fixtures/design-system/forbidden") &&
|
|
source.includes("TOOLTIP_ONLY_REQUIRED_INFORMATION")
|
|
) {
|
|
failures.push(`tooltip-only required information in ${file}`);
|
|
}
|
|
}
|
|
|
|
const report = {
|
|
schemaVersion: 1,
|
|
mode: fixtureMode ? "negative-fixture" : "source",
|
|
checkedTokenCount:
|
|
REQUIRED_PRIMITIVE_TOKENS.length +
|
|
REQUIRED_SEMANTIC_TOKENS.length +
|
|
REQUIRED_COMPONENT_TOKENS.length,
|
|
failures,
|
|
passed: failures.length === 0,
|
|
};
|
|
await mkdir("artifacts/quality", { recursive: true });
|
|
await writeFile(
|
|
fixtureMode
|
|
? "artifacts/quality/design-system-fixture.json"
|
|
: "artifacts/quality/design-system.json",
|
|
`${JSON.stringify(report, null, 2)}\n`,
|
|
);
|
|
|
|
if (failures.length > 0) {
|
|
process.stderr.write(`Design system contract failed:\n${failures.join("\n")}\n`);
|
|
process.exit(1);
|
|
}
|
|
process.stdout.write(
|
|
`Design system contract: ${report.checkedTokenCount} tokens and vendor boundaries PASS\n`,
|
|
);
|