@luckychen1993/pm-mcp 0.3.0 → 0.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/README.md +15 -4
- package/dist/audit-tools.js +17 -0
- package/dist/cli.js +22 -0
- package/dist/duplicates.js +228 -0
- package/dist/governance-audit.js +98 -1
- package/dist/governance-tools.js +9 -1
- package/dist/osv.js +139 -0
- package/dist/semantic-graph.js +2 -0
- package/dist/ui.js +191 -0
- package/dist/version.js +1 -1
- package/package.json +5 -2
package/README.md
CHANGED
|
@@ -33,7 +33,7 @@ node dist/cli.js doctor --root .
|
|
|
33
33
|
|
|
34
34
|
## 已有管理流程
|
|
35
35
|
|
|
36
|
-
用户仍然直接说“继续这个任务”“保存进度”“完成后帮我验收”。
|
|
36
|
+
用户仍然直接说“继续这个任务”“保存进度”“完成后帮我验收”。52 个工具全部保留,由 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
|
-
## 工具清单(
|
|
249
|
+
## 工具清单(52 个)
|
|
239
250
|
|
|
240
251
|
| 域 | 工具 | 说明 |
|
|
241
252
|
|---|---|---|
|
|
@@ -253,8 +264,8 @@ snapshot_codebase + audit_structure 对账;audit_security 安全体检;audit
|
|
|
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
|
|
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`。
|
package/dist/audit-tools.js
CHANGED
|
@@ -8,6 +8,8 @@ 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";
|
|
11
13
|
export function registerAuditTools(server, root) {
|
|
12
14
|
toolW(server, root, "snapshot_codebase", "给代码结构拍快照(文件数/行数/目录分布/测试数/skip 标记/依赖清单)。之后 audit_structure 会与上次快照 diff。建议每个会话或每个里程碑结束时拍一次。", {}, () => {
|
|
13
15
|
requireInitialized(root);
|
|
@@ -19,6 +21,21 @@ export function registerAuditTools(server, root) {
|
|
|
19
21
|
requireInitialized(root);
|
|
20
22
|
return auditStructure(root, budgetLines(root), true);
|
|
21
23
|
});
|
|
24
|
+
toolR(server, root, "find_duplicates", "重复代码检测:注释剥离/字符串占位/空白归一化的行指纹滑动窗口聚类,跨文件重复块按长度降序列出。", {
|
|
25
|
+
min_lines: z.number().int().min(4).max(20).optional().describe("最小重复行数(有效代码行),默认 6"),
|
|
26
|
+
limit: z.number().int().min(1).max(100).optional().describe("最多列出的重复组数,默认 20"),
|
|
27
|
+
}, (args) => {
|
|
28
|
+
requireInitialized(root);
|
|
29
|
+
return renderDuplicates(root, args ?? {}, budgetLines(root));
|
|
30
|
+
});
|
|
31
|
+
toolR(server, root, "audit_osv", "OSV.dev 已知漏洞查询(联网!默认关):把 manifest 声明的依赖(仅包名/版本/生态)批量发给 api.osv.dev 比对;必须显式 confirm=true 才发起网络请求。", {
|
|
32
|
+
confirm: z.literal(true).describe("显式确认联网:本工具会把依赖名与版本发送到 api.osv.dev"),
|
|
33
|
+
timeout_ms: z.number().int().min(5000).max(120000).optional().describe("每批请求超时毫秒数,默认 30000"),
|
|
34
|
+
}, async (args) => {
|
|
35
|
+
requireInitialized(root);
|
|
36
|
+
const scan = await auditOsv(root, { timeoutMs: args.timeout_ms });
|
|
37
|
+
return renderOsv(scan, budgetLines(root));
|
|
38
|
+
});
|
|
22
39
|
toolW(server, root, "audit_security", "安全体检(只扫描本地、不联网,结果写入安全台账):密钥泄露 / 危险模式(eval、SQL 拼接、禁用证书校验等)/ 依赖风险。修复后重扫自动关闭;接受风险必须留理由。", {}, (_args, prepared) => {
|
|
23
40
|
requireInitialized(root);
|
|
24
41
|
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
|
+
}
|
package/dist/governance-audit.js
CHANGED
|
@@ -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
|
+
}
|
package/dist/governance-tools.js
CHANGED
|
@@ -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
|
+
}
|
package/dist/semantic-graph.js
CHANGED
|
@@ -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, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
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 =
|
|
10
|
+
export const TOOL_CATALOG_SIZE = 52;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@luckychen1993/pm-mcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.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": {
|
|
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",
|