@luckychen1993/pm-mcp 0.1.4 → 0.3.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.
@@ -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/paths.js CHANGED
@@ -57,7 +57,7 @@ export function ensurePmRuntimeIgnored(root) {
57
57
  fs.mkdirSync(directory, { recursive: true });
58
58
  fs.mkdirSync(path.join(directory, ".runtime", "idempotency"), { recursive: true });
59
59
  const file = path.join(directory, ".gitignore");
60
- const required = [".lock", ".runtime/", "benchmarks/", "index.db*"];
60
+ const required = [".lock", ".runtime/", "benchmarks/", "index.db*", "usage-log*.jsonl*", "runtime-log*.jsonl*"];
61
61
  for (let attempt = 0; attempt < 3; attempt += 1) {
62
62
  const current = fs.existsSync(file) ? fs.readFileSync(file, "utf8") : "";
63
63
  const lines = current.split(/\r?\n/).map((line) => line.trim());
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
+ }
@@ -1,11 +1,9 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import { createHash } from "node:crypto";
4
- const DEFAULT_IGNORED_DIRECTORIES = new Set([
5
- ".git", ".pm", ".cache", ".gradle", ".idea", ".next", ".nuxt", ".output", ".turbo", ".venv", ".vscode", ".zcode",
6
- "__pycache__", "build", "coverage", "dist", "env", "node_modules", "out", "target", "venv",
7
- ]);
8
- function hashRegularFile(file) {
4
+ import { SCAN_IGNORE_DIRS, ignoreMatcher, projectScanIgnores } from "./scan-policy.js";
5
+ const DEFAULT_IGNORED_DIRECTORIES = SCAN_IGNORE_DIRS;
6
+ export function hashRegularFile(file) {
9
7
  const handle = fs.openSync(file, "r");
10
8
  const digest = createHash("sha256");
11
9
  const buffer = Buffer.allocUnsafe(1024 * 1024);
@@ -43,6 +41,10 @@ export function fingerprintProject(root) {
43
41
  throw new Error(`项目根必须是普通目录: ${absolute}`);
44
42
  const rootReal = fs.realpathSync.native(absolute);
45
43
  const tree = createHash("sha256");
44
+ const ignores = projectScanIgnores(root);
45
+ const excluded = ignoreMatcher(ignores);
46
+ if (ignores.length)
47
+ tree.update(`scan-ignore\0${JSON.stringify(ignores)}\0`);
46
48
  let files = 0;
47
49
  let bytes = 0;
48
50
  const visit = (directory, relativeDirectory) => {
@@ -52,6 +54,8 @@ export function fingerprintProject(root) {
52
54
  continue;
53
55
  const absoluteEntry = path.join(directory, entry.name);
54
56
  const relative = path.posix.join(relativeDirectory.replace(/\\/g, "/"), entry.name);
57
+ if (excluded(relative, entry.isDirectory()))
58
+ continue;
55
59
  if (relative === "PROJECT.md")
56
60
  continue; // generated dashboard; authoritative inputs live in .pm and source files
57
61
  const stat = fs.lstatSync(absoluteEntry);
@@ -8,6 +8,10 @@ import { loadGovernance } from "./governance-model.js";
8
8
  import { loadProject, loadRoadmap, loadSecurity, loadSessions, loadTasks, saveProject, saveRoadmap, } from "./store.js";
9
9
  import { now } from "./types.js";
10
10
  import { budgetLines, toolI, toolR, toolW } from "./tool-base.js";
11
+ import { renderRuntimeLogLines, renderUsageLogLines } from "./usage-log.js";
12
+ import { ScanIgnoreSchema } from "./scan-policy.js";
13
+ import { VERSION, RUNTIME_CONTRACT } from "./version.js";
14
+ import { CompletionEvidenceSchema } from "./quality-evidence.js";
11
15
  function taskLine(t, withNext = false) {
12
16
  const parts = [`[${t.status}]`, t.id, t.title, `(${t.type}${t.milestone ? `,${t.milestone}` : ""})`];
13
17
  if (withNext && t.checkpoint) {
@@ -16,7 +20,7 @@ function taskLine(t, withNext = false) {
16
20
  return parts.join(" ");
17
21
  }
18
22
  export function registerProjectTools(server, root) {
19
- toolW(server, root, "init_project", "初始化当前项目的 .pm/ 状态目录(单一事实来源)。每个新项目只调用一次;已初始化请用 update_project。", {
23
+ toolW(server, root, "init_project", "初始化当前项目的 .pm/ 状态目录(单一事实来源),并自动写入 AGENTS.md 工作规矩(已存在则增量合并,不覆盖你的内容)。每个新项目只调用一次;已初始化请用 update_project。", {
20
24
  name: z.string().min(1).describe("项目名"),
21
25
  description: z.string().optional().describe("一句话描述"),
22
26
  stack: z.array(z.string()).optional().describe("技术栈,如 [\"TypeScript\",\"React\"]"),
@@ -24,13 +28,15 @@ export function registerProjectTools(server, root) {
24
28
  license: z.string().optional().describe("项目许可证(SPDX 名,如 MIT)"),
25
29
  exposure: z.enum(["local", "network", "public"]).optional().describe("暴露面,影响安全告警力度"),
26
30
  modules: z.array(z.string()).optional().describe("初始模块登记"),
31
+ agents_md: z.boolean().optional().describe("默认 true:自动写入/合并 AGENTS.md 工作规矩(false 关闭)"),
27
32
  }, (args) => {
28
- const project = initProject(root, args);
33
+ const project = initProject(root, { ...args, agentsMd: args.agents_md });
29
34
  refreshDerived(root);
30
35
  return [
31
36
  `✅ 项目已初始化: ${project.name}`,
32
37
  `状态目录: ${pmPath(root, "")}(随 git 提交,团队/AI 共享)`,
33
38
  "仪表盘已生成: PROJECT.md",
39
+ args.agents_md === false ? "AGENTS.md 规矩:按要求跳过(agents_md:false)" : "AGENTS.md 工作规矩已写入(已存在则合并;ZCode 每次打开该项目自动加载)",
34
40
  "建议顺序: upsert_module 声明模块/owner/语言 → add_milestone 建里程碑 → add_task 拆任务 → 开工前 get_status / 收工后 log_session。",
35
41
  ].join("\n");
36
42
  });
@@ -43,6 +49,7 @@ export function registerProjectTools(server, root) {
43
49
  const security = loadSecurity(root);
44
50
  const L = [];
45
51
  L.push(`# ${project.name}${project.phase ? ` — ${project.phase}` : ""}`);
52
+ L.push(`运行版本: pm-mcp ${VERSION} · 项目根: ${root}`);
46
53
  if (project.description)
47
54
  L.push(project.description);
48
55
  L.push("");
@@ -93,7 +100,26 @@ export function registerProjectTools(server, root) {
93
100
  }
94
101
  L.push("");
95
102
  L.push("> 三段式定位:本状态 → search_code 精确到行 → 才读具体文件。别全量读代码。");
96
- return foldLines(L, { maxLines: budgetLines(root) });
103
+ return JSON.stringify({ text: foldLines(L, { maxLines: budgetLines(root) }), runtime: { version: VERSION, root, contract: RUNTIME_CONTRACT } });
104
+ }, { cacheVersion: `runtime:${VERSION}`, outputSchema: { runtime: z.object({ version: z.string(), root: z.string(), contract: z.string() }) },
105
+ decode: (value) => { const result = JSON.parse(value); return { text: result.text, data: { runtime: result.runtime } }; } });
106
+ toolR(server, root, "get_usage_log", "使用日志:pm-mcp 被用了哪些工具、成败、耗时,以及输出 token 估算与「折叠省下的 token」——回答“用它到底省不省 token”。只记工具名与计量,不记参数内容。日志滚动保留约 2MB。", {
107
+ last: z.number().int().min(1).max(200).optional().describe("返回最近 N 条明细(默认 20;汇总始终覆盖全部保留日志)"),
108
+ tool: z.string().optional().describe("只看某工具,如 get_status"),
109
+ ok: z.boolean().optional().describe("true 只看成,false 只看败"),
110
+ since: z.string().optional().describe("ISO 日期/时间,只看该时间之后的明细"),
111
+ }, (args) => {
112
+ requireInitialized(root);
113
+ return foldLines(renderUsageLogLines(root, args), { maxLines: budgetLines(root) });
114
+ });
115
+ toolR(server, root, "get_runtime_log", "运行日志:server 就绪、watcher 状态、工具报错等服务级事件(info/warn/error),判断 pm-mcp 运行是否健康、哪里在反复出错。", {
116
+ last: z.number().int().min(1).max(200).optional().describe("返回最近 N 条明细(默认 20)"),
117
+ level: z.enum(["info", "warn", "error"]).optional().describe("只看某级别"),
118
+ event: z.string().optional().describe("只看某事件,如 tool.error / server.ready"),
119
+ since: z.string().optional().describe("ISO 日期/时间,只看该时间之后的明细"),
120
+ }, (args) => {
121
+ requireInitialized(root);
122
+ return foldLines(renderRuntimeLogLines(root, args), { maxLines: budgetLines(root) });
97
123
  });
98
124
  toolW(server, root, "update_project", "更新项目元信息/预算(描述、技术栈、目标、阶段、模块登记、许可证、暴露面、复杂度与输出预算)。", {
99
125
  description: z.string().optional(),
@@ -103,6 +129,8 @@ export function registerProjectTools(server, root) {
103
129
  license: z.string().optional(),
104
130
  exposure: z.enum(["local", "network", "public"]).optional(),
105
131
  modules: z.array(z.string()).optional().describe("模块登记(用于未登记目录检测)"),
132
+ scan_ignore: ScanIgnoreSchema.optional().describe("本项目产物/缓存的相对排除 glob,例如 release/**;传 [] 清除;不应排除真实源码"),
133
+ completion_evidence: CompletionEvidenceSchema.optional().describe("默认要求tests_observed;兼容无计数运行器时显式选择execution_only并记录原因"),
106
134
  max_file_loc: z.number().int().min(10).optional().describe("单文件行数预算"),
107
135
  refactor_quota_pct: z.number().min(0).max(100).optional().describe("里程碑重构类任务最低占比%"),
108
136
  session_blast_radius: z.number().int().min(1).optional().describe("单会话波及文件阈值"),
@@ -124,6 +152,10 @@ export function registerProjectTools(server, root) {
124
152
  project.exposure = args.exposure;
125
153
  if (args.modules !== undefined)
126
154
  project.modules = args.modules;
155
+ if (args.scan_ignore !== undefined)
156
+ project.scan_ignore = args.scan_ignore;
157
+ if (args.completion_evidence !== undefined)
158
+ project.completion_evidence = CompletionEvidenceSchema.parse(args.completion_evidence);
127
159
  if (args.max_file_loc !== undefined)
128
160
  project.budgets.maxFileLoc = args.max_file_loc;
129
161
  if (args.refactor_quota_pct !== undefined)
@@ -0,0 +1,56 @@
1
+ import { createHash } from "node:crypto";
2
+ import { z } from "zod";
3
+ export const CompletionEvidenceSchema = z.object({
4
+ minimum: z.enum(["tests_observed", "execution_only"]),
5
+ reason: z.string().trim().min(8).max(1000),
6
+ });
7
+ const ranks = { unverified: 0, execution_only: 1, tests_observed: 2, coverage_observed: 3 };
8
+ /** 等级描述观测强度,不代表测试充分、结果通过或来源不可伪造。 */
9
+ export function evidenceLevel(kind, summary, truncated = false) {
10
+ if (truncated || !["test", "coverage"].includes(kind))
11
+ return "execution_only";
12
+ const counters = [summary.failed, summary.skipped, summary.cancelled, summary.todo];
13
+ if (summary.tests === null || !Number.isSafeInteger(summary.tests) || summary.tests <= 0 ||
14
+ counters.some((n) => n === null || !Number.isSafeInteger(n) || n < 0) ||
15
+ counters.reduce((total, n) => total + (n ?? 0), 0) > summary.tests)
16
+ return "execution_only";
17
+ if (summary.passed != null && summary.passed + counters.reduce((total, n) => total + (n ?? 0), 0) !== summary.tests)
18
+ return "execution_only";
19
+ const coverage = z.object({ lines_pct: z.number().min(0).max(100), branches_pct: z.number().min(0).max(100), functions_pct: z.number().min(0).max(100) }).safeParse(summary.coverage);
20
+ return coverage.success ? "coverage_observed" : "tests_observed";
21
+ }
22
+ export function weakestEvidence(levels) {
23
+ return levels.length ? levels.reduce((a, b) => ranks[a] < ranks[b] ? a : b) : "execution_only";
24
+ }
25
+ export function evidenceLabel(level) {
26
+ return { unverified: "无可用验证证据", execution_only: "仅执行记录,未确认测试数量", tests_observed: "已观察测试结果", coverage_observed: "已观察测试与覆盖率" }[level ?? ""] ?? "旧报告未标等级";
27
+ }
28
+ export function qualityResultSummary(item) {
29
+ const summary = qualityOutputSummary(item.command.kind, item.stdout, item.stderr, item.truncated);
30
+ return { ...summary, evidence_level: item.exitCode === null ? "unverified" : summary.evidence_level };
31
+ }
32
+ export function qualityOutputSummary(kind, stdout, stderr, truncated = false) {
33
+ const output = `${stdout}\n${stderr}`.replace(/\u001b\[[0-9;]*m/g, "");
34
+ const diagnostic = (label) => {
35
+ const matches = [...output.matchAll(new RegExp(`^[ \\t]*(?:ℹ|#)?[ \\t]*${label}[ \\t]+(\\d+)[ \\t]*\\r?$`, "gmi"))];
36
+ return matches.length ? Number(matches.at(-1)[1]) : null;
37
+ };
38
+ let counts = { tests: diagnostic("tests"), passed: diagnostic("pass"), failed: diagnostic("fail"), cancelled: diagnostic("cancelled"), skipped: diagnostic("skipped"), todo: diagnostic("todo") };
39
+ let counterSource = counts.tests === null ? "unknown" : "node_test_summary";
40
+ const jest = [...output.matchAll(/^\s*Tests:\s*(.+?)(\d+)\s+total\s*$/gm)].at(-1);
41
+ const vitest = [...output.matchAll(/^\s*Tests\s+(.+?)\s*\((\d+)\)\s*$/gm)].at(-1);
42
+ const pytest = [...output.matchAll(/^=+\s+((?:\d+\s+(?:passed|failed|skipped|xfailed|xpassed|deselected|errors?|warnings?)[,\s]*)+)\s+in\s+[\d.]+s.*?=+\s*$/gm)].at(-1);
43
+ if (counts.tests === null && (jest || vitest || pytest)) {
44
+ const text = (jest ?? vitest ?? pytest)[1];
45
+ const number = (label) => Number(new RegExp(`(\\d+)\\s+${label}\\b`).exec(text)?.[1] ?? 0);
46
+ const passed = number("passed"), failed = number("failed") + number("errors?") + number("xpassed");
47
+ const skipped = number("skipped") + number("xfailed") + number("deselected"), todo = number("todo");
48
+ counts = { tests: jest ? Number(jest[2]) : vitest ? Number(vitest[2]) : passed + failed + skipped + todo, passed, failed, skipped, todo, cancelled: 0 };
49
+ counterSource = jest ? "jest_summary" : vitest ? "vitest_summary" : "pytest_summary";
50
+ }
51
+ const coverage = output.match(/(?:^|\n)(?:ℹ|#)?\s*all files\s*\|\s*([\d.]+)\s*\|\s*([\d.]+)\s*\|\s*([\d.]+)(?:\s*\|\s*([\d.]+))?/i);
52
+ counts.coverage = coverage ? { lines_pct: Number(coverage[4] ?? coverage[1]), branches_pct: Number(coverage[2]), functions_pct: Number(coverage[3]) } : null;
53
+ return { stdout_bytes: Buffer.byteLength(stdout), stderr_bytes: Buffer.byteLength(stderr),
54
+ output_sha256: createHash("sha256").update(stdout).update("\0").update(stderr).digest("hex"),
55
+ ...counts, counter_source: counterSource, evidence_level: evidenceLevel(kind, counts, truncated) };
56
+ }
@@ -1,48 +1,28 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
- import { createHash } from "node:crypto";
4
3
  import { atomicWrite, withLedgerLock } from "./store.js";
5
4
  import { refreshDerived } from "./dashboard.js";
6
- function numericDiagnostic(output, label) {
7
- const match = output.match(new RegExp(`(?:^|\\n)(?:ℹ|#)?\\s*${label}\\s+(\\d+)(?:\\s|$)`, "i"));
8
- return match ? Number(match[1]) : null;
9
- }
10
- function outputSummary(stdout, stderr) {
11
- const combined = `${stdout}\n${stderr}`;
12
- // Node's test reporter exposes line|branch|function, while Istanbul/c8 uses
13
- // statement|branch|function|line. Preserve one normalized three-metric shape.
14
- const coverage = combined.match(/(?:^|\n)(?:ℹ|#)?\s*all files\s*\|\s*([\d.]+)\s*\|\s*([\d.]+)\s*\|\s*([\d.]+)(?:\s*\|\s*([\d.]+))?/i);
15
- return {
16
- stdout_bytes: Buffer.byteLength(stdout),
17
- stderr_bytes: Buffer.byteLength(stderr),
18
- output_sha256: createHash("sha256").update(stdout, "utf8").update("\0", "utf8").update(stderr, "utf8").digest("hex"),
19
- tests: numericDiagnostic(combined, "tests"),
20
- passed: numericDiagnostic(combined, "pass"),
21
- failed: numericDiagnostic(combined, "fail"),
22
- cancelled: numericDiagnostic(combined, "cancelled"),
23
- skipped: numericDiagnostic(combined, "skipped"),
24
- todo: numericDiagnostic(combined, "todo"),
25
- coverage: coverage ? { lines_pct: Number(coverage[4] ?? coverage[1]), branches_pct: Number(coverage[2]), functions_pct: Number(coverage[3]) } : null,
26
- };
27
- }
5
+ import { qualityResultSummary, weakestEvidence } from "./quality-evidence.js";
28
6
  /** Persist a secret-minimised quality summary; raw stdout/stderr never enter the ledger. */
29
7
  export function saveQualityRun(root, result, provenance) {
30
8
  const runAt = new Date();
31
9
  const stamp = runAt.toISOString().replace(/[-:]/g, "").replace("T", "-").replace(".", "-").replace(/Z$/, "");
32
10
  const effectiveOk = result.ok && result.execute && result.results.length > 0 && result.results.every((item) => item.status === "passed" && item.exitCode === 0 && item.signal === null);
11
+ const summaries = result.results.map(qualityResultSummary);
33
12
  const payload = JSON.stringify({
34
13
  schema_version: 2,
35
14
  run_at: runAt.toISOString(),
36
15
  ok: effectiveOk,
37
16
  reported_ok: result.ok,
38
17
  execute: result.execute,
18
+ evidence_level: result.execute && result.results.some((item) => item.exitCode !== null) ? weakestEvidence(summaries.filter((_item, i) => ["test", "coverage"].includes(result.results[i].command.kind)).map((item) => item.evidence_level)) : "unverified",
39
19
  environment: { platform: process.platform, arch: process.arch, node: process.version },
40
20
  source: provenance ? {
41
21
  before: provenance.source_before,
42
22
  after: provenance.source_after,
43
23
  stable: provenance.source_before.sha256 === provenance.source_after.sha256,
44
24
  } : null,
45
- results: result.results.map((item) => ({
25
+ results: result.results.map((item, index) => ({
46
26
  unit_root: item.command.cwd,
47
27
  kind: item.command.kind,
48
28
  command: item.command.command,
@@ -52,7 +32,7 @@ export function saveQualityRun(root, result, provenance) {
52
32
  signal: item.signal,
53
33
  duration_ms: item.durationMs,
54
34
  output_truncated: item.truncated,
55
- output_summary: outputSummary(item.stdout, item.stderr),
35
+ output_summary: summaries[index],
56
36
  error: item.error ?? "",
57
37
  })),
58
38
  }, null, 2) + "\n";
package/dist/registry.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import fs from "node:fs";
2
+ import os from "node:os";
2
3
  import path from "node:path";
3
4
  import { RegistrySchema, now } from "./types.js";
4
5
  import { registryFile } from "./paths.js";
@@ -23,9 +24,18 @@ export function saveRegistry(data) {
23
24
  }
24
25
  /** 登记/刷新一个项目(init 与每次写操作都会 touch) */
25
26
  export function touchRegistry(root, name) {
26
- const data = loadRegistry();
27
27
  const abs = path.resolve(root);
28
- const idx = data.projects.findIndex((p) => path.resolve(p.root) === abs);
28
+ // 防污染(T-034 根因):临时目录里的"项目"从不进入真实注册表——
29
+ // 测试若未设 PM_MCP_HOME 沙箱,写入会被这里拦下而不是污染 ~/.pm-mcp。
30
+ // 沙箱化测试(显式设了 PM_MCP_HOME)不受影响,仍写沙箱注册表。
31
+ if (!process.env.PM_MCP_HOME?.trim() && isUnderTemp(abs)) {
32
+ console.error(`[pm-mcp] 跳过注册临时目录项目(如需管理请移出临时目录): ${abs}`);
33
+ return;
34
+ }
35
+ const data = loadRegistry();
36
+ // Windows 路径大小写不敏感:按小写比较去重(e:/E: 重复注册缺陷)
37
+ const key = abs.toLowerCase();
38
+ const idx = data.projects.findIndex((p) => path.resolve(p.root).toLowerCase() === key);
29
39
  const entry = { name, root: abs, last_seen: now() };
30
40
  if (idx >= 0)
31
41
  data.projects[idx] = entry;
@@ -33,6 +43,22 @@ export function touchRegistry(root, name) {
33
43
  data.projects.push(entry);
34
44
  saveRegistry(data);
35
45
  }
46
+ function isUnderTemp(abs) {
47
+ const normalized = abs.toLowerCase();
48
+ // tmpdir 可能以 8.3 短名返回(ADMINI~1),与注册表里的长名不一致——两种形态都比对
49
+ let tmpLong = os.tmpdir().toLowerCase();
50
+ try {
51
+ tmpLong = fs.realpathSync.native(os.tmpdir()).toLowerCase();
52
+ }
53
+ catch {
54
+ /* keep default */
55
+ }
56
+ for (const tmp of new Set([tmpLong, os.tmpdir().toLowerCase()])) {
57
+ if (normalized === tmp || normalized.startsWith(tmp.replace(/\$/, "") + path.sep))
58
+ return true;
59
+ }
60
+ return false;
61
+ }
36
62
  export function listRegistry() {
37
63
  return loadRegistry().projects.sort((a, b) => b.last_seen.localeCompare(a.last_seen));
38
64
  }
@@ -0,0 +1,34 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { z } from "zod";
4
+ import { globToRegExp, normSep } from "./budget.js";
5
+ export const SCAN_IGNORE_DIRS = new Set([
6
+ "node_modules", ".git", "dist", "build", "out", ".next", ".nuxt", ".output", ".turbo", "coverage",
7
+ "__pycache__", ".venv", "venv", "env", ".pm", ".idea", ".vscode", "target", ".cache", ".gradle",
8
+ ".zcode", ".runtime", "EBWebView",
9
+ // 包管理器缓存 / 运行时数据 / 临时与上下文目录:属于环境而非项目源码(T-045)
10
+ ".pnpm-cache", ".pnpm-store", ".npm-cache", ".yarn-cache", ".data", ".tmp", ".temp",
11
+ ".codex-context", "userdata", "logs",
12
+ ]);
13
+ export const ScanIgnoreSchema = z.array(z.string().trim().min(1).max(512).transform(normSep).refine((glob) => !glob.startsWith("/") && !glob.startsWith("!") && !/^[a-z]:/i.test(glob) && !glob.split("/").includes(".."), "扫描排除必须是项目内的正向相对 glob")).max(100);
14
+ export function projectScanIgnores(root) {
15
+ const file = path.join(root, ".pm", "project.json");
16
+ let raw;
17
+ try {
18
+ raw = fs.readFileSync(file, "utf8");
19
+ }
20
+ catch (error) {
21
+ if (error.code === "ENOENT")
22
+ return [];
23
+ throw error;
24
+ }
25
+ return [...new Set(ScanIgnoreSchema.parse(JSON.parse(raw).scan_ignore ?? []))].sort();
26
+ }
27
+ export function ignoreMatcher(globs) {
28
+ const patterns = globs.map(globToRegExp);
29
+ return (rel, directory = false) => patterns.some((pattern) => pattern.test(rel) || (directory && pattern.test(`${rel}/`)));
30
+ }
31
+ export function scanExclusionNote(root) {
32
+ const ignores = projectScanIgnores(root);
33
+ return ignores.length ? [`扫描范围排除:${ignores.join(", ")};排除内容不属于本次检查结论。`] : [];
34
+ }