import type { ScriptParameter, ParameterCandidate } from '@/types' /** * PowerShell variable names to skip — these are PS internals, not user inputs. */ const SKIP_VARIABLES = new Set([ '$ErrorActionPreference', '$WarningPreference', '$VerbosePreference', '$DebugPreference', '$InformationPreference', '$ConfirmPreference', '$ProgressPreference', '$PSDefaultParameterValues', '$PSModuleAutoLoadingPreference', '$OFS', '$FormatEnumerationLimit', '$MaximumHistoryCount', '$_', '$PSItem', '$args', '$input', '$this', '$null', '$true', '$false', ]) /** * Sensitive variable name patterns — if the variable name contains any of these, * suggest password type and mark sensitive. */ const SENSITIVE_PATTERNS = /password|secret|key|credential|token|apikey|api_key/i /** * Convert a PowerShell variable name to a snake_case key. * "$OUPath" → "ou_path", "$ServerName" → "server_name" */ function toSnakeCase(varName: string): string { const name = varName.replace(/^\$/, '') return name .replace(/([a-z0-9])([A-Z])/g, '$1_$2') .replace(/([A-Z]+)([A-Z][a-z])/g, '$1_$2') .toLowerCase() } /** * Convert a snake_case key to a human-readable label. * "ou_path" → "OU Path", "server_name" → "Server Name" */ function toLabel(key: string): string { return key .split('_') .map(w => w.charAt(0).toUpperCase() + w.slice(1)) .join(' ') } /** * Infer the ScriptParameter type from a PowerShell type annotation and/or value. */ function inferType( typeAnnotation: string | null, value: string | null, varName: string ): { type: ScriptParameter['type']; sensitive: boolean; reason: string } { if (typeAnnotation) { const t = typeAnnotation.toLowerCase() if (t === 'switch') { return { type: 'boolean', sensitive: false, reason: 'Detected [switch] type declaration' } } if (t === 'securestring') { return { type: 'password', sensitive: true, reason: 'Detected [SecureString] type — marked as sensitive' } } if (t === 'int' || t === 'int32' || t === 'int64' || t === 'double' || t === 'float' || t === 'decimal') { return { type: 'number', sensitive: false, reason: `Detected [${typeAnnotation}] type declaration` } } if (t === 'bool' || t === 'boolean') { return { type: 'boolean', sensitive: false, reason: `Detected [${typeAnnotation}] type declaration` } } } if (SENSITIVE_PATTERNS.test(varName)) { return { type: 'password', sensitive: true, reason: 'Variable name suggests sensitive data — marked as sensitive' } } if (value !== null) { const trimmed = value.trim() if (trimmed === '$true' || trimmed === '$false') { return { type: 'boolean', sensitive: false, reason: 'Detected boolean value ($true/$false)' } } if (/^-?\d+(\.\d+)?$/.test(trimmed)) { return { type: 'number', sensitive: false, reason: 'Detected numeric value' } } } const reason = typeAnnotation ? `Detected [${typeAnnotation}] type declaration` : 'Defaulting to text (no type annotation detected)' return { type: 'text', sensitive: false, reason } } /** * Parse the default value into the correct JS type. */ function parseDefault(value: string | null, type: ScriptParameter['type']): string | boolean | number | null { if (value === null) return null const trimmed = value.trim() if (type === 'boolean') { if (trimmed === '$true') return true if (trimmed === '$false') return false return null } if (type === 'number') { const n = Number(trimmed) return isNaN(n) ? null : n } if ((trimmed.startsWith("'") && trimmed.endsWith("'")) || (trimmed.startsWith('"') && trimmed.endsWith('"'))) { return trimmed.slice(1, -1) } return trimmed } /** * Find the script-level param() block (not inside any function). */ function findScriptLevelParamBlock(script: string): { start: number; end: number } | null { const lines = script.split('\n') let functionBraceDepth = 0 let paramStart = -1 let parenDepth = 0 for (let i = 0; i < lines.length; i++) { const trimmed = lines[i].trim() // Track function blocks with brace depth to handle nested braces if (/^function\s+/i.test(trimmed)) { // Count braces on this line and subsequent lines for (const ch of lines[i]) { if (ch === '{') functionBraceDepth++ if (ch === '}') functionBraceDepth-- } continue } // Track braces while inside a function if (functionBraceDepth > 0) { for (const ch of lines[i]) { if (ch === '{') functionBraceDepth++ if (ch === '}') functionBraceDepth-- } continue } if (functionBraceDepth === 0 && /^param\s*\(/i.test(trimmed) && paramStart === -1) { paramStart = i let foundOpen = false for (let j = i; j < lines.length; j++) { for (const ch of lines[j]) { if (ch === '(') { parenDepth++; foundOpen = true } if (ch === ')') parenDepth-- if (foundOpen && parenDepth === 0) { return { start: paramStart, end: j } } } } } } return null } /** * Extract parameter candidates from a script-level param() block. */ function extractParamBlockCandidates( script: string, block: { start: number; end: number } ): ParameterCandidate[] { const lines = script.split('\n') const candidates: ParameterCandidate[] = [] // Scan each line in the param block for $VarName patterns. // Lines with [Parameter(...)], [ValidateSet(...)], etc. don't contain $VarName // so they are naturally skipped. The type annotation [string], [int], etc. // appears on the same line as $VarName. const varLineRegex = /(?:\[(\w+)\])?\s*\$(\w+)(?:\s*=\s*(.+?))?(?:\s*,?\s*$)/ for (let i = block.start; i <= block.end; i++) { const trimmed = lines[i].trim() // Skip attribute lines like [Parameter(...)], [ValidateSet(...)], etc. if (/^\[(?:Parameter|ValidateSet|ValidateRange|ValidatePattern|ValidateScript|ValidateLength|ValidateCount|Alias|AllowNull|AllowEmptyString|AllowEmptyCollection)\s*\(/i.test(trimmed)) { continue } const match = trimmed.match(varLineRegex) if (!match) continue const typeAnnotation = match[1] || null const varName = match[2] const rawDefault = match[3]?.trim() ?? null // Skip if type looks like an attribute we didn't catch above if (typeAnnotation && /^Parameter$/i.test(typeAnnotation)) continue const key = toSnakeCase(varName) const { type, sensitive, reason } = inferType(typeAnnotation, rawDefault, varName) const defaultValue = parseDefault(rawDefault, type) candidates.push({ variableName: `$${varName}`, suggestedKey: key, suggestedLabel: toLabel(key), suggestedType: type, sensitive, defaultValue, source: 'param_block', lineNumber: i + 1, matchedLine: trimmed, inferenceReason: reason, }) } return candidates } /** * Extract parameter candidates from variable assignments ($Var = 'value'). */ function extractAssignmentCandidates( script: string, existingVarNames: Set ): ParameterCandidate[] { const lines = script.split('\n') const candidates: ParameterCandidate[] = [] const seenVars = new Set() const assignRegex = /^\s*(\$\w+)\s*=\s*(.+)$/ for (let i = 0; i < lines.length; i++) { const match = lines[i].match(assignRegex) if (!match) continue const fullVar = match[1] const rawValue = match[2].trim() if (SKIP_VARIABLES.has(fullVar)) continue if (existingVarNames.has(fullVar)) continue if (seenVars.has(fullVar)) continue if (!/^['"].*['"]$/.test(rawValue) && !/^-?\d+(\.\d+)?$/.test(rawValue) && !/^\$(true|false)$/i.test(rawValue)) { continue } if (/\{\{.*?\}\}/.test(rawValue)) continue seenVars.add(fullVar) const varName = fullVar.replace(/^\$/, '') const key = toSnakeCase(varName) const { type, sensitive, reason } = inferType(null, rawValue, varName) const defaultValue = parseDefault(rawValue, type) candidates.push({ variableName: fullVar, suggestedKey: key, suggestedLabel: toLabel(key), suggestedType: type, sensitive, defaultValue, source: 'assignment', lineNumber: i + 1, matchedLine: lines[i].trim(), inferenceReason: reason, }) } return candidates } /** * Detect parameter candidates in a PowerShell script body. */ export function detectParameterCandidates(script: string): ParameterCandidate[] { if (!script.trim()) return [] const paramBlock = findScriptLevelParamBlock(script) const paramCandidates = paramBlock ? extractParamBlockCandidates(script, paramBlock) : [] const paramVarNames = new Set(paramCandidates.map(c => c.variableName)) const assignmentCandidates = extractAssignmentCandidates(script, paramVarNames) return [...paramCandidates, ...assignmentCandidates] }