500 lines
15 KiB
JavaScript
500 lines
15 KiB
JavaScript
const fs = require("fs");
|
|
const path = require("path");
|
|
|
|
const { findProjectRoot } = require("./project-root");
|
|
|
|
const DOC_PREFIXES = ["docs/"];
|
|
const DOC_FILES = new Set(["README.md", "CONTRIBUTING.md", "CHANGELOG.md", "walkthrough.md"]);
|
|
const INFRA_PREFIXES = [".github/", "tools/", "apps/"];
|
|
const INFRA_FILES = new Set(["package.json", "package-lock.json"]);
|
|
const REFERENCES_PREFIXES = ["docs/", ".github/", "tools/", "apps/", "data/"];
|
|
const REFERENCES_FILES = new Set([
|
|
"README.md",
|
|
"CONTRIBUTING.md",
|
|
"CHANGELOG.md",
|
|
"walkthrough.md",
|
|
"package.json",
|
|
"package-lock.json",
|
|
]);
|
|
|
|
const REGULAR_FILE_MODE = "100644";
|
|
const ABSENT_FILE_MODE = "000000";
|
|
const MAX_APPROVAL_BLOB_BYTES = 1024 * 1024;
|
|
const MAX_APPROVAL_CHANGE_RECORDS = 500;
|
|
const MAX_APPROVAL_TOTAL_BLOB_BYTES = 8 * 1024 * 1024;
|
|
const SKILL_PATH_SEGMENT = "[a-z0-9]+(?:-[a-z0-9]+)*";
|
|
const CANONICAL_SKILL_PATH = new RegExp(`^skills\\/(?:${SKILL_PATH_SEGMENT}\\/)+SKILL\\.md$`, "u");
|
|
const SKILL_SUPPORT_PATH = new RegExp(
|
|
`^skills\\/(?:${SKILL_PATH_SEGMENT}\\/)+(?:assets|references|resources)\\/(?:[^/]+\\/)*[^/]+$`,
|
|
"u",
|
|
);
|
|
const NARROW_DOC_PATH = /^(?:README\.md|CONTRIBUTING\.md|docs\/(?:[^/]+\/)*[^/]+\.md)$/;
|
|
const LOW_RISK_EXTENSIONS = new Set([
|
|
".csv",
|
|
".gif",
|
|
".jpeg",
|
|
".jpg",
|
|
".json",
|
|
".md",
|
|
".pdf",
|
|
".png",
|
|
".txt",
|
|
".webp",
|
|
".yaml",
|
|
".yml",
|
|
]);
|
|
const CHANGE_STATUS_SIDES = Object.freeze({
|
|
A: { old: false, new: true },
|
|
C: { old: true, new: true },
|
|
D: { old: true, new: false },
|
|
M: { old: true, new: true },
|
|
R: { old: true, new: true },
|
|
});
|
|
|
|
function normalizeRepoPath(filePath) {
|
|
return String(filePath || "").replace(/\\/g, "/").replace(/^\.\//, "");
|
|
}
|
|
|
|
function validateRawRepoPath(filePath) {
|
|
const value = typeof filePath === "string" ? filePath : "";
|
|
const reasons = [];
|
|
|
|
if (!value) {
|
|
reasons.push("missing_path");
|
|
}
|
|
if (/[\u0000-\u001f\u007f]/u.test(value)) {
|
|
reasons.push("control_character_path");
|
|
}
|
|
if (value.includes("\\")) {
|
|
reasons.push("backslash_path");
|
|
}
|
|
if (value.startsWith("/") || /^[A-Za-z]:/u.test(value)) {
|
|
reasons.push("absolute_path");
|
|
}
|
|
if (value.startsWith("./") || value.includes("//")) {
|
|
reasons.push("noncanonical_path");
|
|
}
|
|
|
|
const segments = value.split("/");
|
|
if (segments.some((segment) => (
|
|
!segment ||
|
|
segment === "." ||
|
|
segment === ".." ||
|
|
segment.trim() !== segment ||
|
|
segment.endsWith(".") ||
|
|
segment.includes(":") ||
|
|
Buffer.byteLength(segment, "utf8") > 255
|
|
))) {
|
|
reasons.push("noncanonical_path");
|
|
}
|
|
if (Buffer.byteLength(value, "utf8") > 4096) {
|
|
reasons.push("noncanonical_path");
|
|
}
|
|
if (value && path.posix.normalize(value) !== value) {
|
|
reasons.push("noncanonical_path");
|
|
}
|
|
if (value && value.normalize("NFC") !== value) {
|
|
reasons.push("noncanonical_unicode_path");
|
|
}
|
|
|
|
return {
|
|
safe: reasons.length === 0,
|
|
reasons: [...new Set(reasons)],
|
|
};
|
|
}
|
|
|
|
function classifyPathPolicy(filePath) {
|
|
const pathValidation = validateRawRepoPath(filePath);
|
|
if (!pathValidation.safe) {
|
|
return {
|
|
safe: false,
|
|
sensitive: true,
|
|
approvalSafe: false,
|
|
kind: "invalid",
|
|
reasons: pathValidation.reasons,
|
|
};
|
|
}
|
|
|
|
let kind = "unknown";
|
|
if (CANONICAL_SKILL_PATH.test(filePath)) {
|
|
kind = "canonical_skill";
|
|
} else if (SKILL_SUPPORT_PATH.test(filePath)) {
|
|
kind = "skill_support";
|
|
} else if (NARROW_DOC_PATH.test(filePath)) {
|
|
kind = "documentation";
|
|
}
|
|
|
|
const extension = path.posix.extname(filePath).toLowerCase();
|
|
const recognizedExtension = LOW_RISK_EXTENSIONS.has(extension);
|
|
const reasons = [];
|
|
if (kind === "unknown") {
|
|
reasons.push("unapproved_path");
|
|
}
|
|
if (!recognizedExtension) {
|
|
reasons.push("unknown_extension");
|
|
}
|
|
|
|
const approvalSafe = reasons.length === 0;
|
|
return {
|
|
safe: approvalSafe,
|
|
sensitive: !approvalSafe,
|
|
approvalSafe,
|
|
kind,
|
|
reasons,
|
|
};
|
|
}
|
|
|
|
function isFullObjectId(value, options = {}) {
|
|
const allowZero = Boolean(options.allowZero);
|
|
if (typeof value !== "string" || !/^(?:[0-9a-f]{40}|[0-9a-f]{64})$/u.test(value)) {
|
|
return false;
|
|
}
|
|
return allowZero || !/^0+$/u.test(value);
|
|
}
|
|
|
|
function readBlobSize(record, side, blobSizes) {
|
|
const inlineSize = record[`${side}_size`];
|
|
if (Object.prototype.hasOwnProperty.call(record, `${side}_size`)) {
|
|
return Number.isSafeInteger(inlineSize) && inlineSize >= 0 ? inlineSize : undefined;
|
|
}
|
|
|
|
const oid = record[`${side}_oid`];
|
|
if (blobSizes instanceof Map) {
|
|
return blobSizes.get(oid);
|
|
}
|
|
if (blobSizes && typeof blobSizes === "object") {
|
|
return blobSizes[oid];
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
function classifyChangeRecords(records, options = {}) {
|
|
const requireBlobSizes = options.requireBlobSizes !== false;
|
|
const maxBlobBytes = Number.isSafeInteger(options.maxBlobBytes) && options.maxBlobBytes >= 0
|
|
? options.maxBlobBytes
|
|
: MAX_APPROVAL_BLOB_BYTES;
|
|
const maxChangeRecords = Number.isSafeInteger(options.maxChangeRecords) && options.maxChangeRecords >= 0
|
|
? options.maxChangeRecords
|
|
: MAX_APPROVAL_CHANGE_RECORDS;
|
|
const maxTotalBlobBytes = Number.isSafeInteger(options.maxTotalBlobBytes) && options.maxTotalBlobBytes >= 0
|
|
? options.maxTotalBlobBytes
|
|
: MAX_APPROVAL_TOTAL_BLOB_BYTES;
|
|
const reasons = [];
|
|
const paths = [];
|
|
const canonicalSkillChanges = [];
|
|
const skillContentChanges = [];
|
|
let sensitive = false;
|
|
let totalBlobBytes = 0;
|
|
|
|
if (!Array.isArray(records) || records.length === 0) {
|
|
return {
|
|
safe: false,
|
|
sensitive: true,
|
|
approvalSafe: false,
|
|
reasons: ["missing_change_records"],
|
|
paths,
|
|
requiresHumanReview: false,
|
|
canonicalSkillChanges,
|
|
skillContentChanges,
|
|
};
|
|
}
|
|
if (records.length > maxChangeRecords) {
|
|
reasons.push("too_many_change_records");
|
|
sensitive = true;
|
|
}
|
|
|
|
records.forEach((record, index) => {
|
|
const prefix = `record_${index}`;
|
|
if (!record || typeof record !== "object") {
|
|
reasons.push(`${prefix}:malformed_record`);
|
|
sensitive = true;
|
|
return;
|
|
}
|
|
|
|
const status = String(record.status || "");
|
|
const expectedSides = CHANGE_STATUS_SIDES[status];
|
|
if (!expectedSides) {
|
|
reasons.push(`${prefix}:unknown_status`);
|
|
sensitive = true;
|
|
return;
|
|
}
|
|
|
|
for (const side of ["old", "new"]) {
|
|
const expected = expectedSides[side];
|
|
const filePath = record[`${side}_path`];
|
|
const mode = String(record[`${side}_mode`] || "");
|
|
const oid = String(record[`${side}_oid`] || "");
|
|
|
|
if (!expected) {
|
|
if (filePath !== null) {
|
|
reasons.push(`${prefix}:${side}_invalid_absent_path`);
|
|
}
|
|
if (mode !== ABSENT_FILE_MODE || !isFullObjectId(oid, { allowZero: true }) || !/^0+$/u.test(oid)) {
|
|
reasons.push(`${prefix}:${side}_invalid_absent_side`);
|
|
}
|
|
continue;
|
|
}
|
|
|
|
if (typeof filePath !== "string" || !filePath) {
|
|
reasons.push(`${prefix}:${side}_missing_path`);
|
|
sensitive = true;
|
|
continue;
|
|
}
|
|
|
|
const pathPolicy = classifyPathPolicy(filePath);
|
|
paths.push({ record: index, side, path: filePath, ...pathPolicy });
|
|
if (!pathPolicy.approvalSafe) {
|
|
sensitive = true;
|
|
for (const reason of pathPolicy.reasons) {
|
|
reasons.push(`${prefix}:${side}_${reason}`);
|
|
}
|
|
}
|
|
if (pathPolicy.kind === "canonical_skill") {
|
|
canonicalSkillChanges.push(filePath);
|
|
}
|
|
if (["canonical_skill", "skill_support"].includes(pathPolicy.kind)) {
|
|
skillContentChanges.push(filePath);
|
|
}
|
|
|
|
if (mode !== REGULAR_FILE_MODE) {
|
|
sensitive = true;
|
|
if (mode === "100755") {
|
|
reasons.push(`${prefix}:${side}_executable_mode`);
|
|
} else if (mode === "120000") {
|
|
reasons.push(`${prefix}:${side}_symlink_mode`);
|
|
} else if (mode === "160000") {
|
|
reasons.push(`${prefix}:${side}_gitlink_mode`);
|
|
} else {
|
|
reasons.push(`${prefix}:${side}_unknown_mode`);
|
|
}
|
|
}
|
|
|
|
if (!isFullObjectId(oid)) {
|
|
sensitive = true;
|
|
reasons.push(`${prefix}:${side}_invalid_object_id`);
|
|
} else {
|
|
const blobSize = readBlobSize(record, side, options.blobSizes);
|
|
if ((!Number.isSafeInteger(blobSize) || blobSize < 0) && requireBlobSizes) {
|
|
sensitive = true;
|
|
reasons.push(`${prefix}:${side}_missing_blob_size`);
|
|
} else if (Number.isSafeInteger(blobSize) && blobSize > maxBlobBytes) {
|
|
sensitive = true;
|
|
reasons.push(`${prefix}:${side}_oversized_blob`);
|
|
} else if (Number.isSafeInteger(blobSize)) {
|
|
totalBlobBytes += blobSize;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (status === "M" && record.old_path !== record.new_path) {
|
|
reasons.push(`${prefix}:path_changed_without_rename`);
|
|
sensitive = true;
|
|
}
|
|
if ((status === "R" || status === "C") && record.old_path === record.new_path) {
|
|
reasons.push(`${prefix}:rename_copy_path_unchanged`);
|
|
sensitive = true;
|
|
}
|
|
});
|
|
|
|
if (totalBlobBytes > maxTotalBlobBytes) {
|
|
reasons.push("oversized_total_diff");
|
|
sensitive = true;
|
|
}
|
|
|
|
const uniqueReasons = [...new Set(reasons)];
|
|
const uniqueCanonicalSkillChanges = [...new Set(canonicalSkillChanges)].sort();
|
|
const uniqueSkillContentChanges = [...new Set(skillContentChanges)].sort();
|
|
const approvalSafe = uniqueReasons.length === 0 && !sensitive;
|
|
return {
|
|
safe: approvalSafe,
|
|
sensitive: !approvalSafe,
|
|
approvalSafe,
|
|
reasons: uniqueReasons,
|
|
paths,
|
|
requiresHumanReview: uniqueSkillContentChanges.length > 0,
|
|
canonicalSkillChanges: uniqueCanonicalSkillChanges,
|
|
skillContentChanges: uniqueSkillContentChanges,
|
|
};
|
|
}
|
|
|
|
function matchesContractEntry(filePath, entry) {
|
|
const normalizedPath = normalizeRepoPath(filePath);
|
|
const normalizedEntry = normalizeRepoPath(entry);
|
|
|
|
if (!normalizedEntry) {
|
|
return false;
|
|
}
|
|
|
|
if (normalizedEntry.endsWith("/")) {
|
|
return normalizedPath.startsWith(normalizedEntry);
|
|
}
|
|
|
|
return normalizedPath === normalizedEntry;
|
|
}
|
|
|
|
function escapeRegExp(value) {
|
|
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
}
|
|
|
|
function loadWorkflowContract(startDir = __dirname) {
|
|
const projectRoot = findProjectRoot(startDir);
|
|
const configPath = path.join(projectRoot, "tools", "config", "generated-files.json");
|
|
const rawConfig = fs.readFileSync(configPath, "utf8");
|
|
const config = JSON.parse(rawConfig);
|
|
|
|
return {
|
|
projectRoot,
|
|
configPath,
|
|
derivedFiles: config.derivedFiles.map(normalizeRepoPath),
|
|
mixedFiles: config.mixedFiles.map(normalizeRepoPath),
|
|
releaseManagedFiles: config.releaseManagedFiles.map(normalizeRepoPath),
|
|
};
|
|
}
|
|
|
|
function getManagedFiles(contract, options = {}) {
|
|
const includeMixed = Boolean(options.includeMixed);
|
|
const includeReleaseManaged = Boolean(options.includeReleaseManaged);
|
|
const managedFiles = [...contract.derivedFiles];
|
|
|
|
if (includeMixed) {
|
|
managedFiles.push(...contract.mixedFiles);
|
|
}
|
|
|
|
if (includeReleaseManaged) {
|
|
managedFiles.push(...contract.releaseManagedFiles);
|
|
}
|
|
|
|
return [...new Set(managedFiles.map(normalizeRepoPath))];
|
|
}
|
|
|
|
function isDerivedFile(filePath, contract) {
|
|
return contract.derivedFiles.some((entry) => matchesContractEntry(filePath, entry));
|
|
}
|
|
|
|
function isMixedFile(filePath, contract) {
|
|
return contract.mixedFiles.some((entry) => matchesContractEntry(filePath, entry));
|
|
}
|
|
|
|
function isDocLikeFile(filePath) {
|
|
const normalized = normalizeRepoPath(filePath);
|
|
return normalized.endsWith(".md") || DOC_FILES.has(normalized) || DOC_PREFIXES.some((prefix) => normalized.startsWith(prefix));
|
|
}
|
|
|
|
function isInfraLikeFile(filePath) {
|
|
const normalized = normalizeRepoPath(filePath);
|
|
return (
|
|
INFRA_FILES.has(normalized) ||
|
|
INFRA_PREFIXES.some((prefix) => normalized.startsWith(prefix))
|
|
);
|
|
}
|
|
|
|
function classifyChangedFiles(changedFiles, contract) {
|
|
const categories = new Set();
|
|
const normalizedFiles = changedFiles.map(normalizeRepoPath).filter(Boolean);
|
|
|
|
for (const filePath of normalizedFiles) {
|
|
if (isDerivedFile(filePath, contract)) {
|
|
continue;
|
|
}
|
|
|
|
const isSkillPath = filePath.startsWith("skills/");
|
|
|
|
if (isSkillPath) {
|
|
categories.add("skill");
|
|
}
|
|
|
|
if (!isSkillPath && (isDocLikeFile(filePath) || isMixedFile(filePath, contract))) {
|
|
categories.add("docs");
|
|
}
|
|
|
|
if (isInfraLikeFile(filePath)) {
|
|
categories.add("infra");
|
|
}
|
|
}
|
|
|
|
const orderedCategories = ["skill", "docs", "infra"].filter((category) => categories.has(category));
|
|
let primaryCategory = "none";
|
|
if (orderedCategories.includes("infra")) {
|
|
primaryCategory = "infra";
|
|
} else if (orderedCategories.includes("skill")) {
|
|
primaryCategory = "skill";
|
|
} else if (orderedCategories.includes("docs")) {
|
|
primaryCategory = "docs";
|
|
}
|
|
|
|
return {
|
|
categories: orderedCategories,
|
|
primaryCategory,
|
|
};
|
|
}
|
|
|
|
function getDirectDerivedChanges(changedFiles, contract) {
|
|
return changedFiles
|
|
.map(normalizeRepoPath)
|
|
.filter(Boolean)
|
|
.filter((filePath) => isDerivedFile(filePath, contract));
|
|
}
|
|
|
|
function requiresReferencesValidation(changedFiles, contract) {
|
|
return changedFiles
|
|
.map(normalizeRepoPath)
|
|
.filter(Boolean)
|
|
.some((filePath) => {
|
|
if (isDerivedFile(filePath, contract) || isMixedFile(filePath, contract)) {
|
|
return true;
|
|
}
|
|
|
|
return (
|
|
REFERENCES_FILES.has(filePath) ||
|
|
REFERENCES_PREFIXES.some((prefix) => filePath.startsWith(prefix))
|
|
);
|
|
});
|
|
}
|
|
|
|
function extractChangelogSection(content, version) {
|
|
const headingExpression = new RegExp(`^## \\[${escapeRegExp(version)}\\].*$`, "m");
|
|
const headingMatch = headingExpression.exec(content);
|
|
if (!headingMatch) {
|
|
throw new Error(`CHANGELOG.md does not contain a section for version ${version}.`);
|
|
}
|
|
|
|
const startIndex = headingMatch.index;
|
|
const remainder = content.slice(startIndex + headingMatch[0].length);
|
|
const nextSectionRelativeIndex = remainder.search(/^## \[/m);
|
|
const endIndex =
|
|
nextSectionRelativeIndex === -1
|
|
? content.length
|
|
: startIndex + headingMatch[0].length + nextSectionRelativeIndex;
|
|
|
|
return `${content.slice(startIndex, endIndex).trim()}\n`;
|
|
}
|
|
|
|
function hasQualityChecklist(body) {
|
|
return /quality bar checklist/i.test(String(body || ""));
|
|
}
|
|
|
|
function hasIssueLink(body) {
|
|
return /(?:closes|fixes)\s+#\d+/i.test(String(body || ""));
|
|
}
|
|
|
|
module.exports = {
|
|
classifyChangeRecords,
|
|
classifyChangedFiles,
|
|
classifyPathPolicy,
|
|
extractChangelogSection,
|
|
getDirectDerivedChanges,
|
|
getManagedFiles,
|
|
hasIssueLink,
|
|
hasQualityChecklist,
|
|
isDerivedFile,
|
|
isMixedFile,
|
|
loadWorkflowContract,
|
|
maxApprovalBlobBytes: MAX_APPROVAL_BLOB_BYTES,
|
|
maxApprovalChangeRecords: MAX_APPROVAL_CHANGE_RECORDS,
|
|
maxApprovalTotalBlobBytes: MAX_APPROVAL_TOTAL_BLOB_BYTES,
|
|
normalizeRepoPath,
|
|
matchesContractEntry,
|
|
requiresReferencesValidation,
|
|
validateRawRepoPath,
|
|
};
|