autosnippet 2.12.0 → 2.13.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/README.md +111 -37
- package/bin/cli.js +144 -0
- package/config/default.json +5 -0
- package/lib/core/AstAnalyzer.js +179 -0
- package/lib/external/mcp/handlers/guard.js +7 -1
- package/lib/http/routes/guardRules.js +27 -0
- package/lib/injection/ServiceContainer.js +29 -0
- package/lib/service/automation/handlers/GuardHandler.js +11 -0
- package/lib/service/guard/ComplianceReporter.js +335 -0
- package/lib/service/guard/GuardCheckEngine.js +171 -16
- package/lib/service/guard/GuardFeedbackLoop.js +125 -0
- package/lib/service/guard/GuardService.js +15 -3
- package/lib/service/guard/RuleLearner.js +116 -1
- package/lib/service/guard/SourceFileCollector.js +94 -0
- package/lib/service/guard/ViolationsStore.js +59 -1
- package/package.json +1 -1
- package/templates/guard-ci.yml +80 -0
- package/templates/pre-commit-guard.sh +33 -0
|
@@ -34,6 +34,8 @@ import { SnippetInstaller } from '../service/snippet/SnippetInstaller.js';
|
|
|
34
34
|
import { ExclusionManager } from '../service/guard/ExclusionManager.js';
|
|
35
35
|
import { RuleLearner } from '../service/guard/RuleLearner.js';
|
|
36
36
|
import { ViolationsStore } from '../service/guard/ViolationsStore.js';
|
|
37
|
+
import { ComplianceReporter } from '../service/guard/ComplianceReporter.js';
|
|
38
|
+
import { GuardFeedbackLoop } from '../service/guard/GuardFeedbackLoop.js';
|
|
37
39
|
|
|
38
40
|
// ─── P1: Token Usage Tracking ─────────────────────────
|
|
39
41
|
import { TokenUsageStore } from '../repository/token/TokenUsageStore.js';
|
|
@@ -459,6 +461,33 @@ export class ServiceContainer {
|
|
|
459
461
|
return this.singletons.violationsStore;
|
|
460
462
|
});
|
|
461
463
|
|
|
464
|
+
// Guard: ComplianceReporter
|
|
465
|
+
this.register('complianceReporter', () => {
|
|
466
|
+
if (!this.singletons.complianceReporter) {
|
|
467
|
+
const config = this.singletons._config || {};
|
|
468
|
+
this.singletons.complianceReporter = new ComplianceReporter(
|
|
469
|
+
this.get('guardCheckEngine'),
|
|
470
|
+
this.get('violationsStore'),
|
|
471
|
+
this.get('ruleLearner'),
|
|
472
|
+
this.get('exclusionManager'),
|
|
473
|
+
config.qualityGate || {},
|
|
474
|
+
);
|
|
475
|
+
}
|
|
476
|
+
return this.singletons.complianceReporter;
|
|
477
|
+
});
|
|
478
|
+
|
|
479
|
+
// Guard: GuardFeedbackLoop
|
|
480
|
+
this.register('guardFeedbackLoop', () => {
|
|
481
|
+
if (!this.singletons.guardFeedbackLoop) {
|
|
482
|
+
this.singletons.guardFeedbackLoop = new GuardFeedbackLoop(
|
|
483
|
+
this.get('violationsStore'),
|
|
484
|
+
this.get('feedbackCollector'),
|
|
485
|
+
{ guardCheckEngine: this.get('guardCheckEngine') },
|
|
486
|
+
);
|
|
487
|
+
}
|
|
488
|
+
return this.singletons.guardFeedbackLoop;
|
|
489
|
+
});
|
|
490
|
+
|
|
462
491
|
// Token Usage: 持久化 AI token 消耗
|
|
463
492
|
this.register('tokenUsageStore', () => {
|
|
464
493
|
if (!this.singletons.tokenUsageStore) {
|
|
@@ -211,9 +211,20 @@ function _auditSingleFile(engine, fullPath, code, detectLanguage, scope = 'file'
|
|
|
211
211
|
console.log(` 🛡️ ${errors.length} errors, ${warnings.length} warnings`);
|
|
212
212
|
for (const v of errors) {
|
|
213
213
|
console.log(` ❌ L${v.line} [${v.ruleId}] ${v.message}`);
|
|
214
|
+
if (v.fixSuggestion) console.log(` 🔧 修复建议: ${v.fixSuggestion}`);
|
|
214
215
|
}
|
|
215
216
|
for (const v of warnings.slice(0, 5)) {
|
|
216
217
|
console.log(` ⚠️ L${v.line} [${v.ruleId}] ${v.message}`);
|
|
218
|
+
if (v.fixSuggestion) console.log(` 🔧 修复建议: ${v.fixSuggestion}`);
|
|
217
219
|
}
|
|
218
220
|
}
|
|
221
|
+
|
|
222
|
+
// Guard ↔ Recipe 闭环:检测修复并自动确认使用(fire-and-forget)
|
|
223
|
+
import('../../../injection/ServiceContainer.js').then(({ ServiceContainer }) => {
|
|
224
|
+
try {
|
|
225
|
+
const container = ServiceContainer.getInstance();
|
|
226
|
+
const feedbackLoop = container.get('guardFeedbackLoop');
|
|
227
|
+
feedbackLoop.processFixDetection({ violations }, fullPath);
|
|
228
|
+
} catch { /* guardFeedbackLoop not available */ }
|
|
229
|
+
}).catch(() => { /* ignored */ });
|
|
219
230
|
}
|
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ComplianceReporter — 全项目 Guard 合规报告生成
|
|
3
|
+
*
|
|
4
|
+
* 依赖:
|
|
5
|
+
* - GuardCheckEngine.auditFiles() — 原始 violations 数据
|
|
6
|
+
* - ViolationsStore — 历史统计 & 趋势
|
|
7
|
+
* - RuleLearner — 规则 P/R/F1
|
|
8
|
+
* - ExclusionManager — 排除项(不计入合规分)
|
|
9
|
+
* - config.qualityGate — 阈值配置
|
|
10
|
+
*
|
|
11
|
+
* 输出:
|
|
12
|
+
* ComplianceReport { qualityGate, summary, topViolations, fileHotspots, ruleHealth, trend }
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import Logger from '../../infrastructure/logging/Logger.js';
|
|
16
|
+
import { collectSourceFilesWithContent } from './SourceFileCollector.js';
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Quality Gate 评分算法
|
|
20
|
+
*/
|
|
21
|
+
function computeScore(summary, ruleHealth = []) {
|
|
22
|
+
let score = 100;
|
|
23
|
+
|
|
24
|
+
// 扣分:每个 error -5,每个 warning -1,info -0.2
|
|
25
|
+
score -= summary.errors * 5;
|
|
26
|
+
score -= summary.warnings * 1;
|
|
27
|
+
score -= (summary.infos || 0) * 0.2;
|
|
28
|
+
|
|
29
|
+
// 加分:规则平均 F1 > 0.8 加 5 分
|
|
30
|
+
if (ruleHealth.length > 0) {
|
|
31
|
+
const avgF1 = ruleHealth.reduce((s, r) => s + (r.f1 || 0), 0) / ruleHealth.length;
|
|
32
|
+
if (avgF1 > 0.8) score += 5;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// 扣分:高误报规则每条 -3
|
|
36
|
+
const problematic = ruleHealth.filter(r => (r.precision || 1) < 0.5);
|
|
37
|
+
score -= problematic.length * 3;
|
|
38
|
+
|
|
39
|
+
return Math.max(0, Math.min(100, Math.round(score)));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* 判定 Quality Gate 状态
|
|
44
|
+
*/
|
|
45
|
+
function evaluateGate(summary, score, thresholds) {
|
|
46
|
+
const { maxErrors = 0, maxWarnings = 20, minScore = 70 } = thresholds;
|
|
47
|
+
|
|
48
|
+
if (summary.errors > maxErrors) return 'FAIL';
|
|
49
|
+
if (score < minScore) return 'FAIL';
|
|
50
|
+
if (summary.warnings > maxWarnings) return 'WARN';
|
|
51
|
+
return 'PASS';
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export class ComplianceReporter {
|
|
55
|
+
/**
|
|
56
|
+
* @param {import('./GuardCheckEngine.js').GuardCheckEngine} guardCheckEngine
|
|
57
|
+
* @param {import('./ViolationsStore.js').ViolationsStore} violationsStore
|
|
58
|
+
* @param {import('./RuleLearner.js').RuleLearner} ruleLearner
|
|
59
|
+
* @param {import('./ExclusionManager.js').ExclusionManager} exclusionManager
|
|
60
|
+
* @param {object} qualityGateConfig - { maxErrors, maxWarnings, minScore }
|
|
61
|
+
*/
|
|
62
|
+
constructor(guardCheckEngine, violationsStore, ruleLearner, exclusionManager, qualityGateConfig = {}) {
|
|
63
|
+
this.engine = guardCheckEngine;
|
|
64
|
+
this.violationsStore = violationsStore;
|
|
65
|
+
this.ruleLearner = ruleLearner;
|
|
66
|
+
this.exclusionManager = exclusionManager;
|
|
67
|
+
this.qualityGateConfig = {
|
|
68
|
+
maxErrors: 0,
|
|
69
|
+
maxWarnings: 20,
|
|
70
|
+
minScore: 70,
|
|
71
|
+
...qualityGateConfig,
|
|
72
|
+
};
|
|
73
|
+
this.logger = Logger.getInstance();
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* 生成全项目合规报告
|
|
78
|
+
* @param {string} projectRoot - 项目根目录
|
|
79
|
+
* @param {object} options
|
|
80
|
+
* @param {object} [options.qualityGate] - 覆盖默认的 Quality Gate 阈值
|
|
81
|
+
* @param {number} [options.maxFiles] - 最大扫描文件数
|
|
82
|
+
* @returns {Promise<ComplianceReport>}
|
|
83
|
+
*/
|
|
84
|
+
async generate(projectRoot, options = {}) {
|
|
85
|
+
const thresholds = { ...this.qualityGateConfig, ...(options.qualityGate || {}) };
|
|
86
|
+
const maxFiles = options.maxFiles || 500;
|
|
87
|
+
|
|
88
|
+
// 1. 收集源文件
|
|
89
|
+
const files = await collectSourceFilesWithContent(projectRoot, { maxFiles });
|
|
90
|
+
this.logger.info(`[ComplianceReporter] Collected ${files.length} source files`);
|
|
91
|
+
|
|
92
|
+
// 2. 批量审计
|
|
93
|
+
const auditResult = this.engine.auditFiles(files, { scope: 'project' });
|
|
94
|
+
|
|
95
|
+
// 3. 通过 ExclusionManager 过滤被排除的项
|
|
96
|
+
const filteredFiles = [];
|
|
97
|
+
for (const fileResult of auditResult.files || []) {
|
|
98
|
+
if (this.exclusionManager?.isPathExcluded?.(fileResult.filePath)) continue;
|
|
99
|
+
|
|
100
|
+
const filteredViolations = fileResult.violations.filter(v => {
|
|
101
|
+
// isRuleExcluded 内部已检查全局排除
|
|
102
|
+
if (this.exclusionManager?.isRuleExcluded?.(v.ruleId, fileResult.filePath)) return false;
|
|
103
|
+
return true;
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
filteredFiles.push({
|
|
107
|
+
...fileResult,
|
|
108
|
+
violations: filteredViolations,
|
|
109
|
+
summary: {
|
|
110
|
+
total: filteredViolations.length,
|
|
111
|
+
errors: filteredViolations.filter(v => v.severity === 'error').length,
|
|
112
|
+
warnings: filteredViolations.filter(v => v.severity === 'warning').length,
|
|
113
|
+
infos: filteredViolations.filter(v => v.severity === 'info').length,
|
|
114
|
+
},
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// 4. 汇总
|
|
119
|
+
const summary = {
|
|
120
|
+
filesScanned: files.length,
|
|
121
|
+
totalViolations: filteredFiles.reduce((s, f) => s + f.summary.total, 0),
|
|
122
|
+
errors: filteredFiles.reduce((s, f) => s + f.summary.errors, 0),
|
|
123
|
+
warnings: filteredFiles.reduce((s, f) => s + f.summary.warnings, 0),
|
|
124
|
+
infos: filteredFiles.reduce((s, f) => s + f.summary.infos, 0),
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
// 5. 按规则 ID 聚合 top violations
|
|
128
|
+
const ruleAgg = new Map();
|
|
129
|
+
for (const f of filteredFiles) {
|
|
130
|
+
for (const v of f.violations) {
|
|
131
|
+
const key = v.ruleId;
|
|
132
|
+
if (!ruleAgg.has(key)) {
|
|
133
|
+
ruleAgg.set(key, {
|
|
134
|
+
ruleId: key,
|
|
135
|
+
message: v.message,
|
|
136
|
+
severity: v.severity,
|
|
137
|
+
fileCount: new Set(),
|
|
138
|
+
occurrences: 0,
|
|
139
|
+
fixRecipeId: null,
|
|
140
|
+
fixRecipeTitle: null,
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
const agg = ruleAgg.get(key);
|
|
144
|
+
agg.fileCount.add(f.filePath);
|
|
145
|
+
agg.occurrences++;
|
|
146
|
+
if (v.fixSuggestion && !agg.fixRecipeId) {
|
|
147
|
+
agg.fixRecipeId = v.fixSuggestion.replace(/^recipe:/, '');
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const topViolations = [...ruleAgg.values()]
|
|
153
|
+
.map(v => ({ ...v, fileCount: v.fileCount.size }))
|
|
154
|
+
.sort((a, b) => b.occurrences - a.occurrences)
|
|
155
|
+
.slice(0, 20);
|
|
156
|
+
|
|
157
|
+
// 6. 文件热点
|
|
158
|
+
const fileHotspots = filteredFiles
|
|
159
|
+
.filter(f => f.summary.total > 0)
|
|
160
|
+
.map(f => ({
|
|
161
|
+
filePath: f.filePath,
|
|
162
|
+
violationCount: f.summary.total,
|
|
163
|
+
errorCount: f.summary.errors,
|
|
164
|
+
}))
|
|
165
|
+
.sort((a, b) => b.violationCount - a.violationCount)
|
|
166
|
+
.slice(0, 20);
|
|
167
|
+
|
|
168
|
+
// 7. 规则健康度(来自 RuleLearner)
|
|
169
|
+
let ruleHealth = [];
|
|
170
|
+
try {
|
|
171
|
+
if (this.ruleLearner?.getAllStats) {
|
|
172
|
+
const allStats = this.ruleLearner.getAllStats();
|
|
173
|
+
ruleHealth = Object.entries(allStats).map(([ruleId, stat]) => ({
|
|
174
|
+
ruleId,
|
|
175
|
+
precision: stat.metrics?.precision ?? 1,
|
|
176
|
+
recall: stat.metrics?.recall ?? 1,
|
|
177
|
+
f1: stat.metrics?.f1 ?? 1,
|
|
178
|
+
triggers: stat.triggers || 0,
|
|
179
|
+
warning: (stat.metrics?.precision ?? 1) < 0.5 ? '高误报' : null,
|
|
180
|
+
}));
|
|
181
|
+
}
|
|
182
|
+
} catch {
|
|
183
|
+
// RuleLearner not available
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// 8. 趋势
|
|
187
|
+
let trend = { errorsChange: 0, warningsChange: 0, hasHistory: false };
|
|
188
|
+
try {
|
|
189
|
+
if (this.violationsStore?.getTrend) {
|
|
190
|
+
trend = this.violationsStore.getTrend();
|
|
191
|
+
}
|
|
192
|
+
} catch {
|
|
193
|
+
// ViolationsStore not available
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// 9. 评分 + Gate
|
|
197
|
+
const score = computeScore(summary, ruleHealth);
|
|
198
|
+
const gateStatus = evaluateGate(summary, score, thresholds);
|
|
199
|
+
|
|
200
|
+
// 10. 写入 ViolationsStore(记录本次运行)
|
|
201
|
+
try {
|
|
202
|
+
if (this.violationsStore?.appendRun) {
|
|
203
|
+
const allViolations = filteredFiles.flatMap(f =>
|
|
204
|
+
f.violations.map(v => ({ ...v, filePath: f.filePath }))
|
|
205
|
+
);
|
|
206
|
+
this.violationsStore.appendRun({
|
|
207
|
+
filePath: projectRoot,
|
|
208
|
+
violations: allViolations,
|
|
209
|
+
summary: `Compliance scan: score=${score} ${gateStatus} | ${summary.errors}E ${summary.warnings}W`,
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
} catch {
|
|
213
|
+
// Persist failure — non-critical
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
return {
|
|
217
|
+
timestamp: new Date().toISOString(),
|
|
218
|
+
projectRoot,
|
|
219
|
+
qualityGate: {
|
|
220
|
+
status: gateStatus,
|
|
221
|
+
score,
|
|
222
|
+
thresholds,
|
|
223
|
+
},
|
|
224
|
+
summary,
|
|
225
|
+
topViolations,
|
|
226
|
+
fileHotspots,
|
|
227
|
+
ruleHealth,
|
|
228
|
+
trend,
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* 终端格式化输出报告
|
|
234
|
+
* @param {object} report - generate() 产出的报告
|
|
235
|
+
* @param {object} options - { format: 'text' | 'markdown' | 'json' }
|
|
236
|
+
*/
|
|
237
|
+
printReport(report, options = {}) {
|
|
238
|
+
const { format = 'text' } = options;
|
|
239
|
+
|
|
240
|
+
if (format === 'json') {
|
|
241
|
+
console.log(JSON.stringify(report, null, 2));
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
if (format === 'markdown') {
|
|
246
|
+
this._printMarkdown(report);
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// text format
|
|
251
|
+
this._printText(report);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
_printText(report) {
|
|
255
|
+
const { qualityGate, summary, topViolations, fileHotspots, trend } = report;
|
|
256
|
+
|
|
257
|
+
const gateIcon = qualityGate.status === 'PASS' ? '✅' : qualityGate.status === 'WARN' ? '⚠️' : '❌';
|
|
258
|
+
console.log(`\n${'═'.repeat(60)}`);
|
|
259
|
+
console.log(` 🛡️ Guard Compliance Report`);
|
|
260
|
+
console.log(`${'═'.repeat(60)}`);
|
|
261
|
+
console.log(` ${gateIcon} Quality Gate: ${qualityGate.status} (Score: ${qualityGate.score}/100)`);
|
|
262
|
+
console.log(` 📁 Files Scanned: ${summary.filesScanned}`);
|
|
263
|
+
console.log(` 📊 Violations: ${summary.errors} errors, ${summary.warnings} warnings, ${summary.infos || 0} infos`);
|
|
264
|
+
|
|
265
|
+
if (trend.hasHistory) {
|
|
266
|
+
const errTrend = trend.errorsChange > 0 ? `+${trend.errorsChange}` : `${trend.errorsChange}`;
|
|
267
|
+
const warnTrend = trend.warningsChange > 0 ? `+${trend.warningsChange}` : `${trend.warningsChange}`;
|
|
268
|
+
console.log(` 📈 Trend: errors ${errTrend}, warnings ${warnTrend}`);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
if (topViolations.length > 0) {
|
|
272
|
+
console.log(`\n Top Violations:`);
|
|
273
|
+
for (const v of topViolations.slice(0, 10)) {
|
|
274
|
+
const fix = v.fixRecipeId ? ` → 🔧 recipe:${v.fixRecipeId}` : '';
|
|
275
|
+
console.log(` [${v.severity}] ${v.ruleId} — ${v.occurrences} hits in ${v.fileCount} files${fix}`);
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
if (fileHotspots.length > 0) {
|
|
280
|
+
console.log(`\n File Hotspots:`);
|
|
281
|
+
for (const f of fileHotspots.slice(0, 10)) {
|
|
282
|
+
console.log(` 📄 ${f.filePath} — ${f.violationCount} violations (${f.errorCount} errors)`);
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
console.log(`${'═'.repeat(60)}\n`);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
_printMarkdown(report) {
|
|
290
|
+
const { qualityGate, summary, topViolations, fileHotspots, trend } = report;
|
|
291
|
+
const lines = [];
|
|
292
|
+
|
|
293
|
+
lines.push('# Guard Compliance Report');
|
|
294
|
+
lines.push('');
|
|
295
|
+
lines.push(`| Metric | Value |`);
|
|
296
|
+
lines.push(`|--------|-------|`);
|
|
297
|
+
lines.push(`| Quality Gate | ${qualityGate.status} (Score: ${qualityGate.score}/100) |`);
|
|
298
|
+
lines.push(`| Files Scanned | ${summary.filesScanned} |`);
|
|
299
|
+
lines.push(`| Errors | ${summary.errors} |`);
|
|
300
|
+
lines.push(`| Warnings | ${summary.warnings} |`);
|
|
301
|
+
lines.push(`| Infos | ${summary.infos || 0} |`);
|
|
302
|
+
|
|
303
|
+
if (trend.hasHistory) {
|
|
304
|
+
lines.push(`| Errors Trend | ${trend.errorsChange > 0 ? '+' : ''}${trend.errorsChange} |`);
|
|
305
|
+
lines.push(`| Warnings Trend | ${trend.warningsChange > 0 ? '+' : ''}${trend.warningsChange} |`);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
if (topViolations.length > 0) {
|
|
309
|
+
lines.push('');
|
|
310
|
+
lines.push('## Top Violations');
|
|
311
|
+
lines.push('');
|
|
312
|
+
lines.push('| Rule | Severity | Files | Hits | Fix |');
|
|
313
|
+
lines.push('|------|----------|-------|------|-----|');
|
|
314
|
+
for (const v of topViolations.slice(0, 20)) {
|
|
315
|
+
const fix = v.fixRecipeId ? `recipe:${v.fixRecipeId}` : '-';
|
|
316
|
+
lines.push(`| ${v.ruleId} | ${v.severity} | ${v.fileCount} | ${v.occurrences} | ${fix} |`);
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
if (fileHotspots.length > 0) {
|
|
321
|
+
lines.push('');
|
|
322
|
+
lines.push('## File Hotspots');
|
|
323
|
+
lines.push('');
|
|
324
|
+
lines.push('| File | Violations | Errors |');
|
|
325
|
+
lines.push('|------|-----------|--------|');
|
|
326
|
+
for (const f of fileHotspots.slice(0, 20)) {
|
|
327
|
+
lines.push(`| ${f.filePath} | ${f.violationCount} | ${f.errorCount} |`);
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
console.log(lines.join('\n'));
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
export default ComplianceReporter;
|
|
@@ -2,10 +2,11 @@
|
|
|
2
2
|
* GuardCheckEngine - Guard 规则检查引擎
|
|
3
3
|
*
|
|
4
4
|
* 从 V1 guard/ios 迁移,适配 V2 架构
|
|
5
|
-
* 支持: 正则模式匹配 + code-level 检查 + 多维度审计
|
|
5
|
+
* 支持: 正则模式匹配 + AST 语义规则 + code-level 检查 + 多维度审计
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import Logger from '../../infrastructure/logging/Logger.js';
|
|
9
|
+
import * as AstAnalyzerModule from '../../core/AstAnalyzer.js';
|
|
9
10
|
|
|
10
11
|
/**
|
|
11
12
|
* 内置默认规则集(iOS: ObjC + Swift)
|
|
@@ -227,6 +228,7 @@ export class GuardCheckEngine {
|
|
|
227
228
|
this.logger = Logger.getInstance();
|
|
228
229
|
this._builtInRules = BUILT_IN_RULES;
|
|
229
230
|
this._customRulesCache = null;
|
|
231
|
+
this._astRulesCache = null;
|
|
230
232
|
this._cacheTime = 0;
|
|
231
233
|
this._cacheTTL = options.cacheTTL || 60_000; // 1min
|
|
232
234
|
}
|
|
@@ -251,24 +253,40 @@ export class GuardCheckEngine {
|
|
|
251
253
|
AND lifecycle = 'active'`
|
|
252
254
|
).all();
|
|
253
255
|
} catch { /* table may not exist */ }
|
|
254
|
-
|
|
256
|
+
|
|
257
|
+
const regexRules = [];
|
|
258
|
+
const astRules = [];
|
|
259
|
+
|
|
260
|
+
for (const r of rows) {
|
|
255
261
|
let guards = [];
|
|
256
262
|
try {
|
|
257
263
|
const constraints = JSON.parse(r.constraints || '{}');
|
|
258
264
|
guards = constraints.guards || [];
|
|
259
265
|
} catch { /* ignore */ }
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
266
|
+
|
|
267
|
+
for (const g of guards) {
|
|
268
|
+
const ruleType = g.type || 'regex';
|
|
269
|
+
const base = {
|
|
270
|
+
id: g.id || r.id,
|
|
271
|
+
name: g.name || r.title,
|
|
272
|
+
message: g.message || r.description || r.title,
|
|
273
|
+
languages: r.language ? [r.language] : [],
|
|
274
|
+
severity: g.severity || 'warning',
|
|
275
|
+
dimension: r.scope || 'file',
|
|
276
|
+
source: 'database',
|
|
277
|
+
fixSuggestion: g.fixSuggestion || null,
|
|
278
|
+
};
|
|
279
|
+
|
|
280
|
+
if (ruleType === 'ast' && g.astQuery) {
|
|
281
|
+
astRules.push({ ...base, type: 'ast', astQuery: g.astQuery });
|
|
282
|
+
} else if (g.pattern) {
|
|
283
|
+
regexRules.push({ ...base, type: 'regex', pattern: g.pattern });
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
this._customRulesCache = regexRules;
|
|
289
|
+
this._astRulesCache = astRules;
|
|
272
290
|
this._cacheTime = now;
|
|
273
291
|
}
|
|
274
292
|
rules.push(...this._customRulesCache);
|
|
@@ -289,6 +307,8 @@ export class GuardCheckEngine {
|
|
|
289
307
|
severity: rule.severity,
|
|
290
308
|
dimension: rule.dimension || 'file',
|
|
291
309
|
source: 'built-in',
|
|
310
|
+
type: 'regex',
|
|
311
|
+
fixSuggestion: rule.fixSuggestion || null,
|
|
292
312
|
});
|
|
293
313
|
}
|
|
294
314
|
}
|
|
@@ -298,6 +318,15 @@ export class GuardCheckEngine {
|
|
|
298
318
|
rules = rules.filter(r => !r.languages?.length || r.languages.includes(language));
|
|
299
319
|
}
|
|
300
320
|
|
|
321
|
+
// 合并 AST 规则(供外部调用者使用,如 GuardFeedbackLoop.查找 fixSuggestion)
|
|
322
|
+
if (this._astRulesCache?.length) {
|
|
323
|
+
let astRules = this._astRulesCache;
|
|
324
|
+
if (language) {
|
|
325
|
+
astRules = astRules.filter(r => !r.languages?.length || r.languages.includes(language));
|
|
326
|
+
}
|
|
327
|
+
rules.push(...astRules);
|
|
328
|
+
}
|
|
329
|
+
|
|
301
330
|
return rules;
|
|
302
331
|
}
|
|
303
332
|
|
|
@@ -306,7 +335,7 @@ export class GuardCheckEngine {
|
|
|
306
335
|
* @param {string} code - 源代码
|
|
307
336
|
* @param {string} language - 'objc'|'swift'|'javascript' 等
|
|
308
337
|
* @param {object} options - {scope, filePath}
|
|
309
|
-
* @returns {Array<{ruleId, message, severity, line, snippet, dimension?}>}
|
|
338
|
+
* @returns {Array<{ruleId, message, severity, line, snippet, dimension?, fixSuggestion?}>}
|
|
310
339
|
*/
|
|
311
340
|
checkCode(code, language, options = {}) {
|
|
312
341
|
const { scope = null } = options;
|
|
@@ -346,6 +375,7 @@ export class GuardCheckEngine {
|
|
|
346
375
|
line: i + 1,
|
|
347
376
|
snippet: lines[i].trim().slice(0, 120),
|
|
348
377
|
...(rule.dimension ? { dimension: rule.dimension } : {}),
|
|
378
|
+
...(rule.fixSuggestion ? { fixSuggestion: rule.fixSuggestion } : {}),
|
|
349
379
|
});
|
|
350
380
|
}
|
|
351
381
|
}
|
|
@@ -354,6 +384,9 @@ export class GuardCheckEngine {
|
|
|
354
384
|
// Code-level 检查(不依赖正则)
|
|
355
385
|
violations.push(...this._runCodeLevelChecks(code, language, lines));
|
|
356
386
|
|
|
387
|
+
// AST 语义规则检查
|
|
388
|
+
violations.push(...this._runAstRuleChecks(code, language));
|
|
389
|
+
|
|
357
390
|
// 跟踪 Guard 命中次数(回写 Recipe 统计)
|
|
358
391
|
this.trackGuardHits(violations);
|
|
359
392
|
|
|
@@ -363,11 +396,133 @@ export class GuardCheckEngine {
|
|
|
363
396
|
reasoning: {
|
|
364
397
|
whatViolated: v.ruleId,
|
|
365
398
|
whyItMatters: v.message,
|
|
366
|
-
suggestedFix: v.suggestedFix || null,
|
|
399
|
+
suggestedFix: v.fixSuggestion || v.suggestedFix || null,
|
|
367
400
|
},
|
|
368
401
|
}));
|
|
369
402
|
}
|
|
370
403
|
|
|
404
|
+
/**
|
|
405
|
+
* AST 语义规则检查
|
|
406
|
+
* 支持 3 种查询类型: mustCallThrough, mustNotUseInContext, mustConformToProtocol
|
|
407
|
+
* 仅在 Tree-sitter 可用且语言为 ObjC/Swift 时执行
|
|
408
|
+
* @param {string} code - 源代码
|
|
409
|
+
* @param {string} language - 语言标识
|
|
410
|
+
* @returns {Array} violations
|
|
411
|
+
*/
|
|
412
|
+
_runAstRuleChecks(code, language) {
|
|
413
|
+
// AST 规则仅支持 ObjC + Swift
|
|
414
|
+
const astLangMap = { objc: 'objectivec', swift: 'swift' };
|
|
415
|
+
const astLang = astLangMap[language];
|
|
416
|
+
if (!astLang) return [];
|
|
417
|
+
|
|
418
|
+
// 获取缓存中的 AST 规则
|
|
419
|
+
const astRules = (this._astRulesCache || []).filter(r =>
|
|
420
|
+
!r.languages?.length || r.languages.includes(language)
|
|
421
|
+
);
|
|
422
|
+
if (astRules.length === 0) return [];
|
|
423
|
+
|
|
424
|
+
// 延迟加载 AstAnalyzer
|
|
425
|
+
let AstAnalyzer;
|
|
426
|
+
try {
|
|
427
|
+
// 使用 dynamic import 会是 async,这里用 require 风格同步加载
|
|
428
|
+
// AstAnalyzer 作为 ESM 模块,在 constructor 时已被引入
|
|
429
|
+
AstAnalyzer = this._getAstAnalyzer();
|
|
430
|
+
if (!AstAnalyzer || !AstAnalyzer.isAvailable()) return [];
|
|
431
|
+
} catch {
|
|
432
|
+
this.logger.debug('AstAnalyzer not available, skipping AST rules');
|
|
433
|
+
return [];
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
const violations = [];
|
|
437
|
+
|
|
438
|
+
for (const rule of astRules) {
|
|
439
|
+
const { astQuery } = rule;
|
|
440
|
+
if (!astQuery?.queryType) continue;
|
|
441
|
+
|
|
442
|
+
try {
|
|
443
|
+
switch (astQuery.queryType) {
|
|
444
|
+
case 'mustCallThrough': {
|
|
445
|
+
// 检查某 API 是否只在指定 wrapper 类中调用
|
|
446
|
+
const { targetAPI, wrapperClass } = astQuery.params || {};
|
|
447
|
+
if (!targetAPI || !wrapperClass) break;
|
|
448
|
+
|
|
449
|
+
const calls = AstAnalyzer.findCallExpressions(code, astLang, targetAPI);
|
|
450
|
+
for (const call of calls) {
|
|
451
|
+
if (call.enclosingClass !== wrapperClass) {
|
|
452
|
+
violations.push({
|
|
453
|
+
ruleId: rule.id,
|
|
454
|
+
message: rule.message,
|
|
455
|
+
severity: rule.severity,
|
|
456
|
+
line: call.line,
|
|
457
|
+
snippet: call.snippet,
|
|
458
|
+
dimension: rule.dimension || 'file',
|
|
459
|
+
...(rule.fixSuggestion ? { fixSuggestion: rule.fixSuggestion } : {}),
|
|
460
|
+
});
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
break;
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
case 'mustNotUseInContext': {
|
|
467
|
+
// 在特定上下文中禁止使用某模式
|
|
468
|
+
const { pattern: textPattern, forbiddenContext } = astQuery.params || {};
|
|
469
|
+
if (!textPattern || !forbiddenContext) break;
|
|
470
|
+
|
|
471
|
+
const matches = AstAnalyzer.findPatternInContext(code, astLang, textPattern, {
|
|
472
|
+
forbiddenContext,
|
|
473
|
+
});
|
|
474
|
+
for (const match of matches) {
|
|
475
|
+
violations.push({
|
|
476
|
+
ruleId: rule.id,
|
|
477
|
+
message: rule.message,
|
|
478
|
+
severity: rule.severity,
|
|
479
|
+
line: match.line,
|
|
480
|
+
snippet: match.snippet,
|
|
481
|
+
dimension: rule.dimension || 'file',
|
|
482
|
+
...(rule.fixSuggestion ? { fixSuggestion: rule.fixSuggestion } : {}),
|
|
483
|
+
});
|
|
484
|
+
}
|
|
485
|
+
break;
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
case 'mustConformToProtocol': {
|
|
489
|
+
// 检查类是否实现了指定协议
|
|
490
|
+
const { className, protocolName } = astQuery.params || {};
|
|
491
|
+
if (!className || !protocolName) break;
|
|
492
|
+
|
|
493
|
+
const result = AstAnalyzer.checkProtocolConformance(code, astLang, className, protocolName);
|
|
494
|
+
if (result.classFound && !result.conforms) {
|
|
495
|
+
violations.push({
|
|
496
|
+
ruleId: rule.id,
|
|
497
|
+
message: rule.message,
|
|
498
|
+
severity: rule.severity,
|
|
499
|
+
line: result.classDeclLine || 1,
|
|
500
|
+
snippet: `class ${className} — missing ${protocolName} conformance`,
|
|
501
|
+
dimension: rule.dimension || 'file',
|
|
502
|
+
...(rule.fixSuggestion ? { fixSuggestion: rule.fixSuggestion } : {}),
|
|
503
|
+
});
|
|
504
|
+
}
|
|
505
|
+
break;
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
default:
|
|
509
|
+
this.logger.debug(`Unknown AST query type: ${astQuery.queryType}`);
|
|
510
|
+
}
|
|
511
|
+
} catch (err) {
|
|
512
|
+
this.logger.debug(`AST rule ${rule.id} check failed: ${err.message}`);
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
return violations;
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
/**
|
|
520
|
+
* 获取 AstAnalyzer 模块(静态 import,带可用性检测)
|
|
521
|
+
*/
|
|
522
|
+
_getAstAnalyzer() {
|
|
523
|
+
return AstAnalyzerModule;
|
|
524
|
+
}
|
|
525
|
+
|
|
371
526
|
/**
|
|
372
527
|
* 将 Guard 命中计数回写到对应 Recipe 的 guard_hit_count
|
|
373
528
|
* @param {Array<{ruleId: string}>} violations
|