Commit e5a8852
feat(file): search workspace files by regular expression (#7370)
* feat(file): search workspace files by regular expression
Search read its query as literal text. It now reads it as a line-oriented
regular expression by default, with a Match setting on the block to go back
to verbatim text.
The segment store and its `gin_trgm_ops` index already support this: pg_trgm
extracts trigrams from a regex source too, so `~` / `~*` plan as a bitmap
index scan exactly like `LIKE` / `ILIKE`. No migration, no new index.
One compiled pattern owns every mode-specific decision — how PostgreSQL
matches a segment, whether the segment must hold a whole line, and where the
match sits inside it — so the repository builds one query shape and the
preview renderer one preview shape. Compilation happens in the application
use case, not the route adapter, so every surface gets the same semantics.
The supported syntax is the intersection of PostgreSQL ARE and JavaScript
RegExp, because the same source drives both the indexed predicate and the
client-side match location a preview centres on. Anything the two engines
read differently is rejected by name rather than silently reinterpreted, and
`\b` is rewritten to `\y` on the way to PostgreSQL.
Safety, in four independent layers:
- A pattern must contain 3 consecutive literal characters every match will
include. pg_trgm indexes nothing shorter, and an unextractable pattern
plans as a sequential scan across every workspace's segments.
- `new RegExp` proves it compiles in JavaScript.
- PostgreSQL proves it compiles in ARE; 2201B becomes a 400, not a 500.
- `statement_timeout` bounds the read. This one covers exact matching too,
which has always been able to reach the same scan through a
punctuation-only or non-ASCII query.
`mode` is a builder setting, withheld from the model like `maxResults`. The
model cannot see it and the two readings disagree on every metacharacter, so
`toolEnrichment` replaces the declared syntax with the active mode's — a
regex sent to a block set to exact matching would otherwise be searched for
verbatim and silently find nothing.
Verified against PostgreSQL 17: 14 behavioural checks end to end, a live
search over 150,012 segments in 14ms, 6/6 representative patterns reaching
the trigram index, and the guard cutting a 12s pattern at 10.08s into an
actionable message.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(file): locate regex matches in PostgreSQL, never in JavaScript
Preview rendering ran the user's compiled pattern with `RegExp.exec` to centre
the excerpt on the match. `RegExp` matches by backtracking, and the literal-run
gate admits nested quantifiers, so `(a+)+bcd` against a long segment cost 768ms
at 40 leading `a`s and doubles with each one — synchronously, on the event loop,
once per returned row, and entirely outside the statement timeout that bounds
the query which found the row.
PostgreSQL runs that same pattern in 0.49ms: its engine does not backtrack, and
`regexp_instr` runs inside the read's transaction, so locating a match can never
cost more than having found it. Regex mode now selects the match offsets
alongside the row and `findMatchRange` returns null for it, which is the
interface's contract rather than an omission. Exact mode is unchanged — scanning
for a known string is linear.
PostgreSQL counts characters where JavaScript slices by UTF-16 unit, so the
offsets are converted by walking the segment rather than assuming either width.
Also fixes two audit failures: `getErrorMessage` in place of a hand-written
`instanceof Error` ternary, and regenerated tool metadata and integration docs
for the search params.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(file): accept open-ended repeats, count characters, classify lock waits
Three defects from review, none of which the tests caught:
`{n,}` was rejected. `readQuantifierAt` reports an unbounded maximum as
Infinity, and the repeat cap compared it directly, so every open-ended repeat
failed as "exceeds 1000" — a form the tool's own documentation offers. Only a
stated maximum is measured now, and the minimum always is, since that is what
an expansion unrolls.
Query bounds and literal runs were measured in UTF-16 units while claiming
characters, so two astral characters read as four and slipped a gate written
for three. Both now count characters, which is also what pg_trgm indexes.
`lock_timeout` was set without classifying what it raises. A wait on
conflicting DDL surfaced as an unclassified server error, and folding it in
with the timeout arm would have told the caller to fix a pattern that is
already correct. It now maps to a distinct error the caller is told to retry.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(file): keep long previews honest, credit forced repeats, drop bad hints
Four review findings, each reproduced before it was changed.
A regex match has no length limit, so `abc.*` on a long line produces a match
larger than the whole preview budget. The layout passed it through whole and
let the final byte cap cut it, which removed the closing marker along with the
text — 2048 bytes of output ending mid-line with nothing to say so. The match
is now clipped against a budget that reserves that marker, and a clipped match
always carries one.
A variable repeat was scored at one occurrence when its minimum forces more:
`(?:ab){2,5}` cannot match without `abab` in it, but the run was counted as 2
and the pattern rejected against a gate of 3. It now contributes the copies its
minimum forces.
`\Y`, `\m` and `\M` were rejected with a suggestion to write `\b`, `^` or `$`.
Those are different assertions — a non-boundary, and two word edges rather than
the line's — so the hint handed back different semantics as a fix. They now say
no supported escape means the same thing. `\y`, `\A` and `\Z` keep theirs,
which are genuine.
Smart case was documented as reacting to any uppercase letter, but it reads
literals only, so `\D` and `[A-Z]` do not make a search case-sensitive. The
tool, block and generated docs now say what the code does.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(file): credit runs across a repeat, count matching lines not matches
The tool promised "each match" while the query is distinct on file and line,
so several matches on one line return one row. An agent reading the contract
would have expected otherwise; it now says each matching line once.
A repetition of a non-fixed atom was scored at what one copy guarantees, but
from two copies on its own tail and head meet: every match of
`(?:a(?:x|y)bc){2}` contains `bca`, which neither copy contains alone. That
run is now credited, so patterns the index can serve are no longer rejected.
Scores are capped alongside the strings they measure. Joining two capped
strings yields twice the cap, which `concatenate` could already exceed — the
gate never noticed, since it only compares against three, but the bound is
documented and now holds.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(file): reject overflowed repeat bounds, describe what search covers
An upper bound too large for `Number` arrives as Infinity, and the exception
that lets `{n,}` skip the repeat cap could not tell the two apart — so
`needle{1,<400 digits>}` passed the cap that `needle{1,5000}` fails. The
quantifier now records whether a bound was written at all, and a written one
must be at or under the cap however large it is.
The tool promised every active workspace file. It searches what the index
currently holds: a file still pending, failed, or skipped as unsupported is
not searched, and an agent reading "every file" would take an empty result as
proof of absence. Both descriptions now say so and point at `complete` and
`indexStatus`, which already carry the detail.
The declared query description spoke only for regex mode, which is what the
catalog and the generated docs render — so a builder using exact matching was
told to write a regular expression and to obey a rule that does not apply to
them. It now names both readings; the runtime schema is still enriched with
whichever is in force. The docs overview said literal text, which stopped
being true when regex became the default.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(file): close the allowlist around PostgreSQL bracket expressions
`[:class:]` was rejected while `[=equivalence=]` and `[.collating.]` were
forwarded unchanged. All three are PostgreSQL bracket expressions with no
JavaScript counterpart, and the parser exists to admit only what both engines
spell the same way — so two of them passed an allowlist whose whole point is
to close, and were accepted by documentation that says POSIX classes are not
supported.
They are now rejected by the construct they open, each named in its own error.
An ordinary class holding a literal dot, `[.]` or `[a.b]`, is untouched: the
form only matches on a bracket nested inside a class.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>1 parent c43e842 commit e5a8852
19 files changed
Lines changed: 1803 additions & 218 deletions
File tree
- apps
- docs/content/docs/integrations
- sim
- blocks/blocks
- lib
- internal/file
- workspace-files
- application
- search
- tools
- file
- generated
- packages/testing/src/mocks
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
16 | 16 | | |
17 | 17 | | |
18 | 18 | | |
19 | | - | |
| 19 | + | |
20 | 20 | | |
21 | 21 | | |
22 | 22 | | |
| |||
70 | 70 | | |
71 | 71 | | |
72 | 72 | | |
73 | | - | |
| 73 | + | |
74 | 74 | | |
75 | 75 | | |
76 | 76 | | |
77 | 77 | | |
78 | 78 | | |
79 | | - | |
| 79 | + | |
| 80 | + | |
80 | 81 | | |
81 | 82 | | |
82 | 83 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
72 | 72 | | |
73 | 73 | | |
74 | 74 | | |
75 | | - | |
| 75 | + | |
76 | 76 | | |
77 | 77 | | |
| 78 | + | |
78 | 79 | | |
79 | 80 | | |
| 81 | + | |
80 | 82 | | |
81 | 83 | | |
82 | 84 | | |
83 | 85 | | |
| 86 | + | |
| 87 | + | |
| 88 | + | |
| 89 | + | |
| 90 | + | |
| 91 | + | |
| 92 | + | |
| 93 | + | |
| 94 | + | |
| 95 | + | |
| 96 | + | |
| 97 | + | |
84 | 98 | | |
85 | 99 | | |
86 | 100 | | |
| |||
90 | 104 | | |
91 | 105 | | |
92 | 106 | | |
93 | | - | |
| 107 | + | |
94 | 108 | | |
95 | 109 | | |
96 | 110 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
908 | 908 | | |
909 | 909 | | |
910 | 910 | | |
911 | | - | |
| 911 | + | |
| 912 | + | |
| 913 | + | |
912 | 914 | | |
913 | 915 | | |
914 | 916 | | |
| |||
1007 | 1009 | | |
1008 | 1010 | | |
1009 | 1011 | | |
| 1012 | + | |
| 1013 | + | |
| 1014 | + | |
| 1015 | + | |
| 1016 | + | |
| 1017 | + | |
| 1018 | + | |
| 1019 | + | |
| 1020 | + | |
| 1021 | + | |
| 1022 | + | |
| 1023 | + | |
| 1024 | + | |
| 1025 | + | |
1010 | 1026 | | |
1011 | 1027 | | |
1012 | 1028 | | |
1013 | 1029 | | |
1014 | | - | |
1015 | | - | |
| 1030 | + | |
| 1031 | + | |
1016 | 1032 | | |
1017 | 1033 | | |
1018 | 1034 | | |
| |||
1268 | 1284 | | |
1269 | 1285 | | |
1270 | 1286 | | |
| 1287 | + | |
1271 | 1288 | | |
1272 | 1289 | | |
1273 | 1290 | | |
| |||
1500 | 1517 | | |
1501 | 1518 | | |
1502 | 1519 | | |
1503 | | - | |
| 1520 | + | |
| 1521 | + | |
| 1522 | + | |
| 1523 | + | |
| 1524 | + | |
1504 | 1525 | | |
1505 | 1526 | | |
1506 | 1527 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
148 | 148 | | |
149 | 149 | | |
150 | 150 | | |
151 | | - | |
| 151 | + | |
| 152 | + | |
| 153 | + | |
| 154 | + | |
| 155 | + | |
| 156 | + | |
| 157 | + | |
152 | 158 | | |
153 | 159 | | |
154 | 160 | | |
| |||
168 | 174 | | |
169 | 175 | | |
170 | 176 | | |
171 | | - | |
| 177 | + | |
172 | 178 | | |
173 | 179 | | |
174 | 180 | | |
| |||
191 | 197 | | |
192 | 198 | | |
193 | 199 | | |
| 200 | + | |
194 | 201 | | |
195 | 202 | | |
196 | 203 | | |
| |||
199 | 206 | | |
200 | 207 | | |
201 | 208 | | |
| 209 | + | |
| 210 | + | |
| 211 | + | |
| 212 | + | |
| 213 | + | |
| 214 | + | |
| 215 | + | |
| 216 | + | |
| 217 | + | |
| 218 | + | |
202 | 219 | | |
203 | 220 | | |
204 | 221 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
31 | 31 | | |
32 | 32 | | |
33 | 33 | | |
| 34 | + | |
34 | 35 | | |
35 | 36 | | |
36 | 37 | | |
| |||
57 | 58 | | |
58 | 59 | | |
59 | 60 | | |
| 61 | + | |
60 | 62 | | |
61 | 63 | | |
62 | 64 | | |
| |||
110 | 112 | | |
111 | 113 | | |
112 | 114 | | |
| 115 | + | |
113 | 116 | | |
114 | 117 | | |
115 | 118 | | |
| |||
Lines changed: 34 additions & 10 deletions
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
2 | 2 | | |
3 | 3 | | |
4 | 4 | | |
5 | | - | |
6 | | - | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
7 | 14 | | |
8 | 15 | | |
9 | 16 | | |
10 | 17 | | |
| 18 | + | |
11 | 19 | | |
12 | 20 | | |
13 | 21 | | |
| |||
24 | 32 | | |
25 | 33 | | |
26 | 34 | | |
27 | | - | |
28 | | - | |
29 | | - | |
30 | | - | |
31 | | - | |
32 | | - | |
33 | | - | |
34 | | - | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
| 45 | + | |
| 46 | + | |
| 47 | + | |
| 48 | + | |
| 49 | + | |
| 50 | + | |
| 51 | + | |
| 52 | + | |
| 53 | + | |
| 54 | + | |
| 55 | + | |
| 56 | + | |
| 57 | + | |
| 58 | + | |
35 | 59 | | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
1 | 7 | | |
2 | 8 | | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
3 | 27 | | |
4 | 28 | | |
5 | 29 | | |
| |||
0 commit comments