gx-code-agent 0.0.0-stage → 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/README.md +276 -2
- package/bin/gx-stdio-mcp.js +145 -0
- package/bin/gx.js +99 -0
- package/contracts/README.md +40 -0
- package/contracts/alignment.schema.json +99 -0
- package/contracts/code-chunk.schema.json +28 -0
- package/contracts/evidence.schema.json +108 -0
- package/contracts/examples/alignment.example.json +26 -0
- package/contracts/examples/code-chunk.example.json +20 -0
- package/contracts/examples/evidence.add-field.json +49 -0
- package/contracts/examples/evidence.verify-manifest.json +52 -0
- package/contracts/examples/ext-method.example.json +14 -0
- package/contracts/examples/manifest.example.json +13 -0
- package/contracts/examples/unknown.extension-coverage.json +29 -0
- package/contracts/ext-method.schema.json +57 -0
- package/contracts/manifest.schema.json +27 -0
- package/contracts/unknown.schema.json +119 -0
- package/eval/README.md +52 -0
- package/eval/code-search-eval.json +216 -0
- package/package.json +38 -4
- package/src/adapters/cli/args.js +94 -0
- package/src/adapters/cli/output.js +66 -0
- package/src/adapters/platform/mcpClient.js +202 -0
- package/src/application/applyWorkflow.js +138 -0
- package/src/application/auditCommand.js +130 -0
- package/src/application/auditWorkflow.js +41 -0
- package/src/application/chunksCommand.js +87 -0
- package/src/application/errors.js +27 -0
- package/src/application/evalSearchCommand.js +148 -0
- package/src/application/initWorkflow.js +563 -0
- package/src/application/pullWorkflow.js +74 -0
- package/src/application/scanCommand.js +71 -0
- package/src/application/scanWorkflow.js +689 -0
- package/src/application/verifyWorkflow.js +142 -0
- package/src/domain/canonicalJson.js +72 -0
- package/src/domain/chunker.js +297 -0
- package/src/domain/digest.js +60 -0
- package/src/domain/java/JavaAstChunker.java +139 -0
- package/src/domain/java/JavaAstScanner.java +107 -0
- package/src/domain/javaAst.js +186 -0
- package/src/domain/jdk.js +66 -0
- package/src/domain/keepBlocks.js +122 -0
- package/src/store/backupStore.js +106 -0
- package/src/store/localStore.js +79 -0
- package/templates/platform-rules.md +29 -0
|
@@ -0,0 +1,689 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import fs from "node:fs";
|
|
4
|
+
import os from "node:os";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
|
|
7
|
+
import { scanJavaAst } from "../domain/javaAst.js";
|
|
8
|
+
import { resolveJdkTools } from "../domain/jdk.js";
|
|
9
|
+
|
|
10
|
+
const DEFAULT_SOURCE_DIRS = ["src/main/java"];
|
|
11
|
+
const IGNORED_DIRS = new Set(["node_modules", ".git", "target", "build", "dist", "out", ".gx"]);
|
|
12
|
+
const EXTENSION_CLASS_SUFFIX = /(Hook|Ext|Extension|Listener|Handler|ServiceImpl|Service)$/u;
|
|
13
|
+
const IGNORED_METHODS = new Set(["main", "equals", "hashCode", "toString", "canEqual"]);
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* 递归收集 Java 文件。
|
|
17
|
+
*
|
|
18
|
+
* @param {string} dir
|
|
19
|
+
* @param {string[]} output
|
|
20
|
+
*/
|
|
21
|
+
function collectJavaFiles(dir, output) {
|
|
22
|
+
if (!fs.existsSync(dir)) {
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
26
|
+
if (IGNORED_DIRS.has(entry.name)) {
|
|
27
|
+
continue;
|
|
28
|
+
}
|
|
29
|
+
const full = path.join(dir, entry.name);
|
|
30
|
+
if (entry.isDirectory()) {
|
|
31
|
+
collectJavaFiles(full, output);
|
|
32
|
+
} else if (entry.isFile() && entry.name.endsWith(".java")) {
|
|
33
|
+
output.push(full);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* 按文件路径生成稳定的相对路径。
|
|
40
|
+
*
|
|
41
|
+
* @param {string} dir
|
|
42
|
+
* @param {string} file
|
|
43
|
+
* @returns {string}
|
|
44
|
+
*/
|
|
45
|
+
function relativePath(dir, file) {
|
|
46
|
+
return path.relative(dir, file).split(path.sep).join("/");
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* 计算行号(1 开始)。
|
|
51
|
+
*
|
|
52
|
+
* @param {string} content
|
|
53
|
+
* @param {number} index
|
|
54
|
+
* @returns {number}
|
|
55
|
+
*/
|
|
56
|
+
function lineAt(content, index) {
|
|
57
|
+
return content.slice(0, index).split("\n").length;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* 从 openIndex 开始查找配对的右括号,跳过字符串与注释。
|
|
62
|
+
*
|
|
63
|
+
* @param {string} content
|
|
64
|
+
* @param {number} openIndex
|
|
65
|
+
* @param {string} open
|
|
66
|
+
* @param {string} close
|
|
67
|
+
* @returns {number}
|
|
68
|
+
*/
|
|
69
|
+
function findMatching(content, openIndex, open = "(", close = ")") {
|
|
70
|
+
let depth = 0;
|
|
71
|
+
let quote = null;
|
|
72
|
+
let escaped = false;
|
|
73
|
+
let lineComment = false;
|
|
74
|
+
let blockComment = false;
|
|
75
|
+
for (let i = openIndex; i < content.length; i += 1) {
|
|
76
|
+
const char = content[i];
|
|
77
|
+
const next = content[i + 1];
|
|
78
|
+
if (lineComment) {
|
|
79
|
+
if (char === "\n") {
|
|
80
|
+
lineComment = false;
|
|
81
|
+
}
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
if (blockComment) {
|
|
85
|
+
if (char === "*" && next === "/") {
|
|
86
|
+
blockComment = false;
|
|
87
|
+
i += 1;
|
|
88
|
+
}
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
if (quote) {
|
|
92
|
+
if (escaped) {
|
|
93
|
+
escaped = false;
|
|
94
|
+
} else if (char === "\\") {
|
|
95
|
+
escaped = true;
|
|
96
|
+
} else if (char === quote) {
|
|
97
|
+
quote = null;
|
|
98
|
+
}
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
if (char === "/" && next === "/") {
|
|
102
|
+
lineComment = true;
|
|
103
|
+
i += 1;
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
if (char === "/" && next === "*") {
|
|
107
|
+
blockComment = true;
|
|
108
|
+
i += 1;
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
if (char === '"' || char === "'") {
|
|
112
|
+
quote = char;
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
if (char === open) {
|
|
116
|
+
depth += 1;
|
|
117
|
+
} else if (char === close) {
|
|
118
|
+
depth -= 1;
|
|
119
|
+
if (depth === 0) {
|
|
120
|
+
return i;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
return -1;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* 拆分注解参数中的顶层逗号。
|
|
129
|
+
*
|
|
130
|
+
* @param {string} text
|
|
131
|
+
* @returns {string[]}
|
|
132
|
+
*/
|
|
133
|
+
function splitTopLevel(text) {
|
|
134
|
+
const parts = [];
|
|
135
|
+
let start = 0;
|
|
136
|
+
let depth = 0;
|
|
137
|
+
let quote = null;
|
|
138
|
+
let escaped = false;
|
|
139
|
+
for (let i = 0; i < text.length; i += 1) {
|
|
140
|
+
const char = text[i];
|
|
141
|
+
if (quote) {
|
|
142
|
+
if (escaped) {
|
|
143
|
+
escaped = false;
|
|
144
|
+
} else if (char === "\\") {
|
|
145
|
+
escaped = true;
|
|
146
|
+
} else if (char === quote) {
|
|
147
|
+
quote = null;
|
|
148
|
+
}
|
|
149
|
+
continue;
|
|
150
|
+
}
|
|
151
|
+
if (char === '"' || char === "'") {
|
|
152
|
+
quote = char;
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
if (char === "{" || char === "(" || char === "[") {
|
|
156
|
+
depth += 1;
|
|
157
|
+
} else if (char === "}" || char === ")" || char === "]") {
|
|
158
|
+
depth -= 1;
|
|
159
|
+
} else if (char === "," && depth === 0) {
|
|
160
|
+
parts.push(text.slice(start, i));
|
|
161
|
+
start = i + 1;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
parts.push(text.slice(start));
|
|
165
|
+
return parts.map((part) => part.trim()).filter(Boolean);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* 解析字符串字面量。
|
|
170
|
+
*
|
|
171
|
+
* @param {string} text
|
|
172
|
+
* @returns {string}
|
|
173
|
+
*/
|
|
174
|
+
function parseString(text) {
|
|
175
|
+
const trimmed = text.trim();
|
|
176
|
+
if (trimmed.startsWith('"')) {
|
|
177
|
+
try {
|
|
178
|
+
return JSON.parse(trimmed);
|
|
179
|
+
} catch {
|
|
180
|
+
return trimmed.slice(1, -1);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
if (trimmed.startsWith("'") && trimmed.endsWith("'")) {
|
|
184
|
+
return trimmed.slice(1, -1).replace(/\\'/gu, "'").replace(/\\\\/gu, "\\");
|
|
185
|
+
}
|
|
186
|
+
return trimmed;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* 解析数组字面量中的字符串。
|
|
191
|
+
*
|
|
192
|
+
* @param {string} text
|
|
193
|
+
* @returns {string[]}
|
|
194
|
+
*/
|
|
195
|
+
function parseStringArray(text) {
|
|
196
|
+
const matches = [...text.matchAll(/"((?:\\.|[^"\\])*)"|'((?:\\.|[^'\\])*)'/gu)];
|
|
197
|
+
return matches.map((match) => {
|
|
198
|
+
if (match[1] !== undefined) {
|
|
199
|
+
try {
|
|
200
|
+
return JSON.parse(`"${match[1]}"`);
|
|
201
|
+
} catch {
|
|
202
|
+
return match[1];
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
return match[2].replace(/\\'/gu, "'").replace(/\\\\/gu, "\\");
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* 解析 @ExtMethod 参数。
|
|
211
|
+
*
|
|
212
|
+
* @param {string} text
|
|
213
|
+
* @returns {{ entity?: string, intent?: string, triggers: string[] }}
|
|
214
|
+
*/
|
|
215
|
+
function parseExtMethodArgs(text) {
|
|
216
|
+
const parsed = { triggers: [], fields: [] };
|
|
217
|
+
for (const part of splitTopLevel(text)) {
|
|
218
|
+
const equals = part.indexOf("=");
|
|
219
|
+
if (equals < 0) {
|
|
220
|
+
continue;
|
|
221
|
+
}
|
|
222
|
+
const key = part.slice(0, equals).trim();
|
|
223
|
+
const value = part.slice(equals + 1).trim();
|
|
224
|
+
if (key === "entity" || key === "intent") {
|
|
225
|
+
parsed[key] = parseString(value);
|
|
226
|
+
} else if (key === "triggers") {
|
|
227
|
+
parsed.triggers = parseStringArray(value);
|
|
228
|
+
} else if (key === "fields") {
|
|
229
|
+
parsed.fields = parseStringArray(value);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
return parsed;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* 找到注解所在类名。
|
|
237
|
+
*
|
|
238
|
+
* @param {string} content
|
|
239
|
+
* @param {number} index
|
|
240
|
+
* @returns {string}
|
|
241
|
+
*/
|
|
242
|
+
function classNameAt(content, index) {
|
|
243
|
+
const regex = /\b(?:class|interface|enum|record)\s+([A-Za-z_$][\w$]*)/gu;
|
|
244
|
+
let name = "";
|
|
245
|
+
for (const match of content.matchAll(regex)) {
|
|
246
|
+
if (match.index > index) {
|
|
247
|
+
break;
|
|
248
|
+
}
|
|
249
|
+
name = match[1];
|
|
250
|
+
}
|
|
251
|
+
return name;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* 跳过空白、注释与注解。
|
|
256
|
+
*
|
|
257
|
+
* @param {string} content
|
|
258
|
+
* @param {number} start
|
|
259
|
+
* @returns {number}
|
|
260
|
+
*/
|
|
261
|
+
function skipTrivia(content, start) {
|
|
262
|
+
let i = start;
|
|
263
|
+
while (i < content.length) {
|
|
264
|
+
if (/\s/u.test(content[i])) {
|
|
265
|
+
i += 1;
|
|
266
|
+
continue;
|
|
267
|
+
}
|
|
268
|
+
if (content[i] === "/" && content[i + 1] === "/") {
|
|
269
|
+
const end = content.indexOf("\n", i + 2);
|
|
270
|
+
i = end < 0 ? content.length : end + 1;
|
|
271
|
+
continue;
|
|
272
|
+
}
|
|
273
|
+
if (content[i] === "/" && content[i + 1] === "*") {
|
|
274
|
+
const end = content.indexOf("*/", i + 2);
|
|
275
|
+
i = end < 0 ? content.length : end + 2;
|
|
276
|
+
continue;
|
|
277
|
+
}
|
|
278
|
+
if (content[i] === "@") {
|
|
279
|
+
let j = i + 1;
|
|
280
|
+
while (j < content.length && /[\w$.]/u.test(content[j])) {
|
|
281
|
+
j += 1;
|
|
282
|
+
}
|
|
283
|
+
j = skipTrivia(content, j);
|
|
284
|
+
if (content[j] === "(") {
|
|
285
|
+
const close = findMatching(content, j);
|
|
286
|
+
j = close < 0 ? content.length : close + 1;
|
|
287
|
+
}
|
|
288
|
+
i = j;
|
|
289
|
+
continue;
|
|
290
|
+
}
|
|
291
|
+
break;
|
|
292
|
+
}
|
|
293
|
+
return i;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* 从注解后定位方法。
|
|
298
|
+
*
|
|
299
|
+
* @param {string} content
|
|
300
|
+
* @param {number} start
|
|
301
|
+
* @returns {{ methodName: string, start: number, end: number } | null}
|
|
302
|
+
*/
|
|
303
|
+
function findMethodAfter(content, start) {
|
|
304
|
+
let i = skipTrivia(content, start);
|
|
305
|
+
const modifiers = /^(?:public|protected|private|static|final|abstract|synchronized|native|default|strictfp|transient|volatile)\b/u;
|
|
306
|
+
for (;;) {
|
|
307
|
+
const rest = content.slice(i);
|
|
308
|
+
const match = rest.match(modifiers);
|
|
309
|
+
if (!match) {
|
|
310
|
+
break;
|
|
311
|
+
}
|
|
312
|
+
i = skipTrivia(content, i + match[0].length);
|
|
313
|
+
}
|
|
314
|
+
const paren = content.indexOf("(", i);
|
|
315
|
+
if (paren < 0) {
|
|
316
|
+
return null;
|
|
317
|
+
}
|
|
318
|
+
const segment = content.slice(i, paren);
|
|
319
|
+
const nameMatch = segment.match(/([A-Za-z_$][\w$]*)\s*$/u);
|
|
320
|
+
if (!nameMatch) {
|
|
321
|
+
return null;
|
|
322
|
+
}
|
|
323
|
+
const methodName = nameMatch[1];
|
|
324
|
+
const methodStart = i;
|
|
325
|
+
const afterParen = findMatching(content, paren);
|
|
326
|
+
if (afterParen < 0) {
|
|
327
|
+
return null;
|
|
328
|
+
}
|
|
329
|
+
let cursor = afterParen + 1;
|
|
330
|
+
let bodyStart = -1;
|
|
331
|
+
while (cursor < content.length) {
|
|
332
|
+
const char = content[cursor];
|
|
333
|
+
if (char === ";") {
|
|
334
|
+
return { methodName, start: methodStart, end: cursor + 1 };
|
|
335
|
+
}
|
|
336
|
+
if (char === "{") {
|
|
337
|
+
bodyStart = cursor;
|
|
338
|
+
break;
|
|
339
|
+
}
|
|
340
|
+
cursor += 1;
|
|
341
|
+
}
|
|
342
|
+
if (bodyStart < 0) {
|
|
343
|
+
return { methodName, start: methodStart, end: afterParen + 1 };
|
|
344
|
+
}
|
|
345
|
+
const bodyEnd = findMatching(content, bodyStart, "{", "}");
|
|
346
|
+
return { methodName, start: methodStart, end: bodyEnd < 0 ? content.length : bodyEnd + 1 };
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/**
|
|
350
|
+
* 提取源码中的 @ExtMethod 声明。
|
|
351
|
+
*
|
|
352
|
+
* @param {string} content
|
|
353
|
+
* @param {string} file
|
|
354
|
+
* @param {string} dir
|
|
355
|
+
* @param {string[]} warnings
|
|
356
|
+
* @returns {object[]}
|
|
357
|
+
*/
|
|
358
|
+
function extractExtPoints(content, file, dir, warnings) {
|
|
359
|
+
const points = [];
|
|
360
|
+
const regex = /@(?:[A-Za-z_$][\w$]*\.)*ExtMethod\s*\(/gu;
|
|
361
|
+
for (const match of content.matchAll(regex)) {
|
|
362
|
+
const open = match.index + match[0].lastIndexOf("(");
|
|
363
|
+
const close = findMatching(content, open);
|
|
364
|
+
if (close < 0) {
|
|
365
|
+
warnings.push(`${relativePath(dir, file)}: @ExtMethod 参数括号未闭合`);
|
|
366
|
+
continue;
|
|
367
|
+
}
|
|
368
|
+
const args = parseExtMethodArgs(content.slice(open + 1, close));
|
|
369
|
+
const method = findMethodAfter(content, close + 1);
|
|
370
|
+
if (!method) {
|
|
371
|
+
warnings.push(`${relativePath(dir, file)}: @ExtMethod 后未找到方法`);
|
|
372
|
+
continue;
|
|
373
|
+
}
|
|
374
|
+
const className = classNameAt(content, match.index);
|
|
375
|
+
if (!className || !args.entity || !args.intent) {
|
|
376
|
+
warnings.push(`${relativePath(dir, file)}: @ExtMethod 缺少 entity/intent 或类名`);
|
|
377
|
+
continue;
|
|
378
|
+
}
|
|
379
|
+
const code = content.slice(method.start, method.end).trim();
|
|
380
|
+
points.push({
|
|
381
|
+
entity: args.entity,
|
|
382
|
+
intent: args.intent,
|
|
383
|
+
triggers: args.triggers,
|
|
384
|
+
fields: args.fields,
|
|
385
|
+
class: className,
|
|
386
|
+
method: method.methodName,
|
|
387
|
+
codeHash: createHash("sha256").update(code).digest("hex"),
|
|
388
|
+
file: relativePath(dir, file)
|
|
389
|
+
});
|
|
390
|
+
}
|
|
391
|
+
return points;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/**
|
|
395
|
+
* 判断文件是否属于扩展层候选。
|
|
396
|
+
*
|
|
397
|
+
* @param {string} content
|
|
398
|
+
* @param {string} className
|
|
399
|
+
* @returns {boolean}
|
|
400
|
+
*/
|
|
401
|
+
function isExtensionCandidate(content, className) {
|
|
402
|
+
const pkg = content.match(/^\s*package\s+([\w.]+)\s*;/mu)?.[1] ?? "";
|
|
403
|
+
return /(?:^|\.)(?:ext|extension)(?:\.|$)/iu.test(pkg)
|
|
404
|
+
|| EXTENSION_CLASS_SUFFIX.test(className)
|
|
405
|
+
|| content.includes("@ExtMethod");
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
/**
|
|
409
|
+
* 提取扩展层中缺少 @ExtMethod 的公开方法。
|
|
410
|
+
*
|
|
411
|
+
* @param {string} content
|
|
412
|
+
* @param {string} file
|
|
413
|
+
* @param {string} dir
|
|
414
|
+
* @param {string} className
|
|
415
|
+
* @param {Set<string>} annotated
|
|
416
|
+
* @returns {object[]}
|
|
417
|
+
*/
|
|
418
|
+
function extractUnannotated(content, file, dir, className, annotated) {
|
|
419
|
+
if (!className || !isExtensionCandidate(content, className) || content.includes("@generated GX-CODE")) {
|
|
420
|
+
return [];
|
|
421
|
+
}
|
|
422
|
+
const result = [];
|
|
423
|
+
const regex = /\bpublic\s+(?:static\s+)?(?:final\s+)?(?:synchronized\s+)?(?:abstract\s+)?(?:default\s+)?[\w$<>\[\],.?\s]+\s+([A-Za-z_$][\w$]*)\s*\(/gu;
|
|
424
|
+
for (const match of content.matchAll(regex)) {
|
|
425
|
+
const methodName = match[1];
|
|
426
|
+
if (IGNORED_METHODS.has(methodName) || methodName === className || annotated.has(methodName)) {
|
|
427
|
+
continue;
|
|
428
|
+
}
|
|
429
|
+
result.push({
|
|
430
|
+
className,
|
|
431
|
+
methodName,
|
|
432
|
+
file: relativePath(dir, file),
|
|
433
|
+
line: lineAt(content, match.index),
|
|
434
|
+
reason: "扩展层公开方法未声明 @ExtMethod"
|
|
435
|
+
});
|
|
436
|
+
}
|
|
437
|
+
return result;
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
/**
|
|
441
|
+
* 构建编译 classpath。
|
|
442
|
+
*
|
|
443
|
+
* @param {string} dir
|
|
444
|
+
* @param {string | undefined} extra
|
|
445
|
+
* @returns {string[]}
|
|
446
|
+
*/
|
|
447
|
+
function buildClasspath(dir, extra) {
|
|
448
|
+
const entries = [];
|
|
449
|
+
if (extra) {
|
|
450
|
+
entries.push(...extra.split(path.delimiter).map((item) => item.trim()).filter(Boolean));
|
|
451
|
+
}
|
|
452
|
+
for (const candidate of ["target/classes", "build/classes/java/main", "out/production/classes", "bin/main"]) {
|
|
453
|
+
const full = path.join(dir, candidate);
|
|
454
|
+
if (fs.existsSync(full)) {
|
|
455
|
+
entries.push(full);
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
for (const candidate of ["target/dependency", "build/libs", "lib"]) {
|
|
459
|
+
const full = path.join(dir, candidate);
|
|
460
|
+
if (!fs.existsSync(full)) {
|
|
461
|
+
continue;
|
|
462
|
+
}
|
|
463
|
+
for (const entry of fs.readdirSync(full, { withFileTypes: true })) {
|
|
464
|
+
if (entry.isFile() && entry.name.endsWith(".jar")) {
|
|
465
|
+
entries.push(path.join(full, entry.name));
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
return entries;
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
/**
|
|
473
|
+
* 使用 javac 编译扩展层源码。
|
|
474
|
+
*
|
|
475
|
+
* @param {{ dir: string, files: string[], classpath?: string }} input
|
|
476
|
+
* @returns {object}
|
|
477
|
+
*/
|
|
478
|
+
function compileSources({ dir, files, classpath, sourceDirs }) {
|
|
479
|
+
const tools = resolveJdkTools();
|
|
480
|
+
if (!tools.ok) {
|
|
481
|
+
return { attempted: false, available: false, reason: tools.reason };
|
|
482
|
+
}
|
|
483
|
+
const javac = tools.javac;
|
|
484
|
+
if (files.length === 0) {
|
|
485
|
+
return { attempted: true, available: true, success: true, fileCount: 0, errors: [] };
|
|
486
|
+
}
|
|
487
|
+
const output = fs.mkdtempSync(path.join(os.tmpdir(), "gx-compile-"));
|
|
488
|
+
const cp = buildClasspath(dir, classpath);
|
|
489
|
+
const args = ["-proc:none", "-encoding", "UTF-8", "-d", output];
|
|
490
|
+
if (sourceDirs.length > 0) {
|
|
491
|
+
args.push("-sourcepath", sourceDirs.join(path.delimiter));
|
|
492
|
+
}
|
|
493
|
+
if (cp.length > 0) {
|
|
494
|
+
args.push("-cp", cp.join(path.delimiter));
|
|
495
|
+
}
|
|
496
|
+
args.push(...files);
|
|
497
|
+
const result = spawnSync(javac, args, { encoding: "utf8", maxBuffer: 10 * 1024 * 1024 });
|
|
498
|
+
fs.rmSync(output, { recursive: true, force: true });
|
|
499
|
+
if (result.error) {
|
|
500
|
+
return { attempted: false, available: false, reason: `javac 执行失败:${result.error.message}` };
|
|
501
|
+
}
|
|
502
|
+
const text = `${result.stdout ?? ""}\n${result.stderr ?? ""}`;
|
|
503
|
+
const errors = [];
|
|
504
|
+
let contextLines = 0;
|
|
505
|
+
for (const line of text.split(/\r?\n/u).map((item) => item.trim()).filter(Boolean)) {
|
|
506
|
+
if (line.includes(".java:") || line.startsWith("error:") || line.startsWith("错误:")) {
|
|
507
|
+
errors.push(line);
|
|
508
|
+
contextLines = 3;
|
|
509
|
+
continue;
|
|
510
|
+
}
|
|
511
|
+
if (contextLines > 0) {
|
|
512
|
+
errors.push(line);
|
|
513
|
+
contextLines -= 1;
|
|
514
|
+
}
|
|
515
|
+
if (errors.length >= 50) {
|
|
516
|
+
break;
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
return {
|
|
520
|
+
attempted: true,
|
|
521
|
+
available: true,
|
|
522
|
+
success: result.status === 0,
|
|
523
|
+
exitCode: result.status,
|
|
524
|
+
fileCount: files.length,
|
|
525
|
+
errors
|
|
526
|
+
};
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
/**
|
|
530
|
+
* 扫描扩展点。
|
|
531
|
+
*
|
|
532
|
+
* @param {{ dir: string, sourceDirs?: string[], compile?: boolean, classpath?: string }} input
|
|
533
|
+
* @returns {{ extPoints: object[], unannotatedMethods: object[], warnings: string[], coverage: object, filesScanned: number, compilation: object | null }}
|
|
534
|
+
*/
|
|
535
|
+
export function scanWorkflow({ dir, sourceDirs = DEFAULT_SOURCE_DIRS, compile = false, classpath }) {
|
|
536
|
+
const existingDirs = sourceDirs
|
|
537
|
+
.map((sourceDir) => (path.isAbsolute(sourceDir) ? sourceDir : path.join(dir, sourceDir)))
|
|
538
|
+
.filter((sourceDir) => fs.existsSync(sourceDir));
|
|
539
|
+
|
|
540
|
+
const files = [];
|
|
541
|
+
for (const sourceDir of existingDirs) {
|
|
542
|
+
collectJavaFiles(sourceDir, files);
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
const points = [];
|
|
546
|
+
const unannotated = [];
|
|
547
|
+
const warnings = [];
|
|
548
|
+
const extensionFiles = [];
|
|
549
|
+
const ast = scanJavaAst({ files });
|
|
550
|
+
const astByFile = new Map();
|
|
551
|
+
for (const method of ast.methods) {
|
|
552
|
+
const list = astByFile.get(path.resolve(method.file)) ?? [];
|
|
553
|
+
list.push(method);
|
|
554
|
+
astByFile.set(path.resolve(method.file), list);
|
|
555
|
+
}
|
|
556
|
+
if (!ast.available) {
|
|
557
|
+
warnings.push(`AST 扫描不可用,回退正则检测:${ast.reason}`);
|
|
558
|
+
}
|
|
559
|
+
else {
|
|
560
|
+
warnings.push(...ast.warnings);
|
|
561
|
+
}
|
|
562
|
+
for (const file of files) {
|
|
563
|
+
const content = fs.readFileSync(file, "utf8");
|
|
564
|
+
const pointsInFile = extractExtPoints(content, file, dir, warnings);
|
|
565
|
+
const annotatedByClass = new Map();
|
|
566
|
+
for (const point of pointsInFile) {
|
|
567
|
+
const list = annotatedByClass.get(point.class) ?? new Set();
|
|
568
|
+
list.add(point.method);
|
|
569
|
+
annotatedByClass.set(point.class, list);
|
|
570
|
+
}
|
|
571
|
+
points.push(...pointsInFile);
|
|
572
|
+
const classes = new Set([...content.matchAll(/\b(?:class|interface|enum|record)\s+([A-Za-z_$][\w$]*)/gu)]
|
|
573
|
+
.map((match) => match[1]));
|
|
574
|
+
const astMethods = astByFile.get(path.resolve(file)) ?? [];
|
|
575
|
+
const astAnnotatedByClass = new Map();
|
|
576
|
+
for (const method of astMethods) {
|
|
577
|
+
if (!method.annotations.some((annotation) => annotation.endsWith("ExtMethod"))) {
|
|
578
|
+
continue;
|
|
579
|
+
}
|
|
580
|
+
const list = astAnnotatedByClass.get(method.className) ?? new Set();
|
|
581
|
+
list.add(method.methodName);
|
|
582
|
+
astAnnotatedByClass.set(method.className, list);
|
|
583
|
+
}
|
|
584
|
+
if (ast.available) {
|
|
585
|
+
for (const method of astMethods) {
|
|
586
|
+
const className = method.className;
|
|
587
|
+
const simpleClass = className.split(".").pop();
|
|
588
|
+
if (!className || className.endsWith(".") || !simpleClass || content.includes("@generated GX-CODE")) {
|
|
589
|
+
continue;
|
|
590
|
+
}
|
|
591
|
+
if (!isExtensionCandidate(content, className)
|
|
592
|
+
|| IGNORED_METHODS.has(method.methodName) || method.methodName === simpleClass) {
|
|
593
|
+
continue;
|
|
594
|
+
}
|
|
595
|
+
if (method.annotations.some((annotation) => annotation.endsWith("ExtMethod"))) {
|
|
596
|
+
continue;
|
|
597
|
+
}
|
|
598
|
+
if ((astAnnotatedByClass.get(className) ?? new Set()).has(method.methodName)) {
|
|
599
|
+
continue;
|
|
600
|
+
}
|
|
601
|
+
unannotated.push({
|
|
602
|
+
className,
|
|
603
|
+
methodName: method.methodName,
|
|
604
|
+
file: relativePath(dir, file),
|
|
605
|
+
line: method.line,
|
|
606
|
+
reason: "扩展层公开方法未声明 @ExtMethod(AST)"
|
|
607
|
+
});
|
|
608
|
+
}
|
|
609
|
+
for (const className of new Set(astMethods.map((method) => method.className))) {
|
|
610
|
+
if (className && !className.endsWith(".") && isExtensionCandidate(content, className)) {
|
|
611
|
+
extensionFiles.push(file);
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
else {
|
|
616
|
+
for (const className of classes) {
|
|
617
|
+
unannotated.push(...extractUnannotated(content, file, dir, className,
|
|
618
|
+
annotatedByClass.get(className) ?? new Set()));
|
|
619
|
+
if (isExtensionCandidate(content, className)) {
|
|
620
|
+
extensionFiles.push(file);
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
points.sort((left, right) => `${left.class}#${left.method}`.localeCompare(`${right.class}#${right.method}`));
|
|
627
|
+
unannotated.sort((left, right) => `${left.className}#${left.methodName}`
|
|
628
|
+
.localeCompare(`${right.className}#${right.methodName}`));
|
|
629
|
+
|
|
630
|
+
const sourceAvailable = existingDirs.length > 0;
|
|
631
|
+
const facets = {
|
|
632
|
+
source_files: sourceAvailable
|
|
633
|
+
? { available: true }
|
|
634
|
+
: { available: false, reason: "源码目录不存在" },
|
|
635
|
+
ext_method_scan: sourceAvailable
|
|
636
|
+
? { available: true, reason: null }
|
|
637
|
+
: { available: false, reason: "源码目录不存在,无法扫描 @ExtMethod" },
|
|
638
|
+
ast_scan: sourceAvailable
|
|
639
|
+
? (ast.available ? { available: true, reason: null }
|
|
640
|
+
: { available: false, reason: ast.reason })
|
|
641
|
+
: { available: false, reason: "源码目录不存在,无法执行 AST 扫描" },
|
|
642
|
+
compilation: compile
|
|
643
|
+
? { available: false, reason: "未执行编译" }
|
|
644
|
+
: { available: false, reason: "未执行编译;如需编译请使用 --compile" }
|
|
645
|
+
};
|
|
646
|
+
|
|
647
|
+
let compilation = null;
|
|
648
|
+
if (compile) {
|
|
649
|
+
const compileFiles = extensionFiles.length > 0 ? [...new Set(extensionFiles)] : files;
|
|
650
|
+
compilation = compileSources({ dir, files: compileFiles, classpath, sourceDirs: existingDirs });
|
|
651
|
+
facets.compilation = compilation.available
|
|
652
|
+
? { available: true, success: compilation.success, reason: compilation.success ? null
|
|
653
|
+
: `javac 失败,退出码 ${compilation.exitCode}` }
|
|
654
|
+
: { available: false, reason: compilation.reason };
|
|
655
|
+
if (compilation.available && !compilation.success) {
|
|
656
|
+
warnings.push(`扩展层编译失败,共 ${compilation.errors.length} 条错误`);
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
return {
|
|
661
|
+
extPoints: points,
|
|
662
|
+
unannotatedMethods: dedupeMethods(unannotated),
|
|
663
|
+
warnings,
|
|
664
|
+
filesScanned: files.length,
|
|
665
|
+
compilation,
|
|
666
|
+
coverage: {
|
|
667
|
+
status: sourceAvailable ? "partial" : "unknown",
|
|
668
|
+
facets
|
|
669
|
+
}
|
|
670
|
+
};
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
/**
|
|
674
|
+
* 去重未声明方法。
|
|
675
|
+
*
|
|
676
|
+
* @param {object[]} methods
|
|
677
|
+
* @returns {object[]}
|
|
678
|
+
*/
|
|
679
|
+
function dedupeMethods(methods) {
|
|
680
|
+
const seen = new Set();
|
|
681
|
+
return methods.filter((item) => {
|
|
682
|
+
const key = `${item.className}#${item.methodName}@${item.file}:${item.line}`;
|
|
683
|
+
if (seen.has(key)) {
|
|
684
|
+
return false;
|
|
685
|
+
}
|
|
686
|
+
seen.add(key);
|
|
687
|
+
return true;
|
|
688
|
+
});
|
|
689
|
+
}
|