feat: add PowerShell parameter detection engine

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Michael Chihlas
2026-03-14 18:42:54 -04:00
parent 53b07e92c9
commit c012ca30e6

View File

@@ -0,0 +1,280 @@
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 inFunction = false
let paramStart = -1
let parenDepth = 0
for (let i = 0; i < lines.length; i++) {
const trimmed = lines[i].trim()
if (/^function\s+/i.test(trimmed)) {
inFunction = true
continue
}
if (!inFunction && /^param\s*\(/i.test(trimmed) && paramStart === -1) {
paramStart = i
for (let j = i; j < lines.length; j++) {
for (const ch of lines[j]) {
if (ch === '(') parenDepth++
if (ch === ')') parenDepth--
if (parenDepth === 0 && paramStart !== -1) {
return { start: paramStart, end: j }
}
}
}
}
if (inFunction && trimmed === '}') {
inFunction = false
}
}
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 blockText = lines.slice(block.start, block.end + 1).join('\n')
const candidates: ParameterCandidate[] = []
const paramRegex = /(?:\[(\w+)\])?\s*\$(\w+)(?:\s*=\s*(.+?))?(?:\s*,\s*$|\s*$|\s*\))/gm
let match: RegExpExecArray | null
while ((match = paramRegex.exec(blockText)) !== null) {
const typeAnnotation = match[1] || null
const varName = match[2]
const rawDefault = match[3]?.trim() ?? null
if (typeAnnotation && /^Parameter$/i.test(typeAnnotation)) continue
const key = toSnakeCase(varName)
const { type, sensitive, reason } = inferType(typeAnnotation, rawDefault, varName)
const defaultValue = parseDefault(rawDefault, type)
const lineIndex = lines.findIndex((line, idx) =>
idx >= block.start && idx <= block.end && line.includes(`$${varName}`)
)
candidates.push({
variableName: `$${varName}`,
suggestedKey: key,
suggestedLabel: toLabel(key),
suggestedType: type,
sensitive,
defaultValue,
source: 'param_block',
lineNumber: lineIndex !== -1 ? lineIndex + 1 : block.start + 1,
matchedLine: lineIndex !== -1 ? lines[lineIndex].trim() : `$${varName}`,
inferenceReason: reason,
})
}
return candidates
}
/**
* Extract parameter candidates from variable assignments ($Var = 'value').
*/
function extractAssignmentCandidates(
script: string,
existingVarNames: Set<string>
): ParameterCandidate[] {
const lines = script.split('\n')
const candidates: ParameterCandidate[] = []
const seenVars = new Set<string>()
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]
}