The search indexes collapse whitespace (commit bodies and wiki text are flattened to single spaces), so a phrase typed with two spaces, like "fix the", could never match. Now a run of whitespace inside a quoted phrase is one space, in every section and in code search alike, which keeps the two reading a query the same way. Spaces at a phrase's ends stay, one each. Code lines keep their own whitespace, so a phrase no longer finds code aligned with several spaces. The bench's default queries fetch and find exactly what they did. The search-phrases scenario adds spaced-out phrases to its table and checks that one matches the same commits as its single-spaced form.
- commit
- bd87ef9d2ca817e26a9e16ea2120e25dac4590af
- parent
- f72b3f833fcb3a5010a02d5331e6557ed33d156c
- author
- cat <admin@dev.site>
- ci
- ✓ passed in 93s, signed by cat
- kind
- feat risk score 1.7 of 3, Jev's estimate
- review
- #3 Match quoted phrases in every search section
ci attestation
forge-attestation 1 type ci commit bd87ef9d2ca817e26a9e16ea2120e25dac4590af tree 6f6691b90884b25cc1259db50cef86a663cc786a script .forge/ci e6f038ae810b87f90a4be115a762a9c91dd76a01 result pass exit 0 started 2026-10-18T22:51:12Z duration 93s runner (not shown here; refs/notes/ci has it) system Darwin 25.5.0 arm64 env host log sha256:e7e334403239916a58d755a579fe49728bdf5bb510b5c91d26919846380ef02f 240 lines |remote: review #4: /dami/greet/reviews/4/ |pushed wip/i18n |950ef96 running .forge/ci: greet.sh: say what it is | | sh: /bin/sh | | ok greet |950ef96 pass in 1s, signed |pushed main |remote: review #4: /dami/greet/reviews/4/ |pushed wip/i18n |f61c51b running .forge/ci: wave at the world | | sh: /bin/sh | | FAIL greet : got 'hello, world o/', want 'hello, world' |f61c51b fail in 1s, signed |forge: 1 commit(s) did not pass |remote: review #6: /dami/greet/reviews/6/ |pushed ana/wave |e8c0d33 running .forge/ci: update the test for the wave | | sh: /bin/sh | | ok greet |e8c0d33 pass in 0s, signed |remote: review #6: /dami/greet/reviews/6/ |pushed ana/wave |warning: You appear to have cloned an empty repository. |site home: deployed 2271269 -> /sites/ana/dotfiles/home/ |created ana/notes |remote: review #1: /ana/notes/reviews/1/ |pushed ana/todo |landing #1 A todo list: ana/todo -> main |d15fcff running .forge/ci: web, server and Python versions of greet | | sh: /bin/sh | | ok greet |d15fcff pass in 0s, signed |pushed main |2a70689 running .forge/ci: messages: notes for the later regions | | sh: /bin/sh | | ok greet |2a70689 pass in 0s, signed |remote: review #7: /dami/greet/reviews/7/ |pushed dami/regions |created dami/forge |warning: You appear to have cloned an empty repository. |site guide: deployed 55c6dda -> /sites/dami/forge/guide/ |(node:50780) ExperimentalWarning: stripTypeScriptTypes is an experimental feature and might change at any time |(Use `node --trace-warnings ...` to show where the warning was created) |built ana/dotfiles in 0s -> /Users/dami/Library/Caches/forge/ci/35f7a65d0235/demo/root/.forge/www |(node:50912) ExperimentalWarning: stripTypeScriptTypes is an experimental feature and might change at any time |(Use `node --trace-warnings ...` to show where the warning was created) |built ana/notes in 1s -> /Users/dami/Library/Caches/forge/ci/35f7a65d0235/demo/root/.forge/www |(node:51094) ExperimentalWarning: stripTypeScriptTypes is an experimental feature and might change at any time |(Use `node --trace-warnings ...` to show where the warning was created) |built dami/forge in 0s -> /Users/dami/Library/Caches/forge/ci/35f7a65d0235/demo/root/.forge/www |(node:51253) ExperimentalWarning: stripTypeScriptTypes is an experimental feature and might change at any time |(Use `node --trace-warnings ...` to show where the warning was created) |built dami/greet in 2s -> /Users/dami/Library/Caches/forge/ci/35f7a65d0235/demo/root/.forge/www |(node:51927) ExperimentalWarning: stripTypeScriptTypes is an experimental feature and might change at any time |(Use `node --trace-warnings ...` to show where the warning was created) |built - in 0s -> /Users/dami/Library/Caches/forge/ci/35f7a65d0235/demo/root/.forge/www |226 of 226 pages render the same without 11ty |guide: 4 files, links ok |ok
Signature: good, cat
| M | README.md | 8 | ++++++-- |
| M | site/ | 3 | ++- |
| M | site/ | 14 | ++++++++------ |
| M | test/ | 9 | +++++++++ |
@@ -557,7 +557,9 @@ are cut to 240 characters and wiki pages to 2000, to keep the index small.
557557 558558 Every section reads a query the same way (`site/assets/query.ts`): terms559559 split on whitespace, in any order, and a quoted phrase (`"fix the search"`)560-is one term, spaces and all, that must appear as written. Every term must560+is one term, spaces and all, that must appear as written, except that a run561+of whitespace in it counts as one space, as in the indexes, which collapse562+whitespace (`"fix the"` is `"fix the"`). Every term must561563 match, ignoring case, and results mark each match, a phrase as a whole. A562564 quote opens a phrase only at the start of a term (in `it's"` it is just a563565 character), an unclosed quote runs to the end of the query as a phrase, so a@@ -584,7 +586,9 @@ goes to a worker, and a Code section lists each line that has every word,
584586 ignoring case, as `path:line`, linked to that line of the file's page (the585587 source page for markdown). Its words are the terms the other sections read,586588 so a quoted phrase (`"import fs from"`) is one literal string, spaces and587-all; a word inside another goes, since a line with the longer one has it.589+all, a run of whitespace as one space as everywhere (so it won't find code590+aligned with several spaces); a word inside another goes, since a line with591+the longer one has it.588592 Words of 3 characters or more are looked up589593 in the index; shorter ones only filter the lines it finds. It loads nothing590594 until then: the first code query starts the worker, which loads@@ -427,7 +427,8 @@ export function match(q: string): string {
427427 428428 // A query's words, lower case, its terms as query.ts reads them: split on429429 // whitespace, except that a quoted phrase ("like this") is one literal430-// string, spaces and all (an unclosed quote runs to the end).430+// string, spaces and all, a run of them as one (an unclosed quote runs to431+// the end).431432 // A word that is part of another goes, since every line with the longer one432433 // has it, and so do NULs, which an FTS5 string can't hold.433434 export const words = (q: string): string[] => narrow(terms(q.replaceAll('\0', '')));@@ -2,15 +2,17 @@
22 // commits, reviews, wiki pages, file paths and repos, and codesearch.ts for33 // the lines of files. A query is terms split on whitespace, except that a44 // quoted phrase ("like this") is one term, spaces and all, which must appear5-// as written. A quote opens a phrase only at the start of a term (in6-// `it's"` or `a"b` it is just a character), an unclosed one runs to the end7-// of the query, and empty quotes are no term. Every term must match, ignoring8-// case. Nothing here touches the page, so test/search-phrases.ts checks it9-// in Node as the browser runs it.5+// as written, except that a run of whitespace in it is one space, as in the6+// indexes (lib/search.ts flattens bodies and wiki text). A quote opens a7+// phrase only at the start of a term (in `it's"` or `a"b` it is just a8+// character), an unclosed one runs to the end of the query, and empty quotes9+// are no term. Every term must match, ignoring case. Nothing here touches10+// the page, so test/search-phrases.ts checks it in Node as the browser runs11+// it.1012 1113 // A query's terms, lower case, each once.1214 export function terms(q: string): string[] {13- const all = [...q.toLowerCase().matchAll(/"([^"]*)"?|(\S+)/g)].map((m) => m[1] ?? m[2]).filter((t) => t.trim());15+ const all = [...q.toLowerCase().matchAll(/"([^"]*)"?|(\S+)/g)].map((m) => m[2] ?? m[1].replace(/\s+/g, ' ')).filter((t) => t.trim());1416 return [...new Set(all)];1517 }1618 @@ -34,6 +34,11 @@ ok terms '"Fix the Search"'
3434 eq "$(output)" '"Fix the Search" => [fix the search] | code [fix the search]'3535 ok terms '"the search'3636 eq "$(output)" '"the search => [the search] | code [the search]'37+# A run of whitespace in a phrase is one space, as in the indexes; spaces38+# at its ends stay (one each).39+ok terms '"fix the search"' '" fix "'40+eq "$(output)" '"fix the search" => [fix the search] | code [fix the search]41+" fix " => [ fix ] | code [ fix ]'3742 ok terms '""' '" "' '"'3843 eq "$(output)" '"" => - | code -3944 " " => - | code -@@ -58,6 +63,10 @@ ok match "$ix" '"fix the search"'
5863 eq "$(output | grep '^Commits: ' | sed 's/ | .*//')" 'Commits: [Fix the search] box5964 Commits: Tidy the box'6065 has "$T_LOG" '^Commits: Tidy the box \| This is to \[fix the search\] for good\.$'66+# Spaced out, it is the same phrase.67+ok match "$ix" '"fix the search"'68+eq "$(output | grep '^Commits: ' | sed 's/ | .*//')" 'Commits: [Fix the search] box69+Commits: Tidy the box'6170 # Unclosed, it is the same phrase.6271 ok match "$ix" '"FIX THE SEARCH'6372 eq "$(output | grep '^Commits: ' | sed 's/ | .*//')" 'Commits: [Fix the search] box