feat: PMD 依赖 classpath 自动探测 + SQLFluff AL06 参数修复 + 诊断消息前缀 Code Purifier + AI maxTokens 统一走配置
This commit is contained in:
+13
-3
@@ -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(); });
|
||||
|
||||
@@ -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', '');
|
||||
}
|
||||
|
||||
@@ -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
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
@@ -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,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,
|
||||
});
|
||||
|
||||
@@ -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,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() === '') {
|
||||
|
||||
Reference in New Issue
Block a user