@luckychen1993/pm-mcp 0.1.5 → 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.
@@ -3,9 +3,10 @@ import path from "node:path";
3
3
  import { randomUUID } from "node:crypto";
4
4
  import { isInitialized } from "./paths.js";
5
5
  import { normSep } from "./budget.js";
6
- import { getIndex, getMeta, setMeta } from "./index-db.js";
6
+ import { ignoreMatcher, projectScanIgnores } from "./scan-policy.js";
7
+ import { freshness, getIndex, getMeta, setMeta } from "./index-db.js";
7
8
  import { clearWatcherDirty, coordinateWatcherLeader, markWatcherDirty, watcherIsClean } from "./watcher-coordinator.js";
8
- export { aggregates, closeIndex, getIndex, getMeta, iterateFileRows, setMeta } from "./index-db.js";
9
+ export { aggregates, closeIndex, freshness, getIndex, getMeta, indexSummary, iterateFileRows, setMeta } from "./index-db.js";
9
10
  import { BINARY_EXTS, countLoc, isLockfile, isTestFile, extOf, countSkipLines, needsSkipCount, looksTrivialTest, DEFAULT_IGNORE_DIRS, walkStatEntries, } from "./scan.js";
10
11
  /**
11
12
  * SQLite 扫描索引(超大项目核心):
@@ -65,6 +66,7 @@ export function deleteSubtree(db, prefix) {
65
66
  db.prepare("DELETE FROM files WHERE rel LIKE ?").run(prefix.replace(/[%_]/g, (c) => "\\" + c) + "/%");
66
67
  }
67
68
  export function walkRefresh(root, opts = {}) {
69
+ const scanPolicy = JSON.stringify(projectScanIgnores(root));
68
70
  const db = getIndex(root);
69
71
  db.exec("CREATE TEMP TABLE IF NOT EXISTS walked(rel TEXT PRIMARY KEY, mtime REAL, size INTEGER)");
70
72
  db.exec("DELETE FROM walked");
@@ -152,6 +154,7 @@ export function walkRefresh(root, opts = {}) {
152
154
  setMeta(db, "lastWalk", new Date().toISOString());
153
155
  setMeta(db, "skippedDeep", String(skippedDeep));
154
156
  setMeta(db, "rootPath", path.resolve(root));
157
+ setMeta(db, "scanPolicy", scanPolicy);
155
158
  // 走查刚完成的这一刻索引就是最新的:watcher 模式下立即续心跳,
156
159
  // 否则长走查期间(库忙)心跳可能一直写不进,走查一结束就被判"过期"再走一遍
157
160
  if (getMeta(db, "mode") === "watcher") {
@@ -167,41 +170,16 @@ export function walkRefresh(root, opts = {}) {
167
170
  activeWatchers.get(path.resolve(root))?.reconciled();
168
171
  return { totalFiles: total, changed: processed, deleted: Number(del.changes ?? 0), hits: total - processed, skippedDeep };
169
172
  }
170
- const BEAT_STALE_MS = 90_000;
171
- export function freshness(root) {
172
- const db = getIndex(root);
173
- const files = db.prepare(`SELECT COUNT(*) c FROM files`).get().c;
174
- const lastWalk = getMeta(db, "lastWalk");
175
- const lastBeat = getMeta(db, "lastBeat");
176
- const mode = (getMeta(db, "mode") ?? "walk");
177
- const pending = Number(getMeta(db, "pending") ?? 0);
178
- const beatAge = lastBeat ? Date.now() - Date.parse(lastBeat) : Number.POSITIVE_INFINITY;
179
- // rootPath 守卫:索引库随项目目录被拷贝/移动时,旧心跳与行集都属于原路径——不可信任
180
- const owned = getMeta(db, "rootPath") === path.resolve(root);
181
- const lastEvent = getMeta(db, "lastEvent");
182
- const eventAge = lastEvent ? Date.now() - Date.parse(lastEvent) : Number.POSITIVE_INFINITY;
183
- const watcherSession = getMeta(db, "watcherSession");
184
- const sessionReconciled = watcherSession !== null && getMeta(db, "lastWalkSession") === watcherSession;
185
- const watcherHealthy = !(getMeta(db, "watcherError") ?? "");
186
- const watcherClean = watcherIsClean(root);
187
- // 卡死计数自愈:防抖 150ms,若计数>0 但 30s 无任何事件推进,说明计数器失真(如持锁期抛错),不再信任
188
- const pendingClean = pending === 0 || eventAge > 30_000;
189
- const fresh = files > 0 &&
190
- lastWalk !== null &&
191
- owned &&
192
- mode === "watcher" &&
193
- sessionReconciled &&
194
- watcherHealthy &&
195
- watcherClean &&
196
- pendingClean &&
197
- beatAge < BEAT_STALE_MS;
198
- return { mode, fresh, files, lastWalk, lastBeat, pendingEvents: pending };
199
- }
173
+ /* ------------------------------ watcher 新鲜度 ------------------------------ */
200
174
  /**
201
175
  * 确保索引可用:watcher 活跃且心跳新鲜 → 直接用(稳态免走查);
202
176
  * 否则精确全量走查(冷启动/巡检进程/server 未运行时)。
203
177
  */
