refactor(search): one definition of what a search occurrence is (#6905)

Follow-up to #6901, closing two places where the workflow search index and the
Note card that mirrors it could drift apart. Neither is a live bug; both are the
shape that produced one — the card silently disagreeing with the panel about
which hit is which, counted in one place and painted in another.

THE SCAN. #6901 shared `foldSearchWhitespace` but left the scan around it
duplicated: normalize, then non-overlapping `indexOf` stepping by
`max(len, 1)`, written out once in the indexer and once in the renderer package.
They agree today. They would stop agreeing the moment either grew whole-word
matching, diacritic folding, or a regex mode, and the failure is silent. Both
now call one `forEachSearchOccurrence` in `@sim/utils/string` — the only place
either package can share, since the card renders from a package that cannot
import from `apps/*`.

THE DECLARATION. The indexer projects markdown escapes only for a field
declaring `searchTextFormat: 'markdown'`; the card projects unconditionally,
because it cannot read the block registry. Dropping that one line from the Note
config would leave them disagreeing with nothing to catch it, so a test now
pins it and explains why.

Net negative in lines: this deletes a duplicated loop rather than adding a
layer.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Vikhyath Mondreti
2026-08-20 15:44:22 -07:00
committed by GitHub
co-authored by Claude Opus 5
parent 2e111f615c
commit 85290693c4
5 changed files with 69 additions and 71 deletions
@@ -12,6 +12,7 @@ import {
SEARCH_REPLACE_BLOCK_CONFIGS,
} from '@/lib/workflows/search-replace/search-replace.fixtures'
import { WORKFLOW_SEARCH_SUBFLOW_FIELD_IDS } from '@/lib/workflows/search-replace/subflow-fields'
import { NoteBlock } from '@/blocks/blocks/note'
/**
* Uses the real tool registry. Nothing here imports it directly — the dependency
@@ -167,6 +168,19 @@ describe('indexWorkflowSearchMatches', () => {
expect(matches.some((match) => match.target.kind === 'block-name')).toBe(false)
})
describe('the Note body declares the markdown format the card assumes', () => {
/*
* The canvas card projects markdown escapes unconditionally — it renders from a package that
* cannot read the block registry. The indexer projects only when the field says so. Dropping
* the declaration would leave the two disagreeing about what an occurrence is, and the failure
* is silent: the panel counts a hit the card marks somewhere else.
*/
it('keeps searchTextFormat on the Note content field', () => {
const content = NoteBlock.subBlocks.find((subBlock) => subBlock.id === 'content')
expect(content?.searchTextFormat).toBe('markdown')
})
})
describe('a markdown field is searched as it renders', () => {
/*
* The rich-text editor backslash-escapes every markdown-significant character in prose, so a
@@ -1,5 +1,5 @@
import { isRecordLike } from '@sim/utils/object'
import { foldSearchWhitespace, projectEscapedMarkdownForSearch } from '@sim/utils/string'
import { forEachSearchOccurrence, projectEscapedMarkdownForSearch } from '@sim/utils/string'
import { DEFAULT_SUBBLOCK_TYPE } from '@sim/workflow-persistence/subblocks'
import type { SubBlockType } from '@sim/workflow-types/blocks'
import { isWorkflowBlockProtected } from '@sim/workflow-types/workflow'
@@ -57,16 +57,6 @@ import {
type ToolParameterConfig,
} from '@/tools/params'
/**
* Whitespace is folded before comparison (see {@link foldSearchWhitespace}):
* the fold is one-to-one, so ranges found in the normalized string index the
* original text correctly.
*/
function normalizeForSearch(value: string, caseSensitive: boolean): string {
const folded = foldSearchWhitespace(value)
return caseSensitive ? folded : folded.toLowerCase()
}
/**
* Ranges of `query` in `value`, always in `value`'s own coordinates.
*
@@ -83,23 +73,21 @@ function findTextRanges(
caseSensitive: boolean,
searchTextFormat?: SubBlockConfig['searchTextFormat']
) {
if (!query) return []
const projection = searchTextFormat === 'markdown' ? projectEscapedMarkdownForSearch(value) : null
const source = normalizeForSearch(projection ? projection.text : value, caseSensitive)
const target = normalizeForSearch(query, caseSensitive)
const ranges: Array<{ start: number; end: number }> = []
let index = source.indexOf(target)
while (index !== -1) {
const end = index + target.length
ranges.push(
projection
? { start: projection.starts[index], end: projection.starts[end] }
: { start: index, end }
)
index = source.indexOf(target, index + Math.max(target.length, 1))
}
forEachSearchOccurrence(
projection ? projection.text : value,
query,
(start, end) => {
ranges.push(
projection
? { start: projection.starts[start], end: projection.starts[end] }
: { start, end }
)
},
caseSensitive
)
return ranges
}