From 2366926c675db054c67f848bc875016464a1ccf2 Mon Sep 17 00:00:00 2001 From: Michael Chihlas Date: Sat, 14 Mar 2026 18:35:35 -0400 Subject: [PATCH] docs: add parameter detector implementation plan 6-task plan covering type definition, detection engine, stepper UI, editor integration, manual testing, and final verification. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../2026-03-14-parameter-detector-plan.md | 920 ++++++++++++++++++ 1 file changed, 920 insertions(+) create mode 100644 docs/plans/2026-03-14-parameter-detector-plan.md diff --git a/docs/plans/2026-03-14-parameter-detector-plan.md b/docs/plans/2026-03-14-parameter-detector-plan.md new file mode 100644 index 00000000..3a99e73d --- /dev/null +++ b/docs/plans/2026-03-14-parameter-detector-plan.md @@ -0,0 +1,920 @@ +# Parameter Detector Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Add a client-side PowerShell parameter detection tool to the Script Template Editor that scans script bodies for hardcoded values and walks users through converting them to template parameters via a stepper UI. + +**Architecture:** Pure frontend feature. A detection engine (`lib/scriptParameterDetector.ts`) parses PowerShell script bodies using regex to find script-level `param()` block entries and variable assignments. A stepper component (`ParameterDetectorStepper`) presents candidates one-by-one for review. Accepted candidates update both `form.script_body` (value → `{{key}}`) and `form.parameters_schema` (new `ScriptParameter` appended). + +**Tech Stack:** TypeScript, React, Lucide icons, Tailwind CSS, existing ScriptParameter types + +**Design doc:** `docs/plans/2026-03-14-parameter-detector-design.md` + +--- + +### Task 1: Add ParameterCandidate type + +**Files:** +- Modify: `frontend/src/types/scripts.ts:124` (append after `ScriptTemplateUpdateRequest`) + +**Step 1: Add the interface** + +Add to the end of `frontend/src/types/scripts.ts`: + +```typescript +export interface ParameterCandidate { + variableName: string + suggestedKey: string + suggestedLabel: string + suggestedType: ScriptParameter['type'] + sensitive: boolean + defaultValue: string | boolean | number | null + source: 'param_block' | 'assignment' + lineNumber: number + matchedLine: string + inferenceReason: string +} +``` + +**Step 2: Export from types index** + +Verify `ParameterCandidate` is exported from `frontend/src/types/index.ts`. If scripts types are re-exported with `export * from './scripts'`, it's automatic. Otherwise add the export. + +**Step 3: Run build to verify** + +Run: `cd frontend && npm run build` +Expected: SUCCESS + +**Step 4: Commit** + +```bash +git add frontend/src/types/scripts.ts +git commit -m "feat: add ParameterCandidate type for script parameter detection" +``` + +--- + +### Task 2: Build detection engine + +**Files:** +- Create: `frontend/src/lib/scriptParameterDetector.ts` + +**Step 1: Create the detection utility** + +Create `frontend/src/lib/scriptParameterDetector.ts` with the following: + +```typescript +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 { + // Strip leading $ + const name = varName.replace(/^\$/, '') + // Insert underscore before uppercase letters, then lowercase everything + 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 } { + // Check type annotation first + 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` } + } + // [string] or other → fall through to value/name checks + } + + // Check variable name for sensitive patterns + if (SENSITIVE_PATTERNS.test(varName)) { + return { type: 'password', sensitive: true, reason: `Variable name suggests sensitive data — marked as sensitive` } + } + + // Check value patterns + 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' } + } + } + + // Default + 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 + } + // Strip surrounding quotes for string values + if ((trimmed.startsWith("'") && trimmed.endsWith("'")) || + (trimmed.startsWith('"') && trimmed.endsWith('"'))) { + return trimmed.slice(1, -1) + } + return trimmed +} + +/** + * Find the end index of a script-level param() block. + * Returns -1 if no script-level param block is found. + * Skips param() blocks inside function declarations. + */ +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() + + // Track function blocks — skip param() inside functions + if (/^function\s+/i.test(trimmed)) { + inFunction = true + continue + } + + // Find script-level param keyword + if (!inFunction && /^param\s*\(/i.test(trimmed) && paramStart === -1) { + paramStart = i + // Count parens to find the closing ) + 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 } + } + } + } + } + + // Reset function tracking at closing brace (simplified) + 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[] = [] + + // Match patterns like: [string]$VarName = "default" or $VarName or [switch]$VarName + // Supports [Parameter(Mandatory=$true)] attributes on preceding lines + 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 + + // Skip Parameter() attributes — they look like [Parameter(...)] + if (typeAnnotation && /^Parameter$/i.test(typeAnnotation)) continue + + const key = toSnakeCase(varName) + const { type, sensitive, reason } = inferType(typeAnnotation, rawDefault, varName) + const defaultValue = parseDefault(rawDefault, type) + + // Find the actual line number in the original script + 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 +): ParameterCandidate[] { + const lines = script.split('\n') + const candidates: ParameterCandidate[] = [] + const seenVars = new Set() + + // Match: $VarName = 'value' | "value" | 123 | $true | $false + 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() + + // Skip PS internals + if (SKIP_VARIABLES.has(fullVar)) continue + + // Skip if already found in param block + if (existingVarNames.has(fullVar)) continue + + // Skip if already seen (take first assignment only) + if (seenVars.has(fullVar)) continue + + // Skip if value is a complex expression (function call, pipeline, etc.) + // Only match: quoted strings, numbers, $true/$false + if (!/^['"].*['"]$/.test(rawValue) && + !/^-?\d+(\.\d+)?$/.test(rawValue) && + !/^\$(true|false)$/i.test(rawValue)) { + continue + } + + // Skip if the value already contains a {{placeholder}} + 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. + * Returns candidates from script-level param() block first, then variable assignments. + */ +export function detectParameterCandidates(script: string): ParameterCandidate[] { + if (!script.trim()) return [] + + // 1. Find and extract script-level param block + const paramBlock = findScriptLevelParamBlock(script) + const paramCandidates = paramBlock + ? extractParamBlockCandidates(script, paramBlock) + : [] + + // Track param block var names to avoid duplicates in assignment scan + const paramVarNames = new Set(paramCandidates.map(c => c.variableName)) + + // 2. Extract variable assignments + const assignmentCandidates = extractAssignmentCandidates(script, paramVarNames) + + return [...paramCandidates, ...assignmentCandidates] +} +``` + +**Step 2: Run build to verify** + +Run: `cd frontend && npm run build` +Expected: SUCCESS + +**Step 3: Commit** + +```bash +git add frontend/src/lib/scriptParameterDetector.ts +git commit -m "feat: add PowerShell parameter detection engine" +``` + +--- + +### Task 3: Build ParameterDetectorStepper component + +**Files:** +- Create: `frontend/src/components/script-editor/ParameterDetectorStepper.tsx` + +**Step 1: Create the stepper component** + +Create `frontend/src/components/script-editor/ParameterDetectorStepper.tsx`: + +```tsx +import { useState } from 'react' +import { ChevronRight, SkipForward, Info, Check } from 'lucide-react' +import { cn } from '@/lib/utils' +import { Input } from '@/components/ui/Input' +import type { ParameterCandidate, ScriptParameter } from '@/types' + +const PARAM_TYPES: { value: ScriptParameter['type']; label: string }[] = [ + { value: 'text', label: 'Text' }, + { value: 'password', label: 'Password' }, + { value: 'textarea', label: 'Textarea' }, + { value: 'number', label: 'Number' }, + { value: 'boolean', label: 'Boolean' }, + { value: 'select', label: 'Select' }, + { value: 'multi_text', label: 'Multi-text' }, +] + +interface Props { + candidates: ParameterCandidate[] + existingKeys: string[] + onAccept: (candidate: ParameterCandidate, overrides: { + key: string + label: string + type: ScriptParameter['type'] + sensitive: boolean + required: boolean + defaultValue: string | boolean | number | null + }) => void + onSkip: (candidate: ParameterCandidate) => void + onFinish: (acceptedCount: number, totalCount: number) => void +} + +export function ParameterDetectorStepper({ + candidates, + existingKeys, + onAccept, + onSkip, + onFinish, +}: Props) { + const [currentIndex, setCurrentIndex] = useState(0) + const [acceptedCount, setAcceptedCount] = useState(0) + const [showInferenceInfo, setShowInferenceInfo] = useState(false) + + // Editable overrides for the current candidate + const current = candidates[currentIndex] + const [key, setKey] = useState(current.suggestedKey) + const [label, setLabel] = useState(current.suggestedLabel) + const [type, setType] = useState(current.suggestedType) + const [sensitive, setSensitive] = useState(current.sensitive) + const [required, setRequired] = useState(true) + const [defaultValue, setDefaultValue] = useState( + current.defaultValue !== null ? String(current.defaultValue) : '' + ) + + const isLast = currentIndex === candidates.length - 1 + const keyConflict = existingKeys.includes(key) || + candidates.slice(0, currentIndex).some((_, i) => { + // This is a simplification — actual conflict check happens against + // the running list of accepted keys which is managed by the parent + return false + }) + + const resetFieldsForIndex = (index: number) => { + const c = candidates[index] + setKey(c.suggestedKey) + setLabel(c.suggestedLabel) + setType(c.suggestedType) + setSensitive(c.sensitive) + setRequired(true) + setDefaultValue(c.defaultValue !== null ? String(c.defaultValue) : '') + setShowInferenceInfo(false) + } + + const handleAccept = () => { + const parsedDefault = type === 'boolean' + ? defaultValue === 'true' + : type === 'number' + ? (defaultValue ? Number(defaultValue) : null) + : (defaultValue || null) + + onAccept(current, { + key, + label, + type, + sensitive, + required, + defaultValue: parsedDefault, + }) + + const newAccepted = acceptedCount + 1 + setAcceptedCount(newAccepted) + + if (isLast) { + onFinish(newAccepted, candidates.length) + } else { + const nextIndex = currentIndex + 1 + setCurrentIndex(nextIndex) + resetFieldsForIndex(nextIndex) + } + } + + const handleSkip = () => { + onSkip(current) + + if (isLast) { + onFinish(acceptedCount, candidates.length) + } else { + const nextIndex = currentIndex + 1 + setCurrentIndex(nextIndex) + resetFieldsForIndex(nextIndex) + } + } + + return ( +
+ {/* Progress */} +
+

