Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions docs/docs/games/03-game-design.md
Original file line number Diff line number Diff line change
Expand Up @@ -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: <!-- mirror-NeedState:declared:start -->health, energy, happiness, stress, satiety.<!-- mirror-NeedState:declared:end -->
- Attributes: <!-- mirror-AttributeState:declared:start -->intelligence, discipline, charisma, creativity, resilience, wisdom, luck.<!-- mirror-AttributeState:declared:end -->
- Education, career, housing.
- Inventory and relationships.
- Skills, traits, reputation, flags.
Expand Down
57 changes: 57 additions & 0 deletions tools/Read-SpecSet.Tests.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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: <!-- mirror-Foo:declared:start -->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.<!-- mirror-Foo:declared:end -->" -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`n<!-- mirror-Foo:declared:start -->a<!-- mirror-Bar:declared:start -->b<!-- mirror-Bar:declared:end --><!-- mirror-Foo:declared:end -->" -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: <!-- mirror-Foo:declared:start -->a<!-- mirror-Foo:declared:end -->`nTwo: <!-- mirror-Foo:declared:start -->b<!-- mirror-Foo:declared:end -->" -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<string, number>;
}
type PlayerState = ActorState;
```

Skills: <!-- mirror-PlayerState.skills:declared:start -->cooking<!-- mirror-PlayerState.skills:declared:end -->
'@
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
Expand Down
61 changes: 60 additions & 1 deletion tools/Read-SpecSet.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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]@{
Expand Down Expand Up @@ -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 = '<!--\s*(?<sid>[A-Za-z][\w.-]*):declared:start\s*-->|<!--\s*(?<eid>[A-Za-z][\w.-]*):declared:end\s*-->'
$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)
Expand All @@ -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 }
Expand Down Expand Up @@ -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 = @() }
}
118 changes: 118 additions & 0 deletions tools/Test-SpecSet.Tests.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, number>;
}
type PlayerState = ActorState;
```

Skills: <!-- mirror-PlayerState.skills:declared:start -->cooking<!-- mirror-PlayerState.skills:declared:end -->
'@
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: <!-- mirror-GhostState:declared:start -->nothing here<!-- mirror-GhostState:declared:end -->"
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'
}
}
Loading
Loading