TRELLIS1 迁移 Trellis 主导开发流程
这个提交包含在:
@@ -0,0 +1,257 @@
|
||||
#!/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);
|
||||
在新工单中引用
屏蔽一个用户