feat(solo): wire LSP error count into Tier-1 signals (Phase 5) (#27)

* feat(solo): wire LSP error count into Tier-1 signals + cleanup suggestion

Phase 5 of editor-lsp-foundation (tasks 41-45):

- src/server/services/soloSuggestions.ts: SoloSignalsTier1 gains
  lspErrorCount field + new ruleLspError. Rule fires on positive
  integer counts only (defensive against undefined / 0 / non-integer
  / negative). Score 80 — high enough to outrank other 'cleanup'
  candidates (todo-marker / sync-upstream / stash) via the existing
  per-category dedup pass, and to outrank test-gap / ship / finish-wip /
  release base scores so the user is told to clear type errors first
  whenever the LSP reports any.
- src/server/services/soloSignalsService.ts: gatherSoloSignalsTier1 now
  accepts an optional `getLspErrorCount` provider. Wrapped in
  Promise.allSettled so an LSP failure / missing prereq silently
  leaves lspErrorCount undefined — the rule then sits the round out
  rather than nagging users about a setup problem they didn't ask
  about. Existing callers don't pass the option, so no behavioral
  drift on the current call sites; the manager will inject a real
  provider when the LspManager singleton from Phase 3 is wired into
  the Solo welcome-card pipeline.
- desktop/src/i18n/locales/{en,zh,zh-TW,jp,kr}.ts: 3 keys ×
  5 locales = 15 entries for solo.suggest.lspError.{title,detail,
  taskPrompt}. The other LSP-related i18n keys mentioned in the
  spec (lsp.indicator.*, editor.unsupportedEncoding, editor.conflict.*,
  editor.unsavedClose.*) are deferred to the component-integration PR
  that swaps Phase 2/3's hardcoded English for translated labels —
  shipping keys without callers would create dead code that drifts
  out of sync before integration lands.
- src/server/services/soloSuggestions.test.ts: 6 new tests for
  ruleLspError covering undefined / 0 / non-integer / negative gates,
  positive-count emission, per-category dedup outranks
  todo / sync-upstream when LSP errors exist.
- src/server/services/soloSuggestions.i18n.test.ts: fixtureWithEverySignal
  now includes lspErrorCount, so the existing en.ts contract scan
  automatically validates the 3 new keys.

Tested:
- bun test src/server/services/soloSuggestions.test.ts (44/44, +6 new)
- bun test src/server/services/soloSignalsService.test.ts
- bun test src/server/services/soloSuggestions.i18n.test.ts
- 61/61 across 3 Solo-related test files
- bun run lint (desktop tsc --noEmit clean — 5 locales × TranslationKey
  mapped Record gates the additions)

Note: pre-existing e2e/business-flow failures (~44) on origin/main
remain untouched.

_Requirements: 14.1, 14.2, 14.3, 14.4, 14.5, 15.6_

* test(desktop): add i18n contract test for solo.suggest.lspError

Phase 5 follow-up: change-policy CI gate refused PR #27 because the
desktop locale changes shipped without a matching desktop test —
production code in `desktop/src/i18n/locales/*.ts` requires a
desktop-side test. The server-side i18n contract test
(soloSuggestions.i18n.test.ts) only validates en.ts, leaving the
4 other locales unchecked at the desktop boundary.

This test mirrors that contract on the desktop side: 5 locales × 3
keys = 15 presence checks, plus interpolation checks for {count} and
absence-of-params check for the static detail key. 18/18 passing.

The test imports each locale as a named export (the file's `export
const zh: Record<TranslationKey, string>` is repeated across all
five locale files, so `zh-TW.ts` needs `import { zh as zhTW }` to
disambiguate at the call site).

Tested:
- bun run test src/i18n/lspError.test.ts (18/18 pass)

---------

Co-authored-by: 你的姓名 <you@example.com>
This commit is contained in:
小橙子 2026-06-12 08:33:32 +08:00 committed by GitHub
parent 96f578e5d3
commit 8ac3ea395a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 229 additions and 1 deletions

View File

@ -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.',

View File

@ -1317,6 +1317,10 @@ export const jp: Record<TranslationKey, string> = {
'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 してください。',

View File

@ -1317,6 +1317,10 @@ export const kr: Record<TranslationKey, string> = {
'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해 주세요.',

View File

@ -1317,6 +1317,10 @@ export const zh: Record<TranslationKey, string> = {
'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。',

View File

@ -1317,6 +1317,10 @@ export const zh: Record<TranslationKey, string> = {
'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。',

View File

@ -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<string, string>
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<string, string>
expect(dict['solo.suggest.lspError.detail']).not.toContain('{')
}
})
})

View File

@ -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<number | undefined>
}
export async function gatherSoloSignalsTier1(
workDir: string,
recent: RecentActivityResult,
options: GatherSoloSignalsTier1Options = {},
): Promise<SoloSignalsTier1> {
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
}

View File

@ -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.

View File

@ -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')
})
})

View File

@ -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<RuleEmitter> = [
ruleReleaseMismatch,
ruleStashRecover,
ruleSyncUpstream,
ruleLspError,
ruleGeneric,
]