feat: AI 修复预生成 + 审查面板 diff 预览两步确认 + SQLFluff 行号修复 + PMD 内置规则精简
- AI 修复预生成:静态/custom/AI 条目审查时预生成修复片段(originalText/newText),展开问题即显示行级 diff,无预生成时展示占位提示,匹配失败回退实时 LLM 生成 - 修复前 diff 预览:侧边新建编辑器组打开内置 diff,确认后写入(两步确认),不覆盖当前文件 - fix: sqlfluff 在 jinja 标签位于注释内时 JJ01 JSON 缺失 end_line_no/end_line_pos,适配器 Range 构造产生 NaN 被 VSCode 交换 start/end 导致行号 LNaN;新增 resolveSqlFluffRange 兜底 + 面板/report 行号 Number.isFinite 防御 - fix: 从 PMD 内置 ruleset 移除 5 条实际不可触发规则(AvoidAssertAsIdentifier、AvoidEnumAsIdentifier、AccessorClassGeneration、AccessorMethodGeneration、LoosePackageCoupling),内置配置 274→269 条全部可触发,同步 static-rules.json 与翻译脚本 - docs: README/DESIGN 更新
This commit is contained in:
@@ -104,6 +104,7 @@ function findCustomIssue(ruleId?: string, line?: number): ReviewIssueInput | und
|
||||
line: d.range.start.line,
|
||||
message: d.message,
|
||||
suggestion: d.suggestion,
|
||||
fix: d.aiFix,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -118,9 +119,21 @@ function findAIIssue(ruleId?: string, line?: number): ReviewIssueInput | undefin
|
||||
line: f.line,
|
||||
message: f.title,
|
||||
suggestion: f.suggestion,
|
||||
fix: f.fix,
|
||||
};
|
||||
}
|
||||
|
||||
function enrichLinterDiagnostic(diag: LinterDiagnostic): LinterDiagnostic {
|
||||
if (diag.aiFix || !currentReport) { return diag; }
|
||||
const reportDiag = currentReport.linterDiagnostics.find(d =>
|
||||
d.ruleId === diag.ruleId && d.range.start.line === diag.range.start.line
|
||||
);
|
||||
if (reportDiag?.aiFix) {
|
||||
return { ...diag, aiFix: reportDiag.aiFix };
|
||||
}
|
||||
return diag;
|
||||
}
|
||||
|
||||
async function refreshAfterFix(
|
||||
document: vscode.TextDocument,
|
||||
orchestrator: Orchestrator,
|
||||
@@ -145,6 +158,7 @@ async function refreshAfterFix(
|
||||
severity: d.severity,
|
||||
message: d.message,
|
||||
suggestion: d.suggestion,
|
||||
fix: d.aiFix,
|
||||
line: d.range.start.line + 1,
|
||||
})),
|
||||
translatedDiagnostics: currentReport.translatedDiagnostics,
|
||||
@@ -157,6 +171,7 @@ async function refreshAfterFix(
|
||||
adapterIds: result.adapterIds,
|
||||
aiFixAvailable: currentReport.aiFixAvailable,
|
||||
customRuleFilterInfo: currentReport.customRuleFilterInfo,
|
||||
code: document.getText(),
|
||||
});
|
||||
const panel = ReviewPanel.createOrShow(extensionUri);
|
||||
panel.setFixSession(fixSession);
|
||||
@@ -239,6 +254,7 @@ export function registerCommands(
|
||||
filteredOut: filterResult.filteredOut.length,
|
||||
skippedRequestA: filterResult.skippedRequestA,
|
||||
},
|
||||
code: document.getText(),
|
||||
});
|
||||
|
||||
const panel = ReviewPanel.createOrShow(context.extensionUri);
|
||||
@@ -336,6 +352,7 @@ export function registerCommands(
|
||||
adapterIds: [],
|
||||
aiFixAvailable: false,
|
||||
customRuleFilterInfo: undefined,
|
||||
code: document.getText(),
|
||||
});
|
||||
|
||||
statusCache.set(document.uri, scope.name, totalIssues);
|
||||
@@ -499,7 +516,7 @@ export function registerCommands(
|
||||
location: vscode.ProgressLocation.Notification,
|
||||
title: needsAi ? t('fix.aiRunning') : t('fix.running'),
|
||||
cancellable: false,
|
||||
}, () => resolveFix(context, document, workingDir, adapter, diag, maxIterations, isPreview));
|
||||
}, () => resolveFix(context, document, workingDir, adapter, enrichLinterDiagnostic(diag), maxIterations, isPreview));
|
||||
if (!result.success) {
|
||||
const msg = diag.fix
|
||||
? t('fix.failed', { 0: result.message ?? '' })
|
||||
@@ -577,12 +594,14 @@ export function registerCommands(
|
||||
line: d.range.start.line,
|
||||
message: d.message,
|
||||
suggestion: d.suggestion,
|
||||
fix: d.aiFix,
|
||||
}))
|
||||
: (currentReport?.aiFindings ?? []).map(f => ({
|
||||
ruleId: f.ruleId,
|
||||
line: f.line,
|
||||
message: f.title,
|
||||
suggestion: f.suggestion,
|
||||
fix: f.fix,
|
||||
}));
|
||||
if (issues.length === 0) {
|
||||
vscode.window.showInformationMessage(t('fix.noFix'));
|
||||
@@ -673,7 +692,7 @@ export function registerCommands(
|
||||
d.ruleId === diag.ruleId && d.range.start.line === diag.range.start.line
|
||||
) ?? fresh.diagnostics.find(d => d.ruleId === diag.ruleId);
|
||||
if (!freshDiag) { skipped++; continue; }
|
||||
const result = await resolveFix(context, mock, workingDir, adapter, freshDiag, maxIterations, true);
|
||||
const result = await resolveFix(context, mock, workingDir, adapter, enrichLinterDiagnostic(freshDiag), maxIterations, true);
|
||||
if (result.success && result.newText && result.newText !== currentText) {
|
||||
results.push({ ruleId: freshDiag.ruleId, line: freshDiag.range.start.line, appliedFixes: result.appliedFixes });
|
||||
currentText = result.newText;
|
||||
|
||||
+20
-10
@@ -115,14 +115,29 @@ export function resolveSqlFluffDialect(workspaceRoot: string): SqlFluffDialectIn
|
||||
}
|
||||
|
||||
interface SqlFluffViolation {
|
||||
start_line_no: number;
|
||||
start_line_pos: number;
|
||||
end_line_no: number;
|
||||
end_line_pos: number;
|
||||
start_line_no?: number | null;
|
||||
start_line_pos?: number | null;
|
||||
end_line_no?: number | null;
|
||||
end_line_pos?: number | null;
|
||||
line_no?: number | null;
|
||||
line_pos?: number | null;
|
||||
code: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
function sanitizePosition(value: number | null | undefined, fallback: number): number {
|
||||
const n = Number(value);
|
||||
return Number.isFinite(n) && n >= 1 ? n : fallback;
|
||||
}
|
||||
|
||||
export function resolveSqlFluffRange(v: SqlFluffViolation): [number, number, number, number] {
|
||||
const startLine = sanitizePosition(v.start_line_no ?? v.line_no, 1);
|
||||
const startPos = sanitizePosition(v.start_line_pos ?? v.line_pos, 1);
|
||||
const endLine = sanitizePosition(v.end_line_no, startLine);
|
||||
const endPos = sanitizePosition(v.end_line_pos, startPos);
|
||||
return [startLine - 1, startPos - 1, endLine - 1, endPos - 1];
|
||||
}
|
||||
|
||||
interface SqlFluffResult {
|
||||
filepath: string;
|
||||
violations: SqlFluffViolation[];
|
||||
@@ -213,12 +228,7 @@ export class SqlFluffAdapter implements LinterAdapter {
|
||||
severity: isPRS ? 'error' : tierToSeverity(tierMap.get(v.code)),
|
||||
ruleId: `sqlfluff:${v.code}`,
|
||||
message: isPRS ? buildPRSMessage(v.description, effectiveDialect) : v.description,
|
||||
range: new vscode.Range(
|
||||
v.start_line_no - 1,
|
||||
v.start_line_pos - 1,
|
||||
v.end_line_no - 1,
|
||||
v.end_line_pos - 1
|
||||
),
|
||||
range: new vscode.Range(...resolveSqlFluffRange(v)),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
+49
-19
@@ -105,8 +105,8 @@ function buildCustomRuleSystemPrompt(): string {
|
||||
return `あなたはコードルールレビュアーです。以下のカスタムルールに違反しているかどうかのみを評価してください。
|
||||
意味を理解し、テキストの一致ではなく判断してください。
|
||||
JSONのみを出力、形式:
|
||||
{ "customRuleResults": [{ "ruleId": "ルールID", "line": 行番号, "severity": "error|warning|info", "message": "違反の説明", "suggestion": "具体的な修正提案" }] }
|
||||
"suggestion" は実行可能な修正提案を必ず含めてください。
|
||||
{ "customRuleResults": [{ "ruleId": "ルールID", "line": 行番号, "severity": "error|warning|info", "message": "違反の説明", "suggestion": "具体的な修正提案", "fix": { "originalText": "置換対象のコード原文(コードコンテキスト内に完全一致すること、行番号プレフィックスなし)", "newText": "修正後のコード片" } }] }
|
||||
各違反に対して必ず実行可能な "suggestion" を含め、可能な場合は適用可能な "fix" も提供してください。
|
||||
ルールに違反していない場合は空の配列を返してください。
|
||||
|
||||
出力言語:ja`;
|
||||
@@ -115,8 +115,8 @@ JSONのみを出力、形式:
|
||||
return `You are a code rule reviewer. Only evaluate whether the following custom rules are violated.
|
||||
Understand semantics, not text matching.
|
||||
Output JSON only, format:
|
||||
{ "customRuleResults": [{ "ruleId": "rule id", "line": line number, "severity": "error|warning|info", "message": "violation description", "suggestion": "concrete fix suggestion" }] }
|
||||
Always include a concrete actionable "suggestion" for each violation.
|
||||
{ "customRuleResults": [{ "ruleId": "rule id", "line": line number, "severity": "error|warning|info", "message": "violation description", "suggestion": "concrete fix suggestion", "fix": { "originalText": "the exact code snippet to replace (must exist verbatim in the code context, without the line-number prefix)", "newText": "the fixed code snippet" } }] }
|
||||
Always include a concrete actionable "suggestion" for each violation, and provide an applicable "fix" snippet when possible.
|
||||
If no rules are violated, return an empty array.
|
||||
|
||||
Output language: en`;
|
||||
@@ -124,8 +124,8 @@ Output language: en`;
|
||||
return `你是代码规则审查员,只评估以下自定义规则是否被违反。
|
||||
理解语义而非文本匹配。
|
||||
仅输出 JSON,格式:
|
||||
{ "customRuleResults": [{ "ruleId": "规则ID", "line": 行号, "severity": "error|warning|info", "message": "触发描述", "suggestion": "具体的修复建议" }] }
|
||||
每条违规都必须给出可执行的 "suggestion" 修复建议。
|
||||
{ "customRuleResults": [{ "ruleId": "规则ID", "line": 行号, "severity": "error|warning|info", "message": "触发描述", "suggestion": "具体的修复建议", "fix": { "originalText": "待替换的代码原文(必须在代码上下文中逐字存在,不含行号前缀)", "newText": "修复后的代码片段" } }] }
|
||||
每条违规都必须给出可执行的 "suggestion" 修复建议,并尽量提供可应用的 "fix" 修复片段。
|
||||
如果没有违反任何规则,返回空数组。
|
||||
|
||||
输出语言:zh-CN`;
|
||||
@@ -137,7 +137,7 @@ function buildDeepReviewSystemPrompt(): string {
|
||||
return `あなたはシニアコードレビュー専門家です。2つのタスクを実行してください:
|
||||
1. 英語の静的解析結果を出力言語に翻訳し、修正提案を追加する
|
||||
2. コードを詳細にレビューし、静的解析でカバーされていない問題を発見する
|
||||
重点分野:セキュリティ脆弱性、論理エラー、パフォーマンス問題、設計欠陥
|
||||
重点分野:セキュリティ脆弱性、ロジックエラー、パフォーマンス問題、設計欠陥
|
||||
静的解析ですでに報告された問題を重複しないでください。
|
||||
|
||||
translatedDiagnosticsの要件:
|
||||
@@ -145,12 +145,17 @@ translatedDiagnosticsの要件:
|
||||
- "originalRuleId" はリスト内のルールID(eslint: 等のプレフィックスを含む)をそのままコピーし、書き換えないでください
|
||||
- "translatedMessage" と "translatedSuggestion" は両方必須で、空にしないでください
|
||||
- "translatedSuggestion" は具体的で実行可能な修正提案(例:この書き方に置き換える)を示してください
|
||||
- 可能な場合は各診断に適用可能な "fix" を提供してください
|
||||
|
||||
findingsの要件:
|
||||
- 可能な場合は各発見に適用可能な "fix" を提供してください
|
||||
- "fix.originalText" は提供されたコード内に逐語的に存在すること(行番号プレフィックスなし)
|
||||
|
||||
JSONのみを出力。文字列内の二重引用符は \\" でエスケープしてください。
|
||||
形式:
|
||||
{
|
||||
"translatedDiagnostics": [{ "originalRuleId": "元のID", "translatedMessage": "翻訳メッセージ", "translatedSuggestion": "提案" }],
|
||||
"findings": [{ "ruleId": "kebab-case", "severity": "error|warning|info", "category": "bug|performance|security|style|design", "title": "タイトル", "description": "説明", "suggestion": "提案", "line": 行番号 }]
|
||||
"translatedDiagnostics": [{ "originalRuleId": "元のID", "translatedMessage": "翻訳メッセージ", "translatedSuggestion": "提案", "fix": { "originalText": "置換対象のコード原文(行番号プレフィックスなし)", "newText": "修正後のコード片" } }],
|
||||
"findings": [{ "ruleId": "kebab-case", "severity": "error|warning|info", "category": "bug|performance|security|style|design", "title": "タイトル", "description": "説明", "suggestion": "提案", "line": 行番号, "fix": { "originalText": "置換対象のコード原文(行番号プレフィックスなし)", "newText": "修正後のコード片" } }]
|
||||
}
|
||||
|
||||
出力言語:ja`;
|
||||
@@ -167,12 +172,17 @@ translatedDiagnostics requirements:
|
||||
- "originalRuleId" must be copied verbatim from the listed rule IDs (keep prefixes like eslint:), do not rewrite
|
||||
- "translatedMessage" and "translatedSuggestion" are both required and must not be empty
|
||||
- "translatedSuggestion" should be a concrete actionable fix suggestion (e.g. what to replace it with), not just a replacement snippet
|
||||
- Provide an applicable "fix" snippet for each diagnostic when possible
|
||||
|
||||
findings requirements:
|
||||
- Provide an applicable "fix" snippet for each finding when possible
|
||||
- "fix.originalText" must exist verbatim in the provided code (without the line-number prefix)
|
||||
|
||||
Output JSON only. Double quotes in strings must be escaped with \\".
|
||||
Format:
|
||||
{
|
||||
"translatedDiagnostics": [{ "originalRuleId": "original id", "translatedMessage": "translated message", "translatedSuggestion": "suggestion" }],
|
||||
"findings": [{ "ruleId": "kebab-case", "severity": "error|warning|info", "category": "bug|performance|security|style|design", "title": "title", "description": "description", "suggestion": "suggestion", "line": line number }]
|
||||
"translatedDiagnostics": [{ "originalRuleId": "original id", "translatedMessage": "translated message", "translatedSuggestion": "suggestion", "fix": { "originalText": "exact snippet to replace (without line-number prefix)", "newText": "fixed snippet" } }],
|
||||
"findings": [{ "ruleId": "kebab-case", "severity": "error|warning|info", "category": "bug|performance|security|style|design", "title": "title", "description": "description", "suggestion": "suggestion", "line": line number, "fix": { "originalText": "exact snippet to replace (without line-number prefix)", "newText": "fixed snippet" } }]
|
||||
}
|
||||
|
||||
Output language: en`;
|
||||
@@ -188,12 +198,17 @@ translatedDiagnostics 要求:
|
||||
- "originalRuleId" 必须原样复制列表中的规则 ID(保留 eslint: 等前缀),不得改写
|
||||
- "translatedMessage" 与 "translatedSuggestion" 均为必填字段,不得为空
|
||||
- "translatedSuggestion" 给出具体可执行的修复建议(如应替换成什么写法),不要只给替换片段
|
||||
- 尽量为每条诊断提供 "fix" 可应用修复片段
|
||||
|
||||
findings 要求:
|
||||
- 尽量为每条发现提供 "fix" 可应用修复片段
|
||||
- "fix.originalText" 必须在提供的代码中逐字存在(不含行号前缀)
|
||||
|
||||
仅输出 JSON,字符串中的双引号必须用 \\" 转义。
|
||||
格式:
|
||||
{
|
||||
"translatedDiagnostics": [{ "originalRuleId": "原始ID", "translatedMessage": "翻译", "translatedSuggestion": "建议" }],
|
||||
"findings": [{ "ruleId": "kebab-case", "severity": "error|warning|info", "category": "bug|performance|security|style|design", "title": "标题", "description": "描述", "suggestion": "建议", "line": 行号 }]
|
||||
"translatedDiagnostics": [{ "originalRuleId": "原始ID", "translatedMessage": "翻译", "translatedSuggestion": "建议", "fix": { "originalText": "待替换的代码原文(不含行号前缀)", "newText": "修复后的代码片段" } }],
|
||||
"findings": [{ "ruleId": "kebab-case", "severity": "error|warning|info", "category": "bug|performance|security|style|design", "title": "标题", "description": "描述", "suggestion": "建议", "line": 行号, "fix": { "originalText": "待替换的代码原文(不含行号前缀)", "newText": "修复后的代码片段" } }]
|
||||
}
|
||||
|
||||
输出语言:zh-CN`;
|
||||
@@ -430,7 +445,8 @@ Report violations in "customRuleResults".\n\n`
|
||||
"line": line_number,
|
||||
"severity": "error|warning|info",
|
||||
"message": "violation description",
|
||||
"suggestion": "concrete fix suggestion"
|
||||
"suggestion": "concrete fix suggestion",
|
||||
"fix": { "originalText": "exact snippet to replace (without line-number prefix)", "newText": "fixed snippet" }
|
||||
}
|
||||
],\n`
|
||||
: '';
|
||||
@@ -456,6 +472,9 @@ F. Testability: side effect isolation, dependency mockability, deterministic out
|
||||
- Check whether this method's return value is correctly handled by callers
|
||||
- Check whether exceptions are caught or declared by callers
|
||||
|
||||
Provide an applicable "fix" snippet for each finding when possible.
|
||||
"fix.originalText" must exist verbatim in the provided method code (without the line-number prefix).
|
||||
|
||||
Output JSON only. Double quotes in strings must be escaped with \\".
|
||||
Format:
|
||||
{
|
||||
@@ -468,7 +487,8 @@ ${ruleOutput} "findings": [
|
||||
"description": "detailed description",
|
||||
"suggestion": "fix suggestion",
|
||||
"line": line_number,
|
||||
"path": "trigger path description, e.g. if(order==null) -> NPE on .getId()"
|
||||
"path": "trigger path description, e.g. if(order==null) -> NPE on .getId()",
|
||||
"fix": { "originalText": "exact snippet to replace (without line-number prefix)", "newText": "fixed snippet" }
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -491,7 +511,8 @@ function buildMethodSystemPromptZh(hasRules: boolean): string {
|
||||
"line": 行号,
|
||||
"severity": "error|warning|info",
|
||||
"message": "违规描述",
|
||||
"suggestion": "具体的修复建议"
|
||||
"suggestion": "具体的修复建议",
|
||||
"fix": { "originalText": "待替换的代码原文(不含行号前缀)", "newText": "修复后的代码片段" }
|
||||
}
|
||||
],\n`
|
||||
: '';
|
||||
@@ -517,6 +538,9 @@ F. 可测试性:副作用隔离、依赖可 Mock 性、确定性输出
|
||||
- 检查本方法的返回值是否被调用者正确处理
|
||||
- 检查异常是否被调用者捕获或声明
|
||||
|
||||
尽可能为每条发现提供可应用的 "fix" 修复片段。
|
||||
"fix.originalText" 必须在提供的方法代码中逐字存在(不含行号前缀)。
|
||||
|
||||
输出 JSON,字符串中的双引号必须用 \\" 转义。
|
||||
格式:
|
||||
{
|
||||
@@ -529,7 +553,8 @@ ${ruleOutput} "findings": [
|
||||
"description": "详细描述",
|
||||
"suggestion": "修复建议",
|
||||
"line": 行号,
|
||||
"path": "触发路径描述,如 if(order==null) -> NPE on .getId()"
|
||||
"path": "触发路径描述,如 if(order==null) -> NPE on .getId()",
|
||||
"fix": { "originalText": "待替换的代码原文(不含行号前缀)", "newText": "修复后的代码片段" }
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -552,7 +577,8 @@ function buildMethodSystemPromptJa(hasRules: boolean): string {
|
||||
"line": 行番号,
|
||||
"severity": "error|warning|info",
|
||||
"message": "違反の説明",
|
||||
"suggestion": "具体的な修正提案"
|
||||
"suggestion": "具体的な修正提案",
|
||||
"fix": { "originalText": "置換対象のコード原文(行番号プレフィックスなし)", "newText": "修正後のコード片" }
|
||||
}
|
||||
],\n`
|
||||
: '';
|
||||
@@ -578,6 +604,9 @@ F. テスタビリティ:副作用の分離、依存のモック化容易性
|
||||
- このメソッドの戻り値が呼び出し元で正しく処理されているか確認
|
||||
- 例外が呼び出し元でキャッチまたは宣言されているか確認
|
||||
|
||||
可能な場合は各発見に適用可能な "fix" を提供してください。
|
||||
"fix.originalText" は提供されたメソッドコード内に逐語的に存在すること(行番号プレフィックスなし)。
|
||||
|
||||
JSONのみを出力。文字列内の二重引用符は \\" でエスケープしてください。
|
||||
形式:
|
||||
{
|
||||
@@ -590,7 +619,8 @@ ${ruleOutput} "findings": [
|
||||
"description": "詳細な説明",
|
||||
"suggestion": "修正提案",
|
||||
"line": 行番号,
|
||||
"path": "トリガーパス説明、例: if(order==null) -> .getId() で NPE"
|
||||
"path": "トリガーパス説明、例: if(order==null) -> .getId() で NPE",
|
||||
"fix": { "originalText": "置換対象のコード原文(行番号プレフィックスなし)", "newText": "修正後のコード片" }
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -2,6 +2,10 @@ export interface TranslatedDiagnostic {
|
||||
originalRuleId: string;
|
||||
translatedMessage: string;
|
||||
translatedSuggestion: string;
|
||||
fix?: {
|
||||
originalText: string;
|
||||
newText: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface CustomRuleResult {
|
||||
@@ -10,6 +14,10 @@ export interface CustomRuleResult {
|
||||
severity: 'error' | 'warning' | 'info';
|
||||
message: string;
|
||||
suggestion?: string;
|
||||
fix?: {
|
||||
originalText: string;
|
||||
newText: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface AIFinding {
|
||||
@@ -20,6 +28,10 @@ export interface AIFinding {
|
||||
description: string;
|
||||
suggestion: string;
|
||||
line: number;
|
||||
fix?: {
|
||||
originalText: string;
|
||||
newText: string;
|
||||
};
|
||||
}
|
||||
|
||||
export type MethodFindingCategory =
|
||||
|
||||
@@ -72,6 +72,44 @@ export async function aiFixDiagnostic(
|
||||
const appliedFixes: AppliedFix[] = [];
|
||||
let converged = false;
|
||||
|
||||
const pre = diag.aiFix;
|
||||
if (pre?.originalText && pre?.newText) {
|
||||
const startIndex = currentText.indexOf(pre.originalText);
|
||||
if (startIndex !== -1) {
|
||||
const endIndex = startIndex + pre.originalText.length;
|
||||
const nextText = currentText.slice(0, startIndex) + pre.newText + currentText.slice(endIndex);
|
||||
if (nextText !== currentText) {
|
||||
appliedFixes.push({
|
||||
originalText: pre.originalText,
|
||||
newText: pre.newText,
|
||||
line: diag.range.start.line,
|
||||
});
|
||||
currentText = nextText;
|
||||
converged = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (converged) {
|
||||
if (currentText === originalText) {
|
||||
return { success: true, attempts: 0, appliedFixes };
|
||||
}
|
||||
if (dryRun) {
|
||||
return { success: true, attempts: 0, appliedFixes, newText: currentText };
|
||||
}
|
||||
const edit = new vscode.WorkspaceEdit();
|
||||
const fullRange = new vscode.Range(
|
||||
document.positionAt(0),
|
||||
document.positionAt(originalText.length)
|
||||
);
|
||||
edit.replace(document.uri, fullRange, currentText);
|
||||
const applied = await vscode.workspace.applyEdit(edit);
|
||||
if (!applied) {
|
||||
return { success: false, attempts: 0, message: 'apply-failed', appliedFixes };
|
||||
}
|
||||
return { success: true, attempts: 0, appliedFixes };
|
||||
}
|
||||
|
||||
for (let round = 1; round <= maxIterations; round++) {
|
||||
const context = buildFixContext(currentText, diag.range.start.line);
|
||||
const fix = await requestFix(provider, options, diag, context);
|
||||
|
||||
@@ -68,6 +68,44 @@ export async function aiFixReviewIssue(
|
||||
const appliedFixes: AppliedFix[] = [];
|
||||
let converged = false;
|
||||
|
||||
const pre = diag.fix;
|
||||
if (pre?.originalText && pre?.newText) {
|
||||
const startIndex = currentText.indexOf(pre.originalText);
|
||||
if (startIndex !== -1) {
|
||||
const endIndex = startIndex + pre.originalText.length;
|
||||
const nextText = currentText.slice(0, startIndex) + pre.newText + currentText.slice(endIndex);
|
||||
if (nextText !== currentText) {
|
||||
appliedFixes.push({
|
||||
originalText: pre.originalText,
|
||||
newText: pre.newText,
|
||||
line: diag.line,
|
||||
});
|
||||
currentText = nextText;
|
||||
converged = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (converged) {
|
||||
if (currentText === originalText) {
|
||||
return { success: true, attempts: 0, appliedFixes };
|
||||
}
|
||||
if (dryRun) {
|
||||
return { success: true, attempts: 0, appliedFixes, newText: currentText };
|
||||
}
|
||||
const edit = new vscode.WorkspaceEdit();
|
||||
const fullRange = new vscode.Range(
|
||||
document.positionAt(0),
|
||||
document.positionAt(originalText.length)
|
||||
);
|
||||
edit.replace(document.uri, fullRange, currentText);
|
||||
const applied = await vscode.workspace.applyEdit(edit);
|
||||
if (!applied) {
|
||||
return { success: false, attempts: 0, message: 'apply-failed', appliedFixes };
|
||||
}
|
||||
return { success: true, attempts: 0, appliedFixes };
|
||||
}
|
||||
|
||||
for (let round = 1; round <= maxIterations; round++) {
|
||||
const context = buildFixContext(currentText, diag.line);
|
||||
const fix = await requestFix(provider, options, diag, context);
|
||||
|
||||
@@ -41,7 +41,10 @@ export async function openPreviewDiff(req: PreviewRequest): Promise<vscode.Uri |
|
||||
previewProvider.set(originalUri, req.originalText);
|
||||
previewProvider.set(newUri, req.newText);
|
||||
|
||||
await vscode.commands.executeCommand('vscode.diff', originalUri, newUri, req.title);
|
||||
await vscode.commands.executeCommand('vscode.diff', originalUri, newUri, req.title, {
|
||||
viewColumn: vscode.ViewColumn.Beside,
|
||||
preserveFocus: true,
|
||||
});
|
||||
return newUri;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { LinterDiagnostic } from '../types';
|
||||
import type { LinterDiagnostic, AiFixSnippet } from '../types';
|
||||
import { getLanguage } from '../i18n/messages';
|
||||
|
||||
export interface ReviewIssueInput {
|
||||
@@ -6,6 +6,7 @@ export interface ReviewIssueInput {
|
||||
line: number;
|
||||
message: string;
|
||||
suggestion?: string;
|
||||
fix?: AiFixSnippet;
|
||||
}
|
||||
|
||||
export function buildFixSystemPrompt(): string {
|
||||
|
||||
@@ -883,6 +883,16 @@ const messages: Record<string, Record<Language, string>> = {
|
||||
en: 'AI Fix',
|
||||
ja: 'AI修正',
|
||||
},
|
||||
'report.fixPreview': {
|
||||
'zh-CN': '修复预览',
|
||||
en: 'Fix Preview',
|
||||
ja: '修正プレビュー',
|
||||
},
|
||||
'report.fixUnavailable': {
|
||||
'zh-CN': '无预生成修复,点击修复将实时生成',
|
||||
en: 'No pre-generated fix, click Fix to generate on demand',
|
||||
ja: '事前生成された修正がありません。修正ボタンで生成します',
|
||||
},
|
||||
'report.fixedIssues': {
|
||||
'zh-CN': '已修复',
|
||||
en: 'Fixed',
|
||||
|
||||
+15
-4
@@ -1,5 +1,5 @@
|
||||
import * as vscode from 'vscode';
|
||||
import type { LinterDiagnostic, Severity } from '../types';
|
||||
import type { LinterDiagnostic, Severity, AiFixSnippet } from '../types';
|
||||
import type { TranslatedDiagnostic, CustomRuleResult, AIFinding } from '../ai/schema';
|
||||
|
||||
export interface MergedReport {
|
||||
@@ -40,6 +40,7 @@ interface MergeInput {
|
||||
language: string;
|
||||
adapterIds: string[];
|
||||
aiFixAvailable?: boolean;
|
||||
code?: string;
|
||||
customRuleFilterInfo?: {
|
||||
totalActive: number;
|
||||
injected: number;
|
||||
@@ -88,12 +89,15 @@ function sortBySeverityAndLine<T extends { severity: string }>(items: T[], lineO
|
||||
}
|
||||
|
||||
export function mergeResults(input: MergeInput): MergedReport {
|
||||
const code = input.code ?? '';
|
||||
|
||||
const customRuleDiagnostics: LinterDiagnostic[] = sortBySeverityAndLine(
|
||||
input.customRuleResults.map(r => ({
|
||||
severity: r.severity as Severity,
|
||||
ruleId: r.ruleId,
|
||||
message: r.message,
|
||||
suggestion: r.suggestion,
|
||||
aiFix: r.fix as AiFixSnippet | undefined,
|
||||
range: new vscode.Range(Math.max(0, r.line - 1), 0, Math.max(0, r.line - 1), 1),
|
||||
})),
|
||||
d => d.range.start.line
|
||||
@@ -104,10 +108,17 @@ export function mergeResults(input: MergeInput): MergedReport {
|
||||
const linterDiagnostics = sortBySeverityAndLine(
|
||||
input.staticDiagnostics.map(d => {
|
||||
const td = findTranslation(translationPool, d.ruleId);
|
||||
if (td) {
|
||||
return { ...d, message: td.translatedMessage, suggestion: td.translatedSuggestion || d.suggestion };
|
||||
let fixed = td ? { ...d, message: td.translatedMessage, suggestion: td.translatedSuggestion || d.suggestion } : d;
|
||||
if (fixed.fix && code.length > 0 && !fixed.fix.originalText) {
|
||||
const [start, end] = fixed.fix.range;
|
||||
if (start >= 0 && end >= start && end <= code.length) {
|
||||
fixed = { ...fixed, fix: { ...fixed.fix, originalText: code.slice(start, end) } };
|
||||
}
|
||||
}
|
||||
return d;
|
||||
if (td?.fix) {
|
||||
fixed = { ...fixed, aiFix: td.fix as AiFixSnippet };
|
||||
}
|
||||
return fixed;
|
||||
}),
|
||||
d => d.range.start.line
|
||||
);
|
||||
|
||||
+38
-7
@@ -2,6 +2,7 @@ import * as vscode from 'vscode';
|
||||
import { MergedReport } from '../merger/merger';
|
||||
import { t, onLanguageChange, getLanguage } from '../i18n/messages';
|
||||
import type { FixSessionManager } from '../fix/fixSession';
|
||||
import { computeLineDiff } from '../utils/diff';
|
||||
|
||||
interface PanelMessage {
|
||||
type: 'navigate' | 'rerun' | 'export' | 'fix' | 'fixAll' | 'undo' | 'applyFix' | 'cancelFix' | 'applyAll' | 'cancelAll';
|
||||
@@ -260,9 +261,19 @@ export class ReviewPanel {
|
||||
.detail-text { color: var(--vscode-descriptionForeground); font-size: 13px; line-height: 1.7; }
|
||||
.detail-text code { font-family: 'SF Mono', Consolas, 'Liberation Mono', Menlo, monospace; font-size: 13px; }
|
||||
.detail-suggestion { margin-top: 8px; padding: 8px 12px; background: rgba(97,175,239,0.08); border: 1px solid rgba(97,175,239,0.2); border-radius: 6px; font-size: 13px; color: #79c0ff; }
|
||||
.detail-no-fix { margin-top: 4px; padding: 8px 12px; background: rgba(139,148,158,0.08); border: 1px dashed var(--vscode-panel-border); border-radius: 6px; font-size: 12px; color: var(--vscode-descriptionForeground); }
|
||||
.detail-original { margin-top: 6px; font-size: 12px; color: var(--vscode-descriptionForeground); font-style: italic; }
|
||||
.detail-category { display: inline-flex; align-items: center; gap: 4px; padding: 2px 8px; border-radius: 4px; font-size: 11px; font-weight: 600; background: rgba(139,148,158,0.1); color: var(--vscode-descriptionForeground); margin-top: 6px; }
|
||||
|
||||
.detail-fix-title { margin: 8px 0 4px; font-size: 11px; font-weight: 600; color: var(--vscode-descriptionForeground); text-transform: uppercase; letter-spacing: .03em; }
|
||||
.fix-diff { margin-top: 4px; border: 1px solid var(--vscode-panel-border); border-radius: 6px; overflow: hidden; }
|
||||
.diff-line { display: flex; align-items: flex-start; font-family: 'SF Mono', Consolas, 'Liberation Mono', Menlo, monospace; font-size: 12px; line-height: 1.6; padding: 1px 8px; white-space: pre-wrap; word-break: break-all; }
|
||||
.diff-marker { flex-shrink: 0; width: 16px; color: var(--vscode-descriptionForeground); user-select: none; }
|
||||
.diff-text { flex: 1; min-width: 0; }
|
||||
.diff-same { color: var(--vscode-foreground); }
|
||||
.diff-del { background: rgba(224,108,117,0.15); color: #E06C75; }
|
||||
.diff-add { background: rgba(87,171,90,0.15); color: #57ab5a; }
|
||||
|
||||
.empty { display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 48px 20px; color: var(--vscode-descriptionForeground); text-align: center; font-style: italic; font-size: 13px; }
|
||||
|
||||
.actions { display: flex; gap: 8px; padding: 16px 16px 20px; border-top: 1px solid var(--vscode-panel-border); }
|
||||
@@ -333,7 +344,7 @@ ${errorBox}
|
||||
if (report.linterDiagnostics.length === 0) {
|
||||
html += `<div class="empty">${t('report.noIssues')}</div>`;
|
||||
} else {
|
||||
html += report.linterDiagnostics.map((d, i) => this.buildIssueItem(d.severity, d.ruleId, d.message, d.range.start.line, 'linter', d.suggestion, fixableSet.has(i), aiFixableSet.has(i))).join('');
|
||||
html += report.linterDiagnostics.map((d, i) => this.buildIssueItem(d.severity, d.ruleId, d.message, d.range.start.line, 'linter', d.suggestion, fixableSet.has(i), aiFixableSet.has(i), undefined, true, this.buildFixDiffHtml(d.aiFix?.originalText ?? d.fix?.originalText, d.aiFix?.newText ?? d.fix?.text))).join('');
|
||||
}
|
||||
const linterFixed = fixedEntries.filter(f => f.source === 'linter');
|
||||
if (linterFixed.length > 0) {
|
||||
@@ -382,7 +393,7 @@ ${errorBox}
|
||||
if (remaining.length === 0) {
|
||||
html += `<div class="empty">${t('report.noRuleViolations')}</div>`;
|
||||
} else {
|
||||
html += remaining.map((d, i) => this.buildIssueItem(d.severity, d.ruleId, d.message, d.range.start.line, 'custom', d.suggestion, false, true)).join('');
|
||||
html += remaining.map((d, i) => this.buildIssueItem(d.severity, d.ruleId, d.message, d.range.start.line, 'custom', d.suggestion, false, true, undefined, true, this.buildFixDiffHtml(d.aiFix?.originalText, d.aiFix?.newText))).join('');
|
||||
}
|
||||
if (customFixed.length > 0) {
|
||||
html += `<div class="section-header" style="padding-top:16px"><span class="section-header-title">✅ ${t('report.fixedIssues')} · ${t('report.issuesCount', { 0: customFixed.length })}</span></div>`;
|
||||
@@ -421,7 +432,7 @@ ${errorBox}
|
||||
if (f.suggestion) {
|
||||
details.push(`<div class="detail-suggestion">💡 ${esc(f.suggestion)}</div>`);
|
||||
}
|
||||
parts.push(this.buildIssueItem(f.severity, f.ruleId, f.title, f.line, 'ai', f.suggestion, false, true, details.join('')));
|
||||
parts.push(this.buildIssueItem(f.severity, f.ruleId, f.title, f.line, 'ai', f.suggestion, false, true, details.join(''), true, this.buildFixDiffHtml(f.fix?.originalText, f.fix?.newText)));
|
||||
}
|
||||
}
|
||||
if (aiFixed.length > 0) {
|
||||
@@ -441,10 +452,11 @@ ${errorBox}
|
||||
fixable?: boolean,
|
||||
aiFixable?: boolean,
|
||||
detailHtml?: string,
|
||||
expandable: boolean = true
|
||||
expandable: boolean = true,
|
||||
fixDiffHtml: string = ''
|
||||
): string {
|
||||
const sevCls = severityClass(severity);
|
||||
const lineNum = line + 1;
|
||||
const lineNum = Number.isFinite(line) ? line + 1 : '?';
|
||||
const parts: string[] = [];
|
||||
|
||||
parts.push(`<div class="item"${expandable ? ' onclick="toggleItem(this)"' : ''}>`);
|
||||
@@ -467,11 +479,22 @@ ${errorBox}
|
||||
}
|
||||
parts.push('</div>');
|
||||
|
||||
if (expandable && (detailHtml || (suggestion && suggestion !== message))) {
|
||||
const fixPlaceholder = (fixable || aiFixable) && !fixDiffHtml
|
||||
? `<div class="detail-no-fix">${esc(t('report.fixUnavailable'))}</div>`
|
||||
: '';
|
||||
|
||||
if (expandable && (detailHtml || (suggestion && suggestion !== message) || fixDiffHtml || fixPlaceholder)) {
|
||||
parts.push('<div class="item-detail">');
|
||||
if (fixDiffHtml) {
|
||||
parts.push(`<div class="detail-fix-title">${t('report.fixPreview')}</div>`);
|
||||
parts.push(fixDiffHtml);
|
||||
} else if (fixPlaceholder) {
|
||||
parts.push(`<div class="detail-fix-title">${t('report.fixPreview')}</div>`);
|
||||
parts.push(fixPlaceholder);
|
||||
}
|
||||
if (detailHtml) {
|
||||
parts.push(detailHtml);
|
||||
} else if (suggestion) {
|
||||
} else if (suggestion && suggestion !== message) {
|
||||
parts.push(`<div class="detail-suggestion">💡 ${esc(suggestion)}</div>`);
|
||||
}
|
||||
parts.push('</div>');
|
||||
@@ -482,6 +505,14 @@ ${errorBox}
|
||||
return parts.join('');
|
||||
}
|
||||
|
||||
private buildFixDiffHtml(originalText?: string, newText?: string): string {
|
||||
if (!originalText || !newText || originalText === newText) { return ''; }
|
||||
const lines = computeLineDiff(originalText, newText);
|
||||
return `<div class="fix-diff">${lines.map(l =>
|
||||
`<div class="diff-line diff-${l.type}"><span class="diff-marker">${l.type === 'del' ? '-' : l.type === 'add' ? '+' : ' '}</span><span class="diff-text">${esc(l.text) || ' '}</span></div>`
|
||||
).join('')}</div>`;
|
||||
}
|
||||
|
||||
private async handleMessage(message: PanelMessage): Promise<void> {
|
||||
switch (message.type) {
|
||||
case 'navigate':
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
"eslint": "9.x (92 rules)",
|
||||
"ts-eslint": "8.x (35 rules)",
|
||||
"stylelint": "16.x (68 rules)",
|
||||
"pmd": "7.26.0 (274 Java rules + 12 JSP rules)",
|
||||
"pmd": "7.26.0 (269 Java rules + 12 JSP rules)",
|
||||
"sqlfluff": "4.2.2 (57 recommended)"
|
||||
},
|
||||
"rules": {
|
||||
@@ -1197,18 +1197,6 @@
|
||||
"descriptionZh": "抽象类不包含任何抽象方法",
|
||||
"descriptionJa": "抽象クラスに抽象メソッドが含まれていない"
|
||||
},
|
||||
{
|
||||
"id": "pmd/AccessorClassGeneration",
|
||||
"description": "Avoid instantiation through private constructors from outside",
|
||||
"descriptionZh": "避免从外部通过私有构造函数实例化",
|
||||
"descriptionJa": "外部からプライベートコンストラクタでインスタンス化することを避ける"
|
||||
},
|
||||
{
|
||||
"id": "pmd/AccessorMethodGeneration",
|
||||
"description": "Avoid synthetic accessor methods",
|
||||
"descriptionZh": "避免合成访问器方法",
|
||||
"descriptionJa": "合成アクセッサメソッドを避ける"
|
||||
},
|
||||
{
|
||||
"id": "pmd/ArrayIsStoredDirectly",
|
||||
"description": "Clone objects before storing in constructors/methods",
|
||||
@@ -2013,12 +2001,6 @@
|
||||
"descriptionZh": "使用相反的运算符替代 !",
|
||||
"descriptionJa": "! の代わりに反対の演算子を使用する"
|
||||
},
|
||||
{
|
||||
"id": "pmd/LoosePackageCoupling",
|
||||
"description": "Avoid using classes from outside package hierarchy",
|
||||
"descriptionZh": "避免使用包层次之外的类",
|
||||
"descriptionJa": "パッケージ階層外のクラスの使用を避ける"
|
||||
},
|
||||
{
|
||||
"id": "pmd/MutableStaticState",
|
||||
"description": "Non-private non-final static fields",
|
||||
@@ -2121,12 +2103,6 @@
|
||||
"descriptionZh": "不要使用 setAccessible(true)",
|
||||
"descriptionJa": "setAccessible(true) を使用しない"
|
||||
},
|
||||
{
|
||||
"id": "pmd/AvoidAssertAsIdentifier",
|
||||
"description": "assert is reserved word (Java <1.4)",
|
||||
"descriptionZh": "assert 是保留字(Java <1.4)",
|
||||
"descriptionJa": "assert は予約語である(Java <1.4)"
|
||||
},
|
||||
{
|
||||
"id": "pmd/AvoidBranchingStatementAsLastInLoop",
|
||||
"description": "Branching statement as last in loop",
|
||||
@@ -2157,12 +2133,6 @@
|
||||
"descriptionZh": "避免重复的 String 字面量",
|
||||
"descriptionJa": "重複する文字列リテラルを避ける"
|
||||
},
|
||||
{
|
||||
"id": "pmd/AvoidEnumAsIdentifier",
|
||||
"description": "enum is reserved word (Java <1.5)",
|
||||
"descriptionZh": "enum 是保留字(Java <1.5)",
|
||||
"descriptionJa": "enum は予約語である(Java <1.5)"
|
||||
},
|
||||
{
|
||||
"id": "pmd/AvoidFieldNameMatchingMethodName",
|
||||
"description": "Field name matching method name",
|
||||
|
||||
@@ -13,9 +13,15 @@ export type Severity = 'error' | 'warning' | 'info';
|
||||
|
||||
export type AdapterStatus = 'ok' | 'tool-unavailable' | 'execution-failed';
|
||||
|
||||
export interface AiFixSnippet {
|
||||
originalText: string;
|
||||
newText: string;
|
||||
}
|
||||
|
||||
export interface LinterFix {
|
||||
range: [number, number];
|
||||
text: string;
|
||||
originalText?: string;
|
||||
}
|
||||
|
||||
export interface LinterDiagnostic {
|
||||
@@ -25,6 +31,7 @@ export interface LinterDiagnostic {
|
||||
range: vscode.Range;
|
||||
suggestion?: string;
|
||||
fix?: LinterFix;
|
||||
aiFix?: AiFixSnippet;
|
||||
}
|
||||
|
||||
export interface AdapterResult {
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
export interface DiffLine {
|
||||
type: 'del' | 'add' | 'same';
|
||||
text: string;
|
||||
}
|
||||
|
||||
export function computeLineDiff(originalText: string, newText: string): DiffLine[] {
|
||||
const a = originalText.split('\n');
|
||||
const b = newText.split('\n');
|
||||
|
||||
const n = a.length;
|
||||
const m = b.length;
|
||||
const lcs: number[][] = Array.from({ length: n + 1 }, () => new Array<number>(m + 1).fill(0));
|
||||
|
||||
for (let i = n - 1; i >= 0; i--) {
|
||||
for (let j = m - 1; j >= 0; j--) {
|
||||
if (a[i] === b[j]) {
|
||||
lcs[i][j] = lcs[i + 1][j + 1] + 1;
|
||||
} else {
|
||||
lcs[i][j] = Math.max(lcs[i + 1][j], lcs[i][j + 1]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const out: DiffLine[] = [];
|
||||
let i = 0;
|
||||
let j = 0;
|
||||
while (i < n && j < m) {
|
||||
if (a[i] === b[j]) {
|
||||
out.push({ type: 'same', text: a[i] });
|
||||
i++;
|
||||
j++;
|
||||
} else if (lcs[i + 1][j] >= lcs[i][j + 1]) {
|
||||
out.push({ type: 'del', text: a[i] });
|
||||
i++;
|
||||
} else {
|
||||
out.push({ type: 'add', text: b[j] });
|
||||
j++;
|
||||
}
|
||||
}
|
||||
while (i < n) {
|
||||
out.push({ type: 'del', text: a[i] });
|
||||
i++;
|
||||
}
|
||||
while (j < m) {
|
||||
out.push({ type: 'add', text: b[j] });
|
||||
j++;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
+1
-1
@@ -11,7 +11,7 @@ function severityEmoji(severity: string): string {
|
||||
}
|
||||
|
||||
function formatLine(line: number): string {
|
||||
return `L${line + 1}`;
|
||||
return Number.isFinite(line) ? `L${line + 1}` : 'L?';
|
||||
}
|
||||
|
||||
export function reportToMarkdown(report: MergedReport): string {
|
||||
|
||||
Reference in New Issue
Block a user