@nsyan/db 1.3.0 → 1.3.2

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,7 +15,7 @@
15
15
 
16
16
  - **10 种数据库 · 6 家族**:PostgreSQL · MySQL · Oracle · 达梦 · Redis · Elasticsearch · MongoDB · Neo4j · Hive · Spark
17
17
  - **AI 工具**:查询 / 表结构 / 连接清单 / 扫描建连,读多写少场景的 token 友好输出
18
- - **扫描建连**:AI 驱动——工具返回项目文件树,会话 AI 自行定位并阅读配置文件(任意语言/格式),提取候选后弹框确认建连;防幻觉校验(url 必须能被方言解析)
18
+ - **扫描建连**:AI 驱动——工具返回项目文件树,会话 AI 自行定位并阅读配置文件(任意语言/格式),提取候选后弹框确认建连;防幻觉校验(url 必须能被方言解析)+ 来源可信度评级(远端证据 / 环境标识 / 跨环境冲突)提示“能连通 ≠ 属于当前环境”
19
19
  - **安全模型**:只读模式、写确认 + 执行理由、连接级强制只读、家族白名单、管理命令恒拒
20
20
  - **审计**(可选):写操作按天落盘,含理由与完整 SQL
21
21
  - **零构建**:TypeScript 由 pi/tsx 直接加载,core/dialects 零 pi 运行时依赖
@@ -74,6 +74,7 @@ pi install ./packages/db # 本仓库开发者
74
74
  | 家族白名单 | Redis/ES/Mongo 只读白名单;`DROP`、管理 DDL、服务端 JS、`CALL dbms.*` 恒拒 |
75
75
  | 审计日志 | 默认关;`/db config` 开启后写操作按天落盘(完整 SQL,0600) |
76
76
  | 凭据 | 配置文件 `0600`;扫描场景密码经确认框补录(v1.3 起随 AI 阅读进会话,用户知情接受) |
77
+ | 扫描越界 | `path` 相对会话工作目录(`ctx.cwd`)解析,按**真实路径(realpath)**判定:`..` 上跳、绝对路径、仓库内指向子树外的软链(如 `dbconf -> /etc`)一律拒绝;`ctx.cwd` 缺失时 fail-closed,绝不回落到宿主进程目录 |
77
78
 
78
79
  ## ⚙️ 设置
79
80
 
package/docs/USAGE.md CHANGED
@@ -60,13 +60,19 @@
60
60
 
61
61
  **流程**:
62
62
 
63
- 1. AI 调用 `scan_project_configs` 获取项目文件树(path 强制限定当前工作目录子树内,越界拒绝)
64
- 2. AI 从树中挑出可能含连接配置的文件,用自带读文件工具阅读,提取候选(类型/host/端口/库名/账号/密码/连接串)
63
+ 1. AI 调用 `scan_project_configs` 获取项目文件树(path 相对**会话工作目录**(`ctx.cwd`)解析,越界拒绝;判定按**真实路径**(realpath):仓库内指向子树外的软链(如 `dbconf -> /etc`)同样拒绝,扫描不会成为越界的第二个入口)
64
+ 2. AI 先定位**当前生效的环境**(Spring:application.yml/bootstrap.yml + pom.xml 的 profile,Maven `@xxx@` 占位符真值在 `<profile><properties>` 里),再挑出可能含连接配置的文件阅读提取;配置指向配置中心(nacos/apollo)时以**远端为准**(仓库里签入的副本常已过期)
65
65
  3. AI 调用 `db_scan_save` 提交候选——工具逐个弹确认框:确认 → 补录缺字段 → **测试连接通过才写盘**;同名连接三选一(覆盖/改名/跳过)
66
66
  4. 被拒绝的候选(url 与类型矛盾等)AI 会按返回原因修正后重提
67
67
 
68
+ > ⚠️ **“能连通”≠“属于当前环境”**:同一仓库常并存 dev/test/devops/prod/dm 多套配置,连错的库(尤其是废弃库名、其他环境的地址)一样能测通。请核对确认框里的**来源**字段是否对应生效 profile。
69
+
68
70
  **防幻觉校验**(确定性规则,AI 不可绕过):dialectId 必须在支持列表内;带 `url` 的候选必须能被对应方言 `parseUrl` 解析(解析成功后以 URL 为准);host 必填、port 范围 1~65535。
69
71
 
72
+ **来源可信度**(确定性规则,展示用、不拒绝):插件按候选的 `source` 评级并在确认框/结果里展示理由——🟢 source 含**远端检索证据**(远端 URL / `dataId=` / 显式「远端」)/ 🟡 本地文件带环境标识,或**仅自述配置中心产品名**(仓库里签入的副本也会命中产品名,而签入副本会漂移,所以不升级为可信)/ 🔴 无环境标识、或本批次跨多个环境。`testConnection` 只证明库存在、凭据有效,**不证明它属于当前环境**,这一评级就是你在写盘前拦住「连错环境」的那一眼。
73
+
74
+ > 边界说明:评级输入是**模型自述的 source 文本**,不是独立取证(写什么就信什么);它提高的是「误连环境」的被发现概率,不是安全保证。
75
+
70
76
  **结果状态**:✅ 可直接建 / ✏️ 待补字段(确认框中补录)/ ⏭️ 同名已存在。
71
77
 
72
78
  ### 配置中心场景(Nacos / Apollo / Spring Cloud Config)
