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 +}