diff --git a/docs/docs/games/03-game-design.md b/docs/docs/games/03-game-design.md index 7bc3eee..9d968b5 100644 --- a/docs/docs/games/03-game-design.md +++ b/docs/docs/games/03-game-design.md @@ -138,8 +138,8 @@ Any action plan whose total time cost exceeds available time is rejected. - Identity: name, age, background. - Finances: cash, savings, debt, weekly income, weekly expenses, overdue balance, credit score, accounts. -- Needs: health, energy, happiness, stress, satiety. -- Attributes: intelligence, discipline, charisma, creativity, resilience, wisdom, luck. +- Needs: health, energy, happiness, stress, satiety. +- Attributes: intelligence, discipline, charisma, creativity, resilience, wisdom, luck. - Education, career, housing. - Inventory and relationships. - Skills, traits, reputation, flags. diff --git a/tools/Read-SpecSet.Tests.ps1 b/tools/Read-SpecSet.Tests.ps1 index cc35db7..4dfe5e8 100644 --- a/tools/Read-SpecSet.Tests.ps1 +++ b/tools/Read-SpecSet.Tests.ps1 @@ -18,6 +18,63 @@ Describe 'Read-SpecSetIndex' { ($index.Declarations | Where-Object QualifiedName -eq 'GameMode').Members | Should -Be @('classic', 'open_life', 'challenge') ($index.Declarations | Where-Object QualifiedName -eq 'PlayerState.skills').IsClosed | Should -BeFalse } + It 'extracts the NeedState and AttributeState mirror obligations from §3.1' { + $index = Read-SpecSetIndex -CorpusPath (Join-Path (Split-Path -Parent $PSScriptRoot) 'docs/docs/games') + $index.MirrorObligations.Count | Should -Be 2 + ($index.MirrorObligations | Where-Object QualifiedName -eq 'NeedState').BodyMembers | Should -Be @('health', 'energy', 'happiness', 'stress', 'satiety') + ($index.MirrorObligations | Where-Object QualifiedName -eq 'AttributeState').BodyMembers | Should -Be @('intelligence', 'discipline', 'charisma', 'creativity', 'resilience', 'wisdom', 'luck') + } + It 'S3.6: a document with no declared regions is valid input yielding zero obligations' { + $corpus = Join-Path $TestDrive 'no-regions'; New-Item -ItemType Directory -Path $corpus | Out-Null + Set-Content -LiteralPath (Join-Path $corpus '01-fixture.md') -Value "# Fixture`n`nJust prose, no markers here." -NoNewline + $index = Read-SpecSetIndex -CorpusPath $corpus + $index.State | Should -Be 'Indexed' + $index.MirrorObligations.Count | Should -Be 0 + } + It 'S3.6: an unclosed region yields MalformedRegion' { + $corpus = Join-Path $TestDrive 'unclosed'; New-Item -ItemType Directory -Path $corpus | Out-Null + Set-Content -LiteralPath (Join-Path $corpus '01-fixture.md') -Value "# Fixture`n`nList: a, b." -NoNewline + $index = Read-SpecSetIndex -CorpusPath $corpus + $index.State | Should -Be 'NotEvaluated'; $index.Reason | Should -Be 'MalformedRegion'; $index.Line | Should -BeGreaterThan 0 + } + It 'S3.6: a mismatched end marker yields MalformedRegion' { + $corpus = Join-Path $TestDrive 'mismatched'; New-Item -ItemType Directory -Path $corpus | Out-Null + Set-Content -LiteralPath (Join-Path $corpus '01-fixture.md') -Value "# Fixture`n`nList: a, b." -NoNewline + $index = Read-SpecSetIndex -CorpusPath $corpus + $index.State | Should -Be 'NotEvaluated'; $index.Reason | Should -Be 'MalformedRegion' + } + It 'S3.6: a nested region yields MalformedRegion' { + $corpus = Join-Path $TestDrive 'nested'; New-Item -ItemType Directory -Path $corpus | Out-Null + Set-Content -LiteralPath (Join-Path $corpus '01-fixture.md') -Value "# Fixture`n`nab" -NoNewline + $index = Read-SpecSetIndex -CorpusPath $corpus + $index.State | Should -Be 'NotEvaluated'; $index.Reason | Should -Be 'MalformedRegion' + } + It 'S3.6: two regions sharing an id in the same document yield DuplicateRegionId' { + $corpus = Join-Path $TestDrive 'duplicate'; New-Item -ItemType Directory -Path $corpus | Out-Null + Set-Content -LiteralPath (Join-Path $corpus '01-fixture.md') -Value "# Fixture`n`nOne: a`nTwo: b" -NoNewline + $index = Read-SpecSetIndex -CorpusPath $corpus + $index.State | Should -Be 'NotEvaluated'; $index.Reason | Should -Be 'DuplicateRegionId' + } + It 'S3.4: a mirror region naming an open declaration is still indexed as an obligation' { + $corpus = Join-Path $TestDrive 'open-decl'; New-Item -ItemType Directory -Path $corpus | Out-Null + $content = @' +# Fixture + +```typescript +interface ActorState { + skills: Record; +} +type PlayerState = ActorState; +``` + +Skills: cooking +'@ + Set-Content -LiteralPath (Join-Path $corpus '01-fixture.md') -Value $content -NoNewline + $index = Read-SpecSetIndex -CorpusPath $corpus + $index.State | Should -Be 'Indexed' + ($index.MirrorObligations | Where-Object QualifiedName -eq 'PlayerState.skills') | Should -Not -BeNullOrEmpty + ($index.Declarations | Where-Object QualifiedName -eq 'PlayerState.skills').IsClosed | Should -BeFalse + } It 'fails closed on an unsupported declaration' { $corpus = Join-Path $TestDrive 'games'; New-Item -ItemType Directory -Path $corpus | Out-Null Set-Content -LiteralPath (Join-Path $corpus '01-fixture.md') -Value "# Fixture`n``````typescript`nclass Unsupported {}`n``````" -NoNewline diff --git a/tools/Read-SpecSet.ps1 b/tools/Read-SpecSet.ps1 index 41e107b..1653035 100644 --- a/tools/Read-SpecSet.ps1 +++ b/tools/Read-SpecSet.ps1 @@ -36,6 +36,13 @@ class SpecFinding { [string] $Detail } +class MirrorObligation { + [string] $QualifiedName + [string] $DocumentPath + [int] $Line + [string[]] $BodyMembers = @() +} + function New-SpecSetIndexFailure { param([string] $Reason, [string] $Path, [int] $Line = 0) [pscustomobject]@{ @@ -118,6 +125,51 @@ function Get-FenceDeclarations { [pscustomobject]@{ Declarations = @($rows); Failure = $null; Line = 0 } } +function Get-SpecSetLineNumber { + param([string] $Text, [int] $Index) + 1 + ([regex]::Matches($Text.Substring(0, $Index), "`n")).Count +} + +function Get-DeclaredRegions { + param([string] $Text, [string] $DocumentPath) + + $pattern = '|' + $regions = [System.Collections.Generic.List[object]]::new() + $seenIds = [System.Collections.Generic.HashSet[string]]::new() + $openId = $null; $openLine = 0; $openBodyStart = 0 + + foreach ($m in [regex]::Matches($Text, $pattern)) { + $line = Get-SpecSetLineNumber -Text $Text -Index $m.Index + if ($m.Groups['sid'].Success) { + if ($openId) { return [pscustomobject]@{ Failure = 'MalformedRegion'; Line = $line } } + $openId = $m.Groups['sid'].Value; $openLine = $line; $openBodyStart = $m.Index + $m.Length + continue + } + $eid = $m.Groups['eid'].Value + if (-not $openId -or $eid -ne $openId) { return [pscustomobject]@{ Failure = 'MalformedRegion'; Line = $line } } + if (-not $seenIds.Add($openId)) { return [pscustomobject]@{ Failure = 'DuplicateRegionId'; Line = $openLine } } + $body = $Text.Substring($openBodyStart, $m.Index - $openBodyStart).Trim() + $regions.Add([pscustomobject]@{ Id = $openId; Line = $openLine; Body = $body; DocumentPath = $DocumentPath }) + $openId = $null + } + if ($openId) { return [pscustomobject]@{ Failure = 'MalformedRegion'; Line = $openLine } } + [pscustomobject]@{ Regions = @($regions); Failure = $null } +} + +function Get-MirrorObligationFromRegion { + param([Parameter(Mandatory)][object] $Region) + + if ($Region.Id -notlike 'mirror-*') { return $null } + $afterColon = if ($Region.Body -match ':') { $Region.Body.Substring($Region.Body.IndexOf(':') + 1) } else { $Region.Body } + $members = @([regex]::Matches($afterColon, '[A-Za-z_][A-Za-z0-9_]*') | ForEach-Object { $_.Value } | Select-Object -Unique) + $obligation = [MirrorObligation]::new() + $obligation.QualifiedName = $Region.Id.Substring('mirror-'.Length) + $obligation.DocumentPath = $Region.DocumentPath + $obligation.Line = $Region.Line + $obligation.BodyMembers = $members + $obligation +} + function Read-SpecSetIndex { [CmdletBinding()] param([Parameter(Mandatory)][string] $CorpusPath) @@ -126,11 +178,18 @@ function Read-SpecSetIndex { $root = (Resolve-Path -LiteralPath $CorpusPath).Path $repoRoot = Get-SpecSetRepositoryRoot -CorpusRoot $root $documents = [System.Collections.Generic.List[object]]::new(); $declarations = [System.Collections.Generic.List[object]]::new() + $mirrorObligations = [System.Collections.Generic.List[object]]::new() foreach ($file in @(Get-ChildItem -LiteralPath $root -File -Filter '*.md' | Sort-Object Name)) { try { $text = [System.IO.File]::ReadAllText($file.FullName, [System.Text.UTF8Encoding]::new($false)) } catch { return New-SpecSetIndexFailure -Reason 'UnreadableDocument' -Path $file.FullName } $relative = [System.IO.Path]::GetRelativePath($repoRoot, $file.FullName).Replace('\', '/') $doc = [SpecDocument]::new(); $doc.Path = $relative; $doc.Ordinal = Get-SpecDocumentOrdinal -Name $file.Name $h1 = [regex]::Match($text, '(?m)^#\s+(.+?)\s*$'); $doc.Title = if ($h1.Success) { $h1.Groups[1].Value } else { '' }; $documents.Add($doc) + $regionResult = Get-DeclaredRegions -Text $text -DocumentPath $relative + if ($regionResult.Failure) { return New-SpecSetIndexFailure -Reason $regionResult.Failure -Path $relative -Line $regionResult.Line } + foreach ($region in $regionResult.Regions) { + $obligation = Get-MirrorObligationFromRegion -Region $region + if ($obligation) { $mirrorObligations.Add($obligation) } + } $lines = $text -replace "`r`n", "`n" -split "`n"; $inFence = $false; $fence = @(); $start = 0 for ($i = 0; $i -lt $lines.Count; $i++) { if (-not $inFence -and $lines[$i] -eq '```typescript') { $inFence = $true; $fence = @(); $start = $i + 2; continue } @@ -162,5 +221,5 @@ function Read-SpecSetIndex { $declarations.Add($aliasField) } } - [pscustomobject]@{ State = 'Indexed'; Reason = $null; Detail = ''; Line = 0; Documents = @($documents); Declarations = @($declarations); References = @(); MirrorObligations = @(); ProvisionalEntries = @(); ProvisionalSites = @(); Lifecycles = @() } + [pscustomobject]@{ State = 'Indexed'; Reason = $null; Detail = ''; Line = 0; Documents = @($documents); Declarations = @($declarations); References = @(); MirrorObligations = @($mirrorObligations); ProvisionalEntries = @(); ProvisionalSites = @(); Lifecycles = @() } } diff --git a/tools/Test-SpecSet.Tests.ps1 b/tools/Test-SpecSet.Tests.ps1 index 33ae50d..64b20fc 100644 --- a/tools/Test-SpecSet.Tests.ps1 +++ b/tools/Test-SpecSet.Tests.ps1 @@ -18,3 +18,121 @@ Describe 'Test-SpecSet runner' { $result.Counts.Documents | Should -Be 8 } } + +Describe 'S3.1/S3.5: the mirror check holds against the real corpus as it stands' { + It 'raises no mirror findings anywhere in the real corpus, including §3.5 skills' { + $index = Read-SpecSetIndex -CorpusPath (Join-Path (Split-Path -Parent $PSScriptRoot) 'docs/docs/games') + $index.State | Should -Be 'Indexed' + $findings = Get-MirrorFindings -Index $index + $findings.Count | Should -Be 0 + } +} + +Describe 'S3.2: the mirror check catches attribute drift in both directions' { + BeforeAll { + $script:FixtureRoot = Join-Path $TestDrive 'games' + Copy-Item -Recurse -Path (Join-Path (Split-Path -Parent $PSScriptRoot) 'docs/docs/games') -Destination $script:FixtureRoot + $script:GameDesignPath = Join-Path $script:FixtureRoot '03-game-design.md' + $script:OriginalText = Get-Content -LiteralPath $script:GameDesignPath -Raw + } + + It 'is clean while §3.1 still mentions wisdom' { + $index = Read-SpecSetIndex -CorpusPath $script:FixtureRoot + (Get-MirrorFindings -Index $index).Count | Should -Be 0 + } + + It 'raises exactly one finding naming AttributeState.wisdom once wisdom is deleted from §3.1' { + $mutated = $script:OriginalText -replace 'resilience, wisdom, luck', 'resilience, luck' + $mutated | Should -Not -Be $script:OriginalText + Set-Content -LiteralPath $script:GameDesignPath -Value $mutated -NoNewline + + $index = Read-SpecSetIndex -CorpusPath $script:FixtureRoot + $findings = Get-MirrorFindings -Index $index + $findings.Count | Should -Be 1 + $findings[0].Subject | Should -Be 'AttributeState.wisdom' + $findings[0].Detail | Should -Match 'AttributeState\.wisdom' + $findings[0].Detail | Should -Match '04-engine-specification\.md' + $findings[0].Detail | Should -Match '03-game-design\.md' + } + + It 'returns to clean once wisdom is restored' { + Set-Content -LiteralPath $script:GameDesignPath -Value $script:OriginalText -NoNewline + $index = Read-SpecSetIndex -CorpusPath $script:FixtureRoot + (Get-MirrorFindings -Index $index).Count | Should -Be 0 + } +} + +Describe 'S3.3: SpecFinding never records which side is stale' { + It 'declares only CheckId, Subject, DocumentPath, Line and Detail' { + $props = @([SpecFinding].GetProperties().Name) + $props | Should -Not -Contain 'Culprit' + $props | Should -Not -Contain 'Stale' + $props | Should -Not -Contain 'Correct' + $props | Sort-Object | Should -Be @('CheckId', 'Detail', 'DocumentPath', 'Line', 'Subject') + } +} + +Describe 'S3.4: mirror obligations that cannot hold' { + It 'raises a finding for an obligation naming an open declaration' { + $corpus = Join-Path $TestDrive 'open-decl'; New-Item -ItemType Directory -Path $corpus | Out-Null + $content = @' +# Fixture + +```typescript +interface ActorState { + skills: Record; +} +type PlayerState = ActorState; +``` + +Skills: cooking +'@ + Set-Content -LiteralPath (Join-Path $corpus '01-fixture.md') -Value $content -NoNewline + $index = Read-SpecSetIndex -CorpusPath $corpus + $findings = Get-MirrorFindings -Index $index + $findings.Count | Should -Be 1 + $findings[0].Subject | Should -Be 'PlayerState.skills' + } + It 'raises a finding for an obligation naming a declaration that does not exist' { + $corpus = Join-Path $TestDrive 'ghost-decl'; New-Item -ItemType Directory -Path $corpus | Out-Null + $content = "# Fixture`n`nGhost: nothing here" + Set-Content -LiteralPath (Join-Path $corpus '01-fixture.md') -Value $content -NoNewline + $index = Read-SpecSetIndex -CorpusPath $corpus + $findings = Get-MirrorFindings -Index $index + $findings.Count | Should -Be 1 + $findings[0].Subject | Should -Be 'GhostState' + } +} + +Describe 'S3.7: checks read no files and form a flat call graph' { + It 'no check function calls a file cmdlet or another check function' { + $ast = [System.Management.Automation.Language.Parser]::ParseFile((Join-Path $PSScriptRoot 'Test-SpecSet.ps1'), [ref]$null, [ref]$null) + $checkFns = @($ast.FindAll({ + param($n) $n -is [System.Management.Automation.Language.FunctionDefinitionAst] -and $n.Name -match '^Get-\w+Findings$' + }, $true)) + $checkFns.Count | Should -BeGreaterThan 0 + + $fileCmdlets = @('Get-Content', 'Set-Content', 'Add-Content', 'Get-ChildItem', 'Get-Item', 'Test-Path', 'Import-Csv', 'Out-File', 'Remove-Item', 'New-Item', 'Copy-Item', 'Move-Item', 'Resolve-Path') + $checkNames = @($checkFns | ForEach-Object Name) + + foreach ($fn in $checkFns) { + $calls = @($fn.FindAll({ param($n) $n -is [System.Management.Automation.Language.CommandAst] }, $true) | ForEach-Object { $_.GetCommandName() }) + foreach ($cmdletName in $fileCmdlets) { $calls | Should -Not -Contain $cmdletName } + foreach ($other in $checkNames) { $calls | Should -Not -Contain $other } + } + } +} + +Describe 'S3.8: the report states the count checked and never claims consistency' { + It 'reports the mirror obligation count and never says the documents are consistent' { + $result = [pscustomobject]@{ + State = 'Valid' + Documents = @(1, 2) + Declarations = @(1, 2, 3) + Counts = [pscustomobject]@{ MirrorObligations = 2 } + } + $line = Write-SpecSetReport -Result $result + $line | Should -Match '2 mirror obligations checked' + $line | Should -Not -Match 'consistent' + } +} diff --git a/tools/Test-SpecSet.ps1 b/tools/Test-SpecSet.ps1 index 50dc1b9..fd755ad 100644 --- a/tools/Test-SpecSet.ps1 +++ b/tools/Test-SpecSet.ps1 @@ -9,6 +9,46 @@ function Get-SpecSetExitCode { param([string] $State) switch ($State) { 'Valid' { return 0 } 'Invalid' { return 1 } 'NotEvaluated' { return 2 } default { throw "Unknown spec-set state '$State'." } } } +function Get-MirrorFindings { + param([Parameter(Mandatory)][object] $Index) + + $findings = [System.Collections.Generic.List[object]]::new() + foreach ($obligation in $Index.MirrorObligations) { + $decl = @($Index.Declarations | Where-Object { $_.QualifiedName -eq $obligation.QualifiedName }) + if ($decl.Count -eq 0) { + $f = [SpecFinding]::new() + $f.CheckId = 'mirror'; $f.Subject = $obligation.QualifiedName; $f.DocumentPath = $obligation.DocumentPath; $f.Line = $obligation.Line + $f.Detail = "mirror-$($obligation.QualifiedName) in $($obligation.DocumentPath) names a declaration that does not exist." + $findings.Add($f) + continue + } + $declaration = $decl[0] + if (-not $declaration.IsClosed) { + $f = [SpecFinding]::new() + $f.CheckId = 'mirror'; $f.Subject = $obligation.QualifiedName; $f.DocumentPath = $obligation.DocumentPath; $f.Line = $obligation.Line + $f.Detail = "mirror-$($obligation.QualifiedName) in $($obligation.DocumentPath) names an open declaration, which cannot carry a mirror obligation." + $findings.Add($f) + continue + } + foreach ($member in $declaration.Members) { + if ($member -notin $obligation.BodyMembers) { + $f = [SpecFinding]::new() + $f.CheckId = 'mirror'; $f.Subject = "$($obligation.QualifiedName).$member"; $f.DocumentPath = $obligation.DocumentPath; $f.Line = $obligation.Line + $f.Detail = "$($declaration.DocumentPath) declares $($obligation.QualifiedName).$member; $($obligation.DocumentPath) does not mention it." + $findings.Add($f) + } + } + foreach ($named in $obligation.BodyMembers) { + if ($named -notin $declaration.Members) { + $f = [SpecFinding]::new() + $f.CheckId = 'mirror'; $f.Subject = "$($obligation.QualifiedName).$named"; $f.DocumentPath = $obligation.DocumentPath; $f.Line = $obligation.Line + $f.Detail = "$($obligation.DocumentPath) mentions $($obligation.QualifiedName).$named; $($declaration.DocumentPath) does not declare it." + $findings.Add($f) + } + } + } + return ,@($findings) +} function Invoke-SpecSetCheck { param([Parameter(Mandatory)][object] $Index) @@ -20,13 +60,15 @@ function Invoke-SpecSetCheck { } } + $findings = @(Get-MirrorFindings -Index $Index) + [pscustomobject]@{ - Findings = @() + Findings = $findings Unchecked = $unchecked Counts = [pscustomobject]@{ Unchecked = $unchecked.Count } } } -function Write-SpecSetReport { param([Parameter(Mandatory)][object] $Result) "Spec-set: $($Result.State); $($Result.Documents.Count) documents; $($Result.Declarations.Count) declarations." } +function Write-SpecSetReport { param([Parameter(Mandatory)][object] $Result) "Spec-set: $($Result.State); $($Result.Documents.Count) documents; $($Result.Declarations.Count) declarations; $($Result.Counts.MirrorObligations) mirror obligations checked." } function Get-SpecSetGitInfo { $root = Split-Path -Parent $PSScriptRoot $sha = (& git -C $root rev-parse HEAD 2>$null); if ($LASTEXITCODE -ne 0) { return [pscustomobject]@{ Commit = $null; WorkingTree = 'NotAGitRepository' } } @@ -41,7 +83,7 @@ $check = if ($index.State -eq 'Indexed') { Invoke-SpecSetCheck -Index $index } e $state = if ($index.State -ne 'Indexed' -or $check.Unchecked.Count -gt 0) { 'NotEvaluated' } elseif ($check.Findings.Count -gt 0) { 'Invalid' } else { 'Valid' } $reason = if ($index.State -ne 'Indexed') { $index.Reason } elseif ($check.Unchecked.Count -gt 0) { $check.Unchecked[0].Reason } else { $null } $detail = if ($index.State -ne 'Indexed') { $index.Detail } elseif ($check.Unchecked.Count -gt 0) { $check.Unchecked[0].Detail } else { '' } -$result = [pscustomobject]@{ State = $state; Reason = $reason; Detail = $detail; Line = $index.Line; Documents = $index.Documents; Declarations = $index.Declarations; Findings = $check.Findings; Unchecked = $check.Unchecked; Counts = [pscustomobject]@{ Documents = $index.Documents.Count; Declarations = $index.Declarations.Count; Unchecked = $check.Unchecked.Count }; Commit = $git.Commit; WorkingTree = $git.WorkingTree } +$result = [pscustomobject]@{ State = $state; Reason = $reason; Detail = $detail; Line = $index.Line; Documents = $index.Documents; Declarations = $index.Declarations; Findings = $check.Findings; Unchecked = $check.Unchecked; Counts = [pscustomobject]@{ Documents = $index.Documents.Count; Declarations = $index.Declarations.Count; Unchecked = $check.Unchecked.Count; MirrorObligations = $index.MirrorObligations.Count }; Commit = $git.Commit; WorkingTree = $git.WorkingTree } if (-not $Quiet) { Write-SpecSetReport -Result $result } $result if ($MyInvocation.InvocationName -ne '.') { exit (Get-SpecSetExitCode -State $result.State) }