diff --git a/desktop/src/i18n/locales/en.ts b/desktop/src/i18n/locales/en.ts index 8c6c5999..bf189b80 100644 --- a/desktop/src/i18n/locales/en.ts +++ b/desktop/src/i18n/locales/en.ts @@ -1331,6 +1331,10 @@ export const en = { 'solo.suggest.syncUpstream.detail': 'Local is behind origin', 'solo.suggest.syncUpstream.taskPrompt': 'Local is {count} commit(s) behind upstream. Plan a pull / rebase strategy, run it, and verify the result.', + 'solo.suggest.lspError.title': 'Clear {count} type error(s)', + 'solo.suggest.lspError.detail': 'Language server is reporting errors in this workspace', + 'solo.suggest.lspError.taskPrompt': 'The workspace has {count} type error(s) reported by the language server. Plan how to clear them, fix them in order of severity, then verify the workspace is green.', + 'solo.suggest.resolveConflict.merge.title': 'Resolve in-progress merge', 'solo.suggest.resolveConflict.merge.detail': 'A merge is mid-flight (.git/MERGE_HEAD exists)', 'solo.suggest.resolveConflict.merge.taskPrompt': 'A merge is in progress (.git/MERGE_HEAD exists). Inspect the conflicted files, decide on the resolution strategy, resolve, verify the build, then commit the merge.', diff --git a/desktop/src/i18n/locales/jp.ts b/desktop/src/i18n/locales/jp.ts index 2bdf7e01..70ab735f 100644 --- a/desktop/src/i18n/locales/jp.ts +++ b/desktop/src/i18n/locales/jp.ts @@ -1317,6 +1317,10 @@ export const jp: Record = { 'solo.suggest.syncUpstream.detail': 'ローカルが origin より遅れています', 'solo.suggest.syncUpstream.taskPrompt': 'ローカルは上流より {count} 件遅れています。pull / rebase 戦略を計画、実行、結果を検証してください。', + 'solo.suggest.lspError.title': '{count} 件の型エラーを解消', + 'solo.suggest.lspError.detail': '言語サーバーがこのワークスペースのエラーを報告しています', + 'solo.suggest.lspError.taskPrompt': 'このワークスペースで言語サーバーが {count} 件の型エラーを報告しています。解消方法を計画し、深刻度の高い順に修正し、ワークスペースがクリーンであることを検証してください。', + 'solo.suggest.resolveConflict.merge.title': '進行中の merge を解決', 'solo.suggest.resolveConflict.merge.detail': 'merge が進行中です(.git/MERGE_HEAD あり)', 'solo.suggest.resolveConflict.merge.taskPrompt': 'merge が進行中です(.git/MERGE_HEAD あり)。コンフリクトファイルを確認、解決方針を決定、解決後にビルドを検証してから merge を commit してください。', diff --git a/desktop/src/i18n/locales/kr.ts b/desktop/src/i18n/locales/kr.ts index b1ac3271..aa1ba14e 100644 --- a/desktop/src/i18n/locales/kr.ts +++ b/desktop/src/i18n/locales/kr.ts @@ -1317,6 +1317,10 @@ export const kr: Record = { 'solo.suggest.syncUpstream.detail': '로컬이 origin보다 뒤처져 있음', 'solo.suggest.syncUpstream.taskPrompt': '로컬이 업스트림보다 {count}개 커밋 뒤처져 있습니다. pull / rebase 전략을 계획, 실행, 결과를 검증해 주세요.', + 'solo.suggest.lspError.title': '{count}개 타입 오류 정리', + 'solo.suggest.lspError.detail': '언어 서버가 이 워크스페이스에서 오류를 보고하고 있습니다', + 'solo.suggest.lspError.taskPrompt': '이 워크스페이스에서 언어 서버가 {count}개의 타입 오류를 보고합니다. 정리 방법을 계획하고 심각도 순으로 수정한 다음, 워크스페이스가 깨끗한지 검증해 주세요.', + 'solo.suggest.resolveConflict.merge.title': '진행 중인 merge 해결', 'solo.suggest.resolveConflict.merge.detail': 'merge가 진행 중입니다 (.git/MERGE_HEAD 존재)', 'solo.suggest.resolveConflict.merge.taskPrompt': 'merge가 진행 중입니다 (.git/MERGE_HEAD 존재). 충돌 파일을 확인하고 해결 전략을 정한 뒤 빌드를 검증하고 merge를 commit해 주세요.', diff --git a/desktop/src/i18n/locales/zh-TW.ts b/desktop/src/i18n/locales/zh-TW.ts index 1a648485..7153690d 100644 --- a/desktop/src/i18n/locales/zh-TW.ts +++ b/desktop/src/i18n/locales/zh-TW.ts @@ -1317,6 +1317,10 @@ export const zh: Record = { 'solo.suggest.syncUpstream.detail': '本地落後於 origin', 'solo.suggest.syncUpstream.taskPrompt': '本地落後上游 {count} 個提交。規劃 pull / rebase 策略,執行,然後驗證結果。', + 'solo.suggest.lspError.title': '清理 {count} 個型別錯誤', + 'solo.suggest.lspError.detail': '語言伺服器回報該工作區存在錯誤', + 'solo.suggest.lspError.taskPrompt': '該工作區存在 {count} 個由語言伺服器回報的型別錯誤。規劃清理方案,依嚴重程度依序修復,然後驗證工作區狀態乾淨。', + 'solo.suggest.resolveConflict.merge.title': '處理進行中的 merge', 'solo.suggest.resolveConflict.merge.detail': '有一個 merge 在中途(.git/MERGE_HEAD 存在)', 'solo.suggest.resolveConflict.merge.taskPrompt': '有 merge 在進行(.git/MERGE_HEAD 存在)。檢查衝突文件,定解決策略,處理完跑構建驗證,然後 commit 這個 merge。', diff --git a/desktop/src/i18n/locales/zh.ts b/desktop/src/i18n/locales/zh.ts index e0132799..d8ad4bf5 100644 --- a/desktop/src/i18n/locales/zh.ts +++ b/desktop/src/i18n/locales/zh.ts @@ -1317,6 +1317,10 @@ export const zh: Record = { 'solo.suggest.syncUpstream.detail': '本地落后于 origin', 'solo.suggest.syncUpstream.taskPrompt': '本地落后上游 {count} 个提交。规划 pull / rebase 策略,执行,然后验证结果。', + 'solo.suggest.lspError.title': '清理 {count} 个类型错误', + 'solo.suggest.lspError.detail': '语言服务器报告该工作区存在错误', + 'solo.suggest.lspError.taskPrompt': '该工作区存在 {count} 个由语言服务器报告的类型错误。规划清理方案,按严重程度依次修复,然后验证工作区状态干净。', + 'solo.suggest.resolveConflict.merge.title': '处理进行中的 merge', 'solo.suggest.resolveConflict.merge.detail': '有一个 merge 在中途(.git/MERGE_HEAD 存在)', 'solo.suggest.resolveConflict.merge.taskPrompt': '有 merge 在进行(.git/MERGE_HEAD 存在)。检查冲突文件,定解决策略,处理完跑构建验证,然后 commit 这个 merge。', diff --git a/desktop/src/i18n/lspError.test.ts b/desktop/src/i18n/lspError.test.ts new file mode 100644 index 00000000..049cbb80 --- /dev/null +++ b/desktop/src/i18n/lspError.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from 'vitest' +import { translate } from '.' +import { en } from './locales/en' +import { zh } from './locales/zh' +import { zh as zhTW } from './locales/zh-TW' +import { jp } from './locales/jp' +import { kr } from './locales/kr' + +/** + * Phase 5 of editor-lsp-foundation adds the `solo.suggest.lspError.*` + * trio of i18n keys for the LSP-error cleanup suggestion. This test + * locks the 5-locale × 3-key contract on the desktop side: each locale + * must have all three keys, none empty, and the count param must + * survive interpolation. + * + * `TranslationKey = keyof typeof en` already gates compile-time + * completeness, but a runtime test catches the case where a locale + * imports the key but ships an empty string (visually indistinguishable + * from a real translation in tsc). + */ + +const LOCALES = { en, zh, 'zh-TW': zhTW, jp, kr } as const +type Locale = keyof typeof LOCALES + +const LSP_ERROR_KEYS = [ + 'solo.suggest.lspError.title', + 'solo.suggest.lspError.detail', + 'solo.suggest.lspError.taskPrompt', +] as const + +describe('solo.suggest.lspError i18n contract — Phase 5', () => { + for (const locale of Object.keys(LOCALES) as Locale[]) { + describe(`locale: ${locale}`, () => { + for (const key of LSP_ERROR_KEYS) { + it(`has ${key}`, () => { + const dict = LOCALES[locale] as Record + expect(dict[key]).toBeDefined() + expect(typeof dict[key]).toBe('string') + expect(dict[key]!.length).toBeGreaterThan(0) + }) + } + }) + } + + it('renders the count param for every locale title', () => { + for (const locale of Object.keys(LOCALES) as Locale[]) { + const result = translate(locale, 'solo.suggest.lspError.title', { count: 7 }) + expect(result).toContain('7') + } + }) + + it('renders the count param for every locale taskPrompt', () => { + for (const locale of Object.keys(LOCALES) as Locale[]) { + const result = translate(locale, 'solo.suggest.lspError.taskPrompt', { count: 12 }) + expect(result).toContain('12') + } + }) + + it('detail key is param-free across locales (matches the source-of-truth fixture)', () => { + for (const locale of Object.keys(LOCALES) as Locale[]) { + const dict = LOCALES[locale] as Record + expect(dict['solo.suggest.lspError.detail']).not.toContain('{') + } + }) +}) diff --git a/src/server/services/soloSignalsService.ts b/src/server/services/soloSignalsService.ts index dcbf7c49..473b1e76 100644 --- a/src/server/services/soloSignalsService.ts +++ b/src/server/services/soloSignalsService.ts @@ -417,9 +417,23 @@ function compareSemver(a: string, b: string): number { * parameter so callers don't double-fetch (the welcome flow already * has it from `getRecentActivity`). */ +export type GatherSoloSignalsTier1Options = { + /** + * Optional LSP error-count provider. Phase 5 of editor-lsp-foundation + * adds an `lspErrorCount` Tier-1 signal so the suggestion engine can + * recommend `cleanup` work when the workspace has type errors. + * + * The provider is invoked inside try/catch — any error (LSP not + * ready, prereq missing, manager not attached) silently leaves + * `lspErrorCount` undefined; the rule then sits this round out. + */ + getLspErrorCount?: () => Promise +} + export async function gatherSoloSignalsTier1( workDir: string, recent: RecentActivityResult, + options: GatherSoloSignalsTier1Options = {}, ): Promise { if (!workDir) return {} const dirtyFiles = recent.git?.dirtyFiles ?? [] @@ -430,12 +444,14 @@ export async function gatherSoloSignalsTier1( todoRes, releaseRes, inProgressRes, + lspErrorRes, ] = await Promise.allSettled([ detectStashCount(workDir), detectMissingTests(workDir, dirtyFiles), detectTodoMarkers(workDir, dirtyFiles), detectReleaseMismatch(workDir), detectGitInProgress(workDir), + options.getLspErrorCount ? options.getLspErrorCount() : Promise.resolve(undefined), ]) const out: SoloSignalsTier1 = {} @@ -455,6 +471,14 @@ export async function gatherSoloSignalsTier1( if (inProgressRes.status === 'fulfilled' && inProgressRes.value) { out.gitInProgress = inProgressRes.value } + if ( + lspErrorRes.status === 'fulfilled' && + typeof lspErrorRes.value === 'number' && + Number.isInteger(lspErrorRes.value) && + lspErrorRes.value > 0 + ) { + out.lspErrorCount = lspErrorRes.value + } return out } diff --git a/src/server/services/soloSuggestions.i18n.test.ts b/src/server/services/soloSuggestions.i18n.test.ts index 1c7ea9be..6528cea2 100644 --- a/src/server/services/soloSuggestions.i18n.test.ts +++ b/src/server/services/soloSuggestions.i18n.test.ts @@ -69,6 +69,7 @@ function fixtureWithEverySignal(): { latestNotes: '0.5.9', kind: 'notes-missing', }, + lspErrorCount: 4, // gitInProgress would dominate scoring; we cycle the three // variants in the loop below to make sure all locales are // checked against all three rendering paths. diff --git a/src/server/services/soloSuggestions.test.ts b/src/server/services/soloSuggestions.test.ts index 5a8f6093..a37496d7 100644 --- a/src/server/services/soloSuggestions.test.ts +++ b/src/server/services/soloSuggestions.test.ts @@ -381,3 +381,79 @@ describe('isCodeSource', () => { expect(isCodeSource('')).toBe(false) }) }) + +describe('buildSoloSuggestions — rule: lsp-error (Phase 5)', () => { + it('does NOT fire when lspErrorCount is undefined', () => { + const out = buildSoloSuggestions(quietActivity(), {}, { now: FROZEN_NOW }) + expect(out.find((s) => s.id === 'lsp-error')).toBeUndefined() + }) + + it('does NOT fire when lspErrorCount is 0', () => { + const out = buildSoloSuggestions( + quietActivity(), + { lspErrorCount: 0 }, + { now: FROZEN_NOW }, + ) + expect(out.find((s) => s.id === 'lsp-error')).toBeUndefined() + }) + + it('does NOT fire when lspErrorCount is a non-integer', () => { + const out = buildSoloSuggestions( + quietActivity(), + { lspErrorCount: 1.5 as unknown as number }, + { now: FROZEN_NOW }, + ) + expect(out.find((s) => s.id === 'lsp-error')).toBeUndefined() + }) + + it('does NOT fire when lspErrorCount is negative (defensive)', () => { + const out = buildSoloSuggestions( + quietActivity(), + { lspErrorCount: -3 }, + { now: FROZEN_NOW }, + ) + expect(out.find((s) => s.id === 'lsp-error')).toBeUndefined() + }) + + it('fires a single cleanup-category suggestion when lspErrorCount > 0', () => { + const out = buildSoloSuggestions( + quietActivity(), + { lspErrorCount: 7 }, + { now: FROZEN_NOW }, + ) + const lsp = out.find((s) => s.id === 'lsp-error') + expect(lsp).toBeDefined() + expect(lsp!.category).toBe('cleanup') + expect(lsp!.score).toBe(80) + expect(lsp!.title.params!.count).toBe(7) + expect(lsp!.taskPrompt.params!.count).toBe(7) + expect(lsp!.title.key).toBe('solo.suggest.lspError.title') + expect(lsp!.detail!.key).toBe('solo.suggest.lspError.detail') + expect(lsp!.taskPrompt.key).toBe('solo.suggest.lspError.taskPrompt') + }) + + it('outranks the other cleanup rules (todo / sync-upstream) via per-category dedup', () => { + const activity = quietActivity({ + git: { + branch: 'feat/x', + defaultBranch: 'main', + aheadCount: 0, + behindCount: 5, // would otherwise fire sync-upstream + dirtyCount: 0, + dirtyFiles: [], + }, + }) + const out = buildSoloSuggestions( + activity, + { + lspErrorCount: 3, + todoHits: [{ path: 'src/foo.ts', excerpt: 'TODO: x' }], + }, + { now: FROZEN_NOW }, + ) + const cleanup = out.filter((s) => s.category === 'cleanup') + expect(cleanup.length).toBe(1) + expect(cleanup[0]!.id).toBe('lsp-error') + }) +}) + diff --git a/src/server/services/soloSuggestions.ts b/src/server/services/soloSuggestions.ts index f65927ff..2b25736e 100644 --- a/src/server/services/soloSuggestions.ts +++ b/src/server/services/soloSuggestions.ts @@ -78,6 +78,15 @@ export type SoloSignalsTier1 = { } /** True when `.git/MERGE_HEAD` / `REBASE_HEAD` / `CHERRY_PICK_HEAD` exists. */ gitInProgress?: 'merge' | 'rebase' | 'cherry-pick' + /** + * Total LSP error count across the workspace (any positive integer + * triggers the cleanup suggestion). Sourced from + * `LspManager.getErrorCount(workspaceId)`. Undefined when LSP is + * unavailable, the prereq is missing, or the gather call threw — + * the suggestion silently sits this round out rather than nagging + * the user about a setup problem they didn't ask about. + */ + lspErrorCount?: number } export type SoloSuggestionCategory = @@ -465,7 +474,9 @@ const ruleStashRecover: RuleEmitter = ({ tier1 }) => { ] } -/** R8 — sync upstream. Lowest priority; only when truly nothing else. */ +/** + * R8 — sync upstream. Lowest priority; only when truly nothing else. + */ const ruleSyncUpstream: RuleEmitter = ({ activity }) => { const behind = activity.git?.behindCount ?? 0 if (behind <= 0) return [] @@ -485,6 +496,36 @@ const ruleSyncUpstream: RuleEmitter = ({ activity }) => { ] } +/** + * R10 — LSP error cleanup. Phase 5 of editor-lsp-foundation. + * + * Fires whenever `tier1.lspErrorCount` is a positive integer. Within + * the cleanup category this entry needs to outrank R7 (todo marker) + * and R8 (sync upstream) — the user being told to clear N type errors + * is the most actionable cleanup we can recommend, and the dedup pass + * keeps the highest-scoring cleanup entry. Score 80 is also above + * test-gap/ship/finish-wip/release base scores so it wins outright + * when LSP errors exist. + */ +const ruleLspError: RuleEmitter = ({ tier1 }) => { + const count = tier1.lspErrorCount + if (typeof count !== 'number' || !Number.isInteger(count) || count <= 0) return [] + return [ + { + id: 'lsp-error', + category: 'cleanup', + icon: 'error', + title: { key: 'solo.suggest.lspError.title', params: { count } }, + detail: { key: 'solo.suggest.lspError.detail' }, + taskPrompt: { + key: 'solo.suggest.lspError.taskPrompt', + params: { count }, + }, + score: 80, + }, + ] +} + /** * R9 — generic fallback. Always emitted. Filtered out at the end if * the user has at least one concrete suggestion, OR retained as the @@ -513,6 +554,7 @@ const RULES: ReadonlyArray = [ ruleReleaseMismatch, ruleStashRecover, ruleSyncUpstream, + ruleLspError, ruleGeneric, ]