Skip to content

Commit 4069d35

Browse files
committed
improvement(utils): finish the escapeRegExp sweep and share the metacharacter class
Replaces the nine inline copies of the escape body the name-based sweep missed, folds the catalog cursor comparator into compareStrings, and gives linear-regex its metacharacter test from the same source the escaper uses.
1 parent 02dfb20 commit 4069d35

19 files changed

Lines changed: 72 additions & 40 deletions

File tree

‎.claude/rules/global.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,10 +51,10 @@ Use shared helpers from `@sim/utils` instead of writing inline implementations:
5151
- `structuredClone(value)` — built-in deep clone, no import needed. Never write `JSON.parse(JSON.stringify(obj))`
5252
- `omit(obj, keys)` from `@sim/utils/object` — remove keys from object
5353
- `filterUndefined(obj)` from `@sim/utils/object` — strip undefined-valued keys. Never write `Object.fromEntries(Object.entries(obj).filter(([, v]) => v !== undefined))`
54+
- `isRecordLike(value)` from `@sim/utils/object` — indexable-object guard. Never redeclare `typeof value === 'object' && value !== null && !Array.isArray(value)`
5455
- `truncate(str, maxLength, suffix?)` from `@sim/utils/string` — safe string truncation with ellipsis
5556
- `escapeRegExp(value)` from `@sim/utils/string` — escape regex metacharacters. Never inline `replace(/[.*+?^${}()|[\]\\]/g, '\\$&')`
5657
- `compareStrings(left, right)` from `@sim/utils/string` — code-unit string comparator for hashes, fingerprints, and values compared across processes. Never `localeCompare` there
57-
- `isRecordLike(value)` from `@sim/utils/object` — indexable-object guard. Never redeclare `typeof value === 'object' && value !== null && !Array.isArray(value)`
5858
- `backoffWithJitter(attempt, retryAfterMs, options?)` from `@sim/utils/retry` — exponential backoff with jitter
5959
- `parseRetryAfter(header)` from `@sim/utils/retry` — parse HTTP `Retry-After` header to milliseconds
6060

‎.cursor/rules/global.mdc‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,10 @@ Use shared helpers from `@sim/utils` instead of writing inline implementations:
5454
- `structuredClone(value)` — built-in deep clone, no import needed. Never write `JSON.parse(JSON.stringify(obj))`
5555
- `omit(obj, keys)` from `@sim/utils/object` — remove keys from object
5656
- `filterUndefined(obj)` from `@sim/utils/object` — strip undefined-valued keys. Never write `Object.fromEntries(Object.entries(obj).filter(([, v]) => v !== undefined))`
57+
- `isRecordLike(value)` from `@sim/utils/object` — indexable-object guard. Never redeclare `typeof value === 'object' && value !== null && !Array.isArray(value)`
5758
- `truncate(str, maxLength, suffix?)` from `@sim/utils/string` — safe string truncation with ellipsis
59+
- `escapeRegExp(value)` from `@sim/utils/string` — escape regex metacharacters. Never inline `replace(/[.*+?^${}()|[\]\\]/g, '\\$&')`
60+
- `compareStrings(left, right)` from `@sim/utils/string` — code-unit string comparator for hashes, fingerprints, and values compared across processes. Never `localeCompare` there
5861
- `backoffWithJitter(attempt, retryAfterMs, options?)` from `@sim/utils/retry` — exponential backoff with jitter
5962
- `parseRetryAfter(header)` from `@sim/utils/retry` — parse HTTP `Retry-After` header to milliseconds
6063

