config editor

This commit is contained in:
2026-08-21 01:18:19 -04:00
parent c30ef6ec24
commit 01d08e635c
15 changed files with 1501 additions and 16 deletions
@@ -0,0 +1,56 @@
function Add-PersonaConditionNode {
<#
.SYNOPSIS
Appends a condition or nested group to a group in a rule's condition tree (FR-028).
.DESCRIPTION
Locates the group at ParentPath and appends Node to its `conditions`
collection. Structural checks only - id/priority-style collisions do not apply
to conditions, and nesting-depth enforcement is deliberately not duplicated
here.
Depth is enforced by re-running the real validator (VR-001) against the whole
candidate document after the edit, the same way a hand-edited file would be
checked - see Edit-PersonaEngineConfig.ps1's candidate-edit wrapper. A second,
local depth calculation here would risk drifting from Test-PersonaConditionGroup's
engine semantics (RE-004), which count nesting by group levels, not by leaf
conditions - the exact mismatch VR-002's own depth check is careful to avoid
(see Test-PersonaConfigurationSemantic.ps1).
.PARAMETER Group
The rule's `match` condition group (the root).
.PARAMETER ParentPath
Path (see Get-PersonaConditionNode) to the group to append into. Empty selects
the root.
.PARAMETER Node
The condition or condition group to append.
.OUTPUTS
System.Object - the same Group, mutated in place, returned for convenience.
#>
[CmdletBinding()]
[OutputType([object])]
param(
[Parameter(Mandatory)]
[object] $Group,
[Parameter(Mandatory)]
[AllowEmptyCollection()]
[int[]] $ParentPath,
[Parameter(Mandatory)]
[object] $Node
)
$parent = Get-PersonaConditionNode -Group $Group -Path $ParentPath
if ($null -eq $parent.PSObject.Properties['conditions']) {
throw 'The target node is a leaf condition, not a group. Choose a group (all/any) to add into.'
}
$parent.conditions = @($parent.conditions) + $Node
$Group
}
@@ -0,0 +1,56 @@
function Add-PersonaConfigRule {
<#
.SYNOPSIS
Appends a new rule to a rules collection, rejecting an id/priority collision (FR-027).
.DESCRIPTION
Pure: takes the existing rules and a candidate rule, returns a new array. Never
mutates the input collection, so a caller that discards the result on a
validation failure has changed nothing.
The id/priority check is duplicated with PE-SEM-001/PE-SEM-002 by design - the
interactive editor should not need a full validation round trip just to tell an
operator they typed an id that already exists. Anything that reaches the
semantic layer anyway (for example a collision introduced by two concurrent
edits) is still caught there.
.PARAMETER Rules
The rule collection to append to. Not mutated.
.PARAMETER Rule
The candidate rule. Must carry non-blank `id` and a `priority`.
.OUTPUTS
System.Object[] - the new rules collection, existing rules first.
#>
[CmdletBinding()]
[OutputType([object[]])]
param(
[Parameter(Mandatory)]
[AllowEmptyCollection()]
[object[]] $Rules,
[Parameter(Mandatory)]
[object] $Rule
)
$id = [string]$Rule.id
if ([string]::IsNullOrWhiteSpace($id)) {
throw 'The new rule requires a non-blank id.'
}
$existing = @($Rules)
$byId = @($existing | Where-Object { [string]$_.id -eq $id })
if ($byId.Count -gt 0) {
throw "Rule id '$id' already exists (priority $($byId[0].priority)). Rule ids must be unique (RE-002)."
}
$priority = $Rule.priority
$byPriority = @($existing | Where-Object { [int]$_.priority -eq [int]$priority })
if ($byPriority.Count -gt 0) {
throw "Priority $priority is already used by rule '$($byPriority[0].id)'. Priorities must be unique (RE-002)."
}
, ($existing + $Rule)
}
@@ -0,0 +1,54 @@
function Get-PersonaConditionNode {
<#
.SYNOPSIS
Navigates a rule's condition tree to the node at a path (FR-028).
.DESCRIPTION
A path is a sequence of zero-based indices into successive `.conditions`
collections, root first. An empty path returns the root group itself.
The returned node is the same object reference that lives inside the tree -
deliberately. `ConvertFrom-Json` produces `PSCustomObject`/array trees of
reference types, so a caller that sets a property on the returned leaf (for
example `$node.value = 'x'`) mutates the tree in place. Editing a leaf's
fields is therefore a plain property assignment, not a separate setter
function; see Set-PersonaConditionLeaf for the one case (switching a
condition's operator/type) where stale fields must also be cleared.
.PARAMETER Group
The rule's `match` condition group (the root).
.PARAMETER Path
Zero-based indices from the root, one per nesting level. Empty selects the root.
.OUTPUTS
System.Object - the condition or condition group at that path.
#>
[CmdletBinding()]
[OutputType([object])]
param(
[Parameter(Mandatory)]
[object] $Group,
[Parameter(Mandatory)]
[AllowEmptyCollection()]
[int[]] $Path
)
$node = $Group
foreach ($index in $Path) {
if ($null -eq $node.PSObject.Properties['conditions'] -or $null -eq $node.conditions) {
throw "Path segment $index does not resolve: the node above it is a leaf condition, not a group."
}
$children = @($node.conditions)
if ($index -lt 0 -or $index -ge $children.Count) {
throw "Path segment $index is out of range - this group has $($children.Count) condition(s)."
}
$node = $children[$index]
}
$node
}
@@ -0,0 +1,67 @@
function Remove-PersonaConditionNode {
<#
.SYNOPSIS
Removes a condition or nested group from a rule's condition tree (FR-028).
.DESCRIPTION
Two structural rules are enforced here rather than left to schema validation,
because both would otherwise produce a document the schema calls invalid with
no indication which editor action caused it:
- The root group itself cannot be removed - a rule's `match` is required
(RE-001). Delete the rule instead (Remove-PersonaConfigRule).
- A group's last remaining condition cannot be removed - an empty
`conditions` array violates the schema's `minItems: 1` on conditionGroup.
Remove the parent group instead, or add a replacement first.
.PARAMETER Group
The rule's `match` condition group (the root).
.PARAMETER Path
Path (see Get-PersonaConditionNode) to the node to remove. Must not be empty.
.OUTPUTS
System.Object - the same Group, mutated in place, returned for convenience.
#>
[CmdletBinding()]
[OutputType([object])]
param(
[Parameter(Mandatory)]
[object] $Group,
[Parameter(Mandatory)]
[AllowEmptyCollection()]
[int[]] $Path
)
if (@($Path).Count -eq 0) {
throw "The root condition group cannot be removed this way - delete the rule instead."
}
# Special-cased at length 1 because PowerShell's range operator treats 0..-1 as
# the two-element descending range (0, -1), not an empty range - the naive slice
# would silently pick up the wrong parent for a top-level path.
$parentPath = (@($Path).Count -eq 1) ? @() : @($Path)[0..($Path.Count - 2)]
$index = @($Path)[-1]
$parent = Get-PersonaConditionNode -Group $Group -Path $parentPath
$children = @($parent.conditions)
if ($index -lt 0 -or $index -ge $children.Count) {
throw "Path segment $index is out of range - this group has $($children.Count) condition(s)."
}
if ($children.Count -eq 1) {
throw 'This is the only condition in its group. A group requires at least one condition - remove the group itself instead, or add a replacement first.'
}
# Removed by position, not by value or reference - two structurally identical
# sibling conditions are otherwise indistinguishable.
$remaining = [System.Collections.Generic.List[object]]::new()
for ($i = 0; $i -lt $children.Count; $i++) {
if ($i -ne $index) { $remaining.Add($children[$i]) }
}
$parent.conditions = $remaining.ToArray()
$Group
}
@@ -0,0 +1,39 @@
function Remove-PersonaConfigRule {
<#
.SYNOPSIS
Removes a rule from a rules collection by id (FR-029).
.DESCRIPTION
Pure: takes the existing rules and an id, returns a new array with that rule
absent. Throws if no rule carries the id, because a silent no-op would let an
operator believe a delete happened when it did not.
.PARAMETER Rules
The rule collection to remove from. Not mutated.
.PARAMETER RuleId
The id of the rule to remove.
.OUTPUTS
System.Object[] - the remaining rules.
#>
[CmdletBinding()]
[OutputType([object[]])]
param(
[Parameter(Mandatory)]
[AllowEmptyCollection()]
[object[]] $Rules,
[Parameter(Mandatory)]
[string] $RuleId
)
$existing = @($Rules)
$match = @($existing | Where-Object { [string]$_.id -eq $RuleId })
if ($match.Count -eq 0) {
throw "No rule with id '$RuleId'."
}
, @($existing | Where-Object { [string]$_.id -ne $RuleId })
}
@@ -0,0 +1,56 @@
function Set-PersonaConditionLeaf {
<#
.SYNOPSIS
Replaces a leaf condition's fields in place (FR-028).
.DESCRIPTION
Every condition-specific field (`property`, `value`, `values`,
`groupObjectIds`, `roleIds`, `membershipMode`) is cleared before Fields is
applied, then only the keys in Fields are set. This is not tidiness - the
schema's `additionalProperties: false` on `condition` (VR-001 layer 2) rejects
a stray field outright, and switching, say, a condition from `equals` (which
carries `value`) to `in` (which carries `values`) would otherwise leave the
old `value` behind and fail schema validation for a reason the editor caused
but did not explain.
`type` and `operator` are cleared and reset the same way, since a caller
editing a condition typically passes both.
.PARAMETER Node
The leaf condition to edit (from Get-PersonaConditionNode). Mutated in place.
.PARAMETER Fields
The complete replacement field set for this leaf, for example:
`@{ type = 'property'; property = 'Department'; operator = 'isNotNull' }`.
.OUTPUTS
System.Object - the same Node, mutated in place, returned for convenience.
#>
[CmdletBinding()]
[OutputType([object])]
param(
[Parameter(Mandatory)]
[object] $Node,
[Parameter(Mandatory)]
[hashtable] $Fields
)
if ($null -ne $Node.PSObject.Properties['conditions']) {
throw 'The target node is a condition group, not a leaf condition.'
}
$knownFields = @('type', 'property', 'operator', 'value', 'values', 'groupObjectIds', 'roleIds', 'membershipMode', 'caseSensitive')
foreach ($key in $knownFields) {
if ($null -ne $Node.PSObject.Properties[$key]) {
$Node.PSObject.Properties.Remove($key)
}
}
foreach ($entry in $Fields.GetEnumerator()) {
$Node | Add-Member -NotePropertyName $entry.Key -NotePropertyValue $entry.Value -Force
}
$Node
}