feat: PMD 依赖 classpath 自动探测 + SQLFluff AL06 参数修复 + 诊断消息前缀 Code Purifier + AI maxTokens 统一走配置

This commit is contained in:
范智鹏
2026-08-09 00:07:08 +08:00
parent d22bb61aba
commit 8c3e239acd
14 changed files with 535 additions and 9 deletions
+13 -3
View File
@@ -5,12 +5,14 @@ import { execSync, spawn } from 'child_process';
import type { LinterAdapter, LinterDiagnostic, AdapterResult } from '../types';
import { getPMDJarPath, getPMDRulesetPath, getPMDJspRulesetPath } from '../config';
import { t } from '../i18n/messages';
import { AuxClasspathResolver } from '../services/auxClasspath';
export class PmdAdapter implements LinterAdapter {
id = 'pmd';
supportedLanguages = ['java'];
private pmdDir: string | null = null;
private jarPathChecked = false;
private auxResolver = new AuxClasspathResolver();
private resolvePmdDir(): string {
if (this.pmdDir) { return this.pmdDir; }
@@ -68,7 +70,11 @@ export class PmdAdapter implements LinterAdapter {
const fileArg = isVirtual ? '-' : document.uri.fsPath;
const javaArgs = ['-cp', classpath, 'PmdRunner', fileArg, ruleset, isJsp ? 'jsp' : 'java'];
const result = await this.execPmd(javaArgs, isVirtual ? document.getText() : null, workingDir);
const auxInfo = await this.auxResolver.resolve(workingDir);
if (auxInfo.error) {
console.warn(`[code-reviewer] PMD aux classpath resolve failed: ${auxInfo.error}`);
}
const result = await this.execPmd(javaArgs, isVirtual ? document.getText() : null, workingDir, auxInfo.classpath);
const diagnostics = this.parsePmdOutput(result);
return { diagnostics, status: 'ok' };
@@ -99,9 +105,13 @@ export class PmdAdapter implements LinterAdapter {
: path.join(this.getPmdRunnerClasspath(), 'pmd-jsp-ruleset.xml');
}
private execPmd(args: string[], stdinInput: string | null, cwd: string): Promise<string> {
private execPmd(args: string[], stdinInput: string | null, cwd: string, auxClasspath: string): Promise<string> {
return new Promise((resolve, reject) => {
const proc = spawn('java', args, { cwd });
const env: NodeJS.ProcessEnv = { ...process.env };
if (auxClasspath && auxClasspath.trim() !== '') {
env.PMD_AUXCP = auxClasspath;
}
const proc = spawn('java', args, { cwd, env });
let stdout = '';
let stderr = '';
proc.stdout.on('data', (data: Buffer) => { stdout += data.toString(); });
+4
View File
@@ -18,6 +18,10 @@ export function getPMDJspRulesetPath(): string {
return vscode.workspace.getConfiguration(ROOT).get<string>('pmd.jspRulesetPath', '');
}
export function getPMDAutoAuxClasspath(): boolean {
return vscode.workspace.getConfiguration(ROOT).get<boolean>('pmd.autoAuxClasspath', true);
}
export function getSqlFluffConfigFile(): string {
return vscode.workspace.getConfiguration(ROOT).get<string>('sqlfluff.configFile', '');
}
+13 -1
View File
@@ -1,10 +1,22 @@
import * as vscode from 'vscode';
import type { LinterDiagnostic } from '../types';
const PLUGIN_NAME = 'Code Purifier';
export function isMarkersEnabled(): boolean {
return vscode.workspace.getConfiguration('vscode-code-reviewer').get<boolean>('markers.enabled', true);
}
function formatDiagnosticMessage(d: LinterDiagnostic): string {
const sepIndex = d.ruleId.indexOf(':');
if (sepIndex > 0) {
const linter = d.ruleId.slice(0, sepIndex);
const rule = d.ruleId.slice(sepIndex + 1);
return `[${PLUGIN_NAME} · ${linter}] ${rule}: ${d.message}`;
}
return `[${PLUGIN_NAME}] ${d.ruleId}: ${d.message}`;
}
export function toVscodeDiagnostics(diagnostics: LinterDiagnostic[]): vscode.Diagnostic[] {
return diagnostics.map(d => {
const severity =
@@ -13,7 +25,7 @@ export function toVscodeDiagnostics(diagnostics: LinterDiagnostic[]): vscode.Dia
: d.severity === 'warning'
? vscode.DiagnosticSeverity.Warning
: vscode.DiagnosticSeverity.Information;
return new vscode.Diagnostic(d.range, `[${d.ruleId}] ${d.message}`, severity);
return new vscode.Diagnostic(d.range, formatDiagnosticMessage(d), severity);
});
}
+2 -1
View File
@@ -1,6 +1,7 @@
import * as vscode from 'vscode';
import type { LinterDiagnostic } from '../types';
import type { AIProvider } from '../ai/providers/base';
import { getAIMaxTokens } from '../config';
export type FixCategory = 'naming' | 'style' | 'bug' | 'security' | 'performance';
@@ -136,7 +137,7 @@ export async function generateFix(
const response = await provider.chat(FIX_SYSTEM_PROMPT, userPrompt, {
model,
temperature,
maxTokens: 4096,
maxTokens: getAIMaxTokens(),
timeoutMs,
});
+6
View File
@@ -101,6 +101,9 @@ dialect = ${dialect}
max_line_length = 80
indent_unit = space
tab_space_size = 4
[sqlfluff:rules:aliasing.length]
max_alias_length = 30
`;
}
@@ -331,6 +334,9 @@ export function buildSqlfluffProjectConfigText(dialect: string, lang: Language):
'indent_unit = space',
'tab_space_size = 4',
'',
'[sqlfluff:rules:aliasing.length]',
'max_alias_length = 30',
'',
];
return lines.join('\n');
}
+2 -2
View File
@@ -2,7 +2,7 @@ import * as vscode from 'vscode';
import * as path from 'path';
import * as fs from 'fs';
import { getApiKey } from '../config/secret';
import { getAIConfig, getAITimeout } from '../config/ai';
import { getAIConfig, getAIMaxTokens, getAITimeout } from '../config/ai';
import { createProvider } from '../ai/factory';
import { RuleConverter } from './converters/converter';
import { loadActiveRules } from './yaml-parser';
@@ -476,7 +476,7 @@ export async function convertContentWithAI(
yamlOutput = await provider.chat(prompt, content, {
model: config.model,
temperature: 0,
maxTokens: 8192,
maxTokens: getAIMaxTokens(),
timeoutMs: getAITimeout() * 1000,
seed: 42,
});
+204
View File
@@ -0,0 +1,204 @@
import { existsSync, statSync, readFileSync, writeFileSync, unlinkSync } from 'fs';
import { join } from 'path';
import { tmpdir } from 'os';
import { spawn, type ChildProcess } from 'child_process';
import { getPMDAutoAuxClasspath } from '../config';
export interface AuxClasspathInfo {
classpath: string;
source?: 'maven' | 'gradle';
error?: string;
}
interface BuildTarget {
kind: 'maven' | 'gradle';
path: string;
}
interface CacheEntry {
buildFile: string;
mtimeMs: number;
info: AuxClasspathInfo;
}
const BUILD_TIMEOUT = 120_000;
const GRADLE_INIT_SCRIPT = `
gradle.allprojects { proj ->
if (proj == gradle.rootProject) {
proj.tasks.register('_printRuntimeClasspath') {
doLast {
if (proj.plugins.hasPlugin('java')) {
def main = proj.sourceSets.findByName('main')
if (main != null) {
println main.runtimeClasspath.asPath
}
}
}
}
}
}
`;
export class AuxClasspathResolver {
private cache = new Map<string, CacheEntry>();
private inflight = new Map<string, Promise<AuxClasspathInfo>>();
async resolve(workingDir: string): Promise<AuxClasspathInfo> {
if (!getPMDAutoAuxClasspath()) {
return { classpath: '' };
}
const build = this.detectBuildFile(workingDir);
if (!build) {
return { classpath: '' };
}
const mtime = this.statMtime(build.path);
const cached = this.cache.get(workingDir);
if (cached && cached.buildFile === build.path && cached.mtimeMs === mtime) {
return cached.info;
}
const pending = this.inflight.get(workingDir);
if (pending) {
return pending;
}
const running = this.runBuild(workingDir, build)
.then((info) => {
this.cache.set(workingDir, { buildFile: build.path, mtimeMs: mtime, info });
return info;
})
.finally(() => {
this.inflight.delete(workingDir);
});
this.inflight.set(workingDir, running);
return running;
}
clear(workingDir?: string): void {
if (workingDir) {
this.cache.delete(workingDir);
this.inflight.delete(workingDir);
} else {
this.cache.clear();
this.inflight.clear();
}
}
private detectBuildFile(workingDir: string): BuildTarget | null {
const candidates: BuildTarget[] = [
{ kind: 'maven', path: join(workingDir, 'pom.xml') },
{ kind: 'gradle', path: join(workingDir, 'build.gradle') },
{ kind: 'gradle', path: join(workingDir, 'build.gradle.kts') },
];
for (const candidate of candidates) {
if (existsSync(candidate.path)) {
return candidate;
}
}
return null;
}
private statMtime(file: string): number {
try {
return statSync(file).mtimeMs;
} catch {
return 0;
}
}
private runBuild(workingDir: string, build: BuildTarget): Promise<AuxClasspathInfo> {
if (build.kind === 'maven') {
return this.runMaven(workingDir);
}
return this.runGradle(workingDir);
}
private async runMaven(workingDir: string): Promise<AuxClasspathInfo> {
const outputFile = join(tmpdir(), `pmd-auxcp-${process.pid}-${Date.now()}.txt`);
const cmd = this.resolveTool(workingDir, ['mvnw.cmd', 'mvnw'], isWindows() ? 'mvn.cmd' : 'mvn');
try {
const args = ['-B', '-q', `-Dmdep.outputFile=${outputFile}`, 'dependency:build-classpath'];
await this.exec(cmd, args, workingDir);
const cp = existsSync(outputFile) ? readFileSync(outputFile, 'utf8').trim() : '';
return { classpath: cp, source: 'maven' };
} catch (err) {
return { classpath: '', source: 'maven', error: err instanceof Error ? err.message : String(err) };
} finally {
if (existsSync(outputFile)) {
unlinkSync(outputFile);
}
}
}
private async runGradle(workingDir: string): Promise<AuxClasspathInfo> {
const cmd = this.resolveTool(workingDir, ['gradlew.bat', 'gradlew'], isWindows() ? 'gradle.bat' : 'gradle');
const initScript = join(tmpdir(), `pmd-auxcp-${process.pid}-${Date.now()}.gradle`);
writeFileSync(initScript, GRADLE_INIT_SCRIPT, 'utf8');
try {
const args = ['-q', '-I', initScript, '_printRuntimeClasspath'];
const output = await this.exec(cmd, args, workingDir);
const cp = this.lastNonEmptyLine(output);
return { classpath: cp, source: 'gradle' };
} catch (err) {
return { classpath: '', source: 'gradle', error: err instanceof Error ? err.message : String(err) };
} finally {
if (existsSync(initScript)) {
unlinkSync(initScript);
}
}
}
private resolveTool(workingDir: string, wrapperNames: string[], systemName: string): string {
for (const name of wrapperNames) {
const wrapper = join(workingDir, name);
if (existsSync(wrapper)) {
return wrapper;
}
}
return systemName;
}
private lastNonEmptyLine(output: string): string {
const lines = output.split(/\r?\n/).map((line) => line.trim()).filter((line) => line.length > 0);
return lines.length > 0 ? lines[lines.length - 1] : '';
}
private exec(cmd: string, args: string[], cwd: string): Promise<string> {
return new Promise((resolve, reject) => {
let proc: ChildProcess | null = null;
let stdout = '';
let stderr = '';
const timer = setTimeout(() => {
if (proc) {
proc.kill();
}
reject(new Error(`Command timed out after ${BUILD_TIMEOUT}ms: ${cmd}`));
}, BUILD_TIMEOUT);
try {
proc = spawn(cmd, args, { cwd, windowsHide: true });
} catch (err) {
clearTimeout(timer);
reject(err);
return;
}
const child = proc;
child.stdout?.on('data', (data: Buffer) => { stdout += data.toString(); });
child.stderr?.on('data', (data: Buffer) => { stderr += data.toString(); });
child.on('error', (err) => {
clearTimeout(timer);
reject(err);
});
child.on('close', (code) => {
clearTimeout(timer);
if (code === 0) {
resolve(stdout);
} else {
reject(new Error(`${cmd} exited with code ${code}: ${(stderr || stdout).trim()}`));
}
});
});
}
}
function isWindows(): boolean {
return process.platform === 'win32';
}
+2 -2
View File
@@ -2,7 +2,7 @@ import * as vscode from 'vscode';
import * as path from 'path';
import * as fs from 'fs';
import { execSync } from 'child_process';
import { getAIProvider, getAIModel, getAIOutputLanguage, getAIConfig } from '../config/ai';
import { getAIProvider, getAIModel, getAIOutputLanguage, getAIConfig, getAIMaxTokens } from '../config/ai';
import { getApiKey, setApiKey } from '../config/secret';
import { createProvider, getAllProviderMeta, getProviderModels, invalidateProviderCache } from '../ai/factory';
import { listRuleFiles } from '../rules/yaml-parser';
@@ -367,7 +367,7 @@ export class SetupViewProvider implements vscode.WebviewViewProvider {
const result = await provider.chat('回复 ok', 'ping', {
model: config.model,
temperature: 0,
maxTokens: 1024,
maxTokens: getAIMaxTokens(),
timeoutMs: 15000,
});
if (!result || result.trim() === '') {