@luckychen1993/pm-mcp 0.1.1
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 -0
- package/README.md +304 -0
- package/dist/acceptance-evaluator.js +330 -0
- package/dist/acceptance-model.js +440 -0
- package/dist/acceptance-report.js +335 -0
- package/dist/acceptance-tools.js +318 -0
- package/dist/audit-tools.js +61 -0
- package/dist/audit.js +364 -0
- package/dist/budget.js +71 -0
- package/dist/dashboard.js +241 -0
- package/dist/governance-audit.js +126 -0
- package/dist/governance-model.js +306 -0
- package/dist/governance-tools.js +240 -0
- package/dist/health.js +58 -0
- package/dist/index-db.js +97 -0
- package/dist/index-store.js +381 -0
- package/dist/index.js +210 -0
- package/dist/init.js +40 -0
- package/dist/knowledge-tools.js +204 -0
- package/dist/language-adapters.js +450 -0
- package/dist/language-dependencies.js +351 -0
- package/dist/license.js +261 -0
- package/dist/paths.js +51 -0
- package/dist/polyglot-ast.js +329 -0
- package/dist/portfolio.js +349 -0
- package/dist/project-fingerprint.js +83 -0
- package/dist/project-tools.js +217 -0
- package/dist/quality-store.js +77 -0
- package/dist/registry.js +38 -0
- package/dist/roadmap.js +96 -0
- package/dist/scan.js +271 -0
- package/dist/search.js +312 -0
- package/dist/security.js +269 -0
- package/dist/semantic-evidence-store.js +133 -0
- package/dist/semantic-evidence.js +100 -0
- package/dist/semantic-graph-algorithms.js +86 -0
- package/dist/semantic-graph.js +390 -0
- package/dist/semantic-parsers.js +311 -0
- package/dist/store.js +185 -0
- package/dist/task-tools.js +198 -0
- package/dist/tool-base.js +32 -0
- package/dist/tools.js +21 -0
- package/dist/types.js +208 -0
- package/dist/typescript-semantic.js +282 -0
- package/package.json +70 -0
package/dist/scan.js
ADDED
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { globToRegExp, normSep } from "./budget.js";
|
|
4
|
+
import { aggregates, getIndex, walkRefresh } from "./index-store.js";
|
|
5
|
+
/**
|
|
6
|
+
* 代码树扫描:低层走查(生成器,内存恒定)+ scanProject 聚合入口。
|
|
7
|
+
* 内容级结果存 SQLite 索引(index-store),未变更文件永不重读。
|
|
8
|
+
* 超大项目保护:>10 万文件不物化 files 数组(聚合与点名走 SQL)。
|
|
9
|
+
*/
|
|
10
|
+
export const DEFAULT_IGNORE_DIRS = new Set([
|
|
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
|
+
]);
|
|
32
|
+
/** 真实目录树不太可能超过 64 层;超限的计数上报,绝不静默丢失 */
|
|
33
|
+
export const DEPTH_LIMIT = 64;
|
|
34
|
+
export const BINARY_EXTS = new Set([
|
|
35
|
+
"png", "jpg", "jpeg", "gif", "ico", "webp", "bmp", "pdf",
|
|
36
|
+
"zip", "gz", "tar", "7z", "rar", "bz2", "xz",
|
|
37
|
+
"exe", "dll", "so", "dylib", "bin", "class", "jar", "wasm", "pyc", "o", "a",
|
|
38
|
+
"woff", "woff2", "ttf", "eot", "otf",
|
|
39
|
+
"mp3", "mp4", "mov", "avi", "mkv", "wav", "flac",
|
|
40
|
+
"db", "sqlite", "sqlite3", "mdb", "whl", "egg",
|
|
41
|
+
"node", "map",
|
|
42
|
+
]);
|
|
43
|
+
const LOCKFILES = new Set([
|
|
44
|
+
"package-lock.json",
|
|
45
|
+
"yarn.lock",
|
|
46
|
+
"pnpm-lock.yaml",
|
|
47
|
+
"npm-shrinkwrap.json",
|
|
48
|
+
"poetry.lock",
|
|
49
|
+
"Pipfile.lock",
|
|
50
|
+
"Cargo.lock",
|
|
51
|
+
"composer.lock",
|
|
52
|
+
"Gemfile.lock",
|
|
53
|
+
"go.sum",
|
|
54
|
+
]);
|
|
55
|
+
const SKIP_MARKER_PATTERNS = [
|
|
56
|
+
/\.skip\s*\(/,
|
|
57
|
+
/\.only\s*\(/,
|
|
58
|
+
/\bfit\s*\(/,
|
|
59
|
+
/\bxit\s*\(/,
|
|
60
|
+
/\bxdescribe\s*\(/,
|
|
61
|
+
/@unittest\.skip/,
|
|
62
|
+
/@pytest\.mark\.skip/,
|
|
63
|
+
/pytest\.mark\.xfail/,
|
|
64
|
+
/@Ignore\b/,
|
|
65
|
+
/@Disabled\b/,
|
|
66
|
+
];
|
|
67
|
+
export function extOf(rel) {
|
|
68
|
+
const base = rel.split("/").pop() ?? rel;
|
|
69
|
+
const idx = base.lastIndexOf(".");
|
|
70
|
+
return idx <= 0 ? "" : base.slice(idx + 1).toLowerCase();
|
|
71
|
+
}
|
|
72
|
+
export function isTestFile(rel) {
|
|
73
|
+
if (/\.(test|spec)\.[a-z]+$/i.test(rel))
|
|
74
|
+
return true;
|
|
75
|
+
return /(^|\/)(tests?|__tests__|spec)(\/|$)/i.test(rel);
|
|
76
|
+
}
|
|
77
|
+
export function isLockfile(rel) {
|
|
78
|
+
return LOCKFILES.has(rel.split("/").pop() ?? rel);
|
|
79
|
+
}
|
|
80
|
+
export function countLoc(content) {
|
|
81
|
+
return content.split("\n").length;
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* 空测试启发式:
|
|
85
|
+
* ① 恒真断言——必须是独立断言行(行首就是 expect(true)/assert true),任何行数都报;
|
|
86
|
+
* ② 完全无断言——仅小文件(≤30 行)报,大文件可能断言在别的辅助文件里。
|
|
87
|
+
*/
|
|
88
|
+
export function looksTrivialTest(content) {
|
|
89
|
+
const lines = content.split("\n");
|
|
90
|
+
const tautology = /^\s*(?:expect\s*\(\s*(?:true|1)\s*\)|assert\s+(?:True|true)\s*(?:#.*)?$)/;
|
|
91
|
+
if (lines.some((l) => tautology.test(l)))
|
|
92
|
+
return true;
|
|
93
|
+
if (lines.length > 30)
|
|
94
|
+
return false;
|
|
95
|
+
const hasAssert = /assert|expect|should\.|\.equal|\.toBe|\.toBeTruthy|unittest|raises?\(/.test(content);
|
|
96
|
+
return !hasAssert;
|
|
97
|
+
}
|
|
98
|
+
export function countSkipLines(content) {
|
|
99
|
+
let n = 0;
|
|
100
|
+
for (const line of content.split("\n")) {
|
|
101
|
+
if (SKIP_MARKER_PATTERNS.some((re) => re.test(line)))
|
|
102
|
+
n += 1;
|
|
103
|
+
}
|
|
104
|
+
return n;
|
|
105
|
+
}
|
|
106
|
+
/** 需要 skip 统计的文件(测试文件 + 主流语言源码) */
|
|
107
|
+
export function needsSkipCount(rel, isTest) {
|
|
108
|
+
return isTest || /\.(ts|js|mjs|cjs|py|java)$/.test(rel);
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* 生成器式目录走查:逐个 yield 文件 stat,避免把完整文件树常驻内存。
|
|
112
|
+
* onSkippedDeep 在超深路径被跳过时回调(计数上报用)。
|
|
113
|
+
*/
|
|
114
|
+
export function* walkStatEntries(root, opts = {}, onSkippedDeep) {
|
|
115
|
+
const extraRe = (opts.extraIgnores ?? []).map(globToRegExp);
|
|
116
|
+
const includeRe = opts.include?.map(globToRegExp);
|
|
117
|
+
const maxBytes = opts.maxFileBytes ?? 2 * 1024 * 1024;
|
|
118
|
+
void maxBytes;
|
|
119
|
+
const walk = function* (dir, depth) {
|
|
120
|
+
if (depth > DEPTH_LIMIT) {
|
|
121
|
+
onSkippedDeep?.();
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
let entries;
|
|
125
|
+
try {
|
|
126
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
127
|
+
}
|
|
128
|
+
catch {
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
for (const entry of entries) {
|
|
132
|
+
const abs = path.join(dir, entry.name);
|
|
133
|
+
const rel = normSep(path.relative(root, abs));
|
|
134
|
+
if (entry.isDirectory()) {
|
|
135
|
+
if (DEFAULT_IGNORE_DIRS.has(entry.name))
|
|
136
|
+
continue;
|
|
137
|
+
if (extraRe.some((re) => re.test(rel)))
|
|
138
|
+
continue;
|
|
139
|
+
yield* walk(abs, depth + 1);
|
|
140
|
+
}
|
|
141
|
+
else if (entry.isFile()) {
|
|
142
|
+
if (extraRe.some((re) => re.test(rel)))
|
|
143
|
+
continue;
|
|
144
|
+
if (includeRe && !includeRe.some((re) => re.test(rel)))
|
|
145
|
+
continue;
|
|
146
|
+
let st;
|
|
147
|
+
try {
|
|
148
|
+
st = fs.statSync(abs);
|
|
149
|
+
}
|
|
150
|
+
catch {
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
yield { rel, mtime: st.mtimeMs, size: st.size };
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
};
|
|
157
|
+
yield* walk(root, 0);
|
|
158
|
+
}
|
|
159
|
+
/** 只要文件清单(零值字段;调用方自行读取内容) */
|
|
160
|
+
export function listFiles(root, opts = {}) {
|
|
161
|
+
const out = [];
|
|
162
|
+
for (const e of walkStatEntries(root, opts)) {
|
|
163
|
+
out.push({
|
|
164
|
+
rel: e.rel,
|
|
165
|
+
loc: 0,
|
|
166
|
+
ext: extOf(e.rel),
|
|
167
|
+
isTest: isTestFile(e.rel),
|
|
168
|
+
isLockfile: isLockfile(e.rel),
|
|
169
|
+
trivialTest: false,
|
|
170
|
+
oversize: e.size > 2 * 1024 * 1024,
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
return out.sort((a, b) => a.rel.localeCompare(b.rel));
|
|
174
|
+
}
|
|
175
|
+
/** 读直接依赖:package.json dependencies/devDependencies + requirements.txt */
|
|
176
|
+
export function readDirectDeps(root) {
|
|
177
|
+
const specs = [];
|
|
178
|
+
const pkgFile = path.join(root, "package.json");
|
|
179
|
+
if (fs.existsSync(pkgFile)) {
|
|
180
|
+
try {
|
|
181
|
+
const pkg = JSON.parse(fs.readFileSync(pkgFile, "utf8"));
|
|
182
|
+
for (const section of ["dependencies", "devDependencies", "optionalDependencies"]) {
|
|
183
|
+
const map = pkg[section] ?? {};
|
|
184
|
+
for (const [name, version] of Object.entries(map)) {
|
|
185
|
+
const v = String(version);
|
|
186
|
+
specs.push({ name, version: v, risky: v === "*" || v === "latest" || v === "" });
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
catch {
|
|
191
|
+
/* 非法 package.json 由审计报告提示 */
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
const reqFile = path.join(root, "requirements.txt");
|
|
195
|
+
if (fs.existsSync(reqFile)) {
|
|
196
|
+
for (const line of fs.readFileSync(reqFile, "utf8").split("\n")) {
|
|
197
|
+
const t = line.trim();
|
|
198
|
+
if (!t || t.startsWith("#") || t.startsWith("-"))
|
|
199
|
+
continue;
|
|
200
|
+
const m = t.match(/^([A-Za-z0-9_.\-\[\]]+)/);
|
|
201
|
+
if (m)
|
|
202
|
+
specs.push({ name: m[1], version: t.slice(m[1].length).trim(), risky: false });
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
return { deps: specs.map((s) => s.name), depSpecs: specs };
|
|
206
|
+
}
|
|
207
|
+
/** 超过该文件数不再物化 files 数组(防大规模目录内存膨胀;聚合与点名始终可用) */
|
|
208
|
+
const MATERIALIZE_LIMIT = 100_000;
|
|
209
|
+
/** 完整扫描:走查→SQLite 集合 diff→只读变更文件→SQL 聚合(增量,索引持久化 .pm/index.db) */
|
|
210
|
+
export function scanProject(root, opts = {}) {
|
|
211
|
+
if (opts.content === false) {
|
|
212
|
+
// 清单模式:不碰索引(调用方要的是"现在"的文件集合)
|
|
213
|
+
const files = listFiles(root, opts);
|
|
214
|
+
return {
|
|
215
|
+
files,
|
|
216
|
+
totalFiles: files.length,
|
|
217
|
+
totalLoc: 0,
|
|
218
|
+
testFiles: files.filter((f) => f.isTest).length,
|
|
219
|
+
skipMarkers: 0,
|
|
220
|
+
trivialTests: [],
|
|
221
|
+
lockfiles: files.filter((f) => f.isLockfile).map((f) => f.rel),
|
|
222
|
+
deps: [],
|
|
223
|
+
depSpecs: [],
|
|
224
|
+
warnings: [],
|
|
225
|
+
cacheHits: 0,
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
const walkRes = walkRefresh(root, opts);
|
|
229
|
+
const db = getIndex(root);
|
|
230
|
+
const agg = aggregates(db);
|
|
231
|
+
const { deps, depSpecs } = readDirectDeps(root);
|
|
232
|
+
const warnings = [];
|
|
233
|
+
if (walkRes.skippedDeep > 0) {
|
|
234
|
+
warnings.push(`${walkRes.skippedDeep} 个路径超过 ${DEPTH_LIMIT} 层深度未扫描(极罕见,请检查目录结构)`);
|
|
235
|
+
}
|
|
236
|
+
const pkgFile = path.join(root, "package.json");
|
|
237
|
+
if (fs.existsSync(pkgFile)) {
|
|
238
|
+
try {
|
|
239
|
+
JSON.parse(fs.readFileSync(pkgFile, "utf8"));
|
|
240
|
+
}
|
|
241
|
+
catch {
|
|
242
|
+
warnings.push("package.json 不是合法 JSON");
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
// 大项目不物化(点名类字段仍是 SQL 结果)
|
|
246
|
+
const files = agg.totalFiles <= MATERIALIZE_LIMIT
|
|
247
|
+
? db.prepare(`SELECT rel, loc, ext, is_test, is_lock, trivial, oversize FROM files ORDER BY rel`).all().map((r) => ({
|
|
248
|
+
rel: r.rel,
|
|
249
|
+
loc: r.loc,
|
|
250
|
+
ext: r.ext,
|
|
251
|
+
isTest: r.is_test === 1,
|
|
252
|
+
isLockfile: r.is_lock === 1,
|
|
253
|
+
trivialTest: r.trivial === 1,
|
|
254
|
+
oversize: r.oversize === 1,
|
|
255
|
+
}))
|
|
256
|
+
: [];
|
|
257
|
+
const lockfiles = db.prepare(`SELECT rel FROM files WHERE is_lock=1 ORDER BY rel LIMIT 100`).all().map((r) => r.rel);
|
|
258
|
+
return {
|
|
259
|
+
files,
|
|
260
|
+
totalFiles: agg.totalFiles,
|
|
261
|
+
totalLoc: agg.totalLoc,
|
|
262
|
+
testFiles: agg.testFiles,
|
|
263
|
+
skipMarkers: agg.skipMarkers,
|
|
264
|
+
trivialTests: agg.trivialTests,
|
|
265
|
+
lockfiles,
|
|
266
|
+
deps,
|
|
267
|
+
depSpecs,
|
|
268
|
+
warnings,
|
|
269
|
+
cacheHits: walkRes.hits,
|
|
270
|
+
};
|
|
271
|
+
}
|
package/dist/search.js
ADDED
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { spawnSync } from "node:child_process";
|
|
4
|
+
import { loadDebugLog, loadFeatures, loadFileNotes, loadSessions, loadTasks } from "./store.js";
|
|
5
|
+
import { decisionsDir } from "./paths.js";
|
|
6
|
+
import { loadGovernance } from "./governance-model.js";
|
|
7
|
+
import { listFiles, DEFAULT_IGNORE_DIRS } from "./scan.js";
|
|
8
|
+
import { foldLines, normSep } from "./budget.js";
|
|
9
|
+
/**
|
|
10
|
+
* 求证与检索:search_code(锚定真实代码)+ search_knowledge(历史结论)。
|
|
11
|
+
* token 经济的一部分:返回 file:line 片段而非整文件。
|
|
12
|
+
* 超大项目:检测到 ripgrep 时优先用 rg(并行+SIMD+尊重 .gitignore),
|
|
13
|
+
* 否则回退内置逐文件扫描。
|
|
14
|
+
*/
|
|
15
|
+
let rgCache = null;
|
|
16
|
+
function hasRg() {
|
|
17
|
+
if (rgCache === null) {
|
|
18
|
+
try {
|
|
19
|
+
rgCache = spawnSync("rg", ["--version"], { timeout: 5000 }).status === 0;
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
rgCache = false;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
return rgCache;
|
|
26
|
+
}
|
|
27
|
+
/** rg 后端:成功返回命中,失败/未安装返回 null(回退内置) */
|
|
28
|
+
export function rgSearch(root, query, glob, maxResults, regex) {
|
|
29
|
+
if (!hasRg())
|
|
30
|
+
return null;
|
|
31
|
+
// 项目根不一定已经 `git init`。显式允许 rg 在非 Git 目录中读取
|
|
32
|
+
// .gitignore,避免同一份项目在初始化 Git 前后出现不同搜索语义。
|
|
33
|
+
const args = ["-i", "-n", "--no-heading", "--no-messages", "--no-require-git", "--max-filesize", "4M"];
|
|
34
|
+
for (const dir of DEFAULT_IGNORE_DIRS) {
|
|
35
|
+
if (dir === ".pm")
|
|
36
|
+
args.push("-g", "!.pm/**");
|
|
37
|
+
else
|
|
38
|
+
args.push("-g", `!${dir}/**`);
|
|
39
|
+
}
|
|
40
|
+
args.push("--max-count", String(maxResults * 3));
|
|
41
|
+
if (regex)
|
|
42
|
+
args.push("-e", query);
|
|
43
|
+
else
|
|
44
|
+
args.push("-F", query);
|
|
45
|
+
if (glob)
|
|
46
|
+
args.push("--glob", glob);
|
|
47
|
+
args.push(".");
|
|
48
|
+
const r = spawnSync("rg", args, { cwd: root, encoding: "utf8", timeout: 120_000, maxBuffer: 64 * 1024 * 1024 });
|
|
49
|
+
if (process.env.PM_RG_DEBUG)
|
|
50
|
+
console.error("[rgSearch]", JSON.stringify({ status: r.status, err: r.error?.message, stdout: (r.stdout ?? "").slice(0, 200), args }));
|
|
51
|
+
if (r.error || r.status === null || r.status > 1)
|
|
52
|
+
return null;
|
|
53
|
+
const out = [];
|
|
54
|
+
for (const raw of (r.stdout ?? "").split("\n")) {
|
|
55
|
+
// CRLF 文件:rg 输出行尾带 \r,而 JS 正则的 . 不匹配 \r,必须先剥掉
|
|
56
|
+
const line = raw.replace(/\r$/, "");
|
|
57
|
+
if (!line)
|
|
58
|
+
continue;
|
|
59
|
+
const m = line.match(/^(.*?):(\d+):(.*)$/);
|
|
60
|
+
if (!m)
|
|
61
|
+
continue;
|
|
62
|
+
out.push({ rel: normSep(m[1]).replace(/^\.\//, ""), line: Number(m[2]), text: m[3].trim().slice(0, 200) });
|
|
63
|
+
if (out.length >= maxResults)
|
|
64
|
+
break;
|
|
65
|
+
}
|
|
66
|
+
return out;
|
|
67
|
+
}
|
|
68
|
+
/* --------------------------- 进程内容缓存(超大项目性能) --------------------------- */
|
|
69
|
+
const CACHE_LIMIT_BYTES = 32 * 1024 * 1024;
|
|
70
|
+
const contentCache = new Map();
|
|
71
|
+
let cacheBytes = 0;
|
|
72
|
+
let diskReadFiles = 0;
|
|
73
|
+
let diskReadBytes = 0;
|
|
74
|
+
let cacheHits = 0;
|
|
75
|
+
let contentReadObserver = null;
|
|
76
|
+
/** 读取文本内容:mtime+size 未变走进程缓存(server 常驻,二次搜索/扫描免重读)。返回 null=不可读/二进制。 */
|
|
77
|
+
export function readText(root, rel) {
|
|
78
|
+
const abs = path.join(root, rel);
|
|
79
|
+
// 缓存必须按“项目根 + 相对路径”隔离;仅用 rel 会让两个项目在
|
|
80
|
+
// mtime/size 恰好相同时串用彼此内容,安全与许可证扫描会随之失真。
|
|
81
|
+
const cacheKey = `${path.resolve(root)}\0${normSep(rel)}`;
|
|
82
|
+
let st;
|
|
83
|
+
try {
|
|
84
|
+
st = fs.statSync(abs);
|
|
85
|
+
}
|
|
86
|
+
catch {
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
const hit = contentCache.get(cacheKey);
|
|
90
|
+
if (hit && hit.mtime === st.mtimeMs && hit.size === st.size) {
|
|
91
|
+
// LRU touch
|
|
92
|
+
contentCache.delete(cacheKey);
|
|
93
|
+
contentCache.set(cacheKey, hit);
|
|
94
|
+
cacheHits += 1;
|
|
95
|
+
return hit.content;
|
|
96
|
+
}
|
|
97
|
+
if (st.size > 4 * 1024 * 1024)
|
|
98
|
+
return null; // 单文件上限 4MB
|
|
99
|
+
let content;
|
|
100
|
+
try {
|
|
101
|
+
content = fs.readFileSync(abs, "utf8");
|
|
102
|
+
}
|
|
103
|
+
catch {
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
106
|
+
if (content.includes("\0"))
|
|
107
|
+
return null;
|
|
108
|
+
diskReadFiles += 1;
|
|
109
|
+
diskReadBytes += st.size;
|
|
110
|
+
contentReadObserver?.({ diskReadFiles, diskReadBytes, cacheHits });
|
|
111
|
+
if (hit) {
|
|
112
|
+
cacheBytes -= hit.bytes;
|
|
113
|
+
contentCache.delete(cacheKey);
|
|
114
|
+
}
|
|
115
|
+
const bytes = st.size;
|
|
116
|
+
contentCache.set(cacheKey, { mtime: st.mtimeMs, size: st.size, bytes, content });
|
|
117
|
+
cacheBytes += bytes;
|
|
118
|
+
while (cacheBytes > CACHE_LIMIT_BYTES && contentCache.size > 1) {
|
|
119
|
+
const oldest = contentCache.keys().next().value;
|
|
120
|
+
const ev = contentCache.get(oldest);
|
|
121
|
+
if (ev)
|
|
122
|
+
cacheBytes -= ev.bytes;
|
|
123
|
+
contentCache.delete(oldest);
|
|
124
|
+
}
|
|
125
|
+
return content;
|
|
126
|
+
}
|
|
127
|
+
/** 测试/诊断用:缓存状态 */
|
|
128
|
+
export function contentCacheStats() {
|
|
129
|
+
return { files: contentCache.size, bytes: cacheBytes, diskReadFiles, diskReadBytes, cacheHits };
|
|
130
|
+
}
|
|
131
|
+
/** 基准/诊断阶段重置读取计数;可同时清空内容缓存,建立独立覆盖口径。 */
|
|
132
|
+
export function resetContentReadStats(clearCache = false) {
|
|
133
|
+
diskReadFiles = 0;
|
|
134
|
+
diskReadBytes = 0;
|
|
135
|
+
cacheHits = 0;
|
|
136
|
+
if (clearCache) {
|
|
137
|
+
contentCache.clear();
|
|
138
|
+
cacheBytes = 0;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
/** 长时基准可订阅读取进度;正常 MCP 运行不设置 observer,因此不会产生额外输出。 */
|
|
142
|
+
export function setContentReadObserver(observer) {
|
|
143
|
+
contentReadObserver = observer;
|
|
144
|
+
}
|
|
145
|
+
function buildRegex(query, regex) {
|
|
146
|
+
if (!regex) {
|
|
147
|
+
// 默认字面量:模型/用户输入的 query 不当正则用(灾难回溯会挂起进程)
|
|
148
|
+
const escaped = query.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
149
|
+
return new RegExp(escaped, "i");
|
|
150
|
+
}
|
|
151
|
+
try {
|
|
152
|
+
return new RegExp(query, "i");
|
|
153
|
+
}
|
|
154
|
+
catch {
|
|
155
|
+
const escaped = query.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
156
|
+
return new RegExp(escaped, "i");
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
export function searchCode(root, query, glob, maxResults = 30, regex = false) {
|
|
160
|
+
const re = buildRegex(query, regex);
|
|
161
|
+
const notes = loadFileNotes(root).notes;
|
|
162
|
+
const purposeHits = [];
|
|
163
|
+
for (const [rel, note] of Object.entries(notes)) {
|
|
164
|
+
if (re.test(note.purpose))
|
|
165
|
+
purposeHits.push(`${rel}(用途: ${note.purpose})`);
|
|
166
|
+
}
|
|
167
|
+
// 首选 ripgrep(超大项目唯一可行的全库搜索)
|
|
168
|
+
const rg = rgSearch(root, query, glob, maxResults, regex);
|
|
169
|
+
if (rg !== null) {
|
|
170
|
+
return {
|
|
171
|
+
matches: rg.slice(0, maxResults),
|
|
172
|
+
purposeHits: purposeHits.slice(0, 10),
|
|
173
|
+
scannedFiles: -1,
|
|
174
|
+
truncated: rg.length >= maxResults,
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
// 内置回退:content:false 只枚举,内容按需经 readText 缓存读取
|
|
178
|
+
const include = glob ? [glob] : undefined;
|
|
179
|
+
const files = listFiles(root, { include, content: false });
|
|
180
|
+
const matches = [];
|
|
181
|
+
let scanned = 0;
|
|
182
|
+
for (const f of files) {
|
|
183
|
+
if (f.isLockfile || f.oversize)
|
|
184
|
+
continue;
|
|
185
|
+
if (f.rel.startsWith(".pm/"))
|
|
186
|
+
continue;
|
|
187
|
+
scanned += 1;
|
|
188
|
+
const content = readText(root, f.rel);
|
|
189
|
+
if (content === null)
|
|
190
|
+
continue;
|
|
191
|
+
const lines = content.split("\n");
|
|
192
|
+
for (let i = 0; i < lines.length; i++) {
|
|
193
|
+
if (re.test(lines[i])) {
|
|
194
|
+
matches.push({ rel: f.rel, line: i + 1, text: lines[i].trim().slice(0, 200) });
|
|
195
|
+
if (matches.length >= maxResults * 3)
|
|
196
|
+
break;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
if (matches.length >= maxResults * 3)
|
|
200
|
+
break;
|
|
201
|
+
}
|
|
202
|
+
const truncated = matches.length > maxResults;
|
|
203
|
+
return {
|
|
204
|
+
matches: matches.slice(0, maxResults),
|
|
205
|
+
purposeHits: purposeHits.slice(0, 10),
|
|
206
|
+
scannedFiles: scanned,
|
|
207
|
+
truncated,
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
export function formatCodeSearch(res, query, maxLines = 60) {
|
|
211
|
+
const L = [];
|
|
212
|
+
const backend = res.scannedFiles < 0 ? ",ripgrep 后端" : `,扫描文本文件 ${res.scannedFiles} 个`;
|
|
213
|
+
L.push(`搜索 "${query}":命中 ${res.matches.length} 行${res.truncated ? "(已截断,用 glob 缩小范围)" : ""}${backend}。`);
|
|
214
|
+
for (const m of res.matches) {
|
|
215
|
+
L.push(`${m.rel}:${m.line} ${m.text}`);
|
|
216
|
+
}
|
|
217
|
+
if (res.purposeHits.length > 0) {
|
|
218
|
+
L.push("文件索引命中:");
|
|
219
|
+
L.push(...res.purposeHits);
|
|
220
|
+
}
|
|
221
|
+
if (res.matches.length === 0 && res.purposeHits.length === 0) {
|
|
222
|
+
L.push("(无命中。换个关键词,或先 annotate_file 建立文件用途索引。)");
|
|
223
|
+
}
|
|
224
|
+
return foldLines(L, { maxLines, hint: "用 glob 参数限定文件范围" });
|
|
225
|
+
}
|
|
226
|
+
/* ------------------------------ 知识检索 ------------------------------ */
|
|
227
|
+
export function searchKnowledge(root, query, maxLines = 80) {
|
|
228
|
+
const re = buildRegex(query, false);
|
|
229
|
+
const L = [];
|
|
230
|
+
const hit = (s) => re.test(s);
|
|
231
|
+
const { tasks } = loadTasks(root);
|
|
232
|
+
const taskHits = tasks.filter((t) => hit(t.title) || hit(t.detail) || hit(t.result_note) || hit(t.acceptance));
|
|
233
|
+
if (taskHits.length > 0) {
|
|
234
|
+
L.push("### 任务");
|
|
235
|
+
for (const t of taskHits.slice(0, 10)) {
|
|
236
|
+
L.push(`- [${t.status}] ${t.id} ${t.title}${t.result_note ? ` — 结果: ${t.result_note.slice(0, 100)}` : ""}`);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
const { features } = loadFeatures(root);
|
|
240
|
+
const featureHits = features.filter((f) => hit(f.name) || hit(f.description));
|
|
241
|
+
if (featureHits.length > 0) {
|
|
242
|
+
L.push("### 功能");
|
|
243
|
+
for (const f of featureHits.slice(0, 10)) {
|
|
244
|
+
L.push(`- [${f.status}] ${f.id} ${f.name}${f.module ? `(${f.module})` : ""} — ${f.description.slice(0, 80)}`);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
const { sessions } = loadSessions(root);
|
|
248
|
+
const sessionHits = sessions.filter((s) => hit(s.summary) || s.next_steps.some(hit));
|
|
249
|
+
if (sessionHits.length > 0) {
|
|
250
|
+
L.push("### 会话");
|
|
251
|
+
for (const s of sessionHits.slice(-10).reverse()) {
|
|
252
|
+
L.push(`- ${s.date.slice(0, 10)} [${s.author || "?"}] ${s.summary.slice(0, 120)}`);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
const { entries } = loadDebugLog(root);
|
|
256
|
+
const debugHits = entries.filter((d) => hit(d.symptom) || hit(d.root_cause) || hit(d.fix));
|
|
257
|
+
if (debugHits.length > 0) {
|
|
258
|
+
L.push("### 调试记录");
|
|
259
|
+
for (const d of debugHits.slice(-10).reverse()) {
|
|
260
|
+
L.push(`- ${d.id} ${d.date.slice(0, 10)} 症状: ${d.symptom.slice(0, 60)} → 根因: ${d.root_cause.slice(0, 60)} → 修法: ${d.fix.slice(0, 60)}`);
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
const notes = loadFileNotes(root).notes;
|
|
264
|
+
const noteHits = Object.entries(notes).filter(([, n]) => hit(n.purpose) || hit(n.source));
|
|
265
|
+
if (noteHits.length > 0) {
|
|
266
|
+
L.push("### 文件索引");
|
|
267
|
+
for (const [file, n] of noteHits.slice(0, 10)) {
|
|
268
|
+
L.push(`- ${file}: ${n.purpose}${n.source ? `(来源: ${n.source})` : ""}`);
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
try {
|
|
272
|
+
const governance = loadGovernance(root);
|
|
273
|
+
const moduleHits = governance.modules.filter((module) => hit(module.id) || hit(module.name) || module.owners.some(hit) || module.languages.some(hit) || module.roots.some(hit));
|
|
274
|
+
const interfaceHits = governance.interfaces.filter((item) => hit(item.id) || hit(item.kind) || hit(item.provider) || item.consumers.some(hit) || item.contract_files.some(hit));
|
|
275
|
+
const repositoryHits = governance.repositories.filter((repository) => hit(repository.id) || hit(repository.name) || hit(repository.version) || repository.dependencies.some((dependency) => hit(dependency.repository) || hit(dependency.constraint)));
|
|
276
|
+
if (moduleHits.length || interfaceHits.length || repositoryHits.length) {
|
|
277
|
+
L.push("### 模块/接口/仓库治理");
|
|
278
|
+
for (const module of moduleHits.slice(0, 8))
|
|
279
|
+
L.push(`- module ${module.id} [${module.kind}] owner=${module.owners.join(",") || "无"} roots=${module.roots.join(",")}`);
|
|
280
|
+
for (const item of interfaceHits.slice(0, 8))
|
|
281
|
+
L.push(`- interface ${item.id}@${item.version}: ${item.provider} -> ${item.consumers.join(",") || "无"}`);
|
|
282
|
+
for (const repository of repositoryHits.slice(0, 8))
|
|
283
|
+
L.push(`- repository ${repository.id}@${repository.version}`);
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
catch {
|
|
287
|
+
// 旧项目尚未迁移治理模型时,保留原有知识库检索能力。
|
|
288
|
+
}
|
|
289
|
+
// ADR 文件内容检索
|
|
290
|
+
const dir = decisionsDir(root);
|
|
291
|
+
if (fs.existsSync(dir)) {
|
|
292
|
+
const adrHits = [];
|
|
293
|
+
for (const name of fs.readdirSync(dir).filter((n) => n.endsWith(".md")).sort()) {
|
|
294
|
+
const abs = path.join(dir, name);
|
|
295
|
+
const content = fs.readFileSync(abs, "utf8");
|
|
296
|
+
if (!hit(content) && !hit(name))
|
|
297
|
+
continue;
|
|
298
|
+
const lines = content.split("\n");
|
|
299
|
+
const idx = lines.findIndex((l) => hit(l));
|
|
300
|
+
adrHits.push(`- ${name}: ${idx >= 0 ? lines[idx].trim().slice(0, 100) : "(标题命中)"}`);
|
|
301
|
+
}
|
|
302
|
+
if (adrHits.length > 0) {
|
|
303
|
+
L.push("### 架构决策");
|
|
304
|
+
L.push(...adrHits.slice(0, 10));
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
if (L.length === 0) {
|
|
308
|
+
L.push(`知识库中无 "${query}" 相关记录。可检索范围:任务/功能/会话/调试记录/文件索引/架构决策/治理模型。`);
|
|
309
|
+
}
|
|
310
|
+
return foldLines(L, { maxLines, hint: "可用更具体的关键词" });
|
|
311
|
+
}
|
|
312
|
+
export { normSep };
|