#!/usr/bin/env node import fs from "node:fs"; import path from "node:path"; const repoRoot = process.cwd(); 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 = [ "docs/Agent开发交付实践.md", "docs/文档索引.md", "docs/文档事实源状态矩阵.md", ".agents/skills/hyb-oa-delivery/SKILL.md", ]; const issueIdPattern = /^TRELLIS\d+-\d+$/; 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 parseCsvLine(line) { const cells = []; let current = ""; let quoted = false; for (let index = 0; index < line.length; index += 1) { const char = line[index]; const next = line[index + 1]; if (quoted && char === "\"" && next === "\"") { current += "\""; index += 1; continue; } if (char === "\"") { quoted = !quoted; continue; } if (!quoted && char === ",") { cells.push(current); current = ""; continue; } current += char; } cells.push(current); return cells; } function issueIdsFromCsv(relativePath) { const text = readText(relativePath); const lines = text.split(/\r?\n/).filter((line) => line.trim()); if (lines.length < 2) return new Set(); const header = parseCsvLine(lines[0]); const idIndex = header.indexOf("ID"); if (idIndex < 0) return new Set(); return new Set(lines.slice(1).map((line) => parseCsvLine(line)[idIndex]).filter(Boolean)); } function normalizeIssueCsv(value) { if (typeof value !== "string") return ""; return value.trim().replace(/^\.?\//, ""); } 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"); } } 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 checkTaskBindings(result) { const taskDirs = listTaskDirs(); if (taskDirs.length === 0) { fail(result, "tasks.none", "no .trellis/tasks/* directories found"); 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 issueCsv = normalizeIssueCsv(task.meta?.issueCsv); const issueIds = Array.isArray(task.meta?.issueIds) ? task.meta.issueIds : []; const prdText = exists(prdPath) ? readText(prdPath) : ""; const csvFromPrd = prdText.match(/issues\/[A-Za-z0-9_.-]+\.csv/)?.[0] ?? ""; const boundCsv = issueCsv || csvFromPrd; if (!boundCsv) { fail(result, "task.issue_csv", `${dir} must bind an Issue CSV in task.json meta or prd.md`); continue; } if (!exists(boundCsv)) { fail(result, "task.issue_csv_missing", `${dir} references missing ${boundCsv}`); continue; } const ids = issueIds.length > 0 ? issueIds : [...prdText.matchAll(/TRELLIS\d+-\d+/g)].map((match) => match[0]); if (ids.length === 0) { fail(result, "task.issue_ids", `${dir} must bind at least one TRELLIS Issue ID`); continue; } const csvIds = issueIdsFromCsv(boundCsv); for (const id of ids) { if (!issueIdPattern.test(id)) { fail(result, "task.issue_id_format", `${dir} has invalid Issue ID ${id}`); } else if (!csvIds.has(id)) { fail(result, "task.issue_id_missing", `${dir} references ${id}, not found in ${boundCsv}`); } } } if (result.status === "PASS") pass(result, "task_bindings", `${taskDirs.length} task directories are bound`); } 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("TRELLIS1") || text.includes(".trellis") || text.includes("Trellis"); if (!hasTrellis) { fail(result, "docs.trellis_registration", `${file} must mention Trellis/TRELLIS1`); } } if (result.status === "PASS") pass(result, "docs_registration", `${requiredDocMentions.length} fact sources mention Trellis`); } 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`); } const result = createResult(); try { checkCoreFiles(result); checkTaskBindings(result); checkDocsRegistration(result); checkSensitiveData(result); } catch (error) { fail(result, "exception", error instanceof Error ? error.message : String(error)); } console.log(JSON.stringify(result, null, 2)); process.exit(result.status === "PASS" ? 0 : 1);