import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { dirname, join, resolve } from "node:path"; import { execFile } from "node:child_process"; import { promisify } from "node:util"; import { fileURLToPath } from "node:url"; import type { ProductProfile } from "../product/profile.ts"; import { readActiveRun } from "../harness/state.ts"; import { writeEvidenceFile } from "../harness/evidence.ts"; import { searchKnowledge } from "../knowledge/search.ts"; import { formatSearchResults } from "../knowledge/format.ts"; import { resolveWorkspacePath } from "../platform/path.ts"; const execFileAsync = promisify(execFile); export type OfficialSkillKey = "ok-cosmic" | "ok-ksql"; export interface CommandSpec { executable: string; args: string[]; cwd: string; display: string; } export interface CommandResult { command: string; exitCode: number; stdout: string; stderr: string; } export interface OfficialCommand { display: string; run: () => Promise; } export type OfficialEvidenceFile = "cosmic-config.txt" | "cosmic-metadata.json" | "cosmic-api.txt" | "ksql-lint.txt"; const SKILL_DIRS: Record = { "ok-cosmic": "ok-cosmic", "ok-ksql": "ok-ksql", }; const packageRoot = dirname(dirname(dirname(fileURLToPath(import.meta.url)))); export function isCosmicFamily(profile: ProductProfile): boolean { return profile.platform === "cosmic"; } export function officialSkillsSourceRoot(): string { return officialSkillsSourceRoots()[0]; } export function officialSkillsSourceRoots(): string[] { return [ process.env.KCODE_KINGDEE_SKILLS_ROOT, join(packageRoot, "vendor", "kingdee-skills"), ].filter((value): value is string => Boolean(value)); } export function officialSkillsCacheRoot(cwd: string): string { return join(cwd, ".pi", "kd", "official-skills"); } export async function ensureOfficialSkillRoot(_cwd: string, skill: OfficialSkillKey): Promise { for (const sourceRoot of officialSkillsSourceRoots()) { const expandedSource = join(sourceRoot, SKILL_DIRS[skill]); if (existsSync(expandedSource)) return expandedSource; } throw new Error(`Official skill directory not found for ${skill}. Checked: ${officialSkillsSourceRoots().join(", ")}`); } export async function runCommand(command: CommandSpec, timeoutMs = 120_000): Promise { try { const result = await execFileAsync(command.executable, command.args, { cwd: command.cwd, timeout: timeoutMs, maxBuffer: 1024 * 1024 * 4, }); return { command: command.display, exitCode: 0, stdout: result.stdout ?? "", stderr: result.stderr ?? "", }; } catch (error) { const err = error as { code?: number | string; stdout?: string; stderr?: string; message?: string; }; return { command: command.display, exitCode: typeof err.code === "number" ? err.code : 1, stdout: err.stdout ?? "", stderr: err.stderr ?? err.message ?? "", }; } } export async function runOfficialCommand(command: OfficialCommand): Promise { return command.run(); } export async function cosmicConfigCommand(cwd: string, config?: string): Promise { await ensureOfficialSkillRoot(cwd, "ok-cosmic"); const configPath = resolveConfigPath(cwd, config); const display = formatCommand("kcode-node:cosmic-config", config && configPath ? ["--config", configPath] : []); return officialCommand(display, () => runCosmicConfig(cwd, configPath, Boolean(config))); } export async function cosmicMetadataCommand( cwd: string, params: { form: string; config?: string; fuzzy?: string; typeFilter?: string; sql?: boolean; op?: boolean; showDetail?: boolean; }, ): Promise { await ensureOfficialSkillRoot(cwd, "ok-cosmic"); const args = withConfig(["get", params.form], cwd, params.config); if (params.sql) args.push("--sql"); if (params.op) args.push("--op"); if (params.showDetail) args.push("--show-detail"); if (params.fuzzy) args.push("--fuzzy", ...splitTerms(params.fuzzy)); if (params.typeFilter) args.push("--type", params.typeFilter); const configPath = resolveConfigPath(cwd, params.config); return officialCommand(formatCommand("kcode-node:cosmic-metadata", args), () => runCosmicMetadata(cwd, configPath, Boolean(params.config), params)); } export async function cosmicApiCommand( cwd: string, params: { mode: "search" | "search-method" | "detail"; query: string; config?: string; method?: string; compact?: boolean; }, ): Promise { await ensureOfficialSkillRoot(cwd, "ok-cosmic"); const args = withConfig([params.mode, params.query], cwd, params.config); if (params.method) args.push("--method", params.method); if (params.compact) args.push("--compact"); return officialCommand(formatCommand("kcode-node:cosmic-api", args), () => runCosmicApi(cwd, params)); } export async function ksqlLintCommand(cwd: string, path: string): Promise { await ensureOfficialSkillRoot(cwd, "ok-ksql"); const resolvedPath = resolveWorkspacePath(cwd, path); return officialCommand(formatCommand("kcode-node:ksql-lint", [resolvedPath]), () => runKsqlLint(resolvedPath)); } export function formatCommandResult(result: CommandResult): string { return [ `Command: ${result.command}`, `Exit: ${result.exitCode}`, result.stdout.trim() ? `\nSTDOUT:\n${result.stdout.trim()}` : undefined, result.stderr.trim() ? `\nSTDERR:\n${result.stderr.trim()}` : undefined, ] .filter(Boolean) .join("\n"); } export function writeOfficialEvidence(cwd: string, evidenceFile: OfficialEvidenceFile, result: CommandResult): string | undefined { const run = readActiveRun(cwd); if (!run) return undefined; const content = evidenceFile === "cosmic-metadata.json" ? formatJsonEvidence(evidenceFile, result) : `${formatCommandResult(result)}\nCaptured: ${new Date().toISOString()}\n`; return writeEvidenceFile(cwd, run, join("evidence", evidenceFile), content, { kind: evidenceFile.replace(/\.(txt|json)$/i, ""), command: result.command, exitCode: result.exitCode, }); } function formatJsonEvidence(evidenceFile: OfficialEvidenceFile, result: CommandResult): string { return `${JSON.stringify( { evidence: evidenceFile, capturedAt: new Date().toISOString(), command: result.command, exitCode: result.exitCode, stdout: result.stdout, stderr: result.stderr, }, null, 2, )}\n`; } function officialCommand(display: string, run: () => Promise>): OfficialCommand { return { display, run: async () => { try { const result = await run(); return { command: display, ...result }; } catch (error) { return { command: display, exitCode: 1, stdout: "", stderr: error instanceof Error ? error.message : String(error), }; } }, }; } function resolveConfigPath(cwd: string, config?: string): string | undefined { if (config) return resolveWorkspacePath(cwd, config); const projectConfig = resolve(cwd, "ok-cosmic.json"); return existsSync(projectConfig) ? projectConfig : undefined; } function readJsonObject(path: string): Record { const data = JSON.parse(readFileSync(path, "utf8")) as unknown; if (!data || typeof data !== "object" || Array.isArray(data)) { throw new Error(`配置文件格式错误: ${path} 必须包含 JSON Object`); } return data as Record; } interface LoadedCosmicConfig { config: Record; path?: string; source: "project" | "explicit" | "bundled"; baseDir: string; } async function runCosmicConfig(cwd: string, configPath: string | undefined, explicitConfig: boolean): Promise> { const issues: Array<{ level: "OK" | "WARNING" | "ERROR"; key: string; message: string }> = []; issues.push({ level: "OK", key: "runtime.node", message: `Node ${process.version}` }); let loaded: LoadedCosmicConfig | undefined; try { loaded = loadCosmicConfig(cwd, configPath, explicitConfig); if (loaded.path) { issues.push({ level: "OK", key: "__file__", message: `已找到配置文件: ${loaded.path}` }); } else { issues.push({ level: "WARNING", key: "__file__", message: "当前项目未提供 ok-cosmic.json,已使用 KCode 随包默认配置。项目根目录 cosmic.json 是苍穹工程配置,不是 KCode 官方能力配置。", }); } } catch (error) { issues.push({ level: "ERROR", key: "__file__", message: error instanceof Error ? error.message : String(error) }); } if (loaded) issues.push(...validateCosmicConfig(loaded.config, loaded.baseDir)); const errors = issues.filter((issue) => issue.level === "ERROR").length; const warnings = issues.filter((issue) => issue.level === "WARNING").length; const lines = issues.map((issue) => `[${issue.level}] ${issue.key}: ${issue.message}`); lines.push(`[SUMMARY] errors=${errors} warnings=${warnings}`); return { exitCode: errors ? 1 : 0, stdout: `${lines.join("\n")}\n`, stderr: "" }; } function loadCosmicConfig(cwd: string, configPath: string | undefined, explicitConfig: boolean): LoadedCosmicConfig { if (configPath) { if (!existsSync(configPath)) throw new Error(`找不到配置文件: ${configPath}`); return { config: readJsonObject(configPath), path: configPath, source: explicitConfig ? "explicit" : "project", baseDir: dirname(configPath), }; } if (explicitConfig) throw new Error("指定的 ok-cosmic.json 配置文件不存在。"); return { config: defaultCosmicConfig(), source: "bundled", baseDir: cwd, }; } function defaultCosmicConfig(): Record { return { graph: { dbPath: join(packageRoot, "vendor", "kingdee-skills", "ok-cosmic", "setup", "ok-cosmic-docs.db"), }, route: { apiUrl: process.env.COSMIC_ROUTE_API || process.env.COSMIC_RUNTIME_ROUTE_API || "", timeoutSeconds: Number(process.env.COSMIC_ROUTE_TIMEOUT || 10), }, }; } function validateCosmicConfig(config: Record, baseDir: string): Array<{ level: "OK" | "WARNING" | "ERROR"; key: string; message: string }> { const issues: Array<{ level: "OK" | "WARNING" | "ERROR"; key: string; message: string }> = []; const graph = objectValue(config.graph); if (!graph) { issues.push({ level: "ERROR", key: "graph", message: "缺少 `graph` 配置对象。" }); } else { const dbPath = stringValue(graph.dbPath); if (!dbPath) { issues.push({ level: "ERROR", key: "graph.dbPath", message: "缺少必填项 `graph.dbPath`。" }); } else { const resolved = resolveWorkspacePath(baseDir, dbPath); issues.push({ level: existsSync(resolved) ? "OK" : "WARNING", key: "graph.dbPath", message: existsSync(resolved) ? `知识库路径: ${resolved}` : `graph.dbPath 指向的文件不存在: ${resolved}`, }); } } const route = objectValue(config.route); const routeUrl = stringValue(route?.apiUrl) || process.env.COSMIC_ROUTE_API || process.env.COSMIC_RUNTIME_ROUTE_API || ""; if (!route && !routeUrl) { issues.push({ level: "WARNING", key: "route", message: "缺少 `route` 配置节,统一路由在线查询不可用。" }); } else if (!routeUrl) { issues.push({ level: "WARNING", key: "route.apiUrl", message: "`route.apiUrl` 为空,统一路由在线查询不可用。" }); } else { issues.push({ level: "OK", key: "route.apiUrl", message: "统一路由 API 已配置。" }); } const extensionRepos = config.extensionRepos; if (extensionRepos !== undefined) { if (!Array.isArray(extensionRepos)) { issues.push({ level: "ERROR", key: "extensionRepos", message: "`extensionRepos` 必须是字符串数组。" }); } else { extensionRepos.forEach((raw, index) => { const value = typeof raw === "string" ? raw.trim() : ""; const key = `extensionRepos[${index}]`; if (!value) { issues.push({ level: "ERROR", key, message: `${key} 必须是非空字符串路径。` }); return; } const resolved = resolveWorkspacePath(baseDir, value); issues.push({ level: existsSync(resolved) ? "OK" : "WARNING", key, message: existsSync(resolved) ? `扩展代码库: ${resolved}` : `扩展代码库路径不存在或不是目录: ${resolved}`, }); }); } } return issues; } async function runCosmicMetadata( cwd: string, configPath: string | undefined, explicitConfig: boolean, params: { form: string; fuzzy?: string; typeFilter?: string; sql?: boolean; op?: boolean; showDetail?: boolean }, ): Promise> { const config = loadCosmicConfig(cwd, configPath, explicitConfig).config; const cache = readMetadataCache(cwd); const targets = params.form.split(/[,,]/).map((item) => item.trim()).filter(Boolean); if (targets.length === 0) return { exitCode: 1, stdout: "", stderr: "必须提供 formId 或中文单据名。" }; const outputs: string[] = []; for (const target of targets) { const cached = findCachedMetadata(cache, target); const payload = cached ?? (await fetchMetadata(config, target)); const formId = stringValue(objectValue(payload.form)?.formId) || stringValue(payload.formId) || target; cache[formId] = { payload, updatedAt: Date.now() }; outputs.push(formatMetadata(target, payload, params, cached ? "cache" : "route")); } writeMetadataCache(cwd, cache); return { exitCode: 0, stdout: `${outputs.join("\n\n---\n\n")}\n`, stderr: "" }; } async function fetchMetadata(config: Record, target: string): Promise> { const route = objectValue(config.route); const routeUrl = routeUrlFromConfig(route); if (!routeUrl) { throw new Error("未配置表单元数据查询 API。必须在 ok-cosmic.json 的 route.apiUrl 中配置统一路由,或设置 COSMIC_ROUTE_API。"); } const hasCjk = /[\u3400-\u9fff]/.test(target); const response = await postRoute(routeUrl, route, { data: { type: "meta", reqData: { entityId: hasCjk ? "" : target, formId: hasCjk ? "" : target, billName: hasCjk ? target : "", full: true, }, }, }); if (response.status === false) throw new Error(`接口请求失败: ${stringValue(response.message) || "未知错误"}`); const data = response.status === true && response.data !== undefined ? unwrapRoutePayload(response.data) : unwrapRoutePayload(response); if (!data || typeof data !== "object" || Array.isArray(data)) throw new Error("元数据接口返回格式不是 JSON Object。"); return data as Record; } async function postRoute(url: string, route: Record | undefined, body: unknown): Promise> { const headers: Record = { "Content-Type": "application/json" }; const token = stringValue(route?.apiToken) || stringValue(route?.token) || process.env.COSMIC_ROUTE_TOKEN || ""; if (token) headers.Authorization = `Bearer ${token}`; const timeoutSeconds = Number(route?.timeoutSeconds ?? process.env.COSMIC_ROUTE_TIMEOUT ?? 10); const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), Math.max(1, timeoutSeconds) * 1000); try { const response = await fetch(url, { method: "POST", headers, body: JSON.stringify(body), signal: controller.signal }); const text = await response.text(); if (!response.ok) throw new Error(`HTTP ${response.status}: ${text.slice(0, 500)}`); const parsed = JSON.parse(text) as unknown; if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error("远程接口返回的根对象不是 JSON Object。"); return parsed as Record; } finally { clearTimeout(timeout); } } function routeUrlFromConfig(route: Record | undefined): string { let url = stringValue(route?.apiUrl) || process.env.COSMIC_ROUTE_API || process.env.COSMIC_RUNTIME_ROUTE_API || ""; const sign = stringValue(route?.openApiSign) || stringValue(route?.openapiSign) || process.env.COSMIC_ROUTE_OPEN_API_SIGN || process.env.COSMIC_OPEN_API_SIGN || ""; if (url && sign && !/[?&]openApiSign=/.test(url)) url += `${url.includes("?") ? "&" : "?"}openApiSign=${encodeURIComponent(sign)}`; return url; } function unwrapRoutePayload(value: unknown): unknown { if (!value || typeof value !== "object" || Array.isArray(value)) return value; const obj = value as Record; if (obj.form || obj.formFields || obj.entityFields || obj.code === "MULTI_MATCH" || obj.code === "BILL_NOT_FOUND") return obj; for (const key of ["data", "result", "respData", "response"]) { const nested = unwrapRoutePayload(obj[key]); if (nested && typeof nested === "object" && !Array.isArray(nested)) return nested; } return obj; } type MetadataCache = Record; updatedAt: number }>; function metadataCachePath(cwd: string): string { return join(officialSkillsCacheRoot(cwd), "cosmic-form-metadata-cache.json"); } function readMetadataCache(cwd: string): MetadataCache { const path = metadataCachePath(cwd); if (!existsSync(path)) return {}; try { return JSON.parse(readFileSync(path, "utf8")) as MetadataCache; } catch { return {}; } } function writeMetadataCache(cwd: string, cache: MetadataCache): void { const path = metadataCachePath(cwd); mkdirSync(dirname(path), { recursive: true }); writeFileSync(path, `${JSON.stringify(cache, null, 2)}\n`, "utf8"); } function findCachedMetadata(cache: MetadataCache, target: string): Record | undefined { const direct = cache[target]?.payload; if (direct) return direct; for (const entry of Object.values(cache)) { const form = objectValue(entry.payload.form); const names = [form?.formId, form?.id, form?.formName, form?.name, form?.title].map(stringValue); if (names.includes(target)) return entry.payload; } return undefined; } function formatMetadata( target: string, payload: Record, params: { fuzzy?: string; typeFilter?: string; sql?: boolean; op?: boolean; showDetail?: boolean }, source: "cache" | "route", ): string { const form = objectValue(payload.form) ?? {}; const fields = [...arrayObjects(payload.formFields), ...arrayObjects(payload.entityFields)]; const operations = arrayObjects(payload.operateMetas).length ? arrayObjects(payload.operateMetas) : arrayObjects(payload.buttons); const formName = stringValue(form.formName) || stringValue(form.name) || stringValue(form.title) || target; const formId = stringValue(form.formId) || stringValue(form.id) || stringValue(form.key) || target; const dbName = stringValue(form.dbName) || "-"; const dbTable = stringValue(form.dbTableName) || stringValue(form.dbTableKey) || "-"; if (params.op) { const ops = filterObjects(operations, params.fuzzy); 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"); } const filtered = filterObjects(fields, params.fuzzy).filter((field) => { if (!params.typeFilter) return true; return matchesText(stringValue(field.type), params.typeFilter); }); const selected = filtered.length ? filtered : fields.slice(0, 120); const lines = [ `## [Meta] ${formName} (${formId})`, `**来源**: ${source === "cache" ? "KCode 本地 JSON 缓存" : "统一路由 API"}`, `**表**: dbName=\`${dbName}\`, dbTable=\`${dbTable}\``, "", ]; if (params.sql) { lines.push("| 名称 | 标识 | 类型 | 表名 | 数据库字段 |"); lines.push("| :--- | :--- | :--- | :--- | :--- |"); for (const field of selected) { lines.push(`| ${stringValue(field.name) || "-"} | \`${stringValue(field.key) || "-"}\` | ${stringValue(field.type) || "-"} | \`${fieldTable(field, form)}\` | \`${stringValue(field.dbKey) || "-"}\` |`); } return lines.join("\n"); } lines.push("| 名称 | 标识 | 类型 | 附加信息 |"); lines.push("| :--- | :--- | :--- | :--- |"); for (const field of selected) { const detail = params.showDetail ? fieldDetail(field) : stringValue(field.dbKey) || stringValue(field.refType) || "-"; lines.push(`| ${stringValue(field.name) || "-"} | \`${stringValue(field.key) || "-"}\` | ${stringValue(field.type) || "-"} | ${detail} |`); } return lines.join("\n"); } function runCosmicApi(cwd: string, params: { mode: "search" | "search-method" | "detail"; query: string; method?: string; compact?: boolean }): Promise> { const knowledgePath = join(packageRoot, "knowledge"); const query = params.mode === "detail" && params.method ? `${params.query} ${params.method}` : params.query; const results = searchKnowledge(query, { scopes: ["cosmic", "cangqiong", "xinghan", "flagship"], topK: params.compact ? 5 : 10, minScore: 1 }, knowledgePath); const header = [ `KCode Node Cosmic API query (${params.mode})`, "说明: 当前 npm 包不再调用 Python/SQLite 脚本;这里查询随包金蝶知识库。精确方法签名必须结合项目 SDK/编译输出做红绿验证。", "", ].join("\n"); const stdout = `${header}${formatSearchResults(query, results, knowledgePath)}\n`; return Promise.resolve({ exitCode: results.length ? 0 : 1, stdout, stderr: results.length ? "" : "未在随包知识库找到匹配 API 线索。" }); } function runKsqlLint(path: string): Promise> { if (!existsSync(path)) return Promise.resolve({ exitCode: 2, stdout: "", stderr: `${path}:1: ERROR: 文件不存在。\n` }); const rawSql = readFileSync(path, "utf8"); const findings = [...lintTimestamps(path, rawSql), ...iterStatements(stripCommentsAndLiterals(rawSql)).flatMap(lintStatement)].sort((a, b) => a.line - b.line || a.severity.localeCompare(b.severity)); const lines = findings.map((finding) => `${path}:${finding.line}: ${finding.severity}: ${finding.message}`); const errorCount = findings.filter((finding) => finding.severity === "ERROR").length; const warnCount = findings.filter((finding) => finding.severity === "WARN").length; lines.push(`SUMMARY: ${errorCount} error(s), ${warnCount} warning(s)`); return Promise.resolve({ exitCode: errorCount ? 1 : 0, stdout: `${lines.join("\n")}\n`, stderr: "" }); } interface KsqlFinding { severity: "ERROR" | "WARN"; line: number; message: string; } interface KsqlStatement { text: string; line: number; } function stripCommentsAndLiterals(sql: string): string { let out = ""; let i = 0; let state: "normal" | "line" | "block" | "single" | "double" | "dollar" = "normal"; let dollarTag = ""; while (i < sql.length) { const ch = sql[i]; const next = sql[i + 1] ?? ""; if (state === "normal") { if (ch === "-" && next === "-") { out += " "; i += 2; state = "line"; continue; } if (ch === "/" && next === "*") { out += " "; i += 2; state = "block"; continue; } if (ch === "'") { out += " "; i++; state = "single"; continue; } if (ch === '"') { out += " "; i++; state = "double"; continue; } if (ch === "$") { const match = sql.slice(i).match(/^\$[A-Za-z_][A-Za-z0-9_]*\$|^\$\$/); if (match) { dollarTag = match[0]; out += " ".repeat(dollarTag.length); i += dollarTag.length; state = "dollar"; continue; } } out += ch; i++; continue; } if (state === "line") { out += ch === "\n" ? "\n" : " "; i++; if (ch === "\n") state = "normal"; continue; } if (state === "block") { if (ch === "*" && next === "/") { out += " "; i += 2; state = "normal"; } else { out += ch === "\n" ? "\n" : " "; i++; } continue; } if (state === "single" || state === "double") { const quote = state === "single" ? "'" : '"'; if (ch === quote && next === quote) { out += " "; i += 2; } else { out += ch === "\n" ? "\n" : " "; i++; if (ch === quote) state = "normal"; } continue; } if (sql.startsWith(dollarTag, i)) { out += " ".repeat(dollarTag.length); i += dollarTag.length; state = "normal"; dollarTag = ""; } else { out += ch === "\n" ? "\n" : " "; i++; } } return out; } function iterStatements(maskedSql: string): KsqlStatement[] { const statements: KsqlStatement[] = []; let start = 0; let startLine = 1; let line = 1; for (let i = 0; i < maskedSql.length; i++) { const ch = maskedSql[i]; if (ch === ";") { const text = maskedSql.slice(start, i + 1); if (text.trim()) statements.push({ text, line: startLine }); start = i + 1; startLine = line; } if (ch === "\n") { line++; if (!maskedSql.slice(start, i).trim()) startLine = line; } } const tail = maskedSql.slice(start); if (tail.trim()) statements.push({ text: tail, line: startLine }); return statements; } function lintStatement(stmt: KsqlStatement): KsqlFinding[] { const findings: KsqlFinding[] = []; const compact = stmt.text.split(/\s+/).filter(Boolean).join(" "); const first = compact.match(/^\s*(\w+)/)?.[1]?.toUpperCase() ?? ""; if ((first === "UPDATE" || first === "DELETE") && !hasToken(stmt.text, "WHERE")) { findings.push({ severity: "ERROR", line: stmt.line, message: `${first} 语句缺少 WHERE,禁止生成无范围更新/删除。` }); } const selectStarMatches = [...stmt.text.matchAll(/\bSELECT\s+\*/gi)]; if (selectStarMatches.length) { const isBackup = /\bSELECT\s+\*\s+INTO\s+bak_[a-zA-Z0-9_]+_\d{12}\s+FROM\b/i.test(stmt.text); if (isBackup && hasToken(stmt.text, "WHERE")) { findings.push({ severity: "ERROR", line: stmt.line, message: "备份语句必须整表备份,SELECT * INTO bak_... 不允许带 WHERE。" }); } else if (!isBackup) { for (const match of selectStarMatches) { findings.push({ severity: "ERROR", line: lineOfOffset(stmt.text, stmt.line, match.index ?? 0), message: "查询/验证语句禁止 SELECT *;只有整表备份 SELECT * INTO bak_... 例外。" }); } } } 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)) { findings.push({ severity: "ERROR", line: stmt.line, message: "备份表名必须形如 bak_<原表或业务缩写>_。" }); } for (const match of stmt.text.matchAll(/\bEXISTS\b/gi)) { findings.push({ severity: "WARN", line: lineOfOffset(stmt.text, stmt.line, match.index ?? 0), message: "SQL 可读性偏好:成员关系/半连接默认使用 IN,只有 IN 改变语义时才保留 EXISTS 并说明原因。" }); } if (/\bUPDATE\b.+\bJOIN\b/i.test(compact)) { findings.push({ severity: "WARN", line: stmt.line, message: "PostgreSQL 多表更新默认使用 UPDATE ... FROM ... WHERE ...,禁止使用 MySQL 风格 UPDATE ... JOIN。" }); } if (/=\s*NULL\b|\bNULL\s*=/i.test(stmt.text)) { findings.push({ severity: "ERROR", line: stmt.line, message: "NULL 判断必须使用 IS NULL / IS NOT NULL,不能使用 = NULL。" }); } if (/<>|!=/.test(stmt.text) && hasToken(stmt.text, "NULL")) { findings.push({ severity: "WARN", line: stmt.line, message: "涉及 NULL 的不等比较必须确认语义;PostgreSQL 默认使用 IS DISTINCT FROM。" }); } return findings; } function lintTimestamps(path: string, rawSql: string): KsqlFinding[] { const findings: KsqlFinding[] = []; const backupTimestamps = new Set([...rawSql.matchAll(/\bbak_[a-zA-Z0-9_]+_(\d{12})\b/gi)].map((match) => match[1])); const filenameTs = path.match(/ksql_[^/\\]*_(\d{12})\.txt$/i)?.[1]; const headerTimestamps = new Set([...rawSql.matchAll(/备份表时间戳[::]\s*(\d{12})/g)].map((match) => match[1])); if (backupTimestamps.size > 1) findings.push({ severity: "ERROR", line: 1, message: "同一 SQL 文件中出现多个备份表时间戳;桌面文件、备份表和文件头时间戳必须一致。" }); if (filenameTs && backupTimestamps.size && !backupTimestamps.has(filenameTs)) { findings.push({ severity: "ERROR", line: 1, message: `文件名时间戳 ${filenameTs} 与备份表时间戳 ${[...backupTimestamps].sort().join(", ")} 不一致。` }); } if (headerTimestamps.size > 1) findings.push({ severity: "ERROR", line: 1, message: "文件头出现多个不同的备份表时间戳。" }); if (headerTimestamps.size && backupTimestamps.size && [...headerTimestamps].some((ts) => !backupTimestamps.has(ts))) { findings.push({ severity: "ERROR", line: 1, message: `文件头时间戳 ${[...headerTimestamps].sort().join(", ")} 与备份表时间戳 ${[...backupTimestamps].sort().join(", ")} 不一致。` }); } return findings; } function hasToken(text: string, token: string): boolean { return new RegExp(`\\b${token.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b`, "i").test(text); } function lineOfOffset(text: string, baseLine: number, offset: number): number { return baseLine + (text.slice(0, offset).match(/\n/g)?.length ?? 0); } function arrayObjects(value: unknown): Record[] { return Array.isArray(value) ? value.filter((item): item is Record => Boolean(item) && typeof item === "object" && !Array.isArray(item)) : []; } function objectValue(value: unknown): Record | undefined { return value && typeof value === "object" && !Array.isArray(value) ? (value as Record) : undefined; } function stringValue(value: unknown): string { return typeof value === "string" ? value.trim() : value === undefined || value === null ? "" : String(value).trim(); } function filterObjects(values: Record[], fuzzy?: string): Record[] { const terms = fuzzy ? splitTerms(fuzzy) : []; if (!terms.length) return values; return values.filter((value) => terms.some((term) => matchesText(Object.values(value).map(stringValue).join("|"), term))); } function matchesText(text: string, term: string): boolean { try { return new RegExp(term, "i").test(text); } catch { return text.toLowerCase().includes(term.toLowerCase()); } } function fieldTable(field: Record, form: Record): string { return stringValue(field.dbTableName) || stringValue(field.dbTableKey) || stringValue(form.dbTableName) || stringValue(form.dbTableKey) || "-"; } function fieldDetail(field: Record): string { const parts = []; const extMap = objectValue(field.extMap); if (extMap) parts.push(`枚举: ${Object.entries(extMap).map(([key, value]) => `${key}:${stringValue(value)}`).join(", ")}`); const refType = stringValue(field.refType); if (refType) parts.push(`refType: ${refType}`); const dbKey = stringValue(field.dbKey); if (dbKey) parts.push(`dbKey: ${dbKey}`); return parts.join(";") || "-"; } function withConfig(args: string[], cwd: string, config?: string): string[] { if (!config) return args; return ["--config", resolveWorkspacePath(cwd, config), ...args]; } function splitTerms(value: string): string[] { return value .split(/[,\s]+/) .map((term) => term.trim()) .filter(Boolean); } function formatCommand(executable: string, args: string[]): string { return [executable, ...args].map(quoteArg).join(" "); } function quoteArg(value: string): string { if (!/[\s"'&|<>]/.test(value)) return value; return `"${value.replace(/"/g, '\\"')}"`; }