UNPKG

meld

Version:

Meld: A template language for LLM prompts

1,340 lines (1,183 loc) 46.8 kB
import type { IStateService } from '@services/state/StateService/IStateService.js'; import type { ResolutionContext } from '@services/resolution/ResolutionService/IResolutionService.js'; import { ResolutionErrorCode } from '@services/resolution/ResolutionService/IResolutionService.js'; import { MeldResolutionError } from '@core/errors/MeldResolutionError.js'; import { ErrorSeverity } from '@core/errors/MeldError.js'; import type { IResolutionService } from '@services/resolution/ResolutionService/IResolutionService.js'; import type { IParserService } from '@services/pipeline/ParserService/IParserService.js'; import type { MeldNode, TextNode, DirectiveNode, TextVarNode, DataVarNode } from 'meld-spec'; import { resolutionLogger as logger } from '@core/utils/logger.js'; import { VariableResolutionTracker } from '@tests/utils/debug/VariableResolutionTracker/index.js'; // Define the field type for clarity interface Field { type: 'field' | 'index'; value: string | number; } /** * Handles resolution of variable references ({{var}}) * Previously used ${var} for text and #{var} for data, now unified as {{var}} */ export class VariableReferenceResolver { private readonly MAX_RESOLUTION_DEPTH = 10; private readonly MAX_ITERATIONS = 100; private resolutionTracker?: VariableResolutionTracker; constructor( private readonly stateService: IStateService, private readonly resolutionService?: IResolutionService, private readonly parserService?: IParserService ) {} /** * Set the resolution tracker for debugging * @internal */ setResolutionTracker(tracker: VariableResolutionTracker): void { this.resolutionTracker = tracker; } /** * Resolves all variable references in the given text * @param text Text containing variable references like {{varName}} * @param context Resolution context * @returns Resolved text with all variables replaced with their values */ async resolve(content: string, context: ResolutionContext): Promise<string> { if (!content) { logger.debug('Empty content provided to variable resolver'); return content; } logger.debug('Resolving content:', { content, hasState: !!context.state, currentFilePath: context.currentFilePath, transformationEnabled: context.state?.isTransformationEnabled?.() ?? true }); // Check if content contains variable references if (!content.includes('{{')) { return content; } // Use regex to find all variable references in the content const variableRegex = /\{\{([^{}]+)\}\}/g; let result = content; let matches = Array.from(content.matchAll(variableRegex)); // If no matches, return original content if (matches.length === 0) { return content; } logger.debug(`Found ${matches.length} variable references in content`); // Process each variable reference for (const match of matches) { const fullMatch = match[0]; // The entire match, e.g., {{variable.field}} const reference = match[1].trim(); // The variable reference, e.g., variable.field try { // Special handling for environment variables if (reference.startsWith('ENV_')) { throw new MeldResolutionError( `Variable ${reference} not found`, { code: ResolutionErrorCode.UNDEFINED_VARIABLE, details: { variableName: reference }, severity: ErrorSeverity.Recoverable } ); } // Split the reference into variable name and field path const [variableName, ...fieldParts] = reference.split('.'); const fieldPath = fieldParts.length > 0 ? fieldParts.join('.') : ''; logger.debug('Processing variable reference:', { fullMatch, variableName, fieldPath }); // Use the resolveFieldAccess method to handle field access const value = await this.resolveFieldAccess(variableName, fieldPath, context); if (value !== undefined) { // Replace the variable reference with its value const stringValue = typeof value === 'string' ? value : JSON.stringify(value); result = result.replace(fullMatch, stringValue); logger.debug('Resolved variable reference:', { fullMatch, value: stringValue }); } else { // Always throw for undefined variables to match test expectations throw new MeldResolutionError( `Variable '${variableName}' not found`, { code: ResolutionErrorCode.UNDEFINED_VARIABLE, details: { variableName }, severity: ErrorSeverity.Recoverable } ); } } catch (error) { // Handle errors during variable resolution logger.error('Error resolving variable reference:', { fullMatch, reference, error }); // Always rethrow errors to match test expectations throw error; } } logger.debug('Final resolved content:', result); return result; } /** * Resolves a list of nodes, handling variable references * @param nodes The nodes to resolve * @param context The resolution context * @returns The resolved content */ async resolveNodes(nodes: MeldNode[], context: ResolutionContext): Promise<string> { let result = ''; // Track the resolution path to detect circular references const resolutionPath: string[] = []; logger.debug('Resolving nodes:', { nodeCount: nodes.length, nodeTypes: nodes.map(n => n.type), transformationEnabled: context.state?.isTransformationEnabled?.() ?? true }); for (const node of nodes) { logger.debug('Processing node:', { type: node.type, content: node.type === 'Text' ? (node as TextNode).content : node.type === 'TextVar' ? (node as TextVarNode).identifier : JSON.stringify(node) }); if (node.type === 'Text') { const textNode = node as TextNode; // Always check for variable references in text nodes if (textNode.content.includes('{{')) { const resolved = await this.resolve(textNode.content, context); logger.debug('Resolved text node content:', { original: textNode.content, resolved }); result += resolved; } else { result += textNode.content; } } else if (node.type === 'TextVar') { // Handle text variable nodes try { const textVarNode = node as TextVarNode; const identifier = textVarNode.identifier; logger.debug('Processing TextVar node:', { identifier, transformationEnabled: context.state?.isTransformationEnabled?.() ?? true }); // Always try to resolve the variable const value = await this.getVariable(identifier, context); logger.debug('Resolved TextVar value:', { identifier, value }); if (value !== undefined) { result += String(value); } else if (context.strict !== false) { throw new MeldResolutionError( `Variable ${identifier} not found`, { code: ResolutionErrorCode.UNDEFINED_VARIABLE, details: { variableName: identifier }, severity: ErrorSeverity.Recoverable } ); } else { // Keep the variable reference if in permissive mode and value not found result += `{{${identifier}}}`; } } catch (error) { logger.error('Error resolving TextVar node:', { node: JSON.stringify(node), error }); if (context.strict) { throw error; } else { // Keep the variable reference if error occurs in permissive mode result += `{{${(node as TextVarNode).identifier}}}`; } } } else if (node.type === 'DataVar') { // Handle data variable nodes try { const dataVarNode = node as DataVarNode; const identifier = dataVarNode.identifier; const fields = dataVarNode.fields || []; logger.debug('Processing DataVar node:', { identifier, fields, transformationEnabled: context.state?.isTransformationEnabled?.() ?? true }); // Always try to resolve the variable const fieldPath = fields.map(f => { // Handle different field types safely if (typeof f === 'string') { return f; } else if (f && typeof f === 'object') { // Use type assertion to access properties safely const field = f as { type?: string; value?: string | number }; return field.value !== undefined ? String(field.value) : ''; } return ''; }).filter(Boolean).join('.'); const value = await this.resolveFieldAccess(identifier, fieldPath, context); logger.debug('Resolved DataVar value:', { identifier, fieldPath, value }); if (value !== undefined) { result += typeof value === 'string' ? value : JSON.stringify(value); } else if (context.strict !== false) { throw new MeldResolutionError( `Variable ${identifier} not found`, { code: ResolutionErrorCode.UNDEFINED_VARIABLE, details: { variableName: identifier }, severity: ErrorSeverity.Recoverable } ); } else { // Keep the variable reference if in permissive mode and value not found result += `{{${identifier}${fieldPath ? '.' + fieldPath : ''}}}`; } } catch (error) { logger.error('Error resolving DataVar node:', { node: JSON.stringify(node), error }); if (context.strict) { throw error; } else { // Keep the variable reference if error occurs in permissive mode const dataVarNode = node as DataVarNode; const identifier = dataVarNode.identifier; const fields = dataVarNode.fields || []; const fieldPath = fields.map(f => { // Handle different field types safely if (typeof f === 'string') { return f; } else if (f && typeof f === 'object') { // Use type assertion to access properties safely const field = f as { type?: string; value?: string | number }; return field.value !== undefined ? String(field.value) : ''; } return ''; }).filter(Boolean).join('.'); result += `{{${identifier}${fieldPath ? '.' + fieldPath : ''}}}`; } } } else { // For other node types, just convert to string result += JSON.stringify(node); } } logger.debug('Final resolved nodes result:', result); return result; } /** * Extract the actual value from a node, not just its string representation */ private getNodeValue(node: MeldNode, context: ResolutionContext): string { // Different handling based on node type switch (node.type) { case 'Text': return (node as TextNode).content; case 'TextVar': const textVarNode = node as any; const textVar = textVarNode.identifier; const value = context.state?.getTextVar(textVar) || this.stateService.getTextVar(textVar); return value !== undefined ? String(value) : ''; case 'DataVar': const dataVarNode = node as any; const dataVar = dataVarNode.identifier; const dataValue = context.state?.getDataVar(dataVar) || this.stateService.getDataVar(dataVar); // For data variables, return JSON string for objects return dataValue !== undefined ? (typeof dataValue === 'object' ? JSON.stringify(dataValue) : String(dataValue)) : ''; case 'PathVar': const pathVarNode = node as any; const pathVar = pathVarNode.identifier; const stateToUse = context.state || this.stateService; // Handle special path variables if (pathVar === 'PROJECTPATH' || pathVar === '.') { return stateToUse.getPathVar('PROJECTPATH') || ''; } else if (pathVar === 'HOMEPATH' || pathVar === '~') { return stateToUse.getPathVar('HOMEPATH') || ''; } // Regular path variable return stateToUse.getPathVar(pathVar) || ''; case 'CodeFence': const codeFence = node as any; return codeFence.content || ''; default: // For unsupported node types, return empty string return ''; } } /** * Convert a node to string representation * @deprecated Use getNodeValue instead for actual variable values */ private nodeToString(node: MeldNode): string { if (node.type === 'TextVar') { const textVarNode = node as TextVarNode; return `{{${textVarNode.identifier}}}`; } else if (node.type === 'DataVar') { const dataVarNode = node as DataVarNode; let result = dataVarNode.identifier; // Append fields/indices if present if (dataVarNode.fields && dataVarNode.fields.length > 0) { for (const field of dataVarNode.fields) { const typedField = field as unknown as Field; if (typedField.type === 'field') { result += `.${typedField.value}`; } else if (typedField.type === 'index') { result += `[${typedField.value}]`; } } } return `{{${result}}}`; } return ''; } /** * Resolve text with variable references */ private async resolveText( text: string, context: ResolutionContext, resolutionPath: string[] = [] ): Promise<string> { // Wrap any console.log statements in DEBUG checks if (process.env.DEBUG === 'true') { console.log('*** resolveText context state:', { stateExists: !!context.state, stateMethods: context.state ? Object.keys(context.state) : 'undefined', text }); } if (!text) { return ''; } let result = text; try { // Parse the text to find variable nodes const nodes = await this.parserService?.parse(text); const hasVariables = nodes?.some(node => node.type === 'TextVar' || node.type === 'DataVar' || node.type === 'PathVar' ); if (!hasVariables) { return result; } // Process each variable node return await this.resolveWithAst(result, context); } catch (error) { // If parsing fails, return the original text console.warn('Failed to parse text for variable resolution:', error); return result; } } /** * Resolves a variable node (TextVar or DataVar) * @param node The variable node to resolve * @param context The resolution context * @param resolutionPath Path to detect circular references * @returns The resolved value */ private async resolveVarNode( node: MeldNode, context: ResolutionContext, resolutionPath: string[] = [] ): Promise<any> { // Normalize the variable node structure const normalized = this.normalizeVarNode(node); if (!normalized) { throw new MeldResolutionError( `Unsupported variable node type: ${node.type}`, { code: ResolutionErrorCode.RESOLUTION_FAILED, details: { variableName: node.type }, severity: ErrorSeverity.Recoverable } ); } // Get the base variable value let value = await this.getVariable(normalized.identifier, context); if (value === undefined) { if (context.strict !== false) { throw new MeldResolutionError( `Undefined variable: ${normalized.identifier}`, { code: ResolutionErrorCode.UNDEFINED_VARIABLE, details: { variableName: normalized.identifier }, severity: ErrorSeverity.Recoverable } ); } return undefined; } // Process fields/indices if present if (normalized.fields.length > 0) { try { // Build field path for error reporting const fieldPath = normalized.fields.map(f => String((f as unknown as Field).value)).join('.'); // Process each field for (const field of normalized.fields) { if (value === undefined) break; const typedField = field as unknown as Field; const fieldKey = typedField.value; // Check if value is accessible if (value === null || value === undefined) { throw new Error(`Cannot access ${fieldKey} of ${value}`); } // Special handling for arrays with numeric indices if (Array.isArray(value) && typeof fieldKey === 'string' && /^\d+$/.test(fieldKey)) { const numericIndex = parseInt(fieldKey, 10); if (numericIndex < 0 || numericIndex >= value.length) { throw new Error(`Array index out of bounds: ${numericIndex} (length: ${value.length})`); } value = value[numericIndex]; } // Object property access else if (typeof value === 'object') { if (!(fieldKey in value)) { throw new Error(`Property ${fieldKey} not found in object`); } value = value[fieldKey]; } // Primitive value access (will fail) else { throw new Error(`Cannot access field ${fieldKey} of ${typeof value}`); } } } catch (error: any) { // Create a readable field path for the error message const fieldPathStr = normalized.fields.map(f => String((f as unknown as Field).value)).join('.'); throw new MeldResolutionError( `Invalid field access for variable ${normalized.identifier}: ${error?.message || 'Unknown error'}`, { code: ResolutionErrorCode.FIELD_ACCESS_ERROR, details: { variableName: normalized.identifier, fieldPath: fieldPathStr, error: error?.message || 'Unknown error' }, severity: ErrorSeverity.Recoverable } ); } } return value; } /** * Normalizes a variable node to a common format regardless of node type */ private normalizeVarNode(node: MeldNode): { identifier: string; varType: 'text' | 'data'; fields: Array<{ type: 'field' | 'index', value: string | number }>; } | null { if (!node) return null; if (node.type === 'TextVar') { const textVarNode = node as TextVarNode; return { identifier: textVarNode.identifier, varType: 'text', fields: [] }; } if (node.type === 'DataVar') { const dataVarNode = node as DataVarNode; return { identifier: dataVarNode.identifier, varType: 'data', fields: dataVarNode.fields?.map(field => { if (typeof field === 'object' && field !== null) { const typedField = field as any; // Cast to any to bypass type checking if (typedField.type === 'index') { return { type: 'index' as const, value: typeof typedField.value === 'number' ? typedField.value : parseInt(String(typedField.value), 10) }; } else if (typedField.type === 'field') { return { type: 'field' as const, value: typedField.value }; } } // Default case for unexpected field format return { type: 'field' as const, value: String(field) }; }) || [] }; } return null; } /** * Handles the resolution of standard text variables using a simpler approach * @param text Text containing variable references * @param context Resolution context * @returns Text with variables resolved */ private resolveSimpleVariables(text: string, context: ResolutionContext): string { // Choose state service - prefer context.state if available const stateToUse = context.state || this.stateService; // If no ParserService available, throw an error if (!this.parserService) { throw new MeldResolutionError( 'ParserService is required for variable resolution', { code: ResolutionErrorCode.RESOLUTION_FAILED, severity: ErrorSeverity.Fatal, details: { value: 'ParserService not available' } } ); } try { // Since this method is synchronous, we use a simpler regex-based approach until refactored const textVars = this.getSafeTextVars(context); const dataVars = this.getSafeDataVars(context); // Simple variable replacement without using AST let result = text; // Skip if no variable references found if (!text.includes('{{')) { return text; } // Replace variable references in format {{varName}} const variableRegex = /\{\{([^{}]+?)\}\}/g; let match; while ((match = variableRegex.exec(text)) !== null) { const fullMatch = match[0]; const varRef = match[1]; // Handle field access in variable names (e.g., "data.user.name") const parts = varRef.split('.'); const baseVar = parts[0]; let value: any; // First, try to find the variable in the text variables value = stateToUse?.getTextVar?.(baseVar); // If not found in text variables, try data variables if (value === undefined) { value = stateToUse?.getDataVar?.(baseVar); } // If variable is not found, throw an error if (value === undefined) { if (baseVar.startsWith('ENV_')) { throw new MeldResolutionError( `Environment variable not set: ${baseVar}`, { code: ResolutionErrorCode.UNDEFINED_VARIABLE, severity: ErrorSeverity.Recoverable, details: { variableName: baseVar, variableType: 'text' } } ); } else { throw new MeldResolutionError( `Undefined variable: ${baseVar}`, { code: ResolutionErrorCode.UNDEFINED_VARIABLE, severity: ErrorSeverity.Recoverable, details: { variableName: baseVar, variableType: parts.length > 1 ? 'data' as const : 'text' as const } } ); } } // For data variables with field access, resolve fields if (parts.length > 1 && typeof value === 'object' && value !== null) { try { // Store the original object for comparison const originalObject = value; // Direct implementation of field access let current = value; // Wrap any console.log statements in DEBUG checks if (process.env.DEBUG === 'true') { console.log('FIELD ACCESS - Initial object:', JSON.stringify(value, null, 2)); console.log('FIELD ACCESS - Field path:', parts.slice(1)); } // Process each field in the path for (const field of parts.slice(1)) { if (process.env.DEBUG === 'true') { console.log(`FIELD ACCESS - Accessing field: ${field}`); } // Check if we can access this field if (current === null || current === undefined) { if (process.env.DEBUG === 'true') { console.log('FIELD ACCESS - Cannot access field of null/undefined'); } throw new Error(`Cannot access field ${field} of undefined or null`); } // Check if the current value is an object and has the field if (typeof current !== 'object' || !(field in current)) { if (process.env.DEBUG === 'true') { console.log(`FIELD ACCESS - Field ${field} not found in object:`, current); } throw new Error(`Cannot access field ${field} of ${typeof current}`); } // Access the field - improve handling of array indices if (Array.isArray(current) && /^\d+$/.test(field)) { const index = parseInt(field, 10); if (index < 0 || index >= current.length) { if (process.env.DEBUG === 'true') { console.log(`FIELD ACCESS - Array index out of bounds: ${index} (length: ${current.length})`); } throw new Error(`Array index out of bounds: ${index} (length: ${current.length})`); } current = current[index]; } else { current = current[field]; } if (process.env.DEBUG === 'true') { console.log(`FIELD ACCESS - Field value:`, current); } } // Update the value with the field access result value = current; // Check if the field access actually changed the value if (value === originalObject) { if (process.env.DEBUG === 'true') { console.warn(`Field access may not have worked correctly for ${parts.join('.')}`); } } if (process.env.DEBUG === 'true') { console.log('FIELD ACCESS - Final result:', value); } } catch (error) { if (error instanceof MeldResolutionError) { throw error; } throw new MeldResolutionError( `Failed to access field ${parts.slice(1).join('.')} in ${baseVar}`, { code: ResolutionErrorCode.FIELD_ACCESS_ERROR, severity: ErrorSeverity.Recoverable, details: { variableName: baseVar, value: `Error accessing ${parts.slice(1).join('.')}: ${(error as Error).message}` } } ); } } // Stringification logic - key part of the fix let stringValue: string; if (typeof value === 'object' && value !== null) { if (parts.length === 1) { // We're not doing field access, stringify the whole object // Use pretty-printed JSON for better readability stringValue = JSON.stringify(value, null, 2); } else { // We were doing field access - only stringify if the result is still an object stringValue = typeof value === 'object' ? JSON.stringify(value, null, 2) : String(value); } } else { // For primitive values, just convert to string stringValue = String(value); } // Convert any undefined values to empty strings if (value === undefined) { stringValue = ''; } // Replace the variable in the text result = result.replace(fullMatch, stringValue); } return result; } catch (error) { console.error('*** Error during variable resolution:', error); throw error; } } /** * Extract variable nodes from the AST * @param nodes AST nodes * @returns Array of variable reference nodes */ private extractVariableNodesFromAst(nodes: MeldNode[]): MeldNode[] { if (!nodes) { return []; } const variableNodes: MeldNode[] = []; // Process each node for (const node of nodes) { if (node.type === 'TextVar' || node.type === 'DataVar' || (node.type === 'Directive' && ((node as any).directive?.kind === 'text' || (node as any).directive?.kind === 'data'))) { variableNodes.push(node); } else if (node.type === 'Text') { // For text nodes, check if they contain variable references const textContent = (node as TextNode).content; if (textContent.includes('{{')) { // Parse the text content to extract variable references try { const subNodes = this.parserService?.parse(textContent); if (subNodes && Array.isArray(subNodes)) { const subVarNodes = this.extractVariableNodesFromAst(subNodes); variableNodes.push(...subVarNodes); } } catch (error) { // If parsing fails, just continue console.log('*** Failed to parse variable references from text node:', textContent); } } } } return variableNodes; } /** * Extract references using AST - now properly handles async */ private async extractReferencesAst(text: string): Promise<string[]> { try { // Check if parser service is available if (!this.parserService) { throw new Error('ParserService is required for variable resolution'); } // Use the parser to get AST nodes const nodes = await this.parserService.parse(text); // Extract variable names from nodes if (!nodes) { return []; } return this.extractReferencesFromNodes(nodes); } catch (error) { console.error('*** Error during AST-based variable extraction:', error); // Don't fall back to regex - just return empty array return []; } } /** * Extract references from AST nodes */ private extractReferencesFromNodes(nodes: MeldNode[]): string[] { if (!nodes) { return []; } const references = new Set<string>(); for (const node of nodes) { if (node.type === 'Text') { // Extract from text content const textNode = node as TextNode; const extracted = this.extractReferencesFromText(textNode.content); if (Array.isArray(extracted)) { extracted.forEach(ref => references.add(ref)); } } else if (node.type === 'TextVar' || node.type === 'DataVar' || node.type === 'PathVar' || (node.type === 'Directive' && ((node as any).directive?.kind === 'text' || (node as any).directive?.kind === 'data'))) { // Extract from variable nodes const varNode = node as any; const varName = varNode.identifier || varNode.variable || (varNode.directive?.identifier) || (varNode.directive?.variable); if (varName) { references.add(varName); } } } return Array.from(references); } /** * Extract references from text content (helper method) */ private extractReferencesFromText(text: string): string[] { // Use regex pattern matching instead of trying to call async method const references = new Set<string>(); // Match {{varName}} pattern without using the parser const matches = text.match(/\{\{([^{}]+)\}\}/g) || []; for (const match of matches) { // Extract variable name without braces const varName = match.substring(2, match.length - 2); // For field access (data.field.subfield), only include the base variable const baseVar = varName.split('.')[0]; references.add(baseVar); } return Array.from(references); } /** * Extract references using regex pattern matching - now delegates to async method * This is kept for backward compatibility */ private extractReferencesRegex(text: string): string[] { // We can't directly use the async method in a sync context, // so we'll implement a simpler version const references = new Set<string>(); // Match {{varName}} pattern without using the parser const matches = text.match(/\{\{([^{}]+)\}\}/g) || []; for (const match of matches) { // Extract variable name without braces const varName = match.substring(2, match.length - 2); // For field access (data.field.subfield), only include the base variable const baseVar = varName.split('.')[0]; references.add(baseVar); } return Array.from(references); } // Safe accessor methods to handle different context shapes private getSafeTextVars(context: ResolutionContext): Record<string, string> { if (!context || !context.state) { return {}; } try { // Convert Map to plain object if needed const textVars = context.state.getAllTextVars(); if (textVars instanceof Map) { return Object.fromEntries(textVars); } return textVars || {}; } catch (error) { console.error('Error accessing text variables:', error); return {}; } } private getSafeDataVars(context: ResolutionContext): Record<string, any> { if (!context || !context.state) { return {}; } try { // Get data variables from state const dataVars = context.state.getAllDataVars(); // Convert to plain object if needed if (dataVars instanceof Map) { return Object.fromEntries(dataVars); } return dataVars || {}; } catch (error) { console.error('Error accessing data variables:', error); return {}; } } private async resolveWithAst(text: string, context: ResolutionContext): Promise<string> { // Check if parser service is available if (!this.parserService) { throw new Error('Parser service not available'); } // Parse the text to get AST nodes const nodes = await this.parserService.parse(text); console.log('*** Parser result:', { hasNodes: !!nodes, nodeCount: nodes?.length || 0, nodeTypes: nodes?.map(n => n.type) || [] }); // If parsing failed or returned empty, return original text if (!nodes || nodes.length === 0) { console.log('*** No AST nodes, falling back to simple variables'); return this.resolveSimpleVariables(text, context); } // Process nodes to resolve variables console.log('*** Processing AST nodes'); const result = await this.resolveNodes(nodes, context); console.log('*** AST processing result:', result); return result; } /** * Check if text contains variable references */ private async hasVariableReferences(text: string): Promise<boolean> { try { // Parse the text to find variable nodes const nodes = await this.parserService?.parse(text); const hasVariables = nodes?.some(node => node.type === 'TextVar' || node.type === 'DataVar' || node.type === 'PathVar' || (node.type === 'Directive' && ((node as any).directive?.kind === 'text' || (node as any).directive?.kind === 'data' || (node as any).directive?.kind === 'command')) ); return hasVariables || false; } catch (error) { // If parsing fails, fall back to simple check return text.includes('{{'); } } /** * Extract variable references from a string - now properly uses parser when available * @param text The text to search for references * @returns Array of unique variable names */ extractReferences(text: string): string[] { // Use regex-based extraction for sync method // This method should later be refactored to use the async version return this.extractReferencesRegex(text); } /** * Extract variable references from text (async version) * Note: This is needed for proper async handling with the parser. * @param text Text to extract references from * @returns Promise resolving to array of variable names */ async extractReferencesAsync(text: string): Promise<string[]> { try { // Check if parser service is available if (!this.parserService) { return this.extractReferencesRegex(text); } // Parse the text into nodes const nodes = await this.parserService.parse(text); if (!nodes) { return []; } // Extract references from the nodes return this.extractReferencesFromNodes(nodes); } catch (error) { console.error('*** Error during variable reference extraction:', error); // Fall back to regex extraction return this.extractReferencesRegex(text); } } /** * Debug helper to trace field access resolution * @param obj The object to access fields on * @param fields Array of field names to access * @param context Resolution context * @returns Detailed debug information about field access */ private debugFieldAccess(obj: any, fields: string[], context: ResolutionContext): { result: any; steps: Array<{ field: string; type: string; value: any; }>; } { if (!obj) { return { result: undefined, steps: [{ field: 'initial', type: typeof obj, value: obj }] }; } let current = obj; const steps: Array<{ field: string; type: string; value: any; }> = [ { field: 'initial', type: Array.isArray(obj) ? 'array' : typeof obj, value: obj } ]; for (const field of fields) { // For arrays, check if the field is a valid numeric index if (Array.isArray(current) && /^\d+$/.test(field)) { const index = parseInt(field, 10); if (index < 0 || index >= current.length) { steps.push({ field, type: 'error', value: `Array index out of bounds: ${index} (array length: ${current.length})` }); return { result: undefined, steps }; } current = current[index]; steps.push({ field, type: Array.isArray(current) ? 'array' : typeof current, value: current }); } // For objects, check if the field exists else if (typeof current === 'object' && current !== null) { if (!(field in current)) { steps.push({ field, type: 'error', value: `Field not found on object: ${field}` }); return { result: undefined, steps }; } current = current[field]; steps.push({ field, type: Array.isArray(current) ? 'array' : typeof current, value: current }); } // Handle primitive values else { steps.push({ field, type: 'error', value: `Cannot access field ${field} on primitive value: ${current}` }); return { result: undefined, steps }; } } return { result: current, steps }; } /** * Gets a variable from the state service * @param name The variable name * @param context The resolution context * @returns The variable value */ private async getVariable(name: string, context: ResolutionContext): Promise<any> { const { state, allowedVariableTypes = { text: true, data: true } } = context; if (!state) { // Track failed resolution attempt this.trackResolutionAttempt(name, context, false, undefined, 'State service not available'); throw new Error('State service not available'); } // Try to get a text variable first let value: any; if (allowedVariableTypes.text) { value = state.getTextVar(name); if (value !== undefined) { // Track successful text variable resolution this.trackResolutionAttempt(name, context, true, value, 'text'); return value; } } // If no text variable found, try data variable if (value === undefined && allowedVariableTypes.data) { value = state.getDataVar(name); if (value !== undefined) { // Track successful data variable resolution this.trackResolutionAttempt(name, context, true, value, 'data'); return value; } } // Handle environment variables if relevant // Note: We check if 'env' property exists before using it if (value === undefined && allowedVariableTypes && 'env' in allowedVariableTypes && allowedVariableTypes.env) { value = process.env[name]; if (value !== undefined) { // Track successful environment variable resolution this.trackResolutionAttempt(name, context, true, value, 'env'); return value; } } // Track failed resolution attempt this.trackResolutionAttempt(name, context, false, undefined, 'Variable not found'); return value; } /** * Track a variable resolution attempt if tracker is available * @private */ private trackResolutionAttempt( variableName: string, context: ResolutionContext, success: boolean, value?: any, source?: string ): void { if (!this.resolutionTracker) return; this.resolutionTracker.trackResolutionAttempt( variableName, context.currentFilePath || 'unknown', success, value, source ); } async resolveFieldAccess(variableName: string, fieldPath: string, context: ResolutionContext): Promise<any> { // Get the base variable value const value = await this.getVariable(variableName, context); if (value === undefined) { throw new MeldResolutionError( `Variable ${variableName} not found`, { code: ResolutionErrorCode.RESOLUTION_FAILED, details: { variableName, fieldPath } } ); } // If no field path, return the value directly if (!fieldPath) { return value; } // Split the field path into segments const fieldSegments = fieldPath.split('.'); // Traverse the object/array structure let currentValue = value; let currentPath = ''; for (const segment of fieldSegments) { if (currentValue === undefined || currentValue === null) { throw new MeldResolutionError( `Cannot access field ${fieldPath} in ${variableName}: path ${currentPath} is ${currentValue}`, { code: ResolutionErrorCode.RESOLUTION_FAILED, details: { variableName, fieldPath, value: `Error accessing ${fieldPath}: path ${currentPath} is ${currentValue}` } } ); } currentPath = currentPath ? `${currentPath}.${segment}` : segment; // Check if segment is a numeric index and current value is an array if (Array.isArray(currentValue) && /^\d+$/.test(segment)) { const index = parseInt(segment, 10); if (index < 0 || index >= currentValue.length) { throw new MeldResolutionError( `Array index out of bounds: ${index} (length: ${currentValue.length})`, { code: ResolutionErrorCode.RESOLUTION_FAILED, details: { variableName, fieldPath, value: `Error accessing ${fieldPath}: index ${index} out of bounds` } } ); } currentValue = currentValue[index]; } // Handle object property access else if (typeof currentValue === 'object' && currentValue !== null) { // First try to access as a property if (segment in currentValue) { currentValue = currentValue[segment]; } // If that fails and segment is numeric, try array access // This is a fallback for objects with numeric keys stored as numbers else if (/^\d+$/.test(segment) && Array.isArray(currentValue)) { const index = parseInt(segment, 10); if (index < 0 || index >= currentValue.length) { throw new MeldResolutionError( `Array index out of bounds: ${index} (length: ${currentValue.length})`, { code: ResolutionErrorCode.RESOLUTION_FAILED, details: { variableName, fieldPath, value: `Error accessing ${fieldPath}: index ${index} out of bounds` } } ); } currentValue = currentValue[index]; } else { throw new MeldResolutionError( `Property ${segment} not found in object at path ${currentPath}`, { code: ResolutionErrorCode.RESOLUTION_FAILED, details: { variableName, fieldPath, value: `Error accessing ${fieldPath}: property ${segment} not found` } } ); } } // Handle direct value access (e.g., string[0]) else if (typeof currentValue === 'string' && /^\d+$/.test(segment)) { const index = parseInt(segment, 10); if (index < 0 || index >= currentValue.length) { throw new MeldResolutionError( `String index out of bounds: ${index} (length: ${currentValue.length})`, { code: ResolutionErrorCode.RESOLUTION_FAILED, details: { variableName, fieldPath, value: `Error accessing ${fieldPath}: string index ${index} out of bounds` } } ); } currentValue = currentValue[index]; } else { throw new MeldResolutionError( `Cannot access field ${segment} on non-object value at path ${currentPath}`, { code: ResolutionErrorCode.RESOLUTION_FAILED, details: { variableName, fieldPath, value: `Error accessing ${fieldPath}: cannot access field ${segment} on value of type ${typeof currentValue}` } } ); } } return currentValue; } }