+ Candidate {currentIndex + 1} of {candidates.length} +

+
+ {candidates.map((_, i) => ( +
+ ))} +
+
+ + {/* Matched line */} +
+

+ {current.matchedLine} +

+

+ Line {current.lineNumber} · {current.source === 'param_block' ? 'param() block' : 'variable assignment'} +

+
+ + {/* Editable fields */} +
+
+ + setKey(e.target.value.replace(/[^a-zA-Z0-9_]/g, ''))} + placeholder="param_key" + /> + {existingKeys.includes(key) && ( +

Key already exists — consider a different name

+ )} +
+
+ + setLabel(e.target.value)} + placeholder="Display Label" + /> +
+
+ +
+
+ + + {showInferenceInfo && ( +

+ {current.inferenceReason} +

+ )} +
+
+ + setDefaultValue(e.target.value)} + placeholder="Original value preserved" + /> +
+
+ +
+ + +
+ + {/* Actions */} +
+ + +
+
+ ) +} +``` + +**Step 2: Run build to verify** + +Run: `cd frontend && npm run build` +Expected: SUCCESS + +**Step 3: Commit** + +```bash +git add frontend/src/components/script-editor/ParameterDetectorStepper.tsx +git commit -m "feat: add ParameterDetectorStepper component" +``` + +--- + +### Task 4: Wire detection into ScriptTemplateEditor + +**Files:** +- Modify: `frontend/src/components/script-editor/ScriptTemplateEditor.tsx` + +**Step 1: Add imports** + +At the top of `ScriptTemplateEditor.tsx`, add: + +```typescript +import { Scan } from 'lucide-react' +import { detectParameterCandidates } from '@/lib/scriptParameterDetector' +import { ParameterDetectorStepper } from './ParameterDetectorStepper' +import type { ParameterCandidate, ScriptParameter } from '@/types' +``` + +Update the existing `lucide-react` import to include `Scan` alongside the existing icons. + +**Step 2: Add detection state** + +Inside the `ScriptTemplateEditor` component, after the existing `useState` declarations (around line 59), add: + +```typescript +const [detectedCandidates, setDetectedCandidates] = useState([]) +const [showStepper, setShowStepper] = useState(false) +const [detectionSummary, setDetectionSummary] = useState(null) +``` + +**Step 3: Add detection handler** + +After `handleBack` (around line 188), add: + +```typescript +const handleDetectParameters = () => { + const candidates = detectParameterCandidates(form.script_body) + if (candidates.length === 0) { + setDetectionSummary('No parameter candidates detected in the script body.') + setShowStepper(false) + setTimeout(() => setDetectionSummary(null), 4000) + return + } + setDetectedCandidates(candidates) + setDetectionSummary(null) + setShowStepper(true) +} + +const handleAcceptCandidate = ( + candidate: ParameterCandidate, + overrides: { + key: string + label: string + type: ScriptParameter['type'] + sensitive: boolean + required: boolean + defaultValue: string | boolean | number | null + } +) => { + // 1. Replace the value in the script body with {{key}} + let updatedScript = form.script_body + if (candidate.source === 'param_block') { + // For param block: replace the default value portion + // e.g., $VarName = "default" → $VarName = "{{key}}" + const defaultMatch = candidate.matchedLine.match(/=\s*(.+?)(?:\s*,?\s*$)/) + if (defaultMatch) { + updatedScript = updatedScript.replace( + candidate.matchedLine, + candidate.matchedLine.replace(defaultMatch[1], `'{{${overrides.key}}}'`) + ) + } + } else { + // For assignment: replace the right-hand side value + const assignMatch = candidate.matchedLine.match(/=\s*(.+)$/) + if (assignMatch) { + updatedScript = updatedScript.replace( + candidate.matchedLine, + candidate.matchedLine.replace(assignMatch[1], `'{{${overrides.key}}}'`) + ) + } + } + + // 2. Append new parameter to the schema + const existingParams = form.parameters_schema.parameters + const newParam: ScriptParameter = { + key: overrides.key, + label: overrides.label, + type: overrides.type, + required: overrides.required, + placeholder: null, + group: null, + order: existingParams.length + 1, + help_text: null, + options: null, + default: overrides.defaultValue, + validation: null, + sensitive: overrides.sensitive, + } + + // Update both fields + setForm(f => ({ + ...f, + script_body: updatedScript, + parameters_schema: { + parameters: [...f.parameters_schema.parameters, newParam], + }, + })) + setIsDirty(true) +} + +const handleSkipCandidate = () => { + // Nothing to do — stepper advances internally +} + +const handleDetectionFinish = (acceptedCount: number, totalCount: number) => { + setShowStepper(false) + setDetectedCandidates([]) + setDetectionSummary( + acceptedCount === 0 + ? 'No parameters were added.' + : `Added ${acceptedCount} of ${totalCount} detected parameter${totalCount !== 1 ? 's' : ''}.` + ) + setTimeout(() => setDetectionSummary(null), 5000) +} +``` + +**Step 4: Add UI elements to the Script Body section** + +In the JSX, find the Script Body section (around line 334-348). After the `` and before ``, add the detect button and stepper: + +```tsx + updateField('script_body', v)} + /> + + {/* Detect Parameters button + stepper */} + {form.script_body.trim() && !showStepper && ( + + )} + + {detectionSummary && ( +

{detectionSummary}

+ )} + + {showStepper && detectedCandidates.length > 0 && ( + p.key)} + onAccept={handleAcceptCandidate} + onSkip={handleSkipCandidate} + onFinish={handleDetectionFinish} + /> + )} +``` + +**Step 5: Run build to verify** + +Run: `cd frontend && npm run build` +Expected: SUCCESS + +**Step 6: Commit** + +```bash +git add frontend/src/components/script-editor/ScriptTemplateEditor.tsx +git commit -m "feat: wire parameter detection into ScriptTemplateEditor" +``` + +--- + +### Task 5: Manual testing checklist + +**Step 1: Test with variable assignments** + +1. Navigate to `/scripts/manage` → click New Template +2. Paste this script body: +```powershell +$ServerName = 'DC01' +$OUPath = 'OU=Users,DC=contoso,DC=com' +$DefaultPassword = 'Welcome123!' +$ForceChange = $true +$MaxRetries = 3 +``` +3. Click "Detect Parameters" +4. Verify 5 candidates appear in stepper +5. Verify type inference: ServerName=text, OUPath=text, DefaultPassword=password+sensitive, ForceChange=boolean, MaxRetries=number +6. Accept all — verify script body has `{{key}}` placeholders and Parameters section has 5 entries with defaults preserved + +**Step 2: Test with param() block** + +Paste: +```powershell +param( + [string]$ServerName = "DC01", + [switch]$WhatIf, + [SecureString]$AdminPassword, + [int]$Port = 443 +) + +$Connection = "https://$ServerName:$Port" +``` +Expected: 4 candidates from param block (ServerName, WhatIf, AdminPassword, Port) + 0 from assignments (Connection is a complex expression, not a simple literal) + +**Step 3: Test with function-level param (should be skipped)** + +Paste: +```powershell +$GlobalPath = 'C:\Scripts' + +function Load-Users { + param($filter = "*") + Get-ADUser -Filter $filter +} +``` +Expected: 1 candidate only (GlobalPath). The function-level `param($filter)` should NOT appear. + +**Step 4: Test edge cases** + +- Empty script body → Detect Parameters button hidden +- Script with only `{{key}}` placeholders → "No parameter candidates detected" +- Script with PS internals like `$ErrorActionPreference = 'Stop'` → skipped +- Re-running detect after accepting some → already-converted values skipped + +**Step 5: Commit any fixes** + +```bash +git commit -m "fix: address issues found during parameter detector testing" +``` + +--- + +### Task 6: Final build verification and push + +**Step 1: Run full build** + +Run: `cd frontend && npm run build` +Expected: SUCCESS with no type errors + +**Step 2: Push** + +```bash +git push +```