‎CLAUDE.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,10 +16,10 @@ You are a professional software engineer. All code must follow best practices: a
1616
- `getErrorMessage(e, fallback?)` from `@sim/utils/errors` — extract message string from unknown caught value; never write `e instanceof Error ? e.message : 'fallback'`
1717
- `structuredClone(value)` — built-in deep clone; never `JSON.parse(JSON.stringify(...))`
1818
- `omit(obj, keys)` / `filterUndefined(obj)` from `@sim/utils/object` — object trimming; never `Object.fromEntries(Object.entries(...).filter(...))`
19+
- `isRecordLike(value)` from `@sim/utils/object` — never redeclare `typeof value === 'object' && value !== null && !Array.isArray(value)`
1920
- `truncate(str, maxLength, suffix?)` from `@sim/utils/string` — never inline slice + ellipsis
2021
- `escapeRegExp(value)` from `@sim/utils/string` — never inline `replace(/[.*+?^${}()|[\]\\]/g, '\\$&')`
2122
- `compareStrings(left, right)` from `@sim/utils/string` — code-unit ordering for hashes, fingerprints, and cross-process comparisons; never `localeCompare` there
22-
- `isRecordLike(value)` from `@sim/utils/object` — never redeclare `typeof value === 'object' && value !== null && !Array.isArray(value)`
2323
- `backoffWithJitter(attempt, retryAfterMs, options?)` / `parseRetryAfter(header)` from `@sim/utils/retry` — shared retry pacing; never reimplement exponential backoff inline
2424
- **Deployment flags in the browser**: client code inside a workspace, organization, or standalone settings surface reads `hosted`, `billingEnabled`, `chatEnabled`, and the enterprise feature set through `useDeploymentShape()` (components) or `getDeploymentShape()` (block conditions, stores, helpers) from `@/lib/core/config/deployment-shape`, never `isHosted`/`isBillingEnabled`/... from `env-flags`. The constants freeze at module init from the root layout's `NEXT_PUBLIC_*` transport, which Next's bare 404 shell and `global-error` never emit; the reader is seeded from the server-resolved workspace host context, organization layout, or standalone settings layout instead. Server code keeps reading `env-flags`
2525
- **Package Manager**: Use `bun` and `bunx`, not `npm` and `npx`

