308 行
11 KiB
JavaScript
308 行
11 KiB
JavaScript
#!/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 = [
|
|
"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}`);
|
|
}
|
|
});
|
|
}
|
|
|
|
function checkContextManifest(result, taskDir, action) {
|
|
const relativePath = `${taskDir}/${action}.jsonl`;
|
|
if (!exists(relativePath)) {
|
|
fail(result, `task.${action}_missing`, `${taskDir} missing ${action}.jsonl`);
|
|
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`);
|
|
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) {
|
|
fail(result, `task.${action}_empty`, `${relativePath} must contain at least one real context entry`);
|
|
}
|
|
}
|
|
|
|
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 (/每个 task 必须绑定|先有 Issue CSV 再开发/.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();
|
|
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 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");
|
|
checkContextManifest(result, dir, "check");
|
|
}
|
|
if (result.status === "PASS") pass(result, "task_contracts", `${taskDirs.length} task directories have Trellis contracts`);
|
|
}
|
|
|
|
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`);
|
|
}
|
|
|
|
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));
|
|
}
|
|
|
|
console.log(JSON.stringify(result, null, 2));
|
|
process.exit(result.status === "PASS" ? 0 : 1);
|