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,201 @@
1
+ /**
2
+ * ConventionStore - project convention memory.
3
+ *
4
+ * Defense target: "AI forgetting early conventions" (32/88 failures).
5
+ * The #1 example: data.list vs data.items vs data.records — three variants
6
+ * appeared because AI forgot the original naming convention across sessions.
7
+ *
8
+ * This module:
9
+ * 1. Stores conventions in .devassist/conventions.json (persists across sessions)
10
+ * 2. Injects relevant conventions into AI context automatically
11
+ * 3. Checks code against conventions and flags violations
12
+ * 4. Records ADRs (Architecture Decision Records) — not just "what" but "why"
13
+ */
14
+
15
+ const fs = require('fs');
16
+ const path = require('path');
17
+ const { Logger } = require('../../core/logger');
18
+ const log = new Logger('ConventionStore');
19
+
20
+ class ConventionStore {
21
+ constructor(projectRoot) {
22
+ this.projectRoot = projectRoot;
23
+ this.configDir = path.join(projectRoot, '.devassist');
24
+ this.configFile = path.join(this.configDir, 'conventions.json');
25
+ this.conventions = [];
26
+ this.adrs = [];
27
+ this._load();
28
+ }
29
+
30
+ _load() {
31
+ if (fs.existsSync(this.configFile)) {
32
+ try {
33
+ const data = JSON.parse(fs.readFileSync(this.configFile, 'utf-8'));
34
+ this.conventions = data.conventions || [];
35
+ this.adrs = data.adrs || [];
36
+ log.info(`已加载 ${this.conventions.length} 条约定,${this.adrs.length} 条 ADR`);
37
+ } catch (err) {
38
+ log.warn(`加载约定失败:${err.message}`);
39
+ }
40
+ }
41
+ }
42
+
43
+ _save() {
44
+ if (!fs.existsSync(this.configDir)) {
45
+ fs.mkdirSync(this.configDir, { recursive: true });
46
+ }
47
+ fs.writeFileSync(this.configFile, JSON.stringify({
48
+ conventions: this.conventions,
49
+ adrs: this.adrs,
50
+ }, null, 2), 'utf-8');
51
+ }
52
+
53
+ /**
54
+ * Register a new convention.
55
+ * @param {object} conv - { id, category, rule, description, examples, severity }
56
+ */
57
+ add(conv) {
58
+ if (!conv.id || !conv.rule) {
59
+ throw new Error('约定必须有 id 和 rule');
60
+ }
61
+ // Prevent duplicates
62
+ const existing = this.conventions.find(c => c.id === conv.id);
63
+ if (existing) {
64
+ // Clean up _example flag if present
65
+ delete existing._example;
66
+ Object.assign(existing, conv, { updatedAt: new Date().toISOString() });
67
+ log.info(`已更新约定:${conv.id}`);
68
+ } else {
69
+ this.conventions.push({ ...conv, createdAt: new Date().toISOString() });
70
+ log.info(`已添加约定:${conv.id}`);
71
+ }
72
+ this._save();
73
+ }
74
+
75
+ /**
76
+ * Record an Architecture Decision Record.
77
+ * @param {object} adr - { id, title, context, decision, consequences, date }
78
+ */
79
+ addADR(adr) {
80
+ if (!adr.id || !adr.decision) {
81
+ throw new Error('ADR 必须有 id 和 decision');
82
+ }
83
+ this.adrs.push({ ...adr, date: adr.date || new Date().toISOString() });
84
+ this._save();
85
+ log.info(`已记录 ADR:${adr.id}`);
86
+ }
87
+
88
+ /**
89
+ * Get all conventions relevant to a given context (file type, module, etc.)
90
+ * This is what gets injected into the AI's prompt.
91
+ */
92
+ getRelevant(context) {
93
+ let relevant = this.conventions;
94
+
95
+ if (context && context.fileType) {
96
+ relevant = relevant.filter(c =>
97
+ !c.appliesTo || c.appliesTo.includes(context.fileType) || c.appliesTo.includes('*')
98
+ );
99
+ }
100
+
101
+ return relevant.map(c => ({
102
+ id: c.id,
103
+ rule: c.rule,
104
+ description: c.description || '',
105
+ examples: c.examples || [],
106
+ }));
107
+ }
108
+
109
+ /**
110
+ * Generate a conventions summary for AI prompt injection.
111
+ * Returns a string block that can be prepended to any AI code generation request.
112
+ */
113
+ getPromptBlock(context) {
114
+ const relevant = this.getRelevant(context);
115
+ if (relevant.length === 0) return '';
116
+
117
+ let block = '## 项目约定(必须遵守)\n\n';
118
+ for (const c of relevant) {
119
+ block += `- **${c.id}**:${c.rule}\n`;
120
+ if (c.description) block += ` ${c.description}\n`;
121
+ if (c.examples && c.examples.length > 0) {
122
+ block += ` 示例:${c.examples.join(',')}\n`;
123
+ }
124
+ }
125
+ block += '\n';
126
+ return block;
127
+ }
128
+
129
+ /**
130
+ * Check file content against conventions.
131
+ * Returns findings for any violations.
132
+ */
133
+ check(filePath, content) {
134
+ const findings = [];
135
+ const ext = path.extname(filePath);
136
+ const fileType = ext.replace('.', '');
137
+
138
+ for (const conv of this.conventions) {
139
+ if (conv.appliesTo && !conv.appliesTo.includes(fileType) && !conv.appliesTo.includes('*')) {
140
+ continue;
141
+ }
142
+
143
+ // Pattern-based check: if convention has a `forbid` regex pattern
144
+ if (conv.forbid) {
145
+ const regex = new RegExp(conv.forbid, 'g');
146
+ let match;
147
+ while ((match = regex.exec(content)) !== null) {
148
+ const lines = content.substring(0, match.index).split('\n');
149
+ findings.push({
150
+ ruleId: `conv-${conv.id}`,
151
+ severity: conv.severity || 'warn',
152
+ category: 'convention',
153
+ message: `约定违规:${conv.rule}`,
154
+ file: filePath,
155
+ line: lines.length,
156
+ suggestion: conv.description || '请修改以符合项目约定',
157
+ context: { match: match[0], conventionId: conv.id },
158
+ });
159
+ }
160
+ }
161
+
162
+ // Pattern-based check: if convention has a `require` regex pattern
163
+ if (conv.require) {
164
+ const regex = new RegExp(conv.require);
165
+ if (!regex.test(content)) {
166
+ findings.push({
167
+ ruleId: `conv-${conv.id}`,
168
+ severity: conv.severity || 'warn',
169
+ category: 'convention',
170
+ message: `缺少必需的模式:${conv.rule}`,
171
+ file: filePath,
172
+ suggestion: conv.description || '请添加所需的模式',
173
+ context: { conventionId: conv.id },
174
+ });
175
+ }
176
+ }
177
+ }
178
+
179
+ return findings;
180
+ }
181
+
182
+ list() {
183
+ return {
184
+ conventions: this.conventions,
185
+ adrs: this.adrs,
186
+ };
187
+ }
188
+
189
+ remove(id) {
190
+ const before = this.conventions.length;
191
+ this.conventions = this.conventions.filter(c => c.id !== id);
192
+ if (this.conventions.length < before) {
193
+ this._save();
194
+ log.info(`已移除约定:${id}`);
195
+ return true;
196
+ }
197
+ return false;
198
+ }
199
+ }
200
+
201
+ module.exports = { ConventionStore };
@@ -0,0 +1,292 @@
1
+ /**
2
+ * ImpactAnalyzer - dependency graph + change impact analysis.
3
+ *
4
+ * Defense target: "AI local optimization" (41/88 failures — the #1 killer).
5
+ * The worst case: AI rewrote app.js from 2720 lines to 106 lines because
6
+ * it only saw the current file, not the global dependency graph.
7
+ *
8
+ * This module:
9
+ * 1. Builds a dependency graph by scanning import/require/reference
10
+ * 2. Before any file modification, checks what depends on it
11
+ * 3. Detects dangerous changes: massive line reduction, signature changes
12
+ * 4. Flags "patch" patterns that should be fixed at the source
13
+ */
14
+
15
+ const fs = require('fs');
16
+ const path = require('path');
17
+ const { Logger } = require('../../core/logger');
18
+ const log = new Logger('ImpactAnalyzer');
19
+
20
+ class ImpactAnalyzer {
21
+ constructor(projectRoot) {
22
+ this.projectRoot = projectRoot;
23
+ this.graph = new Map(); // filePath -> { imports: Set, importedBy: Set, lines: number, exports: Set }
24
+ this.configDir = path.join(projectRoot, '.devassist');
25
+ this.graphFile = path.join(this.configDir, 'dependency-graph.json');
26
+ }
27
+
28
+ /**
29
+ * Scan the project and build/update the dependency graph.
30
+ */
31
+ buildGraph(srcDirs) {
32
+ const dirs = srcDirs || ['src', 'lib', 'static', 'public', 'app'];
33
+ const files = [];
34
+
35
+ for (const dir of dirs) {
36
+ const fullDir = path.join(this.projectRoot, dir);
37
+ if (!fs.existsSync(fullDir)) continue;
38
+ this._scanDir(fullDir, files);
39
+ }
40
+
41
+ // Also scan root-level HTML/JS files
42
+ this._scanDir(this.projectRoot, files, true);
43
+
44
+ // Build graph
45
+ for (const file of files) {
46
+ const rel = path.relative(this.projectRoot, file);
47
+ if (!this.graph.has(rel)) {
48
+ this.graph.set(rel, { imports: new Set(), importedBy: new Set(), lines: 0, exports: new Set() });
49
+ }
50
+ const node = this.graph.get(rel);
51
+
52
+ let content = '';
53
+ try { content = fs.readFileSync(file, 'utf-8'); } catch (_) { continue; }
54
+ node.lines = content.split('\n').length;
55
+
56
+ // Detect imports/requires
57
+ const importRegex = /(?:require\s*\(\s*['"`]([^'"`]+)['"`]|import\s+.*from\s+['"`]([^'"`]+)['"`]|src\s*=\s*['"`]([^'"`]+\.js)['"`])/g;
58
+ let match;
59
+ while ((match = importRegex.exec(content)) !== null) {
60
+ const imported = match[1] || match[2] || match[3];
61
+ if (imported) {
62
+ // Normalize: resolve relative paths
63
+ let resolved = imported;
64
+ if (imported.startsWith('.')) {
65
+ resolved = path.normalize(path.join(path.dirname(rel), imported));
66
+ // Add extension if missing
67
+ if (!path.extname(resolved)) {
68
+ for (const ext of ['.js', '.ts', '.jsx', '.tsx']) {
69
+ if (this.graph.has(resolved + ext) || files.some(f => path.relative(this.projectRoot, f) === resolved + ext)) {
70
+ resolved += ext;
71
+ break;
72
+ }
73
+ }
74
+ }
75
+ }
76
+ node.imports.add(resolved);
77
+
78
+ // Reverse link
79
+ if (!this.graph.has(resolved)) {
80
+ this.graph.set(resolved, { imports: new Set(), importedBy: new Set(), lines: 0, exports: new Set() });
81
+ }
82
+ this.graph.get(resolved).importedBy.add(rel);
83
+ }
84
+ }
85
+
86
+ // Detect exports
87
+ const exportRegex = /(?:export\s+(?:default\s+)?(?:function|class|const|let|var)\s+(\w+)|module\.exports\s*=\s*(\w+))/g;
88
+ while ((match = exportRegex.exec(content)) !== null) {
89
+ node.exports.add(match[1] || match[2]);
90
+ }
91
+ }
92
+
93
+ this._saveGraph();
94
+ log.info(`依赖图已构建:${this.graph.size} 个节点,${this._countEdges()} 条边`);
95
+ return this.graph;
96
+ }
97
+
98
+ _scanDir(dir, results, rootOnly) {
99
+ let entries;
100
+ try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch (_) { return; }
101
+ for (const entry of entries) {
102
+ // Skip node_modules, .git, .devassist, etc.
103
+ if (['node_modules', '.git', '.devassist', 'dist', 'build'].includes(entry.name)) continue;
104
+ const full = path.join(dir, entry.name);
105
+ if (entry.isDirectory() && !rootOnly) {
106
+ this._scanDir(full, results);
107
+ } else if (entry.isFile() && /\.(js|ts|jsx|tsx|html|vue)$/.test(entry.name)) {
108
+ results.push(full);
109
+ }
110
+ }
111
+ }
112
+
113
+ _countEdges() {
114
+ let count = 0;
115
+ for (const node of this.graph.values()) count += node.imports.size;
116
+ return count;
117
+ }
118
+
119
+ _saveGraph() {
120
+ const serializable = {};
121
+ for (const [file, node] of this.graph) {
122
+ serializable[file] = {
123
+ imports: Array.from(node.imports),
124
+ importedBy: Array.from(node.importedBy),
125
+ lines: node.lines,
126
+ exports: Array.from(node.exports),
127
+ };
128
+ }
129
+ if (!fs.existsSync(this.configDir)) fs.mkdirSync(this.configDir, { recursive: true });
130
+ fs.writeFileSync(this.graphFile, JSON.stringify(serializable, null, 2), 'utf-8');
131
+ }
132
+
133
+ _loadGraph() {
134
+ if (fs.existsSync(this.graphFile)) {
135
+ try {
136
+ const data = JSON.parse(fs.readFileSync(this.graphFile, 'utf-8'));
137
+ this.graph.clear();
138
+ for (const [file, node] of Object.entries(data)) {
139
+ this.graph.set(file, {
140
+ imports: new Set(node.imports || []),
141
+ importedBy: new Set(node.importedBy || []),
142
+ lines: node.lines || 0,
143
+ exports: new Set(node.exports || []),
144
+ });
145
+ }
146
+ return true;
147
+ } catch (_) { return false; }
148
+ }
149
+ return false;
150
+ }
151
+
152
+ /**
153
+ * Analyze the impact of modifying a file.
154
+ * Returns a risk assessment.
155
+ */
156
+ analyzeChange(filePath, newContent) {
157
+ if (this.graph.size === 0) this._loadGraph();
158
+ if (this.graph.size === 0) this.buildGraph();
159
+
160
+ const rel = path.relative(this.projectRoot, filePath);
161
+ const node = this.graph.get(rel);
162
+ const findings = [];
163
+
164
+ if (!node) {
165
+ // New file — low risk
166
+ return { risk: 'low', findings, dependents: 0, oldLines: 0, newLines: newContent ? newContent.split('\n').length : 0 };
167
+ }
168
+
169
+ const oldLines = node.lines;
170
+ const newLines = newContent ? newContent.split('\n').length : 0;
171
+ const lineDelta = newLines - oldLines;
172
+ const dependents = node.importedBy.size;
173
+
174
+ // CRITICAL: massive line reduction (the app.js 2720→106 case)
175
+ if (oldLines > 200 && lineDelta < -oldLines * 0.5) {
176
+ findings.push({
177
+ ruleId: 'ia-massive-reduction',
178
+ severity: 'block',
179
+ category: 'impact',
180
+ message: `严重:文件从 ${oldLines} 行缩减至 ${newLines} 行(${Math.round((lineDelta / oldLines) * 100)}%)。这可能是一次意外覆盖。`,
181
+ file: filePath,
182
+ suggestion: `这符合已知故障模式:AI 将大文件覆盖为桩代码。请确认此变更是有意为之后再继续。`,
183
+ context: { oldLines, newLines, lineDelta, dependents },
184
+ });
185
+ }
186
+
187
+ // HIGH: critical file with many dependents being modified
188
+ if (dependents >= 5) {
189
+ findings.push({
190
+ ruleId: 'ia-high-dependency',
191
+ severity: 'warn',
192
+ category: 'impact',
193
+ message: `该文件被 ${dependents} 个其他文件引用,此处的修改影响面较大。`,
194
+ file: filePath,
195
+ suggestion: `检查所有依赖文件是否存在破坏性变更。依赖文件:${Array.from(node.importedBy).slice(0, 5).join(',')}${dependents > 5 ? '...' : ''}`,
196
+ context: { dependents, dependentFiles: Array.from(node.importedBy) },
197
+ });
198
+ }
199
+
200
+ // WARN: patch pattern detection — adding compatibility shim instead of fixing source
201
+ if (newContent) {
202
+ const patchPatterns = [
203
+ /if\s*\(.*data\.items.*\)\s*.*data\.list\s*=/, // data.items → data.list compat
204
+ /if\s*\(.*data\.records.*\)\s*.*data\.list\s*=/, // data.records → data.list compat
205
+ /\.replaceAll\s*\(/, // using replaceAll when the source should be fixed
206
+ ];
207
+ for (const pattern of patchPatterns) {
208
+ if (pattern.test(newContent)) {
209
+ findings.push({
210
+ ruleId: 'ia-patch-detected',
211
+ severity: 'warn',
212
+ category: 'impact',
213
+ message: `检测到补丁模式——这看起来是前端兼容性补丁。建议直接修复 API 响应格式。`,
214
+ file: filePath,
215
+ suggestion: `不要添加兼容性代码,而是修复数据源使其返回正确格式。这可以避免"三种变体"问题(data.list/data.items/data.records)。`,
216
+ });
217
+ break;
218
+ }
219
+ }
220
+ }
221
+
222
+ // Export removal detection
223
+ if (newContent && node.exports.size > 0) {
224
+ for (const exp of node.exports) {
225
+ const exportRegex = new RegExp(`(?:export\\s+(?:default\\s+)?(?:function|class|const|let|var)\\s+${exp}|module\\.exports\\s*=\\s*${exp})`);
226
+ if (!exportRegex.test(newContent)) {
227
+ findings.push({
228
+ ruleId: 'ia-export-removed',
229
+ severity: 'block',
230
+ category: 'impact',
231
+ message: `导出 '${exp}' 已被移除,但 ${dependents} 个文件依赖此模块。`,
232
+ file: filePath,
233
+ suggestion: `移除导出会破坏依赖文件。请保留导出或更新所有 ${dependents} 个依赖方。`,
234
+ context: { removedExport: exp, dependents: Array.from(node.importedBy) },
235
+ });
236
+ }
237
+ }
238
+ }
239
+
240
+ const risk = findings.some(f => f.severity === 'block') ? 'critical'
241
+ : findings.some(f => f.severity === 'warn') ? 'high'
242
+ : 'low';
243
+
244
+ return { risk, findings, dependents, oldLines, newLines, lineDelta };
245
+ }
246
+
247
+ /**
248
+ * Get all files that would be affected by changing the given file.
249
+ */
250
+ getAffectedFiles(filePath, depth) {
251
+ if (this.graph.size === 0) this._loadGraph();
252
+ const rel = path.relative(this.projectRoot, filePath);
253
+ const affected = new Set();
254
+ const maxDepth = depth || 3;
255
+
256
+ const traverse = (file, currentDepth) => {
257
+ if (currentDepth >= maxDepth) return;
258
+ const node = this.graph.get(file);
259
+ if (!node) return;
260
+ for (const dep of node.importedBy) {
261
+ if (!affected.has(dep)) {
262
+ affected.add(dep);
263
+ traverse(dep, currentDepth + 1);
264
+ }
265
+ }
266
+ };
267
+
268
+ traverse(rel, 0);
269
+ return Array.from(affected);
270
+ }
271
+
272
+ getStats() {
273
+ let totalImports = 0;
274
+ let maxDependents = 0;
275
+ let mostDependedFile = null;
276
+ for (const [file, node] of this.graph) {
277
+ totalImports += node.imports.size;
278
+ if (node.importedBy.size > maxDependents) {
279
+ maxDependents = node.importedBy.size;
280
+ mostDependedFile = file;
281
+ }
282
+ }
283
+ return {
284
+ totalFiles: this.graph.size,
285
+ totalImports,
286
+ maxDependents,
287
+ mostDependedFile,
288
+ };
289
+ }
290
+ }
291
+
292
+ module.exports = { ImpactAnalyzer };