‎apps/desktop/src/main/local-filesystem.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import {
1919
} from '@sim/desktop-bridge/local-filesystem-limits'
2020
import { generateId } from '@sim/utils/id'
2121
import { isRecordLike } from '@sim/utils/object'
22+
import { escapeRegExp } from '@sim/utils/string'
2223
import { app, dialog, shell } from 'electron'
2324
import micromatch from 'micromatch'
2425
import safeRegex from 'safe-regex2'
@@ -1114,7 +1115,7 @@ export class LocalFilesystemService {
11141115
regex =
11151116
rawPattern !== undefined
11161117
? new RegExp(expression, ignoreCase ? 'i' : '')
1117-
: new RegExp(expression.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), ignoreCase ? 'i' : '')
1118+
: new RegExp(escapeRegExp(expression), ignoreCase ? 'i' : '')
11181119
} catch {
11191120
// An empty result set would tell the model the string appears nowhere in
11201121
// the user's files — a factual claim it will act on, when in truth the

‎apps/sim/app/(landing)/comparisons/[provider]/page.test.tsx‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ vi.mock('@/app/(landing)/comparisons/components/comparison-cards', () => ({
3838
ComparisonCards: () => null,
3939
}))
4040

41+
import { escapeRegExp } from '@sim/utils/string'
4142
import type { Prose } from '@/lib/compare/data'
4243
import { dustProfile } from '@/lib/compare/data'
4344
import ComparisonProviderPage from '@/app/(landing)/comparisons/[provider]/page'
@@ -64,7 +65,7 @@ function countMatches(markup: string, pattern: RegExp): number {
6465
* against the wrong anchor.
6566
*/
6667
function anchorWrapping(markup: string, text: string): string {
67-
const escaped = text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
68+
const escaped = escapeRegExp(text)
6869
return markup.match(new RegExp(`<a [^>]*>${escaped}</a>`))?.[0] ?? ''
6970
}
7071

‎apps/sim/app/workspace/[workspaceId]/components/search-highlight/search-highlight.tsx‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import { escapeRegExp } from '@sim/utils/string'
2+
13
interface SearchHighlightProps {
24
text: string
35
searchQuery: string
@@ -18,7 +20,7 @@ export function SearchHighlight({ text, searchQuery, className = '' }: SearchHig
1820
.trim()
1921
.split(/\s+/)
2022
.filter((term) => term.length > 0)
21-
.map((term) => term.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))
23+
.map(escapeRegExp)
2224

2325
if (searchTerms.length === 0) {
2426
return <span className={className}>{text}</span>

‎apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/components/code-editor/code-editor.tsx‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
highlight,
1111
languages,
1212
} from '@sim/emcn'
13+
import { escapeRegExp } from '@sim/utils/string'
1314
import Editor from 'react-simple-code-editor'
1415
import type { SchemaParameter } from '@/app/workspace/[workspaceId]/components/custom-tool-editor/custom-tool-schema'
1516
import {
@@ -152,7 +153,7 @@ export function CodeEditor({
152153

153154
if (schemaParameters.length > 0) {
154155
schemaParameters.forEach((param) => {
155-
const escapedName = param.name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
156+
const escapedName = escapeRegExp(param.name)
156157
const paramRegex = new RegExp(`\\b(${escapedName})\\b`, 'g')
157158
processedCode = processedCode.replace(paramRegex, (match) => {
158159
const placeholder = `__PARAM_${placeholders.length}__`

‎apps/sim/ee/scim/lib/protocol/normalize.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { isRecordLike } from '@sim/utils/object'
2+
23
/**
34
* Tolerances for what identity providers actually send, as distinct from what
45
* RFC 7644 describes.

‎apps/sim/lib/catalog/application/catalog-page.ts‎

Lines changed: 5 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { compareStrings } from '@sim/utils/string'
12
import type { V2SortOrder } from '@/lib/api/contracts/v2/shared'
23
import { OrchestrationError } from '@/lib/core/orchestration/types'
34

@@ -33,29 +34,14 @@ export function matchesCatalogSearch(
3334
return fields.some((field) => field?.toLowerCase().includes(term))
3435
}
3536

36-
/**
37-
* Orders two strings by UTF-16 code unit, deliberately not by `localeCompare`.
38-
*
39-
* A bare `localeCompare` reads the process's default locale and ICU data, so two
40-
* app instances started with different `LANG` values order the same set
41-
* differently — and an offset cursor minted on one then names a different row on
42-
* the other, silently skipping or repeating entries. Code-unit order is the same
43-
* everywhere, which is the property a cursor needs; catalog ids and names are
44-
* ASCII, so nothing human-visible changes.
45-
*/
46-
function compareCodeUnits(left: string, right: string): number {
47-
if (left < right) return -1
48-
if (left > right) return 1
49-
return 0
50-
}
51-
5237
/**
5338
* Sorts a copy by one string field, breaking ties on `id`.
5439
*
5540
* The tie-break is what makes an offset cursor sound: two entries comparing
5641
* equal on the sort field must still hold a fixed order, or the position a
5742
* cursor names moves between requests. `id` is unique across every catalog, so
58-
* it fully orders each one.
43+
* it fully orders each one. {@link compareStrings} keeps that order identical on
44+
* every instance; a `localeCompare` here would not.
5945
*/
6046
export function sortCatalogEntries<T extends { id: string }>(
6147
entries: readonly T[],
@@ -64,9 +50,9 @@ export function sortCatalogEntries<T extends { id: string }>(
6450
): T[] {
6551
const direction = sortOrder === 'desc' ? -1 : 1
6652
return [...entries].sort((left, right) => {
67-
const compared = compareCodeUnits(select(left), select(right))
53+
const compared = compareStrings(select(left), select(right))
6854
if (compared !== 0) return compared * direction
69-
return compareCodeUnits(left.id, right.id) * direction
55+
return compareStrings(left.id, right.id) * direction
7056
})
7157
}
7258

‎apps/sim/lib/chunkers/structured-data-chunker.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { createLogger } from '@sim/logger'
2+
import { escapeRegExp } from '@sim/utils/string'
23
import { ChunkBudget } from '@/lib/chunkers/chunk-budget'
34
import type { Chunk, StructuredDataOptions } from '@/lib/chunkers/types'
45
import {
@@ -252,7 +253,7 @@ export class StructuredDataChunker {
252253

253254
const delimiters = [',', '\t', '|']
254255
for (const delimiter of delimiters) {
255-
const escaped = delimiter.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
256+
const escaped = escapeRegExp(delimiter)
256257
const counts = lines.map((line) => (line.match(new RegExp(escaped, 'g')) || []).length)
257258
const avgCount = counts.reduce((a, b) => a + b, 0) / counts.length
258259

0 commit comments

Comments
 (0)