kcode-pi 0.1.2 → 0.1.5
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 +378 -171
- package/dist/cli/kcode.d.ts +1 -0
- package/dist/cli/kcode.js +15 -1
- package/dist/context/project-context.d.ts +9 -0
- package/dist/context/project-context.js +193 -0
- package/docs/DEVELOPMENT.md +162 -0
- package/docs/KCODE_DISTRIBUTION.md +1 -1
- package/extensions/kingdee-harness.ts +122 -0
- package/extensions/kingdee-tools.ts +9 -5
- package/knowledge/enterprise-python/python-plugin.md +134 -0
- package/package.json +2 -2
- package/skills/kd-cosmic-dev/SKILL.md +2 -0
- package/skills/kd-cosmic-unittest/SKILL.md +1 -0
- package/skills/kd-enterprise-python-plugin/SKILL.md +43 -0
- package/skills/kd-execute/SKILL.md +6 -2
- package/skills/kd-gen/SKILL.md +1 -0
- package/skills/kd-plan/SKILL.md +7 -1
- package/src/cli/kcode.ts +16 -1
- package/src/context/project-context.ts +215 -0
- package/src/harness/artifacts.ts +35 -1
- package/src/harness/gates.ts +29 -0
- package/src/harness/path-policy.ts +83 -0
- package/src/harness/plan-steps.ts +79 -0
- package/src/harness/tdd-policy.ts +62 -0
- package/src/knowledge/types.ts +1 -1
- package/src/official/kingdee-skills.ts +549 -38
- package/src/platform/path.ts +38 -0
- package/src/product/profile.ts +29 -5
- package/src/rules/checker.ts +1 -1
- package/src/tools/build-debug.ts +7 -1
|
@@ -1,11 +1,14 @@
|
|
|
1
|
-
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
|
|
2
|
-
import { dirname,
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { dirname, join, resolve } from "node:path";
|
|
3
3
|
import { execFile } from "node:child_process";
|
|
4
4
|
import { promisify } from "node:util";
|
|
5
5
|
import { fileURLToPath } from "node:url";
|
|
6
6
|
import type { ProductProfile } from "../product/profile.ts";
|
|
7
7
|
import { readActiveRun } from "../harness/state.ts";
|
|
8
8
|
import { runArtifactPath } from "../harness/paths.ts";
|
|
9
|
+
import { searchKnowledge } from "../knowledge/search.ts";
|
|
10
|
+
import { formatSearchResults } from "../knowledge/format.ts";
|
|
11
|
+
import { resolveWorkspacePath } from "../platform/path.ts";
|
|
9
12
|
|
|
10
13
|
const execFileAsync = promisify(execFile);
|
|
11
14
|
|
|
@@ -25,6 +28,11 @@ export interface CommandResult {
|
|
|
25
28
|
stderr: string;
|
|
26
29
|
}
|
|
27
30
|
|
|
31
|
+
export interface OfficialCommand {
|
|
32
|
+
display: string;
|
|
33
|
+
run: () => Promise<CommandResult>;
|
|
34
|
+
}
|
|
35
|
+
|
|
28
36
|
export type OfficialEvidenceFile = "cosmic-config.txt" | "cosmic-metadata.json" | "cosmic-api.txt" | "ksql-lint.txt";
|
|
29
37
|
|
|
30
38
|
const SKILL_DIRS: Record<OfficialSkillKey, string> = {
|
|
@@ -63,25 +71,6 @@ export async function ensureOfficialSkillRoot(_cwd: string, skill: OfficialSkill
|
|
|
63
71
|
throw new Error(`Official skill directory not found for ${skill}. Checked: ${officialSkillsSourceRoots().join(", ")}`);
|
|
64
72
|
}
|
|
65
73
|
|
|
66
|
-
export function resolveWorkspacePath(cwd: string, path: string): string {
|
|
67
|
-
return isAbsolute(path) ? path : resolve(cwd, path);
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
export function pythonExecutable(): string {
|
|
71
|
-
return process.env.KCODE_PYTHON ?? (process.platform === "win32" ? "python" : "python3");
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
export function buildPythonCommand(cwd: string, scriptPath: string, args: string[]): CommandSpec {
|
|
75
|
-
const executable = pythonExecutable();
|
|
76
|
-
const fullArgs = [scriptPath, ...args];
|
|
77
|
-
return {
|
|
78
|
-
executable,
|
|
79
|
-
args: fullArgs,
|
|
80
|
-
cwd,
|
|
81
|
-
display: formatCommand(executable, fullArgs),
|
|
82
|
-
};
|
|
83
|
-
}
|
|
84
|
-
|
|
85
74
|
export async function runCommand(command: CommandSpec, timeoutMs = 120_000): Promise<CommandResult> {
|
|
86
75
|
try {
|
|
87
76
|
const result = await execFileAsync(command.executable, command.args, {
|
|
@@ -113,11 +102,15 @@ export async function runCommand(command: CommandSpec, timeoutMs = 120_000): Pro
|
|
|
113
102
|
}
|
|
114
103
|
}
|
|
115
104
|
|
|
116
|
-
export async function
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
105
|
+
export async function runOfficialCommand(command: OfficialCommand): Promise<CommandResult> {
|
|
106
|
+
return command.run();
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export async function cosmicConfigCommand(cwd: string, config?: string): Promise<OfficialCommand> {
|
|
110
|
+
await ensureOfficialSkillRoot(cwd, "ok-cosmic");
|
|
111
|
+
const configPath = resolveConfigPath(cwd, config);
|
|
112
|
+
const display = formatCommand("kcode-node:cosmic-config", config ? ["--config", configPath] : []);
|
|
113
|
+
return officialCommand(display, () => runCosmicConfig(cwd, configPath));
|
|
121
114
|
}
|
|
122
115
|
|
|
123
116
|
export async function cosmicMetadataCommand(
|
|
@@ -131,16 +124,16 @@ export async function cosmicMetadataCommand(
|
|
|
131
124
|
op?: boolean;
|
|
132
125
|
showDetail?: boolean;
|
|
133
126
|
},
|
|
134
|
-
): Promise<
|
|
135
|
-
|
|
136
|
-
const script = join(root, "scripts", "cosmic-form-metadata.py");
|
|
127
|
+
): Promise<OfficialCommand> {
|
|
128
|
+
await ensureOfficialSkillRoot(cwd, "ok-cosmic");
|
|
137
129
|
const args = withConfig(["get", params.form], cwd, params.config);
|
|
138
130
|
if (params.sql) args.push("--sql");
|
|
139
131
|
if (params.op) args.push("--op");
|
|
140
132
|
if (params.showDetail) args.push("--show-detail");
|
|
141
133
|
if (params.fuzzy) args.push("--fuzzy", ...splitTerms(params.fuzzy));
|
|
142
134
|
if (params.typeFilter) args.push("--type", params.typeFilter);
|
|
143
|
-
|
|
135
|
+
const configPath = resolveConfigPath(cwd, params.config);
|
|
136
|
+
return officialCommand(formatCommand("kcode-node:cosmic-metadata", args), () => runCosmicMetadata(cwd, configPath, params));
|
|
144
137
|
}
|
|
145
138
|
|
|
146
139
|
export async function cosmicApiCommand(
|
|
@@ -152,19 +145,18 @@ export async function cosmicApiCommand(
|
|
|
152
145
|
method?: string;
|
|
153
146
|
compact?: boolean;
|
|
154
147
|
},
|
|
155
|
-
): Promise<
|
|
156
|
-
|
|
157
|
-
const script = join(root, "scripts", "cosmic-api-knowledge.py");
|
|
148
|
+
): Promise<OfficialCommand> {
|
|
149
|
+
await ensureOfficialSkillRoot(cwd, "ok-cosmic");
|
|
158
150
|
const args = withConfig([params.mode, params.query], cwd, params.config);
|
|
159
151
|
if (params.method) args.push("--method", params.method);
|
|
160
152
|
if (params.compact) args.push("--compact");
|
|
161
|
-
return
|
|
153
|
+
return officialCommand(formatCommand("kcode-node:cosmic-api", args), () => runCosmicApi(cwd, params));
|
|
162
154
|
}
|
|
163
155
|
|
|
164
|
-
export async function ksqlLintCommand(cwd: string, path: string): Promise<
|
|
165
|
-
|
|
166
|
-
const
|
|
167
|
-
return
|
|
156
|
+
export async function ksqlLintCommand(cwd: string, path: string): Promise<OfficialCommand> {
|
|
157
|
+
await ensureOfficialSkillRoot(cwd, "ok-ksql");
|
|
158
|
+
const resolvedPath = resolveWorkspacePath(cwd, path);
|
|
159
|
+
return officialCommand(formatCommand("kcode-node:ksql-lint", [resolvedPath]), () => runKsqlLint(resolvedPath));
|
|
168
160
|
}
|
|
169
161
|
|
|
170
162
|
export function formatCommandResult(result: CommandResult): string {
|
|
@@ -208,6 +200,525 @@ function formatJsonEvidence(evidenceFile: OfficialEvidenceFile, result: CommandR
|
|
|
208
200
|
)}\n`;
|
|
209
201
|
}
|
|
210
202
|
|
|
203
|
+
function officialCommand(display: string, run: () => Promise<Omit<CommandResult, "command">>): OfficialCommand {
|
|
204
|
+
return {
|
|
205
|
+
display,
|
|
206
|
+
run: async () => {
|
|
207
|
+
try {
|
|
208
|
+
const result = await run();
|
|
209
|
+
return { command: display, ...result };
|
|
210
|
+
} catch (error) {
|
|
211
|
+
return {
|
|
212
|
+
command: display,
|
|
213
|
+
exitCode: 1,
|
|
214
|
+
stdout: "",
|
|
215
|
+
stderr: error instanceof Error ? error.message : String(error),
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
},
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function resolveConfigPath(cwd: string, config?: string): string {
|
|
223
|
+
return config ? resolveWorkspacePath(cwd, config) : resolve(cwd, "ok-cosmic.json");
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function readJsonObject(path: string): Record<string, unknown> {
|
|
227
|
+
const data = JSON.parse(readFileSync(path, "utf8")) as unknown;
|
|
228
|
+
if (!data || typeof data !== "object" || Array.isArray(data)) {
|
|
229
|
+
throw new Error(`配置文件格式错误: ${path} 必须包含 JSON Object`);
|
|
230
|
+
}
|
|
231
|
+
return data as Record<string, unknown>;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
async function runCosmicConfig(_cwd: string, configPath: string): Promise<Omit<CommandResult, "command">> {
|
|
235
|
+
const issues: Array<{ level: "OK" | "WARNING" | "ERROR"; key: string; message: string }> = [];
|
|
236
|
+
issues.push({ level: "OK", key: "runtime.node", message: `Node ${process.version}` });
|
|
237
|
+
|
|
238
|
+
let config: Record<string, unknown> | undefined;
|
|
239
|
+
try {
|
|
240
|
+
if (!existsSync(configPath)) throw new Error(`找不到配置文件: ${configPath}`);
|
|
241
|
+
config = readJsonObject(configPath);
|
|
242
|
+
issues.push({ level: "OK", key: "__file__", message: `已找到配置文件: ${configPath}` });
|
|
243
|
+
} catch (error) {
|
|
244
|
+
issues.push({ level: "ERROR", key: "__file__", message: error instanceof Error ? error.message : String(error) });
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
if (config) issues.push(...validateCosmicConfig(config, dirname(configPath)));
|
|
248
|
+
const errors = issues.filter((issue) => issue.level === "ERROR").length;
|
|
249
|
+
const warnings = issues.filter((issue) => issue.level === "WARNING").length;
|
|
250
|
+
const lines = issues.map((issue) => `[${issue.level}] ${issue.key}: ${issue.message}`);
|
|
251
|
+
lines.push(`[SUMMARY] errors=${errors} warnings=${warnings}`);
|
|
252
|
+
return { exitCode: errors ? 1 : 0, stdout: `${lines.join("\n")}\n`, stderr: "" };
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function validateCosmicConfig(config: Record<string, unknown>, baseDir: string): Array<{ level: "OK" | "WARNING" | "ERROR"; key: string; message: string }> {
|
|
256
|
+
const issues: Array<{ level: "OK" | "WARNING" | "ERROR"; key: string; message: string }> = [];
|
|
257
|
+
const graph = objectValue(config.graph);
|
|
258
|
+
if (!graph) {
|
|
259
|
+
issues.push({ level: "ERROR", key: "graph", message: "缺少 `graph` 配置对象。" });
|
|
260
|
+
} else {
|
|
261
|
+
const dbPath = stringValue(graph.dbPath);
|
|
262
|
+
if (!dbPath) {
|
|
263
|
+
issues.push({ level: "ERROR", key: "graph.dbPath", message: "缺少必填项 `graph.dbPath`。" });
|
|
264
|
+
} else {
|
|
265
|
+
const resolved = resolveWorkspacePath(baseDir, dbPath);
|
|
266
|
+
issues.push({
|
|
267
|
+
level: existsSync(resolved) ? "OK" : "WARNING",
|
|
268
|
+
key: "graph.dbPath",
|
|
269
|
+
message: existsSync(resolved) ? `知识库路径: ${resolved}` : `graph.dbPath 指向的文件不存在: ${resolved}`,
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
const route = objectValue(config.route);
|
|
275
|
+
const routeUrl = stringValue(route?.apiUrl) || process.env.COSMIC_ROUTE_API || process.env.COSMIC_RUNTIME_ROUTE_API || "";
|
|
276
|
+
if (!route && !routeUrl) {
|
|
277
|
+
issues.push({ level: "WARNING", key: "route", message: "缺少 `route` 配置节,统一路由在线查询不可用。" });
|
|
278
|
+
} else if (!routeUrl) {
|
|
279
|
+
issues.push({ level: "WARNING", key: "route.apiUrl", message: "`route.apiUrl` 为空,统一路由在线查询不可用。" });
|
|
280
|
+
} else {
|
|
281
|
+
issues.push({ level: "OK", key: "route.apiUrl", message: "统一路由 API 已配置。" });
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
const extensionRepos = config.extensionRepos;
|
|
285
|
+
if (extensionRepos !== undefined) {
|
|
286
|
+
if (!Array.isArray(extensionRepos)) {
|
|
287
|
+
issues.push({ level: "ERROR", key: "extensionRepos", message: "`extensionRepos` 必须是字符串数组。" });
|
|
288
|
+
} else {
|
|
289
|
+
extensionRepos.forEach((raw, index) => {
|
|
290
|
+
const value = typeof raw === "string" ? raw.trim() : "";
|
|
291
|
+
const key = `extensionRepos[${index}]`;
|
|
292
|
+
if (!value) {
|
|
293
|
+
issues.push({ level: "ERROR", key, message: `${key} 必须是非空字符串路径。` });
|
|
294
|
+
return;
|
|
295
|
+
}
|
|
296
|
+
const resolved = resolveWorkspacePath(baseDir, value);
|
|
297
|
+
issues.push({
|
|
298
|
+
level: existsSync(resolved) ? "OK" : "WARNING",
|
|
299
|
+
key,
|
|
300
|
+
message: existsSync(resolved) ? `扩展代码库: ${resolved}` : `扩展代码库路径不存在或不是目录: ${resolved}`,
|
|
301
|
+
});
|
|
302
|
+
});
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
return issues;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
async function runCosmicMetadata(
|
|
310
|
+
cwd: string,
|
|
311
|
+
configPath: string,
|
|
312
|
+
params: { form: string; fuzzy?: string; typeFilter?: string; sql?: boolean; op?: boolean; showDetail?: boolean },
|
|
313
|
+
): Promise<Omit<CommandResult, "command">> {
|
|
314
|
+
const config = readJsonObject(configPath);
|
|
315
|
+
const cache = readMetadataCache(cwd);
|
|
316
|
+
const targets = params.form.split(/[,,]/).map((item) => item.trim()).filter(Boolean);
|
|
317
|
+
if (targets.length === 0) return { exitCode: 1, stdout: "", stderr: "必须提供 formId 或中文单据名。" };
|
|
318
|
+
|
|
319
|
+
const outputs: string[] = [];
|
|
320
|
+
for (const target of targets) {
|
|
321
|
+
const cached = findCachedMetadata(cache, target);
|
|
322
|
+
const payload = cached ?? (await fetchMetadata(config, target));
|
|
323
|
+
const formId = stringValue(objectValue(payload.form)?.formId) || stringValue(payload.formId) || target;
|
|
324
|
+
cache[formId] = { payload, updatedAt: Date.now() };
|
|
325
|
+
outputs.push(formatMetadata(target, payload, params, cached ? "cache" : "route"));
|
|
326
|
+
}
|
|
327
|
+
writeMetadataCache(cwd, cache);
|
|
328
|
+
return { exitCode: 0, stdout: `${outputs.join("\n\n---\n\n")}\n`, stderr: "" };
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
async function fetchMetadata(config: Record<string, unknown>, target: string): Promise<Record<string, unknown>> {
|
|
332
|
+
const route = objectValue(config.route);
|
|
333
|
+
const routeUrl = routeUrlFromConfig(route);
|
|
334
|
+
if (!routeUrl) {
|
|
335
|
+
throw new Error("未配置表单元数据查询 API。请在 ok-cosmic.json 的 route.apiUrl 中配置统一路由,或设置 COSMIC_ROUTE_API。");
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
const hasCjk = /[\u3400-\u9fff]/.test(target);
|
|
339
|
+
const response = await postRoute(routeUrl, route, {
|
|
340
|
+
data: {
|
|
341
|
+
type: "meta",
|
|
342
|
+
reqData: {
|
|
343
|
+
entityId: hasCjk ? "" : target,
|
|
344
|
+
formId: hasCjk ? "" : target,
|
|
345
|
+
billName: hasCjk ? target : "",
|
|
346
|
+
full: true,
|
|
347
|
+
},
|
|
348
|
+
},
|
|
349
|
+
});
|
|
350
|
+
if (response.status === false) throw new Error(`接口请求失败: ${stringValue(response.message) || "未知错误"}`);
|
|
351
|
+
const data = response.status === true && response.data !== undefined ? unwrapRoutePayload(response.data) : unwrapRoutePayload(response);
|
|
352
|
+
if (!data || typeof data !== "object" || Array.isArray(data)) throw new Error("元数据接口返回格式不是 JSON Object。");
|
|
353
|
+
return data as Record<string, unknown>;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
async function postRoute(url: string, route: Record<string, unknown> | undefined, body: unknown): Promise<Record<string, unknown>> {
|
|
357
|
+
const headers: Record<string, string> = { "Content-Type": "application/json" };
|
|
358
|
+
const token = stringValue(route?.apiToken) || stringValue(route?.token) || process.env.COSMIC_ROUTE_TOKEN || "";
|
|
359
|
+
if (token) headers.Authorization = `Bearer ${token}`;
|
|
360
|
+
const timeoutSeconds = Number(route?.timeoutSeconds ?? process.env.COSMIC_ROUTE_TIMEOUT ?? 10);
|
|
361
|
+
const controller = new AbortController();
|
|
362
|
+
const timeout = setTimeout(() => controller.abort(), Math.max(1, timeoutSeconds) * 1000);
|
|
363
|
+
try {
|
|
364
|
+
const response = await fetch(url, { method: "POST", headers, body: JSON.stringify(body), signal: controller.signal });
|
|
365
|
+
const text = await response.text();
|
|
366
|
+
if (!response.ok) throw new Error(`HTTP ${response.status}: ${text.slice(0, 500)}`);
|
|
367
|
+
const parsed = JSON.parse(text) as unknown;
|
|
368
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error("远程接口返回的根对象不是 JSON Object。");
|
|
369
|
+
return parsed as Record<string, unknown>;
|
|
370
|
+
} finally {
|
|
371
|
+
clearTimeout(timeout);
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
function routeUrlFromConfig(route: Record<string, unknown> | undefined): string {
|
|
376
|
+
let url = stringValue(route?.apiUrl) || process.env.COSMIC_ROUTE_API || process.env.COSMIC_RUNTIME_ROUTE_API || "";
|
|
377
|
+
const sign = stringValue(route?.openApiSign) || stringValue(route?.openapiSign) || process.env.COSMIC_ROUTE_OPEN_API_SIGN || process.env.COSMIC_OPEN_API_SIGN || "";
|
|
378
|
+
if (url && sign && !/[?&]openApiSign=/.test(url)) url += `${url.includes("?") ? "&" : "?"}openApiSign=${encodeURIComponent(sign)}`;
|
|
379
|
+
return url;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
function unwrapRoutePayload(value: unknown): unknown {
|
|
383
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return value;
|
|
384
|
+
const obj = value as Record<string, unknown>;
|
|
385
|
+
if (obj.form || obj.formFields || obj.entityFields || obj.code === "MULTI_MATCH" || obj.code === "BILL_NOT_FOUND") return obj;
|
|
386
|
+
for (const key of ["data", "result", "respData", "response"]) {
|
|
387
|
+
const nested = unwrapRoutePayload(obj[key]);
|
|
388
|
+
if (nested && typeof nested === "object" && !Array.isArray(nested)) return nested;
|
|
389
|
+
}
|
|
390
|
+
return obj;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
type MetadataCache = Record<string, { payload: Record<string, unknown>; updatedAt: number }>;
|
|
394
|
+
|
|
395
|
+
function metadataCachePath(cwd: string): string {
|
|
396
|
+
return join(officialSkillsCacheRoot(cwd), "cosmic-form-metadata-cache.json");
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
function readMetadataCache(cwd: string): MetadataCache {
|
|
400
|
+
const path = metadataCachePath(cwd);
|
|
401
|
+
if (!existsSync(path)) return {};
|
|
402
|
+
try {
|
|
403
|
+
return JSON.parse(readFileSync(path, "utf8")) as MetadataCache;
|
|
404
|
+
} catch {
|
|
405
|
+
return {};
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
function writeMetadataCache(cwd: string, cache: MetadataCache): void {
|
|
410
|
+
const path = metadataCachePath(cwd);
|
|
411
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
412
|
+
writeFileSync(path, `${JSON.stringify(cache, null, 2)}\n`, "utf8");
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
function findCachedMetadata(cache: MetadataCache, target: string): Record<string, unknown> | undefined {
|
|
416
|
+
const direct = cache[target]?.payload;
|
|
417
|
+
if (direct) return direct;
|
|
418
|
+
for (const entry of Object.values(cache)) {
|
|
419
|
+
const form = objectValue(entry.payload.form);
|
|
420
|
+
const names = [form?.formId, form?.id, form?.formName, form?.name, form?.title].map(stringValue);
|
|
421
|
+
if (names.includes(target)) return entry.payload;
|
|
422
|
+
}
|
|
423
|
+
return undefined;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
function formatMetadata(
|
|
427
|
+
target: string,
|
|
428
|
+
payload: Record<string, unknown>,
|
|
429
|
+
params: { fuzzy?: string; typeFilter?: string; sql?: boolean; op?: boolean; showDetail?: boolean },
|
|
430
|
+
source: "cache" | "route",
|
|
431
|
+
): string {
|
|
432
|
+
const form = objectValue(payload.form) ?? {};
|
|
433
|
+
const fields = [...arrayObjects(payload.formFields), ...arrayObjects(payload.entityFields)];
|
|
434
|
+
const operations = arrayObjects(payload.operateMetas).length ? arrayObjects(payload.operateMetas) : arrayObjects(payload.buttons);
|
|
435
|
+
const formName = stringValue(form.formName) || stringValue(form.name) || stringValue(form.title) || target;
|
|
436
|
+
const formId = stringValue(form.formId) || stringValue(form.id) || stringValue(form.key) || target;
|
|
437
|
+
const dbName = stringValue(form.dbName) || "-";
|
|
438
|
+
const dbTable = stringValue(form.dbTableName) || stringValue(form.dbTableKey) || "-";
|
|
439
|
+
|
|
440
|
+
if (params.op) {
|
|
441
|
+
const ops = filterObjects(operations, params.fuzzy);
|
|
442
|
+
return [`## [Op] 操作查询: ${formName} (${formId})`, "", "| 名称 | 标识 | 类型 |", "| :--- | :--- | :--- |", ...ops.map((op) => `| ${stringValue(op.name) || stringValue(op.opName) || "-"} | \`${stringValue(op.key) || stringValue(op.opKey) || "-"}\` | ${stringValue(op.type) || stringValue(op.opType) || "-"} |`)].join("\n");
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
const filtered = filterObjects(fields, params.fuzzy).filter((field) => {
|
|
446
|
+
if (!params.typeFilter) return true;
|
|
447
|
+
return matchesText(stringValue(field.type), params.typeFilter);
|
|
448
|
+
});
|
|
449
|
+
const selected = filtered.length ? filtered : fields.slice(0, 120);
|
|
450
|
+
const lines = [
|
|
451
|
+
`## [Meta] ${formName} (${formId})`,
|
|
452
|
+
`**来源**: ${source === "cache" ? "KCode 本地 JSON 缓存" : "统一路由 API"}`,
|
|
453
|
+
`**表**: dbName=\`${dbName}\`, dbTable=\`${dbTable}\``,
|
|
454
|
+
"",
|
|
455
|
+
];
|
|
456
|
+
if (params.sql) {
|
|
457
|
+
lines.push("| 名称 | 标识 | 类型 | 表名 | 数据库字段 |");
|
|
458
|
+
lines.push("| :--- | :--- | :--- | :--- | :--- |");
|
|
459
|
+
for (const field of selected) {
|
|
460
|
+
lines.push(`| ${stringValue(field.name) || "-"} | \`${stringValue(field.key) || "-"}\` | ${stringValue(field.type) || "-"} | \`${fieldTable(field, form)}\` | \`${stringValue(field.dbKey) || "-"}\` |`);
|
|
461
|
+
}
|
|
462
|
+
return lines.join("\n");
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
lines.push("| 名称 | 标识 | 类型 | 附加信息 |");
|
|
466
|
+
lines.push("| :--- | :--- | :--- | :--- |");
|
|
467
|
+
for (const field of selected) {
|
|
468
|
+
const detail = params.showDetail ? fieldDetail(field) : stringValue(field.dbKey) || stringValue(field.refType) || "-";
|
|
469
|
+
lines.push(`| ${stringValue(field.name) || "-"} | \`${stringValue(field.key) || "-"}\` | ${stringValue(field.type) || "-"} | ${detail} |`);
|
|
470
|
+
}
|
|
471
|
+
return lines.join("\n");
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
function runCosmicApi(cwd: string, params: { mode: "search" | "search-method" | "detail"; query: string; method?: string; compact?: boolean }): Promise<Omit<CommandResult, "command">> {
|
|
475
|
+
const knowledgePath = join(packageRoot, "knowledge");
|
|
476
|
+
const query = params.mode === "detail" && params.method ? `${params.query} ${params.method}` : params.query;
|
|
477
|
+
const results = searchKnowledge(query, { scopes: ["cosmic", "cangqiong", "xinghan", "flagship"], topK: params.compact ? 5 : 10, minScore: 1 }, knowledgePath);
|
|
478
|
+
const header = [
|
|
479
|
+
`KCode Node Cosmic API query (${params.mode})`,
|
|
480
|
+
"说明: 当前 npm 包不再调用 Python/SQLite 脚本;这里查询随包金蝶知识库。需要精确方法签名时,请优先结合项目 SDK/编译输出做红绿验证。",
|
|
481
|
+
"",
|
|
482
|
+
].join("\n");
|
|
483
|
+
const stdout = `${header}${formatSearchResults(query, results, knowledgePath)}\n`;
|
|
484
|
+
return Promise.resolve({ exitCode: results.length ? 0 : 1, stdout, stderr: results.length ? "" : "未在随包知识库找到匹配 API 线索。" });
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
function runKsqlLint(path: string): Promise<Omit<CommandResult, "command">> {
|
|
488
|
+
if (!existsSync(path)) return Promise.resolve({ exitCode: 2, stdout: "", stderr: `${path}:1: ERROR: 文件不存在。\n` });
|
|
489
|
+
const rawSql = readFileSync(path, "utf8");
|
|
490
|
+
const findings = [...lintTimestamps(path, rawSql), ...iterStatements(stripCommentsAndLiterals(rawSql)).flatMap(lintStatement)].sort((a, b) => a.line - b.line || a.severity.localeCompare(b.severity));
|
|
491
|
+
const lines = findings.map((finding) => `${path}:${finding.line}: ${finding.severity}: ${finding.message}`);
|
|
492
|
+
const errorCount = findings.filter((finding) => finding.severity === "ERROR").length;
|
|
493
|
+
const warnCount = findings.filter((finding) => finding.severity === "WARN").length;
|
|
494
|
+
lines.push(`SUMMARY: ${errorCount} error(s), ${warnCount} warning(s)`);
|
|
495
|
+
return Promise.resolve({ exitCode: errorCount ? 1 : 0, stdout: `${lines.join("\n")}\n`, stderr: "" });
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
interface KsqlFinding {
|
|
499
|
+
severity: "ERROR" | "WARN";
|
|
500
|
+
line: number;
|
|
501
|
+
message: string;
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
interface KsqlStatement {
|
|
505
|
+
text: string;
|
|
506
|
+
line: number;
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
function stripCommentsAndLiterals(sql: string): string {
|
|
510
|
+
let out = "";
|
|
511
|
+
let i = 0;
|
|
512
|
+
let state: "normal" | "line" | "block" | "single" | "double" | "dollar" = "normal";
|
|
513
|
+
let dollarTag = "";
|
|
514
|
+
while (i < sql.length) {
|
|
515
|
+
const ch = sql[i];
|
|
516
|
+
const next = sql[i + 1] ?? "";
|
|
517
|
+
if (state === "normal") {
|
|
518
|
+
if (ch === "-" && next === "-") {
|
|
519
|
+
out += " ";
|
|
520
|
+
i += 2;
|
|
521
|
+
state = "line";
|
|
522
|
+
continue;
|
|
523
|
+
}
|
|
524
|
+
if (ch === "/" && next === "*") {
|
|
525
|
+
out += " ";
|
|
526
|
+
i += 2;
|
|
527
|
+
state = "block";
|
|
528
|
+
continue;
|
|
529
|
+
}
|
|
530
|
+
if (ch === "'") {
|
|
531
|
+
out += " ";
|
|
532
|
+
i++;
|
|
533
|
+
state = "single";
|
|
534
|
+
continue;
|
|
535
|
+
}
|
|
536
|
+
if (ch === '"') {
|
|
537
|
+
out += " ";
|
|
538
|
+
i++;
|
|
539
|
+
state = "double";
|
|
540
|
+
continue;
|
|
541
|
+
}
|
|
542
|
+
if (ch === "$") {
|
|
543
|
+
const match = sql.slice(i).match(/^\$[A-Za-z_][A-Za-z0-9_]*\$|^\$\$/);
|
|
544
|
+
if (match) {
|
|
545
|
+
dollarTag = match[0];
|
|
546
|
+
out += " ".repeat(dollarTag.length);
|
|
547
|
+
i += dollarTag.length;
|
|
548
|
+
state = "dollar";
|
|
549
|
+
continue;
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
out += ch;
|
|
553
|
+
i++;
|
|
554
|
+
continue;
|
|
555
|
+
}
|
|
556
|
+
if (state === "line") {
|
|
557
|
+
out += ch === "\n" ? "\n" : " ";
|
|
558
|
+
i++;
|
|
559
|
+
if (ch === "\n") state = "normal";
|
|
560
|
+
continue;
|
|
561
|
+
}
|
|
562
|
+
if (state === "block") {
|
|
563
|
+
if (ch === "*" && next === "/") {
|
|
564
|
+
out += " ";
|
|
565
|
+
i += 2;
|
|
566
|
+
state = "normal";
|
|
567
|
+
} else {
|
|
568
|
+
out += ch === "\n" ? "\n" : " ";
|
|
569
|
+
i++;
|
|
570
|
+
}
|
|
571
|
+
continue;
|
|
572
|
+
}
|
|
573
|
+
if (state === "single" || state === "double") {
|
|
574
|
+
const quote = state === "single" ? "'" : '"';
|
|
575
|
+
if (ch === quote && next === quote) {
|
|
576
|
+
out += " ";
|
|
577
|
+
i += 2;
|
|
578
|
+
} else {
|
|
579
|
+
out += ch === "\n" ? "\n" : " ";
|
|
580
|
+
i++;
|
|
581
|
+
if (ch === quote) state = "normal";
|
|
582
|
+
}
|
|
583
|
+
continue;
|
|
584
|
+
}
|
|
585
|
+
if (sql.startsWith(dollarTag, i)) {
|
|
586
|
+
out += " ".repeat(dollarTag.length);
|
|
587
|
+
i += dollarTag.length;
|
|
588
|
+
state = "normal";
|
|
589
|
+
dollarTag = "";
|
|
590
|
+
} else {
|
|
591
|
+
out += ch === "\n" ? "\n" : " ";
|
|
592
|
+
i++;
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
return out;
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
function iterStatements(maskedSql: string): KsqlStatement[] {
|
|
599
|
+
const statements: KsqlStatement[] = [];
|
|
600
|
+
let start = 0;
|
|
601
|
+
let startLine = 1;
|
|
602
|
+
let line = 1;
|
|
603
|
+
for (let i = 0; i < maskedSql.length; i++) {
|
|
604
|
+
const ch = maskedSql[i];
|
|
605
|
+
if (ch === ";") {
|
|
606
|
+
const text = maskedSql.slice(start, i + 1);
|
|
607
|
+
if (text.trim()) statements.push({ text, line: startLine });
|
|
608
|
+
start = i + 1;
|
|
609
|
+
startLine = line;
|
|
610
|
+
}
|
|
611
|
+
if (ch === "\n") {
|
|
612
|
+
line++;
|
|
613
|
+
if (!maskedSql.slice(start, i).trim()) startLine = line;
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
const tail = maskedSql.slice(start);
|
|
617
|
+
if (tail.trim()) statements.push({ text: tail, line: startLine });
|
|
618
|
+
return statements;
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
function lintStatement(stmt: KsqlStatement): KsqlFinding[] {
|
|
622
|
+
const findings: KsqlFinding[] = [];
|
|
623
|
+
const compact = stmt.text.split(/\s+/).filter(Boolean).join(" ");
|
|
624
|
+
const first = compact.match(/^\s*(\w+)/)?.[1]?.toUpperCase() ?? "";
|
|
625
|
+
if ((first === "UPDATE" || first === "DELETE") && !hasToken(stmt.text, "WHERE")) {
|
|
626
|
+
findings.push({ severity: "ERROR", line: stmt.line, message: `${first} 语句缺少 WHERE,禁止生成无范围更新/删除。` });
|
|
627
|
+
}
|
|
628
|
+
const selectStarMatches = [...stmt.text.matchAll(/\bSELECT\s+\*/gi)];
|
|
629
|
+
if (selectStarMatches.length) {
|
|
630
|
+
const isBackup = /\bSELECT\s+\*\s+INTO\s+bak_[a-zA-Z0-9_]+_\d{12}\s+FROM\b/i.test(stmt.text);
|
|
631
|
+
if (isBackup && hasToken(stmt.text, "WHERE")) {
|
|
632
|
+
findings.push({ severity: "ERROR", line: stmt.line, message: "备份语句必须整表备份,SELECT * INTO bak_... 不允许带 WHERE。" });
|
|
633
|
+
} else if (!isBackup) {
|
|
634
|
+
for (const match of selectStarMatches) {
|
|
635
|
+
findings.push({ severity: "ERROR", line: lineOfOffset(stmt.text, stmt.line, match.index ?? 0), message: "查询/验证语句禁止 SELECT *;只有整表备份 SELECT * INTO bak_... 例外。" });
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
if (/\bSELECT\s+\*\s+INTO\b/i.test(stmt.text) && !/\bSELECT\s+\*\s+INTO\s+bak_[a-zA-Z0-9_]+_\d{12}\s+FROM\b/i.test(stmt.text)) {
|
|
640
|
+
findings.push({ severity: "ERROR", line: stmt.line, message: "备份表名必须形如 bak_<原表或业务缩写>_<yyyyMMddHHmm>。" });
|
|
641
|
+
}
|
|
642
|
+
for (const match of stmt.text.matchAll(/\bEXISTS\b/gi)) {
|
|
643
|
+
findings.push({ severity: "WARN", line: lineOfOffset(stmt.text, stmt.line, match.index ?? 0), message: "SQL 可读性偏好:成员关系/半连接默认使用 IN,只有 IN 改变语义时才保留 EXISTS 并说明原因。" });
|
|
644
|
+
}
|
|
645
|
+
if (/\bUPDATE\b.+\bJOIN\b/i.test(compact)) {
|
|
646
|
+
findings.push({ severity: "WARN", line: stmt.line, message: "PostgreSQL 多表更新优先使用 UPDATE ... FROM ... WHERE ...,不要使用 MySQL 风格 UPDATE ... JOIN。" });
|
|
647
|
+
}
|
|
648
|
+
if (/=\s*NULL\b|\bNULL\s*=/i.test(stmt.text)) {
|
|
649
|
+
findings.push({ severity: "ERROR", line: stmt.line, message: "NULL 判断必须使用 IS NULL / IS NOT NULL,不能使用 = NULL。" });
|
|
650
|
+
}
|
|
651
|
+
if (/<>|!=/.test(stmt.text) && hasToken(stmt.text, "NULL")) {
|
|
652
|
+
findings.push({ severity: "WARN", line: stmt.line, message: "涉及 NULL 的不等比较需确认语义;PostgreSQL 可优先使用 IS DISTINCT FROM。" });
|
|
653
|
+
}
|
|
654
|
+
return findings;
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
function lintTimestamps(path: string, rawSql: string): KsqlFinding[] {
|
|
658
|
+
const findings: KsqlFinding[] = [];
|
|
659
|
+
const backupTimestamps = new Set([...rawSql.matchAll(/\bbak_[a-zA-Z0-9_]+_(\d{12})\b/gi)].map((match) => match[1]));
|
|
660
|
+
const filenameTs = path.match(/ksql_[^/\\]*_(\d{12})\.txt$/i)?.[1];
|
|
661
|
+
const headerTimestamps = new Set([...rawSql.matchAll(/备份表时间戳[::]\s*(\d{12})/g)].map((match) => match[1]));
|
|
662
|
+
if (backupTimestamps.size > 1) findings.push({ severity: "ERROR", line: 1, message: "同一 SQL 文件中出现多个备份表时间戳;桌面文件、备份表和文件头时间戳必须一致。" });
|
|
663
|
+
if (filenameTs && backupTimestamps.size && !backupTimestamps.has(filenameTs)) {
|
|
664
|
+
findings.push({ severity: "ERROR", line: 1, message: `文件名时间戳 ${filenameTs} 与备份表时间戳 ${[...backupTimestamps].sort().join(", ")} 不一致。` });
|
|
665
|
+
}
|
|
666
|
+
if (headerTimestamps.size > 1) findings.push({ severity: "ERROR", line: 1, message: "文件头出现多个不同的备份表时间戳。" });
|
|
667
|
+
if (headerTimestamps.size && backupTimestamps.size && [...headerTimestamps].some((ts) => !backupTimestamps.has(ts))) {
|
|
668
|
+
findings.push({ severity: "ERROR", line: 1, message: `文件头时间戳 ${[...headerTimestamps].sort().join(", ")} 与备份表时间戳 ${[...backupTimestamps].sort().join(", ")} 不一致。` });
|
|
669
|
+
}
|
|
670
|
+
return findings;
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
function hasToken(text: string, token: string): boolean {
|
|
674
|
+
return new RegExp(`\\b${token.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b`, "i").test(text);
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
function lineOfOffset(text: string, baseLine: number, offset: number): number {
|
|
678
|
+
return baseLine + (text.slice(0, offset).match(/\n/g)?.length ?? 0);
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
function arrayObjects(value: unknown): Record<string, unknown>[] {
|
|
682
|
+
return Array.isArray(value) ? value.filter((item): item is Record<string, unknown> => Boolean(item) && typeof item === "object" && !Array.isArray(item)) : [];
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
function objectValue(value: unknown): Record<string, unknown> | undefined {
|
|
686
|
+
return value && typeof value === "object" && !Array.isArray(value) ? (value as Record<string, unknown>) : undefined;
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
function stringValue(value: unknown): string {
|
|
690
|
+
return typeof value === "string" ? value.trim() : value === undefined || value === null ? "" : String(value).trim();
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
function filterObjects(values: Record<string, unknown>[], fuzzy?: string): Record<string, unknown>[] {
|
|
694
|
+
const terms = fuzzy ? splitTerms(fuzzy) : [];
|
|
695
|
+
if (!terms.length) return values;
|
|
696
|
+
return values.filter((value) => terms.some((term) => matchesText(Object.values(value).map(stringValue).join("|"), term)));
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
function matchesText(text: string, term: string): boolean {
|
|
700
|
+
try {
|
|
701
|
+
return new RegExp(term, "i").test(text);
|
|
702
|
+
} catch {
|
|
703
|
+
return text.toLowerCase().includes(term.toLowerCase());
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
function fieldTable(field: Record<string, unknown>, form: Record<string, unknown>): string {
|
|
708
|
+
return stringValue(field.dbTableName) || stringValue(field.dbTableKey) || stringValue(form.dbTableName) || stringValue(form.dbTableKey) || "-";
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
function fieldDetail(field: Record<string, unknown>): string {
|
|
712
|
+
const parts = [];
|
|
713
|
+
const extMap = objectValue(field.extMap);
|
|
714
|
+
if (extMap) parts.push(`枚举: ${Object.entries(extMap).map(([key, value]) => `${key}:${stringValue(value)}`).join(", ")}`);
|
|
715
|
+
const refType = stringValue(field.refType);
|
|
716
|
+
if (refType) parts.push(`refType: ${refType}`);
|
|
717
|
+
const dbKey = stringValue(field.dbKey);
|
|
718
|
+
if (dbKey) parts.push(`dbKey: ${dbKey}`);
|
|
719
|
+
return parts.join(";") || "-";
|
|
720
|
+
}
|
|
721
|
+
|
|
211
722
|
function withConfig(args: string[], cwd: string, config?: string): string[] {
|
|
212
723
|
if (!config) return args;
|
|
213
724
|
return ["--config", resolveWorkspacePath(cwd, config), ...args];
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { isAbsolute, resolve } from "node:path";
|
|
2
|
+
|
|
3
|
+
const WINDOWS_DRIVE_RE = /^[a-zA-Z]:[\\/]/;
|
|
4
|
+
|
|
5
|
+
export function resolveWorkspacePath(cwd: string, inputPath: string): string {
|
|
6
|
+
const normalized = normalizeExternalPath(inputPath);
|
|
7
|
+
return isAbsolute(normalized) || WINDOWS_DRIVE_RE.test(normalized) ? resolve(normalized) : resolve(cwd, normalized);
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function normalizeExternalPath(inputPath: string): string {
|
|
11
|
+
let value = inputPath.trim();
|
|
12
|
+
if (value.startsWith("file://")) {
|
|
13
|
+
try {
|
|
14
|
+
value = new URL(value).pathname;
|
|
15
|
+
} catch {
|
|
16
|
+
// Keep original value when it is not a valid file URL.
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
if (process.platform === "win32") {
|
|
21
|
+
const wsl = value.match(/^\/mnt\/([a-zA-Z])\/(.*)$/);
|
|
22
|
+
if (wsl) return `${wsl[1].toUpperCase()}:\\${wsl[2].replace(/\//g, "\\")}`;
|
|
23
|
+
|
|
24
|
+
const msys = value.match(/^\/([a-zA-Z])\/(.*)$/);
|
|
25
|
+
if (msys) return `${msys[1].toUpperCase()}:\\${msys[2].replace(/\//g, "\\")}`;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
return value;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function hasUnixDrivePath(inputPath: string): boolean {
|
|
32
|
+
return /^\/mnt\/[a-zA-Z]\//.test(inputPath.trim()) || /^\/[a-zA-Z]\//.test(inputPath.trim());
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function windowsPathHint(inputPath: string): string | undefined {
|
|
36
|
+
if (process.platform !== "win32" || !hasUnixDrivePath(inputPath)) return undefined;
|
|
37
|
+
return normalizeExternalPath(inputPath);
|
|
38
|
+
}
|