359 lines
13 KiB
JavaScript
359 lines
13 KiB
JavaScript
#!/usr/bin/env node
|
|
import fs from "node:fs";
|
|
import path from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
|
|
export const REQUIRED_DOCS = [
|
|
"docs/staging-live-uat.md",
|
|
"docs/production-checklist.md",
|
|
"docs/release-checklist.md",
|
|
"docs/env-vars.md",
|
|
"docs/deployment.md",
|
|
"docs/database.md",
|
|
"docs/worker.md",
|
|
"docs/mail.md",
|
|
"docs/security.md",
|
|
"docs/observability.md",
|
|
];
|
|
|
|
export const REQUIRED_STAGING_SECTIONS = [
|
|
"# Staging, Live, and UAT Readiness",
|
|
"## Readiness stages",
|
|
"## Local static gates",
|
|
"## Staging environment contract",
|
|
"## Runtime smoke records",
|
|
"## Code-gap tracking before live confidence",
|
|
"## Browser UAT matrix",
|
|
"## Final evidence packet",
|
|
"## Redaction boundary",
|
|
"## Verification",
|
|
];
|
|
|
|
export const REQUIRED_PACKAGE_SCRIPTS = [
|
|
"typecheck",
|
|
"lint",
|
|
"verify:ci-baseline",
|
|
"verify:env-drift",
|
|
"verify:health-baseline",
|
|
"verify:openapi-security",
|
|
"verify:db-migrate-status",
|
|
"verify:deploy-workflow",
|
|
"verify:release-docs",
|
|
"verify:docs-links",
|
|
"verify:staging-live-uat-readiness",
|
|
];
|
|
|
|
export const REQUIRED_CODE_EVIDENCE = [
|
|
{
|
|
path: "packages/db/src/api-keys.server.ts",
|
|
label: "API-key plaintext fallback cutover",
|
|
terms: [/allowPlaintextFallback/, /keyHash/],
|
|
},
|
|
{
|
|
path: "packages/config/src/catalog.ts",
|
|
label: "API-key cutover environment flag",
|
|
terms: [/API_KEY_LEGACY_PLAINTEXT_FALLBACK/],
|
|
},
|
|
{
|
|
path: "packages/storage/src/access/index.ts",
|
|
label: "storage MIME and size upload constraints",
|
|
terms: [/authorizeStorageUploadConstraints/, /unsupported_content_type/, /object_too_large/],
|
|
},
|
|
{
|
|
path: "apps/web/app/routes/app.$tenant/internal/imports/submit.tsx",
|
|
label: "final import submit enqueue route",
|
|
terms: [/findImportJobForInspection/, /buildImportJob/, /enqueueJob/],
|
|
},
|
|
{
|
|
path: "apps/api/src/exportRetentionQueue.ts",
|
|
label: "export artifact retention enqueue helper",
|
|
terms: [/queueExportArtifactRetention/, /expired-export-artifacts/, /queueNames\.maintenance/],
|
|
},
|
|
{
|
|
path: "apps/api/src/routes/maintenance-routes.ts",
|
|
label: "API upload and retention runtime routes",
|
|
terms: [/authorizeStorageUploadConstraints/, /\/v1\/maintenance\/export-retention\/enqueue/],
|
|
},
|
|
{
|
|
path: "apps/api/src/openapi.ts",
|
|
label: "OpenAPI retention enqueue contract",
|
|
terms: [/enqueueExportArtifactRetention/, /maintenance_queue_unavailable/],
|
|
},
|
|
{
|
|
path: "apps/worker/src/handlers.ts",
|
|
label: "worker retention take handling",
|
|
terms: [/expired-export-artifacts/, /payload\.take\s*\?\?/],
|
|
},
|
|
];
|
|
|
|
export const REQUIRED_TERM_GROUPS = [
|
|
{ id: "runtime-surfaces", description: "web, API, worker, database, Redis, and storage surfaces", terms: [/\bweb\b/i, /\bAPI\b/, /\bworker\b/i, /\bdatabase\b|\bPostgres\b/i, /\bRedis\b/i, /\bS3\b|\bstorage\b/i] },
|
|
{ id: "providers", description: "Mailjet, Stripe, and ClamAV provider smoke coverage", terms: [/\bMailjet\b/i, /\bStripe\b/i, /\bClamAV\b/i] },
|
|
{ id: "code-gap-evidence", description: "API-key, storage, imports, retention, and legacy API compatibility tracking", terms: [/\bAPI-key\b|\bAPI key\b/i, /\bstorage lifecycle\b/i, /\bImports\b|\bfinal submit\b/i, /\bRetention\b/i, /\blegacy API\b/i, /\bcompatibility boundary\b/i] },
|
|
{ id: "browser-uat", description: "manual browser UAT matrix", terms: [/\bbrowser UAT\b/i, /\bMFA\b/i, /\bonboarding\b/i, /\breview-before-apply\b/i] },
|
|
{ id: "safe-evidence", description: "request IDs, job IDs, queue names, and redaction", terms: [/\brequest ID\b/i, /\bjob ID\b/i, /\bqueue name\b/i, /\bredact(?:ed|ion)?\b/i] },
|
|
{ id: "manual-boundary", description: "manual runtime boundary", terms: [/\bmanual\b/i, /\bruntime\b/i, /\bprovider-console\b/i] },
|
|
];
|
|
|
|
export const FORBIDDEN_PLACEHOLDER_PATTERN = /\b(?:TODO|TBD|FIXME|XXX)\b/;
|
|
|
|
export const FORBIDDEN_AUTOMATIC_E2E_PATTERNS = [
|
|
{ id: "playwright-test-command", pattern: /(?:^|[`\n>\s])(?:pnpm\s+(?:exec\s+)?|npx\s+)?playwright\s+test\b/i },
|
|
{ id: "cypress-command", pattern: /(?:^|[`\n>\s])(?:pnpm\s+(?:exec\s+)?|npx\s+)?cypress\s+(?:run|open)\b/i },
|
|
{ id: "browser-e2e-script-command", pattern: /(?:^|[`\n>\s])(?:pnpm|npm|yarn)\s+(?:run\s+)?(?:test:e2e|e2e|browser)\b/i },
|
|
];
|
|
|
|
export const FORBIDDEN_SECRET_EXAMPLE_PATTERNS = [
|
|
{ id: "token-like-string", pattern: /\b(?:sk|pk|api|tok|key|secret)_[A-Za-z0-9]{20,}\b/i },
|
|
{ id: "raw-credential-assignment", pattern: /\b(?:API_KEY|TOKEN|SECRET|PASSWORD|WEBHOOK_SECRET|DATABASE_URL|REDIS_URL)\s*=\s*(?!<|\[|REDACTED\b|redacted\b|placeholder\b|example\b)[^\s`'"#]{8,}/i },
|
|
{ id: "credential-url", pattern: /\b(?:postgres|postgresql|redis|s3):\/\/[^\s)`'"<>]+/i },
|
|
];
|
|
|
|
const MARKDOWN_LINK_PATTERN = /!?\[[^\]]*\]\(([^)]+)\)/g;
|
|
|
|
function normalizeRelativePath(relativePath) {
|
|
return relativePath.split(path.sep).join("/");
|
|
}
|
|
|
|
function lineAndColumnForIndex(content, index) {
|
|
const prefix = content.slice(0, index);
|
|
const lines = prefix.split(/\r?\n/);
|
|
return { line: lines.length, column: lines.at(-1).length + 1 };
|
|
}
|
|
|
|
function readDocs(rootDir, requiredDocs = REQUIRED_DOCS) {
|
|
const docs = new Map();
|
|
const failures = [];
|
|
|
|
for (const relativePath of requiredDocs) {
|
|
const normalizedPath = normalizeRelativePath(relativePath);
|
|
const absolutePath = path.join(rootDir, normalizedPath);
|
|
if (!fs.existsSync(absolutePath)) {
|
|
failures.push(`${normalizedPath}: missing required documentation file`);
|
|
continue;
|
|
}
|
|
|
|
const content = fs.readFileSync(absolutePath, "utf8");
|
|
if (content.trim().length === 0) {
|
|
failures.push(`${normalizedPath}: documentation file is empty`);
|
|
continue;
|
|
}
|
|
|
|
docs.set(normalizedPath, content);
|
|
}
|
|
|
|
return { docs, failures };
|
|
}
|
|
|
|
function checkRequiredSections(docs) {
|
|
const failures = [];
|
|
const content = docs.get("docs/staging-live-uat.md");
|
|
if (!content) return failures;
|
|
|
|
for (const section of REQUIRED_STAGING_SECTIONS) {
|
|
const escaped = section.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
if (!new RegExp(`^${escaped}\\s*$`, "im").test(content)) {
|
|
failures.push(`docs/staging-live-uat.md: missing required section heading "${section}"`);
|
|
}
|
|
}
|
|
|
|
return failures;
|
|
}
|
|
|
|
function checkRequiredTermGroups(docs) {
|
|
const failures = [];
|
|
const combined = [...docs.entries()].map(([relativePath, content]) => `\n--- ${relativePath} ---\n${content}`).join("\n");
|
|
|
|
for (const group of REQUIRED_TERM_GROUPS) {
|
|
const missingTerms = group.terms.filter((term) => !term.test(combined));
|
|
if (missingTerms.length > 0) {
|
|
failures.push(`required term group "${group.id}" missing ${missingTerms.length} term(s): ${group.description}`);
|
|
}
|
|
}
|
|
|
|
return failures;
|
|
}
|
|
|
|
function checkForbiddenPattern(docs, patterns, messageForMatch) {
|
|
const failures = [];
|
|
|
|
for (const [relativePath, content] of docs.entries()) {
|
|
for (const patternEntry of patterns) {
|
|
patternEntry.pattern.lastIndex = 0;
|
|
const match = patternEntry.pattern.exec(content);
|
|
if (!match) continue;
|
|
const firstNonWhitespace = match[0].search(/\S/);
|
|
const { line, column } = lineAndColumnForIndex(content, match.index + Math.max(firstNonWhitespace, 0));
|
|
failures.push(messageForMatch(relativePath, line, column, patternEntry.id, match[0].trim()));
|
|
}
|
|
}
|
|
|
|
return failures;
|
|
}
|
|
|
|
function checkForbiddenPlaceholders(docs) {
|
|
const failures = [];
|
|
|
|
for (const [relativePath, content] of docs.entries()) {
|
|
FORBIDDEN_PLACEHOLDER_PATTERN.lastIndex = 0;
|
|
const match = FORBIDDEN_PLACEHOLDER_PATTERN.exec(content);
|
|
if (!match) continue;
|
|
const { line, column } = lineAndColumnForIndex(content, match.index);
|
|
failures.push(`${relativePath}:${line}:${column}: forbidden placeholder marker "${match[0]}"`);
|
|
}
|
|
|
|
return failures;
|
|
}
|
|
|
|
function isExternalOrAnchorOnly(linkTarget) {
|
|
return /^(?:https?:|mailto:|tel:)/i.test(linkTarget) || linkTarget.startsWith("#");
|
|
}
|
|
|
|
function targetPathForMarkdownLink(rootDir, sourceRelativePath, rawTarget) {
|
|
const withoutTitle = rawTarget.trim().replace(/\s+"[^"]*"\s*$/, "");
|
|
const withoutFragment = withoutTitle.split("#")[0].split("?")[0];
|
|
if (!withoutFragment || isExternalOrAnchorOnly(withoutFragment)) return null;
|
|
return path.resolve(rootDir, path.dirname(sourceRelativePath), decodeURIComponent(withoutFragment));
|
|
}
|
|
|
|
function checkLocalMarkdownLinks(rootDir, docs) {
|
|
const failures = [];
|
|
|
|
for (const [relativePath, content] of docs.entries()) {
|
|
MARKDOWN_LINK_PATTERN.lastIndex = 0;
|
|
let match;
|
|
while ((match = MARKDOWN_LINK_PATTERN.exec(content)) !== null) {
|
|
const targetPath = targetPathForMarkdownLink(rootDir, relativePath, match[1]);
|
|
if (!targetPath) continue;
|
|
|
|
if (!targetPath.startsWith(path.resolve(rootDir))) {
|
|
const { line, column } = lineAndColumnForIndex(content, match.index);
|
|
failures.push(`${relativePath}:${line}:${column}: local Markdown link escapes repository "${match[1]}"`);
|
|
continue;
|
|
}
|
|
|
|
if (!fs.existsSync(targetPath)) {
|
|
const { line, column } = lineAndColumnForIndex(content, match.index);
|
|
failures.push(`${relativePath}:${line}:${column}: unresolved local Markdown link "${match[1]}"`);
|
|
}
|
|
}
|
|
}
|
|
|
|
return failures;
|
|
}
|
|
|
|
function checkPackageScripts(rootDir) {
|
|
const failures = [];
|
|
const packageJsonPath = path.join(rootDir, "package.json");
|
|
if (!fs.existsSync(packageJsonPath)) {
|
|
return ["package.json: missing required package metadata"];
|
|
}
|
|
|
|
let packageJson;
|
|
try {
|
|
packageJson = JSON.parse(fs.readFileSync(packageJsonPath, "utf8"));
|
|
} catch {
|
|
return ["package.json: invalid JSON"];
|
|
}
|
|
|
|
const scripts = packageJson.scripts ?? {};
|
|
for (const scriptName of REQUIRED_PACKAGE_SCRIPTS) {
|
|
if (typeof scripts[scriptName] !== "string" || scripts[scriptName].trim().length === 0) {
|
|
failures.push(`package.json: missing required readiness script "${scriptName}"`);
|
|
}
|
|
}
|
|
|
|
return failures;
|
|
}
|
|
|
|
function checkCodeEvidence(rootDir, requiredCodeEvidence = REQUIRED_CODE_EVIDENCE) {
|
|
const failures = [];
|
|
const checked = [];
|
|
|
|
for (const evidence of requiredCodeEvidence) {
|
|
const relativePath = normalizeRelativePath(evidence.path);
|
|
const absolutePath = path.join(rootDir, relativePath);
|
|
checked.push(relativePath);
|
|
|
|
if (!fs.existsSync(absolutePath)) {
|
|
failures.push(`${relativePath}: missing required code evidence for ${evidence.label}`);
|
|
continue;
|
|
}
|
|
|
|
const content = fs.readFileSync(absolutePath, "utf8");
|
|
for (const term of evidence.terms) {
|
|
term.lastIndex = 0;
|
|
if (!term.test(content)) {
|
|
failures.push(`${relativePath}: missing required code evidence term ${term} for ${evidence.label}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
return { checked, failures };
|
|
}
|
|
|
|
export function verifyStagingLiveUatReadiness(rootDir = process.cwd()) {
|
|
const { docs, failures } = readDocs(rootDir);
|
|
|
|
failures.push(...checkRequiredSections(docs));
|
|
failures.push(...checkRequiredTermGroups(docs));
|
|
failures.push(...checkForbiddenPlaceholders(docs));
|
|
failures.push(
|
|
...checkForbiddenPattern(
|
|
docs,
|
|
FORBIDDEN_AUTOMATIC_E2E_PATTERNS,
|
|
(relativePath, line, column, id) => `${relativePath}:${line}:${column}: forbidden automatic browser/E2E command pattern "${id}"`
|
|
)
|
|
);
|
|
failures.push(
|
|
...checkForbiddenPattern(
|
|
docs,
|
|
FORBIDDEN_SECRET_EXAMPLE_PATTERNS,
|
|
(relativePath, line, column, id) => `${relativePath}:${line}:${column}: forbidden secret-shaped example pattern "${id}"`
|
|
)
|
|
);
|
|
failures.push(...checkLocalMarkdownLinks(rootDir, docs));
|
|
failures.push(...checkPackageScripts(rootDir));
|
|
const codeEvidence = checkCodeEvidence(rootDir);
|
|
failures.push(...codeEvidence.failures);
|
|
|
|
return {
|
|
checkedDocs: [...docs.keys()],
|
|
checkedCode: codeEvidence.checked,
|
|
requiredScripts: REQUIRED_PACKAGE_SCRIPTS,
|
|
failures,
|
|
};
|
|
}
|
|
|
|
export function formatStagingLiveUatReadinessResult(result) {
|
|
const lines = [];
|
|
if (result.failures.length === 0) {
|
|
lines.push("PASS staging/live/UAT readiness verification");
|
|
lines.push(`PASS ${result.checkedDocs.length} readiness documentation files satisfy the staging/live/UAT contract`);
|
|
lines.push(`PASS ${result.requiredScripts.length} package scripts required for local readiness are present`);
|
|
lines.push(`PASS ${result.checkedCode.length} code evidence files close staging-blocking local gaps`);
|
|
} else {
|
|
lines.push("FAIL staging/live/UAT readiness verification");
|
|
for (const failure of result.failures) lines.push(`FAIL ${failure}`);
|
|
}
|
|
|
|
lines.push(`OBSERVE checked files: ${result.checkedDocs.join(", ")}`);
|
|
lines.push(`OBSERVE checked code evidence: ${result.checkedCode.join(", ")}`);
|
|
lines.push(`Summary: ${result.failures.length === 0 ? 1 : 0} passed, 1 observed, ${result.failures.length} failed`);
|
|
return lines.join("\n");
|
|
}
|
|
|
|
const isCli = process.argv[1] && fileURLToPath(import.meta.url) === path.resolve(process.argv[1]);
|
|
|
|
if (isCli) {
|
|
const result = verifyStagingLiveUatReadiness(process.cwd());
|
|
const output = formatStagingLiveUatReadinessResult(result);
|
|
|
|
if (result.failures.length > 0) {
|
|
process.stderr.write(output);
|
|
process.exitCode = 1;
|
|
} else {
|
|
process.stdout.write(output);
|
|
}
|
|
}
|