@luckychen1993/pm-mcp 0.1.1

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 (45) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +304 -0
  3. package/dist/acceptance-evaluator.js +330 -0
  4. package/dist/acceptance-model.js +440 -0
  5. package/dist/acceptance-report.js +335 -0
  6. package/dist/acceptance-tools.js +318 -0
  7. package/dist/audit-tools.js +61 -0
  8. package/dist/audit.js +364 -0
  9. package/dist/budget.js +71 -0
  10. package/dist/dashboard.js +241 -0
  11. package/dist/governance-audit.js +126 -0
  12. package/dist/governance-model.js +306 -0
  13. package/dist/governance-tools.js +240 -0
  14. package/dist/health.js +58 -0
  15. package/dist/index-db.js +97 -0
  16. package/dist/index-store.js +381 -0
  17. package/dist/index.js +210 -0
  18. package/dist/init.js +40 -0
  19. package/dist/knowledge-tools.js +204 -0
  20. package/dist/language-adapters.js +450 -0
  21. package/dist/language-dependencies.js +351 -0
  22. package/dist/license.js +261 -0
  23. package/dist/paths.js +51 -0
  24. package/dist/polyglot-ast.js +329 -0
  25. package/dist/portfolio.js +349 -0
  26. package/dist/project-fingerprint.js +83 -0
  27. package/dist/project-tools.js +217 -0
  28. package/dist/quality-store.js +77 -0
  29. package/dist/registry.js +38 -0
  30. package/dist/roadmap.js +96 -0
  31. package/dist/scan.js +271 -0
  32. package/dist/search.js +312 -0
  33. package/dist/security.js +269 -0
  34. package/dist/semantic-evidence-store.js +133 -0
  35. package/dist/semantic-evidence.js +100 -0
  36. package/dist/semantic-graph-algorithms.js +86 -0
  37. package/dist/semantic-graph.js +390 -0
  38. package/dist/semantic-parsers.js +311 -0
  39. package/dist/store.js +185 -0
  40. package/dist/task-tools.js +198 -0
  41. package/dist/tool-base.js +32 -0
  42. package/dist/tools.js +21 -0
  43. package/dist/types.js +208 -0
  44. package/dist/typescript-semantic.js +282 -0
  45. package/package.json +70 -0
