scripts/internal/product-domain-lens.ps1
|
<#
.SYNOPSIS Product & Problem Domain lens record writer/validator (Feature 176). The product-domain lens runs as a first-stage workshop phase before the technical-lens applicability selector. It persists a structured record (product-domain.yml) and a human-readable record (product-domain.md) per feature, captured at adaptive depth with evidence-tagged statements. These functions are pure + deterministic; no network/LLM. YAML note: PowerShell 7 has no native YAML parser. The structured record uses a CONSTRAINED YAML subset whose emitter (ConvertTo-SpecrewProductDomainYaml) and reader (ConvertFrom-SpecrewProductDomainYaml) are co-designed and round-trip-tested. Schema validation projects the parsed object to JSON and uses Test-Json -SchemaFile against contracts/product-domain.schema.json (SC-008). Graceful degradation everywhere (fail-open read, fail-closed gate on a substantive feature) so an absent surface is surfaced, never a silent skip. #> Set-StrictMode -Version Latest $script:SpecrewProductDomainDepths = @('light', 'standard', 'deep') $script:SpecrewProductDomainEvidence = @('known', 'assumed', 'unknown', 'research-needed') $script:SpecrewProductDomainContextScopes = @('feature_standalone', 'product_baseline', 'feature_delta') $script:SpecrewProductDomainConfirmations = @('human-confirmed', 'human-delegated', 'human-skipped') $script:SpecrewProductDomainConfirmationScopes = @{ 'human-confirmed' = 'lens-question' 'human-delegated' = 'explicit-delegation' 'human-skipped' = 'explicit-skip' } function Get-SpecrewProductDomainDepth { # FR-002 / SC-002: map risk + novelty signals to an adaptive depth. Deterministic; never # throws; defaults to 'standard' when ambiguous (the safe middle -- never silently Light). [CmdletBinding()] param( [AllowNull()][AllowEmptyString()][string]$Risk, [AllowNull()][AllowEmptyString()][string]$Novelty ) $r = if ([string]::IsNullOrWhiteSpace($Risk)) { '' } else { $Risk.Trim().ToLowerInvariant() } $n = if ([string]::IsNullOrWhiteSpace($Novelty)) { '' } else { $Novelty.Trim().ToLowerInvariant() } # Deep: new product / regulated / high-risk / migration / new workflow / new segment / pivot. $deepSignals = 'new-product|new product|regulated|high-risk|high risk|migration|replacement|pivot|new-segment|new segment|new-workflow|new workflow|multi-team|commercial' if ($r -match $deepSignals -or $n -match $deepSignals) { return 'deep' } # Light: tiny / bugfix / narrow / spike / personal tool -- only when neither risk nor novelty is high. $lightSignals = 'tiny|bug-?fix|narrow|spike|personal|trivial|utility|chore' $highRisk = $r -match 'high|elevated|major' $knownContext = $n -match 'known|existing|familiar|incremental|low' if (($r -match $lightSignals -or $n -match $lightSignals -or $knownContext) -and -not $highRisk) { return 'light' } return 'standard' # safe middle default } function Get-SpecrewProductDomainEscape { param([AllowNull()][string]$Value) if ($null -eq $Value) { return '' } # Constrained-YAML double-quoted scalar: escape backslash + quote; collapse newlines to spaces. return (($Value -replace '\\', '\\' -replace '"', '\"') -replace '\r?\n', ' ') } function Get-SpecrewProductDomainUnescape { param([AllowNull()][string]$Value) if ($null -eq $Value) { return '' } return ($Value -replace '\\"', '"' -replace '\\\\', '\') } function ConvertTo-SpecrewProductDomainYaml { # Emit the CONSTRAINED YAML for a product-domain record object (ordered hashtable / pscustomobject). # Deterministic key order; 2-space indent; strings double-quoted; null -> 'null'; bool -> true/false. [CmdletBinding()] param([Parameter(Mandatory = $true)][AllowNull()]$Record) if ($null -eq $Record) { return '' } $get = { param($obj, $key) if ($null -eq $obj) { return $null } if ($obj -is [System.Collections.IDictionary]) { if ($obj.Contains($key)) { return $obj[$key] } else { return $null } } $p = $obj.PSObject.Properties[$key]; if ($p) { return $p.Value } else { return $null } } $scalar = { param($v) if ($null -eq $v) { return 'null' } if ($v -is [bool]) { if ($v) { return 'true' } else { return 'false' } } return ('"{0}"' -f (Get-SpecrewProductDomainEscape -Value ([string]$v))) } $sb = [System.Text.StringBuilder]::new() foreach ($k in @('schema_version', 'depth', 'depth_reason', 'context_scope', 'product_id', 'product_context_ref')) { [void]$sb.AppendLine(('{0}: {1}' -f $k, (& $scalar (& $get $Record $k)))) } # areas: a flat map of string answers [void]$sb.AppendLine('areas:') $areas = & $get $Record 'areas' if ($null -ne $areas) { $areaKeys = if ($areas -is [System.Collections.IDictionary]) { $areas.Keys } else { $areas.PSObject.Properties.Name } foreach ($ak in $areaKeys) { [void]$sb.AppendLine((' {0}: {1}' -f $ak, (& $scalar (& $get $areas $ak)))) } } # statements: list of {text, area, evidence, load_bearing?} [void]$sb.AppendLine('statements:') foreach ($st in @(& $get $Record 'statements')) { if ($null -eq $st) { continue } [void]$sb.AppendLine((' - text: {0}' -f (& $scalar (& $get $st 'text')))) [void]$sb.AppendLine((' area: {0}' -f (& $scalar (& $get $st 'area')))) [void]$sb.AppendLine((' evidence: {0}' -f (& $scalar (& $get $st 'evidence')))) $lb = & $get $st 'load_bearing' if ($null -ne $lb) { [void]$sb.AppendLine((' load_bearing: {0}' -f (& $scalar $lb))) } } # skipped: list of {area, reason} [void]$sb.AppendLine('skipped:') foreach ($sk in @(& $get $Record 'skipped')) { if ($null -eq $sk) { continue } [void]$sb.AppendLine((' - area: {0}' -f (& $scalar (& $get $sk 'area')))) [void]$sb.AppendLine((' reason: {0}' -f (& $scalar (& $get $sk 'reason')))) } # follow_up_research: list of scalars [void]$sb.AppendLine('follow_up_research:') foreach ($fr in @(& $get $Record 'follow_up_research')) { if ($null -eq $fr) { continue } [void]$sb.AppendLine((' - {0}' -f (& $scalar $fr))) } [void]$sb.AppendLine(('confirmation: {0}' -f (& $scalar (& $get $Record 'confirmation')))) [void]$sb.AppendLine(('confirmation_scope: {0}' -f (& $scalar (& $get $Record 'confirmation_scope')))) return $sb.ToString() } function ConvertFrom-SpecrewProductDomainScalar { param([AllowNull()][string]$Raw) if ($null -eq $Raw) { return $null } $t = $Raw.Trim() if ($t -eq 'null' -or $t -eq '') { return $null } if ($t -eq 'true') { return $true } if ($t -eq 'false') { return $false } if ($t.StartsWith('"') -and $t.EndsWith('"') -and $t.Length -ge 2) { return (Get-SpecrewProductDomainUnescape -Value $t.Substring(1, $t.Length - 2)) } return $t } function ConvertFrom-SpecrewProductDomainYaml { # Matched reader for the constrained YAML emitted above. Returns an ordered hashtable, or # $null on a structurally unreadable document (graceful -- the caller fails closed). [CmdletBinding()] param([Parameter(Mandatory = $true)][AllowNull()][AllowEmptyString()][string]$Text) if ([string]::IsNullOrWhiteSpace($Text)) { return $null } $lines = $Text -split '\r?\n' $rec = [ordered]@{ areas = [ordered]@{}; statements = @(); skipped = @(); follow_up_research = @() } $statements = [System.Collections.Generic.List[object]]::new() $skipped = [System.Collections.Generic.List[object]]::new() $research = [System.Collections.Generic.List[string]]::new() $section = 'top' $cur = $null foreach ($line in $lines) { if ($line -match '^\s*$') { continue } # Top-level key (no indent) if ($line -match '^(?<k>[a-z_]+):\s*(?<v>.*)$') { $k = $Matches['k']; $v = $Matches['v'] switch ($k) { 'areas' { $section = 'areas'; continue } 'statements' { $section = 'statements'; continue } 'skipped' { $section = 'skipped'; continue } 'follow_up_research' { $section = 'research'; continue } default { $section = 'top' $rec[$k] = ConvertFrom-SpecrewProductDomainScalar -Raw $v continue } } } # areas: 2-space indented map entries if ($section -eq 'areas' -and $line -match '^\s{2}(?<k>[a-z_]+):\s*(?<v>.*)$') { $rec['areas'][$Matches['k']] = ConvertFrom-SpecrewProductDomainScalar -Raw $Matches['v'] continue } # statements / skipped: list items " - key: value" then " key: value" if ($section -eq 'statements' -or $section -eq 'skipped') { if ($line -match '^\s{2}-\s+(?<k>[a-z_]+):\s*(?<v>.*)$') { $cur = [ordered]@{} $cur[$Matches['k']] = ConvertFrom-SpecrewProductDomainScalar -Raw $Matches['v'] if ($section -eq 'statements') { $statements.Add($cur) | Out-Null } else { $skipped.Add($cur) | Out-Null } continue } if ($null -ne $cur -and $line -match '^\s{4}(?<k>[a-z_]+):\s*(?<v>.*)$') { $cur[$Matches['k']] = ConvertFrom-SpecrewProductDomainScalar -Raw $Matches['v'] continue } } # follow_up_research: " - scalar" if ($section -eq 'research' -and $line -match '^\s{2}-\s+(?<v>.*)$') { $val = ConvertFrom-SpecrewProductDomainScalar -Raw $Matches['v'] if ($null -ne $val) { $research.Add([string]$val) | Out-Null } continue } } $rec['statements'] = $statements.ToArray() $rec['skipped'] = $skipped.ToArray() $rec['follow_up_research'] = $research.ToArray() return $rec } function Get-SpecrewProductDomainRecordPath { param([Parameter(Mandatory = $true)][string]$FeatureDir, [ValidateSet('yml', 'md')][string]$Kind = 'yml') return (Join-Path (Join-Path $FeatureDir 'workshop') ('product-domain.{0}' -f $Kind)) } function Format-SpecrewProductDomainMarkdown { # Render the human-readable product-domain.md from a record object (FR-005). [CmdletBinding()] param([Parameter(Mandatory = $true)][AllowNull()]$Record) $get = { param($o, $k) if ($null -eq $o) { return $null } if ($o -is [System.Collections.IDictionary]) { if ($o.Contains($k)) { return $o[$k] } else { return $null } } $p = $o.PSObject.Properties[$k]; if ($p) { return $p.Value } else { return $null } } $sb = [System.Text.StringBuilder]::new() [void]$sb.AppendLine('# Product-Domain Record') [void]$sb.AppendLine('') [void]$sb.AppendLine(('**Depth**: {0} -- {1}' -f (& $get $Record 'depth'), (& $get $Record 'depth_reason'))) [void]$sb.AppendLine(('**Context scope**: {0}' -f (& $get $Record 'context_scope'))) [void]$sb.AppendLine('') [void]$sb.AppendLine('## Areas') [void]$sb.AppendLine('') $areas = & $get $Record 'areas' if ($null -ne $areas) { $akeys = if ($areas -is [System.Collections.IDictionary]) { $areas.Keys } else { $areas.PSObject.Properties.Name } foreach ($ak in $akeys) { [void]$sb.AppendLine(('- **{0}**: {1}' -f $ak, (& $get $areas $ak))) } } [void]$sb.AppendLine('') [void]$sb.AppendLine('## Evidence-tagged statements') [void]$sb.AppendLine('') foreach ($st in @(& $get $Record 'statements')) { if ($null -eq $st) { continue } $lb = & $get $st 'load_bearing' $lbTag = if ($null -ne $lb) { (' [load_bearing: {0}]' -f $lb) } else { '' } [void]$sb.AppendLine(('- ({0}{1}) [{2}] {3}' -f (& $get $st 'evidence'), $lbTag, (& $get $st 'area'), (& $get $st 'text'))) } [void]$sb.AppendLine('') [void]$sb.AppendLine(('**Confirmation**: {0} / {1}' -f (& $get $Record 'confirmation'), (& $get $Record 'confirmation_scope'))) return $sb.ToString() } function New-SpecrewProductDomainRecord { # FR-005: scaffold/persist the structured (.yml) + human-readable (.md) records for a feature. # Idempotent: re-running with the same record rewrites an equivalent file. UTF-8 no-BOM. [CmdletBinding()] param( [Parameter(Mandatory = $true)][string]$FeatureDir, [Parameter(Mandatory = $true)][AllowNull()]$Record, [switch]$Force ) if ($null -eq $Record) { throw 'New-SpecrewProductDomainRecord: -Record is required.' } $ymlPath = Get-SpecrewProductDomainRecordPath -FeatureDir $FeatureDir -Kind 'yml' $mdPath = Get-SpecrewProductDomainRecordPath -FeatureDir $FeatureDir -Kind 'md' $dir = Split-Path -Parent $ymlPath if (-not (Test-Path -LiteralPath $dir -PathType Container)) { $null = New-Item -ItemType Directory -Path $dir -Force } $utf8 = [System.Text.UTF8Encoding]::new($false) if ((Test-Path -LiteralPath $ymlPath -PathType Leaf) -and -not $Force) { # Idempotent: keep the existing .yml, but ensure the human-readable .md ALSO exists -- the gate # requires BOTH files (FR-005), so a deleted .md must be regenerated on a no-Force re-run. if (-not (Test-Path -LiteralPath $mdPath -PathType Leaf)) { [System.IO.File]::WriteAllText($mdPath, (Format-SpecrewProductDomainMarkdown -Record $Record), $utf8) } return $ymlPath } [System.IO.File]::WriteAllText($ymlPath, (ConvertTo-SpecrewProductDomainYaml -Record $Record), $utf8) [System.IO.File]::WriteAllText($mdPath, (Format-SpecrewProductDomainMarkdown -Record $Record), $utf8) return $ymlPath } function Get-SpecrewProductDomainSchemaPath { param([Parameter(Mandatory = $true)][string]$FeatureDir) return (Join-Path (Join-Path $FeatureDir 'contracts') 'product-domain.schema.json') } function Test-SpecrewProductDomainRecord { # FR-004 / FR-010 / SC-003 / SC-008: validate the persisted .yml record. Reads + parses the # constrained YAML, projects to JSON, validates against the schema (Test-Json -SchemaFile when # the schema is present), and enforces evidence-tag + provenance invariants the schema can also # express. Returns a string[] of errors (empty = OK). Graceful: a MISSING record returns a single # 'missing' error (the caller decides fail-open vs fail-closed); an UNREADABLE record fails. [CmdletBinding()] param( [Parameter(Mandatory = $true)][AllowNull()][AllowEmptyString()][string]$Path, [AllowNull()][AllowEmptyString()][string]$SchemaPath ) $errors = [System.Collections.Generic.List[string]]::new() if ([string]::IsNullOrWhiteSpace($Path) -or -not (Test-Path -LiteralPath $Path -PathType Leaf)) { $errors.Add(("product-domain record is missing: {0}" -f $Path)) | Out-Null return $errors.ToArray() } $text = '' try { $text = Get-Content -LiteralPath $Path -Raw -Encoding UTF8 } catch { $errors.Add('product-domain.yml is unreadable.') | Out-Null; return $errors.ToArray() } $rec = ConvertFrom-SpecrewProductDomainYaml -Text $text if ($null -eq $rec) { $errors.Add('product-domain.yml could not be parsed.') | Out-Null; return $errors.ToArray() } # Schema validation via the JSON projection (SC-008), when a schema is available. $json = $null try { $json = ($rec | ConvertTo-Json -Depth 8) } catch { $json = $null } if ($null -ne $json -and -not [string]::IsNullOrWhiteSpace($SchemaPath) -and (Test-Path -LiteralPath $SchemaPath -PathType Leaf)) { $schemaErrors = $null $ok = $true try { $ok = Test-Json -Json $json -SchemaFile $SchemaPath -ErrorVariable schemaErrors -ErrorAction SilentlyContinue } catch { $ok = $false } if (-not $ok) { $detail = if ($schemaErrors) { ($schemaErrors | ForEach-Object { $_.ToString() }) -join '; ' } else { 'schema mismatch' } $errors.Add(("product-domain.yml fails the schema: {0}" -f $detail)) | Out-Null } } # Invariant backstops (also expressed in the schema; checked here so the gate is robust even # when the schema file is absent). $depth = if ($rec.Contains('depth')) { [string]$rec['depth'] } else { '' } if ($depth -notin $script:SpecrewProductDomainDepths) { $errors.Add(("depth must be one of {0} (got '{1}')." -f ($script:SpecrewProductDomainDepths -join ' | '), $depth)) | Out-Null } if ([string]::IsNullOrWhiteSpace([string]$rec['depth_reason'])) { $errors.Add('depth_reason is required.') | Out-Null } $scope = if ($rec.Contains('context_scope')) { [string]$rec['context_scope'] } else { '' } if ($scope -notin $script:SpecrewProductDomainContextScopes) { $errors.Add(("context_scope must be one of {0} (got '{1}')." -f ($script:SpecrewProductDomainContextScopes -join ' | '), $scope)) | Out-Null } foreach ($st in @($rec['statements'])) { if ($null -eq $st) { continue } $ev = if ($st.Contains('evidence')) { [string]$st['evidence'] } else { '' } if ($ev -notin $script:SpecrewProductDomainEvidence) { $errors.Add(("a statement has an invalid evidence tag '{0}' (must be {1})." -f $ev, ($script:SpecrewProductDomainEvidence -join ' | '))) | Out-Null } if ([string]::IsNullOrWhiteSpace([string]$st['text'])) { $errors.Add('a statement is missing its text (untagged/empty material statement).') | Out-Null } if ($ev -eq 'research-needed' -and -not $st.Contains('load_bearing')) { $errors.Add('a research-needed statement must declare load_bearing (true|false).') | Out-Null } } # Provenance: a batch/agenda approval can never satisfy product-domain confirmation (FR-009). $conf = if ($rec.Contains('confirmation')) { [string]$rec['confirmation'] } else { '' } if ($conf -notin $script:SpecrewProductDomainConfirmations) { $errors.Add(("confirmation must be one of {0} (got '{1}'); a batch 'confirm all' is NOT valid provenance." -f ($script:SpecrewProductDomainConfirmations -join ' | '), $conf)) | Out-Null } else { $expectedScope = $script:SpecrewProductDomainConfirmationScopes[$conf] $scopeVal = if ($rec.Contains('confirmation_scope')) { [string]$rec['confirmation_scope'] } else { '' } if ($scopeVal -ne $expectedScope) { $errors.Add(("confirmation_scope must be '{0}' when confirmation is '{1}' (got '{2}'); lens approval is not product-domain confirmation." -f $expectedScope, $conf, $scopeVal)) | Out-Null } } return $errors.ToArray() } function Test-SpecrewProductDomainResearchBlock { # FR-011 / SC-006: return load-bearing research-needed statements that block the plan boundary. # A non-load-bearing research-needed statement is NOT returned (recorded + carried). Graceful @(). [CmdletBinding()] param([Parameter(Mandatory = $true)][AllowNull()][AllowEmptyString()][string]$Path) if ([string]::IsNullOrWhiteSpace($Path) -or -not (Test-Path -LiteralPath $Path -PathType Leaf)) { return @() } $text = '' try { $text = Get-Content -LiteralPath $Path -Raw -Encoding UTF8 } catch { return @() } $rec = ConvertFrom-SpecrewProductDomainYaml -Text $text if ($null -eq $rec) { return @() } $blocking = [System.Collections.Generic.List[string]]::new() foreach ($st in @($rec['statements'])) { if ($null -eq $st) { continue } $ev = if ($st.Contains('evidence')) { [string]$st['evidence'] } else { '' } $lb = if ($st.Contains('load_bearing')) { $st['load_bearing'] } else { $null } if ($ev -eq 'research-needed' -and $null -ne $lb -and [bool]$lb) { $blocking.Add([string]$st['text']) | Out-Null } } return $blocking.ToArray() } function Format-SpecrewProductDomainSummary { # FR-006: render the concise spec.md product-domain summary from the persisted record. # Graceful 'none recorded' when absent. Pure; markdownlint-safe. [CmdletBinding()] param([Parameter(Mandatory = $true)][AllowNull()][AllowEmptyString()][string]$Path) $sb = [System.Text.StringBuilder]::new() [void]$sb.AppendLine('## Product-Domain Summary') [void]$sb.AppendLine('') if ([string]::IsNullOrWhiteSpace($Path) -or -not (Test-Path -LiteralPath $Path -PathType Leaf)) { [void]$sb.AppendLine('*None recorded* (the product-domain phase has not run for this feature).') return $sb.ToString().TrimEnd() } $rec = ConvertFrom-SpecrewProductDomainYaml -Text (Get-Content -LiteralPath $Path -Raw -Encoding UTF8) if ($null -eq $rec) { [void]$sb.AppendLine('*None recorded* (the product-domain record could not be parsed).'); return $sb.ToString().TrimEnd() } [void]$sb.AppendLine(('- **Depth**: {0} ({1})' -f $rec['depth'], $rec['context_scope'])) $areas = $rec['areas'] foreach ($ak in @('users_stakeholders', 'pain_job', 'mvp', 'out_of_scope', 'constraints')) { if ($null -ne $areas -and $areas.Contains($ak) -and -not [string]::IsNullOrWhiteSpace([string]$areas[$ak])) { [void]$sb.AppendLine(('- **{0}**: {1}' -f $ak, $areas[$ak])) } } $research = @($rec['follow_up_research']) if ($research.Count -gt 0) { [void]$sb.AppendLine(('- **Follow-up research**: {0}' -f ($research -join '; '))) } [void]$sb.AppendLine('- Full record: see the workshop product-domain.md / product-domain.yml.') return $sb.ToString().TrimEnd() } |