Uh oh!
There was an error while loading. Please reload this page.
Hoist the shared grep-search modules into src/core/search/ and publish them as ./core/search - #876
Conversation
LLP 0264 #shared requires the client and the server to agree byte for byte on what grep search looks at and what a hit is: "zero hits" only means something if both sides searched the same columns. The server already depends on this package, but the exports map had nothing search-shaped for it to import through, so the shared pieces had no home and two copies were the only option. Adds src/core/search/ with the searchable-column allowlist and the brute-scan projection, the literal/regex matcher (test / locate / rowTest) with the snippet-window constants and the window cut itself, and the GrepSearchHit / GrepSearchResult / GrepSearchMatcher shapes as a .d.ts. Semantics are the server's originals unchanged, so its follow-up import swap is a no-op diff rather than a port. package.json publishes them as ./core/search, shaped exactly like ./core/query. Nothing imports these yet: T4 (the local grep service) and T5 (the verb) are what use them, and no runtime behavior changes here. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…apes importable Review round on #876 found four actionable defects in the hoisted grep-search modules. All four are fixed here, none change what the modules are for. - `tool_args` is a JSON column (iceberg `variant`), so it reads back from parquet as an object. `anySearchableCell` gated on `typeof value === 'string'`, so a column named in the shared allowlist could never produce a hit while still being decoded on every brute scan. A shared `cellText` renders a cell before testing, matching the parsed-or-string handling `parseMaybeJson` already applies to `tool_args` elsewhere in the contract. - LLP 0264 #shared hoists the `GrepSearchHit` / `GrepSearchResult` shapes for the server, but an exports map blocks every subpath it does not name, so no consumer could reach them. New `./core/search/types.js` entry, pointing at the hand-written declaration in `src/` (tsc does not copy a `.d.ts` input into the generated `types/` tree). - `new RegExp(query, 'i')` was unguarded, so `grep --regex '('` escaped a raw SyntaxError from the function whose docblock promises validation lives there. It now throws the same shape as the empty/oversized refusals. - Literal mode located its match in `value.toLowerCase()` and sliced the original value at that offset, so a character whose lowercase form is longer shifted the snippet window; it also copied every cell up to three times per row. Literal mode now compiles an escaped case-insensitive regex, which fixes the offsets and drops the copies. `makeSnippet` also nudges its edges off a surrogate pair. Tests cover each: malformed-regex refusal, literal metacharacters, offsets under a length-changing lowercase, object and string `tool_args` hits with the exclusion still holding, `cellText` shapes, a well-formed snippet across astral characters, and the new exports entry. npm test green (4508 pass / 0 fail / 1 skipped), npm run typecheck clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
philcunliffe
commented
Aug 19, 2026
Neutral review round: PR #876 @ |
…ay, seal the allowlist Three defects found reviewing the hoist, each reproduced against the shipped code: - makeSnippet added the raw match length to the window, so a regex whose match has no bounded width (`.*needle.*`, what an rg-trained user types) returned the whole cell: 4,000,006 chars for a 4MB body, against a doc that promises a bounded window. The matched run is now clamped to SNIPPET_AFTER before the window opens. - `rowTest` decoded a cell through `cellText` while `test`/`locate`/ `makeSnippet` took the raw value, so the natural consumer loop reported a hit with no matched columns, or threw on `value.slice`, for exactly the column the row matched through. All four entry points now render the cell the same way, and the shared interface widens to `unknown`. - `cellText` rendered a JSON cell with JSON.stringify, whose text carries the escapes rather than the characters: a literal query for a Windows path or a multi-line shell command missed the tool call it names. A decoded cell is now walked to its keys and primitive leaves. SEARCHABLE_COLUMNS is also sealed. SCAN_COLUMNS is a load-time snapshot of it, so a caller mutating the exported Set made a column searchable while the brute scan never decoded it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
philcunliffe
commented
Aug 19, 2026
Review round: |
philcunliffe
commented
Aug 19, 2026
Triage: residual findings at |
Uh oh!
There was an error while loading. Please reload this page.
Root cause
LLP 0264 #shared requires the client and the server to agree byte for byte on what grep search looks at and what a hit is: "zero hits" only means something if both sides searched the same columns. The server already depends on this package (
"hypaware": "file:../hypaware"), but the exports map had./core/queryand nothing search-shaped, so there was no entry for the server to import through and two drifting copies were the only available option.The fix
New
src/core/search/, carrying the pieces both repos must share and nothing else:searchable_columns.js:SEARCHABLE_COLUMNS(the allowlist, insertion order meaningful so the content column leads a hit's snippets) andSCAN_COLUMNS(the wider brute-scan projection, its extras derived from the readers). Verbatim semantics from the server'ssrc/search/searchable-columns.js.matcher.js:compileMatcher(query, regex)returninghypQuery/test/locate/rowTest, the snippet-window constants (SNIPPET_BEFORE,SNIPPET_AFTER,MAX_MATCH_COLUMNS,MAX_QUERY_LENGTH), andmakeSnippet. Hoisting the window cut alongside its constants is deliberate: leaving the arithmetic behind would duplicate exactly what the constants are shared to prevent, and T4/T5 need it locally.types.d.ts:GrepSearchHit,GrepSearchResult,GrepSearchMatcher.index.js: thehypaware/core/searchsurface.package.jsongains"./core/search"shaped exactly like./core/query. Semantics are the server's originals unchanged, so its follow-up import swap is a no-op diff rather than a port; the@refs point at LLP 0264 #shared and LLP 0265 #out-of-scope (no config knob for indexed columns).received_atstays inSCAN_COLUMNSeven though the client'sai_gateway_messageshas no such column: hyparquet's object mode skips a name absent from the file, and the whole point is that the two projections do not drift.Nothing imports these modules yet, so there is no runtime behavior change anywhere. T4 (the local grep service) and T5 (the verb) are what use them.
The test that proves it
Three suites under
test/core/, all of which fail onmaster(the modules and the export entry do not exist) and pass here:search-searchable-columns.test.js: the allowlist is exactly the shared ten in order, the bulk machinery columns (system_text,tools,attributes,raw_frame,status) stay out, every searchable column really exists onAI_GATEWAY_MESSAGE_COLUMNS, and the scan projection is the allowlist plus the five reader columns with no duplicates.search-matcher.test.js: literal vs regex compile (both case-insensitive),locateoffsets including the no-match degradation and the zero-width regex match, empty/oversized query refusal,rowTestmatching only through allowlisted columns (system_textcannot produce a hit), and snippet windows at the head, the tail, mid-buffer, and shorter-than-the-window.search-exports.test.js: the./core/searchentry matches the./core/queryshape,src/andtypes/are both in the published file set, andimport('hypaware/core/search')resolves and carries the eight shared names.Verification
npm test: green, 4501 pass / 0 fail / 1 skipped.npm run typecheck: clean.npm run build:types: emitstypes/core/search/{index,matcher,searchable_columns}.d.ts; the root-anchored../../../src/core/search/types.jsspecifier resolves identically fromsrc/and from the generatedtypes/tree.npm pack --dry-run: includessrc/core/search/(4 files,types.d.tsamong them) andtypes/core/search/.Fixes#872