Files
open-design/scripts/guard.ts
PerishFireandGitHub bbdd4e84b5 chore: enforce test directory conventions (#496)
* chore: enforce test directory conventions

Move package, app, and tool tests out of src and add guard enforcement so source directories stay source-only.

* ci: use guard and package-scoped tests

Run the new repository guard in CI and keep test execution aligned with package-scoped commands after removing root aliases.

* ci: align stable release guard check

Use the new repository guard in stable release verification after replacing the residual-JS-only script.

* chore: tighten test layout enforcement

Enforce sibling tests directories, typecheck moved test suites with dedicated configs, and refresh remaining guidance that pointed at src-based tests.

* chore: clarify no-emit test tsconfigs

Explicitly disable declaration-only emit in test tsconfigs so review tooling sees they are no-emit typecheck configs.
2026-05-05 15:34:22 +08:00

224 lines
6.6 KiB
TypeScript

import { readdir } from "node:fs/promises";
import path from "node:path";
const repoRoot = path.resolve(import.meta.dirname, "..");
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([
"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/pack/resources/mac/notarize.cjs",
// electron-builder hook path; CJS compatibility entry used by tools-pack mac builds.
"tools/pack/resources/mac/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/",
// Vendored upstream HyperFrames skill helper scripts.
"skills/hyperframes/scripts/",
// Vendored upstream html-ppt skill runtime assets (lewislulu/html-ppt-skill).
"skills/html-ppt/assets/",
"test-results/",
"vendor/",
];
function isResidualAllowedPath(repositoryPath: string): boolean {
if (residualAllowedExactPaths.has(repositoryPath)) return true;
return residualAllowedPathPrefixes.some((prefix) => repositoryPath.startsWith(prefix));
}
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", "dist", "node_modules", "out"]);
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 checks: GuardCheck[] = [
{ name: "residual JavaScript", run: checkResidualJavaScript },
{ name: "test layout", run: checkTestLayout },
];
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;
}