fix: tmp 目录改用 os.tmpdir() + ripgrep 缺失时自动 fallback 系统 rg

1. Shell.ts / imagePaste.ts / filesystem.ts: Linux/macOS 默认 tmp 路径
   从硬编码 '/tmp' 改为 os.tmpdir(),自动适配 Termux/Android 等无 /tmp
   的环境;macOS 桌面零变化;CLAUDE_CODE_TMPDIR 仍优先级最高。

2. ripgrep.ts: builtin rg 二进制缺失时(Android/Termux、不完整安装)
   自动 fallback 到 PATH 上的系统 rg,通过 note 字段携带人读提示;
   /doctor 渲染 note;init 启动时写一行 stderr warning。

Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win>
This commit is contained in:
claude-code-best
2026-06-15 16:15:25 +08:00
parent 2714bbf812
commit 9d6a98dd06
10 changed files with 786 additions and 9 deletions

View File

@@ -0,0 +1,492 @@
# Ripgrep System Fallback Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Make `getRipgrepConfig()` automatically fall back to system `rg` on `PATH` when the builtin/bundled ripgrep is missing (e.g. on Android/Termux), and surface the fallback via `/doctor` plus a one-time startup warning.
**Architecture:** Add an `existsSync` check on the builtin rg path before returning it. If missing, query `findExecutable('rg', [])`; if found, use system rg with a new human-readable `note` field on `RipgrepConfig` / `getRipgrepStatus()`. Consumers (`/doctor`, startup) read `note` and render it. No new modes — `mode` stays `'system' | 'builtin' | 'embedded'`; `note` carries the fallback narrative.
**Tech Stack:** TypeScript, Bun runtime, `bun:test`, Biome, lodash `memoize`.
**Spec:** `docs/superpowers/specs/2026-06-15-ripgrep-system-fallback-design.md`
---
## File Structure
- **Modify** `src/utils/ripgrep.ts` — extend `RipgrepConfig` type with `note?`; extend internal `ripgrepStatus` singleton with `note?`; extend `getRipgrepStatus()` return type with `note?`; rewrite the `builtin` branch of `getRipgrepConfig()` to add `existsSync` + system-rg fallback; sync `note` into the singleton inside `testRipgrepOnFirstUse`.
- **Create** `src/utils/__tests__/ripgrepConfig.test.ts` — five-branch decision coverage for `getRipgrepConfig()`.
- **Modify** `src/utils/doctorDiagnostic.ts` — propagate `note` from `getRipgrepStatus()` into the diagnostic object.
- **Modify** `src/screens/Doctor.tsx` — render `note` in the `Search:` line.
- **Modify** `src/entrypoints/init.ts` — emit a one-time stderr warning when `note` is set.
Each file has a single clear responsibility and changes stay inside that file's existing role.
---
## Task 1: Extend types with optional `note` field (no behavior change)
**Files:**
- Modify: `src/utils/ripgrep.ts:22-27` (type), `:29-63` (function — minimal shape only), `:523-527` (singleton), `:533-544` (public getter)
This task only adds the optional field everywhere it's needed and populates it with `undefined` for existing branches. Behavior stays identical. Task 2 fills in the real values.
- [ ] **Step 1: Extend `RipgrepConfig` type**
File: `src/utils/ripgrep.ts`, replace lines 22-27.
```ts
type RipgrepConfig = {
mode: 'system' | 'builtin' | 'embedded'
command: string
args: string[]
argv0?: string
note?: string
}
```
- [ ] **Step 2: Extend the `ripgrepStatus` singleton shape**
File: `src/utils/ripgrep.ts`, replace lines 522-527.
```ts
// Singleton to store ripgrep availability status
let ripgrepStatus: {
working: boolean
lastTested: number
config: RipgrepConfig
note?: string
} | null = null
```
- [ ] **Step 3: Extend `getRipgrepStatus()` return type**
File: `src/utils/ripgrep.ts`, replace lines 533-544.
```ts
/**
* Get ripgrep status and configuration info
* Returns current configuration immediately, with working status if available
*/
export function getRipgrepStatus(): {
mode: 'system' | 'builtin' | 'embedded'
path: string
working: boolean | null // null if not yet tested
note?: string
} {
const config = getRipgrepConfig()
return {
mode: config.mode,
path: config.command,
working: ripgrepStatus?.working ?? null,
note: ripgrepStatus?.note ?? config.note,
}
}
```
- [ ] **Step 4: Verify typecheck**
Run: `bunx tsc --noEmit`
Expected: 0 errors. (All `note` fields are optional; existing code is unaffected.)
- [ ] **Step 5: Commit**
```bash
git add src/utils/ripgrep.ts
git commit -m "refactor: add optional note field to RipgrepConfig and getRipgrepStatus"
```
---
## Task 2: Implement fallback decision in `getRipgrepConfig()` (TDD)
**Files:**
- Modify: `src/utils/ripgrep.ts:1-20` (imports), `:56-63` (builtin branch)
- Test: `src/utils/__tests__/ripgrepConfig.test.ts`
- [ ] **Step 1: Write the failing test file**
Create `src/utils/__tests__/ripgrepConfig.test.ts` with this exact content:
```ts
import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test'
// Mock shared side-effect modules. log.ts pulls in bootstrap/state which has
// realpathSync side effects at import time. See project CLAUDE.md "Mock 使用规范".
mock.module('src/utils/log.ts', () => ({
logError: () => {},
logEvent: () => {},
}))
mock.module('src/utils/debug.ts', () => ({
logForDebugging: () => {},
}))
// Overridable fakes. Defaults match the "builtin exists" happy path on the
// runner's actual platform (no process.platform override — avoids polluting
// other tests in the same Bun process, see CLAUDE.md mock contamination note).
let fakeExistsSync = (): boolean => true
let fakeWhich: string | null = '/usr/local/bin/rg'
let fakeBundled = false
mock.module('node:fs', () => ({
existsSync: (p: string) => fakeExistsSync(p),
realpathSync: (p: string) => p,
constants: {},
}))
mock.module('src/utils/which.ts', () => ({
whichSync: () => fakeWhich,
}))
mock.module('src/utils/bundledMode.ts', () => ({
isInBundledMode: () => fakeBundled,
}))
mock.module('src/utils/envUtils.ts', () => ({
isEnvDefinedFalsy: (v: string | undefined) =>
v !== undefined &&
['0', 'false', 'no', 'off'].includes(v.toLowerCase().trim()),
isEnvTruthy: (v: string | undefined) =>
v !== undefined &&
['1', 'true', 'yes', 'on'].includes(v.toLowerCase().trim()),
}))
mock.module('src/utils/distRoot.ts', () => ({
distRoot: '/fake/dist',
}))
mock.module('os', () => ({
homedir: () => '/fake/home',
tmpdir: () => '/tmp',
}))
// Disable memoize so each call re-evaluates with current fakes.
mock.module('lodash-es/memoize.js', () => ({
default: <T extends (...args: any[]) => any>(fn: T): T => fn,
}))
const { getRipgrepConfig } = await import('../ripgrep.ts')
describe('getRipgrepConfig', () => {
const originalEnv = { ...process.env }
beforeEach(() => {
fakeExistsSync = () => true
fakeWhich = '/usr/local/bin/rg'
fakeBundled = false
delete process.env.USE_BUILTIN_RIPGREP
})
afterEach(() => {
process.env = { ...originalEnv }
})
test('USE_BUILTIN_RIPGREP=0 with system rg -> mode=system, no note', () => {
process.env.USE_BUILTIN_RIPGREP = '0'
const cfg = getRipgrepConfig()
expect(cfg.mode).toBe('system')
expect(cfg.command).toBe('rg')
expect(cfg.note).toBeUndefined()
})
test('bundled mode -> mode=embedded, no note', () => {
fakeBundled = true
const cfg = getRipgrepConfig()
expect(cfg.mode).toBe('embedded')
expect(cfg.note).toBeUndefined()
})
test('builtin path exists -> mode=builtin, no note', () => {
fakeExistsSync = () => true
const cfg = getRipgrepConfig()
expect(cfg.mode).toBe('builtin')
expect(cfg.note).toBeUndefined()
})
test('builtin missing + system rg available -> mode=system, note set', () => {
fakeExistsSync = () => false
fakeWhich = '/usr/local/bin/rg'
const cfg = getRipgrepConfig()
expect(cfg.mode).toBe('system')
expect(cfg.command).toBe('rg')
expect(typeof cfg.note).toBe('string')
expect(cfg.note).toContain('fallback')
// Note contains process.platform verbatim — assert the substring shape,
// not a specific platform, so the test is portable.
expect(cfg.note).toMatch(/builtin rg unavailable on \w+, using system rg/)
})
test('builtin missing + system rg missing -> mode=builtin, note set', () => {
fakeExistsSync = () => false
fakeWhich = null
const cfg = getRipgrepConfig()
expect(cfg.mode).toBe('builtin')
expect(typeof cfg.note).toBe('string')
expect(cfg.note).toContain('no ripgrep available')
})
})
```
- [ ] **Step 2: Run test to verify it fails**
Run: `bun test src/utils/__tests__/ripgrepConfig.test.ts`
Expected: The fourth and fifth tests FAIL — currently `getRipgrepConfig()` returns `mode='builtin'` with no `note` when the builtin path is missing, instead of falling back to system rg.
- [ ] **Step 3: Add `existsSync` import to `ripgrep.ts`**
File: `src/utils/ripgrep.ts`, replace lines 1-2.
```ts
import type { ChildProcess, ExecFileException } from 'child_process'
import { execFile, spawn } from 'child_process'
import { existsSync } from 'fs'
```
- [ ] **Step 4: Rewrite the builtin branch with fallback logic**
File: `src/utils/ripgrep.ts`, replace lines 56-63.
```ts
const rgRoot = path.resolve(__dirname, 'vendor', 'ripgrep')
const command =
process.platform === 'win32'
? path.resolve(rgRoot, `${process.arch}-win32`, 'rg.exe')
: path.resolve(rgRoot, `${process.arch}-${process.platform}`, 'rg')
// Builtin binary missing (e.g. Android/Termux, or incomplete install).
// Fall back to system rg on PATH. If neither is available, keep the
// (nonexistent) builtin path so upper layers still see ENOENT, but
// surface a human-readable note so /doctor and startup can explain.
if (!existsSync(command)) {
const { cmd: systemPath } = findExecutable('rg', [])
if (systemPath !== 'rg') {
return {
mode: 'system',
command: 'rg',
args: [],
note: `fallback: builtin rg unavailable on ${process.platform}, using system rg`,
}
}
return {
mode: 'builtin',
command,
args: [],
note: `no ripgrep available on ${process.platform}; install via apt/pkg/brew or set USE_BUILTIN_RIPGREP=0`,
}
}
return { mode: 'builtin', command, args: [] }
})
```
- [ ] **Step 5: Run test to verify it passes**
Run: `bun test src/utils/__tests__/ripgrepConfig.test.ts`
Expected: PASS (5/5).
- [ ] **Step 6: Run full precheck to ensure no regression**
Run: `bun run precheck`
Expected: 0 typecheck errors, 0 lint errors, all tests pass.
- [ ] **Step 7: Commit**
```bash
git add src/utils/ripgrep.ts src/utils/__tests__/ripgrepConfig.test.ts
git commit -m "feat: ripgrep falls back to system rg when builtin binary missing"
```
---
## Task 3: Sync `note` into the singleton inside `testRipgrepOnFirstUse`
**Files:**
- Modify: `src/utils/ripgrep.ts:549-615`
Currently `testRipgrepOnFirstUse` writes `ripgrepStatus = { working, lastTested, config }` without `note`. The new `getRipgrepStatus()` in Task 1 already falls back to `config.note` if the singleton has none, so this task is mostly belt-and-suspenders: persist the note explicitly so consumers reading the singleton directly also see it.
- [ ] **Step 1: Update the success-path assignment**
File: `src/utils/ripgrep.ts`, replace lines 592-596.
```ts
ripgrepStatus = {
working,
lastTested: Date.now(),
config,
note: config.note,
}
```
- [ ] **Step 2: Update the catch-path assignment**
File: `src/utils/ripgrep.ts`, replace lines 608-612.
```ts
ripgrepStatus = {
working: false,
lastTested: Date.now(),
config,
note: config.note,
}
```
- [ ] **Step 3: Run precheck**
Run: `bun run precheck`
Expected: 0 errors.
- [ ] **Step 4: Commit**
```bash
git add src/utils/ripgrep.ts
git commit -m "refactor: persist ripgrep config.note in testRipgrepOnFirstUse singleton"
```
---
## Task 4: Propagate `note` through `/doctor`
**Files:**
- Modify: `src/utils/doctorDiagnostic.ts:588-597`
- Modify: `src/screens/Doctor.tsx:224-232`
- [ ] **Step 1: Extend the diagnostic object**
File: `src/utils/doctorDiagnostic.ts`, replace lines 588-597.
```ts
// Get ripgrep status and configuration info
const ripgrepStatusRaw = getRipgrepStatus()
// Provide simple ripgrep status info
const ripgrepStatus = {
working: ripgrepStatusRaw.working ?? true, // Assume working if not yet tested
mode: ripgrepStatusRaw.mode,
systemPath:
ripgrepStatusRaw.mode === 'system' ? ripgrepStatusRaw.path : null,
note: ripgrepStatusRaw.note ?? null,
}
```
- [ ] **Step 2: Render `note` in Doctor.tsx**
File: `src/screens/Doctor.tsx`, replace lines 224-232.
```tsx
<Text>
Search: {diagnostic.ripgrepStatus.working ? 'OK' : 'Not working'} (
{diagnostic.ripgrepStatus.mode === 'embedded'
? 'bundled'
: diagnostic.ripgrepStatus.mode === 'builtin'
? 'vendor'
: diagnostic.ripgrepStatus.systemPath || 'system'}
)
</Text>
{diagnostic.ripgrepStatus.note && (
<Text color="warning">
Note: {diagnostic.ripgrepStatus.note}
</Text>
)}
```
- [ ] **Step 3: Run precheck (lint + typecheck)**
Run: `bun run precheck`
Expected: 0 errors.
- [ ] **Step 4: Manual smoke check (optional)**
Run: `bun run dev -- doctor 2>&1 | grep -i search`
Expected: prints the `Search:` line; on dev machine `note` should be empty so no `Note:` line appears.
- [ ] **Step 5: Commit**
```bash
git add src/utils/doctorDiagnostic.ts src/screens/Doctor.tsx
git commit -m "feat: /doctor shows ripgrep fallback note"
```
---
## Task 5: Emit one-time startup warning from `init.ts`
**Files:**
- Modify: `src/entrypoints/init.ts:240-243`
- [ ] **Step 1: Add the warning right before `profileCheckpoint('init_function_end')`**
File: `src/entrypoints/init.ts`, replace lines 240-243.
```ts
// Surface ripgrep fallback (e.g. Android/Termux) once per session.
// Goes to stderr so it doesn't corrupt pipe-mode (`-p`) stdout.
try {
const { getRipgrepStatus } = await import('../utils/ripgrep.js')
const status = getRipgrepStatus()
if (status.note) {
process.stderr.write(`[ripgrep] ${status.note}\n`)
}
} catch {
// Ripgrep status is best-effort; never block init.
}
logForDiagnosticsNoPII('info', 'init_completed', {
duration_ms: Date.now() - initStartTime,
})
profileCheckpoint('init_function_end')
```
- [ ] **Step 2: Run precheck**
Run: `bun run precheck`
Expected: 0 errors.
- [ ] **Step 3: Manual smoke check**
Simulate fallback by pointing vendor at a missing path is non-trivial; instead verify no warning fires on the dev machine (where builtin exists):
Run: `bun run dev -- --version`
Expected: `[ripgrep]` line does NOT appear on stderr.
- [ ] **Step 4: Commit**
```bash
git add src/entrypoints/init.ts
git commit -m "feat: warn on stderr when ripgrep falls back to system rg"
```
---
## Task 6: Final full precheck + verification
**Files:** None (verification only)
- [ ] **Step 1: Run full precheck**
Run: `bun run precheck`
Expected: `XXXX pass / 0 fail`, 0 typecheck errors, 0 lint errors.
- [ ] **Step 2: Verify the five-branch test still passes**
Run: `bun test src/utils/__tests__/ripgrepConfig.test.ts`
Expected: PASS (5/5).
- [ ] **Step 3: Verify decision logic via REPL sanity (optional)**
Run:
```bash
bun -e "import('./src/utils/ripgrep.ts').then(m => console.log(JSON.stringify(m.getRipgrepStatus(), null, 2)))"
```
Expected on macOS dev machine: `mode: "builtin"`, `note: undefined`.
---
## Self-Review Notes
**Spec coverage:**
- Decision chain with 5 branches → Task 2 ✓
- `note` field on `RipgrepConfig` / singleton / `getRipgrepStatus()` → Tasks 1, 3 ✓
- `/doctor` rendering → Task 4 ✓
- Startup warning → Task 5 ✓
- Tests for 5 branches → Task 2 Step 1 ✓
- Acceptance criteria 1-5 cross-checked against spec section "Acceptance Criteria"
**Placeholder scan:** None. Each step contains the exact code or command.
**Type consistency:** `note?: string` consistently used across `RipgrepConfig`, `ripgrepStatus` singleton, `getRipgrepStatus()` return, `doctorDiagnostic.ripgrepStatus.note`. In Doctor.tsx the diagnostic object's `note` is `string | null` (Task 4 Step 1 uses `?? null`), accessed with a truthy check (`{note && ...}`) which handles both `null` and `undefined`.
**Mock hygiene note:** Task 2's test mocks `node:fs`, `src/utils/which.ts`, `src/utils/bundledMode.ts`, `src/utils/envUtils.ts`, `src/utils/distRoot.ts`, `os`, and `lodash-es/memoize.js`. These are process-global mocks (Bun limitation — see project CLAUDE.md "Mock 使用规范"). The test file lives at `src/utils/__tests__/ripgrepConfig.test.ts` and there is no existing `ripgrep.test.ts` to collide with, so no contamination risk.