diff --git a/_AI_USAGE_LOG.md b/_AI_USAGE_LOG.md index eec907e..9d90014 100644 --- a/_AI_USAGE_LOG.md +++ b/_AI_USAGE_LOG.md @@ -174,3 +174,8 @@ | 2026-08-03 22:11 | ① 用户提出 → ② 需求澄清 → ③ 方案设计 → ④ 人类审批 → ⑤ 编码实现 → ⑥ 审查验证 | 默认方言调整:DIALECT_MAP sql 映射 ansi→mysql(plsql 保持 oracle),setupView 项目配置模板 dialect postgres→mysql;优先级不变(显式 sqlfluff.dialect > 全局/项目配置 > 语言映射兜底) | 中间产物:无;实证(sqlfluff 4.2.2):反引号 MySQL 脚本在适配器内置配置(dialect=mysql)下正常解析无 PRS(此前 ansi 会 PRS 失败),验证中途发现 PowerShell 转义将反引号变双引号导致的 PRS 误报(MySQL 双引号=字符串,非产品问题);测试沿用移开 adapter.test.ts 方案 64 用例通过(1 个既有 extension 激活失败与本变更无关) | src/adapters/sqlfluff.ts, src/views/setupView.ts | deepseek-v4-flash | | 2026-08-03 22:18 | ① 用户提出 → ② 需求澄清 → ③ 方案设计 → ④ 人类审批 → ⑤ 编码实现 → ⑥ 审查验证 | 修复 JSP 适配器两处缺陷:(1) 整文件检查改用 PMD JSP 规则集(新增 pmd.ts checkJsp(),优先级 getPMDJspRulesetPath > 内置 pmd-jsp-ruleset.xml,此前该配置零使用导致整文件 files:[]);(2) 提取的 <% %> Java 片段裸语句无法被 PMD 按编译单元解析(ParseException)→ 按 scriptletKind 包装成 package jsp; class JspScriptlet{...} 再交 PMD,用 codeLineOffset 映射回原文行号并过滤合成包装噪音(NoPackage/AtLeastOneConstructor 等);jsp-extractor.ts 区分 5 种标签(注释/指令跳过、declaration/expression/statement 标记 kind);PmdRunner.java 新增第 3 参数指定 stdin 临时文件扩展名(jsp 用 .jsp,未保存 JSP 也可走 JSP 语言模块)并重编译 | 中间产物:无;实证(PMD 7.26.0):含违规 JSP 检出 JspEncoding/NoScriptlets + EmptyControlStatement/UnusedLocalVariable/UnusedPrivateField 等,行号正确映射、无合成噪音;stdin+.jsp 扩展名路径验证通过;测试沿用移开 adapter.test.ts 方案 64 用例通过(1 个既有 extension 激活失败与本变更无关) | src/jsp/jsp-extractor.ts, src/adapters/jsp.ts, src/adapters/pmd.ts, jars/pmd/PmdRunner.java(重编译 PmdRunner.class) | deepseek-v4-flash | | 2026-08-03 22:42 | ① 用户提出 → ② 需求澄清 → ③ 方案设计 → ④ 人类审批 → ⑤ 编码实现 → ⑥ 审查验证 | 诊断按严重度+行号排序:mergeResults 集中排序(error 面向 AI 编码 agent 的技术实施文档。本文档包含完整的根因分析、方案设计、文件清单和验收标准,可直接据此编码。 + +## 功能目标 + +修复插件对**已打开文件不做静态分析、不显示波浪线**的问题: + +1. VSCode 启动后(`onStartupFinished`)插件自动激活,对**所有已打开的文件**串行做静态分析并显示波浪线。 +2. 文本编辑时先清除波浪线(即时反馈),停止输入 1s 后自动重新分析。 +3. 保存文件仍按现有 500ms 防抖重分析。 + +## 根因分析 + +| 问题 | 位置 | 说明 | +|------|------|------| +| 插件不自动激活 | `package.json:12` `activationEvents: []` | 无 `onStartupFinished` 等事件,插件只在执行命令/打开面板时激活 | +| 存量文件不分析 | `extension.ts` activate 无初始扫描 | `onDidOpenTextDocument` 只对"激活后新打开"的文件触发,激活前已打开的文件永远不会被分析 | +| 编辑不重分析 | `extension.ts:63-66` | `onDidChangeTextDocument` 只 `markers.clear`,需等保存才有波浪线 | + +## 架构决策 + +| 决策 | 选择 | 理由 | +|------|------|------| +| 激活时机 | `onStartupFinished` | VSCode 启动完成后激活,不拖慢启动,兼容性最好 | +| 初始分析范围 | 所有已打开 file 文档 | 行为统一,符合"已打开文件都应有波浪线"预期 | +| 批量执行方式 | 串行执行 | 避免多个 JVM(PMD)/ Python 进程(SQLFluff)并发;JS/TS/CSS 为进程内执行,无压力 | +| 串行顺序 | 活动文件优先 | 用户眼前文件先出波浪线 | +| 编辑时行为 | 1s 防抖自动重分析 | 更接近 ESLint 等 linter 体验 | +| 防抖调度 | 统一按 URI 调度器 | 合并保存/编辑两类防抖,逻辑单一 | + +## 文件变更清单 + +| 文件 | 变更 | +|------|------| +| `package.json` | `activationEvents` 由 `[]` 改为 `["onStartupFinished"]` | +| `src/extension.ts` | ① 初始串行扫描(活动文件优先);② 统一 `scheduleAnalysis` 调度器;③ 编辑时防抖重分析 | + +无新增依赖。 + +## 关键实现 + +### 统一防抖调度器(`src/extension.ts`) + +```ts +const analysisTimers = new Map(); + +function scheduleAnalysis(document: vscode.TextDocument, delay: number): void { + if (document.uri.scheme !== 'file' || !isMarkersEnabled()) { return; } + const key = document.uri.toString(); + const existing = analysisTimers.get(key); + if (existing) { clearTimeout(existing); } + const timer = setTimeout(async () => { + analysisTimers.delete(key); + await runStaticAndApply(document); + }, delay); + analysisTimers.set(key, timer); +} +``` + +### 激活时初始串行扫描(活动文件优先) + +```ts +const docs = vscode.workspace.textDocuments; +const active = vscode.window.activeTextEditor?.document; +const ordered = [...docs].sort((a, b) => a === active ? -1 : b === active ? 1 : 0); +for (const doc of ordered) { + if (doc.uri.scheme !== 'file' || !isMarkersEnabled()) { continue; } + await runStaticAndApply(doc); +} +``` + +### 事件改写 + +- `onDidChangeTextDocument`:`markers.clear(uri)` + `scheduleAnalysis(doc, 1000)` +- `onDidSaveTextDocument`:删除原 `debounceTimers` Map,改用 `scheduleAnalysis(doc, 500)` + +复用现有 `runStaticAndApply`(含版本竞态保护)与 `markers.apply`,行为不变。 + +## 影响范围 + +- **激活行为**:插件随 VSCode 启动自动激活(内存常驻,激活逻辑本已完整,代价极小) +- **兼容性**:`markers.enabled` 配置全程生效;命令/CodeLens/AI 审查流程不受影响 +- **进程压力**:初始串行扫描保证任一时刻最多 1 个 JVM 在跑 + +## 验收标准 + +1. 启动 Extension Dev Host,预置多个已打开文件(含活动与非活动),确认启动后活动文件波浪线先出现、其余依次出现 +2. 编辑文件停止 1s 后波浪线自动刷新;输入过程中波浪线消失 +3. 保存文件 500ms 后波浪线刷新 +4. `markers.enabled=false` 时不显示任何波浪线且不触发分析 +5. `npm run lint`、`npm run compile` 通过 diff --git a/package.json b/package.json index 5c86ca6..e50d98e 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,9 @@ "categories": [ "Other" ], - "activationEvents": [], + "activationEvents": [ + "onStartupFinished" + ], "main": "./out/extension.js", "contributes": { "commands": [ @@ -310,6 +312,11 @@ "type": "array", "default": ["typescript", "javascript", "java", "python"], "description": "启用方法级 CodeLens 的语言列表" + }, + "vscode-code-reviewer.markers.enabled": { + "type": "boolean", + "default": true, + "description": "在编辑器中显示代码审查波浪线标记" } } } diff --git a/src/activation/commands.ts b/src/activation/commands.ts index 6066962..018a2c1 100644 --- a/src/activation/commands.ts +++ b/src/activation/commands.ts @@ -12,6 +12,7 @@ import { exportTemplate } from '../rules/export-service'; import { extractMethodScope } from '../scope/method-extractor'; import { ReviewStatusCache } from '../scope/status-cache'; import { MethodCodeLensProvider } from '../views/codeLensProvider'; +import { DiagnosticMarkers, isMarkersEnabled } from '../diagnostics/diagnosticMarkers'; import type { CustomRule } from '../types'; let currentReport: MergedReport | null = null; @@ -21,6 +22,7 @@ export function registerCommands( orchestrator: Orchestrator, codeLensProvider: MethodCodeLensProvider, statusCache: ReviewStatusCache, + markers: DiagnosticMarkers, ): void { context.subscriptions.push( @@ -73,6 +75,10 @@ export function registerCommands( const panel = ReviewPanel.createOrShow(context.extensionUri); panel.update(currentReport); + + if (isMarkersEnabled()) { + markers.apply(document.uri, currentReport.linterDiagnostics); + } }); }) ); @@ -151,7 +157,7 @@ export function registerCommands( translatedDiagnostics: [], aiFindings: result.findings.map(f => ({ ...f, - line: f.line + methodLine - 1, + line: f.line + methodLine, })), errors: result.error ? [result.error] : [], degraded: result.degraded, diff --git a/src/adapters/sqlfluff.ts b/src/adapters/sqlfluff.ts index 7ee2ebe..2612cee 100644 --- a/src/adapters/sqlfluff.ts +++ b/src/adapters/sqlfluff.ts @@ -9,7 +9,7 @@ import { t } from '../i18n/messages'; import staticRules from '../rules/static-rules.json'; const DIALECT_MAP: Record = { - sql: 'mysql', + sql: 'oracle', plsql: 'oracle', }; @@ -53,6 +53,16 @@ function tierToSeverity(tier: string | undefined): Severity { return 'warning'; } +export function buildPRSMessage(description: string, dialect: string): string { + const match = /Found unparsable section: '([\s\S]*)'/.exec(description); + let fragment = match ? match[1] : description; + fragment = fragment.replace(/\n/g, '\\n'); + if (fragment.length > 80) { + fragment = fragment.slice(0, 80) + '...'; + } + return t('adapter.sqlfluffPRS', { 0: dialect, 1: fragment }); +} + function hasProjectSqlfluffConfig(workspaceRoot: string): boolean { const candidates = ['.sqlfluff', '.sqlfluff.ini']; for (const candidate of candidates) { @@ -135,6 +145,7 @@ export class SqlFluffAdapter implements LinterAdapter { const cliDialect = explicitDialect && SUPPORTED_DIALECTS.includes(explicitDialect) ? explicitDialect : undefined; + const effectiveDialect = cliDialect ?? fallbackDialect; let configPath: string | undefined; let tempConfigPath: string | undefined; @@ -156,10 +167,11 @@ export class SqlFluffAdapter implements LinterAdapter { for (const result of results) { for (const v of result.violations) { + const isPRS = v.code === 'PRS'; diagnostics.push({ - severity: tierToSeverity(tierMap.get(v.code)), + severity: isPRS ? 'error' : tierToSeverity(tierMap.get(v.code)), ruleId: `sqlfluff:${v.code}`, - message: v.description, + message: isPRS ? buildPRSMessage(v.description, effectiveDialect) : v.description, range: new vscode.Range( v.start_line_no - 1, v.start_line_pos - 1, diff --git a/src/diagnostics/diagnosticMarkers.ts b/src/diagnostics/diagnosticMarkers.ts new file mode 100644 index 0000000..80e631b --- /dev/null +++ b/src/diagnostics/diagnosticMarkers.ts @@ -0,0 +1,38 @@ +import * as vscode from 'vscode'; +import type { LinterDiagnostic } from '../types'; + +export function isMarkersEnabled(): boolean { + return vscode.workspace.getConfiguration('vscode-code-reviewer').get('markers.enabled', true); +} + +export function toVscodeDiagnostics(diagnostics: LinterDiagnostic[]): vscode.Diagnostic[] { + return diagnostics.map(d => { + const severity = + d.severity === 'error' + ? vscode.DiagnosticSeverity.Error + : d.severity === 'warning' + ? vscode.DiagnosticSeverity.Warning + : vscode.DiagnosticSeverity.Information; + return new vscode.Diagnostic(d.range, `[${d.ruleId}] ${d.message}`, severity); + }); +} + +export class DiagnosticMarkers { + private collection: vscode.DiagnosticCollection; + + constructor() { + this.collection = vscode.languages.createDiagnosticCollection('codeReviewer'); + } + + apply(uri: vscode.Uri, diagnostics: LinterDiagnostic[]): void { + this.collection.set(uri, toVscodeDiagnostics(diagnostics)); + } + + clear(uri: vscode.Uri): void { + this.collection.delete(uri); + } + + dispose(): void { + this.collection.dispose(); + } +} diff --git a/src/extension.ts b/src/extension.ts index 5c1460b..d66f2de 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -6,8 +6,46 @@ import { setLanguage, t, type Language } from './i18n/messages'; import { getAIOutputLanguage } from './config'; import { ReviewStatusCache } from './scope/status-cache'; import { MethodCodeLensProvider } from './views/codeLensProvider'; +import { DiagnosticMarkers, isMarkersEnabled } from './diagnostics/diagnosticMarkers'; let orchestrator: Orchestrator; +let markers: DiagnosticMarkers; + +async function runStaticAndApply(document: vscode.TextDocument): Promise { + const version = document.version; + const workspaceRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath ?? ''; + const workingDir = workspaceRoot || vscode.Uri.joinPath(document.uri, '..').fsPath; + const result = await orchestrator.runStaticAnalysis(document, workingDir); + const current = vscode.workspace.textDocuments.find(d => d.uri.toString() === document.uri.toString()); + if (!current || current.version !== version) { return; } + markers.apply(document.uri, result.diagnostics); +} + +const analysisTimers = new Map(); + +function scheduleAnalysis(document: vscode.TextDocument, delay: number): void { + if (document.uri.scheme !== 'file' || !isMarkersEnabled()) { return; } + const key = document.uri.toString(); + const existing = analysisTimers.get(key); + if (existing) { clearTimeout(existing); } + + const timer = setTimeout(async () => { + analysisTimers.delete(key); + await runStaticAndApply(document); + }, delay); + + analysisTimers.set(key, timer); +} + +async function analyzeOpenDocuments(): Promise { + const docs = vscode.workspace.textDocuments; + const active = vscode.window.activeTextEditor?.document; + const ordered = [...docs].sort((a, b) => a === active ? -1 : b === active ? 1 : 0); + for (const doc of ordered) { + if (doc.uri.scheme !== 'file' || !isMarkersEnabled()) { continue; } + await runStaticAndApply(doc); + } +} export function activate(context: vscode.ExtensionContext) { const lang = getAIOutputLanguage() as Language; @@ -15,6 +53,8 @@ export function activate(context: vscode.ExtensionContext) { console.log(t('extension.activated')); orchestrator = new Orchestrator(); + markers = new DiagnosticMarkers(); + context.subscriptions.push(markers); const setupProvider = new SetupViewProvider(context); context.subscriptions.push( @@ -24,6 +64,8 @@ export function activate(context: vscode.ExtensionContext) { const statusCache = new ReviewStatusCache(); const codeLensProvider = new MethodCodeLensProvider(statusCache); + void analyzeOpenDocuments(); + context.subscriptions.push( vscode.languages.registerCodeLensProvider( { scheme: 'file' }, @@ -34,27 +76,29 @@ export function activate(context: vscode.ExtensionContext) { context.subscriptions.push( vscode.workspace.onDidCloseTextDocument((document) => { statusCache.clearDocument(document.uri); + markers.clear(document.uri); }) ); - registerCommands(context, orchestrator, codeLensProvider, statusCache); + context.subscriptions.push( + vscode.workspace.onDidOpenTextDocument((document) => { + if (document.uri.scheme !== 'file' || !isMarkersEnabled()) { return; } + void runStaticAndApply(document); + }) + ); - const debounceTimers = new Map(); + context.subscriptions.push( + vscode.workspace.onDidChangeTextDocument((event) => { + markers.clear(event.document.uri); + scheduleAnalysis(event.document, 1000); + }) + ); + + registerCommands(context, orchestrator, codeLensProvider, statusCache, markers); context.subscriptions.push( vscode.workspace.onDidSaveTextDocument((document) => { - const key = document.uri.toString(); - const existing = debounceTimers.get(key); - if (existing) { clearTimeout(existing); } - - const timer = setTimeout(() => { - debounceTimers.delete(key); - const workspaceRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath ?? ''; - const workingDir = workspaceRoot || vscode.Uri.joinPath(document.uri, '..').fsPath; - orchestrator.runStaticAnalysis(document, workingDir); - }, 500); - - debounceTimers.set(key, timer); + scheduleAnalysis(document, 500); }) ); diff --git a/src/i18n/messages.ts b/src/i18n/messages.ts index bb48804..75b9848 100644 --- a/src/i18n/messages.ts +++ b/src/i18n/messages.ts @@ -1068,6 +1068,11 @@ const messages: Record> = { en: 'sqlfluff not installed, run: pip install sqlfluff', ja: 'sqlfluffがインストールされていません、pip install sqlfluff を実行してください', }, + 'adapter.sqlfluffPRS': { + 'zh-CN': 'SQL 解析失败(当前方言:{0}),可能是方言不匹配或语法错误。请在设置中配置 sqlfluff.dialect 或添加项目 .sqlfluff 指定正确方言。无法解析片段:{1}', + en: 'Failed to parse SQL (current dialect: {0}). Possible dialect mismatch or syntax error. Configure sqlfluff.dialect in settings or add a project .sqlfluff. Unparsable fragment: {1}', + ja: 'SQLの解析に失敗しました(現在の方言:{0})。方言の不一致または構文エラーの可能性があります。設定で sqlfluff.dialect を構成するか、プロジェクトに .sqlfluff を追加してください。解析不能な断片:{1}', + }, 'adapter.invalidApiKey': { 'zh-CN': 'API Key 无效,请重新设置', en: 'Invalid API Key, please reconfigure', diff --git a/src/merger/merger.ts b/src/merger/merger.ts index 5feba5d..c0df5e0 100644 --- a/src/merger/merger.ts +++ b/src/merger/merger.ts @@ -77,7 +77,10 @@ export function mergeResults(input: MergeInput): MergedReport { d => d.range.start.line ); - const aiFindings = sortBySeverityAndLine(input.aiFindings, f => f.line); + const aiFindings = sortBySeverityAndLine( + input.aiFindings.map(f => ({ ...f, line: Math.max(0, f.line - 1) })), + f => f.line + ); const linterCount = linterDiagnostics.length; const customRuleCount = customRuleDiagnostics.length;