@@ -83,7 +89,7 @@
83
89
  ```
84
90
  3. 从返回的 YAML 原文提取连接候选 → `db_scan_save` 提交(校验/确认/测试连接照常)
85
91
 
86
- 本地 profile 文件(如 `application-dev.yml`)与配置中心共有的项目,两边都会被提取,重复的连接靠同名查重(⏭️ exists 状态)与确认框兜底。
92
+ 本地 profile 文件(如 `application-dev.yml`)与配置中心共有的项目:**以远端为准**(仓库里签入的副本常已过期),两边若都提取出候选,重复连接靠同名查重(⏭️ exists 状态)与确认框兜底,但冲突值不取本地。
87
93
 
88
94
  **隐私行为变化**(v1.3.0,用户知情接受):配置文件原文(含密码)会随 AI 阅读进入会话上下文;v1.2 及以前"密码只在终端补录、不进模型上下文"的承诺不再适用于扫描场景(查询/连接管理不受影响)。
89
95
 
package/index.ts CHANGED
@@ -5,7 +5,9 @@ import { registry } from "./src/dialects/index.js";
5
5
  import { decide, effectiveReadonly, writeRequiresReason } from "./src/core/policy.js";
6
6
  import { appendAuditLog } from "./src/core/audit.js";
7
7
  import { collectTree } from "./src/core/scan/tree.js";
8
+ import { MAX_DEPTH } from "./src/core/scan/walker.js";
8
9
  import { validateCandidates } from "./src/core/scan/validate.js";
10
+ import { TRUST_GLYPH, TRUST_LABEL } from "./src/core/scan/trust.js";
9
11
  import type { Candidate, ConnConfig, DbTypeId } from "./src/core/types.js";
10
12
  import {
11
13
  loadConfigs, saveConfigs, loadPluginConfig, savePluginConfig, getConfigSummary,
@@ -50,13 +52,21 @@ function buildDisplayList(configs: ConnConfig[]): { list: string[]; map: Map<str
50
52
  // ── 构建「可用数据库」提示(注入系统提示,让 AI 知道有哪些连接) ────
51
53
  // 效率项(Spec §6):每连接只注一行 `名称[家族] + 一行语义`,细节压进系统提示
52
54
 
53
- // 各家族一行语义(KV/搜索/大数据行由后续任务的 dialect.label 接管,此处三库先行)
55
+ // 各家族一行语义:让 AI 一眼知道该类型的 sql 参数填什么(覆盖全部十种类型)
54
56
  function familyHint(c: ConnConfig): string {
55
57
  switch (c.type) {
56
58
  case "postgresql":
57
59
  case "mysql":
58
60
  case "oracle":
61
+ case "dm":
59
62
  return "关系型,sql 参数填 SQL";
63
+ case "redis":
64
+ return "Redis 键值库,sql 参数填单条 Redis 命令(如 GET key / SCAN 0 MATCH user:* COUNT 100;KEYS 已禁用,扫描请用 SCAN)";
65
+ case "elasticsearch":
66
+ return "Elasticsearch 搜索库,sql 参数填 DSL JSON(如 {\"query\":{\"match_all\":{}}};仅支持读端点 _search/_count/_mget)";
67
+ case "hive":
68
+ case "spark":
69
+ return "大数据 SQL 引擎,sql 参数填 SQL(如 SELECT * FROM 库.表 LIMIT 10)";
60
70
  case "mongodb":
61
71
  return "MongoDB 文档库,sql 参数填 JSON 命令信封(如 {\"find\":\"users\",\"filter\":{}};读命令 find/count/distinct/aggregate)";
62
72
  case "neo4j":
@@ -70,7 +80,7 @@ function familyHint(c: ConnConfig): string {
70
80
  const SCAN_TRIGGER_HINT = [
71
81
  "[项目扫描建连]",
72
82
  "当用户说“连一下这个项目的数据库”“帮我把这项目的库配上”“/db scan”等时,走两步 AI 扫描流程:",
73
- "第 1 步:调用 scan_project_configs 获取项目文件树(path 必须在当前工作目录子树内,越界拒绝);",
83
+ "第 1 步:调用 scan_project_configs 获取项目文件树(path 相对会话工作目录 ctx.cwd 解析,按真实路径判定越界,越界拒绝);",
74
84
  "第 2 步:从树中自行判断哪些文件可能包含数据库连接配置(任意语言:Spring/Py/Go/TS/Rust 的 yml/toml/env/ini/json 等都算),用自带读文件工具阅读它们,",
75
85
  " 从中提取连接候选(类型/host/端口/库名/账号/密码),用户说“只要 pg”这类话时只提取对应类型;",
76
86
  "第 3 步:调用 db_scan_save 提交候选——工具会弹确认框,用户逐个确认并补录密码后才写盘,绝不静默建连。",
@@ -126,7 +136,16 @@ const SCAN_GLYPH: Record<Candidate["status"], string> = {
126
136
  function candidateLine(c: Candidate): string {
127
137
  const p = c.partial;
128
138
  const miss = c.missing.length ? `,缺: ${c.missing.join("/")}` : "";
129
- return `${SCAN_GLYPH[c.status]} [${c.status}] ${p.name} (${c.dialectId}) ${p.host ?? ""}:${p.port ?? ""}${p.database ? "/" + p.database : ""}${miss} <- ${c.source}`;
139
+ const trust = `${TRUST_GLYPH[c.trust.level]}${TRUST_LABEL[c.trust.level]}(环境: ${c.trust.env ?? "未知"})`;
140
+ return `${SCAN_GLYPH[c.status]} [${c.status}] ${p.name} (${c.dialectId}) ${p.host ?? ""}:${p.port ?? ""}${p.database ? "/" + p.database : ""}${miss} ${trust} <- ${c.source}`;
141
+ }
142
+
143
+ /** 会话工作目录(信任锚)——扫描与审计都用它,不用 `process.cwd()`。
144
+ * `process.cwd()` 是宿主进程的 OS cwd,pi-web 下是 pi-web 包目录、TUI 下是启动目录,
145
+ * 都与会话 cwd 不同源(见 walker.resolveRoot 注释)。缺失时返回 null,由调用方 fail-closed。 */
146
+ function sessionCwdOf(ctx: any): string | null {
147
+ const cwd = ctx?.cwd;
148
+ return typeof cwd === "string" && cwd ? cwd : null;
130
149
  }
131
150
 
132
151
  // ── 导出扩展 ──────────────────────────────────────
@@ -140,9 +159,12 @@ export default function (pi: ExtensionAPI) {
140
159
  const confirmAndSaveCandidate = async (ctx: any, cand: Candidate): Promise<"saved" | "skipped" | "failed"> => {
141
160
  const p = cand.partial;
142
161
  let name = p.name!;
162
+ // 来源可信度必须出现在确认框里:testConnection 通过之后,唯一能拦住「连错环境」的就是用户这一眼
163
+ const trustBlock = `\n${TRUST_GLYPH[cand.trust.level]} ${TRUST_LABEL[cand.trust.level]}(环境: ${cand.trust.env ?? "未知"})\n`
164
+ + cand.trust.reasons.map((r) => ` · ${r}`).join("\n");
143
165
  const ok = await ctx.ui.confirm(
144
166
  "AI 扫描建连",
145
- `添加 ${name} (${cand.dialectId})?\n${p.host ?? ""}:${p.port ?? ""}${p.database ? "/" + p.database : ""}\n来源: ${cand.source}`,
167
+ `添加 ${name} (${cand.dialectId})?\n${p.host ?? ""}:${p.port ?? ""}${p.database ? "/" + p.database : ""}\n来源: ${cand.source}${trustBlock}`,
146
168
  );
147
169
  if (!ok) return "skipped";
148
170
 
@@ -530,15 +552,15 @@ export default function (pi: ExtensionAPI) {
530
552
  if (result.rows.length > 10) {
531
553
  lines.push(`... 还有 ${result.rows.length - 10} 行`);
532
554
  }
533
- if (result.rows.length > 50) {
555
+ if (result.rows.length > 50 || result.truncated) {
534
556
  try {
535
557
  const exp = writeQueryExport(result.columns ?? [], result.rows, `db-menu-${config.name.replace(/[^\w.-]/g, "_")}`);
536
- lines.push(`完整结果已导出: ${exp.csvPath} | ${exp.jsonPath}`);
558
+ lines.push(`已导出取回的结果 (${result.rows.length} 行): ${exp.csvPath} | ${exp.jsonPath}`);
537
559
  } catch { /* 导出失败不影响主结果 */ }
538
560
  }
539
561
  }
540
562
  if (result.truncated) {
541
- lines.push("(结果已达 maxRows 上限截断)");
563
+ lines.push("(结果已达 maxRows 上限截断,服务端仍有更多数据未取回)");
542
564
  }
543
565
  ctx.ui.notify(lines.join("\n"), "info");
544
566
  } else {
@@ -655,7 +677,7 @@ export default function (pi: ExtensionAPI) {
655
677
  const val = await ctx.ui.select(`选择 ${field.label}`, field.options);
656
678
  if (!val) continue;
657
679
  if (field.valueMap) {
658
- (newCfg as any)[field.key] = field.valueMap[val];
680
+ (newCfg as any)[field.key] = (field.valueMap as Record<string, unknown>)[val];
659
681
  } else {
660
682
  (newCfg as any)[field.key] = val === "是";
661
683
  }
@@ -699,7 +721,11 @@ export default function (pi: ExtensionAPI) {
699
721
  };
700
722
  });
701
723
 
702
- // ── 注册 3 个工具(给 LLM 调用) ──────────────────
724
+ // ── 注册 6 个工具(给 LLM 调用) ──────────────────
725
+ // 注:pi 的 AgentToolResult.details 为必填字段(registerTool 的 TDetails 默认 unknown)。
726
+ // 本插件工具没有结构化详情,故在返回对象里统一显式写 details: undefined——与"完全不提供该字段"
727
+ // 在运行时等价:宿主读取走可选链(result.details?.x)、合并判定用 `!== undefined`,
728
+ // 且 JSON 序列化会跳过 undefined 值。
703
729
 
704
730
  // 解析工具 database 参数:缺省走默认连接(Spec §11.1);无默认则报错指引
705
731
  function resolveTargetDb(name: string | undefined): { config?: ConnConfig; error?: string } {
@@ -735,7 +761,7 @@ export default function (pi: ExtensionAPI) {
735
761
 
736
762
  const target = resolveTargetDb(params.database);
737
763
  if (target.error || !target.config) {
738
- return {
764
+ return { details: undefined,
739
765
  content: [{ type: "text" as const, text: target.error ?? "未找到数据库配置" }],
740
766
  };
741
767
  }
@@ -748,7 +774,7 @@ export default function (pi: ExtensionAPI) {
748
774
  // verdict = dialect.isAllowed(sql, readonly) → decide → deny/confirm/run
749
775
  const dialect = registry.get(config.type);
750
776
  if (!dialect) {
751
- return {
777
+ return { details: undefined,
752
778
  content: [{ type: "text" as const, text: `数据库类型 "${config.type}" 暂不支持。` }],
753
779
  };
754
780
  }
@@ -758,19 +784,19 @@ export default function (pi: ExtensionAPI) {
758
784
  const note = !cfg.ai_readonly && config.forceReadonly === true
759
785
  ? `(连接 ${config.name} 已设置强制只读)`
760
786
  : "";
761
- return {
787
+ return { details: undefined,
762
788
  content: [{ type: "text" as const, text: (verdict.reason ?? "该操作不被允许。如需修改,请执行 /db config 更改配置。") + note }],
763
789
  };
764
790
  }
765
791
  // 写操作强制附执行理由(v1.1 UX 共识 Q1/Q3:与确认策略解耦;缺 reason 拒绝并引导 AI 补充)
766
792
  if (writeRequiresReason(verdict, params.reason)) {
767
- return {
793
+ return { details: undefined,
768
794
  content: [{ type: "text" as const, text: "写操作必须附执行理由:请在 reason 参数中说明动机与影响范围(如\"将status=2的历史订单归档,预计影响1.2万行\"),补充后重试。" }],
769
795
  };
770
796
  }
771
797
  if (action === "confirm") {
772
798
  if (!ctx?.hasUI) {
773
- return {
799
+ return { details: undefined,
774
800
  content: [{ type: "text" as const, text: "当前环境无法弹出确认对话框,已取消 SQL 执行。请在有界面的环境中操作。" }],
775
801
  };
776
802
  }
@@ -779,7 +805,7 @@ export default function (pi: ExtensionAPI) {
779
805
  `理由: ${params.reason}\n\n${verdict.summary ?? ""}\n\n数据库: ${config.name}\n\nSQL:\n${params.sql}`,
780
806
  );
781
807
  if (!ok) {
782
- return {
808
+ return { details: undefined,
783
809
  content: [{ type: "text" as const, text: "用户取消了 SQL 执行。" }],
784
810
  };
785
811
  }
@@ -793,7 +819,7 @@ export default function (pi: ExtensionAPI) {
793
819
  );
794
820
 
795
821
  if (!result.success) {
796
- return {
822
+ return { details: undefined,
797
823
  content: [{ type: "text" as const, text: `查询失败: ${result.error}` }],
798
824
  };
799
825
  }
@@ -803,7 +829,7 @@ export default function (pi: ExtensionAPI) {
803
829
  if (verdict.isWrite && cfg.audit_enabled) {
804
830
  appendAuditLog({
805
831
  time: new Date().toISOString(),
806
- project: process.cwd(),
832
+ project: sessionCwdOf(ctx) ?? "(会话目录未知)",
807
833
  connection: config.name,
808
834
  type: config.type,
809
835
  summary: verdict.summary ?? "",
@@ -823,15 +849,17 @@ export default function (pi: ExtensionAPI) {
823
849
  const rows = result.rows.slice(0, 50).map((r) => r.join(" | "));
824
850
  text += [header, separator, ...rows].join("\n");
825
851
  if (result.rows.length > 50) {
826
- // P0 导出:长结果落盘 /tmp(CSV+JSON),只回路径不贴全量
827
- text += `\n... 还有 ${result.rows.length - 50} 行`;
852
+ text += `\n... 还有 ${result.rows.length - 50} 行(本次共取回 ${result.rows.length} 行)`;
853
+ }
854
+ // 取回行数超展示上限,或被 maxRows 截断时落盘 /tmp(CSV+JSON),只回路径不贴全量
855
+ if (result.rows.length > 50 || result.truncated) {
828
856
  try {
829
857
  const exp = writeQueryExport(result.columns ?? [], result.rows, `db-query-${config.name.replace(/[^\w.-]/g, "_")}`);
830
- text += `\n完整结果已导出: ${exp.csvPath} | ${exp.jsonPath}`;
858
+ text += `\n已导出取回的结果 (${result.rows.length} 行): ${exp.csvPath} | ${exp.jsonPath}`;
831
859
  } catch { /* 导出失败不影响主结果 */ }
832
860
  }
833
861
  if (result.truncated) {
834
- text += `\n(结果已达 maxRows 上限截断)`;
862
+ text += `\n(结果已达 maxRows 上限截断,服务端仍有更多数据未取回)`;
835
863
  }
836
864
  } else {
837
865
  text += "无数据返回。";
@@ -841,7 +869,7 @@ export default function (pi: ExtensionAPI) {
841
869
  text += `\n执行理由: ${params.reason}`;
842
870
  }
843
871
 
844
- return { content: [{ type: "text" as const, text }] };
872
+ return { details: undefined, content: [{ type: "text" as const, text }] };
845
873
  },
846
874
  });
847
875
 
@@ -858,7 +886,7 @@ export default function (pi: ExtensionAPI) {
858
886
  async execute(_toolCallId: string, params: { database?: string; pattern?: string }, _signal: any) {
859
887
  const target = resolveTargetDb(params.database);
860
888
  if (target.error || !target.config) {
861
- return {
889
+ return { details: undefined,
862
890
  content: [{ type: "text" as const, text: target.error ?? "未找到数据库配置" }],
863
891
  };
864
892
  }
@@ -866,14 +894,14 @@ export default function (pi: ExtensionAPI) {
866
894
 
867
895
  const dialect = registry.get(config.type);
868
896
  if (!dialect) {
869
- return {
897
+ return { details: undefined,
870
898
  content: [{ type: "text" as const, text: `数据库类型 "${config.type}" 暂不支持。` }],
871
899
  };
872
900
  }
873
901
  const result = await dialect.listTables(toRuntimeConfig(config, dialect.defaultPort), params.pattern);
874
902
 
875
903
  if (!result.success || !result.tables) {
876
- return {
904
+ return { details: undefined,
877
905
  content: [{ type: "text" as const, text: `获取表列表失败: ${result.error}` }],
878
906
  };
879
907
  }
@@ -885,7 +913,7 @@ export default function (pi: ExtensionAPI) {
885
913
  return `${schema}${t.name} (${t.type})${desc}`;
886
914
  });
887
915
 
888
- return {
916
+ return { details: undefined,
889
917
  content: [{ type: "text" as const, text: `数据库 "${config.name}"${params.pattern ? `(pattern: ${params.pattern})` : ""} 共 ${result.count} 张表:\n${lines.join("\n")}` }],
890
918
  };
891
919
  },
@@ -904,7 +932,7 @@ export default function (pi: ExtensionAPI) {
904
932
  async execute(_toolCallId: string, params: { database?: string; table: string }, _signal: any) {
905
933
  const target = resolveTargetDb(params.database);
906
934
  if (target.error || !target.config) {
907
- return {
935
+ return { details: undefined,
908
936
  content: [{ type: "text" as const, text: target.error ?? "未找到数据库配置" }],
909
937
  };
910
938
  }
@@ -912,14 +940,14 @@ export default function (pi: ExtensionAPI) {
912
940
 
913
941
  const dialect = registry.get(config.type);
914
942
  if (!dialect) {
915
- return {
943
+ return { details: undefined,
916
944
  content: [{ type: "text" as const, text: `数据库类型 "${config.type}" 暂不支持。` }],
917
945
  };
918
946
  }
919
947
  const result = await dialect.describeTable(toRuntimeConfig(config, dialect.defaultPort), params.table);
920
948
 
921
949
  if (!result.success || !result.columns) {
922
- return {
950
+ return { details: undefined,
923
951
  content: [{ type: "text" as const, text: `获取表结构失败: ${result.error}` }],
924
952
  };
925
953
  }
@@ -935,7 +963,7 @@ export default function (pi: ExtensionAPI) {
935
963
  );
936
964
  }
937
965
 
938
- return {
966
+ return { details: undefined,
939
967
  content: [{ type: "text" as const, text: lines.join("\n") }],
940
968
  };
941
969
  },
@@ -945,30 +973,44 @@ export default function (pi: ExtensionAPI) {
945
973
  pi.registerTool({
946
974
  name: "scan_project_configs",
947
975
  label: "扫描项目数据库配置",
948
- description: "获取项目文件树,供 AI 自行定位并阅读数据库连接配置文件(任意语言:Spring/Py/Go/TS/Rust 的 yml/toml/env/ini/json 等均覆盖)。本身不做提取;提取结果用 db_scan_save 提交。",
949
- promptSnippet: "当用户说「连一下这个项目的数据库」「帮我把这项目的库配上」「/db scan 配置pg」等时调用第 1 步。返回项目文件树后:自己判断哪些文件可能含数据库连接配置(用户指了类型就只找该类型),用自带读文件工具阅读,提取连接候选(类型/host/端口/库名/账号/密码),然后调用 db_scan_save 提交。path 必须在当前工作目录子树内。",
976
+ description: "获取项目文件树,供 AI 自行定位并阅读数据库连接配置文件(任意语言:Spring/Py/Go/TS/Rust 的 yml/toml/env/ini/json 等均覆盖)。本身不做提取;提取结果用 db_scan_save 提交。path 相对会话工作目录(ctx.cwd)解析,按真实路径(realpath)强制限定在该子树内。",
977
+ promptSnippet: "当用户说「连一下这个项目的数据库」「帮我把这项目的库配上」「/db scan 配置pg」等时调用第 1 步。返回项目文件树后:自己判断哪些文件可能含数据库连接配置(用户指了类型就只找该类型),用自带读文件工具阅读,提取连接候选(类型/host/端口/库名/账号/密码),然后调用 db_scan_save 提交。path 相对会话工作目录解析,必须在会话工作目录子树内。",
978
+ promptGuidelines: [
979
+ "扫描建连(scan_project_configs → db_scan_save)先定位「当前生效的环境」再取配置:Spring 项目读 application.yml/bootstrap.yml 与 pom.xml 的 profiles(Maven 的 @xxx@ 占位符真值在 pom.xml 的 <profile><properties> 里);同仓库常并存 dev/test/devops/prod/dm 多套配置,读错 profile 会拿到废弃库。",
980
+ "配置指向配置中心(nacos/apollo/spring-cloud-config)时以远端为准:仓库里签入的 *_配置副本.yaml 常已过期,远端与本地冲突时用远端值;每个候选的 source 写清「生效 profile + 来源文件/dataId」。",
981
+ "能连通只证明库存在且凭据有效,不等于它属于当前环境:来源可疑(非生效 profile、环境无法确定)的候选不要提交,先向用户确认。插件按确定性规则给每个候选评级并随工具返回(🟢 source 含远端证据 URL/dataId、🟡 本地文件带环境标识或仅自述配置中心、🔴 无环境标识或本批跨环境冲突),汇报结果时必须如实转述评级与理由,不得把 🟡/🔴 说成已核实;从远端拉的候选请在 source 里写明 dataId 或 URL。",
982
+ ],
950
983
  parameters: Type.Object({
951
- path: Type.Optional(Type.String({ description: "扫描根目录,缺省为当前工作目录;强制限定在当前工作目录子树内,越界拒绝" })),
984
+ path: Type.Optional(Type.String({ description: "扫描根目录,缺省为会话工作目录;强制限定在会话工作目录子树内(按真实路径判定,指向子树外的软链同样被拒,越界拒绝)" })),
952
985
  }),
953
- async execute(_toolCallId: string, params: { path?: string }, _signal: any) {
986
+ async execute(_toolCallId: string, params: { path?: string }, _signal: any, _onUpdate?: any, ctx?: any) {
987
+ // 锚点取会话 cwd(ctx.cwd)。缺失时 **fail-closed**:用 process.cwd() 兜底等于把红线
988
+ // 钉在宿主目录上(pi-web 下就是 pi-web 包目录),正是本版修掉的错锚点
989
+ const anchor = sessionCwdOf(ctx);
990
+ if (!anchor) {
991
+ return { details: undefined,
992
+ content: [{ type: "text" as const, text: "扫描失败: 无法获取会话工作目录(ctx.cwd 缺失)——拒绝在未知锚点下扫描。" }],
993
+ };
994
+ }
954
995
  try {
955
- const tree = collectTree(params.path ?? ".");
996
+ const tree = collectTree(params.path ?? ".", anchor);
956
997
  const text = [
957
998
  `项目根目录: ${tree.root}(文件总数 ${tree.total}${tree.truncated ? ",已截断展示前 " + tree.lines.length + " 条" : ""})`,
999
+ ...(tree.depthCapped ? [`⚠️ 文件树可能不完整:有目录未展开(超过 ${MAX_DEPTH} 层或目录数上限)——可把更深的子目录作为 path 再扫一次`] : []),
958
1000
  "",
959
1001
  "文件树(相对路径,浅层优先):",
960
1002
  ...tree.lines,
961
1003
  "",
962
1004
  "下一步(必须执行):",
963
- "1. 从文件树中判断哪些文件可能包含数据库连接配置——任意语言/格式都算:Spring application*.yml、Python settings/config.py、Go config.yaml、Node .env、Rust .env+config.toml、docker-compose、K8s manifest 等;",
964
- "2. 用你的读文件工具阅读这些文件;若发现 bootstrap.yml/application.yml 指向配置中心(nacos/apollo/spring-cloud-config),用配置中心地址与凭据拉取远端配置后再提取——例如 Nacos:先用 POST {server}/nacos/v1/auth/login(form: username/password)拿 accessToken,再 GET {server}/nacos/v1/cs/configs?dataId=<服务名>.yaml&group=<group>&tenant=<namespace-id>&accessToken=<token>,返回的 YAML 原文同样提取候选;",
965
- "3. 从本地/远端配置中提取连接候选:dialectId(postgresql/mysql/oracle/dm/redis/elasticsearch/mongodb/neo4j/hive/spark)、host、port、database、username、password、url(如有完整连接串)、source(来源文件或 dataId);用户指定了类型(如「配置pg」)就只提取该类型;",
1005
+ "1. 先定位「当前生效的环境」,再看文件:Spring 项目看 application.yml/bootstrap.yml 与 pom.xml 的 profiles(Maven 的 @xxx@ 占位符真值在 pom.xml 的 <profile><properties> 里),确定生效 profile(如 devops)与生效配置文件(如 application-devops.yml)——同仓库常并存 dev/test/devops/prod/dm 多套配置,读错 profile 会拿到废弃库;再判断哪些文件可能包含数据库连接配置——任意语言/格式都算:Spring application*.yml、Python settings/config.py、Go config.yaml、Node .env、Rust .env+config.toml、docker-compose、K8s manifest 等;",
1006
+ "2. 用你的读文件工具阅读生效 profile 的文件;若 bootstrap.yml/application.yml 指向配置中心(nacos/apollo/spring-cloud-config),必须以远端配置为准(仓库里签入的副本常已过期)——例如 Nacos:先用 POST {server}/nacos/v1/auth/login(form: username/password)拿 accessToken,再 GET {server}/nacos/v1/cs/configs?dataId=<服务名>.yaml&group=<group>&tenant=<namespace-id>&accessToken=<token>;远端与本地冲突时以远端为准;",
1007
+ "3. 从生效配置中提取连接候选:dialectId(postgresql/mysql/oracle/dm/redis/elasticsearch/mongodb/neo4j/hive/spark)、host、port、database、username、password、url(如有完整连接串)、source(写清「生效 profile + 来源文件/dataId」,如 pom.xml#devops → application-devops.yml);用户指定了类型(如「配置pg」)就只提取该类型;来源可疑(非生效 profile、环境无法确定)的候选不要提交,先向用户确认;",
966
1008
  "4. 调用 db_scan_save 提交候选数组。带 url 的候选必须能被对应方言解析,编造的 url 会被拒绝;",
967
1009
  "5. 工具会弹确认框让用户逐个确认并补录密码,把工具返回的保存结果汇报给用户。",
968
1010
  ].join("\n");
969
- return { content: [{ type: "text" as const, text }] };
1011
+ return { details: undefined, content: [{ type: "text" as const, text }] };
970
1012
  } catch (err) {
971
- return {
1013
+ return { details: undefined,
972
1014
  content: [{ type: "text" as const, text: `扫描失败: ${err instanceof Error ? err.message : String(err)}` }],
973
1015
  };
974
1016
  }
@@ -991,12 +1033,12 @@ export default function (pi: ExtensionAPI) {
991
1033
  password: Type.Optional(Type.String({ description: "密码(缺失时确认框会引导用户补录)" })),
992
1034
  url: Type.Optional(Type.String({ description: "完整连接串(可选,提供时必须能被方言解析,否则整条拒绝)" })),
993
1035
  name: Type.Optional(Type.String({ description: "连接名称(缺省自动生成)" })),
994
- source: Type.Optional(Type.String({ description: "来源描述,如配置文件相对路径" })),
1036
+ source: Type.Optional(Type.String({ description: "来源描述。本地文件写「生效 profile + 来源文件」,如 pom.xml#devops → application-devops.yml;从配置中心拉的必须写远端证据(远端 URL 或 dataId=),否则只能评为 🟡。插件按此评级并在确认框展示,缺失/无环境标识评为 🔴" })),
995
1037
  }), { description: "连接候选数组" }),
996
1038
  }),
997
1039
  async execute(_toolCallId: string, params: { candidates: unknown[] }, _signal: any, _onUpdate?: any, ctx?: any) {
998
1040
  if (!ctx?.ui) {
999
- return { content: [{ type: "text" as const, text: "db_scan_save 需要交互式会话(ctx.ui 不可用)。请引导用户在终端执行 /db add 手动添加。" }] };
1041
+ return { details: undefined, content: [{ type: "text" as const, text: "db_scan_save 需要交互式会话(ctx.ui 不可用)。请引导用户在终端执行 /db add 手动添加。" }] };
1000
1042
  }
1001
1043
  const { candidates, rejected } = validateCandidates(params.candidates, new Set(loadConfigs().map((c) => c.name)));
1002
1044
  const lines: string[] = [];
@@ -1006,20 +1048,23 @@ export default function (pi: ExtensionAPI) {
1006
1048
  if (candidates.length === 0) {
1007
1049
  lines.push(rejected.length > 0 ? "没有可保存的候选。" : "未收到有效候选(candidates 需为数组,每项含 dialectId+host)。",
1008
1050
  "提示:从配置文件原文提取后重试;确认方言取值在支持列表内。");
1009
- return { content: [{ type: "text" as const, text: lines.join("\n") }] };
1051
+ return { details: undefined, content: [{ type: "text" as const, text: lines.join("\n") }] };
1010
1052
  }
1011
1053
  lines.push(`收到 ${candidates.length} 个连接候选,逐个确认:`, ...candidates.map(candidateLine), "");
1012
1054
  const results: string[] = [];
1013
1055
  for (const cand of candidates) {
1014
1056
  try {
1015
1057
  const r = await confirmAndSaveCandidate(ctx, cand);
1016
- results.push(`${SCAN_GLYPH[cand.status]} ${cand.partial.name} (${cand.dialectId}): ${r === "saved" ? "已保存" : r === "skipped" ? "用户跳过" : "连接失败未保存"}`);
1058
+ const trustLine = `${TRUST_GLYPH[cand.trust.level]}${TRUST_LABEL[cand.trust.level]}(环境: ${cand.trust.env ?? "未知"})`;
1059
+ // 理由一并回传:promptGuidelines 要求模型如实转述评级与理由,模型看不到就无从转述
1060
+ const why = cand.trust.reasons.length ? `\n ${cand.trust.reasons.join(";")}` : "";
1061
+ results.push(`${SCAN_GLYPH[cand.status]} ${cand.partial.name} (${cand.dialectId}): ${r === "saved" ? "已保存" : r === "skipped" ? "用户跳过" : "连接失败未保存"} ${trustLine}${why}`);
1017
1062
  } catch (err) {
1018
1063
  results.push(`❌ ${cand.partial.name} (${cand.dialectId}): 异常 ${err instanceof Error ? err.message : String(err)}`);
1019
1064
  }
1020
1065
  }
1021
1066
  lines.push("", "保存结果:", ...results.map((r) => " - " + r));
1022
- return { content: [{ type: "text" as const, text: lines.join("\n") }] };
1067
+ return { details: undefined, content: [{ type: "text" as const, text: lines.join("\n") }] };
1023
1068
  },
1024
1069
  });
1025
1070
 
@@ -1033,7 +1078,7 @@ export default function (pi: ExtensionAPI) {
1033
1078
  async execute() {
1034
1079
  const configs = loadConfigs();
1035
1080
  if (configs.length === 0) {
1036
- return {
1081
+ return { details: undefined,
1037
1082
  content: [{ type: "text" as const, text: "尚无数据库连接。请引导用户在终端执行 /db add 或 /db scan 建连。" }],
1038
1083
  };
1039
1084
  }
@@ -1046,7 +1091,7 @@ export default function (pi: ExtensionAPI) {
1046
1091
  const url = registry.get(c.type)?.displayUrl(c) ?? "";
1047
1092
  return `- ${c.name} [${shortTypeLabel(c.type)}]${marks ? " " + marks : ""} - ${fullTypeLabel(c.type)}${c.database ? " · 库: " + c.database : ""} · ${url} · ${test}${used ? " · " + used : ""}${c.description ? " · " + c.description : ""}`;
1048
1093
  });
1049
- return {
1094
+ return { details: undefined,
1050
1095
  content: [{ type: "text" as const, text: `共 ${configs.length} 个连接:\n${lines.join("\n")}\n\n标注 [prod·强制只读] 的连接永远只读(即使全局允许写操作)。` }],
1051
1096
  };
1052
1097
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nsyan/db",
3
- "version": "1.3.0",
3
+ "version": "1.3.2",
4
4
  "description": "AI 数据库接入扩展 for pi —— 达梦(DM)/PostgreSQL/MySQL/Oracle/Redis/Elasticsearch/MongoDB/Neo4j/Hive/Spark 十种数据库,六大家族方言,提供查询/表结构/扫描建连/连接清单 LLM 工具 | Database access extension for the pi coding agent: query, schema browsing, connection scanning and audit tools for PostgreSQL, MySQL, Oracle, DM (Dameng 达梦), Redis, Elasticsearch, MongoDB, Neo4j, Hive and Spark",
5
5
  "keywords": [
6
6
  "pi-extension",
@@ -60,14 +60,17 @@
60
60
  "mysql2": "^3.23.1",
61
61
  "neo4j-driver": "^5.28.3",
62
62
  "oracledb": "^7.0.1",
63
- "neo4j-driver": "^5.28.3",
64
63
  "pg": "^8.22.0"
65
64
  },
66
65
  "devDependencies": {
66
+ "@earendil-works/pi-coding-agent": "^0.85.1",
67
+ "@types/node": "^22.20.2",
67
68
  "@types/pg": "^8.20.0",
68
- "tsx": "^4.23.13"
69
+ "tsx": "^4.23.13",
70
+ "typescript": "^7.0.2"
69
71
  },
70
72
  "scripts": {
71
- "test": "tsx --test \"test/**/*.test.ts\""
73
+ "test": "tsx --test \"test/**/*.test.ts\"",
74
+ "typecheck": "tsc -p tsconfig.typecheck.json"
72
75
  }
73
76
  }
package/src/config.ts CHANGED
@@ -274,11 +274,26 @@ export function toRuntimeConfig(c: ConnConfig, defaultPort: number): ConnConfig
274
274
  };
275
275
  }
276
276
 
277
- // 供 UI 层展示类型短标签(原 index.ts 内 5 处重复映射的收敛点之一)
277
+ // 供 UI 层展示类型标签(原 index.ts 内 5 处重复映射的收敛点之一)
278
+ // 单一数据源:短标签与全称同表维护。键类型用 Record<DbTypeId, ...> 而非 Record<string, ...>,
279
+ // 新增方言而漏补标签时会在类型检查阶段报错,不再静默 fallback 成原始 id。
280
+ const TYPE_LABELS: Record<DbTypeId, { short: string; full: string }> = {
281
+ postgresql: { short: "PG", full: "PostgreSQL" },
282
+ mysql: { short: "MySQL", full: "MySQL" },
283
+ oracle: { short: "Oracle", full: "Oracle" },
284
+ dm: { short: "DM", full: "达梦" },
285
+ redis: { short: "Redis", full: "Redis" },
286
+ elasticsearch: { short: "ES", full: "Elasticsearch" },
287
+ mongodb: { short: "MongoDB", full: "MongoDB" },
288
+ neo4j: { short: "Neo4j", full: "Neo4j" },
289
+ hive: { short: "Hive", full: "Hive" },
290
+ spark: { short: "Spark", full: "Spark" },
291
+ };
292
+
278
293
  export function shortTypeLabel(type: DbTypeId): string {
279
- return ({ postgresql: "PG", mysql: "MySQL", oracle: "Oracle", mongodb: "MongoDB", neo4j: "Neo4j" } as Record<string, string>)[type] ?? type;
294
+ return TYPE_LABELS[type]?.short ?? type;
280
295
  }
281
296
 
282
297
  export function fullTypeLabel(type: DbTypeId): string {
283
- return ({ postgresql: "PostgreSQL", mysql: "MySQL", oracle: "Oracle", mongodb: "MongoDB", neo4j: "Neo4j" } as Record<string, string>)[type] ?? type;
298
+ return TYPE_LABELS[type]?.full ?? type;
284
299
  }
package/src/core/audit.ts CHANGED
@@ -12,7 +12,7 @@ export const AUDIT_DIR = join(homedir(), ".pi", "agent", "db-audit");
12
12
  export interface AuditEntry {
13
13
  /** ISO 时间 */
14
14
  time: string;
15
- /** 执行时的项目路径(process.cwd()),跨项目区分用 */
15
+ /** 执行时的项目路径(会话 cwd = ctx.cwd),跨项目区分用 */
16
16
  project: string;
17
17
  /** 连接名 */
18
18
  connection: string;
@@ -10,15 +10,18 @@ export interface TreeResult {
10
10
  root: string; // 绝对路径
11
11
  total: number; // 收集到的文件总数
12
12
  truncated: boolean; // 是否超出 MAX_TREE_LINES 被截断
13
+ depthCapped: boolean; // 是否有目录未展开(超 MAX_DEPTH 或目录预算;如实上报,避免静默漏扫)
13
14
  lines: string[]; // 相对路径清单(已排序:浅层优先)
14
15
  }
15
16
 
16
17
  const MAX_TREE_LINES = 3000;
17
18
 
18
- /** 收集项目文件树(相对路径排序清单);越界由 resolveRoot 拒绝 */
19
- export function collectTree(rootInput: string): TreeResult {
20
- const root = resolveRoot(rootInput);
21
- const files = walk(root);
19
+ /** 收集项目文件树(相对路径排序清单);越界由 resolveRoot 拒绝。
20
+ * anchor 传会话工作目录(`ctx.cwd`),勿传 `process.cwd()`,原因见 walker.resolveRoot。
21
+ * 输出确定:walk 对目录项排序,本函数再按「浅层优先 + 字典序」排序,同一项目每次结果一致 */
22
+ export function collectTree(rootInput: string, anchor: string): TreeResult {
23
+ const root = resolveRoot(rootInput, anchor);
24
+ const { files, depthCapped } = walk(root);
22
25
  const rels = files
23
26
  .map((f) => f.slice(root.length + 1).split("\\").join("/"))
24
27
  .sort((a, b) => {
@@ -29,6 +32,7 @@ export function collectTree(rootInput: string): TreeResult {
29
32
  root,
30
33
  total: rels.length,
31
34
  truncated: rels.length > MAX_TREE_LINES,
35
+ depthCapped,
32
36
  lines: rels.slice(0, MAX_TREE_LINES),
33
37
  };
34
38
  }
@@ -0,0 +1,86 @@
1
+ // scan/trust.ts —— 候选「来源可信度」评估(确定性规则,纯函数,可单测)
2
+ // 动机:db_scan_save 唯一的客观闸门是 testConnection,而「能连通」只证明库存在、凭据有效,
3
+ // 不证明它属于当前环境。同仓库常并存 dev/test/devops/prod/dm 多套配置,签入的配置中心
4
+ // 副本又会随时间漂移,模型很容易提取到废弃库名或别的环境地址——而且照样能测通。
5
+ // 本模块不判断配置对不对(无确定解),只把「来源能证明什么」分级、在确认框里显式展示,
6
+ // 把环境判断交回用户,而不是让它静默通过。
7
+ //
8
+ // 边界(必须明说):评级输入是**模型自述的 `source` 文本**,不是独立取证——写什么就信什么。
9
+ // 它抬高的是「误连环境」的被发现概率(用户在确认框多看一眼),不构成安全保证;
10
+ // 真正的闸门只有 testConnection 与用户确认。
11
+ // 因此 🟢 要求 source 里有**远端检索证据**(URL / dataId / 显式「远端」);仅出现产品名
12
+ // (nacos/apollo/…)不升级——仓库里签入的同名副本会命中,而那恰恰是会漂移的东西。
13
+
14
+ export type TrustLevel = "high" | "medium" | "low";
15
+
16
+ export interface SourceTrust {
17
+ level: TrustLevel;
18
+ /** 从 source 识别出的环境标识(local/dev/test/devops/prod…),识别不出为 null */
19
+ env: string | null;
20
+ /** 展示给用户的中文理由:只陈述来源能证明什么,不含结论 */
21
+ reasons: string[];
22
+ }
23
+
24
+ /** 远端检索证据:只有真的去远端拉过才会写进 source 的标记(URL / Nacos OpenAPI / dataId= / 显式「远端」) */
25
+ const REMOTE_EVIDENCE = /https?:\/\/|dataid\s*=|cs\/configs|(?:远端|远程|remote)/i;
26
+
27
+ /** 配置中心产品名:仓库里签入的副本文件名同样命中,不能单独证明「取自远端」 */
28
+ const CONFIG_CENTER_NAME = /\bnacos\b|\bapollo\b|\bconsul\b|config[-_ ]?center|config[-_ ]?server|spring-cloud-config/i;
29
+
30
+ /** 环境标识白名单:按分隔符整段比对,避免 "dev" 命中 "devops" */
31
+ const ENV_TOKENS = new Set([
32
+ "local", "devops", "dev", "sit", "test", "uat", "stage", "staging", "pre", "gray", "prod", "release", "dm",
33
+ ]);
34
+
35
+ const PROD_TOKENS = new Set(["prod", "release"]);
36
+
37
+ /** 从 source 文本取环境段(按 . _ - / \ # 空格 = 切分后整段匹配;切 `=` 使 `group=devops` 也能识别) */
38
+ export function envOf(source: string): string | null {
39
+ for (const tok of source.toLowerCase().split(/[._\-/\\#\s=]+/)) {
40
+ if (ENV_TOKENS.has(tok)) return tok;
41
+ }
42
+ return null;
43
+ }
44
+
45
+ /**
46
+ * 批量评估来源可信度(跨候选的环境冲突必须看整批,故入口是数组)。
47
+ * @param sources 与候选一一对应的 source 文本(空串表示未提供)
48
+ */
49
+ export function assessSourceTrust(sources: readonly string[]): SourceTrust[] {
50
+ const envs = sources.map(envOf);
51
+ const distinct = [...new Set(envs.filter((e): e is string => e !== null))];
52
+
53
+ return sources.map((source, i) => {
54
+ const reasons: string[] = [];
55
+ const env = envs[i];
56
+ let level: TrustLevel;
57
+
58
+ if (REMOTE_EVIDENCE.test(source)) {
59
+ level = "high";
60
+ reasons.push("来源含远端检索证据(URL / dataId / 显式「远端」):远端配置是运行时真值");
61
+ } else if (CONFIG_CENTER_NAME.test(source)) {
62
+ level = "medium";
63
+ reasons.push("来源自述为配置中心但未写远端证据(URL / dataId):请确认它取自远端接口,而不是仓库里签入的副本——签入的副本常已过期");
64
+ } else if (env === null) {
65
+ level = "low";
66
+ reasons.push("来源无环境标识:同仓库常并存多套环境配置,且签入的配置中心副本可能已过期,无法判断它是否属于当前环境");
67
+ } else {
68
+ level = "medium";
69
+ reasons.push(`来源为本地文件、环境标识「${env}」:请确认它就是当前生效 profile(Maven 的 @xxx@ 占位符真值在 pom.xml 的 <profile><properties> 里)`);
70
+ }
71
+
72
+ // 一批里出现多个环境,说明至少有一部分候选不属于当前环境,必须让用户定夺
73
+ if (distinct.length > 1 && env !== null) {
74
+ level = "low";
75
+ reasons.unshift(`本批次候选来自多个环境(${distinct.join("、")}):请确认当前生效的是哪一个,只取生效环境的库`);
76
+ }
77
+
78
+ if (env !== null && PROD_TOKENS.has(env)) {
79
+ reasons.push("疑似生产环境:保存后建议用 /db 编辑该连接、把环境标签设为 prod(会启用强制只读)");
80
+ }
81
+ return { level, env, reasons };
82
+ });
83
+ }
84
+
85
+ export const TRUST_GLYPH: Record<TrustLevel, string> = { high: "🟢", medium: "🟡", low: "🔴" };
86
+ export const TRUST_LABEL: Record<TrustLevel, string> = { high: "来源可信", medium: "来源待核", low: "来源存疑" };
@@ -6,7 +6,8 @@
6
6
 
7
7
  import { basename } from "node:path";
8
8
  import { registry } from "../../dialects/index.js";
9
- import type { Candidate, CandidateInput, CandidateStatus, DbTypeId } from "../types.js";
9
+ import { assessSourceTrust } from "./trust.js";
10
+ import type { Candidate, CandidateInput, CandidateStatus, ConnConfig, DbTypeId } from "../types.js";
10
11
 
11
12
  /** 各类型建连必需字段(空串同样视为缺失)——家族语义,非正则,保留 */
12
13
  const REQUIRED: Record<DbTypeId, string[]> = {
@@ -37,6 +38,13 @@ export function validateCandidates(raw: unknown, existingNames: Set<string>): Va
37
38
  const candidates: Candidate[] = [];
38
39
  const rejected: string[] = [];
39
40
  const seenNames = new Set(existingNames);
41
+ // 来源可信度:跨候选的环境冲突要看整批,故先批量评估,再按原始下标挂到候选上
42
+ const trusts = assessSourceTrust(
43
+ list.map((item) =>
44
+ item && typeof item === "object" && nonEmpty((item as Record<string, unknown>).source)
45
+ ? String((item as Record<string, unknown>).source)
46
+ : ""),
47
+ );
40
48
 
41
49
  for (const [i, item] of list.entries()) {
42
50
  if (item === null || typeof item !== "object") {
@@ -54,7 +62,9 @@ export function validateCandidates(raw: unknown, existingNames: Set<string>): Va
54
62
 
55
63
  // ② 带 url 时:方言 parseUrl 必须能解析(防幻觉——claim 与 url 矛盾整条拒)
56
64
  // 解析成功时以解析结果为准(host/port/database/username/password 由 URL 补全)
57
- let bag: Partial<CandidateInput> = {
65
+ // 归一化后 port/dbIndex 恒为 number(下行 parseInt 收敛)。此处用 Partial<ConnConfig> 而非
66
+ // Partial<CandidateInput>——后者的 port 是 number|string,会让下方端口范围校验退化为字符串比较。
67
+ let bag: Partial<ConnConfig> = {
58
68
  host: typeof c.host === "string" ? c.host.trim() : undefined,
59
69
  port: typeof c.port === "number" ? c.port : parseInt(String(c.port ?? ""), 10) || undefined,
60
70
  username: nonEmpty(c.username) ? String(c.username) : undefined,
@@ -90,7 +100,7 @@ export function validateCandidates(raw: unknown, existingNames: Set<string>): Va
90
100
  }
91
101
 
92
102
  // ④ 缺字段判定
93
- const missing = (REQUIRED[dialect.id] ?? []).filter((f) => !nonEmpty(bag[f as keyof CandidateInput]));
103
+ const missing = (REQUIRED[dialect.id] ?? []).filter((f) => !nonEmpty(bag[f as keyof ConnConfig]));
94
104
 
95
105
  // ⑤ 命名:显式 name > 默认;同名(含与本批前序候选撞名)→ exists 状态
96
106
  let name = nonEmpty(c.name) ? String(c.name).trim()
@@ -104,7 +114,8 @@ export function validateCandidates(raw: unknown, existingNames: Set<string>): Va
104
114
  dialectId: dialect.id,
105
115
  partial: { name, ...bag } as Partial<import("../types.js").ConnConfig>,
106
116
  missing,
107
- source: nonEmpty(c.source) ? String(c.source) : "AI 提取",
117
+ source: nonEmpty(c.source) ? String(c.source) : "AI 提取(未提供来源)",
118
+ trust: trusts[i],
108
119
  warnings: Array.isArray(c.warnings) ? c.warnings.map(String).slice(0, 3) : undefined,
109
120
  });
110
121
  }
@@ -1,14 +1,23 @@
1
1
  // scan/walker.ts —— 目录漫步:默认忽略常见无关目录;resolveRoot 越界拒绝(Spec §8.5 红线)
2
- import { readdirSync, statSync, type Dirent } from "node:fs";
3
- import { extname, isAbsolute, join, relative, resolve } from "node:path";
2
+ import { readdirSync, realpathSync, statSync, type Dirent } from "node:fs";
3
+ import { basename, extname, isAbsolute, join, relative, resolve, sep } from "node:path";
4
4
 
5
+ // 忽略的目录:只列**确定无关**的(依赖/构建产物/版本控制元数据/工具缓存)。
6
+ // 注意 docs 不在此列:本模块不对「目录名是否与配置有关」做猜测(见 tree.ts 设计注释),
7
+ // 配置样例/真实配置放 docs 下的项目是存在的,漏扫比多扫代价大。
5
8
  const IGNORED_DIRS = new Set([
6
- "node_modules", ".git", "target", "dist", "venv", "logs", "docs",
7
- "build", "coverage", "__pycache__", ".idea", ".vscode",
9
+ "node_modules", ".git", ".svn", ".hg", "target", "dist", "venv", ".venv", "logs",
10
+ "build", "coverage", "__pycache__", ".idea", ".vscode", ".next", ".nuxt", ".cache",
11
+ ".gradle", ".tox", ".pytest_cache", ".mypy_cache", ".turbo", ".parcel-cache", ".sass-cache",
8
12
  ]);
9
13
 
10
14
  const MAX_FILES = 2000;
11
- const MAX_DEPTH = 8;
15
+ /** 目录访问预算:软链别名会重复走到同一个真实目录(见 walk 的断环说明),
16
+ * 需要一个上界防止病态目录图把扫描拖死;超限经 depthCapped 上报,不静默。 */
17
+ const MAX_DIRS = 20000;
18
+ /** 深度上限:Java 多模块可达 `m/src/main/java/com/x/.../service/mapper/impl`(实测 14 层)、
19
+ * monorepo 嵌套更深;超限会通过 depthCapped 如实上报。导出供工具提示引用,避免硬编码层数漂移。 */
20
+ export const MAX_DEPTH = 16;
12
21
 
13
22
  /** 扫描目标配置文件:不受普通文件配额挤占(大型 Java 工程源码文件可轻易冲爆 MAX_FILES) */
14
23
  const CONFIG_EXTS = new Set([".yml", ".yaml", ".properties"]);
@@ -17,47 +26,117 @@ function isConfigFile(name: string): boolean {
17
26
  return base === ".env" || base.startsWith(".env.") || CONFIG_EXTS.has(extname(base));
18
27
  }
19
28
 
29
+ /** target 是否在 base 子树内(纯路径判定,用于拦住软链逃逸) */
30
+ function within(base: string, target: string): boolean {
31
+ const rel = relative(base, target);
32
+ return rel === "" || (rel !== ".." && !rel.startsWith(".." + sep) && !isAbsolute(rel));
33
+ }
34
+
20
35
  /**
21
- * 把输入路径解析为绝对路径;越界(cwd 子树之外、`..` 上跳、cwd 本身除外)
22
- * 直接抛 "scan path out of scope"——与「密码不进上下文」同级别的红线。
36
+ * 把输入路径解析为绝对路径;越界(anchor 子树之外、`..` 上跳)直接抛
37
+ * "scan path out of scope"——与「密码不进上下文」同级别的红线。
38
+ *
39
+ * anchor 必须由调用方显式传入「会话工作目录」(pi 的 `ctx.cwd`),不能取 `process.cwd()`:
40
+ * 扩展与 pi 主进程同进程,`process.cwd()` 是**宿主进程的 OS cwd**。pi-web 下宿主 cwd 是
41
+ * pi-web 包目录、TUI 下是 pi 的启动目录,都与会话 cwd 不同源(会话 cwd 存在 session header
42
+ * 里,`/resume` 后可与启动目录不同)。锚错对象的后果是双向的:
43
+ * - fail-closed:项目路径全被拒(pi-web 下的必然结果);
44
+ * - fail-open:宿主 cwd 子树反而被放行——红线保护了不相干的目录树。
45
+ * 因此锚点只能来自 `ctx.cwd`,绝不能由模型传入的 input 反推。
46
+ *
47
+ * 判定分两道:
48
+ * ① 字面路径必须在 anchor 子树内(拦 `..` 上跳与绝对路径);
49
+ * ② **真实路径**(realpath)也必须在 anchor 的真实路径子树内——字面合法 ≠ 真实位置合法:
50
+ * 仓库里一个 `dbconf -> /etc` 的软链会让 abs 字面在子树内、真实位置在外(仅比对
51
+ * 「root 的 realpath 与其子项」会让软链扫描根自我授权)。两端都取 realpath 再比对,
52
+ * 因此「项目本身位于软链路径下」(如 macOS `/tmp → /private/tmp`)不会被误伤。
23
53
  */
24
- export function resolveRoot(input: string): string {
25
- const cwd = process.cwd();
54
+ export function resolveRoot(input: string, anchor: string): string {
55
+ const cwd = resolve(anchor);
26
56
  const abs = isAbsolute(input) ? resolve(input) : resolve(cwd, input);
27
57
  const rel = relative(cwd, abs);
28
- if (rel.startsWith("..") || isAbsolute(rel)) {
29
- throw new Error(`scan path out of scope: ${input}`);
58
+ // 注意:不能写 rel.startsWith("..")——cwd 内合法的 `..foo/` 也会命中前缀,属假阳性误杀
59
+ if (rel === ".." || rel.startsWith(".." + sep) || isAbsolute(rel)) {
60
+ throw new Error(`scan path out of scope: ${input}(允许根目录: ${cwd})`);
61
+ }
62
+ let anchorReal: string;
63
+ try { anchorReal = realpathSync(cwd); } catch { throw new Error(`scan anchor does not exist: ${cwd}`); }
64
+ let absReal: string;
65
+ try { absReal = realpathSync(abs); } catch { throw new Error(`scan path does not exist: ${input}`); }
66
+ if (!within(anchorReal, absReal)) {
67
+ throw new Error(`scan path out of scope: ${input}(真实路径 ${absReal} 不在允许根目录 ${anchorReal} 子树内)`);
30
68
  }
31
69
  return abs;
32
70
  }
33
71
 
34
- /** 递归收集文本候选文件(忽略无关目录与隐藏目录,上限 MAX_FILES);配置类文件单独收集不被挤占 */
35
- export function walk(root: string): string[] {
72
+ export interface WalkResult {
73
+ files: string[];
74
+ /** 是否有目录未展开(超 MAX_DEPTH 深度上限或 MAX_DIRS 目录预算),静默截断会让模型误判「项目里没有更深的配置」 */
75
+ depthCapped: boolean;
76
+ }
77
+
78
+ /** 递归收集文本候选文件(上限 MAX_FILES);配置类文件单独收集不被挤占。
79
+ * 与 v1.3.1 的行为差异:
80
+ * ① 不再跳过 `.` 开头的目录(`.config/db.yml` 这类配置此前永远扫不到),改为靠 IGNORED_DIRS 排噪;
81
+ * ② 软链不再静默忽略:目标 realpath 在 root 子树内则按真实类型访问(拦越界逃逸);
82
+ * ③ 深度超限不再静默:通过 depthCapped 如实上报;
83
+ * ④ 输出确定:目录项排序 + 只用祖先链断环(不做全局 realpath 去重),
84
+ * 真目录与软链别名两条路径都保留,不随文件系统的 readdir 顺序变化。*/
85
+ export function walk(root: string): WalkResult {
36
86
  const out: string[] = [];
37
87
  const configs: string[] = [];
38
- const visit = (dir: string, depth: number): void => {
39
- // 不因 out 满而提前返回:必须走完整棵树,否则后遍历到的目录里的配置文件永远收不到
40
- if (depth > MAX_DEPTH) return;
88
+ let rootReal = resolve(root);
89
+ try { rootReal = realpathSync(root); } catch { /* root 不存在时由 readdir 兜底 */ }
90
+ let depthCapped = false;
91
+ let dirsVisited = 0;
92
+
93
+ const push = (path: string, isConfig: boolean): void => {
94
+ if (isConfig) {
95
+ if (configs.length < MAX_FILES) configs.push(path);
96
+ } else if (out.length < MAX_FILES) {
97
+ out.push(path);
98
+ }
99
+ };
100
+
101
+ /**
102
+ * ancestors 只装「当前遍历路径上」的目录 realpath,用于断环,**不做全局去重**:
103
+ * 全局去重会让真目录与软链别名按 readdir 顺序互相吞掉(谁先遍历到谁赢),
104
+ * 同一项目因此在 APFS/ext4 上产出不同的文件树——别名路径照样要走,只是不能成环。
105
+ */
106
+ const visit = (dir: string, depth: number, ancestors: ReadonlySet<string>): void => {
107
+ if (depth > MAX_DEPTH || dirsVisited >= MAX_DIRS) { depthCapped = true; return; }
108
+ let real: string;
109
+ try { real = realpathSync(dir); } catch { return; }
110
+ if (ancestors.has(real)) return; // 软链环(a/self -> a):断环即可
111
+ dirsVisited++;
112
+ const next = new Set(ancestors);
113
+ next.add(real);
41
114
  let entries: Dirent[];
42
115
  try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return; }
116
+ // readdir 顺序随文件系统而变(APFS 插入序 / ext4 哈希序),排序后文件树才是确定的
117
+ entries.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
43
118
  for (const e of entries) {
44
119
  if (IGNORED_DIRS.has(e.name)) continue;
45
- if (e.isDirectory() && e.name.startsWith(".")) continue;
46
120
  const p = join(dir, e.name);
47
- if (e.isDirectory()) visit(p, depth + 1);
48
- else if (e.isFile()) {
49
- try {
50
- if (statSync(p).isFile()) {
51
- if (isConfigFile(e.name)) {
52
- if (configs.length < MAX_FILES) configs.push(p);
53
- } else if (out.length < MAX_FILES) {
54
- out.push(p);
55
- }
56
- }
57
- } catch { /* ignore */ }
58
- }
121
+ if (e.isDirectory()) { visit(p, depth + 1, next); continue; }
122
+ if (e.isFile()) { push(p, isConfigFile(e.name)); continue; }
123
+ // 软链:readdir 的 Dirent 对软链 isDirectory/isFile 均为 false,需按 realpath 判定。
124
+ // 指向 root 子树之外的一律忽略——扫描目录树不能成为越界的第二个入口。
125
+ if (!e.isSymbolicLink()) continue;
126
+ let target: string;
127
+ try { target = realpathSync(p); } catch { continue; }
128
+ if (!within(rootReal, target)) continue;
129
+ let targetIsDir = false, targetIsFile = false;
130
+ try {
131
+ const st = statSync(target);
132
+ targetIsDir = st.isDirectory();
133
+ targetIsFile = st.isFile();
134
+ } catch { continue; }
135
+ if (targetIsDir) visit(p, depth + 1, next);
136
+ // 软链文件名可能不带配置后缀(.env 被链接成 env.local):名字与目标名任一像配置就按配置收
137
+ else if (targetIsFile) push(p, isConfigFile(e.name) || isConfigFile(basename(target)));
59
138
  }
60
139
  };
61
- visit(root, 0);
62
- return [...configs, ...out];
140
+ visit(root, 0, new Set<string>());
141
+ return { files: [...configs, ...out], depthCapped };
63
142
  }
package/src/core/types.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  // core/types.ts
2
+ import type { SourceTrust } from "./scan/trust.js";
2
3
  export type DbTypeId = "postgresql" | "mysql" | "oracle" | "dm"
3
4
  | "redis" | "elasticsearch" | "mongodb" | "hive" | "spark" | "neo4j";
4
5
  export type DbFamily = "relational" | "kv" | "search" | "document" | "bigdata" | "graph";
@@ -58,6 +59,8 @@ export interface Candidate {
58
59
  partial: Partial<ConnConfig>; // 归一化后的字段(含默认 name)
59
60
  missing: string[]; // 待补字段名(incomplete 时)
60
61
  source: string; // 来源描述
62
+ /** 来源可信度(插件按确定性规则从模型自述的 source 评估,供展示,不影响 status;不是独立取证) */
63
+ trust: SourceTrust;
61
64
  warnings?: string[];
62
65
  }
63
66
 
@@ -80,10 +80,12 @@ class DmDialect extends RelationalDialect {
80
80
  protected async doExecute(client: unknown, stmt: string, opts: ExecOpts): Promise<{ columns: string[]; rows: unknown[][]; rowCount: number }> {
81
81
  const dmConn = client as dmdb.Connection;
82
82
  const maxRows = opts.maxRows;
83
+ // 上游 dmdb 的 ExecuteOptions 未声明 maxRows/fetchArraySize(运行时可接受或被忽略);
84
+ // 结果另有下方 .slice(0, maxRows) 兜底,故此处仅放宽类型断言,不改运行时行为。
83
85
  const res = await dmConn.execute(stmt, [], {
84
86
  maxRows,
85
87
  fetchArraySize: maxRows,
86
- });
88
+ } as dmdb.ExecuteOptions);
87
89
  if (res.metaData && res.metaData.length > 0) {
88
90
  const columns = res.metaData.map((m: any) => m.name);
89
91
  const rows = (res.rows ?? []).slice(0, maxRows).map((r: any) => [...r]);
@@ -95,7 +97,8 @@ class DmDialect extends RelationalDialect {
95
97
  async versionQuery(conn: DbConnection): Promise<string> {
96
98
  const dmConn = conn.client as dmdb.Connection;
97
99
  const res = await dmConn.execute("SELECT * FROM V$VERSION");
98
- return (res.rows ?? [])[0]?.[0] as string ?? "unknown";
100
+ const rows = (res.rows as unknown[][] | undefined) ?? [];
101
+ return (rows[0]?.[0] as string | undefined) ?? "unknown";
99
102
  }
100
103
 
101
104
  async listTables(config: ConnConfig, pattern?: string): Promise<ListTablesResult> {
@@ -144,7 +144,7 @@ class ElasticsearchDialect extends SearchDialect {
144
144
  }
145
145
  }
146
146
 
147
- protected async doSearch(client: unknown, config: ConnConfig, sql: string, kind: DslKind, opts: ExecOpts): Promise<{ columns: string[]; rows: unknown[][]; rowCount: number }> {
147
+ protected async doSearch(client: unknown, config: ConnConfig, sql: string, kind: DslKind, opts: ExecOpts): Promise<{ columns: string[]; rows: unknown[][]; rowCount: number; truncated?: boolean }> {
148
148
  const es = client as ClientV8;
149
149
  const index = config.database;
150
150
  if (!index) throw new Error("ES 连接缺少默认 index(database 字段)");
@@ -160,14 +160,20 @@ class ElasticsearchDialect extends SearchDialect {
160
160
  if (kind.endpoint === "_count") {
161
161
  const res = await es.count({ index });
162
162
  const count = (unwrap(res) as unknown as { count?: number }).count ?? 0;
163
- return { columns: ["count"], rows: [[count]], rowCount: count };
163
+ // rowCount 为“取回行数”(1 行 = 计数值本身),实际计数在单元格里,避免“返回 88 行”却只给一行的误读
164
+ return { columns: ["count"], rows: [[count]], rowCount: 1 };
164
165
  }
165
166
  if (kind.endpoint === "_mget") {
166
- const res = await es.mget({ index, body: { docs: [] } });
167
+ // 注:_mget 需兼容 v7 客户端的 body 形态(本文件为 v7/v8/v9 三客户端分发),
168
+ // 而 v8/v9 的类型已把请求体改为顶层 docs,故按运行时通用形态传参并放宽参数类型。
169
+ const res = await es.mget({ index, body: { docs: [] } } as unknown as Parameters<ClientV8["mget"]>[0]);
167
170
  return docsToRows(unwrap(res));
168
171
  }
169
- // _search:DSL 整体即 body
170
- const res = await es.search({ index, body, size: opts.maxRows });
172
+ // _search:DSL 整体即 body;body 显式带数字 size 时不再注入顶层 size
173
+ // (尊重调用方分页与 size:0 纯聚合意图,否则顶层 size 会覆盖 body.size 使聚合退化为普通搜索)
174
+ const searchParams: Record<string, unknown> = { index, body };
175
+ if (typeof (body as { size?: unknown } | undefined)?.size !== "number") searchParams.size = opts.maxRows;
176
+ const res = await es.search(searchParams as Parameters<ClientV8["search"]>[0]);
171
177
  return hitsToRows(unwrap(res));
172
178
  }
173
179
  throw new Error(`ES 写端点 ${kind.endpoint} 需走非只读确认流程执行,本方言 executeOn 仅执行读查询`);
@@ -233,12 +239,48 @@ class ElasticsearchDialect extends SearchDialect {
233
239
  }
234
240
 
235
241
  // ── 结果转换 ──────────────────────────────
236
- function hitsToRows(res: unknown): { columns: string[]; rows: unknown[][]; rowCount: number } {
237
- const body = res as { hits?: { total?: number | { value?: number }; hits?: Array<{ _id?: string; _source?: unknown }> } };
242
+ interface EsHitsBody {
243
+ hits?: { total?: number | { value?: number }; hits?: Array<{ _id?: string; _source?: unknown }> };
244
+ aggregations?: Record<string, unknown>;
245
+ }
246
+
247
+ /** 搜索响应 → 行;纯聚合查询(无命中 + aggregations)展开为聚合行,命中存在时保持文档视图 */
248
+ export function hitsToRows(res: unknown): { columns: string[]; rows: unknown[][]; rowCount: number; truncated?: boolean } {
249
+ const body = res as EsHitsBody;
238
250
  const hits = body.hits?.hits ?? [];
239
251
  const total = typeof body.hits?.total === "number" ? body.hits.total : (body.hits?.total?.value ?? hits.length);
252
+ if (hits.length === 0 && body.aggregations !== undefined
253
+ && body.aggregations !== null && typeof body.aggregations === "object") {
254
+ return aggsToRows(body.aggregations);
255
+ }
240
256
  const rows = hits.map((h) => [h._id, typeof h._source === "object" ? JSON.stringify(h._source) : h._source]);
241
- return { columns: ["_id", "_source"], rows, rowCount: typeof total === "number" ? total : hits.length };
257
+ const rowCount = typeof total === "number" ? total : hits.length;
258
+ // total 为服务端统计(可能是 gte 下限),大于取回行数即已截断(供工具层提示与导出)
259
+ return { columns: ["_id", "_source"], rows, rowCount, truncated: rowCount > rows.length };
260
+ }
261
+
262
+ /** 聚合结果 → 行:列 [aggregation, key, doc_count, value];metric 取值,桶内子聚合折叠为 value 列 JSON */
263
+ export function aggsToRows(aggs: Record<string, unknown>): { columns: string[]; rows: unknown[][]; rowCount: number } {
264
+ const rows: unknown[][] = [];
265
+ for (const [name, agg] of Object.entries(aggs)) {
266
+ if (agg === null || typeof agg !== "object") { rows.push([name, null, null, agg ?? null]); continue; }
267
+ const buckets = (agg as { buckets?: unknown }).buckets;
268
+ if (Array.isArray(buckets)) {
269
+ for (const b of buckets) {
270
+ if (b === null || typeof b !== "object") { rows.push([name, b, null, null]); continue; }
271
+ const bucket = b as Record<string, unknown>;
272
+ const key = bucket.key_as_string ?? bucket.key ?? null;
273
+ const docCount = typeof bucket.doc_count === "number" ? bucket.doc_count : null;
274
+ const extra = Object.entries(bucket).filter(([k]) => k !== "key" && k !== "key_as_string" && k !== "doc_count");
275
+ rows.push([name, key, docCount, extra.length > 0 ? JSON.stringify(Object.fromEntries(extra)) : null]);
276
+ }
277
+ continue;
278
+ }
279
+ const metric = agg as { value?: unknown; value_as_string?: unknown };
280
+ if ("value" in metric) { rows.push([name, null, null, metric.value_as_string ?? metric.value ?? null]); continue; }
281
+ rows.push([name, null, null, JSON.stringify(agg)]);
282
+ }
283
+ return { columns: ["aggregation", "key", "doc_count", "value"], rows, rowCount: rows.length };
242
284
  }
243
285
 
244
286
  function docsToRows(res: unknown): { columns: string[]; rows: unknown[][]; rowCount: number } {
@@ -61,7 +61,9 @@ class HiveDialect extends BigDataDialect {
61
61
  }
62
62
 
63
63
  protected async doConnect(config: ConnConfig, timeoutMs: number): Promise<DbConnection> {
64
- const client = new hive.HiveClient(TCLIService, TCLIService_types);
64
+ // 上游 hive-driver 的 TCLIServiceTypes 声明不完整(缺 25 个请求类型),与其运行时实际
65
+ // 使用的 thrift 定义不一致;仅放宽类型断言,运行时对象未做任何改动。
66
+ const client = new hive.HiveClient(TCLIService, TCLIService_types as any);
65
67
  await client.connect(
66
68
  { host: config.host ?? "localhost", port: config.port ?? DEFAULT_PORT },
67
69
  new hive.connections.TcpConnection(),
@@ -109,9 +109,11 @@ class MysqlDialect extends RelationalDialect {
109
109
  try {
110
110
  const tables = await this.withConnection(config, async (conn) => {
111
111
  const mysqlConn = conn.client as mysql.Connection;
112
+ // config.database 为可选(ConnConfig),而 mysql2 的 ExecuteValues 不接受 undefined 元素;
113
+ // 此处仅放宽类型断言,运行时参数与既有行为完全一致。
112
114
  const [rows] = await mysqlConn.execute(
113
115
  "SELECT TABLE_NAME, TABLE_TYPE, TABLE_COMMENT FROM information_schema.TABLES WHERE TABLE_SCHEMA = ? ORDER BY TABLE_NAME",
114
- [config.database],
116
+ [config.database] as any[],
115
117
  );
116
118
  const all = (rows as any[]).map((r: any) => ({
117
119
  schema: "",
@@ -149,7 +151,7 @@ class MysqlDialect extends RelationalDialect {
149
151
  try {
150
152
  const [commentRows] = await mysqlConn.execute(
151
153
  "SELECT COLUMN_NAME, COLUMN_COMMENT FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ?",
152
- [config.database, table],
154
+ [config.database, table] as any[],
153
155
  );
154
156
  const commentMap = new Map((commentRows as any[]).map((r: any) => [r.COLUMN_NAME, r.COLUMN_COMMENT]));
155
157
  for (const col of cols) {
@@ -270,7 +270,7 @@ class Neo4jDialect extends GraphDialect {
270
270
  const f = r._fields ?? [];
271
271
  const types = f[3] as string[] | null;
272
272
  if (!Array.isArray(types) || !types.includes(graphName)) continue;
273
- indexLines.push(`${f[0]}(${f[1]}, ${f[5] ?? "online"}) ON ${isRel ? "rel" : "node"}(${types.join(":")}).(${(f[4] ?? []).join(",")})`);
273
+ indexLines.push(`${f[0]}(${f[1]}, ${f[5] ?? "online"}) ON ${isRel ? "rel" : "node"}(${types.join(":")}).(${((f[4] ?? []) as string[]).join(",")})`);
274
274
  }
275
275
  } catch { /* SHOW 失败降级 */ }
276
276
  try {
@@ -281,7 +281,7 @@ class Neo4jDialect extends GraphDialect {
281
281
  const types = f[3] as string[] | null;
282
282
  if (!Array.isArray(types) || !types.includes(graphName)) continue;
283
283
  for (const p of (f[4] ?? []) as string[]) if (String(f[1]).toUpperCase().includes("UNIQUE")) uniqueProps.add(p);
284
- indexLines.push(`${f[0]}(${f[1]}) ON ${types.join(":")}.(${(f[4] ?? []).join(",")})`);
284
+ indexLines.push(`${f[0]}(${f[1]}) ON ${types.join(":")}.(${((f[4] ?? []) as string[]).join(",")})`);
285
285
  }
286
286
  } catch { /* 降级 */ }
287
287
 
@@ -1,7 +1,7 @@
1
1
  // src/dialects/oracle.ts
2
2
  import oracledb from "oracledb";
3
3
  import type { ConnConfig, DbConnection, ExecOpts, ParsedTarget,
4
- ListTablesResult, DescribeTableResult, ColumnInfo, TestConnectionResult } from "../core/types.js";
4
+ ListTablesResult, DescribeTableResult, TableInfo, ColumnInfo, TestConnectionResult } from "../core/types.js";
5
5
  import { RelationalDialect } from "./relational-dialect.js";
6
6
  import { register, filterTables, type Fingerprints } from "./dialect.js";
7
7
 
@@ -107,7 +107,9 @@ class OracleDialect extends RelationalDialect {
107
107
  WHERE T.OWNER NOT IN ('SYS', 'SYSTEM', 'DBSNMP', 'XDB')
108
108
  ORDER BY T.OWNER, T.TABLE_NAME`,
