@luckychen1993/pm-mcp 0.1.5 → 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.
- package/LICENSE +21 -21
- package/README.md +102 -10
- package/dist/agents-md.js +3 -3
- package/dist/audit.js +25 -21
- package/dist/cli.js +32 -0
- package/dist/dashboard.js +6 -4
- package/dist/doctor.js +148 -0
- package/dist/git-state.js +75 -0
- package/dist/governance-tools.js +12 -2
- package/dist/index-db.js +40 -0
- package/dist/index-store.js +35 -44
- package/dist/index.js +15 -8
- package/dist/knowledge-tools.js +6 -15
- package/dist/language-adapters.js +11 -10
- package/dist/license.js +28 -2
- package/dist/probe.js +141 -0
- package/dist/project-fingerprint.js +9 -5
- package/dist/project-tools.js +13 -2
- package/dist/quality-evidence.js +56 -0
- package/dist/quality-store.js +5 -25
- package/dist/registry.js +28 -2
- package/dist/scan-policy.js +34 -0
- package/dist/scan.js +5 -25
- package/dist/search.js +4 -0
- package/dist/security.js +2 -0
- package/dist/semantic-evidence-store.js +22 -0
- package/dist/semantic-parsers.js +18 -0
- package/dist/session-log.js +14 -0
- package/dist/setup-project.js +146 -0
- package/dist/setup.js +92 -94
- package/dist/task-evidence.js +110 -0
- package/dist/task-pagination.js +64 -0
- package/dist/task-tools.js +47 -37
- package/dist/tool-base.js +7 -5
- package/dist/types.js +5 -0
- package/dist/version.js +10 -0
- package/package.json +7 -4
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
|
-
|
|
5
|
-
|
|
6
|
-
|
|
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);
|
package/dist/project-tools.js
CHANGED
|
@@ -9,6 +9,9 @@ import { loadProject, loadRoadmap, loadSecurity, loadSessions, loadTasks, savePr
|
|
|
9
9
|
import { now } from "./types.js";
|
|
10
10
|
import { budgetLines, toolI, toolR, toolW } from "./tool-base.js";
|
|
11
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";
|
|
12
15
|
function taskLine(t, withNext = false) {
|
|
13
16
|
const parts = [`[${t.status}]`, t.id, t.title, `(${t.type}${t.milestone ? `,${t.milestone}` : ""})`];
|
|
14
17
|
if (withNext && t.checkpoint) {
|
|
@@ -46,6 +49,7 @@ export function registerProjectTools(server, root) {
|
|
|
46
49
|
const security = loadSecurity(root);
|
|
47
50
|
const L = [];
|
|
48
51
|
L.push(`# ${project.name}${project.phase ? ` — ${project.phase}` : ""}`);
|
|
52
|
+
L.push(`运行版本: pm-mcp ${VERSION} · 项目根: ${root}`);
|
|
49
53
|
if (project.description)
|
|
50
54
|
L.push(project.description);
|
|
51
55
|
L.push("");
|
|
@@ -96,8 +100,9 @@ export function registerProjectTools(server, root) {
|
|
|
96
100
|
}
|
|
97
101
|
L.push("");
|
|
98
102
|
L.push("> 三段式定位:本状态 → search_code 精确到行 → 才读具体文件。别全量读代码。");
|
|
99
|
-
return foldLines(L, { maxLines: budgetLines(root) });
|
|
100
|
-
})
|
|
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 } }; } });
|
|
101
106
|
toolR(server, root, "get_usage_log", "使用日志:pm-mcp 被用了哪些工具、成败、耗时,以及输出 token 估算与「折叠省下的 token」——回答“用它到底省不省 token”。只记工具名与计量,不记参数内容。日志滚动保留约 2MB。", {
|
|
102
107
|
last: z.number().int().min(1).max(200).optional().describe("返回最近 N 条明细(默认 20;汇总始终覆盖全部保留日志)"),
|
|
103
108
|
tool: z.string().optional().describe("只看某工具,如 get_status"),
|
|
@@ -124,6 +129,8 @@ export function registerProjectTools(server, root) {
|
|
|
124
129
|
license: z.string().optional(),
|
|
125
130
|
exposure: z.enum(["local", "network", "public"]).optional(),
|
|
126
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并记录原因"),
|
|
127
134
|
max_file_loc: z.number().int().min(10).optional().describe("单文件行数预算"),
|
|
128
135
|
refactor_quota_pct: z.number().min(0).max(100).optional().describe("里程碑重构类任务最低占比%"),
|
|
129
136
|
session_blast_radius: z.number().int().min(1).optional().describe("单会话波及文件阈值"),
|
|
@@ -145,6 +152,10 @@ export function registerProjectTools(server, root) {
|
|
|
145
152
|
project.exposure = args.exposure;
|
|
146
153
|
if (args.modules !== undefined)
|
|
147
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);
|
|
148
159
|
if (args.max_file_loc !== undefined)
|
|
149
160
|
project.budgets.maxFileLoc = args.max_file_loc;
|
|
150
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
|
+
}
|
package/dist/quality-store.js
CHANGED
|
@@ -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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
+
}
|
package/dist/scan.js
CHANGED
|
@@ -2,33 +2,13 @@ import fs from "node:fs";
|
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { globToRegExp, normSep } from "./budget.js";
|
|
4
4
|
import { aggregates, getIndex, walkRefresh } from "./index-store.js";
|
|
5
|
+
import { SCAN_IGNORE_DIRS, ignoreMatcher, projectScanIgnores } from "./scan-policy.js";
|
|
5
6
|
/**
|
|
6
7
|
* 代码树扫描:低层走查(生成器,内存恒定)+ scanProject 聚合入口。
|
|
7
8
|
* 内容级结果存 SQLite 索引(index-store),未变更文件永不重读。
|
|
8
9
|
* 超大项目保护:>10 万文件不物化 files 数组(聚合与点名走 SQL)。
|
|
9
10
|
*/
|
|
10
|
-
export const DEFAULT_IGNORE_DIRS =
|
|
11
|
-
"node_modules",
|
|
12
|
-
".git",
|
|
13
|
-
"dist",
|
|
14
|
-
"build",
|
|
15
|
-
"out",
|
|
16
|
-
".next",
|
|
17
|
-
".nuxt",
|
|
18
|
-
".output",
|
|
19
|
-
".turbo",
|
|
20
|
-
"coverage",
|
|
21
|
-
"__pycache__",
|
|
22
|
-
".venv",
|
|
23
|
-
"venv",
|
|
24
|
-
"env",
|
|
25
|
-
".pm",
|
|
26
|
-
".idea",
|
|
27
|
-
".vscode",
|
|
28
|
-
"target",
|
|
29
|
-
".cache",
|
|
30
|
-
".gradle",
|
|
31
|
-
]);
|
|
11
|
+
export const DEFAULT_IGNORE_DIRS = SCAN_IGNORE_DIRS;
|
|
32
12
|
/** 真实目录树不太可能超过 64 层;超限的计数上报,绝不静默丢失 */
|
|
33
13
|
export const DEPTH_LIMIT = 64;
|
|
34
14
|
export const BINARY_EXTS = new Set([
|
|
@@ -112,7 +92,7 @@ export function needsSkipCount(rel, isTest) {
|
|
|
112
92
|
* onSkippedDeep 在超深路径被跳过时回调(计数上报用)。
|
|
113
93
|
*/
|
|
114
94
|
export function* walkStatEntries(root, opts = {}, onSkippedDeep) {
|
|
115
|
-
const
|
|
95
|
+
const excluded = ignoreMatcher([...projectScanIgnores(root), ...(opts.extraIgnores ?? [])]);
|
|
116
96
|
const includeRe = opts.include?.map(globToRegExp);
|
|
117
97
|
const maxBytes = opts.maxFileBytes ?? 2 * 1024 * 1024;
|
|
118
98
|
void maxBytes;
|
|
@@ -134,12 +114,12 @@ export function* walkStatEntries(root, opts = {}, onSkippedDeep) {
|
|
|
134
114
|
if (entry.isDirectory()) {
|
|
135
115
|
if (DEFAULT_IGNORE_DIRS.has(entry.name))
|
|
136
116
|
continue;
|
|
137
|
-
if (
|
|
117
|
+
if (excluded(rel, true))
|
|
138
118
|
continue;
|
|
139
119
|
yield* walk(abs, depth + 1);
|
|
140
120
|
}
|
|
141
121
|
else if (entry.isFile()) {
|
|
142
|
-
if (
|
|
122
|
+
if (excluded(rel))
|
|
143
123
|
continue;
|
|
144
124
|
if (includeRe && !includeRe.some((re) => re.test(rel)))
|
|
145
125
|
continue;
|
package/dist/search.js
CHANGED
|
@@ -6,6 +6,7 @@ import { decisionsDir } from "./paths.js";
|
|
|
6
6
|
import { loadGovernance } from "./governance-model.js";
|
|
7
7
|
import { listFiles, DEFAULT_IGNORE_DIRS } from "./scan.js";
|
|
8
8
|
import { foldLines, normSep } from "./budget.js";
|
|
9
|
+
import { projectScanIgnores } from "./scan-policy.js";
|
|
9
10
|
/**
|
|
10
11
|
* 求证与检索:search_code(锚定真实代码)+ search_knowledge(历史结论)。
|
|
11
12
|
* token 经济的一部分:返回 file:line 片段而非整文件。
|
|
@@ -36,6 +37,7 @@ export function rgSearch(root, query, glob, maxResults, regex) {
|
|
|
36
37
|
args.push("-g", "!.pm/**");
|
|
37
38
|
else
|
|
38
39
|
args.push("-g", `!${dir}/**`);
|
|
40
|
+
args.push("-g", `!**/${dir}/**`);
|
|
39
41
|
}
|
|
40
42
|
args.push("--max-count", String(maxResults * 3));
|
|
41
43
|
if (regex)
|
|
@@ -44,6 +46,8 @@ export function rgSearch(root, query, glob, maxResults, regex) {
|
|
|
44
46
|
args.push("-F", "-e", query);
|
|
45
47
|
if (glob)
|
|
46
48
|
args.push("--glob", glob);
|
|
49
|
+
for (const excluded of projectScanIgnores(root))
|
|
50
|
+
args.push("--glob", `!${excluded}`);
|
|
47
51
|
args.push(".");
|
|
48
52
|
const r = spawnSync("rg", args, { cwd: root, encoding: "utf8", timeout: 120_000, maxBuffer: 64 * 1024 * 1024 });
|
|
49
53
|
if (process.env.PM_RG_DEBUG)
|
package/dist/security.js
CHANGED
|
@@ -7,6 +7,7 @@ import { readDirectDeps } from "./scan.js";
|
|
|
7
7
|
import { ensureFresh, getIndex, iterateFileRows, walkRefresh } from "./index-store.js";
|
|
8
8
|
import { readText } from "./search.js";
|
|
9
9
|
import { now } from "./types.js";
|
|
10
|
+
import { scanExclusionNote } from "./scan-policy.js";
|
|
10
11
|
const SECRET_PATTERNS = [
|
|
11
12
|
{ id: "secret.aws-access-key", severity: "high", pattern: /AKIA[0-9A-Z]{16}/, message: "疑似 AWS Access Key" },
|
|
12
13
|
{ id: "secret.github-token", severity: "high", pattern: /gh[pousr]_[A-Za-z0-9]{20,}/, message: "疑似 GitHub Token" },
|
|
@@ -265,6 +266,7 @@ export function auditSecurity(root, prepared) {
|
|
|
265
266
|
const high = open.filter((f) => f.severity === "high");
|
|
266
267
|
const L = [];
|
|
267
268
|
L.push(`## 安全体检报告(exposure=${project.exposure})`);
|
|
269
|
+
L.push(...scanExclusionNote(root));
|
|
268
270
|
// exposure 兑现:不同暴露面使用不同处置口径
|
|
269
271
|
if (project.exposure === "public") {
|
|
270
272
|
const mustFix = open.filter((f) => f.severity === "high" || f.severity === "medium");
|
|
@@ -102,6 +102,28 @@ export function saveSemanticEvidence(root, id, input) {
|
|
|
102
102
|
return parsed.data;
|
|
103
103
|
});
|
|
104
104
|
}
|
|
105
|
+
/** Atomically replace the evidence set for one repository-relative source file under the ledger lock. */
|
|
106
|
+
export function replaceSemanticEvidenceForFile(root, id, input) {
|
|
107
|
+
const safeId = validateSemanticEvidenceId(id);
|
|
108
|
+
const parsed = SemanticEvidenceDocumentSchema.safeParse(input);
|
|
109
|
+
if (!parsed.success) {
|
|
110
|
+
throw new Error(`语义证据 schema 校验失败: ${parsed.error.issues.map((issue) => `${issue.path.join(".") || "document"}: ${issue.message}`).join("; ")}`);
|
|
111
|
+
}
|
|
112
|
+
const absoluteRoot = path.resolve(root);
|
|
113
|
+
const directory = semanticEvidenceDir(absoluteRoot);
|
|
114
|
+
assertRealDirectoryWithinRoot(absoluteRoot, directory, true);
|
|
115
|
+
return withLedgerLock(absoluteRoot, () => {
|
|
116
|
+
assertRealDirectoryWithinRoot(absoluteRoot, directory, false);
|
|
117
|
+
const oldIds = evidenceIdsUnlocked(absoluteRoot)
|
|
118
|
+
.filter((candidate) => candidate !== safeId)
|
|
119
|
+
.filter((candidate) => loadUnlocked(absoluteRoot, candidate).file === parsed.data.file);
|
|
120
|
+
atomicWrite(semanticEvidencePath(absoluteRoot, safeId), `${JSON.stringify(parsed.data, null, 2)}
|
|
121
|
+
`);
|
|
122
|
+
for (const oldId of oldIds)
|
|
123
|
+
fs.rmSync(semanticEvidencePath(absoluteRoot, oldId), { force: false });
|
|
124
|
+
return parsed.data;
|
|
125
|
+
});
|
|
126
|
+
}
|
|
105
127
|
export function loadSemanticEvidence(root, id) {
|
|
106
128
|
return loadUnlocked(path.resolve(root), validateSemanticEvidenceId(id));
|
|
107
129
|
}
|
package/dist/semantic-parsers.js
CHANGED
|
@@ -298,6 +298,24 @@ export function candidates(from, specifier, language, ownerRoots = []) {
|
|
|
298
298
|
if (base === ".." || base.startsWith("../"))
|
|
299
299
|
continue;
|
|
300
300
|
out.add(base);
|
|
301
|
+
// TypeScript ESM convention: source specifiers ending in .js/.jsx/.mjs/.cjs often
|
|
302
|
+
// refer to .ts/.tsx/.mts/.cts files on disk (tsc rewriteRelativeImportExtensions
|
|
303
|
+
// and Node type stripping both map them this way). Candidates are appended after
|
|
304
|
+
// the literal path, so an existing .js file still wins resolution.
|
|
305
|
+
const specExt = path.posix.extname(base);
|
|
306
|
+
if (specExt === ".js" || specExt === ".jsx" || specExt === ".mjs" || specExt === ".cjs") {
|
|
307
|
+
const stem = base.slice(0, base.length - specExt.length);
|
|
308
|
+
if (specExt === ".js") {
|
|
309
|
+
out.add(`${stem}.ts`);
|
|
310
|
+
out.add(`${stem}.tsx`);
|
|
311
|
+
}
|
|
312
|
+
else if (specExt === ".jsx")
|
|
313
|
+
out.add(`${stem}.tsx`);
|
|
314
|
+
else if (specExt === ".mjs")
|
|
315
|
+
out.add(`${stem}.mts`);
|
|
316
|
+
else
|
|
317
|
+
out.add(`${stem}.cts`);
|
|
318
|
+
}
|
|
301
319
|
if (!path.posix.extname(base)) {
|
|
302
320
|
for (const ext of RESOLVE_EXTS)
|
|
303
321
|
out.add(base + ext);
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { loadSessions, nextId, saveSessions } from "./store.js";
|
|
2
|
+
import { now } from "./types.js";
|
|
3
|
+
import { captureFileHashes, projectFilePath } from "./git-state.js";
|
|
4
|
+
/** 调用方持有账本锁;共享显式会话与任务自动会话的同一记录方式。 */
|
|
5
|
+
export function appendSession(root, input, hashes) {
|
|
6
|
+
const files = (input.files ?? []).map(projectFilePath);
|
|
7
|
+
const fileHashes = hashes ?? captureFileHashes(root, files);
|
|
8
|
+
const data = loadSessions(root);
|
|
9
|
+
const id = nextId("S", data.seq, 4);
|
|
10
|
+
data.seq += 1;
|
|
11
|
+
data.sessions.push({ id, date: now(), author: input.author ?? "", summary: input.summary, files, next_steps: input.next_steps ?? [], file_hashes: fileHashes });
|
|
12
|
+
saveSessions(root, data);
|
|
13
|
+
return id;
|
|
14
|
+
}
|