kcode-pi 0.1.4 → 0.1.6

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 CHANGED
@@ -15,6 +15,7 @@ KCode 不要求你单独安装全局 `pi` 命令,也不会修改用户全局 P
15
15
  - Node.js `>=22.19.0`
16
16
  - npm
17
17
  - Windows 推荐使用 Windows Terminal
18
+ - 不需要安装 Python。KCode 随包工具使用 Node/TypeScript 实现;只有你自己的业务项目或外部脚本明确依赖 Python 时才需要另行安装。
18
19
 
19
20
  全局安装:
20
21
 
@@ -60,6 +61,12 @@ kcode context --refresh
60
61
  kcode doctor
61
62
  ```
62
63
 
64
+ 查看当前 KCode 版本:
65
+
66
+ ```powershell
67
+ kcode version
68
+ ```
69
+
63
70
  启动工作环境:
64
71
 
65
72
  ```powershell
@@ -235,6 +242,16 @@ kcode context --refresh
235
242
  kcode doctor
236
243
  ```
237
244
 
245
+ ### `kcode version`
246
+
247
+ 显示当前 KCode package 版本、安装路径、随包 Pi CLI 版本和 Node 版本。
248
+
249
+ ```powershell
250
+ kcode version
251
+ kcode --version
252
+ kcode -v
253
+ ```
254
+
238
255
  ### `kcode start`
239
256
 
240
257
  启动 KCode 工作环境。
@@ -394,11 +411,19 @@ kd_build 按产品画像执行或 dry-run 构建
394
411
  kd_debug 分析金蝶日志和堆栈
395
412
  ```
396
413
 
414
+ 这些工具不依赖本机 Python:
415
+
416
+ - `kd_ksql_lint` 是内置 Node 静态检查器。
417
+ - `kd_cosmic_config` 使用 Node 读取并校验 `ok-cosmic.json`。
418
+ - `kd_cosmic_metadata` 使用 `ok-cosmic.json` 中的统一路由 API,并在当前项目 `.pi/kd/official-skills/` 下维护 JSON 缓存。
419
+ - `kd_cosmic_api` 查询随包金蝶知识库;需要精确方法签名时,仍要结合当前项目 SDK、编译输出或红绿证据确认。
420
+
397
421
  ## 升级
398
422
 
399
423
  查看当前安装版本:
400
424
 
401
425
  ```powershell
426
+ kcode version
402
427
  npm ls -g kcode-pi --depth=0
403
428
  ```
404
429
 
@@ -514,6 +539,23 @@ notepad $env:USERPROFILE\.pi\agent\settings.json
514
539
  }
