@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.
- package/LICENSE +21 -21
- package/README.md +114 -11
- package/dist/agents-md.js +3 -3
- package/dist/audit-tools.js +17 -0
- package/dist/audit.js +25 -21
- package/dist/cli.js +54 -0
- package/dist/dashboard.js +6 -4
- package/dist/doctor.js +148 -0
- package/dist/duplicates.js +228 -0
- package/dist/git-state.js +75 -0
- package/dist/governance-audit.js +98 -1
- package/dist/governance-tools.js +21 -3
- 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/osv.js +139 -0
- 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-graph.js +2 -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/ui.js +191 -0
- package/dist/version.js +10 -0
- package/package.json +10 -4
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { foldLines } from "./budget.js";
|
|
4
|
+
import { ensureFresh, getIndex } from "./index-store.js";
|
|
5
|
+
/**
|
|
6
|
+
* 重复代码检测(零依赖、确定性):行指纹滑动窗口聚类。
|
|
7
|
+
*
|
|
8
|
+
* 归一化规则:剥离注释(按语言族:// 与 # 、块注释 /* *\/)、字符串字面量内容替换为占位符、
|
|
9
|
+
* 空白折叠——因此注释/空白/字面量文本的差异不算差异,标识符与结构差异才算。
|
|
10
|
+
* 聚类:对每个哈希相同且出现在 ≥2 个文件的窗口做连续对齐合并(成对扩展成长块),
|
|
11
|
+
* 长度 ≥ minLines(有效代码行)的块按长度降序输出。仅跨文件重复计入 v1。
|
|
12
|
+
*/
|
|
13
|
+
export const DUPLICATE_SOURCE_EXTENSIONS = new Set([
|
|
14
|
+
".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".mts", ".cts",
|
|
15
|
+
".py", ".go", ".rs", ".java", ".kt", ".kts", ".cs", ".c", ".h", ".cpp", ".hpp",
|
|
16
|
+
".php", ".rb", ".swift", ".scala", ".vue", ".svelte",
|
|
17
|
+
]);
|
|
18
|
+
const MAX_FILE_BYTES = 400 * 1024;
|
|
19
|
+
const MAX_RUNS = 5000;
|
|
20
|
+
const MAX_LOCATIONS_PER_HASH = 8;
|
|
21
|
+
const SLASH_STYLE = { lineMarkers: ["//"], blockOpen: "/*", blockClose: "*/" };
|
|
22
|
+
const HASH_STYLE = { lineMarkers: ["#"], blockOpen: null, blockClose: null };
|
|
23
|
+
function styleFor(ext) {
|
|
24
|
+
return ext === ".py" || ext === ".rb" ? HASH_STYLE : SLASH_STYLE;
|
|
25
|
+
}
|
|
26
|
+
/** 单遍扫描:跨行维护块注释与字符串状态;字符串内容丢弃、留一个占位符。 */
|
|
27
|
+
function normalizeLines(content, style) {
|
|
28
|
+
const out = [];
|
|
29
|
+
let inBlock = false;
|
|
30
|
+
let inString = null;
|
|
31
|
+
let buffer = "";
|
|
32
|
+
let lineNo = 1;
|
|
33
|
+
const flush = () => {
|
|
34
|
+
const norm = buffer.replace(/\s+/g, " ").trim();
|
|
35
|
+
if (norm.length > 0)
|
|
36
|
+
out.push({ norm, line: lineNo });
|
|
37
|
+
buffer = "";
|
|
38
|
+
};
|
|
39
|
+
for (let i = 0; i < content.length; i++) {
|
|
40
|
+
const ch = content[i];
|
|
41
|
+
if (ch === "\n") {
|
|
42
|
+
flush();
|
|
43
|
+
lineNo += 1;
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
if (inBlock) {
|
|
47
|
+
if (style.blockClose && content.startsWith(style.blockClose, i)) {
|
|
48
|
+
inBlock = false;
|
|
49
|
+
i += style.blockClose.length - 1;
|
|
50
|
+
}
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
if (inString) {
|
|
54
|
+
if (ch === "\\") {
|
|
55
|
+
i += 1;
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
if (ch === inString)
|
|
59
|
+
inString = null;
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
if (style.blockOpen && content.startsWith(style.blockOpen, i)) {
|
|
63
|
+
inBlock = true;
|
|
64
|
+
i += style.blockOpen.length - 1;
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
if (style.lineMarkers.some((marker) => content.startsWith(marker, i))) {
|
|
68
|
+
while (i < content.length && content[i] !== "\n")
|
|
69
|
+
i += 1;
|
|
70
|
+
i -= 1;
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
if (ch === '"' || ch === "'" || ch === "`") {
|
|
74
|
+
inString = ch;
|
|
75
|
+
buffer += "s";
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
buffer += ch;
|
|
79
|
+
}
|
|
80
|
+
flush();
|
|
81
|
+
return out;
|
|
82
|
+
}
|
|
83
|
+
/** 双 32 位 djb2 变体拼成 64-bit 指纹;跨平台确定性,够抵御行级指纹碰撞。 */
|
|
84
|
+
function hashLines(joined) {
|
|
85
|
+
let h1 = 5381;
|
|
86
|
+
let h2 = 52711;
|
|
87
|
+
for (let i = 0; i < joined.length; i++) {
|
|
88
|
+
const code = joined.charCodeAt(i);
|
|
89
|
+
h1 = ((h1 * 33) ^ code) >>> 0;
|
|
90
|
+
h2 = (h2 * 31 + code) >>> 0;
|
|
91
|
+
}
|
|
92
|
+
return h1.toString(16).padStart(8, "0") + h2.toString(16).padStart(8, "0");
|
|
93
|
+
}
|
|
94
|
+
const clamp = (value, min, max, fallback) => {
|
|
95
|
+
const n = Math.trunc(value ?? fallback);
|
|
96
|
+
return Math.min(max, Math.max(min, Number.isFinite(n) ? n : fallback));
|
|
97
|
+
};
|
|
98
|
+
export function findDuplicateBlocks(root, options = {}) {
|
|
99
|
+
const started = Date.now();
|
|
100
|
+
const minLines = clamp(options.minLines, 4, 20, 6);
|
|
101
|
+
const limit = clamp(options.limit, 1, 100, 20);
|
|
102
|
+
ensureFresh(root);
|
|
103
|
+
const db = getIndex(root);
|
|
104
|
+
const rows = db.prepare("SELECT rel, size, oversize FROM files WHERE skip = 0").all();
|
|
105
|
+
let skippedOversize = rows.filter((row) => row.oversize !== 0).length;
|
|
106
|
+
const normalized = new Map();
|
|
107
|
+
let scannedFiles = 0;
|
|
108
|
+
for (const row of rows) {
|
|
109
|
+
const ext = path.extname(row.rel).toLowerCase();
|
|
110
|
+
if (!DUPLICATE_SOURCE_EXTENSIONS.has(ext))
|
|
111
|
+
continue;
|
|
112
|
+
if (row.oversize !== 0 || row.size > MAX_FILE_BYTES) {
|
|
113
|
+
skippedOversize += 1;
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
let content;
|
|
117
|
+
try {
|
|
118
|
+
content = fs.readFileSync(path.join(root, row.rel), "utf8");
|
|
119
|
+
}
|
|
120
|
+
catch {
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
normalized.set(row.rel, normalizeLines(content, styleFor(ext)));
|
|
124
|
+
scannedFiles += 1;
|
|
125
|
+
}
|
|
126
|
+
// 窗口指纹 → 位置(按文件名规范排序,保证成对遍历确定性)
|
|
127
|
+
const windows = new Map();
|
|
128
|
+
for (const [rel, lines] of normalized) {
|
|
129
|
+
if (lines.length < minLines)
|
|
130
|
+
continue;
|
|
131
|
+
for (let i = 0; i + minLines <= lines.length; i++) {
|
|
132
|
+
const slice = lines.slice(i, i + minLines);
|
|
133
|
+
const key = hashLines(slice.map((item) => item.norm).join("\n"));
|
|
134
|
+
const bucket = windows.get(key);
|
|
135
|
+
if (bucket)
|
|
136
|
+
bucket.push({ file: rel, index: i, line: slice[0].line });
|
|
137
|
+
else
|
|
138
|
+
windows.set(key, [{ file: rel, index: i, line: slice[0].line }]);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
// 先按哈希收集全部成对匹配,再按索引排序做连续对齐合并——顺序处理才能拼出完整长块
|
|
142
|
+
const pairMatches = new Map();
|
|
143
|
+
let truncated = false;
|
|
144
|
+
for (const locations of windows.values()) {
|
|
145
|
+
if (locations.length < 2)
|
|
146
|
+
continue;
|
|
147
|
+
const sorted = [...locations].sort((x, y) => x.file.localeCompare(y.file) || x.index - y.index);
|
|
148
|
+
if (sorted.length > MAX_LOCATIONS_PER_HASH)
|
|
149
|
+
truncated = true;
|
|
150
|
+
const capped = sorted.slice(0, MAX_LOCATIONS_PER_HASH);
|
|
151
|
+
for (let i = 0; i < capped.length; i++) {
|
|
152
|
+
for (let j = i + 1; j < capped.length; j++) {
|
|
153
|
+
const p = capped[i], q = capped[j];
|
|
154
|
+
if (p.file === q.file)
|
|
155
|
+
continue; // v1 只报跨文件重复
|
|
156
|
+
const pairKey = p.file < q.file ? `${p.file}\u0000${q.file}` : `${q.file}\u0000${p.file}`;
|
|
157
|
+
const forward = p.file < q.file;
|
|
158
|
+
const match = forward
|
|
159
|
+
? { a: p.file, b: q.file, indexA: p.index, indexB: q.index }
|
|
160
|
+
: { a: q.file, b: p.file, indexA: q.index, indexB: p.index };
|
|
161
|
+
const list = pairMatches.get(pairKey);
|
|
162
|
+
if (list)
|
|
163
|
+
list.push(match);
|
|
164
|
+
else
|
|
165
|
+
pairMatches.set(pairKey, [match]);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
const pairRuns = new Map();
|
|
170
|
+
const runs = [];
|
|
171
|
+
for (const [pairKey, matches] of pairMatches) {
|
|
172
|
+
matches.sort((x, y) => x.indexA - y.indexA || x.indexB - y.indexB);
|
|
173
|
+
for (const match of matches) {
|
|
174
|
+
const list = pairRuns.get(pairKey);
|
|
175
|
+
const previous = list?.[list.length - 1];
|
|
176
|
+
if (previous && previous.endA === match.indexA - 1 && previous.endB === match.indexB - 1) {
|
|
177
|
+
previous.endA = match.indexA;
|
|
178
|
+
previous.endB = match.indexB;
|
|
179
|
+
previous.len += 1;
|
|
180
|
+
continue;
|
|
181
|
+
}
|
|
182
|
+
if (runs.length >= MAX_RUNS) {
|
|
183
|
+
truncated = true;
|
|
184
|
+
break;
|
|
185
|
+
}
|
|
186
|
+
const run = { a: match.a, b: match.b, startA: match.indexA, startB: match.indexB, endA: match.indexA, endB: match.indexB, len: 1 };
|
|
187
|
+
if (list)
|
|
188
|
+
list.push(run);
|
|
189
|
+
else
|
|
190
|
+
pairRuns.set(pairKey, [run]);
|
|
191
|
+
runs.push(run);
|
|
192
|
+
}
|
|
193
|
+
if (truncated)
|
|
194
|
+
break;
|
|
195
|
+
}
|
|
196
|
+
const linesOf = (rel) => normalized.get(rel) ?? [];
|
|
197
|
+
const toRange = (file, startIndex, len) => {
|
|
198
|
+
const lines = linesOf(file);
|
|
199
|
+
const first = lines[startIndex]?.line ?? startIndex + 1;
|
|
200
|
+
const last = lines[Math.min(startIndex + len - 1, lines.length - 1)]?.line ?? first;
|
|
201
|
+
return { file, start: first, end: Math.max(first, last) };
|
|
202
|
+
};
|
|
203
|
+
const groups = runs
|
|
204
|
+
.sort((x, y) => y.len - x.len || x.a.localeCompare(y.a) || x.b.localeCompare(y.b) || x.startA - y.startA)
|
|
205
|
+
.slice(0, limit)
|
|
206
|
+
.map((run) => ({
|
|
207
|
+
lines: run.len + minLines - 1,
|
|
208
|
+
members: [toRange(run.a, run.startA, run.len + minLines - 1), toRange(run.b, run.startB, run.len + minLines - 1)],
|
|
209
|
+
}));
|
|
210
|
+
return { scannedFiles, skippedOversize, threshold: minLines, groups, truncated, durationMs: Date.now() - started };
|
|
211
|
+
}
|
|
212
|
+
export function renderDuplicates(root, options = {}, maxLines = 150) {
|
|
213
|
+
const scan = findDuplicateBlocks(root, options);
|
|
214
|
+
const lines = [
|
|
215
|
+
"## 重复代码",
|
|
216
|
+
`- 扫描: ${scan.scannedFiles} 个源文件(超限跳过 ${scan.skippedOversize})· 阈值 ≥${scan.threshold} 有效代码行 · 跨文件 · 耗时 ${scan.durationMs}ms`,
|
|
217
|
+
];
|
|
218
|
+
if (scan.groups.length === 0)
|
|
219
|
+
lines.push("- ✅ 未发现跨文件重复块");
|
|
220
|
+
else {
|
|
221
|
+
lines.push(`- 共列出 ${scan.groups.length} 组重复(按长度降序${scan.truncated ? ",已达运行上限截断" : ""}):`);
|
|
222
|
+
scan.groups.forEach((group, index) => {
|
|
223
|
+
const members = group.members.map((m) => `${m.file}:${m.start}-${m.end}`).join(" ↔ ");
|
|
224
|
+
lines.push(`- 组${index + 1}(${group.lines} 行)${members}`);
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
return foldLines(lines, { maxLines, hint: "调高 min_lines 只看大块克隆;检测不改变任何台账状态" });
|
|
228
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { createHash } from "node:crypto";
|
|
4
|
+
import { spawnSync } from "node:child_process";
|
|
5
|
+
import { normSep } from "./budget.js";
|
|
6
|
+
import { hashRegularFile } from "./project-fingerprint.js";
|
|
7
|
+
export function projectFilePath(relative) {
|
|
8
|
+
const rel = path.posix.normalize(normSep(relative));
|
|
9
|
+
if (!relative || path.isAbsolute(rel) || /^[a-z]:/i.test(rel) || rel === "." || rel === ".." || rel.startsWith("../")) {
|
|
10
|
+
throw new Error("文件必须是项目内的相对路径");
|
|
11
|
+
}
|
|
12
|
+
return rel;
|
|
13
|
+
}
|
|
14
|
+
/** 使用 NUL 分隔,避免中文转义、空格及文件名中的箭头被误解析。 */
|
|
15
|
+
export function changedGitFiles(root) {
|
|
16
|
+
const result = spawnSync("git", ["status", "--porcelain=v1", "-z", "-uall"], { cwd: root, encoding: "utf8", timeout: 10000, maxBuffer: 16 * 1024 * 1024 });
|
|
17
|
+
if (result.error || result.status !== 0)
|
|
18
|
+
throw new Error(result.error?.message ?? `git status 退出码 ${result.status}`);
|
|
19
|
+
const records = result.stdout.split("\0");
|
|
20
|
+
const changed = new Set();
|
|
21
|
+
const add = (file) => {
|
|
22
|
+
const rel = projectFilePath(file);
|
|
23
|
+
if (!rel.startsWith(".pm/") && rel !== "PROJECT.md" && rel !== ".gitignore")
|
|
24
|
+
changed.add(rel);
|
|
25
|
+
};
|
|
26
|
+
for (let i = 0; i < records.length; i++) {
|
|
27
|
+
const row = records[i];
|
|
28
|
+
if (!row)
|
|
29
|
+
continue;
|
|
30
|
+
add(row.slice(3));
|
|
31
|
+
const status = row.slice(0, 2);
|
|
32
|
+
if (/[RC]/.test(status)) {
|
|
33
|
+
const original = records[++i]; // -z 先输出目标,再输出原路径
|
|
34
|
+
if (/R/.test(status))
|
|
35
|
+
add(original); // 重命名还需要核对旧路径的删除;复制不改原文件
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return [...changed];
|
|
39
|
+
}
|
|
40
|
+
/** 只对调用者声明的文件流式取摘要,不跟随父目录链接或读取链接指向的内容。 */
|
|
41
|
+
export function captureFileHashes(root, files) {
|
|
42
|
+
const hashes = Object.create(null);
|
|
43
|
+
for (const file of files) {
|
|
44
|
+
const rel = projectFilePath(file);
|
|
45
|
+
let current = path.resolve(root);
|
|
46
|
+
let signature = "missing";
|
|
47
|
+
const parts = rel.split("/");
|
|
48
|
+
for (let index = 0; index < parts.length; index++) {
|
|
49
|
+
current = path.join(current, parts[index]);
|
|
50
|
+
let stat;
|
|
51
|
+
try {
|
|
52
|
+
stat = fs.lstatSync(current);
|
|
53
|
+
}
|
|
54
|
+
catch (error) {
|
|
55
|
+
if (error.code === "ENOENT")
|
|
56
|
+
break;
|
|
57
|
+
throw error;
|
|
58
|
+
}
|
|
59
|
+
if (stat.isSymbolicLink()) {
|
|
60
|
+
if (index !== parts.length - 1)
|
|
61
|
+
throw new Error(`文件路径穿过符号链接: ${rel}`);
|
|
62
|
+
signature = `link\0${fs.readlinkSync(current)}`;
|
|
63
|
+
}
|
|
64
|
+
else if (index === parts.length - 1) {
|
|
65
|
+
if (!stat.isFile())
|
|
66
|
+
throw new Error(`会话文件必须是普通文件或已删除文件: ${rel}`);
|
|
67
|
+
signature = `file\0${hashRegularFile(current).sha256}`;
|
|
68
|
+
}
|
|
69
|
+
else if (!stat.isDirectory())
|
|
70
|
+
throw new Error(`文件路径不是目录: ${rel}`);
|
|
71
|
+
}
|
|
72
|
+
hashes[rel] = createHash("sha256").update(signature).digest("hex");
|
|
73
|
+
}
|
|
74
|
+
return hashes;
|
|
75
|
+
}
|
package/dist/governance-audit.js
CHANGED
|
@@ -5,6 +5,7 @@ import { loadGovernance } from "./governance-model.js";
|
|
|
5
5
|
import { assessQualityCoverage, createQualityPlan, discoverProjectUnits } from "./language-adapters.js";
|
|
6
6
|
import { buildSemanticGraph, moduleForFile } from "./semantic-graph.js";
|
|
7
7
|
import { loadAllSemanticEvidence } from "./semantic-evidence-store.js";
|
|
8
|
+
import { relPath } from "./semantic-parsers.js";
|
|
8
9
|
function languageIssues(governance, graph) {
|
|
9
10
|
const issues = [];
|
|
10
11
|
for (const module of governance.modules) {
|
|
@@ -64,6 +65,13 @@ function graphIssues(graph) {
|
|
|
64
65
|
for (const cycle of graph.cycles) {
|
|
65
66
|
issues.push({ severity: "error", code: "module-cycle", message: `模块依赖循环: ${cycle.join(" -> ")} -> ${cycle[0]}`, evidence: cycle });
|
|
66
67
|
}
|
|
68
|
+
// 文件级循环通常是 barrel/重导出或真实的循环依赖;比模块环轻,作警告可见。
|
|
69
|
+
for (const cycle of graph.fileCycles.slice(0, 10)) {
|
|
70
|
+
issues.push({ severity: "warning", code: "file-cycle", message: `文件级依赖循环: ${cycle.join(" -> ")} -> ${cycle[0]}`, evidence: cycle });
|
|
71
|
+
}
|
|
72
|
+
if (graph.fileCycles.length > 10) {
|
|
73
|
+
issues.push({ severity: "warning", code: "file-cycle-truncated", message: `文件级依赖循环共 ${graph.fileCycles.length} 个,仅列出前 10 个`, evidence: [] });
|
|
74
|
+
}
|
|
67
75
|
return issues;
|
|
68
76
|
}
|
|
69
77
|
function render(governance, graph, units, qualityCoverage, issues, maxLines) {
|
|
@@ -73,7 +81,7 @@ function render(governance, graph, units, qualityCoverage, issues, maxLines) {
|
|
|
73
81
|
"## 跨文件/模块/语言治理审计",
|
|
74
82
|
`${errors.length === 0 ? "✅" : "🚩"} modules=${governance.modules.length} · interfaces=${governance.interfaces.length} · repositories=${governance.repositories.length} · errors=${errors.length} · warnings=${warnings.length}`,
|
|
75
83
|
`语义覆盖: source ${graph.coverage.sourceCoveragePct}%(${graph.coverage.parsedFiles}/${graph.coverage.sourceCandidateFiles})· assurance ${graph.coverage.semanticAssurancePct}% · AST ${graph.coverage.astFiles} · runtime ${graph.coverage.runtimeFiles} · heuristic ${graph.coverage.heuristicFiles} · internal resolution ${graph.coverage.resolutionPct}% · unresolved ${graph.coverage.unresolvedInternal}`,
|
|
76
|
-
`关系: files ${graph.files.length} · file edges ${graph.fileEdges.length} · module edges ${graph.moduleEdges.length} · contracts ${graph.contracts.length} · cycles ${graph.cycles.length}`,
|
|
84
|
+
`关系: files ${graph.files.length} · file edges ${graph.fileEdges.length} · module edges ${graph.moduleEdges.length} · contracts ${graph.contracts.length} · cycles ${graph.cycles.length} · file cycles ${graph.fileCycles.length}`,
|
|
77
85
|
`质量矩阵: units ${qualityCoverage.totalUnits} · covered ${qualityCoverage.unitsWithCommands} · coverage ${qualityCoverage.coveragePct}% · commands ${qualityCoverage.commandKinds.join(", ") || "无"}`,
|
|
78
86
|
`manifest 依赖: ${units.reduce((sum, unit) => sum + unit.dependencies.length, 0)} · parse errors ${units.reduce((sum, unit) => sum + unit.dependencyErrors.length, 0)}`,
|
|
79
87
|
`语言: ${[...new Set(units.flatMap((unit) => unit.languages))].sort().join(", ") || "未发现"}`,
|
|
@@ -124,3 +132,92 @@ export function auditGovernance(root, maxLines = 150) {
|
|
|
124
132
|
const ok = !issues.some((issue) => issue.severity === "error");
|
|
125
133
|
return { ok, governance, graph, units, qualityPlan, qualityCoverage, issues, report: render(governance, graph, units, qualityCoverage, issues, maxLines) };
|
|
126
134
|
}
|
|
135
|
+
const MAX_CYCLE_LIST = 10;
|
|
136
|
+
const MAX_FOCUS_NODES = 80;
|
|
137
|
+
/** 依赖图查询:全局摘要(关系/环/枢纽)+ 模块与文件级循环 + 聚焦文件邻域;输出折叠在行预算内。 */
|
|
138
|
+
export function dependencyGraphReport(root, args = {}, maxLines = 150) {
|
|
139
|
+
const absolute = path.resolve(root);
|
|
140
|
+
const governance = loadGovernance(absolute);
|
|
141
|
+
const graph = buildSemanticGraph(absolute, governance, { semanticEvidence: loadAllSemanticEvidence(absolute) });
|
|
142
|
+
const lines = [
|
|
143
|
+
"## 依赖图",
|
|
144
|
+
`- 关系: files ${graph.files.length} · file edges ${graph.fileEdges.length} · module edges ${graph.moduleEdges.length} · unresolved ${graph.unresolved.length}`,
|
|
145
|
+
`- 环: module cycles ${graph.cycles.length} · file cycles ${graph.fileCycles.length}`,
|
|
146
|
+
];
|
|
147
|
+
const fanOut = new Map();
|
|
148
|
+
const fanIn = new Map();
|
|
149
|
+
for (const edge of graph.fileEdges) {
|
|
150
|
+
if (edge.to === null || edge.resolution !== "file")
|
|
151
|
+
continue;
|
|
152
|
+
fanOut.set(edge.from, (fanOut.get(edge.from) ?? 0) + 1);
|
|
153
|
+
fanIn.set(edge.to, (fanIn.get(edge.to) ?? 0) + 1);
|
|
154
|
+
}
|
|
155
|
+
const hubs = (counts) => [...counts.entries()].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])).slice(0, 5).map(([file, n]) => `${file}(${n})`).join(", ") || "无";
|
|
156
|
+
lines.push(`- 出边枢纽: ${hubs(fanOut)}`);
|
|
157
|
+
lines.push(`- 入边枢纽: ${hubs(fanIn)}`);
|
|
158
|
+
for (const cycle of graph.cycles.slice(0, 5))
|
|
159
|
+
lines.push(`- 🔴 模块循环: ${cycle.join(" -> ")} -> ${cycle[0]}`);
|
|
160
|
+
for (const cycle of graph.fileCycles.slice(0, MAX_CYCLE_LIST))
|
|
161
|
+
lines.push(`- ⚠️ 文件循环: ${cycle.join(" -> ")} -> ${cycle[0]}`);
|
|
162
|
+
if (args.focus) {
|
|
163
|
+
// focus 可传相对或绝对路径:先解析并转成项目内相对 posix 路径,再与语义图对表。
|
|
164
|
+
const focusRel = relPath(path.relative(absolute, path.resolve(absolute, args.focus)));
|
|
165
|
+
if (!graph.files.some((file) => file.path === focusRel)) {
|
|
166
|
+
lines.push(`- 🚩 聚焦文件不在语义图中(未解析或不在治理范围): ${focusRel}`);
|
|
167
|
+
return foldLines(lines, { maxLines, hint: "先确认路径在模块 root 下且语言已登记" });
|
|
168
|
+
}
|
|
169
|
+
const forward = new Map();
|
|
170
|
+
const backward = new Map();
|
|
171
|
+
for (const edge of graph.fileEdges) {
|
|
172
|
+
if (edge.to === null || edge.resolution !== "file")
|
|
173
|
+
continue;
|
|
174
|
+
forward.set(edge.from, [...(forward.get(edge.from) ?? []), edge.to]);
|
|
175
|
+
backward.set(edge.to, [...(backward.get(edge.to) ?? []), edge.from]);
|
|
176
|
+
}
|
|
177
|
+
const depth = args.depth ?? 2;
|
|
178
|
+
const walk = (start, adjacency) => {
|
|
179
|
+
const seen = new Map([[start, 0]]);
|
|
180
|
+
let frontier = [start];
|
|
181
|
+
for (let level = 1; level <= depth; level += 1) {
|
|
182
|
+
const next = [];
|
|
183
|
+
for (const node of frontier)
|
|
184
|
+
for (const neighbor of adjacency.get(node) ?? []) {
|
|
185
|
+
if (!seen.has(neighbor)) {
|
|
186
|
+
seen.set(neighbor, level);
|
|
187
|
+
next.push(neighbor);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
if (next.length === 0)
|
|
191
|
+
break;
|
|
192
|
+
frontier = next.sort();
|
|
193
|
+
}
|
|
194
|
+
return seen;
|
|
195
|
+
};
|
|
196
|
+
lines.push(`### 聚焦 ${focusRel}(depth=${depth})`);
|
|
197
|
+
const sides = [
|
|
198
|
+
{ title: "依赖(出边)", adjacency: forward, enabled: args.direction !== "dependents" },
|
|
199
|
+
{ title: "被依赖(入边)", adjacency: backward, enabled: args.direction !== "deps" },
|
|
200
|
+
];
|
|
201
|
+
for (const side of sides) {
|
|
202
|
+
if (!side.enabled)
|
|
203
|
+
continue;
|
|
204
|
+
const seen = walk(focusRel, side.adjacency);
|
|
205
|
+
const levels = [...seen.entries()].filter(([file]) => file !== focusRel).sort((a, b) => a[1] - b[1] || a[0].localeCompare(b[0]));
|
|
206
|
+
if (levels.length === 0) {
|
|
207
|
+
lines.push(`- ${side.title}: 无`);
|
|
208
|
+
continue;
|
|
209
|
+
}
|
|
210
|
+
const byLevel = new Map();
|
|
211
|
+
for (const [file, level] of levels.slice(0, MAX_FOCUS_NODES))
|
|
212
|
+
byLevel.set(level, [...(byLevel.get(level) ?? []), file]);
|
|
213
|
+
for (const level of [...byLevel.keys()].sort((a, b) => a - b))
|
|
214
|
+
lines.push(`- ${side.title} L${level}: ${byLevel.get(level).join(", ")}`);
|
|
215
|
+
if (levels.length > MAX_FOCUS_NODES)
|
|
216
|
+
lines.push(` …另有 ${levels.length - MAX_FOCUS_NODES} 个节点未展开(提高 depth 或改用 impact_analysis 反向闭包)`);
|
|
217
|
+
}
|
|
218
|
+
const focusUnresolved = graph.unresolved.filter((edge) => edge.from === focusRel).slice(0, 10).map((edge) => edge.specifier);
|
|
219
|
+
if (focusUnresolved.length > 0)
|
|
220
|
+
lines.push(`- 未解析引用: ${focusUnresolved.join(", ")}`);
|
|
221
|
+
}
|
|
222
|
+
return foldLines(lines, { maxLines, hint: "用 focus 参数聚焦单个文件,或提高 depth 扩大邻域" });
|
|
223
|
+
}
|
package/dist/governance-tools.js
CHANGED
|
@@ -5,13 +5,14 @@ import { foldLines } from "./budget.js";
|
|
|
5
5
|
import { refreshDerived } from "./dashboard.js";
|
|
6
6
|
import { requireInitialized } from "./paths.js";
|
|
7
7
|
import { initGovernance, loadGovernance, saveGovernance, } from "./governance-model.js";
|
|
8
|
-
import { auditGovernance } from "./governance-audit.js";
|
|
8
|
+
import { auditGovernance, dependencyGraphReport } from "./governance-audit.js";
|
|
9
9
|
import { impactAnalysis } from "./semantic-graph.js";
|
|
10
10
|
import { assessQualityCoverage, createQualityPlan, discoverProjectUnits, runQualityPlan } from "./language-adapters.js";
|
|
11
11
|
import { buildPortfolioFromRegistry, buildPortfolioReport, loadPortfolioProject } from "./portfolio.js";
|
|
12
12
|
import { saveQualityRun } from "./quality-store.js";
|
|
13
|
+
import { evidenceLabel, qualityResultSummary } from "./quality-evidence.js";
|
|
13
14
|
import { SemanticEvidenceDocumentSchema } from "./semantic-evidence.js";
|
|
14
|
-
import { listSemanticEvidence, saveSemanticEvidence } from "./semantic-evidence-store.js";
|
|
15
|
+
import { listSemanticEvidence, replaceSemanticEvidenceForFile, saveSemanticEvidence } from "./semantic-evidence-store.js";
|
|
15
16
|
import { fingerprintProject } from "./project-fingerprint.js";
|
|
16
17
|
const id = z.string().trim().min(1);
|
|
17
18
|
const texts = z.array(z.string().trim().min(1));
|
|
@@ -155,10 +156,26 @@ export function registerGovernanceTools(server, root) {
|
|
|
155
156
|
requireInitialized(root);
|
|
156
157
|
return auditGovernance(root, budgetLines(root)).report;
|
|
157
158
|
});
|
|
159
|
+
toolR(server, root, "dependency_graph", "查询跨文件/模块依赖图:全局摘要(关系/环/入出边枢纽)、模块与文件级循环、聚焦文件的依赖邻域(BFS 方向与深度可调、未解析引用)。", {
|
|
160
|
+
focus: z.string().trim().min(1).max(512).optional().describe("聚焦文件(相对项目根路径)"),
|
|
161
|
+
direction: z.enum(["deps", "dependents", "both"]).optional().describe("邻域方向,默认 both"),
|
|
162
|
+
depth: z.number().int().min(1).max(6).optional().describe("邻域深度 1-6,默认 2"),
|
|
163
|
+
}, (args) => {
|
|
164
|
+
requireInitialized(root);
|
|
165
|
+
return dependencyGraphReport(root, args ?? {}, budgetLines(root));
|
|
166
|
+
});
|
|
158
167
|
toolR(server, root, "list_semantic_evidence", "列出已登记的语言原生 AST、编译器或运行时语义证据;损坏文档会使整次读取失败。", {}, () => {
|
|
159
168
|
requireInitialized(root);
|
|
160
169
|
return JSON.stringify(listSemanticEvidence(root), null, 2);
|
|
161
170
|
});
|
|
171
|
+
toolI(server, root, "replace_semantic_evidence_for_file", "按源码文件原子替换语义证据:删除该文件旧 evidence,仅保留当前 hash-bound evidence。", {
|
|
172
|
+
id: z.string().trim().min(1).max(128),
|
|
173
|
+
document: SemanticEvidenceDocumentSchema,
|
|
174
|
+
}, (args) => {
|
|
175
|
+
requireInitialized(root);
|
|
176
|
+
const saved = replaceSemanticEvidenceForFile(root, args.id, args.document);
|
|
177
|
+
return `✅ semantic evidence replaced ${args.id}:${saved.file} · ${saved.analyzer.id} · ${saved.analyzer.assurance} · ${saved.status}`;
|
|
178
|
+
});
|
|
162
179
|
toolI(server, root, "save_semantic_evidence", "保存一份绑定源码 SHA-256 的语言原生 AST/编译器/运行时证据;过期摘要在治理审计中严格失败。", {
|
|
163
180
|
id: z.string().trim().min(1).max(128),
|
|
164
181
|
document: SemanticEvidenceDocumentSchema,
|
|
@@ -216,7 +233,8 @@ export function registerGovernanceTools(server, root) {
|
|
|
216
233
|
if (!sourceStable)
|
|
217
234
|
lines.push(`- 🚩 源码在执行期间变化:before=${sourceBefore.sha256} after=${sourceAfter.sha256}`);
|
|
218
235
|
for (const item of result.results)
|
|
219
|
-
lines.push(`- [${item.status}] ${item.command.kind} ${item.command.cwd} (${item.durationMs}ms${item.exitCode === null ? "" : `, exit ${item.exitCode}`})`);
|
|
236
|
+
lines.push(`- [${item.status}] ${item.command.kind} ${item.command.cwd} (${item.durationMs}ms${item.exitCode === null ? "" : `, exit ${item.exitCode}`}) · ${evidenceLabel(qualityResultSummary(item).evidence_level)}`);
|
|
237
|
+
lines.push("命令执行结果与任务完成证据分开判断;未知计数不等于已观察测试通过。");
|
|
220
238
|
return foldLines(lines, { maxLines: budgetLines(root), hint: "查看 .pm/quality-runs 结构化摘要(不落原始输出)" });
|
|
221
239
|
});
|
|
222
240
|
toolR(server, root, "get_portfolio", "聚合当前项目或全局注册项目的阶段、里程碑、债务、安全、语言、跨仓依赖/版本/cycle;加载失败不能假绿。", {
|
package/dist/index-db.js
CHANGED
|
@@ -2,6 +2,7 @@ import fs from "node:fs";
|
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { DatabaseSync } from "node:sqlite";
|
|
4
4
|
import { pmPath } from "./paths.js";
|
|
5
|
+
import { watcherIsClean } from "./watcher-coordinator.js";
|
|
5
6
|
/** Low-level SQLite lifecycle, metadata, row iteration, and aggregate queries. */
|
|
6
7
|
const SCHEMA = `
|
|
7
8
|
CREATE TABLE IF NOT EXISTS files(
|
|
@@ -95,3 +96,42 @@ export function aggregates(db) {
|
|
|
95
96
|
indexCoverageBase: contentOk.c,
|
|
96
97
|
};
|
|
97
98
|
}
|
|
99
|
+
const BEAT_STALE_MS = 90_000;
|
|
100
|
+
export function freshness(root) {
|
|
101
|
+
const db = getIndex(root);
|
|
102
|
+
const files = db.prepare(`SELECT COUNT(*) c FROM files`).get().c;
|
|
103
|
+
const lastWalk = getMeta(db, "lastWalk");
|
|
104
|
+
const lastBeat = getMeta(db, "lastBeat");
|
|
105
|
+
const mode = (getMeta(db, "mode") ?? "walk");
|
|
106
|
+
const pending = Number(getMeta(db, "pending") ?? 0);
|
|
107
|
+
const beatAge = lastBeat ? Date.now() - Date.parse(lastBeat) : Number.POSITIVE_INFINITY;
|
|
108
|
+
// rootPath 守卫:索引库随项目目录被拷贝/移动时,旧心跳与行集都属于原路径——不可信任
|
|
109
|
+
const owned = getMeta(db, "rootPath") === path.resolve(root);
|
|
110
|
+
const lastEvent = getMeta(db, "lastEvent");
|
|
111
|
+
const eventAge = lastEvent ? Date.now() - Date.parse(lastEvent) : Number.POSITIVE_INFINITY;
|
|
112
|
+
const watcherSession = getMeta(db, "watcherSession");
|
|
113
|
+
const sessionReconciled = watcherSession !== null && getMeta(db, "lastWalkSession") === watcherSession;
|
|
114
|
+
const watcherHealthy = !(getMeta(db, "watcherError") ?? "");
|
|
115
|
+
const watcherClean = watcherIsClean(root);
|
|
116
|
+
// 卡死计数自愈:防抖 150ms,若计数>0 但 30s 无任何事件推进,说明计数器失真(如持锁期抛错),不再信任
|
|
117
|
+
const pendingClean = pending === 0 || eventAge > 30_000;
|
|
118
|
+
const fresh = files > 0 &&
|
|
119
|
+
lastWalk !== null &&
|
|
120
|
+
owned &&
|
|
121
|
+
mode === "watcher" &&
|
|
122
|
+
sessionReconciled &&
|
|
123
|
+
watcherHealthy &&
|
|
124
|
+
watcherClean &&
|
|
125
|
+
pendingClean &&
|
|
126
|
+
beatAge < BEAT_STALE_MS;
|
|
127
|
+
return { mode, fresh, files, lastWalk, lastBeat, pendingEvents: pending };
|
|
128
|
+
}
|
|
129
|
+
/** 巡检/诊断用:索引概况一行 */
|
|
130
|
+
export function indexSummary(root) {
|
|
131
|
+
const f = freshness(root);
|
|
132
|
+
if (f.files === 0)
|
|
133
|
+
return "索引:空(未走查)";
|
|
134
|
+
const age = f.lastWalk ? `,走查于 ${f.lastWalk.slice(0, 16).replace("T", " ")}` : "";
|
|
135
|
+
const beat = f.fresh ? ",watcher 保鲜中" : `(watcher ${f.mode === "watcher" ? "心跳过期" : "未运行"},下次审计将全量走查)`;
|
|
136
|
+
return `索引:${f.files} 文件${age}${beat}`;
|
|
137
|
+
}
|