diff --git a/_AI_USAGE_LOG.md b/_AI_USAGE_LOG.md index 24e4a28..f0b0cfc 100644 --- a/_AI_USAGE_LOG.md +++ b/_AI_USAGE_LOG.md @@ -185,3 +185,9 @@ | 2026-08-06 19:52 | ① 用户提出 → ② 需求澄清 → ③ 方案设计 | 生成「创建项目配置 = 内置配置(含三语规则注释)」设计书 | 中间产物:SQLFluff 内置非 rules=all 的纠错、ESLint 方案 A/B 抉择(选 B 仅 JS 零依赖)、注释语言三选一(选三语切换)、链接策略从逐条改为每文件单个索引链接、PMD 类别级注释而非 274 条展开 | docs/superpowers/specs/2026-08-06-builtin-config-template-design.md | deepseek-v4-flash | | 2026-08-06 20:02 | ① 用户提出 → ② 需求澄清 → ③ 方案设计 → ④ 人类审批 → ⑤ 编码实现 | 实现「创建项目配置 = 内置配置(含三语规则注释)」,验证 lint/compile/test 全通过 | 中间产物:ESLint 内置 JS 实际 92 条(61+31)而非方案初稿 93/32;Stylelint 顶层静态 import 纯 ESM 包为既有 bug(CJS require 报 No exports main),改为动态 import;PMD 22 条 exclude 缺描述补写进 static-rules.json;SQLFluff 内置非 rules=all 的纠错 | src/rules/builtin-rules.ts(新建) src/adapters/eslint.ts src/adapters/stylelint.ts src/adapters/sqlfluff.ts src/views/setupView.ts src/rules/static-rules.json src/i18n/messages.ts docs/superpowers/specs/2026-08-06-builtin-config-template-design.md | deepseek-v4-flash | | 2026-08-06 20:37 | ① 用户提出 → ② 需求澄清 → ③ 方案设计 → ④ 人类审批 → ⑤ 编码实现 → ⑥ 审查验证 | 修复安装验证发现的 3 个问题:①PMD 创建配置不生成文件(resolvePmdRulesetPath 打包后 __dirname 退级过多,改逐级向上探测 5 级 + getExtension 路径);②static-rules.json 新增 57 条描述中文被 PowerShell stdin 管道 ASCII 编码破坏成 '?'(改 UTF-8 落盘 .mjs 脚本重写修复);③生成 eslint.config.js/.stylelintrc.js 被插件静态分析报 no-undef 'module'(首行加 /* global module */)。重新打包 1.2.0 VSIX | 中间产物:修复②初判为 esbuild 编码问题(bundle 中文存大写 \\uXXXX 转义,运行时解码正常,排除);仓库根因测试生成的 eslint.config.js 覆盖自身 lint 配置致 146 no-undef,已删除 | src/rules/builtin-rules.ts src/rules/static-rules.json docs/superpowers/specs/2026-08-06-builtin-config-template-design.md | deepseek-v4-flash | +| 2026-08-08 15:22 | ① 用户提出 → ② 需求澄清 → ③ 方案设计 → ④ 人类审批 → ⑤ 编码实现 → ⑥ 审查验证 | 修复 SQLFluff AL06 未生效:内置配置与项目配置模板追加 [sqlfluff:rules:aliasing.length] max_alias_length=30 | 中间产物:初判需向 BUILTIN_SQLFLUFF_RULES 加 AL06,经官方文档+本机实测推翻(core 组已含 AL06),实为参数默认 None 永不触发 | src/rules/builtin-rules.ts docs/superpowers/specs/2026-08-08-sqlfluff-al06-fix-design.md | deepseek-v4-flash | +| 2026-08-08 22:07 | ① 用户提出 → ② 需求澄清 → ③ 方案设计 → ④ 人类审批 → ⑤ 编码实现 → ⑥ 审查验证 | 波浪线诊断消息前缀改为「Code Purifier · linter」:toVscodeDiagnostics 新增 PLUGIN_NAME 常量与 formatDiagnosticMessage,按 ruleId 冒号拆分 linter/规则,消息由 [eslint:ruleId] 改为 [Code Purifier · eslint] ruleId;同步更新 diagnostics.test.ts 旧断言并新增无前缀兜底用例,lint/compile/test 全通过 | 中间产物:无(需求澄清阶段询问显示形式,用户选「只改消息前缀」) | src/diagnostics/diagnosticMarkers.ts src/test/diagnostics.test.ts | deepseek-v4-flash | +| 2026-08-08 22:36 | ① 用户提出 → ② 需求澄清 → ③ 方案设计 → ④ 人类审批 → ⑤ 编码实现 → ⑥ 审查验证 | maxTokens 统一走配置:fixer(4096→getAIMaxTokens)、import-service(8192→getAIMaxTokens)、setupView 连接测试(1024→getAIMaxTokens),各文件补 import,lint/compile/test 全通过 | 中间产物:无 | src/fixer/fixer.ts src/rules/import-service.ts src/views/setupView.ts | deepseek-v4-flash | +| 2026-08-08 23:40 | ① 用户提出 → ② 需求澄清 → ③ 方案设计 | PMD 依赖 classpath 自动探测设计:核实用户所述「PmdRunner 已支持 PMD_AUXCP」不成立(源码+编译 class 均无 getenv),设计两端修改方案(AuxClasspathResolver 服务 + PmdRunner 读 PMD_AUXCP),共识含探测优先级/缓存/多模块/失败降级/runtimeClasspath 范围,新增 autoAuxClasspath 开关待审批 | 中间产物:无 | docs/superpowers/specs/2026-08-08-pmd-auxclasspath-design.md | deepseek-v4-flash | +| 2026-08-08 23:47 | ① 用户提出 → ② 需求澄清 → ③ 方案设计 → ④ 人类审批 → ⑤ 编码实现 → ⑥ 审查验证 | 实现 PMD 依赖 classpath 自动探测:新建 src/services/auxClasspath.ts(AuxClasspathResolver:pom.xml→build.gradle→build.gradle.kts 优先级探测、按 workingDir+构建文件 mtime 缓存、in-flight 去重、mvn dependency:build-classpath/Gradle init 脚本 _printRuntimeClasspath、120s 超时、失败降级);pmd.ts run() 解析 aux 并在 execPmd spawn 传 env.PMD_AUXCP;PmdRunner.java 读 PMD_AUXCP/PMD_AUX_CLASSPATH 调 prependAuxClasspath 并重编译 .class;package.json + linter.ts 新增 pmd.autoAuxClasspath 开关(默认 true)。lint 0 error / compile 通过 / npm test 79 通过;本机无 mvn/gradle,用 vscode stub 冒烟验证无构建降级、工具缺失降级(spawn EINVAL 同步抛被 try/catch 捕获)、缓存命中(cached-same=true)、开关关闭直返;PmdRunner 带 PMD_AUXCP 实测正常输出 JSON | 中间产物:auxClasspath.ts 初版 proc.stdout 类型报 TS18047(ChildProcess|null 未收窄),改 const child = proc + stdout/stderr 可选链;冒烟脚本曾误用 process.argv[1](脚本路径)导致探测不到 pom.xml,改 argv[2] 重测 | src/services/auxClasspath.ts(新建) src/adapters/pmd.ts jars/pmd/PmdRunner.java jars/pmd/PmdRunner.class package.json src/config/linter.ts docs/superpowers/specs/2026-08-08-pmd-auxclasspath-design.md | deepseek-v4-flash | +| 2026-08-08 23:49 | ① 用户提出 → ⑤ 编码实现 → ⑥ 审查验证 | 打包生产 VSIX:npm run package-prod(esbuild build + vsce package),产物 vscode-code-reviewer-1.2.0.vsix(68.91MB);确认 out/extension.js 已包含 PMD_AUXCP 逻辑、jars/ 含重编译的 PmdRunner.class | 中间产物:无 | vscode-code-reviewer-1.2.0.vsix | deepseek-v4-flash | \ No newline at end of file diff --git a/docs/superpowers/specs/2026-08-08-pmd-auxclasspath-design.md b/docs/superpowers/specs/2026-08-08-pmd-auxclasspath-design.md new file mode 100644 index 0000000..1276ce2 --- /dev/null +++ b/docs/superpowers/specs/2026-08-08-pmd-auxclasspath-design.md @@ -0,0 +1,180 @@ +# PMD 依赖 Classpath 自动探测 — 设计 Spec + +日期:2026-08-08 +状态:待审批 +关联流程:① 用户提出 → ② 需求澄清 → ③ 方案设计 + +## 1. 背景与根因 + +PMD 做类型感知分析(如 `UnusedImports`、`UnnecessaryFullyQualifiedName` 等规则)时,需要能解析源码中引用的外部类型,即需要"辅助 classpath"(aux classpath)。当前插件调用 PMD 时未提供该 classpath: + +1. `src/adapters/pmd.ts:104` 的 `spawn('java', args, { cwd })` 未设置 `env`,依赖 classpath 无从传入。 +2. `jars/pmd/PmdRunner.java` 也未读取任何 aux classpath 来源——经源码与编译产物 `PmdRunner.class` 双重验证,其既无 `getenv` 调用,也未调用 PMD 的 `PMDConfiguration.prependAuxClasspath(String)`(pmd-core-7.26.0 中该方法存在,`javap` 已验证)。 + +因此即使插件侧把 classpath 设置进环境变量,PmdRunner 当前也不会消费它,需两端同时修改。 + +## 2. 共识(Stage ② 确认) + +- **classpath 来源**:自动探测 Maven / Gradle 构建系统,不新增手动配置项。 +- **探测优先级**:`pom.xml` → `build.gradle` → `build.gradle.kts`,取第一个命中项;三者都没有则不设置 aux classpath(保持现状行为,不报错)。 +- **缓存策略**:按 workingDir 缓存探测结果;以构建文件路径 + mtime 作为失效键,构建文件变更后自动重新探测。 +- **多模块支持**:按模块根独立探测(workingDir 即模块根),不跨模块合并 classpath。 +- **失败处理**:探测失败(工具未装 / 命令报错 / 超时)降级为不带 classpath 继续审查,仅日志提示,不影响可用性。 +- **classpath 范围**:runtimeClasspath 超集(compile + runtime + 传递依赖)。 + +## 3. 方案 + +### 3.1 架构概览 + +新增独立服务 `AuxClasspathResolver`(位于基础层 `src/services/`),对 PMD 适配器透明暴露 `resolve(workingDir) => Promise`。数据流: + +``` +PmdAdapter.run() + ├─ AuxClasspathResolver.resolve(workingDir) + │ ├─ 探测构建文件(pom.xml/build.gradle(.kts)),无 → 返回 '' + │ ├─ 缓存命中(构建文件路径+mtime 未变)→ 返回缓存 classpath + │ ├─ 未命中 → 运行 mvn/gradle 生成 classpath → 更新缓存(成功/失败均缓存) + └─ spawn('java', args, { cwd, env: { ...process.env, PMD_AUXCP } }) + └─ PmdRunner.main() 读 PMD_AUXCP → config.prependAuxClasspath(aux) +``` + +classpath 以 `PMD_AUXCP` 环境变量经 `spawn` 的 `env` 传入子进程;`PmdRunner.java` 读取后注入 `PMDConfiguration`。 + +### 3.2 文件变更清单 + +| 文件 | 变更 | 说明 | +|------|------|------| +| `src/services/auxClasspath.ts` | 新建 | `AuxClasspathResolver`:探测 + 缓存 + Maven/Gradle 调用 | +| `src/adapters/pmd.ts` | 修改 | `run()` 中解析 aux classpath,`execPmd()` 的 spawn 传入 `env` | +| `jars/pmd/PmdRunner.java` | 修改 | 读取 `PMD_AUXCP`(兼容 `PMD_AUX_CLASSPATH`),调用 `prependAuxClasspath` | +| `jars/pmd/PmdRunner.class` | 重编译 | `javac -cp "jars/pmd/lib/*" -d jars/pmd/ jars/pmd/PmdRunner.java` | +| `package.json` | 修改 | 新增开关 `vscode-code-reviewer.pmd.autoAuxClasspath`(默认 true,见 §3.4) | +| `src/config/linter.ts` | 修改 | 新增 `getPMDAutoAuxClasspath()` 读取开关 | + +### 3.3 关键接口 + +#### 3.3.1 `AuxClasspathResolver`(`src/services/auxClasspath.ts`) + +```ts +export interface AuxClasspathInfo { + classpath: string; // '' 表示无可用 classpath + source?: 'maven' | 'gradle'; + error?: string; // 探测失败时的错误信息(仅日志用) +} + +export class AuxClasspathResolver { + resolve(workingDir: string): Promise; +} +``` + +内部实现要点: + +- **缓存结构**:`Map`,键为 `workingDir`;外加 `Map>` 作 in-flight 去重,避免并发检查重复运行构建命令。 +- **失效键**:`buildFile 路径 + stat.mtimeMs`。每次 `resolve` 重新探测构建文件并 `stat`,mtime 变化即重跑。 +- **探测工具**: + - Maven:优先 `mvnw`/`mvnw.cmd`,回退 `mvn`/`mvn.cmd`(Windows 用 `.cmd`,避免 `spawn` PATHEXT 问题)。 + - Gradle:优先 `gradlew`/`gradlew.bat`,回退 `gradle`/`gradle.bat`。 +- **Maven 命令**(写临时文件再读取,避免解析 stdout 噪音): + ``` + -B -q -Dmdep.outputFile= dependency:build-classpath + ``` + 读取 `tempFile` 内容 trim 后作为 classpath。插件默认 `includeScope=runtime`(`dependency:build-classpath` 默认值,符合 runtimeClasspath 超集共识)。 +- **Gradle 命令**(init 脚本注册一次性任务,打印 root 工程 `sourceSets.main.runtimeClasspath.asPath`): + ``` + -q -I _printRuntimeClasspath + ``` + init 脚本内容(Groovy,兼容 Groovy DSL 与 Kotlin DSL 项目): + ```groovy + allprojects { proj -> + if (proj == rootProject) { + proj.tasks.register('_printRuntimeClasspath') { + doLast { + if (proj.plugins.hasPlugin('java')) { + def main = proj.sourceSets.findByName('main') + if (main != null) { + println main.runtimeClasspath.asPath + } + } + } + } + } + } + ``` + 取 stdout 末行非空行作为 classpath。 +- **超时**:`spawn` + 手动计时(Maven 120s / Gradle 120s),超时 `proc.kill()` 并视为失败。 +- **失败语义**:捕获所有异常/非零退出,返回 `{ classpath: '', error: message }`,并同样写入缓存(mtime 未变前不重跑,避免每次保存文件重复失败告警)。 + +#### 3.3.2 `PmdRunner.java` 改动 + +```java +String auxcp = System.getenv("PMD_AUXCP"); +if (auxcp == null || auxcp.isEmpty()) { + auxcp = System.getenv("PMD_AUX_CLASSPATH"); // 兼容 PMD CLI 约定 +} +if (auxcp != null && !auxcp.isEmpty()) { + config.prependAuxClasspath(auxcp); +} +``` + +放在 `config.addRuleSet(rulesetPath)` 之后、`PmdAnalysis.create(config)` 之前。 + +#### 3.3.3 `src/adapters/pmd.ts` 改动 + +- `PmdAdapter` 增加私有字段 `private auxResolver = new AuxClasspathResolver();`。 +- `run()` 中 `classpath` 计算后、`execPmd` 前: + ```ts + 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, stdin, workingDir, auxInfo.classpath); + ``` +- `execPmd` 增加第 4 参数 `auxClasspath: string`,`spawn` 改为: + ```ts + const env = { ...process.env }; + if (auxClasspath && auxClasspath.trim() !== '') { env.PMD_AUXCP = auxClasspath; } + const proc = spawn('java', args, { cwd, env }); + ``` +- Java 与 JSP 两条路径共用该逻辑(两者均可能受益于类型解析)。 + +### 3.4 新增配置项(待审批确认) + +为自动探测提供安全阀: + +```jsonc +"vscode-code-reviewer.pmd.autoAuxClasspath": { + "type": "boolean", + "default": true, + "description": "自动探测 Maven/Gradle 依赖并传给 PMD 作为辅助 classpath" +} +``` + +默认开启;置 `false` 时 `AuxClasspathResolver` 直接返回空,不做任何构建调用。若审批不通过可整体砍掉此项,不影响核心修复。 + +### 3.5 错误处理与降级 + +- 无构建文件 / 工具缺失 / 命令失败 / 超时 → `{ classpath: '' }`,PMD 以无 aux classpath 运行(等同现状)。 +- 失败信息仅 `console.warn`,不上浮错误弹窗、不改变 `AdapterResult.status`(保持 `'ok'`,避免误报 execution-failed 干扰现有审查流程)。 +- `mvn/gradle` 输出含 Windows 盘符路径或空格时作为单个 env 变量值传递,无 shell 插值风险(`spawn` 不经 shell,`env` 值原样透传)。 + +### 3.6 验证方式 + +1. `npm test`(pretest 自动 `compile` + `lint`;当前仓库无 test 用例文件,adapter.test.ts 已移出,测试仅验证扩展可激活)。 +2. 手工端到端(Windows 本机): + - 构造含 `pom.xml` 的最小 Maven 项目,被测 Java 源码引用第三方类型;确认 `.class` 的 PMD 类型敏感规则(如 `UnusedImports`)在改动前后行为差异、且无 `PMD exited with code` 报错。 + - 构造含 `build.gradle` 项目跑同用例。 + - 断言 `_printRuntimeClasspath` 任务输出非空;验证缓存:连续两次 check 只跑一次构建命令(改 `pom.xml` mtime 后重跑)。 + - 无构建文件目录:行为与改动前一致。 +3. `javac` 重编译 `PmdRunner.java` 成功,并核对 `PmdRunner.class` 中包含 `PMD_AUXCP` 字符串与 `prependAuxClasspath` 调用(`strings`/`javap` 验证)。 + +## 4. 影响范围 + +- 仅影响 PMD 适配器(Java + JSP)调用路径;无构建文件或开关关闭时行为与现状完全一致。 +- 不改变规则集选择、诊断严重度映射、AI 审查流程。 +- 新增基础层服务文件 1 个、PMD 适配器与 PmdRunner 修改、配置项 1 个;无 i18n 文案变更(失败仅 console 日志)。 +- 首次探测某项目会额外运行一次 `mvn/gradle`(秒级),之后走缓存;可通过开关关闭。 + +## 5. 风险 + +- **Gradle init 脚本兼容性**:`build.gradle.kts` 项目与 Groovy init 脚本组合在 Gradle 8/9 下可用(init 脚本恒为 Groovy,通过 Project API 访问 sourceSets);若项目配置特殊(无 java 插件、sourceSets 重命名),输出为空 → 降级无 classpath,不阻塞审查。 +- **Maven 依赖解析失败**:私有仓库/离线场景 `dependency:build-classpath` 可能失败 → 降级,不影响审查。 +- **首次延迟**:新项目首次检查多耗时(构建探测),后续缓存;可接受,且有开关兜底。 +- **classpath 大小**:大型项目 runtimeClasspath 字符串可能很长,Windows 单环境变量上限约 32KB;超限时降级为无 classpath(读回为空/截断判断),不影响审查可用性。 diff --git a/docs/superpowers/specs/2026-08-08-sqlfluff-al06-fix-design.md b/docs/superpowers/specs/2026-08-08-sqlfluff-al06-fix-design.md new file mode 100644 index 0000000..3d38b58 --- /dev/null +++ b/docs/superpowers/specs/2026-08-08-sqlfluff-al06-fix-design.md @@ -0,0 +1,90 @@ +# SQLFluff AL06 生效修复 — 设计 Spec + +日期:2026-08-08 +状态:待审批 +关联流程:① 用户提出 → ② 需求澄清 → ③ 方案设计 + +## 1. 背景与根因 + +用户反馈 SQLFluff 的 AL06(`aliasing.length`,强制表别名长度)未生效。 + +经代码调研 + 官方文档核实 + 本机 sqlfluff 4.2.2 实测,根因为: + +1. 内置规则集 `rules = core,AM03,...`(`src/rules/builtin-rules.ts:94`)中的 `core` 组已包含 AL06。 + - 佐证:static-rules.json 中 32 条 P0 规则与 SQLFluff 官方 core 组完全一致,AL06 在其中。 +2. AL06 默认参数 `min_alias_length = None`、`max_alias_length = None`(官方默认配置),无限制时永不产生 violation, + 表现为"未生效"。 +3. 实测:`SELECT a FROM orders AS this_is_a_very_long_table_alias_x;` 在 `--rules core` 下仅报 AL05,无 AL06。 + +### 结论 + +AL06 不是"未启用",而是"已启用但缺少长度参数"。修复方向是为内置 SQLFluff 配置补充 +`[sqlfluff:rules:aliasing.length]` 参数段,而非向 `BUILTIN_SQLFLUFF_RULES` 添加 AL06。 + +## 2. 共识(Stage ② 确认) + +- 场景:插件默认内置配置(无全局 configFile、无项目 .sqlfluff)下。 +- 阈值:`max_alias_length = 30`(与 Oracle 标识符 30 字符上限对齐,本项目 sql/plsql 方言兜底为 oracle);不设 `min_alias_length`。 +- 范围:不做其它 AL 规则调整;不改 `BUILTIN_SQLFLUFF_RULES` 字符串(避免 i18n 计数文案连锁变更)。 + +## 3. 方案 + +### 3.1 架构概览 + +无需架构调整。改动局限于 `src/rules/builtin-rules.ts` 中两个配置生成函数,使生成的 +配置文件同时携带 `[sqlfluff] rules` 与 `[sqlfluff:rules:aliasing.length] max_alias_length = 30`。 + +数据流不变: + +``` +SqlFluffAdapter.check() + └─ 无全局/项目配置时 + └─ buildBuiltinSqlfluffConfig(dialect) ──写临时文件──> sqlfluff lint --config ... +``` + +### 3.2 文件变更清单 + +| 文件 | 变更 | 说明 | +|------|------|------| +| `src/rules/builtin-rules.ts` | 修改 | `buildBuiltinSqlfluffConfig()` 输出追加参数段 | +| `src/rules/builtin-rules.ts` | 修改 | `buildSqlfluffProjectConfigText()` 输出同步追加参数段(设置面板生成的项目 .sqlfluff 模板与内置保持一致) | + +无新建、无删除。 + +### 3.3 关键接口 + +两函数签名不变: + +```ts +export function buildBuiltinSqlfluffConfig(dialect: string): string +export function buildSqlfluffProjectConfigText(dialect: string, lang: Language): string +``` + +新增配置段内容(两处一致): + +```ini +[sqlfluff:rules:aliasing.length] +max_alias_length = 30 +``` + +### 3.4 配置优先级影响 + +- 全局 `sqlfluff.configFile` / 项目 `.sqlfluff` 存在时仍优先(`sqlfluff.ts:141-149`),本次改动不影响这两条路径。 +- 仅内置兜底配置获得 AL06 长度限制,行为符合"与插件内置规则一致"的产品语义。 + +### 3.5 验证方式 + +1. `npm test`(lint → compile → test)。 +2. 手工端到端:`sqlfluff lint --rules core --config <生成配置>` 对超长别名 SQL 应报 AL06;现有 4.2.2 实测基线已确认改前不报。 +3. 可选:新增单测断言 `buildBuiltinSqlfluffConfig` 输出包含 `max_alias_length = 30`。 + +## 4. 影响范围 + +- 仅影响未配置任何外部 SQLFluff 配置的默认路径。 +- 不触发 i18n 文案、static-rules.json、package.json 变更。 +- 诊断严重度:AL06 tier 为 P0 → 保持 error(`tierToSeverity`,sqlfluff.ts:38)。 + +## 5. 风险 + +- SQLFluff 低版本(< 4.x)对 `[sqlfluff:rules:aliasing.length]` 段名兼容性:暂无低版本支持承诺,目标版本 4.2.2+,风险低。 +- 长别名在 Oracle 中本就受限(30 字符),阈值不会产生误报。 \ No newline at end of file diff --git a/jars/pmd/PmdRunner.class b/jars/pmd/PmdRunner.class index 327ce44..f10fc6c 100644 Binary files a/jars/pmd/PmdRunner.class and b/jars/pmd/PmdRunner.class differ diff --git a/jars/pmd/PmdRunner.java b/jars/pmd/PmdRunner.java index 1fda823..3fdc9e5 100644 --- a/jars/pmd/PmdRunner.java +++ b/jars/pmd/PmdRunner.java @@ -27,6 +27,14 @@ public class PmdRunner { PMDConfiguration config = new PMDConfiguration(); config.addRuleSet(rulesetPath); + String auxcp = System.getenv("PMD_AUXCP"); + if (auxcp == null || auxcp.isEmpty()) { + auxcp = System.getenv("PMD_AUX_CLASSPATH"); + } + if (auxcp != null && !auxcp.isEmpty()) { + config.prependAuxClasspath(auxcp); + } + Writer writer = new StringWriter(); JsonRenderer renderer = new JsonRenderer(); renderer.setWriter(writer); diff --git a/package.json b/package.json index e50d98e..0cc70b5 100644 --- a/package.json +++ b/package.json @@ -227,6 +227,11 @@ "default": "", "description": "JSP 规则集 XML 路径(空=使用内置)" }, + "vscode-code-reviewer.pmd.autoAuxClasspath": { + "type": "boolean", + "default": true, + "description": "自动探测 Maven/Gradle 依赖并传给 PMD 作为辅助 classpath" + }, "vscode-code-reviewer.sqlfluff.configFile": { "type": "string", "default": "", diff --git a/src/adapters/pmd.ts b/src/adapters/pmd.ts index 92d5103..217d4cc 100644 --- a/src/adapters/pmd.ts +++ b/src/adapters/pmd.ts @@ -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 { + private execPmd(args: string[], stdinInput: string | null, cwd: string, auxClasspath: string): Promise { 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(); }); diff --git a/src/config/linter.ts b/src/config/linter.ts index 1b4924b..cc8c606 100644 --- a/src/config/linter.ts +++ b/src/config/linter.ts @@ -18,6 +18,10 @@ export function getPMDJspRulesetPath(): string { return vscode.workspace.getConfiguration(ROOT).get('pmd.jspRulesetPath', ''); } +export function getPMDAutoAuxClasspath(): boolean { + return vscode.workspace.getConfiguration(ROOT).get('pmd.autoAuxClasspath', true); +} + export function getSqlFluffConfigFile(): string { return vscode.workspace.getConfiguration(ROOT).get('sqlfluff.configFile', ''); } diff --git a/src/diagnostics/diagnosticMarkers.ts b/src/diagnostics/diagnosticMarkers.ts index 80e631b..dd84a9a 100644 --- a/src/diagnostics/diagnosticMarkers.ts +++ b/src/diagnostics/diagnosticMarkers.ts @@ -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('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); }); } diff --git a/src/fixer/fixer.ts b/src/fixer/fixer.ts index 9f98acf..f176efd 100644 --- a/src/fixer/fixer.ts +++ b/src/fixer/fixer.ts @@ -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, }); diff --git a/src/rules/builtin-rules.ts b/src/rules/builtin-rules.ts index 26c31c6..22fb7c3 100644 --- a/src/rules/builtin-rules.ts +++ b/src/rules/builtin-rules.ts @@ -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'); } diff --git a/src/rules/import-service.ts b/src/rules/import-service.ts index bc21a54..57e5641 100644 --- a/src/rules/import-service.ts +++ b/src/rules/import-service.ts @@ -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, }); diff --git a/src/services/auxClasspath.ts b/src/services/auxClasspath.ts new file mode 100644 index 0000000..6a130bf --- /dev/null +++ b/src/services/auxClasspath.ts @@ -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(); + private inflight = new Map>(); + + async resolve(workingDir: string): Promise { + 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 { + if (build.kind === 'maven') { + return this.runMaven(workingDir); + } + return this.runGradle(workingDir); + } + + private async runMaven(workingDir: string): Promise { + 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 { + 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 { + 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'; +} diff --git a/src/views/setupView.ts b/src/views/setupView.ts index 4d3c161..c504502 100644 --- a/src/views/setupView.ts +++ b/src/views/setupView.ts @@ -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() === '') {