362 lines
12 KiB
JavaScript
362 lines
12 KiB
JavaScript
#!/usr/bin/env node
|
|
|
|
const fs = require("fs");
|
|
const path = require("path");
|
|
const { spawnSync } = require("child_process");
|
|
const sanitizeFilename = require("sanitize-filename");
|
|
|
|
const { resolveBlobSizes } = require("../lib/git-blob-sizes");
|
|
const { findProjectRoot } = require("../lib/project-root");
|
|
const { parseRawDiff } = require("../lib/git-raw-diff");
|
|
const {
|
|
classifyChangeRecords,
|
|
classifyChangedFiles,
|
|
classifyShadowImpact,
|
|
getDirectDerivedChanges,
|
|
hasIssueLink,
|
|
hasQualityChecklist,
|
|
loadWorkflowContract,
|
|
normalizeRepoPath,
|
|
requiresReferencesValidation,
|
|
} = require("../lib/workflow-contract");
|
|
|
|
function parseArgs(argv) {
|
|
const args = {
|
|
repo: null,
|
|
base: null,
|
|
head: "HEAD",
|
|
eventPath: null,
|
|
checkPolicy: false,
|
|
checkForkSafety: false,
|
|
noRun: false,
|
|
writeGithubOutput: false,
|
|
writeStepSummary: false,
|
|
json: false,
|
|
};
|
|
|
|
for (let index = 0; index < argv.length; index += 1) {
|
|
const arg = argv[index];
|
|
if (arg === "--repo") {
|
|
args.repo = argv[index + 1] || null;
|
|
index += 1;
|
|
} else if (arg === "--base") {
|
|
args.base = argv[index + 1];
|
|
index += 1;
|
|
} else if (arg === "--head") {
|
|
args.head = argv[index + 1];
|
|
index += 1;
|
|
} else if (arg === "--event-path") {
|
|
args.eventPath = argv[index + 1];
|
|
index += 1;
|
|
} else if (arg === "--check-policy") {
|
|
args.checkPolicy = true;
|
|
} else if (arg === "--check-fork-safety") {
|
|
args.checkForkSafety = true;
|
|
} else if (arg === "--no-run") {
|
|
args.noRun = true;
|
|
} else if (arg === "--write-github-output") {
|
|
args.writeGithubOutput = true;
|
|
} else if (arg === "--write-step-summary") {
|
|
args.writeStepSummary = true;
|
|
} else if (arg === "--json") {
|
|
args.json = true;
|
|
}
|
|
}
|
|
|
|
return args;
|
|
}
|
|
|
|
function safeUserPath(pathValue, baseDir = process.cwd()) {
|
|
if (path.isAbsolute(String(pathValue || ""))) {
|
|
return path.resolve(pathValue);
|
|
}
|
|
|
|
const root = path.resolve(baseDir);
|
|
const segments = String(pathValue || "").split(/[\\/]+/).filter(Boolean).map((segment) => {
|
|
const sanitized = sanitizeFilename(segment);
|
|
if (sanitized !== segment || !sanitized) {
|
|
throw new Error(`Unsafe path segment: ${segment}`);
|
|
}
|
|
return sanitized;
|
|
});
|
|
const target = path.resolve(root, ...segments);
|
|
const rel = path.relative(root, target);
|
|
if (rel.startsWith("..") || path.isAbsolute(rel)) {
|
|
throw new Error(`Path escapes allowed directory: ${pathValue}`);
|
|
}
|
|
return target;
|
|
}
|
|
|
|
function runGit(args, options = {}) {
|
|
const result = spawnSync("git", args, {
|
|
cwd: options.cwd,
|
|
encoding: "utf8",
|
|
stdio: options.capture ? ["ignore", "pipe", "pipe"] : "inherit",
|
|
});
|
|
|
|
if (result.error) {
|
|
throw result.error;
|
|
}
|
|
|
|
if (typeof result.status !== "number" || result.status !== 0) {
|
|
const stderr = options.capture ? result.stderr.trim() : "";
|
|
throw new Error(stderr || `git ${args.join(" ")} failed with status ${result.status}`);
|
|
}
|
|
|
|
return options.capture ? result.stdout.trim() : "";
|
|
}
|
|
|
|
function runCommand(command, args, cwd) {
|
|
console.log(`[pr:preflight] ${command} ${args.join(" ")}`);
|
|
const result = spawnSync(command, args, {
|
|
cwd,
|
|
stdio: "inherit",
|
|
shell: process.platform === "win32",
|
|
});
|
|
|
|
if (result.error) {
|
|
throw result.error;
|
|
}
|
|
|
|
if (typeof result.status !== "number" || result.status !== 0) {
|
|
process.exit(result.status || 1);
|
|
}
|
|
}
|
|
|
|
function resolveBaseRef(projectRoot) {
|
|
for (const candidate of ["origin/main", "main"]) {
|
|
const result = spawnSync("git", ["rev-parse", "--verify", candidate], {
|
|
cwd: projectRoot,
|
|
stdio: "ignore",
|
|
});
|
|
if (result.status === 0) {
|
|
return candidate;
|
|
}
|
|
}
|
|
|
|
return "HEAD";
|
|
}
|
|
|
|
function getChangeRecords(projectRoot, baseRef, headRef) {
|
|
if (baseRef === headRef) {
|
|
return [];
|
|
}
|
|
|
|
const result = spawnSync(
|
|
"git",
|
|
["diff", "--raw", "--no-abbrev", "-z", "-M", "--find-copies-harder", `${baseRef}...${headRef}`, "--"],
|
|
{
|
|
cwd: projectRoot,
|
|
encoding: null,
|
|
stdio: ["ignore", "pipe", "pipe"],
|
|
maxBuffer: 64 * 1024 * 1024,
|
|
},
|
|
);
|
|
if (result.error) throw result.error;
|
|
if (typeof result.status !== "number" || result.status !== 0) {
|
|
const stderr = Buffer.isBuffer(result.stderr) ? result.stderr.toString("utf8").trim() : "";
|
|
throw new Error(stderr || `git diff failed with status ${result.status}`);
|
|
}
|
|
return parseRawDiff(result.stdout, { allowEmpty: true });
|
|
}
|
|
|
|
function getChangedFiles(projectRoot, baseRef, headRef) {
|
|
const records = getChangeRecords(projectRoot, baseRef, headRef);
|
|
return [...new Set(records.flatMap((record) => [record.old_path, record.new_path])
|
|
.filter(Boolean)
|
|
.map(normalizeRepoPath))];
|
|
}
|
|
|
|
function changedFilesFromRecords(records) {
|
|
return [...new Set(records.flatMap((record) => [record.old_path, record.new_path])
|
|
.filter(Boolean)
|
|
.map(normalizeRepoPath))];
|
|
}
|
|
|
|
function loadPullRequestEvent(eventPath) {
|
|
if (!eventPath) {
|
|
return null;
|
|
}
|
|
|
|
const rawEvent = fs.readFileSync(safeUserPath(eventPath), "utf8");
|
|
return JSON.parse(rawEvent).pull_request || null;
|
|
}
|
|
|
|
function evaluateForkSafety(projectRoot, changeRecords, pullRequest) {
|
|
if (!pullRequest) {
|
|
return { applicable: false, approvalSafe: true, reasons: [], requiresHumanReview: false };
|
|
}
|
|
const headRepository = String(pullRequest?.head?.repo?.full_name || "").toLowerCase();
|
|
const baseRepository = String(pullRequest?.base?.repo?.full_name || "").toLowerCase();
|
|
if (!headRepository || !baseRepository) {
|
|
return {
|
|
applicable: true,
|
|
approvalSafe: false,
|
|
reasons: ["pull_request_repository_identity_unavailable"],
|
|
requiresHumanReview: false,
|
|
};
|
|
}
|
|
if (headRepository === baseRepository) {
|
|
return { applicable: false, approvalSafe: true, reasons: [], requiresHumanReview: false };
|
|
}
|
|
|
|
const preliminary = classifyChangeRecords(changeRecords, { requireBlobSizes: false });
|
|
if (!preliminary.approvalSafe) {
|
|
return { applicable: true, ...preliminary };
|
|
}
|
|
const blobSizes = resolveBlobSizes(projectRoot, changeRecords);
|
|
return { applicable: true, ...classifyChangeRecords(changeRecords, { blobSizes }) };
|
|
}
|
|
|
|
function appendGithubOutput(result) {
|
|
const outputPath = process.env.GITHUB_OUTPUT;
|
|
if (!outputPath) {
|
|
return;
|
|
}
|
|
|
|
const lines = [
|
|
`primary_category=${result.primaryCategory}`,
|
|
`categories=${result.categories.join(",")}`,
|
|
`requires_references=${String(result.requiresReferencesValidation)}`,
|
|
`direct_derived_changes_count=${String(result.directDerivedChanges.length)}`,
|
|
`direct_derived_changes=${JSON.stringify(result.directDerivedChanges)}`,
|
|
`changed_files_count=${String(result.changedFiles.length)}`,
|
|
`has_quality_checklist=${String(result.prBody.hasQualityChecklist)}`,
|
|
`has_issue_link=${String(result.prBody.hasIssueLink)}`,
|
|
`fork_safety_applicable=${String(result.forkSafety.applicable)}`,
|
|
`fork_approval_safe=${String(result.forkSafety.approvalSafe)}`,
|
|
`fork_safety_reasons=${JSON.stringify(result.forkSafety.reasons)}`,
|
|
`impact_profile=${result.shadowImpact.profile}`,
|
|
`impact_reasons=${JSON.stringify(result.shadowImpact.reasons)}`,
|
|
];
|
|
|
|
fs.appendFileSync(outputPath, `${lines.join("\n")}\n`, "utf8");
|
|
}
|
|
|
|
function appendStepSummary(result) {
|
|
const summaryPath = process.env.GITHUB_STEP_SUMMARY;
|
|
if (!summaryPath) {
|
|
return;
|
|
}
|
|
|
|
const derivedSummary =
|
|
result.directDerivedChanges.length === 0
|
|
? "none"
|
|
: result.directDerivedChanges.map((filePath) => `\`${filePath}\``).join(", ");
|
|
|
|
const lines = [
|
|
"## PR Workflow Intake",
|
|
"",
|
|
`- Primary change: \`${result.primaryCategory}\``,
|
|
`- Categories: ${result.categories.length > 0 ? result.categories.map((category) => `\`${category}\``).join(", ") : "\`none\`"}`,
|
|
`- Changed files: ${result.changedFiles.length}`,
|
|
`- Direct derived-file edits: ${derivedSummary}`,
|
|
`- \`validate:references\` required: ${result.requiresReferencesValidation ? "yes" : "no"}`,
|
|
`- PR template checklist: ${result.prBody.hasQualityChecklist ? "present" : "missing"}`,
|
|
`- Issue auto-close link: ${result.prBody.hasIssueLink ? "detected" : "not detected"}`,
|
|
`- Fork approval safety: ${result.forkSafety.applicable ? (result.forkSafety.approvalSafe ? "safe" : "blocked") : "not applicable"}`,
|
|
`- Shadow impact profile: \`${result.shadowImpact.profile}\` (observational only; no test is skipped)`,
|
|
"",
|
|
"> Generated drift is reported separately in the artifact preview job and remains informational on pull requests.",
|
|
];
|
|
|
|
fs.appendFileSync(summaryPath, `${lines.join("\n")}\n`, "utf8");
|
|
}
|
|
|
|
function main() {
|
|
const args = parseArgs(process.argv.slice(2));
|
|
const projectRoot = args.repo ? path.resolve(args.repo) : findProjectRoot(__dirname);
|
|
const contract = loadWorkflowContract(__dirname);
|
|
const baseRef = args.base || resolveBaseRef(projectRoot);
|
|
const changeRecords = getChangeRecords(projectRoot, baseRef, args.head);
|
|
const changedFiles = changedFilesFromRecords(changeRecords);
|
|
const classification = classifyChangedFiles(changedFiles, contract);
|
|
const directDerivedChanges = getDirectDerivedChanges(changedFiles, contract);
|
|
const pullRequest = loadPullRequestEvent(args.eventPath);
|
|
const pullRequestBody = pullRequest?.body ?? null;
|
|
const forkSafety = evaluateForkSafety(projectRoot, changeRecords, pullRequest);
|
|
const shadowImpact = classifyShadowImpact(changeRecords, contract);
|
|
|
|
const result = {
|
|
baseRef,
|
|
headRef: args.head,
|
|
changeRecords,
|
|
changedFiles,
|
|
categories: classification.categories,
|
|
primaryCategory: classification.primaryCategory,
|
|
directDerivedChanges,
|
|
requiresReferencesValidation: requiresReferencesValidation(changedFiles, contract),
|
|
forkSafety,
|
|
shadowImpact,
|
|
prBody: {
|
|
available: pullRequestBody !== null,
|
|
hasQualityChecklist: hasQualityChecklist(pullRequestBody),
|
|
hasIssueLink: hasIssueLink(pullRequestBody),
|
|
},
|
|
};
|
|
|
|
if (args.writeGithubOutput) {
|
|
appendGithubOutput(result);
|
|
}
|
|
|
|
if (args.writeStepSummary) {
|
|
appendStepSummary(result);
|
|
}
|
|
|
|
if (args.json) {
|
|
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
|
|
} else {
|
|
console.log(`[pr:preflight] Base ref: ${baseRef}`);
|
|
console.log(`[pr:preflight] Changed files: ${changedFiles.length}`);
|
|
console.log(
|
|
`[pr:preflight] Classification: ${result.categories.length > 0 ? result.categories.join(", ") : "none"}`,
|
|
);
|
|
}
|
|
|
|
if (args.checkPolicy) {
|
|
if (directDerivedChanges.length > 0) {
|
|
console.error(
|
|
[
|
|
"Pull requests are source-only.",
|
|
"Remove derived files from the PR and let main regenerate them after merge.",
|
|
`Derived files detected: ${directDerivedChanges.join(", ")}`,
|
|
].join(" "),
|
|
);
|
|
process.exit(1);
|
|
}
|
|
|
|
if (pullRequestBody !== null && !result.prBody.hasQualityChecklist) {
|
|
console.error("PR body must include the Quality Bar Checklist section from the template.");
|
|
process.exit(1);
|
|
}
|
|
}
|
|
|
|
if (args.checkForkSafety && forkSafety.applicable && !forkSafety.approvalSafe) {
|
|
console.error(`Fork PR is not approval-safe: ${forkSafety.reasons.slice(0, 12).join(", ") || "unclassified diff"}.`);
|
|
process.exit(1);
|
|
}
|
|
|
|
if (!args.noRun) {
|
|
runCommand("npm", ["run", "validate"], projectRoot);
|
|
|
|
if (result.requiresReferencesValidation) {
|
|
runCommand("npm", ["run", "validate:references"], projectRoot);
|
|
}
|
|
|
|
runCommand("npm", ["run", "test"], projectRoot);
|
|
}
|
|
}
|
|
|
|
if (require.main === module) {
|
|
main();
|
|
}
|
|
|
|
module.exports = {
|
|
changedFilesFromRecords,
|
|
evaluateForkSafety,
|
|
getChangeRecords,
|
|
getChangedFiles,
|
|
loadPullRequestEvent,
|
|
parseArgs,
|
|
};
|