109
109
  );
110
- const all = (res.rows ?? []).map((r: any) => ({
110
+ // 显式标注 TableInfo[]:oracledb 无类型声明,res.rows 退化为 any,若不标注则
111
+ // filterTables 的泛型会回退到约束 { name; schema? },丢失 type/description 两个字段。
112
+ const all: TableInfo[] = (res.rows ?? []).map((r: any) => ({
111
113
  schema: r[1],
112
114
  name: r[0],
113
115
  type: "TABLE",
@@ -93,9 +93,13 @@ class RedisDialect extends KvDialect {
93
93
  let sampled = 0;
94
94
  let cursor = "0";
95
95
  // LIKE(%/_)→ SCAN 通配(*/?),全局替换(String.replace 单次替换是 bug)
96
- const matchArgs = pattern ? ["MATCH", pattern.split("").map((ch) => ch === "%" ? "*" : ch === "_" ? "?" : ch).join("")] : [];
96
+ const scanPattern = pattern ? pattern.split("").map((ch) => ch === "%" ? "*" : ch === "_" ? "?" : ch).join("") : null;
97
97
  do {
98
- const [next, keys] = await redis.scan(cursor, "COUNT", 100, ...matchArgs);
98
+ // ioredis 的 scan 重载要求 MATCH/COUNT 固定次序,不能靠 spread 拼参;
99
+ // Redis 的 SCAN 命令本身不区分参数顺序,故两个分支与原来的命令语义完全一致。
100
+ const [next, keys] = scanPattern
101
+ ? await redis.scan(cursor, "MATCH", scanPattern, "COUNT", 100)
102
+ : await redis.scan(cursor, "COUNT", 100);
99
103
  cursor = next;
100
104
  for (const key of keys) {
101
105
  if (sampled >= 200) break;
@@ -166,7 +170,8 @@ class RedisDialect extends KvDialect {
166
170
  } else if (type === "set") {
167
171
  preview = JSON.stringify(await redis.smembers(key)).slice(0, 200);
168
172
  } else if (type === "zset") {
169
- preview = JSON.stringify(await redis.zrange(key, 0, 9, "WITHSCORES")).slice(0, 200);
173
+ // ioredis 6 的 zrange 重载把 stop 声明为 string|Buffer(漏了 number);索引 "9" 与数字 9 等价。
174
+ preview = JSON.stringify(await redis.zrange(key, 0, "9", "WITHSCORES")).slice(0, 200);
170
175
  }
171
176
  } catch { /* ignore */ }
172
177
  return [
@@ -56,7 +56,10 @@ export abstract class RelationalDialect implements Dialect {
56
56
  await this.withConnection(config, async (conn) => {
57
57
  for (const s of stmts) last = await this.doExecute(conn.client, s, opts);
58
58
  }, opts.timeoutSec * 1000);
59
- return { success: true, ...last, duration: `${Date.now() - start}ms` };
59
+ // 结果集被 maxRows 截断时标注(各行返回 rowCount=服务端完整行数、rows 已按 maxRows 截取);
60
+ // 仅对结果集(有列)判定,写操作 rows 为空而 rowCount=影响行数,不得误标
61
+ const truncated = last.columns.length > 0 && last.rowCount > last.rows.length;
62
+ return { success: true, ...last, truncated, duration: `${Date.now() - start}ms` };
60
63
  } catch (err: unknown) {
61
64
  return { success: false, error: err instanceof Error ? err.message : String(err), duration: `${Date.now() - start}ms` };
62
65
  }
@@ -58,7 +58,8 @@ class SparkDialect extends BigDataDialect {
58
58
  }
59
59
 
60
60
  protected async doConnect(config: ConnConfig, timeoutMs: number): Promise<DbConnection> {
61
- const client = new hive.HiveClient(TCLIService, TCLIService_types);
61
+ // 同 hive.ts:上游 hive-driver 的 TCLIServiceTypes 声明不完整,仅放宽类型断言,不动运行时。
62
+ const client = new hive.HiveClient(TCLIService, TCLIService_types as any);
62
63
  await client.connect(
63
64
  { host: config.host ?? "localhost", port: config.port ?? DEFAULT_PORT },
64
65
  new hive.connections.TcpConnection(),