mirror of
https://github.com/nexu-io/open-design.git
synced 2026-10-27 09:51:12 +01:00
* chore(scripts): make root scripts/ test-free and purify the guard entrypoint
Root scripts/ is now source-only, enforced by a new "scripts test-free"
guard check (any *.test.{ts,tsx,js,jsx,mjs,cjs} under scripts/ fails
pnpm guard). The root guard script becomes a pure tsx ./scripts/guard.ts
invocation instead of chaining a hand-maintained node --test file list.
Script behavior-contract coverage follows the boundary e2e/AGENTS.md
already declares for e2e/tests/scripts/: the fork PR approval security
tests migrate there (converted to Vitest, 24 tests passing), and the
remaining script unit tests are deleted — five of them (batch-design-
system-test, check-design-system-package-quality, plugin-previews-diff,
plugin-previews-gc, migrate-to-plugins/example) were orphans no runner
ever executed; the guard-chained checker tests covered logic whose
repo-wide enforcement remains in guard.ts checks. Coverage with a real
test claim gets rebuilt in e2e/tests/scripts/ on demand.
* test(ci): pin characterization goldens for scopes.ts scope detection
Golden tests run scripts/scopes.ts as a subprocess exactly the way ci.yml
does, with gh stubbed through the existing OPEN_DESIGN_GH_NODE_SCRIPT seam
(hermetic stub materialized per run), and pin the complete 20-key output
plan for a matrix of changed-file sets across pull_request,
workflow_dispatch (hot/full), and merge_group events.
They deliberately pin current quirks (skills/*.md arming daemon+web without
the ui-critical fallback; e2e/ui/*.md arming ui_p0 with workspace validation
off; dispatch-hot skipping the workspace_validation re-derivation) so the
upcoming rule-table restructuring can prove behavior preservation
byte-for-byte.
* refactor(ci): restructure scopes.ts into a confidence-tiered rule table
Scope detection becomes data plus a pure evaluator: every changed file is
classified by an additive rule table (union of matched effects, minimum
confidence across matched rules), and an evaluation context brings a trust
threshold — files below it, or matching no rule, escalate fail-closed to
the full radius. Today's behavior is exactly the degenerate case of this
structure (all rules medium, certain set empty): pull_request/hot believes
everything, the merge queue believes nothing and stays full. The
characterization goldens pass unchanged, byte-for-byte.
The exempt surface becomes an explicit empty-radius rule instead of a
special case, so exemptions can be split, rated, and promoted per rule.
Promotion to "certain" requires a guard field naming the enforcing check
(enforced by a rule-table invariant test).
New offline entry points for rule iteration: `plan --context
pr|merge-queue|full --files ...` evaluates without any GitHub environment
and prints { plan, trace }; `rules` lists the table. Every evaluation now
returns a decision trace (per-rule hit counts, per-file escalations, and
an ifTrustAll shadow plan that shows what medium-confidence rules would
have allowed — the evidence stream for future certain-tier promotions);
github-output mode logs it and writes a step-summary table without
touching the frozen 20-key output contract.
The legacy dispatch-hot asymmetry (no workspace_validation re-derivation)
is preserved behind an explicit option and pinned by the goldens.
* feat(ci): evaluate merge-group union diffs at the certain threshold with shadow trace
The merge queue now resolves the queued group's union diff
(compare base_sha...head_sha from the merge_group event payload) and
evaluates it through the rule table at the "certain" trust threshold.
While the certain rule set is empty every file escalates and the queue
keeps running everything — the goldens pin this byte-for-byte — but the
evaluation's ifTrustAll shadow column now records, for every queued
group, what medium-confidence rules would have allowed: the evidence
stream that future certain-tier promotions will cite.
Queue lanes derive from the evaluated outputs (ci_mode stays reported as
"full", but lanes are no longer unconditionally forced), so promoting a
rule to "certain" later is a pure rule-table change with no further
structural work.
Fail-safe behavior is explicit and tested: resolution errors and empty
resolutions fall open to the full plan without failing the scopes job
(queue throughput must never depend on this evaluation), while
pull_request resolution failures keep failing the run as before.
Changed-file resolution no longer filters out removed files (manual hot
runs previously did): deleting a runtime source file must trigger the
same validation scopes as editing it. This is the only behavior change,
and it is strictly fail-closed.
* fix(ci): preserve guard contracts and fail closed on compare limits
Restore the eight guard behavior suites in the e2e script lane and make merge-group compare resolution treat GitHub's 300-file ceiling as incomplete.
Generated-By: looper 0.11.0 (runner=fixer, agent=codex)
* fix(ci): enforce extension-agnostic script test boundary
Match every suffix after .test. and cover TypeScript, MTS, and non-test filenames in the e2e script suite.
Generated-By: looper 0.11.0 (runner=fixer, agent=codex)
* fix(ci): route script contract tests to e2e
Ensure changes under e2e/tests/scripts arm E2E Vitest with the same daemon and web scope effects as their root script counterparts, and pin the behavior with a PR golden.\n\nGenerated-By: looper 0.11.0 (runner=fixer, agent=codex)
---------
Co-authored-by: Looper <looper@noreply.github.com>
1371 lines
49 KiB
TypeScript
1371 lines
49 KiB
TypeScript
import { readFile, readdir } from "node:fs/promises";
|
|
import path from "node:path";
|
|
import { pathToFileURL } from "node:url";
|
|
import ts from "typescript";
|
|
|
|
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 { checkPluginPreviewManifest } from "./check-plugin-preview-manifest.ts";
|
|
import { validatePlaywrightSuiteTopology } from "../e2e/lib/playwright/suites.ts";
|
|
import {
|
|
checkDesignSystemA1RequiredTokens,
|
|
checkDesignSystemA2DefaultsParity,
|
|
checkDesignSystemA2RequiredTokens,
|
|
checkDesignSystemBSlotRequiredTokens,
|
|
checkDesignSystemTokenFixtureSync,
|
|
checkDesignSystemUnknownTokens,
|
|
} from "./check-tokens-fixture-sync.ts";
|
|
import { checkCraftReferences } from "./lint-craft-references.ts";
|
|
import { collectCssHardcodedColorMatches, cssWideAndSpecialColorKeywords, realNamedColors } from "./style-policy.ts";
|
|
|
|
const repoRoot = path.resolve(import.meta.dirname, "..");
|
|
const allowedE2eScripts = new Set([
|
|
"e2e/scripts/playwright.ts",
|
|
"e2e/scripts/release-smoke.ts",
|
|
"e2e/scripts/visual-report.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/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",
|
|
// Shared nav enhancer for the landing-page static `/community/` pages,
|
|
// which are verbatim HTML served straight from `public/` (not Astro-
|
|
// compiled). It must ship as a browser-loadable `.js` asset, same as the
|
|
// web notifications service worker above.
|
|
"apps/landing-page/public/community/_site-nav.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 --runtime opencode` 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",
|
|
"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",
|
|
"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 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/",
|
|
"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;
|
|
}
|
|
|
|
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;
|
|
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);
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
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,
|
|
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.`,
|
|
);
|
|
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 = ts.createSourceFile(
|
|
repositoryPath,
|
|
source,
|
|
ts.ScriptTarget.Latest,
|
|
true,
|
|
repositoryPath.endsWith(".tsx") ? ts.ScriptKind.TSX : ts.ScriptKind.TS,
|
|
);
|
|
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]);
|
|
}
|
|
|
|
ts.forEachChild(node, 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[] = [];
|
|
|
|
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");
|
|
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|PaletteTweaks|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 Open Design 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 Open Design 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 Open Design 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 Open Design 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;
|
|
}
|
|
|
|
async function checkCiTopology(): Promise<boolean> {
|
|
const ciWorkflow = await readFile(path.join(repoRoot, ".github/workflows/ci.yml"), "utf8");
|
|
const errors = [
|
|
...validatePlaywrightSuiteTopology(),
|
|
...[
|
|
"run: node --experimental-strip-types scripts/scopes.ts github-output",
|
|
"ci_mode: ${{ steps.detect.outputs.ci_mode }}",
|
|
"ui_p0_validation_required: ${{ steps.detect.outputs.ui_p0_validation_required }}",
|
|
"run_ui_p0: ${{ steps.detect.outputs.run_ui_p0 }}",
|
|
"ui_p0_matrix: ${{ steps.detect.outputs.ui_p0_matrix }}",
|
|
"visual_matrix: ${{ steps.detect.outputs.visual_matrix }}",
|
|
"include: ${{ fromJSON(needs.scopes.outputs.ui_p0_matrix) }}",
|
|
"include: ${{ fromJSON(needs.scopes.outputs.visual_matrix) }}",
|
|
"needs.scopes.outputs.run_ui_p0 == 'true'",
|
|
"pnpm -C e2e exec tsx scripts/playwright.ts run-ui-group critical-extras",
|
|
"pnpm -C e2e exec tsx scripts/playwright.ts run-ui-group ${{ matrix.shard }}",
|
|
]
|
|
.filter((needle) => !ciWorkflow.includes(needle))
|
|
.map((needle) => `.github/workflows/ci.yml is missing ${needle}`),
|
|
];
|
|
|
|
if (errors.length > 0) {
|
|
console.error("CI topology check failed:");
|
|
for (const error of errors) console.error(`- ${error}`);
|
|
return false;
|
|
}
|
|
|
|
console.log("CI topology check passed: scopes, Playwright suites, and workflow matrices stay aligned.");
|
|
return true;
|
|
}
|
|
|
|
const checks: GuardCheck[] = [
|
|
{ name: "residual JavaScript", run: checkResidualJavaScript },
|
|
{ name: "package dependency specs", run: checkPackageDependencySpecs },
|
|
{ name: "product neutrality", run: checkProductNeutrality },
|
|
{ name: "cross-app imports", run: checkCrossAppImports },
|
|
{ name: "@ts-nocheck import resolution", run: checkTsNocheckImports },
|
|
{ name: "test layout", run: checkTestLayout },
|
|
{ name: "scripts test-free", run: checkScriptsTestFree },
|
|
{ name: "e2e layout", run: checkE2eLayout },
|
|
{ name: "web test layout", run: checkWebTestLayout },
|
|
{ name: "web import isolation", run: checkWebImportIsolation },
|
|
{ name: "tools layout", run: checkToolsLayout },
|
|
{ name: "style policy", run: checkStylePolicy },
|
|
{ name: "CI topology", run: checkCiTopology },
|
|
{ name: "craft references", run: checkCraftReferences },
|
|
{ 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 },
|
|
];
|
|
|
|
async function runChecks(): Promise<boolean> {
|
|
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);
|
|
}
|
|
}
|
|
|
|
return results.every(Boolean);
|
|
}
|
|
|
|
const isMain = process.argv[1] ? import.meta.url === pathToFileURL(process.argv[1]).href : false;
|
|
if (isMain && !(await runChecks())) {
|
|
process.exitCode = 1;
|
|
}
|