@brightliu/ai-control 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +126 -0
  3. package/addons/export-adapters.js +132 -0
  4. package/bin/ai.js +58 -0
  5. package/lib/change.js +62 -0
  6. package/lib/core.js +94 -0
  7. package/lib/doctor.js +68 -0
  8. package/lib/gate.js +193 -0
  9. package/lib/init.js +88 -0
  10. package/lib/junit.js +76 -0
  11. package/lib/testrun.js +136 -0
  12. package/package.json +37 -0
  13. package/payload/AGENTS.md +62 -0
  14. package/payload/agents/agent-dba.md +104 -0
  15. package/payload/agents/agent-dev.md +100 -0
  16. package/payload/agents/agent-spec.md +209 -0
  17. package/payload/agents/agent-test.md +74 -0
  18. package/payload/agents/dev/go.md +23 -0
  19. package/payload/agents/dev/java.md +71 -0
  20. package/payload/agents/dev/php.md +23 -0
  21. package/payload/agents/dev/web.md +62 -0
  22. package/payload/hooks/guard-bash.js +37 -0
  23. package/payload/hooks/guard-write.js +92 -0
  24. package/payload/rules/00-agent-base.md +56 -0
  25. package/payload/rules/01-code-change.md +27 -0
  26. package/payload/rules/02-product-ux.md +141 -0
  27. package/payload/rules/10-db-schema.md +84 -0
  28. package/payload/rules/20-api.md +160 -0
  29. package/payload/rules/21-jwt.md +25 -0
  30. package/payload/rules/22-rbac.md +38 -0
  31. package/payload/rules/24-openapi.md +14 -0
  32. package/payload/rules/30-frontend.md +58 -0
  33. package/payload/rules/31-vue3.md +20 -0
  34. package/payload/rules/32-react.md +20 -0
  35. package/payload/rules/40-backend.md +63 -0
  36. package/payload/rules/41-spring-boot.md +184 -0
  37. package/payload/rules/42-go-gin.md +20 -0
  38. package/payload/rules/43-php.md +19 -0
  39. package/payload/rules/44-java-enum.md +108 -0
  40. package/payload/rules/50-testing.md +39 -0
  41. package/payload/rules/51-security.md +50 -0
  42. package/payload/rules/52-performance.md +45 -0
  43. package/payload/rules/53-release.md +141 -0
  44. package/payload/rules/README.md +12 -0
  45. package/payload/templates/design.md +15 -0
  46. package/payload/templates/proposal-lite.md +21 -0
  47. package/payload/templates/proposal.md +28 -0
  48. package/payload/templates/review-prompt.md +20 -0
  49. package/payload/templates/spec.md +13 -0
  50. package/payload/templates/test-cases.md +18 -0
