Files
resolutionflow/docs/plans/archive/2026-03-14-parameter-detector-plan.md
Michael Chihlas cbb4b25671
All checks were successful
Mirror to GitHub / mirror (push) Successful in 5s
CI / frontend (pull_request) Successful in 6m42s
CI / e2e (pull_request) Successful in 10m11s
CI / backend (pull_request) Successful in 10m43s
fix(ui): drop setState-in-effect in useAuthSessionExpiry
CI surfaced react-hooks/set-state-in-effect on the synchronous
setState(computeState(token)) inside the useEffect body. The earlier
shape mirrored token -> state via an effect, which is exactly the
"you might not need an effect" pattern React 19's eslint rule now
flags.

Switch to derived state: compute during render, use a useReducer
tick to force re-render on the 30s cadence (so relative timestamps
stay current even when token props don't change). Same observable
behavior, no cascading renders.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-13 20:15:11 -04:00

28 KiB

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:

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

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:

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<string>
): ParameterCandidate[] {
  const lines = script.split('\n')
  const candidates: ParameterCandidate[] = []
  const seenVars = new Set<string>()

  // 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

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:

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<ScriptParameter['type']>(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 (
    <div className="border border-primary/20 rounded-xl bg-primary/[0.03] p-4 space-y-3">
      {/* Progress */}
      <div className="flex items-center justify-between">
        <p className="text-xs font-medium text-foreground">
          Candidate {currentIndex + 1} of {candidates.length}
        </p>
        <div className="flex items-center gap-1">
          {candidates.map((_, i) => (
            <div
              key={i}
              className={cn(
                'h-1.5 w-1.5 rounded-full transition-colors',
                i < currentIndex ? 'bg-primary' :
                i === currentIndex ? 'bg-primary animate-pulse' :
                'bg-border'
              )}
            />
          ))}
        </div>
      </div>

      {/* Matched line */}
      <div className="rounded-lg bg-black/20 px-3 py-2">
        <p className="font-label text-xs text-amber-400 break-all">
          {current.matchedLine}
        </p>
        <p className="font-label text-[0.5rem] text-muted-foreground mt-1">
          Line {current.lineNumber} · {current.source === 'param_block' ? 'param() block' : 'variable assignment'}
        </p>
      </div>

      {/* Editable fields */}
      <div className="grid grid-cols-2 gap-3">
        <div>
          <label className="text-xs text-muted-foreground mb-1 block">Key</label>
          <Input
            value={key}
            onChange={e => setKey(e.target.value.replace(/[^a-zA-Z0-9_]/g, ''))}
            placeholder="param_key"
          />
          {existingKeys.includes(key) && (
            <p className="text-[0.625rem] text-amber-400 mt-0.5">Key already exists  consider a different name</p>
          )}
        </div>
        <div>
          <label className="text-xs text-muted-foreground mb-1 block">Label</label>
          <Input
            value={label}
            onChange={e => setLabel(e.target.value)}
            placeholder="Display Label"
          />
        </div>
      </div>

      <div className="grid grid-cols-2 gap-3">
        <div>
          <label className="text-xs text-muted-foreground mb-1 flex items-center gap-1.5">
            Type
            <button
              type="button"
              onClick={() => setShowInferenceInfo(!showInferenceInfo)}
              className="text-muted-foreground hover:text-primary transition-colors"
              title={current.inferenceReason}
            >
              <Info size={11} />
            </button>
          </label>
          <select
            value={type}
            onChange={e => setType(e.target.value as ScriptParameter['type'])}
            className="w-full rounded-[10px] border border-border bg-card text-foreground px-3 py-2 text-sm focus:outline-none focus:border-[rgba(6,182,212,0.3)]"
          >
            {PARAM_TYPES.map(t => (
              <option key={t.value} value={t.value}>{t.label}</option>
            ))}
          </select>
          {showInferenceInfo && (
            <p className="text-[0.625rem] text-primary/80 mt-1 italic">
              {current.inferenceReason}
            </p>
          )}
        </div>
        <div>
          <label className="text-xs text-muted-foreground mb-1 block">Default value</label>
          <Input
            value={defaultValue}
            onChange={e => setDefaultValue(e.target.value)}
            placeholder="Original value preserved"
          />
        </div>
      </div>

      <div className="flex items-center gap-4">
        <label className="flex items-center gap-2 text-sm text-foreground">
          <input
            type="checkbox"
            checked={required}
            onChange={e => setRequired(e.target.checked)}
            className="rounded border-border"
          />
          Required
        </label>
        <label className="flex items-center gap-2 text-sm text-foreground">
          <input
            type="checkbox"
            checked={sensitive}
            onChange={e => setSensitive(e.target.checked)}
            className="rounded border-border"
          />
          Sensitive
        </label>
      </div>

      {/* Actions */}
      <div className="flex items-center justify-end gap-2 pt-1 border-t border-border">
        <button
          type="button"
          onClick={handleSkip}
          className="flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground transition-colors px-3 py-1.5"
        >
          <SkipForward size={13} />
          {isLast ? 'Skip & Finish' : 'Skip'}
        </button>
        <button
          type="button"
          onClick={handleAccept}
          disabled={!key.trim() || !label.trim()}
          className="flex items-center gap-1.5 bg-gradient-brand text-[#101114] font-semibold text-sm px-4 py-1.5 rounded-[10px] hover:opacity-90 active:scale-[0.97] transition-all shadow-lg shadow-primary/20 disabled:opacity-50 disabled:cursor-not-allowed"
        >
          {isLast ? (
            <><Check size={13} /> Accept &amp; Finish</>
          ) : (
            <><ChevronRight size={13} /> Accept &amp; Next</>
          )}
        </button>
      </div>
    </div>
  )
}

Step 2: Run build to verify

Run: cd frontend && npm run build Expected: SUCCESS

Step 3: Commit

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:

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:

const [detectedCandidates, setDetectedCandidates] = useState<ParameterCandidate[]>([])
const [showStepper, setShowStepper] = useState(false)
const [detectionSummary, setDetectionSummary] = useState<string | null>(null)

Step 3: Add detection handler

After handleBack (around line 188), add:

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 <ScriptBodyEditor> and before </section>, add the detect button and stepper:

        <ScriptBodyEditor
          value={form.script_body}
          onChange={v => updateField('script_body', v)}
        />

        {/* Detect Parameters button + stepper */}
        {form.script_body.trim() && !showStepper && (
          <button
            type="button"
            onClick={handleDetectParameters}
            className="flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground bg-[rgba(255,255,255,0.04)] border border-[rgba(255,255,255,0.06)] hover:border-[rgba(255,255,255,0.12)] px-3 py-1.5 rounded-[10px] transition-all"
          >
            <Scan size={14} />
            Detect Parameters
          </button>
        )}

        {detectionSummary && (
          <p className="text-xs text-muted-foreground italic">{detectionSummary}</p>
        )}

        {showStepper && detectedCandidates.length > 0 && (
          <ParameterDetectorStepper
            candidates={detectedCandidates}
            existingKeys={form.parameters_schema.parameters.map(p => 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

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:
$ServerName = 'DC01'
$OUPath = 'OU=Users,DC=contoso,DC=com'
$DefaultPassword = 'Welcome123!'
$ForceChange = $true
$MaxRetries = 3
  1. Click "Detect Parameters"
  2. Verify 5 candidates appear in stepper
  3. Verify type inference: ServerName=text, OUPath=text, DefaultPassword=password+sensitive, ForceChange=boolean, MaxRetries=number
  4. Accept all — verify script body has {{key}} placeholders and Parameters section has 5 entries with defaults preserved

Step 2: Test with param() block

Paste:

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:

$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

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

git push