mirror of
https://github.com/nexu-io/open-design.git
synced 2026-10-27 09:51:12 +01:00
* perf(ci): narrow zero-effect queue validation * docs(ci): keep scope spec state-focused
406 lines
15 KiB
TypeScript
406 lines
15 KiB
TypeScript
import { readFile, readdir } from "node:fs/promises";
|
|
import path from "node:path";
|
|
import ts from "typescript";
|
|
|
|
import { CERTAIN_EXEMPT_EXACT, CERTAIN_EXEMPT_PREFIXES } from "./scopes.ts";
|
|
|
|
// Guard for the certain-tier exempt core in `scripts/scopes.ts` (rule
|
|
// `certain-exempt-surface`; methodology in `specs/current/ci.md`).
|
|
//
|
|
// Boundary invariant: no source that a *skippable* merge-gate lane executes may
|
|
// consume a certain-exempt file. If that held false, a docs-only change could
|
|
// invalidate a lane the promoted rule tells the merge queue to skip.
|
|
//
|
|
// What "consumption" means here, at two precision levels:
|
|
// - a dot-relative literal (`../../docs/...`) that resolves from the file's
|
|
// repo location into the certain-exempt surface: flagged everywhere — it can
|
|
// only mean the repository surface.
|
|
// - a bare repo-relative literal (`docs/CHANGELOG`): flagged everywhere unless
|
|
// it is an argument to a known sandbox-fixture writer. Test files can also
|
|
// contain repo-root helpers, so exempting them wholesale would hide real
|
|
// consumption.
|
|
// - path.join/path.resolve expressions made from static segments (optionally
|
|
// anchored at repoRoot), and template literals whose static prefix already
|
|
// enters an exempt directory: flagged as the same repository dependency.
|
|
//
|
|
// Deliberately outside the checked surface:
|
|
// - root `scripts/` — policy-floor code. Preflight is armed on every plan, so
|
|
// its guard checks may read the exempt surface (product neutrality validates
|
|
// docs/ prose on every run).
|
|
// - `apps/landing-page/` — it IS the exempt surface; landing-page CI owns it.
|
|
|
|
const repoRoot = path.resolve(import.meta.dirname, "..");
|
|
|
|
const checkedRoots = ["apps", "packages", "tools", "e2e"] as const;
|
|
|
|
const skippedDirectoryNames = new Set([
|
|
".astro",
|
|
".next",
|
|
".od-data",
|
|
"dist",
|
|
"node_modules",
|
|
"out",
|
|
"reports",
|
|
"test-results",
|
|
"vendor",
|
|
]);
|
|
|
|
const skippedRepositoryPrefixes = ["apps/landing-page/"];
|
|
|
|
const checkedExtensions = new Set([".ts", ".tsx"]);
|
|
|
|
// These helpers interpret their path argument inside a temporary project
|
|
// workspace. Keep this list narrow: repo-root readers used by tests must remain
|
|
// visible to the guard.
|
|
const sandboxFixtureWriterNames = new Set(["writeProjectFile"]);
|
|
|
|
// File-level exceptions. Every entry must explain why the reference is not
|
|
// gate-lane consumption of exempt-file *content*; revisit the entry if the
|
|
// file's relationship to the exempt surface changes.
|
|
const allowedConsumers = new Map<string, string>([
|
|
[
|
|
"apps/daemon/tests/claude-design-import.test.ts",
|
|
"archive-entry and extracted-project fixture paths; every docs/ literal names data inside a temporary project",
|
|
],
|
|
[
|
|
"apps/daemon/tests/design-systems/file-score.test.ts",
|
|
"path-classification fixture strings; the test does not open the editor configuration files",
|
|
],
|
|
[
|
|
"apps/daemon/tests/project-classifiers.test.ts",
|
|
"file-kind classifier input; the LICENSE literal is never resolved or opened",
|
|
],
|
|
// NOTE: apps/daemon/tests/runtimes/trae-cli.test.ts is a conditional
|
|
// exception added in checkCertainExemptConsumption(), not here: it genuinely
|
|
// reads docs/agent-adapters.md, and its exemption holds only while the
|
|
// ci.yml daemon lane runs nothing but project-watchers.test.ts. See
|
|
// DAEMON_LANE_ALLOWED_TEST_COMMAND below.
|
|
[
|
|
"apps/web/tests/components/ChatPane.imported-folder-artifacts.test.tsx",
|
|
"imported-project artifact fixture paths rendered from in-memory test data",
|
|
],
|
|
[
|
|
"apps/web/tests/components/file-viewer-markdown-copy.test.tsx",
|
|
"project-relative markdown path inputs used to test URL construction, not repository reads",
|
|
],
|
|
[
|
|
"apps/web/tests/utils/inlineMentions.test.ts",
|
|
"in-memory mention parser fixture paths; no filesystem access occurs",
|
|
],
|
|
[
|
|
"tools/release/src/release-note/prepare.ts",
|
|
"docs/CHANGELOG feeds release-note preparation, which runs only in release workflows; @open-design/tools-release tests run in no ci.yml lane",
|
|
],
|
|
[
|
|
"e2e/tests/packaged-smoke-workflow.test.ts",
|
|
"scope-planner and workflow-text assertion fixtures; certain-exempt literals are passed as data and never opened",
|
|
],
|
|
[
|
|
"e2e/tests/scripts/product-neutrality.test.ts",
|
|
"virtual source path passed to the product-neutrality collector; it does not access the repository file",
|
|
],
|
|
[
|
|
"e2e/tests/scripts/scopes.test.ts",
|
|
"behavior fixtures for this very check: source snippets passed to the collector as data, never resolved or opened",
|
|
],
|
|
]);
|
|
|
|
// apps/daemon/tests/runtimes/trae-cli.test.ts genuinely consumes repository
|
|
// docs (docs/agent-adapters.md coverage assertion). It is tolerable only
|
|
// because ci.yml currently executes exactly one daemon test command, targeting
|
|
// a single test file that is not it. If the daemon test inventory ever widens,
|
|
// the exception disappears and the guard reports the read, forcing
|
|
// reclassification (docs → daemon scope rule, or relocating the consistency
|
|
// test to e2e/tests/) instead of letting the entry rot.
|
|
const DAEMON_LANE_ALLOWED_TEST_COMMAND =
|
|
"pnpm --filter @open-design/daemon exec vitest run -c vitest.config.ts tests/project-watchers.test.ts";
|
|
|
|
const CONDITIONAL_CONSUMER = "apps/daemon/tests/runtimes/trae-cli.test.ts";
|
|
|
|
function workflowRunBodies(workflow: string): string[] {
|
|
const runBodies: string[] = [];
|
|
const lines = workflow.split("\n");
|
|
|
|
for (let index = 0; index < lines.length; index += 1) {
|
|
const match = lines[index]?.match(/^(\s*)(?:-\s+)?run:\s*(.*)$/);
|
|
if (match === null || match === undefined) continue;
|
|
const indentation = match[1]?.length ?? 0;
|
|
const value = match[2]?.trim() ?? "";
|
|
if (!/^[|>][+-]?$/.test(value)) {
|
|
runBodies.push(value);
|
|
continue;
|
|
}
|
|
|
|
const bodyLines: string[] = [];
|
|
while (index + 1 < lines.length) {
|
|
const nextLine = lines[index + 1] ?? "";
|
|
const nextIndentation = nextLine.match(/^\s*/)?.[0].length ?? 0;
|
|
if (nextLine.trim() !== "" && nextIndentation <= indentation) break;
|
|
index += 1;
|
|
bodyLines.push(nextLine.slice(Math.min(nextIndentation, indentation + 2)));
|
|
}
|
|
runBodies.push(bodyLines.join(value.startsWith(">") ? " " : "\n"));
|
|
}
|
|
|
|
return runBodies;
|
|
}
|
|
|
|
function shellCommands(body: string): string[][] {
|
|
const commands: string[][] = [];
|
|
let command: string[] = [];
|
|
let token = "";
|
|
let quote: "'" | '"' | undefined;
|
|
let escaped = false;
|
|
|
|
const finishToken = (): void => {
|
|
if (token !== "") command.push(token);
|
|
token = "";
|
|
};
|
|
const finishCommand = (): void => {
|
|
finishToken();
|
|
if (command.length > 0) commands.push(command);
|
|
command = [];
|
|
};
|
|
|
|
for (const character of body) {
|
|
if (escaped) {
|
|
token += character;
|
|
escaped = false;
|
|
} else if (character === "\\" && quote !== "'") {
|
|
escaped = true;
|
|
} else if (quote !== undefined) {
|
|
if (character === quote) quote = undefined;
|
|
else token += character;
|
|
} else if (character === "'" || character === '"') {
|
|
quote = character;
|
|
} else if (/\s/.test(character)) {
|
|
finishToken();
|
|
if (character === "\n") finishCommand();
|
|
} else if (character === ";" || character === "|" || character === "&") {
|
|
finishCommand();
|
|
} else {
|
|
token += character;
|
|
}
|
|
}
|
|
finishCommand();
|
|
return commands;
|
|
}
|
|
|
|
export function daemonTestInvocationsFromWorkflow(workflow: string): string[] {
|
|
const invocations: string[] = [];
|
|
|
|
for (const tokens of workflowRunBodies(workflow).flatMap(shellCommands)) {
|
|
const pnpmIndex = tokens.indexOf("pnpm");
|
|
if (pnpmIndex === -1) continue;
|
|
const pnpmTokens = tokens.slice(pnpmIndex + 1);
|
|
const selectsDaemon = pnpmTokens.some(
|
|
(token, index) =>
|
|
((token === "--filter" || token === "-F") && pnpmTokens[index + 1] === "@open-design/daemon") ||
|
|
token === "--filter=@open-design/daemon" ||
|
|
token === "-F=@open-design/daemon" ||
|
|
((token === "--dir" || token === "-C") && pnpmTokens[index + 1] === "apps/daemon") ||
|
|
token === "--dir=apps/daemon" ||
|
|
token === "-C=apps/daemon",
|
|
);
|
|
if (!selectsDaemon) continue;
|
|
|
|
const testIndex = pnpmTokens.findIndex(
|
|
(token, index) =>
|
|
token === "test" ||
|
|
token === "vitest" ||
|
|
(token === "exec" && pnpmTokens[index + 1] === "vitest") ||
|
|
(token === "run" && pnpmTokens[index + 1] === "test"),
|
|
);
|
|
if (testIndex === -1) continue;
|
|
invocations.push(
|
|
`pnpm --filter @open-design/daemon ${pnpmTokens.slice(testIndex).join(" ")}`,
|
|
);
|
|
}
|
|
|
|
return invocations;
|
|
}
|
|
|
|
export function workflowRunsOnlyAllowedDaemonTest(workflow: string): boolean {
|
|
const invocations = daemonTestInvocationsFromWorkflow(workflow);
|
|
return invocations.length === 1 && invocations[0] === DAEMON_LANE_ALLOWED_TEST_COMMAND;
|
|
}
|
|
|
|
type ConsumptionViolation = {
|
|
filePath: string;
|
|
lineNumber: number;
|
|
literal: string;
|
|
};
|
|
|
|
function toRepositoryPath(filePath: string): string {
|
|
return path.relative(repoRoot, filePath).split(path.sep).join("/");
|
|
}
|
|
|
|
function landsInCertainExemptSurface(repositoryPath: string): boolean {
|
|
return (
|
|
CERTAIN_EXEMPT_PREFIXES.some((prefix) => repositoryPath.startsWith(prefix)) ||
|
|
(CERTAIN_EXEMPT_EXACT as readonly string[]).includes(repositoryPath)
|
|
);
|
|
}
|
|
|
|
function literalConsumesCertainExemptSurface(fromRepositoryPath: string, literal: string): boolean {
|
|
if (literal.startsWith("./") || literal.startsWith("../")) {
|
|
const resolved = path.posix.normalize(path.posix.join(path.posix.dirname(fromRepositoryPath), literal));
|
|
return landsInCertainExemptSurface(resolved);
|
|
}
|
|
return landsInCertainExemptSurface(literal);
|
|
}
|
|
|
|
function isSandboxFixtureWriterArgument(node: ts.Expression): boolean {
|
|
const call = node.parent;
|
|
if (!ts.isCallExpression(call) || !call.arguments.includes(node)) return false;
|
|
|
|
const callee = call.expression;
|
|
const name = ts.isIdentifier(callee)
|
|
? callee.text
|
|
: ts.isPropertyAccessExpression(callee)
|
|
? callee.name.text
|
|
: undefined;
|
|
return name !== undefined && sandboxFixtureWriterNames.has(name);
|
|
}
|
|
|
|
type StaticPath = {
|
|
path: string;
|
|
display: string;
|
|
prefixOnly: boolean;
|
|
};
|
|
|
|
function staticPathFromExpression(node: ts.Expression, sourceFile: ts.SourceFile): StaticPath | undefined {
|
|
if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) {
|
|
return { path: node.text, display: node.text, prefixOnly: false };
|
|
}
|
|
|
|
if (ts.isTemplateExpression(node)) {
|
|
const prefix = node.head.text;
|
|
if (!CERTAIN_EXEMPT_PREFIXES.some((exemptPrefix) => prefix.startsWith(exemptPrefix))) {
|
|
return undefined;
|
|
}
|
|
return { path: prefix, display: node.getText(sourceFile), prefixOnly: true };
|
|
}
|
|
|
|
if (!ts.isCallExpression(node) || !ts.isPropertyAccessExpression(node.expression)) return undefined;
|
|
const callee = node.expression;
|
|
if (
|
|
!ts.isIdentifier(callee.expression) ||
|
|
callee.expression.text !== "path" ||
|
|
(callee.name.text !== "join" && callee.name.text !== "resolve")
|
|
) {
|
|
return undefined;
|
|
}
|
|
|
|
const segments: string[] = [];
|
|
for (const [index, argument] of node.arguments.entries()) {
|
|
if (index === 0 && ts.isIdentifier(argument) && argument.text === "repoRoot") continue;
|
|
const segment = staticPathFromExpression(argument, sourceFile);
|
|
if (segment === undefined || segment.prefixOnly) return undefined;
|
|
segments.push(segment.path);
|
|
}
|
|
if (segments.length === 0) return undefined;
|
|
|
|
return {
|
|
path: path.posix.join(...segments),
|
|
display: node.getText(sourceFile),
|
|
prefixOnly: false,
|
|
};
|
|
}
|
|
|
|
export function collectCertainExemptConsumptionFromSource(
|
|
repositoryPath: string,
|
|
source: string,
|
|
): ConsumptionViolation[] {
|
|
const sourceFile = ts.createSourceFile(
|
|
repositoryPath,
|
|
source,
|
|
ts.ScriptTarget.Latest,
|
|
true,
|
|
repositoryPath.endsWith(".tsx") ? ts.ScriptKind.TSX : ts.ScriptKind.TS,
|
|
);
|
|
const violations: ConsumptionViolation[] = [];
|
|
|
|
const visit = (node: ts.Node): void => {
|
|
if (ts.isExpression(node)) {
|
|
const candidate = staticPathFromExpression(node, sourceFile);
|
|
if (candidate !== undefined && !isSandboxFixtureWriterArgument(node)) {
|
|
const consumes = candidate.prefixOnly
|
|
? CERTAIN_EXEMPT_PREFIXES.some((prefix) => candidate.path.startsWith(prefix))
|
|
: literalConsumesCertainExemptSurface(repositoryPath, candidate.path);
|
|
if (consumes) {
|
|
violations.push({
|
|
filePath: repositoryPath,
|
|
lineNumber: sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile)).line + 1,
|
|
literal: candidate.display,
|
|
});
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
ts.forEachChild(node, visit);
|
|
};
|
|
|
|
visit(sourceFile);
|
|
return violations;
|
|
}
|
|
|
|
async function collectCheckedFiles(directory: 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);
|
|
const repositoryPath = toRepositoryPath(fullPath);
|
|
|
|
if (entry.isDirectory()) {
|
|
if (
|
|
skippedDirectoryNames.has(entry.name) ||
|
|
entry.name.startsWith(".next-") ||
|
|
skippedRepositoryPrefixes.some((prefix) => `${repositoryPath}/`.startsWith(prefix))
|
|
) {
|
|
continue;
|
|
}
|
|
files.push(...(await collectCheckedFiles(fullPath)));
|
|
continue;
|
|
}
|
|
|
|
if (entry.isFile() && checkedExtensions.has(path.extname(entry.name))) {
|
|
files.push(repositoryPath);
|
|
}
|
|
}
|
|
|
|
return files;
|
|
}
|
|
|
|
export async function checkCertainExemptConsumption(): Promise<boolean> {
|
|
const violations: ConsumptionViolation[] = [];
|
|
|
|
const ciWorkflow = await readFile(path.join(repoRoot, ".github/workflows/ci.yml"), "utf8");
|
|
const conditionallyAllowed = workflowRunsOnlyAllowedDaemonTest(ciWorkflow)
|
|
? new Set([CONDITIONAL_CONSUMER])
|
|
: new Set<string>();
|
|
|
|
for (const root of checkedRoots) {
|
|
for (const repositoryPath of await collectCheckedFiles(path.join(repoRoot, root))) {
|
|
if (allowedConsumers.has(repositoryPath) || conditionallyAllowed.has(repositoryPath)) continue;
|
|
const source = await readFile(path.join(repoRoot, repositoryPath), "utf8");
|
|
violations.push(...collectCertainExemptConsumptionFromSource(repositoryPath, source));
|
|
}
|
|
}
|
|
|
|
if (violations.length > 0) {
|
|
console.error("Certain-exempt surface consumption found in gate-lane sources:");
|
|
for (const violation of violations) {
|
|
console.error(`- ${violation.filePath}:${violation.lineNumber} \`${violation.literal}\``);
|
|
}
|
|
console.error(
|
|
"Certain-tier exempt files must stay unconsumed by skippable merge-gate lanes (see specs/current/ci.md). Move the dependency, or add a justified allowlist entry in scripts/check-certain-exempt-consumption.ts.",
|
|
);
|
|
return false;
|
|
}
|
|
|
|
console.log("Certain-exempt consumption check passed: gate-lane sources do not read the certain-exempt surface.");
|
|
return true;
|
|
}
|