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,281 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Watch List — 待观察发现跟踪器
|
|
3
|
+
*
|
|
4
|
+
* 引擎判断不一致的发现会被标记为"待观察",不立即修复,
|
|
5
|
+
* 等实际运行验证后再决定是否处理。
|
|
6
|
+
*
|
|
7
|
+
* 文件位置: .devassist/watch-list.json
|
|
8
|
+
*
|
|
9
|
+
* 数据结构:
|
|
10
|
+
* {
|
|
11
|
+
* items: [
|
|
12
|
+
* {
|
|
13
|
+
* id: "app.js:2781:xss-innerhtml", // 唯一标识(file:line:ruleId)
|
|
14
|
+
* status: "pending" | "resolved" | "dismissed",
|
|
15
|
+
* finding: { severity, message, file, line, ruleId },
|
|
16
|
+
* aiAnalyses: [ { engineName, isFalsePositive, rootCause, confidence } ],
|
|
17
|
+
* note: "待观察:引擎判断不一致,等运行时验证",
|
|
18
|
+
* deferredAt: "2026-07-22T00:00:00.000Z",
|
|
19
|
+
* resolvedAt: null,
|
|
20
|
+
* resolution: null, // "fixed" | "no-issue" | null
|
|
21
|
+
* runtimeNotes: "" // 运行时观察到的现象
|
|
22
|
+
* }
|
|
23
|
+
* ]
|
|
24
|
+
* }
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
const fs = require('fs');
|
|
28
|
+
const path = require('path');
|
|
29
|
+
|
|
30
|
+
const WATCH_LIST_FILE = 'watch-list.json';
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Get the path to the watch list file.
|
|
34
|
+
*/
|
|
35
|
+
function getWatchListPath(projectRoot) {
|
|
36
|
+
return path.join(projectRoot, '.devassist', WATCH_LIST_FILE);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Load the watch list from disk.
|
|
41
|
+
* Returns { items: [] } if file doesn't exist.
|
|
42
|
+
*/
|
|
43
|
+
function loadWatchList(projectRoot) {
|
|
44
|
+
const filePath = getWatchListPath(projectRoot);
|
|
45
|
+
if (!fs.existsSync(filePath)) {
|
|
46
|
+
return { items: [] };
|
|
47
|
+
}
|
|
48
|
+
try {
|
|
49
|
+
const data = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
|
|
50
|
+
if (!Array.isArray(data.items)) data.items = [];
|
|
51
|
+
return data;
|
|
52
|
+
} catch (_) {
|
|
53
|
+
return { items: [] };
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Save the watch list to disk.
|
|
59
|
+
*/
|
|
60
|
+
function saveWatchList(projectRoot, watchList) {
|
|
61
|
+
const dir = path.join(projectRoot, '.devassist');
|
|
62
|
+
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
|
63
|
+
const filePath = getWatchListPath(projectRoot);
|
|
64
|
+
fs.writeFileSync(filePath, JSON.stringify(watchList, null, 2), 'utf-8');
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Generate a unique ID for a finding.
|
|
69
|
+
*/
|
|
70
|
+
function makeFindingId(finding) {
|
|
71
|
+
return `${finding.file || 'unknown'}:${finding.line || 0}:${finding.ruleId || 'no-rule'}`;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Determine if a finding's AI analyses show disagreement.
|
|
76
|
+
* Disagreement = at least 2 engines returned results but they disagree on isFalsePositive.
|
|
77
|
+
*/
|
|
78
|
+
function isDisagreement(finding) {
|
|
79
|
+
if (!finding.aiAnalyses) return false;
|
|
80
|
+
const available = finding.aiAnalyses.filter(ea => ea.available && ea.analysis);
|
|
81
|
+
if (available.length < 2) return false;
|
|
82
|
+
|
|
83
|
+
const fpValues = available.map(ea => ea.analysis.isFalsePositive);
|
|
84
|
+
const allSame = fpValues.every(v => v === fpValues[0]);
|
|
85
|
+
return !allSame;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Add disagreement findings to the watch list as "pending".
|
|
90
|
+
* Only adds findings that aren't already tracked.
|
|
91
|
+
* Returns the number of newly added items.
|
|
92
|
+
*/
|
|
93
|
+
function addDeferredFindings(projectRoot, findings, defaultNote) {
|
|
94
|
+
const watchList = loadWatchList(projectRoot);
|
|
95
|
+
let added = 0;
|
|
96
|
+
|
|
97
|
+
for (const f of findings) {
|
|
98
|
+
if (!isDisagreement(f)) continue;
|
|
99
|
+
|
|
100
|
+
const id = makeFindingId(f);
|
|
101
|
+
const existing = watchList.items.find(item => item.id === id);
|
|
102
|
+
|
|
103
|
+
if (existing) {
|
|
104
|
+
// Update finding info and analyses (keep status/note if already set)
|
|
105
|
+
existing.finding = {
|
|
106
|
+
severity: f.severity,
|
|
107
|
+
message: f.message,
|
|
108
|
+
file: f.file,
|
|
109
|
+
line: f.line,
|
|
110
|
+
ruleId: f.ruleId,
|
|
111
|
+
};
|
|
112
|
+
existing.aiAnalyses = (f.aiAnalyses || []).map(ea => ({
|
|
113
|
+
engineName: ea.engineName,
|
|
114
|
+
isFalsePositive: ea.analysis?.isFalsePositive,
|
|
115
|
+
rootCause: ea.analysis?.rootCause,
|
|
116
|
+
fix: ea.analysis?.fix,
|
|
117
|
+
confidence: ea.analysis?.confidence,
|
|
118
|
+
}));
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
watchList.items.push({
|
|
123
|
+
id,
|
|
124
|
+
status: 'pending',
|
|
125
|
+
finding: {
|
|
126
|
+
severity: f.severity,
|
|
127
|
+
message: f.message,
|
|
128
|
+
file: f.file,
|
|
129
|
+
line: f.line,
|
|
130
|
+
ruleId: f.ruleId,
|
|
131
|
+
},
|
|
132
|
+
aiAnalyses: (f.aiAnalyses || []).map(ea => ({
|
|
133
|
+
engineName: ea.engineName,
|
|
134
|
+
isFalsePositive: ea.analysis?.isFalsePositive,
|
|
135
|
+
rootCause: ea.analysis?.rootCause,
|
|
136
|
+
fix: ea.analysis?.fix,
|
|
137
|
+
confidence: ea.analysis?.confidence,
|
|
138
|
+
})),
|
|
139
|
+
note: defaultNote || '待观察:引擎判断不一致,等运行时验证',
|
|
140
|
+
deferredAt: new Date().toISOString(),
|
|
141
|
+
resolvedAt: null,
|
|
142
|
+
resolution: null,
|
|
143
|
+
runtimeNotes: '',
|
|
144
|
+
});
|
|
145
|
+
added++;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
saveWatchList(projectRoot, watchList);
|
|
149
|
+
return added;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Get all pending (unresolved) watch list items.
|
|
154
|
+
*/
|
|
155
|
+
function getPending(projectRoot) {
|
|
156
|
+
const watchList = loadWatchList(projectRoot);
|
|
157
|
+
return watchList.items.filter(item => item.status === 'pending');
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Get all watch list items (including resolved/dismissed).
|
|
162
|
+
*/
|
|
163
|
+
function getAll(projectRoot) {
|
|
164
|
+
const watchList = loadWatchList(projectRoot);
|
|
165
|
+
return watchList.items;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Resolve a watch list item — mark as fixed after runtime confirmed issue.
|
|
170
|
+
*/
|
|
171
|
+
function resolveItem(projectRoot, idOrIndex, resolution, runtimeNotes) {
|
|
172
|
+
const watchList = loadWatchList(projectRoot);
|
|
173
|
+
const item = findItem(watchList, idOrIndex);
|
|
174
|
+
if (!item) return null;
|
|
175
|
+
|
|
176
|
+
item.status = 'resolved';
|
|
177
|
+
item.resolvedAt = new Date().toISOString();
|
|
178
|
+
item.resolution = resolution || 'fixed';
|
|
179
|
+
if (runtimeNotes) item.runtimeNotes = runtimeNotes;
|
|
180
|
+
|
|
181
|
+
saveWatchList(projectRoot, watchList);
|
|
182
|
+
return item;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Dismiss a watch list item — runtime confirmed no issue.
|
|
187
|
+
*/
|
|
188
|
+
function dismissItem(projectRoot, idOrIndex, runtimeNotes) {
|
|
189
|
+
const watchList = loadWatchList(projectRoot);
|
|
190
|
+
const item = findItem(watchList, idOrIndex);
|
|
191
|
+
if (!item) return null;
|
|
192
|
+
|
|
193
|
+
item.status = 'dismissed';
|
|
194
|
+
item.resolvedAt = new Date().toISOString();
|
|
195
|
+
item.resolution = 'no-issue';
|
|
196
|
+
if (runtimeNotes) item.runtimeNotes = runtimeNotes;
|
|
197
|
+
|
|
198
|
+
saveWatchList(projectRoot, watchList);
|
|
199
|
+
return item;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Update the note on a watch list item.
|
|
204
|
+
*/
|
|
205
|
+
function updateNote(projectRoot, idOrIndex, note) {
|
|
206
|
+
const watchList = loadWatchList(projectRoot);
|
|
207
|
+
const item = findItem(watchList, idOrIndex);
|
|
208
|
+
if (!item) return null;
|
|
209
|
+
|
|
210
|
+
item.note = note;
|
|
211
|
+
saveWatchList(projectRoot, watchList);
|
|
212
|
+
return item;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Remove a watch list item entirely.
|
|
217
|
+
*/
|
|
218
|
+
function removeItem(projectRoot, idOrIndex) {
|
|
219
|
+
const watchList = loadWatchList(projectRoot);
|
|
220
|
+
const idx = findItemIndex(watchList, idOrIndex);
|
|
221
|
+
if (idx === -1) return false;
|
|
222
|
+
|
|
223
|
+
const removed = watchList.items.splice(idx, 1)[0];
|
|
224
|
+
saveWatchList(projectRoot, watchList);
|
|
225
|
+
return removed;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Find an item by ID string or numeric index.
|
|
230
|
+
*/
|
|
231
|
+
function findItem(watchList, idOrIndex) {
|
|
232
|
+
return findItemIndex(watchList, idOrIndex) >= 0
|
|
233
|
+
? watchList.items[findItemIndex(watchList, idOrIndex)]
|
|
234
|
+
: null;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function findItemIndex(watchList, idOrIndex) {
|
|
238
|
+
// Try numeric index (into pending items)
|
|
239
|
+
const idx = parseInt(idOrIndex, 10);
|
|
240
|
+
if (!isNaN(idx)) {
|
|
241
|
+
const pending = watchList.items.filter(item => item.status === 'pending');
|
|
242
|
+
if (idx >= 0 && idx < pending.length) {
|
|
243
|
+
return watchList.items.indexOf(pending[idx]);
|
|
244
|
+
}
|
|
245
|
+
// Also try as direct index into all items
|
|
246
|
+
if (idx >= 0 && idx < watchList.items.length) {
|
|
247
|
+
return idx;
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
// Try as ID string
|
|
251
|
+
return watchList.items.findIndex(item => item.id === idOrIndex);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Get summary stats.
|
|
256
|
+
*/
|
|
257
|
+
function getStats(projectRoot) {
|
|
258
|
+
const items = getAll(projectRoot);
|
|
259
|
+
return {
|
|
260
|
+
total: items.length,
|
|
261
|
+
pending: items.filter(i => i.status === 'pending').length,
|
|
262
|
+
resolved: items.filter(i => i.status === 'resolved').length,
|
|
263
|
+
dismissed: items.filter(i => i.status === 'dismissed').length,
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
module.exports = {
|
|
268
|
+
loadWatchList,
|
|
269
|
+
saveWatchList,
|
|
270
|
+
addDeferredFindings,
|
|
271
|
+
isDisagreement,
|
|
272
|
+
makeFindingId,
|
|
273
|
+
getPending,
|
|
274
|
+
getAll,
|
|
275
|
+
resolveItem,
|
|
276
|
+
dismissItem,
|
|
277
|
+
updateNote,
|
|
278
|
+
removeItem,
|
|
279
|
+
getStats,
|
|
280
|
+
getWatchListPath,
|
|
281
|
+
};
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* EventBus - lightweight pub/sub event bus
|
|
3
|
+
* Decouples modules: dev-time checks, runtime monitors, AI adapter all communicate via events.
|
|
4
|
+
*
|
|
5
|
+
* Design from the 88-failure report: the #1 cause of "local optimization" bugs was
|
|
6
|
+
* modules not knowing what other modules were doing. EventBus fixes this by making
|
|
7
|
+
* all check results broadcast events that any module can react to.
|
|
8
|
+
*/
|
|
9
|
+
class EventBus {
|
|
10
|
+
constructor() {
|
|
11
|
+
this._handlers = new Map();
|
|
12
|
+
this._history = [];
|
|
13
|
+
this._maxHistory = 200;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
on(event, handler) {
|
|
17
|
+
if (!this._handlers.has(event)) {
|
|
18
|
+
this._handlers.set(event, []);
|
|
19
|
+
}
|
|
20
|
+
this._handlers.get(event).push(handler);
|
|
21
|
+
return () => this.off(event, handler);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
off(event, handler) {
|
|
25
|
+
const list = this._handlers.get(event);
|
|
26
|
+
if (!list) return;
|
|
27
|
+
const idx = list.indexOf(handler);
|
|
28
|
+
if (idx >= 0) list.splice(idx, 1);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async emit(event, payload) {
|
|
32
|
+
const entry = { event, payload, ts: Date.now() };
|
|
33
|
+
this._history.push(entry);
|
|
34
|
+
if (this._history.length > this._maxHistory) {
|
|
35
|
+
this._history.shift();
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const list = this._handlers.get(event);
|
|
39
|
+
if (!list) return;
|
|
40
|
+
|
|
41
|
+
const errors = [];
|
|
42
|
+
for (const handler of list) {
|
|
43
|
+
try {
|
|
44
|
+
await handler(payload);
|
|
45
|
+
} catch (err) {
|
|
46
|
+
errors.push({ event, handler: handler.name || 'anonymous', err });
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
// Also emit a catch-all for logging/monitoring
|
|
50
|
+
const allList = this._handlers.get('*');
|
|
51
|
+
if (allList) {
|
|
52
|
+
for (const handler of allList) {
|
|
53
|
+
try { await handler(entry); } catch (_) {}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
// Don't swallow errors silently — return them for the caller to decide
|
|
57
|
+
if (errors.length > 0) {
|
|
58
|
+
return errors;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
emitSync(event, payload) {
|
|
63
|
+
const list = this._handlers.get(event);
|
|
64
|
+
if (!list) return;
|
|
65
|
+
for (const handler of list) {
|
|
66
|
+
try { handler(payload); } catch (_) {}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
getHistory(filter) {
|
|
71
|
+
if (!filter) return this._history.slice();
|
|
72
|
+
return this._history.filter(e => e.event === filter);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
clear() {
|
|
76
|
+
this._handlers.clear();
|
|
77
|
+
this._history = [];
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Singleton instance shared across all modules
|
|
82
|
+
const bus = new EventBus();
|
|
83
|
+
module.exports = { EventBus, bus };
|
package/src/core/fsm.js
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* FSM - Finite State Machine for the DevAssist agent lifecycle.
|
|
3
|
+
*
|
|
4
|
+
* Borrowed from the ChannelAgent design in the 麦克纳姆 project.
|
|
5
|
+
* States: IDLE -> PERCEIVING -> ANALYZING -> DECIDING -> ACTING -> LEARNING -> IDLE
|
|
6
|
+
*
|
|
7
|
+
* The FSM ensures the agent never gets stuck in an infinite loop
|
|
8
|
+
* (failure #7 in the report: RecoveryEngine infinite loop).
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
const STATES = {
|
|
12
|
+
IDLE: 'IDLE',
|
|
13
|
+
PERCEIVING: 'PERCEIVING',
|
|
14
|
+
ANALYZING: 'ANALYZING',
|
|
15
|
+
DECIDING: 'DECIDING',
|
|
16
|
+
ACTING: 'ACTING',
|
|
17
|
+
LEARNING: 'LEARNING',
|
|
18
|
+
ERROR: 'ERROR',
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
const TRANSITIONS = {
|
|
22
|
+
IDLE: ['PERCEIVING', 'ERROR'],
|
|
23
|
+
PERCEIVING: ['ANALYZING', 'IDLE', 'ERROR'],
|
|
24
|
+
ANALYZING: ['DECIDING', 'IDLE', 'ERROR'],
|
|
25
|
+
DECIDING: ['ACTING', 'IDLE', 'ERROR'],
|
|
26
|
+
ACTING: ['LEARNING', 'IDLE', 'ERROR'],
|
|
27
|
+
LEARNING: ['IDLE', 'ERROR'],
|
|
28
|
+
ERROR: ['IDLE'],
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
class FSM {
|
|
32
|
+
constructor() {
|
|
33
|
+
this._state = STATES.IDLE;
|
|
34
|
+
this._history = [{ state: STATES.IDLE, ts: Date.now(), reason: 'init' }];
|
|
35
|
+
this._maxHistory = 50;
|
|
36
|
+
this._maxTransitions = 100; // hard limit to prevent infinite loops
|
|
37
|
+
this._transitionCount = 0;
|
|
38
|
+
this._listeners = new Map();
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
get state() {
|
|
42
|
+
return this._state;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
get history() {
|
|
46
|
+
return this._history.slice();
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
canTransition(to) {
|
|
50
|
+
const allowed = TRANSITIONS[this._state];
|
|
51
|
+
return allowed ? allowed.includes(to) : false;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
transition(to, reason) {
|
|
55
|
+
if (!STATES[to]) {
|
|
56
|
+
throw new Error(`Unknown state: ${to}`);
|
|
57
|
+
}
|
|
58
|
+
if (!this.canTransition(to)) {
|
|
59
|
+
throw new Error(`Invalid transition: ${this._state} -> ${to}. Allowed: ${(TRANSITIONS[this._state] || []).join(', ')}`);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Infinite loop protection (failure #7 defense)
|
|
63
|
+
this._transitionCount++;
|
|
64
|
+
if (this._transitionCount > this._maxTransitions) {
|
|
65
|
+
this._state = STATES.ERROR;
|
|
66
|
+
this._history.push({ state: STATES.ERROR, ts: Date.now(), reason: 'max transitions exceeded' });
|
|
67
|
+
if (this._history.length > this._maxHistory) this._history.shift();
|
|
68
|
+
throw new Error(`FSM transition limit (${this._maxTransitions}) exceeded — possible infinite loop. Forcing ERROR state.`);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const from = this._state;
|
|
72
|
+
this._state = to;
|
|
73
|
+
this._history.push({ state: to, ts: Date.now(), reason: reason || '', from });
|
|
74
|
+
if (this._history.length > this._maxHistory) this._history.shift();
|
|
75
|
+
|
|
76
|
+
// Notify listeners
|
|
77
|
+
const listeners = this._listeners.get('*') || [];
|
|
78
|
+
const stateListeners = this._listeners.get(to) || [];
|
|
79
|
+
for (const fn of [...listeners, ...stateListeners]) {
|
|
80
|
+
try { fn({ from, to, reason }); } catch (_) {}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
on(state, fn) {
|
|
85
|
+
if (!this._listeners.has(state)) {
|
|
86
|
+
this._listeners.set(state, []);
|
|
87
|
+
}
|
|
88
|
+
this._listeners.get(state).push(fn);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
reset() {
|
|
92
|
+
this._state = STATES.IDLE;
|
|
93
|
+
this._transitionCount = 0;
|
|
94
|
+
this._history.push({ state: STATES.IDLE, ts: Date.now(), reason: 'reset' });
|
|
95
|
+
if (this._history.length > this._maxHistory) this._history.shift();
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
isTerminal() {
|
|
99
|
+
return this._state === STATES.IDLE || this._state === STATES.ERROR;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
module.exports = { FSM, STATES };
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Logger - structured logging with severity levels.
|
|
3
|
+
* Keeps a ring buffer for debugging and emits events to EventBus.
|
|
4
|
+
*/
|
|
5
|
+
const { bus } = require('./event-bus');
|
|
6
|
+
|
|
7
|
+
const LEVELS = { debug: 10, info: 20, warn: 30, error: 40, fatal: 50 };
|
|
8
|
+
const LEVEL_NAMES = { 10: 'DEBUG', 20: 'INFO', 30: 'WARN', 40: 'ERROR', 50: 'FATAL' };
|
|
9
|
+
|
|
10
|
+
class Logger {
|
|
11
|
+
constructor(name) {
|
|
12
|
+
this.name = name;
|
|
13
|
+
this._level = LEVELS.info;
|
|
14
|
+
this._buffer = [];
|
|
15
|
+
this._maxBuffer = 500;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
setLevel(level) {
|
|
19
|
+
this._level = LEVELS[level] || LEVELS.info;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
_log(level, msg, meta) {
|
|
23
|
+
if (level < this._level) return;
|
|
24
|
+
const entry = {
|
|
25
|
+
ts: new Date().toISOString(),
|
|
26
|
+
level: LEVEL_NAMES[level],
|
|
27
|
+
module: this.name,
|
|
28
|
+
msg,
|
|
29
|
+
meta: meta || null,
|
|
30
|
+
};
|
|
31
|
+
this._buffer.push(entry);
|
|
32
|
+
if (this._buffer.length > this._maxBuffer) this._buffer.shift();
|
|
33
|
+
|
|
34
|
+
// Color output for console
|
|
35
|
+
const colors = { DEBUG: '\x1b[90m', INFO: '\x1b[36m', WARN: '\x1b[33m', ERROR: '\x1b[31m', FATAL: '\x1b[35m' };
|
|
36
|
+
const reset = '\x1b[0m';
|
|
37
|
+
const c = colors[entry.level] || '';
|
|
38
|
+
const metaStr = meta ? ' ' + JSON.stringify(meta) : '';
|
|
39
|
+
console.log(`${c}[${entry.level}]${reset} ${entry.module}: ${msg}${metaStr}`);
|
|
40
|
+
|
|
41
|
+
// Emit for EventBus subscribers (e.g. NotificationCenter)
|
|
42
|
+
bus.emitSync('log', entry);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
debug(msg, meta) { this._log(LEVELS.debug, msg, meta); }
|
|
46
|
+
info(msg, meta) { this._log(LEVELS.info, msg, meta); }
|
|
47
|
+
warn(msg, meta) { this._log(LEVELS.warn, msg, meta); }
|
|
48
|
+
error(msg, meta) { this._log(LEVELS.error, msg, meta); }
|
|
49
|
+
fatal(msg, meta) { this._log(LEVELS.fatal, msg, meta); }
|
|
50
|
+
|
|
51
|
+
getBuffer() { return this._buffer.slice(); }
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
module.exports = { Logger };
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RuleEngine - the deterministic first-pass checker.
|
|
3
|
+
*
|
|
4
|
+
* Core principle (from the design doc): "Rules engine first, AI second."
|
|
5
|
+
* The rule engine runs with zero latency, predictable results, and catches
|
|
6
|
+
* 90% of issues. Only the remaining 10% of "suspect" findings go to the AI.
|
|
7
|
+
*
|
|
8
|
+
* Each rule has:
|
|
9
|
+
* id - unique identifier (e.g. "cq-null-check")
|
|
10
|
+
* severity - 'block' | 'warn' | 'info'
|
|
11
|
+
* category - which module owns this rule
|
|
12
|
+
* check - (ctx) => Finding | null (synchronous, deterministic)
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* @typedef {Object} Finding
|
|
17
|
+
* @property {string} ruleId - which rule triggered
|
|
18
|
+
* @property {string} severity - block | warn | info
|
|
19
|
+
* @property {string} category - module category
|
|
20
|
+
* @property {string} message - human-readable description
|
|
21
|
+
* @property {string} file - affected file path
|
|
22
|
+
* @property {number} [line] - line number if available
|
|
23
|
+
* @property {string} [suggestion]- quick fix suggestion
|
|
24
|
+
* @property {object} [context] - extra data for AI deep-analysis
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
class RuleEngine {
|
|
28
|
+
constructor() {
|
|
29
|
+
this._rules = new Map();
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
register(rule) {
|
|
33
|
+
if (!rule.id || !rule.check) {
|
|
34
|
+
throw new Error(`Invalid rule: missing id or check. Got: ${JSON.stringify(rule)}`);
|
|
35
|
+
}
|
|
36
|
+
this._rules.set(rule.id, rule);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
registerBatch(rules) {
|
|
40
|
+
for (const r of rules) this.register(r);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
unregister(ruleId) {
|
|
44
|
+
this._rules.delete(ruleId);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
list() {
|
|
48
|
+
return Array.from(this._rules.values());
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Run all rules (or filtered by category) against a context.
|
|
53
|
+
* @param {object} ctx - { files, config, projectRoot, gitDiff, ... }
|
|
54
|
+
* @param {string} [categoryFilter] - only run rules in this category
|
|
55
|
+
* @returns {{ findings: Finding[], stats: object }}
|
|
56
|
+
*/
|
|
57
|
+
run(ctx, categoryFilter) {
|
|
58
|
+
const findings = [];
|
|
59
|
+
let passed = 0;
|
|
60
|
+
let failed = 0;
|
|
61
|
+
|
|
62
|
+
for (const rule of this._rules.values()) {
|
|
63
|
+
if (categoryFilter && rule.category !== categoryFilter) continue;
|
|
64
|
+
try {
|
|
65
|
+
const result = rule.check(ctx);
|
|
66
|
+
if (result) {
|
|
67
|
+
if (Array.isArray(result)) {
|
|
68
|
+
for (const f of result) {
|
|
69
|
+
findings.push({ ...f, ruleId: rule.id, severity: f.severity || rule.severity, category: rule.category });
|
|
70
|
+
}
|
|
71
|
+
} else {
|
|
72
|
+
findings.push({
|
|
73
|
+
...result,
|
|
74
|
+
ruleId: rule.id,
|
|
75
|
+
severity: result.severity || rule.severity,
|
|
76
|
+
category: rule.category
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
failed++;
|
|
80
|
+
} else {
|
|
81
|
+
passed++;
|
|
82
|
+
}
|
|
83
|
+
} catch (err) {
|
|
84
|
+
// A broken rule should not crash the entire engine
|
|
85
|
+
findings.push({
|
|
86
|
+
ruleId: rule.id,
|
|
87
|
+
severity: 'info',
|
|
88
|
+
category: rule.category || 'unknown',
|
|
89
|
+
message: `Rule execution error: ${err.message}`,
|
|
90
|
+
file: ctx.file || '(unknown)',
|
|
91
|
+
context: { error: err.stack }
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const stats = {
|
|
97
|
+
totalRules: categoryFilter
|
|
98
|
+
? Array.from(this._rules.values()).filter(r => r.category === categoryFilter).length
|
|
99
|
+
: this._rules.size,
|
|
100
|
+
passed,
|
|
101
|
+
failed,
|
|
102
|
+
findings: findings.length,
|
|
103
|
+
blocks: findings.filter(f => f.severity === 'block').length,
|
|
104
|
+
warnings: findings.filter(f => f.severity === 'warn').length,
|
|
105
|
+
infos: findings.filter(f => f.severity === 'info').length,
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
// Sort: blocks first, then warnings, then info
|
|
109
|
+
const severityOrder = { block: 0, warn: 1, info: 2 };
|
|
110
|
+
findings.sort((a, b) => (severityOrder[a.severity] || 3) - (severityOrder[b.severity] || 3));
|
|
111
|
+
|
|
112
|
+
return { findings, stats };
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const engine = new RuleEngine();
|
|
117
|
+
module.exports = { RuleEngine, engine };
|
package/src/index.js
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DevAssist Agent - main entry point
|
|
3
|
+
*
|
|
4
|
+
* Layer 1 (Dev-time): ConventionStore, SchemaRegistry, ImpactAnalyzer,
|
|
5
|
+
* TechDebtTracker, CodeQualityGuard, ArchRiskAssessor, PreDeployGuard, VersionManager
|
|
6
|
+
*
|
|
7
|
+
* Layer 2 (Runtime base code): ChannelAgent, ChannelMiddleware, ToolRegistry,
|
|
8
|
+
* InfrastructureGuard, NotificationCenter, SchemaGatekeeper
|
|
9
|
+
* (Install into projects via: devassist init --base-code)
|
|
10
|
+
*
|
|
11
|
+
* Layer 3 (AI interface): Unified adapter for Qwen/OpenAI/Claude
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
// Core
|
|
15
|
+
const { EventBus, bus } = require('./core/event-bus');
|
|
16
|
+
const { RuleEngine, engine } = require('./core/rule-engine');
|
|
17
|
+
const { FSM, STATES } = require('./core/fsm');
|
|
18
|
+
const { Logger } = require('./core/logger');
|
|
19
|
+
|
|
20
|
+
// Dev-time modules (Layer 1)
|
|
21
|
+
const { ConventionStore } = require('./modules/dev-time/convention-store');
|
|
22
|
+
const { SchemaRegistry } = require('./modules/dev-time/schema-registry');
|
|
23
|
+
const { ImpactAnalyzer } = require('./modules/dev-time/impact-analyzer');
|
|
24
|
+
const { TechDebtTracker } = require('./modules/dev-time/tech-debt-tracker');
|
|
25
|
+
const { codeQualityRules } = require('./modules/dev-time/code-quality-guard');
|
|
26
|
+
const { ArchRiskAssessor } = require('./modules/dev-time/arch-risk-assessor');
|
|
27
|
+
const { PreDeployGuard } = require('./modules/dev-time/pre-deploy-guard');
|
|
28
|
+
const { VersionManager } = require('./modules/dev-time/version-manager');
|
|
29
|
+
|
|
30
|
+
// Runtime modules (Layer 2)
|
|
31
|
+
const runtime = require('./modules/runtime');
|
|
32
|
+
|
|
33
|
+
// AI adapter (Layer 3)
|
|
34
|
+
const ai = require('./ai/adapter');
|
|
35
|
+
|
|
36
|
+
// Register default rules
|
|
37
|
+
engine.registerBatch(codeQualityRules);
|
|
38
|
+
|
|
39
|
+
module.exports = {
|
|
40
|
+
// Core
|
|
41
|
+
EventBus,
|
|
42
|
+
bus,
|
|
43
|
+
RuleEngine,
|
|
44
|
+
engine,
|
|
45
|
+
FSM,
|
|
46
|
+
STATES,
|
|
47
|
+
Logger,
|
|
48
|
+
|
|
49
|
+
// Dev-time modules (Layer 1)
|
|
50
|
+
ConventionStore,
|
|
51
|
+
SchemaRegistry,
|
|
52
|
+
ImpactAnalyzer,
|
|
53
|
+
TechDebtTracker,
|
|
54
|
+
ArchRiskAssessor,
|
|
55
|
+
PreDeployGuard,
|
|
56
|
+
VersionManager,
|
|
57
|
+
codeQualityRules,
|
|
58
|
+
|
|
59
|
+
// Runtime modules (Layer 2)
|
|
60
|
+
...runtime,
|
|
61
|
+
|
|
62
|
+
// AI (Layer 3)
|
|
63
|
+
ai,
|
|
64
|
+
};
|