diff --git a/apps/sim/lib/workflows/editing/selector-reference-guard.test.ts b/apps/sim/lib/workflows/editing/selector-reference-guard.test.ts new file mode 100644 index 00000000000..ce82a6eb87b --- /dev/null +++ b/apps/sim/lib/workflows/editing/selector-reference-guard.test.ts @@ -0,0 +1,80 @@ +/** + * @vitest-environment node + * + * Integration coverage for the Tier-2 reference guard, against the REAL block registry. + * + * `validation.test.ts` exercises the same guard with hand-written block fixtures, which cannot + * catch a fixture that has drifted from the shipped block config. This file unmocks the registry + * so the canonical pair, its `mode`s and its sub-block types come from `knowledge.ts` itself. + * Only the database lookup is mocked - it is the one dependency the guard deliberately protects. + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +vi.unmock('@/blocks/registry') + +const { mockValidateSelectorIds } = vi.hoisted(() => ({ + mockValidateSelectorIds: vi.fn(), +})) + +vi.mock('@/lib/workflows/editing/selector-validator', () => ({ + validateSelectorIds: mockValidateSelectorIds, +})) + +import { collectUnresolvedReferences } from '@/lib/workflows/editing/validation' +import { getBlock } from '@/blocks/registry' + +const CTX = { userId: 'user-1', workspaceId: 'workspace-1' } as const + +/** The real basic member of the knowledge block's `knowledgeBaseId` canonical pair. */ +const KB_SELECTOR_ID = 'knowledgeBaseSelector' + +function knowledgeGraph(value: string) { + return { + blocks: { + kb1: { + type: 'knowledge', + name: 'KB', + subBlocks: { [KB_SELECTOR_ID]: { value } }, + }, + }, + } +} + +describe('Tier-2 reference guard (real block registry)', () => { + beforeEach(() => { + vi.clearAllMocks() + mockValidateSelectorIds.mockResolvedValue({ valid: [], invalid: [] }) + }) + + it('the fixture matches the shipped knowledge block, so the cases below are meaningful', () => { + const config = getBlock('knowledge') + const member = config?.subBlocks?.find((s) => s.id === KB_SELECTOR_ID) + expect(member).toBeDefined() + expect(member?.type).toBe('knowledge-base-selector') + expect(member?.canonicalParamId).toBe('knowledgeBaseId') + expect(member?.mode).toBe('basic') + }) + + it.each([ + ['a block-output reference', ''], + ['an env-var reference', '{{KB_ID}}'], + ['a partially templated value', 'kb_'], + ])('never hits the database for %s', async (_label, value) => { + /** + * Seeded so `toHaveLength(0)` is load-bearing: with a permissive mock it would pass + * whether or not the guard ran. + */ + mockValidateSelectorIds.mockResolvedValue({ valid: [], invalid: [value] }) + const refs = await collectUnresolvedReferences(knowledgeGraph(value), CTX) + expect(mockValidateSelectorIds).not.toHaveBeenCalled() + expect(refs).toHaveLength(0) + }) + + it('still reports a literal id that does not resolve', async () => { + mockValidateSelectorIds.mockResolvedValue({ valid: [], invalid: ['kb_gone'] }) + const refs = await collectUnresolvedReferences(knowledgeGraph('kb_gone'), CTX) + expect(mockValidateSelectorIds).toHaveBeenCalledWith('knowledge-base-selector', 'kb_gone', CTX) + expect(refs).toHaveLength(1) + expect(refs[0]).toMatchObject({ blockId: 'kb1', field: KB_SELECTOR_ID, kind: 'resource' }) + }) +}) diff --git a/apps/sim/lib/workflows/editing/validation.test.ts b/apps/sim/lib/workflows/editing/validation.test.ts index bd1a65a2d89..bac22b0df99 100644 --- a/apps/sim/lib/workflows/editing/validation.test.ts +++ b/apps/sim/lib/workflows/editing/validation.test.ts @@ -1197,6 +1197,394 @@ describe('collectUnresolvedReferences', () => { expect(refs).toHaveLength(1) expect(refs[0]).toMatchObject({ field: 'credential', kind: 'credential' }) }) + + it('does not validate a selector holding a reference', async () => { + mockValidateSelectorIds.mockResolvedValue({ valid: [], invalid: [''] }) + const state = { + blocks: { + kb1: { + type: 'knowledge', + name: 'KB', + subBlocks: { knowledgeBaseId: { value: '' } }, + }, + }, + } + const refs = await collectUnresolvedReferences(state, CTX) + expect(mockValidateSelectorIds).not.toHaveBeenCalled() + expect(refs).toHaveLength(0) + }) + + it('does not validate a selector holding a {{ENV_VAR}} reference', async () => { + mockValidateSelectorIds.mockResolvedValue({ valid: [], invalid: ['{{KB_ID}}'] }) + const state = { + blocks: { + kb1: { + type: 'knowledge', + name: 'KB', + subBlocks: { knowledgeBaseId: { value: '{{KB_ID}}' } }, + }, + }, + } + const refs = await collectUnresolvedReferences(state, CTX) + expect(mockValidateSelectorIds).not.toHaveBeenCalled() + expect(refs).toHaveLength(0) + }) + + it('does not validate a partially templated selector value', async () => { + mockValidateSelectorIds.mockResolvedValue({ valid: [], invalid: ['kb_'] }) + const state = { + blocks: { + kb1: { + type: 'knowledge', + name: 'KB', + subBlocks: { knowledgeBaseId: { value: 'kb_' } }, + }, + }, + } + const refs = await collectUnresolvedReferences(state, CTX) + expect(mockValidateSelectorIds).not.toHaveBeenCalled() + expect(refs).toHaveLength(0) + }) + + it('validates only the literal ids in a mixed comma-separated value', async () => { + mockValidateSelectorIds.mockResolvedValue({ valid: [], invalid: ['kb_missing'] }) + const state = { + blocks: { + kb1: { + type: 'knowledge', + name: 'KB', + subBlocks: { knowledgeBaseId: { value: 'kb_missing,' } }, + }, + }, + } + const refs = await collectUnresolvedReferences(state, CTX) + expect(mockValidateSelectorIds).toHaveBeenCalledWith( + 'knowledge-base-selector', + ['kb_missing'], + CTX + ) + expect(refs).toHaveLength(1) + }) + + it('validates the literal entries when a multi-select opens AND closes with a template', async () => { + /** + * The whole string contains references, so only filtering per entry keeps `kb_real` checked. + */ + mockValidateSelectorIds.mockResolvedValue({ valid: [], invalid: ['kb_real'] }) + const state = { + blocks: { + kb1: { + type: 'knowledge', + name: 'KB', + subBlocks: { knowledgeBaseId: { value: ',kb_real,' } }, + }, + }, + } + const refs = await collectUnresolvedReferences(state, CTX) + expect(mockValidateSelectorIds).toHaveBeenCalledWith( + 'knowledge-base-selector', + ['kb_real'], + CTX + ) + expect(refs).toHaveLength(1) + }) + + it('never hits the database when every entry of a mixed-delimiter list is templated', async () => { + mockValidateSelectorIds.mockResolvedValue({ valid: [], invalid: ['{{A}}'] }) + const state = { + blocks: { + kb1: { + type: 'knowledge', + name: 'KB', + subBlocks: { knowledgeBaseId: { value: '{{A}},' } }, + }, + }, + } + const refs = await collectUnresolvedReferences(state, CTX) + expect(mockValidateSelectorIds).not.toHaveBeenCalled() + expect(refs).toHaveLength(0) + }) + + it('still validates a literal id however long it is', async () => { + const value = `kb_${'a'.repeat(10_000)}` + mockValidateSelectorIds.mockResolvedValue({ valid: [], invalid: [value] }) + const state = { + blocks: { + kb1: { type: 'knowledge', name: 'KB', subBlocks: { knowledgeBaseId: { value } } }, + }, + } + const refs = await collectUnresolvedReferences(state, CTX) + expect(mockValidateSelectorIds).toHaveBeenCalledWith('knowledge-base-selector', value, CTX) + expect(refs).toHaveLength(1) + }) + + it('checks the literal ids around a reference that contains a comma', async () => { + mockValidateSelectorIds.mockResolvedValue({ valid: [], invalid: ['kb_missing'] }) + const state = { + blocks: { + kb1: { + type: 'knowledge', + name: 'KB', + subBlocks: { knowledgeBaseId: { value: 'kb_a,,kb_missing' } }, + }, + }, + } + const refs = await collectUnresolvedReferences(state, CTX) + expect(mockValidateSelectorIds).toHaveBeenCalledWith( + 'knowledge-base-selector', + ['kb_a', 'kb_missing'], + CTX + ) + expect(refs).toHaveLength(1) + }) + + it('still validates a plain literal id (the guard must not over-skip)', async () => { + mockValidateSelectorIds.mockResolvedValue({ valid: [], invalid: ['kb_missing'] }) + const state = { + blocks: { + kb1: { + type: 'knowledge', + name: 'KB', + subBlocks: { knowledgeBaseId: { value: 'kb_missing' } }, + }, + }, + } + const refs = await collectUnresolvedReferences(state, CTX) + expect(mockValidateSelectorIds).toHaveBeenCalledWith( + 'knowledge-base-selector', + 'kb_missing', + CTX + ) + expect(refs).toHaveLength(1) + }) + + /** + * A torn reference reads as plain literals, so these pin the reference-aware split end to end. + */ + it('does not split a reference that contains a comma', async () => { + const state = { + blocks: { + kb1: { + type: 'knowledge', + name: 'KB', + subBlocks: { knowledgeBaseId: { value: '' } }, + }, + }, + } + const refs = await collectUnresolvedReferences(state, CTX) + expect(mockValidateSelectorIds).not.toHaveBeenCalled() + expect(refs).toHaveLength(0) + }) + + it('does not split a {{ENV_VAR}} reference that contains a comma', async () => { + const state = { + blocks: { + kb1: { + type: 'knowledge', + name: 'KB', + subBlocks: { knowledgeBaseId: { value: '{{KB_A,KB_B}}' } }, + }, + }, + } + const refs = await collectUnresolvedReferences(state, CTX) + expect(mockValidateSelectorIds).not.toHaveBeenCalled() + expect(refs).toHaveLength(0) + }) + + it('skips a comma-separated value whose entries are ALL templates', async () => { + const state = { + blocks: { + kb1: { + type: 'knowledge', + name: 'KB', + subBlocks: { knowledgeBaseId: { value: ',{{KB_ID}}' } }, + }, + }, + } + const refs = await collectUnresolvedReferences(state, CTX) + expect(mockValidateSelectorIds).not.toHaveBeenCalled() + expect(refs).toHaveLength(0) + }) + + /** A native array value never reaches the comma split, so it enters the filter independently. */ + it('filters templates out of a value that is already an array', async () => { + mockValidateSelectorIds.mockResolvedValue({ valid: [], invalid: ['kb_missing'] }) + const state = { + blocks: { + kb1: { + type: 'knowledge', + name: 'KB', + subBlocks: { knowledgeBaseId: { value: ['kb_missing', '', '{{KB_ID}}'] } }, + }, + }, + } + const refs = await collectUnresolvedReferences(state, CTX) + expect(mockValidateSelectorIds).toHaveBeenCalledWith( + 'knowledge-base-selector', + ['kb_missing'], + CTX + ) + expect(refs).toHaveLength(1) + }) + + it('skips an array value whose entries are ALL templates', async () => { + const state = { + blocks: { + kb1: { + type: 'knowledge', + name: 'KB', + subBlocks: { knowledgeBaseId: { value: ['', '{{KB_ID}}'] } }, + }, + }, + } + const refs = await collectUnresolvedReferences(state, CTX) + expect(mockValidateSelectorIds).not.toHaveBeenCalled() + expect(refs).toHaveLength(0) + }) + + /** + * A separator-only string is truthy, so it passes the `!subBlockValue` bail and splits to nothing. + */ + it('skips a value that is nothing but separators', async () => { + const state = { + blocks: { + kb1: { type: 'knowledge', name: 'KB', subBlocks: { knowledgeBaseId: { value: ' , , ' } } }, + }, + } + const refs = await collectUnresolvedReferences(state, CTX) + expect(mockValidateSelectorIds).not.toHaveBeenCalled() + expect(refs).toHaveLength(0) + }) + + it('skips an empty array value rather than validating an empty list', async () => { + const state = { + blocks: { + kb1: { type: 'knowledge', name: 'KB', subBlocks: { knowledgeBaseId: { value: [] } } }, + }, + } + const refs = await collectUnresolvedReferences(state, CTX) + expect(mockValidateSelectorIds).not.toHaveBeenCalled() + expect(refs).toHaveLength(0) + }) + + /** A non-string can never be a reference, so it passes through the filter untouched. */ + it('does not throw on a non-string entry inside an array value', async () => { + mockValidateSelectorIds.mockResolvedValue({ valid: [], invalid: [] }) + const state = { + blocks: { + kb1: { + type: 'knowledge', + name: 'KB', + subBlocks: { knowledgeBaseId: { value: [42, null, 'kb_ok', ''] } }, + }, + }, + } + const refs = await collectUnresolvedReferences(state, CTX) + expect(mockValidateSelectorIds).toHaveBeenCalledWith( + 'knowledge-base-selector', + [42, null, 'kb_ok'], + CTX + ) + expect(refs).toHaveLength(0) + }) + + /** A lone `<` or `{{` is a malformed literal, not a reference, and must still be reported. */ + it.each([ + ['an unclosed < delimiter', 'kb_ delimiter', 'start.kbId>'], + ['an unclosed {{ delimiter', 'kb_{{KB_ID'], + ])('still validates a value with %s', async (_label, value) => { + mockValidateSelectorIds.mockResolvedValue({ valid: [], invalid: [value] }) + const state = { + blocks: { + kb1: { type: 'knowledge', name: 'KB', subBlocks: { knowledgeBaseId: { value } } }, + }, + } + const refs = await collectUnresolvedReferences(state, CTX) + expect(mockValidateSelectorIds).toHaveBeenCalledWith('knowledge-base-selector', value, CTX) + expect(refs).toHaveLength(1) + }) + + it('drops whitespace-only entries without validating an empty id', async () => { + mockValidateSelectorIds.mockResolvedValue({ valid: [], invalid: ['kb_missing'] }) + const state = { + blocks: { + kb1: { + type: 'knowledge', + name: 'KB', + subBlocks: { knowledgeBaseId: { value: 'kb_missing, , ,' } }, + }, + }, + } + const refs = await collectUnresolvedReferences(state, CTX) + expect(mockValidateSelectorIds).toHaveBeenCalledWith( + 'knowledge-base-selector', + ['kb_missing'], + CTX + ) + expect(refs).toHaveLength(1) + }) + + /** + * The guard runs after the canonical active-member check, so it must not flip the active member. + */ + it('skips a template held by the ACTIVE canonical member', async () => { + mockValidateSelectorIds.mockResolvedValue({ valid: [], invalid: [''] }) + const state = { + blocks: { + c1: { + type: 'canonicalcred', + name: 'Cred', + subBlocks: { credential: { value: '' }, manualCredential: { value: '' } }, + }, + }, + } + const refs = await collectUnresolvedReferences(state, CTX) + expect(mockValidateSelectorIds).not.toHaveBeenCalled() + expect(refs).toHaveLength(0) + }) +}) + +/** + * validateWorkflowSelectorIds shares collectSelectorFields with the lint, so it skips the same values. + */ +describe('validateWorkflowSelectorIds (reference guard)', () => { + beforeEach(() => { + vi.clearAllMocks() + mockValidateSelectorIds.mockResolvedValue({ valid: [], invalid: [] }) + }) + + it.each([ + ['a block-output reference', ''], + ['an env-var reference', '{{KB_ID}}'], + ['a partially templated value', 'kb_'], + ])('reports no error for a selector holding %s', async (_label, value) => { + mockValidateSelectorIds.mockResolvedValue({ valid: [], invalid: [value] }) + const state = { + blocks: { + kb1: { type: 'knowledge', name: 'KB', subBlocks: { knowledgeBaseId: { value } } }, + }, + } + const errors = await validateWorkflowSelectorIds(state, CTX) + expect(mockValidateSelectorIds).not.toHaveBeenCalled() + expect(errors).toHaveLength(0) + }) + + it('still reports a literal id that does not resolve', async () => { + mockValidateSelectorIds.mockResolvedValue({ valid: [], invalid: ['kb_gone'] }) + const state = { + blocks: { + kb1: { + type: 'knowledge', + name: 'KB', + subBlocks: { knowledgeBaseId: { value: 'kb_gone' } }, + }, + }, + } + const errors = await validateWorkflowSelectorIds(state, CTX) + expect(errors).toHaveLength(1) + expect(errors[0]?.error).toContain('kb_gone') + }) }) describe('validateInputsForBlock - agent tools (tool-input)', () => { diff --git a/apps/sim/lib/workflows/editing/validation.ts b/apps/sim/lib/workflows/editing/validation.ts index ec2a366a1bd..8dab7e443ac 100644 --- a/apps/sim/lib/workflows/editing/validation.ts +++ b/apps/sim/lib/workflows/editing/validation.ts @@ -10,7 +10,7 @@ import type { PermissionGroupConfig } from '@/lib/permission-groups/fields' import { resolveAccessControlBlockType } from '@/lib/permission-groups/integration-allowlist' import { getCustomToolById } from '@/lib/workflows/custom-tools/operations' import { validateSelectorIds } from '@/lib/workflows/editing/selector-validator' -import { containsReference } from '@/lib/workflows/sanitization/references' +import { containsReference, splitOutsideReferences } from '@/lib/workflows/sanitization/references' import { getSkillById } from '@/lib/workflows/skills/operations' import { buildCanonicalIndex, @@ -1139,10 +1139,18 @@ function collectSelectorFields( // Handle comma-separated values for multi-select let values: string | string[] = subBlockValue if (typeof subBlockValue === 'string' && subBlockValue.includes(',')) { - values = subBlockValue - .split(',') - .map((v: string) => v.trim()) - .filter(Boolean) + values = splitOutsideReferences(subBlockValue) + } + + /** + * A reference or env var only resolves to an id at execution time, so it cannot be checked + * here. Filtered per entry so the literal ids of a mixed multi-select are still checked. + */ + if (Array.isArray(values)) { + values = values.filter((entry) => !containsReference(entry)) + if (values.length === 0) continue + } else if (containsReference(values)) { + continue } fields.push({ diff --git a/apps/sim/lib/workflows/sanitization/references.ts b/apps/sim/lib/workflows/sanitization/references.ts index 5579605148e..43d01634746 100644 --- a/apps/sim/lib/workflows/sanitization/references.ts +++ b/apps/sim/lib/workflows/sanitization/references.ts @@ -1,6 +1,7 @@ import { findWorkflowReferenceTokens, isLikelyWorkflowReferenceSegment, + splitOutsideWorkflowReferences, splitWorkflowReferenceSegment, } from '@sim/utils/workflow-references' import { normalizeName, REFERENCE } from '@/executor/constants' @@ -9,6 +10,7 @@ export const SYSTEM_REFERENCE_PREFIXES = new Set(['loop', 'parallel', 'variable' export const splitReferenceSegment = splitWorkflowReferenceSegment export const isLikelyReferenceSegment = isLikelyWorkflowReferenceSegment +export const splitOutsideReferences = splitOutsideWorkflowReferences /** * Whether a subblock value carries a `` / `` reference or a diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts index c486a88bd8e..45a34387d54 100644 --- a/packages/utils/src/index.ts +++ b/packages/utils/src/index.ts @@ -57,6 +57,7 @@ export { export { findWorkflowReferenceTokens, isLikelyWorkflowReferenceSegment, + splitOutsideWorkflowReferences, splitWorkflowReferenceSegment, type WorkflowReferenceToken, type WorkflowReferenceTokenKind, diff --git a/packages/utils/src/workflow-references.test.ts b/packages/utils/src/workflow-references.test.ts index 99d8835989e..e7090c422bc 100644 --- a/packages/utils/src/workflow-references.test.ts +++ b/packages/utils/src/workflow-references.test.ts @@ -1,6 +1,7 @@ import { findWorkflowReferenceTokens, isLikelyWorkflowReferenceSegment, + splitOutsideWorkflowReferences, splitWorkflowReferenceSegment, } from '@sim/utils/workflow-references' import { describe, expect, it } from 'vitest' @@ -70,4 +71,60 @@ describe('workflow references', () => { }, ]) }) + + it('suppresses a workflow reference that overlaps an environment placeholder', () => { + expect( + findWorkflowReferenceTokens(' ').map(({ kind, value }) => ({ kind, value })) + ).toEqual([ + { kind: 'environment', value: '{{B}}' }, + { kind: 'workflow', value: '' }, + ]) + }) + + it('stays linear on a reference-dense value', () => { + const startedAt = performance.now() + const tokens = findWorkflowReferenceTokens('{{C}}'.repeat(80_000)) + expect(tokens).toHaveLength(160_000) + expect(performance.now() - startedAt).toBeLessThan(1000) + }) +}) + +describe('splitOutsideWorkflowReferences', () => { + it('splits on commas, trims entries, and drops empty ones', () => { + expect(splitOutsideWorkflowReferences(' kb_a , , kb_b ,')).toEqual(['kb_a', 'kb_b']) + expect(splitOutsideWorkflowReferences('kb_a')).toEqual(['kb_a']) + expect(splitOutsideWorkflowReferences(' , ')).toEqual([]) + }) + + it('keeps a comma inside a workflow reference or environment placeholder', () => { + expect(splitOutsideWorkflowReferences('kb_a,,kb_b')).toEqual([ + 'kb_a', + '', + 'kb_b', + ]) + expect(splitOutsideWorkflowReferences('{{A,B}},kb_a')).toEqual(['{{A,B}}', 'kb_a']) + }) + + it('protects a workflow reference that wraps an environment placeholder', () => { + expect(splitOutsideWorkflowReferences(',kb_a')).toEqual([ + '', + 'kb_a', + ]) + expect(splitOutsideWorkflowReferences(',kb_a')).toEqual(['', 'kb_a']) + }) + + it('splits a near-miss that does not read as a reference', () => { + expect(splitOutsideWorkflowReferences('')).toEqual(['']) + expect(splitOutsideWorkflowReferences('value max')).toEqual([ + 'value max', + ]) + }) + + it('stays linear on a large value', () => { + const startedAt = performance.now() + const entries = splitOutsideWorkflowReferences('{{A}},,'.repeat(40_000)) + expect(entries).toHaveLength(80_000) + expect(performance.now() - startedAt).toBeLessThan(1000) + }) }) diff --git a/packages/utils/src/workflow-references.ts b/packages/utils/src/workflow-references.ts index 64bc63b76ab..92165258d67 100644 --- a/packages/utils/src/workflow-references.ts +++ b/packages/utils/src/workflow-references.ts @@ -3,7 +3,9 @@ const REFERENCE_END = '>' const REFERENCE_PATH_DELIMITER = '.' const INVALID_REFERENCE_CHARS = /[+*/=<>!&|]/ const LEADING_REFERENCE_PATTERN = /^[<>=!\s]*$/ +const ENV_REFERENCE_START = '{{' const ENV_REFERENCE_PATTERN = /\{\{[^{}\r\n]+\}\}/g +const LIST_SEPARATOR = ',' export type WorkflowReferenceTokenKind = 'environment' | 'workflow' @@ -57,15 +59,14 @@ export function isLikelyWorkflowReferenceSegment(segment: string): boolean { return !INVALID_REFERENCE_CHARS.test(inner) && !/^\d+$/.test(inner) && !/\s\d/.test(inner) } -/** Finds non-overlapping `{{ENV}}` and `` tokens in source order. */ -export function findWorkflowReferenceTokens(source: string): WorkflowReferenceToken[] { - const tokens: WorkflowReferenceToken[] = [] - - for (const match of source.matchAll(ENV_REFERENCE_PATTERN)) { - const start = match.index - tokens.push({ kind: 'environment', value: match[0], start, end: start + match[0].length }) - } - +/** + * Calls `onReference` with the `[start, end)` span of every `` candidate, in + * source order. Spans never overlap each other, but may overlap an `{{ENV}}` placeholder. + */ +function scanWorkflowReferenceSpans( + source: string, + onReference: (start: number, end: number) => void +): void { let candidateStart = -1 for (let index = 0; index < source.length; index += 1) { const character = source[index] @@ -83,13 +84,81 @@ export function findWorkflowReferenceTokens(source: string): WorkflowReferenceTo const split = splitWorkflowReferenceSegment(candidate) if (split && isLikelyWorkflowReferenceSegment(candidate)) { const start = candidateStart + split.leading.length - const end = start + split.reference.length - if (!tokens.some((token) => start < token.end && end > token.start)) { - tokens.push({ kind: 'workflow', value: split.reference, start, end }) - } + onReference(start, start + split.reference.length) } candidateStart = -1 } +} + +/** Finds non-overlapping `{{ENV}}` and `` tokens in source order. */ +export function findWorkflowReferenceTokens(source: string): WorkflowReferenceToken[] { + const environmentTokens: WorkflowReferenceToken[] = [] + for (const match of source.matchAll(ENV_REFERENCE_PATTERN)) { + const start = match.index + environmentTokens.push({ + kind: 'environment', + value: match[0], + start, + end: start + match[0].length, + }) + } - return tokens.sort((left, right) => left.start - right.start) + /** + * Environment tokens are disjoint and ordered, and workflow spans arrive in increasing order, so + * one forward cursor finds the only environment token a span can overlap - linear, where a scan + * of every prior token per span is quadratic on reference-dense values. + */ + const workflowTokens: WorkflowReferenceToken[] = [] + let environmentIndex = 0 + scanWorkflowReferenceSpans(source, (start, end) => { + while ( + environmentIndex < environmentTokens.length && + environmentTokens[environmentIndex].end <= start + ) { + environmentIndex += 1 + } + const next = environmentTokens[environmentIndex] + if (next && next.start < end) return + workflowTokens.push({ kind: 'workflow', value: source.slice(start, end), start, end }) + }) + + return [...environmentTokens, ...workflowTokens].sort((left, right) => left.start - right.start) +} + +/** + * Splits a comma-separated list without tearing a reference apart. + * + * A `` or `{{ENV_VAR}}` may itself contain a comma (``), so only a + * comma outside every reference is a separator. Unlike {@link findWorkflowReferenceTokens}, a + * `<...>` that wraps a placeholder (``) protects its whole span. Entries are + * trimmed and empty entries dropped. + */ +export function splitOutsideWorkflowReferences(source: string): string[] { + const protectedIndexes = new Uint8Array(source.length) + const protect = (start: number, end: number) => { + protectedIndexes.fill(1, start, end) + } + if (source.includes(ENV_REFERENCE_START)) { + for (const match of source.matchAll(ENV_REFERENCE_PATTERN)) { + protect(match.index, match.index + match[0].length) + } + } + if (source.includes(REFERENCE_START)) { + scanWorkflowReferenceSpans(source, protect) + } + + const entries: string[] = [] + const pushEntry = (start: number, end: number) => { + const entry = source.slice(start, end).trim() + if (entry) entries.push(entry) + } + let entryStart = 0 + for (let index = 0; index < source.length; index += 1) { + if (source[index] === LIST_SEPARATOR && !protectedIndexes[index]) { + pushEntry(entryStart, index) + entryStart = index + 1 + } + } + pushEntry(entryStart, source.length) + return entries }