@luckychen1993/pm-mcp 0.3.0 → 0.5.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/README.md CHANGED
@@ -33,7 +33,7 @@ node dist/cli.js doctor --root .
33
33
 
34
34
  ## 已有管理流程
35
35
 
36
- 用户仍然直接说“继续这个任务”“保存进度”“完成后帮我验收”。49 个工具全部保留,由 AI 组合调用,无需用户记工具名。
36
+ 用户仍然直接说“继续这个任务”“保存进度”“完成后帮我验收”。53 个工具全部保留,由 AI 组合调用,无需用户记工具名。
37
37
 
38
38
  - `update_task` 可同时更新进度、保存 `checkpoint:{note,next_step}` 并记录会话;完成时给 `record_session:true`,返回会话编号后不再另调 `log_session`。省略该参数仍兼容旧的手动记会话流程。同一幂等键重放不会重复落账;跨多个文件的异常仍遵守原有“不确定状态需核对”规则,不宣称事务原子性。
39
39
  - `feature/fix` 转 `done` 必须关联实际文件,并先通过 `run_quality_matrix` 执行相应测试。系统自动选择最新报告,核对执行成功、测试前后与当前源码摘要、测试单元目录和项目证据策略。`verification_run` 可明确指向最新报告,文字 `verification` 只作补充说明。报告存于 `.pm/quality-runs/`,任务保存报告与源码 SHA-256。目录范围匹配不等于代码覆盖率证明;第一方报告也不构成防篡改认证。
@@ -202,6 +202,17 @@ node dist/cli.js probe --expect-file 上一轮probe报告.json -- node 其他服
202
202
  - `--expect-file` 接受上一轮 probe 报告、字符串数组或 `{tools:[...]}`,便于跨项目基线比对。
203
203
  - 探测进程与服务器子进程在结束时都会被清理,不会残留。
204
204
 
205
+ ### pm-mcp ui:本地只读 Web 仪表盘
206
+
207
+ ```bash
208
+ node dist/cli.js ui --root . # 端口自动分配
209
+ node dist/cli.js ui --root . --port 7781
210
+ ```
211
+
212
+ - 仅绑定 `127.0.0.1`、纯 GET 只读、未纳管项目拒绝启动;概览(任务/功能/安全/会话/索引/质量门禁)+ 里程碑进度 + 最近任务。
213
+ - `/api/state` 输出同源 JSON,`/healthz` 健康检查;所有插值 HTML 转义,CSP 禁止脚本。
214
+ - Ctrl+C 退出。不新增 MCP 工具,不改变工具目录契约。
215
+
205
216
  ## 推荐工作流(写给 AI 的规矩)
206
217
 
207
218
  把下面这段放进目标项目的 `AGENTS.md`,任何 AI 会话自动遵守:
@@ -235,7 +246,7 @@ snapshot_codebase + audit_structure 对账;audit_security 安全体检;audit
235
246
 
236
247
  也可以直接用内置 prompts:`start-session` / `end-session` / `onboard` / `architecture-review` / `acceptance-review`;其中 `onboard` 会引导客户端读取状态并生成新人/AI 入职简报。
237
248
 
238
- ## 工具清单(49 个)
249
+ ## 工具清单(53 个)
239
250
 
240
251
  | 域 | 工具 | 说明 |
241
252
  |---|---|---|
@@ -248,13 +259,13 @@ snapshot_codebase + audit_structure 对账;audit_security 安全体检;audit
248
259
  | 调试 | `log_debug` | 调试知识账:症状/根因/修法/验证 |
249
260
  | 求证 | `search_code` / `search_knowledge` / `annotate_file` | 代码检索(file:line)、七类知识源检索(含模块/接口/仓库治理)、文件用途索引 |
250
261
  | 审计 | `snapshot_codebase` / `audit_structure` | 结构快照;完整性/增长/漂移/债务/churn/复杂度/索引/足迹/测试/Git 十节对账 |
251
- | 安全 | `audit_security` / `list_findings` / `resolve_finding` | 密钥+危险模式+新增/通配依赖提示;扫描命中原文不落盘,处置 note 拒绝内置密钥形态 |
262
+ | 安全 | `audit_security` / `list_findings` / `resolve_finding` / `search_code_public` | 密钥+危险模式+新增/通配依赖提示;扫描命中原文不落盘,处置 note 拒绝内置密钥形态;公开代码反查(联网默认关,confirm 门控,字面命中≠侵权结论) |
252
263
  | 法律 | `audit_license` | 依赖许可证清单、copyleft 冲突、GPL 头检测、LICENSE 检查、来源登记 |
253
264
  | 可观测 | `get_usage_log` / `get_runtime_log` | 使用日志(工具调用统计、错误率、耗时、输出与折叠省下的 token 估算——验证省不省 token)与运行日志(server/watcher/工具报错);只记计量不记参数内容 |
254
265
  | 注册表 | `list_projects` | 本机所有被管理项目 |
255
266
  | 治理模型 | `init_governance` / `get_governance` / `upsert_module` / `upsert_interface` / `upsert_repository` / `set_governance_policies` | 结构化模块根、owner、语言、公开接口、允许/禁止依赖、跨仓版本约束与强制策略 |
256
- | 语义治理 | `discover_languages` / `audit_governance` / `impact_analysis` / `list_semantic_evidence` / `save_semantic_evidence` / `replace_semantic_evidence_for_file` | 编译器/Tree-sitter AST 关系、hash-bound 原生分析器/运行时证据、循环/越界/unresolved/覆盖率与变更反向闭包 |
257
- | 质量与组合 | `plan_quality_matrix` / `run_quality_matrix` / `get_portfolio` | plan 与真实执行严格分离;shell=false;跨仓阶段/债务/安全/版本/cycle 聚合 |
267
+ | 语义治理 | `discover_languages` / `audit_governance` / `dependency_graph` / `impact_analysis` / `list_semantic_evidence` / `save_semantic_evidence` / `replace_semantic_evidence_for_file` | 编译器/Tree-sitter AST 关系、hash-bound 原生分析器/运行时证据、模块与文件级循环、依赖邻域/枢纽/unresolved/覆盖率与变更反向闭包 |
268
+ | 质量与组合 | `plan_quality_matrix` / `run_quality_matrix` / `get_portfolio` / `find_duplicates` / `audit_osv` | plan 与真实执行严格分离;shell=false;跨仓阶段/债务/安全/版本/cycle 聚合;行指纹聚类跨文件重复块;OSV 联网漏洞比对(默认关,须显式 confirm=true) |
258
269
  | 标准化验收 | `list_acceptance_baselines` / `get_acceptance_baseline` / `save_acceptance_baseline_draft` / `approve_acceptance_baseline` / `evaluate_acceptance` | ISO/SQuaRE 对齐的版本化质量基线、冻结证据指针、需求—风险—测试追踪、机器判定报告与 SHA-256 manifest |
259
270
 
260
271
  Resources:`pm://dashboard`、`pm://roadmap`、`pm://tasks`、`pm://changelog`、`pm://architecture`、`pm://portfolio`、`pm://acceptance`。
@@ -8,6 +8,9 @@ import { listRegistry } from "./registry.js";
8
8
  import { auditSecurity, listFindings, prepareSecurityAudit, resolveFinding } from "./security.js";
9
9
  import { budgetLines, toolR, toolW } from "./tool-base.js";
10
10
  import { walkRefresh } from "./index-store.js";
