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
+389 -5
View File
@@ -306,17 +306,206 @@ function Save-PersonaConfiguration {
$true $true
} }
function Test-PersonaCandidateEdit {
<#
.SYNOPSIS
Applies a structural edit to a cloned document and re-validates it (FR-030).
.DESCRIPTION
Deep-clones Document via a JSON round trip (nested objects are reference
types, so mutating Document directly would leave a half-applied edit in
place if validation then rejected it), runs Apply against the clone, and
validates the clone in full - the identical check the `[V]` command and
`Save-PersonaConfiguration` use, so add/edit/delete can never be held to a
looser standard than a hand edit.
The document entering the interactive editor is already Error-free, so a
clone that fails here failed because of Apply, not because of pre-existing
state.
.PARAMETER Document
The current in-memory configuration document.
.PARAMETER Apply
A scriptblock taking the clone as its first parameter, followed by whatever
ApplyArgs supplies, and mutating the clone in place. Values are passed as
explicit arguments rather than closed over from the caller's scope: `&`
invocation runs a scriptblock in a new child scope of wherever it is invoked
(here, inside this function) rather than of the scope where the scriptblock
literal was written, so a bare `{ $doc.rules = ... $id ... }` would see $id as
unbound. Explicit parameters sidestep that entirely.
.PARAMETER ApplyArgs
Positional arguments passed to Apply after the clone.
.OUTPUTS
pscustomobject with Applied (bool), Document (the clone if Applied, otherwise
the original Document, unchanged), and Result (the validation result).
#>
param(
[Parameter(Mandatory)] [object] $Document,
[Parameter(Mandatory)] [scriptblock] $Apply,
[object[]] $ApplyArgs = @()
)
$clone = $Document | ConvertTo-Json -Depth 32 | ConvertFrom-Json -Depth 32
& $Apply $clone @ApplyArgs
$temp = [System.IO.Path]::GetTempFileName()
try {
Set-Content -LiteralPath $temp -Value ($clone | ConvertTo-Json -Depth 32) -Encoding utf8NoBOM
$check = Invoke-ConfigurationValidation -Path $temp
}
finally {
Remove-Item -LiteralPath $temp -Force -ErrorAction SilentlyContinue
}
[pscustomobject]@{
Applied = $check.Result.IsValid
Document = $check.Result.IsValid ? $clone : $Document
Result = $check.Result
}
}
function ConvertTo-PersonaConditionPath {
<#
.SYNOPSIS
Parses an operator-typed path like "1.0" into the int[] Get-PersonaConditionNode expects.
.DESCRIPTION
Blank input, or the literal word "root", addresses the rule's match group
itself.
#>
param([string] $Text)
$trimmed = ([string]$Text).Trim()
if ([string]::IsNullOrEmpty($trimmed) -or $trimmed -eq 'root') { return , @() }
$indices = foreach ($part in ($trimmed -split '[.\s]+' | Where-Object { $_ -ne '' })) {
$value = 0
if (-not [int]::TryParse($part, [ref] $value)) {
throw "'$part' is not a valid path segment - use dot-separated indices, for example '1.0'."
}
$value
}
, @($indices)
}
function Read-PersonaConditionLeafFields {
<#
.SYNOPSIS
Prompts for one leaf condition's fields, in the shape Set-PersonaConditionLeaf expects.
#>
Write-Host ' Condition type: [P]roperty [M]embership [R]ole' -ForegroundColor DarkGray
$typeChoice = (Read-Host ' Type').Trim().ToUpperInvariant()
$type = switch ($typeChoice) { 'P' { 'property' } 'M' { 'membership' } 'R' { 'role' } default { $null } }
if (-not $type) { throw "Unrecognized condition type '$typeChoice'." }
Write-Host ' Operators: equals notEquals contains notContains startsWith endsWith matchesRegex in notIn isNull isNotNull memberOf notMemberOf' -ForegroundColor DarkGray
$operator = (Read-Host ' Operator').Trim()
$fields = @{ type = $type; operator = $operator }
switch ($type) {
'property' { $fields['property'] = (Read-Host ' Property name').Trim() }
'membership' {
$ids = (Read-Host ' Group Object IDs (comma-separated)')
$fields['groupObjectIds'] = @($ids -split ',' | ForEach-Object { $_.Trim() } | Where-Object { $_ -ne '' })
$mode = (Read-Host ' Membership mode (direct/transitive, blank = configuration default)').Trim()
if ($mode) { $fields['membershipMode'] = $mode }
}
'role' {
$ids = (Read-Host ' Role IDs (comma-separated)')
$fields['roleIds'] = @($ids -split ',' | ForEach-Object { $_.Trim() } | Where-Object { $_ -ne '' })
}
}
if ($operator -in @('in', 'notIn')) {
$values = (Read-Host ' Values (comma-separated)')
$fields['values'] = @($values -split ',' | ForEach-Object { $_.Trim() } | Where-Object { $_ -ne '' })
}
elseif ($operator -notin @('isNull', 'isNotNull', 'memberOf', 'notMemberOf')) {
$fields['value'] = Read-Host ' Comparison value'
}
$fields
}
function Read-PersonaConditionNode {
<#
.SYNOPSIS
Interactively builds one condition or nested group (FR-027, FR-028).
.DESCRIPTION
Recurses for a group's children. Nesting depth is not limited here - see
Test-PersonaCandidateEdit and Add-PersonaConditionNode's doc comment for why
depth is enforced by the real validator instead of a second, local count.
#>
param([int] $Depth = 1)
$indent = ' ' * $Depth
Write-Host ("{0}[C]ondition or [G]roup?" -f $indent) -ForegroundColor DarkGray
$kind = (Read-Host "$indent Kind").Trim().ToUpperInvariant()
if ($kind -eq 'G') {
$operator = (Read-Host "$indent Group operator (all/any)").Trim().ToLowerInvariant()
if ($operator -notin @('all', 'any')) { throw "Group operator must be 'all' or 'any'." }
$children = [System.Collections.Generic.List[object]]::new()
do {
$children.Add((Read-PersonaConditionNode -Depth ($Depth + 1)))
$more = (Read-Host "$indent Add another condition to this group? (y/N)").Trim()
} while ($more.ToLowerInvariant() -eq 'y')
return [pscustomobject]@{ operator = $operator; conditions = $children.ToArray() }
}
[pscustomobject](Read-PersonaConditionLeafFields)
}
function Read-PersonaRuleFields {
<#
.SYNOPSIS
Prompts for a new rule's top-level fields (RE-001), excluding its match tree.
#>
$id = (Read-Host 'Rule id').Trim()
$name = (Read-Host 'Rule name').Trim()
$description = (Read-Host 'Description').Trim()
$priority = 0
if (-not [int]::TryParse((Read-Host 'Priority (integer)'), [ref] $priority)) {
throw 'Priority must be an integer.'
}
$persona = (Read-Host 'Persona').Trim()
$enabled = (Read-Host 'Enabled? (Y/n)').Trim().ToLowerInvariant() -ne 'n'
[pscustomobject]@{
id = $id; name = $name; description = $description
enabled = $enabled; priority = $priority; persona = $persona
}
}
function Invoke-InteractiveEditor { function Invoke-InteractiveEditor {
<# <#
.SYNOPSIS .SYNOPSIS
The interactive loop (FR-023). The interactive loop (FR-023).
.DESCRIPTION .DESCRIPTION
Deliberately small. It toggles rules, adjusts priorities, re-validates, tests Toggles rules, adjusts priorities, adds/edits/deletes whole rules including
against fixtures, and saves. It does not attempt to be a JSON editor - rule their condition trees (FR-027 - FR-030), re-validates, tests against
authoring belongs in a text editor with schema completion, and a fixtures, and saves.
half-featured structural editor would only add ways to corrupt a file that
is already under version control. Every structural edit (add/edit/delete) goes through Test-PersonaCandidateEdit,
which applies it to a clone and re-runs full validation before it is allowed
to reach $Document. The live document entering this loop is already
Error-free - the main script exits before this function is ever called
otherwise - so a clone that fails validation failed because of the edit just
applied, and is discarded rather than committed. This is what makes
structural editing safe to add at all: the editor can never produce a worse
document than a careful hand edit would, because it runs the same four
validation layers a hand edit is checked against at save time.
#> #>
param( param(
[Parameter(Mandatory)] [object] $Document, [Parameter(Mandatory)] [object] $Document,
@@ -333,6 +522,7 @@ function Invoke-InteractiveEditor {
Write-Host (" rules {0} ({1} enabled)" -f @($Document.rules).Count, @($Document.rules | Where-Object { $_.enabled }).Count) Write-Host (" rules {0} ({1} enabled)" -f @($Document.rules).Count, @($Document.rules | Where-Object { $_.enabled }).Count)
Write-Host '' Write-Host ''
Write-Host ' [L] list rules [T] toggle a rule [P] change a priority' Write-Host ' [L] list rules [T] toggle a rule [P] change a priority'
Write-Host ' [A] add a rule [E] edit a rule [D] delete a rule'
Write-Host ' [V] re-validate [R] run rule test [S] save' Write-Host ' [V] re-validate [R] run rule test [S] save'
Write-Host ' [Q] quit' Write-Host ' [Q] quit'
Write-Host '' Write-Host ''
@@ -378,6 +568,200 @@ function Invoke-InteractiveEditor {
Write-Host "Rule $id priority is now $value. Re-validate before saving - a reorder changes which rule wins." -ForegroundColor Green Write-Host "Rule $id priority is now $value. Re-validate before saving - a reorder changes which rule wins." -ForegroundColor Green
} }
'A' {
# FR-027. The candidate rule is built entirely from prompts before
# Test-PersonaCandidateEdit ever sees it, so a collision or a
# validation failure is reported once, against the whole rule, rather
# than mid-build against a rule that only half exists yet.
try {
$fields = Read-PersonaRuleFields
Write-Host 'Build the condition tree for this rule (the root group):' -ForegroundColor Cyan
$rootOperator = (Read-Host 'Root operator (all/any)').Trim().ToLowerInvariant()
if ($rootOperator -notin @('all', 'any')) { throw "Root operator must be 'all' or 'any'." }
$rootChildren = [System.Collections.Generic.List[object]]::new()
do {
$rootChildren.Add((Read-PersonaConditionNode -Depth 1))
$more = (Read-Host 'Add another top-level condition? (y/N)').Trim()
} while ($more.ToLowerInvariant() -eq 'y')
$newRule = [pscustomobject]@{
id = $fields.id
name = $fields.name
description = $fields.description
enabled = $fields.enabled
priority = $fields.priority
persona = $fields.persona
match = [pscustomobject]@{ operator = $rootOperator; conditions = $rootChildren.ToArray() }
}
$attempt = Test-PersonaCandidateEdit -Document $Document -ApplyArgs @($newRule) -Apply {
param($doc, $rule)
$doc.rules = Add-PersonaConfigRule -Rules $doc.rules -Rule $rule
}
if ($attempt.Applied) {
$Document = $attempt.Document
$dirty = $true
Write-Host "Rule $($newRule.id) added." -ForegroundColor Green
}
else {
Write-Host 'Add rejected: the resulting configuration would be invalid.' -ForegroundColor Red
Write-PersonaValidationFinding -Findings $attempt.Result.Findings
}
}
catch {
Write-Host "Add cancelled: $($_.Exception.Message)" -ForegroundColor Yellow
}
}
'E' {
# FR-028. One structural operation per pass through this case - the
# operator repeats [E] to make further changes, which keeps each
# Test-PersonaCandidateEdit call scoped to one edit and one finding
# set, rather than a batch where a rejection leaves it unclear which
# of several changes caused it.
try {
$id = (Read-Host 'Rule id to edit').Trim()
$rule = $Document.rules | Where-Object { [string]$_.id -eq $id } | Select-Object -First 1
if (-not $rule) { Write-Host "No rule with ID '$id'." -ForegroundColor Yellow; break }
Write-Host ("Editing rule {0} ({1})." -f $rule.id, $rule.name) -ForegroundColor Cyan
Write-Host ' [F] change a top-level field [C] add a condition/group'
Write-Host ' [E] edit a leaf condition [X] remove a condition/group'
Write-Host ' [B] back to the main menu, no change'
$subChoice = (Read-Host ' Edit action').Trim().ToUpperInvariant()
$attempt = switch ($subChoice) {
'F' {
$field = (Read-Host ' Field (name/description/priority/persona/enabled)').Trim().ToLowerInvariant()
$value = Read-Host ' New value'
Test-PersonaCandidateEdit -Document $Document -ApplyArgs @($id, $field, $value) -Apply {
param($doc, $ruleId, $fieldName, $newValue)
$target = $doc.rules | Where-Object { [string]$_.id -eq $ruleId } | Select-Object -First 1
switch ($fieldName) {
'priority' { $target.priority = [int]$newValue }
'enabled' { $target.enabled = $newValue.Trim().ToLowerInvariant() -in @('y', 'yes', 'true', '1') }
'name' { $target.name = $newValue }
'description' { $target.description = $newValue }
'persona' { $target.persona = $newValue }
default { throw "Unrecognized field '$fieldName'." }
}
}
}
'C' {
$parentPath = ConvertTo-PersonaConditionPath -Text (Read-Host ' Parent path (blank = root, e.g. "1.0")')
$newNode = Read-PersonaConditionNode -Depth ($parentPath.Count + 1)
# ApplyArgs is built with .Add() rather than @(...), because an
# int[] placed inside a `@()` array literal is unrolled into the
# outer array (the same gotcha Get-CapturedAuditRecord's comma
# idiom guards against elsewhere) - @($id, $parentPath, $newNode)
# would silently flatten a two-segment path into two extra
# positional arguments instead of passing it as one array.
$args = [System.Collections.Generic.List[object]]::new()
$args.Add($id); $args.Add($parentPath); $args.Add($newNode)
Test-PersonaCandidateEdit -Document $Document -ApplyArgs $args.ToArray() -Apply {
param($doc, $ruleId, $parentPathArg, $node)
$target = $doc.rules | Where-Object { [string]$_.id -eq $ruleId } | Select-Object -First 1
Add-PersonaConditionNode -Group $target.match -ParentPath $parentPathArg -Node $node | Out-Null
}
}
'E' {
$path = ConvertTo-PersonaConditionPath -Text (Read-Host ' Leaf condition path (e.g. "1.0")')
if ($path.Count -eq 0) { throw 'The root is a group, not a leaf condition - navigate to a leaf path.' }
$fields = Read-PersonaConditionLeafFields
$args = [System.Collections.Generic.List[object]]::new()
$args.Add($id); $args.Add($path); $args.Add($fields)
Test-PersonaCandidateEdit -Document $Document -ApplyArgs $args.ToArray() -Apply {
param($doc, $ruleId, $pathArg, $fieldsArg)
$target = $doc.rules | Where-Object { [string]$_.id -eq $ruleId } | Select-Object -First 1
$leaf = Get-PersonaConditionNode -Group $target.match -Path $pathArg
Set-PersonaConditionLeaf -Node $leaf -Fields $fieldsArg | Out-Null
}
}
'X' {
$path = ConvertTo-PersonaConditionPath -Text (Read-Host ' Condition/group path to remove (e.g. "1.0")')
$args = [System.Collections.Generic.List[object]]::new()
$args.Add($id); $args.Add($path)
Test-PersonaCandidateEdit -Document $Document -ApplyArgs $args.ToArray() -Apply {
param($doc, $ruleId, $pathArg)
$target = $doc.rules | Where-Object { [string]$_.id -eq $ruleId } | Select-Object -First 1
Remove-PersonaConditionNode -Group $target.match -Path $pathArg | Out-Null
}
}
'B' { $null }
default { Write-Host 'Unrecognized edit action.' -ForegroundColor Yellow; $null }
}
if ($null -ne $attempt) {
if ($attempt.Applied) {
$Document = $attempt.Document
$dirty = $true
Write-Host "Rule $id updated." -ForegroundColor Green
}
else {
Write-Host 'Edit rejected: the resulting configuration would be invalid.' -ForegroundColor Red
Write-PersonaValidationFinding -Findings $attempt.Result.Findings
}
}
}
catch {
Write-Host "Edit cancelled: $($_.Exception.Message)" -ForegroundColor Yellow
}
}
'D' {
# FR-029. Confirmation names id, name, and priority before anything is
# touched, matching the acceptance criterion literally rather than a
# generic "are you sure?".
try {
$id = (Read-Host 'Rule id to delete').Trim()
$rule = $Document.rules | Where-Object { [string]$_.id -eq $id } | Select-Object -First 1
if (-not $rule) { Write-Host "No rule with ID '$id'." -ForegroundColor Yellow; break }
Write-Host ("About to delete rule {0} '{1}' priority {2}." -f $rule.id, $rule.name, $rule.priority) -ForegroundColor Yellow
$confirm = (Read-Host 'Delete this rule? (y/N)').Trim().ToLowerInvariant()
if ($confirm -ne 'y') {
Write-Host 'Delete cancelled.' -ForegroundColor Yellow
break
}
$attempt = Test-PersonaCandidateEdit -Document $Document -ApplyArgs @($id) -Apply {
param($doc, $ruleId)
$doc.rules = Remove-PersonaConfigRule -Rules $doc.rules -RuleId $ruleId
}
if ($attempt.Applied) {
$Document = $attempt.Document
$dirty = $true
Write-Host "Rule $id deleted." -ForegroundColor Green
}
else {
Write-Host 'Delete rejected: the resulting configuration would be invalid.' -ForegroundColor Red
Write-PersonaValidationFinding -Findings $attempt.Result.Findings
}
}
catch {
Write-Host "Delete cancelled: $($_.Exception.Message)" -ForegroundColor Yellow
}
}
'V' { 'V' {
$temp = [System.IO.Path]::GetTempFileName() $temp = [System.IO.Path]::GetTempFileName()
try { try {
@@ -73,6 +73,29 @@ Stability matters: pipelines and runbooks will match on these codes.
| `3` | Configuration file not found or unreadable | | `3` | Configuration file not found or unreadable |
| `4` | Schema file not found or itself invalid | | `4` | Schema file not found or itself invalid |
## Interactive editor commands (FR-023, FR-027 FR-030)
The interactive loop (entered when neither `-ValidateOnly` nor `-NonInteractive` is set) supports:
| Command | Behaviour |
| --- | --- |
| List rules | Show every rule's priority, ID, persona, and enabled state. |
| Toggle a rule | Flip `enabled` on an existing rule. |
| Change a priority | Set a new numeric priority on an existing rule. |
| **Add a rule** | Prompt for every RE-001 field (`id`, `name`, `description`, `priority`, `persona`, `enabled`, and optional fields) and for the condition tree — nested `all`/`any` groups and, per leaf condition, the property/membership source, operator, and comparison value. Reject on the spot if the `id` or `priority` collides with an existing rule (FR-027). |
| **Edit a rule** | Select an existing rule by `id`; change any top-level field and/or the condition tree — add, edit, remove, or renest conditions and groups within `MaxConditionDepth` (FR-028). |
| **Delete a rule** | Select an existing rule by `id`; show its `id`, `name`, and `priority` and require explicit confirmation before removing it (FR-029). |
| Re-validate | Run all four validation layers against the in-memory document, including any unsaved add/edit/delete, and print findings without saving. |
| Run rule test | Evaluate the in-memory document (including unsaved structural edits) against `-TestDataPath` fixtures. |
| Save | Re-validate, then persist per the save contract below. |
| Quit | Warn if there are unsaved changes (including structural edits) before discarding them. |
All structural edits (add/edit/delete) are applied to the in-memory document only. They are never
written to `-ConfigPath` (or `-OutputPath`) until a `Save` re-validates the full document and that
validation passes — the same rule that governs field-level edits (FR-030). A depth violation
introduced by an add or edit is reported immediately using the same finding the runtime validator
would produce, rather than deferred to the next save or re-validate.
## Save contract (FR-026) ## Save contract (FR-026)
1. Re-validate the edited document in full. 1. Re-validate the edited document in full.
+12 -1
View File
@@ -84,7 +84,7 @@ An operator watching a run sees each user's result appear immediately, receives
### User Story 5 - Validate and edit configuration safely (Priority: P2) ### User Story 5 - Validate and edit configuration safely (Priority: P2)
A configuration owner validates a configuration file, edits rules interactively, tests rules against synthetic sample users, compares against another configuration, and saves only after validation passes — with a backup taken first. A configuration owner validates a configuration file, adds, edits, and deletes rules interactively — including each rule's condition tree — tests rules against synthetic sample users, compares against another configuration, and saves only after validation passes — with a backup taken first.
**Why this priority**: Makes the configuration-driven model usable and safe in practice, but the engine can be exercised with a hand-authored file first. **Why this priority**: Makes the configuration-driven model usable and safe in practice, but the engine can be exercised with a hand-authored file first.
@@ -96,6 +96,13 @@ A configuration owner validates a configuration file, edits rules interactively,
2. **Given** an edit session with unsaved valid changes, **When** the file is saved over an existing configuration, **Then** a timestamped backup or save-as output is produced first. 2. **Given** an edit session with unsaved valid changes, **When** the file is saved over an existing configuration, **Then** a timestamped backup or save-as output is produced first.
3. **Given** synthetic sample users are supplied, **When** rules are tested, **Then** the resulting persona for each sample is reported without any tenant connection. 3. **Given** synthetic sample users are supplied, **When** rules are tested, **Then** the resulting persona for each sample is reported without any tenant connection.
4. **Given** a valid configuration, **When** validation runs, **Then** no Error findings are produced and the file is accepted. 4. **Given** a valid configuration, **When** validation runs, **Then** no Error findings are produced and the file is accepted.
5. **Given** an interactive edit session, **When** the configuration owner adds a new rule, **Then** they are prompted for every required field (`id`, `name`, `description`, `priority`, `persona`, `enabled`) and for the rule's condition tree — including nested `all`/`any` groups and, for each leaf condition, the property or membership source, operator, and comparison value — and the new rule is appended to the in-memory document without being written to disk until save.
6. **Given** an interactive edit session, **When** the configuration owner attempts to add a rule using an `id` or `priority` already present in the document, **Then** the attempt is rejected before the rule is added, with a message naming the conflicting rule.
7. **Given** an interactive edit session, **When** the configuration owner selects an existing rule to edit, **Then** they can change any of its top-level fields and its condition tree — adding, editing, removing, or renesting conditions and `all`/`any` groups within the configured depth limit — and the change is held in memory, unsaved, until the session validates and saves.
8. **Given** an interactive edit session, **When** the configuration owner deletes a rule, **Then** they are shown the rule's `id`, `name`, and `priority` and asked to confirm before it is removed from the in-memory document.
9. **Given** a rule add, edit, or delete has been made in an edit session, **When** the session re-validates or saves, **Then** the same four validation layers (VR-001) run over the modified document exactly as they would over a hand-edited file, and any resulting Error finding blocks the save.
10. **Given** an edit session with unsaved add, edit, or delete changes, **When** the operator quits without saving, **Then** they are warned that unsaved changes will be lost and the file on disk is unchanged.
11. **Given** a rule add, edit, or delete would exceed the configured maximum condition nesting depth, **When** the change is applied, **Then** it is rejected at edit time with the same finding the runtime validator would produce, rather than deferred to the next save.
--- ---
@@ -219,6 +226,10 @@ Requirement identifiers are carried forward unchanged from the Phase 0 baseline
- **FR-024** — Non-interactive validation: the configuration tool MUST support non-interactive validation and return a non-zero exit code on failure. - **FR-024** — Non-interactive validation: the configuration tool MUST support non-interactive validation and return a non-zero exit code on failure.
- **FR-025** — Configuration test data: the editor MUST support testing rules against synthetic sample users without tenant connectivity. - **FR-025** — Configuration test data: the editor MUST support testing rules against synthetic sample users without tenant connectivity.
- **FR-026** — Backup before save: interactive edits MUST be validated before save and SHOULD create a timestamped backup or save-as output before replacing an existing configuration. - **FR-026** — Backup before save: interactive edits MUST be validated before save and SHOULD create a timestamped backup or save-as output before replacing an existing configuration.
- **FR-027** — Add a rule: the interactive editor MUST support adding a new rule, prompting for every field in RE-001 and for the rule's condition tree (nested `all`/`any` groups and leaf conditions), and MUST reject an `id` or `priority` that collides with an existing rule before the rule is added.
- **FR-028** — Edit a rule: the interactive editor MUST support editing any field of an existing rule, including full editing of its condition tree — adding, changing, removing, and renesting conditions and groups within the configured depth limit (RE-004).
- **FR-029** — Delete a rule: the interactive editor MUST support deleting an existing rule, and MUST require the operator to confirm against the rule's `id`, `name`, and `priority` before removal.
- **FR-030** — Uniform validation for structural edits: a rule added, edited, or deleted in the interactive editor MUST be subject to the same four validation layers (VR-001) as a hand-edited file, at re-validation and at save; edits are held only in memory until save succeeds (FR-026).
### Rule Engine Requirements ### Rule Engine Requirements
+34 -8
View File
@@ -28,16 +28,21 @@ pass offline Pester tests before any Graph integration exists.
Single PowerShell module at repository root: `src/`, `tests/`, `config/`, `docs/`, `pipelines/`, Single PowerShell module at repository root: `src/`, `tests/`, `config/`, `docs/`, `pipelines/`,
per the Project Structure section of [plan.md](plan.md). per the Project Structure section of [plan.md](plan.md).
## Implementation status — 2026-08-20 ## Implementation status — 2026-08-21
**109 of 121 tasks complete.** All twelve remaining tasks require something this workstation does not **117 of 129 tasks complete.** All twelve remaining tasks require something this workstation does not
have: a tenant connection (T055, T056, T101T103) or an Azure Automation account (Phase 13, have: a tenant connection (T055, T056, T101T103) or an Azure Automation account (Phase 13,
T115T121). Nothing offline-implementable is outstanding. T115T121). Nothing offline-implementable is outstanding.
T122T129 (interactive add/edit/delete for rules, FR-027FR-030) landed after the initial Phase 6
build, which had shipped with toggle/priority editing only. 400 offline Pester tests now pass (up
from 354); see [traceability.md](traceability.md) for FR-027FR-030's implementation and test
mapping.
``` ```
354 offline Pester tests PASS 400 offline Pester tests PASS
Engine purity (Principle IV) PASS Engine purity (Principle IV) PASS
Sanitization (SC-013) PASS 156 files Sanitization (SC-013) PASS
Graph module loaded during tests False (SC-008 holds) Graph module loaded during tests False (SC-008 holds)
``` ```
@@ -235,7 +240,28 @@ documented finding code, severity, and location.
- [X] T069 [US5] Implement the timestamped backup and Save-As path in Edit-PersonaEngineConfig.ps1 (FR-026) - [X] T069 [US5] Implement the timestamped backup and Save-As path in Edit-PersonaEngineConfig.ps1 (FR-026)
- [X] T070 [US5] Implement synthetic rule testing via `-TestDataPath` in Edit-PersonaEngineConfig.ps1, reusing the rule engine with no tenant connectivity (FR-025) - [X] T070 [US5] Implement synthetic rule testing via `-TestDataPath` in Edit-PersonaEngineConfig.ps1, reusing the rule engine with no tenant connectivity (FR-025)
**Checkpoint**: invalid configurations cannot reach the engine or overwrite a good file. ### Rule CRUD in the interactive editor (US5 addition — FR-027FR-030)
Added after the initial Phase 6 build. The editor originally shipped deliberately scoped to
toggle/priority only (see the `Invoke-InteractiveEditor` comment header); spec.md now requires
add/edit/delete, including full condition-tree editing, so that scope is reversed here rather than
worked around.
#### Tests for rule CRUD
- [X] T122 [P] [US5] Add-rule tests in tests/Configuration/EditorAddRule.Tests.ps1 covering the full field/condition-tree prompt sequence and rejection of a duplicate `id` or `priority` before the rule is added (FR-027)
- [X] T123 [P] [US5] Edit-rule tests in tests/Configuration/EditorEditRule.Tests.ps1 covering top-level field changes and condition-tree add/edit/remove/renest operations within `MaxConditionDepth` (FR-028)
- [X] T124 [P] [US5] Delete-rule tests in tests/Configuration/EditorDeleteRule.Tests.ps1 covering the `id`/`name`/`priority` confirmation prompt and removal, including a declined confirmation leaving the rule in place (FR-029)
- [X] T125 [P] [US5] Structural-edit validation tests in tests/Configuration/EditorStructuralEdits.Tests.ps1 asserting add/edit/delete changes stay in memory until save, re-validate through all four layers on `[V]`/`[S]`, and that a depth-limit violation is reported immediately at edit time rather than deferred (FR-030)
#### Implementation for rule CRUD
- [X] T126 [US5] Implement rule addition (prompt for every RE-001 field plus the condition tree, reject a colliding `id`/`priority`) in Edit-PersonaEngineConfig.ps1 and wire the `[A]` command into the interactive loop (FR-027)
- [X] T127 [US5] Implement rule editing (top-level fields plus add/edit/remove/renest on the condition tree) in Edit-PersonaEngineConfig.ps1 and wire the `[E]` command into the interactive loop (FR-028)
- [X] T128 [US5] Implement rule deletion with an `id`/`name`/`priority` confirmation prompt in Edit-PersonaEngineConfig.ps1 and wire the `[D]` command into the interactive loop (FR-029)
- [X] T129 [US5] Route add/edit/delete through the existing `$dirty` tracking and `Invoke-ConfigurationValidation` re-validation path in Edit-PersonaEngineConfig.ps1, and surface a depth-limit finding at edit time using the same check the runtime validator uses (FR-030)
**Checkpoint**: invalid configurations cannot reach the engine or overwrite a good file, and rules can be added, edited, and deleted entirely from the editor.
--- ---
@@ -430,7 +456,7 @@ reads the audit shape US7 defines.
| 3 | T016T020 (all tests, separate files) | | 3 | T016T020 (all tests, separate files) |
| 4 | T027, T028, T029 | | 4 | T027, T028, T029 |
| 5 | T034T038 (tests); then T039, T040 | | 5 | T034T038 (tests); then T039, T040 |
| 6 | T057T060 | | 6 | T057T060; then T122T125 |
| 7 | T071, T072 | | 7 | T071, T072 |
| 8 | T077, T078 | | 8 | T077, T078 |
| 9 | T083, T084, T085 | | 9 | T083, T084, T085 |
@@ -470,7 +496,7 @@ Automation PowerShell 7 run.
| 3 | US2 (P1) | T016T026 | 11 | | 3 | US2 (P1) | T016T026 | 11 |
| 4 | US3 (P1) | T027T033 | 7 | | 4 | US3 (P1) | T027T033 | 7 |
| 5 | US1 (P1) 🎯 | T034T056 | 23 | | 5 | US1 (P1) 🎯 | T034T056 | 23 |
| 6 | US5 (P2) | T057T070 | 14 | | 6 | US5 (P2) | T057T070, T122T129 | 22 |
| 7 | US6 (P2) | T071T076 | 6 | | 7 | US6 (P2) | T071T076 | 6 |
| 8 | US4 (P2) | T077T082 | 6 | | 8 | US4 (P2) | T077T082 | 6 |
| 9 | US7 (P2) | T083T090 | 8 | | 9 | US7 (P2) | T083T090 | 8 |
@@ -478,4 +504,4 @@ Automation PowerShell 7 run.
| 11 | US8 (P3) 🔒 | T095T103 | 9 | | 11 | US8 (P3) 🔒 | T095T103 | 9 |
| 12 Polish | — | T104T114 | 11 | | 12 Polish | — | T104T114 | 11 |
| 13 Stage B | — | T115T121 | 7 (deferred) | | 13 Stage B | — | T115T121 | 7 (deferred) |
| **Total** | | | **121** | | **Total** | | | **129** |
+7 -2
View File
@@ -6,8 +6,9 @@ Every functional requirement, non-functional requirement, and success criterion
A row with no test is a requirement nobody is checking. Those are listed explicitly at the bottom A row with no test is a requirement nobody is checking. Those are listed explicitly at the bottom
rather than left out, because an incomplete matrix that looks complete is worse than no matrix. rather than left out, because an incomplete matrix that looks complete is worse than no matrix.
**Status as at 2026-08-20**: 354 offline tests passing; engine purity and sanitization gates passing; **Status as at 2026-08-21**: 400 offline tests passing; engine purity and sanitization gates passing;
no tenant-dependent item verified. no tenant-dependent item verified. The increase from 354 is FR-027FR-030 (rule add/edit/delete in
the interactive editor), added to spec.md and implemented in the same change (T122T129).
## Functional requirements ## Functional requirements
@@ -39,6 +40,10 @@ no tenant-dependent item verified.
| FR-024 | Non-interactive validation with exit codes | `-NonInteractive` short-circuit | `NonInteractive.Tests.ps1` | | FR-024 | Non-interactive validation with exit codes | `-NonInteractive` short-circuit | `NonInteractive.Tests.ps1` |
| FR-025 | Synthetic rule testing, no tenant | `Invoke-SyntheticRuleTest` | `NonInteractive.Tests.ps1` | | FR-025 | Synthetic rule testing, no tenant | `Invoke-SyntheticRuleTest` | `NonInteractive.Tests.ps1` |
| FR-026 | Validate and back up before save | `Save-PersonaConfiguration` | `Safety.Tests.ps1` (`PE-SAF-007`) | | FR-026 | Validate and back up before save | `Save-PersonaConfiguration` | `Safety.Tests.ps1` (`PE-SAF-007`) |
| FR-027 | Add a rule interactively | `Add-PersonaConfigRule`; `[A]` command in `Edit-PersonaEngineConfig.ps1` | `EditorAddRule.Tests.ps1`, `EditorStructuralEdits.Tests.ps1` |
| FR-028 | Edit a rule interactively, including its condition tree | `Get/Add/Remove-PersonaConditionNode`, `Set-PersonaConditionLeaf`; `[E]` command | `EditorEditRule.Tests.ps1`, `EditorStructuralEdits.Tests.ps1` |
| FR-029 | Delete a rule interactively, with confirmation | `Remove-PersonaConfigRule`; `[D]` command | `EditorDeleteRule.Tests.ps1`, `EditorStructuralEdits.Tests.ps1` |
| FR-030 | Structural edits validated like a hand-edited file | `Test-PersonaCandidateEdit` in `Edit-PersonaEngineConfig.ps1` | `EditorStructuralEdits.Tests.ps1` |
## Rule engine requirements ## Rule engine requirements
@@ -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
}
+133
View File
@@ -0,0 +1,133 @@
#Requires -Modules @{ ModuleName = 'Pester'; ModuleVersion = '5.0.0' }
<#
FR-027: the interactive editor can add a new rule, and rejects a colliding id or
priority before the rule is added.
Add-PersonaConfigRule is exercised directly rather than through the interactive
script - it is the pure function the [A] command calls, and pure functions are
what this suite can test without driving Read-Host through a subprocess (see
NonInteractive.Tests.ps1 for why that path is reserved for hang/prompt proof, not
ordinary behaviour).
#>
BeforeAll {
$repoRoot = Split-Path (Split-Path $PSScriptRoot -Parent) -Parent
. (Join-Path $repoRoot 'tests/TestHelpers.ps1')
foreach ($file in (Get-PersonaSourceFile -RepoRoot $repoRoot)) { . $file }
function New-TestRule {
param(
[string] $Id = 'RULE-0500-TEST',
[int] $Priority = 500,
[string] $Persona = 'Employee'
)
[pscustomobject]@{
id = $Id
name = 'Test rule'
description = 'A rule added by a test.'
enabled = $true
priority = $Priority
persona = $Persona
match = [pscustomobject]@{
operator = 'all'
conditions = @([pscustomobject]@{ type = 'property'; property = 'Department'; operator = 'isNotNull' })
}
}
}
}
Describe 'Add-PersonaConfigRule (FR-027)' {
It 'appends the new rule to the collection' {
$existing = @((New-TestRule -Id 'RULE-0010-GUEST' -Priority 10))
$new = New-TestRule -Id 'RULE-0500-TEST' -Priority 500
$result = Add-PersonaConfigRule -Rules $existing -Rule $new
$result.Count | Should -Be 2
($result | Where-Object { [string]$_.id -eq 'RULE-0500-TEST' }).Count | Should -Be 1
}
It 'does not mutate the input collection' {
$existing = @((New-TestRule -Id 'RULE-0010-GUEST' -Priority 10))
$new = New-TestRule -Id 'RULE-0500-TEST' -Priority 500
Add-PersonaConfigRule -Rules $existing -Rule $new | Out-Null
$existing.Count | Should -Be 1
}
It 'always returns an array, even when the result has exactly one rule' {
# The single-element case is the one PowerShell silently collapses to a
# scalar unless the function guards against it (see Add-PersonaConfigRule's
# `, (...)` return) - assigning the call's result directly, with no extra
# @() at this call site, is what proves the function's own guard is doing
# the work rather than an @() here masking a function that does not.
$new = New-TestRule -Id 'RULE-0500-TEST' -Priority 500
$result = Add-PersonaConfigRule -Rules @() -Rule $new
, $result | Should -BeOfType [array]
$result.Count | Should -Be 1
}
It 'rejects a duplicate id before adding' {
$existing = @((New-TestRule -Id 'RULE-0010-GUEST' -Priority 10))
$colliding = New-TestRule -Id 'RULE-0010-GUEST' -Priority 999
{ Add-PersonaConfigRule -Rules $existing -Rule $colliding } | Should -Throw '*RULE-0010-GUEST*'
}
It 'rejects a duplicate priority before adding' {
$existing = @((New-TestRule -Id 'RULE-0010-GUEST' -Priority 10))
$colliding = New-TestRule -Id 'RULE-0500-TEST' -Priority 10
{ Add-PersonaConfigRule -Rules $existing -Rule $colliding } | Should -Throw '*10*'
}
It 'leaves the original collection unchanged when the add is rejected' {
$existing = @((New-TestRule -Id 'RULE-0010-GUEST' -Priority 10))
$colliding = New-TestRule -Id 'RULE-0010-GUEST' -Priority 999
try { Add-PersonaConfigRule -Rules $existing -Rule $colliding } catch { }
$existing.Count | Should -Be 1
}
It 'rejects a blank id' {
$existing = @((New-TestRule -Id 'RULE-0010-GUEST' -Priority 10))
$blank = New-TestRule -Id '' -Priority 999
{ Add-PersonaConfigRule -Rules $existing -Rule $blank } | Should -Throw
}
It 'the added rule survives a full validation pass' {
$document = New-TestConfigurationDocument
$new = @{
id = 'RULE-0500-TEST'
name = 'Test rule'
description = 'A rule added by a test.'
enabled = $true
priority = 500
persona = 'Employee'
match = @{
operator = 'all'
conditions = @(@{ type = 'property'; property = 'Department'; operator = 'isNotNull' })
}
}
$document.rules = Add-PersonaConfigRule -Rules $document.rules -Rule $new
$path = Save-TestConfiguration -Document $document -Directory ([System.IO.Path]::GetTempPath())
try {
$result = Test-PersonaConfiguration -Path $path
$result.IsValid | Should -BeTrue
@($result.Document.rules).Count | Should -Be 3
}
finally {
Remove-Item -LiteralPath $path -Force -ErrorAction SilentlyContinue
}
}
}
@@ -0,0 +1,95 @@
#Requires -Modules @{ ModuleName = 'Pester'; ModuleVersion = '5.0.0' }
<#
FR-029: the interactive editor can delete an existing rule.
Remove-PersonaConfigRule is the pure function the [D] command calls after the
operator confirms against the rule's id, name, and priority - the confirmation
prompt itself lives in Edit-PersonaEngineConfig.ps1 and is covered by
EditorStructuralEdits.Tests.ps1, which drives the real interactive loop.
#>
BeforeAll {
$repoRoot = Split-Path (Split-Path $PSScriptRoot -Parent) -Parent
. (Join-Path $repoRoot 'tests/TestHelpers.ps1')
foreach ($file in (Get-PersonaSourceFile -RepoRoot $repoRoot)) { . $file }
function New-TestRule {
param([string] $Id, [int] $Priority)
[pscustomobject]@{
id = $Id
name = "Rule $Id"
description = 'A test rule.'
enabled = $true
priority = $Priority
persona = 'Employee'
match = [pscustomobject]@{
operator = 'all'
conditions = @([pscustomobject]@{ type = 'property'; property = 'Department'; operator = 'isNotNull' })
}
}
}
}
Describe 'Remove-PersonaConfigRule (FR-029)' {
It 'removes the named rule' {
$rules = @(
(New-TestRule -Id 'RULE-0010-GUEST' -Priority 10),
(New-TestRule -Id 'RULE-0900-EMPLOYEE' -Priority 900)
)
$result = Remove-PersonaConfigRule -Rules $rules -RuleId 'RULE-0010-GUEST'
, $result | Should -BeOfType [array]
$result.Count | Should -Be 1
$result[0].id | Should -Be 'RULE-0900-EMPLOYEE'
}
It 'does not mutate the input collection' {
$rules = @(
(New-TestRule -Id 'RULE-0010-GUEST' -Priority 10),
(New-TestRule -Id 'RULE-0900-EMPLOYEE' -Priority 900)
)
Remove-PersonaConfigRule -Rules $rules -RuleId 'RULE-0010-GUEST' | Out-Null
$rules.Count | Should -Be 2
}
It 'throws when the id does not exist, rather than silently doing nothing' {
$rules = @((New-TestRule -Id 'RULE-0010-GUEST' -Priority 10))
{ Remove-PersonaConfigRule -Rules $rules -RuleId 'RULE-9999-ABSENT' } | Should -Throw '*RULE-9999-ABSENT*'
}
It 'can remove the only remaining rule, leaving an empty collection' {
$rules = @((New-TestRule -Id 'RULE-0010-GUEST' -Priority 10))
$result = Remove-PersonaConfigRule -Rules $rules -RuleId 'RULE-0010-GUEST'
, $result | Should -BeOfType [array]
$result.Count | Should -Be 0
}
It 'a deletion that leaves zero enabled rules is caught by re-validation (PE-SEM-003)' {
# Remove-PersonaConfigRule itself has no opinion on "zero enabled rules" - that
# is VR-002's job. Exercised here to prove the editor's re-validation path
# would in fact block this rather than silently accepting it.
$document = New-TestConfigurationDocument
($document.rules | Where-Object { $_.id -eq 'RULE-0010-GUEST' }).enabled = $false
$document.rules = Remove-PersonaConfigRule -Rules $document.rules -RuleId 'RULE-0900-EMPLOYEE'
$path = Save-TestConfiguration -Document $document -Directory ([System.IO.Path]::GetTempPath())
try {
$result = Test-PersonaConfiguration -Path $path
$result.IsValid | Should -BeFalse
@($result.Findings | Where-Object Code -EQ 'PE-SEM-003').Count | Should -BeGreaterThan 0
}
finally {
Remove-Item -LiteralPath $path -Force -ErrorAction SilentlyContinue
}
}
}
@@ -0,0 +1,255 @@
#Requires -Modules @{ ModuleName = 'Pester'; ModuleVersion = '5.0.0' }
<#
FR-028: the interactive editor can edit an existing rule, including its condition
tree - adding, editing, removing, and renesting conditions and all/any groups.
Get-PersonaConditionNode, Add-PersonaConditionNode, Remove-PersonaConditionNode,
and Set-PersonaConditionLeaf are the pure functions the [E] command composes.
Editing a rule's top-level fields (name, priority, persona, ...) is a plain
property assignment - Get-PersonaConditionNode's own doc comment explains why -
so there is nothing there to unit test beyond what Add-PersonaConfigRule already
proves about the shape surviving validation.
Depth-limit rejection at edit time (FR-030 / acceptance scenario 11) is
deliberately not tested here: Add-PersonaConditionNode does not duplicate depth
math (see its doc comment), so that behaviour lives entirely in
Edit-PersonaEngineConfig.ps1's candidate-edit wrapper and is covered by
EditorStructuralEdits.Tests.ps1, which drives the real interactive loop.
#>
BeforeAll {
$repoRoot = Split-Path (Split-Path $PSScriptRoot -Parent) -Parent
. (Join-Path $repoRoot 'tests/TestHelpers.ps1')
foreach ($file in (Get-PersonaSourceFile -RepoRoot $repoRoot)) { . $file }
function New-TestMatchGroup {
# root(all) -> [ leaf(Department isNotNull), group(any) -> [ leaf(UserType equals Guest) ] ]
[pscustomobject]@{
operator = 'all'
conditions = @(
[pscustomobject]@{ type = 'property'; property = 'Department'; operator = 'isNotNull' }
[pscustomobject]@{
operator = 'any'
conditions = @(
[pscustomobject]@{ type = 'property'; property = 'UserType'; operator = 'equals'; value = 'Guest' }
)
}
)
}
}
}
Describe 'Get-PersonaConditionNode (FR-028)' {
It 'returns the root group for an empty path' {
$match = New-TestMatchGroup
$node = Get-PersonaConditionNode -Group $match -Path @()
$node.operator | Should -Be 'all'
}
It 'navigates to a top-level leaf' {
$match = New-TestMatchGroup
$node = Get-PersonaConditionNode -Group $match -Path @(0)
$node.property | Should -Be 'Department'
}
It 'navigates into a nested group' {
$match = New-TestMatchGroup
$node = Get-PersonaConditionNode -Group $match -Path @(1)
$node.operator | Should -Be 'any'
}
It 'navigates to a leaf inside a nested group' {
$match = New-TestMatchGroup
$node = Get-PersonaConditionNode -Group $match -Path @(1, 0)
$node.property | Should -Be 'UserType'
}
It 'throws on an out-of-range index' {
$match = New-TestMatchGroup
{ Get-PersonaConditionNode -Group $match -Path @(9) } | Should -Throw '*out of range*'
}
It 'throws when a path segment tries to descend into a leaf' {
$match = New-TestMatchGroup
{ Get-PersonaConditionNode -Group $match -Path @(0, 0) } | Should -Throw '*leaf*'
}
}
Describe 'Add-PersonaConditionNode (FR-028)' {
It 'appends a leaf condition to the root group' {
$match = New-TestMatchGroup
$newLeaf = [pscustomobject]@{ type = 'property'; property = 'CompanyName'; operator = 'isNotNull' }
Add-PersonaConditionNode -Group $match -ParentPath @() -Node $newLeaf | Out-Null
@($match.conditions).Count | Should -Be 3
$match.conditions[2].property | Should -Be 'CompanyName'
}
It 'appends into a nested group by path' {
$match = New-TestMatchGroup
$newLeaf = [pscustomobject]@{ type = 'property'; property = 'JobTitle'; operator = 'isNotNull' }
Add-PersonaConditionNode -Group $match -ParentPath @(1) -Node $newLeaf | Out-Null
$nested = Get-PersonaConditionNode -Group $match -Path @(1)
@($nested.conditions).Count | Should -Be 2
}
It 'appends a whole nested group as the new node (renesting)' {
$match = New-TestMatchGroup
$newGroup = [pscustomobject]@{
operator = 'any'
conditions = @([pscustomobject]@{ type = 'property'; property = 'JobTitle'; operator = 'isNotNull' })
}
Add-PersonaConditionNode -Group $match -ParentPath @() -Node $newGroup | Out-Null
$added = Get-PersonaConditionNode -Group $match -Path @(2)
$added.operator | Should -Be 'any'
}
It 'throws when the target path is a leaf, not a group' {
$match = New-TestMatchGroup
$newLeaf = [pscustomobject]@{ type = 'property'; property = 'X'; operator = 'isNotNull' }
{ Add-PersonaConditionNode -Group $match -ParentPath @(0) -Node $newLeaf } | Should -Throw '*leaf*'
}
}
Describe 'Remove-PersonaConditionNode (FR-028)' {
It 'removes a top-level leaf' {
$match = New-TestMatchGroup
Remove-PersonaConditionNode -Group $match -Path @(0) | Out-Null
@($match.conditions).Count | Should -Be 1
$match.conditions[0].operator | Should -Be 'any'
}
It 'removes a leaf nested inside a group' {
$match = New-TestMatchGroup
Add-PersonaConditionNode -Group $match -ParentPath @(1) `
-Node ([pscustomobject]@{ type = 'property'; property = 'JobTitle'; operator = 'isNotNull' }) | Out-Null
Remove-PersonaConditionNode -Group $match -Path @(1, 1) | Out-Null
$nested = Get-PersonaConditionNode -Group $match -Path @(1)
@($nested.conditions).Count | Should -Be 1
$nested.conditions[0].property | Should -Be 'UserType'
}
It 'removes a whole nested group in one call' {
$match = New-TestMatchGroup
Remove-PersonaConditionNode -Group $match -Path @(1) | Out-Null
@($match.conditions).Count | Should -Be 1
}
It 'throws rather than removing the root group' {
$match = New-TestMatchGroup
{ Remove-PersonaConditionNode -Group $match -Path @() } | Should -Throw '*root*'
}
It 'throws rather than emptying a group down to zero conditions' {
$match = New-TestMatchGroup
{ Remove-PersonaConditionNode -Group $match -Path @(1, 0) } | Should -Throw '*only condition*'
}
It 'removal by position is unambiguous between identical siblings' {
$match = [pscustomobject]@{
operator = 'any'
conditions = @(
[pscustomobject]@{ type = 'property'; property = 'Department'; operator = 'isNotNull' }
[pscustomobject]@{ type = 'property'; property = 'Department'; operator = 'isNotNull' }
)
}
Remove-PersonaConditionNode -Group $match -Path @(0) | Out-Null
@($match.conditions).Count | Should -Be 1
}
}
Describe 'Set-PersonaConditionLeaf (FR-028)' {
It 'mutates the node in place, visible through the original tree reference' {
$match = New-TestMatchGroup
$leaf = Get-PersonaConditionNode -Group $match -Path @(0)
Set-PersonaConditionLeaf -Node $leaf -Fields @{ type = 'property'; property = 'CompanyName'; operator = 'isNotNull' } | Out-Null
$match.conditions[0].property | Should -Be 'CompanyName'
}
It 'clears stale fields when switching operator families (equals -> isNull)' {
$match = New-TestMatchGroup
$leaf = Get-PersonaConditionNode -Group $match -Path @(1, 0)
$leaf.value | Should -Be 'Guest'
Set-PersonaConditionLeaf -Node $leaf -Fields @{ type = 'property'; property = 'UserType'; operator = 'isNull' } | Out-Null
$leaf.PSObject.Properties['value'] | Should -BeNullOrEmpty
}
It 'clears a stale value when switching to an in/values operator' {
$match = New-TestMatchGroup
$leaf = Get-PersonaConditionNode -Group $match -Path @(1, 0)
Set-PersonaConditionLeaf -Node $leaf -Fields @{
type = 'property'; property = 'UserType'; operator = 'in'; values = @('Guest', 'Member')
} | Out-Null
$leaf.PSObject.Properties['value'] | Should -BeNullOrEmpty
@($leaf.values) | Should -Be @('Guest', 'Member')
}
It 'throws when the target node is a group, not a leaf' {
$match = New-TestMatchGroup
$group = Get-PersonaConditionNode -Group $match -Path @(1)
{ Set-PersonaConditionLeaf -Node $group -Fields @{ type = 'property'; property = 'X'; operator = 'isNotNull' } } | Should -Throw '*group*'
}
It 'an edited leaf survives a full validation pass' {
$document = New-TestConfigurationDocument
$rule = $document.rules | Where-Object { $_.id -eq 'RULE-0900-EMPLOYEE' }
# Replaced with a fresh pscustomobject tree - the shape Get-PersonaConditionNode
# expects and the shape ConvertFrom-Json actually produces (see its doc comment).
$rule.match = [pscustomobject]@{
operator = 'all'
conditions = @([pscustomobject]@{ type = 'property'; property = 'Department'; operator = 'isNotNull' })
}
$leaf = Get-PersonaConditionNode -Group $rule.match -Path @(0)
Set-PersonaConditionLeaf -Node $leaf -Fields @{ type = 'property'; property = 'JobTitle'; operator = 'isNotNull' } | Out-Null
$path = Save-TestConfiguration -Document $document -Directory ([System.IO.Path]::GetTempPath())
try {
$result = Test-PersonaConfiguration -Path $path
$result.IsValid | Should -BeTrue
}
finally {
Remove-Item -LiteralPath $path -Force -ErrorAction SilentlyContinue
}
}
}
@@ -0,0 +1,225 @@
#Requires -Modules @{ ModuleName = 'Pester'; ModuleVersion = '5.0.0' }
<#
FR-030 / acceptance scenarios 9-11 (spec.md User Story 5): the [A]/[E]/[D]
commands are actually wired into Edit-PersonaEngineConfig.ps1's interactive loop,
structural edits stay in memory until Save, and a depth-limit violation is
rejected at edit time using the real validator's own finding.
The pure functions behind these commands (Add-PersonaConfigRule,
Get/Add/Remove-PersonaConditionNode, Set-PersonaConditionLeaf) are covered
directly in EditorAddRule/EditorEditRule/EditorDeleteRule.Tests.ps1. This suite
exists to prove the wiring itself - the prompts, the menu, and the
candidate-edit-or-revert behaviour - which only exists inside the script, not in
a dot-sourceable function.
Same technique as NonInteractive.Tests.ps1: a child pwsh with stdin redirected
from a file. There, an EMPTY file proves the non-interactive path never reads it.
Here, a SCRIPTED file drives the interactive menu exactly as a human typing
answers would, which is the only way to exercise Read-Host-based prompts from
a top-level script that is not a dot-sourceable module.
#>
BeforeAll {
$repoRoot = Split-Path (Split-Path $PSScriptRoot -Parent) -Parent
. (Join-Path $repoRoot 'tests/TestHelpers.ps1')
$script:editor = Join-Path $repoRoot 'Edit-PersonaEngineConfig.ps1'
$script:scratch = Join-Path ([System.IO.Path]::GetTempPath()) ("pe-structural-{0}" -f [guid]::NewGuid().ToString('N'))
$null = New-Item -ItemType Directory -Path $script:scratch -Force
function Invoke-EditorWithScriptedInput {
<#
Runs the editor interactively (no -NonInteractive) with stdin fed from a
sequence of scripted answers, one per line, and a hard timeout so a
prompt sequence that runs dry (and therefore blocks on Read-Host) fails
the test instead of hanging the suite.
#>
param(
[string[]] $ArgumentList,
[string[]] $Answers,
[int] $TimeoutSeconds = 60
)
$stdinFile = Join-Path $script:scratch ("in-{0}.txt" -f [guid]::NewGuid().ToString('N'))
Set-Content -LiteralPath $stdinFile -Value ($Answers -join "`n") -NoNewline -Encoding utf8NoBOM
$stdout = Join-Path $script:scratch ("out-{0}.txt" -f [guid]::NewGuid().ToString('N'))
$stderr = Join-Path $script:scratch ("err-{0}.txt" -f [guid]::NewGuid().ToString('N'))
$process = Start-Process -FilePath (Get-Process -Id $PID).Path `
-ArgumentList (@('-NoProfile', '-File', $script:editor) + $ArgumentList) `
-RedirectStandardInput $stdinFile `
-RedirectStandardOutput $stdout `
-RedirectStandardError $stderr `
-PassThru -WindowStyle Hidden
if (-not $process.WaitForExit($TimeoutSeconds * 1000)) {
$process.Kill($true)
return [pscustomobject]@{ ExitCode = -1; Output = 'TIMED OUT'; Error = ''; TimedOut = $true }
}
[pscustomobject]@{
ExitCode = $process.ExitCode
Output = (Get-Content -LiteralPath $stdout -Raw -ErrorAction SilentlyContinue)
Error = (Get-Content -LiteralPath $stderr -Raw -ErrorAction SilentlyContinue)
TimedOut = $false
}
}
function New-ScratchConfigPath {
param([hashtable] $Document = (New-TestConfigurationDocument))
Save-TestConfiguration -Document $Document -Directory $script:scratch
}
}
AfterAll {
Remove-Item -LiteralPath $script:scratch -Recurse -Force -ErrorAction SilentlyContinue
}
Describe 'Interactive rule add/edit/delete (FR-027 - FR-030)' {
It 'adds a rule and persists it only after Save' {
$path = New-ScratchConfigPath
$answers = @(
'A'
'RULE-0500-TEST'; 'Test rule'; 'A rule added by a test'; '500'; 'Employee'; 'Y'
'all'
'C'; 'P'; 'isNotNull'; 'JobTitle'
'N'
'S'
'Q'
)
$run = Invoke-EditorWithScriptedInput -ArgumentList @('-ConfigPath', $path) -Answers $answers
$run.TimedOut | Should -BeFalse
$run.ExitCode | Should -Be 0
$run.Output | Should -Match 'Rule RULE-0500-TEST added'
$run.Output | Should -Match 'Saved:'
$saved = Get-Content -LiteralPath $path -Raw | ConvertFrom-Json -Depth 32
@($saved.rules | Where-Object { $_.id -eq 'RULE-0500-TEST' }).Count | Should -Be 1
}
It 'leaves the file untouched when an add is not saved' {
$path = New-ScratchConfigPath
$before = Get-Content -LiteralPath $path -Raw
$answers = @(
'A'
'RULE-0500-TEST'; 'Test rule'; 'A rule added by a test'; '500'; 'Employee'; 'Y'
'all'
'C'; 'P'; 'isNotNull'; 'JobTitle'
'N'
'Q'; 'y'
)
$run = Invoke-EditorWithScriptedInput -ArgumentList @('-ConfigPath', $path) -Answers $answers
$run.TimedOut | Should -BeFalse
$run.ExitCode | Should -Be 0
(Get-Content -LiteralPath $path -Raw) | Should -Be $before
}
It 'rejects a duplicate rule id with a message naming the conflict, and does not save it' {
$path = New-ScratchConfigPath
$answers = @(
'A'
'RULE-0010-GUEST'; 'Duplicate'; 'Collides with an existing id'; '999'; 'Employee'; 'Y'
'all'
'C'; 'P'; 'isNotNull'; 'JobTitle'
'N'
'Q'
)
$run = Invoke-EditorWithScriptedInput -ArgumentList @('-ConfigPath', $path) -Answers $answers
$run.TimedOut | Should -BeFalse
$run.Output | Should -Match 'RULE-0010-GUEST'
$run.Output | Should -Match 'Add cancelled'
$saved = Get-Content -LiteralPath $path -Raw | ConvertFrom-Json -Depth 32
@($saved.rules).Count | Should -Be 2
}
It 'deletes a rule after confirmation and persists it only after Save' {
$path = New-ScratchConfigPath
$answers = @('D', 'RULE-0010-GUEST', 'y', 'S', 'Q')
$run = Invoke-EditorWithScriptedInput -ArgumentList @('-ConfigPath', $path) -Answers $answers
$run.TimedOut | Should -BeFalse
$run.Output | Should -Match 'Rule RULE-0010-GUEST deleted'
$saved = Get-Content -LiteralPath $path -Raw | ConvertFrom-Json -Depth 32
@($saved.rules | Where-Object { $_.id -eq 'RULE-0010-GUEST' }).Count | Should -Be 0
}
It 'declining the delete confirmation leaves the rule in place' {
$path = New-ScratchConfigPath
$answers = @('D', 'RULE-0010-GUEST', 'n', 'Q')
$run = Invoke-EditorWithScriptedInput -ArgumentList @('-ConfigPath', $path) -Answers $answers
$run.TimedOut | Should -BeFalse
$run.Output | Should -Match 'Delete cancelled'
$saved = Get-Content -LiteralPath $path -Raw | ConvertFrom-Json -Depth 32
@($saved.rules | Where-Object { $_.id -eq 'RULE-0010-GUEST' }).Count | Should -Be 1
}
It 'rejects a condition-tree edit that would exceed the configured nesting depth, using the real validator finding' {
# maxConditionDepth: 2 means root(1) -> leaf(2) is the deepest a rule may go.
# Nesting a group under the root puts that group's own children at depth 3,
# which PE-SEM-012 catches - the same code the runtime validator and the [V]
# command produce (see Test-PersonaConfigurationSemantic.ps1).
$document = New-TestConfigurationDocument
$document.engine.maxConditionDepth = 2
$path = New-ScratchConfigPath -Document $document
$before = Get-Content -LiteralPath $path -Raw
$answers = @(
'E'; 'RULE-0900-EMPLOYEE'
'C'; ''
'G'; 'any'
'C'; 'P'; 'isNotNull'; 'JobTitle'
'N'
'Q'
)
$run = Invoke-EditorWithScriptedInput -ArgumentList @('-ConfigPath', $path) -Answers $answers
$run.TimedOut | Should -BeFalse
$run.Output | Should -Match 'Edit rejected'
$run.Output | Should -Match 'PE-SEM-012'
(Get-Content -LiteralPath $path -Raw) | Should -Be $before
}
It 'an accepted condition-tree edit is held in memory and appears after Save' {
$document = New-TestConfigurationDocument
$path = New-ScratchConfigPath -Document $document
$answers = @(
'E'; 'RULE-0900-EMPLOYEE'
'C'; ''
'C'; 'P'; 'isNotNull'; 'JobTitle'
'S'; 'Q'
)
$run = Invoke-EditorWithScriptedInput -ArgumentList @('-ConfigPath', $path) -Answers $answers
$run.TimedOut | Should -BeFalse
$run.Output | Should -Match 'Rule RULE-0900-EMPLOYEE updated'
$run.Output | Should -Match 'Saved:'
$saved = Get-Content -LiteralPath $path -Raw | ConvertFrom-Json -Depth 32
$rule = $saved.rules | Where-Object { $_.id -eq 'RULE-0900-EMPLOYEE' }
@($rule.match.conditions).Count | Should -Be 2
}
}