@nsyan/db 1.2.0 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/core/types.ts CHANGED
@@ -37,14 +37,28 @@ export interface ParsedTarget {
37
37
  export interface DbConnection { type: DbTypeId; client: unknown; close(): Promise<void>; }
38
38
  export interface ExecOpts { readonly: boolean; maxRows: number; timeoutSec: number; }
39
39
 
40
- // scan/candidates.ts
41
- export type CandidateStatus = "ready" | "incomplete" | "encrypted" | "exists";
40
+ // scan(v1.3.0:文件发现与提取交给会话模型,本层只做校验归一化)
41
+ export type CandidateStatus = "ready" | "incomplete" | "exists";
42
+ /** 模型经 db_scan_save 提交的原始候选形状 */
43
+ export interface CandidateInput {
44
+ dialectId: DbTypeId;
45
+ host?: string; port?: number | string;
46
+ username?: string; password?: string;
47
+ database?: string; dbIndex?: number | string;
48
+ /** 完整连接串(可选;提供时必须能被方言 parseUrl 解析,否则整条拒绝——防幻觉) */
49
+ url?: string;
50
+ name?: string;
51
+ /** 来源描述(如配置文件相对路径),展示用 */
52
+ source?: string;
53
+ warnings?: string[];
54
+ }
42
55
  export interface Candidate {
43
56
  status: CandidateStatus;
44
- dialectId: DbTypeId; // 由方言 fingerprints 匹配得出
45
- partial: Partial<ConnConfig>; // 已抽到的字段
57
+ dialectId: DbTypeId;
58
+ partial: Partial<ConnConfig>; // 归一化后的字段(含默认 name)
46
59
  missing: string[]; // 待补字段名(incomplete 时)
47
- source: { file: string; profile?: string; confidence: number };
60
+ source: string; // 来源描述
61
+ warnings?: string[];
48
62
  }
49
63
 
50
64
  // ── 以下从 src/db.ts 原样搬入(字段不变) ──────────
@@ -7,19 +7,27 @@ import type { ConnConfig, DbConnection, ExecOpts, ParsedTarget,
7
7
  import { SearchDialect, type DslKind } from "./search-dialect.js";
8
8
  import { register, filterTables, type Fingerprints } from "./dialect.js";
9
9
 
10
- // ── URL 解析:http(s)://host:port(认证走账号/密码字段,API Key 二期)───
11
- const ES_RE = /^(https?):\/\/([^:/?#@]+)(?::(\d+))?(\/.*)?$/;
10
+ // ── URL 解析:http(s)://[user:pass@]host:port(认证解出后归一化到 username/password,API Key 二期)───
11
+ const ES_RE = /^(https?):\/\/(?:([^:/?#@]*)(?::([^@]*))?@)?([^:/?#@]+)(?::(\d+))?(\/.*)?$/;
12
12
 
13
13
  function parseEsUrl(url: string): ParsedTarget | null {
14
14
  const clean = url.split("?")[0].replace(/\/+$/, "");
15
15
  const m = clean.match(ES_RE);
16
16
  if (!m) return null;
17
- const host = m[2];
17
+ const host = m[4];
18
18
  const defaultPort = m[1] === "https" ? 443 : 9200;
19
- const port = m[3] ? parseInt(m[3], 10) : defaultPort;
19
+ const port = m[5] ? parseInt(m[5], 10) : defaultPort;
20
20
  const ssl = m[1] === "https";
21
21
  if (!host) return null;
22
- return { host, port, ssl };
22
+ const out: ParsedTarget = { host, port, ssl };
23
+ // userinfo 段(可能含 URL 编码密码)解出归一化,供扫描建连/测连直接用
24
+ if (m[2] !== undefined) {
25
+ try { out.username = decodeURIComponent(m[2]); } catch { out.username = m[2]; }
26
+ }
27
+ if (m[3] !== undefined) {
28
+ try { out.password = decodeURIComponent(m[3]); } catch { out.password = m[3]; }
29
+ }
30
+ return out;
23
31
  }
24
32
 
25
33
  /** 从 `version.number`(如 "7.17.0")取大版本号 */
@@ -1,294 +0,0 @@
1
- // scan/candidates.ts —— scanProject 组装:walker → parsers → spring/placeholders → scoring → Candidate[]
2
- // 状态机:ready(可直接测连)/ incomplete(缺字段)/ encrypted(jasypt ENC,只标注不建)/ exists(同名已存在)
3
- // 红线:绝不静默建连(写盘由 index.ts 向导确认后执行);密码掩码在输出层(index.ts)做,
4
- // 本模块的 partial 保留真实值仅供 TUI 补录。
5
- import { readFileSync } from "node:fs";
6
- import { basename, dirname, extname, join, relative } from "node:path";
7
- import { registry } from "../../dialects/index.js";
8
- import { loadConfigs } from "../../config.js";
9
- import type { Candidate, ConnConfig, DbTypeId, ParsedTarget } from "../types.js";
10
- import { resolveRoot, walk } from "./walker.js";
11
- import { parseCompose, parseEnv, parseProperties, parseSimpleYaml, extractUrls } from "./parsers.js";
12
- import { springKeysToRaw, profileOf, type RawDbConfig } from "./spring.js";
13
- import { resolvePlaceholder } from "./placeholders.js";
14
- import { isExcluded, sourceWeight } from "./scoring.js";
15
-
16
- // ── 内部表示 ──────────────────────────────────────
17
-
18
- interface FieldBag {
19
- host?: string; port?: number;
20
- username?: string; password?: string;
21
- database?: string; dbIndex?: number;
22
- ssl?: boolean;
23
- options?: Record<string, string>;
24
- url?: string;
25
- }
26
-
27
- interface RawCand {
28
- dialectId: DbTypeId;
29
- bag: FieldBag;
30
- file: string;
31
- profile: string;
32
- confidence: number;
33
- }
34
-
35
- // 各家族建连必需字段(missing 的判定依据;空串同样视为缺失)
36
- const REQUIRED: Record<DbTypeId, string[]> = {
37
- postgresql: ["host", "port", "database", "username", "password"],
38
- mysql: ["host", "port", "database", "username", "password"],
39
- oracle: ["host", "port", "database", "username", "password"],
40
- dm: ["host", "port", "database", "username", "password"],
41
- hive: ["host", "port", "database", "username"],
42
- spark: ["host", "port", "database", "username"],
43
- redis: ["host", "port", "password"],
44
- elasticsearch: ["host", "port"],
45
- neo4j: ["host", "port", "username", "password"], // 工作库可选(缺省 neo4j;社区版默认开认证)
46
- mongodb: ["host", "port"], // 账号/工作库可选(本地无认证常见)
47
- };
48
-
49
- // ── 工具函数 ──────────────────────────────────────
50
-
51
- function flattenYaml(node: unknown, prefix = "", out: Record<string, string> = {}): Record<string, string> {
52
- if (node && typeof node === "object" && !Array.isArray(node)) {
53
- for (const [k, v] of Object.entries(node as Record<string, unknown>)) {
54
- flattenYaml(v, prefix ? `${prefix}.${k}` : k, out);
55
- }
56
- } else if (typeof node === "string" || typeof node === "number") {
57
- out[prefix] = String(node);
58
- }
59
- return out;
60
- }
61
-
62
- function toInt(v: unknown): number | undefined {
63
- const n = typeof v === "number" ? v : parseInt(String(v), 10);
64
- return Number.isFinite(n) ? n : undefined;
65
- }
66
-
67
- function stripQuery(url: string): string {
68
- return url.split(/[?#]/)[0];
69
- }
70
-
71
- /** 遍历 registry 各方言 parseUrl,首个非 null 胜出(与 index.ts parseDbUrl 同一模式)。 */
72
- function parseUrlViaRegistry(url: string): ({ dialectId: DbTypeId } & ParsedTarget) | null {
73
- for (const d of registry.values()) {
74
- const p = d.parseUrl(url);
75
- if (p) return { dialectId: d.id, ...p };
76
- }
77
- return null;
78
- }
79
-
80
- function dialectFromImage(image: string): DbTypeId | null {
81
- const i = image.toLowerCase();
82
- if (/postgres/.test(i)) return "postgresql";
83
- if (/(^|\/)(mysql|mariadb)/.test(i)) return "mysql";
84
- if (/(^|\/)mongo/.test(i)) return "mongodb"; // mongo / mongodb 镜像(mongo-express 误报可忍变)
85
- if (/redis/.test(i)) return "redis";
86
- if (/neo4j/.test(i)) return "neo4j";
87
- if (/elasticsearch/.test(i)) return "elasticsearch";
88
- if (/dm8|dameng/.test(i)) return "dm";
89
- if (/hive/.test(i)) return "hive";
90
- return null;
91
- }
92
-
93
- /** 同目录 .env 缓存(${KEY} 无 default 时的第二优先级) */
94
- function loadDirEnv(file: string, cache: Map<string, Record<string, string> | undefined>): Record<string, string> | undefined {
95
- const dir = dirname(file);
96
- if (cache.has(dir)) return cache.get(dir);
97
- let env: Record<string, string> | undefined;
98
- try {
99
- env = parseEnv(readFileSync(join(dir, ".env"), "utf-8"));
100
- } catch { env = undefined; }
101
- cache.set(dir, env);
102
- return env;
103
- }
104
-
105
- // ── 主流程 ────────────────────────────────────────
106
-
107
- export async function scanProject(
108
- rootInput: string,
109
- opts?: { existingNames?: string[] },
110
- ): Promise<Candidate[]> {
111
- const root = resolveRoot(rootInput); // 越界直接抛(红线)
112
- const files = walk(root);
113
- const raws: RawCand[] = [];
114
- const dirEnvCache = new Map<string, Record<string, string> | undefined>();
115
-
116
- const pushUrl = (url: string, file: string, profile: string, confidence: number, keyFields?: Partial<RawDbConfig>): void => {
117
- // 先用完整 URL 路由(MongoDB 的 authSource/replicaSet 等在 query 里,不能剥);
118
- // 失败再回退剥离 query 的旧路径(兼容 JDBC 带查询参数时各 parseUrl 的锚点匹配)
119
- const parsed = parseUrlViaRegistry(url) ?? parseUrlViaRegistry(stripQuery(url));
120
- if (!parsed) return;
121
- raws.push({
122
- dialectId: parsed.dialectId,
123
- // URL 内嵌账号密码与 spring 键字段合并:显式键(spring.datasource.username/password)优先
124
- bag: {
125
- url,
126
- host: parsed.host,
127
- port: parsed.port,
128
- username: keyFields?.username ?? parsed.username,
129
- password: keyFields?.password ?? parsed.password,
130
- database: parsed.database,
131
- dbIndex: parsed.dbIndex,
132
- ssl: parsed.ssl,
133
- options: parsed.options,
134
- },
135
- file, profile, confidence,
136
- });
137
- };
138
-
139
- const pushRaw = (raw: RawDbConfig): void => {
140
- if (raw.url) {
141
- pushUrl(raw.url, raw.file, raw.profile, raw.weight, raw);
142
- return;
143
- }
144
- // 无 URL 的键式候选:redis/es 可凭 host 判定;关系型无 URL 无法建连,跳过
145
- let dialectId: DbTypeId | null = null;
146
- if (raw.group === "redis" && raw.host) dialectId = "redis";
147
- else if (raw.group === "es" && raw.host) dialectId = "elasticsearch";
148
- if (!dialectId) return;
149
- raws.push({
150
- dialectId,
151
- bag: { host: raw.host, port: toInt(raw.port), username: raw.username, password: raw.password, dbIndex: toInt(raw.dbIndex) },
152
- file: raw.file, profile: raw.profile, confidence: raw.weight,
153
- });
154
- };
155
-
156
- for (const file of files) {
157
- const rel = relative(root, file).split("\\").join("/");
158
- if (isExcluded(rel)) continue;
159
- const ext = extname(file).toLowerCase();
160
- const base = basename(file).toLowerCase();
161
- let text: string;
162
- try { text = readFileSync(file, "utf-8"); } catch { continue; }
163
- if (text.length > 512 * 1024) continue; // 大文件跳过
164
-
165
- const weight = sourceWeight(rel);
166
- const profile = profileOf(base);
167
-
168
- if (base === ".env" || base.startsWith(".env.")) {
169
- const env = parseEnv(text);
170
- for (const [k, v] of Object.entries(env)) {
171
- if (/(^|_)(DATABASE_URL|DATASOURCE_URL|REDIS_URL|ELASTICSEARCH_URL|MONGODB_URI|MONGO_URL|NEO4J_URI|NEO4J_URL|BOLT_URL|DB_URL|JDBC_URL)$|_URL$/i.test(k)) {
172
- pushUrl(v, file, profile, weight);
173
- }
174
- }
175
- continue;
176
- }
177
-
178
- if (ext === ".yml" || ext === ".yaml") {
179
- if (base.startsWith("docker-compose") || base.startsWith("compose")) {
180
- for (const svc of parseCompose(text)) {
181
- const dialectId = svc.image ? dialectFromImage(svc.image) : null;
182
- if (!dialectId) continue;
183
- const bag: FieldBag = {
184
- host: svc.name, // compose 网络内服务名即主机名
185
- port: hostPortOf(svc.ports),
186
- username: svc.env["POSTGRES_USER"] ?? svc.env["MYSQL_USER"] ?? svc.env["ES_USERNAME"] ?? svc.env["MONGO_INITDB_ROOT_USERNAME"] ?? svc.env["NEO4J_AUTH"]?.split("/")[0],
187
- password: svc.env["POSTGRES_PASSWORD"] ?? svc.env["MYSQL_ROOT_PASSWORD"] ?? svc.env["MYSQL_PASSWORD"] ?? svc.env["REDIS_PASSWORD"] ?? svc.env["ELASTIC_PASSWORD"] ?? svc.env["MONGO_INITDB_ROOT_PASSWORD"] ?? svc.env["NEO4J_PASSWORD"],
188
- database: svc.env["POSTGRES_DB"] ?? svc.env["MYSQL_DATABASE"] ?? svc.env["MONGO_INITDB_DATABASE"],
189
- };
190
- raws.push({ dialectId, bag, file, profile, confidence: weight });
191
- // compose 里也可能带完整 Spring URL
192
- for (const [k, v] of Object.entries(svc.env)) {
193
- if (/URL$/.test(k)) pushUrl(v, file, profile, weight);
194
- }
195
- }
196
- } else {
197
- const dotted = flattenYaml(parseSimpleYaml(text));
198
- for (const raw of springKeysToRaw(dotted, file, weight)) pushRaw(raw);
199
- }
200
- } else if (ext === ".properties") {
201
- const dotted = parseProperties(text);
202
- for (const raw of springKeysToRaw(dotted, file, weight)) pushRaw(raw);
203
- }
204
-
205
- // 通用 URL 正则全文件扫(兜底,低权重)
206
- for (const url of extractUrls(text)) pushUrl(url, file, profile, 0.5);
207
- }
208
-
209
- // ── 占位符解析 + 状态机 ──────────────────────────
210
- const projectName = basename(root);
211
- const existingNames = new Set(opts?.existingNames ?? loadConfigs().map((c) => c.name));
212
-
213
- const resolved: RawCand[] = [];
214
- const seen = new Map<string, number>(); // dedupe key → index in resolved
215
- for (const raw of raws) {
216
- const env = loadDirEnv(raw.file, dirEnvCache);
217
- const bag: FieldBag = {};
218
- for (const [k, v] of Object.entries(raw.bag)) {
219
- if (v === undefined) continue;
220
- if (typeof v !== "string") { (bag as Record<string, unknown>)[k] = v; continue; }
221
- const r = resolvePlaceholder(v, env);
222
- if (r.resolved) (bag as Record<string, unknown>)[k] = r.value;
223
- // 未解析的占位符:字段从 bag 消失 → 落入 missing
224
- }
225
-
226
- // 去重:同一实例(dialect|host|port|database)只保留一条;
227
- // 后到的低置信度候选(如同文件通用 URL 兆底)把自身字段补入已有候选(凭据增强),不新增
228
- const key = `${raw.dialectId}|${bag.host ?? ""}|${bag.port ?? ""}|${bag.database ?? ""}`;
229
- const prevIdx = seen.get(key);
230
- if (prevIdx !== undefined) {
231
- const prev = resolved[prevIdx];
232
- if (raw.confidence > prev.confidence) {
233
- prev.confidence = raw.confidence;
234
- prev.profile = raw.profile;
235
- }
236
- for (const [k, v] of Object.entries(bag)) {
237
- if ((prev.bag as Record<string, unknown>)[k] === undefined && v !== undefined) {
238
- (prev.bag as Record<string, unknown>)[k] = v;
239
- }
240
- }
241
- continue;
242
- }
243
- seen.set(key, resolved.length);
244
- resolved.push({ ...raw, bag });
245
- }
246
-
247
- // 确定性输出:置信度降序 → 默认名升序(同名多 profile 时 default 在前,消费方 find 可预期)
248
- const nameOf = (r: RawCand): string => `${projectName}-${r.profile}-${r.dialectId}`;
249
- resolved.sort((a, b) => b.confidence - a.confidence || nameOf(a).localeCompare(nameOf(b)));
250
-
251
- const candidates: Candidate[] = resolved.map((raw) => {
252
- const defaultName = `${projectName}-${raw.profile}-${raw.dialectId}`;
253
- const missing: string[] = [];
254
- for (const f of REQUIRED[raw.dialectId]) {
255
- const v = (raw.bag as Record<string, unknown>)[f];
256
- if (v === undefined || v === "") missing.push(f);
257
- }
258
- let status: Candidate["status"];
259
- const pwd = raw.bag.password;
260
- if (typeof pwd === "string" && pwd.startsWith("ENC(")) {
261
- status = "encrypted";
262
- } else if (existingNames.has(defaultName)) {
263
- status = "exists";
264
- } else if (missing.length === 0) {
265
- status = "ready";
266
- } else {
267
- status = "incomplete";
268
- }
269
- const partial: Partial<ConnConfig> = {
270
- name: defaultName,
271
- type: raw.dialectId,
272
- ...raw.bag,
273
- };
274
- return {
275
- status,
276
- dialectId: raw.dialectId,
277
- partial,
278
- missing,
279
- source: { file: raw.file, profile: raw.profile, confidence: raw.confidence },
280
- };
281
- });
282
-
283
- return candidates;
284
- }
285
-
286
- /** compose 端口映射取主机侧端口:"5432:5432" → 5432;"127.0.0.1:5432:5432" → 5432 */
287
- function hostPortOf(ports: string[]): number | undefined {
288
- for (const p of ports) {
289
- const parts = p.split(":");
290
- const n = parseInt(parts[parts.length - 2] ?? parts[0], 10);
291
- if (Number.isFinite(n)) return n;
292
- }
293
- return undefined;
294
- }
@@ -1,149 +0,0 @@
1
- // scan/parsers.ts —— 按文件类型解析:.env / .properties / yml+yaml(Spring 结构优先)/ docker-compose.yml / 通用 URL 正则
2
-
3
- export function parseEnv(text: string): Record<string, string> {
4
- const out: Record<string, string> = {};
5
- for (const line of text.split("\n")) {
6
- const t = line.trim();
7
- if (!t || t.startsWith("#")) continue;
8
- const eq = t.indexOf("=");
9
- if (eq <= 0) continue;
10
- const key = t.slice(0, eq).trim();
11
- let val = t.slice(eq + 1).trim();
12
- if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'"))) {
13
- val = val.slice(1, -1);
14
- }
15
- out[key] = val;
16
- }
17
- return out;
18
- }
19
-
20
- export function parseProperties(text: string): Record<string, string> {
21
- const out: Record<string, string> = {};
22
- for (const line of text.split("\n")) {
23
- const t = line.trim();
24
- if (!t || t.startsWith("#") || t.startsWith("!")) continue;
25
- const m = /^([^=:!]+)[=:](.*)$/.exec(t);
26
- if (!m) continue;
27
- out[m[1].trim()] = m[2].trim();
28
- }
29
- return out;
30
- }
31
-
32
- /**
33
- * 极简 YAML 子集解析:嵌套 map + 标量(够 Spring 配置用)。
34
- * 不支持多行标量/锚点/复杂流式结构——遇到即跳过该行。
35
- */
36
- export function parseSimpleYaml(text: string): Record<string, unknown> {
37
- interface YamlLine { indent: number; key: string; value: string | null; }
38
- const lines: YamlLine[] = [];
39
- for (const raw of text.split("\n")) {
40
- if (!raw.trim() || raw.trim().startsWith("#")) continue;
41
- if (raw.trim().startsWith("- ")) continue; // 列表项:Spring 配置子树用不到,交给 compose 解析器
42
- const indent = raw.length - raw.trimStart().length;
43
- const content = raw.trim();
44
- const colon = content.indexOf(":");
45
- if (colon < 0) continue; // 多行标量等不支持,跳过
46
- const key = content.slice(0, colon).trim().replace(/^["']|["']$/g, "");
47
- let value: string | null = content.slice(colon + 1).trim();
48
- if (value === "" || value === "|" || value === ">") value = null;
49
- else value = value.replace(/^["']|["']$/g, "");
50
- lines.push({ indent, key, value });
51
- }
52
- const root: Record<string, unknown> = {};
53
- const stack: { indent: number; obj: Record<string, unknown> }[] = [{ indent: -1, obj: root }];
54
- for (const ln of lines) {
55
- while (stack.length > 1 && ln.indent <= stack[stack.length - 1].indent) stack.pop();
56
- const parent = stack[stack.length - 1].obj;
57
- if (ln.value !== null) {
58
- parent[ln.key] = ln.value;
59
- } else {
60
- const child: Record<string, unknown> = {};
61
- parent[ln.key] = child;
62
- stack.push({ indent: ln.indent, obj: child });
63
- }
64
- }
65
- return root;
66
- }
67
-
68
- // ── docker-compose.yml ────────────────────────────
69
-
70
- export interface ComposeService {
71
- name: string;
72
- image?: string;
73
- env: Record<string, string>;
74
- ports: string[];
75
- }
76
-
77
- export function parseCompose(text: string): ComposeService[] {
78
- const services: ComposeService[] = [];
79
- let cur: ComposeService | null = null;
80
- let block: { kind: "environment" | "ports"; indent: number } | null = null;
81
- let servicesIndent = -1;
82
-
83
- for (const raw of text.split("\n")) {
84
- const t = raw.trim();
85
- if (!t || t.startsWith("#")) continue;
86
- const indent = raw.length - raw.trimStart().length;
87
-
88
- if (t.startsWith("- ")) {
89
- if (!cur || !block || indent <= block.indent) continue;
90
- const item = t.slice(2).trim().replace(/^["']|["']$/g, "");
91
- if (block.kind === "ports") {
92
- cur.ports.push(item);
93
- } else {
94
- const eq = item.indexOf("=");
95
- if (eq > 0) cur.env[item.slice(0, eq).trim()] = item.slice(eq + 1).trim();
96
- }
97
- continue;
98
- }
99
-
100
- if (block && indent <= block.indent) block = null;
101
-
102
- const colon = t.indexOf(":");
103
- if (colon < 0) continue;
104
- const key = t.slice(0, colon).trim().replace(/^["']|["']$/g, "");
105
- const value = t.slice(colon + 1).trim();
106
-
107
- if (key === "services" && value === "") {
108
- servicesIndent = indent;
109
- cur = null;
110
- block = null;
111
- continue;
112
- }
113
- // service 名行:services 的直接子级(缩进 = servicesIndent + 2)且无内联值
114
- if (servicesIndent >= 0 && indent === servicesIndent + 2 && value === "") {
115
- cur = { name: key, image: undefined, env: {}, ports: [] };
116
- services.push(cur);
117
- block = null;
118
- continue;
119
- }
120
- if (cur) {
121
- if (key === "image" && value) {
122
- cur.image = value.replace(/^["']|["']$/g, "");
123
- } else if (key === "environment") {
124
- if (value === "") block = { kind: "environment", indent };
125
- else {
126
- const eq = value.indexOf("=");
127
- if (eq > 0) cur.env[value.slice(0, eq).trim()] = value.slice(eq + 1).trim().replace(/^["']|["']$/g, "");
128
- }
129
- } else if (key === "ports") {
130
- block = value === "" ? { kind: "ports", indent } : null;
131
- } else if (block?.kind === "environment" && indent > block.indent && value !== "") {
132
- cur.env[key] = value.replace(/^["']|["']$/g, "");
133
- }
134
- }
135
- }
136
- return services;
137
- }
138
-
139
- // ── 通用 URL 正则(全文件扫描兜底)──────────────────
140
-
141
- const URL_RE = /(?:jdbc:(?:postgresql|mysql|oracle|dm|hive2)|rediss?|mongodb\+srv|mongodb|neo4j\+s(sc)?|neo4j|bolt\+s(sc)?|bolt|postgresql|mysql):\/\/[^\s"'<>`]+|https?:\/\/[^\s"'<>`]*:9200[^\s"'<>`]*/g;
142
-
143
- export function extractUrls(text: string): string[] {
144
- const out = new Set<string>();
145
- for (const m of text.matchAll(URL_RE)) {
146
- out.add(m[0].replace(/[),.;\]]+$/, ""));
147
- }
148
- return [...out];
149
- }
@@ -1,24 +0,0 @@
1
- // scan/placeholders.ts —— Spring 占位符 ${KEY:default} 解析:default → 同目录 .env → 进程 env → 标「待补」
2
- const PH_RE = /^\$\{([^:}]+)(?::((?:.|\n)*))?\}$/;
3
-
4
- export interface PlaceholderResult {
5
- resolved: boolean;
6
- value?: string;
7
- }
8
-
9
- export function resolvePlaceholder(value: string, localEnv?: Record<string, string>): PlaceholderResult {
10
- const m = PH_RE.exec(value.trim());
11
- if (!m) return { resolved: true, value };
12
- const key = m[1];
13
- const hasDefault = m[2] !== undefined;
14
-
15
- // 有非空 default → 直接用
16
- if (hasDefault && m[2] !== "") return { resolved: true, value: m[2] };
17
-
18
- // 无 default 或 default 为空串:查同目录 .env → 进程 env
19
- const fromEnv = localEnv?.[key] ?? process.env[key];
20
- if (fromEnv !== undefined && fromEnv !== "") return { resolved: true, value: fromEnv };
21
-
22
- // 缺省:标待补(空串 default 同样视为待补,如 ${REDIS_PASSWORD:})
23
- return { resolved: false };
24
- }
@@ -1,25 +0,0 @@
1
- // scan/scoring.ts —— 置信度:docker-compose/.env/application.yml 高权重;*test*/*example*/*.md/logs 降权或排除
2
- // 注:排除/降权只看相对扫描根的路径——固件本身在 test/ 下不受影响。
3
-
4
- const SOURCE_WEIGHT: Array<[RegExp, number]> = [
5
- [/application[^/]*\.ya?ml$/i, 0.9],
6
- [/docker-compose[^/]*\.ya?ml$/i, 0.85],
7
- [/^docker-compose[^/]*\.ya?ml$/i, 0.85],
8
- [/\.env(\.[^/]*)?$/i, 0.8],
9
- [/application[^/]*\.properties$/i, 0.7],
10
- [/\.properties$/i, 0.6],
11
- ];
12
-
13
- const FALLBACK_WEIGHT = 0.5; // 通用 URL 正则扫出的候选
14
-
15
- /** 命中测试/示例/文档/日志路径的文件整体排除,不产候选。 */
16
- export function isExcluded(relPath: string): boolean {
17
- return /(^|\/)(test|tests|spec|specs|example|examples|docs?)(\/|$)/i.test(relPath)
18
- || /\.md$/i.test(relPath)
19
- || /\.(log|bak)$/i.test(relPath);
20
- }
21
-
22
- export function sourceWeight(relPath: string): number {
23
- for (const [re, w] of SOURCE_WEIGHT) if (re.test(relPath)) return w;
24
- return FALLBACK_WEIGHT;
25
- }
@@ -1,99 +0,0 @@
1
- // scan/spring.ts —— Spring 专属:datasource / data.redis / elasticsearch / data.mongodb 键映射 + profile 分组
2
- import type { DbTypeId } from "../types.js";
3
-
4
- export type SpringGroup = "datasource" | "redis" | "es" | "mongo" | "neo4j";
5
-
6
- export interface RawDbConfig {
7
- group: SpringGroup;
8
- dialectId?: DbTypeId; // 有 URL 时由 candidates 层经 registry 判定
9
- url?: string;
10
- host?: string;
11
- port?: string;
12
- username?: string;
13
- password?: string;
14
- database?: string;
15
- dbIndex?: string;
16
- profile: string;
17
- file: string;
18
- weight: number; // scoring 来源权重
19
- }
20
-
21
- export function profileOf(filename: string): string {
22
- const m = /application-([A-Za-z0-9_-]+)\.(?:yml|yaml|properties)$/i.exec(filename);
23
- return m ? m[1] : "default";
24
- }
25
-
26
- type Field = "url" | "username" | "password" | "host" | "port" | "database" | "dbIndex";
27
-
28
- const SPRING_KEYS: Array<[string, Field]> = [
29
- ["spring.datasource.url", "url"],
30
- ["spring.datasource.jdbc-url", "url"],
31
- ["spring.datasource.username", "username"],
32
- ["spring.datasource.password", "password"],
33
- ["spring.data.redis.host", "host"],
34
- ["spring.data.redis.port", "port"],
35
- ["spring.data.redis.password", "password"],
36
- ["spring.data.redis.database", "dbIndex"],
37
- ["spring.redis.host", "host"], // Spring Boot 2.x 旧前缀
38
- ["spring.redis.port", "port"],
39
- ["spring.redis.password", "password"],
40
- ["spring.redis.database", "dbIndex"],
41
- ["spring.elasticsearch.uris", "url"],
42
- ["spring.elasticsearch.url", "url"],
43
- ["spring.elasticsearch.username", "username"],
44
- ["spring.elasticsearch.password", "password"],
45
- ["spring.data.elasticsearch.uris", "url"],
46
- ["spring.data.elasticsearch.username", "username"],
47
- ["spring.data.elasticsearch.password", "password"],
48
- ["spring.data.mongodb.uri", "url"], // Spring Boot 2.x+(含 3.x)
49
- ["spring.mongodb.uri", "url"], // Spring Boot 1.x 旧前缀
50
- ["spring.neo4j.uri", "url"], // Spring Boot 3.x
51
- ["spring.data.neo4j.uri", "url"], // Spring Boot 2.x 旧前缀
52
- ["spring.neo4j.authentication.username", "username"],
53
- ["spring.neo4j.authentication.password", "password"],
54
- ["spring.data.neo4j.username", "username"],
55
- ["spring.data.neo4j.password", "password"],
56
- ];
57
-
58
- function groupOf(key: string): SpringGroup {
59
- if (key.includes("redis")) return "redis";
60
- if (key.includes("elasticsearch")) return "es";
61
- if (key.includes("mongodb")) return "mongo"; // 独立分组:避免与 datasource 的 url 字段互相覆盖
62
- if (key.includes("neo4j")) return "neo4j";
63
- return "datasource";
64
- }
65
-
66
- /** 扁平化 dotted 键 → 按组聚合成 RawDbConfig[] */
67
- export function springKeysToRaw(dotted: Record<string, string>, file: string, weight: number): RawDbConfig[] {
68
- const groups = new Map<SpringGroup, RawDbConfig>();
69
- for (const [key, field] of SPRING_KEYS) {
70
- const v = dotted[key];
71
- if (v === undefined || v === "") continue;
72
- const g = groupOf(key);
73
- const raw = groups.get(g) ?? { group: g, profile: profileOf(basename(file)), file, weight };
74
- (raw as Record<string, unknown>)[field] = v;
75
- groups.set(g, raw);
76
- }
77
- // baomidou dynamic-datasource(多数据源):spring.datasource.dynamic.datasource.<name>.<field>
78
- // 每个 <name> 独立成候选(与 Spec 单组 datasource 键互不覆盖)
79
- const dynamic = new Map<string, RawDbConfig>();
80
- const DYNAMIC_PREFIX = "spring.datasource.dynamic.datasource.";
81
- for (const [key, value] of Object.entries(dotted)) {
82
- if (!key.startsWith(DYNAMIC_PREFIX) || value === "") continue;
83
- const rest = key.slice(DYNAMIC_PREFIX.length); // "<name>.<field>"
84
- const dot = rest.indexOf(".");
85
- if (dot <= 0) continue;
86
- const name = rest.slice(0, dot);
87
- const field = rest.slice(dot + 1);
88
- if (field !== "url" && field !== "jdbc-url" && field !== "username" && field !== "password") continue;
89
- const raw = dynamic.get(name) ?? { group: "datasource" as const, profile: profileOf(basename(file)), file, weight };
90
- (raw as Record<string, unknown>)[field === "jdbc-url" ? "url" : field] = value;
91
- dynamic.set(name, raw);
92
- }
93
- return [...groups.values(), ...dynamic.values()];
94
- }
95
-
96
- function basename(p: string): string {
97
- const i = Math.max(p.lastIndexOf("/"), p.lastIndexOf("\\"));
98
- return i >= 0 ? p.slice(i + 1) : p;
99
- }