204
178
  export function ensureFresh(root) {
179
+ if (getMeta(getIndex(root), "scanPolicy") !== JSON.stringify(projectScanIgnores(root))) {
180
+ walkRefresh(root, { forceContent: true });
181
+ return { used: "walk", freshness: freshness(root) };
182
+ }
205
183
  let f = freshness(root);
206
184
  if (!f.fresh && f.mode === "watcher" && f.pendingEvents > 0) {
207
185
  // 变更风暴刚过:事件还在防抖队列里。本进程有 watcher 时就地排空(毫秒级),
@@ -248,12 +226,15 @@ export function startWatcher(root) {
248
226
  let leader = false;
249
227
  let stopped = false;
250
228
  let locallyUnhealthy = false;
229
+ let policy = projectScanIgnores(abs);
230
+ let customIgnored = ignoreMatcher(policy);
251
231
  const active = {
252
232
  timers,
253
233
  closed: () => stopped || !leader,
254
- reconciled: () => { locallyUnhealthy = false; },
234
+ reconciled: () => { locallyUnhealthy = false; policy = projectScanIgnores(abs); customIgnored = ignoreMatcher(policy); },
255
235
  };
256
- const ignored = (rel) => DEFAULT_IGNORE_DIRS.has(rel.split("/")[0]) || rel.split("/")[0] === ".pm";
236
+ const ignored = (rel) => DEFAULT_IGNORE_DIRS.has(rel.split("/")[0]) ||
237
+ rel.split("/").slice(0, -1).some((part) => DEFAULT_IGNORE_DIRS.has(part)) || customIgnored(rel, true);
257
238
  const recordWatcherError = (stage, error) => {
258
239
  locallyUnhealthy = true;
259
240
  try {
@@ -291,6 +272,12 @@ export function startWatcher(root) {
291
272
  return;
292
273
  let succeeded = false;
293
274
  try {
275
+ if (ignored(rel)) {
276
+ deleteFile(db, rel);
277
+ deleteSubtree(db, rel);
278
+ succeeded = true;
279
+ return;
280
+ }
294
281
  const full = path.join(abs, ...rel.split("/"));
295
282
  let st;
296
283
  try {
@@ -362,11 +349,24 @@ export function startWatcher(root) {
362
349
  db = getIndex(abs);
363
350
  try {
364
351
  watcher = fs.watch(abs, { recursive: true }, (_event, filename) => {
365
- if (stopped || !leader || !filename)
352
+ if (stopped || !leader)
353
+ return;
354
+ if (!filename) {
355
+ recordWatcherError("incomplete event", new Error("监视器未提供文件名,需要精确走查恢复索引。"));
366
356
  return;
357
+ }
367
358
  let rel;
368
359
  try {
369
360
  rel = normSep(String(filename));
361
+ if (rel === ".pm/project.json") {
362
+ const currentPolicy = projectScanIgnores(abs);
363
+ if (JSON.stringify(currentPolicy) !== JSON.stringify(policy)) {
364
+ policy = currentPolicy;
365
+ customIgnored = ignoreMatcher(policy);
366
+ markWatcherDirty(abs);
367
+ }
368
+ return;
369
+ }
370
370
  if (ignored(rel))
371
371
  return;
372
372
  }
@@ -438,13 +438,4 @@ export function startWatcher(root) {
438
438
  coordinator.stop();
439
439
  } } };
440
440
  }
441
- /** 巡检/诊断用:索引概况一行 */
442
- export function indexSummary(root) {
443
- const f = freshness(root);
444
- if (f.files === 0)
445
- return "索引:空(未走查)";
446
- const age = f.lastWalk ? `,走查于 ${f.lastWalk.slice(0, 16).replace("T", " ")}` : "";
447
- const beat = f.fresh ? ",watcher 保鲜中" : `(watcher ${f.mode === "watcher" ? "心跳过期" : "未运行"},下次审计将全量走查)`;
448
- return `索引:${f.files} 文件${age}${beat}`;
449
- }
450
441
  export { isInitialized };
package/dist/index.js CHANGED
@@ -17,6 +17,9 @@ import { AcceptanceReportSchema } from "./acceptance-report.js";
17
17
  import { runCoalescedRead } from "./idempotency.js";
18
18
  import { recordUsage } from "./tool-base.js";
19
19
  import { logRuntime } from "./usage-log.js";
20
+ import { VERSION } from "./version.js";
21
+ import { taskPage } from "./task-pagination.js";
22
+ import { budgetLines } from "./tool-base.js";
20
23
  /* --root 参数 > PM_ROOT 环境变量 > 启动时工作目录 */
21
24
  const argv = process.argv.slice(2);
22
25
  let explicitRoot;
@@ -25,8 +28,9 @@ for (let i = 0; i < argv.length; i++) {
25
28
  explicitRoot = argv[i + 1];
26
29
  }
27
30
  const root = resolveRoot(explicitRoot);
28
- const server = new McpServer({ name: "pm-mcp", version: "0.1.5" }, {
31
+ const server = new McpServer({ name: "pm-mcp", version: VERSION }, {
29
32
  instructions: [
33
+ `pm-mcp ${VERSION}。get_status 返回实际运行版本与项目根;配置修改后须重连核验。list_tasks 有 has_more 时保持过滤条件并使用 next_cursor 续取,不能把一页当全量。feature/fix 默认要求 tests_observed,未知计数不冒充测试通过。`,
30
34
  "多 Agent 规则:相同业务的所有写工具调用必须携带相同 idempotency_key(建议 task-id:operation);同键同参数只执行一次,同键不同参数会被拒绝。",
31
35
  "完全相同的并行读请求会由服务端合并并复用结果;不要让多个分支重复请求同一工具和参数。",
32
36
  "看到“幂等复用”时直接采用首次结果;看到“幂等占位/正在执行”时等待负责该业务的 Agent,不要换键重试。",
@@ -72,20 +76,23 @@ server.registerResource("roadmap", "pm://roadmap", { description: "路线图(
72
76
  },
73
77
  ],
74
78
  }));
75
- server.registerResource("tasks", "pm://tasks", { description: "任务清单原始数据(tasks.json;超 100 条截断,完整数据用 list_tasks 过滤)" }, async (uri) => ({
79
+ server.registerResource("tasks", "pm://tasks", { description: "任务原始数据;大列表末尾返回 continuation,可通过 list_tasks 保持 include_done:true 连续取回后续任务" }, async (uri) => ({
76
80
  contents: [
77
81
  {
78
82
  uri: uri.href,
79
83
  mimeType: "application/json",
80
84
  text: await resourceText("tasks", () => {
81
- const tasks = loadTasks(root).tasks;
85
+ const data = loadTasks(root);
86
+ const tasks = data.tasks;
82
87
  // 资源也要守 token 预算:全量 dump 大账本会吃爆上下文(截断后仍是合法 JSON)
83
88
  if (tasks.length <= 100)
84
89
  return JSON.stringify(tasks, null, 2);
85
- const shown = tasks.slice(0, 100);
90
+ const page = taskPage(root, { include_done: true, page_size: 100 }, budgetLines(root) - 4, data);
91
+ const shown = tasks.slice(0, page.returned);
86
92
  shown.push({
87
- id: `_TRUNCATED_${tasks.length - 100}`,
88
- title: `…另有 ${tasks.length - 100} 个任务已截断,用 list_tasks 按状态/里程碑过滤获取`,
93
+ id: `_TRUNCATED_${tasks.length - page.returned}`,
94
+ title: `…本次输出已截断,另有 ${tasks.length - page.returned} 个任务,用 continuation 连续读取`,
95
+ continuation: { tool: "list_tasks", arguments: { include_done: true, cursor: page.next_cursor } },
89
96
  });
90
97
  return JSON.stringify(shown, null, 2);
91
98
  }),
@@ -156,12 +163,12 @@ server.registerPrompt("end-session", { description: "收工仪式:更新任务
156
163
  type: "text",
157
164
  text: [
158
165
  "收工前依次完成(顺序执行,缺一不可):",
159
- "1. 完成的任务:update_task 置 done,必须写 result_note(做了什么),feature/fix 类补 verification(用什么测试/命令证明)。",
166
+ "1. feature/fix 先执行适用测试,生成最新质量报告;update_task 置 done,给 result_note 与实际 files,record_session=true 可一并归档。",
160
167
  "2. 未完成的任务:checkpoint 保存「进展 + 下一步具体动作」。",
161
168
  "3. 本次落地的功能:register_feature 登记(入口文件 + 测试文件)。",
162
169
  "4. 修过的 bug:log_debug 记录症状/根因/修法/验证。",
163
170
  "5. 走过的捷径:add_task(type=debt) 登记债务,别让它在沉默中腐烂。",
164
- "6. log_session 记录会话摘要与改动文件清单(如实列出,波及面统计靠它)。",
171
+ "6. update_task 已返回自动会话编号时,不重复 log_session;否则用 log_session 记录真实改动清单。纯问答不需要另建任务或反复写会话。",
165
172
  "7. 若到达里程碑节点:update_milestone 流转状态;可 snapshot_codebase 拍快照 + audit_structure 对账。",
166
173
  ].join("\n"),
167
174
  },
@@ -5,9 +5,11 @@ import { foldLines, normSep } from "./budget.js";
5
5
  import { refreshDerived } from "./dashboard.js";
6
6
  import { decisionsDir, requireInitialized } from "./paths.js";
7
7
  import { formatCodeSearch, searchCode, searchKnowledge } from "./search.js";
8
- import { loadDebugLog, loadFeatures, loadFileNotes, loadProject, loadSessions, nextId, saveDebugLog, saveFeatures, saveFileNotes, saveSessions, } from "./store.js";
8
+ import { loadDebugLog, loadFeatures, loadFileNotes, loadProject, nextId, saveDebugLog, saveFeatures, saveFileNotes, } from "./store.js";
9
9
  import { budgetLines, toolR, toolW } from "./tool-base.js";
10
10
  import { now } from "./types.js";
11
+ import { appendSession } from "./session-log.js";
12
+ import { captureFileHashes } from "./git-state.js";
11
13
  export function registerFeatureTools(server, root) {
12
14
  toolW(server, root, "register_feature", "登记功能(回答「这个项目有哪些功能」)。entry_files=入口文件(漂移对账锚点),test_files=测试文件(测试背书)。", {
13
15
  name: z.string().min(1),
@@ -113,28 +115,17 @@ export function registerSessionTools(server, root) {
113
115
  files: z.array(z.string()).optional().describe("本次改动/新增/删除的文件(相对路径)"),
114
116
  next_steps: z.array(z.string()).optional().describe("留给下次的下一步"),
115
117
  author: z.string().optional().describe("署名:zcode / codex / human:名字"),
116
- }, (args) => {
118
+ }, (args, hashes) => {
117
119
  requireInitialized(root);
118
120
  const project = loadProject(root);
119
- const data = loadSessions(root);
120
- const id = nextId("S", data.seq, 4);
121
- data.seq += 1;
122
- data.sessions.push({
123
- id,
124
- date: now(),
125
- author: args.author ?? "",
126
- summary: args.summary,
127
- files: (args.files ?? []).map(normSep),
128
- next_steps: args.next_steps ?? [],
129
- });
130
- saveSessions(root, data);
121
+ const id = appendSession(root, args, hashes);
131
122
  refreshDerived(root);
132
123
  const L = [`✅ 会话 ${id} 已记录,changelog.md 已更新。`];
133
124
  if ((args.files ?? []).length > project.budgets.sessionBlastRadius) {
134
125
  L.push(`⚠️ 本次波及 ${(args.files ?? []).length} 个文件,超过阈值 ${project.budgets.sessionBlastRadius}——波及面过大,考虑拆任务或立重构任务。`);
135
126
  }
136
127
  return L.join("\n");
137
- });
128
+ }, (args) => { requireInitialized(root); return captureFileHashes(root, args.files ?? []); });
138
129
  toolW(server, root, "log_debug", "记录调试过程:症状 / 根因 / 修法 / 怎么验证的。调试知识账——下次同样的错不用从零猜,search_knowledge 可查。", {
139
130
  symptom: z.string().describe("症状:什么现象、报什么错"),
140
131
  root_cause: z.string().describe("根因(诊断出的真正原因,不是表面现象)"),
@@ -1,18 +1,14 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import { spawn } from "node:child_process";
4
+ import { SCAN_IGNORE_DIRS, ignoreMatcher, projectScanIgnores } from "./scan-policy.js";
4
5
  import { parseManifestDependencies } from "./language-dependencies.js";
5
- const DEFAULT_IGNORE_DIRS = new Set([
6
- ".git", ".pm", ".idea", ".vscode", ".venv", "venv", "env", "__pycache__",
7
- "node_modules", "dist", "build", "out", "target", ".gradle", ".next", ".nuxt",
8
- ".output", ".turbo", "coverage", ".cache",
9
- ]);
10
- const LANGUAGE_ORDER = [
11
- "javascript", "typescript", "python", "go", "rust", "java", "kotlin", "csharp",
12
- ];
6
+ const DEFAULT_IGNORE_DIRS = SCAN_IGNORE_DIRS;
7
+ const LANGUAGE_ORDER = ["javascript", "typescript", "python", "go", "rust", "java", "kotlin", "csharp"];
13
8
  const KIND_ORDER = ["test", "build", "lint", "typecheck", "coverage", "security"];
14
9
  const DEFAULT_TIMEOUT_MS = 5 * 60_000;
15
- const DEFAULT_OUTPUT_BYTES = 64 * 1024;
10
+ // 计数汇总("# tests N")在输出末尾,只保留头部会把它一起丢掉 → 计数器解析为 null → 验收把实测值判成 -1。
11
+ const DEFAULT_OUTPUT_BYTES = 1024 * 1024;
16
12
  function normalize(value) {
17
13
  return value.replace(/\\/g, "/");
18
14
  }
@@ -258,6 +254,7 @@ export function discoverProjectUnits(root, options = {}) {
258
254
  if (!Number.isInteger(maxDepth) || maxDepth < 0)
259
255
  throw new Error("maxDepth must be a non-negative integer");
260
256
  const ignored = new Set([...DEFAULT_IGNORE_DIRS, ...(options.ignoreDirs ?? [])]);
257
+ const excluded = ignoreMatcher(projectScanIgnores(repositoryRoot));
261
258
  const grouped = new Map();
262
259
  const walk = (dir, depth) => {
263
260
  if (depth > maxDepth)
@@ -265,6 +262,8 @@ export function discoverProjectUnits(root, options = {}) {
265
262
  const entries = fs.readdirSync(dir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name));
266
263
  for (const entry of entries) {
267
264
  const absolute = path.join(dir, entry.name);
265
+ if (excluded(normalize(path.relative(repositoryRoot, absolute)), entry.isDirectory()))
266
+ continue;
268
267
  if (entry.isDirectory()) {
269
268
  if (!ignored.has(entry.name))
270
269
  walk(absolute, depth + 1);
@@ -403,7 +402,9 @@ async function runOne(item) {
403
402
  // shell:false is intentional: arguments never undergo shell parsing.
404
403
  let child;
405
404
  try {
406
- child = spawn(item.command, item.args, { cwd: item.cwd, shell: false, windowsHide: true, stdio: ["ignore", "pipe", "pipe"] });
405
+ // Node 测试宿主的内部标记会让子 node --test 直接跳过文件并退出0,不能泄漏给质量命令。
406
+ const { NODE_TEST_CONTEXT: _nodeTestContext, ...env } = process.env;
407
+ child = spawn(item.command, item.args, { cwd: item.cwd, env, shell: false, windowsHide: true, stdio: ["ignore", "pipe", "pipe"] });
407
408
  }
408
409
  catch (error) {
409
410
  const err = error;
package/dist/license.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import fs from "node:fs";
2
+ import { scanExclusionNote } from "./scan-policy.js";
2
3
  import path from "node:path";
3
4
  import { loadFileNotes, loadProject } from "./store.js";
4
5
  import { isTestFile, readDirectDeps } from "./scan.js";
@@ -11,10 +12,11 @@ import { foldLines } from "./budget.js";
11
12
  */
12
13
  const STRONG_COPYLEFT = ["GPL-2.0", "GPL-3.0", "AGPL-3.0", "SSPL-1.0", "GPL", "AGPL", "SSPL"];
13
14
  const WEAK_COPYLEFT = ["LGPL", "MPL-2.0", "MPL", "EPL", "CDDL", "EUPL", "Sleepycat"];
14
- const PERMISSIVE = ["MIT", "Apache-2.0", "Apache", "BSD-2-Clause", "BSD-3-Clause", "BSD", "ISC", "0BSD", "Unlicense", "Zlib", "Python-2.0", "BSL-1.0"];
15
+ const PERMISSIVE = ["MIT", "Apache-2.0", "Apache", "BSD-2-Clause", "BSD-3-Clause", "BSD", "ISC", "0BSD", "Unlicense", "Zlib", "Python-2.0", "BSL-1.0", "BlueOak-1.0.0"];
15
16
  /** 单个包最多登记数(巨型依赖树保护;超出部分计数上报) */
16
17
  const MAX_DEPS = 800;
17
- function classify(license) {
18
+ /** 单个许可标识的判定(不含 SPDX 表达式拆解)。 */
19
+ function classifySingle(license) {
18
20
  const l = license.trim();
19
21
  if (!l)
20
22
  return "unknown";
@@ -31,6 +33,27 @@ function classify(license) {
31
33
  return "permissive";
32
34
  return "unknown";
33
35
  }
36
+ /**
37
+ * 判定依赖许可证。支持 SPDX 表达式:去掉外层括号后按 OR 拆开,逐项判定并取【最严格】的一项。
38
+ * 取最严格而非最宽松,是为了不削弱发布门禁:(MIT OR GPL-3.0) 仍判 strong-copyleft;
39
+ * 只有全部操作数都为宽松许可时(如 (MIT OR WTFPL))才不再落到 unknown。
40
+ */
41
+ function classify(license) {
42
+ const stripped = license.trim().replace(/^\((.*)\)$/u, "$1").trim();
43
+ if (!stripped)
44
+ return "unknown";
45
+ if (/\s+OR\s+/iu.test(stripped)) {
46
+ const rank = { unknown: 0, permissive: 1, "weak-copyleft": 2, "strong-copyleft": 3 };
47
+ let worst = "unknown";
48
+ for (const part of stripped.split(/\s+OR\s+/iu)) {
49
+ const kind = classifySingle(part.replace(/^\((.*)\)$/u, "$1").trim());
50
+ if (rank[kind] > rank[worst])
51
+ worst = kind;
52
+ }
53
+ return worst;
54
+ }
55
+ return classifySingle(stripped);
56
+ }
34
57
  function readPkgLicense(pkgFile) {
35
58
  try {
36
59
  const pkg = JSON.parse(fs.readFileSync(pkgFile, "utf8"));
@@ -96,6 +119,8 @@ function readNodeLicenses(root) {
96
119
  if (!fs.existsSync(nested))
97
120
  continue;
98
121
  for (const entry of fs.readdirSync(nested, { withFileTypes: true })) {
122
+ if (entry.name.startsWith("."))
123
+ continue;
99
124
  if (entry.name.startsWith("@") && isDirLike(entry)) {
100
125
  for (const sub of fs.readdirSync(path.join(nested, entry.name), { withFileTypes: true })) {
101
126
  if (isDirLike(sub))
@@ -147,6 +172,7 @@ export function auditLicense(root, maxLines = 150, forceFresh = false, indexPrep
147
172
  const declared = project.license || pkgLicense;
148
173
  const projectKind = classify(declared);
149
174
  const L = [];
175
+ L.push(...scanExclusionNote(root));
150
176
  L.push("## 许可证审计");
151
177
  /* ① 项目声明 */
152
178
  L.push("");
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/probe.js ADDED
@@ -0,0 +1,141 @@
1
+ import fs from "node:fs";
2
+ import { spawnSync } from "node:child_process";
3
+ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
4
+ import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
5
+ import { VERSION } from "./version.js";
6
+ /**
7
+ * 标准 raw tools/list 直连探针。
8
+ *
9
+ * 宿主(ZCode/Codex/Cursor)是唯一 MCP client 持有者,不向模型透传原始
10
+ * `tools/list`;本探针绕过宿主,自己以 MCP client 身份直连服务器进程,
11
+ * 拿协议线上的原始工具清单,并可与期望表比对(如 Codex allowlist)。
12
+ */
13
+ export const PROBE_USAGE = "pm-mcp probe [--timeout <毫秒>] [--expect <逗号分隔工具名> | --expect-file <json>] -- <启动 MCP 服务器的命令...>";
14
+ export function parseProbeArgs(argv) {
15
+ const separator = argv.indexOf("--");
16
+ const optionArgs = separator >= 0 ? argv.slice(0, separator) : argv;
17
+ const serverArgs = separator >= 0 ? argv.slice(separator + 1) : [];
18
+ const options = { command: "", args: [], timeoutMs: 30_000 };
19
+ let expectSeen = 0;
20
+ for (let index = 0; index < optionArgs.length; index++) {
21
+ const arg = optionArgs[index], value = optionArgs[index + 1];
22
+ if (arg === "--timeout") {
23
+ const parsed = Number(value);
24
+ if (!value || !Number.isFinite(parsed) || parsed <= 0)
25
+ throw new Error(`probe 参数无效: ${arg} ${value ?? "(缺值)"}`);
26
+ options.timeoutMs = parsed;
27
+ index++;
28
+ }
29
+ else if (arg === "--expect" || arg === "--expect-file") {
30
+ if (!value)
31
+ throw new Error(`probe 参数无效: ${arg} (缺值)`);
32
+ if (++expectSeen > 1)
33
+ throw new Error("--expect 与 --expect-file 只能用一个。");
34
+ options.expect = arg === "--expect" ? parseExpectList(value) : readExpectFile(value);
35
+ index++;
36
+ }
37
+ else
38
+ throw new Error(`probe 参数无效: ${arg}。用法:${PROBE_USAGE}`);
39
+ }
40
+ if (separator < 0 || serverArgs.length === 0)
41
+ throw new Error(`缺少 "-- <服务器启动命令>"。用法:${PROBE_USAGE}`);
42
+ if (serverArgs.includes("--"))
43
+ throw new Error("服务器启动命令内部不能再出现 -- 分隔符。");
44
+ options.command = serverArgs[0];
45
+ options.args = serverArgs.slice(1);
46
+ return options;
47
+ }
48
+ function parseExpectList(value) {
49
+ const names = [...new Set(value.split(",").map((name) => name.trim()).filter(Boolean))];
50
+ if (names.length === 0)
51
+ throw new Error("--expect 至少需要一个工具名。");
52
+ return names;
53
+ }
54
+ /** 期望表接受:字符串数组、上一轮 probe 报告、或 {tools:[名字|{name}]}。 */
55
+ function readExpectFile(file) {
56
+ const stat = fs.lstatSync(file);
57
+ if (!stat.isFile() || stat.isSymbolicLink() || stat.size > 2 * 1024 * 1024)
58
+ throw new Error("expect 文件必须为有界普通文件,禁止链接。");
59
+ let parsed;
60
+ try {
61
+ parsed = JSON.parse(fs.readFileSync(file, "utf8"));
62
+ }
63
+ catch (error) {
64
+ throw new Error(`expect 文件不是合法 JSON:${error.message}`);
65
+ }
66
+ const list = Array.isArray(parsed) ? parsed
67
+ : Array.isArray(parsed?.tools) ? parsed.tools
68
+ : undefined;
69
+ if (!list)
70
+ throw new Error("expect 文件格式不识别:应为字符串数组、probe 报告或 {tools:[...]}。");
71
+ const names = [...new Set(list.map((entry) => typeof entry === "string" ? entry : entry?.name)
72
+ .filter((name) => typeof name === "string" && name.length > 0))];
73
+ if (names.length === 0)
74
+ throw new Error("expect 文件里没有可识别的工具名。");
75
+ return names;
76
+ }
77
+ export async function runProbe(options) {
78
+ const timeoutMs = options.timeoutMs;
79
+ const report = { ok: false, command: options.command, args: options.args, toolCount: 0, tools: [], issues: [] };
80
+ const client = new Client({ name: "pm-mcp-probe", version: VERSION });
81
+ // env 为覆盖式合并(SDK 先取默认安全白名单再覆盖),供 PM_MCP_HOME 之类的沙箱重定向。
82
+ const transport = new StdioClientTransport({ command: options.command, args: options.args, stderr: "pipe", env: options.env });
83
+ try {
84
+ await client.connect(transport, { timeout: timeoutMs });
85
+ transport.stderr?.on("data", () => undefined);
86
+ const info = client.getServerVersion();
87
+ if (info)
88
+ report.serverInfo = { name: info.name, version: info.version };
89
+ const tools = [];
90
+ let cursor;
91
+ do {
92
+ const page = await client.listTools(cursor ? { cursor } : {}, { timeout: timeoutMs });
93
+ for (const tool of page.tools)
94
+ tools.push({ name: tool.name, description: tool.description, inputSchema: tool.inputSchema });
95
+ cursor = page.nextCursor;
96
+ } while (cursor);
97
+ report.tools = tools;
98
+ report.toolCount = tools.length;
99
+ if (tools.length === 0)
100
+ report.issues.push("服务器未暴露任何工具。");
101
+ if (options.expect) {
102
+ const actual = new Set(tools.map((tool) => tool.name));
103
+ const expected = [...new Set(options.expect)];
104
+ const missing = expected.filter((name) => !actual.has(name));
105
+ const extra = tools.map((tool) => tool.name).filter((name) => !expected.includes(name));
106
+ report.expect = { expected, missing, extra };
107
+ if (missing.length > 0)
108
+ report.issues.push(`服务器未暴露 ${missing.length} 个期望工具: ${missing.join(", ")}`);
109
+ if (extra.length > 0)
110
+ report.issues.push(`服务器暴露了 ${extra.length} 个期望之外的工具: ${extra.join(", ")}`);
111
+ }
112
+ report.ok = report.issues.length === 0;
113
+ }
114
+ catch (error) {
115
+ report.issues.push(`raw tools/list 探测失败: ${error.message}`);
116
+ }
117
+ finally {
118
+ await client.close().catch(() => undefined);
119
+ hardKill(transport);
120
+ }
121
+ return report;
122
+ }
123
+ /** client.close() 正常会终止子进程;这里兜底清理仍存活的残留,避免夹具/服务器挂死。 */
124
+ function hardKill(transport) {
125
+ const pid = transport.pid;
126
+ if (!pid)
127
+ return;
128
+ try {
129
+ process.kill(pid, 0);
130
+ }
131
+ catch {
132
+ return;
133
+ }
134
+ try {
135
+ if (process.platform === "win32")
136
+ spawnSync("taskkill", ["/PID", String(pid), "/T", "/F"], { windowsHide: true });
137
+ else
138
+ process.kill(pid, "SIGKILL");
139
+ }
140
+ catch { /* 尽力而为 */ }
141
+ }