文件
hyb-oa/scripts/qa/live-smoke-readiness-report.mjs
T

294 行
8.8 KiB
JavaScript

#!/usr/bin/env node
import { execFileSync } from "node:child_process";
import {
existsSync,
mkdirSync,
readFileSync,
readdirSync,
statSync,
writeFileSync,
} from "node:fs";
import path from "node:path";
import process from "node:process";
import { fileURLToPath } from "node:url";
export const READINESS_STEP_NAMES = [
"environment",
"publicReady",
"publicBundle",
"pendingOcr",
"liveSmoke",
"liveOcr",
"privacy",
];
const CHILD_STEP_NAMES = ["environment", "pendingOcr", "liveSmoke", "liveOcr"];
const BLOCKING_STATUS_PATTERN = /FAIL|ERROR|BLOCKED/i;
const MANUAL_STATUS_PATTERN = /SKIP|MISSING|PAUSED|PARTIAL|PASS_WITH_FINDINGS/i;
export function toReportRef(rootDir, file) {
const absolute = path.resolve(file);
const relative = path.relative(path.resolve(rootDir), absolute);
if (relative && !relative.startsWith("..") && !path.isAbsolute(relative)) {
return relative.split(path.sep).join("/");
}
return path.basename(absolute);
}
function safeBaseUrl(value) {
try {
const url = new URL(String(value || ""));
url.username = "";
url.password = "";
return `${url.origin}${url.pathname.replace(/\/+$/, "")}`;
} catch {
return null;
}
}
function normalizeFindingCount(status, value) {
const parsed = Number(value || 0);
const count = Number.isFinite(parsed) && parsed > 0 ? Math.floor(parsed) : 0;
return status === "PASS" ? count : Math.max(1, count);
}
function normalizeStep(name, input = {}) {
const status = String(input.status || "MISSING").toUpperCase();
return {
name,
status,
kind: input.kind === "required" ? "required" : "finding_allowed",
code: Number(input.code || 0),
findingCount: normalizeFindingCount(status, input.findingCount),
detail: String(input.detail || "").slice(0, 240),
logRef: input.logRef || null,
artifactRef: input.artifactRef || null,
checkedAt: input.checkedAt || null,
};
}
export function evaluateReadinessSteps(stepInputs) {
const steps = Object.fromEntries(
READINESS_STEP_NAMES.map((name) => [name, normalizeStep(name, stepInputs?.[name])]),
);
const findings = [];
for (const [name, step] of Object.entries(steps)) {
if (step.status === "PASS" && step.findingCount === 0) {
continue;
}
const blocking = step.kind === "required" && BLOCKING_STATUS_PATTERN.test(step.status);
findings.push({
source: name,
status: step.status,
severity: blocking
? "blocker"
: MANUAL_STATUS_PATTERN.test(step.status)
? "manual_or_config"
: "finding",
findingCount: step.findingCount,
reportRef: step.artifactRef || step.logRef,
});
}
const findingCount = findings.reduce((total, finding) => total + Math.max(1, finding.findingCount), 0);
const status = findings.some((finding) => finding.severity === "blocker")
? "FAIL"
: findingCount > 0
? "PASS_WITH_FINDINGS"
: "PASS";
return { status, findingCount, findings, steps };
}
function readJson(file) {
if (!existsSync(file)) {
return null;
}
try {
return JSON.parse(readFileSync(file, "utf8"));
} catch {
return null;
}
}
function currentCommit(rootDir) {
try {
return execFileSync("git", ["rev-parse", "HEAD"], {
cwd: rootDir,
encoding: "utf8",
stdio: ["ignore", "pipe", "ignore"],
}).trim();
} catch {
return "unknown";
}
}
export function buildReadinessSummary({
rootDir,
reportDir,
baseUrl,
commit = currentCommit(rootDir),
checkedAt = new Date().toISOString(),
}) {
const stepInputs = Object.fromEntries(
READINESS_STEP_NAMES.map((name) => [
name,
readJson(path.join(reportDir, `${name}.step.json`)) || {
status: "MISSING",
kind: "required",
logRef: toReportRef(rootDir, path.join(reportDir, `${name}.log`)),
},
]),
);
const evaluated = evaluateReadinessSteps(stepInputs);
const environmentReport = readJson(path.join(reportDir, "environment.json"));
const childReports = Object.fromEntries(
CHILD_STEP_NAMES.map((name) => {
const step = evaluated.steps[name];
return [name, {
status: step.status,
findingCount: step.findingCount,
reportRef: step.artifactRef || step.logRef,
}];
}),
);
return {
issueId: process.env.QA_ISSUE_ID?.trim() || null,
mode: "live-smoke-readiness",
status: evaluated.status,
conclusion: evaluated.status,
baseUrl: safeBaseUrl(baseUrl),
commit,
sourceCommit: commit,
expectedBuildSha: environmentReport?.publicReady?.expectedBuildSha ?? null,
observedBuildSha: environmentReport?.publicReady?.observedBuildSha ?? null,
buildShaMatches: environmentReport?.publicReady?.buildShaMatches ?? null,
checkedAt,
reportRef: toReportRef(rootDir, reportDir),
findingCount: evaluated.findingCount,
findings: evaluated.findings,
childReports,
steps: evaluated.steps,
boundaries: {
readOnlyChecksOnly: true,
noCredentialValues: true,
noInvoiceSamplesEmbedded: true,
noAbsoluteWorkspacePaths: true,
},
};
}
function writeStep({ rootDir, reportDir, name, status, kind, code, findingCount, detail, artifactFile }) {
if (!READINESS_STEP_NAMES.includes(name)) {
throw new Error(`unknown readiness step: ${name}`);
}
const step = normalizeStep(name, {
status,
kind,
code,
findingCount,
detail,
logRef: toReportRef(rootDir, path.join(reportDir, `${name}.log`)),
artifactRef: artifactFile ? toReportRef(rootDir, artifactFile) : null,
checkedAt: new Date().toISOString(),
});
mkdirSync(reportDir, { recursive: true });
writeFileSync(path.join(reportDir, `${name}.step.json`), `${JSON.stringify(step, null, 2)}\n`, "utf8");
}
function collectReportFiles(directory) {
if (!existsSync(directory)) {
return [];
}
const files = [];
for (const entry of readdirSync(directory)) {
const file = path.join(directory, entry);
const stat = statSync(file);
if (stat.isDirectory()) {
files.push(...collectReportFiles(file));
} else if (/\.(json|log|txt)$/i.test(entry) && entry !== "privacy-findings.json") {
files.push(file);
}
}
return files;
}
function escapeRegExp(value) {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
export function scanReadinessPrivacy({ rootDir, reportDir }) {
const patterns = [
{ category: "workspace_path", pattern: new RegExp(escapeRegExp(path.resolve(rootDir)), "g") },
{ category: "secret_field", pattern: /\b[A-Z][A-Z0-9_]*(?:PASSWORD|SECRET|TOKEN|ENCODING_AES_KEY)[A-Z0-9_]*\b/g },
{ category: "bearer_value", pattern: /Authorization:\s*Bearer\s+[A-Za-z0-9._-]{8,}/gi },
{ category: "private_key", pattern: /-----BEGIN [A-Z ]*PRIVATE KEY-----/g },
{ category: "email", pattern: /[\w.%+-]+@[\w.-]+\.[A-Za-z]{2,}/g },
{ category: "phone", pattern: /(?<!\d)1[3-9]\d{9}(?!\d)/g },
];
const findings = [];
for (const file of collectReportFiles(reportDir)) {
const lines = readFileSync(file, "utf8").split(/\r?\n/);
lines.forEach((line, index) => {
for (const { category, pattern } of patterns) {
pattern.lastIndex = 0;
if (pattern.test(line)) {
findings.push({
fileRef: toReportRef(rootDir, file),
line: index + 1,
category,
});
}
}
});
}
const report = {
status: findings.length ? "FAIL" : "PASS",
findingCount: findings.length,
findings,
checkedAt: new Date().toISOString(),
};
writeFileSync(path.join(reportDir, "privacy-findings.json"), `${JSON.stringify(report, null, 2)}\n`, "utf8");
return report;
}
function usage() {
console.error("Usage: live-smoke-readiness-report.mjs <write-step|summarize|privacy> ...");
}
function main() {
const [command, ...args] = process.argv.slice(2);
if (command === "write-step") {
const [rootDir, reportDir, name, status, kind, code, findingCount, detail = "", artifactFile = ""] = args;
writeStep({ rootDir, reportDir, name, status, kind, code, findingCount, detail, artifactFile });
return;
}
if (command === "summarize") {
const [rootDir, reportDir, baseUrl] = args;
const summary = buildReadinessSummary({ rootDir, reportDir, baseUrl });
writeFileSync(path.join(reportDir, "summary.json"), `${JSON.stringify(summary, null, 2)}\n`, "utf8");
console.log(JSON.stringify({ status: summary.status, findingCount: summary.findingCount }));
return;
}
if (command === "privacy") {
const [rootDir, reportDir] = args;
const report = scanReadinessPrivacy({ rootDir, reportDir });
console.log(JSON.stringify({ status: report.status, findingCount: report.findingCount }));
if (report.findingCount > 0) {
process.exitCode = 1;
}
return;
}
usage();
process.exitCode = 2;
}
const isMain = process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
if (isMain) {
main();
}