Files
orca/src/shared/search-subprocess-lines.ts
T
Neil f107499e44 fix(lint): enable anti-slop/no-reflect-get (#20786)
`anti-slop/no-reflect-get` rejects every call to `Reflect.get`. The
reflective read bypasses ordinary property access and throws away the
type evidence the compiler would otherwise give you: the result is
`any`/`unknown` with no narrowing, so a typo in the key or a shape drift
in the source object is invisible until runtime. The rule's remedy is to
parse dynamic input into a named domain type (or narrow it with `in`)
and then read the field normally.

Baseline: 86 violations across 67 files. Now zero unsuppressed
violations under
`npx oxlint --config config/oxlint-anti-slop.json --ignore-pattern 'config/oxlint-plugins/anti-slop/**' src config tests mobile`.

Fix pattern
-----------
44 of the 86 were rewritten. The dominant shape was an `unknown` value
read through `Reflect.get` right after a `typeof === 'object'` guard;
those became `in`-narrowed property access, which TypeScript checks:

  - Reflect.get(value, 'agents')
  + 'agents' in value ? value.agents : null

Two further shapes:
- `Reflect.get(Object(x), 'k')` on a possibly-primitive envelope became a
  small named reader that boxes once and indexes a
  `Record<string, unknown>` (`settingsField` in
  mobile/src/transport/settings-read-operations.ts).
- Tests reaching into private state moved to TypeScript's checked
  bracket-index escape hatch (`runtime['layoutQueues']`), or to a
  documented read-only accessor on the owning class
  (`SearchSubprocessLineAccumulator.retainedCapacityBytes()`,
  `CodexSubagentExecutions.retentionSizes()`).

No type assertion was added anywhere: the diff contains zero net-new
`as` casts, `as any`, `as unknown as`, `@ts-ignore`, or
`@ts-expect-error`, so nothing was laundered into the sibling
assertion rules.

Suppressions
------------
42x `// oxlint-disable-next-line anti-slop/no-reflect-get` across 38
files. Every one is the default-forward branch of a `Proxy` `get` trap:

    get(target, property, receiver) {
      ...
      return Reflect.get(target, property, receiver)
    }

`Reflect.get(target, property, receiver)` is the only construct that
forwards with correct `receiver` semantics; `target[property]` invokes
an accessor with the wrong `this` and silently breaks getters that read
sibling state. There is no typed alternative, so these are suppressed
rather than rewritten.

3x `// oxlint-disable-next-line typescript-eslint/consistent-type-definitions
-- declaration merging requires interface` in
tests/e2e/github-url-smart-input-transition.spec.ts,
tests/e2e/linear-url-workspace-entry.spec.ts, and
tests/e2e/worktree-active-delete-scroll-position.spec.ts. Replacing
`Reflect.get(window, 'x')` with typed `window.x` requires a
`declare global { interface Window }` block, and `interface` is
mandatory for declaration merging. Matches the existing convention at
tests/e2e/helpers/runtime-types.ts:63.

1x `// eslint-disable-next-line no-var -- main-process gate handle for
this spec` in tests/e2e/project-group-creation-visibility.spec.ts, for
the same reason a `var` global is needed to type the handle. Matches
tests/e2e/agent-session-log-tail-stability.spec.ts:24.

Also updates two source-text anchors in mobile's rpc-recording mutation
harness (mobile/src/test-support/rpc-recording/operation-mutations.ts
and recording-runner.test.ts), which pin the exact text of the rewritten
line in settings-read-operations.ts and would otherwise fail with
"Mutant anchor matched 0 sites, expected 1".
2026-09-15 01:24:30 -07:00

95 lines
2.8 KiB
TypeScript

export const SEARCH_SUBPROCESS_MAX_LINE_BYTES = 64 * 1024 * 1024
const SEARCH_SUBPROCESS_INITIAL_LINE_BUFFER_BYTES = 4 * 1024
export class SearchSubprocessLineAccumulator {
private buffer: Buffer | null = null
private bytes = 0
constructor(private readonly maxLineBytes = SEARCH_SUBPROCESS_MAX_LINE_BYTES) {
if (!Number.isSafeInteger(maxLineBytes) || maxLineBytes < 0) {
throw new RangeError('Search line limit must be a non-negative safe integer')
}
}
push(rawChunk: Buffer | string, onLine: (line: string) => void): boolean {
// Three bytes per UTF-16 code unit bounds UTF-8 size without re-encoding complete batches.
if (
typeof rawChunk === 'string' &&
this.bytes === 0 &&
rawChunk.endsWith('\n') &&
rawChunk.length * 3 <= this.maxLineBytes
) {
const lines = rawChunk.split('\n')
lines.pop()
for (const line of lines) {
onLine(line)
}
return true
}
const chunk = Buffer.isBuffer(rawChunk) ? rawChunk : Buffer.from(rawChunk, 'utf8')
let cursor = 0
while (cursor < chunk.length) {
const newline = chunk.indexOf(0x0a, cursor)
const end = newline === -1 ? chunk.length : newline
const segmentBytes = end - cursor
if (this.bytes + segmentBytes > this.maxLineBytes) {
this.clear()
return false
}
if (newline !== -1 && this.bytes === 0) {
onLine(chunk.toString('utf8', cursor, end))
} else if (segmentBytes > 0) {
this.append(chunk.subarray(cursor, end))
if (newline !== -1) {
onLine(this.takeLine())
}
} else if (newline !== -1) {
onLine(this.takeLine())
}
if (newline === -1) {
return true
}
cursor = newline + 1
}
return true
}
finish(): string | null {
return this.bytes > 0 ? this.takeLine() : null
}
clear(): void {
this.buffer = null
this.bytes = 0
}
/** Capacity of the retained growable buffer, or null once it has been released. */
retainedCapacityBytes(): number | null {
return this.buffer?.length ?? null
}
private append(segment: Buffer): void {
const requiredBytes = this.bytes + segment.length
if (!this.buffer || this.buffer.length < requiredBytes) {
const doubledCapacity = this.buffer?.length ? this.buffer.length * 2 : 0
const nextCapacity = Math.min(
this.maxLineBytes,
Math.max(SEARCH_SUBPROCESS_INITIAL_LINE_BUFFER_BYTES, doubledCapacity, requiredBytes)
)
const next = Buffer.allocUnsafe(nextCapacity)
this.buffer?.copy(next, 0, 0, this.bytes)
this.buffer = next
}
segment.copy(this.buffer, this.bytes)
this.bytes = requiredBytes
}
private takeLine(): string {
const line = this.buffer?.toString('utf8', 0, this.bytes) ?? ''
this.clear()
return line
}
}