package/lib/gate.js ADDED
@@ -0,0 +1,193 @@
1
+ // gate:check(结构与契约门禁)、confirm(确认留痕)与 ship(证据门禁)
2
+ const fs = require("fs");
3
+ const path = require("path");
4
+ const { execSync } = require("child_process");
5
+ const { requireProjectRoot, changeDir, loadConfig, die, read, exists, scopeItems, isLite } = require("./core");
6
+ const { parseReports, findReportDirs } = require("./junit");
7
+
8
+ const SCOPE_KEYS = ["affected_files", "affected_tables", "affected_apis", "affected_pages"];
9
+
10
+ // 覆盖闭环关键词:内置中英双语,可在 .ai/config.json 的 gateKeywords 覆盖(P2-6)
11
+ const DEFAULT_KEYWORDS = {
12
+ exception: ["异常流", "异常", "exception", "invalid"],
13
+ permission: ["权限", "permission", "unauthorized"],
14
+ emptyOrError: ["空态", "错误态", "empty", "error"],
15
+ };
16
+ const MANUAL_RE = /手动|manual/i;
17
+
18
+ function collect(dir) {
19
+ const proposal = path.join(dir, "proposal.md");
20
+ const cases = path.join(dir, "test-cases.md");
21
+ if (!exists(proposal)) die("缺少 proposal.md");
22
+ if (!exists(cases)) die("缺少 test-cases.md;验收用例必须在设计阶段产出并随 change 确认");
23
+ return { proposalText: read(proposal), casesText: read(cases) };
24
+ }
25
+
26
+ function tcRows(casesText) {
27
+ return casesText.split("\n").filter((l) => /^\|\s*TC-/i.test(l));
28
+ }
29
+
30
+ function requireChange(args, usage) {
31
+ const id = args.find((a) => !a.startsWith("--"));
32
+ if (!id) die(usage);
33
+ const root = requireProjectRoot();
34
+ const dir = changeDir(root, id);
35
+ if (!exists(dir)) die(`change 不存在: ${id}`);
36
+ return { id, root, dir };
37
+ }
38
+
39
+ // ── check 核心(cmdCheck 与 cmdConfirm 共用)──────────────
40
+ function runCheck(root, dir, id) {
41
+ const { proposalText, casesText } = collect(dir);
42
+ const cfg = loadConfig(root);
43
+ const kw = (name) => (cfg.gateKeywords && cfg.gateKeywords[name]) || DEFAULT_KEYWORDS[name];
44
+ const hasKw = (name) => kw(name).some((k) => casesText.toLowerCase().includes(k.toLowerCase()));
45
+ const errors = [];
46
+
47
+ // 影响范围字段齐全
48
+ for (const k of SCOPE_KEYS) {
49
+ if (scopeItems(proposalText, k) === null) errors.push(`影响范围缺少字段: ${k}`);
50
+ }
51
+ const tables = scopeItems(proposalText, "affected_tables") || [];
52
+ const apis = scopeItems(proposalText, "affected_apis") || [];
53
+ const pages = scopeItems(proposalText, "affected_pages") || [];
54
+
55
+ // lite 越界门禁
56
+ if (isLite(proposalText)) {
57
+ if (tables.length) errors.push(`lite 变更不允许涉及数据库表;运行 ai new ${id} --upgrade 升级为完整流程(保留已写内容)`);
58
+ if (apis.length) errors.push(`lite 变更不允许涉及 API 契约;运行 ai new ${id} --upgrade 升级为完整流程(保留已写内容)`);
59
+ }
60
+
61
+ // 用例存在与覆盖闭环
62
+ const rows = tcRows(casesText);
63
+ if (rows.length === 0) errors.push("test-cases.md 没有任何用例行(用例ID 须以 TC- 开头)");
64
+ if (apis.length && !hasKw("exception"))
65
+ errors.push("affected_apis 非 none:必须包含异常流用例(参数错误、数据不存在、状态不允许)");
66
+ if (pages.length) {
67
+ if (!hasKw("permission")) errors.push("affected_pages 非 none:必须包含权限用例");
68
+ if (!hasKw("emptyOrError")) errors.push("affected_pages 非 none:必须包含空态或错误态用例");
69
+ }
70
+ // P0-1:高风险变更(碰表/接口)必须有自动化用例,否则证据门禁会因"全手动豁免"而失效
71
+ if ((tables.length || apis.length) && rows.length && rows.every((r) => MANUAL_RE.test(r))) {
72
+ errors.push("涉及数据库/API 的变更必须至少 1 条非手动(自动化)用例——发布门禁凭 JUnit 报告验收,全手动会让证据链失效");
73
+ }
74
+
75
+ // 待确认门禁:只查"## 待确认问题"章节内的未答条目(不全文扫字样)
76
+ for (const f of fs.readdirSync(dir, { recursive: true })) {
77
+ const p = path.join(dir, String(f));
78
+ if (!p.endsWith(".md") || !fs.statSync(p).isFile()) continue;
79
+ for (const item of unresolvedItems(read(p))) {
80
+ errors.push(`待确认问题未答(${path.basename(p)}): ${item}`);
81
+ }
82
+ }
83
+ return errors;
84
+ }
85
+
86
+ function unresolvedItems(text) {
87
+ const m = text.match(/^## 待确认问题\n([\s\S]*?)(?=^## |$(?![\s\S]))/m);
88
+ if (!m) return [];
89
+ return m[1]
90
+ .split("\n")
91
+ .filter((l) => /^\s*-\s*\S/.test(l))
92
+ .map((l) => l.replace(/^\s*-\s*/, "").trim())
93
+ .filter((v) => !/已确认|已解决|无待确认|暂无|(没有则写/.test(v));
94
+ }
95
+
96
+ function cmdCheck(args) {
97
+ const { id, root, dir } = requireChange(args, "用法: ai check <change-id>");
98
+ const errors = runCheck(root, dir, id);
99
+ if (errors.length) {
100
+ errors.forEach((e) => console.error(`[FAIL] ${e}`));
101
+ die(`check 未通过(${errors.length} 项)`, 2);
102
+ }
103
+ console.log("CHECK_PASSED:可向用户输出确认单(标注级别与判级理由)。");
104
+ }
105
+
106
+ // ── ai confirm:把"用户点头"变成可审计留痕(by/at/sha)────
107
+ function cmdConfirm(args) {
108
+ const { id, root, dir } = requireChange(args, "用法: ai confirm <change-id>(用户确认变更单后执行)");
109
+ const errors = runCheck(root, dir, id);
110
+ if (errors.length) {
111
+ errors.forEach((e) => console.error(`[FAIL] ${e}`));
112
+ die("check 未通过,禁止确认;修复后重试", 2);
113
+ }
114
+ const git = (cmd) => { try { return execSync(cmd, { cwd: root, stdio: ["ignore", "pipe", "ignore"] }).toString().trim(); } catch { return ""; } };
115
+ const record = {
116
+ by: git("git config user.name") || process.env.USER || "unknown",
117
+ at: new Date().toISOString(),
118
+ sha: git("git rev-parse HEAD"),
119
+ };
120
+ fs.writeFileSync(path.join(dir, "confirmed.json"), JSON.stringify(record, null, 2) + "\n");
121
+ console.log(`CONFIRM_OK:已留痕(by=${record.by})。开始实现;proposal/test-cases 再改动需重新确认。`);
122
+ }
123
+
124
+ // 读取确认留痕;proposal/test-cases/specs 在确认后被改动 → 确认失效
125
+ function requireFreshConfirm(dir, id) {
126
+ const confPath = path.join(dir, "confirmed.json");
127
+ if (!exists(confPath)) die(`变更未确认:向用户输出确认单,用户点头后运行 ai confirm ${id}`, 2);
128
+ let confAt = 0;
129
+ try { confAt = Date.parse(JSON.parse(read(confPath)).at) || 0; } catch { /* 落入下方校验 */ }
130
+ if (!confAt) die("confirmed.json 无效;重新运行 ai confirm", 2);
131
+ for (const f of fs.readdirSync(dir, { recursive: true })) {
132
+ const rel = String(f);
133
+ const p = path.join(dir, rel);
134
+ const base = path.basename(rel);
135
+ const isContract = base === "proposal.md" || base === "test-cases.md" || rel.startsWith("specs");
136
+ if (!isContract || !p.endsWith(".md") || !fs.statSync(p).isFile()) continue;
137
+ if (fs.statSync(p).mtimeMs > confAt) {
138
+ die(`确认已过期:${rel} 在确认后被修改;重新向用户确认并运行 ai confirm ${id}`, 2);
139
+ }
140
+ }
141
+ return confAt;
142
+ }
143
+
144
+ // ── ai ship ──────────────────────────────────────────────
145
+ function cmdShip(args) {
146
+ const { id, root, dir } = requireChange(args, "用法: ai ship <change-id> [junit-报告目录...]");
147
+ const { proposalText, casesText } = collect(dir);
148
+ const confAt = requireFreshConfirm(dir, id);
149
+ const rows = tcRows(casesText);
150
+ const autoRows = rows.filter((l) => !MANUAL_RE.test(l));
151
+
152
+ if (autoRows.length === 0) {
153
+ console.log("全部用例为手动验证:跳过 JUnit 报告核对,验收结果须逐条写入交付说明。");
154
+ } else {
155
+ const extraDirs = args.filter((a) => !a.startsWith("--") && a !== id);
156
+ // N-3 串号收口:存在多个变更时,全局报告目录无法证明"这是本变更的证据"——
157
+ // 必须用隔离目录(ai test <id> 自动生成)或显式传入报告路径
158
+ const scoped = path.join(root, "test-results", id);
159
+ const allChanges = fs.readdirSync(path.join(root, ".ai", "changes"))
160
+ .filter((d) => { try { return fs.statSync(path.join(root, ".ai", "changes", d)).isDirectory(); } catch { return false; } });
161
+ if (!extraDirs.length && !exists(scoped) && allChanges.length > 1) {
162
+ die(`存在多个变更(${allChanges.join(", ")}):证据必须按变更隔离。运行 ai test ${id}(报告自动写入 test-results/${id}/),或显式传入报告目录`, 2);
163
+ }
164
+ const reportDirs = extraDirs.length ? extraDirs : findReportDirs(root, id);
165
+ if (!reportDirs.length) die(`未找到 JUnit 报告目录;请先运行 ai test ${id}`, 2);
166
+ const { parsed, failures, passedIds, newestMtime } = parseReports(reportDirs);
167
+ if (!parsed) die("报告目录中没有可解析的 JUnit XML", 2);
168
+ // P0-2:报告必须晚于确认时间——旧迭代/别的变更的残留报告不算证据
169
+ if (newestMtime < confAt) die("测试报告早于本变更的确认时间——疑似旧报告;重跑 ai test 后再 ship", 2);
170
+ const missing = autoRows
171
+ .map((l) => l.split("|")[1].trim())
172
+ .filter((tc) => {
173
+ const n = parseInt(tc.replace(/^TC[-_]?0*/i, ""), 10);
174
+ return !passedIds.has(n);
175
+ });
176
+ failures.forEach((f) => console.error(`FAILED_TEST: ${f}`));
177
+ missing.forEach((c) => console.error(`MISSING_CASE: ${c}`));
178
+ if (failures.length) die("存在失败的测试;修复后重跑(报告即证据)", 2);
179
+ if (missing.length) die("以上非手动用例在报告中没有通过记录(测试名须含 TC-ID)", 2);
180
+ console.log(`EVIDENCE_OK:报告 ${parsed} 份,非手动用例 ${autoRows.length} 条全部有通过记录。`);
181
+ }
182
+
183
+ // 高风险提示(结构化判定,防 v1 的 grep 越界误报)
184
+ if ((scopeItems(proposalText, "affected_tables") || []).length) {
185
+ console.log("提示:本变更涉及数据库,建议在新会话用 .ai/templates/review-prompt.md 做一次独立审查。");
186
+ } else if (/支付|状态流转/.test(proposalText.split("## 待确认问题")[0])) {
187
+ console.log("提示:本变更涉及支付/状态流,建议在新会话用 .ai/templates/review-prompt.md 做一次独立审查。");
188
+ }
189
+
190
+ console.log("SHIP_GATES_PASSED:门禁全部通过。手动用例结果与残余风险写入交付说明。");
191
+ }
192
+
193
+ module.exports = { cmdCheck, cmdConfirm, cmdShip };
package/lib/init.js ADDED
@@ -0,0 +1,88 @@
1
+ // ai init:把 payload 资产装进目标项目;--update 只更新框架文件(先备份,不碰用户数据)
2
+ const fs = require("fs");
3
+ const path = require("path");
4
+ const { PAYLOAD, die, exists, copyDir } = require("./core");
5
+
6
+ const FRAMEWORK_DIRS = ["agents", "rules", "templates", "hooks"]; // .ai/ 下属于框架的部分
7
+ const STACKS = ["java", "go", "php", "vue", "react", "node", "mixed"];
8
+
9
+ function init(args) {
10
+ const root = process.cwd();
11
+ const update = args.includes("--update");
12
+ const force = args.includes("--force");
13
+ const stackIdx = args.indexOf("--stack");
14
+ const stack = stackIdx >= 0 ? args[stackIdx + 1] : detectStack(root);
15
+ if (stackIdx >= 0 && !STACKS.includes(stack)) {
16
+ die(`未知 stack "${stack || ""}"。可用值: ${STACKS.join(" | ")}(多语言混合项目用 mixed)`);
17
+ }
18
+
19
+ const agentsMd = path.join(root, "AGENTS.md");
20
+ const aiDir = path.join(root, ".ai");
21
+ const stampNow = () => new Date().toISOString().replace(/[-:T]/g, "").slice(0, 14);
22
+
23
+ if (!update) {
24
+ // 项目原有 AGENTS.md(未装过本系统)是常见情况:--force 备份后覆盖,避免与 --update 互踢死锁
25
+ if ((exists(agentsMd) || exists(aiDir)) && !force) {
26
+ if (exists(aiDir)) die("已安装(存在 .ai/)。升级框架用: ai init --update");
27
+ die("AGENTS.md 已存在(项目原有)。用 ai init --force 安装:原文件自动备份为 AGENTS.md.bak-<时间戳>,装完后请把原有内容手动合并回 AGENTS.md 尾部");
28
+ }
29
+ if (force && exists(agentsMd)) {
30
+ const bak = `AGENTS.md.bak-${stampNow()}`;
31
+ fs.copyFileSync(agentsMd, path.join(root, bak));
32
+ console.log(`原 AGENTS.md 已备份为 ${bak},请安装后手动合并需要保留的内容。`);
33
+ }
34
+ } else {
35
+ if (!exists(aiDir)) die("未安装(缺 .ai/),--update 无从更新。全新安装用 ai init;项目已有自己的 AGENTS.md 时用 ai init --force(自动备份原文件)");
36
+ // 备份框架部分(用户数据 .ai/changes/、config.json 不动)
37
+ const stamp = new Date().toISOString().replace(/[-:T]/g, "").slice(0, 14);
38
+ const backup = path.join(aiDir, "backup", `update-${stamp}`);
39
+ fs.mkdirSync(backup, { recursive: true });
40
+ for (const d of FRAMEWORK_DIRS) {
41
+ const src = path.join(aiDir, d);
42
+ if (exists(src)) copyDir(src, path.join(backup, d));
43
+ }
44
+ if (exists(agentsMd)) fs.copyFileSync(agentsMd, path.join(backup, "AGENTS.md"));
45
+ console.log(`已备份到 .ai/backup/update-${stamp}/`);
46
+ }
47
+
48
+ // 拷贝框架资产
49
+ for (const d of FRAMEWORK_DIRS) {
50
+ copyDir(path.join(PAYLOAD, d), path.join(aiDir, d));
51
+ }
52
+ fs.copyFileSync(path.join(PAYLOAD, "AGENTS.md"), agentsMd);
53
+
54
+ // config:首装写入;update 保留已有
55
+ const cfgPath = path.join(aiDir, "config.json");
56
+ if (!exists(cfgPath)) {
57
+ fs.writeFileSync(cfgPath, JSON.stringify({ stack: stack || "unknown", testCommand: "" }, null, 2) + "\n");
58
+ }
59
+ fs.mkdirSync(path.join(root, ".ai", "changes"), { recursive: true });
60
+
61
+ // .gitignore 补 .ai/backup
62
+ const gi = path.join(root, ".gitignore");
63
+ const line = ".ai/backup/";
64
+ if (!exists(gi) || !fs.readFileSync(gi, "utf8").includes(line)) {
65
+ fs.appendFileSync(gi, `${line}\n`);
66
+ }
67
+
68
+ console.log(update ? "UPDATE_OK:框架已更新,用户数据未触碰(.ai/changes/、config.json)。"
69
+ : `INIT_OK:已安装(stack=${stack || "unknown"})。日常命令: ai new / check / confirm / test / ship`);
70
+ }
71
+
72
+ function detectStack(root) {
73
+ if (exists(path.join(root, "pom.xml"))) return "java";
74
+ if (exists(path.join(root, "go.mod"))) return "go";
75
+ if (exists(path.join(root, "composer.json"))) return "php";
76
+ if (exists(path.join(root, "package.json"))) {
77
+ try {
78
+ const pkg = JSON.parse(fs.readFileSync(path.join(root, "package.json"), "utf8"));
79
+ const deps = { ...pkg.dependencies, ...pkg.devDependencies };
80
+ if (deps.vue) return "vue";
81
+ if (deps.react) return "react";
82
+ } catch { /* 忽略解析失败 */ }
83
+ return "node";
84
+ }
85
+ return "";
86
+ }
87
+
88
+ module.exports = { init, detectStack };
package/lib/junit.js ADDED
@@ -0,0 +1,76 @@
1
+ // JUnit XML 解析(零依赖,正则级;JUnit testcase 结构足够规整)
2
+ const fs = require("fs");
3
+ const path = require("path");
4
+
5
+ const TC_RE = /TC[-_]?0*(\d+)/gi;
6
+
7
+ function findReportDirs(root, changeId) {
8
+ // 按变更隔离的报告目录优先:存在则只用它,避免别的变更/上次迭代的旧报告串号(P0-2)
9
+ if (changeId) {
10
+ const scoped = path.join(root, "test-results", changeId);
11
+ if (fs.existsSync(scoped)) return [scoped];
12
+ }
13
+ return [
14
+ "target/surefire-reports",
15
+ "target/failsafe-reports",
16
+ "build/test-results",
17
+ "test-results",
18
+ "reports/junit",
19
+ ]
20
+ .map((d) => path.join(root, d))
21
+ .filter((d) => fs.existsSync(d));
22
+ }
23
+
24
+ function xmlFiles(dirs) {
25
+ const out = [];
26
+ for (const d of dirs) {
27
+ const st = fs.statSync(d);
28
+ if (st.isFile() && d.endsWith(".xml")) { out.push(d); continue; }
29
+ if (!st.isDirectory()) continue;
30
+ for (const f of fs.readdirSync(d, { recursive: true })) {
31
+ const p = path.join(d, String(f));
32
+ if (p.endsWith(".xml") && fs.statSync(p).isFile()) out.push(p);
33
+ }
34
+ }
35
+ return out;
36
+ }
37
+
38
+ // 返回 { parsed, failures, passedIds, newestMtime: 最新报告的修改时间(ms) }
39
+ function parseReports(dirs) {
40
+ let parsed = 0;
41
+ let newestMtime = 0;
42
+ const failures = [];
43
+ const passedIds = new Set();
44
+
45
+ for (const file of xmlFiles(dirs)) {
46
+ const xml = fs.readFileSync(file, "utf8");
47
+ // 匹配自闭合与含子元素两种 testcase
48
+ const caseRe = /<testcase\b([^>]*?)(?:\/>|>([\s\S]*?)<\/testcase>)/g;
49
+ let m, found = false;
50
+ while ((m = caseRe.exec(xml)) !== null) {
51
+ found = true;
52
+ const attrs = m[1] || "";
53
+ const inner = m[2] || "";
54
+ // 边界防护:name= 不能匹配到 classname= 内部(v1 grep 越界的同类坑)
55
+ const name = (attrs.match(/(?:^|[^a-zA-Z])name="([^"]*)"/) || [])[1] || "";
56
+ const cls = (attrs.match(/classname="([^"]*)"/) || [])[1] || "";
57
+ const failed = /<(failure|error)\b/.test(inner);
58
+ const skipped = /<skipped\b/.test(inner);
59
+ if (failed) failures.push(name || "?");
60
+ if (skipped) continue;
61
+ let t;
62
+ TC_RE.lastIndex = 0;
63
+ const hay = `${name} ${cls}`;
64
+ while ((t = TC_RE.exec(hay)) !== null) {
65
+ if (!failed) passedIds.add(parseInt(t[1], 10));
66
+ }
67
+ }
68
+ if (found) {
69
+ parsed++;
70
+ newestMtime = Math.max(newestMtime, fs.statSync(file).mtimeMs);
71
+ }
72
+ }
73
+ return { parsed, failures, passedIds, newestMtime };
74
+ }
75
+
76
+ module.exports = { findReportDirs, parseReports };
package/lib/testrun.js ADDED
@@ -0,0 +1,136 @@
1
+ // ai test:跑项目测试(config.testCommand 优先,否则按栈自动探测)
2
+ // 多栈 JUnit 自动注入(P1-2):证据门禁靠 JUnit 报告,但只有 Maven 自带——
3
+ // 其余栈在此自动补齐报告输出,统一落到 test-results/,用户零配置。
4
+ const fs = require("fs");
5
+ const path = require("path");
6
+ const { spawnSync } = require("child_process");
7
+ const { requireProjectRoot, loadConfig, die, exists } = require("./core");
8
+
9
+ const OUT_DIR = "test-results";
10
+
11
+ function cmdTest(args = []) {
12
+ const root = requireProjectRoot();
13
+ // ai test <id>:报告写入 test-results/<id>/,与其他变更隔离(ship 优先只认它)
14
+ const id = (args || []).find((a) => !a.startsWith("--")) || "";
15
+ if (id && !exists(path.join(root, ".ai", "changes", id))) die(`change 不存在: ${id}`);
16
+ const out = id ? `${OUT_DIR}/${id}` : OUT_DIR;
17
+ const started = Date.now();
18
+ // maven/自定义命令无法定向输出——跑完后把本次新产生的报告收割进隔离目录
19
+ const after = id ? () => harvest(root, out, started) : null;
20
+
21
+ const custom = loadConfig(root).testCommand || "";
22
+ if (custom) return run(root, custom, "自定义命令不注入报告参数;请确保命令本身输出 JUnit XML", after);
23
+
24
+ if (exists(path.join(root, "pom.xml"))) return run(root, "mvn test", null, after); // surefire 自带报告
25
+ if (exists(path.join(root, "go.mod"))) return runGo(root, out);
26
+ if (exists(path.join(root, "package.json"))) return runNode(root, out);
27
+ if (exists(path.join(root, "composer.json")))
28
+ return run(root, `vendor/bin/phpunit --log-junit ${out}/phpunit.xml`);
29
+ die("无法确定测试命令;在 .ai/config.json 的 testCommand 中配置");
30
+ }
31
+
32
+ function run(root, cmd, note, after) {
33
+ console.log(`==> ${cmd}`);
34
+ if (note) console.log(`(${note})`);
35
+ const r = spawnSync(cmd, { shell: true, stdio: "inherit", cwd: root });
36
+ if (r.status !== 0) die(`TEST_FAILED(exit=${r.status});修复后重跑。报告即验收证据`, r.status || 1);
37
+ if (after) after();
38
+ console.log("TEST_PASSED(JUnit 报告即验收证据,ai ship 时核对)");
39
+ }
40
+
41
+ // 把本次运行新产生的 JUnit XML 收进隔离目录(只收 mtime 晚于本次启动的)
42
+ function harvest(root, outRel, started) {
43
+ const SOURCES = [
44
+ "target/surefire-reports", "target/failsafe-reports",
45
+ "build/test-results", "reports/junit",
46
+ ];
47
+ const outAbs = path.join(root, outRel);
48
+ fs.mkdirSync(outAbs, { recursive: true });
49
+ let n = 0;
50
+ const grab = (dir, recursive) => {
51
+ if (!exists(dir)) return;
52
+ for (const f of fs.readdirSync(dir, { withFileTypes: true })) {
53
+ const p = path.join(dir, f.name);
54
+ if (f.isDirectory()) { if (recursive) grab(p, true); continue; }
55
+ if (!f.name.endsWith(".xml")) continue;
56
+ if (fs.statSync(p).mtimeMs >= started - 2000) {
57
+ fs.copyFileSync(p, path.join(outAbs, f.name));
58
+ n++;
59
+ }
60
+ }
61
+ };
62
+ SOURCES.forEach((d) => grab(path.join(root, d), true));
63
+ grab(path.join(root, OUT_DIR), false); // test-results 根层文件(不递归,避免收走别的变更的隔离目录)
64
+ if (n) console.log(`已把 ${n} 份新报告收入 ${outRel}/(按变更隔离)`);
65
+ else console.log(`提示:本次未检测到新报告;若测试框架输出位置特殊,请显式传给 ai ship`);
66
+ }
67
+
68
+ // ── Go:go test 原生不产 XML;捕获 -v 输出自转 JUnit(零依赖,无需 gotestsum)──
69
+ function runGo(root, out) {
70
+ const cmd = "go test ./... -v";
71
+ console.log(`==> ${cmd}(输出将转为 JUnit 报告:${out}/go.xml)`);
72
+ const r = spawnSync(cmd, { shell: true, cwd: root, encoding: "utf8", maxBuffer: 64 * 1024 * 1024 });
73
+ const txt = (r.stdout || "") + (r.stderr || "");
74
+ process.stdout.write(txt);
75
+ const cases = parseGoTestOutput(txt);
76
+ if (cases.length) {
77
+ fs.mkdirSync(path.join(root, out), { recursive: true });
78
+ fs.writeFileSync(path.join(root, out, "go.xml"), buildJUnitXml("go", cases));
79
+ console.log(`已生成 ${out}/go.xml(${cases.length} 条用例)`);
80
+ }
81
+ if (r.status !== 0) die(`TEST_FAILED(exit=${r.status});修复后重跑。报告即验收证据`, r.status || 1);
82
+ console.log("TEST_PASSED(JUnit 报告即验收证据,ai ship 时核对)");
83
+ }
84
+
85
+ // 解析 `go test -v` 的 --- PASS/FAIL/SKIP 行(含子测试缩进)
86
+ function parseGoTestOutput(out) {
87
+ const cases = [];
88
+ for (const line of out.split("\n")) {
89
+ const m = line.match(/^\s*--- (PASS|FAIL|SKIP): (\S+) \(([\d.]+)s\)/);
90
+ if (m) cases.push({ name: m[2], status: m[1], time: m[3] });
91
+ }
92
+ return cases;
93
+ }
94
+
95
+ function buildJUnitXml(suite, cases) {
96
+ const esc = (s) => s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/"/g, "&quot;");
97
+ const failures = cases.filter((c) => c.status === "FAIL").length;
98
+ const body = cases
99
+ .map((c) => {
100
+ const inner = c.status === "FAIL" ? "<failure/>" : c.status === "SKIP" ? "<skipped/>" : "";
101
+ return inner
102
+ ? ` <testcase classname="${esc(suite)}" name="${esc(c.name)}" time="${c.time}">${inner}</testcase>`
103
+ : ` <testcase classname="${esc(suite)}" name="${esc(c.name)}" time="${c.time}"/>`;
104
+ })
105
+ .join("\n");
106
+ return `<?xml version="1.0" encoding="UTF-8"?>\n<testsuite name="${esc(suite)}" tests="${cases.length}" failures="${failures}">\n${body}\n</testsuite>\n`;
107
+ }
108
+
109
+ // ── Node:Vitest 用内置 junit reporter;Jest 需 jest-junit(缺则给一行安装指引)──
110
+ function runNode(root, out) {
111
+ let deps = {};
112
+ try {
113
+ const pkg = JSON.parse(fs.readFileSync(path.join(root, "package.json"), "utf8"));
114
+ deps = { ...pkg.dependencies, ...pkg.devDependencies };
115
+ } catch { /* 按 npm test 兜底 */ }
116
+
117
+ if (deps.vitest) {
118
+ return run(root, `npx vitest run --reporter=default --reporter=junit --outputFile=${out}/vitest.xml`);
119
+ }
120
+ if (deps.jest) {
121
+ if (!deps["jest-junit"]) {
122
+ console.log("提示:Jest 无内置 JUnit 输出。运行 `npm i -D jest-junit` 后重跑 ai test,报告将自动生成。");
123
+ return run(root, "npm test", "本次无 JUnit 报告,ship 的证据门禁将无法通过");
124
+ }
125
+ console.log(`==> npx jest --reporters=default --reporters=jest-junit(报告:${out}/jest.xml)`);
126
+ const r = spawnSync("npx jest --reporters=default --reporters=jest-junit", {
127
+ shell: true, stdio: "inherit", cwd: root,
128
+ env: { ...process.env, JEST_JUNIT_OUTPUT_DIR: out, JEST_JUNIT_OUTPUT_NAME: "jest.xml" },
129
+ });
130
+ if (r.status !== 0) die(`TEST_FAILED(exit=${r.status});修复后重跑。报告即验收证据`, r.status || 1);
131
+ return console.log("TEST_PASSED(JUnit 报告即验收证据,ai ship 时核对)");
132
+ }
133
+ return run(root, "npm test", "未识别测试框架,无法自动注入 JUnit 输出;如需证据门禁请配置 junit reporter");
134
+ }
135
+
136
+ module.exports = { cmdTest, parseGoTestOutput, buildJUnitXml };
package/package.json ADDED
@@ -0,0 +1,37 @@
1
+ {
2
+ "name": "@brightliu/ai-control",
3
+ "version": "2.1.0",
4
+ "description": "AGENTS.md 标准之上的中文全栈 AI 工程控制层:规格先行、DB 两阶段确认、证据验收。支持 Codex / Claude Code / Cursor / Kimi / Qoder / WorkBuddy。",
5
+ "bin": {
6
+ "ai": "bin/ai.js"
7
+ },
8
+ "files": [
9
+ "bin/",
10
+ "lib/",
11
+ "payload/",
12
+ "addons/",
13
+ "README.md"
14
+ ],
15
+ "scripts": {
16
+ "test": "node tests/unit.test.js && node tests/budget.js && node tests/refs.js && npx bats tests/"
17
+ },
18
+ "engines": {
19
+ "node": ">=18.17"
20
+ },
21
+ "repository": {
22
+ "type": "git",
23
+ "url": "https://github.com/BrightLiu4917/ai-control.git"
24
+ },
25
+ "keywords": [
26
+ "agents-md",
27
+ "ai-coding",
28
+ "spec-driven",
29
+ "claude-code",
30
+ "codex",
31
+ "cursor"
32
+ ],
33
+ "license": "MIT",
34
+ "devDependencies": {
35
+ "bats": "^1.11.0"
36
+ }
37
+ }
@@ -0,0 +1,62 @@
1
+ # 全局契约
2
+
3
+ 你是在严格约束下工作的全栈工程师。不确定时:停止并询问。禁止带着影响正确性的假设继续。
4
+
5
+ ## 绝对红线
6
+
7
+ - 禁止发明业务事实:字段、枚举值、API 路径、权限、状态流转、响应格式必须来自已确认规格或用户确认。
8
+ - 禁止伪实现:不写空方法、TODO 桩、假数据糊弄验证;禁止为通过验证降低测试或安全标准。
9
+ - 禁止在代码、日志、配置、文档中出现密钥、密码、token、生产连接串。
10
+ - 禁止修改无关文件、顺手重构、未经批准引入依赖或删除既有接口/字段/页面入口。
11
+
12
+ ## 两个必须等用户点头的确认点
13
+
14
+ 1. **change 确认**:proposal + test-cases 未经用户确认,禁止写任何业务代码;用户点头后**由用户本人**运行 `ai confirm <id>` 留痕(AI 禁止代跑,把命令交给用户)(ship 门禁的前置,确认后再改 proposal/test-cases 即失效须重确认)。
15
+ 2. **数据库确认**:涉及表结构/字段/索引/迁移时,先输出表结构设计审查,确认后再输出变更确认包(含目标 DDL、回滚 SQL、联动清单),再次确认后才能执行 SQL 或写 migration。DROP/TRUNCATE 前默认备份(`原表名_copy_yyyyMMdd`)。完整细则见 `.ai/rules/10-db-schema.md`。
16
+
17
+ ## 任务分级
18
+
19
+ - **简单任务**(可跳过 change):仅文档/注释/typo,单文件,不碰任何业务行为。
20
+ - **lite**(`ai new <id> --lite`,两件套):不碰数据库、API 契约、权限/租户/支付/状态流转;越界会被门禁拦下,用 `ai new <id> --upgrade` 升级(保留已写内容)。lite 减仪式不减纪律:同样要求原子提交和可验证用例。
21
+ - **完整**(`ai new <id>`):其余一切。涉及跨模块/数据库/接口兼容时按模板补建 design.md。
22
+
23
+ ## 工作流
24
+
25
+ ```text
26
+ 用户提需求 → 影响探测(检索代码,列出触碰的文件/表/接口)→ 判级(证据判级,拿不准判高一级)
27
+ → ai new 建骨架 → 补全 proposal + test-cases → ai check → 确认单标注级别与理由,等用户确认 → 用户运行 ai confirm
28
+ → 实现(最小切片)→ ai test <id> → ai ship(证据门禁秒级)→ 交付
29
+ ```
30
+
31
+ - 用户说"测一下"→ `ai test <id>`(报告自动按变更隔离);"能上线吗/发布"→ `ai ship <id>`;"表加字段"→ 走数据库确认。
32
+ - 用户明说小/大需求时尊重其判级,门禁照常。
33
+
34
+ ## 验收契约
35
+
36
+ - test-cases.md 随规格一起经用户确认;执行阶段禁止为迁就实现修改用例,改需重新确认。
37
+ - 自动化测试方法名必须含用例ID(如 `test_TC01_xxx`);验收以 JUnit 报告为证据(存在、无失败、TC-ID 覆盖非手动用例),不维护状态表格。
38
+ - 手动用例的执行结果逐条写入交付说明;禁止谎报未执行的验证。
39
+ - 验证尽量在独立上下文进行(Claude Code 用测试工程师 subagent,其他工具建议新会话);用 goal-backward 提问:"这个功能要成立,哪些行为必须可观察到?"
40
+
41
+ ## 角色路由(4 个)
42
+
43
+ 按任务读取对应手册,对用户使用中文角色名:
44
+
45
+ - 产品规格工程师 `.ai/agents/agent-spec.md`:需求澄清、架构影响、变更规格、API 契约。非简单任务必先经过。
46
+ - 数据库工程师 `.ai/agents/agent-dba.md`:表结构、SQL、迁移回滚、两阶段确认。碰 DB 必先经过。
47
+ - 开发工程师 `.ai/agents/agent-dev.md`:全部实现;按栈读 `.ai/agents/dev/` 对应手册与 `.ai/rules/` 栈规则。
48
+ - 测试工程师 `.ai/agents/agent-test.md`:设计期写验收用例,实现后按报告核对证据。
49
+ - 发布检查按 `.ai/rules/53-release.md` 清单执行(只过与本次变更相关的项);高风险变更建议用 `.ai/templates/review-prompt.md` 在新会话做一次独立审查。
50
+
51
+ ## 规则库
52
+
53
+ `.ai/rules/` 单层编号,按需读取(索引见其 README):10-db-schema、20-api、21-jwt、22-rbac、30-frontend、31-vue3、32-react、40-backend、41-spring-boot、42-go-gin、43-php、44-java-enum、50-testing、51-security、52-performance、53-release、01-code-change、02-product-ux。
54
+ 开工提示会注入本次必读清单;不读本次未涉及的规则。
55
+
56
+ ## 必须澄清(停止并询问)
57
+
58
+ 字段含义、枚举值、表结构、状态流转、权限/租户规则、删除行为、支付扣减、API 响应格式、页面入口与异常态、规格与现有代码冲突——任一不清即停。
59
+
60
+ ## 交付输出
61
+
62
+ 只列实际发生变化的项(有 SQL 必附回滚;有手动用例必逐条报告结果);无变化的项不写"无"占位。大型功能建议归档已确认行为到 `.ai/specs/`,中小变更不强制。