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,138 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
|
|
5
|
+
import { mergeKeepBlocks, stripKeepBlockContents } from "../domain/keepBlocks.js";
|
|
6
|
+
import { InitTransaction, createBackupRoot, pruneBackups } from "../store/backupStore.js";
|
|
7
|
+
import { readJson, readText, writeJson, writeText } from "../store/localStore.js";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* 计算文件生成层骨架的 SHA-256。
|
|
11
|
+
*
|
|
12
|
+
* @param {string} content
|
|
13
|
+
* @returns {string}
|
|
14
|
+
*/
|
|
15
|
+
function skeletonHash(content) {
|
|
16
|
+
return createHash("sha256").update(stripKeepBlockContents(content)).digest("hex");
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* apply 前预检:识别上次 apply 之后生成层骨架被手改的文件。
|
|
21
|
+
*
|
|
22
|
+
* @param {{ dir: string, manifest: object, previous: object | null, force: boolean, dryRun: boolean, io: object }} input
|
|
23
|
+
* @returns {{ checked: number, drifted: string[], unverified: string[] }}
|
|
24
|
+
*/
|
|
25
|
+
function preflight({ dir, manifest, previous, force, dryRun, io }) {
|
|
26
|
+
const previousHashes = previous && typeof previous === "object" && previous.fileHashes
|
|
27
|
+
&& typeof previous.fileHashes === "object" ? previous.fileHashes : {};
|
|
28
|
+
const drifted = [];
|
|
29
|
+
const unverified = [];
|
|
30
|
+
let checked = 0;
|
|
31
|
+
for (const entry of manifest.files) {
|
|
32
|
+
if (!entry || typeof entry.path !== "string" || typeof entry.sha256 !== "string") {
|
|
33
|
+
continue;
|
|
34
|
+
}
|
|
35
|
+
const target = path.join(dir, entry.path);
|
|
36
|
+
if (!fs.existsSync(target)) {
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
checked += 1;
|
|
40
|
+
const actual = skeletonHash(readText(target));
|
|
41
|
+
if (actual === entry.sha256) {
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
const previousHash = previousHashes[entry.path];
|
|
45
|
+
if (previousHash) {
|
|
46
|
+
if (actual !== previousHash) {
|
|
47
|
+
drifted.push(entry.path);
|
|
48
|
+
}
|
|
49
|
+
} else {
|
|
50
|
+
unverified.push(entry.path);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
const preview = (items) => `${items.slice(0, 10).join(", ")}${items.length > 10 ? ` 等 ${items.length} 个` : ""}`;
|
|
54
|
+
if (drifted.length > 0) {
|
|
55
|
+
const message = `检测到 ${drifted.length} 个生成层文件在最近一次 apply 后被修改:${preview(drifted)}`;
|
|
56
|
+
if (!force && !dryRun) {
|
|
57
|
+
throw new Error(`${message}。请先执行 gx verify 确认漂移;如确认要覆盖生成层,请使用 gx apply --force`);
|
|
58
|
+
}
|
|
59
|
+
io.stderr?.write?.(`[warn] ${message}${force ? ",已使用 --force 覆盖" : ""}\n`);
|
|
60
|
+
}
|
|
61
|
+
if (unverified.length > 0) {
|
|
62
|
+
io.stderr?.write?.(`[warn] ${unverified.length} 个文件缺少上次 apply 指纹,无法判断是否手改:${preview(unverified)}\n`);
|
|
63
|
+
}
|
|
64
|
+
return { checked, drifted, unverified };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* 将 .gx/generated 中的文件应用到项目,并保留自定义块。
|
|
69
|
+
*
|
|
70
|
+
* @param {Record<string, unknown>} args
|
|
71
|
+
* @param {{ stdout: NodeJS.WriteStream, stderr?: NodeJS.WriteStream }} io
|
|
72
|
+
* @returns {Promise<Record<string, unknown>>}
|
|
73
|
+
*/
|
|
74
|
+
export async function runApply(args, io = { stdout: process.stdout, stderr: process.stderr }) {
|
|
75
|
+
const dir = path.resolve(String(args.dir || process.cwd()));
|
|
76
|
+
const manifestFile = path.join(dir, ".gx", "manifest.json");
|
|
77
|
+
const manifest = readJson(manifestFile, null);
|
|
78
|
+
if (!manifest || !Array.isArray(manifest.files)) {
|
|
79
|
+
throw new Error("未找到 .gx/manifest.json,请先执行 gx pull");
|
|
80
|
+
}
|
|
81
|
+
const appliedFile = path.join(dir, ".gx", "applied.json");
|
|
82
|
+
const previous = readJson(appliedFile, null);
|
|
83
|
+
const dryRun = Boolean(args["dry-run"]);
|
|
84
|
+
const force = Boolean(args.force);
|
|
85
|
+
const preflightResult = preflight({ dir, manifest, previous, force, dryRun, io });
|
|
86
|
+
const transaction = new InitTransaction(dir, createBackupRoot(dir));
|
|
87
|
+
const fileHashes = {};
|
|
88
|
+
let applied = 0;
|
|
89
|
+
|
|
90
|
+
try {
|
|
91
|
+
for (const entry of manifest.files) {
|
|
92
|
+
const generatedFile = path.join(dir, ".gx", "generated", entry.path);
|
|
93
|
+
if (!fs.existsSync(generatedFile)) {
|
|
94
|
+
throw new Error(`缺少生成文件:${generatedFile}`);
|
|
95
|
+
}
|
|
96
|
+
const generated = readText(generatedFile);
|
|
97
|
+
const actual = createHash("sha256").update(stripKeepBlockContents(generated)).digest("hex");
|
|
98
|
+
if (actual !== entry.sha256) {
|
|
99
|
+
throw new Error(`生成文件校验失败:${entry.path},请重新 gx pull`);
|
|
100
|
+
}
|
|
101
|
+
const target = path.join(dir, entry.path);
|
|
102
|
+
const existing = fs.existsSync(target) ? readText(target) : "";
|
|
103
|
+
const merged = mergeKeepBlocks(existing, generated);
|
|
104
|
+
if (dryRun) {
|
|
105
|
+
io.stdout.write(`[dry-run] apply ${entry.path}\n`);
|
|
106
|
+
} else {
|
|
107
|
+
transaction.record(target);
|
|
108
|
+
writeText(target, merged);
|
|
109
|
+
fileHashes[entry.path] = entry.sha256;
|
|
110
|
+
}
|
|
111
|
+
applied += 1;
|
|
112
|
+
}
|
|
113
|
+
if (!dryRun) {
|
|
114
|
+
writeJson(appliedFile, {
|
|
115
|
+
planId: manifest.planId,
|
|
116
|
+
metadataVersion: manifest.metadataVersion,
|
|
117
|
+
appliedAt: new Date().toISOString(),
|
|
118
|
+
fileCount: applied,
|
|
119
|
+
fileHashes,
|
|
120
|
+
preflight: {
|
|
121
|
+
checked: preflightResult.checked,
|
|
122
|
+
drifted: preflightResult.drifted,
|
|
123
|
+
unverified: preflightResult.unverified
|
|
124
|
+
}
|
|
125
|
+
});
|
|
126
|
+
pruneBackups(dir);
|
|
127
|
+
}
|
|
128
|
+
io.stdout.write(`${dryRun ? "[dry-run] " : ""}已应用 ${applied} 个文件\n`);
|
|
129
|
+
return {
|
|
130
|
+
status: dryRun ? "planned" : "applied",
|
|
131
|
+
fileCount: applied,
|
|
132
|
+
preflight: preflightResult
|
|
133
|
+
};
|
|
134
|
+
} catch (error) {
|
|
135
|
+
transaction.rollback();
|
|
136
|
+
throw error;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
|
|
3
|
+
import { McpHttpClient } from "../adapters/platform/mcpClient.js";
|
|
4
|
+
import { readJson, writeJson } from "../store/localStore.js";
|
|
5
|
+
import { auditWorkflow } from "./auditWorkflow.js";
|
|
6
|
+
import { scanWorkflow } from "./scanWorkflow.js";
|
|
7
|
+
import { verifyWorkflow } from "./verifyWorkflow.js";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* 把 CLI 本地编译结果合并进 audit_alignment 的覆盖度。
|
|
11
|
+
*
|
|
12
|
+
* @param {object | null} alignment
|
|
13
|
+
* @param {object | null} compilation
|
|
14
|
+
* @returns {object | null}
|
|
15
|
+
*/
|
|
16
|
+
export function mergeLocalCompilation(alignment, compilation) {
|
|
17
|
+
if (!alignment || !compilation) {
|
|
18
|
+
return alignment;
|
|
19
|
+
}
|
|
20
|
+
const coverage = alignment.coverage && typeof alignment.coverage === "object" ? alignment.coverage : {};
|
|
21
|
+
const facets = coverage.facets && typeof coverage.facets === "object" ? coverage.facets : {};
|
|
22
|
+
return {
|
|
23
|
+
...alignment,
|
|
24
|
+
coverage: {
|
|
25
|
+
...coverage,
|
|
26
|
+
facets: {
|
|
27
|
+
...facets,
|
|
28
|
+
compilation: compilation.available
|
|
29
|
+
? {
|
|
30
|
+
available: true,
|
|
31
|
+
success: compilation.success,
|
|
32
|
+
reason: compilation.success ? null : `javac 失败,退出码 ${compilation.exitCode}`
|
|
33
|
+
}
|
|
34
|
+
: { available: false, reason: compilation.reason }
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* 执行 gx audit:上报扩展点、调用 audit_alignment、合并 Unknown。
|
|
42
|
+
*
|
|
43
|
+
* @param {Record<string, unknown>} args
|
|
44
|
+
* @param {{ stdout: NodeJS.WriteStream, stderr: NodeJS.WriteStream }} io
|
|
45
|
+
* @returns {Promise<Record<string, unknown>>}
|
|
46
|
+
*/
|
|
47
|
+
export async function runAudit(args, io = { stdout: process.stdout, stderr: process.stderr }) {
|
|
48
|
+
const dir = path.resolve(String(args.dir || process.cwd()));
|
|
49
|
+
const config = readJson(path.join(dir, ".gx", "config.json"), {});
|
|
50
|
+
const planId = String(args.plan || config.planId || "");
|
|
51
|
+
const url = String(args.url || config.mcpUrl || "http://127.0.0.1:4013/mcp");
|
|
52
|
+
const token = String(args.token || process.env.GX_API_TOKEN || "");
|
|
53
|
+
const sourceDirs = typeof args["source-dir"] === "string"
|
|
54
|
+
? args["source-dir"].split(",").map((item) => item.trim()).filter(Boolean)
|
|
55
|
+
: undefined;
|
|
56
|
+
const scan = scanWorkflow({ dir, sourceDirs, compile: Boolean(args.compile), classpath: typeof args.classpath === "string" ? args.classpath : undefined });
|
|
57
|
+
const verify = verifyWorkflow({ dir });
|
|
58
|
+
const manifest = readJson(path.join(dir, ".gx", "manifest.json"), null);
|
|
59
|
+
const warnings = [];
|
|
60
|
+
|
|
61
|
+
if (!planId || !token) {
|
|
62
|
+
const local = auditWorkflow({ dir });
|
|
63
|
+
const result = {
|
|
64
|
+
ok: local.unknowns.length === 0,
|
|
65
|
+
status: "local-only",
|
|
66
|
+
planId: planId || null,
|
|
67
|
+
alignment: null,
|
|
68
|
+
unknowns: local.unknowns,
|
|
69
|
+
coverage: local.coverage,
|
|
70
|
+
warnings: [planId ? "缺少 API Token,未调用 audit_alignment" : "未配置 planId,未调用 audit_alignment"]
|
|
71
|
+
};
|
|
72
|
+
writeJson(path.join(dir, ".gx", "audit.json"), result);
|
|
73
|
+
io.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
|
|
74
|
+
return result;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const client = new McpHttpClient({ url, token });
|
|
78
|
+
let report = null;
|
|
79
|
+
try {
|
|
80
|
+
if (!args["no-report"]) {
|
|
81
|
+
try {
|
|
82
|
+
report = await client.callTool("record_ext_points", {
|
|
83
|
+
planId,
|
|
84
|
+
methods: scan.extPoints,
|
|
85
|
+
replace: true
|
|
86
|
+
});
|
|
87
|
+
writeJson(path.join(dir, ".gx", "ext-points.json"), {
|
|
88
|
+
planId,
|
|
89
|
+
reportedAt: new Date().toISOString(),
|
|
90
|
+
extPoints: scan.extPoints,
|
|
91
|
+
unannotatedMethods: scan.unannotatedMethods,
|
|
92
|
+
report
|
|
93
|
+
});
|
|
94
|
+
} catch (error) {
|
|
95
|
+
warnings.push(`record_ext_points 失败:${error instanceof Error ? error.message : String(error)}`);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const alignment = await client.callTool("audit_alignment", {
|
|
100
|
+
planId,
|
|
101
|
+
manifest,
|
|
102
|
+
unannotatedMethods: scan.unannotatedMethods,
|
|
103
|
+
driftedFiles: verify.drifted,
|
|
104
|
+
missingFiles: verify.missing,
|
|
105
|
+
extraFiles: verify.extra
|
|
106
|
+
});
|
|
107
|
+
const mergedAlignment = mergeLocalCompilation(alignment, scan.compilation);
|
|
108
|
+
|
|
109
|
+
let platformUnknowns;
|
|
110
|
+
try {
|
|
111
|
+
const page = await client.callTool("list_unknowns", { planId, limit: 100 });
|
|
112
|
+
platformUnknowns = Array.isArray(page?.items) ? page.items : undefined;
|
|
113
|
+
} catch (error) {
|
|
114
|
+
warnings.push(`list_unknowns 失败:${error instanceof Error ? error.message : String(error)}`);
|
|
115
|
+
}
|
|
116
|
+
const local = auditWorkflow({ dir, platformUnknowns });
|
|
117
|
+
const result = {
|
|
118
|
+
...mergedAlignment,
|
|
119
|
+
status: mergedAlignment?.ok ? "aligned" : "drifted",
|
|
120
|
+
report,
|
|
121
|
+
unknowns: local.unknowns,
|
|
122
|
+
warnings: [...(alignment?.warnings ?? []), ...warnings]
|
|
123
|
+
};
|
|
124
|
+
writeJson(path.join(dir, ".gx", "audit.json"), result);
|
|
125
|
+
io.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
|
|
126
|
+
return result;
|
|
127
|
+
} finally {
|
|
128
|
+
await client.close();
|
|
129
|
+
}
|
|
130
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
|
|
3
|
+
import { readJson } from "../store/localStore.js";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* 合并本地与平台的 Unknown。
|
|
7
|
+
*
|
|
8
|
+
* @param {{ dir: string, platformUnknowns?: object[] }} input
|
|
9
|
+
* @returns {{ unknowns: object[], coverage: object }}
|
|
10
|
+
*/
|
|
11
|
+
export function auditWorkflow({ dir, platformUnknowns }) {
|
|
12
|
+
const localFile = path.join(dir, ".gx", "unknowns.json");
|
|
13
|
+
const local = readJson(localFile, { unknowns: [] });
|
|
14
|
+
const localUnknowns = Array.isArray(local) ? local : Array.isArray(local.unknowns) ? local.unknowns : [];
|
|
15
|
+
|
|
16
|
+
const merged = new Map();
|
|
17
|
+
for (const unknown of localUnknowns) {
|
|
18
|
+
if (unknown && typeof unknown.unknownId === "string") {
|
|
19
|
+
merged.set(unknown.unknownId, unknown);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
const platformList = Array.isArray(platformUnknowns) ? platformUnknowns : [];
|
|
23
|
+
for (const unknown of platformList) {
|
|
24
|
+
if (unknown && typeof unknown.unknownId === "string") {
|
|
25
|
+
merged.set(unknown.unknownId, unknown);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
return {
|
|
30
|
+
unknowns: [...merged.values()].sort((left, right) => left.unknownId.localeCompare(right.unknownId)),
|
|
31
|
+
coverage: {
|
|
32
|
+
status: "partial",
|
|
33
|
+
facets: {
|
|
34
|
+
local_unknowns: { available: true },
|
|
35
|
+
platform_unknowns: Array.isArray(platformUnknowns)
|
|
36
|
+
? { available: true }
|
|
37
|
+
: { available: false, reason: "未提供平台 Unknown 列表" }
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
};
|
|
41
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
|
|
3
|
+
import { McpHttpClient } from "../adapters/platform/mcpClient.js";
|
|
4
|
+
import { buildChunks } from "../domain/chunker.js";
|
|
5
|
+
import { readJson, writeJson } from "../store/localStore.js";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* 生成代码切片,并可选上报 record_code_chunks。
|
|
9
|
+
*
|
|
10
|
+
* @param {Record<string, unknown>} args
|
|
11
|
+
* @param {{ stdout: NodeJS.WriteStream, stderr: NodeJS.WriteStream }} io
|
|
12
|
+
* @returns {Promise<Record<string, unknown>>}
|
|
13
|
+
*/
|
|
14
|
+
export async function runChunks(args, io = { stdout: process.stdout, stderr: process.stderr }) {
|
|
15
|
+
const dir = path.resolve(String(args.dir || process.cwd()));
|
|
16
|
+
const config = readJson(path.join(dir, ".gx", "config.json"), {});
|
|
17
|
+
const planId = String(args.plan || config.planId || "");
|
|
18
|
+
const url = String(args.url || config.mcpUrl || "http://127.0.0.1:4013/mcp");
|
|
19
|
+
const token = String(args.token || process.env.GX_API_TOKEN || "");
|
|
20
|
+
const sourceDirs = typeof args["source-dir"] === "string"
|
|
21
|
+
? args["source-dir"].split(",").map((item) => item.trim()).filter(Boolean)
|
|
22
|
+
: undefined;
|
|
23
|
+
const built = buildChunks({ dir, planId, projectId: planId, sourceDirs });
|
|
24
|
+
const local = {
|
|
25
|
+
generatedAt: new Date().toISOString(),
|
|
26
|
+
planId,
|
|
27
|
+
filesScanned: built.filesScanned,
|
|
28
|
+
chunkCount: built.chunks.length,
|
|
29
|
+
chunks: built.chunks,
|
|
30
|
+
coverage: built.coverage
|
|
31
|
+
};
|
|
32
|
+
writeJson(path.join(dir, ".gx", "chunks.json"), local);
|
|
33
|
+
|
|
34
|
+
const explicitReport = Boolean(args.report);
|
|
35
|
+
const autoReport = !args["no-report"] && Boolean(planId && token);
|
|
36
|
+
const shouldReport = explicitReport || autoReport;
|
|
37
|
+
if (!shouldReport) {
|
|
38
|
+
const result = {
|
|
39
|
+
planId,
|
|
40
|
+
filesScanned: built.filesScanned,
|
|
41
|
+
chunkCount: built.chunks.length,
|
|
42
|
+
coverage: built.coverage,
|
|
43
|
+
report: {
|
|
44
|
+
status: "skipped",
|
|
45
|
+
reason: !planId ? "未配置 planId" : !token ? "缺少 API Token" : "使用 --no-report 跳过"
|
|
46
|
+
}
|
|
47
|
+
};
|
|
48
|
+
io.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
|
|
49
|
+
return result;
|
|
50
|
+
}
|
|
51
|
+
if (!planId || !token) {
|
|
52
|
+
throw new Error("gx chunks --report 需要 planId 与 API Token");
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const chunks = args["no-content"] ? built.chunks.map(({ content, ...rest }) => ({ ...rest, content: null })) : built.chunks;
|
|
56
|
+
const client = new McpHttpClient({ url, token });
|
|
57
|
+
try {
|
|
58
|
+
const report = await client.callTool("record_code_chunks", { planId, chunks, replace: true });
|
|
59
|
+
const result = {
|
|
60
|
+
planId,
|
|
61
|
+
filesScanned: built.filesScanned,
|
|
62
|
+
chunkCount: built.chunks.length,
|
|
63
|
+
coverage: built.coverage,
|
|
64
|
+
report: { status: "reported", ...report }
|
|
65
|
+
};
|
|
66
|
+
io.stdout.write(`已上报 ${report.accepted ?? 0} 个切片,拒绝 ${report.rejected?.length ?? 0} 个\n`);
|
|
67
|
+
for (const rejected of report.rejected ?? []) {
|
|
68
|
+
io.stderr.write(`[warn] ${rejected.path}: ${rejected.reason}\n`);
|
|
69
|
+
}
|
|
70
|
+
io.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
|
|
71
|
+
return result;
|
|
72
|
+
} catch (error) {
|
|
73
|
+
if (explicitReport) {
|
|
74
|
+
throw error;
|
|
75
|
+
}
|
|
76
|
+
io.stderr.write(`[warn] record_code_chunks 上报失败:${error.message}\n`);
|
|
77
|
+
return {
|
|
78
|
+
planId,
|
|
79
|
+
filesScanned: built.filesScanned,
|
|
80
|
+
chunkCount: built.chunks.length,
|
|
81
|
+
coverage: built.coverage,
|
|
82
|
+
report: { status: "failed", reason: error.message }
|
|
83
|
+
};
|
|
84
|
+
} finally {
|
|
85
|
+
await client.close();
|
|
86
|
+
}
|
|
87
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 需要用户批准但当前无法交互时抛出。
|
|
3
|
+
*/
|
|
4
|
+
export class ApprovalRequiredError extends Error {
|
|
5
|
+
/**
|
|
6
|
+
* @param {string} message
|
|
7
|
+
*/
|
|
8
|
+
constructor(message) {
|
|
9
|
+
super(message);
|
|
10
|
+
this.name = "ApprovalRequiredError";
|
|
11
|
+
this.exitCode = 2;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* 参数或用法错误。
|
|
17
|
+
*/
|
|
18
|
+
export class UsageError extends Error {
|
|
19
|
+
/**
|
|
20
|
+
* @param {string} message
|
|
21
|
+
*/
|
|
22
|
+
constructor(message) {
|
|
23
|
+
super(message);
|
|
24
|
+
this.name = "UsageError";
|
|
25
|
+
this.exitCode = 1;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
|
|
3
|
+
import { McpHttpClient } from "../adapters/platform/mcpClient.js";
|
|
4
|
+
import { readJson, writeJson } from "../store/localStore.js";
|
|
5
|
+
|
|
6
|
+
function normalize(value) {
|
|
7
|
+
return String(value ?? "").replace(/\\/gu, "/");
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* 判断命中是否满足期望。
|
|
12
|
+
*
|
|
13
|
+
* @param {object} hit
|
|
14
|
+
* @param {object[]} expectedAny
|
|
15
|
+
* @returns {boolean}
|
|
16
|
+
*/
|
|
17
|
+
export function matchesExpected(hit, expectedAny) {
|
|
18
|
+
if (!Array.isArray(expectedAny)) {
|
|
19
|
+
return false;
|
|
20
|
+
}
|
|
21
|
+
return expectedAny.some((expected) => {
|
|
22
|
+
if (!expected || typeof expected !== "object") {
|
|
23
|
+
return false;
|
|
24
|
+
}
|
|
25
|
+
if (expected.path && normalize(hit.path) === normalize(expected.path)) {
|
|
26
|
+
return true;
|
|
27
|
+
}
|
|
28
|
+
if (expected.pathSuffix && normalize(hit.path).endsWith(normalize(expected.pathSuffix))) {
|
|
29
|
+
return true;
|
|
30
|
+
}
|
|
31
|
+
if (expected.signature && String(hit.signature ?? "") === String(expected.signature)) {
|
|
32
|
+
return true;
|
|
33
|
+
}
|
|
34
|
+
return false;
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* 计算检索评测指标。
|
|
40
|
+
*
|
|
41
|
+
* @param {object[]} cases
|
|
42
|
+
* @param {Record<string, { hits?: object[], latencyMs?: number, embedding?: boolean }>} results
|
|
43
|
+
* @param {number} defaultLimit
|
|
44
|
+
* @returns {object}
|
|
45
|
+
*/
|
|
46
|
+
export function scoreCases(cases, results, defaultLimit = 5) {
|
|
47
|
+
let hitCount = 0;
|
|
48
|
+
let reciprocalRank = 0;
|
|
49
|
+
let latencySum = 0;
|
|
50
|
+
let embeddingCount = 0;
|
|
51
|
+
const details = [];
|
|
52
|
+
for (const item of cases) {
|
|
53
|
+
const limit = Number.isFinite(item.limit) && item.limit > 0 ? item.limit : defaultLimit;
|
|
54
|
+
const result = results[item.id] ?? {};
|
|
55
|
+
const hits = Array.isArray(result.hits) ? result.hits.slice(0, limit) : [];
|
|
56
|
+
const index = hits.findIndex((hit) => matchesExpected(hit, item.expectedAny));
|
|
57
|
+
const hit = index >= 0;
|
|
58
|
+
if (hit) {
|
|
59
|
+
hitCount += 1;
|
|
60
|
+
reciprocalRank += 1 / (index + 1);
|
|
61
|
+
}
|
|
62
|
+
const latencyMs = Number(result.latencyMs) || 0;
|
|
63
|
+
latencySum += latencyMs;
|
|
64
|
+
if (result.embedding) {
|
|
65
|
+
embeddingCount += 1;
|
|
66
|
+
}
|
|
67
|
+
details.push({
|
|
68
|
+
id: item.id,
|
|
69
|
+
query: item.query,
|
|
70
|
+
tags: item.tags ?? [],
|
|
71
|
+
hit,
|
|
72
|
+
rank: hit ? index + 1 : null,
|
|
73
|
+
latencyMs,
|
|
74
|
+
embedding: Boolean(result.embedding),
|
|
75
|
+
topHits: hits.slice(0, 3).map((hitItem) => ({
|
|
76
|
+
signature: hitItem.signature,
|
|
77
|
+
path: hitItem.path,
|
|
78
|
+
score: hitItem.score
|
|
79
|
+
}))
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
const total = cases.length;
|
|
83
|
+
return {
|
|
84
|
+
total,
|
|
85
|
+
hitCount,
|
|
86
|
+
missCount: total - hitCount,
|
|
87
|
+
hitRate: total === 0 ? 0 : Number((hitCount / total).toFixed(4)),
|
|
88
|
+
mrr: total === 0 ? 0 : Number((reciprocalRank / total).toFixed(4)),
|
|
89
|
+
avgLatencyMs: total === 0 ? 0 : Math.round(latencySum / total),
|
|
90
|
+
embeddingAvailableCount: embeddingCount,
|
|
91
|
+
details
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* 执行 search_code 评测。
|
|
97
|
+
*
|
|
98
|
+
* @param {Record<string, unknown>} args
|
|
99
|
+
* @param {{ stdout: NodeJS.WriteStream, stderr: NodeJS.WriteStream }} io
|
|
100
|
+
* @returns {Promise<object>}
|
|
101
|
+
*/
|
|
102
|
+
export async function runEvalSearch(args, io = { stdout: process.stdout, stderr: process.stderr }) {
|
|
103
|
+
const dir = path.resolve(String(args.dir || process.cwd()));
|
|
104
|
+
const datasetPath = path.resolve(dir, String(args.dataset || "eval/code-search-eval.json"));
|
|
105
|
+
const dataset = readJson(datasetPath, null);
|
|
106
|
+
if (!dataset || !Array.isArray(dataset.cases)) {
|
|
107
|
+
throw new Error(`评测集格式不合法:${datasetPath}`);
|
|
108
|
+
}
|
|
109
|
+
const planId = String(args.plan || dataset.planId || "");
|
|
110
|
+
const url = String(args.url || "http://127.0.0.1:4013/mcp");
|
|
111
|
+
const token = String(args.token || process.env.GX_API_TOKEN || "");
|
|
112
|
+
if (!planId || !token) {
|
|
113
|
+
throw new Error("eval-search 需要 planId(--plan 或数据集 planId)与 API Token");
|
|
114
|
+
}
|
|
115
|
+
const limit = args.limit ? Number(args.limit) : 5;
|
|
116
|
+
const client = new McpHttpClient({ url, token });
|
|
117
|
+
const results = {};
|
|
118
|
+
try {
|
|
119
|
+
for (const item of dataset.cases) {
|
|
120
|
+
const started = performance.now();
|
|
121
|
+
const response = await client.callTool("search_code", {
|
|
122
|
+
planId,
|
|
123
|
+
query: item.query,
|
|
124
|
+
tags: item.tags && !item.tags.includes("keyword") && !item.tags.includes("semantic") ? item.tags : undefined,
|
|
125
|
+
limit: Number.isFinite(item.limit) && item.limit > 0 ? item.limit : limit
|
|
126
|
+
});
|
|
127
|
+
results[item.id] = {
|
|
128
|
+
hits: response.hits ?? [],
|
|
129
|
+
latencyMs: Math.round(performance.now() - started),
|
|
130
|
+
embedding: response.coverage?.facets?.embedding?.available === true
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
} finally {
|
|
134
|
+
await client.close();
|
|
135
|
+
}
|
|
136
|
+
const summary = scoreCases(dataset.cases, results, limit);
|
|
137
|
+
const output = {
|
|
138
|
+
generatedAt: new Date().toISOString(),
|
|
139
|
+
planId,
|
|
140
|
+
dataset: path.relative(dir, datasetPath).split(path.sep).join("/"),
|
|
141
|
+
limit,
|
|
142
|
+
...summary
|
|
143
|
+
};
|
|
144
|
+
const outPath = path.join(dir, String(args.out || ".gx/eval-search.json"));
|
|
145
|
+
writeJson(outPath, output);
|
|
146
|
+
io.stdout.write(`${JSON.stringify(output, null, 2)}\n`);
|
|
147
|
+
return output;
|
|
148
|
+
}
|