Files
orca/src/relay/fs-handler-utils.ts
NeilandOrca 46646d7ff1 chore(lint): upgrade oxlint to 1.71 + enable 7 new rules (autofixed backlog) (#6841)
* chore(lint): upgrade oxlint to 1.71 and enable 7 new rules

Upgrade oxlint 1.67.0 -> 1.71.0 (1.72 was blocked by the repo's 3-day
minimum-release-age supply-chain guard; nothing here needs it). The
bump is a no-op on the existing config.

Enable 3 error rules (backlog autofixed to zero in this commit) and
4 warn rules (surface signal without gating CI):

error (autofixed, behavior-preserving):
- unicorn/prefer-node-protocol        (~1531 sites: bare builtin -> node:)
- typescript/no-import-type-side-effects (~36: all-inline-type -> import type)
- unicorn/no-array-reverse            (19: copy-then-reverse -> toReversed)

warn (real signal, current fires are test-only/correct):
- unicorn/no-array-fill-with-reference-type  (aliasing footgun guard)
- typescript/no-unsafe-function-type         (bans bare Function type)
- unicorn/prefer-array-flat-map              (map().flat() -> flatMap())
- unicorn/prefer-regexp-test                 (.match() in bool ctx -> .test())

mobile/.oxlintrc.json extends root, so it inherits all 7; the autofix
ran from root and covered mobile/ too.

Verification (all green): oxlint 0 errors (root+mobile+aux configs),
oxfmt clean, typecheck (node+cli+web), vitest 22795 passed / 0 failed,
builds (electron-vite + web + cli) succeed. node: rewrites confirmed to
skip embedded SSH/CLI string payloads (AST-only); all toReversed sites
verified to operate on fresh copies or write-once locals.

* chore(lint): bump mobile oxlint to 1.71 so inherited rules parse

mobile/ is a standalone pnpm project pinning its own oxlint@1.67, which
lacks unicorn/no-array-fill-with-reference-type (needs >=1.70). Since
mobile/.oxlintrc.json extends the root config, mobile CI's 'cd mobile &&
oxlint' failed to parse the new rule. Bump mobile to match root (1.71).

Verified in mobile/: oxlint 0 errors, oxfmt --check clean, tsc --noEmit
pass, vitest 978 passed / 0 failed.

Co-authored-by: Orca <help@stably.ai>

---------

Co-authored-by: Orca <help@stably.ai>
2026-06-29 22:38:29 -07:00

223 lines
7.5 KiB
TypeScript

/**
* Pure helpers and child-process search utilities extracted from fs-handler.ts.
*
* Why: oxlint max-lines requires .ts files to stay under 300 lines.
* These functions depend only on their arguments (plus `rg` being on PATH),
* so they are straightforward to test independently.
*/
import { spawn, execFile } from 'node:child_process'
import { open } from 'node:fs/promises'
import {
buildRgArgs,
createAccumulator,
finalize,
ingestRgJsonLine,
SEARCH_TIMEOUT_MS as SHARED_SEARCH_TIMEOUT_MS
} from '../shared/text-search'
import { IMAGE_FILE_MIME_TYPES } from '../shared/image-file-extensions'
import type { SearchResult as SharedSearchResult } from '../shared/types'
// ─── Constants ───────────────────────────────────────────────────────
// Why: remote reads still travel through bounded JSON-RPC frames, but matching
// the old 5MB search cap would block common JSON/log files before Monaco's
// large-file optimizations can handle them.
export const MAX_TEXT_FILE_SIZE = 10 * 1024 * 1024
// Why: matches the local cap (src/main/ipc/filesystem.ts MAX_PREVIEWABLE_BINARY_SIZE).
// Reads above the legacy 16MB single-frame budget go through fs.readFileStream,
// which chunks at STREAM_CHUNK_SIZE; see docs/relay-file-stream-design.md.
export const MAX_PREVIEWABLE_BINARY_SIZE = 50 * 1024 * 1024
export const BINARY_PROBE_BYTES = 8192
export const SEARCH_TIMEOUT_MS = SHARED_SEARCH_TIMEOUT_MS
export const DEFAULT_MAX_RESULTS = 2000
export const IMAGE_MIME_TYPES: Record<string, string> = {
...IMAGE_FILE_MIME_TYPES,
'.pdf': 'application/pdf'
}
// ─── Binary detection ────────────────────────────────────────────────
export function isBinaryBuffer(buffer: Buffer): boolean {
const len = Math.min(buffer.length, 8192)
for (let i = 0; i < len; i++) {
if (buffer[i] === 0) {
return true
}
}
return false
}
export async function isBinaryFilePrefix(filePath: string): Promise<boolean> {
const handle = await open(filePath, 'r')
try {
const probe = Buffer.alloc(BINARY_PROBE_BYTES)
const { bytesRead } = await handle.read(probe, 0, probe.length, 0)
return isBinaryBuffer(probe.subarray(0, bytesRead))
} finally {
await handle.close()
}
}
// ─── Search types ────────────────────────────────────────────────────
export type SearchOptions = {
caseSensitive?: boolean
wholeWord?: boolean
useRegex?: boolean
includePattern?: string
excludePattern?: string
maxResults: number
}
export type SearchResult = SharedSearchResult
// ─── rg-based search ─────────────────────────────────────────────────
/**
* Run ripgrep (`rg`) with JSON output to collect text matches.
*
* Why `spawn` and not `execFile`: `execFile` buffers stdout internally and
* kills the child when `maxBuffer` is exceeded, even when 'data' listeners
* are attached. Under rg's verbose `--json` output, a 50MB buffer fills
* well before the match cap in large folders, and `execFile`'s silent
* buffer-exceeded error resolves the result as `truncated: false` despite
* dropping matches. See docs/design/share-text-search.md.
*/
export function searchWithRg(
rootPath: string,
query: string,
opts: SearchOptions
): Promise<SearchResult> {
return new Promise((resolve) => {
const rgArgs = buildRgArgs(query, rootPath, opts)
const acc = createAccumulator()
let buffer = ''
let resolved = false
// Why: spawn can throw synchronously on invalid options (e.g. bad cwd),
// which would leak out of the `new Promise` executor and leave the
// promise forever pending. Treat a synchronous throw as a clean
// "no results" fallback, the same way an async 'error' event is handled.
let child: ReturnType<typeof spawn>
try {
child = spawn('rg', rgArgs, {
cwd: rootPath,
stdio: ['ignore', 'pipe', 'pipe']
})
} catch {
resolve(finalize(acc))
return
}
let killTimeout: ReturnType<typeof setTimeout>
function resolveOnce(): void {
if (resolved) {
return
}
resolved = true
clearTimeout(killTimeout)
// Why: child.kill() is advisory over SSH; detach listeners if the
// process ignores timeout kill so old searches cannot retain closures.
child.stdout!.off('data', handleStdoutData)
child.stderr!.off('data', handleStderrData)
child.off('error', handleError)
child.off('close', handleClose)
resolve(finalize(acc))
}
function processLine(line: string): void {
const verdict = ingestRgJsonLine(line, rootPath, acc, opts.maxResults)
if (verdict === 'stop') {
child.kill()
}
}
function handleStdoutData(chunk: string): void {
buffer += chunk
const lines = buffer.split('\n')
buffer = lines.pop() ?? ''
for (const line of lines) {
processLine(line)
}
}
function handleStderrData(): void {
/* drain */
}
function handleError(): void {
resolveOnce()
}
function handleClose(): void {
if (buffer) {
processLine(buffer)
}
resolveOnce()
}
child.stdout!.setEncoding('utf-8')
child.stdout!.on('data', handleStdoutData)
child.stderr!.on('data', handleStderrData)
child.once('error', handleError)
child.once('close', handleClose)
killTimeout = setTimeout(() => {
acc.truncated = true
child.kill()
resolveOnce()
}, SEARCH_TIMEOUT_MS)
})
}
// ─── rg availability check ──────────────────────────────────────────
// Why no cache: `rg --version` is a sub-10ms local spawn, and caching the
// result caused a footgun — a negative cache persisted across rg installs
// (forcing a relay restart), while a positive cache could mask an rg that
// was uninstalled or broken mid-session. The `settled` flag below closes
// the original race between 'error' and 'close' that the cache was added
// to paper over, so re-checking per call is both simpler and safer.
const RG_AVAILABILITY_TIMEOUT_MS = 5000
export function checkRgAvailable(): Promise<boolean> {
return new Promise((resolve) => {
let settled = false
const child = execFile('rg', ['--version'])
let timeout: ReturnType<typeof setTimeout> | null = null
const cleanup = (): void => {
if (timeout) {
clearTimeout(timeout)
timeout = null
}
child.off('error', onError)
child.off('close', onClose)
}
const settle = (available: boolean, options?: { kill?: boolean }): void => {
if (settled) {
return
}
settled = true
cleanup()
if (options?.kill) {
child.kill()
}
resolve(available)
}
const onError = (): void => settle(false)
const onClose = (code: number | null): void => settle(code === 0)
child.once('error', onError)
child.once('close', onClose)
timeout = setTimeout(() => settle(false, { kill: true }), RG_AVAILABILITY_TIMEOUT_MS)
if (typeof timeout.unref === 'function') {
timeout.unref()
}
})
}
// Moved to fs-handler-list-files.ts to keep this file under 300 lines (oxlint)
export { listFilesWithRg, LIST_FILES_TIMEOUT_MS } from './fs-handler-list-files'