mirror of
https://github.com/nexu-io/open-design.git
synced 2026-10-27 09:51:12 +01:00
Hand-painted animation is no longer a standalone video model that hides its source and delivers only an MP4. It is now an optional Motion template: - `od media scaffold --composition-dir motion-source/<id> --template hand-painted` (same /api endpoint for UI and CLI) writes a visible, replayable source: scene index.html with the shared Motion player, pinned local p5 / p5.brush, the painting engine, a player clock and the third-party license notices. - The internal renderer id `claude-animation-p5` is kept for compatibility, only accepts motion-source/<id> sources, no longer injects p5 at render time, and runs the standard Motion export checks (linked MP4 path, silent audio policy, decodable frame). First version stays 5s, 16:9, silent. - The example card moves from the Video rail to Motion design; the Video model picker, Home preset, form special-casing and prompt template are removed. The hide-source system prompt is removed; motion-design skill gains a routing note (OD Next task profile synced, version 1.3.3). - Engine, starter scene and licenses move from design-templates/ to the bundled example plugin's runtime/. - The internal renderer is excluded from the generic video model list in the media contract prompt, keeping the prompt snapshot identical to main.
1563 lines
59 KiB
TypeScript
1563 lines
59 KiB
TypeScript
import { readFile, readdir } from "node:fs/promises";
|
|
import path from "node:path";
|
|
import { pathToFileURL } from "node:url";
|
|
import * as ts from "@typescript/native/unstable/ast";
|
|
import { parseSource, withNativeSourceParser, forEachNativeSource } from "./lib/typescript/source.ts";
|
|
|
|
import { checkCrossAppImports } from "./check-cross-app-imports.ts";
|
|
import { checkTsNocheckImports } from "./check-ts-nocheck-imports.ts";
|
|
import { checkDesignSystemManifests } from "./check-design-system-manifests.ts";
|
|
import { checkDesignSystemPackageQuality } from "./check-design-system-package-quality.ts";
|
|
import { checkDesignSystemComponentFixtureReport } from "./check-components-fixtures.ts";
|
|
import { checkDesignSystemFlagParity } from "./check-design-system-flag-parity.ts";
|
|
import { checkComponentsManifestExtraction } from "./check-components-manifest-extraction.ts";
|
|
import { checkHtmlPluginPreviewContracts } from "./check-html-plugin-preview-contracts.ts";
|
|
import { checkPluginPreviewManifest } from "./check-plugin-preview-manifest.ts";
|
|
import {
|
|
checkDesignSystemA1RequiredTokens,
|
|
checkDesignSystemA2DefaultsParity,
|
|
checkDesignSystemA2RequiredTokens,
|
|
checkDesignSystemBSlotRequiredTokens,
|
|
checkDesignSystemTokenFixtureSync,
|
|
checkDesignSystemUnknownTokens,
|
|
} from "./check-tokens-fixture-sync.ts";
|
|
import { checkCraftReferences } from "./lint-craft-references.ts";
|
|
import { checkWhatsNewDocument } from "./check-whats-new-document.ts";
|
|
import { checkWhatsNewPublishWorkflow } from "./check-whats-new-publish-workflow.ts";
|
|
import { collectCssHardcodedColorMatches, cssWideAndSpecialColorKeywords, realNamedColors } from "./style-policy.ts";
|
|
import { checkScriptsLibraryArchitecture } from "./lib/guard/architecture.ts";
|
|
import { runGuardChecks, type GuardCheck, type GuardContext } from "./lib/guard/core.ts";
|
|
|
|
const repoRoot = path.resolve(import.meta.dirname, "..");
|
|
const allowedE2eScripts = new Set([
|
|
"e2e/scripts/artifact-render-parity.ts",
|
|
"e2e/scripts/playwright.ts",
|
|
"e2e/scripts/release-smoke.ts",
|
|
// Explicit opt-in local daemon acceptance; not part of hermetic CI test discovery.
|
|
"e2e/scripts/syntax-acceptance.ts",
|
|
"e2e/scripts/visual-report.ts",
|
|
// Cross-version real Vela / synthetic OpenCode protocol acceptance.
|
|
"e2e/scripts/vela-contract.ts",
|
|
]);
|
|
|
|
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",
|
|
// Local agent deepwork/worktree scratch (git-ignored; not product source).
|
|
".slim",
|
|
".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/agui-adapter/esbuild.config.mjs",
|
|
"packages/contracts/esbuild.config.mjs",
|
|
"packages/diagnostics/esbuild.config.mjs",
|
|
"packages/download/esbuild.config.mjs",
|
|
"packages/host/esbuild.config.mjs",
|
|
"packages/launcher-proto/esbuild.config.mjs",
|
|
"packages/metatool/esbuild.config.mjs",
|
|
"packages/platform/esbuild.config.mjs",
|
|
"packages/plugin-runtime/esbuild.config.mjs",
|
|
"packages/registry-protocol/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",
|
|
// Checked-in bin shim so pnpm can link `od` before daemon dist output exists.
|
|
"apps/daemon/bin/od.mjs",
|
|
"apps/packaged/esbuild.config.mjs",
|
|
// Browser service workers must be served as JavaScript files.
|
|
"apps/web/public/od-notifications-sw.js",
|
|
// Vendored dom-to-pptx browser bundle used by the packaged desktop renderer
|
|
// for editable PPTX export. It is loaded into the off-screen Chromium page as
|
|
// an upstream browser asset, not compiled as project-owned TypeScript.
|
|
"apps/desktop/vendor/dom-to-pptx/dom-to-pptx.bundle.js",
|
|
// PostCSS loads Tailwind through a web-local .mjs compatibility config entry.
|
|
"apps/web/postcss.config.mjs",
|
|
"scripts/bake-html-ppt-examples.mjs",
|
|
// CI-only plugin-preview renderer. Kept .mjs and run directly by Node so its
|
|
// runtime deps (puppeteer-core + a headless Chrome + ffmpeg) are provided by
|
|
// the CI environment and never pulled into the daemon/web TS build or bundle.
|
|
"scripts/bake-plugin-previews.mjs",
|
|
// Manifest diff guard + its node:test coverage. Run directly by Node from the
|
|
// bake workflows (no TS build step there) to decide whether a `previews` entry
|
|
// actually changed, ignoring the per-run `generatedAt` timestamp.
|
|
"scripts/plugin-previews-diff.mjs",
|
|
"scripts/plugin-previews-diff.test.mjs",
|
|
// CI-only R2 garbage collector for orphaned preview clips + its node:test.
|
|
"scripts/plugin-previews-gc.mjs",
|
|
"scripts/plugin-previews-gc.test.mjs",
|
|
"scripts/scaffold-html-ppt-skills.mjs",
|
|
"scripts/sync-hyperframes-skill.mjs",
|
|
"scripts/verify-media-models.mjs",
|
|
// AMR (vela) verifier: ad-hoc dev runner that imports the daemon's compiled
|
|
// `dist/acp.js` and drives a real `vela agent run` against a live model.
|
|
// Kept as .mjs so it can be invoked directly via Node without any transform.
|
|
"apps/daemon/scripts/verify-amr-real-vela.mjs",
|
|
// Fake `vela agent run` ACP stdio stub used by the AMR
|
|
// integration tests. The Vitest test spawns it via `child_process.spawn`,
|
|
// which needs a directly-executable file (shebang + .mjs).
|
|
"apps/daemon/tests/fixtures/fake-vela.mjs",
|
|
// Fake ACP agent CLI that answers `initialize` and then rejects
|
|
// `session/new`, used by the ACP handshake-rejection wiring tests. Same
|
|
// precedent as `fake-vela.mjs`: Vitest puts it on PATH and the daemon
|
|
// spawns it, so it must be directly executable (shebang + .mjs).
|
|
"apps/daemon/tests/fixtures/fake-acp-handshake-cli.mjs",
|
|
// Fake `kimi acp` ACP stdio stub used by the stdio-MCP wiring test. It
|
|
// records the `session/new` params the daemon actually sends, and the test
|
|
// spawns it through a PATH shim, so it must be directly executable by Node
|
|
// without a transform — same precedent as `fake-vela.mjs` above.
|
|
"apps/daemon/tests/fixtures/fake-kimi-acp-cli.mjs",
|
|
"tools/dev/bin/tools-dev.mjs",
|
|
"tools/dev/esbuild.config.mjs",
|
|
"tools/pack/bin/tools-pack.mjs",
|
|
"tools/pack/esbuild.config.mjs",
|
|
// Checked-in bin shim so pnpm can link `tools-release` before dist output exists.
|
|
"tools/release/bin/tools-release.mjs",
|
|
"tools/release/esbuild.config.mjs",
|
|
"tools/serve/bin/tools-serve.mjs",
|
|
"tools/serve/esbuild.config.mjs",
|
|
// Terminal distributions execute these native runtime entrypoints with the
|
|
// verified embedded Node after leaving the pnpm/TypeScript workspace.
|
|
"shells/terminal/runtime/fixture-lifecycle.mjs",
|
|
"shells/terminal/runtime/fixture-shell-updater.mjs",
|
|
"shells/terminal/runtime/fossil.mjs",
|
|
"shells/terminal/runtime/sidecar-bootstrap.mjs",
|
|
"shells/terminal/runtime/sidecar-host.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/",
|
|
// Pinned MIT browser engine from JohnHeibel/ClaudeAnimationBase; retained
|
|
// byte-for-byte and copied into hand-painted Motion sources by the scaffold.
|
|
"plugins/_official/examples/claude-animation/runtime/",
|
|
// Vendored upstream Web Clone skill helper scripts. These are portable
|
|
// Node-run skill utilities executed from user workspaces via explicit script
|
|
// paths, and stay as `.mjs` to preserve the upstream skill packaging.
|
|
"skills/web-clone/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/",
|
|
// Vendored upstream website-clone recon/mirror/audit helpers
|
|
// (Jane-xiaoer/claude-skill-web-clone). Global skill assets staged into the
|
|
// project cwd for direct `node scripts/...` execution by the agent.
|
|
"skills/web-clone/scripts/",
|
|
// Replay-based mock CLIs that impersonate the agent CLIs OD spawns
|
|
// (opencode/claude/codex/gemini/cursor-agent + ACP family). Need to
|
|
// be directly executable via Node so `child_process.spawn` from test
|
|
// harnesses and PATH-overlay shells work without any transform step.
|
|
// `mocks/scripts/` holds the maintainer-facing helpers (manifest math,
|
|
// fetch from R2) which are also pure-node single-file modules — same
|
|
// precedent as `apps/daemon/tests/fixtures/fake-vela.mjs` (an ACP
|
|
// stdio stub, allowlisted individually above). See `mocks/README.md`.
|
|
"mocks/lib/",
|
|
"mocks/mock-agent.mjs",
|
|
"mocks/scripts/",
|
|
// OD Clipper - a standalone Chrome MV3 extension subproject (not a pnpm
|
|
// workspace package, no build step). It ships hand-written browser-loadable
|
|
// JavaScript (service worker, content script, popup) the same way as the
|
|
// web notifications service worker; it must not be retypecast to TypeScript.
|
|
"clipper/",
|
|
// OD Figma Import - a standalone Figma plugin subproject (no build step,
|
|
// not a pnpm workspace package). Figma plugins load hand-written
|
|
// browser-loadable JavaScript (`code.js` sandbox + `ui.html`); same
|
|
// precedent as the clipper, and it must not be retypecast to TypeScript.
|
|
"figma-plugin/",
|
|
// ChatPanel 评审载体:场景模拟器与它的出图脚本(`docs/design/chat-sim/`、
|
|
// `docs/design/chat-panel-diagrams/`)。模拟器是**双击即开的浏览器页面** ——
|
|
// 设计与产品要在没有构建步骤、没有服务的情况下打开单文件 HTML 评审,
|
|
// 所以那些脚本必须是浏览器可直接加载的 JS,不能改成 TypeScript(改了就得先编译,
|
|
// 评审载体也就没法直接发人了)。出图脚本(`shoot.mjs` / `topng.mjs`)是同一批
|
|
// 一次性工具,用 Node 直接跑、只读 mermaid 源码出 SVG/PNG。
|
|
// 这批是 docs 下的设计评审产物,不参与产品运行时。见
|
|
// `docs/design/chat-sim/README.md` 与 `docs/design/chat-panel-diagrams/README.md`。
|
|
// `docs/design/chat-mirror/` 是同一批里的第三个:用我们的组件渲染的镜像陈列页
|
|
// 与它的逐格出图脚本(`shoot.mjs`,走无头 Chrome 的 CDP)。
|
|
// 见 `docs/design/chat-mirror/README.md`。
|
|
"docs/design/chat-sim/",
|
|
"docs/design/chat-panel-diagrams/",
|
|
"docs/design/chat-mirror/",
|
|
"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$/,
|
|
// Bundled example/skill plugins copy the upstream skill's `assets/`
|
|
// and `references/` directories verbatim so the daemon's preview
|
|
// surface can render the baked HTML without staging detours. Those
|
|
// assets are vendored runtime, never project-owned code, and must
|
|
// not be retypecasted to TypeScript.
|
|
/^plugins\/_official\/examples\/[^/]+\/(assets|references)\/.+$/,
|
|
];
|
|
|
|
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;
|
|
}
|
|
|
|
export async function checkRootPackageManagerLockfiles(root: string = repoRoot): Promise<boolean> {
|
|
const entries = await readdir(root);
|
|
if (entries.includes("bun.lock")) {
|
|
console.error("Unexpected root bun.lock found.");
|
|
console.error("pnpm-lock.yaml is the repository's only dependency lockfile; remove bun.lock.");
|
|
return false;
|
|
}
|
|
|
|
console.log("Root package-manager lockfile check passed: no bun.lock found.");
|
|
return true;
|
|
}
|
|
|
|
const sourcePackageManifestRootPaths = ["package.json", "e2e/package.json"];
|
|
const sourcePackageManifestScopedDirectories = ["apps", "packages", "tools"];
|
|
const packageDependencySections = [
|
|
"dependencies",
|
|
"devDependencies",
|
|
"peerDependencies",
|
|
"optionalDependencies",
|
|
];
|
|
const packageManagerOverridePaths = ["pnpm.overrides", "overrides", "resolutions"];
|
|
const exactVersionPattern = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/;
|
|
const exactNpmAliasPattern = /^npm:(?:@[^/]+\/)?[^@]+@\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/;
|
|
|
|
type DependencySpecViolation = {
|
|
filePath: string;
|
|
fieldPath: string;
|
|
name: string;
|
|
spec: unknown;
|
|
reason: string;
|
|
};
|
|
|
|
type DependencySpecStats = {
|
|
exact: number;
|
|
externalHostPeer: number;
|
|
manifests: number;
|
|
total: number;
|
|
workspace: number;
|
|
};
|
|
|
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
}
|
|
|
|
function isAllowedDependencySpec(spec: string): boolean {
|
|
return spec === "workspace:*" || exactVersionPattern.test(spec) || exactNpmAliasPattern.test(spec);
|
|
}
|
|
|
|
// Manifests published for an EXTERNAL host to load as a plugin, where
|
|
// `peerDependencies` describes packages the host supplies rather than
|
|
// anything this repository installs or can pin.
|
|
//
|
|
// Exact specs are right everywhere else: they keep our own installs
|
|
// reproducible. A peer range aimed at a third-party host is the opposite
|
|
// case — the host's version is chosen by the user, so an exact peer means
|
|
// any host upgrade leaves the peer unsatisfiable and the plugin refuses to
|
|
// install at all. `@open-design/dsh-runtime` hit exactly that: pinned to a
|
|
// single DeepSeek Harness release candidate, it became uninstallable the
|
|
// moment the upstream shipped the next one.
|
|
//
|
|
// This exemption covers `peerDependencies` only. `dependencies` and
|
|
// `devDependencies` in these manifests are still installed by us and still
|
|
// have to be exact.
|
|
const externalHostPluginManifests = new Set(["packages/dsh-runtime/package.json"]);
|
|
|
|
function isExternalHostPeerSpec(filePath: string, fieldPath: string): boolean {
|
|
return (
|
|
externalHostPluginManifests.has(filePath) &&
|
|
fieldPath.split(".")[0] === "peerDependencies"
|
|
);
|
|
}
|
|
|
|
function dependencySpecReason(spec: string): string {
|
|
if (spec.startsWith("workspace:") && spec !== "workspace:*") {
|
|
return "workspace dependencies must use exactly workspace:*";
|
|
}
|
|
|
|
return "dependency specs must be exact versions like 1.2.3 or workspace:*";
|
|
}
|
|
|
|
function dependencySpecFieldValue(value: unknown): string {
|
|
return typeof value === "string" ? value : JSON.stringify(value);
|
|
}
|
|
|
|
async function collectScopedPackageManifestPaths(scopeDirectory: string): Promise<string[]> {
|
|
const scopeRoot = path.join(repoRoot, scopeDirectory);
|
|
const entries = await readdir(scopeRoot, { withFileTypes: true });
|
|
const manifestPaths: string[] = [];
|
|
|
|
for (const entry of entries) {
|
|
if (!entry.isDirectory()) continue;
|
|
|
|
const packageDirectory = path.join(scopeRoot, entry.name);
|
|
const packageEntries = await readdir(packageDirectory, { withFileTypes: true });
|
|
if (packageEntries.some((packageEntry) => packageEntry.isFile() && packageEntry.name === "package.json")) {
|
|
manifestPaths.push(`${scopeDirectory}/${entry.name}/package.json`);
|
|
}
|
|
}
|
|
|
|
return manifestPaths;
|
|
}
|
|
|
|
async function collectSourcePackageManifestPaths(): Promise<string[]> {
|
|
const scopedManifestPaths = (
|
|
await Promise.all(sourcePackageManifestScopedDirectories.map((scope) => collectScopedPackageManifestPaths(scope)))
|
|
).flat();
|
|
|
|
return [...sourcePackageManifestRootPaths, ...scopedManifestPaths].sort();
|
|
}
|
|
|
|
function getPackageJsonField(packageJson: Record<string, unknown>, fieldPath: string): unknown {
|
|
let current: unknown = packageJson;
|
|
for (const part of fieldPath.split(".")) {
|
|
if (!isRecord(current)) return undefined;
|
|
current = current[part];
|
|
}
|
|
return current;
|
|
}
|
|
|
|
function checkDependencySpecRecord(
|
|
record: Record<string, unknown>,
|
|
filePath: string,
|
|
fieldPath: string,
|
|
violations: DependencySpecViolation[],
|
|
stats: DependencySpecStats,
|
|
): void {
|
|
for (const [name, spec] of Object.entries(record).sort(([left], [right]) => left.localeCompare(right))) {
|
|
if (isRecord(spec)) {
|
|
checkDependencySpecRecord(spec, filePath, `${fieldPath}.${name}`, violations, stats);
|
|
continue;
|
|
}
|
|
|
|
stats.total += 1;
|
|
if (typeof spec !== "string") {
|
|
violations.push({
|
|
filePath,
|
|
fieldPath,
|
|
name,
|
|
spec,
|
|
reason: "dependency specs must be strings",
|
|
});
|
|
continue;
|
|
}
|
|
|
|
if (spec === "workspace:*") {
|
|
stats.workspace += 1;
|
|
continue;
|
|
}
|
|
|
|
if (isAllowedDependencySpec(spec)) {
|
|
stats.exact += 1;
|
|
continue;
|
|
}
|
|
|
|
if (isExternalHostPeerSpec(filePath, fieldPath)) {
|
|
stats.externalHostPeer += 1;
|
|
continue;
|
|
}
|
|
|
|
violations.push({
|
|
filePath,
|
|
fieldPath,
|
|
name,
|
|
spec,
|
|
reason: dependencySpecReason(spec),
|
|
});
|
|
}
|
|
}
|
|
|
|
async function checkPackageDependencySpecs(): Promise<boolean> {
|
|
const manifestPaths = await collectSourcePackageManifestPaths();
|
|
const violations: DependencySpecViolation[] = [];
|
|
const stats: DependencySpecStats = {
|
|
exact: 0,
|
|
externalHostPeer: 0,
|
|
manifests: manifestPaths.length,
|
|
total: 0,
|
|
workspace: 0,
|
|
};
|
|
|
|
for (const manifestPath of manifestPaths) {
|
|
const packageJson = JSON.parse(await readFile(path.join(repoRoot, manifestPath), "utf8")) as Record<string, unknown>;
|
|
|
|
for (const section of packageDependencySections) {
|
|
const value = packageJson[section];
|
|
if (value === undefined) continue;
|
|
if (!isRecord(value)) {
|
|
violations.push({
|
|
filePath: manifestPath,
|
|
fieldPath: section,
|
|
name: section,
|
|
spec: value,
|
|
reason: "dependency sections must be objects",
|
|
});
|
|
continue;
|
|
}
|
|
|
|
checkDependencySpecRecord(value, manifestPath, section, violations, stats);
|
|
}
|
|
|
|
for (const overridePath of packageManagerOverridePaths) {
|
|
const value = getPackageJsonField(packageJson, overridePath);
|
|
if (value === undefined) continue;
|
|
if (!isRecord(value)) {
|
|
violations.push({
|
|
filePath: manifestPath,
|
|
fieldPath: overridePath,
|
|
name: overridePath,
|
|
spec: value,
|
|
reason: "package-manager override sections must be objects",
|
|
});
|
|
continue;
|
|
}
|
|
|
|
checkDependencySpecRecord(value, manifestPath, overridePath, violations, stats);
|
|
}
|
|
}
|
|
|
|
if (violations.length > 0) {
|
|
console.error("Package dependency spec violations found:");
|
|
for (const violation of violations) {
|
|
console.error(
|
|
`- ${violation.filePath} ${violation.fieldPath}.${violation.name}=${dependencySpecFieldValue(violation.spec)} -> ${violation.reason}`,
|
|
);
|
|
}
|
|
return false;
|
|
}
|
|
|
|
console.log(
|
|
`Package dependency spec check passed: ${stats.manifests} package.json files, ${stats.exact} exact specs, ${stats.workspace} workspace:* specs, ${stats.externalHostPeer} external-host peer ranges.`,
|
|
);
|
|
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 checkScriptsTestFree(): Promise<boolean> {
|
|
const scriptsFiles = await collectRepositoryFiles(path.join(repoRoot, "scripts"), testLayoutSkippedDirectories);
|
|
const violations = scriptsFiles.filter(isScriptTestFile);
|
|
|
|
if (violations.length > 0) {
|
|
console.error(
|
|
"Root scripts/ is test-free: move behavior-contract coverage to e2e/tests/scripts/ (see e2e/AGENTS.md):",
|
|
);
|
|
for (const violation of violations) {
|
|
console.error(`- ${violation}`);
|
|
}
|
|
return false;
|
|
}
|
|
|
|
console.log("Scripts test-free check passed: no test files under root scripts/.");
|
|
return true;
|
|
}
|
|
|
|
export function isScriptTestFile(repositoryPath: string): boolean {
|
|
return /\.test\.[^/]+$/.test(repositoryPath);
|
|
}
|
|
|
|
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:p0",
|
|
"test:p0p1",
|
|
"test:p1",
|
|
"test:p2",
|
|
"test:ui",
|
|
"test:ui:critical",
|
|
"test:ui:extended",
|
|
"test:ui:p0",
|
|
"test:ui:p0p1",
|
|
"test:ui:p1",
|
|
"test:ui:p2",
|
|
"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;
|
|
}
|
|
|
|
const productNeutralitySkippedDirectories = new Set([
|
|
".git",
|
|
".od",
|
|
".tmp",
|
|
"dist",
|
|
"node_modules",
|
|
"out",
|
|
"test-results",
|
|
]);
|
|
// Public contracts, help/prompt strings, docs, and shipped content should
|
|
// describe the integration role, not name a private deployment. The default
|
|
// check blocks named "orchestrator such as ..." examples; private forks can
|
|
// add stricter local terms through OD_PRODUCT_NEUTRALITY_FORBIDDEN_TERMS.
|
|
const productNeutralityCheckedPathPrefixes = [
|
|
"apps/daemon/src/",
|
|
"apps/web/app/",
|
|
"apps/web/src/",
|
|
"craft/",
|
|
"design-systems/",
|
|
"design-templates/",
|
|
"docs/",
|
|
"packages/contracts/src/",
|
|
"skills/",
|
|
];
|
|
const productNeutralityTextExtensions = new Set([".md", ".mdx", ".ts", ".tsx"]);
|
|
const productNeutralityDocFilePattern =
|
|
/(?:^|\/)(?:AGENTS|CLAUDE|CONTRIBUTING(?:\.[^.]+)?|QUICKSTART|README(?:\.[^.]+)?)\.md$/;
|
|
const namedOrchestratorExamplePattern =
|
|
/\borchestrator\s+(?:such as|like|for example,?)\s+[`"']?[A-Z][A-Za-z0-9_-]+/gi;
|
|
|
|
type ProductNeutralityViolation = {
|
|
filePath: string;
|
|
lineNumber: number;
|
|
reason: string;
|
|
};
|
|
|
|
export function isProductNeutralityCheckedPath(repositoryPath: string): boolean {
|
|
return (
|
|
productNeutralityCheckedPathPrefixes.some((prefix) => repositoryPath.startsWith(prefix)) ||
|
|
productNeutralityDocFilePattern.test(repositoryPath)
|
|
);
|
|
}
|
|
|
|
function isProductNeutralityTextFile(repositoryPath: string): boolean {
|
|
return productNeutralityTextExtensions.has(path.extname(repositoryPath));
|
|
}
|
|
|
|
function productNeutralityForbiddenTerms(): string[] {
|
|
return String(process.env.OD_PRODUCT_NEUTRALITY_FORBIDDEN_TERMS ?? "")
|
|
.split(",")
|
|
.map((term) => term.trim())
|
|
.filter((term) => term.length > 0);
|
|
}
|
|
|
|
export function collectProductNeutralityViolationsFromSource(
|
|
repositoryPath: string,
|
|
source: string,
|
|
forbiddenTerms = productNeutralityForbiddenTerms(),
|
|
): ProductNeutralityViolation[] {
|
|
if (!isProductNeutralityCheckedPath(repositoryPath) || !isProductNeutralityTextFile(repositoryPath)) {
|
|
return [];
|
|
}
|
|
|
|
const lowerSource = source.toLowerCase();
|
|
const violations: ProductNeutralityViolation[] = [];
|
|
|
|
for (const match of source.matchAll(namedOrchestratorExamplePattern)) {
|
|
violations.push({
|
|
filePath: repositoryPath,
|
|
lineNumber: lineNumberForIndex(source, match.index ?? 0),
|
|
reason: "use generic \"external orchestrator\" phrasing instead of named orchestrator examples",
|
|
});
|
|
}
|
|
|
|
for (const term of forbiddenTerms) {
|
|
const lowerTerm = term.toLowerCase();
|
|
let index = lowerSource.indexOf(lowerTerm);
|
|
|
|
while (index !== -1) {
|
|
violations.push({
|
|
filePath: repositoryPath,
|
|
lineNumber: lineNumberForIndex(source, index),
|
|
reason: "use generic \"external orchestrator\" phrasing instead of private deployment names",
|
|
});
|
|
index = lowerSource.indexOf(lowerTerm, index + lowerTerm.length);
|
|
}
|
|
}
|
|
|
|
return violations;
|
|
}
|
|
|
|
async function checkProductNeutrality(): Promise<boolean> {
|
|
const violations: ProductNeutralityViolation[] = [];
|
|
|
|
for (const repositoryPath of await collectRepositoryFiles(repoRoot, productNeutralitySkippedDirectories)) {
|
|
if (!isProductNeutralityCheckedPath(repositoryPath) || !isProductNeutralityTextFile(repositoryPath)) {
|
|
continue;
|
|
}
|
|
const source = await readFile(path.join(repoRoot, repositoryPath), "utf8");
|
|
violations.push(...collectProductNeutralityViolationsFromSource(repositoryPath, source));
|
|
}
|
|
|
|
if (violations.length > 0) {
|
|
console.error("Product-neutrality violations found:");
|
|
for (const violation of violations) {
|
|
console.error(`${violation.filePath}:${violation.lineNumber} -> ${violation.reason}`);
|
|
}
|
|
return false;
|
|
}
|
|
|
|
console.log("Product-neutrality check passed: public docs, contracts, and prompts use generic orchestrator naming.");
|
|
return true;
|
|
}
|
|
|
|
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/playwright.visual.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 webImportIsolationSourcePrefixes = ["apps/web/app/", "apps/web/src/"];
|
|
const webImportIsolationExtensions = new Set([".ts", ".tsx"]);
|
|
const webImportIsolationSkippedDirectories = new Set([
|
|
".next",
|
|
"dist",
|
|
"node_modules",
|
|
"out",
|
|
"reports",
|
|
"test-results",
|
|
]);
|
|
const webImportIsolationForbiddenPackages = [
|
|
"@open-design/platform",
|
|
"@open-design/sidecar",
|
|
"@open-design/sidecar-proto",
|
|
];
|
|
const webImportIsolationForbiddenDaemonRoots = [
|
|
"apps/daemon/src",
|
|
"apps/daemon/tests",
|
|
];
|
|
const webImportIsolationForbiddenPackageRoots = [
|
|
"packages/platform",
|
|
"packages/sidecar",
|
|
"packages/sidecar-proto",
|
|
];
|
|
|
|
type WebImportIsolationViolation = {
|
|
filePath: string;
|
|
lineNumber: number;
|
|
specifier: string;
|
|
reason: string;
|
|
};
|
|
|
|
type SourceImportSpecifier = {
|
|
lineNumber: number;
|
|
specifier: string;
|
|
};
|
|
|
|
export function isWebImportIsolationSourcePath(repositoryPath: string): boolean {
|
|
return (
|
|
webImportIsolationSourcePrefixes.some((prefix) => repositoryPath.startsWith(prefix)) &&
|
|
webImportIsolationExtensions.has(path.extname(repositoryPath))
|
|
);
|
|
}
|
|
|
|
function pushStringSpecifier(
|
|
imports: SourceImportSpecifier[],
|
|
sourceFile: ts.SourceFile,
|
|
node: ts.Node | undefined,
|
|
): void {
|
|
if (!node) return;
|
|
if (!ts.isStringLiteral(node) && !ts.isNoSubstitutionTemplateLiteral(node)) return;
|
|
|
|
imports.push({
|
|
lineNumber: sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile)).line + 1,
|
|
specifier: node.text,
|
|
});
|
|
}
|
|
|
|
function collectImportSpecifiersFromSource(repositoryPath: string, source: string): SourceImportSpecifier[] {
|
|
const sourceFile = parseSource(repositoryPath, source);
|
|
const imports: SourceImportSpecifier[] = [];
|
|
|
|
const visit = (node: ts.Node): void => {
|
|
if (ts.isImportDeclaration(node) || ts.isExportDeclaration(node)) {
|
|
pushStringSpecifier(imports, sourceFile, node.moduleSpecifier);
|
|
} else if (ts.isImportTypeNode(node) && ts.isLiteralTypeNode(node.argument)) {
|
|
pushStringSpecifier(imports, sourceFile, node.argument.literal);
|
|
} else if (
|
|
ts.isCallExpression(node) &&
|
|
(node.expression.kind === ts.SyntaxKind.ImportKeyword ||
|
|
(ts.isIdentifier(node.expression) && node.expression.text === "require"))
|
|
) {
|
|
pushStringSpecifier(imports, sourceFile, node.arguments[0]);
|
|
}
|
|
|
|
node.forEachChild(visit);
|
|
};
|
|
|
|
visit(sourceFile);
|
|
return imports;
|
|
}
|
|
|
|
function isPackageOrSubpath(specifier: string, packageName: string): boolean {
|
|
return specifier === packageName || specifier.startsWith(`${packageName}/`);
|
|
}
|
|
|
|
function isPathOrDescendant(repositoryPath: string, root: string): boolean {
|
|
return repositoryPath === root || repositoryPath.startsWith(`${root}/`);
|
|
}
|
|
|
|
function resolveWebImportRepositoryPath(fromRepositoryPath: string, specifier: string): string | null {
|
|
const pathOnly = specifier.split(/[?#]/, 1)[0];
|
|
if (!pathOnly) return null;
|
|
|
|
if (pathOnly.startsWith("@/")) {
|
|
return path.posix.normalize(path.posix.join("apps/web", pathOnly.slice("@/".length)));
|
|
}
|
|
|
|
if (!pathOnly.startsWith(".")) return null;
|
|
return path.posix.normalize(path.posix.join(path.posix.dirname(fromRepositoryPath), pathOnly));
|
|
}
|
|
|
|
function webImportIsolationViolationReason(fromRepositoryPath: string, specifier: string): string | null {
|
|
if (webImportIsolationForbiddenPackages.some((packageName) => isPackageOrSubpath(specifier, packageName))) {
|
|
return "apps/web must not import sidecar or platform control-plane packages directly";
|
|
}
|
|
|
|
const resolvedPath = resolveWebImportRepositoryPath(fromRepositoryPath, specifier);
|
|
if (!resolvedPath) return null;
|
|
|
|
if (webImportIsolationForbiddenDaemonRoots.some((root) => isPathOrDescendant(resolvedPath, root))) {
|
|
return "apps/web must use daemon HTTP APIs or @open-design/contracts instead of daemon private source";
|
|
}
|
|
|
|
if (webImportIsolationForbiddenPackageRoots.some((root) => isPathOrDescendant(resolvedPath, root))) {
|
|
return "apps/web must not import sidecar or platform control-plane source directly";
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
export function collectWebImportIsolationViolationsFromSource(
|
|
repositoryPath: string,
|
|
source: string,
|
|
): WebImportIsolationViolation[] {
|
|
if (!isWebImportIsolationSourcePath(repositoryPath)) return [];
|
|
|
|
return collectImportSpecifiersFromSource(repositoryPath, source).flatMap((sourceImport) => {
|
|
const reason = webImportIsolationViolationReason(repositoryPath, sourceImport.specifier);
|
|
if (!reason) return [];
|
|
return [{
|
|
filePath: repositoryPath,
|
|
lineNumber: sourceImport.lineNumber,
|
|
specifier: sourceImport.specifier,
|
|
reason,
|
|
}];
|
|
});
|
|
}
|
|
|
|
async function checkWebImportIsolation(): Promise<boolean> {
|
|
const violations: WebImportIsolationViolation[] = [];
|
|
const sources = new Map<string, string>();
|
|
|
|
for (const repositoryPrefix of webImportIsolationSourcePrefixes) {
|
|
const repositoryDirectory = repositoryPrefix.replace(/\/$/, "");
|
|
if (!(await repositoryDirectoryExists(repositoryDirectory))) continue;
|
|
|
|
for (const repositoryPath of await collectRepositoryFiles(
|
|
path.join(repoRoot, repositoryDirectory),
|
|
webImportIsolationSkippedDirectories,
|
|
)) {
|
|
if (!isWebImportIsolationSourcePath(repositoryPath)) continue;
|
|
const source = await readFile(path.join(repoRoot, repositoryPath), "utf8");
|
|
sources.set(repositoryPath, source);
|
|
}
|
|
}
|
|
|
|
await forEachNativeSource(sources, (repositoryPath, source) => {
|
|
violations.push(...collectWebImportIsolationViolationsFromSource(repositoryPath, source));
|
|
});
|
|
|
|
if (violations.length > 0) {
|
|
console.error("Web import isolation violations found:");
|
|
for (const violation of violations) {
|
|
console.error(`- ${violation.filePath}:${violation.lineNumber} \`${violation.specifier}\` -> ${violation.reason}`);
|
|
}
|
|
return false;
|
|
}
|
|
|
|
console.log("Web import isolation check passed: web runtime imports stay behind contracts and daemon HTTP APIs.");
|
|
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"],
|
|
["release", "directory"],
|
|
["serve", "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/, release/, and serve/`);
|
|
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 stylePolicySkippedDirectories = new Set([
|
|
".next",
|
|
".od-data",
|
|
"dist",
|
|
"node_modules",
|
|
"out",
|
|
"reports",
|
|
"test-results",
|
|
]);
|
|
|
|
const stylePolicySourcePrefixes = ["apps/web/app/", "apps/web/src/"];
|
|
const stylePolicyHardcodedColorEnforcedPrefixes = ["scripts/guard-style-policy-fixtures/"];
|
|
const stylePolicyCheckedDirectoryPrefixes = [
|
|
...new Set([...stylePolicySourcePrefixes, ...stylePolicyHardcodedColorEnforcedPrefixes]),
|
|
];
|
|
const stylePolicyExtensions = new Set([".css", ".ts", ".tsx"]);
|
|
const tailwindDefaultColorNames = [
|
|
"slate",
|
|
"gray",
|
|
"zinc",
|
|
"neutral",
|
|
"stone",
|
|
"red",
|
|
"orange",
|
|
"amber",
|
|
"yellow",
|
|
"lime",
|
|
"green",
|
|
"emerald",
|
|
"teal",
|
|
"cyan",
|
|
"sky",
|
|
"blue",
|
|
"indigo",
|
|
"violet",
|
|
"purple",
|
|
"fuchsia",
|
|
"pink",
|
|
"rose",
|
|
"white",
|
|
"black",
|
|
].join("|");
|
|
const tailwindDefaultPaletteClassPrefixes = [
|
|
"bg",
|
|
"text",
|
|
"border(?:-(?:x|y|s|e|t|r|b|l))?",
|
|
"divide",
|
|
"placeholder",
|
|
"marker",
|
|
"from",
|
|
"via",
|
|
"to",
|
|
"ring(?:-offset)?",
|
|
"outline",
|
|
"decoration",
|
|
"(?:inset-|text-|drop-)?shadow",
|
|
"accent",
|
|
"caret",
|
|
"fill",
|
|
"stroke",
|
|
].join("|");
|
|
const defaultTailwindPaletteClassPattern = new RegExp(
|
|
`\\b(?:${tailwindDefaultPaletteClassPrefixes})-(?:${tailwindDefaultColorNames})(?:-\\d{2,3})?\\b`,
|
|
"g",
|
|
);
|
|
|
|
const hardcodedColorPattern = new RegExp(
|
|
`#[0-9a-fA-F]{3,8}\\b|rgba?\\([^)]*\\)|hsla?\\([^)]*\\)|(?<quote>['"])\\s*(?<named>${realNamedColors.join("|")}|transparent|currentColor|currentcolor|inherit|initial|unset|revert)\\s*\\k<quote>`,
|
|
"g",
|
|
);
|
|
|
|
type StylePolicyAllowlistEntry = {
|
|
pathPattern: RegExp;
|
|
valuePattern: RegExp;
|
|
reason: string;
|
|
};
|
|
|
|
const hardcodedColorAllowlist: StylePolicyAllowlistEntry[] = [
|
|
{
|
|
pathPattern: /^apps\/web\/src\/index\.css$/,
|
|
valuePattern: /^(?:#[0-9a-fA-F]{3,8}\b|rgba?\([^)]*\)|hsla?\([^)]*\))$/,
|
|
reason: "global token definitions, shadows, overlays, and retained migration inventory live in the CSS source of truth",
|
|
},
|
|
{
|
|
pathPattern: /^apps\/web\/src\/components\/(?:AgentIcon|PetSettings|SettingsDialog)\.tsx$/,
|
|
valuePattern: /^(?:#[0-9a-fA-F]{3,8}\b|rgba?\([^)]*\)|hsla?\([^)]*\))$/,
|
|
reason: "brand accents, user accent choices, and legacy token fallbacks are classified as Phase 1 migration inventory",
|
|
},
|
|
{
|
|
pathPattern: /^apps\/web\/src\/components\/(?:SketchEditor|SketchPreview|NewProjectPanel)\.tsx$/,
|
|
valuePattern: /^(?:#[0-9a-fA-F]{3,8}\b|rgba?\([^)]*\)|hsla?\([^)]*\)|['\"](?:none|currentColor|currentcolor|transparent)['\"])$/,
|
|
reason: "sketch/canvas data and SVG illustrations keep narrow hardcoded color exceptions until their migration slice",
|
|
},
|
|
{
|
|
pathPattern: /^apps\/web\/src\/components\/(?:FileViewer|ManualEditPanel)\.tsx$/,
|
|
valuePattern: /^(?:#[0-9a-fA-F]{3,8}\b|rgba?\([^)]*\)|hsla?\([^)]*\))$/,
|
|
reason: "user-authored file, inspect, and editable style colors are handled by the file/viewer migration slice",
|
|
},
|
|
{
|
|
pathPattern: /^apps\/web\/src\/components\/(?:MemorySection|MemoryModelInline|MemoryToast)\.tsx$/,
|
|
valuePattern: /^(?:#[0-9a-fA-F]{3,8}\b|rgba?\([^)]*\)|hsla?\([^)]*\))$/,
|
|
reason: "memory UI legacy color fallbacks are classified as Phase 1 migration inventory",
|
|
},
|
|
{
|
|
pathPattern: /^apps\/web\/tests\//,
|
|
valuePattern: /.*/,
|
|
reason: "tests and fixtures may assert rejected colors explicitly",
|
|
},
|
|
];
|
|
|
|
type StylePolicyViolation = {
|
|
filePath: string;
|
|
lineNumber: number;
|
|
match: string;
|
|
reason: string;
|
|
};
|
|
|
|
function lineNumberForIndex(source: string, index: number): number {
|
|
return source.slice(0, index).split("\n").length;
|
|
}
|
|
|
|
function isStylePolicySource(repositoryPath: string): boolean {
|
|
return stylePolicySourcePrefixes.some((prefix) => repositoryPath.startsWith(prefix));
|
|
}
|
|
|
|
function isHardcodedColorEnforcedPath(repositoryPath: string): boolean {
|
|
return stylePolicyHardcodedColorEnforcedPrefixes.some((prefix) => repositoryPath.startsWith(prefix));
|
|
}
|
|
|
|
function isHardcodedColorAllowlisted(repositoryPath: string, match: string): boolean {
|
|
const normalizedMatch = match.trim();
|
|
const unquotedMatch = normalizedMatch.replace(/^['"]|['"]$/g, "");
|
|
if (cssWideAndSpecialColorKeywords.has(unquotedMatch.toLowerCase())) return true;
|
|
|
|
return hardcodedColorAllowlist.some(
|
|
(entry) => entry.pathPattern.test(repositoryPath) && entry.valuePattern.test(normalizedMatch),
|
|
);
|
|
}
|
|
|
|
function addStylePolicyViolation(
|
|
violations: StylePolicyViolation[],
|
|
repositoryPath: string,
|
|
source: string,
|
|
index: number,
|
|
match: string,
|
|
reason: string,
|
|
): void {
|
|
violations.push({
|
|
filePath: repositoryPath,
|
|
lineNumber: lineNumberForIndex(source, index),
|
|
match,
|
|
reason,
|
|
});
|
|
}
|
|
|
|
function collectStylePolicyViolationsFromSource(repositoryPath: string, source: string): StylePolicyViolation[] {
|
|
const violations: StylePolicyViolation[] = [];
|
|
|
|
if (isStylePolicySource(repositoryPath)) {
|
|
for (const match of source.matchAll(defaultTailwindPaletteClassPattern)) {
|
|
violations.push({
|
|
filePath: repositoryPath,
|
|
lineNumber: lineNumberForIndex(source, match.index ?? 0),
|
|
match: match[0],
|
|
reason: "default Tailwind palette classes must use OpenDesign token utilities instead",
|
|
});
|
|
}
|
|
}
|
|
|
|
if (isStylePolicySource(repositoryPath) || isHardcodedColorEnforcedPath(repositoryPath)) {
|
|
if (repositoryPath.endsWith(".css") && isHardcodedColorEnforcedPath(repositoryPath)) {
|
|
for (const match of collectCssHardcodedColorMatches(source)) {
|
|
const value = match.value;
|
|
if (value === undefined || isHardcodedColorAllowlisted(repositoryPath, value)) continue;
|
|
|
|
addStylePolicyViolation(
|
|
violations,
|
|
repositoryPath,
|
|
source,
|
|
match.index,
|
|
value,
|
|
"unregistered hardcoded UI colors must use OpenDesign tokens or an explicit allowlist entry",
|
|
);
|
|
}
|
|
} else {
|
|
for (const match of source.matchAll(hardcodedColorPattern)) {
|
|
const value = match[0];
|
|
if (isHardcodedColorAllowlisted(repositoryPath, value)) continue;
|
|
if (!isHardcodedColorEnforcedPath(repositoryPath)) continue;
|
|
|
|
addStylePolicyViolation(
|
|
violations,
|
|
repositoryPath,
|
|
source,
|
|
match.index ?? 0,
|
|
value,
|
|
"unregistered hardcoded UI colors must use OpenDesign tokens or an explicit allowlist entry",
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
return violations;
|
|
}
|
|
|
|
async function collectStylePolicyViolations(directory: string): Promise<StylePolicyViolation[]> {
|
|
const entries = await readdir(directory, { withFileTypes: true });
|
|
const violations: StylePolicyViolation[] = [];
|
|
|
|
for (const entry of entries) {
|
|
const fullPath = path.join(directory, entry.name);
|
|
if (entry.isDirectory()) {
|
|
if (stylePolicySkippedDirectories.has(entry.name)) continue;
|
|
violations.push(...(await collectStylePolicyViolations(fullPath)));
|
|
continue;
|
|
}
|
|
|
|
if (!entry.isFile() || !stylePolicyExtensions.has(path.extname(entry.name))) continue;
|
|
|
|
const repositoryPath = toRepositoryPath(fullPath);
|
|
if (!isStylePolicySource(repositoryPath) && !isHardcodedColorEnforcedPath(repositoryPath)) continue;
|
|
|
|
violations.push(...collectStylePolicyViolationsFromSource(repositoryPath, await readFile(fullPath, "utf8")));
|
|
}
|
|
|
|
return violations;
|
|
}
|
|
|
|
async function repositoryDirectoryExists(repositoryPath: string): Promise<boolean> {
|
|
const parentPath = path.join(repoRoot, path.dirname(repositoryPath));
|
|
const directoryName = path.basename(repositoryPath);
|
|
const entries = await readdir(parentPath, { withFileTypes: true });
|
|
|
|
return entries.some((entry) => entry.name === directoryName && entry.isDirectory());
|
|
}
|
|
|
|
async function collectStylePolicyViolationsFromCheckedPaths(): Promise<StylePolicyViolation[]> {
|
|
const violations: StylePolicyViolation[] = [];
|
|
|
|
for (const repositoryPrefix of stylePolicyCheckedDirectoryPrefixes) {
|
|
const repositoryDirectory = repositoryPrefix.replace(/\/$/, "");
|
|
if (!(await repositoryDirectoryExists(repositoryDirectory))) continue;
|
|
|
|
violations.push(...(await collectStylePolicyViolations(path.join(repoRoot, repositoryDirectory))));
|
|
}
|
|
|
|
return violations;
|
|
}
|
|
|
|
async function checkStylePolicy(): Promise<boolean> {
|
|
const violations = await collectStylePolicyViolationsFromCheckedPaths();
|
|
|
|
if (violations.length > 0) {
|
|
console.error("Style policy violations found:");
|
|
for (const violation of violations) {
|
|
console.error(`- ${violation.filePath}:${violation.lineNumber} \`${violation.match}\` -> ${violation.reason}`);
|
|
}
|
|
console.error("Use OpenDesign token utilities/CSS variables or add a narrow allowlist entry with a reason.");
|
|
return false;
|
|
}
|
|
|
|
console.log("Style policy check passed: Tailwind palette classes and enforced hardcoded UI colors stay token-first.");
|
|
return true;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// HTML structural boundary lookups
|
|
//
|
|
// Preview and export splice bridges into an artifact's own bytes, so they need
|
|
// the offset of a real `<head>` / `</body>` / `<base>` / `<title>`. Finding one
|
|
// with a plain text match is what broke nexu-io/open-design#7410: those tags
|
|
// are also ordinary content, and any prototype that builds an HTML document
|
|
// string writes them into a script or an attribute. The injected markup then
|
|
// lands inside the author's string and silently truncates their page.
|
|
//
|
|
// That defect reappeared in six separate files because each one hand-rolled its
|
|
// own lookup. `@open-design/contracts/runtime/html-injection-points` is now the
|
|
// single implementation, and this check is what keeps the next one from being
|
|
// written: a grep-driven sweep already missed an entire app once.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const htmlBoundaryOwnerPath = "packages/contracts/src/runtime/html-injection-points.ts";
|
|
const htmlBoundarySkippedDirectories = new Set([".git", ".od", ".tmp", "dist", "node_modules", "out", "test-results"]);
|
|
const htmlBoundaryCheckedPathPrefixes = [
|
|
"apps/daemon/src/",
|
|
"apps/desktop/src/",
|
|
"apps/packaged/src/",
|
|
"apps/web/src/",
|
|
"packages/",
|
|
"tools/",
|
|
];
|
|
const htmlBoundarySourceExtensions = new Set([".ts", ".tsx", ".mjs", ".cjs", ".js"]);
|
|
/**
|
|
* A boundary tag reached by pattern-matching the raw text. These are always
|
|
* wrong: the match lands wherever the tag first appears, content or not.
|
|
*/
|
|
const htmlBoundaryPatternOpPattern = new RegExp(
|
|
[
|
|
// `html.replace(/<\/body>/i, …)` — operation first, literal second. The
|
|
// escaped slash matters: a close-tag regex is always written `<\/body`,
|
|
// and that backslash is what an earlier version of this check missed,
|
|
// leaving the exact shape of #7410 invisible to it.
|
|
String.raw`(?:replace|replaceAll|split|search|exec|match|test)\s*\(\s*(?:\/|['"\`])\s*<\s*\\?\/?\s*(?:body|head|html|base|title)\b`,
|
|
// `/<\/body>/i.test(html)` — literal first, operation second.
|
|
String.raw`(?:\/|['"\`])\s*<\s*\\?\/?\s*(?:body|head|html|base|title)\b[^/\n]*\/[gimsuy]*\s*\.\s*(?:test|exec)`,
|
|
].join("|"),
|
|
"i",
|
|
);
|
|
/**
|
|
* A boundary tag reached by a plain index scan. Legitimate as a *continuation*
|
|
* — once the shared locator has found an element's start, walking forward to
|
|
* its close tag is correct — so this only fires in files that never import the
|
|
* locator at all.
|
|
*/
|
|
const htmlBoundaryIndexOpPattern =
|
|
/(?:indexOf|lastIndexOf)\s*\(\s*['"`]\s*<\s*\/?\s*(?:body|head|html|base|title)\b/i;
|
|
const htmlBoundaryLocatorImport = "runtime/html-injection-points";
|
|
/**
|
|
* An anchored pattern asks "does this text start/end with the tag", which is a
|
|
* shape assertion on a buffer, not a search for a boundary inside a document.
|
|
* It cannot find the wrong one, because the anchor pins the position.
|
|
*/
|
|
const htmlBoundaryAnchoredPattern = /\/\^|\$\s*\/[gimsuy]*/;
|
|
|
|
async function checkHtmlBoundaryLookups(): Promise<boolean> {
|
|
const files = await collectRepositoryFiles(repoRoot, htmlBoundarySkippedDirectories);
|
|
const violations: { filePath: string; lineNumber: number; line: string }[] = [];
|
|
|
|
for (const filePath of files) {
|
|
if (filePath === htmlBoundaryOwnerPath) continue;
|
|
if (!htmlBoundaryCheckedPathPrefixes.some((prefix) => filePath.startsWith(prefix))) continue;
|
|
if (!htmlBoundarySourceExtensions.has(path.extname(filePath))) continue;
|
|
// Tests are where these shapes get *asserted against*, so they may say them.
|
|
if (isTestFile(path.basename(filePath)) || filePath.includes("/tests/")) continue;
|
|
|
|
const contents = await readFile(path.join(repoRoot, filePath), "utf8");
|
|
const usesLocator = contents.includes(htmlBoundaryLocatorImport);
|
|
contents.split("\n").forEach((line, index) => {
|
|
const offending =
|
|
!htmlBoundaryAnchoredPattern.test(line) &&
|
|
(htmlBoundaryPatternOpPattern.test(line) || (!usesLocator && htmlBoundaryIndexOpPattern.test(line)));
|
|
if (offending) {
|
|
violations.push({ filePath, lineNumber: index + 1, line: line.trim().slice(0, 120) });
|
|
}
|
|
});
|
|
}
|
|
|
|
if (violations.length > 0) {
|
|
console.error("HTML structural boundary check failed.");
|
|
console.error(
|
|
"These locate a `<head>`/`</body>`/`<base>`/`<title>` by text match. A tag an author",
|
|
);
|
|
console.error(
|
|
"wrote into a script string or an attribute would match first, and the injection would",
|
|
);
|
|
console.error("land inside their content (nexu-io/open-design#7410). Use findRealTagOffset /");
|
|
console.error(`findRealTagEnd from ${htmlBoundaryOwnerPath} instead.`);
|
|
for (const violation of violations) {
|
|
console.error(`- ${violation.filePath}:${violation.lineNumber}: ${violation.line}`);
|
|
}
|
|
return false;
|
|
}
|
|
|
|
console.log(`HTML structural boundary check passed: no hand-rolled boundary lookups outside ${htmlBoundaryOwnerPath}.`);
|
|
return true;
|
|
}
|
|
|
|
let crossAppImportsResult: Promise<boolean> | undefined;
|
|
|
|
function checkCrossAppImportsOnce(): Promise<boolean> {
|
|
crossAppImportsResult ??= Promise.resolve(checkCrossAppImports());
|
|
return crossAppImportsResult;
|
|
}
|
|
|
|
|
|
// Only the internal run-creation service may start a physical Run.
|
|
//
|
|
// The run analytics lifecycle is installed there, once, for every Run. Four
|
|
// daemon-internal callers used to reach past it and call the run registry
|
|
// directly; each of those Runs reported no `run_created` and no `run_finished`,
|
|
// and nothing said so (OPEND-2365). The service's `start` now requires the
|
|
// caller to declare its analytics identity, but that only binds callers who go
|
|
// through it — this check is what keeps the bypass from coming back.
|
|
const RUN_START_BYPASS_ALLOWLIST = new Set([
|
|
"apps/daemon/src/services/internal-run-service.ts",
|
|
]);
|
|
|
|
async function checkRunStartChokePoint(): Promise<boolean> {
|
|
const violations: string[] = [];
|
|
const daemonSource = path.join(repoRoot, "apps", "daemon", "src");
|
|
if (!(await repositoryDirectoryExists("apps/daemon/src"))) return true;
|
|
|
|
for (const repositoryPath of await collectRepositoryFiles(daemonSource)) {
|
|
if (!repositoryPath.endsWith(".ts")) continue;
|
|
if (RUN_START_BYPASS_ALLOWLIST.has(repositoryPath)) continue;
|
|
const source = await readFile(path.join(repoRoot, repositoryPath), "utf8");
|
|
source.split("\n").forEach((line, index) => {
|
|
if (!/\.runs\.start\s*\(/.test(line)) return;
|
|
violations.push(`${repositoryPath}:${index + 1} ${line.trim()}`);
|
|
});
|
|
}
|
|
|
|
if (violations.length > 0) {
|
|
console.error("Run start choke-point violations found:");
|
|
console.error("Start physical Runs through `internalRunCreation.start(run, analytics, starter)`");
|
|
console.error("so the Run analytics lifecycle is installed. See AGENTS.md -> Starting a physical Run.");
|
|
for (const violation of violations) console.error(`- ${violation}`);
|
|
return false;
|
|
}
|
|
|
|
console.log("Run start choke-point check passed: every physical Run starts through the internal run-creation service.");
|
|
return true;
|
|
}
|
|
|
|
const checks: GuardCheck[] = [
|
|
{ name: "residual JavaScript", run: checkResidualJavaScript },
|
|
{ name: "root package-manager lockfile", run: ({ repoRoot: root }) => checkRootPackageManagerLockfiles(root) },
|
|
{ name: "package dependency specs", run: checkPackageDependencySpecs },
|
|
{ name: "product neutrality", run: checkProductNeutrality },
|
|
{ name: "cross-app imports", run: checkCrossAppImportsOnce },
|
|
{ name: "HTML structural boundaries", run: checkHtmlBoundaryLookups },
|
|
{ name: "@ts-nocheck import resolution", run: checkTsNocheckImports },
|
|
{ name: "test layout", run: checkTestLayout },
|
|
{ name: "scripts test-free", run: checkScriptsTestFree },
|
|
{ name: "scripts library architecture", run: checkScriptsLibraryArchitecture },
|
|
{ name: "e2e layout", run: checkE2eLayout },
|
|
{ name: "web test layout", run: checkWebTestLayout },
|
|
{ name: "web import isolation", run: checkWebImportIsolation },
|
|
{ name: "run start choke point", run: checkRunStartChokePoint },
|
|
{ name: "tools layout", run: checkToolsLayout },
|
|
{ name: "style policy", run: checkStylePolicy },
|
|
{ name: "craft references", run: checkCraftReferences },
|
|
{ name: "what's new document", run: ({ repoRoot: root }) => checkWhatsNewDocument(root) },
|
|
{ name: "what's new publish workflow", run: ({ repoRoot: root }) => checkWhatsNewPublishWorkflow(root) },
|
|
{ name: "HTML plugin preview contracts", run: ({ repoRoot: root }) => checkHtmlPluginPreviewContracts(root) },
|
|
{ name: "plugin preview manifest", run: checkPluginPreviewManifest },
|
|
{ name: "design system manifests", run: checkDesignSystemManifests },
|
|
{ name: "design system package quality", run: checkDesignSystemPackageQuality },
|
|
{ name: "design system component fixture report", run: checkDesignSystemComponentFixtureReport },
|
|
{ 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 },
|
|
{ name: "design system flag parity", run: checkDesignSystemFlagParity },
|
|
{ name: "design system component manifest extraction", run: checkComponentsManifestExtraction },
|
|
];
|
|
|
|
const isMain = process.argv[1] ? import.meta.url === pathToFileURL(process.argv[1]).href : false;
|
|
if (isMain) {
|
|
// `--list-checks` is the machine-readable registry of repository guard checks.
|
|
if (process.argv[2] === "--list-checks") {
|
|
for (const check of checks) console.log(check.name);
|
|
} else if (!(await withNativeSourceParser(() => runGuardChecks(checks, { repoRoot })))) {
|
|
process.exitCode = 1;
|
|
}
|
|
}
|