open-design/scripts/guard.ts
chaoxiaoche a75d9938c7
feat(design-systems): add structured tokens.css schema (default + kami) (#1231)
* feat(design-systems): add structured tokens.css schema (default + kami)

Compile each brand's DESIGN.md prose into a machine-readable :root
block agents paste verbatim, removing the "Primary → --accent"
translation step where most token misuse happens. Daemon prompt
injection lands in a follow-up; lint-artifact already enforces the
shared token vocabulary so no rule changes needed.

Schema validated across two contrasting aesthetics:
- default (sans-serif, cobalt, B2B utility) — stress test the
  shallow form, 2-level fg / 2-level surface
- kami (serif, parchment, ink-blue, print-first) — stress test the
  rich form, 4-level fg ramp, 3-level surface, ring elevation, i18n
  font stacks, and solid-hex tag tints (print renderers double-paint
  alpha)

Schema growth from kami's stress test (5 new optional slots, all
backward-compatible — default aliases via var() to existing tokens):
- --fg-2 / --meta (4-level fg ramp)
- --surface-warm (3-level surface)
- --border-soft (2-level border)
- --elev-ring (ring elevation as first-class level)

Brand-specific extensions live in tokens.css with explicit "NOT in
shared schema" labels and a documented promotion path (≥2 brands
need it → promote to schema slot).

components.html in each brand is a self-contained reference fixture
that exercises every token through real layouts. Both fixtures lint
clean against apps/daemon/src/lint-artifact.ts.

Co-authored-by: Cursor <cursoragent@cursor.com>

* feat(design-systems): add token-fixture drift guard

Each design system in design-systems/<brand>/ ships two files agents
consume in tandem: tokens.css (canonical token bindings) and
components.html (a self-contained fixture whose first <style> embeds
the same :root paste so the file renders standalone). The fixture's
:root block is a copy of tokens.css's :root block, kept in sync only
by an inline comment.

This adds scripts/check-tokens-fixture-sync.ts and registers it in
pnpm guard. The check pairs each brand's tokens.css with its
components.html and asserts the unscoped :root block is byte-equivalent
after canonical normalization (CSS comments stripped, whitespace
collapsed, separator spacing normalized). Brands missing one half of
the pair, or with no :root rule in either file, fail the guard.

