diff --git a/Tools/windows/Test-CurrentThemeContract.ps1 b/Tools/windows/Test-CurrentThemeContract.ps1 new file mode 100644 index 00000000..4078c015 --- /dev/null +++ b/Tools/windows/Test-CurrentThemeContract.ps1 @@ -0,0 +1,261 @@ +# Static source-drift check for investigation/visual-baseline/manifest.json's +# currentThemeContract section. Re-derives color tokens from the actual +# Theme.swift/DesignTokens.zig text on disk and asserts an exact (zero-tolerance) +# match. This file is dot-sourced by visual-baseline.ps1 for the real static run, +# and invoked directly (as its own pwsh process) by +# Tools\windows\Tests\VisualBaseline.Tests.ps1 against fixture files, so tests +# exercise this exact production code -- never a re-declared copy. +[CmdletBinding()] +param( + [Parameter(Mandatory)] [string] $ManifestPath, + [Parameter(Mandatory)] [string] $ThemeSwiftPath, + [Parameter(Mandatory)] [string] $DesignTokensPath +) + +$ErrorActionPreference = "Stop" + +# The two tokens this contract currently tracks. Exact identity/cardinality is +# enforced below so an empty or partial token list cannot pass vacuously. +$script:RequiredThemeContractTokenNames = @("Theme.canvasTone", "Theme.canvasGridLine") + +# Each required token must map to exactly this Windows constant name; the +# cross-check below is mandatory, not opt-in via a field that could be left +# blank or removed to silently disable it. +$script:RequiredWindowsTokenByThemeToken = @{ + "Theme.canvasTone" = "canvas_tone" + "Theme.canvasGridLine" = "canvas_grid_line" +} + +function ConvertTo-Rgb8Channel([double] $Channel) { + # Round-half-up: floor(x*255 + 0.5), clamped to [0,255]. + $clamped = [Math]::Max(0.0, [Math]::Min(1.0, $Channel)) + return [int] [Math]::Floor(($clamped * 255.0) + 0.5) +} + +function Remove-LineComments([string] $Text, [string] $CommentToken) { + # Strips "// ..." (or the given token) from each line so a commented-out, + # obsolete declaration is never matched as if it were active. + return (($Text -split "`r?`n" | ForEach-Object { + $idx = $_.IndexOf($CommentToken) + if ($idx -ge 0) { $_.Substring(0, $idx) } else { $_ } + }) -join "`n") +} + +function Remove-SwiftComments([string] $Text) { + # Strips "// ..." per line first, then "/* ... */" blocks (which may span + # multiple lines) so a declaration commented out either way is treated as + # absent, never matched as active. This is a minimal, explicit grammar, not + # a general Swift parser: if a "/*" or "*/" marker survives both passes + # (e.g. an unterminated block comment), fail explicitly rather than risk + # silently validating or silently ignoring a declaration. + $lineStripped = Remove-LineComments $Text "//" + $blockStripped = [regex]::Replace($lineStripped, "(?s)/\*.*?\*/", "") + if ($blockStripped -match "/\*" -or $blockStripped -match "\*/") { + throw "Theme.swift contains an unterminated or unsupported block-comment delimiter ('/*' or '*/') that this checker's minimal comment grammar cannot safely parse" + } + return $blockStripped +} + +function Get-ThemeSwiftTokenRgb([string] $ThemeText, [string] $TokenName) { + $active = Remove-SwiftComments $ThemeText + $pattern = "static let $([regex]::Escape($TokenName))\s*=\s*Color\(red:\s*([0-9.]+),\s*green:\s*([0-9.]+),\s*blue:\s*([0-9.]+)\)([^\n]*)" + $found = [regex]::Matches($active, $pattern) + if ($found.Count -eq 0) { + throw "Theme.swift no longer defines an active Color(red:green:blue:) literal for token: $TokenName" + } + if ($found.Count -gt 1) { + throw "Theme.swift defines $($found.Count) active Color(red:green:blue:) literals for token: $TokenName (ambiguous)" + } + $match = $found[0] + $trailing = $match.Groups[4].Value.Trim() + if ($trailing.Length -gt 0) { + # e.g. a trailing ".opacity(0.5)" would silently change the effective color; + # this contract only supports a bare opaque literal, so reject instead of + # ignoring the suffix. + throw "Theme.swift token $TokenName has an unsupported trailing expression after its Color(...) literal: '$trailing' -- only a bare opaque Color(red:green:blue:) literal is supported" + } + return @( + (ConvertTo-Rgb8Channel ([double] $match.Groups[1].Value)), + (ConvertTo-Rgb8Channel ([double] $match.Groups[2].Value)), + (ConvertTo-Rgb8Channel ([double] $match.Groups[3].Value)) + ) +} + +function Get-DesignTokenColorref([string] $DesignTokensText, [string] $TokenName) { + $active = Remove-LineComments $DesignTokensText "//" + $pattern = "pub const $([regex]::Escape($TokenName)):\s*Color\s*=\s*(0x[0-9A-Fa-f]+)\s*;" + $found = [regex]::Matches($active, $pattern) + if ($found.Count -eq 0) { + throw "DesignTokens.zig no longer defines an active Color constant: $TokenName" + } + if ($found.Count -gt 1) { + throw "DesignTokens.zig defines $($found.Count) active Color constants for: $TokenName (ambiguous)" + } + return [Convert]::ToInt32($found[0].Groups[1].Value, 16) +} + +function ConvertTo-IntegralRgbChannel([object] $Value, [string] $TokenName, [int] $ChannelIndex) { + # Reject before any [int] coercion would silently round a fraction or parse + # a string: a recorded channel must already be a whole number. + if ($null -eq $Value) { + throw "currentThemeContract token $TokenName has a null RGB channel value at index $ChannelIndex" + } + if ($Value -is [string] -or $Value -is [bool]) { + throw "currentThemeContract token $TokenName has a non-numeric RGB channel value at index $ChannelIndex`: '$Value'" + } + $asDouble = [double] $Value + if ($asDouble -ne [Math]::Truncate($asDouble)) { + throw "currentThemeContract token $TokenName has a fractional (non-integral) RGB channel value at index $ChannelIndex`: $Value" + } + return [int] $asDouble +} + +function ConvertFrom-Colorref([int] $Colorref) { + # Win32 COLORREF packs 0x00BBGGRR, the reverse of what a hex literal like this + # superficially resembles. + $r = $Colorref -band 0xFF + $g = ($Colorref -shr 8) -band 0xFF + $b = ($Colorref -shr 16) -band 0xFF + return @($r, $g, $b) +} + +function Get-NormalizedTextSha256([string] $Text) { + # CRLF-normalized to LF before hashing, so this does not depend on the + # checkout's line-ending config (core.autocrlf) or on any historical git object. + $normalized = $Text -replace "`r`n", "`n" + $bytes = [System.Text.Encoding]::UTF8.GetBytes($normalized) + $sha256 = [System.Security.Cryptography.SHA256]::Create() + try { + $hash = $sha256.ComputeHash($bytes) + return (($hash | ForEach-Object { $_.ToString("x2") }) -join "") + } finally { + $sha256.Dispose() + } +} + +function Test-CurrentThemeContract { + param( + [Parameter(Mandatory)] [object] $Manifest, + [Parameter(Mandatory)] [string] $ThemeSwiftText, + [Parameter(Mandatory)] [string] $DesignTokensText, + [string] $ThemeSwiftPathForDiagnostics = "Theme.swift", + [string] $DesignTokensPathForDiagnostics = "DesignTokens.zig" + ) + + $contract = $Manifest.currentThemeContract + if ($null -eq $contract) { throw "currentThemeContract section is missing from the manifest" } + if ($contract.schemaVersion -ne 1) { + throw "currentThemeContract.schemaVersion must be 1, got $($contract.schemaVersion)" + } + if ($contract.supersedes -ne "tokenContracts") { + throw "currentThemeContract must declare it supersedes tokenContracts, not replace it" + } + + $tokens = @($contract.tokens) + if ($tokens.Count -eq 0) { + throw "currentThemeContract.tokens must not be empty" + } + + $seenNames = New-Object 'System.Collections.Generic.HashSet[string]' + foreach ($token in $tokens) { + if (-not $seenNames.Add([string] $token.name)) { + throw "currentThemeContract.tokens contains a duplicate token name: $($token.name)" + } + } + $actualNames = [string[]] (($tokens | ForEach-Object { [string] $_.name }) | Sort-Object) + $expectedNames = [string[]] ($script:RequiredThemeContractTokenNames | Sort-Object) + if (($actualNames -join "|") -ne ($expectedNames -join "|")) { + throw ("currentThemeContract.tokens must contain exactly {" + ($script:RequiredThemeContractTokenNames -join ", ") + + "}, got {" + ($actualNames -join ", ") + "}") + } + + foreach ($token in $tokens) { + $rawRgb = @($token.rgb) + if ($rawRgb.Count -ne 3) { + throw "currentThemeContract token $($token.name) must record exactly 3 RGB channel values, got $($rawRgb.Count)" + } + $recordedRgb = @() + for ($i = 0; $i -lt $rawRgb.Count; $i++) { + $recordedRgb += (ConvertTo-IntegralRgbChannel $rawRgb[$i] $token.name $i) + } + foreach ($channel in $recordedRgb) { + if ($channel -lt 0 -or $channel -gt 255) { + throw "currentThemeContract token $($token.name) has an out-of-range RGB channel value: $channel" + } + } + if ($token.hex -notmatch "^#[0-9A-Fa-f]{6}$") { + throw "currentThemeContract token $($token.name) has a malformed hex field: $($token.hex)" + } + $expectedHex = "#{0:X2}{1:X2}{2:X2}" -f $recordedRgb[0], $recordedRgb[1], $recordedRgb[2] + if ($token.hex.ToUpperInvariant() -ne $expectedHex) { + throw "currentThemeContract token $($token.name) hex field ($($token.hex)) does not match its recorded rgb ($($recordedRgb -join ','))" + } + + $shortName = $token.name -replace "^Theme\.", "" + $derivedRgb = Get-ThemeSwiftTokenRgb $ThemeSwiftText $shortName + if (($derivedRgb -join ",") -ne ($recordedRgb -join ",")) { + throw ("currentThemeContract drift detected for $($token.name): Theme.swift ($ThemeSwiftPathForDiagnostics, " + + "line ~$($token.sourceLine)) now derives RGB($($derivedRgb -join ',')) but the manifest still " + + "records RGB($($recordedRgb -join ',')). If this is an intentional design change, update " + + "currentThemeContract's rgb/hex/swiftLiteral/themeSwiftBlobSha256; otherwise this is a real regression.") + } + + # Mandatory: every required token must map to its exact required Windows + # constant name. A blank/missing/wrong-mapped windowsToken field fails + # outright rather than silently skipping the cross-source check. + $expectedWindowsToken = $script:RequiredWindowsTokenByThemeToken[$token.name] + $actualWindowsToken = [string] $token.windowsToken + if ([string]::IsNullOrWhiteSpace($actualWindowsToken)) { + throw "currentThemeContract token $($token.name) is missing its required windowsToken mapping (expected '$expectedWindowsToken')" + } + if ($actualWindowsToken -ne $expectedWindowsToken) { + throw "currentThemeContract token $($token.name) has windowsToken '$actualWindowsToken' but the required mapping is '$expectedWindowsToken'" + } + + # Genuine cross-source check: an actual Windows constant, independently + # decoded, compared against the Swift-derived expectation above -- not a + # manifest constant compared against another manifest constant. + $designColorref = Get-DesignTokenColorref $DesignTokensText $actualWindowsToken + $decoded = ConvertFrom-Colorref $designColorref + if (($decoded -join ",") -ne ($recordedRgb -join ",")) { + throw ("DesignTokens.zig ($DesignTokensPathForDiagnostics) $actualWindowsToken decodes to RGB(" + + "$($decoded -join ',')) but Theme.swift ($ThemeSwiftPathForDiagnostics) $shortName derives RGB(" + + "$($recordedRgb -join ',')) -- the Windows and macOS sources have drifted apart.") + } + } + + # Whole-file provenance check, last: any per-token literal mismatch above is a + # more specific and actionable diagnostic than this, so it must win first. This + # catches an approved-blob change that a per-token regex would not notice (e.g. + # an edit elsewhere in the file, or to an untracked token). + if ([string]::IsNullOrWhiteSpace([string] $contract.themeSwiftBlobSha256)) { + throw "currentThemeContract.themeSwiftBlobSha256 is missing" + } + $actualBlobHash = Get-NormalizedTextSha256 $ThemeSwiftText + if ($actualBlobHash -ne $contract.themeSwiftBlobSha256) { + throw ("Theme.swift ($ThemeSwiftPathForDiagnostics) no longer matches the approved blob hash: " + + "recorded $($contract.themeSwiftBlobSha256), actual $actualBlobHash (SHA-256 over LF-normalized text)") + } +} + +# Self-checks for the helpers above: worked examples, run on every invocation. +if ((ConvertTo-Rgb8Channel 0.040) -ne 10) { throw "rounding reimplementation drifted at 0.040" } +if ((ConvertTo-Rgb8Channel 0.048) -ne 12) { throw "rounding reimplementation drifted at 0.048" } +if ((ConvertTo-Rgb8Channel 0.044) -ne 11) { throw "rounding reimplementation drifted at 0.044" } +$colorrefWorkedExample = ConvertFrom-Colorref 0x00161815 +if (($colorrefWorkedExample -join ",") -ne "21,24,22") { + throw "ConvertFrom-Colorref byte order is wrong: expected 0x00161815 -> RGB(21,24,22), got $($colorrefWorkedExample -join ',')" +} + +if (-not (Test-Path -LiteralPath $ManifestPath)) { throw "manifest is missing: $ManifestPath" } +if (-not (Test-Path -LiteralPath $ThemeSwiftPath)) { throw "Theme.swift is missing: $ThemeSwiftPath" } +if (-not (Test-Path -LiteralPath $DesignTokensPath)) { throw "DesignTokens.zig is missing: $DesignTokensPath" } + +$manifest = Get-Content -LiteralPath $ManifestPath -Raw | ConvertFrom-Json +$themeSwiftText = Get-Content -LiteralPath $ThemeSwiftPath -Raw +$designTokensText = Get-Content -LiteralPath $DesignTokensPath -Raw + +Test-CurrentThemeContract -Manifest $manifest -ThemeSwiftText $themeSwiftText -DesignTokensText $designTokensText ` + -ThemeSwiftPathForDiagnostics $ThemeSwiftPath -DesignTokensPathForDiagnostics $DesignTokensPath + +Write-Output "currentThemeContract: PASS" diff --git a/Tools/windows/Tests/VisualBaseline.Tests.ps1 b/Tools/windows/Tests/VisualBaseline.Tests.ps1 index dea0329d..1867302b 100644 --- a/Tools/windows/Tests/VisualBaseline.Tests.ps1 +++ b/Tools/windows/Tests/VisualBaseline.Tests.ps1 @@ -2,10 +2,14 @@ $ErrorActionPreference = "Stop" $repoRoot = Resolve-Path (Join-Path $PSScriptRoot "..\..\..") $validator = Join-Path $repoRoot "Tools\windows\visual-baseline.ps1" +$themeContractValidator = Join-Path $repoRoot "Tools\windows\Test-CurrentThemeContract.ps1" if (-not (Test-Path -LiteralPath $validator)) { throw "RED: visual baseline validator is missing at $validator" } +if (-not (Test-Path -LiteralPath $themeContractValidator)) { + throw "RED: currentThemeContract validator is missing at $themeContractValidator" +} $output = & $validator if ($LASTEXITCODE -ne 0) { @@ -15,5 +19,308 @@ if (($output -join "`n") -notmatch "Visual baseline: PASS") { throw "Visual baseline validator did not report PASS" } +# --- currentThemeContract regression coverage -------------------------------- +# +# These tests invoke Tools\windows\Test-CurrentThemeContract.ps1 itself, as a real +# subprocess, against temporary fixture files -- never a re-declared copy of its +# logic. Because it is the exact production comparator, deleting or breaking the +# real drift assertion in that file would make these tests fail too, not silently +# keep passing. + +function New-ThemeContractFixture { + param( + [Parameter(Mandatory)] [string] $ThemeSwiftText, + [Parameter(Mandatory)] [string] $DesignTokensText, + [Parameter(Mandatory)] [hashtable] $ManifestObject + ) + $dir = Join-Path ([IO.Path]::GetTempPath()) "graphcode-theme-contract-$([guid]::NewGuid())" + New-Item -ItemType Directory -Path $dir | Out-Null + $themePath = Join-Path $dir "Theme.swift" + $designPath = Join-Path $dir "DesignTokens.zig" + $manifestPath = Join-Path $dir "manifest.json" + Set-Content -LiteralPath $themePath -Value $ThemeSwiftText -NoNewline + Set-Content -LiteralPath $designPath -Value $DesignTokensText -NoNewline + ($ManifestObject | ConvertTo-Json -Depth 10) | Set-Content -LiteralPath $manifestPath -NoNewline + return [pscustomobject]@{ + Dir = $dir; ThemePath = $themePath; DesignPath = $designPath; ManifestPath = $manifestPath + } +} + +function Invoke-ThemeContractValidator { + param( + [Parameter(Mandatory)] [string] $ManifestPath, + [Parameter(Mandatory)] [string] $ThemeSwiftPath, + [Parameter(Mandatory)] [string] $DesignTokensPath + ) + $captured = & pwsh -NoProfile -File $themeContractValidator ` + -ManifestPath $ManifestPath -ThemeSwiftPath $ThemeSwiftPath -DesignTokensPath $DesignTokensPath 2>&1 + return [pscustomobject]@{ ExitCode = $LASTEXITCODE; Text = ($captured | Out-String) } +} + +function Assert-Fails([object] $Result, [string] $ExpectedSubstring, [string] $ScenarioName) { + if ($Result.ExitCode -eq 0) { + throw "RED ($ScenarioName): expected a nonzero exit code but got 0. Output: $($Result.Text)" + } + if ($Result.Text -notlike "*$ExpectedSubstring*") { + throw "RED ($ScenarioName): expected diagnostic containing '$ExpectedSubstring', got: $($Result.Text)" + } +} + +function Assert-Passes([object] $Result, [string] $ScenarioName) { + if ($Result.ExitCode -ne 0) { + throw "RED ($ScenarioName): expected exit code 0 (restored/PASS) but got $($Result.ExitCode). Output: $($Result.Text)" + } + if ($Result.Text -notlike "*currentThemeContract: PASS*") { + throw "RED ($ScenarioName): expected 'currentThemeContract: PASS', got: $($Result.Text)" + } +} + +$goodThemeSwift = @" +enum Theme { + static let canvasTone = Color(red: 0.040, green: 0.048, blue: 0.044) + static let canvasGridLine = Color(red: 0.082, green: 0.094, blue: 0.086) +} +"@ + +$goodDesignTokens = @" +pub const Color = u32; +pub const canvas_tone: Color = 0x000B0C0A; +pub const canvas_grid_line: Color = 0x00161815; +"@ + +function New-GoodManifestObject([string] $ThemeSwiftBlobSha256) { + return @{ + currentThemeContract = @{ + schemaVersion = 1 + supersedes = "tokenContracts" + themeSwiftBlobSha256 = $ThemeSwiftBlobSha256 + tokens = @( + @{ name = "Theme.canvasTone"; sourceLine = 2; rgb = @(10, 12, 11); hex = "#0A0C0B"; windowsToken = "canvas_tone" } + @{ name = "Theme.canvasGridLine"; sourceLine = 3; rgb = @(21, 24, 22); hex = "#151816"; windowsToken = "canvas_grid_line" } + ) + } + } +} + +# The good fixture's own approved blob hash: this is fixture setup data (what +# hash the checked-in-below Theme.swift text ought to have), not a copy of the +# validator's comparison -- the validator (Test-CurrentThemeContract.ps1) is what +# actually re-derives and checks this hash against $fixture.ThemePath below. +function Get-Sha256HexOfLfNormalizedText([string] $Text) { + $normalized = $Text -replace "`r`n", "`n" + $bytes = [System.Text.Encoding]::UTF8.GetBytes($normalized) + $sha256 = [System.Security.Cryptography.SHA256]::Create() + try { + $hash = $sha256.ComputeHash($bytes) + return (($hash | ForEach-Object { $_.ToString("x2") }) -join "") + } finally { + $sha256.Dispose() + } +} +$goodHash = Get-Sha256HexOfLfNormalizedText $goodThemeSwift + +$fixture = $null +try { + +# Baseline fixture with the real hash: must pass outright. +$fixture = New-ThemeContractFixture -ThemeSwiftText $goodThemeSwift -DesignTokensText $goodDesignTokens ` + -ManifestObject (New-GoodManifestObject $goodHash) +Assert-Passes (Invoke-ThemeContractValidator -ManifestPath $fixture.ManifestPath ` + -ThemeSwiftPath $fixture.ThemePath -DesignTokensPath $fixture.DesignPath) "baseline fixture" + +function Test-ThemeSwiftMutation { + param([string] $MutatedThemeSwift, [string] $ExpectedSubstring, [string] $ScenarioName) + Set-Content -LiteralPath $fixture.ThemePath -Value $MutatedThemeSwift -NoNewline + Assert-Fails (Invoke-ThemeContractValidator -ManifestPath $fixture.ManifestPath ` + -ThemeSwiftPath $fixture.ThemePath -DesignTokensPath $fixture.DesignPath) $ExpectedSubstring $ScenarioName + Set-Content -LiteralPath $fixture.ThemePath -Value $goodThemeSwift -NoNewline + Assert-Passes (Invoke-ThemeContractValidator -ManifestPath $fixture.ManifestPath ` + -ThemeSwiftPath $fixture.ThemePath -DesignTokensPath $fixture.DesignPath) "$ScenarioName (restored)" +} + +function Test-DesignTokensMutation { + param([string] $MutatedDesignTokens, [string] $ExpectedSubstring, [string] $ScenarioName) + Set-Content -LiteralPath $fixture.DesignPath -Value $MutatedDesignTokens -NoNewline + Assert-Fails (Invoke-ThemeContractValidator -ManifestPath $fixture.ManifestPath ` + -ThemeSwiftPath $fixture.ThemePath -DesignTokensPath $fixture.DesignPath) $ExpectedSubstring $ScenarioName + Set-Content -LiteralPath $fixture.DesignPath -Value $goodDesignTokens -NoNewline + Assert-Passes (Invoke-ThemeContractValidator -ManifestPath $fixture.ManifestPath ` + -ThemeSwiftPath $fixture.ThemePath -DesignTokensPath $fixture.DesignPath) "$ScenarioName (restored)" +} + +function Test-ManifestMutation { + param([hashtable] $MutatedManifestObject, [string] $ExpectedSubstring, [string] $ScenarioName) + ($MutatedManifestObject | ConvertTo-Json -Depth 10) | Set-Content -LiteralPath $fixture.ManifestPath -NoNewline + Assert-Fails (Invoke-ThemeContractValidator -ManifestPath $fixture.ManifestPath ` + -ThemeSwiftPath $fixture.ThemePath -DesignTokensPath $fixture.DesignPath) $ExpectedSubstring $ScenarioName + ((New-GoodManifestObject $goodHash) | ConvertTo-Json -Depth 10) | Set-Content -LiteralPath $fixture.ManifestPath -NoNewline + Assert-Passes (Invoke-ThemeContractValidator -ManifestPath $fixture.ManifestPath ` + -ThemeSwiftPath $fixture.ThemePath -DesignTokensPath $fixture.DesignPath) "$ScenarioName (restored)" +} + +# 1) Theme.swift mutation: a changed literal must produce the specific per-token +# drift diagnostic, not merely "something is wrong". +Test-ThemeSwiftMutation ` + ($goodThemeSwift -replace "0\.040", "0.095") ` + "currentThemeContract drift detected for Theme.canvasTone" ` + "Theme.swift literal mutated" + +# 2) Manifest mutation (Theme.swift untouched): the other direction of the same +# drift check, with its own internally-consistent (but wrong) rgb/hex so the +# hex-consistency check does not mask it. +$manifestWithWrongGridLine = New-GoodManifestObject $goodHash +$manifestWithWrongGridLine.currentThemeContract.tokens[1].rgb = @(21, 25, 22) +$manifestWithWrongGridLine.currentThemeContract.tokens[1].hex = "#151916" +Test-ManifestMutation $manifestWithWrongGridLine ` + "currentThemeContract drift detected for Theme.canvasGridLine" ` + "manifest rgb/hex mutated away from Theme.swift" + +# 2b) Recorded blob hash itself is wrong (Theme.swift and tokens both untouched +# and correct): the whole-file provenance check must independently reject it. +$manifestWithWrongHash = New-GoodManifestObject ("0" * 64) +Test-ManifestMutation $manifestWithWrongHash "no longer matches the approved blob hash" "recorded blob hash wrong" + +# 3) Empty token list must not pass vacuously. +$manifestWithNoTokens = New-GoodManifestObject $goodHash +$manifestWithNoTokens.currentThemeContract.tokens = @() +Test-ManifestMutation $manifestWithNoTokens "tokens must not be empty" "empty token list" + +# 4) Missing a required token (wrong cardinality/identity). +$manifestWithOneToken = New-GoodManifestObject $goodHash +$manifestWithOneToken.currentThemeContract.tokens = @($manifestWithOneToken.currentThemeContract.tokens[0]) +Test-ManifestMutation $manifestWithOneToken "must contain exactly" "required token missing" + +# 5) Duplicate token identity. +$manifestWithDuplicateToken = New-GoodManifestObject $goodHash +$manifestWithDuplicateToken.currentThemeContract.tokens = @( + $manifestWithDuplicateToken.currentThemeContract.tokens[0], + $manifestWithDuplicateToken.currentThemeContract.tokens[0] +) +Test-ManifestMutation $manifestWithDuplicateToken "duplicate token name" "duplicate token" + +# 6) RGB shape violation (wrong channel count). +$manifestWithBadShape = New-GoodManifestObject $goodHash +$manifestWithBadShape.currentThemeContract.tokens[0].rgb = @(10, 12) +Test-ManifestMutation $manifestWithBadShape "must record exactly 3 RGB channel values" "RGB wrong channel count" + +# 7) RGB range violation (out of 0..255). +$manifestWithBadRange = New-GoodManifestObject $goodHash +$manifestWithBadRange.currentThemeContract.tokens[0].rgb = @(10, 12, 300) +Test-ManifestMutation $manifestWithBadRange "out-of-range RGB channel value" "RGB out of range" + +# 8) A commented-out, obsolete declaration must not be silently validated: comment +# out the only active definition and confirm it is treated as absent, not as a +# match. +$themeSwiftWithCommentedToken = @" +enum Theme { + // static let canvasTone = Color(red: 0.040, green: 0.048, blue: 0.044) + static let canvasGridLine = Color(red: 0.082, green: 0.094, blue: 0.086) +} +"@ +Test-ThemeSwiftMutation $themeSwiftWithCommentedToken ` + "no longer defines an active Color(red:green:blue:) literal for token: canvasTone" ` + "commented-out declaration ignored" + +# 9) An opacity suffix on the literal must be rejected outright, not silently +# validated against only the base RGB it wraps. +$themeSwiftWithOpacitySuffix = $goodThemeSwift -replace ` + "Color\(red: 0\.040, green: 0\.048, blue: 0\.044\)", ` + "Color(red: 0.040, green: 0.048, blue: 0.044).opacity(0.5)" +Test-ThemeSwiftMutation $themeSwiftWithOpacitySuffix ` + "unsupported trailing expression" ` + "opacity suffix rejected" + +# 10) Windows/macOS cross-source mismatch: DesignTokens.zig's real constant must +# independently agree with Theme.swift, not merely echo a manifest constant. +Test-DesignTokensMutation ` + ($goodDesignTokens -replace "0x00161815", "0x00161915") ` + "the Windows and macOS sources have drifted apart" ` + "DesignTokens.zig cross-check mismatch" + +# 11) Whole-file blob hash catches a change a per-token regex would miss (an +# addition that touches neither tracked literal). +$themeSwiftWithUnrelatedEdit = "// unrelated added comment`n" + $goodThemeSwift +Test-ThemeSwiftMutation $themeSwiftWithUnrelatedEdit ` + "no longer matches the approved blob hash" ` + "unrelated edit caught by blob hash" + +# 12) windowsToken is mandatory: blank/missing must fail outright, not silently +# skip the Windows/macOS cross-check. +$manifestWithBlankWindowsToken = New-GoodManifestObject $goodHash +$manifestWithBlankWindowsToken.currentThemeContract.tokens[0].windowsToken = "" +Test-ManifestMutation $manifestWithBlankWindowsToken ` + "is missing its required windowsToken mapping" ` + "windowsToken blank" + +$manifestWithRemovedWindowsToken = New-GoodManifestObject $goodHash +$manifestWithRemovedWindowsToken.currentThemeContract.tokens[0].Remove("windowsToken") +Test-ManifestMutation $manifestWithRemovedWindowsToken ` + "is missing its required windowsToken mapping" ` + "windowsToken field removed" + +# 13) windowsToken present but mapped to the wrong constant name must also fail +# outright, not silently cross-check against an unrelated constant. +$manifestWithWrongWindowsTokenMap = New-GoodManifestObject $goodHash +$manifestWithWrongWindowsTokenMap.currentThemeContract.tokens[0].windowsToken = "canvas_grid_line" +Test-ManifestMutation $manifestWithWrongWindowsTokenMap ` + "has windowsToken 'canvas_grid_line' but the required mapping is" ` + "windowsToken wrong mapping" + +# 14) schemaVersion must be exactly 1. +$manifestWithWrongSchemaVersion = New-GoodManifestObject $goodHash +$manifestWithWrongSchemaVersion.currentThemeContract.schemaVersion = 2 +Test-ManifestMutation $manifestWithWrongSchemaVersion ` + "schemaVersion must be 1" ` + "wrong schemaVersion" + +# 15) A fractional recorded RGB channel must be rejected before any [int] +# coercion would silently round it. +$manifestWithFractionalChannel = New-GoodManifestObject $goodHash +$manifestWithFractionalChannel.currentThemeContract.tokens[0].rgb = @(10, 12, 11.5) +Test-ManifestMutation $manifestWithFractionalChannel ` + "has a fractional (non-integral) RGB channel value" ` + "fractional RGB channel" + +# 16) A null recorded RGB channel must be rejected before any [int] coercion +# would silently convert it to 0. +$manifestWithNullChannel = New-GoodManifestObject $goodHash +$manifestWithNullChannel.currentThemeContract.tokens[0].rgb = @(10, 12, $null) +Test-ManifestMutation $manifestWithNullChannel ` + "has a null RGB channel value" ` + "null RGB channel" + +# 17) A declaration commented out via a Swift block comment ("/* ... */", +# possibly spanning multiple lines) must be treated as absent, same as a +# line-commented one -- not matched as if it were active. +$themeSwiftWithBlockCommentedToken = @" +enum Theme { + /* static let canvasTone = + Color(red: 0.040, green: 0.048, blue: 0.044) */ + static let canvasGridLine = Color(red: 0.082, green: 0.094, blue: 0.086) +} +"@ +Test-ThemeSwiftMutation $themeSwiftWithBlockCommentedToken ` + "no longer defines an active Color(red:green:blue:) literal for token: canvasTone" ` + "block-commented-out declaration ignored" + +# 18) An unterminated block comment is unsupported syntax for this minimal +# grammar; it must fail explicitly rather than silently misparse the rest +# of the file as active or as commented out. +$themeSwiftWithUnterminatedBlockComment = @" +enum Theme { + /* static let canvasTone = Color(red: 0.040, green: 0.048, blue: 0.044) + static let canvasGridLine = Color(red: 0.082, green: 0.094, blue: 0.086) +} +"@ +Test-ThemeSwiftMutation $themeSwiftWithUnterminatedBlockComment ` + "unterminated or unsupported block-comment delimiter" ` + "unterminated block comment rejected" + +} finally { + if ($fixture) { + Remove-Item -LiteralPath $fixture.Dir -Recurse -Force -ErrorAction SilentlyContinue + } +} + Write-Host "VisualBaseline.Tests.ps1: PASS" exit 0 diff --git a/Tools/windows/visual-baseline.ps1 b/Tools/windows/visual-baseline.ps1 index d6904f92..307eae78 100644 --- a/Tools/windows/visual-baseline.ps1 +++ b/Tools/windows/visual-baseline.ps1 @@ -285,5 +285,18 @@ foreach ($snapshot in @($manifest.terminalSnapshots)) { "terminal snapshot contains an environment-specific path: $($snapshot.id)" } +# --- currentThemeContract: static, zero-tolerance source-drift check --------- +# +# Re-derives color tokens straight from the current-worktree Theme.swift/ +# DesignTokens.zig on disk (not a historical git blob) and asserts an exact +# match against manifest.currentThemeContract, distinct from and additive to +# the frozen historical tokenContracts/baseCommit checked above. See +# Test-CurrentThemeContract.ps1 for the actual comparator; it is dot-sourced +# here (rather than duplicated) so this run and VisualBaseline.Tests.ps1's +# fixture-based tests exercise the identical production code. +$themeSwiftPath = Join-Path $repoRoot "graphcode\Sources\Features\App\Theme.swift" +. (Join-Path $PSScriptRoot "Test-CurrentThemeContract.ps1") ` + -ManifestPath $manifestPath -ThemeSwiftPath $themeSwiftPath -DesignTokensPath $designTokensPath + Write-Output "Visual baseline: PASS" exit 0 diff --git a/investigation/visual-baseline/README.md b/investigation/visual-baseline/README.md index 7fc5a5bc..e428bf77 100644 --- a/investigation/visual-baseline/README.md +++ b/investigation/visual-baseline/README.md @@ -28,3 +28,52 @@ The GraphCode-owned regions are safe for screenshot comparison. Terminal renderi input, IME, clipboard, resize, and accessibility remain live Winghostty functional tests; the text files in `fixtures` are only stable placeholders for testing workspace layout and split ownership. + +## Static command + +```powershell +pwsh Tools/windows/visual-baseline.ps1 +``` + +Static, no-launch validator: it never builds or launches `graphcode-windows.exe`, +only reads source and manifest files already in the checkout. Exits non-zero with +a specific diagnostic on any contract violation, or prints `Visual baseline: +PASS`. Must be run with `pwsh` (PowerShell 7), not Windows PowerShell 5.1's +`powershell.exe` — an unrelated, pre-existing check elsewhere in this script +fails under Desktop edition regardless of this contract. + +## currentThemeContract + +`tokenContracts`/`baseCommit` above is an immutable historical pin at commit +`ece55b6` and is never edited. `currentThemeContract` is a separate, additive +section that instead tracks *today's* `graphcode/Sources/Features/App/Theme.swift`, +re-derived from the file on disk every run. + +`Tools\windows\Test-CurrentThemeContract.ps1` is the actual comparator. It is +dot-sourced by `visual-baseline.ps1` for the real run, and invoked directly (same +file, as its own process) by `Tools\windows\Tests\VisualBaseline.Tests.ps1` +against fixture files — tests exercise this exact production code, not a +re-declared copy. For each of the two tracked tokens it enforces: + +- `schemaVersion` is exactly `1`; exact token identity, cardinality, and no + duplicates (an empty or partial token list fails, it does not pass vacuously) +- RGB shape (3 channels), that each channel is an integral number in `0..255` + (a fractional or null recorded channel is rejected before any lossy `[int]` + coercion), and that `hex` matches `rgb` +- zero-tolerance equality between the token's recorded `rgb` and the RGB + derived from Theme.swift's active `Color(red:green:blue:)` literal; a + declaration commented out with `//` or a `/* ... */` block, or one with a + trailing expression such as `.opacity(...)`, is rejected rather than + silently matched or accepted -- an unterminated block comment is likewise an + explicit failure, not a silent misparse +- a whole-file SHA-256 (`themeSwiftBlobSha256`, CRLF normalized to LF) against + the approved Theme.swift blob, without requiring any historical git object +- a mandatory `windowsToken` mapping to the real + `graphcode-windows/src/DesignTokens.zig` `Color` constant, decoded from its + Win32 `COLORREF` (`0x00BBGGRR`) byte order and compared to the same + Theme.swift-derived RGB -- an independent macOS-vs-Windows source + cross-check, not one manifest constant compared against another; a blank, + missing, or wrongly-mapped `windowsToken` field fails outright + +This is a source-contract check only. It does not capture or compare live +screenshot pixels; no live/launch mode exists in this script. diff --git a/investigation/visual-baseline/manifest.json b/investigation/visual-baseline/manifest.json index 3f9b1591..5472c894 100644 --- a/investigation/visual-baseline/manifest.json +++ b/investigation/visual-baseline/manifest.json @@ -22,6 +22,41 @@ "graphcode/Sources/Features/App/ProjectHeader.swift", "investigation/ui-parity-matrix.md" ], + "currentThemeContract": { + "_comment": "Additive, separately-versioned. Distinct from the immutable historical tokenContracts/baseCommit (pinned at ece55b6, never edited to 'fix' drift). Tracks today's graphcode/Sources/Features/App/Theme.swift, re-derived and re-asserted every run by Tools/windows/Test-CurrentThemeContract.ps1 against the worktree file on disk (no historical git blob dependency).", + "schemaVersion": 1, + "supersedes": "tokenContracts", + "comparedAgainstBaseCommit": "ece55b6", + "roundingRule": "round-half-up per channel: floor(channel * 255 + 0.5), clamped to [0, 255]", + "recordedAtCommit": "a65bd3e31fac8e5f224e478fbce0b29db5fd2032", + "themeSwiftPath": "graphcode/Sources/Features/App/Theme.swift", + "themeSwiftBlobSha256": "fd7f8d3140239019a9c0eaec614347eb64ec3db9ef496e4eb31882c821d29e06", + "themeSwiftBlobHashRule": "SHA-256 over the file's current text with CRLF normalized to LF before hashing.", + "tokens": [ + { + "name": "Theme.canvasTone", + "path": "graphcode/Sources/Features/App/Theme.swift", + "sourceLine": 54, + "swiftLiteral": "Color(red: 0.040, green: 0.048, blue: 0.044)", + "rgb": [10, 12, 11], + "hex": "#0A0C0B", + "opaque": true, + "windowsToken": "canvas_tone", + "note": "canvasBackground aliases this directly (opaque) as of recordedAtCommit; at the historical baseCommit ece55b6 it was instead canvasTone.opacity(0.62) with canvasTone = Color(white: 0.095) (#181818) -- see tokenContracts above for that historical value." + }, + { + "name": "Theme.canvasGridLine", + "path": "graphcode/Sources/Features/App/Theme.swift", + "sourceLine": 62, + "swiftLiteral": "Color(red: 0.082, green: 0.094, blue: 0.086)", + "rgb": [21, 24, 22], + "hex": "#151816", + "opaque": true, + "windowsToken": "canvas_grid_line", + "note": "One step off canvasTone, also opaque at recordedAtCommit. Windows renders this as a plain (non-anti-aliased) 1px GDI line." + } + ] + }, "tokenContracts": [ { "name": "Theme.windowTone", "value": "#1E1E1E" }, { "name": "Theme.windowBackground", "value": "opacity(0.55)" },