@@ -0,0 +1,61 @@
1
+ import { z } from "zod";
2
+ import { auditStructure, snapshotCodebase } from "./audit.js";
3
+ import { foldLines } from "./budget.js";
4
+ import { refreshDerived } from "./dashboard.js";
5
+ import { auditLicense } from "./license.js";
6
+ import { isInitialized, requireInitialized } from "./paths.js";
7
+ import { listRegistry } from "./registry.js";
8
+ import { auditSecurity, listFindings, resolveFinding } from "./security.js";
9
+ import { budgetLines, tool, toolW } from "./tool-base.js";
10
+ export function registerAuditTools(server, root) {
11
+ toolW(server, root, "snapshot_codebase", "给代码结构拍快照(文件数/行数/目录分布/测试数/skip 标记/依赖清单)。之后 audit_structure 会与上次快照 diff。建议每个会话或每个里程碑结束时拍一次。", {}, () => {
12
+ requireInitialized(root);
13
+ const { snapshot, summary } = snapshotCodebase(root);
14
+ refreshDerived(root);
15
+ return [...summary, `下次 audit_structure 将以此为基线(${snapshot.file})。`].join("\n");
16
+ });
17
+ tool(server, "audit_structure", "八项结构对账(定期做):①增长与新增依赖 ②漂移对账(功能↔文件,防幻觉)③债务与重构配额 ④churn 热点 ⑤复杂度预算 ⑥索引覆盖率 ⑦足迹/产出 ⑧测试健康(禁用/蒸发/空测试/背书占比)。", {}, () => {
18
+ requireInitialized(root);
19
+ return auditStructure(root, budgetLines(root));
20
+ });
21
+ toolW(server, root, "audit_security", "安全体检(只扫描本地、不联网,结果写入安全台账):密钥泄露 / 危险模式(eval、SQL 拼接、禁用证书校验等)/ 依赖风险。修复后重扫自动关闭;接受风险必须留理由。", {}, () => {
22
+ requireInitialized(root);
23
+ const report = auditSecurity(root);
24
+ refreshDerived(root);
25
+ return report.text.join("\n");
26
+ });
27
+ tool(server, "list_findings", "查看安全台账(可按状态过滤)。", { status: z.enum(["open", "fixed", "accepted"]).optional() }, (args) => {
28
+ requireInitialized(root);
29
+ const list = listFindings(root, args.status);
30
+ if (list.length === 0)
31
+ return `(无${args.status ?? ""}安全发现。)`;
32
+ const L = list.map((f) => `[${f.status}/${f.severity}] ${f.id} ${f.file}:${f.line} ${f.rule} — ${f.note.slice(0, 60)}`);
33
+ return foldLines([`共 ${list.length} 条:`, ...L], { maxLines: budgetLines(root) });
34
+ });
35
+ toolW(server, root, "resolve_finding", "处置安全发现:修复后标 fixed;带理由接受风险标 accepted(必须填 note——接受是显式选择,要留痕)。", {
36
+ id: z.string().describe("如 SEC-001"),
37
+ status: z.enum(["fixed", "accepted"]),
38
+ note: z.string().optional().describe("accepted 时必填:接受理由"),
39
+ }, (args) => {
40
+ requireInitialized(root);
41
+ const f = resolveFinding(root, args.id, args.status, args.note ?? "");
42
+ refreshDerived(root);
43
+ return `✅ ${f.id} 已置为 ${f.status}${f.note ? `(${f.note})` : ""}。`;
44
+ });
45
+ tool(server, "audit_license", "许可证审计(法律账):依赖许可证清单与 copyleft 冲突、源码中的 GPL 家族许可证头、LICENSE 文件检查、来源登记清单。离线启发式,不替代法务。", {}, () => {
46
+ requireInitialized(root);
47
+ return auditLicense(root, budgetLines(root));
48
+ });
49
+ }
50
+ export function registerRegistryTools(server) {
51
+ tool(server, "list_projects", "列出本机所有被 pm-mcp 管理的项目(全局注册表)。", {}, () => {
52
+ const list = listRegistry();
53
+ if (list.length === 0)
54
+ return "(注册表为空。在任何项目里 init_project 后会自动登记。)";
55
+ const L = list.map((p) => {
56
+ const alive = isInitialized(p.root) ? "" : "(⚠️ .pm 不存在,可能已移动)";
57
+ return `- ${p.name} → ${p.root}${alive}`;
58
+ });
59
+ return foldLines([`共 ${list.length} 个项目:`, ...L], { maxLines: 60 });
60
+ });
61
+ }
package/dist/audit.js ADDED
@@ -0,0 +1,364 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { spawnSync } from "node:child_process";
4
+ import { loadFeatures, loadFileNotes, loadProject, loadRoadmap, loadSessions, loadTasks, saveJson, } from "./store.js";
5
+ import { decisionsDir, pmPath, snapshotsDir } from "./paths.js";
6
+ import { normSep, foldLines } from "./budget.js";
7
+ import { readDirectDeps } from "./scan.js";
8
+ import { aggregates, ensureFresh, getIndex, getMeta } from "./index-store.js";
9
+ import { quotaWarnings, milestoneStats } from "./roadmap.js";
10
+ import { blastRadiusWarning, churnStats, debtAging, footprint } from "./health.js";
11
+ import { now } from "./types.js";
12
+ /* --------------------------------- 快照 ---------------------------------- */
13
+ /** 用途索引覆盖:登记过用途且真实存在的文件数 */
14
+ export function annotatedCount(root, db) {
15
+ const notes = loadFileNotes(root).notes;
16
+ const stmt = db.prepare("SELECT 1 FROM files WHERE rel=?");
17
+ let n = 0;
18
+ for (const key of Object.keys(notes)) {
19
+ if (stmt.get(key))
20
+ n += 1;
21
+ }
22
+ return n;
23
+ }
24
+ export function snapshotCodebase(root) {
25
+ ensureFresh(root);
26
+ const db = getIndex(root);
27
+ const agg = aggregates(db);
28
+ const annotated = annotatedCount(root, db);
29
+ const coverage = agg.indexCoverageBase === 0 ? 0 : Math.round((annotated / agg.indexCoverageBase) * 100);
30
+ const { deps } = readDirectDeps(root);
31
+ const stamp = now().replace(/[-:]/g, "").replace(/\..+$/, "").replace("T", "-");
32
+ const snapName = `snap-${stamp}.json`;
33
+ const snapshot = {
34
+ taken_at: now(),
35
+ file: `.pm/snapshots/${snapName}`,
36
+ total_files: agg.totalFiles,
37
+ total_loc: agg.totalLoc,
38
+ test_files: agg.testFiles,
39
+ skip_markers: agg.skipMarkers,
40
+ deps,
41
+ by_ext: agg.byExt.map((e) => ({ ext: e.ext || "(无扩展名)", files: e.files, loc: e.loc })),
42
+ top_dirs: agg.topDirs,
43
+ largest_files: agg.largestFiles,
44
+ index_coverage_pct: coverage,
45
+ };
46
+ saveJson(pmPath(root, "snapshots", snapName), snapshot);
47
+ return {
48
+ snapshot,
49
+ summary: [
50
+ `快照已保存: ${snapshot.file}`,
51
+ `文件 ${snapshot.total_files} 个,总行数 ${snapshot.total_loc},测试文件 ${snapshot.test_files} 个,skip 标记 ${snapshot.skip_markers} 处`,
52
+ `索引覆盖率 ${coverage}%(${annotated}/${agg.indexCoverageBase} 个源文件有用途注解)`,
53
+ ],
54
+ };
55
+ }
56
+ export function latestSnapshot(root) {
57
+ const dir = snapshotsDir(root);
58
+ if (!fs.existsSync(dir))
59
+ return null;
60
+ const names = fs
61
+ .readdirSync(dir)
62
+ .filter((n) => n.startsWith("snap-") && n.endsWith(".json"))
63
+ .sort();
64
+ if (names.length === 0)
65
+ return null;
66
+ try {
67
+ return JSON.parse(fs.readFileSync(path.join(dir, names[names.length - 1]), "utf8"));
68
+ }
69
+ catch {
70
+ return null;
71
+ }
72
+ }
73
+ /** 漂移检测:功能/任务引用的文件是否真实存在(防幻觉对账) */
74
+ export function detectDrift(root) {
75
+ const out = [];
76
+ const features = loadFeatures(root).features;
77
+ for (const f of features) {
78
+ const missing = (f.status === "implemented" ? f.entry_files : []).filter((p) => !fs.existsSync(path.join(root, p)));
79
+ if (missing.length > 0)
80
+ out.push({ kind: "feature", id: f.id, name: f.name, missing });
81
+ }
82
+ const tasks = loadTasks(root).tasks;
83
+ for (const t of tasks) {
84
+ if (t.status !== "done")
85
+ continue;
86
+ const missing = t.files.filter((p) => !fs.existsSync(path.join(root, p)));
87
+ if (missing.length > 0)
88
+ out.push({ kind: "task", id: t.id, name: t.title, missing });
89
+ }
90
+ return out;
91
+ }
92
+ /** 功能验证状态(仪表盘用):✅ 入口文件齐全 / ⚠️ 漂移 / ○ 无锚点 / 🧪 无测试 */
93
+ export function featureVerifyMark(root, entryFiles, testFiles) {
94
+ if (entryFiles.length === 0)
95
+ return "○ 无锚点";
96
+ const missing = entryFiles.filter((p) => !fs.existsSync(path.join(root, p)));
97
+ if (missing.length > 0)
98
+ return "⚠️ 漂移";
99
+ if (testFiles.filter((p) => fs.existsSync(path.join(root, p))).length === 0)
100
+ return "🧪 无测试";
101
+ return "✅";
102
+ }
103
+ /* ------------------------- 完整性 / git 对账(防钻空) ------------------------- */
104
+ const PM_REQUIRED_FILES = [
105
+ "project.json",
106
+ "roadmap.json",
107
+ "tasks.json",
108
+ "features.json",
109
+ "sessions.json",
110
+ "debuglog.json",
111
+ "file-notes.json",
112
+ "security.json",
113
+ "governance.json",
114
+ ];
115
+ /** .pm 完整性:账本文件被删不能静默回退空表,必须点名 */
116
+ export function pmIntegrity(root) {
117
+ const missing = [];
118
+ for (const f of PM_REQUIRED_FILES) {
119
+ if (!fs.existsSync(pmPath(root, f)))
120
+ missing.push(f);
121
+ }
122
+ if (!fs.existsSync(decisionsDir(root)))
123
+ missing.push("decisions/");
124
+ if (!fs.existsSync(snapshotsDir(root)))
125
+ missing.push("snapshots/");
126
+ return missing;
127
+ }
128
+ /** git 对账:自报变更足迹 vs 真实工作区状态 */
129
+ export function gitAudit(root) {
130
+ const out = { available: false, unaccounted: [], ignoresPm: false, error: null };
131
+ if (!fs.existsSync(path.join(root, ".git")))
132
+ return out;
133
+ out.available = true;
134
+ // .gitignore 是否把 .pm 排除出版本控制
135
+ const gi = path.join(root, ".gitignore");
136
+ if (fs.existsSync(gi)) {
137
+ for (const line of fs.readFileSync(gi, "utf8").split("\n")) {
138
+ const t = line.trim();
139
+ if (t.startsWith("#") || !t)
140
+ continue;
141
+ if (t === ".pm" || t === ".pm/" || t === "/.pm" || t === "/.pm/") {
142
+ out.ignoresPm = true;
143
+ break;
144
+ }
145
+ }
146
+ }
147
+ // -uall:未跟踪目录展开为具体文件(默认只给 "?? src/" 目录缩写,抓不到文件)
148
+ const status = spawnSync("git", ["status", "--porcelain", "-uall"], { cwd: root, encoding: "utf8", timeout: 10_000 });
149
+ if (status.error || status.status !== 0) {
150
+ out.error = status.error?.message ?? `git status 退出码 ${status.status ?? "未知"}: ${(status.stderr ?? "").trim().slice(0, 200)}`;
151
+ return out;
152
+ }
153
+ const changed = new Set();
154
+ for (const line of status.stdout.split("\n")) {
155
+ const rel = normSep(line.slice(3).trim().split(" -> ").pop() ?? "");
156
+ if (!rel)
157
+ continue;
158
+ if (rel.startsWith(".pm/") || rel === "PROJECT.md" || rel === ".gitignore")
159
+ continue;
160
+ changed.add(rel);
161
+ }
162
+ const sessions = loadSessions(root).sessions;
163
+ const lastFiles = new Set(sessions[sessions.length - 1]?.files ?? []);
164
+ out.unaccounted = [...changed].filter((f) => !lastFiles.has(f));
165
+ return out;
166
+ }
167
+ /** 完整结构对账:九项报告 */
168
+ export function auditStructure(root, maxLines = 150) {
169
+ const project = loadProject(root);
170
+ const { features } = loadFeatures(root);
171
+ const { tasks } = loadTasks(root);
172
+ const { milestones } = loadRoadmap(root);
173
+ const { sessions } = loadSessions(root);
174
+ const fresh = ensureFresh(root);
175
+ const db = getIndex(root);
176
+ const agg = aggregates(db);
177
+ const deps = readDirectDeps(root).deps;
178
+ const prev = latestSnapshot(root);
179
+ const L = [];
180
+ const budgets = project.budgets;
181
+ const topLoc = (top) => db.prepare(`SELECT COALESCE(SUM(loc),0) s FROM files WHERE top=?`).get(top).s;
182
+ /* ⓪ .pm 完整性(账本被删不得静默回退空表) */
183
+ const missing = pmIntegrity(root);
184
+ L.push("## ⓪ 账本完整性");
185
+ if (missing.length === 0) {
186
+ L.push("✅ .pm 账本文件齐全。");
187
+ }
188
+ else {
189
+ L.push(`- 🚩 账本文件丢失: ${missing.join(", ")}——数据可能被误删,请从 git 历史恢复(load 层的空表回退只是应急,不是恢复)。`);
190
+ }
191
+ /* ① 增长 */
192
+ L.push("");
193
+ L.push("## ① 增长(对比上次快照)");
194
+ L.push(`新鲜度: ${fresh.used === "watcher" ? "watcher 保鲜(免全量走查)" : "全量走查"}`);
195
+ if (prev) {
196
+ const dFiles = agg.totalFiles - prev.total_files;
197
+ const dLoc = agg.totalLoc - prev.total_loc;
198
+ L.push(`基线快照: ${prev.file}(${prev.taken_at.slice(0, 16).replace("T", " ")})`);
199
+ L.push(`文件 ${prev.total_files} → ${agg.totalFiles}(${dFiles >= 0 ? "+" : ""}${dFiles}),行数 ${prev.total_loc} → ${agg.totalLoc}(${dLoc >= 0 ? "+" : ""}${dLoc})`);
200
+ // 增长热点:历史 top + 当前 top-30 的并集,逐 top SUM 查询
201
+ const prevDirs = new Map(prev.top_dirs.map((d) => [d.dir, d.loc]));
202
+ const unionTops = new Set([...prevDirs.keys(), ...agg.topDirs.map((d) => d.dir)]);
203
+ const growth = [...unionTops]
204
+ .map((dir) => ({ dir, delta: topLoc(dir) - (prevDirs.get(dir) ?? 0) }))
205
+ .filter((g) => g.delta > 0)
206
+ .sort((a, b) => b.delta - a.delta)
207
+ .slice(0, 5);
208
+ for (const g of growth)
209
+ L.push(`- 增长热点: ${g.dir} +${g.delta} 行`);
210
+ const newDeps = deps.filter((d) => !prev.deps.includes(d));
211
+ if (newDeps.length > 0)
212
+ L.push(`- ⚠️ 新增依赖(AI 本次引入,需审查): ${newDeps.join(", ")}`);
213
+ }
214
+ else {
215
+ L.push(`(这是首次对账,无历史快照。当前:文件 ${agg.totalFiles},行数 ${agg.totalLoc}。下次先 snapshot_codebase 建立基线。)`);
216
+ }
217
+ /* 未登记目录(只看真实目录:嵌套文件的首段;根级文件不算目录) */
218
+ const topDirSet = new Set(agg.rootDirs.filter((d) => d !== "." && !d.startsWith(".")));
219
+ const modules = new Set(project.modules);
220
+ const unregistered = [...topDirSet].filter((d) => !modules.has(d) && d !== "node_modules");
221
+ if (unregistered.length > 0) {
222
+ L.push(`- 💡 未登记为模块的顶层目录(update_project 登记后纳入管理): ${unregistered.join(", ")}`);
223
+ }
224
+ /* ② 漂移(防幻觉对账) */
225
+ L.push("");
226
+ L.push("## ② 漂移对账(功能/任务 ↔ 真实文件)");
227
+ const drift = detectDrift(root);
228
+ const noAnchor = features.filter((f) => f.status === "implemented" && f.entry_files.length === 0);
229
+ if (drift.length === 0 && noAnchor.length === 0) {
230
+ L.push("✅ 无漂移:所有 implemented 功能的入口文件与 done 任务的关联文件均真实存在。");
231
+ }
232
+ else {
233
+ for (const d of drift.slice(0, 10)) {
234
+ L.push(`- ⚠️ ${d.kind === "feature" ? "功能" : "任务"} ${d.id} ${d.name} 引用了不存在的文件: ${d.missing.slice(0, 3).join(", ")}`);
235
+ }
236
+ if (drift.length > 10)
237
+ L.push(`…另有 ${drift.length - 10} 项漂移未列出`);
238
+ for (const f of noAnchor.slice(0, 10)) {
239
+ L.push(`- ○ 功能 ${f.id} ${f.name} 无锚点(entry_files 为空,无法对账)——补 register 信息或改 planned/deprecated,别让它逃过对账。`);
240
+ }
241
+ }
242
+ /* ③ 债务与重构占比 */
243
+ L.push("");
244
+ L.push("## ③ 债务账(反挤出)");
245
+ const aging = debtAging(tasks);
246
+ L.push(`未清债务 ${aging.openDebt} 条` + (aging.oldestTask ? `,最老 ${aging.oldestDays} 天(${aging.oldestTask})` : ""));
247
+ const warns = quotaWarnings(project, milestones, tasks);
248
+ if (warns.length > 0) {
249
+ for (const w of warns) {
250
+ L.push(`- ⚠️ 重构被挤出: ${w.milestone}(${w.total} 个任务中重构类仅 ${w.refactorPct}%,低于配额 ${w.quotaPct}%)`);
251
+ }
252
+ }
253
+ else {
254
+ const actives = milestones.filter((m) => m.status === "active");
255
+ if (actives.length > 0) {
256
+ for (const m of actives) {
257
+ const s = milestoneStats(tasks, m.id);
258
+ if (s.total > 0)
259
+ L.push(`- ${m.id} 重构类占比 ${s.refactorPct}%(配额 ${budgets.refactorQuotaPct}%)`);
260
+ }
261
+ }
262
+ else {
263
+ L.push("- (无活跃里程碑)");
264
+ }
265
+ }
266
+ /* ④ churn */
267
+ L.push("");
268
+ L.push("## ④ churn(变更率)");
269
+ const churn = churnStats(sessions);
270
+ if (churn.sessionCount === 0) {
271
+ L.push("(暂无会话记录,用 log_session 记录变更足迹后这里会出现热点分析)");
272
+ }
273
+ else if (churn.hotspots.length === 0) {
274
+ L.push(`✅ ${churn.sessionCount} 次会话,无被反复波及(≥3 次)的热点文件。`);
275
+ }
276
+ else {
277
+ L.push(`⚠️ 热点文件(被多次会话波及 = 散弹式修改嫌疑,建议立 refactor 任务):`);
278
+ for (const h of churn.hotspots.slice(0, 5))
279
+ L.push(`- ${h.file}(${h.count} 次)`);
280
+ const bw = blastRadiusWarning(sessions, budgets);
281
+ if (bw)
282
+ L.push(`- ${bw}`);
283
+ }
284
+ /* ⑤ 复杂度预算 */
285
+ L.push("");
286
+ L.push("## ⑤ 复杂度预算");
287
+ const oversized = agg.largestFiles.filter((f) => f.loc > budgets.maxFileLoc);
288
+ if (oversized.length === 0 && agg.oversizeFiles.length === 0) {
289
+ L.push(`✅ 无超过 ${budgets.maxFileLoc} 行的文件。`);
290
+ }
291
+ else {
292
+ for (const f of oversized)
293
+ L.push(`- ⚠️ ${f.path}: ${f.loc} 行(预算 ${budgets.maxFileLoc})`);
294
+ for (const rel of agg.oversizeFiles.slice(0, 10)) {
295
+ L.push(`- 🚩 ${rel}: 超过扫描上限(>2MB),行数未计——巨文件同样逃不过点名,请拆分。`);
296
+ }
297
+ }
298
+ // 预算偏离提示:调大预算能让告警消失,这本身必须可见(防"预算自肥")
299
+ const defaults = { maxFileLoc: 500, refactorQuotaPct: 20, sessionBlastRadius: 15, outputBudgetLines: 150 };
300
+ const drifted = ["maxFileLoc", "refactorQuotaPct", "sessionBlastRadius", "outputBudgetLines"].filter((k) => budgets[k] !== defaults[k]);
301
+ if (drifted.length > 0) {
302
+ L.push(`- 💡 预算已偏离默认值: ${drifted.map((k) => `${k}=${budgets[k]}(默认 ${defaults[k]})`).join(",")}——确认这是显式决定而非为了消除告警。`);
303
+ }
304
+ /* ⑥ 索引覆盖 */
305
+ L.push("");
306
+ L.push("## ⑥ 文件索引(token 经济)");
307
+ const srcCount = agg.indexCoverageBase;
308
+ const annotated = annotatedCount(root, db);
309
+ const pct = srcCount === 0 ? 0 : Math.round((annotated / srcCount) * 100);
310
+ L.push(`用途索引覆盖率 ${pct}%(${annotated}/${srcCount})。覆盖越高,后续会话越不需要打开文件读代码。`);
311
+ /* ⑦ 足迹/产出 */
312
+ L.push("");
313
+ L.push("## ⑦ 足迹/产出(空转代理指标)");
314
+ const fp = footprint(sessions, tasks);
315
+ L.push(`近 30 天完成任务 ${fp.tasksCompleted30d} 个;平均每次会话波及 ${fp.avgFilesPerSession} 个文件。`);
316
+ for (const s of fp.recentSessions.slice(0, 5)) {
317
+ L.push(`- ${s.date} [${s.author}] ${s.files} 文件 — ${s.summary}`);
318
+ }
319
+ /* ⑧ 测试健康 */
320
+ L.push("");
321
+ L.push("## ⑧ 测试健康(反投机)");
322
+ L.push(`测试文件 ${agg.testFiles} 个` + (prev ? `(上次 ${prev.test_files},${agg.testFiles - prev.test_files >= 0 ? "+" : ""}${agg.testFiles - prev.test_files})` : ""));
323
+ if (prev && agg.testFiles < prev.test_files) {
324
+ L.push(`- 🚩 测试蒸发:测试文件数下降(${prev.test_files} → ${agg.testFiles})。删除测试来"修复"构建是严重信号。`);
325
+ }
326
+ L.push(`skip/only 标记 ${agg.skipMarkers} 处` + (prev ? `(上次 ${prev.skip_markers},${agg.skipMarkers - prev.skip_markers >= 0 ? "+" : ""}${agg.skipMarkers - prev.skip_markers})` : ""));
327
+ if (prev && agg.skipMarkers > prev.skip_markers) {
328
+ L.push(`- 🚩 新增 ${agg.skipMarkers - prev.skip_markers} 处禁用/独占测试标记(.skip/.only/xfail 等)——让测试变绿的捷径。`);
329
+ }
330
+ if (agg.trivialTests.length > 0) {
331
+ L.push(`- ⚠️ 空测试嫌疑: ${agg.trivialTests.slice(0, 5).join(", ")}`);
332
+ }
333
+ const implemented = features.filter((f) => f.status === "implemented");
334
+ const withTests = implemented.filter((f) => f.test_files.some((p) => fs.existsSync(path.join(root, p))));
335
+ const ratio = implemented.length === 0 ? 0 : Math.round((withTests.length / implemented.length) * 100);
336
+ L.push(`已验证功能占比 ${ratio}%(${withTests.length}/${implemented.length} 个 implemented 功能有真实存在的测试文件;代理指标,非行覆盖率)`);
337
+ /* ⑨ git 对账(变更足迹真实性) */
338
+ const git = gitAudit(root);
339
+ L.push("");
340
+ L.push("## ⑨ git 对账(自报足迹 vs 真实工作区)");
341
+ if (git.available) {
342
+ if (git.error) {
343
+ L.push(`- 🚩 git status 对账失败(fail-closed): ${git.error}`);
344
+ }
345
+ if (git.ignoresPm) {
346
+ L.push("- 🚩 .gitignore 把 .pm/ 排除出版本控制——团队与多 AI 共享失效,状态只活在你本机。请移除该规则并提交 .pm/。");
347
+ }
348
+ if (git.unaccounted.length > 0) {
349
+ L.push(`- 🚩 ${git.unaccounted.length} 个文件有 git 变更但未在最近一次会话入账(log_session 漏报会被这里抓到): ${git.unaccounted.slice(0, 5).join(", ")}`);
350
+ }
351
+ if (!git.error && !git.ignoresPm && git.unaccounted.length === 0) {
352
+ L.push("✅ 工作区变更与最近会话足迹一致。");
353
+ }
354
+ }
355
+ else {
356
+ L.push("- ⚠️ 当前项目根不是 Git 工作区,无法核验 log_session 足迹或 .pm/ 是否会被版本控制;Git 对账未启用。");
357
+ }
358
+ const deepSkipped = Number(getMeta(db, "skippedDeep") ?? 0);
359
+ if (deepSkipped > 0) {
360
+ L.push("");
361
+ L.push(`⚠️ ${deepSkipped} 个路径超过 64 层深度未扫描(极罕见,请检查目录结构)`);
362
+ }
363
+ return foldLines(L, { maxLines: maxLines, hint: "各报告均可通过参数过滤" });
364
+ }
package/dist/budget.js ADDED
@@ -0,0 +1,71 @@
1
+ import { relative as pathRelative } from "node:path";
2
+ /** 限制字符串单行长度,超长截断(防止一行巨长文件吃 token) */
3
+ export function capLine(line, maxChars = 300) {
4
+ if (line.length <= maxChars)
5
+ return line;
6
+ return line.slice(0, maxChars) + "…";
7
+ }
8
+ /**
9
+ * 把行数组折叠到预算内。
10
+ * 保留前 keep + 后 tail 行,中间折叠;行数少时原样返回。
11
+ */
12
+ export function foldLines(lines, opts) {
13
+ const { maxLines, hint } = opts;
14
+ const capped = lines.map((l) => capLine(l));
15
+ if (capped.length <= maxLines)
16
+ return capped.join("\n");
17
+ const keep = Math.max(1, Math.floor(maxLines * 0.8));
18
+ const tail = Math.max(0, maxLines - keep - 1);
19
+ const hidden = capped.length - keep - tail;
20
+ const parts = capped.slice(0, keep);
21
+ parts.push(`…(另有 ${hidden} 项已折叠${hint ? "," + hint : ""},请用过滤参数缩小范围)`);
22
+ if (tail > 0)
23
+ parts.push(...capped.slice(-tail));
24
+ return parts.join("\n");
25
+ }
26
+ /** 对整段文本按行折叠 */
27
+ export function foldText(text, maxLines, hint) {
28
+ return foldLines(text.split("\n"), { maxLines, hint });
29
+ }
30
+ /** 简易 glob → RegExp:支持 * ** ?(路径分隔符统一为 / 后匹配) */
31
+ export function globToRegExp(pattern) {
32
+ const normalized = pattern.replace(/\\/g, "/").replace(/^\.\//, "");
33
+ let re = "";
34
+ let i = 0;
35
+ while (i < normalized.length) {
36
+ const ch = normalized[i];
37
+ if (ch === "*") {
38
+ if (normalized[i + 1] === "*") {
39
+ // ** 跨目录
40
+ re += ".*";
41
+ i += 2;
42
+ if (normalized[i] === "/")
43
+ i += 1;
44
+ }
45
+ else {
46
+ re += "[^/]*";
47
+ i += 1;
48
+ }
49
+ }
50
+ else if (ch === "?") {
51
+ re += "[^/]";
52
+ i += 1;
53
+ }
54
+ else if ("/.$^+()[]{}|".includes(ch)) {
55
+ re += "\\" + ch;
56
+ i += 1;
57
+ }
58
+ else {
59
+ re += ch;
60
+ i += 1;
61
+ }
62
+ }
63
+ return new RegExp("^" + re + "$");
64
+ }
65
+ /** 规范化相对路径分隔符(Windows 反斜杠 → /) */
66
+ export function normSep(p) {
67
+ return p.replace(/\\/g, "/");
68
+ }
69
+ export function toRel(root, abs) {
70
+ return normSep(pathRelative(root, abs));
71
+ }