gx-code-agent 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/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 +37 -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,142 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
|
|
5
|
+
import { readJson } from "../store/localStore.js";
|
|
6
|
+
import { stripKeepBlockContents } from "../domain/keepBlocks.js";
|
|
7
|
+
|
|
8
|
+
const IGNORED_DIRS = new Set(["node_modules", ".git", "target", "build", "dist", "out", ".gx"]);
|
|
9
|
+
const GENERATED_MARKERS = ["@generated GX-CODE", "[CN01-GENERATED]"];
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* 递归收集目录下的文件。
|
|
13
|
+
*
|
|
14
|
+
* @param {string} dir
|
|
15
|
+
* @param {string[]} output
|
|
16
|
+
*/
|
|
17
|
+
function collectFiles(dir, output) {
|
|
18
|
+
if (!fs.existsSync(dir)) {
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
22
|
+
if (IGNORED_DIRS.has(entry.name)) {
|
|
23
|
+
continue;
|
|
24
|
+
}
|
|
25
|
+
const full = path.join(dir, entry.name);
|
|
26
|
+
if (entry.isDirectory()) {
|
|
27
|
+
collectFiles(full, output);
|
|
28
|
+
} else if (entry.isFile()) {
|
|
29
|
+
output.push(full);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* 检测 manifest 目录下带生成标记但未登记的文件。
|
|
36
|
+
*
|
|
37
|
+
* @param {string} dir
|
|
38
|
+
* @param {Set<string>} expected
|
|
39
|
+
* @returns {string[]}
|
|
40
|
+
*/
|
|
41
|
+
function findExtraGeneratedFiles(dir, expected) {
|
|
42
|
+
const directories = new Set();
|
|
43
|
+
for (const relative of expected) {
|
|
44
|
+
const parent = path.posix.dirname(relative);
|
|
45
|
+
if (parent && parent !== ".") {
|
|
46
|
+
directories.add(path.join(dir, parent));
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
const files = [];
|
|
50
|
+
for (const directory of directories) {
|
|
51
|
+
collectFiles(directory, files);
|
|
52
|
+
}
|
|
53
|
+
const extra = [];
|
|
54
|
+
for (const file of files) {
|
|
55
|
+
const relative = path.relative(dir, file).split(path.sep).join("/");
|
|
56
|
+
if (expected.has(relative)) {
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
const content = fs.readFileSync(file, "utf8");
|
|
60
|
+
if (GENERATED_MARKERS.some((marker) => content.includes(marker))) {
|
|
61
|
+
extra.push(relative);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return [...new Set(extra)].sort();
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* 计算文件 SHA-256;文件不存在时返回 null。
|
|
69
|
+
*
|
|
70
|
+
* @param {string} file
|
|
71
|
+
* @returns {string | null}
|
|
72
|
+
*/
|
|
73
|
+
function fileSha256(file) {
|
|
74
|
+
if (!fs.existsSync(file)) {
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
return createHash("sha256").update(stripKeepBlockContents(fs.readFileSync(file, "utf8"))).digest("hex");
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* 校验 manifest 与实际文件是否一致。
|
|
82
|
+
*
|
|
83
|
+
* @param {{ dir: string, manifestPath?: string }} input
|
|
84
|
+
* @returns {{ ok: boolean, drifted: string[], missing: string[], extra: string[], coverage: object }}
|
|
85
|
+
*/
|
|
86
|
+
export function verifyWorkflow({ dir, manifestPath = ".gx/manifest.json" }) {
|
|
87
|
+
const manifestFile = path.isAbsolute(manifestPath) ? manifestPath : path.join(dir, manifestPath);
|
|
88
|
+
const manifest = readJson(manifestFile, null);
|
|
89
|
+
|
|
90
|
+
if (!manifest || !Array.isArray(manifest.files)) {
|
|
91
|
+
return {
|
|
92
|
+
ok: true,
|
|
93
|
+
drifted: [],
|
|
94
|
+
missing: [],
|
|
95
|
+
extra: [],
|
|
96
|
+
coverage: {
|
|
97
|
+
status: "unknown",
|
|
98
|
+
facets: {
|
|
99
|
+
manifest: { available: false, reason: "manifest 不存在或格式不合法" },
|
|
100
|
+
files: { available: false, reason: "没有可校验的文件清单" },
|
|
101
|
+
compilation: { available: false, reason: "未执行编译" }
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const drifted = [];
|
|
108
|
+
const missing = [];
|
|
109
|
+
for (const entry of manifest.files) {
|
|
110
|
+
if (!entry || typeof entry.path !== "string" || typeof entry.sha256 !== "string") {
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
const file = path.isAbsolute(entry.path) ? entry.path : path.join(dir, entry.path);
|
|
114
|
+
const actual = fileSha256(file);
|
|
115
|
+
if (actual === null) {
|
|
116
|
+
missing.push(entry.path);
|
|
117
|
+
} else if (actual !== entry.sha256) {
|
|
118
|
+
drifted.push(entry.path);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const expected = new Set(manifest.files
|
|
123
|
+
.filter((entry) => entry && typeof entry.path === "string")
|
|
124
|
+
.map((entry) => entry.path.replace(/\\/gu, "/")));
|
|
125
|
+
const extra = findExtraGeneratedFiles(dir, expected);
|
|
126
|
+
|
|
127
|
+
return {
|
|
128
|
+
ok: drifted.length === 0 && missing.length === 0 && extra.length === 0,
|
|
129
|
+
drifted,
|
|
130
|
+
missing,
|
|
131
|
+
extra,
|
|
132
|
+
coverage: {
|
|
133
|
+
status: "complete",
|
|
134
|
+
facets: {
|
|
135
|
+
manifest: { available: true },
|
|
136
|
+
files: { available: true },
|
|
137
|
+
extra_files: { available: true },
|
|
138
|
+
compilation: { available: false, reason: "未执行编译" }
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
};
|
|
142
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 稳定 JSON 序列化。
|
|
3
|
+
*
|
|
4
|
+
* 规则与契约文档一致:
|
|
5
|
+
* - 对象 key 按 Unicode code point 排序;
|
|
6
|
+
* - 丢弃值为 undefined 的字段;
|
|
7
|
+
* - 数组保持原顺序;
|
|
8
|
+
* - 不接受函数、symbol、BigInt、NaN、Infinity;
|
|
9
|
+
* - 字符串不做 Unicode 归一化。
|
|
10
|
+
*
|
|
11
|
+
* @param {unknown} value
|
|
12
|
+
* @returns {string}
|
|
13
|
+
*/
|
|
14
|
+
export function canonicalJson(value) {
|
|
15
|
+
return JSON.stringify(canonicalize(value));
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* @param {unknown} value
|
|
20
|
+
* @returns {unknown}
|
|
21
|
+
*/
|
|
22
|
+
export function canonicalize(value) {
|
|
23
|
+
if (value === null) {
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
const type = typeof value;
|
|
27
|
+
if (type === "string" || type === "boolean") {
|
|
28
|
+
return value;
|
|
29
|
+
}
|
|
30
|
+
if (type === "number") {
|
|
31
|
+
if (!Number.isFinite(value)) {
|
|
32
|
+
throw new TypeError("Canonical JSON 不接受 NaN / Infinity");
|
|
33
|
+
}
|
|
34
|
+
return value;
|
|
35
|
+
}
|
|
36
|
+
if (Array.isArray(value)) {
|
|
37
|
+
return value.map((item) => canonicalize(item));
|
|
38
|
+
}
|
|
39
|
+
if (type === "object") {
|
|
40
|
+
/** @type {Record<string, unknown>} */
|
|
41
|
+
const output = {};
|
|
42
|
+
const keys = Object.keys(value).sort(compareCodePoints);
|
|
43
|
+
for (const key of keys) {
|
|
44
|
+
const item = /** @type {Record<string, unknown>} */ (value)[key];
|
|
45
|
+
if (item !== undefined) {
|
|
46
|
+
output[key] = canonicalize(item);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return output;
|
|
50
|
+
}
|
|
51
|
+
throw new TypeError(`Canonical JSON 不支持的类型:${type}`);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* 按 Unicode code point 比较,避免 UTF-16 code unit 顺序带来的差异。
|
|
56
|
+
*
|
|
57
|
+
* @param {string} left
|
|
58
|
+
* @param {string} right
|
|
59
|
+
* @returns {number}
|
|
60
|
+
*/
|
|
61
|
+
export function compareCodePoints(left, right) {
|
|
62
|
+
const a = Array.from(left);
|
|
63
|
+
const b = Array.from(right);
|
|
64
|
+
const length = Math.min(a.length, b.length);
|
|
65
|
+
for (let i = 0; i < length; i += 1) {
|
|
66
|
+
const diff = a[i].codePointAt(0) - b[i].codePointAt(0);
|
|
67
|
+
if (diff !== 0) {
|
|
68
|
+
return diff;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return a.length - b.length;
|
|
72
|
+
}
|
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
|
|
5
|
+
import { scanJavaChunks } from "./javaAst.js";
|
|
6
|
+
|
|
7
|
+
const IGNORED_DIRS = new Set(["node_modules", ".git", "target", "build", "dist", "out", ".gx"]);
|
|
8
|
+
const DEFAULT_SOURCE_DIRS = ["src/main/java", "src"];
|
|
9
|
+
const JAVA_EXTENSIONS = new Set([".java"]);
|
|
10
|
+
const FRONTEND_EXTENSIONS = new Set([".vue", ".ts", ".tsx", ".js", ".jsx"]);
|
|
11
|
+
|
|
12
|
+
function hash(value) {
|
|
13
|
+
return createHash("sha256").update(value).digest("hex");
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function lineAt(content, index) {
|
|
17
|
+
return content.slice(0, index).split("\n").length;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function findMatching(content, openIndex, open = "{", close = "}") {
|
|
21
|
+
let depth = 0;
|
|
22
|
+
let quote = null;
|
|
23
|
+
let escaped = false;
|
|
24
|
+
let lineComment = false;
|
|
25
|
+
let blockComment = false;
|
|
26
|
+
for (let i = openIndex; i < content.length; i += 1) {
|
|
27
|
+
const char = content[i];
|
|
28
|
+
const next = content[i + 1];
|
|
29
|
+
if (lineComment) {
|
|
30
|
+
if (char === "\n") lineComment = false;
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
33
|
+
if (blockComment) {
|
|
34
|
+
if (char === "*" && next === "/") {
|
|
35
|
+
blockComment = false;
|
|
36
|
+
i += 1;
|
|
37
|
+
}
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
if (quote) {
|
|
41
|
+
if (escaped) escaped = false;
|
|
42
|
+
else if (char === "\\") escaped = true;
|
|
43
|
+
else if (char === quote) quote = null;
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
if (char === "/" && next === "/") {
|
|
47
|
+
lineComment = true;
|
|
48
|
+
i += 1;
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
if (char === "/" && next === "*") {
|
|
52
|
+
blockComment = true;
|
|
53
|
+
i += 1;
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
if (char === '"' || char === "'" || char === "`") {
|
|
57
|
+
quote = char;
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
if (char === open) depth += 1;
|
|
61
|
+
else if (char === close) {
|
|
62
|
+
depth -= 1;
|
|
63
|
+
if (depth === 0) return i;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return -1;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function truncate(value, limit = 8000) {
|
|
70
|
+
return value.length > limit ? `${value.slice(0, limit)}\n/* ...truncated... */` : value;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function inferLayer(filePath, className) {
|
|
74
|
+
const haystack = `${filePath} ${className ?? ""}`.toLowerCase();
|
|
75
|
+
if (haystack.includes("controller")) return "controller";
|
|
76
|
+
if (haystack.includes("repository") || haystack.includes("dao")) return "repository";
|
|
77
|
+
if (haystack.includes("serviceimpl") || haystack.includes("service")) return "service";
|
|
78
|
+
if (haystack.includes("entity") || haystack.includes("model")) return "entity";
|
|
79
|
+
if (haystack.includes("dto") || haystack.includes("vo")) return "dto";
|
|
80
|
+
if (haystack.includes("hook") || haystack.includes("extension") || haystack.includes("ext")) return "extension";
|
|
81
|
+
if (haystack.includes("vue") || haystack.endsWith(".vue")) return "frontend";
|
|
82
|
+
return "unknown";
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function frameworkTags(filePath) {
|
|
86
|
+
const extension = path.extname(filePath).toLowerCase();
|
|
87
|
+
const tags = [];
|
|
88
|
+
if (extension === ".java") tags.push("java", "spring");
|
|
89
|
+
if (extension === ".vue") tags.push("vue");
|
|
90
|
+
if (extension === ".ts" || extension === ".tsx") tags.push("typescript");
|
|
91
|
+
if (extension === ".js" || extension === ".jsx") tags.push("javascript");
|
|
92
|
+
return tags;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function annotationsBefore(content, index) {
|
|
96
|
+
const lines = content.slice(0, index).split("\n");
|
|
97
|
+
const annotations = [];
|
|
98
|
+
for (let i = lines.length - 1; i >= 0; i -= 1) {
|
|
99
|
+
const line = lines[i].trim();
|
|
100
|
+
if (!line) continue;
|
|
101
|
+
const match = line.match(/^@([\w$.]+)/u);
|
|
102
|
+
if (match) {
|
|
103
|
+
annotations.unshift(match[1]);
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
if (line.endsWith("*") || line.startsWith("*") || line.startsWith("//")) continue;
|
|
107
|
+
break;
|
|
108
|
+
}
|
|
109
|
+
return annotations;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function makeChunk({ planId, projectId, filePath, content, startLine, endLine, granularity, signature,
|
|
113
|
+
className, methodName, annotations, intent = null }) {
|
|
114
|
+
const body = truncate(content);
|
|
115
|
+
return {
|
|
116
|
+
chunkId: `ck_${hash(`${projectId}|${filePath}|${granularity}|${startLine}|${endLine}|${body}`).slice(0, 32)}`,
|
|
117
|
+
projectId,
|
|
118
|
+
planId,
|
|
119
|
+
modelId: null,
|
|
120
|
+
path: filePath,
|
|
121
|
+
startLine,
|
|
122
|
+
endLine,
|
|
123
|
+
granularity,
|
|
124
|
+
signature,
|
|
125
|
+
className: className ?? null,
|
|
126
|
+
methodName: methodName ?? null,
|
|
127
|
+
layer: inferLayer(filePath, className),
|
|
128
|
+
annotations,
|
|
129
|
+
frameworkTags: frameworkTags(filePath),
|
|
130
|
+
intent,
|
|
131
|
+
contentHash: hash(body),
|
|
132
|
+
content: body,
|
|
133
|
+
embedding: null
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function chunkJava({ planId, projectId, filePath, content }) {
|
|
138
|
+
const chunks = [];
|
|
139
|
+
const classRegex = /\b(?:class|interface|enum|record)\s+([A-Za-z_$][\w$]*)[^{]*\{/gu;
|
|
140
|
+
let classCount = 0;
|
|
141
|
+
for (const match of content.matchAll(classRegex)) {
|
|
142
|
+
const className = match[1];
|
|
143
|
+
const open = content.indexOf("{", match.index);
|
|
144
|
+
const close = findMatching(content, open);
|
|
145
|
+
if (close < 0) continue;
|
|
146
|
+
classCount += 1;
|
|
147
|
+
chunks.push(makeChunk({ planId, projectId, filePath,
|
|
148
|
+
content: content.slice(match.index, close + 1),
|
|
149
|
+
startLine: lineAt(content, match.index), endLine: lineAt(content, close),
|
|
150
|
+
granularity: "class", signature: className, className,
|
|
151
|
+
annotations: annotationsBefore(content, match.index) }));
|
|
152
|
+
|
|
153
|
+
const bodyStart = open + 1;
|
|
154
|
+
const body = content.slice(bodyStart, close);
|
|
155
|
+
const methodRegex = /(?:^|\n)([ \t]*(?:@[\w$.]+(?:\([^)]*\))?[ \t]*\n)*[ \t]*(?:(?:public|protected|private|static|final|abstract|synchronized|default|native|transient|volatile)\s+)*(?:<[^>]+>\s*)?[\w$.<>\[\],?]+\s+([A-Za-z_$][\w$]*)\s*\([^)]*\)[^{;]*\{)/gu;
|
|
156
|
+
for (const method of body.matchAll(methodRegex)) {
|
|
157
|
+
const methodOpen = bodyStart + method.index + method[0].lastIndexOf("{");
|
|
158
|
+
const methodClose = findMatching(content, methodOpen);
|
|
159
|
+
if (methodClose < 0) continue;
|
|
160
|
+
const methodName = method[2];
|
|
161
|
+
if (methodName === className) continue;
|
|
162
|
+
const methodText = content.slice(bodyStart + method.index, methodClose + 1);
|
|
163
|
+
const methodAnnotations = [...method[1].matchAll(/@([\w$.]+)/gu)].map((item) => item[1]);
|
|
164
|
+
const signatureOffset = method[1].lastIndexOf(methodName);
|
|
165
|
+
chunks.push(makeChunk({ planId, projectId, filePath, content: methodText,
|
|
166
|
+
startLine: lineAt(content, bodyStart + method.index + Math.max(0, signatureOffset)),
|
|
167
|
+
endLine: lineAt(content, methodClose),
|
|
168
|
+
granularity: "method", signature: `${className}.${methodName}()`, className, methodName,
|
|
169
|
+
annotations: methodAnnotations }));
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
if (classCount === 0) {
|
|
173
|
+
chunks.push(makeChunk({ planId, projectId, filePath, content,
|
|
174
|
+
startLine: 1, endLine: content.split("\n").length, granularity: "file", signature: path.basename(filePath),
|
|
175
|
+
annotations: annotationsBefore(content, 0) }));
|
|
176
|
+
}
|
|
177
|
+
return chunks;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function chunkJavaAst({ planId, projectId, filePath, content, astChunks }) {
|
|
181
|
+
const lines = content.split("\n");
|
|
182
|
+
const chunks = [];
|
|
183
|
+
for (const node of astChunks) {
|
|
184
|
+
const startLine = node.startLine;
|
|
185
|
+
const endLine = node.endLine;
|
|
186
|
+
const body = lines.slice(startLine - 1, endLine).join("\n");
|
|
187
|
+
if (!body.trim()) {
|
|
188
|
+
continue;
|
|
189
|
+
}
|
|
190
|
+
if (node.kind === "class") {
|
|
191
|
+
chunks.push(makeChunk({ planId, projectId, filePath, content: body, startLine, endLine,
|
|
192
|
+
granularity: "class", signature: node.className, className: node.className,
|
|
193
|
+
methodName: null, annotations: node.annotations }));
|
|
194
|
+
}
|
|
195
|
+
else if (node.kind === "method") {
|
|
196
|
+
const simpleClass = (node.className || "").split(".").pop();
|
|
197
|
+
if (!simpleClass || node.methodName === simpleClass) {
|
|
198
|
+
continue;
|
|
199
|
+
}
|
|
200
|
+
chunks.push(makeChunk({ planId, projectId, filePath, content: body, startLine, endLine,
|
|
201
|
+
granularity: "method", signature: `${node.className}.${node.methodName}()`,
|
|
202
|
+
className: node.className, methodName: node.methodName, annotations: node.annotations }));
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
return chunks;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function chunkVue({ planId, projectId, filePath, content }) {
|
|
209
|
+
const chunks = [];
|
|
210
|
+
const script = /<script[^>]*>([\s\S]*?)<\/script>/u.exec(content);
|
|
211
|
+
if (script) {
|
|
212
|
+
const startLine = lineAt(content, script.index);
|
|
213
|
+
chunks.push(makeChunk({ planId, projectId, filePath, content: script[1],
|
|
214
|
+
startLine, endLine: startLine + script[1].split("\n").length - 1, granularity: "class",
|
|
215
|
+
signature: "script", className: "script", annotations: [] }));
|
|
216
|
+
}
|
|
217
|
+
const template = /<template[^>]*>([\s\S]*?)<\/template>/u.exec(content);
|
|
218
|
+
if (template) {
|
|
219
|
+
const startLine = lineAt(content, template.index);
|
|
220
|
+
chunks.push(makeChunk({ planId, projectId, filePath, content: template[1],
|
|
221
|
+
startLine, endLine: startLine + template[1].split("\n").length - 1, granularity: "file",
|
|
222
|
+
signature: "template", annotations: [] }));
|
|
223
|
+
}
|
|
224
|
+
if (chunks.length === 0) {
|
|
225
|
+
chunks.push(makeChunk({ planId, projectId, filePath, content, startLine: 1,
|
|
226
|
+
endLine: content.split("\n").length, granularity: "file", signature: path.basename(filePath),
|
|
227
|
+
annotations: [] }));
|
|
228
|
+
}
|
|
229
|
+
return chunks;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function collectFiles(dir, output) {
|
|
233
|
+
if (!fs.existsSync(dir)) return;
|
|
234
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
235
|
+
if (IGNORED_DIRS.has(entry.name)) continue;
|
|
236
|
+
const full = path.join(dir, entry.name);
|
|
237
|
+
if (entry.isDirectory()) collectFiles(full, output);
|
|
238
|
+
else if (entry.isFile()) output.push(full);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* 生成 Java / Vue 源码切片。
|
|
244
|
+
*
|
|
245
|
+
* @param {{ dir: string, planId: string, projectId?: string, sourceDirs?: string[] }} input
|
|
246
|
+
* @returns {{ chunks: object[], filesScanned: number, coverage: object }}
|
|
247
|
+
*/
|
|
248
|
+
export function buildChunks({ dir, planId, projectId, sourceDirs = DEFAULT_SOURCE_DIRS }) {
|
|
249
|
+
const files = [];
|
|
250
|
+
for (const sourceDir of sourceDirs) {
|
|
251
|
+
const full = path.isAbsolute(sourceDir) ? sourceDir : path.join(dir, sourceDir);
|
|
252
|
+
collectFiles(full, files);
|
|
253
|
+
}
|
|
254
|
+
const uniqueFiles = [...new Set(files)].filter((file) => {
|
|
255
|
+
const extension = path.extname(file).toLowerCase();
|
|
256
|
+
return JAVA_EXTENSIONS.has(extension) || FRONTEND_EXTENSIONS.has(extension);
|
|
257
|
+
});
|
|
258
|
+
const javaFiles = uniqueFiles.filter((file) => JAVA_EXTENSIONS.has(path.extname(file).toLowerCase()));
|
|
259
|
+
const ast = scanJavaChunks({ files: javaFiles });
|
|
260
|
+
const astByFile = new Map();
|
|
261
|
+
for (const node of ast.chunks) {
|
|
262
|
+
const key = path.resolve(node.file);
|
|
263
|
+
const list = astByFile.get(key) ?? [];
|
|
264
|
+
list.push(node);
|
|
265
|
+
astByFile.set(key, list);
|
|
266
|
+
}
|
|
267
|
+
const chunks = [];
|
|
268
|
+
for (const file of uniqueFiles) {
|
|
269
|
+
const content = fs.readFileSync(file, "utf8");
|
|
270
|
+
const filePath = path.relative(dir, file).split(path.sep).join("/");
|
|
271
|
+
const extension = path.extname(file).toLowerCase();
|
|
272
|
+
const input = { planId, projectId: projectId ?? planId, filePath, content };
|
|
273
|
+
if (JAVA_EXTENSIONS.has(extension)) {
|
|
274
|
+
const astChunks = astByFile.get(path.resolve(file)) ?? [];
|
|
275
|
+
chunks.push(...(ast.available && astChunks.length > 0
|
|
276
|
+
? chunkJavaAst({ ...input, astChunks })
|
|
277
|
+
: chunkJava(input)));
|
|
278
|
+
}
|
|
279
|
+
else {
|
|
280
|
+
chunks.push(...chunkVue(input));
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
return {
|
|
284
|
+
chunks,
|
|
285
|
+
filesScanned: uniqueFiles.length,
|
|
286
|
+
coverage: {
|
|
287
|
+
status: "partial",
|
|
288
|
+
facets: {
|
|
289
|
+
source_files: { available: uniqueFiles.length > 0 },
|
|
290
|
+
chunking: { available: true, engine: ast.available ? "ast" : "regex" },
|
|
291
|
+
ast: ast.available ? { available: true, reason: null }
|
|
292
|
+
: { available: false, reason: ast.reason },
|
|
293
|
+
embedding: { available: false, reason: "CLI 不上传向量;平台上报时按内容自动向量化" }
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
};
|
|
297
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
import { canonicalJson } from "./canonicalJson.js";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* 计算字符串的 SHA-256 小写 hex。
|
|
7
|
+
*
|
|
8
|
+
* @param {string} value
|
|
9
|
+
* @returns {string}
|
|
10
|
+
*/
|
|
11
|
+
export function sha256Hex(value) {
|
|
12
|
+
return createHash("sha256").update(value, "utf8").digest("hex");
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* 计算任意 JSON 值的 canonical SHA-256。
|
|
17
|
+
*
|
|
18
|
+
* @param {unknown} value
|
|
19
|
+
* @returns {string}
|
|
20
|
+
*/
|
|
21
|
+
export function sha256Canonical(value) {
|
|
22
|
+
return sha256Hex(canonicalJson(value));
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Evidence ID:`ev_` + canonical(Evidence without evidenceId) 的 SHA-256。
|
|
27
|
+
*
|
|
28
|
+
* @param {Record<string, unknown>} evidenceWithoutId
|
|
29
|
+
* @returns {string}
|
|
30
|
+
*/
|
|
31
|
+
export function buildEvidenceId(evidenceWithoutId) {
|
|
32
|
+
return `ev_${sha256Canonical(evidenceWithoutId)}`;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Unknown ID:`unk_` + canonical({domain, question, scope}) 的 SHA-256。
|
|
37
|
+
*
|
|
38
|
+
* 问题文本会先做空白归一化,保证同一问题不同排版得到同一 ID。
|
|
39
|
+
*
|
|
40
|
+
* @param {{ domain: string, question: string, scope: unknown }} input
|
|
41
|
+
* @returns {string}
|
|
42
|
+
*/
|
|
43
|
+
export function buildUnknownId(input) {
|
|
44
|
+
const question = input.question.replace(/\s+/gu, " ").trim();
|
|
45
|
+
return `unk_${sha256Canonical({
|
|
46
|
+
domain: input.domain.trim(),
|
|
47
|
+
question,
|
|
48
|
+
scope: input.scope
|
|
49
|
+
})}`;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Manifest hash:`manifestHash` 字段之外的 canonical JSON 摘要。
|
|
54
|
+
*
|
|
55
|
+
* @param {Record<string, unknown>} manifestWithoutHash
|
|
56
|
+
* @returns {string}
|
|
57
|
+
*/
|
|
58
|
+
export function buildManifestHash(manifestWithoutHash) {
|
|
59
|
+
return sha256Canonical(manifestWithoutHash);
|
|
60
|
+
}
|