282 lines
9.2 KiB
JavaScript
282 lines
9.2 KiB
JavaScript
#!/usr/bin/env node
|
|
/* eslint-disable no-console */
|
|
import { spawn } from "node:child_process";
|
|
import { readdir, readFile, stat } from "node:fs/promises";
|
|
import path from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
|
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
export const repoRoot = path.resolve(__dirname, "..");
|
|
|
|
export const buildCommand = { command: "pnpm", args: ["run", "build"] };
|
|
export const runtimeScanRoots = ["package.json", "apps", "packages", "scripts", "Dockerfile"];
|
|
export const excludedRuntimePrefixes = ["packages/plugins/"];
|
|
|
|
export const ignoredDirectoryNames = new Set([
|
|
".git",
|
|
".cache",
|
|
".next",
|
|
".react-router",
|
|
".tc",
|
|
".turbo",
|
|
".vite",
|
|
"build",
|
|
"cache",
|
|
"coverage",
|
|
"dist",
|
|
"generated",
|
|
"node_modules",
|
|
"tmp",
|
|
"temp",
|
|
"vendor",
|
|
]);
|
|
|
|
export const ignoredFileNames = new Set([
|
|
"package-lock.json",
|
|
"pnpm-lock.yaml",
|
|
"yarn.lock",
|
|
]);
|
|
|
|
export const sourceFileExtensions = new Set([
|
|
".json",
|
|
".js",
|
|
".jsx",
|
|
".mjs",
|
|
".cjs",
|
|
".ts",
|
|
".tsx",
|
|
]);
|
|
|
|
const forbiddenPatterns = [
|
|
{ id: "toolctrl-plugins-package", pattern: /(?:^|["'`\s])@toolctrl\/plugins(?:["'`\s/]|$)/ },
|
|
{ id: "packages-plugins-path", pattern: /(?:^|["'`\s./])packages\/plugins(?:["'`\s/]|$)/ },
|
|
];
|
|
|
|
function isAllowedPluginMetadataReference(relativeFilePath, line) {
|
|
const normalized = normalizeRelativePath(relativeFilePath);
|
|
if (normalized === "Dockerfile" && line.trim() === "COPY packages/plugins/package.json packages/plugins/package.json") {
|
|
return true;
|
|
}
|
|
return normalized === "scripts/coverage-targets.json";
|
|
}
|
|
|
|
function toRelativePath(root, absolutePath) {
|
|
return path.relative(root, absolutePath).split(path.sep).join("/");
|
|
}
|
|
|
|
function normalizeRelativePath(relativePath) {
|
|
return relativePath.split(path.sep).join("/").replace(/^\.\//, "");
|
|
}
|
|
|
|
export function isIgnoredDirectory(name, relativePath = name) {
|
|
const normalized = normalizeRelativePath(relativePath);
|
|
return ignoredDirectoryNames.has(name) || ignoredDirectoryNames.has(normalized) || name.endsWith(".cache") || normalized.includes("/__generated__/");
|
|
}
|
|
|
|
export function isGeneratedOrIgnoredFile(filePath) {
|
|
const name = path.basename(filePath);
|
|
return (
|
|
ignoredFileNames.has(name) ||
|
|
name.endsWith(".generated.ts") ||
|
|
name.endsWith(".generated.tsx") ||
|
|
name.endsWith(".gen.ts") ||
|
|
name.endsWith(".gen.tsx") ||
|
|
name.endsWith(".min.js") ||
|
|
name.endsWith(".map") ||
|
|
name.endsWith(".d.ts")
|
|
);
|
|
}
|
|
|
|
export function isSourceFile(filePath) {
|
|
return path.basename(filePath) === "Dockerfile" || sourceFileExtensions.has(path.extname(filePath));
|
|
}
|
|
|
|
export function isTestFile(filePath) {
|
|
const normalized = normalizeRelativePath(filePath);
|
|
const name = path.basename(normalized);
|
|
return (
|
|
/(?:^|[./_-])(?:test|spec|stories|story)\.[cm]?[jt]sx?$/.test(name) ||
|
|
/\.(?:test|spec|stories|story)\.[cm]?[jt]sx?$/.test(name) ||
|
|
normalized.includes("/__tests__/") ||
|
|
normalized.includes("/test/") ||
|
|
normalized.includes("/tests/") ||
|
|
normalized.includes("/fixtures/") ||
|
|
normalized.includes("/mocks/") ||
|
|
normalized.includes("/__mocks__/")
|
|
);
|
|
}
|
|
|
|
export function isExcludedRuntimePath(relativePath) {
|
|
const normalized = normalizeRelativePath(relativePath);
|
|
return excludedRuntimePrefixes.some((prefix) => normalized === prefix.slice(0, -1) || normalized.startsWith(prefix));
|
|
}
|
|
|
|
async function pathExists(root, relativePath) {
|
|
try {
|
|
await stat(path.join(root, relativePath));
|
|
return true;
|
|
} catch (error) {
|
|
if (error?.code === "ENOENT") return false;
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
export async function collectSourceFiles(root = repoRoot, scanRoots = runtimeScanRoots) {
|
|
const files = [];
|
|
const roots = [];
|
|
const missingRoots = [];
|
|
|
|
async function walk(directory) {
|
|
const entries = await readdir(directory, { withFileTypes: true });
|
|
for (const entry of entries) {
|
|
const absolutePath = path.join(directory, entry.name);
|
|
const relativePath = toRelativePath(root, absolutePath);
|
|
if (entry.isDirectory()) {
|
|
if (isIgnoredDirectory(entry.name, relativePath) || isExcludedRuntimePath(relativePath)) continue;
|
|
await walk(absolutePath);
|
|
continue;
|
|
}
|
|
if (!entry.isFile()) continue;
|
|
if (relativePath === "scripts/verify-core-build-without-plugins-smoke.mjs") continue;
|
|
if (relativePath === "scripts/scan-removed-modules.ts") continue;
|
|
if (!isSourceFile(entry.name) || isGeneratedOrIgnoredFile(entry.name)) continue;
|
|
if (isExcludedRuntimePath(relativePath) || isTestFile(relativePath)) continue;
|
|
files.push(absolutePath);
|
|
}
|
|
}
|
|
|
|
for (const scanRoot of scanRoots) {
|
|
const normalizedRoot = normalizeRelativePath(scanRoot);
|
|
if (!(await pathExists(root, normalizedRoot))) {
|
|
missingRoots.push(normalizedRoot);
|
|
continue;
|
|
}
|
|
roots.push(normalizedRoot);
|
|
const absoluteRoot = path.join(root, normalizedRoot);
|
|
const rootStat = await stat(absoluteRoot);
|
|
if (rootStat.isFile()) {
|
|
if (isSourceFile(absoluteRoot) && !isGeneratedOrIgnoredFile(absoluteRoot) && !isTestFile(normalizedRoot) && !isExcludedRuntimePath(normalizedRoot)) {
|
|
files.push(absoluteRoot);
|
|
}
|
|
continue;
|
|
}
|
|
await walk(absoluteRoot);
|
|
}
|
|
|
|
files.sort((a, b) => toRelativePath(root, a).localeCompare(toRelativePath(root, b)));
|
|
return { roots, missingRoots, files };
|
|
}
|
|
|
|
export function findForbiddenPluginImports(sourceText, relativeFilePath) {
|
|
const diagnostics = [];
|
|
const lines = sourceText.split(/\r?\n/);
|
|
lines.forEach((line, index) => {
|
|
for (const { id, pattern } of forbiddenPatterns) {
|
|
pattern.lastIndex = 0;
|
|
if (!pattern.test(line)) continue;
|
|
if (isAllowedPluginMetadataReference(relativeFilePath, line)) continue;
|
|
diagnostics.push({ file: normalizeRelativePath(relativeFilePath), line: index + 1, id, text: line.trim() });
|
|
}
|
|
});
|
|
return diagnostics;
|
|
}
|
|
|
|
export async function scanForbiddenPluginImports(root = repoRoot, scanRoots = runtimeScanRoots) {
|
|
const collection = await collectSourceFiles(root, scanRoots);
|
|
const diagnostics = [];
|
|
for (const file of collection.files) {
|
|
const relativeFilePath = toRelativePath(root, file);
|
|
const sourceText = await readFile(file, "utf8");
|
|
diagnostics.push(...findForbiddenPluginImports(sourceText, relativeFilePath));
|
|
}
|
|
return { ...collection, diagnostics };
|
|
}
|
|
|
|
function excerptOutput(output, maxLength = 2400) {
|
|
const normalized = output.trim();
|
|
if (!normalized) return "(no output)";
|
|
if (normalized.length <= maxLength) return normalized;
|
|
return `${normalized.slice(0, maxLength)}\n... output truncated ...`;
|
|
}
|
|
|
|
export function runBuild(root = repoRoot) {
|
|
return new Promise((resolve) => {
|
|
const startedAt = Date.now();
|
|
const child = spawn(buildCommand.command, buildCommand.args, { cwd: root, env: { ...process.env, CI: "1" }, shell: false });
|
|
let stdout = "";
|
|
let stderr = "";
|
|
child.stdout.on("data", (chunk) => {
|
|
stdout += chunk;
|
|
});
|
|
child.stderr.on("data", (chunk) => {
|
|
stderr += chunk;
|
|
});
|
|
child.on("error", (error) => {
|
|
stderr += `\n${error.message}`;
|
|
});
|
|
child.on("close", (code) => {
|
|
resolve({
|
|
command: [buildCommand.command, ...buildCommand.args].join(" "),
|
|
exitCode: code ?? 1,
|
|
durationMs: Date.now() - startedAt,
|
|
stdout,
|
|
stderr,
|
|
outputExcerpt: excerptOutput(`${stdout}\n${stderr}`),
|
|
});
|
|
});
|
|
});
|
|
}
|
|
|
|
export function renderSmokeReport({ buildResult, scanResult }) {
|
|
const diagnostics = scanResult.diagnostics ?? [];
|
|
const lines = [
|
|
"Feature Manifest Core Build Without Plugins Smoke",
|
|
`Build Command: ${buildResult.command}`,
|
|
`Build Exit Code: ${buildResult.exitCode}`,
|
|
`Build Duration Ms: ${buildResult.durationMs}`,
|
|
`Scanned Roots: ${scanResult.roots.join(", ") || "(none)"}`,
|
|
`Missing Roots Skipped: ${scanResult.missingRoots.join(", ") || "(none)"}`,
|
|
`Scanned File Count: ${scanResult.files.length}`,
|
|
`Forbidden Import Count: ${diagnostics.length}`,
|
|
];
|
|
|
|
if (buildResult.exitCode !== 0) {
|
|
lines.push("Build Output Excerpt:", buildResult.outputExcerpt);
|
|
}
|
|
|
|
lines.push("Forbidden Import Diagnostics:");
|
|
if (diagnostics.length === 0) {
|
|
lines.push("(none)");
|
|
} else {
|
|
for (const diagnostic of diagnostics.slice(0, 20)) {
|
|
lines.push(`${diagnostic.file}:${diagnostic.line}: [${diagnostic.id}] ${diagnostic.text}`);
|
|
}
|
|
if (diagnostics.length > 20) lines.push(`... ${diagnostics.length - 20} additional forbidden imports omitted`);
|
|
}
|
|
|
|
return `${lines.join("\n")}\n`;
|
|
}
|
|
|
|
export async function runSmoke(root = repoRoot) {
|
|
const buildResult = await runBuild(root);
|
|
const scanResult = await scanForbiddenPluginImports(root, runtimeScanRoots);
|
|
return { buildResult, scanResult, report: renderSmokeReport({ buildResult, scanResult }) };
|
|
}
|
|
|
|
async function main() {
|
|
const result = await runSmoke(repoRoot);
|
|
process.stdout.write(result.report);
|
|
if (result.buildResult.exitCode !== 0) {
|
|
process.exitCode = result.buildResult.exitCode;
|
|
return;
|
|
}
|
|
process.exitCode = result.scanResult.diagnostics.length > 0 ? 1 : 0;
|
|
}
|
|
|
|
if (process.argv[1] === fileURLToPath(import.meta.url)) {
|
|
main().catch((error) => {
|
|
console.error(error?.stack ?? error);
|
|
process.exitCode = 1;
|
|
});
|
|
}
|