#!/usr/bin/env node import fs from "node:fs"; import { createHash } from "node:crypto"; import path from "node:path"; import { fileURLToPath } from "node:url"; const repoRoot = process.cwd(); const registeredEvidence = [{ document: ".trellis/tasks/archive/2026-07/07-18-small-team-internal-readiness/evidence.md", directory: "output/playwright/07-18-small-team-internal-readiness", count: 14, }]; export function validateRegisteredEvidence(root = repoRoot, registrations = registeredEvidence) { const result = { checked: 0, errors: [] }; for (const registration of registrations) { let lines; try { lines = fs.readFileSync(path.join(root, registration.document), "utf8").split(/\r?\n/); } catch { result.errors.push(`evidence_document_missing:${registration.document}`); continue; } const header = lines.findIndex(line => /^\|\s*截图\s*\|\s*SHA-256\s*\|\s*$/i.test(line)); if (header < 0 || !/^\|\s*-+\s*\|\s*-+\s*\|\s*$/.test(lines[header + 1] ?? "")) { result.errors.push(`evidence_table_missing:${registration.document}`); continue; } const rows = []; for (let index = header + 2; index < lines.length && lines[index].startsWith("|"); index += 1) rows.push(lines[index]); if (rows.length !== registration.count) result.errors.push(`evidence_count_mismatch:${registration.document}`); const names = new Set(); for (const row of rows) { const match = row.match(/^\|\s*`([^`]+)`\s*\|\s*`([a-f0-9]{64})`\s*\|\s*$/i); if (!match || !/^[\w.-]+\.png$/.test(match[1]) || match[1].startsWith(".") || names.has(match[1])) { result.errors.push(`evidence_row_invalid:${registration.document}`); continue; } names.add(match[1]); const relative = path.join(registration.directory, match[1]); const target = path.join(root, relative); try { if (!fs.lstatSync(target).isFile() || fs.realpathSync(target) !== path.join(fs.realpathSync(root), relative)) { result.errors.push(`evidence_not_regular_file:${relative}`); continue; } const observed = createHash("sha256").update(fs.readFileSync(target)).digest("hex"); if (observed !== match[2].toLowerCase()) result.errors.push(`evidence_digest_mismatch:${relative}`); else result.checked += 1; } catch { result.errors.push(`evidence_file_missing_or_unreadable:${relative}`); } } } return result; } const requiredSpecs = [ ".trellis/spec/hyb-oa/index.md", ".trellis/spec/hyb-oa/project-governance.md", ".trellis/spec/hyb-oa/backend-fastify-prisma.md", ".trellis/spec/hyb-oa/frontend-react-vite-design.md", ".trellis/spec/hyb-oa/approval-workflow.md", ".trellis/spec/hyb-oa/reimbursement-invoice-ocr.md", ".trellis/spec/hyb-oa/wecom-boundary.md", ".trellis/spec/hyb-oa/deployment-trialrun.md", ".trellis/spec/hyb-oa/docs-and-evidence.md", ".trellis/spec/hyb-oa/security-privacy.md", ]; const requiredDocMentions = [ "AGENTS.md", "docs/Agent开发交付实践.md", "docs/文档索引.md", "docs/文档事实源状态矩阵.md", ".trellis/workflow.md", ".trellis/spec/hyb-oa/index.md", ]; const validTaskStatuses = new Set(["planning", "in_progress", "review", "completed"]); const requiredPrdSections = [ { code: "goal", pattern: /(^|\n)#{1,4}\s*(Goal|目标)\b/i }, { code: "scope", pattern: /(^|\n)#{1,4}\s*(Scope|范围)\b/i }, { code: "out_of_scope", pattern: /(^|\n)#{1,4}\s*(Out Of Scope|Out of Scope|不做|范围外|边界)\b/i }, { code: "acceptance", pattern: /(^|\n)#{1,4}\s*(Acceptance|验收|完成口径)\b/i }, { code: "test_plan", pattern: /(^|\n)#{1,4}\s*(Test Plan|测试计划|验证|Test Method)\b/i }, ]; const complexTaskPattern = /\b(api|schema|migration|prisma|permission|role|deployment|production|database|remote|wecom|payment)\b|权限|状态机|审批|部署|公网|数据库|数据迁移|企业微信|付款|流程治理|workflow-governance/i; function readText(relativePath) { return fs.readFileSync(path.join(repoRoot, relativePath), "utf8"); } function exists(relativePath) { return fs.existsSync(path.join(repoRoot, relativePath)); } function listFiles(relativeDir) { const abs = path.join(repoRoot, relativeDir); if (!fs.existsSync(abs)) return []; const result = []; for (const entry of fs.readdirSync(abs, { withFileTypes: true })) { const relative = path.join(relativeDir, entry.name); if (entry.isDirectory()) { if (relative.includes(`${path.sep}__pycache__`)) continue; result.push(...listFiles(relative)); } else { result.push(relative); } } return result; } function listTaskDirs() { const tasksDir = path.join(repoRoot, ".trellis/tasks"); if (!fs.existsSync(tasksDir)) return []; return fs .readdirSync(tasksDir, { withFileTypes: true }) .filter((entry) => entry.isDirectory() && !entry.name.startsWith(".") && entry.name !== "archive") .map((entry) => `.trellis/tasks/${entry.name}`); } function parseJsonl(relativePath) { const text = readText(relativePath); return text .split(/\r?\n/) .map((line) => line.trim()) .filter(Boolean) .map((line, index) => { try { return { value: JSON.parse(line), line: index + 1 }; } catch (error) { throw new Error(`${relativePath}:${index + 1} invalid JSONL: ${error.message}`); } }); } export function contextManifestPolicy(dispatchMode) { const inlineDispatch = dispatchMode === "inline"; return { required: !inlineDispatch, requireRealEntries: !inlineDispatch, rejectSeedRows: !inlineDispatch, }; } export function activeTaskDirectoryPolicy(taskCount) { if (taskCount === 0) { return { valid: true, detail: "0 active task directories; idle state after archive is valid", }; } return { valid: true, detail: `${taskCount} task directories have Trellis contracts`, }; } function checkContextManifest(result, taskDir, action, dispatchMode) { const relativePath = `${taskDir}/${action}.jsonl`; const policy = contextManifestPolicy(dispatchMode); if (!exists(relativePath)) { 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) { if (policy.rejectSeedRows) { fail(result, `task.${action}_seed`, `${relativePath}:${line} still contains seed _example row`); } continue; } if (typeof value.file !== "string" || !value.file.trim()) { fail(result, `task.${action}_file`, `${relativePath}:${line} missing file`); continue; } if (typeof value.reason !== "string" || !value.reason.trim()) { fail(result, `task.${action}_reason`, `${relativePath}:${line} missing reason`); continue; } if (!exists(value.file)) { fail(result, `task.${action}_file_missing`, `${relativePath}:${line} references missing ${value.file}`); continue; } if (!value.file.startsWith(".trellis/spec/") && !value.file.includes("/research/")) { warn(result, `task.${action}_non_spec_context`, `${relativePath}:${line} should usually reference .trellis/spec/ or task research`); } realEntryCount += 1; } 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`); } } function createResult() { return { status: "PASS", checks: [], warnings: [], errors: [], }; } function pass(result, name, detail = "") { result.checks.push({ name, status: "PASS", detail }); } function warn(result, code, message) { result.warnings.push({ code, message }); } function fail(result, code, message) { result.status = "FAIL"; result.errors.push({ code, message }); } function checkCoreFiles(result) { const requiredFiles = [ "AGENTS.md", ".trellis/workflow.md", ".trellis/config.yaml", ...requiredSpecs, ]; for (const file of requiredFiles) { if (!exists(file)) fail(result, "missing_file", file); } if (result.status === "PASS") pass(result, "core_files", `${requiredFiles.length} required files exist`); if (exists("AGENTS.md")) { const agents = readText("AGENTS.md"); if (!agents.includes(".trellis/workflow.md")) { fail(result, "agents.workflow_pointer", "AGENTS.md must point to .trellis/workflow.md"); } else { pass(result, "agents.workflow_pointer", "AGENTS.md points to Trellis workflow"); } const legacyIssueRequiredPatterns = [ /每个\s*task\s*必须绑定/, /先有\s+Issue\s+CSV\s+再开发/, ]; if (legacyIssueRequiredPatterns.some((pattern) => pattern.test(agents))) { fail(result, "agents.legacy_issue_required", "AGENTS.md still requires Issue CSV before development"); } } if (exists(".trellis/config.yaml")) { const config = readText(".trellis/config.yaml"); if (!/session_auto_commit:\s*false/.test(config)) { fail(result, "config.auto_commit", "session_auto_commit must be false"); } else { pass(result, "config.auto_commit", "session_auto_commit=false"); } if (!/dispatch_mode:\s*inline/.test(config)) { warn(result, "config.dispatch_mode", "Codex dispatch_mode is not explicitly inline"); } } } 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) { const policy = activeTaskDirectoryPolicy(taskDirs.length); pass(result, "task_contracts", policy.detail); return; } for (const dir of taskDirs) { const taskJsonPath = `${dir}/task.json`; const prdPath = `${dir}/prd.md`; if (!exists(taskJsonPath)) { fail(result, "task.missing_task_json", `${dir} missing task.json`); continue; } const task = JSON.parse(readText(taskJsonPath)); const prdText = exists(prdPath) ? readText(prdPath) : ""; if (!task.id || !task.name || !task.title) { fail(result, "task.identity", `${dir} task.json must include id, name and title`); } if (!validTaskStatuses.has(task.status)) { fail(result, "task.status", `${dir} has invalid status ${task.status}`); } if (typeof task.description !== "string" || !task.description.trim()) { fail(result, "task.description", `${dir} task.json must include description`); } if (!exists(prdPath)) { fail(result, "task.prd_missing", `${dir} missing prd.md`); continue; } for (const section of requiredPrdSections) { if (!section.pattern.test(prdText)) { fail(result, `task.prd_${section.code}`, `${prdPath} missing ${section.code} section`); } } if (!task.scope && (!Array.isArray(task.relatedFiles) || task.relatedFiles.length === 0)) { fail(result, "task.scope", `${dir} must declare scope or relatedFiles in task.json`); } const complexText = [task.title, task.description, task.scope, task.notes, prdText].filter(Boolean).join("\n"); 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", dispatchMode); checkContextManifest(result, dir, "check", dispatchMode); } if (result.status === "PASS") { const policy = activeTaskDirectoryPolicy(taskDirs.length); pass(result, "task_contracts", policy.detail); } } function checkDocsRegistration(result) { for (const file of requiredDocMentions) { if (!exists(file)) { fail(result, "docs.required_missing", file); continue; } const text = readText(file); const hasTrellis = text.includes("TRELLIS2") || text.includes(".trellis") || text.includes("Trellis"); if (!hasTrellis) { fail(result, "docs.trellis_registration", `${file} must mention Trellis/TRELLIS2`); } } if (result.status === "PASS") pass(result, "docs_registration", `${requiredDocMentions.length} fact sources mention Trellis`); } function checkRemovedProjectSkillReferences(result) { const removedSkill = ["hyb", "oa", "delivery"].join("-"); const removedSkillPath = [".agents", "skills", removedSkill, "SKILL.md"].join("/"); const removedSkillDir = [".agents", "skills", removedSkill].join("/"); const legacyPriorityText = ["项目", "Skill 优先"].join(" "); const files = [ "AGENTS.md", "docs/Agent开发交付实践.md", "docs/文档索引.md", "docs/文档事实源状态矩阵.md", "scripts/qa/audit-docs.mjs", "scripts/qa/check-process-contract.mjs", ]; for (const file of files) { if (!exists(file)) continue; const text = readText(file); const staleTerms = [removedSkill, removedSkillPath, removedSkillDir, legacyPriorityText].filter((term) => text.includes(term)); if (staleTerms.length > 0) { fail(result, "removed_project_skill.reference", `${file} still references removed project delivery skill`); } } if (result.status === "PASS") pass(result, "removed_project_skill", "current governance files are Trellis-only"); } function checkSensitiveData(result) { const scanFiles = [ "AGENTS.md", ...listFiles(".trellis").filter((file) => /\.(md|json|jsonl|ya?ml|toml|txt)$/i.test(file)), ]; const patterns = [ { code: "private_key", pattern: /BEGIN (RSA |EC |OPENSSH )?PRIVATE KEY/ }, { code: "assigned_secret", pattern: /\b(secret|token|password|api[_-]?key)\b\s*[:=]\s*["']?[A-Za-z0-9_./+=:-]{16,}/i }, { code: "live_smoke_password", pattern: /LIVE_SMOKE_[A-Z_]*PASSWORD\s*[:=]/ }, { code: "complete_local_path", pattern: /\/home\/shunli\/Projects\/[^ \n)"']+/ }, { code: "raw_wecom_id", pattern: /\b(sp_no|template_id|userid)\s*[:=]\s*["']?[A-Za-z0-9_-]{12,}/i }, ]; for (const file of scanFiles) { const text = readText(file); for (const { code, pattern } of patterns) { if (pattern.test(text)) { fail(result, `sensitive.${code}`, `possible sensitive data in ${file}`); } } } if (result.status === "PASS") pass(result, "sensitive_scan", `${scanFiles.length} Trellis files scanned`); } export function runGovernanceChecks() { const result = createResult(); try { checkCoreFiles(result); checkTaskContracts(result); checkDocsRegistration(result); checkRemovedProjectSkillReferences(result); checkSensitiveData(result); const evidence = validateRegisteredEvidence(); for (const error of evidence.errors) fail(result, "evidence.sha256", error); if (!evidence.errors.length) pass(result, "evidence.sha256", `${evidence.checked} registered local evidence files verified`); } catch (error) { fail(result, "exception", error instanceof Error ? error.message : String(error)); } return result; } 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); }