progmune-runtime 3.3.7 → 3.4.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.
- package/CHANGELOG.md +34 -0
- package/README.md +11 -11
- package/README.zh-CN.md +11 -11
- package/dist/agent-cli.js +209 -0
- package/dist/agent-loop.js +324 -0
- package/dist/agent-loop.test.js +256 -0
- package/dist/agent-patrol.js +200 -0
- package/dist/agent-patrol.test.js +141 -0
- package/dist/agent-perception.js +197 -0
- package/dist/agent-perception.test.js +125 -0
- package/dist/agent-supervision.js +145 -0
- package/dist/agent-supervision.test.js +60 -0
- package/dist/execute.js +6 -2
- package/dist/extract-ir.js +3 -1
- package/dist/patrol-cli.js +130 -0
- package/dist/planner-prompts.js +3 -0
- package/dist/planner.js +2 -0
- package/dist/trust/engine.js +81 -0
- package/package.json +4 -1
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Phase 12: Agent 免疫巡逻 (P4) —— 形态 B 第一版
|
|
4
|
+
*
|
|
5
|
+
* 监听/扫描项目 → trust_check(Trust Engine 全量评估)→ 违规报告 + 建议补丁。
|
|
6
|
+
*
|
|
7
|
+
* 修复信任悖论(设计文档铁律):
|
|
8
|
+
* 100% 检测精度 ≠ 修复正确率。巡逻第一形态**只报告 + 建议补丁,
|
|
9
|
+
* 绝不自动合并**(autoApplied 恒为 false)——修错比不修更糟,
|
|
10
|
+
* 第一份错误修复会摧毁"免疫巡逻"信任。
|
|
11
|
+
*
|
|
12
|
+
* 感知职责:每次扫描前强制重提 IR(IR_STALE 的消费方),保证验证的是新世界。
|
|
13
|
+
*/
|
|
14
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
15
|
+
if (k2 === undefined) k2 = k;
|
|
16
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
17
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
18
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
19
|
+
}
|
|
20
|
+
Object.defineProperty(o, k2, desc);
|
|
21
|
+
}) : (function(o, m, k, k2) {
|
|
22
|
+
if (k2 === undefined) k2 = k;
|
|
23
|
+
o[k2] = m[k];
|
|
24
|
+
}));
|
|
25
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
26
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
27
|
+
}) : function(o, v) {
|
|
28
|
+
o["default"] = v;
|
|
29
|
+
});
|
|
30
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
31
|
+
var ownKeys = function(o) {
|
|
32
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
33
|
+
var ar = [];
|
|
34
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
35
|
+
return ar;
|
|
36
|
+
};
|
|
37
|
+
return ownKeys(o);
|
|
38
|
+
};
|
|
39
|
+
return function (mod) {
|
|
40
|
+
if (mod && mod.__esModule) return mod;
|
|
41
|
+
var result = {};
|
|
42
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
43
|
+
__setModuleDefault(result, mod);
|
|
44
|
+
return result;
|
|
45
|
+
};
|
|
46
|
+
})();
|
|
47
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
48
|
+
exports.runPatrol = runPatrol;
|
|
49
|
+
exports.formatPatrolTerminal = formatPatrolTerminal;
|
|
50
|
+
exports.formatPatrolMarkdown = formatPatrolMarkdown;
|
|
51
|
+
exports.writePatrolReport = writePatrolReport;
|
|
52
|
+
const fs = __importStar(require("fs"));
|
|
53
|
+
const path = __importStar(require("path"));
|
|
54
|
+
const engine_1 = require("./trust/engine");
|
|
55
|
+
const agent_perception_1 = require("./agent-perception");
|
|
56
|
+
const extract_ir_1 = require("./extract-ir");
|
|
57
|
+
// ── Main ──
|
|
58
|
+
/**
|
|
59
|
+
* 运行一次免疫巡逻:重提 IR → evaluateTrust → 映射违规(含 fixPath)→ 报告。
|
|
60
|
+
*/
|
|
61
|
+
async function runPatrol(projectPath) {
|
|
62
|
+
const abs = path.resolve(projectPath);
|
|
63
|
+
const git = (0, agent_perception_1.collectGitContext)(abs);
|
|
64
|
+
// 感知:扫描前强制重提 IR(IR_STALE 消费方)——验证的是新世界
|
|
65
|
+
try {
|
|
66
|
+
const ir = (0, extract_ir_1.extractIR)(abs);
|
|
67
|
+
fs.writeFileSync(path.join(abs, "ir.json"), JSON.stringify(ir, null, 2));
|
|
68
|
+
}
|
|
69
|
+
catch { /* IR 提取失败不阻塞 —— trust 引擎会降级 */ }
|
|
70
|
+
const decision = await (0, engine_1.evaluateTrust)({
|
|
71
|
+
projectPath: abs,
|
|
72
|
+
projectName: path.basename(abs),
|
|
73
|
+
commit: git.available ? (git.recentCommits[0]?.split(" ")[0] || "unknown") : "unknown",
|
|
74
|
+
branch: git.available ? git.branch : undefined,
|
|
75
|
+
});
|
|
76
|
+
// fixPath 来自引擎的 violationTraces(BFS 修复路径)
|
|
77
|
+
const traces = new Map((decision.violationTraces || []).map((t) => [`${t.rule_id}|${t.file}|${t.function}`, t]));
|
|
78
|
+
const findings = decision.violations.map((v) => {
|
|
79
|
+
const trace = traces.get(`${v.rule_id}|${v.file}|${v.function}`);
|
|
80
|
+
return {
|
|
81
|
+
rule_id: v.rule_id,
|
|
82
|
+
severity: v.severity,
|
|
83
|
+
file: v.file,
|
|
84
|
+
function: v.function,
|
|
85
|
+
message: v.message,
|
|
86
|
+
fixPath: trace?.fixPath || [],
|
|
87
|
+
fix: v.fix,
|
|
88
|
+
evidence: v.evidence,
|
|
89
|
+
reasoningSteps: (trace?.steps || []).map((s) => `[${s.label}] ${s.action} → ${s.explanation}`),
|
|
90
|
+
};
|
|
91
|
+
});
|
|
92
|
+
const auditTrail = [
|
|
93
|
+
{
|
|
94
|
+
timestamp: new Date().toISOString(),
|
|
95
|
+
event: "patrol:scan",
|
|
96
|
+
detail: `decision=${decision.overall.decision} score=${decision.overall.score} ` +
|
|
97
|
+
`violations=${decision.violations.length}`,
|
|
98
|
+
},
|
|
99
|
+
{
|
|
100
|
+
timestamp: decision.timestamp,
|
|
101
|
+
event: "patrol:trust-audit",
|
|
102
|
+
detail: `checkId=${decision.auditTrail.checkId} engine=${decision.engineVersion} ` +
|
|
103
|
+
`reproducible=${decision.auditTrail.reproducible}`,
|
|
104
|
+
},
|
|
105
|
+
];
|
|
106
|
+
return {
|
|
107
|
+
scannedAt: new Date().toISOString(),
|
|
108
|
+
project: decision.project,
|
|
109
|
+
branch: git.available ? git.branch : "",
|
|
110
|
+
commit: decision.commit,
|
|
111
|
+
decision: decision.overall.decision,
|
|
112
|
+
score: decision.overall.score,
|
|
113
|
+
confidence: decision.overall.confidence,
|
|
114
|
+
findings,
|
|
115
|
+
summary: {
|
|
116
|
+
total: decision.summary.total,
|
|
117
|
+
critical: decision.summary.critical,
|
|
118
|
+
high: decision.summary.high,
|
|
119
|
+
medium: decision.summary.medium,
|
|
120
|
+
low: decision.summary.low,
|
|
121
|
+
},
|
|
122
|
+
auditTrail,
|
|
123
|
+
engineVersion: decision.engineVersion,
|
|
124
|
+
checkId: decision.auditTrail.checkId,
|
|
125
|
+
autoApplied: false,
|
|
126
|
+
changedFiles: git.changedFiles,
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
// ── Formatters ──
|
|
130
|
+
/** 终端一行摘要。 */
|
|
131
|
+
function formatPatrolTerminal(r) {
|
|
132
|
+
const icon = r.decision === "APPROVED" ? "✅" : r.decision === "BLOCKED" ? "🛑" : "⚠️";
|
|
133
|
+
const lines = [
|
|
134
|
+
`${icon} 免疫巡逻: ${r.project} — ${r.decision} (score=${r.score}, confidence=${r.confidence})`,
|
|
135
|
+
` 违规: ${r.summary.total} (critical=${r.summary.critical} high=${r.summary.high} ` +
|
|
136
|
+
`medium=${r.summary.medium} low=${r.summary.low}) | 自动合并: 永不(只报告+建议)`,
|
|
137
|
+
];
|
|
138
|
+
for (const f of r.findings.slice(0, 10)) {
|
|
139
|
+
lines.push(` [${f.severity}] ${f.rule_id} @ ${f.file}${f.function ? `::${f.function}` : ""}` +
|
|
140
|
+
(f.fixPath.length > 0 ? ` → 建议补丁: ${f.fixPath.join(" → ")}` : ""));
|
|
141
|
+
}
|
|
142
|
+
if (r.findings.length > 10)
|
|
143
|
+
lines.push(` … 其余 ${r.findings.length - 10} 项见报告`);
|
|
144
|
+
return lines.join("\n");
|
|
145
|
+
}
|
|
146
|
+
/** 完整 Markdown 巡逻报告(含证据链回放,可入审计档案)。 */
|
|
147
|
+
function formatPatrolMarkdown(r) {
|
|
148
|
+
const lines = [
|
|
149
|
+
`# 🛡️ Progmune 免疫巡逻报告`,
|
|
150
|
+
``,
|
|
151
|
+
`- 扫描时间: ${r.scannedAt}`,
|
|
152
|
+
`- 项目: ${r.project}${r.branch ? `(分支 ${r.branch})` : ""}`,
|
|
153
|
+
`- 提交: ${r.commit}`,
|
|
154
|
+
`- 决策: **${r.decision}** | 分数: ${r.score} | 置信度: ${r.confidence}`,
|
|
155
|
+
`- 引擎: ${r.engineVersion} | checkId: ${r.checkId}`,
|
|
156
|
+
`- **自动合并: 永不**(只报告 + 建议补丁,修复需人工审批)`,
|
|
157
|
+
``,
|
|
158
|
+
`## 违规摘要`,
|
|
159
|
+
``,
|
|
160
|
+
`| 严重级 | 数量 |`,
|
|
161
|
+
`|---|---|`,
|
|
162
|
+
`| critical | ${r.summary.critical} |`,
|
|
163
|
+
`| high | ${r.summary.high} |`,
|
|
164
|
+
`| medium | ${r.summary.medium} |`,
|
|
165
|
+
`| low | ${r.summary.low} |`,
|
|
166
|
+
`| **合计** | **${r.summary.total}** |`,
|
|
167
|
+
``,
|
|
168
|
+
];
|
|
169
|
+
if (r.findings.length === 0) {
|
|
170
|
+
lines.push(`✅ 未发现违规。`, ``);
|
|
171
|
+
}
|
|
172
|
+
else {
|
|
173
|
+
lines.push(`## 违规明细与建议补丁`, ``);
|
|
174
|
+
r.findings.forEach((f, i) => {
|
|
175
|
+
lines.push(`### ${i + 1}. [${f.severity}] ${f.rule_id} — ${f.file}${f.function ? `::${f.function}` : ""}`, ``, `- **问题**: ${f.message}`, `- **修复建议**: ${f.fix}`);
|
|
176
|
+
if (f.fixPath.length > 0) {
|
|
177
|
+
lines.push(`- **建议补丁路径**(不自动应用): \`${f.fixPath.join(" → ")}\``);
|
|
178
|
+
}
|
|
179
|
+
if (f.reasoningSteps.length > 0) {
|
|
180
|
+
lines.push(``, `**推理回放**:`, ``);
|
|
181
|
+
f.reasoningSteps.forEach((s) => lines.push(` - ${s}`));
|
|
182
|
+
}
|
|
183
|
+
lines.push(``);
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
lines.push(`## 证据链(可回放)`, ``);
|
|
187
|
+
r.auditTrail.forEach((e) => lines.push(`- \`${e.timestamp}\` **${e.event}**: ${e.detail}`));
|
|
188
|
+
if (r.changedFiles.length > 0) {
|
|
189
|
+
lines.push(``, `## 扫描时变更文件`, ``);
|
|
190
|
+
r.changedFiles.forEach((f) => lines.push(`- ${f}`));
|
|
191
|
+
}
|
|
192
|
+
lines.push(``, `---`, `*由 Progmune 免疫巡逻生成。修复责任: 人工审批后应用。*`, ``);
|
|
193
|
+
return lines.join("\n");
|
|
194
|
+
}
|
|
195
|
+
/** 写入巡逻报告到项目目录,返回文件路径。 */
|
|
196
|
+
function writePatrolReport(r, projectPath) {
|
|
197
|
+
const reportPath = path.join(path.resolve(projectPath), ".progmune_patrol_report.md");
|
|
198
|
+
fs.writeFileSync(reportPath, formatPatrolMarkdown(r), "utf-8");
|
|
199
|
+
return reportPath;
|
|
200
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Phase 12: 免疫巡逻测试 (P4)
|
|
4
|
+
*
|
|
5
|
+
* evaluateTrust / extractIR / git 全部 mock —— 验证:
|
|
6
|
+
* - 违规 → 报告映射(fixPath 来自 violationTraces)
|
|
7
|
+
* - autoApplied 恒为 false(修复信任悖论)
|
|
8
|
+
* - Markdown 报告含建议补丁 + 证据链
|
|
9
|
+
* - 报告落盘
|
|
10
|
+
*/
|
|
11
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
12
|
+
const vitest_1 = require("vitest");
|
|
13
|
+
const engine_1 = require("./trust/engine");
|
|
14
|
+
const extract_ir_1 = require("./extract-ir");
|
|
15
|
+
const child_process_1 = require("child_process");
|
|
16
|
+
const agent_patrol_1 = require("./agent-patrol");
|
|
17
|
+
vitest_1.vi.mock("./trust/engine", () => ({
|
|
18
|
+
evaluateTrust: vitest_1.vi.fn(),
|
|
19
|
+
}));
|
|
20
|
+
vitest_1.vi.mock("./extract-ir", () => ({
|
|
21
|
+
extractIR: vitest_1.vi.fn(),
|
|
22
|
+
}));
|
|
23
|
+
vitest_1.vi.mock("child_process", () => ({
|
|
24
|
+
execSync: vitest_1.vi.fn(),
|
|
25
|
+
}));
|
|
26
|
+
const mockEvaluateTrust = vitest_1.vi.mocked(engine_1.evaluateTrust);
|
|
27
|
+
const mockExtractIR = vitest_1.vi.mocked(extract_ir_1.extractIR);
|
|
28
|
+
const mockExecSync = vitest_1.vi.mocked(child_process_1.execSync);
|
|
29
|
+
/** 构造受控的 TrustDecision */
|
|
30
|
+
function trustDecision(overrides = {}) {
|
|
31
|
+
return {
|
|
32
|
+
project: "demo-patrol",
|
|
33
|
+
commit: "abc123",
|
|
34
|
+
timestamp: "2026-08-21T08:00:00.000Z",
|
|
35
|
+
engineVersion: "trust-runtime-v1.0.0",
|
|
36
|
+
overall: { score: 41, decision: "BLOCKED", confidence: "HIGH" },
|
|
37
|
+
dimensions: {},
|
|
38
|
+
violations: [
|
|
39
|
+
{
|
|
40
|
+
severity: "high",
|
|
41
|
+
rule_id: "SSG_PROTOCOL",
|
|
42
|
+
file: "bad_flow.ts",
|
|
43
|
+
function: "bad_flow",
|
|
44
|
+
message: 'SSG state violation: "generate_jwt" requires states [PASSWORD_VERIFIED]',
|
|
45
|
+
evidence: "调用序列 [generate_jwt] 违反协议",
|
|
46
|
+
why: "缺少密码验证前置",
|
|
47
|
+
fix: "在 generate_jwt 前调用 verify_password",
|
|
48
|
+
policy_ref: "REF-SSG-001",
|
|
49
|
+
},
|
|
50
|
+
],
|
|
51
|
+
violationTraces: [
|
|
52
|
+
{
|
|
53
|
+
rule_id: "SSG_PROTOCOL",
|
|
54
|
+
file: "bad_flow.ts",
|
|
55
|
+
function: "bad_flow",
|
|
56
|
+
steps: [
|
|
57
|
+
{ step: 1, label: "状态", action: "generate_jwt", preState: "UNAUTHENTICATED", explanation: "前置 PASSWORD_VERIFIED 缺失" },
|
|
58
|
+
],
|
|
59
|
+
fixPath: ["verify_password"],
|
|
60
|
+
estimatedReadingTimeMinutes: 1,
|
|
61
|
+
},
|
|
62
|
+
],
|
|
63
|
+
summary: { critical: 0, high: 1, medium: 0, low: 0, total: 1 },
|
|
64
|
+
auditTrail: {
|
|
65
|
+
commit: "abc123",
|
|
66
|
+
policy: "default",
|
|
67
|
+
policyVersion: "v1.0.0",
|
|
68
|
+
engineVersion: "trust-runtime-v1.0.0",
|
|
69
|
+
generatedAt: "2026-08-21T08:00:00.000Z",
|
|
70
|
+
reproducible: true,
|
|
71
|
+
checkId: "check_abc",
|
|
72
|
+
},
|
|
73
|
+
...overrides,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
(0, vitest_1.beforeEach)(() => {
|
|
77
|
+
vitest_1.vi.clearAllMocks();
|
|
78
|
+
mockExtractIR.mockReturnValue([]);
|
|
79
|
+
mockExecSync.mockImplementation((cmd) => {
|
|
80
|
+
const c = String(cmd);
|
|
81
|
+
if (c.includes("rev-parse"))
|
|
82
|
+
return "main";
|
|
83
|
+
if (c.includes("log --oneline"))
|
|
84
|
+
return "abc123 feat: demo";
|
|
85
|
+
if (c.includes("status --porcelain"))
|
|
86
|
+
return " M bad_flow.ts";
|
|
87
|
+
throw new Error("unexpected cmd: " + c);
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
(0, vitest_1.describe)("agent-patrol", () => {
|
|
91
|
+
(0, vitest_1.it)("违规映射到报告:fixPath 来自 violationTraces,autoApplied 恒为 false", async () => {
|
|
92
|
+
mockEvaluateTrust.mockResolvedValue(trustDecision());
|
|
93
|
+
const r = await (0, agent_patrol_1.runPatrol)("/tmp/fake-project");
|
|
94
|
+
(0, vitest_1.expect)(r.decision).toBe("BLOCKED");
|
|
95
|
+
(0, vitest_1.expect)(r.score).toBe(41);
|
|
96
|
+
(0, vitest_1.expect)(r.summary).toEqual({ critical: 0, high: 1, medium: 0, low: 0, total: 1 });
|
|
97
|
+
(0, vitest_1.expect)(r.findings).toHaveLength(1);
|
|
98
|
+
(0, vitest_1.expect)(r.findings[0].fixPath).toEqual(["verify_password"]);
|
|
99
|
+
(0, vitest_1.expect)(r.findings[0].reasoningSteps.length).toBe(1);
|
|
100
|
+
(0, vitest_1.expect)(r.autoApplied).toBe(false); // 铁律:永不自动合并
|
|
101
|
+
(0, vitest_1.expect)(r.auditTrail.map((e) => e.event)).toContain("patrol:scan");
|
|
102
|
+
});
|
|
103
|
+
(0, vitest_1.it)("无违规时 APPROVED 报告不含明细", async () => {
|
|
104
|
+
mockEvaluateTrust.mockResolvedValue(trustDecision({
|
|
105
|
+
overall: { score: 95, decision: "APPROVED", confidence: "HIGH" },
|
|
106
|
+
violations: [],
|
|
107
|
+
violationTraces: [],
|
|
108
|
+
summary: { critical: 0, high: 0, medium: 0, low: 0, total: 0 },
|
|
109
|
+
}));
|
|
110
|
+
const r = await (0, agent_patrol_1.runPatrol)("/tmp/fake-project");
|
|
111
|
+
(0, vitest_1.expect)(r.decision).toBe("APPROVED");
|
|
112
|
+
(0, vitest_1.expect)(r.findings).toHaveLength(0);
|
|
113
|
+
const md = (0, agent_patrol_1.formatPatrolMarkdown)(r);
|
|
114
|
+
(0, vitest_1.expect)(md).toContain("未发现违规");
|
|
115
|
+
(0, vitest_1.expect)(md).toContain("自动合并: 永不");
|
|
116
|
+
});
|
|
117
|
+
(0, vitest_1.it)("Markdown 报告含建议补丁路径与证据链回放", async () => {
|
|
118
|
+
mockEvaluateTrust.mockResolvedValue(trustDecision());
|
|
119
|
+
const r = await (0, agent_patrol_1.runPatrol)("/tmp/fake-project");
|
|
120
|
+
const md = (0, agent_patrol_1.formatPatrolMarkdown)(r);
|
|
121
|
+
(0, vitest_1.expect)(md).toContain("免疫巡逻报告");
|
|
122
|
+
(0, vitest_1.expect)(md).toContain("SSG_PROTOCOL");
|
|
123
|
+
(0, vitest_1.expect)(md).toContain("建议补丁路径");
|
|
124
|
+
(0, vitest_1.expect)(md).toContain("verify_password");
|
|
125
|
+
(0, vitest_1.expect)(md).toContain("推理回放");
|
|
126
|
+
(0, vitest_1.expect)(md).toContain("证据链(可回放)");
|
|
127
|
+
(0, vitest_1.expect)(md).toContain("checkId: check_abc");
|
|
128
|
+
});
|
|
129
|
+
(0, vitest_1.it)("writePatrolReport 落盘到项目目录", async () => {
|
|
130
|
+
const fs = require("fs");
|
|
131
|
+
const os = require("os");
|
|
132
|
+
const path = require("path");
|
|
133
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "pm-patrol-"));
|
|
134
|
+
mockEvaluateTrust.mockResolvedValue(trustDecision());
|
|
135
|
+
const r = await (0, agent_patrol_1.runPatrol)(dir);
|
|
136
|
+
const reportPath = (0, agent_patrol_1.writePatrolReport)(r, dir);
|
|
137
|
+
(0, vitest_1.expect)(fs.existsSync(reportPath)).toBe(true);
|
|
138
|
+
const content = fs.readFileSync(reportPath, "utf-8");
|
|
139
|
+
(0, vitest_1.expect)(content).toContain("免疫巡逻报告");
|
|
140
|
+
});
|
|
141
|
+
});
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Phase 12: Agent 感知层 (P2)
|
|
4
|
+
*
|
|
5
|
+
* 为 agent loop 提供"看清世界"的能力(设计文档 P2):
|
|
6
|
+
* - GitContext — 仓库扫描 + git 上下文(分支/最近提交/变更文件/源文件清单)
|
|
7
|
+
* - extractIRWithDelta — IR 提取 + 前后函数名差集(IR 增量重提的观测面)
|
|
8
|
+
* - RepoWatcher — 文件变更监听(fs.watch + 防抖)→ 触发 IR 重提回调
|
|
9
|
+
*
|
|
10
|
+
* 原则:感知失败不阻塞主循环(best-effort,明确记录 unavailable)。
|
|
11
|
+
*/
|
|
12
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
13
|
+
if (k2 === undefined) k2 = k;
|
|
14
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
15
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
16
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
17
|
+
}
|
|
18
|
+
Object.defineProperty(o, k2, desc);
|
|
19
|
+
}) : (function(o, m, k, k2) {
|
|
20
|
+
if (k2 === undefined) k2 = k;
|
|
21
|
+
o[k2] = m[k];
|
|
22
|
+
}));
|
|
23
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
24
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
25
|
+
}) : function(o, v) {
|
|
26
|
+
o["default"] = v;
|
|
27
|
+
});
|
|
28
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
29
|
+
var ownKeys = function(o) {
|
|
30
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
31
|
+
var ar = [];
|
|
32
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
33
|
+
return ar;
|
|
34
|
+
};
|
|
35
|
+
return ownKeys(o);
|
|
36
|
+
};
|
|
37
|
+
return function (mod) {
|
|
38
|
+
if (mod && mod.__esModule) return mod;
|
|
39
|
+
var result = {};
|
|
40
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
41
|
+
__setModuleDefault(result, mod);
|
|
42
|
+
return result;
|
|
43
|
+
};
|
|
44
|
+
})();
|
|
45
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
46
|
+
exports.RepoWatcher = void 0;
|
|
47
|
+
exports.collectGitContext = collectGitContext;
|
|
48
|
+
exports.extractIRWithDelta = extractIRWithDelta;
|
|
49
|
+
const fs = __importStar(require("fs"));
|
|
50
|
+
const path = __importStar(require("path"));
|
|
51
|
+
const child_process_1 = require("child_process");
|
|
52
|
+
const extract_ir_1 = require("./extract-ir");
|
|
53
|
+
// ── Git Context ──
|
|
54
|
+
function git(projectPath, args) {
|
|
55
|
+
return (0, child_process_1.execSync)(`git -C "${projectPath}" ${args}`, {
|
|
56
|
+
encoding: "utf-8",
|
|
57
|
+
timeout: 10000,
|
|
58
|
+
stdio: "pipe",
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
/** 收集仓库上下文(分支、最近提交、变更文件)与源文件清单。best-effort。 */
|
|
62
|
+
function collectGitContext(projectPath) {
|
|
63
|
+
try {
|
|
64
|
+
const branch = git(projectPath, "rev-parse --abbrev-ref HEAD").trim();
|
|
65
|
+
const recentCommits = git(projectPath, "log --oneline -5")
|
|
66
|
+
.split("\n").filter(Boolean);
|
|
67
|
+
// porcelain 输出首字符可能是空格(未暂存),必须整串不 trim 才能保住 3 字符状态列
|
|
68
|
+
const changedFiles = git(projectPath, "status --porcelain")
|
|
69
|
+
.split("\n").filter(Boolean).map((l) => l.slice(3).trim());
|
|
70
|
+
const sourceFiles = listSourceFiles(projectPath);
|
|
71
|
+
return { available: true, branch, recentCommits, changedFiles, sourceFiles };
|
|
72
|
+
}
|
|
73
|
+
catch (e) {
|
|
74
|
+
// 非 git 仓库等 → 只给源文件清单
|
|
75
|
+
try {
|
|
76
|
+
return {
|
|
77
|
+
available: false,
|
|
78
|
+
branch: "",
|
|
79
|
+
recentCommits: [],
|
|
80
|
+
changedFiles: [],
|
|
81
|
+
sourceFiles: listSourceFiles(projectPath),
|
|
82
|
+
error: e?.message || String(e),
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
return { available: false, branch: "", recentCommits: [], changedFiles: [], sourceFiles: [], error: e?.message || String(e) };
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
/** 浅层扫描项目源文件(不递归进依赖目录)。 */
|
|
91
|
+
function listSourceFiles(projectPath) {
|
|
92
|
+
const SKIP_DIRS = new Set(["node_modules", "dist", "build", ".git", ".progmune_corpus", "__pycache__", "venv", ".venv"]);
|
|
93
|
+
const EXTS = new Set([".ts", ".js", ".py", ".tsx", ".jsx"]);
|
|
94
|
+
const out = [];
|
|
95
|
+
const stack = [projectPath];
|
|
96
|
+
const seen = new Set();
|
|
97
|
+
while (stack.length > 0 && out.length < 500) {
|
|
98
|
+
const dir = stack.pop();
|
|
99
|
+
if (seen.has(dir))
|
|
100
|
+
continue;
|
|
101
|
+
seen.add(dir);
|
|
102
|
+
let entries;
|
|
103
|
+
try {
|
|
104
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
105
|
+
}
|
|
106
|
+
catch {
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
for (const e of entries) {
|
|
110
|
+
const full = path.join(dir, e.name);
|
|
111
|
+
if (e.isDirectory()) {
|
|
112
|
+
if (!SKIP_DIRS.has(e.name) && !e.name.startsWith("."))
|
|
113
|
+
stack.push(full);
|
|
114
|
+
}
|
|
115
|
+
else if (EXTS.has(path.extname(e.name))) {
|
|
116
|
+
out.push(path.relative(projectPath, full));
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
return out.sort();
|
|
121
|
+
}
|
|
122
|
+
// ── IR 增量 ──
|
|
123
|
+
/**
|
|
124
|
+
* 提取 IR 并计算与上次函数名集合的差集。
|
|
125
|
+
* prevNames 缺省时只返回全量 IR(delta 为空)。
|
|
126
|
+
*/
|
|
127
|
+
function extractIRWithDelta(projectPath, prevNames) {
|
|
128
|
+
const ir = (0, extract_ir_1.extractIR)(projectPath);
|
|
129
|
+
const names = ir.map((f) => String(f.name || "")).filter(Boolean);
|
|
130
|
+
if (!prevNames) {
|
|
131
|
+
return { ir, delta: { added: [], removed: [], functionCount: names.length } };
|
|
132
|
+
}
|
|
133
|
+
const cur = new Set(names);
|
|
134
|
+
const added = names.filter((n) => !prevNames.has(n));
|
|
135
|
+
const removed = [...prevNames].filter((n) => !cur.has(n));
|
|
136
|
+
return { ir, delta: { added, removed, functionCount: names.length } };
|
|
137
|
+
}
|
|
138
|
+
// ── Repo Watcher ──
|
|
139
|
+
const WATCH_EXTS = new Set([".ts", ".js", ".py", ".tsx", ".jsx"]);
|
|
140
|
+
/**
|
|
141
|
+
* 文件变更监听器:fs.watch 递归监听项目目录,按文件防抖后回调。
|
|
142
|
+
* 用途:agent 或用户修改文件后触发 IR 增量重提(IR_STALE 消费方)。
|
|
143
|
+
*/
|
|
144
|
+
class RepoWatcher {
|
|
145
|
+
constructor(projectPath, onChange, debounceMs = 500) {
|
|
146
|
+
this.projectPath = projectPath;
|
|
147
|
+
this.onChange = onChange;
|
|
148
|
+
this.debounceMs = debounceMs;
|
|
149
|
+
this.watcher = null;
|
|
150
|
+
this.timers = new Map();
|
|
151
|
+
}
|
|
152
|
+
start() {
|
|
153
|
+
if (this.watcher)
|
|
154
|
+
return;
|
|
155
|
+
try {
|
|
156
|
+
this.watcher = fs.watch(this.projectPath, { recursive: true }, (event, filename) => {
|
|
157
|
+
if (!filename)
|
|
158
|
+
return;
|
|
159
|
+
const ext = path.extname(String(filename));
|
|
160
|
+
if (!WATCH_EXTS.has(ext))
|
|
161
|
+
return;
|
|
162
|
+
const rel = path.relative(this.projectPath, path.join(this.projectPath, String(filename)));
|
|
163
|
+
// 防抖:同一文件的连续事件合并
|
|
164
|
+
const prev = this.timers.get(rel);
|
|
165
|
+
if (prev)
|
|
166
|
+
clearTimeout(prev);
|
|
167
|
+
this.timers.set(rel, setTimeout(() => {
|
|
168
|
+
this.timers.delete(rel);
|
|
169
|
+
try {
|
|
170
|
+
this.onChange(rel);
|
|
171
|
+
}
|
|
172
|
+
catch { /* 回调异常不杀死 watcher */ }
|
|
173
|
+
}, this.debounceMs));
|
|
174
|
+
});
|
|
175
|
+
this.watcher.on("error", () => { });
|
|
176
|
+
}
|
|
177
|
+
catch {
|
|
178
|
+
this.watcher = null; // recursive 不支持时降级为不可用
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
stop() {
|
|
182
|
+
for (const t of this.timers.values())
|
|
183
|
+
clearTimeout(t);
|
|
184
|
+
this.timers.clear();
|
|
185
|
+
if (this.watcher) {
|
|
186
|
+
try {
|
|
187
|
+
this.watcher.close();
|
|
188
|
+
}
|
|
189
|
+
catch { /* ignore */ }
|
|
190
|
+
this.watcher = null;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
get active() {
|
|
194
|
+
return this.watcher !== null;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
exports.RepoWatcher = RepoWatcher;
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Phase 12: 感知层测试 (P2)
|
|
4
|
+
*
|
|
5
|
+
* collectGitContext / extractIRWithDelta:mock git 与 IR,不触真实仓库。
|
|
6
|
+
* RepoWatcher:真实临时目录(fs.watch 需要真实文件系统)。
|
|
7
|
+
*/
|
|
8
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
9
|
+
if (k2 === undefined) k2 = k;
|
|
10
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
11
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
12
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
13
|
+
}
|
|
14
|
+
Object.defineProperty(o, k2, desc);
|
|
15
|
+
}) : (function(o, m, k, k2) {
|
|
16
|
+
if (k2 === undefined) k2 = k;
|
|
17
|
+
o[k2] = m[k];
|
|
18
|
+
}));
|
|
19
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
20
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
21
|
+
}) : function(o, v) {
|
|
22
|
+
o["default"] = v;
|
|
23
|
+
});
|
|
24
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
25
|
+
var ownKeys = function(o) {
|
|
26
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
27
|
+
var ar = [];
|
|
28
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
29
|
+
return ar;
|
|
30
|
+
};
|
|
31
|
+
return ownKeys(o);
|
|
32
|
+
};
|
|
33
|
+
return function (mod) {
|
|
34
|
+
if (mod && mod.__esModule) return mod;
|
|
35
|
+
var result = {};
|
|
36
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
37
|
+
__setModuleDefault(result, mod);
|
|
38
|
+
return result;
|
|
39
|
+
};
|
|
40
|
+
})();
|
|
41
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
42
|
+
const vitest_1 = require("vitest");
|
|
43
|
+
const child_process_1 = require("child_process");
|
|
44
|
+
const extract_ir_1 = require("./extract-ir");
|
|
45
|
+
const agent_perception_1 = require("./agent-perception");
|
|
46
|
+
vitest_1.vi.mock("child_process", () => ({
|
|
47
|
+
execSync: vitest_1.vi.fn(),
|
|
48
|
+
}));
|
|
49
|
+
vitest_1.vi.mock("./extract-ir", () => ({
|
|
50
|
+
extractIR: vitest_1.vi.fn(),
|
|
51
|
+
}));
|
|
52
|
+
const mockExecSync = vitest_1.vi.mocked(child_process_1.execSync);
|
|
53
|
+
const mockExtractIR = vitest_1.vi.mocked(extract_ir_1.extractIR);
|
|
54
|
+
(0, vitest_1.beforeEach)(() => {
|
|
55
|
+
vitest_1.vi.clearAllMocks();
|
|
56
|
+
});
|
|
57
|
+
(0, vitest_1.describe)("agent-perception", () => {
|
|
58
|
+
(0, vitest_1.it)("collectGitContext 解析分支/提交/变更文件/源文件清单", () => {
|
|
59
|
+
mockExecSync.mockImplementation((cmd) => {
|
|
60
|
+
const c = String(cmd);
|
|
61
|
+
if (c.includes("rev-parse"))
|
|
62
|
+
return "main";
|
|
63
|
+
if (c.includes("log --oneline"))
|
|
64
|
+
return "abc123 feat: login\nbcd456 fix: session";
|
|
65
|
+
if (c.includes("status --porcelain"))
|
|
66
|
+
return " M src/auth.ts\n?? src/new.ts";
|
|
67
|
+
throw new Error("unexpected cmd: " + c);
|
|
68
|
+
});
|
|
69
|
+
const ctx = (0, agent_perception_1.collectGitContext)("/tmp/fake-project");
|
|
70
|
+
(0, vitest_1.expect)(ctx.available).toBe(true);
|
|
71
|
+
(0, vitest_1.expect)(ctx.branch).toBe("main");
|
|
72
|
+
(0, vitest_1.expect)(ctx.recentCommits).toHaveLength(2);
|
|
73
|
+
(0, vitest_1.expect)(ctx.changedFiles).toEqual(["src/auth.ts", "src/new.ts"]);
|
|
74
|
+
(0, vitest_1.expect)(ctx.sourceFiles.length).toBeGreaterThanOrEqual(0);
|
|
75
|
+
});
|
|
76
|
+
(0, vitest_1.it)("collectGitContext 非 git 仓库时降级为 available=false 且不抛", () => {
|
|
77
|
+
mockExecSync.mockImplementation(() => {
|
|
78
|
+
throw new Error("fatal: not a git repository");
|
|
79
|
+
});
|
|
80
|
+
const ctx = (0, agent_perception_1.collectGitContext)("/tmp/fake-project");
|
|
81
|
+
(0, vitest_1.expect)(ctx.available).toBe(false);
|
|
82
|
+
(0, vitest_1.expect)(ctx.error).toContain("not a git repository");
|
|
83
|
+
});
|
|
84
|
+
(0, vitest_1.it)("extractIRWithDelta 计算新增/消失函数差集", () => {
|
|
85
|
+
mockExtractIR.mockReturnValue([
|
|
86
|
+
{ name: "verify_password" },
|
|
87
|
+
{ name: "main" },
|
|
88
|
+
]);
|
|
89
|
+
const prev = new Set(["verify_password", "logout"]);
|
|
90
|
+
const { delta } = (0, agent_perception_1.extractIRWithDelta)("/tmp/fake-project", prev);
|
|
91
|
+
(0, vitest_1.expect)(delta.added).toEqual(["main"]);
|
|
92
|
+
(0, vitest_1.expect)(delta.removed).toEqual(["logout"]);
|
|
93
|
+
(0, vitest_1.expect)(delta.functionCount).toBe(2);
|
|
94
|
+
});
|
|
95
|
+
(0, vitest_1.it)("RepoWatcher 文件变更防抖回调", async () => {
|
|
96
|
+
const fs = await Promise.resolve().then(() => __importStar(require("fs")));
|
|
97
|
+
const os = await Promise.resolve().then(() => __importStar(require("os")));
|
|
98
|
+
const path = await Promise.resolve().then(() => __importStar(require("path")));
|
|
99
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "pm-watch-"));
|
|
100
|
+
const changed = [];
|
|
101
|
+
const w = new agent_perception_1.RepoWatcher(dir, (f) => changed.push(f), 50);
|
|
102
|
+
w.start();
|
|
103
|
+
// 等待 watcher 就绪后写文件
|
|
104
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
105
|
+
fs.writeFileSync(path.join(dir, "new.ts"), "export function f() {}");
|
|
106
|
+
await new Promise((r) => setTimeout(r, 300));
|
|
107
|
+
w.stop();
|
|
108
|
+
(0, vitest_1.expect)(changed).toContain("new.ts");
|
|
109
|
+
(0, vitest_1.expect)(w.active).toBe(false);
|
|
110
|
+
});
|
|
111
|
+
(0, vitest_1.it)("RepoWatcher 忽略非源文件扩展名", async () => {
|
|
112
|
+
const fs = await Promise.resolve().then(() => __importStar(require("fs")));
|
|
113
|
+
const os = await Promise.resolve().then(() => __importStar(require("os")));
|
|
114
|
+
const path = await Promise.resolve().then(() => __importStar(require("path")));
|
|
115
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "pm-watch2-"));
|
|
116
|
+
const changed = [];
|
|
117
|
+
const w = new agent_perception_1.RepoWatcher(dir, (f) => changed.push(f), 50);
|
|
118
|
+
w.start();
|
|
119
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
120
|
+
fs.writeFileSync(path.join(dir, "notes.txt"), "hello");
|
|
121
|
+
await new Promise((r) => setTimeout(r, 300));
|
|
122
|
+
w.stop();
|
|
123
|
+
(0, vitest_1.expect)(changed).toHaveLength(0);
|
|
124
|
+
});
|
|
125
|
+
});
|