Scoped overrides like :root[lang="zh-CN"] are not required to appear
in the fixture (per the kami fixture's inline comment they are pasted
only when an artifact's <html lang> matches), so the check only
compares the unscoped :root block.

Verified: pnpm guard passes for default + kami, fails on intentional
value drift, fails on missing token, tolerates whitespace-only
formatting differences.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(design-systems): point fixture CTAs to real files

Both default and kami components.html advertised in-page anchors
(#tokens, #spec, #surface, #accent, #type, #components) but defined
no matching ids, so every CTA was a no-op when the fixture was
opened locally — flagged by mrcfps in #1231.

Re-point each link to a real artifact in the same brand directory:

- "View tokens" / "Inspect tokens" / "Inspect typography" → ./tokens.css
- "Read the spec" / "Read the rule" → ./DESIGN.md

Browsers render these as raw source views, which is the desired UX
for a reference fixture: clicking the CTA shows the underlying
contract instead of jumping to nothing. Agents copying the fixture
also learn the pattern of "buttons link to actual sibling resources".

The :root token block is unchanged, so the token-fixture drift guard
still passes for both brands.

Co-authored-by: Cursor <cursoragent@cursor.com>

* feat(design-systems): codify token schema (A1/A2/B/C layers)

The two-brand pilot (default + kami) settled the shape of the shared
token schema; this commit codifies it as a machine-readable contract
and enforces it in pnpm guard, addressing lefarcen's review on #1231:

> the optional-vs-required split won't generalize cleanly when brand
> #3 needs different Layer A tokens or when multiple brands converge
> on the same extension (promoting C→B→A). Consider surfacing that
> limitation in the PR narrative or in a future SCHEMA.md.

Schema lives under design-systems/_schema/ as three files:

- tokens.schema.ts   — TypeScript declaration of every shared token
                       with its layer (A1-identity / A1-structure /
                       A2 / B-slot), plus per-brand C-extension
                       allowlists and a global C-prefix allowlist
- defaults.css       — CSS mirror of A2 fallback values, used as the
                       human-readable contract reviewer's-eye copy
                       and the future input to the derive script
- AGENTS.md          — schema layer model, C → B-slot → A2 promotion
                       rules, when-not-to-add-a-token guidance

Layer model:

  A1-identity    8 tokens — bg/surface/fg/muted/border/accent +
                 font-display/font-body. The brand IS these values;
                 no fallback is defensible.

  A1-structure  18 tokens — type scale (8), leading (2), tracking
                 (1), section-y (3), container (4). Structural
                 decisions vary per brand by design and have no
                 cross-brand default.

  A2            26 tokens — accent states, semantic colors, motion,
                 base spacing scale, radius, elevation, focus,
                 font-mono. Required in every tokens.css; fallback
                 lives in defaults.css for the future derive script
                 to inline when DESIGN.md does not specify the value.

  B-slot         4 tokens — fg-2 / meta / surface-warm / border-soft.
                 Brand may bind independently or alias the named
                 sibling via var(...) for components that target the
                 richer ramp.

  C-extension    n tokens — brand-specific names (kami's tag-bg-*,
                 leading-display, accent-light, etc.). Allowlisted
                 per-brand in BRAND_EXTENSIONS or globally by prefix
                 in BRAND_EXTENSION_PREFIXES. Promote when a second
                 brand adopts the same name.

Why A2 fails the guard today:
  Artifacts are generated by agents pasting one brand's :root block
  into a single <style>; there is no global stylesheet that supplies
  fallbacks at runtime. A tokens.css missing an A2 declaration would
  silently break any var() reference in the fixture. Until the
  derive script (PR-B) lands and inlines defaults, every brand's
  tokens.css must declare every A2 token directly. The guard
  enforces this strictly.

Why --font-mono lands in A2 (not A1):
  149 brands' DESIGN.md files were surveyed: 87 (58%) declare a
  monospace stack, 62 (42%) do not — including major brands like
  bmw / nike / apple / notion / mastercard / meta. Agent paste
  cannot rely on the brand author having written it down; a
  defaultable A2 fallback (with CJK brands like kami overriding) is
  safer than forcing every brand author to add a field they may not
  realize their kbd / code-block components need.

Five guard checks, each registered as its own entry in scripts/guard.ts
so failures attribute to a specific contract:

  1. token-fixture sync       — components.html :root ↔ tokens.css :root
                                 byte-equivalent (existing)
  2. A1 required tokens       — every brand declares every A1 token
  3. A2 required tokens       — every brand declares every A2 token
  4. unknown token allowlist  — every declared token is in schema or
                                 brand-extension allowlist
  5. A2 defaults parity       — defaults.css ↔ tokens.schema.ts
                                 fallback byte-equivalent

Verified on default + kami:
  - 26 A1 tokens declared in both brands
  - 26 A2 tokens declared in both brands
  - 129 total declarations, all match shared schema or brand extensions
  - defaults.css ↔ tokens.schema.ts parity holds
  - sanity test: drifting --motion-fast in defaults.css fails check 5
    with a clear divergence message

The PR description originally listed "Dedicated SCHEMA.md" as
explicitly NOT in this PR ("Once 3+ brands ship, extracting a single
source of truth becomes worthwhile"). That boundary moves: lefarcen's
review surfaced the schema-generalization risk, and the schema must
exist as a machine-enforced contract before the derive script can
read it. The TS file replaces the markdown that was deferred.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(web/tests): pass missing designTemplates prop to ProjectView

Pre-existing typecheck regression on main: PR #955 (b5eb8c16,
"generic skills + split skills/design-templates + finalize-design
API") added required `designTemplates: SkillSummary[]` to ProjectView
Props but updated only two of the three test fixtures that render
ProjectView directly. The third — ProjectView.api-empty-response.test.tsx
— was missed, so `pnpm typecheck` (and CI on any PR merging into
main) fails on:

  apps/web/tests/components/ProjectView.api-empty-response.test.tsx
    (168,6): error TS2741: Property 'designTemplates' is missing in
    type ...

The other two ProjectView tests already pass `designTemplates={[]}`,
so this aligns this fixture with the existing pattern. Out of scope
for #1231 strictly, but the regression blocks the merged-state
typecheck CI runs that #1231 triggers, and the one-line fix here
restores main's typecheck health for everyone.

Co-authored-by: Cursor <cursoragent@cursor.com>

* feat(design-systems): enforce B-slot required tokens in pnpm guard

Closes mrcfps + lefarcen review comment thread on #1231:

> The guard validates A2 required tokens here, but there's no
> sibling check for B-slot aliases (--fg-2, --meta, --surface-warm,
> --border-soft). Per the schema docs, every brand must declare
> A1 + A2 + B-slot names so shared components can safely read
> var(--fg-2) etc. Without a B-slot guard, a brand can omit those
> aliases, pass pnpm guard, and break any artifact that references
> them.

Same artifact-paste constraint as A2: agents render artifacts by
pasting one brand's :root block into a single <style>; there is no
runtime cascade, so a missing B-slot makes any var(--fg-2) reference
resolve to nothing. Until now the schema narrative claimed B-slots
were optional with a var() default, but no machine check enforced
declaration — a contract gap reviewers reasonably refused to merge.

This commit closes the gap in three places so machine and narrative
agree:

1. scripts/check-tokens-fixture-sync.ts
   - Add checkDesignSystemBSlotRequiredTokens, mirroring the A2
     check but using getBSlotNames() from the schema.
   - Failure message names each missing slot AND the schema-suggested
     alias (--fg-2 (default alias: var(--fg))) so a brand author
     fixing the failure has a copy-pasteable resolution.
   - Renumber section comments: 5 checks → 6 checks.

2. scripts/guard.ts
   - Register the new check between A2 required and unknown
     allowlist so failures attribute to a specific contract.

3. design-systems/_schema/AGENTS.md
   - Update the layer table: B-slot row's "If omitted" column
     changes from "resolves via var() to a richer sibling" to
     "guard fails — brand must declare, either as var(--sibling)
     (collapsed) or independent value (richer)".
   - Add a "Why B-slot is required (and what the alias is for)"
     section that distinguishes the schema-suggested alias from a
     runtime fallback, with worked examples for default (alias) and
     kami (independent bind).

Verified on default + kami:
- pnpm guard passes all 6 design-system checks
- 4 B-slot tokens declared in both brands (default aliases via var(),
  kami binds independently — both forms satisfy the contract)
- pnpm typecheck clean across the workspace
- Sanity test: removing --fg-2 + --meta from default/tokens.css fires
  the new guard with a precise per-token alias hint:
    [default] design-systems/default/tokens.css is missing 2 B-slot
    tokens (alias the named sibling via var(...) or bind
    independently):
      --fg-2 (default alias: var(--fg)),
      --meta (default alias: var(--muted))

The schema contract is now machine-enforced end-to-end (A1 + A2 +
B-slot all required-with-fixed-form-of-fallback). The derive script
in PR-B can rely on every brand's tokens.css containing every shared
slot name.

Co-authored-by: Cursor <cursoragent@cursor.com>

* test(e2e): skip leading-underscore meta-directories under design-systems/

CI for #1231 went red on `Validate workspace` after merging origin/main.
Cause is a clean collision between two recently-landed changes:

- main #1270 (be77dc03 "Default English resource i18n fallback")
  tightened tests/localized-content.test.ts so every directory under
  design-systems/ is run through assertResourceId() with the strict
  RESOURCE_ID_PATTERN /^[a-z0-9][a-z0-9-]*$/.

- this branch #1231 introduced design-systems/_schema/ as the home
  of the shared token contract (tokens.schema.ts, defaults.css,
  AGENTS.md). The leading underscore signals "meta-directory, not
  brand" — the same convention SCSS partials, Jekyll, Hugo all use.

The two changes never met until CI built the merge commit, where
assertResourceId('_schema') deterministically failed:

  Error: Design system directory _schema has malformed resource id: _schema
    at invariant tests/localized-content.test.ts:66:11
    at assertResourceId tests/localized-content.test.ts:71:3
    at readDesignSystemResources tests/localized-content.test.ts:202:8

Fix tightens readDesignSystemResources's directory filter so the
leading-underscore convention is recognised explicitly:

    .filter((entry) => entry.isDirectory() && !entry.name.startsWith('_'))

This aligns with what apps/daemon/src/design-systems.ts:listDesignSystems
already does implicitly — it requires DESIGN.md per directory, so
_schema/ was always invisible at runtime; the test was the only place
that surfaced it.

Verified locally on the post-merge tree:
- pnpm test (e2e vitest) — tests/localized-content.test.ts: 4 passed
- pnpm guard — all 6 design-system checks pass on default + kami
- pnpm typecheck — clean across the workspace (after pnpm install
  to pull deps for tools/pr that arrived with main)

The fix is intentionally narrow (one filter line in one test) and
documents the convention inline so future meta-directories under
design-systems/ (e.g. _archive/, _drafts/) are covered for free.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: chaoxiaoche <chaoxiaoche@192.168.10.16>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-11 22:23:34 +08:00

449 lines
15 KiB
TypeScript

import { readFile, readdir } from "node:fs/promises";
import path from "node:path";
import {
checkDesignSystemA1RequiredTokens,
checkDesignSystemA2DefaultsParity,
checkDesignSystemA2RequiredTokens,
checkDesignSystemBSlotRequiredTokens,
checkDesignSystemTokenFixtureSync,
checkDesignSystemUnknownTokens,
} from "./check-tokens-fixture-sync.ts";
const repoRoot = path.resolve(import.meta.dirname, "..");
const allowedE2eScripts = new Set([
"e2e/scripts/playwright.ts",
"e2e/scripts/release-smoke.ts",
]);
type GuardCheck = {
name: string;
run: () => Promise<boolean>;
};
function toRepositoryPath(filePath: string): string {
return path.relative(repoRoot, filePath).split(path.sep).join("/");
}
const residualExtensions = new Set([".js", ".mjs", ".cjs"]);
const residualSkippedDirectories = new Set([
".agents",
".astro",
".claude",
".claude-sessions",
".codex",
".cursor",
".git",
".od",
".od-e2e",
".opencode",
".task",
".tmp",
".vite",
"dist",
"node_modules",
"out",
]);
const residualAllowedExactPaths = new Set([
// esbuild config entrypoints are executed directly by Node before package
// dist output exists.
"packages/contracts/esbuild.config.mjs",
"packages/platform/esbuild.config.mjs",
"packages/sidecar/esbuild.config.mjs",
"packages/sidecar-proto/esbuild.config.mjs",
// Maintainer utility scripts ported from the media branch. They are
// executed directly by Node and are not loaded by the app runtime.
"scripts/import-prompt-templates.mjs",
"scripts/postinstall.mjs",
"apps/packaged/esbuild.config.mjs",
// Browser service workers must be served as JavaScript files.
"apps/web/public/od-notifications-sw.js",
"scripts/bake-html-ppt-examples.mjs",
"scripts/scaffold-html-ppt-skills.mjs",
"scripts/sync-hyperframes-skill.mjs",
"scripts/verify-media-models.mjs",
"tools/dev/bin/tools-dev.mjs",
"tools/dev/esbuild.config.mjs",
"tools/pack/bin/tools-pack.mjs",
"tools/pack/esbuild.config.mjs",
"tools/pr/bin/tools-pr.mjs",
"tools/pr/esbuild.config.mjs",
"tools/pack/resources/mac/notarize.cjs",
// electron-builder hook path; CJS compatibility entry used by tools-pack desktop builds.
"tools/pack/resources/web-standalone-after-pack.cjs",
]);
const residualAllowedPathPrefixes = [
"apps/daemon/dist/",
"apps/web/.next/",
"apps/web/out/",
"generated/",
"e2e/playwright-report/",
"e2e/reports/html/",
"e2e/reports/playwright-html-report/",
"e2e/reports/test-results/",
"e2e/ui/.od-data/",
"e2e/ui/reports/playwright-html-report/",
"e2e/ui/reports/test-results/",
"e2e/ui/test-results/",
// Vendored upstream HyperFrames helper scripts (design template).
"design-templates/hyperframes/scripts/",
// Vendored upstream Last30Days runtime helper used by the engine (design template).
"design-templates/last30days/scripts/lib/vendor/",
// Vendored upstream html-ppt runtime assets (lewislulu/html-ppt-skill, design template).
"design-templates/html-ppt/assets/",
"test-results/",
"vendor/",
];
const residualAllowedPathPatterns: RegExp[] = [
// Vendored upstream Zara template runtimes — one design template per template,
// name prefix `html-ppt-zhangzara-` (zarazhangrui/beautiful-html-templates).
// Only the vendored deck-stage runtime asset is allowlisted; any other
// JavaScript under these design-template directories must still be converted
// to TypeScript or explicitly listed in `residualAllowedExactPaths`.
/^design-templates\/html-ppt-zhangzara-[^/]+\/assets\/deck-stage\.js$/,
];
function isResidualAllowedPath(repositoryPath: string): boolean {
if (residualAllowedExactPaths.has(repositoryPath)) return true;
if (residualAllowedPathPrefixes.some((prefix) => repositoryPath.startsWith(prefix))) return true;
return residualAllowedPathPatterns.some((pattern) => pattern.test(repositoryPath));
}
function isResidualSkippedDirectoryName(directoryName: string): boolean {
return (
residualSkippedDirectories.has(directoryName) || directoryName === ".next" || directoryName.startsWith(".next-")
);
}
async function collectResidualJavaScript(directory: string): Promise<string[]> {
const entries = await readdir(directory, { withFileTypes: true });
const residualFiles: string[] = [];
for (const entry of entries) {
const fullPath = path.join(directory, entry.name);
const repositoryPath = toRepositoryPath(fullPath);
if (entry.isDirectory()) {
if (isResidualSkippedDirectoryName(entry.name) || isResidualAllowedPath(`${repositoryPath}/`)) {
continue;
}
residualFiles.push(...(await collectResidualJavaScript(fullPath)));
continue;
}
if (!entry.isFile() || !residualExtensions.has(path.extname(entry.name))) {
continue;
}
if (isResidualAllowedPath(repositoryPath)) {
continue;
}
residualFiles.push(repositoryPath);
}
return residualFiles;
}
async function checkResidualJavaScript(): Promise<boolean> {
const residualFiles = await collectResidualJavaScript(repoRoot);
if (residualFiles.length > 0) {
console.error("Residual project-owned JavaScript files found:");
for (const filePath of residualFiles) {
console.error(`- ${filePath}`);
}
console.error("Convert these files to TypeScript or add a documented generated/vendor/output allowlist entry.");
return false;
}
console.log("Residual JavaScript check passed: project-owned code is TypeScript-only.");
return true;
}
const testLayoutScopedDirectories = ["apps", "packages", "tools"];
const testLayoutSkippedDirectories = new Set([".next", ".od-data", "dist", "node_modules", "out", "reports", "test-results"]);
function isTestFile(fileName: string): boolean {
return /\.test\.tsx?$/.test(fileName);
}
function expectedTestPath(repositoryPath: string): string {
const [scope, project, ...relativeParts] = repositoryPath.split("/");
if (!testLayoutScopedDirectories.includes(scope ?? "") || project == null || relativeParts.length === 0) {
return repositoryPath;
}
const normalizedRelativeParts = relativeParts[0] === "src" ? relativeParts.slice(1) : relativeParts;
return [scope, project, "tests", ...normalizedRelativeParts].join("/");
}
function isAllowedScopedTestPath(repositoryPath: string): boolean {
const [scope, project, directory] = repositoryPath.split("/");
return testLayoutScopedDirectories.includes(scope ?? "") && project != null && directory === "tests";
}
async function collectTestLayoutViolations(directory: string): Promise<string[]> {
const entries = await readdir(directory, { withFileTypes: true });
const violations: string[] = [];
for (const entry of entries) {
const fullPath = path.join(directory, entry.name);
if (entry.isDirectory()) {
if (testLayoutSkippedDirectories.has(entry.name)) {
continue;
}
violations.push(...(await collectTestLayoutViolations(fullPath)));
continue;
}
if (!entry.isFile() || !isTestFile(entry.name)) {
continue;
}
const repositoryPath = toRepositoryPath(fullPath);
if (!isAllowedScopedTestPath(repositoryPath)) {
violations.push(repositoryPath);
}
}
return violations;
}
async function checkTestLayout(): Promise<boolean> {
const violations = (
await Promise.all(
testLayoutScopedDirectories.map((directory) => collectTestLayoutViolations(path.join(repoRoot, directory))),
)
).flat();
if (violations.length > 0) {
console.error("Test files under apps/, packages/, and tools/ must live in tests/ sibling to src/:");
for (const violation of violations) {
console.error(`- ${violation} -> ${expectedTestPath(violation)}`);
}
return false;
}
console.log("Test layout check passed: apps/packages/tools tests live in sibling tests directories.");
return true;
}
const e2ePackageJsonPath = path.join(repoRoot, "e2e", "package.json");
const e2eSkippedDirectories = new Set([".od-data", "node_modules", "reports", "test-results"]);
const e2eAllowedScripts = [
"test",
"test:ui:critical",
"test:ui:extended",
"typecheck",
];
async function collectRepositoryFiles(directory: string, skippedDirectoryNames = new Set<string>()): Promise<string[]> {
const entries = await readdir(directory, { withFileTypes: true });
const files: string[] = [];
for (const entry of entries) {
const fullPath = path.join(directory, entry.name);
if (entry.isDirectory()) {
if (skippedDirectoryNames.has(entry.name)) continue;
files.push(...(await collectRepositoryFiles(fullPath, skippedDirectoryNames)));
continue;
}
if (entry.isFile()) files.push(toRepositoryPath(fullPath));
}
return files;
}
async function checkE2eLayout(): Promise<boolean> {
const violations: string[] = [];
const packageJson = JSON.parse(await readFile(e2ePackageJsonPath, "utf8")) as {
scripts?: Record<string, unknown>;
};
const scriptNames = Object.keys(packageJson.scripts ?? {}).sort();
if (scriptNames.join("\0") !== e2eAllowedScripts.join("\0")) {
violations.push(
`e2e/package.json scripts must be exactly ${e2eAllowedScripts.join(", ")} (found: ${scriptNames.join(", ")})`,
);
}
const e2eRoot = path.join(repoRoot, "e2e");
for (const repositoryPath of await collectRepositoryFiles(e2eRoot, e2eSkippedDirectories)) {
if (
repositoryPath === "e2e/package.json" ||
repositoryPath === "e2e/tsconfig.json" ||
repositoryPath === "e2e/vitest.config.ts" ||
repositoryPath === "e2e/playwright.config.ts" ||
repositoryPath === "e2e/AGENTS.md"
) {
continue;
}
if (repositoryPath.startsWith("e2e/specs/")) {
if (!/\.spec\.ts$/.test(repositoryPath)) {
violations.push(`${repositoryPath} -> e2e specs must be *.spec.ts`);
}
continue;
}
if (repositoryPath.startsWith("e2e/tests/")) {
if (!/\.test\.ts$/.test(repositoryPath)) {
violations.push(`${repositoryPath} -> e2e tests must be *.test.ts`);
}
continue;
}
if (repositoryPath.startsWith("e2e/ui/")) {
const relativePath = repositoryPath.slice("e2e/ui/".length);
if (relativePath.includes("/") || !/\.test\.ts$/.test(repositoryPath)) {
violations.push(`${repositoryPath} -> e2e UI files must be flat Playwright *.test.ts files under ui/`);
}
continue;
}
if (repositoryPath.startsWith("e2e/resources/")) {
const relativePath = repositoryPath.slice("e2e/resources/".length);
if (relativePath.includes("/") || !/\.ts$/.test(repositoryPath)) {
violations.push(`${repositoryPath} -> e2e resources must be flat TypeScript files under resources/`);
}
continue;
}
if (repositoryPath.startsWith("e2e/lib/")) {
if (!/\.ts$/.test(repositoryPath)) {
violations.push(`${repositoryPath} -> e2e lib files must be TypeScript`);
}
continue;
}
if (repositoryPath.startsWith("e2e/scripts/")) {
if (!allowedE2eScripts.has(repositoryPath)) {
violations.push(`${repositoryPath} -> e2e scripts must be an approved package-owned entrypoint`);
}
continue;
}
violations.push(`${repositoryPath} -> e2e source files must live in specs/, tests/, ui/, resources/, lib/, or approved scripts`);
}
if (violations.length > 0) {
console.error("E2E package layout violations found:");
for (const violation of violations) console.error(`- ${violation}`);
return false;
}
console.log("E2E layout check passed: Vitest, Playwright UI, resources, lib, and scripts stay in their lanes.");
return true;
}
const webTestSkippedDirectories = new Set([".od-data", "reports", "test-results"]);
async function checkWebTestLayout(): Promise<boolean> {
const violations: string[] = [];
const webTestsRoot = path.join(repoRoot, "apps", "web", "tests");
for (const repositoryPath of await collectRepositoryFiles(webTestsRoot, webTestSkippedDirectories)) {
if (repositoryPath.startsWith("apps/web/tests/vitest/") || repositoryPath.startsWith("apps/web/tests/playwright/")) {
violations.push(`${repositoryPath} -> web tests should stay lightweight under apps/web/tests/ without vitest/playwright nesting`);
continue;
}
if (/\.(spec|test)\.tsx?$/.test(repositoryPath) && !/\.test\.tsx?$/.test(repositoryPath)) {
violations.push(`${repositoryPath} -> web Vitest test files must be *.test.ts or *.test.tsx`);
}
}
if (violations.length > 0) {
console.error("Web test layout violations found:");
for (const violation of violations) console.error(`- ${violation}`);
return false;
}
console.log("Web test layout check passed: web tests stay lightweight and Vitest-only.");
return true;
}
const toolsRootAllowlist = new Map<string, "directory" | "file">([
// Keep top-level tools intentionally small. `tools/launcher` was an incoming
// Windows shim experiment from PR #683 and is not an active repo boundary.
["AGENTS.md", "file"],
["dev", "directory"],
["pack", "directory"],
["pr", "directory"],
]);
async function checkToolsLayout(): Promise<boolean> {
const toolsRoot = path.join(repoRoot, "tools");
const entries = await readdir(toolsRoot, { withFileTypes: true });
const seen = new Set<string>();
const violations: string[] = [];
for (const entry of entries) {
const expected = toolsRootAllowlist.get(entry.name);
const repositoryPath = `tools/${entry.name}${entry.isDirectory() ? "/" : ""}`;
if (expected == null) {
violations.push(`${repositoryPath} -> tools/ top-level entries are allowlisted; expected only AGENTS.md, dev/, pack/, and pr/`);
continue;
}
seen.add(entry.name);
if (expected === "directory" && !entry.isDirectory()) {
violations.push(`${repositoryPath} -> expected tools/${entry.name}/ to be a directory`);
}
if (expected === "file" && !entry.isFile()) {
violations.push(`${repositoryPath} -> expected tools/${entry.name} to be a file`);
}
}
for (const [entryName, expected] of toolsRootAllowlist) {
if (!seen.has(entryName)) {
violations.push(`tools/${entryName}${expected === "directory" ? "/" : ""} -> required tools boundary is missing`);
}
}
if (violations.length > 0) {
console.error("Tools layout violations found:");
for (const violation of violations) console.error(`- ${violation}`);
return false;
}
console.log("Tools layout check passed: tools/ top-level entries match the active boundary allowlist.");
return true;
}
const checks: GuardCheck[] = [
{ name: "residual JavaScript", run: checkResidualJavaScript },
{ name: "test layout", run: checkTestLayout },
{ name: "e2e layout", run: checkE2eLayout },
{ name: "web test layout", run: checkWebTestLayout },
{ name: "tools layout", run: checkToolsLayout },
{ name: "design system token-fixture sync", run: checkDesignSystemTokenFixtureSync },
{ name: "design system A1 required tokens", run: checkDesignSystemA1RequiredTokens },
{ name: "design system A2 required tokens", run: checkDesignSystemA2RequiredTokens },
{ name: "design system B-slot required tokens", run: checkDesignSystemBSlotRequiredTokens },
{ name: "design system unknown token allowlist", run: checkDesignSystemUnknownTokens },
{ name: "design system A2 defaults parity", run: checkDesignSystemA2DefaultsParity },
];
const results: boolean[] = [];
for (const check of checks) {
try {
results.push(await check.run());
} catch (error) {
console.error(`Guard check failed unexpectedly: ${check.name}`);
console.error(error);
results.push(false);
}
}
if (results.some((passed) => !passed)) {
process.exitCode = 1;
}