devassist-agent 1.0.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.
Files changed (59) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +467 -0
  3. package/bin/devassist.js +220 -0
  4. package/package.json +44 -0
  5. package/src/ai/adapter.js +464 -0
  6. package/src/ai/providers/claude.js +80 -0
  7. package/src/ai/providers/hunyuan.js +87 -0
  8. package/src/ai/providers/openai.js +74 -0
  9. package/src/ai/providers/qwen.js +81 -0
  10. package/src/cli/commands/ai.js +944 -0
  11. package/src/cli/commands/ask.js +79 -0
  12. package/src/cli/commands/backup.js +30 -0
  13. package/src/cli/commands/check.js +327 -0
  14. package/src/cli/commands/clean.js +130 -0
  15. package/src/cli/commands/comparison-report.js +326 -0
  16. package/src/cli/commands/convention.js +91 -0
  17. package/src/cli/commands/debt.js +49 -0
  18. package/src/cli/commands/deploy.js +88 -0
  19. package/src/cli/commands/diff.js +193 -0
  20. package/src/cli/commands/doctor.js +186 -0
  21. package/src/cli/commands/fix.js +195 -0
  22. package/src/cli/commands/init.js +431 -0
  23. package/src/cli/commands/inject.js +254 -0
  24. package/src/cli/commands/report.js +310 -0
  25. package/src/cli/commands/restore.js +78 -0
  26. package/src/cli/commands/schema.js +93 -0
  27. package/src/cli/commands/watch.js +212 -0
  28. package/src/cli/shared/code-context.js +51 -0
  29. package/src/cli/shared/config-loader.js +89 -0
  30. package/src/cli/shared/file-collector.js +116 -0
  31. package/src/cli/shared/inline-ignore.js +142 -0
  32. package/src/cli/shared/watch-list.js +281 -0
  33. package/src/core/event-bus.js +83 -0
  34. package/src/core/fsm.js +103 -0
  35. package/src/core/logger.js +54 -0
  36. package/src/core/rule-engine.js +117 -0
  37. package/src/index.js +64 -0
  38. package/src/modules/dev-time/arch-risk-assessor.js +250 -0
  39. package/src/modules/dev-time/code-quality-guard.js +1340 -0
  40. package/src/modules/dev-time/convention-store.js +201 -0
  41. package/src/modules/dev-time/impact-analyzer.js +292 -0
  42. package/src/modules/dev-time/pre-deploy-guard.js +284 -0
  43. package/src/modules/dev-time/schema-registry.js +284 -0
  44. package/src/modules/dev-time/tech-debt-tracker.js +225 -0
  45. package/src/modules/dev-time/version-manager.js +280 -0
  46. package/src/modules/runtime/channel-agent.js +404 -0
  47. package/src/modules/runtime/channel-middleware.js +316 -0
  48. package/src/modules/runtime/index.js +64 -0
  49. package/src/modules/runtime/infrastructure-guard.js +582 -0
  50. package/src/modules/runtime/notification-center.js +443 -0
  51. package/src/modules/runtime/schema-gatekeeper.js +664 -0
  52. package/src/modules/runtime/tool-registry.js +329 -0
  53. package/templates/ci/github-actions.yml +60 -0
  54. package/templates/ci/gitlab-ci.yml +30 -0
  55. package/templates/ci/pre-commit-hook.sh +18 -0
  56. package/templates/git-hooks/pre-commit +61 -0
  57. package/tests/run-all.js +434 -0
  58. package/tests/test-layer2.js +461 -0
  59. package/tests/test-new-rules.js +157 -0
