Files
2026-03-24 18:34:56 +08:00

434 lines
14 KiB
JavaScript

import fs from "node:fs";
import path from "node:path";
import { execFileSync } from "node:child_process";
import { fileURLToPath } from "node:url";
const zeroShaPattern = /^0{40}$/;
const scriptDir = path.dirname(fileURLToPath(import.meta.url));
export const repoRoot = path.resolve(scriptDir, "../..");
export const artifactsDir = path.join(repoRoot, ".artifacts", "harness");
export const pnpmBin = process.platform === "win32" ? "pnpm.cmd" : "pnpm";
export const suiteDefinitions = {
static: {
description: "Fast static validation: lint and workspace typecheck.",
commands: [
{ args: ["lint"], label: "Lint repository" },
{ args: ["typecheck"], label: "Typecheck workspace" }
]
},
component: {
description: "Fast component feedback: lint, typecheck, and unit coverage.",
commands: [
{ args: ["lint"], label: "Lint repository" },
{ args: ["typecheck"], label: "Typecheck workspace" },
{ args: ["test"], label: "Run component tests" }
]
},
docs: {
description: "Build the Storybook review surface.",
commands: [{ args: ["build:docs"], label: "Build Storybook" }]
},
a11y: {
description: "Run Storybook accessibility validation and report violations/incomplete results.",
commands: [{ args: ["test:e2e:a11y"], label: "Run Storybook accessibility checks" }]
},
"docs-smoke": {
description: "Exercise high-value Storybook flows with Playwright.",
commands: [{ args: ["test:e2e:smoke"], label: "Run Storybook smoke tests" }]
},
consumers: {
description: "Validate generated registry metadata and downstream consumers.",
commands: [
{ args: ["registry:check"], label: "Check registry metadata" },
{ args: ["test:registry:consumer"], label: "Run registry consumer smoke test" },
{ args: ["test:package:consumer"], label: "Run package consumer smoke test" }
]
},
pr: {
description: "Baseline pull request gate for source, docs, and consumer surfaces.",
commands: [
{ args: ["lint"], label: "Lint repository" },
{ args: ["typecheck"], label: "Typecheck workspace" },
{ args: ["test"], label: "Run component tests" },
{ args: ["build"], label: "Build packages" },
{ args: ["build:docs"], label: "Build Storybook" },
{ args: ["test:e2e:a11y"], label: "Run Storybook accessibility checks" },
{ args: ["registry:check"], label: "Check registry metadata" },
{ args: ["test:registry:consumer"], label: "Run registry consumer smoke test" },
{ args: ["test:package:consumer"], label: "Run package consumer smoke test" }
]
},
release: {
description: "Full release gate, including browser-driven Storybook coverage.",
commands: [
{ args: ["lint"], label: "Lint repository" },
{ args: ["typecheck"], label: "Typecheck workspace" },
{ args: ["test"], label: "Run component tests" },
{ args: ["build"], label: "Build packages" },
{ args: ["build:docs"], label: "Build Storybook" },
{ args: ["test:e2e:a11y"], label: "Run Storybook accessibility checks" },
{ args: ["registry:check"], label: "Check registry metadata" },
{ args: ["test:registry:consumer"], label: "Run registry consumer smoke test" },
{ args: ["test:package:consumer"], label: "Run package consumer smoke test" },
{ args: ["test:e2e:smoke"], label: "Run Storybook smoke tests" }
]
}
};
const suiteOrder = [
"static",
"component",
"docs",
"a11y",
"docs-smoke",
"consumers",
"pr",
"release"
];
const prSupersededSuites = ["static", "component", "docs", "a11y", "docs-smoke", "consumers"];
const releaseSupersededSuites = [...prSupersededSuites, "pr"];
const rootStaticFiles = new Set([
"eslint.config.mjs",
"package.json",
"playwright.config.ts",
"pnpm-lock.yaml",
"pnpm-workspace.yaml",
"tsconfig.base.json",
"tsconfig.json",
"vitest.config.ts"
]);
function normalizeFilePath(filePath) {
return filePath.replace(/\\/g, path.posix.sep).split(path.sep).join(path.posix.sep);
}
function isExactMatch(filePath, exactPaths) {
return exactPaths.has(filePath);
}
function isWithin(filePath, directory) {
return filePath === directory || filePath.startsWith(`${directory}/`);
}
function isPackageSourceChange(filePath) {
return isWithin(filePath, "packages/ui/src") || isWithin(filePath, "packages/tokens/src");
}
function isPackageContractChange(filePath) {
return (
isPackageSourceChange(filePath) ||
filePath === "packages/ui/package.json" ||
filePath === "packages/tokens/package.json"
);
}
function isDocsSurfaceChange(filePath) {
return isWithin(filePath, "apps/docs") || isWithin(filePath, ".storybook");
}
function isDocsInteractiveChange(filePath) {
return (
isWithin(filePath, "apps/docs/src") ||
isWithin(filePath, ".storybook") ||
filePath === "playwright.config.ts"
);
}
function isConsumerSurfaceChange(filePath) {
return (
isWithin(filePath, "registry") ||
isWithin(filePath, "tests/package-consumer") ||
isWithin(filePath, "tests/registry") ||
filePath === "scripts/build-registry.mjs" ||
filePath === "scripts/registry-install.mjs" ||
isPackageContractChange(filePath)
);
}
function isHarnessControlPlaneChange(filePath) {
return (
isWithin(filePath, "scripts/harness") ||
filePath === "playwright.config.ts" ||
filePath === "vitest.config.ts" ||
isWithin(filePath, "tests/e2e/support") ||
filePath === "AGENTS.md" ||
filePath === "CONTRIBUTING.md" ||
filePath === "README.md" ||
filePath === "docs/harness-engineering.md" ||
filePath === "docs/orchestration.md" ||
isWithin(filePath, "docs/exec-plans")
);
}
function isReleaseRiskChange(filePath) {
return (
filePath === "package.json" ||
filePath === "docs/releasing.md" ||
filePath === "docs/registry.md" ||
filePath === "scripts/release-metadata.mjs" ||
filePath === "scripts/build-registry.mjs" ||
filePath === "scripts/registry-install.mjs" ||
filePath === "packages/ui/package.json" ||
filePath === "packages/tokens/package.json" ||
isWithin(filePath, ".github/workflows") ||
isWithin(filePath, "tests/package-consumer") ||
isWithin(filePath, "tests/registry")
);
}
function isStaticSurfaceChange(filePath) {
return (
isPackageContractChange(filePath) ||
isDocsSurfaceChange(filePath) ||
isWithin(filePath, "tests") ||
isWithin(filePath, "scripts") ||
isWithin(filePath, ".github/workflows") ||
isExactMatch(filePath, rootStaticFiles) ||
isHarnessControlPlaneChange(filePath) ||
isReleaseRiskChange(filePath)
);
}
function isLikelyCodeChange(filePath) {
return /\.(?:cjs|css|cts|js|json|jsx|mdx|mjs|mts|scss|ts|tsx|yaml|yml)$/.test(filePath);
}
function runGitCommand(args) {
return execFileSync("git", args, {
cwd: repoRoot,
encoding: "utf8"
})
.split("\n")
.map((line) => line.trim())
.filter(Boolean)
.map((line) => normalizeFilePath(line));
}
function getWorkingTreeChangedFiles(runGitCommandFn = runGitCommand) {
const files = new Set([
...runGitCommandFn(["diff", "--name-only", "--diff-filter=ACMR", "HEAD"]),
...runGitCommandFn(["diff", "--cached", "--name-only", "--diff-filter=ACMR", "HEAD"]),
...runGitCommandFn(["ls-files", "--others", "--exclude-standard"])
]);
return [...files].sort();
}
export function getChangedFiles(options = {}) {
const changedFiles = options.changedFiles ?? [];
const from = options.from ?? null;
const to = options.to ?? null;
const runGitCommandFn = options.runGitCommandFn ?? runGitCommand;
if (changedFiles.length > 0) {
return [...new Set(changedFiles.map((filePath) => normalizeFilePath(filePath)))].sort();
}
if (from || to) {
const toRef = to ?? "HEAD";
if (from && zeroShaPattern.test(from)) {
return [
...new Set([
...runGitCommandFn(["ls-files"]),
...runGitCommandFn(["ls-files", "--others", "--exclude-standard"])
])
].sort();
}
if (!from) {
throw new Error("Expected --from when selecting changed files from git refs.");
}
return [...runGitCommandFn(["diff", "--name-only", "--diff-filter=ACMR", `${from}...${toRef}`])].sort();
}
return getWorkingTreeChangedFiles(runGitCommandFn);
}
function addReason(reasonMap, suiteName, filePath, reason) {
const currentReasons = reasonMap.get(suiteName) ?? [];
currentReasons.push({ file: filePath, reason });
reasonMap.set(suiteName, currentReasons);
}
export function selectSuitesForChangedFiles(changedFiles) {
const normalizedFiles = [...new Set(changedFiles.map((filePath) => normalizeFilePath(filePath)))].sort();
const selectedSuites = new Set();
const reasons = new Map();
const unmatchedFiles = [];
for (const filePath of normalizedFiles) {
let matched = false;
if (isReleaseRiskChange(filePath)) {
selectedSuites.add("release");
addReason(reasons, "release", filePath, "Release or consumer control plane changed.");
matched = true;
}
if (isHarnessControlPlaneChange(filePath)) {
selectedSuites.add("pr");
addReason(reasons, "pr", filePath, "Harness control plane changed.");
matched = true;
}
if (isPackageContractChange(filePath)) {
selectedSuites.add("component");
selectedSuites.add("docs");
selectedSuites.add("a11y");
selectedSuites.add("consumers");
addReason(reasons, "component", filePath, "Package contract or source changed.");
addReason(reasons, "docs", filePath, "Package changes affect the Storybook review surface.");
addReason(reasons, "a11y", filePath, "Package changes can alter Storybook accessibility results.");
addReason(reasons, "consumers", filePath, "Package changes affect downstream consumers.");
matched = true;
if (isPackageSourceChange(filePath)) {
selectedSuites.add("docs-smoke");
addReason(reasons, "docs-smoke", filePath, "Interactive package source changed.");
}
}
if (isDocsSurfaceChange(filePath)) {
selectedSuites.add("static");
selectedSuites.add("docs");
selectedSuites.add("a11y");
addReason(reasons, "static", filePath, "Docs or Storybook source changed.");
addReason(reasons, "docs", filePath, "Docs or Storybook source changed.");
addReason(reasons, "a11y", filePath, "Docs or Storybook source changed.");
matched = true;
if (isDocsInteractiveChange(filePath)) {
selectedSuites.add("docs-smoke");
addReason(reasons, "docs-smoke", filePath, "Storybook stories or smoke harness changed.");
}
}
if (isConsumerSurfaceChange(filePath)) {
selectedSuites.add("static");
selectedSuites.add("consumers");
addReason(reasons, "static", filePath, "Registry or consumer validation surface changed.");
addReason(reasons, "consumers", filePath, "Registry or consumer validation surface changed.");
matched = true;
}
if (isStaticSurfaceChange(filePath)) {
selectedSuites.add("static");
addReason(reasons, "static", filePath, "Static validation surface changed.");
matched = true;
}
if (!matched) {
unmatchedFiles.push(filePath);
}
}
if (selectedSuites.has("component")) {
selectedSuites.delete("static");
reasons.delete("static");
}
if (selectedSuites.has("pr")) {
for (const suiteName of prSupersededSuites) {
selectedSuites.delete(suiteName);
reasons.delete(suiteName);
}
}
if (selectedSuites.has("release")) {
for (const suiteName of releaseSupersededSuites) {
selectedSuites.delete(suiteName);
reasons.delete(suiteName);
}
}
if (selectedSuites.size === 0 && unmatchedFiles.some((filePath) => isLikelyCodeChange(filePath))) {
selectedSuites.add("static");
for (const filePath of unmatchedFiles) {
if (isLikelyCodeChange(filePath)) {
addReason(reasons, "static", filePath, "Fallback static validation for unmatched code change.");
}
}
}
return {
changedFiles: normalizedFiles,
reasons: Object.fromEntries(
[...reasons.entries()].map(([suiteName, suiteReasons]) => [suiteName, suiteReasons])
),
suites: suiteOrder.filter((suiteName) => selectedSuites.has(suiteName)),
unmatchedFiles
};
}
export function getCommandsForSuites(suiteNames) {
const seenCommands = new Set();
const commands = [];
for (const suiteName of suiteNames) {
const suite = suiteDefinitions[suiteName];
if (!suite) {
throw new Error(`Unknown suite "${suiteName}".`);
}
for (const command of suite.commands) {
const key = command.args.join("\u0000");
if (seenCommands.has(key)) {
continue;
}
seenCommands.add(key);
commands.push({
...command,
suite: suiteName
});
}
}
return commands;
}
export function ensureKnownSuite(name) {
if (name === "changed") {
return;
}
if (!(name in suiteDefinitions)) {
const knownSuites = [...suiteOrder, "changed"].join(", ");
throw new Error(`Unknown suite "${name}". Expected one of: ${knownSuites}.`);
}
}
export function writeHarnessArtifact(name, payload, outputDir = artifactsDir) {
fs.mkdirSync(outputDir, { recursive: true });
const outputPath = path.join(outputDir, `${name}.json`);
fs.writeFileSync(outputPath, `${JSON.stringify(payload, null, 2)}\n`, "utf8");
return outputPath;
}
export function formatSuiteListing() {
return [...suiteOrder, "changed"]
.map((suiteName) => {
if (suiteName === "changed") {
return {
name: suiteName,
description: "Select suites from git diff or working tree changes before validating."
};
}
return {
name: suiteName,
description: suiteDefinitions[suiteName].description
};
});
}