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.
- package/LICENSE +21 -0
- package/README.md +467 -0
- package/bin/devassist.js +220 -0
- package/package.json +44 -0
- package/src/ai/adapter.js +464 -0
- package/src/ai/providers/claude.js +80 -0
- package/src/ai/providers/hunyuan.js +87 -0
- package/src/ai/providers/openai.js +74 -0
- package/src/ai/providers/qwen.js +81 -0
- package/src/cli/commands/ai.js +944 -0
- package/src/cli/commands/ask.js +79 -0
- package/src/cli/commands/backup.js +30 -0
- package/src/cli/commands/check.js +327 -0
- package/src/cli/commands/clean.js +130 -0
- package/src/cli/commands/comparison-report.js +326 -0
- package/src/cli/commands/convention.js +91 -0
- package/src/cli/commands/debt.js +49 -0
- package/src/cli/commands/deploy.js +88 -0
- package/src/cli/commands/diff.js +193 -0
- package/src/cli/commands/doctor.js +186 -0
- package/src/cli/commands/fix.js +195 -0
- package/src/cli/commands/init.js +431 -0
- package/src/cli/commands/inject.js +254 -0
- package/src/cli/commands/report.js +310 -0
- package/src/cli/commands/restore.js +78 -0
- package/src/cli/commands/schema.js +93 -0
- package/src/cli/commands/watch.js +212 -0
- package/src/cli/shared/code-context.js +51 -0
- package/src/cli/shared/config-loader.js +89 -0
- package/src/cli/shared/file-collector.js +116 -0
- package/src/cli/shared/inline-ignore.js +142 -0
- package/src/cli/shared/watch-list.js +281 -0
- package/src/core/event-bus.js +83 -0
- package/src/core/fsm.js +103 -0
- package/src/core/logger.js +54 -0
- package/src/core/rule-engine.js +117 -0
- package/src/index.js +64 -0
- package/src/modules/dev-time/arch-risk-assessor.js +250 -0
- package/src/modules/dev-time/code-quality-guard.js +1340 -0
- package/src/modules/dev-time/convention-store.js +201 -0
- package/src/modules/dev-time/impact-analyzer.js +292 -0
- package/src/modules/dev-time/pre-deploy-guard.js +284 -0
- package/src/modules/dev-time/schema-registry.js +284 -0
- package/src/modules/dev-time/tech-debt-tracker.js +225 -0
- package/src/modules/dev-time/version-manager.js +280 -0
- package/src/modules/runtime/channel-agent.js +404 -0
- package/src/modules/runtime/channel-middleware.js +316 -0
- package/src/modules/runtime/index.js +64 -0
- package/src/modules/runtime/infrastructure-guard.js +582 -0
- package/src/modules/runtime/notification-center.js +443 -0
- package/src/modules/runtime/schema-gatekeeper.js +664 -0
- package/src/modules/runtime/tool-registry.js +329 -0
- package/templates/ci/github-actions.yml +60 -0
- package/templates/ci/gitlab-ci.yml +30 -0
- package/templates/ci/pre-commit-hook.sh +18 -0
- package/templates/git-hooks/pre-commit +61 -0
- package/tests/run-all.js +434 -0
- package/tests/test-layer2.js +461 -0
- 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 };
|