ppxans-harness 2.4.0 → 3.2.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.
Files changed (186) hide show
  1. package/LICENSE +201 -201
  2. package/README.md +218 -265
  3. package/bin/ppx-channels.js +2 -2
  4. package/bin/ppx-serve.js +5 -5
  5. package/bin/ppx-setup.js +124 -0
  6. package/bin/ppx-web.js +140 -0
  7. package/bin/ppx.js +2 -2
  8. package/config/identity.md +6 -6
  9. package/config/ishiki.md +16 -16
  10. package/config/ppx.json +15 -151
  11. package/config/ppx.json.example +143 -0
  12. package/package.json +17 -10
  13. package/skills/.usage.json +6 -0
  14. package/skills/agent-professional-training/SKILL.md +94 -0
  15. package/skills/brainstorm/SKILL.md +24 -0
  16. package/skills/cupid-lover-comms/SKILL.md +37 -0
  17. package/skills/debug/SKILL.md +26 -0
  18. package/skills/plan/SKILL.md +25 -0
  19. package/skills/ponytail/SKILL.md +25 -0
  20. package/skills/ppx-memory/SKILL.md +91 -0
  21. package/skills/ppx-memory/scripts/cli.js +192 -0
  22. package/skills/ppx-memory/scripts/experience.js +133 -0
  23. package/skills/ppx-memory/scripts/fact-store.js +842 -0
  24. package/skills/ppx-memory/scripts/l0.js +52 -0
  25. package/skills/ppx-memory/scripts/l2.js +146 -0
  26. package/skills/ppx-memory/scripts/l3.js +112 -0
  27. package/skills/ppx-memory/scripts/memory-ticker.js +238 -0
  28. package/skills/ppx-memory/scripts/pii.js +42 -0
  29. package/skills/ppx-memory/scripts/schema.js +80 -0
  30. package/skills/ppx-memory/scripts/session.js +398 -0
  31. package/skills/ppx-memory/scripts/similarity.js +43 -0
  32. package/skills/ppx-memory/scripts/store.js +116 -0
  33. package/skills/ppx-memory/scripts/wal.js +38 -0
  34. package/skills/ppx-selfheal/SKILL.md +24 -0
  35. package/skills/ppx-selfheal/scripts/cli.js +80 -0
  36. package/skills/ppx-selfheal/scripts/healer.js +184 -0
  37. package/skills/ppx-selfheal/scripts/logger.js +17 -0
  38. package/skills/ppx-selfheal/scripts/store.js +116 -0
  39. package/skills/prompt-depth-kit/SKILL.md +28 -0
  40. package/skills/session-naming/SKILL.md +36 -0
  41. package/skills/verify/SKILL.md +25 -0
  42. package/src/agent/index.js +1340 -717
  43. package/src/agent/prompts.js +47 -3
  44. package/src/aml-server.js +197 -151
  45. package/src/ans/eviction.js +123 -143
  46. package/src/ans/guard.js +159 -120
  47. package/src/ans/lifecycle.js +96 -93
  48. package/src/ans/proactive.js +112 -129
  49. package/src/ans/reward.js +95 -111
  50. package/src/ans/values.js +15 -15
  51. package/src/audit/audit-chain.js +43 -7
  52. package/src/audit/verifier.js +157 -120
  53. package/src/bus/circuit-breaker.js +9 -1
  54. package/src/bus/runtime-bus.js +107 -93
  55. package/src/channels/base.js +57 -34
  56. package/src/channels/feishu.js +118 -126
  57. package/src/channels/http.js +1090 -592
  58. package/src/channels/index.js +111 -110
  59. package/src/channels/log.js +29 -29
  60. package/src/channels/wechat-crypto.js +73 -74
  61. package/src/channels/wechat.js +191 -197
  62. package/src/channels/workspace.js +94 -0
  63. package/src/channels-cli.js +126 -124
  64. package/src/cli.js +129 -120
  65. package/src/commands/index.js +142 -0
  66. package/src/config/channels.js +137 -170
  67. package/src/config/index.js +277 -224
  68. package/src/config/placeholder.js +31 -0
  69. package/src/config/providers.js +148 -188
  70. package/src/config/settings.js +156 -182
  71. package/src/core/policy.js +69 -18
  72. package/src/core/trace.js +8 -6
  73. package/src/edit/editblock.js +266 -0
  74. package/src/edit/snapshot.js +67 -0
  75. package/src/evidence/index.js +153 -0
  76. package/src/evolve/playbook.js +9 -10
  77. package/src/hooks/index.js +113 -0
  78. package/src/llm/client.js +188 -446
  79. package/src/llm/dsml.js +74 -74
  80. package/src/llm/embedder.js +41 -35
  81. package/src/llm/fence.js +52 -105
  82. package/src/llm/index.js +4 -4
  83. package/src/llm/local-embedder.js +94 -0
  84. package/src/llm/presets.js +113 -0
  85. package/src/llm/pricing.js +93 -0
  86. package/src/llm/retry.js +73 -73
  87. package/src/llm/router.js +92 -97
  88. package/src/mcp/admin.js +326 -0
  89. package/src/mcp/client.js +487 -375
  90. package/src/mcp/http.js +203 -0
  91. package/src/mcp/index.js +116 -116
  92. package/src/mcp/server.js +392 -0
  93. package/src/mcp/tasks.js +133 -0
  94. package/src/memory/asset-hub.js +6 -11
  95. package/src/memory/canvas.js +2 -5
  96. package/src/memory/compaction.js +28 -28
  97. package/src/memory/experience.js +133 -122
  98. package/src/memory/fact-store.js +914 -698
  99. package/src/memory/failure-episode.js +20 -11
  100. package/src/memory/fork.js +17 -8
  101. package/src/memory/index.js +8 -6
  102. package/src/memory/l0.js +53 -52
  103. package/src/memory/l2.js +145 -130
  104. package/src/memory/l3.js +111 -111
  105. package/src/memory/legion-board.js +71 -0
  106. package/src/memory/memory-ticker.js +239 -240
  107. package/src/memory/session.js +398 -397
  108. package/src/memory/sqlite-store.js +581 -0
  109. package/src/mode/blackboard.js +49 -49
  110. package/src/mode/graph.js +42 -41
  111. package/src/mode/index.js +64 -64
  112. package/src/mode/legion.js +54 -51
  113. package/src/mode/plan-exec.js +50 -50
  114. package/src/mode/router.js +28 -40
  115. package/src/orchestrator/agent-worker.js +69 -69
  116. package/src/orchestrator/dag.js +90 -83
  117. package/src/orchestrator/experts.js +76 -0
  118. package/src/orchestrator/index.js +1 -1
  119. package/src/orchestrator/legion.js +179 -187
  120. package/src/orchestrator/supervisor.js +6 -8
  121. package/src/permissions/index.js +378 -0
  122. package/src/persona/index.js +28 -29
  123. package/src/plugin/builtin.js +313 -212
  124. package/src/plugin/context.js +80 -79
  125. package/src/plugin/index.js +62 -62
  126. package/src/plugin/v3.js +73 -0
  127. package/src/protocol/index.js +148 -0
  128. package/src/repomap/index.js +309 -0
  129. package/src/review/index.js +393 -0
  130. package/src/seam/registry.js +3 -0
  131. package/src/seam/shell.js +55 -55
  132. package/src/security/injection.js +79 -0
  133. package/src/selfheal/evolve.js +67 -67
  134. package/src/selfheal/healer.js +184 -167
  135. package/src/selfheal/run.js +9 -9
  136. package/src/server.js +63 -60
  137. package/src/services/diagnose.js +180 -0
  138. package/src/services/learning-service.js +9 -0
  139. package/src/services/memory-health.js +34 -6
  140. package/src/services/memory-service.js +42 -9
  141. package/src/services/triage.js +138 -0
  142. package/src/session/parts.js +76 -0
  143. package/src/session/projection.js +73 -0
  144. package/src/session/rollout.js +54 -0
  145. package/src/session/turn.js +137 -0
  146. package/src/skills/lint.js +72 -0
  147. package/src/skills/loader.js +231 -150
  148. package/src/skills/search.js +58 -0
  149. package/src/skills/verify.js +95 -100
  150. package/src/tools/advanced.js +388 -352
  151. package/src/tools/builtin.js +384 -297
  152. package/src/tools/catalog.js +283 -159
  153. package/src/tools/command-guard.js +112 -112
  154. package/src/tools/custom.js +47 -47
  155. package/src/tools/delegate.js +383 -297
  156. package/src/tools/document.js +254 -253
  157. package/src/tools/git.js +151 -0
  158. package/src/tools/governance.js +47 -20
  159. package/src/tools/index.js +16 -11
  160. package/src/tools/methods.js +178 -178
  161. package/src/tools/ocr.js +59 -59
  162. package/src/tools/sandbox-worker.js +40 -0
  163. package/src/tools/sandbox.js +92 -0
  164. package/src/tools/seam.js +162 -125
  165. package/src/tools/selfmod.js +196 -176
  166. package/src/tools/v3.js +225 -0
  167. package/src/tools/vad.js +176 -0
  168. package/src/tools/voice.js +238 -0
  169. package/src/utils/async.js +14 -0
  170. package/src/utils/config-file.js +53 -0
  171. package/src/utils/crashguard.js +88 -0
  172. package/src/utils/http.js +53 -0
  173. package/src/utils/id.js +8 -0
  174. package/src/utils/json-state.js +33 -0
  175. package/src/utils/logger.js +17 -17
  176. package/src/utils/ndjson.js +25 -0
  177. package/src/utils/pii.js +42 -42
  178. package/src/utils/rate-limit.js +50 -0
  179. package/src/utils/schema.js +80 -0
  180. package/src/utils/similarity.js +43 -0
  181. package/src/utils/store.js +170 -108
  182. package/src/utils/text.js +15 -15
  183. package/src/utils/trace.js +153 -153
  184. package/src/utils/wal.js +39 -0
  185. package/src/utils/winutf8.js +16 -15
  186. package/src/wiki/index.js +170 -0
