完善 readiness、发布追溯与敏感治理(07-15-production-trust-closeout-cleanup)

这个提交包含在:
2026-07-16 05:17:38 +08:00
父节点 abb6a22f5c
当前提交 28e8e746b7
共修改 29 个文件,包含 1849 行新增和 233 行删除
+22 -5
查看文件
@@ -3,21 +3,31 @@ set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
echo "=== [1/5] Process Contract Gate ==="
echo "=== [1/7] Process Contract Gate ==="
(
cd "$ROOT_DIR"
node scripts/qa/check-process-contract.mjs --baseline
)
echo ""
echo "=== [2/5] Server Prisma Validate ==="
echo "=== [2/7] Governance + Sensitive Content ==="
(
cd "$ROOT_DIR"
node scripts/qa/check-trellis-governance.mjs
node scripts/qa/check-sensitive-content.mjs
bash -n scripts/qa/run-live-smoke-readiness.sh
bash -n deploy/trialrun/deploy.sh
)
echo ""
echo "=== [3/7] Server Prisma Validate ==="
(
cd "$ROOT_DIR/server"
npm run prisma:validate
)
echo ""
echo "=== [3/5] Server Static Checks ==="
echo "=== [4/7] Server Static Checks ==="
(
cd "$ROOT_DIR/server"
node --check src/routes/reimbursements.js
@@ -69,7 +79,14 @@ echo "=== [3/5] Server Static Checks ==="
)
echo ""
echo "=== [4/5] Client Typecheck + Build ==="
echo "=== [5/7] Server Unit Tests ==="
(
cd "$ROOT_DIR/server"
npm test
)
echo ""
echo "=== [6/7] Client Typecheck + Build ==="
(
cd "$ROOT_DIR/client"
npm run typecheck
@@ -77,7 +94,7 @@ echo "=== [4/5] Client Typecheck + Build ==="
)
echo ""
echo "=== [5/5] Feature Matrix Consistency ==="
echo "=== [7/7] Feature Matrix Consistency ==="
(
cd "$ROOT_DIR"
python3 scripts/validate-feature-matrix.py
+49 -3
查看文件
@@ -3,6 +3,22 @@ import { existsSync, readdirSync, statSync } from "node:fs";
import { extname } from "node:path";
const baseUrl = (process.env.LIVE_SMOKE_BASE_URL || "https://oa.hyinda.com").trim().replace(/\/$/, "");
const expectedBuildShaInput = String(process.env.LIVE_SMOKE_EXPECTED_BUILD_SHA ?? "").trim();
function normalizeBuildSha(value) {
const normalized = String(value ?? "").trim().toLowerCase();
if (normalized === "unknown") {
return "unknown";
}
return /^[0-9a-f]{7,64}$/.test(normalized) ? normalized.slice(0, 12) : null;
}
function buildShaMatches(expected, observed) {
if (!expected || !observed || observed === "unknown") {
return null;
}
return expected.startsWith(observed) || observed.startsWith(expected);
}
function present(key) {
return Boolean(process.env[key]?.trim());
@@ -108,7 +124,7 @@ function addMissing(checks) {
checks.missing.push("ocrOrEmployeeCredentialReady");
}
if (!checks.liveOcr.invoiceSampleReady) {
checks.missing.push("PLAYWRIGHT_OCR_LIVE_FILE 或 PLAYWRIGHT_OCR_LIVE_DIR 中的 PDF 样票");
checks.missing.push("invoiceSampleReady");
}
}
@@ -121,12 +137,40 @@ async function main() {
const ready = await fetchJson(`${baseUrl}/ready`);
const wecomConfig = await fetchJson(`${baseUrl}/api/auth/wecom/config`);
const authCapabilities = await fetchJson(`${baseUrl}/api/auth/capabilities`);
const findings = checks.missing.map((code) => ({ code, severity: "manual_or_config" }));
if (!ready.ok) findings.push({ code: "publicReady", severity: "blocker" });
const expectedBuildSha = normalizeBuildSha(expectedBuildShaInput);
const observedBuildSha = normalizeBuildSha(
ready.json?.data?.buildSha ?? ready.json?.error?.details?.buildSha,
);
const buildMatches = buildShaMatches(expectedBuildSha, observedBuildSha);
if (ready.ok && (!observedBuildSha || observedBuildSha === "unknown")) {
findings.push({ code: "publicBuildShaUnavailable", severity: "manual_or_config" });
} else if (ready.ok && expectedBuildSha && buildMatches === false) {
findings.push({ code: "publicBuildShaMismatch", severity: "manual_or_config" });
}
if (expectedBuildShaInput && !expectedBuildSha) {
findings.push({ code: "expectedBuildShaInvalid", severity: "manual_or_config" });
}
if (!wecomConfig.ok) findings.push({ code: "wecomConfigReady", severity: "blocker" });
if (!authCapabilities.ok) findings.push({ code: "authCapabilitiesReady", severity: "blocker" });
const status = findings.some((finding) => finding.severity === "blocker")
? "FAIL"
: findings.length
? "PASS_WITH_FINDINGS"
: "PASS";
const result = {
status,
findingCount: findings.length,
findings,
checkedAt: new Date().toISOString(),
...checks,
publicReady: {
ok: ready.ok,
status: ready.status,
expectedBuildSha,
observedBuildSha,
buildShaMatches: buildMatches,
},
wecomConfig: {
ok: wecomConfig.ok,
@@ -135,7 +179,7 @@ async function main() {
entryEnabled: wecomConfig.json?.data?.entryEnabled ?? null,
oauthEnabled: wecomConfig.json?.data?.oauthEnabled ?? null,
defaultReturnTo: wecomConfig.json?.data?.defaultReturnTo ?? null,
loginUrl: wecomConfig.json?.data?.loginUrl ?? null,
loginUrlReady: Boolean(wecomConfig.json?.data?.loginUrl),
},
authCapabilities: {
ok: authCapabilities.ok,
@@ -150,8 +194,10 @@ async function main() {
if (jsonOnly) {
console.log(JSON.stringify(result, null, 2));
} else {
console.log(`[live-smoke-env] status=${result.status} findings=${result.findingCount}`);
console.log(`[live-smoke-env] base=${result.baseUrl}`);
console.log(`[live-smoke-env] publicReady=${result.publicReady.ok} status=${result.publicReady.status}`);
console.log(`[live-smoke-env] buildShaObserved=${result.publicReady.observedBuildSha ?? "unavailable"} match=${result.publicReady.buildShaMatches ?? "not_checked"}`);
console.log(`[live-smoke-env] wecom=${result.wecomConfig.statusText ?? "unknown"} entry=${result.wecomConfig.entryEnabled} oauth=${result.wecomConfig.oauthEnabled}`);
console.log(`[live-smoke-env] authCapabilities=${result.authCapabilities.ok} emailDelivery=${result.authCapabilities.emailDeliveryReady}`);
console.log(`[live-smoke-env] liveSmokeReady=${result.liveSmokeReady}`);
@@ -161,7 +207,7 @@ async function main() {
}
}
if (strict && (!result.liveSmokeReady || !result.liveOcrReady || !result.publicReady.ok || !result.wecomConfig.ok || !result.authCapabilities.ok)) {
if (strict && result.status !== "PASS") {
process.exit(1);
}
}
+116 -65
查看文件
@@ -1,78 +1,129 @@
#!/usr/bin/env node
import path from "node:path";
import process from "node:process";
import { PrismaClient } from "../../server/node_modules/@prisma/client/index.js";
import { fileURLToPath } from "node:url";
function parseThresholds() {
const arg = process.argv.find((item) => item.startsWith("--thresholds="));
export function parseThresholds(argv = process.argv.slice(2)) {
const arg = argv.find((item) => item.startsWith("--thresholds="));
const raw = arg ? arg.slice("--thresholds=".length) : "2,5,10";
return raw
.split(",")
.map((item) => Number(item.trim()))
.filter((item) => Number.isFinite(item) && item > 0)
.sort((a, b) => a - b);
return Array.from(new Set(
raw
.split(",")
.map((item) => Number(item.trim()))
.filter((item) => Number.isFinite(item) && item > 0)
.map((item) => Math.floor(item)),
)).sort((a, b) => a - b);
}
function minutesAgo(minutes) {
return new Date(Date.now() - minutes * 60_000);
function minutesAgo(minutes, now) {
return new Date(now.getTime() - minutes * 60_000);
}
export async function buildPendingOcrSummary({
databaseReady,
thresholds,
countPending,
now = new Date(),
}) {
if (!databaseReady) {
return {
mode: "pending-ocr-readiness",
status: "SKIP",
findingCount: 1,
findings: [{ code: "database_not_configured", severity: "manual_or_config" }],
thresholds: [],
checkedAt: now.toISOString(),
};
}
const thresholdResults = [];
for (const thresholdMinutes of thresholds) {
const count = Number(await countPending({
status: "pending_ocr",
created_at: { lte: minutesAgo(thresholdMinutes, now) },
}));
thresholdResults.push({
thresholdMinutes,
count: Number.isFinite(count) && count > 0 ? Math.floor(count) : 0,
});
}
const findingCount = thresholdResults.reduce((maximum, item) => Math.max(maximum, item.count), 0);
return {
mode: "pending-ocr-readiness",
status: findingCount > 0 ? "PASS_WITH_FINDINGS" : "PASS",
findingCount,
findings: findingCount > 0
? [{ code: "pending_ocr_detected", severity: "finding", count: findingCount }]
: [],
thresholds: thresholdResults,
checkedAt: now.toISOString(),
};
}
export async function executePendingOcrCheck({
databaseReady = Boolean(process.env.DATABASE_URL?.trim()),
thresholds = parseThresholds(),
} = {}) {
if (!databaseReady) {
return buildPendingOcrSummary({ databaseReady: false, thresholds, countPending: async () => 0 });
}
const prismaClientModule = await import("../../server/src/prismaClient.js");
const createPrismaClient = prismaClientModule.createPrismaClient
?? prismaClientModule.default?.createPrismaClient;
if (typeof createPrismaClient !== "function") {
throw new Error("Prisma client factory is unavailable");
}
const { prisma, pool } = createPrismaClient();
try {
return await buildPendingOcrSummary({
databaseReady: true,
thresholds,
countPending: (where) => prisma.invoicePool.count({ where }),
});
} finally {
try {
await prisma.$disconnect();
} finally {
await pool.end();
}
}
}
function printSummary(summary, jsonOnly) {
if (jsonOnly) {
console.log(JSON.stringify(summary, null, 2));
return;
}
console.log(`[pending-ocr][${summary.status}] findings=${summary.findingCount}`);
for (const item of summary.thresholds) {
console.log(`[pending-ocr] thresholdMinutes=${item.thresholdMinutes} count=${item.count}`);
}
}
async function main() {
if (!process.env.DATABASE_URL) {
console.log("[pending-ocr][SKIP] missing DATABASE_URL");
return;
}
const thresholds = parseThresholds();
const prisma = new PrismaClient();
const jsonOnly = process.argv.includes("--json");
try {
const summary = [];
for (const minutes of thresholds) {
const where = {
status: "pending_ocr",
created_at: { lte: minutesAgo(minutes) },
};
const count = await prisma.invoicePool.count({ where });
const rows = await prisma.invoicePool.findMany({
where,
orderBy: { created_at: "asc" },
take: 50,
select: {
id: true,
created_at: true,
updated_at: true,
ocr_attempts: true,
ocr_last_requested_at: true,
ocr_last_error: true,
ocr_locked_at: true,
ocr_lock_owner: true,
},
});
summary.push({
thresholdMinutes: minutes,
count,
sampleLimit: 50,
items: rows.map((item) => ({
id: item.id,
createdAt: item.created_at?.toISOString?.() ?? null,
updatedAt: item.updated_at?.toISOString?.() ?? null,
attempts: item.ocr_attempts ?? 0,
lastRequestedAt: item.ocr_last_requested_at?.toISOString?.() ?? null,
lastError: item.ocr_last_error ?? null,
lockedAt: item.ocr_locked_at?.toISOString?.() ?? null,
lockOwner: item.ocr_lock_owner ?? null,
})),
});
}
console.log(JSON.stringify({ ok: true, thresholds: summary }, null, 2));
} finally {
await prisma.$disconnect();
const summary = await executePendingOcrCheck();
printSummary(summary, jsonOnly);
} catch {
const summary = {
mode: "pending-ocr-readiness",
status: "FAIL",
findingCount: 1,
findings: [{ code: "pending_ocr_check_failed", severity: "blocker" }],
thresholds: [],
checkedAt: new Date().toISOString(),
};
printSummary(summary, jsonOnly);
console.error("[pending-ocr][FAIL] database check failed");
process.exitCode = 1;
}
}
main().catch((error) => {
console.error(`[pending-ocr][FAIL] ${error instanceof Error ? error.message : String(error)}`);
process.exit(1);
});
const isMain = process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
if (isMain) {
await main();
}
+46 -6
查看文件
@@ -4,11 +4,21 @@ import process from "node:process";
const baseUrl = normalizeBaseUrl(process.argv[2] ?? process.env.PUBLIC_BASE_URL ?? process.env.LIVE_SMOKE_BASE_URL ?? "https://oa.hyinda.com");
const focusedAssetPattern = /(MobileReimbursementDetailPage|ReimbursementDetailPage|page-mobile|page-assets|page-travel)/;
const forbiddenTerms = ["绑定整单", "不绑定明细", "识别完成后可在", "发票池", "OCR"];
const expectedBuildSha = normalizeBuildSha(process.env.PUBLIC_EXPECTED_BUILD_SHA);
const forbiddenFullBuildSha = normalizeBuildSha(process.env.PUBLIC_FORBIDDEN_BUILD_SHA, { keepFull: true });
function normalizeBaseUrl(value) {
return String(value).replace(/\/+$/, "");
}
function normalizeBuildSha(value, { keepFull = false } = {}) {
const normalized = String(value ?? "").trim().toLowerCase();
if (!/^[0-9a-f]{7,64}$/.test(normalized)) {
return null;
}
return keepFull ? normalized : normalized.slice(0, 12);
}
function toAssetPath(value) {
if (value.startsWith("/")) {
return value;
@@ -27,6 +37,7 @@ async function fetchText(path) {
async function collectAssets() {
const seen = new Set();
const queue = [];
const sources = new Map();
const html = await fetchText("/");
for (const match of html.matchAll(/(?:src|href)=["'](\/assets\/[^"']+\.js)["']/g)) {
@@ -40,6 +51,7 @@ async function collectAssets() {
for (let index = 0; index < queue.length; index += 1) {
const assetPath = queue[index];
const source = await fetchText(assetPath);
sources.set(assetPath, source);
for (const match of source.matchAll(/\.\/([A-Za-z0-9_./-]+\.js)/g)) {
const nextPath = toAssetPath(match[1]);
if (!seen.has(nextPath)) {
@@ -49,28 +61,56 @@ async function collectAssets() {
}
}
return Array.from(seen).sort();
return {
assets: Array.from(seen).sort(),
sources,
};
}
const assets = await collectAssets();
const { assets, sources } = await collectAssets();
const findings = [];
let expectedBuildShaFound = expectedBuildSha === null;
let forbiddenFullBuildShaFound = false;
for (const assetPath of assets) {
const source = sources.get(assetPath) ?? "";
if (expectedBuildSha && source.toLowerCase().includes(expectedBuildSha)) {
expectedBuildShaFound = true;
}
if (
forbiddenFullBuildSha
&& forbiddenFullBuildSha !== expectedBuildSha
&& source.toLowerCase().includes(forbiddenFullBuildSha)
) {
forbiddenFullBuildShaFound = true;
}
if (!focusedAssetPattern.test(assetPath)) {
continue;
}
const source = await fetchText(assetPath);
for (const term of forbiddenTerms) {
if (source.includes(term)) {
findings.push({ assetPath, term });
findings.push({ assetPath, category: "forbidden_copy", term });
}
}
}
console.log(`[public-bundle-copy] base=${baseUrl} scanned=${assets.length} focused=${assets.filter((item) => focusedAssetPattern.test(item)).length} findings=${findings.length}`);
if (!expectedBuildShaFound) {
findings.push({ assetPath: "public-bundle", category: "build_sha_missing" });
}
if (forbiddenFullBuildShaFound) {
findings.push({ assetPath: "public-bundle", category: "full_build_sha_exposed" });
}
const buildMarkerStatus = expectedBuildSha === null
? "NOT_CHECKED"
: expectedBuildShaFound && !forbiddenFullBuildShaFound
? "PASS"
: "FAIL";
console.log(`[public-bundle-copy] base=${baseUrl} scanned=${assets.length} focused=${assets.filter((item) => focusedAssetPattern.test(item)).length} buildMarker=${buildMarkerStatus} findings=${findings.length}`);
for (const finding of findings) {
console.log(`ERROR ${finding.assetPath} contains ${finding.term}`);
console.log(`ERROR ${finding.assetPath} category=${finding.category}`);
}
if (findings.length) {
+155
查看文件
@@ -0,0 +1,155 @@
#!/usr/bin/env node
import { existsSync, readFileSync } from "node:fs";
import path from "node:path";
import process from "node:process";
import { fileURLToPath } from "node:url";
export const DEFAULT_SCAN_FILES = [
"AGENTS.md",
"README.md",
"server/README.md",
"client/README.md",
"server/.env.example",
"docker-compose.yml",
"deploy/trialrun/deploy.sh",
"deploy/trialrun/.env.example",
"deploy/trialrun/runbook.md",
"deploy/trialrun/checklist.md",
"deploy/trialrun/setup-server.sh",
"docs/Agent开发交付实践.md",
"docs/文档索引.md",
"docs/文档事实源状态矩阵.md",
"docs/产品设计规则.md",
"docs/报销发票附件规则.md",
"docs/试运行与自动化验证手册.md",
];
const CURRENT_DOCUMENT_REGISTRY = "docs/文档事实源状态矩阵.md";
const TEXT_PATTERNS = [
{
category: "private_key",
pattern: /-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----/,
},
{
category: "bearer_value",
pattern: /Authorization:\s*Bearer\s+[A-Za-z0-9._-]{8,}/i,
},
{
category: "credential_in_url",
pattern: /postgres(?:ql)?:\/\/[^\s:/<>]+:[^\s@<>]+@/i,
},
{
category: "assigned_secret",
pattern: /\b[A-Z][A-Z0-9_]*(?:PASSWORD|SECRET|TOKEN|API_KEY)[A-Z0-9_]*\b\s*[:=]\s*["']?(?!\s*(?:<|\$\{|\*|""|''|REDACTED|example))[A-Za-z0-9._/+=:-]{8,}/,
},
{
category: "personal_workspace_path",
pattern: /\/(?:home|Users)\/(?!<)[^/\s`'"()]+\/[A-Za-z0-9._/ -]+/,
},
{
category: "email_account",
pattern: /[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/,
},
{
category: "phone_account",
pattern: /(?<!\d)1[3-9]\d{9}(?!\d)/,
},
];
function isPublicIpv4(value) {
const parts = value.split(".").map(Number);
if (parts.length !== 4 || parts.some((item) => !Number.isInteger(item) || item < 0 || item > 255)) {
return false;
}
if (parts[0] === 10 || parts[0] === 127 || parts[0] === 0) return false;
if (parts[0] === 169 && parts[1] === 254) return false;
if (parts[0] === 172 && parts[1] >= 16 && parts[1] <= 31) return false;
if (parts[0] === 192 && parts[1] === 168) return false;
if (parts[0] >= 224) return false;
return true;
}
export function scanSensitiveText(text, fileRef) {
const findings = [];
String(text).split(/\r?\n/).forEach((line, index) => {
for (const { category, pattern } of TEXT_PATTERNS) {
if (category === "credential_in_url" && (/\$\{/.test(line) || /<[^>]+>/.test(line))) {
continue;
}
if (pattern.test(line)) {
findings.push({ fileRef, line: index + 1, category });
}
}
const ipv4Values = line.match(/(?<!\d)(?:\d{1,3}\.){3}\d{1,3}(?!\d)/g) || [];
if (ipv4Values.some(isPublicIpv4)) {
findings.push({ fileRef, line: index + 1, category: "public_ipv4" });
}
});
return findings;
}
export function extractCurrentDocumentFiles(matrixText) {
const files = new Set();
for (const line of String(matrixText).split(/\r?\n/)) {
if (!line.startsWith("|")) {
continue;
}
const cells = line
.split("|")
.slice(1, -1)
.map((cell) => cell.trim());
if (!cells.some((cell) => cell === "`CURRENT`" || cell === "CURRENT")) {
continue;
}
const contractCell = cells[1] ?? "";
for (const match of contractCell.matchAll(/`(docs\/[^`|]+\.(?:md|csv))`/g)) {
const normalized = path.posix.normalize(match[1]);
if (normalized.startsWith("docs/") && !normalized.includes("..")) {
files.add(normalized);
}
}
}
return [...files].sort();
}
export function resolveDefaultScanFiles(rootDir) {
const registryPath = path.join(rootDir, CURRENT_DOCUMENT_REGISTRY);
const currentDocuments = existsSync(registryPath)
? extractCurrentDocumentFiles(readFileSync(registryPath, "utf8"))
: [];
return [...new Set([...DEFAULT_SCAN_FILES, ...currentDocuments])];
}
export function scanSensitiveContent({ rootDir, files }) {
const findings = [];
let filesScanned = 0;
const scanFiles = files ?? resolveDefaultScanFiles(rootDir);
for (const fileRef of scanFiles) {
const file = path.join(rootDir, fileRef);
if (!existsSync(file)) {
continue;
}
filesScanned += 1;
findings.push(...scanSensitiveText(readFileSync(file, "utf8"), fileRef));
}
return {
status: findings.length ? "FAIL" : "PASS",
findingCount: findings.length,
filesScanned,
findings,
};
}
function main() {
const rootDir = path.resolve(new URL("../..", import.meta.url).pathname);
const result = scanSensitiveContent({ rootDir });
console.log(JSON.stringify(result, null, 2));
if (result.findingCount > 0) {
process.exitCode = 1;
}
}
const isMain = process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
if (isMain) {
main();
}
+45 -17
查看文件
@@ -2,6 +2,7 @@
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const repoRoot = process.cwd();
@@ -87,17 +88,33 @@ function parseJsonl(relativePath) {
});
}
function checkContextManifest(result, taskDir, action) {
export function contextManifestPolicy(dispatchMode) {
const inlineDispatch = dispatchMode === "inline";
return {
required: !inlineDispatch,
requireRealEntries: !inlineDispatch,
rejectSeedRows: !inlineDispatch,
};
}
function checkContextManifest(result, taskDir, action, dispatchMode) {
const relativePath = `${taskDir}/${action}.jsonl`;
const policy = contextManifestPolicy(dispatchMode);
if (!exists(relativePath)) {
fail(result, `task.${action}_missing`, `${taskDir} missing ${action}.jsonl`);
if (policy.required) {
fail(result, `task.${action}_missing`, `${taskDir} missing ${action}.jsonl`);
} else {
pass(result, `task.${action}_inline_optional`, `${taskDir} uses inline dispatch; ${action}.jsonl is optional`);
}
return;
}
const entries = parseJsonl(relativePath);
let realEntryCount = 0;
for (const { value, line } of entries) {
if (value._example) {
fail(result, `task.${action}_seed`, `${relativePath}:${line} still contains seed _example row`);
if (policy.rejectSeedRows) {
fail(result, `task.${action}_seed`, `${relativePath}:${line} still contains seed _example row`);
}
continue;
}
if (typeof value.file !== "string" || !value.file.trim()) {
@@ -117,8 +134,10 @@ function checkContextManifest(result, taskDir, action) {
}
realEntryCount += 1;
}
if (realEntryCount === 0) {
if (realEntryCount === 0 && policy.requireRealEntries) {
fail(result, `task.${action}_empty`, `${relativePath} must contain at least one real context entry`);
} else if (realEntryCount === 0) {
pass(result, `task.${action}_inline_optional`, `${relativePath} has no injected context because dispatch_mode=inline`);
}
}
@@ -187,6 +206,8 @@ function checkCoreFiles(result) {
function checkTaskContracts(result) {
const taskDirs = listTaskDirs();
const config = exists(".trellis/config.yaml") ? readText(".trellis/config.yaml") : "";
const dispatchMode = /dispatch_mode:\s*sub-agent\b/.test(config) ? "sub-agent" : "inline";
if (taskDirs.length === 0) {
fail(result, "tasks.none", "no .trellis/tasks/* directories found");
return;
@@ -227,8 +248,8 @@ function checkTaskContracts(result) {
if (complexTaskPattern.test(complexText) && !exists(`${dir}/design.md`) && !exists(`${dir}/implement.md`)) {
fail(result, "task.complex_design", `${dir} complex task must include design.md or implement.md`);
}
checkContextManifest(result, dir, "implement");
checkContextManifest(result, dir, "check");
checkContextManifest(result, dir, "implement", dispatchMode);
checkContextManifest(result, dir, "check", dispatchMode);
}
if (result.status === "PASS") pass(result, "task_contracts", `${taskDirs.length} task directories have Trellis contracts`);
}
@@ -296,16 +317,23 @@ function checkSensitiveData(result) {
if (result.status === "PASS") pass(result, "sensitive_scan", `${scanFiles.length} Trellis files scanned`);
}
const result = createResult();
try {
checkCoreFiles(result);
checkTaskContracts(result);
checkDocsRegistration(result);
checkRemovedProjectSkillReferences(result);
checkSensitiveData(result);
} catch (error) {
fail(result, "exception", error instanceof Error ? error.message : String(error));
export function runGovernanceChecks() {
const result = createResult();
try {
checkCoreFiles(result);
checkTaskContracts(result);
checkDocsRegistration(result);
checkRemovedProjectSkillReferences(result);
checkSensitiveData(result);
} catch (error) {
fail(result, "exception", error instanceof Error ? error.message : String(error));
}
return result;
}
console.log(JSON.stringify(result, null, 2));
process.exit(result.status === "PASS" ? 0 : 1);
const isMain = process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
if (isMain) {
const result = runGovernanceChecks();
console.log(JSON.stringify(result, null, 2));
process.exit(result.status === "PASS" ? 0 : 1);
}
+293
查看文件
@@ -0,0 +1,293 @@
#!/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();
}
可执行文件 -> 普通文件
+151 -40
查看文件
@@ -1,30 +1,114 @@
#!/usr/bin/env bash
set -euo pipefail
set -u -o pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
CLIENT_DIR="$ROOT_DIR/client"
PUBLIC_BASE_URL="${LIVE_SMOKE_BASE_URL:-https://oa.hyinda.com}"
REPORT_DIR="${LIVE_SMOKE_READINESS_DIR:-$ROOT_DIR/artifacts/qa-flow/live-smoke-readiness-$(date +%Y%m%d-%H%M%S)}"
REPORT_TOOL="$ROOT_DIR/scripts/qa/live-smoke-readiness-report.mjs"
STATUS=0
SOURCE_COMMIT="$(git -C "$ROOT_DIR" rev-parse HEAD 2>/dev/null || printf 'unknown')"
SOURCE_SHORT_COMMIT="${SOURCE_COMMIT:0:12}"
EXPECTED_BUILD_SHA="${LIVE_SMOKE_EXPECTED_BUILD_SHA:-$SOURCE_SHORT_COMMIT}"
FORBIDDEN_FULL_BUILD_SHA=""
if [[ "$SOURCE_COMMIT" =~ ^[0-9a-fA-F]{40,64}$ && "${EXPECTED_BUILD_SHA:0:12}" == "$SOURCE_SHORT_COMMIT" ]]; then
FORBIDDEN_FULL_BUILD_SHA="$SOURCE_COMMIT"
fi
mkdir -p "$REPORT_DIR"
write_summary() {
local exit_code="$?"
local status="PASS"
if [[ "$exit_code" -ne 0 ]]; then
status="FAIL"
fi
QA_REPORT_DIR="$REPORT_DIR" \
QA_ISSUE_ID="${QA_ISSUE_ID:-}" \
QA_BASE_URL="$PUBLIC_BASE_URL" \
QA_MODE="live-smoke-readiness" \
QA_STATUS="$status" \
QA_SUMMARY_NOTES="公网 /ready、public bundle、pending OCR、live smoke 和真实 OCR 可用性检查;缺少凭据或样票时按脚本规则记录 SKIP。" \
node "$ROOT_DIR/scripts/qa/write-qa-summary.mjs" >/dev/null 2>&1 || true
return "$exit_code"
write_step() {
local name="$1"
local status="$2"
local kind="$3"
local code="$4"
local finding_count="$5"
local detail="$6"
local artifact_file="${7:-}"
node "$REPORT_TOOL" write-step \
"$ROOT_DIR" "$REPORT_DIR" "$name" "$status" "$kind" "$code" "$finding_count" "$detail" "$artifact_file"
}
trap write_summary EXIT
apply_step_status() {
local status="$1"
local kind="$2"
if [[ "$kind" == "required" && "$status" =~ FAIL|ERROR|BLOCKED ]]; then
STATUS=1
fi
}
run_command_step() {
local name="$1"
local kind="$2"
local detail="$3"
shift 3
local log_file="$REPORT_DIR/${name}.log"
local code=0
local status="PASS"
local finding_count=0
echo ">>> [live-readiness] $name"
if "$@" > >(tee "$log_file") 2>&1; then
echo "[live-readiness][PASS] $name"
else
code="$?"
status="FAIL"
finding_count=1
echo "[live-readiness][FAIL] $name exit=$code"
fi
write_step "$name" "$status" "$kind" "$code" "$finding_count" "$detail" "$log_file"
apply_step_status "$status" "$kind"
}
run_json_step() {
local name="$1"
local kind="$2"
local detail="$3"
local artifact_file="$4"
shift 4
local log_file="$REPORT_DIR/${name}.log"
local code=0
echo ">>> [live-readiness] $name"
if "$@" >"$artifact_file" 2> >(tee "$log_file" >&2); then
code=0
else
code="$?"
fi
local parsed
parsed="$(node -e '
const fs = require("node:fs");
try {
const value = JSON.parse(fs.readFileSync(process.argv[1], "utf8"));
const status = String(value.status || "FAIL").toUpperCase();
const count = Number(value.findingCount || 0);
process.stdout.write(`${status}\t${Number.isFinite(count) ? count : 1}`);
} catch {
process.stdout.write("FAIL\t1");
process.exitCode = 1;
}
' "$artifact_file")" || true
local status="FAIL"
local finding_count=1
IFS=$'\t' read -r status finding_count <<<"$parsed"
if [[ "$code" -ne 0 && ! "$status" =~ FAIL|ERROR|BLOCKED ]]; then
status="FAIL"
finding_count=1
fi
echo "[live-readiness][$status] $name findings=$finding_count"
write_step "$name" "$status" "$kind" "$code" "$finding_count" "$detail" "$artifact_file"
apply_step_status "$status" "$kind"
}
write_skip_step() {
local name="$1"
local detail="$2"
local log_file="$REPORT_DIR/${name}.log"
echo "[live-readiness][SKIP] $name:$detail" | tee "$log_file"
write_step "$name" "SKIP" "finding_allowed" 0 1 "$detail" "$log_file"
}
has_live_smoke_credentials() {
[[ -n "${LIVE_SMOKE_ADMIN_IDENTIFIER:-}" ]] &&
@@ -54,39 +138,66 @@ has_live_ocr_sample() {
return 1
}
echo ">>> [live-readiness] 环境与公网入口检查"
node "$ROOT_DIR/scripts/qa/check-live-smoke-env.mjs" --json > "$REPORT_DIR/env.json"
node "$ROOT_DIR/scripts/qa/check-live-smoke-env.mjs"
run_json_step \
"environment" \
"required" \
"public endpoints and safe readiness labels" \
"$REPORT_DIR/environment.json" \
env LIVE_SMOKE_BASE_URL="$PUBLIC_BASE_URL" LIVE_SMOKE_EXPECTED_BUILD_SHA="$EXPECTED_BUILD_SHA" node "$ROOT_DIR/scripts/qa/check-live-smoke-env.mjs" --json
echo ">>> [live-readiness] /ready: $PUBLIC_BASE_URL"
curl -fsS "$PUBLIC_BASE_URL/ready" >/dev/null
run_command_step \
"publicReady" \
"required" \
"public readiness endpoint" \
curl -fsS "$PUBLIC_BASE_URL/ready"
echo ">>> [live-readiness] 公网资源文案扫描"
node "$ROOT_DIR/scripts/qa/check-public-bundle-copy.mjs" "$PUBLIC_BASE_URL" | tee "$REPORT_DIR/public-bundle-copy.txt"
run_command_step \
"publicBundle" \
"required" \
"public bundle copy and build marker scan" \
env PUBLIC_EXPECTED_BUILD_SHA="$EXPECTED_BUILD_SHA" PUBLIC_FORBIDDEN_BUILD_SHA="$FORBIDDEN_FULL_BUILD_SHA" node "$ROOT_DIR/scripts/qa/check-public-bundle-copy.mjs" "$PUBLIC_BASE_URL"
echo ">>> [live-readiness] pending OCR 扫描"
node "$ROOT_DIR/scripts/qa/check-pending-ocr.mjs" --thresholds=2,5,10 | tee "$REPORT_DIR/pending-ocr.txt"
run_json_step \
"pendingOcr" \
"required" \
"pending OCR count summary" \
"$REPORT_DIR/pending-ocr.json" \
node "$ROOT_DIR/scripts/qa/check-pending-ocr.mjs" --thresholds=2,5,10 --json
if has_live_smoke_credentials; then
echo ">>> [live-readiness] 执行公网 live smoke"
LIVE_SMOKE_BASE_URL="$PUBLIC_BASE_URL" bash "$ROOT_DIR/scripts/qa/run-web-gates.sh" smoke-live | tee "$REPORT_DIR/live-smoke.txt"
run_command_step \
"liveSmoke" \
"required" \
"authenticated public smoke" \
env LIVE_SMOKE_BASE_URL="$PUBLIC_BASE_URL" bash "$ROOT_DIR/scripts/qa/run-web-gates.sh" smoke-live
else
echo ">>> [live-readiness] SKIP live smoke:缺少完整 LIVE_SMOKE_* 凭据" | tee "$REPORT_DIR/live-smoke.txt"
write_skip_step "liveSmoke" "authenticated smoke prerequisites are incomplete"
fi
if has_live_ocr_credentials && has_live_ocr_sample; then
echo ">>> [live-readiness] 执行真实 OCR 样本复核"
(
cd "$CLIENT_DIR"
PLAYWRIGHT_MANUAL=1 \
PLAYWRIGHT_OCR_LIVE=1 \
PLAYWRIGHT_OCR_ALLOW_DUPLICATE="${PLAYWRIGHT_OCR_ALLOW_DUPLICATE:-1}" \
E2E_BASE_URL="$PUBLIC_BASE_URL" \
npx playwright test tests/e2e/manual/live-invoice-ocr.spec.ts --workers=1 --reporter=list
) | tee "$REPORT_DIR/live-ocr.txt"
run_command_step \
"liveOcr" \
"required" \
"live OCR sample verification" \
bash -c 'cd "$1" && PLAYWRIGHT_MANUAL=1 PLAYWRIGHT_OCR_LIVE=1 PLAYWRIGHT_OCR_ALLOW_DUPLICATE="${PLAYWRIGHT_OCR_ALLOW_DUPLICATE:-1}" E2E_BASE_URL="$2" npx playwright test tests/e2e/manual/live-invoice-ocr.spec.ts --workers=1 --reporter=list' _ "$CLIENT_DIR" "$PUBLIC_BASE_URL"
else
echo ">>> [live-readiness] SKIP 真实 OCR:缺少 smoke 员工凭据或 PLAYWRIGHT_OCR_LIVE_FILE/PLAYWRIGHT_OCR_LIVE_DIR" | tee "$REPORT_DIR/live-ocr.txt"
write_skip_step "liveOcr" "OCR credentials or PDF sample are incomplete"
fi
echo "[live-readiness][PASS] readiness 检查完成"
echo "[live-readiness] report=$REPORT_DIR"
if node "$REPORT_TOOL" privacy "$ROOT_DIR" "$REPORT_DIR" >"$REPORT_DIR/privacy.log" 2>&1; then
write_step "privacy" "PASS" "required" 0 0 "readiness report privacy scan" "$REPORT_DIR/privacy-findings.json"
else
write_step "privacy" "FAIL" "required" 1 1 "redacted privacy findings detected" "$REPORT_DIR/privacy-findings.json"
STATUS=1
fi
if ! node "$REPORT_TOOL" summarize "$ROOT_DIR" "$REPORT_DIR" "$PUBLIC_BASE_URL"; then
STATUS=1
fi
summary_status="$(node -e 'const s=require(process.argv[1]); process.stdout.write(String(s.status || "UNKNOWN"))' "$REPORT_DIR/summary.json" 2>/dev/null || printf 'UNKNOWN')"
summary_ref="$(node -e 'const s=require(process.argv[1]); process.stdout.write(String(s.reportRef || "summary unavailable"))' "$REPORT_DIR/summary.json" 2>/dev/null || printf 'summary unavailable')"
echo "[live-readiness][$summary_status] readiness 检查完成"
echo "[live-readiness] report=$summary_ref"
exit "$STATUS"