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,79 @@
1
+ /**
2
+ * devassist ask <question> - Ask AI a question about your codebase
3
+ */
4
+
5
+ const fs = require('fs');
6
+ const path = require('path');
7
+ const ai = require('../../ai/adapter');
8
+ const qwen = require('../../ai/providers/qwen');
9
+ const openai = require('../../ai/providers/openai');
10
+ const claude = require('../../ai/providers/claude');
11
+ const hunyuan = require('../../ai/providers/hunyuan');
12
+ const { ConventionStore } = require('../../modules/dev-time/convention-store');
13
+
14
+ ai.registerProvider('qwen', qwen);
15
+ ai.registerProvider('openai', openai);
16
+ ai.registerProvider('claude', claude);
17
+ ai.registerProvider('hunyuan', hunyuan);
18
+
19
+ module.exports = async function ask(projectRoot, args, ui) {
20
+ const { printHeader, c } = ui;
21
+ printHeader('Ask AI');
22
+
23
+ const question = args.filter(a => !a.startsWith('--')).join(' ');
24
+ if (!question) {
25
+ console.log(` Usage: devassist ask ${c.gray('<your question>')}\n`);
26
+ console.log(` ${c.gray('Example:')} devassist ask "Why is my API returning data.items instead of data.list?"\n`);
27
+ return;
28
+ }
29
+
30
+ // Load AI config
31
+ const configPath = path.join(projectRoot, '.devassist', 'ai-config.json');
32
+ let config = null;
33
+ if (fs.existsSync(configPath)) {
34
+ try {
35
+ config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
36
+ // Override with env vars
37
+ if (!config.apiKey) {
38
+ const envMap = { qwen: 'DASHSCOPE_API_KEY', openai: 'OPENAI_API_KEY', claude: 'ANTHROPIC_API_KEY', hunyuan: 'HUNYUAN_API_KEY' };
39
+ const envVar = envMap[config.provider];
40
+ if (envVar && process.env[envVar]) {
41
+ config.apiKey = process.env[envVar];
42
+ config.enabled = true;
43
+ }
44
+ }
45
+ } catch (_) {}
46
+ }
47
+
48
+ if (!config || !config.enabled || !config.provider) {
49
+ console.log(` ${c.yellow('AI 尚未配置。')}\n`);
50
+ console.log(` ${c.gray('启用步骤:')}`);
51
+ console.log(` 1. 编辑 .devassist/ai-config.json`);
52
+ console.log(` 2. 设置 "provider": "qwen"(或 "openai" / "claude" / "hunyuan")`);
53
+ console.log(` 3. 设置 "apiKey": "你的API密钥"`);
54
+ console.log(` 4. 设置 "enabled": true`);
55
+ console.log(`\n ${c.gray('或设置环境变量:')}`);
56
+ console.log(` ${c.bold('DASHSCOPE_API_KEY')} 通义千问`);
57
+ console.log(` ${c.bold('OPENAI_API_KEY')} OpenAI`);
58
+ console.log(` ${c.bold('ANTHROPIC_API_KEY')} Claude`);
59
+ console.log(` ${c.bold('HUNYUAN_API_KEY')} 腾讯混元\n`);
60
+ return;
61
+ }
62
+
63
+ // Load conventions as context
64
+ const conventionStore = new ConventionStore(projectRoot);
65
+ const conventions = conventionStore.getPromptBlock({});
66
+
67
+ console.log(` ${c.gray('Question:')} ${question}`);
68
+ console.log(` ${c.gray('Provider:')} ${config.provider} (${config.model || 'default'})\n`);
69
+ console.log(` ${c.gray('Thinking...')}\n`);
70
+
71
+ const result = await ai.ask(config, question, { conventions });
72
+
73
+ if (result.unavailable || result.error) {
74
+ console.log(` ${c.red('AI error:')} ${result.content}\n`);
75
+ } else {
76
+ console.log(` ${result.content}\n`);
77
+ if (result.model) console.log(` ${c.gray('(model: ' + result.model + ')')}\n`);
78
+ }
79
+ };
@@ -0,0 +1,30 @@
1
+ /**
2
+ * devassist backup - Create incremental backup
3
+ * devassist restore - Restore from backup (non-destructive)
4
+ */
5
+
6
+ const { VersionManager } = require('../../modules/dev-time/version-manager');
7
+
8
+ module.exports = function backup(projectRoot, args, ui) {
9
+ const { printHeader, c } = ui;
10
+ printHeader('创建备份');
11
+
12
+ const vm = new VersionManager(projectRoot);
13
+ const label = args.find(a => !a.startsWith('--')) || '手动备份';
14
+
15
+ const manifest = vm.backup(label);
16
+ console.log(` ${c.green('备份已创建:')} ${manifest.versionId}`);
17
+ console.log(` ${c.bold('标签:')} ${manifest.label}`);
18
+ console.log(` ${c.bold('文件:')} 共 ${manifest.files.length} 个`);
19
+ console.log(` ${manifest.files.filter(f => f.status === 'new').length} 新增`);
20
+ console.log(` ${manifest.files.filter(f => f.status === 'changed').length} 变更`);
21
+ console.log(` ${manifest.files.filter(f => f.status === 'unchanged').length} 未变\n`);
22
+
23
+ // Also list available versions
24
+ const versions = vm.listVersions();
25
+ console.log(` ${c.gray('可用备份:')}`);
26
+ for (const v of versions.slice(-5)) {
27
+ console.log(` ${c.blue(v.versionId)} ${c.gray(v.label)} ${c.gray('(' + v.stats.total + ' 个文件)')}`);
28
+ }
29
+ console.log();
30
+ };
@@ -0,0 +1,327 @@
1
+ /**
2
+ * devassist check - Run all dev-time checks
3
+ *
4
+ * Executes:
5
+ * 1. CodeQualityGuard rules (16 rules: null safety, race conditions, duplicates, etc.)
6
+ * 2. ConventionStore checks (naming, patterns)
7
+ * 3. ImpactAnalyzer (if git diff available, check change impact)
8
+ * 4. ArchRiskAssessor (complexity, circular deps, injection)
9
+ * 5. SchemaRegistry drift detection (data.list vs data.items)
10
+ * 6. TechDebtTracker scan (file size, coupling, duplication)
11
+ *
12
+ * Post-processing:
13
+ * - Apply .devassist/config.json rule overrides (enable/disable/severity)
14
+ * - Filter out findings covered by inline ignore comments
15
+ * - Enrich findings with code context for AI analysis
16
+ *
17
+ * If --ai flag is set, sends suspect findings to AI for deep analysis.
18
+ */
19
+
20
+ const fs = require('fs');
21
+ const path = require('path');
22
+
23
+ // Core
24
+ const { engine } = require('../../core/rule-engine');
25
+
26
+ // Shared utilities
27
+ const { collectFileContents } = require('../shared/file-collector');
28
+ const { loadConfig, applyConfigToFindings } = require('../shared/config-loader');
29
+ const { filterIgnored } = require('../shared/inline-ignore');
30
+ const { enrichWithContext } = require('../shared/code-context');
31
+
32
+ // Modules
33
+ const { ConventionStore } = require('../../modules/dev-time/convention-store');
34
+ const { codeQualityRules } = require('../../modules/dev-time/code-quality-guard');
35
+ const { ImpactAnalyzer } = require('../../modules/dev-time/impact-analyzer');
36
+ const { ArchRiskAssessor } = require('../../modules/dev-time/arch-risk-assessor');
37
+ const { TechDebtTracker } = require('../../modules/dev-time/tech-debt-tracker');
38
+ const { SchemaRegistry } = require('../../modules/dev-time/schema-registry');
39
+
40
+ // AI
41
+ const ai = require('../../ai/adapter');
42
+ const qwen = require('../../ai/providers/qwen');
43
+ const openai = require('../../ai/providers/openai');
44
+ const claude = require('../../ai/providers/claude');
45
+ const hunyuan = require('../../ai/providers/hunyuan');
46
+ const { loadAIConfig, PROVIDER_INFO, computeAgreementStats } = require('./ai');
47
+
48
+ // Register AI providers
49
+ ai.registerProvider('qwen', qwen);
50
+ ai.registerProvider('openai', openai);
51
+ ai.registerProvider('claude', claude);
52
+ ai.registerProvider('hunyuan', hunyuan);
53
+
54
+ module.exports = async function check(projectRoot, args, ui) {
55
+ const { printHeader, printFinding, printStats, c } = ui;
56
+ const isPreCommit = args.includes('--pre-commit');
57
+ const useAI = args.includes('--ai');
58
+ const useCompare = args.includes('--compare');
59
+ const jsonOutput = args.includes('--json');
60
+
61
+ printHeader('运行全部检查');
62
+
63
+ // Load project config
64
+ const config = loadConfig(projectRoot);
65
+
66
+ // Collect all source files
67
+ const fileContents = collectFileContents(projectRoot);
68
+ if (fileContents.length === 0) {
69
+ console.log(` ${c.yellow('未找到源文件。')} 请确认项目目录是否正确。\n`);
70
+ return;
71
+ }
72
+
73
+ const ctx = {
74
+ files: fileContents,
75
+ projectRoot,
76
+ config,
77
+ };
78
+
79
+ // 1. Register and run CodeQualityGuard rules
80
+ engine.registerBatch(codeQualityRules);
81
+
82
+ // 2. Load conventions and add convention rules
83
+ const conventionStore = new ConventionStore(projectRoot);
84
+ engine.register({
85
+ id: 'convention-check',
86
+ severity: 'warn',
87
+ category: 'convention',
88
+ check(ctx) {
89
+ const allFindings = [];
90
+ for (const file of ctx.files) {
91
+ const findings = conventionStore.check(file.path, file.content);
92
+ allFindings.push(...findings);
93
+ }
94
+ return allFindings.length > 0 ? allFindings : null;
95
+ }
96
+ });
97
+
98
+ // Run rule engine
99
+ const { findings, stats } = engine.run(ctx);
100
+
101
+ // 3. Impact analysis (if git diff available)
102
+ let impactFindings = [];
103
+ if (!isPreCommit) {
104
+ try {
105
+ const { execSync } = require('child_process');
106
+ const diff = execSync('git diff --name-only HEAD 2>/dev/null', { cwd: projectRoot, encoding: 'utf-8' }).trim();
107
+ if (diff) {
108
+ const impactAnalyzer = new ImpactAnalyzer(projectRoot);
109
+ impactAnalyzer.buildGraph();
110
+ const changedFiles = diff.split('\n').filter(f => f);
111
+ for (const file of changedFiles) {
112
+ const fullPath = path.join(projectRoot, file);
113
+ if (fs.existsSync(fullPath)) {
114
+ const content = fs.readFileSync(fullPath, 'utf-8');
115
+ const result = impactAnalyzer.analyzeChange(fullPath, content);
116
+ impactFindings.push(...result.findings);
117
+ }
118
+ }
119
+ }
120
+ } catch (_) {} // Not a git repo or no changes
121
+ }
122
+
123
+ // 4. Architecture risk assessment
124
+ const archAssessor = new ArchRiskAssessor(projectRoot);
125
+ const archFindings = [];
126
+ for (const file of fileContents) {
127
+ const findings = archAssessor.analyzeFile(file.path, file.content);
128
+ archFindings.push(...findings);
129
+ }
130
+
131
+ // 5. SchemaRegistry drift detection
132
+ const schemaRegistry = new SchemaRegistry(projectRoot);
133
+ const schemaFindings = schemaRegistry.detectDrift ? schemaRegistry.detectDrift(fileContents) : [];
134
+
135
+ // 6. TechDebtTracker scan
136
+ let debtFindings = [];
137
+ try {
138
+ const debtTracker = new TechDebtTracker(projectRoot);
139
+ const debtBoard = debtTracker.scan();
140
+ if (debtBoard && debtBoard.items) {
141
+ // Convert high-priority debt items to findings
142
+ debtFindings = debtBoard.items
143
+ .filter(item => item.severity === 'high')
144
+ .slice(0, 10) // limit to top 10
145
+ .map(item => ({
146
+ severity: 'warn',
147
+ message: `技术债务:${item.description || item.type || '未知问题'}`,
148
+ file: item.file || '(unknown)',
149
+ line: item.line || 1,
150
+ suggestion: `优先级 ${item.priority ? item.priority.toFixed(2) : '?'},预估修复工时 ${item.effort || '?'}。运行 devassist debt --scan 查看详情。`,
151
+ ruleId: 'debt-tracker',
152
+ category: 'tech-debt',
153
+ }));
154
+ }
155
+ } catch (_) {}
156
+
157
+ // Merge all findings
158
+ let allFindings = [...findings, ...impactFindings, ...archFindings, ...schemaFindings, ...debtFindings];
159
+
160
+ // Post-processing: apply config-based rule overrides
161
+ allFindings = applyConfigToFindings(config, allFindings);
162
+
163
+ // Post-processing: filter inline ignore comments
164
+ allFindings = filterIgnored(allFindings, fileContents);
165
+
166
+ // Enrich with code context for AI analysis
167
+ allFindings = enrichWithContext(allFindings, fileContents);
168
+
169
+ // 7. AI analysis (optional)
170
+ let finalFindings = allFindings;
171
+ let aiConfig = null;
172
+ if (useAI || useCompare || (config.ai && config.ai.autoAnalyze)) {
173
+ aiConfig = loadAIConfig(projectRoot);
174
+ if (aiConfig && aiConfig.engines && aiConfig.engines.length > 0) {
175
+ const readyEngines = aiConfig.engines.filter(e => e.enabled && e.apiKey);
176
+ if (readyEngines.length > 0) {
177
+ const aiContext = { conventions: conventionStore.getRelevant({}), projectInfo: { root: projectRoot } };
178
+
179
+ if (useCompare && readyEngines.length >= 2) {
180
+ // Multi-engine comparison mode
181
+ console.log(` ${c.cyan(`${readyEngines.length} 引擎对比模式:`)}${readyEngines.map(e => e.provider).join(' + ')}`);
182
+ console.log(` ${c.gray('正在并行分析...')}\n`);
183
+ finalFindings = await ai.multiAnalyze(readyEngines, allFindings, aiContext);
184
+ } else {
185
+ // Single engine mode (use primary/first engine)
186
+ console.log(` ${c.cyan('AI 分析模式:')}${readyEngines[0].provider}`);
187
+ finalFindings = await ai.analyze(readyEngines[0], allFindings, aiContext);
188
+ }
189
+ } else {
190
+ console.log(` ${c.gray('(AI 引擎未就绪。请在 .devassist/ai-config.json 中配置 API Key)')}\n`);
191
+ }
192
+ } else {
193
+ console.log(` ${c.gray('(AI 分析已禁用。请在 .devassist/ai-config.json 中配置)')}\n`);
194
+ }
195
+ }
196
+
197
+ // Output
198
+ if (jsonOutput) {
199
+ console.log(JSON.stringify({ findings: finalFindings, stats }, null, 2));
200
+ saveFindings(projectRoot, finalFindings, stats);
201
+ return;
202
+ }
203
+
204
+ // Save findings for ai --analyze (even in non-json mode)
205
+ saveFindings(projectRoot, finalFindings, stats);
206
+
207
+ // Save check history
208
+ saveHistory(projectRoot, finalFindings);
209
+
210
+ // Generate comparison HTML report if in compare mode
211
+ if (useCompare && finalFindings.some(f => f.aiAnalyses && f.aiAnalyses.length >= 2)) {
212
+ saveComparisonReport(projectRoot, finalFindings, aiConfig, c);
213
+ }
214
+
215
+ // Print findings
216
+ if (finalFindings.length === 0) {
217
+ console.log(` ${c.green('全部检查通过!')} 未发现问题。\n`);
218
+ return;
219
+ }
220
+
221
+ for (const f of finalFindings) {
222
+ printFinding(f);
223
+ }
224
+ printStats({
225
+ ...stats,
226
+ blocks: finalFindings.filter(f => f.severity === 'block').length,
227
+ warnings: finalFindings.filter(f => f.severity === 'warn').length,
228
+ infos: finalFindings.filter(f => f.severity === 'info').length,
229
+ });
230
+
231
+ // Exit code for pre-commit
232
+ const hasBlocks = finalFindings.some(f => f.severity === 'block');
233
+ if (hasBlocks && isPreCommit) {
234
+ console.log(` ${c.red('提交已阻断。')} 请修复 BLOCK 级别问题,或使用 ${c.bold('git commit --no-verify')} 跳过检查。\n`);
235
+ process.exit(1);
236
+ }
237
+ };
238
+
239
+ function saveFindings(projectRoot, findings, stats) {
240
+ const reportDir = path.join(projectRoot, '.devassist', 'reports');
241
+ if (!fs.existsSync(reportDir)) fs.mkdirSync(reportDir, { recursive: true });
242
+ const reportPath = path.join(reportDir, 'latest.json');
243
+ fs.writeFileSync(reportPath, JSON.stringify({
244
+ generatedAt: new Date().toISOString(),
245
+ findings,
246
+ stats,
247
+ }, null, 2), 'utf-8');
248
+ }
249
+
250
+ function saveHistory(projectRoot, findings) {
251
+ const historyDir = path.join(projectRoot, '.devassist', 'history');
252
+ if (!fs.existsSync(historyDir)) fs.mkdirSync(historyDir, { recursive: true });
253
+ const now = new Date();
254
+ const dateStr = `${now.getFullYear()}-${String(now.getMonth()+1).padStart(2,'0')}-${String(now.getDate()).padStart(2,'0')}`;
255
+ const entry = {
256
+ date: now.toISOString(),
257
+ blocks: findings.filter(f => f.severity === 'block').length,
258
+ warnings: findings.filter(f => f.severity === 'warn').length,
259
+ infos: findings.filter(f => f.severity === 'info').length,
260
+ total: findings.length,
261
+ };
262
+
263
+ // Append to daily history file
264
+ const historyFile = path.join(historyDir, `${dateStr}.json`);
265
+ let entries = [];
266
+ if (fs.existsSync(historyFile)) {
267
+ try { entries = JSON.parse(fs.readFileSync(historyFile, 'utf-8')); } catch (_) {}
268
+ }
269
+ entries.push(entry);
270
+ fs.writeFileSync(historyFile, JSON.stringify(entries, null, 2), 'utf-8');
271
+
272
+ // Clean up history older than 90 days
273
+ try {
274
+ const files = fs.readdirSync(historyDir);
275
+ const cutoff = new Date();
276
+ cutoff.setDate(cutoff.getDate() - 90);
277
+ for (const f of files) {
278
+ if (!f.endsWith('.json')) continue;
279
+ const fileDate = new Date(f.replace('.json', ''));
280
+ if (fileDate < cutoff) {
281
+ fs.unlinkSync(path.join(historyDir, f));
282
+ }
283
+ }
284
+ } catch (_) {}
285
+ }
286
+
287
+ function saveComparisonReport(projectRoot, findings, aiConfig, c) {
288
+ const { generateComparisonHTML } = require('./comparison-report');
289
+ const watchList = require('../shared/watch-list');
290
+ const readyEngines = aiConfig.engines.filter(e => e.enabled && e.apiKey);
291
+ const stats = computeAgreementStats(findings, readyEngines.length);
292
+
293
+ // Auto-add disagreement findings to watch list
294
+ const deferredCount = watchList.addDeferredFindings(projectRoot, findings,
295
+ '待观察:引擎判断不一致,等运行时验证');
296
+
297
+ const html = generateComparisonHTML({
298
+ projectRoot: path.basename(projectRoot),
299
+ timestamp: new Date().toISOString(),
300
+ engines: readyEngines.map(e => ({ provider: e.provider, model: e.model, name: e.name })),
301
+ stats,
302
+ findings,
303
+ });
304
+
305
+ const reportDir = path.join(projectRoot, '.devassist', 'reports');
306
+ if (!fs.existsSync(reportDir)) fs.mkdirSync(reportDir, { recursive: true });
307
+ const htmlPath = path.join(reportDir, 'ai-comparison.html');
308
+ fs.writeFileSync(htmlPath, html, 'utf-8');
309
+
310
+ // Also save JSON
311
+ const jsonPath = path.join(reportDir, 'ai-comparison.json');
312
+ fs.writeFileSync(jsonPath, JSON.stringify({
313
+ analyzedAt: new Date().toISOString(),
314
+ engines: readyEngines.map(e => ({ provider: e.provider, model: e.model, name: e.name })),
315
+ stats,
316
+ findings,
317
+ }, null, 2), 'utf-8');
318
+
319
+ console.log(` ${c.green('多引擎对比报告已生成:')}`);
320
+ console.log(` HTML: .devassist/reports/ai-comparison.html`);
321
+ console.log(` JSON: .devassist/reports/ai-comparison.json`);
322
+ if (deferredCount > 0) {
323
+ console.log(` ${c.yellow(`⏳ ${deferredCount} 个判断不一致的发现已标记为待观察(.devassist/watch-list.json)`)}`);
324
+ console.log(` ${c.gray('暂不修复,等实际运行验证后再处理。')}`);
325
+ }
326
+ console.log('');
327
+ }
@@ -0,0 +1,130 @@
1
+ /**
2
+ * devassist clean - Clean up old reports, backups, and history
3
+ *
4
+ * Usage:
5
+ * devassist clean # Clean items older than 30 days
6
+ * devassist clean --days 7 # Clean items older than 7 days
7
+ * devassist clean --all # Clean all except latest
8
+ * devassist clean --dry-run # Show what would be cleaned without deleting
9
+ */
10
+
11
+ const fs = require('fs');
12
+ const path = require('path');
13
+
14
+ module.exports = function clean(projectRoot, args, ui) {
15
+ const { printHeader, c } = ui;
16
+ printHeader('清理旧文件');
17
+
18
+ const dryRun = args.includes('--dry-run');
19
+ const cleanAll = args.includes('--all');
20
+ const daysIdx = args.indexOf('--days');
21
+ const days = daysIdx >= 0 ? parseInt(args[daysIdx + 1]) || 30 : 30;
22
+
23
+ const configDir = path.join(projectRoot, '.devassist');
24
+ if (!fs.existsSync(configDir)) {
25
+ console.log(` ${c.yellow('项目未初始化。')} 运行 devassist init 后再使用清理。\n`);
26
+ return;
27
+ }
28
+
29
+ const cutoff = new Date();
30
+ cutoff.setDate(cutoff.getDate() - days);
31
+
32
+ let totalFiles = 0;
33
+ let totalSize = 0;
34
+
35
+ // 1. Clean reports
36
+ const reportDir = path.join(configDir, 'reports');
37
+ if (fs.existsSync(reportDir)) {
38
+ console.log(` ${c.bold('报告目录 (.devassist/reports/)')}`);
39
+ const files = fs.readdirSync(reportDir);
40
+ for (const file of files) {
41
+ if (file === 'latest.html' || file === 'latest.json') continue; // always keep latest
42
+ const filePath = path.join(reportDir, file);
43
+ const stat = fs.statSync(filePath);
44
+ const shouldClean = cleanAll || stat.mtime < cutoff;
45
+ if (shouldClean) {
46
+ const sizeKB = (stat.size / 1024).toFixed(1);
47
+ console.log(` ${c.gray('删除')} ${file} ${c.gray(`(${sizeKB} KB, ${stat.mtime.toISOString().slice(0, 10)})`)}`);
48
+ totalFiles++;
49
+ totalSize += stat.size;
50
+ if (!dryRun) fs.unlinkSync(filePath);
51
+ }
52
+ }
53
+ console.log('');
54
+ }
55
+
56
+ // 2. Clean backups
57
+ const backupDir = path.join(configDir, 'backups');
58
+ if (fs.existsSync(backupDir)) {
59
+ console.log(` ${c.bold('备份目录 (.devassist/backups/)')}`);
60
+ const entries = fs.readdirSync(backupDir, { withFileTypes: true });
61
+ for (const entry of entries) {
62
+ if (entry.isDirectory()) {
63
+ const dirPath = path.join(backupDir, entry.name);
64
+ const manifestPath = path.join(dirPath, 'manifest.json');
65
+ let dirTime = new Date(0);
66
+ if (fs.existsSync(manifestPath)) {
67
+ try {
68
+ const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf-8'));
69
+ dirTime = new Date(manifest.createdAt || 0);
70
+ } catch (_) { dirTime = fs.statSync(dirPath).mtime; }
71
+ } else {
72
+ dirTime = fs.statSync(dirPath).mtime;
73
+ }
74
+ const shouldClean = cleanAll || dirTime < cutoff;
75
+ if (shouldClean) {
76
+ // Calculate size
77
+ let dirSize = 0;
78
+ const innerFiles = fs.readdirSync(dirPath);
79
+ for (const f of innerFiles) {
80
+ dirSize += fs.statSync(path.join(dirPath, f)).size;
81
+ }
82
+ const sizeKB = (dirSize / 1024).toFixed(1);
83
+ console.log(` ${c.gray('删除')} ${entry.name}/ ${c.gray(`(${sizeKB} KB, ${dirTime.toISOString().slice(0, 10)})`)}`);
84
+ totalFiles++;
85
+ totalSize += dirSize;
86
+ if (!dryRun) {
87
+ for (const f of innerFiles) fs.unlinkSync(path.join(dirPath, f));
88
+ fs.rmdirSync(dirPath);
89
+ }
90
+ }
91
+ }
92
+ }
93
+ console.log('');
94
+ }
95
+
96
+ // 3. Clean history
97
+ const historyDir = path.join(configDir, 'history');
98
+ if (fs.existsSync(historyDir)) {
99
+ console.log(` ${c.bold('历史记录目录 (.devassist/history/)')}`);
100
+ const files = fs.readdirSync(historyDir);
101
+ for (const file of files) {
102
+ if (!file.endsWith('.json')) continue;
103
+ const filePath = path.join(historyDir, file);
104
+ const stat = fs.statSync(filePath);
105
+ const fileDate = new Date(file.replace('.json', ''));
106
+ const shouldClean = cleanAll || fileDate < cutoff;
107
+ if (shouldClean) {
108
+ const sizeKB = (stat.size / 1024).toFixed(1);
109
+ console.log(` ${c.gray('删除')} ${file} ${c.gray(`(${sizeKB} KB)`)}`);
110
+ totalFiles++;
111
+ totalSize += stat.size;
112
+ if (!dryRun) fs.unlinkSync(filePath);
113
+ }
114
+ }
115
+ console.log('');
116
+ }
117
+
118
+ // Summary
119
+ const sizeMB = (totalSize / (1024 * 1024)).toFixed(2);
120
+ if (totalFiles === 0) {
121
+ console.log(` ${c.green('无需清理。')} 没有超过 ${days} 天的旧文件。\n`);
122
+ } else {
123
+ const action = dryRun ? '将删除' : '已删除';
124
+ console.log(` ${c.green(action + ' ' + totalFiles + ' 个文件')},释放 ${sizeMB} MB 空间。`);
125
+ if (dryRun) {
126
+ console.log(` ${c.gray('(--dry-run 模式,未实际删除。移除 --dry-run 执行清理。)')}`);
127
+ }
128
+ console.log('');
129
+ }
130
+ };