@@ -0,0 +1,309 @@
1
+ // src/repomap/index.js - 仓库地图 (吸收 aider 思路, 纯 JS 实现, 零依赖)
2
+ // 职责: 递归扫描仓库 -> 正则提取 def/ref -> 引用图 -> PageRank 排序 -> token 预算内渲染目录+签名骨架
3
+ // 设计目标: 给 LLM 一段"地图", 在长上下文压缩/摘要时保留最重要的定义与引用关系。
4
+
5
+ import fs from "node:fs";
6
+ import path from "node:path";
7
+
8
+ // 默认跳过的目录 (与既有 .gitignore/构建产物约定一致)
9
+ export const DEFAULT_IGNORE = [".git", "node_modules", "dist", "build", ".next", "tmp", "data", ".workbuddy"];
10
+
11
+ // 参与扫描的扩展名族: js/ts/mjs/cjs/py/md/json/go/java
12
+ const SCAN_EXT = [".js", ".mjs", ".cjs", ".ts", ".py", ".md", ".json", ".go", ".java"];
13
+
14
+ // 常见语言关键字/噪音词, 不作为标识符/定义统计 (避免 if/for/function 等主导 PageRank)
15
+ const STOP = new Set([
16
+ "if", "else", "elif", "for", "while", "do", "switch", "case", "default", "break", "continue",
17
+ "function", "return", "class", "const", "let", "var", "new", "await", "async", "yield",
18
+ "try", "catch", "finally", "throw", "typeof", "instanceof", "in", "of", "void", "delete",
19
+ "export", "import", "from", "extends", "implements", "static", "public", "private", "protected",
20
+ "interface", "type", "struct", "enum", "namespace", "using", "package", "func", "def", "go",
21
+ "with", "as", "self", "this", "super", "true", "false", "null", "undefined", "None", "True",
22
+ "False", "print", "console", "log", "warn", "error", "debug", "require", "module", "end",
23
+ ]);
24
+
25
+ // 标识符词法 (词边界): 字母/下划线/$ 开头, 后接 字母/数字/_/$
26
+ const ID_RE = /\b([A-Za-z_$][\w$]*)\b/g;
27
+
28
+ // 按扩展名返回"提取定义"的正则列表
29
+ function defRegexes(ext) {
30
+ switch (ext) {
31
+ case ".js":
32
+ case ".mjs":
33
+ case ".cjs":
34
+ case ".ts":
35
+ return [
36
+ /(?:async\s+)?function\s+([A-Za-z_$][\w$]*)/g,
37
+ /(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=/g,
38
+ /class\s+([A-Za-z_$][\w$]*)/g,
39
+ ];
40
+ case ".py":
41
+ return [
42
+ /(?:async\s+)?def\s+([A-Za-z_$][\w$]*)/g,
43
+ /class\s+([A-Za-z_$][\w$]*)/g,
44
+ ];
45
+ case ".go":
46
+ return [
47
+ /func\s+(?:\([^)]*\)\s*)?([A-Za-z_$][\w$]*)\s*\(/g,
48
+ /type\s+([A-Za-z_$][\w$]*)\s+/g,
49
+ ];
50
+ case ".java":
51
+ return [
52
+ /class\s+([A-Za-z_$][\w$]*)/g,
53
+ /interface\s+([A-Za-z_$][\w$]*)/g,
54
+ /(?:public|private|protected|static|final|\s)*?([A-Za-z_$][\w$]*)\s*\([^;]*\)\s*\{/g,
55
+ ];
56
+ case ".json":
57
+ return [/"([A-Za-z_$][\w$]*)"\s*:/g];
58
+ default:
59
+ return [];
60
+ }
61
+ }
62
+
63
+ // 从单个文件内容提取 { defs:[{name,line,sig,kind}], refSet:Set, refCounts:Map }
64
+ // (2026-10-02 导出: wiki 生成器复用符号提取)
65
+ export function extractSymbols(content, ext) {
66
+ const lines = content.split(/\r?\n/);
67
+ const defs = [];
68
+ const defLineSet = new Set();
69
+ const defNames = new Set();
70
+
71
+ const pushDef = (name, lineNo) => {
72
+ if (!name || STOP.has(name)) return;
73
+ // 签名 = 定义行原文 (trim, 截 120 字符): 渲染时比 rank 数值对 LLM 有用得多
74
+ const sig = lines[lineNo - 1].trim().slice(0, 120);
75
+ defs.push({ name, line: lineNo, sig, kind: ext });
76
+ defLineSet.add(lineNo);
77
+ defNames.add(name);
78
+ };
79
+
80
+ if (ext === ".md") {
81
+ // markdown 标题视为定义节点 (目录锚点)
82
+ for (let i = 0; i < lines.length; i++) {
83
+ const m = lines[i].match(/^#{1,6}\s+(.+?)\s*#*$/);
84
+ if (m) {
85
+ const name = m[1].trim().toLowerCase().replace(/[^a-z0-9_$]+/g, "_").replace(/^_+|_+$/g, "");
86
+ if (name) pushDef(name, i + 1);
87
+ }
88
+ }
89
+ } else {
90
+ for (const re of defRegexes(ext)) {
91
+ for (let i = 0; i < lines.length; i++) {
92
+ const line = lines[i];
93
+ re.lastIndex = 0;
94
+ let m;
95
+ while ((m = re.exec(line)) !== null) {
96
+ const name = m[1] || m[2] || m[3];
97
+ if (name) pushDef(name, i + 1);
98
+ if (m.index === re.lastIndex) re.lastIndex++; // 防零宽死循环
99
+ }
100
+ }
101
+ }
102
+ }
103
+
104
+ // 引用统计: 跳过 def 所在行 (定义自身不计为引用), 词边界统计
105
+ const refSet = new Set();
106
+ const refCounts = new Map();
107
+ for (let i = 0; i < lines.length; i++) {
108
+ if (defLineSet.has(i + 1)) continue; // 跳过定义行
109
+ const line = lines[i];
110
+ let m;
111
+ ID_RE.lastIndex = 0;
112
+ while ((m = ID_RE.exec(line)) !== null) {
113
+ const name = m[1];
114
+ if (STOP.has(name)) continue;
115
+ refSet.add(name);
116
+ refCounts.set(name, (refCounts.get(name) || 0) + 1);
117
+ }
118
+ }
119
+
120
+ return { defs, defNames, refSet, refCounts };
121
+ }
122
+
123
+ // 递归扫描仓库, 返回结构化结果
124
+ export function scanRepo(root, opts = {}) {
125
+ const { maxFiles = 400, maxDepth = 12, ignore = DEFAULT_IGNORE } = opts;
126
+ const ignoreSet = new Set(ignore);
127
+ const files = [];
128
+ // 每个文件: { file:rel, defs:Set<name>, symbols:Set<name> } —— symbols = defs ∪ refs (用于同现边)
129
+ const fileSymbols = [];
130
+ const defRecords = new Map(); // name -> [{file, line, kind}]
131
+ const refCounts = new Map(); // name -> 总出现次数 (排除定义行)
132
+ let totalRefs = 0;
133
+
134
+ const walk = (dir, depth) => {
135
+ if (files.length >= maxFiles || depth > maxDepth) return;
136
+ let entries;
137
+ try {
138
+ entries = fs.readdirSync(dir, { withFileTypes: true });
139
+ } catch {
140
+ return;
141
+ }
142
+ // 先文件后目录, 保证 maxFiles 截断时优先保留浅层文件
143
+ const dirs = [];
144
+ for (const e of entries) {
145
+ if (files.length >= maxFiles) return;
146
+ const abs = path.join(dir, e.name);
147
+ if (e.isDirectory()) {
148
+ if (ignoreSet.has(e.name)) continue;
149
+ dirs.push(abs);
150
+ } else if (e.isFile()) {
151
+ const ext = path.extname(e.name).toLowerCase();
152
+ if (!SCAN_EXT.includes(ext)) continue;
153
+ const rel = path.relative(root, abs).split(path.sep).join("/");
154
+ let content;
155
+ try {
156
+ const st = fs.statSync(abs);
157
+ if (st.size > 1024 * 1024) { files.push({ rel, ext, dir: path.dirname(rel) }); continue; } // 超大文件跳过解析
158
+ content = fs.readFileSync(abs, "utf8");
159
+ } catch {
160
+ continue;
161
+ }
162
+ const { defs, defNames, refSet, refCounts: localRefs } = extractSymbols(content, ext);
163
+ const symbols = new Set([...defNames, ...refSet]);
164
+ fileSymbols.push({ file: rel, defs: defNames, symbols });
165
+ files.push({ rel, ext, dir: path.dirname(rel) });
166
+ for (const d of defs) {
167
+ if (!defRecords.has(d.name)) defRecords.set(d.name, []);
168
+ defRecords.get(d.name).push({ file: rel, line: d.line, sig: d.sig, kind: d.kind });
169
+ }
170
+ for (const [name, c] of localRefs) {
171
+ refCounts.set(name, (refCounts.get(name) || 0) + c);
172
+ totalRefs += c;
173
+ }
174
+ }
175
+ }
176
+ for (const d of dirs) walk(d, depth + 1);
177
+ };
178
+
179
+ walk(root, 0);
180
+ return { root, files, fileSymbols, defRecords, refCounts, totalRefs };
181
+ }
182
+
183
+ // 由扫描结果构建有向图: 节点=标识符, 边 = 文件内 def 指向同现的其它标识符
184
+ function buildGraph(scan) {
185
+ const adj = new Map(); // node -> Map(neighbor -> weight)
186
+ const nodes = new Set();
187
+ for (const fs of scan.fileSymbols) {
188
+ for (const d of fs.defs) {
189
+ nodes.add(d);
190
+ if (!adj.has(d)) adj.set(d, new Map());
191
+ const w = adj.get(d);
192
+ for (const x of fs.symbols) {
193
+ if (x === d) continue;
194
+ w.set(x, (w.get(x) || 0) + 1);
195
+ nodes.add(x);
196
+ }
197
+ }
198
+ }
199
+ return { adj, nodes };
200
+ }
201
+
202
+ // 纯 JS PageRank (幂迭代): 阻尼 0.85, 默认 20 次迭代, 处理悬挂节点
203
+ export function computePageRank(scan, opts = {}) {
204
+ const { damping = 0.85, iterations = 20 } = opts;
205
+ const { adj, nodes } = buildGraph(scan);
206
+ const arr = [...nodes];
207
+ const N = arr.length;
208
+ if (N === 0) return new Map();
209
+ const rank = new Map(arr.map((n) => [n, 1 / N]));
210
+ const outDeg = new Map();
211
+
212
+ for (let it = 0; it < iterations; it++) {
213
+ let danglingSum = 0;
214
+ outDeg.clear();
215
+ for (const n of arr) {
216
+ const w = adj.get(n);
217
+ let deg = 0;
218
+ if (w) for (const v of w.values()) deg += v;
219
+ outDeg.set(n, deg);
220
+ if (deg === 0) danglingSum += rank.get(n);
221
+ }
222
+ const next = new Map();
223
+ for (const n of arr) {
224
+ let sum = 0;
225
+ const w = adj.get(n);
226
+ if (w) {
227
+ for (const [m, weight] of w) {
228
+ const od = outDeg.get(m);
229
+ if (od > 0) sum += (rank.get(m) * weight) / od;
230
+ }
231
+ }
232
+ sum += danglingSum / N; // 悬挂节点均分
233
+ next.set(n, (1 - damping) / N + damping * sum);
234
+ }
235
+ rank.clear();
236
+ for (const [k, v] of next) rank.set(k, v);
237
+ }
238
+ return rank;
239
+ }
240
+
241
+ // ---- 渲染 ----
242
+
243
+ // 极简 token 估算: 按空白切词 (足够用于预算截断)
244
+ function approxTokens(s) {
245
+ return s.split(/\s+/).filter(Boolean).length || 1;
246
+ }
247
+
248
+ // 渲染仓库地图为树形文本, 超 tokenBudget 截断; root 维度内存缓存 30s
249
+ const _cache = new Map(); // root -> { t, value }
250
+
251
+ export function renderRepoMap(root, opts = {}) {
252
+ const { tokenBudget = 1024, maxFiles, maxDepth, ignore, topPerDir = 8, scan } = opts;
253
+ const cached = _cache.get(root);
254
+ if (cached && Date.now() - cached.t < 30000) return cached.value;
255
+
256
+ const s = scan || scanRepo(root, { maxFiles, maxDepth, ignore });
257
+ const rank = computePageRank(s);
258
+
259
+ // 定义节点按所在目录分组 (同名取最高 rank, 签名取该 rank 对应记录)
260
+ const byDir = new Map(); // dir -> Map(name -> { score, sig, where })
261
+ for (const [name, recs] of s.defRecords) {
262
+ const dir = path.dirname(recs[0].file);
263
+ const score = rank.get(name) || 0;
264
+ if (!byDir.has(dir)) byDir.set(dir, new Map());
265
+ const m = byDir.get(dir);
266
+ if (!m.has(name) || score > m.get(name)) {
267
+ m.set(name, { score, sig: recs[0].sig, where: `${recs[0].file}:${recs[0].line}` });
268
+ }
269
+ }
270
+
271
+ const lines = [];
272
+ lines.push(`仓库地图 (${s.root})`);
273
+ lines.push(`文件: ${s.files.length} · 定义: ${s.defRecords.size} · 引用: ${s.totalRefs}`);
274
+ lines.push("");
275
+
276
+ let used = approxTokens(lines.join("\n"));
277
+ let truncated = false;
278
+
279
+ const dirs = [...byDir.keys()].sort();
280
+ for (const dir of dirs) {
281
+ if (truncated) break;
282
+ const head = dir === "." ? "(root)" : dir;
283
+ lines.push(head);
284
+ used += approxTokens(head);
285
+ const entries = [...byDir.get(dir).entries()].sort((a, b) => b[1].score - a[1].score).slice(0, topPerDir);
286
+ for (const [name, info] of entries) {
287
+ // 签名优先 (LLM 拿到函数骨架), rank 数值是排序依据不是展示内容
288
+ const line = info.sig ? ` ${info.sig} // ${info.where}` : ` ${name} (${info.where})`;
289
+ const tk = approxTokens(line);
290
+ if (used + tk > tokenBudget) { truncated = true; break; }
291
+ lines.push(line);
292
+ used += tk;
293
+ }
294
+ }
295
+
296
+ const text = lines.join("\n");
297
+ const value = {
298
+ text,
299
+ stats: { files: s.files.length, defs: s.defRecords.size, refs: s.totalRefs },
300
+ truncated,
301
+ };
302
+ _cache.set(root, { t: Date.now(), value });
303
+ return value;
304
+ }
305
+
306
+ // 测试/调试辅助: 清空缓存
307
+ export function clearRepoMapCache() {
308
+ _cache.clear();
309
+ }
@@ -0,0 +1,393 @@
1
+ // src/review/index.js - 分级审查流水线 (吸收 OpenCode-Review / OCR 五阶段思路, 纯 JS, 零依赖)
2
+ // 五阶段: plan(范围/上下文) -> group(语义分组) -> review(逐组高/中/低) -> relocate(变更换位识别) -> filter(噪音过滤)
3
+ // 输出: P0/P1/P2 报告 (high/medium/low 映射)
4
+ //
5
+ // 设计定位: 这是"静态规则"审查内核, 不调用 LLM。集成层可把本模块产出的 issues 再交给 LLM 做语义精修。
6
+
7
+ import fs from "node:fs";
8
+
9
+ // ---- 阶段一: 计划 (范围/上下文估计) ----
10
+ export function planReview({ files = [], context = "" } = {}) {
11
+ const fileList = Array.isArray(files) ? files : (files ? [files] : []);
12
+ const estimatedGroups = Math.max(1, Math.ceil(fileList.length / 4));
13
+ return {
14
+ stages: [
15
+ { stage: 1, name: "plan", desc: "确定审查范围与上下文", done: true },
16
+ { stage: 2, name: "group", desc: "按目录/扩展名/变更类型语义分组", done: false },
17
+ { stage: 3, name: "review", desc: "逐组应用静态规则, 产出高/中/低问题", done: false },
18
+ { stage: 4, name: "relocate", desc: "识别变更换位(代码在文件间移动)并去重", done: false },
19
+ { stage: 5, name: "filter", desc: "过滤 lock 文件/生成目录/纯空白噪音", done: false },
20
+ ],
21
+ groups: estimatedGroups,
22
+ summary: `计划审查 ${fileList.length} 个文件, 预估 ${estimatedGroups} 个变更分组, 上下文长度 ${String(context).length}。`,
23
+ };
24
+ }
25
+
26
+ // ---- 阶段二: 语义分组 ----
27
+ // 入参可为: diff 文本 (解析 unified diff 得到文件列表) 或 文件路径数组
28
+ export function groupChanges(diffTextOrFileList) {
29
+ let files = [];
30
+ if (typeof diffTextOrFileList === "string") {
31
+ files = parseDiffFiles(diffTextOrFileList);
32
+ } else if (Array.isArray(diffTextOrFileList)) {
33
+ files = diffTextOrFileList.map((f) => (typeof f === "string" ? f : f.file || f.path || "")).filter(Boolean);
34
+ }
35
+ if (files.length === 0) return [];
36
+
37
+ // 按 "目录::扩展名" 分组 (同目录同类型归一组, 更贴近"语义相邻")
38
+ const map = new Map();
39
+ for (const f of files) {
40
+ const dir = f.includes("/") || f.includes("\\") ? f.replace(/[\\/][^\\/]+$/, "") : "(root)";
41
+ const ext = (f.match(/\.([a-zA-Z0-9]+)$/) || [, ""])[1].toLowerCase();
42
+ const key = `${dir}::${ext || "none"}`;
43
+ if (!map.has(key)) map.set(key, { key, dir, ext, files: [], kind: inferKind(f) });
44
+ map.get(key).files.push(f);
45
+ }
46
+ return [...map.values()];
47
+ }
48
+
49
+ function inferKind(file) {
50
+ if (/\.(test|spec)\./i.test(file)) return "test";
51
+ if (/(\b|_)(config|conf)\b/i.test(file) || /\.(json|ya?ml|toml|ini|env)$/i.test(file)) return "config";
52
+ if (/\.(md|txt|rst)$/i.test(file)) return "doc";
53
+ return "source";
54
+ }
55
+
56
+ // 解析 unified diff 头获取涉及文件 (支持 `diff --git a/x b/y` 与 `+++ b/x`)
57
+ function parseDiffFiles(diff) {
58
+ const files = [];
59
+ const seen = new Set();
60
+ for (const line of diff.split(/\r?\n/)) {
61
+ let m = line.match(/^diff --git a\/(.+?) b\/(.+?)\s*$/);
62
+ if (m) { pushUnique(files, seen, m[2]); continue; }
63
+ m = line.match(/^\+\+\+ b\/(.+?)\s*$/);
64
+ if (m) { pushUnique(files, seen, m[1]); continue; }
65
+ m = line.match(/^--- a\/(.+?)\s*$/);
66
+ if (m && !/^\/dev\/null/.test(m[1])) { pushUnique(files, seen, m[1]); }
67
+ }
68
+ return files;
69
+ }
70
+ function pushUnique(arr, seen, v) { if (!seen.has(v)) { seen.add(v); arr.push(v); } }
71
+
72
+ // ---- 阶段三: 逐组静态规则审查 ----
73
+ // 规则集中在此, 便于扩展。每条规则返回 { rule, severity, title, detail, suggestion } 或可空。
74
+ export function reviewGroup(group, opts = {}) {
75
+ const { maxIssues = 20 } = opts;
76
+ const issues = [];
77
+ const files = group.files || [];
78
+ for (const file of files) {
79
+ let content;
80
+ try {
81
+ content = fs.readFileSync(file, "utf8");
82
+ } catch {
83
+ continue; // 文件不存在(仅 diff 路径)则跳过
84
+ }
85
+ const rules = STATIC_RULES.filter((r) => !r.ext || r.ext.test(file));
86
+ for (const rule of rules) {
87
+ for (const hit of rule.scan(content, file)) {
88
+ if (issues.length >= maxIssues) return issues;
89
+ issues.push({
90
+ severity: rule.severity,
91
+ file,
92
+ line: hit.line,
93
+ title: rule.title,
94
+ detail: hit.detail,
95
+ suggestion: rule.suggestion,
96
+ });
97
+ }
98
+ }
99
+ }
100
+ return issues;
101
+ }
102
+
103
+ // 规则集: 每条 { severity, title, ext?(正则限定文件), suggestion, scan(content,file)->[{line,detail}] }
104
+ const STATIC_RULES = [
105
+ {
106
+ // 硬编码密钥 / api_key / token 字面量
107
+ severity: "high",
108
+ title: "硬编码敏感凭据",
109
+ ext: /\.(js|mjs|cjs|ts|py|go|java|json|ya?ml|env)$/i,
110
+ suggestion: "将密钥移入环境变量或密钥管理服务, 禁止明文提交。",
111
+ scan(content) {
112
+ const re = /(?:api[_-]?key|apikey|secret|token|access[_-]?key|private[_-]?key|password|passwd|client[_-]?secret)\s*[:=]\s*['"][^'"]{6,}['"]/gi;
113
+ const out = [];
114
+ let m;
115
+ while ((m = re.exec(content)) !== null) {
116
+ out.push({ line: lineOf(content, m.index), detail: `疑似硬编码凭据: ${m[0].slice(0, 40)}...` });
117
+ }
118
+ return out;
119
+ },
120
+ },
121
+ {
122
+ // TODO / FIXME / HACK 残留
123
+ severity: "low",
124
+ title: "待办/临时标记残留",
125
+ ext: /\.(js|mjs|cjs|ts|py|go|java|md)$/i,
126
+ suggestion: "跟踪到 issue 后移除标记, 或明确 owner 与截止时间。",
127
+ scan(content) {
128
+ const re = /\b(TODO|FIXME|HACK|XXX)\b/g;
129
+ const out = [];
130
+ let m;
131
+ while ((m = re.exec(content)) !== null) {
132
+ out.push({ line: lineOf(content, m.index), detail: `发现标记: ${m[0]}` });
133
+ }
134
+ return out;
135
+ },
136
+ },
137
+ {
138
+ // 调试残留 console.log / print
139
+ severity: "low",
140
+ title: "调试输出残留",
141
+ ext: /\.(js|mjs|cjs|ts)$/i,
142
+ suggestion: "移除临时 console.log/debug, 改用结构化日志。",
143
+ scan(content) {
144
+ const re = /\b(console\.log|console\.debug|console\.info)\s*\(/g;
145
+ const out = [];
146
+ let m;
147
+ while ((m = re.exec(content)) !== null) {
148
+ out.push({ line: lineOf(content, m.index), detail: `调试输出: ${m[0]}` });
149
+ }
150
+ return out;
151
+ },
152
+ },
153
+ {
154
+ // 调试残留 (python print)
155
+ severity: "low",
156
+ title: "调试输出残留",
157
+ ext: /\.py$/i,
158
+ suggestion: "移除临时 print, 改用 logging 模块。",
159
+ scan(content) {
160
+ const re = /(?<![A-Za-z_.])print\s*\(/g;
161
+ const out = [];
162
+ let m;
163
+ while ((m = re.exec(content)) !== null) {
164
+ out.push({ line: lineOf(content, m.index), detail: `调试输出: print(...)` });
165
+ }
166
+ return out;
167
+ },
168
+ },
169
+ {
170
+ // 超长函数 (>200 行)
171
+ severity: "medium",
172
+ title: "函数过长(>200行)",
173
+ ext: /\.(js|mjs|cjs|ts|go|java)$/i,
174
+ suggestion: "拆分为更小的单一职责函数, 提升可读性与可测试性。",
175
+ scan(content, file) {
176
+ return longFunctions(content, 200, "brace");
177
+ },
178
+ },
179
+ {
180
+ severity: "medium",
181
+ title: "函数过长(>200行)",
182
+ ext: /\.py$/i,
183
+ suggestion: "拆分为更小的单一职责函数, 提升可读性与可测试性。",
184
+ scan(content) {
185
+ return longFunctions(content, 200, "indent");
186
+ },
187
+ },
188
+ {
189
+ // 空 catch
190
+ severity: "medium",
191
+ title: "空 catch 吞掉异常",
192
+ ext: /\.(js|mjs|cjs|ts|java)$/i,
193
+ suggestion: "至少记录异常或显式声明忽略理由, 禁止静默吞掉。",
194
+ scan(content) {
195
+ const re = /catch\s*\([^)]*\)\s*\{\s*\}/g;
196
+ const out = [];
197
+ let m;
198
+ while ((m = re.exec(content)) !== null) {
199
+ out.push({ line: lineOf(content, m.index), detail: "空 catch 块: 异常被静默吞掉" });
200
+ }
201
+ return out;
202
+ },
203
+ },
204
+ {
205
+ severity: "medium",
206
+ title: "空 except 吞掉异常",
207
+ ext: /\.py$/i,
208
+ suggestion: "至少 logging.exception(...) 或显式 pass 加注释说明原因。",
209
+ scan(content) {
210
+ const re = /except\s+[\w.]*(?:\s+as\s+\w+)?\s*:\s*(?:pass\s*)?(?:\n|$)/g;
211
+ const out = [];
212
+ let m;
213
+ while ((m = re.exec(content)) !== null) {
214
+ // 仅匹配后面紧跟 pass 或换行的空 except
215
+ const tail = content.slice(m.index + m[0].length, m.index + m[0].length + 40);
216
+ if (/^\s*(pass\s*)?($|\n)/.test(tail) || /except\s+[\w.]*\s*:\s*pass/.test(m[0])) {
217
+ out.push({ line: lineOf(content, m.index), detail: "空 except 块: 异常被静默吞掉" });
218
+ }
219
+ }
220
+ return out;
221
+ },
222
+ },
223
+ {
224
+ // 松散相等 == (js)
225
+ severity: "low",
226
+ title: "使用松散相等 == (建议 ===)",
227
+ ext: /\.(js|mjs|cjs|ts)$/i,
228
+ suggestion: "使用 === / !== 避免隐式类型转换带来的意外。",
229
+ scan(content) {
230
+ const out = [];
231
+ const lines = content.split(/\r?\n/);
232
+ for (let i = 0; i < lines.length; i++) {
233
+ const t = lines[i].replace(/===|!==|=>/g, " ");
234
+ if (/(^|[^=!])=(?!=)(?![=])(?=.*\S)/.test(t) && /(\w\s*==\s*\w|\w==\w)/.test(t)) {
235
+ out.push({ line: i + 1, detail: "检测到 == 松散相等" });
236
+ }
237
+ }
238
+ return out;
239
+ },
240
+ },
241
+ {
242
+ // 未转义 HTML 拼接 (XSS 风险)
243
+ severity: "high",
244
+ title: "未转义 HTML 拼接(XSS 风险)",
245
+ ext: /\.(js|mjs|cjs|ts|py|java|go)$/i,
246
+ suggestion: "对动态值做 HTML 转义或使用安全模板, 避免 innerHTML 拼接。",
247
+ scan(content) {
248
+ const re = /(innerHTML|outerHTML|document\.write|dangerouslySetInnerHTML|insertAdjacentHTML)\b[^;]*([+`]|\$\{)/gi;
249
+ const out = [];
250
+ let m;
251
+ while ((m = re.exec(content)) !== null) {
252
+ out.push({ line: lineOf(content, m.index), detail: `疑似未转义 HTML 拼接: ${m[0].slice(0, 40)}...` });
253
+ }
254
+ return out;
255
+ },
256
+ },
257
+ ];
258
+
259
+ // 计算字符偏移对应的行号
260
+ function lineOf(content, index) {
261
+ let line = 1;
262
+ for (let i = 0; i < index && i < content.length; i++) {
263
+ if (content[i] === "\n") line++;
264
+ }
265
+ return line;
266
+ }
267
+
268
+ // 长函数检测: brace 模式(花括号配对) / indent 模式(python 缩进)
269
+ function longFunctions(content, limit, mode) {
270
+ const lines = content.split(/\r?\n/);
271
+ const out = [];
272
+ const startRe = mode === "indent"
273
+ ? /^\s*(?:async\s+)?def\s+\w+\s*\(/
274
+ : /^\s*(?:async\s+)?function\s+\w+|^\s*(?:const|let|var)\s+\w+\s*=\s*(?:async\s+)?\(?[^=]*\)?\s*=>|^\s*(?:public|private|protected|static|\s)*[\w<>\[\],\s]+\s+\w+\s*\([^;]*\)\s*\{|^\s*func\s+/;
275
+
276
+ for (let i = 0; i < lines.length; i++) {
277
+ if (!startRe.test(lines[i])) continue;
278
+ let end = i;
279
+ if (mode === "brace") {
280
+ let depth = 0;
281
+ let entered = false;
282
+ for (let j = i; j < lines.length; j++) {
283
+ for (const ch of lines[j]) {
284
+ if (ch === "{") { depth++; entered = true; }
285
+ else if (ch === "}") { depth--; if (entered && depth === 0) { end = j; break; } }
286
+ }
287
+ if (end !== i) break;
288
+ }
289
+ } else {
290
+ const baseIndent = lines[i].match(/^\s*/)[0].length;
291
+ for (let j = i + 1; j < lines.length; j++) {
292
+ const blank = lines[j].trim().length === 0;
293
+ if (!blank && lines[j].match(/^\s*/)[0].length <= baseIndent) { end = j - 1; break; }
294
+ end = j;
295
+ }
296
+ }
297
+ const len = end - i + 1;
298
+ if (len > limit) {
299
+ out.push({ line: i + 1, detail: `函数约 ${len} 行, 超过 ${limit} 行上限` });
300
+ }
301
+ }
302
+ return out;
303
+ }
304
+
305
+ // ---- 阶段四: 变更换位识别 (去重) ----
306
+ // moves: 描述"同一段代码从 A 移动到 B"的签名列表。命中签名的重复 issue 只保留一份(优先保留 to 端)。
307
+ export function relocateIssues(issues, moves = []) {
308
+ if (!Array.isArray(moves) || moves.length === 0) return issues;
309
+ const movedSigs = new Set();
310
+ for (const mv of moves) {
311
+ if (mv && mv.signature) movedSigs.add(mv.signature);
312
+ else if (mv && mv.from && mv.to) {
313
+ // 以 "from->to" 作为换位特征也加入
314
+ movedSigs.add(`${mv.from}->${mv.to}`);
315
+ }
316
+ }
317
+ const kept = [];
318
+ const seenSigs = new Set();
319
+ for (const it of issues) {
320
+ const sig = it.title + "@" + (it.detail || "");
321
+ if (movedSigs.has(sig) || movedSigs.has(`${it.file}`)) {
322
+ if (seenSigs.has(sig)) continue; // 同签名重复, 去重
323
+ seenSigs.add(sig);
324
+ }
325
+ kept.push(it);
326
+ }
327
+ return kept;
328
+ }
329
+
330
+ // ---- 阶段五: 噪音过滤 ----
331
+ export function filterNoise(issues) {
332
+ const LOCK = /(package-lock\.json|yarn\.lock|pnpm-lock\.yaml|composer\.lock|Gemfile\.lock|go\.sum)$/i;
333
+ const GEN = /(^|[\\/])(dist|build|\.next|coverage|node_modules|\.workbuddy)([\\/]|$)/i;
334
+ return issues.filter((it) => {
335
+ const f = it.file || "";
336
+ if (LOCK.test(f)) return false;
337
+ if (GEN.test(f)) return false;
338
+ // 纯空白/空变更: 无 title 或无有效定位
339
+ if (!it.title) return false;
340
+ return true;
341
+ });
342
+ }
343
+
344
+ // ---- 编排: 全流程 ----
345
+ export function runReview({ files = [], diff } = {}) {
346
+ const plan = planReview({ files, context: typeof diff === "string" ? diff : "" });
347
+ const groups = groupChanges(diff !== undefined ? diff : files);
348
+ let issues = [];
349
+ for (const g of groups) {
350
+ issues.push(...reviewGroup(g));
351
+ }
352
+ issues = relocateIssues(issues, []);
353
+ issues = filterNoise(issues);
354
+
355
+ const graded = { high: [], medium: [], low: [] };
356
+ for (const it of issues) {
357
+ if (it.severity === "high") graded.high.push(it);
358
+ else if (it.severity === "medium") graded.medium.push(it);
359
+ else graded.low.push(it);
360
+ }
361
+
362
+ const report = buildReport(graded, plan);
363
+ return { issues, graded, report, plan };
364
+ }
365
+
366
+ function buildReport(graded, plan) {
367
+ const lines = [];
368
+ lines.push("# 代码审查报告");
369
+ lines.push("");
370
+ lines.push(`> 范围: ${plan.summary}`);
371
+ lines.push("");
372
+ lines.push(`## 分级汇总`);
373
+ lines.push(`- P0 (high): ${graded.high.length}`);
374
+ lines.push(`- P1 (medium): ${graded.medium.length}`);
375
+ lines.push(`- P2 (low): ${graded.low.length}`);
376
+ lines.push("");
377
+
378
+ const section = (label, list, tag) => {
379
+ if (list.length === 0) return;
380
+ lines.push(`## ${label} (${tag})`);
381
+ for (const it of list) {
382
+ const loc = it.file + (it.line ? `:${it.line}` : "");
383
+ lines.push(`- [${tag}] **${it.title}** \`${loc}\``);
384
+ if (it.detail) lines.push(` - ${it.detail}`);
385
+ if (it.suggestion) lines.push(` - 建议: ${it.suggestion}`);
386
+ }
387
+ lines.push("");
388
+ };
389
+ section("P0 严重", graded.high, "P0");
390
+ section("P1 中等", graded.medium, "P1");
391
+ section("P2 轻微", graded.low, "P2");
392
+ return lines.join("\n").replace(/\n{3,}/g, "\n\n").trim() + "\n";
393
+ }