Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .claude/rules/global.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
3 changes: 3 additions & 0 deletions .cursor/rules/global.mdc
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
3 changes: 3 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
3 changes: 2 additions & 1 deletion apps/desktop/src/main/local-filesystem.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion apps/sim/app/(landing)/comparisons/[provider]/page.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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(`<a [^>]*>${escaped}</a>`))?.[0] ?? ''
}

Expand Down
8 changes: 2 additions & 6 deletions apps/sim/app/(landing)/integrations/(shell)/[slug]/page.tsx
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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
Expand All @@ -152,7 +148,7 @@ function mentionifyPromptForNames(prompt: string, names: readonly string[]): str
)
if (unique.length === 0) return prompt
const regex = new RegExp(
`(?<![A-Za-z0-9_@])(${unique.map(escapeRegex).join('|')})(?![A-Za-z0-9_])`,
`(?<![A-Za-z0-9_@])(${unique.map(escapeRegExp).join('|')})(?![A-Za-z0-9_])`,
'gi'
)
return prompt.replace(regex, (match) => `@${match}`)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { escapeRegExp } from '@sim/utils/string'

interface SearchHighlightProps {
text: string
searchQuery: string
Expand All @@ -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 <span className={className}>{text}</span>
Expand Down
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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)) {
Expand Down
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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])

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -22,10 +23,6 @@ interface UserMessageContentProps {
compact?: boolean
}

function escapeRegex(str: string): string {
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
}

interface MentionRange {
start: number
end: number
Expand Down Expand Up @@ -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]
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { escapeRegExp } from '@sim/utils/string'
import {
FOLDER_CONFIGS,
type MentionFolderId,
Expand All @@ -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
Expand Down Expand Up @@ -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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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}__`
Expand Down
7 changes: 2 additions & 5 deletions apps/sim/blocks/integration-matcher.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -32,10 +33,6 @@ export interface IntegrationMatcher {
byName: Map<string, IntegrationDescriptor>
}

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
Expand Down Expand Up @@ -81,7 +78,7 @@ function buildMatcher(): IntegrationMatcher {

names.sort((a, b) => b.length - a.length)
const regex = names.length
? new RegExp(`(?<![A-Za-z0-9_])(${names.map(escapeRegex).join('|')})(?![A-Za-z0-9_])`, 'gi')
? new RegExp(`(?<![A-Za-z0-9_])(${names.map(escapeRegExp).join('|')})(?![A-Za-z0-9_])`, 'gi')
: null

return { regex, byName }
Expand Down
9 changes: 5 additions & 4 deletions apps/sim/ee/scim/lib/protocol/canonical.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import type { ScimUserAttributes, ScimUserEmail } from '@sim/db/schema'
import { isRecordLike } from '@sim/utils/object'
import { isValidEmailSyntax } from '@sim/utils/string'
import type { ScimGroupWriteParsed, ScimUserWriteParsed } from '@/lib/api/contracts/scim'
import { SCIM_ENTERPRISE_USER_SCHEMA } from '@/ee/scim/lib/protocol/constants'
import { invalidValue } from '@/ee/scim/lib/protocol/errors'
import { isRecord, isScimPasswordAttribute } from '@/ee/scim/lib/protocol/normalize'
import { isScimPasswordAttribute } from '@/ee/scim/lib/protocol/normalize'

/** Attributes Sim models itself; everything else is preserved under `extra`. */
const MODELLED_USER_KEYS = new Set([
Expand Down Expand Up @@ -111,7 +112,7 @@ export function toCanonicalUser(body: ScimUserWriteParsed): ScimUserAttributes {
...(displayName ? { displayName, displayNameSource: 'provider' as const } : {}),
name,
emails,
...(isRecord(enterprise) ? { enterprise: normalizeEnterprise(enterprise) } : {}),
...(isRecordLike(enterprise) ? { enterprise: normalizeEnterprise(enterprise) } : {}),
...(extra ? { extra } : {}),
}
}
Expand Down Expand Up @@ -142,7 +143,7 @@ function normalizeEnterprise(value: Record<string, unknown>): 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) {
Expand Down Expand Up @@ -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')
}
5 changes: 3 additions & 2 deletions apps/sim/ee/scim/lib/protocol/group-patch.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -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
Expand Down
9 changes: 3 additions & 6 deletions apps/sim/ee/scim/lib/protocol/normalize.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { isRecordLike } from '@sim/utils/object'

/**
* Tolerances for what identity providers actually send, as distinct from what
* RFC 7644 describes.
Expand Down Expand Up @@ -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<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}

/**
* Strips a schema URN prefix from an attribute path and decodes it.
*
Expand Down Expand Up @@ -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<string, unknown> = {}
for (const [key, value] of Object.entries(body)) {
Expand Down
9 changes: 3 additions & 6 deletions apps/sim/ee/scim/lib/protocol/resources.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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'
Expand Down Expand Up @@ -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<string, unknown> = {}
for (const [key, nested] of Object.entries(value)) {
const selected = projectAttribute(
Expand Down
Loading
Loading