11
+ import { renderDuplicates } from "./duplicates.js";
12
+ import { renderOsv, auditOsv } from "./osv.js";
13
+ import { renderPublicSearch, searchPublicCode } from "./public-search.js";
11
14
  export function registerAuditTools(server, root) {
12
15
  toolW(server, root, "snapshot_codebase", "给代码结构拍快照(文件数/行数/目录分布/测试数/skip 标记/依赖清单)。之后 audit_structure 会与上次快照 diff。建议每个会话或每个里程碑结束时拍一次。", {}, () => {
13
16
  requireInitialized(root);
@@ -19,6 +22,33 @@ export function registerAuditTools(server, root) {
19
22
  requireInitialized(root);
20
23
  return auditStructure(root, budgetLines(root), true);
21
24
  });
25
+ toolR(server, root, "find_duplicates", "重复代码检测:注释剥离/字符串占位/空白归一化的行指纹滑动窗口聚类,跨文件重复块按长度降序列出。", {
26
+ min_lines: z.number().int().min(4).max(20).optional().describe("最小重复行数(有效代码行),默认 6"),
27
+ limit: z.number().int().min(1).max(100).optional().describe("最多列出的重复组数,默认 20"),
28
+ }, (args) => {
29
+ requireInitialized(root);
30
+ return renderDuplicates(root, args ?? {}, budgetLines(root));
31
+ });
32
+ toolR(server, root, "audit_osv", "OSV.dev 已知漏洞查询(联网!默认关):把 manifest 声明的依赖(仅包名/版本/生态)批量发给 api.osv.dev 比对;必须显式 confirm=true 才发起网络请求。", {
33
+ confirm: z.literal(true).describe("显式确认联网:本工具会把依赖名与版本发送到 api.osv.dev"),
34
+ timeout_ms: z.number().int().min(5000).max(120000).optional().describe("每批请求超时毫秒数,默认 30000"),
35
+ }, async (args) => {
36
+ requireInitialized(root);
37
+ const scan = await auditOsv(root, { timeoutMs: args.timeout_ms });
38
+ return renderOsv(scan, budgetLines(root));
39
+ });
40
+ toolR(server, root, "search_code_public", "公开代码反查(联网!默认关):把一段本仓源码行作为精确短语发给 GitHub Code Search,列出包含相同代码的公开仓库;片段在参数里对用户全程可见,必须显式 confirm=true。", {
41
+ confirm: z.literal(true).describe("显式确认联网:本工具会把一段真实代码行发送到 GitHub"),
42
+ snippet: z.string().trim().min(8).max(400).optional().describe("要反查的单行代码片段(与 file 二选一)"),
43
+ file: z.string().trim().min(1).max(512).optional().describe("从该文件取一行作为片段(相对项目根;缺省取最长行)"),
44
+ line: z.number().int().min(1).optional().describe("指定取第几行(1-based)"),
45
+ max_results: z.number().int().min(1).max(30).optional().describe("列出的命中数上限,默认 10"),
46
+ timeout_ms: z.number().int().min(5000).max(120000).optional().describe("请求超时毫秒数,默认 30000"),
47
+ }, async (args) => {
48
+ requireInitialized(root);
49
+ const result = await searchPublicCode(root, { snippet: args.snippet, file: args.file, line: args.line, maxResults: args.max_results, timeoutMs: args.timeout_ms });
50
+ return renderPublicSearch(result, budgetLines(root));
51
+ });
22
52
  toolW(server, root, "audit_security", "安全体检(只扫描本地、不联网,结果写入安全台账):密钥泄露 / 危险模式(eval、SQL 拼接、禁用证书校验等)/ 依赖风险。修复后重扫自动关闭;接受风险必须留理由。", {}, (_args, prepared) => {
23
53
  requireInitialized(root);
24
54
  const report = auditSecurity(root, prepared);
package/dist/cli.js CHANGED
@@ -25,6 +25,28 @@ else if (process.argv[2] === "doctor") {
25
25
  process.exitCode = 1;
26
26
  }
27
27
  }
28
+ else if (process.argv[2] === "ui") {
29
+ try {
30
+ const { startUiServer } = await import("./ui.js");
31
+ const args = process.argv.slice(3);
32
+ const value = (flag) => {
33
+ const index = args.indexOf(flag);
34
+ return index >= 0 ? args[index + 1] : undefined;
35
+ };
36
+ const root = value("--root") ?? process.cwd();
37
+ const portArg = value("--port");
38
+ const { url, close } = await startUiServer(root, { port: portArg ? Number(portArg) : 0 });
39
+ console.log(`pm-mcp ui 已启动(只读、仅绑定 127.0.0.1):${url} 按 Ctrl+C 退出`);
40
+ const shutdown = () => { void close().finally(() => process.exit(0)); };
41
+ process.on("SIGINT", shutdown);
42
+ process.on("SIGTERM", shutdown);
43
+ await new Promise(() => { });
44
+ }
45
+ catch (error) {
46
+ console.error(`[pm-mcp ui] ERROR: ${error.message}`);
47
+ process.exitCode = 1;
48
+ }
49
+ }
28
50
  else if (process.argv[2] === "probe") {
29
51
  try {
30
52
  const { PROBE_USAGE, parseProbeArgs, runProbe } = await import("./probe.js");
@@ -0,0 +1,228 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { foldLines } from "./budget.js";
4
+ import { ensureFresh, getIndex } from "./index-store.js";
5
+ /**
6
+ * 重复代码检测(零依赖、确定性):行指纹滑动窗口聚类。
7
+ *
8
+ * 归一化规则:剥离注释(按语言族:// 与 # 、块注释 /* *\/)、字符串字面量内容替换为占位符、
9
+ * 空白折叠——因此注释/空白/字面量文本的差异不算差异,标识符与结构差异才算。
10
+ * 聚类:对每个哈希相同且出现在 ≥2 个文件的窗口做连续对齐合并(成对扩展成长块),
11
+ * 长度 ≥ minLines(有效代码行)的块按长度降序输出。仅跨文件重复计入 v1。
12
+ */
13
+ export const DUPLICATE_SOURCE_EXTENSIONS = new Set([
14
+ ".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".mts", ".cts",
15
+ ".py", ".go", ".rs", ".java", ".kt", ".kts", ".cs", ".c", ".h", ".cpp", ".hpp",
16
+ ".php", ".rb", ".swift", ".scala", ".vue", ".svelte",
17
+ ]);
18
+ const MAX_FILE_BYTES = 400 * 1024;
19
+ const MAX_RUNS = 5000;
20
+ const MAX_LOCATIONS_PER_HASH = 8;
21
+ const SLASH_STYLE = { lineMarkers: ["//"], blockOpen: "/*", blockClose: "*/" };
22
+ const HASH_STYLE = { lineMarkers: ["#"], blockOpen: null, blockClose: null };
23
+ function styleFor(ext) {
24
+ return ext === ".py" || ext === ".rb" ? HASH_STYLE : SLASH_STYLE;
25
+ }
26
+ /** 单遍扫描:跨行维护块注释与字符串状态;字符串内容丢弃、留一个占位符。 */
27
+ function normalizeLines(content, style) {
28
+ const out = [];
29
+ let inBlock = false;
30
+ let inString = null;
31
+ let buffer = "";
32
+ let lineNo = 1;
33
+ const flush = () => {
34
+ const norm = buffer.replace(/\s+/g, " ").trim();
35
+ if (norm.length > 0)
36
+ out.push({ norm, line: lineNo });
37
+ buffer = "";
38
+ };
39
+ for (let i = 0; i < content.length; i++) {
40
+ const ch = content[i];
41
+ if (ch === "\n") {
42
+ flush();
43
+ lineNo += 1;
44
+ continue;
45
+ }
46
+ if (inBlock) {
47
+ if (style.blockClose && content.startsWith(style.blockClose, i)) {
48
+ inBlock = false;
49
+ i += style.blockClose.length - 1;
50
+ }
51
+ continue;
52
+ }
53
+ if (inString) {
54
+ if (ch === "\\") {
55
+ i += 1;
56
+ continue;
57
+ }
58
+ if (ch === inString)
59
+ inString = null;
60
+ continue;
61
+ }
62
+ if (style.blockOpen && content.startsWith(style.blockOpen, i)) {
63
+ inBlock = true;
64
+ i += style.blockOpen.length - 1;
65
+ continue;
66
+ }
67
+ if (style.lineMarkers.some((marker) => content.startsWith(marker, i))) {
68
+ while (i < content.length && content[i] !== "\n")
69
+ i += 1;
70
+ i -= 1;
71
+ continue;
72
+ }
73
+ if (ch === '"' || ch === "'" || ch === "`") {
74
+ inString = ch;
75
+ buffer += "s";
76
+ continue;
77
+ }
78
+ buffer += ch;
79
+ }
80
+ flush();
81
+ return out;
82
+ }
83
+ /** 双 32 位 djb2 变体拼成 64-bit 指纹;跨平台确定性,够抵御行级指纹碰撞。 */
84
+ function hashLines(joined) {
85
+ let h1 = 5381;
86
+ let h2 = 52711;
87
+ for (let i = 0; i < joined.length; i++) {
88
+ const code = joined.charCodeAt(i);
89
+ h1 = ((h1 * 33) ^ code) >>> 0;
90
+ h2 = (h2 * 31 + code) >>> 0;
91
+ }
92
+ return h1.toString(16).padStart(8, "0") + h2.toString(16).padStart(8, "0");
93
+ }
94
+ const clamp = (value, min, max, fallback) => {
95
+ const n = Math.trunc(value ?? fallback);
96
+ return Math.min(max, Math.max(min, Number.isFinite(n) ? n : fallback));
97
+ };
98
+ export function findDuplicateBlocks(root, options = {}) {
99
+ const started = Date.now();
100
+ const minLines = clamp(options.minLines, 4, 20, 6);
101
+ const limit = clamp(options.limit, 1, 100, 20);
102
+ ensureFresh(root);
103
+ const db = getIndex(root);
104
+ const rows = db.prepare("SELECT rel, size, oversize FROM files WHERE skip = 0").all();
105
+ let skippedOversize = rows.filter((row) => row.oversize !== 0).length;
106
+ const normalized = new Map();
107
+ let scannedFiles = 0;
108
+ for (const row of rows) {
109
+ const ext = path.extname(row.rel).toLowerCase();
110
+ if (!DUPLICATE_SOURCE_EXTENSIONS.has(ext))
111
+ continue;
112
+ if (row.oversize !== 0 || row.size > MAX_FILE_BYTES) {
113
+ skippedOversize += 1;
114
+ continue;
115
+ }
116
+ let content;
117
+ try {
118
+ content = fs.readFileSync(path.join(root, row.rel), "utf8");
119
+ }
120
+ catch {
121
+ continue;
122
+ }
123
+ normalized.set(row.rel, normalizeLines(content, styleFor(ext)));
124
+ scannedFiles += 1;
125
+ }
126
+ // 窗口指纹 → 位置(按文件名规范排序,保证成对遍历确定性)
127
+ const windows = new Map();
128
+ for (const [rel, lines] of normalized) {
129
+ if (lines.length < minLines)
130
+ continue;
131
+ for (let i = 0; i + minLines <= lines.length; i++) {
132
+ const slice = lines.slice(i, i + minLines);
133
+ const key = hashLines(slice.map((item) => item.norm).join("\n"));
134
+ const bucket = windows.get(key);
135
+ if (bucket)
136
+ bucket.push({ file: rel, index: i, line: slice[0].line });
137
+ else
138
+ windows.set(key, [{ file: rel, index: i, line: slice[0].line }]);
139
+ }
140
+ }
141
+ // 先按哈希收集全部成对匹配,再按索引排序做连续对齐合并——顺序处理才能拼出完整长块
142
+ const pairMatches = new Map();
143
+ let truncated = false;
144
+ for (const locations of windows.values()) {
145
+ if (locations.length < 2)
146
+ continue;
147
+ const sorted = [...locations].sort((x, y) => x.file.localeCompare(y.file) || x.index - y.index);
148
+ if (sorted.length > MAX_LOCATIONS_PER_HASH)
149
+ truncated = true;
150
+ const capped = sorted.slice(0, MAX_LOCATIONS_PER_HASH);
151
+ for (let i = 0; i < capped.length; i++) {
152
+ for (let j = i + 1; j < capped.length; j++) {
153
+ const p = capped[i], q = capped[j];
154
+ if (p.file === q.file)
155
+ continue; // v1 只报跨文件重复
156
+ const pairKey = p.file < q.file ? `${p.file}\u0000${q.file}` : `${q.file}\u0000${p.file}`;
157
+ const forward = p.file < q.file;
158
+ const match = forward
159
+ ? { a: p.file, b: q.file, indexA: p.index, indexB: q.index }
160
+ : { a: q.file, b: p.file, indexA: q.index, indexB: p.index };
161
+ const list = pairMatches.get(pairKey);
162
+ if (list)
163
+ list.push(match);
164
+ else
165
+ pairMatches.set(pairKey, [match]);
166
+ }
167
+ }
168
+ }
169
+ const pairRuns = new Map();
170
+ const runs = [];
171
+ for (const [pairKey, matches] of pairMatches) {
172
+ matches.sort((x, y) => x.indexA - y.indexA || x.indexB - y.indexB);
173
+ for (const match of matches) {
174
+ const list = pairRuns.get(pairKey);
175
+ const previous = list?.[list.length - 1];
176
+ if (previous && previous.endA === match.indexA - 1 && previous.endB === match.indexB - 1) {
177
+ previous.endA = match.indexA;
178
+ previous.endB = match.indexB;
179
+ previous.len += 1;
180
+ continue;
181
+ }
182
+ if (runs.length >= MAX_RUNS) {
183
+ truncated = true;
184
+ break;
185
+ }
186
+ const run = { a: match.a, b: match.b, startA: match.indexA, startB: match.indexB, endA: match.indexA, endB: match.indexB, len: 1 };
187
+ if (list)
188
+ list.push(run);
189
+ else
190
+ pairRuns.set(pairKey, [run]);
191
+ runs.push(run);
192
+ }
193
+ if (truncated)
194
+ break;
195
+ }
196
+ const linesOf = (rel) => normalized.get(rel) ?? [];
197
+ const toRange = (file, startIndex, len) => {
198
+ const lines = linesOf(file);
199
+ const first = lines[startIndex]?.line ?? startIndex + 1;
200
+ const last = lines[Math.min(startIndex + len - 1, lines.length - 1)]?.line ?? first;
201
+ return { file, start: first, end: Math.max(first, last) };
202
+ };
203
+ const groups = runs
204
+ .sort((x, y) => y.len - x.len || x.a.localeCompare(y.a) || x.b.localeCompare(y.b) || x.startA - y.startA)
205
+ .slice(0, limit)
206
+ .map((run) => ({
207
+ lines: run.len + minLines - 1,
208
+ members: [toRange(run.a, run.startA, run.len + minLines - 1), toRange(run.b, run.startB, run.len + minLines - 1)],
209
+ }));
210
+ return { scannedFiles, skippedOversize, threshold: minLines, groups, truncated, durationMs: Date.now() - started };
211
+ }
212
+ export function renderDuplicates(root, options = {}, maxLines = 150) {
213
+ const scan = findDuplicateBlocks(root, options);
214
+ const lines = [
215
+ "## 重复代码",
216
+ `- 扫描: ${scan.scannedFiles} 个源文件(超限跳过 ${scan.skippedOversize})· 阈值 ≥${scan.threshold} 有效代码行 · 跨文件 · 耗时 ${scan.durationMs}ms`,
217
+ ];
218
+ if (scan.groups.length === 0)
219
+ lines.push("- ✅ 未发现跨文件重复块");
220
+ else {
221
+ lines.push(`- 共列出 ${scan.groups.length} 组重复(按长度降序${scan.truncated ? ",已达运行上限截断" : ""}):`);
222
+ scan.groups.forEach((group, index) => {
223
+ const members = group.members.map((m) => `${m.file}:${m.start}-${m.end}`).join(" ↔ ");
224
+ lines.push(`- 组${index + 1}(${group.lines} 行)${members}`);
225
+ });
226
+ }
227
+ return foldLines(lines, { maxLines, hint: "调高 min_lines 只看大块克隆;检测不改变任何台账状态" });
228
+ }
@@ -5,6 +5,7 @@ import { loadGovernance } from "./governance-model.js";
5
5
  import { assessQualityCoverage, createQualityPlan, discoverProjectUnits } from "./language-adapters.js";
6
6
  import { buildSemanticGraph, moduleForFile } from "./semantic-graph.js";
7
7
  import { loadAllSemanticEvidence } from "./semantic-evidence-store.js";
8
+ import { relPath } from "./semantic-parsers.js";
8
9
  function languageIssues(governance, graph) {
9
10
  const issues = [];
10
11
  for (const module of governance.modules) {
@@ -64,6 +65,13 @@ function graphIssues(graph) {
64
65
  for (const cycle of graph.cycles) {
65
66
  issues.push({ severity: "error", code: "module-cycle", message: `模块依赖循环: ${cycle.join(" -> ")} -> ${cycle[0]}`, evidence: cycle });
66
67
  }
68
+ // 文件级循环通常是 barrel/重导出或真实的循环依赖;比模块环轻,作警告可见。
69
+ for (const cycle of graph.fileCycles.slice(0, 10)) {
70
+ issues.push({ severity: "warning", code: "file-cycle", message: `文件级依赖循环: ${cycle.join(" -> ")} -> ${cycle[0]}`, evidence: cycle });
71
+ }
72
+ if (graph.fileCycles.length > 10) {
73
+ issues.push({ severity: "warning", code: "file-cycle-truncated", message: `文件级依赖循环共 ${graph.fileCycles.length} 个,仅列出前 10 个`, evidence: [] });
74
+ }
67
75
  return issues;
68
76
  }
69
77
  function render(governance, graph, units, qualityCoverage, issues, maxLines) {
@@ -73,7 +81,7 @@ function render(governance, graph, units, qualityCoverage, issues, maxLines) {
73
81
  "## 跨文件/模块/语言治理审计",
74
82
  `${errors.length === 0 ? "✅" : "🚩"} modules=${governance.modules.length} · interfaces=${governance.interfaces.length} · repositories=${governance.repositories.length} · errors=${errors.length} · warnings=${warnings.length}`,
75
83
  `语义覆盖: source ${graph.coverage.sourceCoveragePct}%(${graph.coverage.parsedFiles}/${graph.coverage.sourceCandidateFiles})· assurance ${graph.coverage.semanticAssurancePct}% · AST ${graph.coverage.astFiles} · runtime ${graph.coverage.runtimeFiles} · heuristic ${graph.coverage.heuristicFiles} · internal resolution ${graph.coverage.resolutionPct}% · unresolved ${graph.coverage.unresolvedInternal}`,
76
- `关系: files ${graph.files.length} · file edges ${graph.fileEdges.length} · module edges ${graph.moduleEdges.length} · contracts ${graph.contracts.length} · cycles ${graph.cycles.length}`,
84
+ `关系: files ${graph.files.length} · file edges ${graph.fileEdges.length} · module edges ${graph.moduleEdges.length} · contracts ${graph.contracts.length} · cycles ${graph.cycles.length} · file cycles ${graph.fileCycles.length}`,
77
85
  `质量矩阵: units ${qualityCoverage.totalUnits} · covered ${qualityCoverage.unitsWithCommands} · coverage ${qualityCoverage.coveragePct}% · commands ${qualityCoverage.commandKinds.join(", ") || "无"}`,
78
86
  `manifest 依赖: ${units.reduce((sum, unit) => sum + unit.dependencies.length, 0)} · parse errors ${units.reduce((sum, unit) => sum + unit.dependencyErrors.length, 0)}`,
79
87
  `语言: ${[...new Set(units.flatMap((unit) => unit.languages))].sort().join(", ") || "未发现"}`,
@@ -124,3 +132,92 @@ export function auditGovernance(root, maxLines = 150) {
124
132
  const ok = !issues.some((issue) => issue.severity === "error");
125
133
  return { ok, governance, graph, units, qualityPlan, qualityCoverage, issues, report: render(governance, graph, units, qualityCoverage, issues, maxLines) };
126
134
  }
135
+ const MAX_CYCLE_LIST = 10;
136
+ const MAX_FOCUS_NODES = 80;
137
+ /** 依赖图查询:全局摘要(关系/环/枢纽)+ 模块与文件级循环 + 聚焦文件邻域;输出折叠在行预算内。 */
138
+ export function dependencyGraphReport(root, args = {}, maxLines = 150) {
139
+ const absolute = path.resolve(root);
140
+ const governance = loadGovernance(absolute);
141
+ const graph = buildSemanticGraph(absolute, governance, { semanticEvidence: loadAllSemanticEvidence(absolute) });
142
+ const lines = [
143
+ "## 依赖图",
144
+ `- 关系: files ${graph.files.length} · file edges ${graph.fileEdges.length} · module edges ${graph.moduleEdges.length} · unresolved ${graph.unresolved.length}`,
145
+ `- 环: module cycles ${graph.cycles.length} · file cycles ${graph.fileCycles.length}`,
146
+ ];
147
+ const fanOut = new Map();
148
+ const fanIn = new Map();
149
+ for (const edge of graph.fileEdges) {
150
+ if (edge.to === null || edge.resolution !== "file")
151
+ continue;
152
+ fanOut.set(edge.from, (fanOut.get(edge.from) ?? 0) + 1);
153
+ fanIn.set(edge.to, (fanIn.get(edge.to) ?? 0) + 1);
154
+ }
155
+ const hubs = (counts) => [...counts.entries()].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])).slice(0, 5).map(([file, n]) => `${file}(${n})`).join(", ") || "无";
156
+ lines.push(`- 出边枢纽: ${hubs(fanOut)}`);
157
+ lines.push(`- 入边枢纽: ${hubs(fanIn)}`);
158
+ for (const cycle of graph.cycles.slice(0, 5))
159
+ lines.push(`- 🔴 模块循环: ${cycle.join(" -> ")} -> ${cycle[0]}`);
160
+ for (const cycle of graph.fileCycles.slice(0, MAX_CYCLE_LIST))
161
+ lines.push(`- ⚠️ 文件循环: ${cycle.join(" -> ")} -> ${cycle[0]}`);
162
+ if (args.focus) {
163
+ // focus 可传相对或绝对路径:先解析并转成项目内相对 posix 路径,再与语义图对表。
164
+ const focusRel = relPath(path.relative(absolute, path.resolve(absolute, args.focus)));
165
+ if (!graph.files.some((file) => file.path === focusRel)) {
166
+ lines.push(`- 🚩 聚焦文件不在语义图中(未解析或不在治理范围): ${focusRel}`);
167
+ return foldLines(lines, { maxLines, hint: "先确认路径在模块 root 下且语言已登记" });
168
+ }
169
+ const forward = new Map();
170
+ const backward = new Map();
171
+ for (const edge of graph.fileEdges) {
172
+ if (edge.to === null || edge.resolution !== "file")
173
+ continue;
174
+ forward.set(edge.from, [...(forward.get(edge.from) ?? []), edge.to]);
175
+ backward.set(edge.to, [...(backward.get(edge.to) ?? []), edge.from]);
176
+ }
177
+ const depth = args.depth ?? 2;
178
+ const walk = (start, adjacency) => {
179
+ const seen = new Map([[start, 0]]);
180
+ let frontier = [start];
181
+ for (let level = 1; level <= depth; level += 1) {
182
+ const next = [];
183
+ for (const node of frontier)
184
+ for (const neighbor of adjacency.get(node) ?? []) {
185
+ if (!seen.has(neighbor)) {
186
+ seen.set(neighbor, level);
187
+ next.push(neighbor);
188
+ }
189
+ }
190
+ if (next.length === 0)
191
+ break;
192
+ frontier = next.sort();
193
+ }
194
+ return seen;
195
+ };
196
+ lines.push(`### 聚焦 ${focusRel}(depth=${depth})`);
197
+ const sides = [
198
+ { title: "依赖(出边)", adjacency: forward, enabled: args.direction !== "dependents" },
199
+ { title: "被依赖(入边)", adjacency: backward, enabled: args.direction !== "deps" },
200
+ ];
201
+ for (const side of sides) {
202
+ if (!side.enabled)
203
+ continue;
204
+ const seen = walk(focusRel, side.adjacency);
205
+ const levels = [...seen.entries()].filter(([file]) => file !== focusRel).sort((a, b) => a[1] - b[1] || a[0].localeCompare(b[0]));
206
+ if (levels.length === 0) {
207
+ lines.push(`- ${side.title}: 无`);
208
+ continue;
209
+ }
210
+ const byLevel = new Map();
211
+ for (const [file, level] of levels.slice(0, MAX_FOCUS_NODES))
212
+ byLevel.set(level, [...(byLevel.get(level) ?? []), file]);
213
+ for (const level of [...byLevel.keys()].sort((a, b) => a - b))
214
+ lines.push(`- ${side.title} L${level}: ${byLevel.get(level).join(", ")}`);
215
+ if (levels.length > MAX_FOCUS_NODES)
216
+ lines.push(` …另有 ${levels.length - MAX_FOCUS_NODES} 个节点未展开(提高 depth 或改用 impact_analysis 反向闭包)`);
217
+ }
218
+ const focusUnresolved = graph.unresolved.filter((edge) => edge.from === focusRel).slice(0, 10).map((edge) => edge.specifier);
219
+ if (focusUnresolved.length > 0)
220
+ lines.push(`- 未解析引用: ${focusUnresolved.join(", ")}`);
221
+ }
222
+ return foldLines(lines, { maxLines, hint: "用 focus 参数聚焦单个文件,或提高 depth 扩大邻域" });
223
+ }
@@ -5,7 +5,7 @@ import { foldLines } from "./budget.js";
5
5
  import { refreshDerived } from "./dashboard.js";
6
6
  import { requireInitialized } from "./paths.js";
7
7
  import { initGovernance, loadGovernance, saveGovernance, } from "./governance-model.js";
8
- import { auditGovernance } from "./governance-audit.js";
8
+ import { auditGovernance, dependencyGraphReport } from "./governance-audit.js";
9
9
  import { impactAnalysis } from "./semantic-graph.js";
10
10
  import { assessQualityCoverage, createQualityPlan, discoverProjectUnits, runQualityPlan } from "./language-adapters.js";
11
11
  import { buildPortfolioFromRegistry, buildPortfolioReport, loadPortfolioProject } from "./portfolio.js";
@@ -156,6 +156,14 @@ export function registerGovernanceTools(server, root) {
156
156
  requireInitialized(root);
157
157
  return auditGovernance(root, budgetLines(root)).report;
158
158
  });
159
+ toolR(server, root, "dependency_graph", "查询跨文件/模块依赖图:全局摘要(关系/环/入出边枢纽)、模块与文件级循环、聚焦文件的依赖邻域(BFS 方向与深度可调、未解析引用)。", {
160
+ focus: z.string().trim().min(1).max(512).optional().describe("聚焦文件(相对项目根路径)"),
161
+ direction: z.enum(["deps", "dependents", "both"]).optional().describe("邻域方向,默认 both"),
162
+ depth: z.number().int().min(1).max(6).optional().describe("邻域深度 1-6,默认 2"),
163
+ }, (args) => {
164
+ requireInitialized(root);
165
+ return dependencyGraphReport(root, args ?? {}, budgetLines(root));
166
+ });
159
167
  toolR(server, root, "list_semantic_evidence", "列出已登记的语言原生 AST、编译器或运行时语义证据;损坏文档会使整次读取失败。", {}, () => {
160
168
  requireInitialized(root);
161
169
  return JSON.stringify(listSemanticEvidence(root), null, 2);
package/dist/osv.js ADDED
@@ -0,0 +1,139 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { discoverProjectUnits } from "./language-adapters.js";
4
+ import { foldLines } from "./budget.js";
5
+ /**
6
+ * OSV.dev 已知漏洞查询(联网,显式确认后才会发起请求)。
7
+ *
8
+ * 依赖来源是本地 manifest 解析(discoverProjectUnits),只把「包名/版本/生态」
9
+ * 三个字段发给 api.osv.dev querybatch;不发送代码、路径或任何项目内容。
10
+ * 版本为范围声明(^ ~ >=)时按原样精确匹配,可能漏报——在报告中如实声明该边界。
11
+ */
12
+ const OSV_ENDPOINT = "https://api.osv.dev/v1/querybatch";
13
+ const BATCH_SIZE = 100;
14
+ export function ecosystemFor(parser) {
15
+ if (parser === "package-json")
16
+ return "npm";
17
+ if (parser.startsWith("requirements") || parser.startsWith("pyproject"))
18
+ return "PyPI";
19
+ if (parser.startsWith("go-mod"))
20
+ return "Go";
21
+ if (parser.startsWith("cargo"))
22
+ return "crates.io";
23
+ if (parser.startsWith("maven") || parser.startsWith("gradle"))
24
+ return "Maven";
25
+ if (parser.startsWith("msbuild"))
26
+ return "NuGet";
27
+ return null;
28
+ }
29
+ /**
30
+ * npm 生态优先用 package-lock.json 的实装版本:manifest 里的 ^ ~ 范围串发给 OSV
31
+ * 会被解析到边界之下造成误报(实装版本已在修复线之上也会命中),实装版本才准确。
32
+ */
33
+ function npmLockVersions(root) {
34
+ const versions = new Map();
35
+ try {
36
+ const lock = JSON.parse(fs.readFileSync(path.join(root, "package-lock.json"), "utf8"));
37
+ for (const [key, entry] of Object.entries(lock.packages ?? {})) {
38
+ if (!key.startsWith("node_modules/"))
39
+ continue;
40
+ const name = key.slice("node_modules/".length);
41
+ if (name.length === 0 || name.includes("node_modules/") || typeof entry.version !== "string")
42
+ continue;
43
+ if (!versions.has(name))
44
+ versions.set(name, entry.version);
45
+ }
46
+ }
47
+ catch { /* 无 lockfile 或不可读:退回 manifest 版本并在报告中声明 */ }
48
+ return versions;
49
+ }
50
+ export function collectOsvQueries(root) {
51
+ const npmVersions = npmLockVersions(root);
52
+ const merged = new Map();
53
+ let skippedUnknownEcosystem = 0;
54
+ for (const unit of discoverProjectUnits(root)) {
55
+ for (const dep of unit.dependencies) {
56
+ const ecosystem = ecosystemFor(dep.parser);
57
+ if (!ecosystem) {
58
+ skippedUnknownEcosystem += 1;
59
+ continue;
60
+ }
61
+ const resolved = ecosystem === "npm" ? npmVersions.get(dep.name) : undefined;
62
+ const version = resolved ?? dep.version;
63
+ const key = `${dep.name}@${version}@${ecosystem}`;
64
+ const existing = merged.get(key);
65
+ if (existing) {
66
+ if (!existing.sourceManifests.includes(dep.sourceManifest))
67
+ existing.sourceManifests.push(dep.sourceManifest);
68
+ }
69
+ else
70
+ merged.set(key, { name: dep.name, version, ecosystem, sourceManifests: [dep.sourceManifest], versionSource: resolved ? "lockfile" : "manifest" });
71
+ }
72
+ }
73
+ return { queries: [...merged.values()].sort((a, b) => a.ecosystem.localeCompare(b.ecosystem) || a.name.localeCompare(b.name)), skippedUnknownEcosystem };
74
+ }
75
+ /** 分批查询 OSV;fetcher 可注入以便测试全程离线。 */
76
+ export async function auditOsv(root, options = {}) {
77
+ const started = Date.now();
78
+ const timeoutMs = options.timeoutMs ?? 30_000;
79
+ const fetcher = options.fetcher ?? globalThis.fetch;
80
+ const { queries } = collectOsvQueries(root);
81
+ const capped = typeof options.maxPackages === "number" ? queries.slice(0, options.maxPackages) : queries;
82
+ const batches = Math.ceil(capped.length / BATCH_SIZE);
83
+ const vulnerablePackages = [];
84
+ let vulnCount = 0;
85
+ for (let batch = 0; batch < batches; batch += 1) {
86
+ const slice = capped.slice(batch * BATCH_SIZE, (batch + 1) * BATCH_SIZE);
87
+ const response = await fetcher(OSV_ENDPOINT, {
88
+ method: "POST",
89
+ headers: { "content-type": "application/json", "user-agent": "pm-mcp-osv (project ledger; reports only package name/version/ecosystem)" },
90
+ body: JSON.stringify({ queries: slice.map((q) => ({ package: { name: q.name, ecosystem: q.ecosystem }, version: q.version })) }),
91
+ signal: AbortSignal.timeout(timeoutMs),
92
+ });
93
+ if (!response.ok)
94
+ throw new Error(`OSV 查询失败:HTTP ${response.status}(第 ${batch + 1}/${batches} 批)`);
95
+ const payload = JSON.parse(await response.text());
96
+ const results = payload.results ?? [];
97
+ for (let i = 0; i < slice.length; i += 1) {
98
+ const vulns = (results[i]?.vulns ?? []).map((v) => ({
99
+ id: typeof v.id === "string" ? v.id : "UNKNOWN",
100
+ summary: typeof v.summary === "string" ? v.summary : typeof v.details === "string" ? v.details.slice(0, 160) : "",
101
+ aliases: Array.isArray(v.aliases) ? v.aliases.filter((a) => typeof a === "string") : [],
102
+ }));
103
+ if (vulns.length === 0)
104
+ continue;
105
+ vulnerablePackages.push({ ...slice[i], vulns });
106
+ vulnCount += vulns.length;
107
+ }
108
+ }
109
+ return {
110
+ queried: capped.length,
111
+ batches,
112
+ lockfileVersions: capped.filter((q) => q.versionSource === "lockfile").length,
113
+ manifestVersions: capped.filter((q) => q.versionSource === "manifest").length,
114
+ vulnerablePackages,
115
+ vulnCount,
116
+ durationMs: Date.now() - started,
117
+ };
118
+ }
119
+ export function renderOsv(scan, maxLines = 150, skippedUnknownEcosystem = 0) {
120
+ const lines = [
121
+ "## OSV 已知漏洞查询(联网完成)",
122
+ `- 查询: ${scan.queried} 个依赖(${scan.batches} 批,仅发送 包名/版本/生态)· 命中 ${scan.vulnerablePackages.length} 个包 / ${scan.vulnCount} 条漏洞 · 耗时 ${scan.durationMs}ms`,
123
+ ];
124
+ for (const finding of scan.vulnerablePackages) {
125
+ for (const vuln of finding.vulns) {
126
+ const aliases = vuln.aliases.length > 0 ? `(${vuln.aliases.slice(0, 3).join(", ")})` : "";
127
+ lines.push(`- 🚩 ${finding.ecosystem}/${finding.name}@${finding.version} ${vuln.id}${aliases} ${vuln.summary.slice(0, 120)}`);
128
+ }
129
+ }
130
+ if (scan.vulnerablePackages.length === 0)
131
+ lines.push("- ✅ 未命中已知漏洞");
132
+ else
133
+ lines.push(`- ✅ 其余 ${scan.queried - scan.vulnerablePackages.length} 个依赖未命中已知漏洞`);
134
+ lines.push("> 边界:npm 依赖按 package-lock.json 实装版本查询(" + scan.lockfileVersions + " 个)" +
135
+ (scan.manifestVersions > 0 ? `;${scan.manifestVersions} 个非 npm 依赖按 manifest 声明版本(范围声明可能不准)` : "") +
136
+ ";devDependencies 一并计入;结果不写台账,联网由每次调用显式 confirm 门控。" +
137
+ (skippedUnknownEcosystem > 0 ? ` 未识别生态依赖 ${skippedUnknownEcosystem} 个已跳过。` : ""));
138
+ return foldLines(lines, { maxLines, hint: "修复后可再次 audit_osv 复核" });
139
+ }
@@ -0,0 +1,105 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { spawnSync } from "node:child_process";
4
+ import { foldLines } from "./budget.js";
5
+ /**
6
+ * 公开代码反查(GitHub Code Search,联网,显式确认后才会发起请求)。
7
+ *
8
+ * 与 audit_osv 的本质差异:OSV 只发包名/版本,本工具会把一段**真实代码行**发到
9
+ * GitHub——因此门控更强:confirm 必须显式为 true,且片段来自用户显式传入的
10
+ * snippet 参数或 file+line 选择(在客户端审批界面全程可见)。
11
+ * 仅做精确短语匹配:命中 ≠ 侵权结论,私有仓库不可见,配额 10 次/分钟。
12
+ */
13
+ const GITHUB_SEARCH_ENDPOINT = "https://api.github.com/search/code";
14
+ const MIN_SNIPPET = 8;
15
+ const MAX_QUERY_CHARS = 200;
16
+ /** 解析要外发的片段:显式 snippet 优先;否则 file(+可选 line,缺省取最长行)。 */
17
+ export function resolveSnippet(root, args) {
18
+ if (args.snippet !== undefined && args.snippet !== "") {
19
+ const snippet = args.snippet.replace(/\s+/g, " ").trim();
20
+ if (snippet.length < MIN_SNIPPET)
21
+ throw new Error(`片段太短(≥${MIN_SNIPPET} 字符),太短会命中海量无关仓库。`);
22
+ if (args.file !== undefined)
23
+ throw new Error("snippet 与 file 二选一。");
24
+ return snippet;
25
+ }
26
+ if (args.file !== undefined) {
27
+ const absolute = path.resolve(root, args.file);
28
+ if (absolute !== root && !absolute.startsWith(root + path.sep))
29
+ throw new Error("file 必须位于项目根内。");
30
+ const raw = fs.readFileSync(absolute, "utf8");
31
+ const lines = raw.split(/\r?\n/);
32
+ const index = args.line === undefined
33
+ ? lines.reduce((best, line, i) => (line.trim().length > (lines[best]?.trim().length ?? 0) ? i : best), 0)
34
+ : args.line - 1;
35
+ const line = lines[index];
36
+ if (line === undefined)
37
+ throw new Error(`行号越界:${args.file} 共 ${lines.length} 行,第 ${args.line ?? 0} 行不存在。`);
38
+ const snippet = line.replace(/\s+/g, " ").trim();
39
+ if (snippet.length < MIN_SNIPPET)
40
+ throw new Error(`选中的行太短(≥${MIN_SNIPPET} 字符):${snippet.slice(0, 40)}`);
41
+ return snippet.slice(0, MAX_QUERY_CHARS);
42
+ }
43
+ throw new Error("需要 snippet 或 file 之一:确认要外发到 GitHub 的代码片段。");
44
+ }
45
+ function resolveToken(explicit) {
46
+ if (explicit)
47
+ return explicit;
48
+ const fromEnv = process.env.GH_TOKEN ?? process.env.GITHUB_TOKEN;
49
+ if (fromEnv)
50
+ return fromEnv;
51
+ const gh = spawnSync("gh", ["auth", "token"], { encoding: "utf8", windowsHide: true });
52
+ const fromGh = gh.status === 0 ? gh.stdout.trim() : "";
53
+ if (fromGh)
54
+ return fromGh;
55
+ throw new Error("未找到 GitHub 凭据:设置 GH_TOKEN/GITHUB_TOKEN,或先 gh auth login。");
56
+ }
57
+ export async function searchPublicCode(root, args = {}) {
58
+ const started = Date.now();
59
+ const snippet = resolveSnippet(root, args);
60
+ const token = args.tokenResolver ? args.tokenResolver() : resolveToken(args.token);
61
+ const fetcher = args.fetcher ?? globalThis.fetch;
62
+ const maxResults = Math.min(30, Math.max(1, Math.trunc(args.maxResults ?? 10)));
63
+ // GitHub REST code search 是词项检索(不支持带引号短语):按空白拆词、去掉引号后以 AND 语义提交。
64
+ const query = snippet.replace(/"/g, " ").split(/\s+/).filter(Boolean).join(" ");
65
+ const response = await fetcher(`${GITHUB_SEARCH_ENDPOINT}?q=${encodeURIComponent(query)}&per_page=${maxResults}`, {
66
+ method: "GET",
67
+ headers: {
68
+ authorization: `Bearer ${token}`,
69
+ accept: "application/vnd.github+json",
70
+ "user-agent": "pm-mcp-public-search (single-line provenance lookup)",
71
+ "x-github-api-version": "2022-11-28",
72
+ },
73
+ signal: AbortSignal.timeout(args.timeoutMs ?? 30_000),
74
+ });
75
+ if (response.status === 401)
76
+ throw new Error("GitHub token 无效(401)。");
77
+ if (response.status === 403)
78
+ throw new Error("GitHub 拒绝(403):可能触发了 code search 限流(10 次/分钟)或 token 权限不足。");
79
+ if (response.status === 422)
80
+ throw new Error("GitHub 拒绝查询(422):短语可能不合法,换一段更普通的行试试。");
81
+ if (!response.ok)
82
+ throw new Error(`GitHub Code Search 失败:HTTP ${response.status}`);
83
+ const payload = JSON.parse(await response.text());
84
+ const items = Array.isArray(payload.items) ? payload.items : [];
85
+ const hits = items.slice(0, maxResults).map((item) => ({
86
+ repo: typeof item.repository?.full_name === "string" ? item.repository.full_name : "unknown",
87
+ path: typeof item.path === "string" ? item.path : "",
88
+ url: typeof item.html_url === "string" ? item.html_url : "",
89
+ }));
90
+ const totalCount = typeof payload.total_count === "number" ? payload.total_count : hits.length;
91
+ return { snippet: snippet.slice(0, MAX_QUERY_CHARS), totalCount, hits, truncated: totalCount > hits.length, durationMs: Date.now() - started };
92
+ }
93
+ export function renderPublicSearch(result, maxLines = 150) {
94
+ const lines = [
95
+ "## 公开代码反查(GitHub Code Search,联网完成)",
96
+ `- 外发片段(已确认): "${result.snippet}"`,
97
+ `- 命中: ${result.totalCount} 处(列出前 ${result.hits.length}${result.truncated ? ",已截断" : ""})· 耗时 ${result.durationMs}ms`,
98
+ ];
99
+ for (const hit of result.hits)
100
+ lines.push(`- 命中 ${hit.repo} · ${hit.path}${hit.url ? ` · ${hit.url}` : ""}`);
101
+ if (result.hits.length === 0)
102
+ lines.push("- ✅ 公开索引中未发现相同代码行");
103
+ lines.push("> 命中是字面相同,不是侵权结论:可能是同一开源出处、常见写法或独立实现。私有仓库不可见;配额 10 次/分钟。");
104
+ return foldLines(lines, { maxLines, hint: "换更长或更独特的行可提高区分度" });
105
+ }
@@ -363,6 +363,8 @@ export function buildSemanticGraph(root, governance, options = {}) {
363
363
  limitations: [...analysisLimitations],
364
364
  },
365
365
  files, fileEdges, moduleEdges, exports: exportedSymbols, contracts, unresolved, cycles: findModuleCycles(moduleEdges), violations,
366
+ // Tarjan 对 {from,to} 边同构,文件内边与模块边共用同一实现。
367
+ fileCycles: findModuleCycles(fileEdges.flatMap((edge) => (edge.to !== null && edge.resolution === "file" ? [{ from: edge.from, to: edge.to }] : []))),
366
368
  coverage: {
367
369
  totalFiles: files.length,
368
370
  sourceCandidateFiles,
package/dist/ui.js ADDED
@@ -0,0 +1,191 @@
1
+ import http from "node:http";
2
+ import path from "node:path";
3
+ import fs from "node:fs";
4
+ import { isInitialized } from "./paths.js";
5
+ import { loadProject, loadTasks, loadRoadmap, loadFeatures, loadSessions, loadSecurity } from "./store.js";
6
+ import { milestoneStats } from "./roadmap.js";
7
+ import { aggregates, ensureFresh, getIndex } from "./index-store.js";
8
+ import { VERSION } from "./version.js";
9
+ /**
10
+ * 本地只读 Web 仪表盘(零依赖 node:http):
11
+ * pm-mcp ui [--root <项目>] [--port <端口>]
12
+ *
13
+ * 安全边界:仅绑定 127.0.0.1、纯 GET 只读、未初始化 fail-closed、无外部资源、
14
+ * 所有插值经 HTML 转义(任务标题等用户内容不可注入);响应 CSP 禁止脚本。
15
+ */
16
+ const esc = (value) => value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
17
+ function latestQualityRun(root) {
18
+ const dir = path.join(root, ".pm", "quality-runs");
19
+ try {
20
+ const latest = fs.readdirSync(dir).filter((name) => name.endsWith(".json")).sort().pop();
21
+ if (!latest)
22
+ return null;
23
+ const raw = JSON.parse(fs.readFileSync(path.join(dir, latest), "utf8"));
24
+ const summary = raw.results?.[0]?.output_summary;
25
+ return { at: raw.run_at ?? "", ok: raw.ok === true, tests: summary ? `${summary.passed}/${summary.tests}` : "n/a" };
26
+ }
27
+ catch {
28
+ return null;
29
+ }
30
+ }
31
+ export function buildDashboardState(root) {
32
+ const absolute = path.resolve(root);
33
+ const project = loadProject(absolute);
34
+ const { tasks } = loadTasks(absolute);
35
+ const { milestones } = loadRoadmap(absolute);
36
+ const features = loadFeatures(absolute).features;
37
+ const security = loadSecurity(absolute).findings;
38
+ const sessions = loadSessions(absolute).sessions;
39
+ const byStatus = {};
40
+ for (const task of tasks)
41
+ byStatus[task.status] = (byStatus[task.status] ?? 0) + 1;
42
+ const recent = [...tasks]
43
+ .sort((a, b) => b.id.localeCompare(a.id))
44
+ .slice(0, 10)
45
+ .map((task) => ({ id: task.id, title: task.title, status: task.status, priority: task.priority ?? "" }));
46
+ let index = null;
47
+ try {
48
+ ensureFresh(absolute);
49
+ const agg = aggregates(getIndex(absolute));
50
+ index = { files: agg.totalFiles, loc: agg.totalLoc };
51
+ }
52
+ catch {
53
+ index = null;
54
+ }
55
+ const last = sessions.length > 0 ? sessions[sessions.length - 1] : null;
56
+ return {
57
+ name: project.name,
58
+ description: project.description,
59
+ phase: project.phase,
60
+ exposure: project.exposure,
61
+ version: VERSION,
62
+ milestones: milestones.map((milestone) => {
63
+ const stats = milestoneStats(tasks, milestone.id);
64
+ return { id: milestone.id, title: milestone.title, status: milestone.status, done: stats.done, total: stats.total, pct: stats.total === 0 ? 0 : stats.pct };
65
+ }),
66
+ tasks: { total: tasks.length, byStatus, recent },
67
+ features: {
68
+ total: features.length,
69
+ implemented: features.filter((f) => f.status === "implemented").length,
70
+ planned: features.filter((f) => f.status === "planned").length,
71
+ deprecated: features.filter((f) => f.status === "deprecated").length,
72
+ },
73
+ security: {
74
+ open: security.filter((f) => f.status === "open").length,
75
+ high: security.filter((f) => f.status === "open" && f.severity === "high").length,
76
+ accepted: security.filter((f) => f.status === "accepted").length,
77
+ },
78
+ sessions: {
79
+ count: sessions.length,
80
+ last: last ? { at: last.date, author: last.author, summary: last.summary } : null,
81
+ },
82
+ index,
83
+ quality: latestQualityRun(absolute),
84
+ };
85
+ }
86
+ export function renderDashboardHtml(state) {
87
+ const taskStatus = Object.entries(state.tasks.byStatus).map(([status, count]) => `${esc(status)} ${count}`).join(" · ") || "无";
88
+ const milestoneRows = state.milestones.map((m) => {
89
+ const statusIcon = m.status === "done" ? "✅" : m.status === "active" ? "▶" : "☐";
90
+ return `<tr><td>${esc(m.id)}</td><td>${statusIcon} ${esc(m.title)}</td><td>${m.done}/${m.total}</td>` +
91
+ `<td><div class="bar"><div class="fill" style="width:${Math.min(100, Math.max(0, m.pct))}%"></div></div>${m.pct}%</td></tr>`;
92
+ }).join("");
93
+ const taskRows = state.tasks.recent.map((task) => `<tr><td>${esc(task.id)}</td><td>${esc(task.title)}</td><td>${esc(task.status)}</td><td>${esc(task.priority)}</td></tr>`).join("");
94
+ const lastSession = state.sessions.last
95
+ ? `<p>最近:${esc(state.sessions.last.at)} · ${esc(state.sessions.last.author)} — ${esc(state.sessions.last.summary.slice(0, 160))}</p>`
96
+ : "<p>(无会话记录)</p>";
97
+ const quality = state.quality
98
+ ? `<span class="${state.quality.ok ? "ok" : "bad"}">${state.quality.ok ? "✅" : "🚩"} ${esc(state.quality.tests)}</span> @ ${esc(state.quality.at)}`
99
+ : "(暂无质量报告)";
100
+ return `<!doctype html>
101
+ <html lang="zh-CN"><head><meta charset="utf-8">
102
+ <meta name="viewport" content="width=device-width,initial-scale=1">
103
+ <title>${esc(state.name)} · pm-mcp</title>
104
+ <style>
105
+ body{font-family:system-ui,"Segoe UI",sans-serif;margin:0;background:#f6f7f9;color:#1c2733}
106
+ main{max-width:960px;margin:0 auto;padding:24px}
107
+ h1{font-size:22px}h2{font-size:16px;margin:28px 0 8px;border-bottom:1px solid #dde3ea;padding-bottom:6px}
108
+ .grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:12px}
109
+ .card{background:#fff;border:1px solid #dde3ea;border-radius:8px;padding:12px}
110
+ .card b{display:block;font-size:20px}.card span{color:#5b6b7c;font-size:12px}
111
+ table{width:100%;border-collapse:collapse;background:#fff;border:1px solid #dde3ea}
112
+ td,th{padding:6px 10px;border-bottom:1px solid #eef1f4;text-align:left;font-size:13px}
113
+ .bar{background:#e8edf2;border-radius:6px;height:10px;min-width:80px;display:inline-block;vertical-align:middle;margin-right:6px}
114
+ .fill{background:#3d7bd9;height:10px;border-radius:6px}
115
+ .ok{color:#1a7f37}.bad{color:#c62828}
116
+ footer{color:#8a97a5;font-size:12px;margin-top:24px}
117
+ </style></head><body><main>
118
+ <h1>${esc(state.name)} <small style="font-size:13px;color:#5b6b7c">pm-mcp v${esc(state.version)}</small></h1>
119
+ <p>${esc(state.description || "(无描述)")} · 阶段:${esc(state.phase || "未设置")} · 暴露面:${esc(state.exposure)}</p>
120
+ <h2>概览</h2>
121
+ <div class="grid">
122
+ <div class="card"><b>${state.tasks.total}</b><span>任务</span></div>
123
+ <div class="card"><b>${state.features.implemented}</b><span>功能已实现 / 共 ${state.features.total}</span></div>
124
+ <div class="card"><b class="${state.security.high > 0 ? "bad" : "ok"}">${state.security.open}</b><span>安全未处理(高危 ${state.security.high})</span></div>
125
+ <div class="card"><b>${state.sessions.count}</b><span>会话记录</span></div>
126
+ <div class="card"><b>${state.index ? state.index.files : "—"}</b><span>索引文件${state.index ? ` / ${state.index.loc} 行` : ""}</span></div>
127
+ <div class="card"><b>${quality}</b><span>最近质量门禁</span></div>
128
+ </div>
129
+ <h2>里程碑</h2>
130
+ ${milestoneRows ? `<table><tr><th>ID</th><th>标题</th><th>任务</th><th>进度</th></tr>${milestoneRows}</table>` : "<p>(无里程碑)</p>"}
131
+ <h2>最近任务</h2>
132
+ ${taskRows ? `<table><tr><th>ID</th><th>标题</th><th>状态</th><th>优先级</th></tr>${taskRows}</table><p style="font-size:12px;color:#5b6b7c">状态分布:${esc(taskStatus)}</p>` : "<p>(无任务)</p>"}
133
+ <h2>会话</h2>
134
+ ${lastSession}
135
+ <footer>只读视图 · 仅绑定 127.0.0.1 · 数据来自 ${esc(state.name)} 的 .pm/ 台账 · ${new Date().toISOString()}</footer>
136
+ </main></body></html>`;
137
+ }
138
+ const HEAD_405 = { allow: "GET, HEAD" };
139
+ export async function startUiServer(root, options = {}) {
140
+ const absolute = path.resolve(root);
141
+ if (!isInitialized(absolute))
142
+ throw new Error(`目标不是已纳管项目(缺 .pm/):${absolute}`);
143
+ const server = http.createServer((request, response) => {
144
+ response.setHeader("content-security-policy", "default-src 'none'; style-src 'unsafe-inline'");
145
+ response.setHeader("x-content-type-options", "nosniff");
146
+ response.setHeader("cache-control", "no-store");
147
+ try {
148
+ const url = new URL(request.url ?? "/", "http://127.0.0.1");
149
+ if (request.method !== "GET" && request.method !== "HEAD") {
150
+ response.writeHead(405, HEAD_405);
151
+ response.end("只读服务");
152
+ return;
153
+ }
154
+ if (url.pathname === "/healthz") {
155
+ response.writeHead(200, { "content-type": "text/plain" });
156
+ response.end("ok");
157
+ return;
158
+ }
159
+ if (url.pathname === "/api/state") {
160
+ response.writeHead(200, { "content-type": "application/json; charset=utf-8" });
161
+ response.end(JSON.stringify(buildDashboardState(absolute)));
162
+ return;
163
+ }
164
+ if (url.pathname === "/") {
165
+ response.writeHead(200, { "content-type": "text/html; charset=utf-8" });
166
+ response.end(renderDashboardHtml(buildDashboardState(absolute)));
167
+ return;
168
+ }
169
+ response.writeHead(404, { "content-type": "text/plain; charset=utf-8" });
170
+ response.end("404");
171
+ }
172
+ catch (error) {
173
+ // fail-closed:不泄漏堆栈,只给一条简短原因
174
+ response.writeHead(500, { "content-type": "text/plain; charset=utf-8" });
175
+ response.end(`500: ${error.message.slice(0, 200)}`);
176
+ }
177
+ });
178
+ await new Promise((resolve, reject) => {
179
+ server.once("error", reject);
180
+ server.listen(options.port ?? 0, "127.0.0.1", resolve);
181
+ });
182
+ const address = server.address();
183
+ if (address === null || typeof address === "string") {
184
+ server.close();
185
+ throw new Error("UI 服务监听失败。");
186
+ }
187
+ return {
188
+ url: `http://127.0.0.1:${address.port}/`,
189
+ close: () => new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve()))),
190
+ };
191
+ }
package/dist/version.js CHANGED
@@ -7,4 +7,4 @@ export const PACKAGE_SPEC = `${metadata.name}@${VERSION}`;
7
7
  export const RUNTIME_CONTRACT = "pagination-evidence-v1";
8
8
  // 服务器工具目录契约(doctor 与独立巡检 health-check 都以此核对)。
9
9
  // 新增/移除 MCP 工具时必须同步更新这里,并用 `pm-mcp probe` 实测复核。
10
- export const TOOL_CATALOG_SIZE = 49;
10
+ export const TOOL_CATALOG_SIZE = 53;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@luckychen1993/pm-mcp",
3
- "version": "0.3.0",
3
+ "version": "0.5.0",
4
4
  "description": "AI 编码项目的单一事实来源 + 健康台账 MCP 服务(事实来源 / 路线图 / 防幻觉 / 七本账 / 断点续做 / token 经济)",
5
5
  "keywords": [
6
6
  "mcp",
@@ -33,7 +33,10 @@
33
33
  "node": ">=22.18.0"
34
34
  },
35
35
  "pm_mcp": {
36
- "acceptance_baseline": { "id": "pm-mcp-local-release", "version": "1.1.0" }
36
+ "acceptance_baseline": {
37
+ "id": "pm-mcp-local-release",
38
+ "version": "1.1.0"
39
+ }
37
40
  },
38
41
  "scripts": {
39
42
  "build": "tsc -p tsconfig.json",