@@ -0,0 +1,284 @@
1
+ /**
2
+ * PreDeployGuard - pre-deployment configuration checks.
3
+ *
4
+ * Defense target: "Deployment config errors" (27% of 88 failures).
5
+ * #1 - port 8080 occupied by supervisord
6
+ * #3 - deployment directory confusion (container vs host)
7
+ * #10 - MIME type misconfigured for .js files
8
+ * #11 - nginx route interception (static files blocked by proxy_pass)
9
+ * #13 - deployment file omission (forgot to copy config)
10
+ *
11
+ * This module runs BEFORE deploy to catch all of these.
12
+ */
13
+
14
+ const fs = require('fs');
15
+ const path = require('path');
16
+ const net = require('net');
17
+ const { Logger } = require('../../core/logger');
18
+ const log = new Logger('PreDeployGuard');
19
+
20
+ class PreDeployGuard {
21
+ constructor(projectRoot) {
22
+ this.projectRoot = projectRoot;
23
+ }
24
+
25
+ /**
26
+ * Check if a TCP port is available.
27
+ * Failure #1: supervisord occupied port 8080, app couldn't start.
28
+ */
29
+ async checkPort(port, host) {
30
+ host = host || '127.0.0.1';
31
+ return new Promise((resolve) => {
32
+ const tester = net.createServer()
33
+ .once('error', (err) => {
34
+ if (err.code === 'EADDRINUSE') {
35
+ resolve({
36
+ ruleId: 'pd-port-in-use',
37
+ severity: 'block',
38
+ category: 'pre-deploy',
39
+ message: `端口 ${port} 已被占用,应用将无法启动。`,
40
+ file: '(network)',
41
+ suggestion: `查找并终止占用端口 ${port} 的进程,或更换应用端口。执行:lsof -i :${port}(Linux/Mac)或 netstat -ano | findstr :${port}(Windows)`,
42
+ context: { port, host },
43
+ });
44
+ } else {
45
+ resolve({ ruleId: 'pd-port-check', severity: 'info', category: 'pre-deploy', message: `端口 ${port} 检查出错:${err.code}`, file: '(network)' });
46
+ }
47
+ })
48
+ .once('listening', () => {
49
+ tester.close();
50
+ resolve(null); // port is free
51
+ })
52
+ .listen(port, host);
53
+ });
54
+ }
55
+
56
+ /**
57
+ * Check nginx/routing config for common issues.
58
+ * Failure #11: nginx proxy_pass intercepted static file routes.
59
+ */
60
+ checkRouting(configPath) {
61
+ const findings = [];
62
+ const nginxConf = configPath || path.join(this.projectRoot, 'nginx.conf');
63
+ let content = '';
64
+ try { content = fs.readFileSync(nginxConf, 'utf-8'); } catch (_) { return findings; }
65
+
66
+ // Check: proxy_pass without excluding static files
67
+ if (/proxy_pass\s+http:\/\//.test(content) && !/location\s+.*\.(js|css|png|jpg|html)/.test(content)) {
68
+ findings.push({
69
+ ruleId: 'pd-nginx-static-intercept',
70
+ severity: 'block',
71
+ category: 'pre-deploy',
72
+ message: `nginx 配置了 proxy_pass 但没有显式的静态文件 location 块,静态文件可能被代理拦截。`,
73
+ file: nginxConf,
74
+ suggestion: `在 proxy_pass 之前添加静态文件的 location 块。示例:\n location ~* \\.(js|css|png|jpg|jpeg|gif|ico|html)$ { root /your/static/path; }`,
75
+ });
76
+ }
77
+
78
+ // Check: try_files with fallback to index.html (SPA routing)
79
+ if (/try_files/.test(content) && !/index\.html/.test(content)) {
80
+ findings.push({
81
+ ruleId: 'pd-nginx-no-spa-fallback',
82
+ severity: 'warn',
83
+ category: 'pre-deploy',
84
+ message: `try_files 指令缺少 index.html 回退——SPA 路由刷新时会 404。`,
85
+ file: nginxConf,
86
+ suggestion: `在 location 块中添加 'try_files $uri $uri/ /index.html;'`,
87
+ });
88
+ }
89
+
90
+ return findings;
91
+ }
92
+
93
+ /**
94
+ * Check MIME type configuration.
95
+ * Failure #10: .js files served as text/plain, browser refused to execute.
96
+ */
97
+ checkMimeTypes(configPath) {
98
+ const findings = [];
99
+ const mimeFile = configPath || path.join(this.projectRoot, 'mime.types');
100
+
101
+ // Check nginx config for MIME types
102
+ const nginxConf = path.join(this.projectRoot, 'nginx.conf');
103
+ if (fs.existsSync(nginxConf)) {
104
+ const content = fs.readFileSync(nginxConf, 'utf-8');
105
+ if (!/include\s+.*mime\.types/.test(content) && !/types\s*\{/.test(content)) {
106
+ findings.push({
107
+ ruleId: 'pd-mime-missing',
108
+ severity: 'block',
109
+ category: 'pre-deploy',
110
+ message: `nginx.conf 中未找到 MIME 类型配置,.js 文件可能以 text/plain 提供。`,
111
+ file: nginxConf,
112
+ suggestion: `添加 'include mime.types;' 或定义 types 块:\n types { text/javascript js; text/css css; ... }`,
113
+ });
114
+ }
115
+ }
116
+
117
+ // Check Express static config
118
+ const files = this._findFiles(this.projectRoot, /\.(js|ts)$/, ['node_modules', '.git']);
119
+ for (const file of files) {
120
+ let content = '';
121
+ try { content = fs.readFileSync(file, 'utf-8'); } catch (_) { continue; }
122
+ if (/express\.static/.test(content) && !/setHeaders|mime/.test(content)) {
123
+ // This is just an info — Express handles MIME by default, but worth noting
124
+ findings.push({
125
+ ruleId: 'pd-express-mime',
126
+ severity: 'info',
127
+ category: 'pre-deploy',
128
+ message: `express.static 未显式配置 MIME。默认行为通常没问题,但建议验证 .js 文件以 application/javascript 提供。`,
129
+ file: file,
130
+ suggestion: `如遇 MIME 问题,可配置:express.static(path, { setHeaders: (res, path) => { ... } })`,
131
+ });
132
+ break; // one is enough
133
+ }
134
+ }
135
+
136
+ return findings;
137
+ }
138
+
139
+ /**
140
+ * Check deployment file manifest.
141
+ * Failure #13: forgot to copy config file to deployment directory.
142
+ */
143
+ checkDeployManifest(manifestPath) {
144
+ const findings = [];
145
+ const manifestFile = manifestPath || path.join(this.projectRoot, '.devassist', 'deploy-manifest.json');
146
+
147
+ if (!fs.existsSync(manifestFile)) {
148
+ findings.push({
149
+ ruleId: 'pd-no-manifest',
150
+ severity: 'warn',
151
+ category: 'pre-deploy',
152
+ message: `未找到部署清单文件,部署时可能遗漏文件。`,
153
+ file: manifestFile,
154
+ suggestion: `创建 .devassist/deploy-manifest.json 列出所有必须部署的文件。执行:devassist init`,
155
+ });
156
+ return findings;
157
+ }
158
+
159
+ let manifest;
160
+ try { manifest = JSON.parse(fs.readFileSync(manifestFile, 'utf-8')); } catch (err) {
161
+ findings.push({
162
+ ruleId: 'pd-manifest-invalid',
163
+ severity: 'block',
164
+ category: 'pre-deploy',
165
+ message: `部署清单 JSON 无效:${err.message}`,
166
+ file: manifestFile,
167
+ });
168
+ return findings;
169
+ }
170
+
171
+ // Verify every file in manifest exists
172
+ const files = manifest.files || [];
173
+ for (const entry of files) {
174
+ const fullPath = path.join(this.projectRoot, entry.path || entry);
175
+ if (!fs.existsSync(fullPath)) {
176
+ findings.push({
177
+ ruleId: 'pd-missing-file',
178
+ severity: 'block',
179
+ category: 'pre-deploy',
180
+ message: `部署清单引用了不存在的文件:${entry.path || entry}`,
181
+ file: fullPath,
182
+ suggestion: `创建该文件或从清单中移除。对应故障 #13:部署时遗漏文件。`,
183
+ });
184
+ }
185
+ }
186
+
187
+ return findings;
188
+ }
189
+
190
+ /**
191
+ * Check for Cache-Busting configuration.
192
+ * Failure #15: users got old cached version after deployment.
193
+ */
194
+ checkCacheBusting() {
195
+ const findings = [];
196
+ const indexHtml = path.join(this.projectRoot, 'index.html');
197
+ if (!fs.existsSync(indexHtml)) return findings;
198
+
199
+ const content = fs.readFileSync(indexHtml, 'utf-8');
200
+
201
+ // Check if JS/CSS references have version query strings or hashes
202
+ const scriptTags = content.match(/<script[^>]+src=["'][^"']+["'][^>]*>/g) || [];
203
+ for (const tag of scriptTags) {
204
+ const srcMatch = tag.match(/src=["']([^"']+)["']/);
205
+ if (srcMatch && !/[?&]v=|[\?&]\w{8,}\./.test(srcMatch[1]) && !srcMatch[1].includes('cdn')) {
206
+ findings.push({
207
+ ruleId: 'pd-no-cache-bust',
208
+ severity: 'warn',
209
+ category: 'pre-deploy',
210
+ message: `脚本引用缺少缓存失效标记:${srcMatch[1]}`,
211
+ file: indexHtml,
212
+ suggestion: `添加版本查询参数(?v=时间戳)或内容哈希以防止旧缓存。对应故障 #15:部署后用户获取旧版本。`,
213
+ });
214
+ }
215
+ }
216
+
217
+ return findings;
218
+ }
219
+
220
+ /**
221
+ * Run all pre-deploy checks.
222
+ */
223
+ async runAll(config) {
224
+ const allFindings = [];
225
+
226
+ // Port check
227
+ if (config && config.port) {
228
+ const portResult = await this.checkPort(config.port);
229
+ if (portResult) allFindings.push(portResult);
230
+ }
231
+
232
+ // Routing check
233
+ allFindings.push(...this.checkRouting());
234
+
235
+ // MIME check
236
+ allFindings.push(...this.checkMimeTypes());
237
+
238
+ // Manifest check
239
+ allFindings.push(...this.checkDeployManifest());
240
+
241
+ // Cache-busting check
242
+ allFindings.push(...this.checkCacheBusting());
243
+
244
+ log.info(`部署前检查完成:${allFindings.length} 个发现(${allFindings.filter(f => f.severity === 'block').length} 个阻断)`);
245
+ return allFindings;
246
+ }
247
+
248
+ _findDir(dir, maxDepth, currentDepth) {
249
+ const results = [];
250
+ if (currentDepth >= maxDepth) return results;
251
+ let entries;
252
+ try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch (_) { return results; }
253
+ for (const entry of entries) {
254
+ if (entry.isDirectory()) {
255
+ if (entry.name === 'static' || entry.name === 'public') {
256
+ results.push(path.join(dir, entry.name));
257
+ }
258
+ if (!['node_modules', '.git', 'dist', 'build'].includes(entry.name)) {
259
+ results.push(...this._findDir(path.join(dir, entry.name), maxDepth, currentDepth + 1));
260
+ }
261
+ }
262
+ }
263
+ return results;
264
+ }
265
+
266
+ _findFiles(dir, extRegex, skipDirs) {
267
+ const results = [];
268
+ const walk = (d) => {
269
+ let entries;
270
+ try { entries = fs.readdirSync(d, { withFileTypes: true }); } catch (_) { return; }
271
+ for (const entry of entries) {
272
+ if (entry.isDirectory()) {
273
+ if (!skipDirs.includes(entry.name)) walk(path.join(d, entry.name));
274
+ } else if (extRegex.test(entry.name)) {
275
+ results.push(path.join(d, entry.name));
276
+ }
277
+ }
278
+ };
279
+ walk(dir);
280
+ return results;
281
+ }
282
+ }
283
+
284
+ module.exports = { PreDeployGuard };
@@ -0,0 +1,284 @@
1
+ /**
2
+ * SchemaRegistry - DB schema + API contract registry.
3
+ *
4
+ * Defense target: "Codebase decay" (35/88 failures).
5
+ * The worst pattern: data.list -> data.items -> data.records — three
6
+ * variants of the same field appeared because there was no central registry.
7
+ * Also: ChatSession fields added inside Docker container but not synced
8
+ * to source code, causing schema drift.
9
+ *
10
+ * This module:
11
+ * 1. Tracks DB table structure versions
12
+ * 2. Detects schema drift (container vs source code)
13
+ * 3. Enforces API contract consistency (registered response shape vs actual)
14
+ * 4. Enforces "ADD-only, no DROP" discipline
15
+ */
16
+
17
+ const fs = require('fs');
18
+ const path = require('path');
19
+ const { Logger } = require('../../core/logger');
20
+ const log = new Logger('SchemaRegistry');
21
+
22
+ class SchemaRegistry {
23
+ constructor(projectRoot) {
24
+ this.projectRoot = projectRoot;
25
+ this.configDir = path.join(projectRoot, '.devassist');
26
+ this.registryFile = path.join(this.configDir, 'schema-registry.json');
27
+ this.schemas = {}; // table -> { columns, version, history }
28
+ this.apiContracts = {}; // endpoint -> { responseShape, version }
29
+ this._load();
30
+ }
31
+
32
+ _load() {
33
+ if (fs.existsSync(this.registryFile)) {
34
+ try {
35
+ const data = JSON.parse(fs.readFileSync(this.registryFile, 'utf-8'));
36
+ this.schemas = data.schemas || {};
37
+ this.apiContracts = data.apiContracts || {};
38
+ log.info(`已加载 ${Object.keys(this.schemas).length} 张表结构,${Object.keys(this.apiContracts).length} 个 API 契约`);
39
+ } catch (err) {
40
+ log.warn(`加载 Schema 注册中心失败:${err.message}`);
41
+ }
42
+ }
43
+ }
44
+
45
+ _save() {
46
+ if (!fs.existsSync(this.configDir)) fs.mkdirSync(this.configDir, { recursive: true });
47
+ fs.writeFileSync(this.registryFile, JSON.stringify({
48
+ schemas: this.schemas,
49
+ apiContracts: this.apiContracts,
50
+ }, null, 2), 'utf-8');
51
+ }
52
+
53
+ /**
54
+ * Register or update a DB table schema.
55
+ * @param {string} table - table name
56
+ * @param {object} schema - { columns: [{ name, type, nullable, default }], version }
57
+ */
58
+ registerTable(table, schema) {
59
+ const existing = this.schemas[table];
60
+ const newColumns = schema.columns || [];
61
+ const findings = [];
62
+
63
+ if (existing) {
64
+ // Detect removed columns (violation of ADD-only discipline)
65
+ const existingNames = existing.columns.map(c => c.name);
66
+ const newNames = newColumns.map(c => c.name);
67
+
68
+ const removed = existingNames.filter(n => !newNames.includes(n));
69
+ if (removed.length > 0) {
70
+ findings.push({
71
+ ruleId: 'sr-column-removed',
72
+ severity: 'block',
73
+ category: 'schema',
74
+ message: `表 '${table}' 删除了字段:${removed.join(', ')}。DROP COLUMN 需要显式批准。`,
75
+ file: '(database)',
76
+ suggestion: `恢复字段或获得对此 Schema 变更的显式批准。删除字段可能破坏引用它们的现有代码。`,
77
+ context: { table, removedColumns: removed },
78
+ });
79
+ }
80
+
81
+ // Detect new columns (allowed, but log for tracking)
82
+ const added = newNames.filter(n => !existingNames.includes(n));
83
+ if (added.length > 0) {
84
+ log.info(`表 '${table}' 新增字段:${added.join(', ')}(仅追加原则:通过)`);
85
+ }
86
+
87
+ // Detect type changes (dangerous)
88
+ for (const newCol of newColumns) {
89
+ const oldCol = existing.columns.find(c => c.name === newCol.name);
90
+ if (oldCol && oldCol.type !== newCol.type) {
91
+ findings.push({
92
+ ruleId: 'sr-type-changed',
93
+ severity: 'warn',
94
+ category: 'schema',
95
+ message: `字段 '${table}.${newCol.name}' 类型变更:${oldCol.type} -> ${newCol.type}`,
96
+ file: '(database)',
97
+ suggestion: `类型变更可能导致数据丢失。确保存在迁移脚本并用真实数据测试。`,
98
+ context: { table, column: newCol.name, oldType: oldCol.type, newType: newCol.type },
99
+ });
100
+ }
101
+ }
102
+
103
+ // Update with version bump
104
+ this.schemas[table] = {
105
+ ...schema,
106
+ version: (existing.version || 1) + 1,
107
+ history: [...(existing.history || []), { version: existing.version, columns: existing.columns, at: new Date().toISOString() }],
108
+ };
109
+ } else {
110
+ // New table
111
+ this.schemas[table] = { ...schema, version: 1, history: [] };
112
+ log.info(`注册新表:${table}`);
113
+ }
114
+
115
+ this._save();
116
+ return findings;
117
+ }
118
+
119
+ /**
120
+ * Register an API response contract.
121
+ * @param {string} endpoint - e.g. "GET /api/chat/list"
122
+ * @param {object} contract - { responseShape: { fields, dataKey } }
123
+ *
124
+ * dataKey is the key used for list responses — e.g. "data.list" means
125
+ * the API returns { code: 200, data: { list: [...] } }
126
+ */
127
+ registerAPI(endpoint, contract) {
128
+ const existing = this.apiContracts[endpoint];
129
+ this.apiContracts[endpoint] = { ...contract, version: existing ? existing.version + 1 : 1 };
130
+ this._save();
131
+ log.info(`注册 API 契约:${endpoint} (v${this.apiContracts[endpoint].version})`);
132
+ }
133
+
134
+ /**
135
+ * Check an API response against its registered contract.
136
+ * This catches the data.list -> data.items drift problem.
137
+ */
138
+ checkAPIResponse(endpoint, responseData) {
139
+ const contract = this.apiContracts[endpoint];
140
+ if (!contract) return null; // no contract registered, skip
141
+
142
+ const findings = [];
143
+ const expectedDataKey = contract.dataKey || 'data.list';
144
+
145
+ // Navigate to the expected data location
146
+ const keys = expectedDataKey.split('.');
147
+ let actual = responseData;
148
+ let found = true;
149
+ for (const key of keys) {
150
+ if (actual && typeof actual === 'object' && key in actual) {
151
+ actual = actual[key];
152
+ } else {
153
+ found = false;
154
+ break;
155
+ }
156
+ }
157
+
158
+ if (!found) {
159
+ // The registered data key doesn't exist — check if a variant is being used
160
+ const variants = this._findListVariants(responseData);
161
+ if (variants.length > 0) {
162
+ findings.push({
163
+ ruleId: 'sr-api-key-drift',
164
+ severity: 'block',
165
+ category: 'schema',
166
+ message: `API '${endpoint}' 返回 data.${variants[0]} 但契约期望 ${expectedDataKey}。这是"三变体"反模式。`,
167
+ file: endpoint,
168
+ suggestion: `修复 API 使其返回 ${expectedDataKey},或在变更有意时更新契约。不要添加前端兼容补丁。`,
169
+ context: { endpoint, expected: expectedDataKey, actual: `data.${variants[0]}`, allVariants: variants },
170
+ });
171
+ } else {
172
+ findings.push({
173
+ ruleId: 'sr-api-missing-key',
174
+ severity: 'warn',
175
+ category: 'schema',
176
+ message: `API '${endpoint}' 响应缺少期望的键:${expectedDataKey}`,
177
+ file: endpoint,
178
+ suggestion: `检查 API 响应结构是否已变更。`,
179
+ });
180
+ }
181
+ }
182
+
183
+ // Check response shape fields
184
+ if (contract.responseShape && contract.responseShape.fields && found && Array.isArray(actual)) {
185
+ if (actual.length > 0) {
186
+ const firstItem = actual[0];
187
+ const expectedFields = contract.responseShape.fields;
188
+ const actualFields = Object.keys(firstItem);
189
+ const missingFields = expectedFields.filter(f => !actualFields.includes(f));
190
+ if (missingFields.length > 0) {
191
+ findings.push({
192
+ ruleId: 'sr-api-missing-fields',
193
+ severity: 'warn',
194
+ category: 'schema',
195
+ message: `API '${endpoint}' 响应条目缺少字段:${missingFields.join(', ')}`,
196
+ file: endpoint,
197
+ suggestion: `在 API 响应中添加缺失字段,或更新契约。`,
198
+ });
199
+ }
200
+ }
201
+ }
202
+
203
+ return findings.length > 0 ? findings : null;
204
+ }
205
+
206
+ /**
207
+ * Find list-like keys in a response object (detects data.list/items/records/rows variants).
208
+ */
209
+ _findListVariants(obj, prefix) {
210
+ const variants = [];
211
+ prefix = prefix || '';
212
+ if (!obj || typeof obj !== 'object') return variants;
213
+
214
+ const listLikeKeys = ['list', 'items', 'records', 'rows', 'data', 'results', 'array'];
215
+
216
+ for (const [key, value] of Object.entries(obj)) {
217
+ const fullKey = prefix ? `${prefix}.${key}` : key;
218
+ if (Array.isArray(value) && listLikeKeys.includes(key)) {
219
+ variants.push(fullKey);
220
+ } else if (value && typeof value === 'object' && !Array.isArray(value)) {
221
+ variants.push(...this._findListVariants(value, fullKey));
222
+ }
223
+ }
224
+ return variants;
225
+ }
226
+
227
+ /**
228
+ * Import schema from a SQL DDL file.
229
+ * Parses CREATE TABLE statements to extract column definitions.
230
+ */
231
+ importFromDDL(sqlContent) {
232
+ const findings = [];
233
+ const tableRegex = /CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?[`"]?(\w+)[`"]?\s*\(([^;]+)\)/gi;
234
+ let match;
235
+
236
+ while ((match = tableRegex.exec(sqlContent)) !== null) {
237
+ const tableName = match[1];
238
+ const body = match[2];
239
+
240
+ // Parse columns
241
+ const columns = [];
242
+ const lines = body.split(',').map(l => l.trim()).filter(l => l && !l.startsWith('--'));
243
+ for (const line of lines) {
244
+ // Skip constraints (PRIMARY KEY, FOREIGN KEY, etc.)
245
+ if (/^(PRIMARY|FOREIGN|UNIQUE|CONSTRAINT|INDEX|KEY)/i.test(line)) continue;
246
+
247
+ const colMatch = line.match(/[`"]?(\w+)[`"]?\s+(\w+)(?:\s*\([^)]+\))?(.*)/);
248
+ if (colMatch) {
249
+ columns.push({
250
+ name: colMatch[1],
251
+ type: colMatch[2].toUpperCase(),
252
+ nullable: !/NOT\s+NULL/i.test(colMatch[3] || ''),
253
+ default: (colMatch[3] || '').match(/DEFAULT\s+([\w'"]+)/i)?.[1] || null,
254
+ });
255
+ }
256
+ }
257
+
258
+ if (columns.length > 0) {
259
+ const tableFindings = this.registerTable(tableName, { columns });
260
+ findings.push(...tableFindings);
261
+ }
262
+ }
263
+
264
+ log.info(`从 DDL 导入 ${Object.keys(this.schemas).length} 张表`);
265
+ return findings;
266
+ }
267
+
268
+ getStats() {
269
+ return {
270
+ tables: Object.keys(this.schemas).length,
271
+ apiContracts: Object.keys(this.apiContracts).length,
272
+ totalColumns: Object.values(this.schemas).reduce((sum, t) => sum + (t.columns?.length || 0), 0),
273
+ };
274
+ }
275
+
276
+ exportSnapshot() {
277
+ return {
278
+ schemas: this.schemas,
279
+ apiContracts: this.apiContracts,
280
+ };
281
+ }
282
+ }
283
+
284
+ module.exports = { SchemaRegistry };