diff --git a/.claude/rules/global.md b/.claude/rules/global.md
index 3a222b935d5..f38e09d9b07 100644
--- a/.claude/rules/global.md
+++ b/.claude/rules/global.md
@@ -51,7 +51,10 @@ Use shared helpers from `@sim/utils` instead of writing inline implementations:
- `structuredClone(value)` — built-in deep clone, no import needed. Never write `JSON.parse(JSON.stringify(obj))`
- `omit(obj, keys)` from `@sim/utils/object` — remove keys from object
- `filterUndefined(obj)` from `@sim/utils/object` — strip undefined-valued keys. Never write `Object.fromEntries(Object.entries(obj).filter(([, v]) => v !== undefined))`
+- `isRecordLike(value)` from `@sim/utils/object` — indexable-object guard. Never redeclare `typeof value === 'object' && value !== null && !Array.isArray(value)`
- `truncate(str, maxLength, suffix?)` from `@sim/utils/string` — safe string truncation with ellipsis
+- `escapeRegExp(value)` from `@sim/utils/string` — escape regex metacharacters. Never inline `replace(/[.*+?^${}()|[\]\\]/g, '\\$&')`
+- `compareStrings(left, right)` from `@sim/utils/string` — code-unit string comparator for hashes, fingerprints, and values compared across processes. Never `localeCompare` there
- `backoffWithJitter(attempt, retryAfterMs, options?)` from `@sim/utils/retry` — exponential backoff with jitter
- `parseRetryAfter(header)` from `@sim/utils/retry` — parse HTTP `Retry-After` header to milliseconds
diff --git a/.cursor/rules/global.mdc b/.cursor/rules/global.mdc
index 1bf193b00ec..052ceead52d 100644
--- a/.cursor/rules/global.mdc
+++ b/.cursor/rules/global.mdc
@@ -54,7 +54,10 @@ Use shared helpers from `@sim/utils` instead of writing inline implementations:
- `structuredClone(value)` — built-in deep clone, no import needed. Never write `JSON.parse(JSON.stringify(obj))`
- `omit(obj, keys)` from `@sim/utils/object` — remove keys from object
- `filterUndefined(obj)` from `@sim/utils/object` — strip undefined-valued keys. Never write `Object.fromEntries(Object.entries(obj).filter(([, v]) => v !== undefined))`
+- `isRecordLike(value)` from `@sim/utils/object` — indexable-object guard. Never redeclare `typeof value === 'object' && value !== null && !Array.isArray(value)`
- `truncate(str, maxLength, suffix?)` from `@sim/utils/string` — safe string truncation with ellipsis
+- `escapeRegExp(value)` from `@sim/utils/string` — escape regex metacharacters. Never inline `replace(/[.*+?^${}()|[\]\\]/g, '\\$&')`
+- `compareStrings(left, right)` from `@sim/utils/string` — code-unit string comparator for hashes, fingerprints, and values compared across processes. Never `localeCompare` there
- `backoffWithJitter(attempt, retryAfterMs, options?)` from `@sim/utils/retry` — exponential backoff with jitter
- `parseRetryAfter(header)` from `@sim/utils/retry` — parse HTTP `Retry-After` header to milliseconds
diff --git a/CLAUDE.md b/CLAUDE.md
index 09dabbc5d78..bc41ebfae0c 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -16,7 +16,10 @@ You are a professional software engineer. All code must follow best practices: a
- `getErrorMessage(e, fallback?)` from `@sim/utils/errors` — extract message string from unknown caught value; never write `e instanceof Error ? e.message : 'fallback'`
- `structuredClone(value)` — built-in deep clone; never `JSON.parse(JSON.stringify(...))`
- `omit(obj, keys)` / `filterUndefined(obj)` from `@sim/utils/object` — object trimming; never `Object.fromEntries(Object.entries(...).filter(...))`
+ - `isRecordLike(value)` from `@sim/utils/object` — never redeclare `typeof value === 'object' && value !== null && !Array.isArray(value)`
- `truncate(str, maxLength, suffix?)` from `@sim/utils/string` — never inline slice + ellipsis
+ - `escapeRegExp(value)` from `@sim/utils/string` — never inline `replace(/[.*+?^${}()|[\]\\]/g, '\\$&')`
+ - `compareStrings(left, right)` from `@sim/utils/string` — code-unit ordering for hashes, fingerprints, and cross-process comparisons; never `localeCompare` there
- `backoffWithJitter(attempt, retryAfterMs, options?)` / `parseRetryAfter(header)` from `@sim/utils/retry` — shared retry pacing; never reimplement exponential backoff inline
- **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`
- **Package Manager**: Use `bun` and `bunx`, not `npm` and `npx`
diff --git a/apps/desktop/src/main/local-filesystem.ts b/apps/desktop/src/main/local-filesystem.ts
index 573f5a8cb88..edf226d4592 100644
--- a/apps/desktop/src/main/local-filesystem.ts
+++ b/apps/desktop/src/main/local-filesystem.ts
@@ -19,6 +19,7 @@ import {
} from '@sim/desktop-bridge/local-filesystem-limits'
import { generateId } from '@sim/utils/id'
import { isRecordLike } from '@sim/utils/object'
+import { escapeRegExp } from '@sim/utils/string'
import { app, dialog, shell } from 'electron'
import micromatch from 'micromatch'
import safeRegex from 'safe-regex2'
@@ -1114,7 +1115,7 @@ export class LocalFilesystemService {
regex =
rawPattern !== undefined
? new RegExp(expression, ignoreCase ? 'i' : '')
- : new RegExp(expression.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), ignoreCase ? 'i' : '')
+ : new RegExp(escapeRegExp(expression), ignoreCase ? 'i' : '')
} catch {
// An empty result set would tell the model the string appears nowhere in
// the user's files — a factual claim it will act on, when in truth the
diff --git a/apps/sim/app/(landing)/comparisons/[provider]/page.test.tsx b/apps/sim/app/(landing)/comparisons/[provider]/page.test.tsx
index 6798b467cf9..0237f09be38 100644
--- a/apps/sim/app/(landing)/comparisons/[provider]/page.test.tsx
+++ b/apps/sim/app/(landing)/comparisons/[provider]/page.test.tsx
@@ -38,6 +38,7 @@ vi.mock('@/app/(landing)/comparisons/components/comparison-cards', () => ({
ComparisonCards: () => null,
}))
+import { escapeRegExp } from '@sim/utils/string'
import type { Prose } from '@/lib/compare/data'
import { dustProfile } from '@/lib/compare/data'
import ComparisonProviderPage from '@/app/(landing)/comparisons/[provider]/page'
@@ -64,7 +65,7 @@ function countMatches(markup: string, pattern: RegExp): number {
* against the wrong anchor.
*/
function anchorWrapping(markup: string, text: string): string {
- const escaped = text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
+ const escaped = escapeRegExp(text)
return markup.match(new RegExp(`]*>${escaped}`))?.[0] ?? ''
}
diff --git a/apps/sim/app/(landing)/integrations/(shell)/[slug]/page.tsx b/apps/sim/app/(landing)/integrations/(shell)/[slug]/page.tsx
index f920c55ec56..168d44601bc 100644
--- a/apps/sim/app/(landing)/integrations/(shell)/[slug]/page.tsx
+++ b/apps/sim/app/(landing)/integrations/(shell)/[slug]/page.tsx
@@ -1,5 +1,5 @@
import { ChipLink } from '@sim/emcn'
-import { truncate } from '@sim/utils/string'
+import { escapeRegExp, truncate } from '@sim/utils/string'
import type { Metadata } from 'next'
import Link from 'next/link'
import { notFound } from 'next/navigation'
@@ -127,10 +127,6 @@ function sentenceWithTerminalPunctuation(value: string): string {
return /[.!?]$/.test(trimmedValue) ? trimmedValue : `${trimmedValue}.`
}
-function escapeRegex(value: string): string {
- return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
-}
-
/**
* Server-side rewrite of bare integration names in a curated template prompt
* to `@`-mention form (`Slack` → `@Slack`) so the prompt chips with brand
@@ -152,7 +148,7 @@ function mentionifyPromptForNames(prompt: string, names: readonly string[]): str
)
if (unique.length === 0) return prompt
const regex = new RegExp(
- `(? `@${match}`)
diff --git a/apps/sim/app/workspace/[workspaceId]/components/search-highlight/search-highlight.tsx b/apps/sim/app/workspace/[workspaceId]/components/search-highlight/search-highlight.tsx
index 41062a49b00..5cbf9366999 100644
--- a/apps/sim/app/workspace/[workspaceId]/components/search-highlight/search-highlight.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/components/search-highlight/search-highlight.tsx
@@ -1,3 +1,5 @@
+import { escapeRegExp } from '@sim/utils/string'
+
interface SearchHighlightProps {
text: string
searchQuery: string
@@ -18,7 +20,7 @@ export function SearchHighlight({ text, searchQuery, className = '' }: SearchHig
.trim()
.split(/\s+/)
.filter((term) => term.length > 0)
- .map((term) => term.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))
+ .map(escapeRegExp)
if (searchTerms.length === 0) {
return {text}
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/prompt-editor/use-prompt-editor.ts b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/prompt-editor/use-prompt-editor.ts
index 2d4048a9c33..808f6f37902 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/prompt-editor/use-prompt-editor.ts
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/prompt-editor/use-prompt-editor.ts
@@ -1,6 +1,7 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { toast } from '@sim/emcn'
import { assessTextPaste, PASTE_LIMITS, PASTE_RENDER_THRESHOLDS } from '@sim/utils/paste'
+import { escapeRegExp } from '@sim/utils/string'
import {
attachSelectionContextToClipboard,
readSelectionContextFromClipboard,
@@ -28,7 +29,6 @@ import {
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks'
import {
areContextsEqual,
- escapeRegex,
filterContextsPresentInMessage,
prepareContextForInsert,
restoreSkillTriggerText,
@@ -369,7 +369,7 @@ export function usePromptEditor({
const labelIsUsed = (candidate: string): boolean => {
if (selectedContexts.some((selected) => selected.label === candidate)) return true
- return new RegExp(`(^|\\s)@${escapeRegex(candidate)}(?![A-Za-z0-9_])`).test(currentValue)
+ return new RegExp(`(^|\\s)@${escapeRegExp(candidate)}(?![A-Za-z0-9_])`).test(currentValue)
}
while (labelIsUsed(label)) {
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/hooks/use-skill-auto-mention.ts b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/hooks/use-skill-auto-mention.ts
index 2ce6adcf83e..68aec3558e9 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/hooks/use-skill-auto-mention.ts
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/hooks/use-skill-auto-mention.ts
@@ -1,8 +1,6 @@
import { useCallback, useMemo, useRef } from 'react'
-import {
- escapeRegex,
- SKILL_CHIP_TRIGGER,
-} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/utils'
+import { escapeRegExp } from '@sim/utils/string'
+import { SKILL_CHIP_TRIGGER } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/utils'
import type { McpServer } from '@/hooks/queries/mcp'
import type { SkillDefinition } from '@/hooks/queries/skills'
import type { ChatContext } from '@/stores/panel'
@@ -104,8 +102,8 @@ export function useSkillAutoMention({
// Match either trigger: the typed '/' or the stored sentinel, so both fresh
// input and pasted/restored chips resolve. The trigger group is the match's
// first char (`text[match.index]`); group 1 is the skill name.
- const trigger = `(?:/|${escapeRegex(SKILL_CHIP_TRIGGER)})`
- const pattern = `${trigger}(${names.map(escapeRegex).join('|')})(?![A-Za-z0-9_-])`
+ const trigger = `(?:/|${escapeRegExp(SKILL_CHIP_TRIGGER)})`
+ const pattern = `${trigger}(${names.map(escapeRegExp).join('|')})(?![A-Za-z0-9_-])`
return { regex: new RegExp(pattern, 'gi'), byName }
}, [skills, mcpServers])
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/user-message-content/user-message-content.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/user-message-content/user-message-content.tsx
index 00e16bdb723..d807f00a5be 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/components/user-message-content/user-message-content.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/user-message-content/user-message-content.tsx
@@ -2,6 +2,7 @@
import { useMemo } from 'react'
import { cn } from '@sim/emcn'
+import { escapeRegExp } from '@sim/utils/string'
import { ContextMentionIcon } from '@/app/workspace/[workspaceId]/home/components/context-mention-icon'
import type { ChatMessageContext } from '@/app/workspace/[workspaceId]/home/types'
import { getIntegrationMatcher } from '@/blocks/integration-matcher'
@@ -22,10 +23,6 @@ interface UserMessageContentProps {
compact?: boolean
}
-function escapeRegex(str: string): string {
- return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
-}
-
interface MentionRange {
start: number
end: number
@@ -54,7 +51,7 @@ function computeMentionRanges(text: string, contexts: ChatMessageContext[]): Men
const ctx = withResolvedBlockType(rawCtx)
const prefix = ctx.kind === 'skill' || ctx.kind === 'mcp' ? '/' : '@'
const token = `${prefix}${ctx.label}`
- const pattern = new RegExp(`(^|\\s)(${escapeRegex(token)})(\\s|$)`, 'g')
+ const pattern = new RegExp(`(^|\\s)(${escapeRegExp(token)})(\\s|$)`, 'g')
let match: RegExpExecArray | null
while ((match = pattern.exec(text)) !== null) {
const leadingSpace = match[1]
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/utils.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/utils.ts
index ac670cb9875..e6389b08116 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/utils.ts
+++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/utils.ts
@@ -1,3 +1,4 @@
+import { escapeRegExp } from '@sim/utils/string'
import {
FOLDER_CONFIGS,
type MentionFolderId,
@@ -24,15 +25,6 @@ export function restoreSkillTriggerText(text: string): string {
return text.replaceAll(SKILL_CHIP_TRIGGER, '/')
}
-/**
- * Escapes special regex characters in a string
- * @param value - String to escape
- * @returns Escaped string safe for use in RegExp
- */
-export function escapeRegex(value: string): string {
- return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
-}
-
/**
* Extracts mention tokens from contexts for display/matching
* Filters out current_workflow contexts and builds prefixed labels
@@ -107,7 +99,7 @@ export function computeMentionHighlightRanges(
if (!tokens.length || !text) return []
const longestFirstTokens = [...new Set(tokens)].sort((a, b) => b.length - a.length)
- const pattern = new RegExp(`(${longestFirstTokens.map(escapeRegex).join('|')})`, 'g')
+ const pattern = new RegExp(`(${longestFirstTokens.map(escapeRegExp).join('|')})`, 'g')
const ranges: MentionHighlightRange[] = []
let match: RegExpExecArray | null
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/components/code-editor/code-editor.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/components/code-editor/code-editor.tsx
index b1ac077b0e9..d0a6b84f7dd 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/components/code-editor/code-editor.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/components/code-editor/code-editor.tsx
@@ -10,6 +10,7 @@ import {
highlight,
languages,
} from '@sim/emcn'
+import { escapeRegExp } from '@sim/utils/string'
import Editor from 'react-simple-code-editor'
import type { SchemaParameter } from '@/app/workspace/[workspaceId]/components/custom-tool-editor/custom-tool-schema'
import {
@@ -152,7 +153,7 @@ export function CodeEditor({
if (schemaParameters.length > 0) {
schemaParameters.forEach((param) => {
- const escapedName = param.name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
+ const escapedName = escapeRegExp(param.name)
const paramRegex = new RegExp(`\\b(${escapedName})\\b`, 'g')
processedCode = processedCode.replace(paramRegex, (match) => {
const placeholder = `__PARAM_${placeholders.length}__`
diff --git a/apps/sim/blocks/integration-matcher.ts b/apps/sim/blocks/integration-matcher.ts
index dad46f2c134..bd022122b4c 100644
--- a/apps/sim/blocks/integration-matcher.ts
+++ b/apps/sim/blocks/integration-matcher.ts
@@ -1,3 +1,4 @@
+import { escapeRegExp } from '@sim/utils/string'
import { LandingPromptStorage } from '@/lib/core/utils/browser-storage'
import { getCanonicalBlocksByCategory } from '@/blocks/registry'
import type { BlockIcon } from '@/blocks/types'
@@ -32,10 +33,6 @@ export interface IntegrationMatcher {
byName: Map
}
-function escapeRegex(value: string): string {
- return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
-}
-
/** Strips ` (Legacy)` / ` V2` suffixes so the display uses the natural name. */
function normalizeDisplayName(name: string): string {
return name
@@ -81,7 +78,7 @@ function buildMatcher(): IntegrationMatcher {
names.sort((a, b) => b.length - a.length)
const regex = names.length
- ? new RegExp(`(?): EnterpriseAttribut
const manager = value.manager
if (typeof manager === 'string' && manager.trim()) {
enterprise.manager = { value: manager.trim() }
- } else if (isRecord(manager)) {
+ } else if (isRecordLike(manager)) {
const managerValue = text(manager.value)
const displayName = text(manager.displayName)
if (managerValue || displayName) {
@@ -195,6 +196,6 @@ export function toCanonicalGroup(body: ScimGroupWriteParsed): CanonicalScimGroup
/** Reads the member id out of a PATCH value entry, which may be bare or wrapped. */
export function readMemberValue(entry: unknown): string {
if (typeof entry === 'string') return entry.trim()
- if (isRecord(entry) && typeof entry.value === 'string') return entry.value.trim()
+ if (isRecordLike(entry) && typeof entry.value === 'string') return entry.value.trim()
throw invalidValue('Group member entries require a value')
}
diff --git a/apps/sim/ee/scim/lib/protocol/group-patch.ts b/apps/sim/ee/scim/lib/protocol/group-patch.ts
index 0f86d5e5543..6f9f8f0127b 100644
--- a/apps/sim/ee/scim/lib/protocol/group-patch.ts
+++ b/apps/sim/ee/scim/lib/protocol/group-patch.ts
@@ -1,7 +1,8 @@
+import { isRecordLike } from '@sim/utils/object'
import type { ScimPatchOperation } from '@/lib/api/contracts/scim'
import { readMemberValue } from '@/ee/scim/lib/protocol/canonical'
import { invalidPath, invalidValue, mutability, noTarget } from '@/ee/scim/lib/protocol/errors'
-import { isRecord, normalizeAttributePath } from '@/ee/scim/lib/protocol/normalize'
+import { normalizeAttributePath } from '@/ee/scim/lib/protocol/normalize'
/**
* A parsed Group PATCH.
@@ -86,7 +87,7 @@ export function parseGroupPatch(operations: readonly ScimPatchOperation[]): Grou
if (!operation.path) {
const value = operation.value
- if (!isRecord(value)) {
+ if (!isRecordLike(value)) {
throw invalidValue('A PATCH operation without a path requires an object value')
}
incremental = false
diff --git a/apps/sim/ee/scim/lib/protocol/normalize.ts b/apps/sim/ee/scim/lib/protocol/normalize.ts
index 41e898aea2d..4d7f4ac6c0b 100644
--- a/apps/sim/ee/scim/lib/protocol/normalize.ts
+++ b/apps/sim/ee/scim/lib/protocol/normalize.ts
@@ -1,3 +1,5 @@
+import { isRecordLike } from '@sim/utils/object'
+
/**
* Tolerances for what identity providers actually send, as distinct from what
* RFC 7644 describes.
@@ -35,11 +37,6 @@ export function unwrapSingleElement(value: unknown): unknown {
return Array.isArray(value) && value.length === 1 ? value[0] : value
}
-/** True when the value is a plain object rather than an array or null. */
-export function isRecord(value: unknown): value is Record {
- return typeof value === 'object' && value !== null && !Array.isArray(value)
-}
-
/**
* Strips a schema URN prefix from an attribute path and decodes it.
*
@@ -94,7 +91,7 @@ export function canonicalizeAttributeNames(
body: unknown,
canonicalNames: readonly string[]
): unknown {
- if (!isRecord(body)) return body
+ if (!isRecordLike(body)) return body
const byLower = new Map(canonicalNames.map((name) => [name.toLowerCase(), name]))
const result: Record = {}
for (const [key, value] of Object.entries(body)) {
diff --git a/apps/sim/ee/scim/lib/protocol/resources.ts b/apps/sim/ee/scim/lib/protocol/resources.ts
index 739df0bdbee..2a71cf8c78c 100644
--- a/apps/sim/ee/scim/lib/protocol/resources.ts
+++ b/apps/sim/ee/scim/lib/protocol/resources.ts
@@ -1,4 +1,5 @@
import type { ScimUserAttributes } from '@sim/db/schema'
+import { isRecordLike } from '@sim/utils/object'
import {
SCIM_ENTERPRISE_USER_SCHEMA,
SCIM_GROUP_SCHEMA,
@@ -7,11 +8,7 @@ import {
SCIM_USER_SCHEMA,
} from '@/ee/scim/lib/protocol/constants'
import { invalidValue } from '@/ee/scim/lib/protocol/errors'
-import {
- isRecord,
- isScimPasswordAttribute,
- normalizeAttributePath,
-} from '@/ee/scim/lib/protocol/normalize'
+import { isScimPasswordAttribute, normalizeAttributePath } from '@/ee/scim/lib/protocol/normalize'
export interface ScimResourceMeta {
resourceType: 'User' | 'Group'
@@ -291,7 +288,7 @@ function projectAttribute(
.filter((entry) => entry !== undefined)
return included || projected.length > 0 ? projected : undefined
}
- if (!isRecord(value)) return included ? value : undefined
+ if (!isRecordLike(value)) return included ? value : undefined
const projected: Record = {}
for (const [key, nested] of Object.entries(value)) {
const selected = projectAttribute(
diff --git a/apps/sim/ee/scim/lib/protocol/user-patch.ts b/apps/sim/ee/scim/lib/protocol/user-patch.ts
index 5c0ee34443f..d71f9684cfc 100644
--- a/apps/sim/ee/scim/lib/protocol/user-patch.ts
+++ b/apps/sim/ee/scim/lib/protocol/user-patch.ts
@@ -1,8 +1,8 @@
import type { ScimUserAttributes, ScimUserEmail } from '@sim/db/schema'
+import { isRecordLike } from '@sim/utils/object'
import type { ScimPatchOperation } from '@/lib/api/contracts/scim'
import { invalidPath, invalidValue, mutability, noTarget } from '@/ee/scim/lib/protocol/errors'
import {
- isRecord,
isScimPasswordAttribute,
normalizeAttributePath,
normalizeScimBoolean,
@@ -89,7 +89,7 @@ function normalizeEmailList(
const entries = Array.isArray(value) ? value : [value]
const normalized: ScimUserEmail[] = []
for (const entry of entries) {
- if (!isRecord(entry)) throw invalidValue(`${attribute} entries must be objects`)
+ if (!isRecordLike(entry)) throw invalidValue(`${attribute} entries must be objects`)
const address = requireString(entry.value, `${attribute}.value`)
normalized.push({
value: address.toLowerCase(),
@@ -146,7 +146,7 @@ function applyOperation(
else user.enterprise = undefined
return
}
- if (!isRecord(value)) throw invalidValue(`${path} requires an object value`)
+ if (!isRecordLike(value)) throw invalidValue(`${path} requires an object value`)
for (const [sub, nested] of sortFormattedLast(Object.entries(value))) {
applyOperation(user, op, `${path}.${sub}`, nested)
}
@@ -253,7 +253,7 @@ function applyOperation(
user.enterprise.manager = { value: unwrapped }
return
}
- if (isRecord(unwrapped)) {
+ if (isRecordLike(unwrapped)) {
user.enterprise.manager = {
...(typeof unwrapped.value === 'string' ? { value: unwrapped.value } : {}),
...(typeof unwrapped.displayName === 'string'
@@ -301,7 +301,7 @@ function extensionTarget(
...(path.length > existing.length ? { attribute: path.slice(existing.length + 1) } : {}),
}
}
- if (resourceAttribute && isRecord(value)) return { schema: path }
+ if (resourceAttribute && isRecordLike(value)) return { schema: path }
const separator = path.lastIndexOf(':')
if (separator <= 'urn:'.length) throw invalidPath(`User PATCH path ${path} is not supported`)
return { schema: path.slice(0, separator), attribute: path.slice(separator + 1) }
@@ -329,14 +329,14 @@ function applyExtraOperation(
if (!extension.attribute) {
if (op === 'remove') delete user.extra[extension.schema]
else {
- if (!isRecord(value)) throw invalidValue(`${path} requires an object value`)
+ if (!isRecordLike(value)) throw invalidValue(`${path} requires an object value`)
const current = user.extra[extension.schema]
- user.extra[extension.schema] = { ...(isRecord(current) ? current : {}), ...value }
+ user.extra[extension.schema] = { ...(isRecordLike(current) ? current : {}), ...value }
}
return
}
const current = user.extra[extension.schema]
- const attributes = isRecord(current) ? { ...current } : {}
+ const attributes = isRecordLike(current) ? { ...current } : {}
applyExtraAttribute(attributes, op, extension.attribute, value)
user.extra[extension.schema] = attributes
return
@@ -368,18 +368,18 @@ function applyExtraAttribute(
if (type) {
const list = Array.isArray(attributes[attribute]) ? [...attributes[attribute]] : []
const index = list.findIndex(
- (entry) => isRecord(entry) && String(entry.type).toLowerCase() === type.toLowerCase()
+ (entry) => isRecordLike(entry) && String(entry.type).toLowerCase() === type.toLowerCase()
)
if (op === 'remove' && !sub) {
if (index !== -1) list.splice(index, 1)
} else if (sub) {
if (op === 'remove' && index === -1) return
- const current = index !== -1 && isRecord(list[index]) ? list[index] : { type }
+ const current = index !== -1 && isRecordLike(list[index]) ? list[index] : { type }
const key = Object.keys(current).find((key) => key.toLowerCase() === sub.toLowerCase()) ?? sub
const next = { ...current, [key]: op === 'remove' ? undefined : value }
if (index === -1) list.push(next)
else list[index] = next
- } else if (isRecord(value)) {
+ } else if (isRecordLike(value)) {
if (index === -1) list.push({ type, ...value })
else list[index] = { ...(list[index] as Record), ...value }
} else {
@@ -389,7 +389,7 @@ function applyExtraAttribute(
return
}
- const current = isRecord(attributes[attribute]) ? { ...attributes[attribute] } : {}
+ const current = isRecordLike(attributes[attribute]) ? { ...attributes[attribute] } : {}
const key = Object.keys(current).find((key) => key.toLowerCase() === sub.toLowerCase()) ?? sub
current[key] = op === 'remove' ? undefined : value
attributes[attribute] = current
@@ -404,7 +404,7 @@ function applyExtraAttribute(
*/
function sortDeep(value: unknown): unknown {
if (Array.isArray(value)) return value.map(sortDeep)
- if (!isRecord(value)) return value
+ if (!isRecordLike(value)) return value
const sorted: Record = {}
for (const key of Object.keys(value).sort()) sorted[key] = sortDeep(value[key])
return sorted
@@ -454,7 +454,7 @@ export function applyUserPatch(
if (!operation.path) {
const value = operation.value
- if (!isRecord(value)) {
+ if (!isRecordLike(value)) {
throw invalidValue('A PATCH operation without a path requires an object value')
}
/**
diff --git a/apps/sim/executor/constants.ts b/apps/sim/executor/constants.ts
index 353bc846826..e0b4b08c283 100644
--- a/apps/sim/executor/constants.ts
+++ b/apps/sim/executor/constants.ts
@@ -469,10 +469,6 @@ export function stripCustomToolPrefix(name: string): string {
: name
}
-export function escapeRegExp(value: string): string {
- return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
-}
-
/**
* Normalizes a name for comparison by converting to lowercase and removing
* spaces and dots. Used for both block names and variable names to ensure
diff --git a/apps/sim/executor/utils/file-tool-processor.ts b/apps/sim/executor/utils/file-tool-processor.ts
index e81a2134539..f10d286cbc9 100644
--- a/apps/sim/executor/utils/file-tool-processor.ts
+++ b/apps/sim/executor/utils/file-tool-processor.ts
@@ -1,6 +1,6 @@
import { createLogger } from '@sim/logger'
import { toError } from '@sim/utils/errors'
-import { omit } from '@sim/utils/object'
+import { isRecordLike, omit } from '@sim/utils/object'
import { isCanonicalBase64 } from '@/lib/api/contracts/primitives'
import { isUserFile, type UserFileLike } from '@/lib/core/utils/user-file'
import {
@@ -16,10 +16,6 @@ import type { ToolDefinition } from '@/tools/types'
const logger = createLogger('FileToolProcessor')
-function isRecord(value: unknown): value is Record {
- return typeof value === 'object' && value !== null && !Array.isArray(value)
-}
-
/** Strip a data URI prefix while preserving legitimate zero-byte payloads. */
function stripBase64DataUri(value: string): string {
return /^data:[^,]*;base64,/i.test(value) ? value.slice(value.indexOf(',') + 1) : value
@@ -113,7 +109,7 @@ export class FileToolProcessor {
const files = outputDef.type === 'file[]' && Array.isArray(value) ? value : [value]
for (const file of files) {
signal?.throwIfAborted()
- if (!isRecord(file)) throw new Error('File output must be a file object')
+ if (!isRecordLike(file)) throw new Error('File output must be a file object')
if (isUserFile(file)) {
if (file.base64 !== undefined) replacements.set(file, omit(file, ['base64']))
continue
@@ -137,7 +133,7 @@ export class FileToolProcessor {
})
if (replacements.size === 0) return toolOutput
const output = replaceFileReferences(toolOutput, replacements)
- if (!isRecord(output)) throw new Error('Tool file output must be an object')
+ if (!isRecordLike(output)) throw new Error('Tool file output must be an object')
return output
}
if (pendingFiles.size === 0) return present([])
@@ -145,7 +141,7 @@ export class FileToolProcessor {
createInternalToolFilesResult([...pendingFiles.values()], present),
context,
(output) => {
- if (!isRecord(output)) throw new Error('Tool file output must be an object')
+ if (!isRecordLike(output)) throw new Error('Tool file output must be an object')
return output
},
signal
@@ -178,7 +174,7 @@ export class FileToolProcessor {
data instanceof ArrayBuffer
? Buffer.from(data)
: Buffer.from(data.buffer, data.byteOffset, data.byteLength)
- } else if (Array.isArray(data) || (isRecord(data) && data.type === 'Buffer')) {
+ } else if (Array.isArray(data) || (isRecordLike(data) && data.type === 'Buffer')) {
const bytes = Array.isArray(data) ? data : data.data
if (!Array.isArray(bytes)) throw new Error(`Invalid serialized buffer format for ${name}`)
assertFileSize(bytes.length, name, remainingBytes)
diff --git a/apps/sim/executor/utils/resolved-secret-matcher-capacity.ts b/apps/sim/executor/utils/resolved-secret-matcher-capacity.ts
index 6a64d5fd806..590a81e815c 100644
--- a/apps/sim/executor/utils/resolved-secret-matcher-capacity.ts
+++ b/apps/sim/executor/utils/resolved-secret-matcher-capacity.ts
@@ -1,14 +1,10 @@
+import { compareStrings } from '@sim/utils/string'
+
const MAX_MATCHER_NODES = 250_000
const MAX_SECRET_LITERAL_LENGTH = 64 * 1024
export type ResolvedSecretMatcherCapacityFailure = 'literal-too-long' | 'node-limit-exceeded'
-function compareStrings(left: string, right: string): number {
- if (left < right) return -1
- if (left > right) return 1
- return 0
-}
-
function commonPrefixLength(left: string, right: string): number {
const limit = Math.min(left.length, right.length)
let index = 0
diff --git a/apps/sim/executor/utils/resolved-secret-matcher.ts b/apps/sim/executor/utils/resolved-secret-matcher.ts
index cd08ceecaf5..7ea20237747 100644
--- a/apps/sim/executor/utils/resolved-secret-matcher.ts
+++ b/apps/sim/executor/utils/resolved-secret-matcher.ts
@@ -1,3 +1,4 @@
+import { compareStrings } from '@sim/utils/string'
import { MAX_INLINE_MATERIALIZATION_BYTES } from '@/lib/execution/payloads/limits'
import { isNonIdentifyingSecretLiteral } from '@/executor/utils/resolved-secret-match-policy'
import { getResolvedSecretMatcherCapacityFailure } from '@/executor/utils/resolved-secret-matcher-capacity'
@@ -49,12 +50,6 @@ class ResolvedSecretMatcherError extends Error {
}
}
-function compareStrings(left: string, right: string): number {
- if (left < right) return -1
- if (left > right) return 1
- return 0
-}
-
function createMatcherFromReplacements(
replacements: readonly SecretReplacement[]
): ResolvedSecretMatcher {
diff --git a/apps/sim/executor/utils/resolved-secret-trace-registry.ts b/apps/sim/executor/utils/resolved-secret-trace-registry.ts
index 925a6d18bf5..cc0bb3bf1fc 100644
--- a/apps/sim/executor/utils/resolved-secret-trace-registry.ts
+++ b/apps/sim/executor/utils/resolved-secret-trace-registry.ts
@@ -1,5 +1,6 @@
import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
+import { compareStrings } from '@sim/utils/string'
import { decryptSecret } from '@/lib/core/security/encryption'
import { isLargeArrayManifest } from '@/lib/execution/payloads/large-array-manifest-metadata'
import { isLargeValueRef } from '@/lib/execution/payloads/large-value-ref'
@@ -443,12 +444,6 @@ export interface CreateResolvedSecretTraceRegistryOptions {
workspaceUnredactedKeys?: readonly string[]
}
-function compareStrings(left: string, right: string): number {
- if (left < right) return -1
- if (left > right) return 1
- return 0
-}
-
function cloneProvenanceScope(scope: ResolvedSecretTraceScopeV1): ResolvedSecretTraceScopeV1 {
return {
userId: scope.userId,
diff --git a/apps/sim/lib/catalog/application/catalog-page.ts b/apps/sim/lib/catalog/application/catalog-page.ts
index a280d4b1e20..eaf19d0aa9a 100644
--- a/apps/sim/lib/catalog/application/catalog-page.ts
+++ b/apps/sim/lib/catalog/application/catalog-page.ts
@@ -1,3 +1,4 @@
+import { compareStrings } from '@sim/utils/string'
import type { V2SortOrder } from '@/lib/api/contracts/v2/shared'
import { OrchestrationError } from '@/lib/core/orchestration/types'
@@ -33,29 +34,14 @@ export function matchesCatalogSearch(
return fields.some((field) => field?.toLowerCase().includes(term))
}
-/**
- * Orders two strings by UTF-16 code unit, deliberately not by `localeCompare`.
- *
- * A bare `localeCompare` reads the process's default locale and ICU data, so two
- * app instances started with different `LANG` values order the same set
- * differently — and an offset cursor minted on one then names a different row on
- * the other, silently skipping or repeating entries. Code-unit order is the same
- * everywhere, which is the property a cursor needs; catalog ids and names are
- * ASCII, so nothing human-visible changes.
- */
-function compareCodeUnits(left: string, right: string): number {
- if (left < right) return -1
- if (left > right) return 1
- return 0
-}
-
/**
* Sorts a copy by one string field, breaking ties on `id`.
*
* The tie-break is what makes an offset cursor sound: two entries comparing
* equal on the sort field must still hold a fixed order, or the position a
* cursor names moves between requests. `id` is unique across every catalog, so
- * it fully orders each one.
+ * it fully orders each one. {@link compareStrings} keeps that order identical on
+ * every instance; a `localeCompare` here would not.
*/
export function sortCatalogEntries(
entries: readonly T[],
@@ -64,9 +50,9 @@ export function sortCatalogEntries(
): T[] {
const direction = sortOrder === 'desc' ? -1 : 1
return [...entries].sort((left, right) => {
- const compared = compareCodeUnits(select(left), select(right))
+ const compared = compareStrings(select(left), select(right))
if (compared !== 0) return compared * direction
- return compareCodeUnits(left.id, right.id) * direction
+ return compareStrings(left.id, right.id) * direction
})
}
diff --git a/apps/sim/lib/chunkers/structured-data-chunker.ts b/apps/sim/lib/chunkers/structured-data-chunker.ts
index 74522c251fe..e2188455347 100644
--- a/apps/sim/lib/chunkers/structured-data-chunker.ts
+++ b/apps/sim/lib/chunkers/structured-data-chunker.ts
@@ -1,4 +1,5 @@
import { createLogger } from '@sim/logger'
+import { escapeRegExp } from '@sim/utils/string'
import { ChunkBudget } from '@/lib/chunkers/chunk-budget'
import type { Chunk, StructuredDataOptions } from '@/lib/chunkers/types'
import {
@@ -252,7 +253,7 @@ export class StructuredDataChunker {
const delimiters = [',', '\t', '|']
for (const delimiter of delimiters) {
- const escaped = delimiter.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
+ const escaped = escapeRegExp(delimiter)
const counts = lines.map((line) => (line.match(new RegExp(escaped, 'g')) || []).length)
const avgCount = counts.reduce((a, b) => a + b, 0) / counts.length
diff --git a/apps/sim/lib/copilot/chat/process-contents.ts b/apps/sim/lib/copilot/chat/process-contents.ts
index a098a28db70..22af34bd256 100644
--- a/apps/sim/lib/copilot/chat/process-contents.ts
+++ b/apps/sim/lib/copilot/chat/process-contents.ts
@@ -4,6 +4,7 @@ import {
authorizeWorkflowByWorkspacePermission,
getActiveWorkflowRecord,
} from '@sim/platform-authz/workflow'
+import { escapeRegExp } from '@sim/utils/string'
import { eq } from 'drizzle-orm'
import { createCopilotChatKnowledgePrincipal } from '@/lib/copilot/application/execute-knowledge-use-case'
import { createCopilotChatPrincipal } from '@/lib/copilot/auth/application-delegation'
@@ -59,7 +60,6 @@ import { workflowDelegationPolicy } from '@/lib/workflows/application/authorizat
import { readWorkflowMetadata } from '@/lib/workflows/application/read-workflow'
import { readWorkspaceFileMetadata } from '@/lib/workspace-files/application/read-workspace-file-metadata'
import { getBlockRegistry } from '@/blocks/registry'
-import { escapeRegExp } from '@/executor/constants'
import type { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry'
import type { BrowserTextSelection, ChatContext, TerminalTextSelection } from '@/stores/panel'
diff --git a/apps/sim/lib/copilot/tools/streaming-args.ts b/apps/sim/lib/copilot/tools/streaming-args.ts
index 80bfb112245..c7672990b77 100644
--- a/apps/sim/lib/copilot/tools/streaming-args.ts
+++ b/apps/sim/lib/copilot/tools/streaming-args.ts
@@ -1,10 +1,12 @@
+import { escapeRegExp } from '@sim/utils/string'
+
/** Extract a completed JSON string field from an argument buffer that may still be partial. */
export function extractStreamingStringArgument(
input: string | undefined,
key: string
): string | undefined {
if (!input) return undefined
- const escapedKey = key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
+ const escapedKey = escapeRegExp(key)
const match = new RegExp(`"${escapedKey}"\\s*:\\s*"((?:\\\\.|[^"\\\\])*)"`).exec(input)
if (!match?.[1]) return undefined
try {
diff --git a/apps/sim/lib/core/security/linear-regex.ts b/apps/sim/lib/core/security/linear-regex.ts
index 7c17a693404..83c45b186cb 100644
--- a/apps/sim/lib/core/security/linear-regex.ts
+++ b/apps/sim/lib/core/security/linear-regex.ts
@@ -1,3 +1,4 @@
+import { escapeRegExp, hasRegexMetacharacter } from '@sim/utils/string'
import { RE2JS } from 're2js'
/**
@@ -43,16 +44,9 @@ export interface LinearRegex {
iterateSplits(text: string): IterableIterator
}
-const METACHARACTERS = /[.*+?^${}()|[\]\\]/
-
-/** Escape every regex metacharacter so `input` matches only itself. */
-function escapeRegExp(input: string): string {
- return input.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
-}
-
/** True when `pattern` has no metacharacter, so both engines behave identically. */
export function isPlainText(pattern: string): boolean {
- return !METACHARACTERS.test(pattern)
+ return !hasRegexMetacharacter(pattern)
}
/**
diff --git a/apps/sim/lib/core/security/redaction.ts b/apps/sim/lib/core/security/redaction.ts
index af6ea2c5487..94f5ccf6866 100644
--- a/apps/sim/lib/core/security/redaction.ts
+++ b/apps/sim/lib/core/security/redaction.ts
@@ -2,6 +2,7 @@
* Centralized redaction utilities for sensitive data
*/
+import { escapeRegExp } from '@sim/utils/string'
import { filterUserFileForDisplay, isUserFile } from '@/lib/core/utils/user-file'
export const REDACTED_MARKER = '[REDACTED]'
@@ -433,7 +434,7 @@ export function redactKnownSensitiveValues(value: string, secrets: string[]): st
}
for (const encoded of encodedVariants) {
if (encoded !== secret) {
- const escaped = encoded.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
+ const escaped = escapeRegExp(encoded)
result = result.replace(new RegExp(escaped, 'gi'), REDACTED_MARKER)
}
}
diff --git a/apps/sim/lib/execution/durable-secret-provenance.ts b/apps/sim/lib/execution/durable-secret-provenance.ts
index 2e7900dfd0c..980c666298f 100644
--- a/apps/sim/lib/execution/durable-secret-provenance.ts
+++ b/apps/sim/lib/execution/durable-secret-provenance.ts
@@ -1,5 +1,6 @@
import { createHash } from 'node:crypto'
import type { DurableSecretProvenanceEntry } from '@sim/db/schema'
+import { compareStrings } from '@sim/utils/string'
import {
isPrivateSecretProvenanceBundleV1,
type PrivateSecretProvenanceBundleV1,
@@ -24,10 +25,6 @@ export const EXACT_EMPTY_DURABLE_SECRET_PROVENANCE = Object.freeze({
entries: Object.freeze([]),
})
-function compareStrings(left: string, right: string): number {
- return left < right ? -1 : left > right ? 1 : 0
-}
-
/** Normalizes one private sidecar payload and enforces the shared resource bounds. */
export function normalizeDurableSecretProvenanceEntries(
value: unknown
diff --git a/apps/sim/lib/execution/mounted-file-secret-provenance.ts b/apps/sim/lib/execution/mounted-file-secret-provenance.ts
index 15ec17b42fa..1bf6003ad6c 100644
--- a/apps/sim/lib/execution/mounted-file-secret-provenance.ts
+++ b/apps/sim/lib/execution/mounted-file-secret-provenance.ts
@@ -1,4 +1,5 @@
import type { WorkspaceFileSecretProvenanceEntry } from '@sim/db/schema'
+import { compareStrings } from '@sim/utils/string'
import { decryptSecret } from '@/lib/core/security/encryption'
import type { WorkspaceFileSecretProvenance } from '@/lib/uploads/contexts/workspace/workspace-file-secret-provenance'
import {
@@ -27,10 +28,6 @@ const UNKNOWN_MOUNTED_FILE_SECRET_PROVENANCE_SCANNER: MountedFileSecretProvenanc
scan: () => ({ status: 'unknown' }),
}
-function compareStrings(left: string, right: string): number {
- return left < right ? -1 : left > right ? 1 : 0
-}
-
/**
* Builds a bounded output-file classifier from encrypted provenance carried across the trusted
* Function request boundary. Plaintext exists only in this route-local scanner and is never added
diff --git a/apps/sim/lib/function-execution/execute-request.ts b/apps/sim/lib/function-execution/execute-request.ts
index af34bdf672e..964d6721c14 100644
--- a/apps/sim/lib/function-execution/execute-request.ts
+++ b/apps/sim/lib/function-execution/execute-request.ts
@@ -3,6 +3,7 @@ import { createLogger } from '@sim/logger'
import { sha256Hex } from '@sim/security/hash'
import { getErrorMessage } from '@sim/utils/errors'
import { toRecord } from '@sim/utils/object'
+import { escapeRegExp } from '@sim/utils/string'
import { NextResponse } from 'next/server'
import type { ParsedFunctionExecuteBody } from '@/lib/api/contracts'
import {
@@ -108,7 +109,7 @@ import { rebindWorkspaceFileDelegatedPrincipal } from '@/lib/workspace-files/app
import { fileOperations } from '@/lib/workspace-files/application/operations'
import { readWorkspaceFileContent } from '@/lib/workspace-files/application/read-workspace-file-content'
import { resolveWorkspaceFileReference } from '@/lib/workspace-files/application/resolve-workspace-file-reference'
-import { escapeRegExp, normalizeName, REFERENCE, sanitizeFileName } from '@/executor/constants'
+import { normalizeName, REFERENCE, sanitizeFileName } from '@/executor/constants'
import type { UserFile } from '@/executor/types'
import { type OutputSchema, resolveBlockReference } from '@/executor/utils/block-reference'
import {
diff --git a/apps/sim/lib/internal/buffer/operations.ts b/apps/sim/lib/internal/buffer/operations.ts
index 644846f7179..251cb316906 100644
--- a/apps/sim/lib/internal/buffer/operations.ts
+++ b/apps/sim/lib/internal/buffer/operations.ts
@@ -1,5 +1,6 @@
import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
+import { isRecordLike } from '@sim/utils/object'
import type { EgressProfile } from '@/lib/core/security/egress/profiles'
import {
secureFetchWithPinnedIP,
@@ -50,10 +51,6 @@ export interface BufferOperationContext {
signal?: AbortSignal
}
-function isRecord(value: unknown): value is Record {
- return typeof value === 'object' && value !== null && !Array.isArray(value)
-}
-
function mediaKindFromExtension(pathOrName: string): 'image' | 'video' | null {
const lowered = pathOrName.toLowerCase().split(/[?#]/)[0]
if (VIDEO_EXTENSIONS.some((extension) => lowered.endsWith(extension))) return 'video'
@@ -169,7 +166,7 @@ async function executePostMutation(args: {
})
const data = await parseBufferGraphQLResponse(response)
const candidate = data.createPost ?? data.editPost
- result = isRecord(candidate) ? candidate : {}
+ result = isRecordLike(candidate) ? candidate : {}
} catch (error) {
context.signal?.throwIfAborted()
const message = getErrorMessage(error, 'Buffer API request failed')
@@ -177,7 +174,7 @@ async function executePostMutation(args: {
throw new BufferOperationError(message, 502)
}
- if (result.__typename !== 'PostActionSuccess' || !isRecord(result.post)) {
+ if (result.__typename !== 'PostActionSuccess' || !isRecordLike(result.post)) {
const message = typeof result.message === 'string' ? result.message : 'Buffer rejected the post'
throw new BufferOperationError(message, 400)
}
diff --git a/apps/sim/lib/internal/sap-concur/client.ts b/apps/sim/lib/internal/sap-concur/client.ts
index bb77379101a..8a67bb75eab 100644
--- a/apps/sim/lib/internal/sap-concur/client.ts
+++ b/apps/sim/lib/internal/sap-concur/client.ts
@@ -3,6 +3,7 @@ import { createLogger } from '@sim/logger'
import { isPrivateIpHost } from '@sim/security/ssrf'
import { getErrorMessage } from '@sim/utils/errors'
import { generateId } from '@sim/utils/id'
+import { isRecordLike } from '@sim/utils/object'
import { truncate } from '@sim/utils/string'
import { env } from '@/lib/core/config/env'
import {
@@ -576,10 +577,6 @@ export function describeSapConcurFetchError(error: unknown): string {
return message
}
-function isRecord(value: unknown): value is Record {
- return typeof value === 'object' && value !== null && !Array.isArray(value)
-}
-
function nonEmptyString(value: unknown): string | undefined {
return typeof value === 'string' && value.length > 0 ? value : undefined
}
@@ -589,8 +586,8 @@ function nonEmptyString(value: unknown): string | undefined {
* `{ Content: { Error: { Message } } }` or `{ Error: { Message } }`.
*/
function legacyEnvelopeMessage(obj: Record): string | undefined {
- const container = isRecord(obj.Content) ? obj.Content : obj
- const error = isRecord(container.Error) ? container.Error : undefined
+ const container = isRecordLike(obj.Content) ? obj.Content : obj
+ const error = isRecordLike(container.Error) ? container.Error : undefined
return error ? nonEmptyString(error.Message) : undefined
}
@@ -602,7 +599,7 @@ const SCIM_CONCUR_ERROR_URN = 'urn:ietf:params:scim:api:messages:concur:2.0:Erro
* `errorType` is kept because it distinguishes a hard `ERROR` from a `WARNING`.
*/
function formatErrorMessageListEntry(entry: unknown): string {
- if (!isRecord(entry)) return String(entry)
+ if (!isRecordLike(entry)) return String(entry)
const label = [nonEmptyString(entry.errorType), nonEmptyString(entry.errorCode)]
.filter(Boolean)
.join(' ')
@@ -612,7 +609,7 @@ function formatErrorMessageListEntry(entry: unknown): string {
/** Render one Concur SCIM extension message (`{ code, message, schemaPath, type }`). */
function formatScimExtensionMessage(entry: unknown): string {
- if (!isRecord(entry)) return String(entry)
+ if (!isRecordLike(entry)) return String(entry)
const code = nonEmptyString(entry.code)
const message = nonEmptyString(entry.message) ?? ''
const schemaPath = nonEmptyString(entry.schemaPath)
@@ -635,7 +632,7 @@ function joinNonEmpty(values: unknown[], format: (value: unknown) => string): st
* `message` is unwrapped once before the string-valued `message` shape is considered.
*/
function extractFromRecord(obj: Record, depth: number): string | undefined {
- if (depth === 0 && isRecord(obj.message)) {
+ if (depth === 0 && isRecordLike(obj.message)) {
const nested = extractFromRecord(obj.message, depth + 1)
if (nested) return nested
}
@@ -652,7 +649,7 @@ function extractFromRecord(obj: Record, depth: number): string
if (errorMessage) {
const validationErrors = Array.isArray(obj.validationErrors)
? obj.validationErrors
- .map((v) => (isRecord(v) ? nonEmptyString(v.message) : undefined))
+ .map((v) => (isRecordLike(v) ? nonEmptyString(v.message) : undefined))
.filter((m): m is string => Boolean(m))
: []
return validationErrors.length > 0
@@ -672,7 +669,7 @@ function extractFromRecord(obj: Record, depth: number): string
}
const scimExtension = obj[SCIM_CONCUR_ERROR_URN]
- if (isRecord(scimExtension) && Array.isArray(scimExtension.messages)) {
+ if (isRecordLike(scimExtension) && Array.isArray(scimExtension.messages)) {
const joined = joinNonEmpty(scimExtension.messages, formatScimExtensionMessage)
if (joined) return joined
}
@@ -685,7 +682,7 @@ function extractFromRecord(obj: Record, depth: number): string
if (Array.isArray(obj.errors) && obj.errors.length > 0) {
return joinNonEmpty(obj.errors, (e) => {
- if (!isRecord(e)) return String(e)
+ if (!isRecordLike(e)) return String(e)
const code = nonEmptyString(e.errorCode)
const msg = nonEmptyString(e.errorMessage) ?? ''
return `${code ? `[${code}] ` : ''}${msg}`.trim()
@@ -717,7 +714,7 @@ export function extractSapConcurError(
status: number,
options: ExtractSapConcurErrorOptions = {}
): string {
- if (isRecord(body)) {
+ if (isRecordLike(body)) {
const message = extractFromRecord(body, 0)
if (message) return message
}
diff --git a/apps/sim/lib/knowledge/search/snippet.ts b/apps/sim/lib/knowledge/search/snippet.ts
index dbc866c77fb..2315eeb0f98 100644
--- a/apps/sim/lib/knowledge/search/snippet.ts
+++ b/apps/sim/lib/knowledge/search/snippet.ts
@@ -1,3 +1,5 @@
+import { escapeRegExp } from '@sim/utils/string'
+
/** Characters of a document shown under a search result. */
export const SNIPPET_LENGTH = 280
/** Characters kept before the selected match, so the hit sits in context rather than at the edge. */
@@ -15,10 +17,6 @@ const HEADER_LINE = /^[A-Z][A-Za-z-]{1,15}: .*$/
const WORD_CHARACTER =
/(?![\p{sc=Han}\p{sc=Hiragana}\p{sc=Katakana}\p{sc=Hangul}\p{sc=Thai}])[\p{L}\p{N}_]/u
-function escapeRegExp(value: string): string {
- return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
-}
-
/**
* The document text without the header block some connectors prefix (the
* `Subject:` / `From:` / `To:` lines of an email): the title already says
diff --git a/apps/sim/lib/memory/retrieval.ts b/apps/sim/lib/memory/retrieval.ts
index 06d63cffe70..8b369e06a9b 100644
--- a/apps/sim/lib/memory/retrieval.ts
+++ b/apps/sim/lib/memory/retrieval.ts
@@ -1,5 +1,6 @@
import { createHash } from 'node:crypto'
import { isRecordLike } from '@sim/utils/object'
+import { escapeRegExp } from '@sim/utils/string'
import { z } from 'zod'
import { OrchestrationError } from '@/lib/core/orchestration/types'
import {
@@ -202,7 +203,7 @@ async function projectText(
function textChunk(text: string, offset: number, args: MemoryRetrievalArguments) {
const relativeMatch = args.query
- ? text.slice(offset).search(new RegExp(args.query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'iu'))
+ ? text.slice(offset).search(new RegExp(escapeRegExp(args.query), 'iu'))
: 0
const match = relativeMatch < 0 ? -1 : offset + relativeMatch
if (match < 0 || match >= text.length) return undefined
diff --git a/apps/sim/lib/microsoft-word/document.server.ts b/apps/sim/lib/microsoft-word/document.server.ts
index 1f5f9669c56..25782dd0fd6 100644
--- a/apps/sim/lib/microsoft-word/document.server.ts
+++ b/apps/sim/lib/microsoft-word/document.server.ts
@@ -1,3 +1,4 @@
+import { escapeRegExp } from '@sim/utils/string'
import { Document, HeadingLevel, Packer, Paragraph, TextRun } from 'docx'
import JSZip from 'jszip'
import { DocxParser } from '@/lib/file-parsers/docx-parser'
@@ -277,11 +278,6 @@ function decodeXmlText(value: string): string {
.replace(/&/g, '&')
}
-/** Escapes a literal for embedding in a regular expression. */
-function escapeRegExp(value: string): string {
- return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
-}
-
interface TextNode {
/** Offset of the whole `…` element within its paragraph. */
start: number
diff --git a/apps/sim/lib/table/rows/secret-provenance.ts b/apps/sim/lib/table/rows/secret-provenance.ts
index 7e4a952e304..2dc90cf0bdf 100644
--- a/apps/sim/lib/table/rows/secret-provenance.ts
+++ b/apps/sim/lib/table/rows/secret-provenance.ts
@@ -6,6 +6,7 @@ import {
userTableRows,
} from '@sim/db/schema'
import { createLogger } from '@sim/logger'
+import { compareStrings } from '@sim/utils/string'
import { and, asc, eq, gt, inArray, type SQL, sql } from 'drizzle-orm'
import { SecretProvenanceBudget } from '@/lib/execution/provenance-budget'
import {
@@ -121,12 +122,6 @@ function reportUnvouchedTableRowWrite(
})
}
-function compareStrings(left: string, right: string): number {
- if (left < right) return -1
- if (left > right) return 1
- return 0
-}
-
function serializedBytes(value: unknown): number {
return Buffer.byteLength(JSON.stringify(value), 'utf8')
}
diff --git a/apps/sim/lib/uploads/contexts/workspace/workspace-file-secret-provenance.ts b/apps/sim/lib/uploads/contexts/workspace/workspace-file-secret-provenance.ts
index 6d55219628a..c5b84b52275 100644
--- a/apps/sim/lib/uploads/contexts/workspace/workspace-file-secret-provenance.ts
+++ b/apps/sim/lib/uploads/contexts/workspace/workspace-file-secret-provenance.ts
@@ -5,6 +5,7 @@ import {
workspaceFileSecretProvenance,
workspaceFiles,
} from '@sim/db/schema'
+import { compareStrings } from '@sim/utils/string'
import { and, desc, eq, gte, inArray, isNull, lt, or, sql } from 'drizzle-orm'
import { encryptSecret } from '@/lib/core/security/encryption'
import type { DbTransaction } from '@/lib/db/types'
@@ -163,10 +164,6 @@ export function mergeWorkspaceFileSecretProvenance(
return { status: 'exact', entries: [...entries.values()] }
}
-function compareStrings(left: string, right: string): number {
- return left < right ? -1 : left > right ? 1 : 0
-}
-
function exactEntryByteSize(entry: WorkspaceFileSecretProvenanceEntry): number {
return (
Buffer.byteLength(entry.sourceUserId, 'utf8') +
diff --git a/apps/sim/lib/workspaces/naming.ts b/apps/sim/lib/workspaces/naming.ts
index 902d82d34bf..0fa2e6aee4d 100644
--- a/apps/sim/lib/workspaces/naming.ts
+++ b/apps/sim/lib/workspaces/naming.ts
@@ -2,6 +2,7 @@
* Utility functions for generating names for workspaces and folders
*/
+import { escapeRegExp } from '@sim/utils/string'
import { requestJson } from '@/lib/api/client/request'
import { type FolderApi, listFoldersContract } from '@/lib/api/contracts/folders'
@@ -20,7 +21,7 @@ export function generateIncrementalName(
existingEntities: T[],
prefix: string
): string {
- const pattern = new RegExp(`^${prefix.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')} (\\d+)$`)
+ const pattern = new RegExp(`^${escapeRegExp(prefix)} (\\d+)$`)
const existingNumbers = existingEntities
.map((entity) => entity.name.match(pattern))
diff --git a/apps/sim/stores/workflows/utils.ts b/apps/sim/stores/workflows/utils.ts
index 113e2f62194..ede20204e4a 100644
--- a/apps/sim/stores/workflows/utils.ts
+++ b/apps/sim/stores/workflows/utils.ts
@@ -1,11 +1,12 @@
import { generateId } from '@sim/utils/id'
+import { escapeRegExp } from '@sim/utils/string'
import { mergeSubblockStateWithValues } from '@sim/workflow-persistence/subblocks'
import { filterUniqueWorkflowEdges } from '@sim/workflow-types/workflow'
import type { Edge } from '@xyflow/react'
import { DEFAULT_DUPLICATE_OFFSET } from '@/lib/workflows/autolayout/constants'
import { remapConditionBlockIds, remapConditionEdgeHandle } from '@/lib/workflows/condition-ids'
import { isDynamicHandleSubblock } from '@/lib/workflows/dynamic-handle-topology'
-import { escapeRegExp, normalizeName } from '@/executor/constants'
+import { normalizeName } from '@/executor/constants'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
import { validateEdges } from '@/stores/workflows/workflow/edge-validation'
diff --git a/apps/sim/tools/http/utils.ts b/apps/sim/tools/http/utils.ts
index 0495fb3073c..9cbf78ba74b 100644
--- a/apps/sim/tools/http/utils.ts
+++ b/apps/sim/tools/http/utils.ts
@@ -1,4 +1,4 @@
-import { escapeRegExp } from '@/executor/constants'
+import { escapeRegExp } from '@sim/utils/string'
import { transformTable } from '@/tools/shared/table'
import type { TableRow } from '@/tools/types'
diff --git a/apps/sim/tools/jotform/create_questions.ts b/apps/sim/tools/jotform/create_questions.ts
index 0934a5d64a9..de906803ceb 100644
--- a/apps/sim/tools/jotform/create_questions.ts
+++ b/apps/sim/tools/jotform/create_questions.ts
@@ -1,3 +1,4 @@
+import { isRecordLike } from '@sim/utils/object'
import { normalizeQuestion, toList } from '@/tools/jotform/normalize'
import type {
JotformCreateQuestionsParams,
@@ -6,7 +7,6 @@ import type {
import {
buildJotformHeaders,
buildJotformUrl,
- isRecord,
parseJotformResponse,
requireValue,
toJsonArray,
@@ -75,7 +75,7 @@ export const createQuestionsTool: ToolConfig<
const indexed: Record = {}
questions.forEach((question, index) => {
- if (!isRecord(question)) {
+ if (!isRecordLike(question)) {
throw new Error('Every entry in questions must be a JSON object.')
}
indexed[String(index + 1)] = question
diff --git a/apps/sim/tools/jotform/create_submissions.ts b/apps/sim/tools/jotform/create_submissions.ts
index 2a4a867a679..a8ffe75c6f8 100644
--- a/apps/sim/tools/jotform/create_submissions.ts
+++ b/apps/sim/tools/jotform/create_submissions.ts
@@ -1,3 +1,4 @@
+import { isRecordLike } from '@sim/utils/object'
import type {
JotformCreateSubmissionsParams,
JotformCreateSubmissionsResponse,
@@ -5,7 +6,6 @@ import type {
import {
buildJotformHeaders,
buildJotformUrl,
- isRecord,
parseJotformResponse,
requireValue,
toJsonArray,
@@ -80,7 +80,7 @@ export const createSubmissionsTool: ToolConfig<
const envelope = await parseJotformResponse(response, 'Jotform Create Submissions')
const entries = Array.isArray(envelope.content) ? envelope.content : []
- const created = entries.filter(isRecord).map((entry) => ({
+ const created = entries.filter(isRecordLike).map((entry) => ({
submissionId: toStringOrNull(entry.submissionID),
url: toStringOrNull(entry.URL),
}))
diff --git a/apps/sim/tools/jotform/get_form_properties.ts b/apps/sim/tools/jotform/get_form_properties.ts
index 5d36cf52048..9c76c97b454 100644
--- a/apps/sim/tools/jotform/get_form_properties.ts
+++ b/apps/sim/tools/jotform/get_form_properties.ts
@@ -1,3 +1,4 @@
+import { isRecordLike } from '@sim/utils/object'
import type {
JotformFormPropertiesResponse,
JotformGetFormPropertiesParams,
@@ -5,7 +6,6 @@ import type {
import {
buildJotformHeaders,
buildJotformUrl,
- isRecord,
parseJotformResponse,
requireValue,
trimOrUndefined,
@@ -70,7 +70,7 @@ export const getFormPropertiesTool: ToolConfig<
return {
success: true,
output: {
- properties: isRecord(envelope.content) ? envelope.content : {},
+ properties: isRecordLike(envelope.content) ? envelope.content : {},
},
}
},
diff --git a/apps/sim/tools/jotform/normalize.ts b/apps/sim/tools/jotform/normalize.ts
index f9281819628..bbbab55926a 100644
--- a/apps/sim/tools/jotform/normalize.ts
+++ b/apps/sim/tools/jotform/normalize.ts
@@ -1,3 +1,4 @@
+import { isRecordLike } from '@sim/utils/object'
import type {
JotformFile,
JotformForm,
@@ -11,7 +12,7 @@ import type {
JotformSubUser,
JotformUser,
} from '@/tools/jotform/types'
-import { isRecord, toJsonArray, toStringOrNull } from '@/tools/jotform/utils'
+import { toJsonArray, toStringOrNull } from '@/tools/jotform/utils'
/**
* Jotform returns every scalar as a string and documents `content` as either an
@@ -21,14 +22,14 @@ import { isRecord, toJsonArray, toStringOrNull } from '@/tools/jotform/utils'
/** Unwraps the single-element array form some endpoints document for one resource. */
export function unwrapSingle(content: unknown): Record | null {
- if (Array.isArray(content)) return isRecord(content[0]) ? content[0] : null
- return isRecord(content) ? content : null
+ if (Array.isArray(content)) return isRecordLike(content[0]) ? content[0] : null
+ return isRecordLike(content) ? content : null
}
export function toList(content: unknown): Record[] {
- if (Array.isArray(content)) return content.filter(isRecord)
+ if (Array.isArray(content)) return content.filter(isRecordLike)
/* Folder `forms` and question maps come back keyed by id rather than as arrays. */
- if (isRecord(content)) return Object.values(content).filter(isRecord)
+ if (isRecordLike(content)) return Object.values(content).filter(isRecordLike)
return []
}
@@ -129,9 +130,9 @@ function buildValues(answers: Record): Record): JotformSubmission {
const answers: Record = {}
- if (isRecord(raw.answers)) {
+ if (isRecordLike(raw.answers)) {
for (const [qid, answer] of Object.entries(raw.answers)) {
- if (isRecord(answer)) answers[qid] = normalizeAnswer(answer)
+ if (isRecordLike(answer)) answers[qid] = normalizeAnswer(answer)
}
}
@@ -200,7 +201,7 @@ export function normalizeUser(raw: Record): JotformUser {
export function normalizeSubUser(raw: Record): JotformSubUser {
const permissions = Array.isArray(raw.permissions)
- ? raw.permissions.filter(isRecord).map((permission) => ({
+ ? raw.permissions.filter(isRecordLike).map((permission) => ({
type: toStringOrNull(permission.type),
resource_id: toStringOrNull(permission.resource_id),
access_type: toStringOrNull(permission.access_type),
@@ -246,8 +247,8 @@ const MAX_LABEL_DEPTH = 32
* turns the documented single root label into an empty list.
*/
function toLabelNodes(content: unknown): Record[] {
- if (Array.isArray(content)) return content.filter(isRecord)
- return isRecord(content) ? [content] : []
+ if (Array.isArray(content)) return content.filter(isRecordLike)
+ return isRecordLike(content) ? [content] : []
}
export function normalizeLabelTree(content: unknown, depth = 0): JotformLabelNode[] {
@@ -296,7 +297,7 @@ export function toLabelResourcePayload(
}
return entries.map((entry) => {
- if (!isRecord(entry)) {
+ if (!isRecordLike(entry)) {
throw new Error('Every entry in resources must be a JSON object with an id and a type.')
}
const id = toStringOrNull(entry.id)
@@ -318,7 +319,7 @@ export function normalizeWebhooks(content: unknown): Array<{ id: string; url: st
.map((url, index) => ({ id: String(index), url: toStringOrNull(url) }))
.filter((entry): entry is { id: string; url: string } => entry.url !== null)
}
- if (!isRecord(content)) return []
+ if (!isRecordLike(content)) return []
return Object.entries(content)
.map(([id, url]) => ({ id, url: toStringOrNull(url) }))
.filter((entry): entry is { id: string; url: string } => entry.url !== null)
diff --git a/apps/sim/tools/jotform/update_form_properties.ts b/apps/sim/tools/jotform/update_form_properties.ts
index 6c4f7eee41e..52fece050e9 100644
--- a/apps/sim/tools/jotform/update_form_properties.ts
+++ b/apps/sim/tools/jotform/update_form_properties.ts
@@ -1,3 +1,4 @@
+import { isRecordLike } from '@sim/utils/object'
import type {
JotformFormPropertiesResponse,
JotformUpdateFormPropertiesParams,
@@ -5,7 +6,6 @@ import type {
import {
buildJotformHeaders,
buildJotformUrl,
- isRecord,
parseJotformResponse,
requireValue,
toJsonObject,
@@ -82,7 +82,7 @@ export const updateFormPropertiesTool: ToolConfig<
return {
success: true,
output: {
- properties: isRecord(envelope.content) ? envelope.content : {},
+ properties: isRecordLike(envelope.content) ? envelope.content : {},
},
}
},
diff --git a/apps/sim/tools/jotform/utils.ts b/apps/sim/tools/jotform/utils.ts
index 6cc57bdc1be..4b2ed551e86 100644
--- a/apps/sim/tools/jotform/utils.ts
+++ b/apps/sim/tools/jotform/utils.ts
@@ -1,4 +1,5 @@
import { getErrorMessage } from '@sim/utils/errors'
+import { isRecordLike } from '@sim/utils/object'
import { truncate } from '@sim/utils/string'
/**
@@ -119,10 +120,6 @@ export function toStringOrNull(value: unknown): string | null {
return null
}
-export function isRecord(value: unknown): value is Record {
- return typeof value === 'object' && value !== null && !Array.isArray(value)
-}
-
/**
* `json` params arrive parsed from the block but as a raw JSON string when a tool is
* called straight from the registry, so object bodies normalize both shapes.
@@ -132,7 +129,7 @@ export function toJsonObject(
field: string
): Record {
if (value === undefined || value === null || value === '') return {}
- if (isRecord(value)) return value
+ if (isRecordLike(value)) return value
let parsed: unknown
try {
@@ -141,7 +138,7 @@ export function toJsonObject(
throw new Error(`Invalid JSON input for ${field}: ${getErrorMessage(error)}`)
}
- if (!isRecord(parsed)) {
+ if (!isRecordLike(parsed)) {
throw new Error(`Expected ${field} to be a JSON object.`)
}
@@ -188,7 +185,7 @@ function appendFormPairs(pairs: string[], key: string, value: unknown): void {
return
}
- if (isRecord(value)) {
+ if (isRecordLike(value)) {
for (const [childKey, childValue] of Object.entries(value)) {
appendFormPairs(pairs, `${key}[${childKey}]`, childValue)
}
@@ -237,7 +234,7 @@ export function normalizeSubmissionAnswers(
const qid = key.slice(0, separator)
const subField = key.slice(separator + 1)
const existing = normalized[qid]
- const target = isRecord(existing) ? existing : {}
+ const target = isRecordLike(existing) ? existing : {}
target[subField] = value
normalized[qid] = target
}
diff --git a/apps/sim/tools/servicenow/utils.ts b/apps/sim/tools/servicenow/utils.ts
index 6a732255aa9..cc28de6649e 100644
--- a/apps/sim/tools/servicenow/utils.ts
+++ b/apps/sim/tools/servicenow/utils.ts
@@ -1,4 +1,4 @@
-import { filterUndefined } from '@sim/utils/object'
+import { filterUndefined, isRecordLike } from '@sim/utils/object'
import { DEFAULT_DISPLAY_VALUE } from '@/tools/servicenow/constants'
import type {
ServiceNowAuthParams,
@@ -251,9 +251,9 @@ export function toRecordArray(result: unknown): ServiceNowRecord[] {
return (Array.isArray(result) ? result : [result]).filter(isRecord)
}
-/** Narrows an unknown value to a plain (non-array, non-null) object. */
+/** {@link isRecordLike} re-narrowed to `ServiceNowRecord` for the response shapes here. */
export function isRecord(value: unknown): value is ServiceNowRecord {
- return Boolean(value) && typeof value === 'object' && !Array.isArray(value)
+ return isRecordLike(value)
}
/**
diff --git a/packages/emcn/src/components/code/code.tsx b/packages/emcn/src/components/code/code.tsx
index b044aefea98..2e540301caa 100644
--- a/packages/emcn/src/components/code/code.tsx
+++ b/packages/emcn/src/components/code/code.tsx
@@ -10,6 +10,7 @@ import {
useRef,
useState,
} from 'react'
+import { escapeRegExp } from '@sim/utils/string'
import { findWorkflowReferenceTokens } from '@sim/utils/workflow-references'
import { useVirtualizer } from '@tanstack/react-virtual'
import { ChevronRight } from '../../icons'
@@ -823,7 +824,7 @@ function applySearchHighlightingToLine(
): { html: string; matchesInLine: number } {
if (!searchQuery.trim()) return { html, matchesInLine: 0 }
- const escaped = escapeRegex(searchQuery)
+ const escaped = escapeRegExp(searchQuery)
const regex = new RegExp(`(${escaped})`, 'gi')
const parts = html.split(/(<[^>]+>)/g)
let matchesInLine = 0
@@ -888,13 +889,6 @@ interface CodeViewerProps {
showCollapseColumn?: boolean
}
-/**
- * Escapes special regex characters in a string.
- */
-function escapeRegex(str: string): string {
- return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
-}
-
/**
* Applies search highlighting to already syntax-highlighted HTML.
* Wraps matches in spans with appropriate highlighting classes.
@@ -913,7 +907,7 @@ function applySearchHighlighting(
): string {
if (!searchQuery.trim()) return html
- const escaped = escapeRegex(searchQuery)
+ const escaped = escapeRegExp(searchQuery)
const regex = new RegExp(`(${escaped})`, 'gi')
// We need to be careful not to match inside HTML tags
@@ -1024,7 +1018,7 @@ const VirtualizedViewerInner = memo(function VirtualizedViewerInner({
const offsets: number[] = []
let cumulative = 0
- const escaped = escapeRegex(searchQuery)
+ const escaped = escapeRegExp(searchQuery)
const regex = new RegExp(escaped, 'gi')
const visibleSet = new Set(visibleLineIndices)
@@ -1237,7 +1231,7 @@ const ViewerInner = memo(function ViewerInner({
if (!searchQuery?.trim()) return { cumulativeMatches: [0], matchCount: 0 }
const cumulative: number[] = [0]
- const escaped = escapeRegex(searchQuery)
+ const escaped = escapeRegExp(searchQuery)
const regex = new RegExp(escaped, 'gi')
const visibleSet = new Set(visibleLineIndices)
diff --git a/packages/sim-cli/src/commands/protocol/workflow-run-follow.ts b/packages/sim-cli/src/commands/protocol/workflow-run-follow.ts
index 67566760bc0..887ccf1c1c9 100644
--- a/packages/sim-cli/src/commands/protocol/workflow-run-follow.ts
+++ b/packages/sim-cli/src/commands/protocol/workflow-run-follow.ts
@@ -1,3 +1,4 @@
+import { isRecordLike } from '@sim/utils/object'
import chalk from 'chalk'
import type { Command } from 'commander'
import { clientFrom } from '../../context'
@@ -99,10 +100,6 @@ export function resolveWorkflowRunSelection(
}
}
-function isRecord(value: unknown): value is Record {
- return typeof value === 'object' && value !== null && !Array.isArray(value)
-}
-
function stringField(frame: Record, key: string): string | null {
const value = frame[key]
return typeof value === 'string' ? value : null
@@ -125,14 +122,14 @@ async function readWorkflowResult(response: Response): Promise {
- return typeof value === 'object' && value !== null && !Array.isArray(value)
-}
-
function optionalString(value: unknown): string | null {
return typeof value === 'string' && value !== '' ? value : null
}
@@ -101,11 +98,11 @@ function optionalString(value: unknown): string | null {
* still polls, rather than refusing to find a status that is right there.
*/
function readRun(raw: unknown): RunSnapshot {
- const run = isRecord(raw) && isRecord(raw.data) ? raw.data : raw
- if (!isRecord(run) || typeof run.status !== 'string') {
+ const run = isRecordLike(raw) && isRecordLike(raw.data) ? raw.data : raw
+ if (!isRecordLike(run) || typeof run.status !== 'string') {
throw new SimApiError('Run status response carried no status.', 0)
}
- const paused = isRecord(run.paused) ? run.paused : null
+ const paused = isRecordLike(run.paused) ? run.paused : null
return {
status: run.status,
pauseKind: paused ? optionalString(paused.pauseKind) : null,
diff --git a/packages/utils/src/string.test.ts b/packages/utils/src/string.test.ts
index 718079ebf2a..1a33db3ce04 100644
--- a/packages/utils/src/string.test.ts
+++ b/packages/utils/src/string.test.ts
@@ -3,8 +3,11 @@
*/
import { describe, expect, it } from 'vitest'
import {
+ compareStrings,
+ escapeRegExp,
forEachSearchOccurrence,
formatQuotedNameList,
+ hasRegexMetacharacter,
isVersionedType,
normalizeEmail,
projectEscapedMarkdownForSearch,
@@ -246,3 +249,58 @@ describe('forEachSearchOccurrence', () => {
expect(spans('\u0130xyz target', 'target')).toEqual(['target'])
})
})
+
+describe('escapeRegExp', () => {
+ it('escapes every regex metacharacter', () => {
+ const metacharacters = '.*+?^$' + '{}()|[]\\'
+ expect(escapeRegExp(metacharacters)).toBe('\\.\\*\\+\\?\\^\\$\\{\\}\\(\\)\\|\\[\\]\\\\')
+ })
+
+ it('leaves ordinary text untouched', () => {
+ expect(escapeRegExp('plain text 42')).toBe('plain text 42')
+ })
+
+ it('matches the literal value once interpolated', () => {
+ const pattern = new RegExp(escapeRegExp('a.b'))
+ expect(pattern.test('a.b')).toBe(true)
+ expect(pattern.test('axb')).toBe(false)
+ })
+
+ it('escapes every occurrence, not just the first', () => {
+ expect(escapeRegExp('a.b.c')).toBe('a\\.b\\.c')
+ })
+})
+
+describe('compareStrings', () => {
+ it('orders by code unit', () => {
+ expect(compareStrings('a', 'b')).toBe(-1)
+ expect(compareStrings('b', 'a')).toBe(1)
+ expect(compareStrings('a', 'a')).toBe(0)
+ })
+
+ it('sorts uppercase before lowercase, unlike localeCompare', () => {
+ expect(compareStrings('Z', 'a')).toBe(-1)
+ expect(['a', 'Z'].sort(compareStrings)).toEqual(['Z', 'a'])
+ })
+
+ it('orders digit-led keys lexically, not numerically', () => {
+ expect(['2', '10'].sort(compareStrings)).toEqual(['10', '2'])
+ })
+})
+
+describe('hasRegexMetacharacter', () => {
+ it('reports the characters escapeRegExp would escape', () => {
+ expect(hasRegexMetacharacter('a.b')).toBe(true)
+ expect(hasRegexMetacharacter('a|b')).toBe(true)
+ })
+
+ it('reports plain text as free of them', () => {
+ expect(hasRegexMetacharacter('plain text 42')).toBe(false)
+ })
+
+ it('agrees with escapeRegExp about what needs escaping', () => {
+ for (const value of ['plain', 'a.b', 'a|b', '', 'x-y']) {
+ expect(hasRegexMetacharacter(value)).toBe(escapeRegExp(value) !== value)
+ }
+ })
+})
diff --git a/packages/utils/src/string.ts b/packages/utils/src/string.ts
index c4ba5d8d221..5518821940f 100644
--- a/packages/utils/src/string.ts
+++ b/packages/utils/src/string.ts
@@ -337,3 +337,41 @@ function identityStarts(length: number): number[] {
for (let index = 0; index <= length; index += 1) starts[index] = index
return starts
}
+
+/**
+ * One character class, declared twice: the `/g` copy is stateful under `.test()`
+ * (`lastIndex` advances between calls), so only `.replace` may use it.
+ */
+const REGEX_METACHARACTER = /[.*+?^${}()|[\]\\]/
+const REGEX_METACHARACTERS = /[.*+?^${}()|[\]\\]/g
+
+/**
+ * Escapes every regex metacharacter in `value` so it matches only itself when
+ * interpolated into a `RegExp`.
+ *
+ * @example
+ * new RegExp(escapeRegExp('a.b')) // matches the literal 'a.b', not 'axb'
+ */
+export function escapeRegExp(value: string): string {
+ return value.replace(REGEX_METACHARACTERS, '\\$&')
+}
+
+/** Reports whether `value` carries a character {@link escapeRegExp} would escape. */
+export function hasRegexMetacharacter(value: string): boolean {
+ return REGEX_METACHARACTER.test(value)
+}
+
+/**
+ * Compares two strings by code unit, the ordering `Array.prototype.sort` applies
+ * by default. Deliberately not `localeCompare`: ordering that feeds a hash, a
+ * fingerprint, or a value compared across processes must not vary with the
+ * host's locale.
+ *
+ * @example
+ * ['a', 'Z'].sort(compareStrings) // ['Z', 'a'] — uppercase sorts first
+ */
+export function compareStrings(left: string, right: string): number {
+ if (left < right) return -1
+ if (left > right) return 1
+ return 0
+}