mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-27 15:13:37 +08:00
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:
parent
96f578e5d3
commit
8ac3ea395a
@ -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.',
|
||||
|
||||
@ -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 してください。',
|
||||
|
||||
@ -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해 주세요.',
|
||||
|
||||
@ -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。',
|
||||
|
||||
@ -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。',
|
||||
|
||||
65
desktop/src/i18n/lspError.test.ts
Normal file
65
desktop/src/i18n/lspError.test.ts
Normal 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('{')
|
||||
}
|
||||
})
|
||||
})
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@ -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,
|
||||
]
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user