515
540
  ```
516
541
 
542
+ ### Windows 下出现 `/mnt/d/...` 找不到文件
543
+
544
+ 现象:
545
+
546
+ ```text
547
+ read /mnt/d/projects/xxx/src/main/java/Foo.java
548
+ ENOENT: no such file or directory, access 'D:\mnt\d\projects\xxx\src\main\java\Foo.java'
549
+ ```
550
+
551
+ 原因是当前运行环境是 Windows,但 LLM 或工具把 Windows 路径误写成了 WSL/MSYS 风格路径。正确做法:
552
+
553
+ - 优先使用项目相对路径,例如 `code/fi/module/src/main/java/.../Foo.java`。
554
+ - 如果必须使用绝对路径,在 Windows 下使用 `D:\projects\xxx\...`,不要使用 `/mnt/d/...` 或 `/d/...`。
555
+ - 发现项目结构变化后运行 `kcode context --refresh`,让项目常驻上下文重新记录真实源码根。
556
+
557
+ KCode Harness 会拦截常见 `/mnt/<drive>/...` 和 `/<drive>/...` 文件工具调用,并提示改用 Windows 路径或项目相对路径。
558
+
517
559
  ### 仍然加载旧版本 KCode
518
560
 
519
561
  先检查当前项目配置:
@@ -12,5 +12,6 @@ export declare function runKcodeCli(args: string[], cwd?: string): KcodeCliResul
12
12
  export declare function initProject(cwd: string): KcodeCliResult;
13
13
  export declare function context(cwd: string, args: string[]): KcodeCliResult;
14
14
  export declare function doctor(cwd: string): KcodeCliResult;
15
+ export declare function version(): KcodeCliResult;
15
16
  export declare function start(cwd: string, piArgs: string[]): KcodeCliResult;
16
17
  export declare function resolvePiCliCommand(piArgs?: string[]): PiCliCommand | undefined;
package/dist/cli/kcode.js CHANGED
@@ -6,7 +6,9 @@ import { createRequire } from "node:module";
6
6
  import { ensureProjectContext, writeProjectContext } from "../context/project-context.js";
7
7
  const packageRoot = dirname(dirname(dirname(fileURLToPath(import.meta.url))));
8
8
  const require = createRequire(import.meta.url);
9
- const packageName = readPackageName(packageRoot) ?? "kcode-pi";
9
+ const packageMetadata = readPackageMetadata(packageRoot);
10
+ const packageName = packageMetadata.name ?? "kcode-pi";
11
+ const packageVersion = packageMetadata.version ?? "unknown";
10
12
  export function runKcodeCli(args, cwd = process.cwd()) {
11
13
  const command = args[0] ?? "help";
12
14
  switch (command) {
@@ -16,6 +18,10 @@ export function runKcodeCli(args, cwd = process.cwd()) {
16
18
  return context(cwd, args.slice(1));
17
19
  case "doctor":
18
20
  return doctor(cwd);
21
+ case "version":
22
+ case "--version":
23
+ case "-v":
24
+ return version();
19
25
  case "start":
20
26
  return start(cwd, args.slice(1));
21
27
  case "help":
@@ -57,6 +63,7 @@ export function doctor(cwd) {
57
63
  const settingsPath = projectSettingsPath(cwd);
58
64
  lines.push(`Node:${node.status === 0 ? node.stdout.trim() : "未找到"}`);
59
65
  lines.push(`Pi CLI:${formatPiCliStatus(piCli, pi)}`);
66
+ lines.push(`KCode version:${packageName}@${packageVersion}`);
60
67
  lines.push(`KCode package:${packageRoot}`);
61
68
  lines.push(`项目配置:${existsSync(settingsPath) ? settingsPath : "未创建,请先运行 kcode init"}`);
62
69
  lines.push(`项目上下文:${existsSync(join(cwd, ".pi", "kd", "PROJECT_CONTEXT.md")) ? join(cwd, ".pi", "kd", "PROJECT_CONTEXT.md") : "未创建,请运行 kcode context"}`);
@@ -70,6 +77,19 @@ export function doctor(cwd) {
70
77
  output: lines.join("\n"),
71
78
  };
72
79
  }
80
+ export function version() {
81
+ const piCli = resolvePiCliCommand(["--version"]);
82
+ const pi = piCli ? spawnSync(piCli.command, piCli.args, { encoding: "utf8" }) : undefined;
83
+ return {
84
+ exitCode: 0,
85
+ output: [
86
+ `${packageName}@${packageVersion}`,
87
+ `KCode package:${packageRoot}`,
88
+ `Pi CLI:${formatPiCliStatus(piCli, pi)}`,
89
+ `Node:${process.version}`,
90
+ ].join("\n"),
91
+ };
92
+ }
73
93
  export function start(cwd, piArgs) {
74
94
  const init = initProject(cwd);
75
95
  const piCli = resolvePiCliCommand(piArgs);
@@ -127,12 +147,14 @@ function isSameKcodePackage(candidate, currentPackage) {
127
147
  return candidateName === packageName;
128
148
  }
129
149
  function readPackageName(packagePath) {
150
+ return readPackageMetadata(packagePath).name;
151
+ }
152
+ function readPackageMetadata(packagePath) {
130
153
  try {
131
- const packageJson = JSON.parse(readFileSync(join(packagePath, "package.json"), "utf8"));
132
- return packageJson.name;
154
+ return JSON.parse(readFileSync(join(packagePath, "package.json"), "utf8"));
133
155
  }
134
156
  catch {
135
- return undefined;
157
+ return {};
136
158
  }
137
159
  }
138
160
  function packageNameFromPath(path) {
@@ -182,6 +204,7 @@ function helpText() {
182
204
  " kcode init 初始化当前项目的 .pi/settings.json",
183
205
  " kcode context 生成或刷新 .pi/kd/PROJECT_CONTEXT.md",
184
206
  " kcode doctor 检查 Node、随包 Pi CLI、KCode package 和项目级配置",
207
+ " kcode version 显示 KCode、随包 Pi CLI 和 Node 版本",
185
208
  " kcode start 初始化项目配置后启动 KCode 工作环境",
186
209
  ].join("\n");
187
210
  }
@@ -58,6 +58,7 @@ export function generateProjectContext(cwd) {
58
58
  "- This file is project memory for KCode. Read it before planning or editing code.",
59
59
  "- Do not create demo/sample/scaffold code for business requirements.",
60
60
  "- Do not assume module layout. Follow the actual paths below and verify target files before editing.",
61
+ "- Use project-relative paths when calling file tools. On Windows, do not rewrite paths to /mnt/<drive>/... or /<drive>/...; use Windows paths only when an absolute path is necessary.",
61
62
  "- If this file is stale, regenerate with `kcode context --refresh` before planning.",
62
63
  "- Write product code only after the Harness reaches `execute` and PLAN.md names the real target path.",
63
64
  "",
@@ -43,7 +43,7 @@ npm run smoke:kcode-cli
43
43
  - 金蝶知识库搜索。
44
44
  - `kd_check` 静态规则。
45
45
  - Harness 阶段和门禁。
46
- - 官方脚本适配器。
46
+ - 官方能力 Node 适配器。
47
47
  - 构建/调试诊断。
48
48
  - package manifest、skills、vendor 文件完整性。
49
49
  - `kcode` 项目级入口逻辑。
@@ -15,6 +15,7 @@ import { readArtifact } from "../src/harness/artifacts.ts";
15
15
  import { flagshipWriteBlockReason, isSourceLikePath, planWriteBlockReason } from "../src/harness/path-policy.ts";
16
16
  import { tddProductionWriteBlockReason } from "../src/harness/tdd-policy.ts";
17
17
  import { readProjectContext } from "../src/context/project-context.ts";
18
+ import { windowsPathHint } from "../src/platform/path.ts";
18
19
 
19
20
  const KINGDEE_INTENT_PATTERN =
20
21
  /金蝶|苍穹|星瀚|星空|旗舰|企业版|单据|表单|列表|插件|操作插件|校验器|反写|转换|工作流|基础资料|动态对象|DynamicObject|BOS|Cosmic|IronPython|Python\s*插件|py\s*插件|kd_|KSQL/i;
@@ -104,6 +105,7 @@ function workflowPromptForRun(cwd: string, run: NonNullable<ReturnType<typeof re
104
105
  "",
105
106
  phaseGuidance[run.phase],
106
107
  "必须先理解当前业务项目已有目录、模块、包名、基类和本地封装,再决定文件位置和实现方式。",
108
+ "路径规则:在 Windows 工作区内,优先使用项目相对路径;如需绝对路径必须使用 `D:\\...` 这类 Windows 路径,禁止把路径改写成 `/mnt/d/...`、`/d/...` 等 WSL/MSYS 风格路径。",
107
109
  "execute 阶段只能写 PLAN.md 明确列出的源码文件;如果目标文件不在计划内,必须先回到 plan 更新 PLAN.md。",
108
110
  "写生产源码前必须先有红灯证据 evidence/tdd-red.md;红绿证据可以是 API/基类/方法签名、元数据、编译、既有测试框架或外部接口最小验证,不要为了测试引入额外 jar。",
109
111
  ].join("\n");
@@ -179,9 +181,17 @@ export default function (pi: ExtensionAPI) {
179
181
  });
180
182
 
181
183
  pi.on("tool_call", (event, ctx) => {
184
+ const input = event.input as Record<string, unknown>;
185
+ const path = typeof input.path === "string" ? input.path : undefined;
186
+ const hint = path ? windowsPathHint(path) : undefined;
187
+ if (hint && ["read", "write", "edit"].includes(event.toolName)) {
188
+ const reason = `当前是 Windows 工作区,不能使用 WSL/MSYS 路径 ${path}。请改用项目相对路径,或使用 Windows 路径 ${hint}。`;
189
+ if (ctx.hasUI) ctx.ui.notify(reason, "warning");
190
+ return { block: true, reason };
191
+ }
192
+
182
193
  if (event.toolName !== "write" && event.toolName !== "edit") return undefined;
183
194
 
184
- const path = typeof event.input.path === "string" ? event.input.path : undefined;
185
195
  const reason = codeWriteBlockReason(ctx.cwd, path) ?? flagshipWriteBlockReason(readActiveRun(ctx.cwd), path, ctx.cwd);
186
196
  if (!reason) return undefined;
187
197
 
@@ -17,9 +17,10 @@ import {
17
17
  type OfficialEvidenceFile,
18
18
  isCosmicFamily,
19
19
  ksqlLintCommand,
20
- runCommand,
20
+ runOfficialCommand,
21
21
  writeOfficialEvidence,
22
22
  } from "../src/official/kingdee-skills.ts";
23
+ import { resolveWorkspacePath } from "../src/platform/path.ts";
23
24
 
24
25
  const extensionDir = dirname(fileURLToPath(import.meta.url));
25
26
  const knowledgePath = join(extensionDir, "..", "knowledge");
@@ -83,7 +84,7 @@ async function runOrDryRun(
83
84
  };
84
85
  }
85
86
 
86
- const result = await runCommand(command);
87
+ const result = await runOfficialCommand(command);
87
88
  const evidencePath = evidenceFile ? writeOfficialEvidence(ctx.cwd, evidenceFile, result) : undefined;
88
89
  return {
89
90
  content: [{ type: "text" as const, text: formatCommandResult(result) }],
@@ -194,7 +195,7 @@ const kdCheckTool = defineTool({
194
195
  let source = "inline";
195
196
 
196
197
  if (!code && params.path) {
197
- const filePath = join(ctx.cwd, params.path);
198
+ const filePath = resolveWorkspacePath(ctx.cwd, params.path);
198
199
  code = readFileSync(filePath, "utf8");
199
200
  source = params.path;
200
201
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kcode-pi",
3
- "version": "0.1.4",
3
+ "version": "0.1.6",
4
4
  "description": "Kingdee-specific package and harness for Pi Coding Agent",
5
5
  "type": "module",
6
6
  "private": false,
package/src/cli/kcode.ts CHANGED
@@ -17,7 +17,9 @@ interface PiSettings {
17
17
 
18
18
  const packageRoot = dirname(dirname(dirname(fileURLToPath(import.meta.url))));
19
19
  const require = createRequire(import.meta.url);
20
- const packageName = readPackageName(packageRoot) ?? "kcode-pi";
20
+ const packageMetadata = readPackageMetadata(packageRoot);
21
+ const packageName = packageMetadata.name ?? "kcode-pi";
22
+ const packageVersion = packageMetadata.version ?? "unknown";
21
23
 
22
24
  export interface PiCliCommand {
23
25
  command: string;
@@ -36,6 +38,10 @@ export function runKcodeCli(args: string[], cwd = process.cwd()): KcodeCliResult
36
38
  return context(cwd, args.slice(1));
37
39
  case "doctor":
38
40
  return doctor(cwd);
41
+ case "version":
42
+ case "--version":
43
+ case "-v":
44
+ return version();
39
45
  case "start":
40
46
  return start(cwd, args.slice(1));
41
47
  case "help":
@@ -84,6 +90,7 @@ export function doctor(cwd: string): KcodeCliResult {
84
90
 
85
91
  lines.push(`Node:${node.status === 0 ? node.stdout.trim() : "未找到"}`);
86
92
  lines.push(`Pi CLI:${formatPiCliStatus(piCli, pi)}`);
93
+ lines.push(`KCode version:${packageName}@${packageVersion}`);
87
94
  lines.push(`KCode package:${packageRoot}`);
88
95
  lines.push(`项目配置:${existsSync(settingsPath) ? settingsPath : "未创建,请先运行 kcode init"}`);
89
96
  lines.push(`项目上下文:${existsSync(join(cwd, ".pi", "kd", "PROJECT_CONTEXT.md")) ? join(cwd, ".pi", "kd", "PROJECT_CONTEXT.md") : "未创建,请运行 kcode context"}`);
@@ -100,6 +107,20 @@ export function doctor(cwd: string): KcodeCliResult {
100
107
  };
101
108
  }
102
109
 
110
+ export function version(): KcodeCliResult {
111
+ const piCli = resolvePiCliCommand(["--version"]);
112
+ const pi = piCli ? spawnSync(piCli.command, piCli.args, { encoding: "utf8" }) : undefined;
113
+ return {
114
+ exitCode: 0,
115
+ output: [
116
+ `${packageName}@${packageVersion}`,
117
+ `KCode package:${packageRoot}`,
118
+ `Pi CLI:${formatPiCliStatus(piCli, pi)}`,
119
+ `Node:${process.version}`,
120
+ ].join("\n"),
121
+ };
122
+ }
123
+
103
124
  export function start(cwd: string, piArgs: string[]): KcodeCliResult {
104
125
  const init = initProject(cwd);
105
126
  const piCli = resolvePiCliCommand(piArgs);
@@ -168,11 +189,14 @@ function isSameKcodePackage(candidate: string, currentPackage: string): boolean
168
189
  }
169
190
 
170
191
  function readPackageName(packagePath: string): string | undefined {
192
+ return readPackageMetadata(packagePath).name;
193
+ }
194
+
195
+ function readPackageMetadata(packagePath: string): { name?: string; version?: string } {
171
196
  try {
172
- const packageJson = JSON.parse(readFileSync(join(packagePath, "package.json"), "utf8")) as { name?: string };
173
- return packageJson.name;
197
+ return JSON.parse(readFileSync(join(packagePath, "package.json"), "utf8")) as { name?: string; version?: string };
174
198
  } catch {
175
- return undefined;
199
+ return {};
176
200
  }
177
201
  }
178
202
 
@@ -229,6 +253,7 @@ function helpText(): string {
229
253
  " kcode init 初始化当前项目的 .pi/settings.json",
230
254
  " kcode context 生成或刷新 .pi/kd/PROJECT_CONTEXT.md",
231
255
  " kcode doctor 检查 Node、随包 Pi CLI、KCode package 和项目级配置",
256
+ " kcode version 显示 KCode、随包 Pi CLI 和 Node 版本",
232
257
  " kcode start 初始化项目配置后启动 KCode 工作环境",
233
258
  ].join("\n");
234
259
  }
@@ -70,6 +70,7 @@ export function generateProjectContext(cwd: string): string {
70
70
  "- This file is project memory for KCode. Read it before planning or editing code.",
71
71
  "- Do not create demo/sample/scaffold code for business requirements.",
72
72
  "- Do not assume module layout. Follow the actual paths below and verify target files before editing.",
73
+ "- Use project-relative paths when calling file tools. On Windows, do not rewrite paths to /mnt/<drive>/... or /<drive>/...; use Windows paths only when an absolute path is necessary.",
73
74
  "- If this file is stale, regenerate with `kcode context --refresh` before planning.",
74
75
  "- Write product code only after the Harness reaches `execute` and PLAN.md names the real target path.",
75
76
  "",
@@ -1,11 +1,14 @@
1
- import { existsSync, mkdirSync, writeFileSync } from "node:fs";
2
- import { dirname, isAbsolute, join, resolve } from "node:path";
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 cosmicConfigCommand(cwd: string, config?: string): Promise<CommandSpec> {
117
- const root = await ensureOfficialSkillRoot(cwd, "ok-cosmic");
118
- const script = join(root, "scripts", "cosmic-config-check.py");
119
- const args = config ? ["--config", resolveWorkspacePath(cwd, config)] : [];
120
- return buildPythonCommand(cwd, script, args);
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<CommandSpec> {
135
- const root = await ensureOfficialSkillRoot(cwd, "ok-cosmic");
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
- return buildPythonCommand(cwd, script, args);
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<CommandSpec> {
156
- const root = await ensureOfficialSkillRoot(cwd, "ok-cosmic");
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 buildPythonCommand(cwd, script, args);
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<CommandSpec> {
165
- const root = await ensureOfficialSkillRoot(cwd, "ok-ksql");
166
- const script = join(root, "scripts", "ksql_lint.py");
167
- return buildPythonCommand(cwd, script, [resolveWorkspacePath(cwd, path)]);
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
+ }
@@ -1,7 +1,8 @@
1
1
  import { existsSync, readFileSync, readdirSync } from "node:fs";
2
2
  import { extname, join } from "node:path";
3
3
  import type { ProductProfile } from "../product/profile.ts";
4
- import { type CommandSpec, formatCommandResult, resolveWorkspacePath, runCommand } from "../official/kingdee-skills.ts";
4
+ import { type CommandSpec, formatCommandResult, runCommand } from "../official/kingdee-skills.ts";
5
+ import { resolveWorkspacePath } from "../platform/path.ts";
5
6
 
6
7
  export interface BuildPlan {
7
8
  profile: string;