From 6fcd665a53ea1ba40c5b24b0f6fdc9246b8f5b62 Mon Sep 17 00:00:00 2001 From: Martin Hinshelwood Date: Sun, 27 Sep 2026 16:33:38 +0100 Subject: [PATCH 1/2] fix: preserve translation exclusions and reject ambiguous inputs --- .../Add-GuideContribution.ps1 | 6 ++ .../Get-GuideSiteTranslationWork.ps1 | 29 +++++++- .../Get-GuideTranslationWork.ps1 | 4 +- .../New-GuideTranslationScaffold.ps1 | 1 + .../TranslationReadiness/README.md | 2 + .../Set-GuideWrapperTranslation.ps1 | 2 + .../Testing/Test-SiteTranslationWorkflow.ps1 | 2 +- tests/Core/GuideCredits.Tests.ps1 | 18 +++++ tests/Core/SiteTranslationWork.Tests.ps1 | 74 ++++++++++++++++++- tests/Core/TranslationWorkflows.Tests.ps1 | 10 +++ 10 files changed, 143 insertions(+), 5 deletions(-) diff --git a/system/OpenGuidePlatform.PowerShell.Core/ContributorManagement/Add-GuideContribution.ps1 b/system/OpenGuidePlatform.PowerShell.Core/ContributorManagement/Add-GuideContribution.ps1 index 537fe8b..7c26a3d 100644 --- a/system/OpenGuidePlatform.PowerShell.Core/ContributorManagement/Add-GuideContribution.ps1 +++ b/system/OpenGuidePlatform.PowerShell.Core/ContributorManagement/Add-GuideContribution.ps1 @@ -20,6 +20,12 @@ function Add-GuideContribution { $original=$encoding.GetString($originalBytes) if(-not $CandidateYaml.StartsWith($original,[StringComparison]::Ordinal)){throw 'Append must preserve every existing byte, including comments and order.'} Import-Module powershell-yaml -MinimumVersion 0.4.12 -ErrorAction Stop + foreach($yaml in @($original,$CandidateYaml)){ + $stream=[YamlDotNet.RepresentationModel.YamlStream]::new() + $reader=[IO.StringReader]::new($yaml.TrimStart([char]0xFEFF)) + try {$stream.Load($reader)} finally {$reader.Dispose()} + if($stream.Documents.Count -ne 1){throw 'Contributor source and candidate must each contain exactly one YAML document.'} + } $before=ConvertFrom-Yaml $original.TrimStart([char]0xFEFF) $after=ConvertFrom-Yaml $CandidateYaml.TrimStart([char]0xFEFF) if($before -isnot [Collections.IList] -or $after -isnot [Collections.IList] -or $after.Count -ne ($before.Count+1)){throw 'Append exactly one contributor to the existing collection.'} diff --git a/system/OpenGuidePlatform.PowerShell.Core/TranslationReadiness/Get-GuideSiteTranslationWork.ps1 b/system/OpenGuidePlatform.PowerShell.Core/TranslationReadiness/Get-GuideSiteTranslationWork.ps1 index ba20489..5573ee8 100644 --- a/system/OpenGuidePlatform.PowerShell.Core/TranslationReadiness/Get-GuideSiteTranslationWork.ps1 +++ b/system/OpenGuidePlatform.PowerShell.Core/TranslationReadiness/Get-GuideSiteTranslationWork.ps1 @@ -47,20 +47,45 @@ function Get-GuideSiteTranslationWork { WorkItem 'json-text-selection-required' $relative ($relative.Substring(0,$relative.Length-$sourceLanguage.Length-5)+"$Language.json") 'Set-GuideWrapperTranslation -JsonTextPaths' }} ) + function SameJsonShape($source,$target){ + if($source -is [Collections.IDictionary]){ + if($target -isnot [Collections.IDictionary] -or $source.Count -ne $target.Count){return $false} + foreach($key in $source.Keys){if(-not $target.Contains($key) -or -not (SameJsonShape $source[$key] $target[$key])){return $false}} + }elseif($source -is [Collections.IList]){ + if($target -isnot [Collections.IList] -or $source.Count -ne $target.Count){return $false} + for($i=0;$i -lt $source.Count;$i++){if(-not (SameJsonShape $source[$i] $target[$i])){return $false}} + }else{ + function JsonScalarKind($value){if($null -eq $value){'null'}elseif($value -is [string]){'string'}elseif($value -is [bool]){'boolean'}elseif($value -is [ValueType]){'number'}else{'unsupported'}} + if((JsonScalarKind $source) -ne (JsonScalarKind $target)){return $false} + } + return $true + } foreach($item in $wrappers){ + if($item.Kind -eq 'json-text-selection-required'){ + try { + $sourceJson=ConvertFrom-Json ([IO.File]::ReadAllText((Resolve-GuideWorkspacePath $WorkspaceRoot $item.SourcePath))) -AsHashtable -NoEnumerate -ErrorAction Stop + if($item.TargetSha256){ + $targetJson=ConvertFrom-Json ([IO.File]::ReadAllText((Resolve-GuideWorkspacePath $WorkspaceRoot $item.TargetPath))) -AsHashtable -NoEnumerate -ErrorAction Stop + if(-not (SameJsonShape $sourceJson $targetJson)){$item.State='unsupported-json-schema-reconciliation';$item.SupportedOperation=$null} + } + }catch{$item.State='unsupported-invalid-json';$item.SupportedOperation=$null} + } if($item.Kind -eq 'catalogue'){ $existing=@(foreach($ext in @('yaml','yml')){$candidate="$wrapper/i18n/$Language.$ext";if(FileHash $candidate){$candidate}}) - if($existing.Count -eq 1){$item.TargetPath=$existing[0];$item.TargetSha256=FileHash $existing[0];$item.State='existing-review-required'} + if($existing.Count -eq 1){$item.TargetPath=$existing[0];$item.TargetSha256=FileHash $existing[0];$item.State='existing-review-required';$item.WriteAllowed=(Test-GuideWritePolicy $Policy $item.TargetPath).Allowed} + elseif($existing.Count -gt 1){$item.State='ambiguous-target-catalogue';$item.SupportedOperation=$null} } if(($item.Kind -eq 'markdown' -and -not $item.TargetPath.StartsWith("$wrapper/content/")) -or ($item.Kind -eq 'json-text-selection-required' -and -not $item.TargetPath.StartsWith("$wrapper/data/"))){$item.SupportedOperation=$null;$item.State='unsupported-custom-directory'} } + $sourceCatalogues=@($wrappers|Where-Object Kind -EQ catalogue) + if($sourceCatalogues.Count -gt 1){foreach($item in $sourceCatalogues){$item.State='ambiguous-source-catalogue';$item.SupportedOperation=$null}} foreach($group in @($wrappers|Group-Object TargetPath|Where-Object Count -gt 1)){foreach($item in $group.Group){$item.State='ambiguous-source';$item.SupportedOperation=$null}} $guides=@(foreach($guide in $Policy.guides){foreach($edition in $guide.editions){ $target="$($guide.contentRoot)/$($edition.path)/index.$Language.md";$path=Resolve-GuideWorkspacePath $WorkspaceRoot $target $translation=@($edition.translations|Where-Object language -CEQ $Language) $intent=if($translation.Count){$translation[0].intent}else{$null} $state=if([IO.File]::Exists($path)){if([string]::IsNullOrWhiteSpace((Read-GuideDocument $path).Body)){'empty-stub'}else{'populated'}}else{'missing'} - $excluded=@($Policy.publication.permanentExclusions|Where-Object {($_.subject -eq 'guide' -and $_.id -eq $guide.id) -or ($_.subject -eq 'edition' -and $_.id -eq "$($guide.id)/$($edition.id)")}).Count -gt 0 + $excluded=$intent -eq 'excluded' -or @($Policy.publication.permanentExclusions|Where-Object {($_.subject -eq 'guide' -and $_.id -eq $guide.id) -or ($_.subject -eq 'edition' -and $_.id -eq "$($guide.id)/$($edition.id)")}).Count -gt 0 $allowed=(Test-GuideWritePolicy $Policy $target).Allowed [pscustomobject]@{GuideId=$guide.id;EditionId=$edition.id;SourceLanguage=$edition.sourceLanguage;SourceLanguageSelected=($Language -ieq $edition.sourceLanguage);SourcePath="$($guide.contentRoot)/$($edition.path)/index.md";SourceSha256=(FileHash "$($guide.contentRoot)/$($edition.path)/index.md");TargetPath=$target;TargetSha256=(FileHash $target);State=$state;Intent=$intent;Excluded=$excluded;WriteAllowed=$allowed;Downloads=@($translation|ForEach-Object {$_.downloads});CanCreateScaffold=($state -eq 'missing' -and $Language -ine $edition.sourceLanguage -and $allowed -and $disabled -and -not $excluded -and $intent -notin @('pdf-only','fallback'))} }}) diff --git a/system/OpenGuidePlatform.PowerShell.Core/TranslationReadiness/Get-GuideTranslationWork.ps1 b/system/OpenGuidePlatform.PowerShell.Core/TranslationReadiness/Get-GuideTranslationWork.ps1 index 6eb7b6a..4835135 100644 --- a/system/OpenGuidePlatform.PowerShell.Core/TranslationReadiness/Get-GuideTranslationWork.ps1 +++ b/system/OpenGuidePlatform.PowerShell.Core/TranslationReadiness/Get-GuideTranslationWork.ps1 @@ -78,6 +78,7 @@ function Get-GuideTranslationWork { $target=if([IO.File]::Exists($targetFile)){Read-GuideSnapshot $WorkspaceRoot $targetPath}else{$null} $declared=@($selection.Edition.translations|Where-Object language -CEQ $Language) if($declared.Count -gt 1){throw 'Target language is ambiguous in the supplied inventory.'} + $excluded=$declared.Count -eq 1 -and $declared[0].intent -eq 'excluded' $write=Test-GuideWritePolicy -Policy $Policy -RelativePath $targetPath $productionPath=Resolve-GuideWorkspacePath $WorkspaceRoot "$($Policy.wrapper.sourcePath)/hugo.production.yaml" $disabled=$false @@ -89,6 +90,7 @@ function Get-GuideTranslationWork { } $comparison=if($SourceRevision){Get-GuideSourceComparison $WorkspaceRoot $SourceRevision $SourcePathAtRevision $source}else{$null} $findings=@( + if($excluded){[pscustomobject]@{Code='TRANSLATION_EXCLUDED';Action='Preserve the declared excluded translation; do not create a scaffold.'}} if(-not $write.Allowed){[pscustomobject]@{Code='PROTECTED_RESOURCE';Action=$write.Reason}} if(-not $target -and -not $disabled){[pscustomobject]@{Code='SCAFFOLD_CONFIGURATION_REQUIRED';Action="Declare $Language disabled in hugo.production.yaml before scaffolding. Review main/preview language configuration separately."}} if($target -and -not $declared.Count){[pscustomobject]@{Code='REFRESH_DISCOVERY';Action='Rerun Prepare to discover the existing target before applying content.'}} @@ -101,7 +103,7 @@ function Get-GuideTranslationWork { Source=$source;Target=$target;TargetPath=$targetPath;TargetDeclared=($declared.Count -eq 1) TargetIntent=if($declared.Count){$declared[0].intent}else{$null} Downloads=if($declared.Count){@($declared[0].downloads)}else{@()} - CanCreateScaffold=(-not $target -and $disabled -and $write.Allowed) + CanCreateScaffold=(-not $target -and $disabled -and $write.Allowed -and -not $excluded) WriteAllowed=$write.Allowed;ProductionExplicitlyDisabled=$disabled Comparison=$comparison;Wrapper=(Get-GuideWrapperStatus -WorkspaceRoot $WorkspaceRoot -Policy $Policy -Languages @($Language)) Findings=$findings;TranslationQualityAssessed=$false;PublicationVerified=$false diff --git a/system/OpenGuidePlatform.PowerShell.Core/TranslationReadiness/New-GuideTranslationScaffold.ps1 b/system/OpenGuidePlatform.PowerShell.Core/TranslationReadiness/New-GuideTranslationScaffold.ps1 index 224eed7..6975f9c 100644 --- a/system/OpenGuidePlatform.PowerShell.Core/TranslationReadiness/New-GuideTranslationScaffold.ps1 +++ b/system/OpenGuidePlatform.PowerShell.Core/TranslationReadiness/New-GuideTranslationScaffold.ps1 @@ -7,6 +7,7 @@ function New-GuideTranslationScaffold { $target=Resolve-GuideWorkspacePath $WorkspaceRoot $relative Assert-GuideWriteAllowed $Policy $relative if ([IO.File]::Exists($target)) { return [pscustomobject]@{Status='preserved';Path=$relative;ProductionChanged=$false} } + if(@($selection.Edition.translations|Where-Object { $_.language -ceq $Language -and $_.intent -eq 'excluded' }).Count){throw 'Preserve the declared excluded translation; do not create a scaffold.'} # Require an explicit disabled entry before creating a language file. Never enable it here. $production=Resolve-GuideWorkspacePath $WorkspaceRoot "$($Policy.wrapper.sourcePath)/hugo.production.yaml" Import-Module powershell-yaml -MinimumVersion 0.4.12 -ErrorAction Stop diff --git a/system/OpenGuidePlatform.PowerShell.Core/TranslationReadiness/README.md b/system/OpenGuidePlatform.PowerShell.Core/TranslationReadiness/README.md index ef29838..ed2ce5a 100644 --- a/system/OpenGuidePlatform.PowerShell.Core/TranslationReadiness/README.md +++ b/system/OpenGuidePlatform.PowerShell.Core/TranslationReadiness/README.md @@ -73,6 +73,8 @@ Set-GuideWrapperTranslation @wrapperChange A new target omits `ExpectedSha256`; creation refuses an existing destination. JSON pointers such as `/hero/title` select actual string leaves in that site's schema, not a universal field list. Existing JSON starts from the target; new JSON starts from the source so unselected values remain intact. Inspect the source and candidate together and do not refresh a stale hash merely to apply an old candidate. +JSON support is text-only, not source-schema synchronization. Existing source and target must have matching object keys, array structure and scalar JSON types (string, number, boolean or null). Added or removed keys, changed array lengths and shape or type changes are reported as `unsupported-json-schema-reconciliation`, with no supported operation; the writer refuses them without changing target bytes. Invalid source or target JSON is reported as `unsupported-invalid-json`. Review schema reconciliation through a separately supported operation before applying selected text changes. Do not bypass this limitation with direct edits or claim that text reconciliation synchronized the schema. + Configuration candidates instead start from the existing path and hash in `$siteWork.Configuration` (`ProductionPath`/`ProductionSha256` first, then `MainPath`/`MainSha256`). Edit only the selected language mapping and supply that configuration's hash as `ExpectedSha256` to the same writer. They are not wrapper-source entries and do not use JSON pointers. Read command help before applying a candidate and review the actual diff after each operation. Multi-file changes are not one transaction: inspect partial progress and refresh reports before resuming. diff --git a/system/OpenGuidePlatform.PowerShell.Core/TranslationReadiness/Set-GuideWrapperTranslation.ps1 b/system/OpenGuidePlatform.PowerShell.Core/TranslationReadiness/Set-GuideWrapperTranslation.ps1 index 22f9d30..9c8d91f 100644 --- a/system/OpenGuidePlatform.PowerShell.Core/TranslationReadiness/Set-GuideWrapperTranslation.ps1 +++ b/system/OpenGuidePlatform.PowerShell.Core/TranslationReadiness/Set-GuideWrapperTranslation.ps1 @@ -67,6 +67,8 @@ function Set-GuideWrapperTranslation { $work=Get-GuideSiteTranslationWork -WorkspaceRoot $WorkspaceRoot -Policy $Policy -Language $Language $selected=@($work.Wrappers|Where-Object { $_.Kind -eq 'json-text-selection-required' -and $_.TargetPath -ceq $RelativePath }) if($selected.Count -ne 1){throw 'Select discovered source-language JSON data; arbitrary JSON paths are unsupported.'} + if($selected[0].State -eq 'unsupported-json-schema-reconciliation'){throw 'JSON source and target schemas differ. Schema reconciliation is unsupported by this text-only writer; review keys and array structure through a separately supported operation before selecting text leaves.'} + if($selected[0].State -eq 'unsupported-invalid-json'){throw 'Source or target JSON is invalid; resolve the reported resource before selecting text leaves.'} $sourceFile=Resolve-GuideWorkspacePath $WorkspaceRoot $selected[0].SourcePath if(-not $ExpectedSourceSha256 -or (Get-FileHash -LiteralPath $sourceFile).Hash -ine $ExpectedSourceSha256){throw 'JSON source changed since review or ExpectedSourceSha256 is missing.'} if(-not $JsonTextPaths -or @($JsonTextPaths|Sort-Object -Unique).Count -ne $JsonTextPaths.Count){throw 'Select unique reviewed JSON string pointers using JsonTextPaths.'} diff --git a/system/OpenGuidePlatform.PowerShell.PlatformBuild/Testing/Test-SiteTranslationWorkflow.ps1 b/system/OpenGuidePlatform.PowerShell.PlatformBuild/Testing/Test-SiteTranslationWorkflow.ps1 index 21e5441..7cc9287 100644 --- a/system/OpenGuidePlatform.PowerShell.PlatformBuild/Testing/Test-SiteTranslationWorkflow.ps1 +++ b/system/OpenGuidePlatform.PowerShell.PlatformBuild/Testing/Test-SiteTranslationWorkflow.ps1 @@ -35,7 +35,7 @@ function Read-Relative([string]$Path){[IO.File]::ReadAllText((Resolve-GuideWorks # exercises JSON safety without claiming a semantic translation of real content. $main=Get-Content "$destination/hugo.yaml" -Raw|ConvertFrom-Yaml $sourceLanguage=[string]$main.defaultContentLanguage -$language=@('de','kn','fr'|Where-Object {-not $main.languages.Contains($_)})|Select-Object -First 1 +$language=@('de','kn','fr')|Where-Object {-not $main.languages.Contains($_)}|Select-Object -First 1 if(-not $language){throw 'Site translation acceptance needs a language absent from sample configuration.'} $jsonDirectory="$destination/data/site-translation-fixture" [IO.Directory]::CreateDirectory($jsonDirectory)|Out-Null diff --git a/tests/Core/GuideCredits.Tests.ps1 b/tests/Core/GuideCredits.Tests.ps1 index 67d7e08..8b556cf 100644 --- a/tests/Core/GuideCredits.Tests.ps1 +++ b/tests/Core/GuideCredits.Tests.ps1 @@ -81,6 +81,24 @@ Describe 'Contributor records and credits' { [IO.File]::ReadAllBytes($path) | Should -Be ([Text.UTF8Encoding]::new($false).GetBytes($candidate)) @(Find-ContributorIssues $workspace $policy|Where-Object Severity -eq blocker).Count | Should -Be 0 } + It 'rejects an extra YAML document in the candidate without changing the file' { + $path=Join-Path $workspace "$data/example.fa.yml" + $original=[IO.File]::ReadAllText($path);$hash=(Get-FileHash $path).Hash + $candidate=$original+"- name: New Reviewer`n role: reviewer`n contributions: [`"$editionId`"]`n---`n- name: Hidden Contributor`n" + {Add-GuideContribution $workspace $policy example fa $hash $candidate} | Should -Throw '*exactly one YAML document*' + (Get-FileHash $path).Hash | Should -Be $hash + [IO.File]::ReadAllText($path) | Should -BeExactly $original + } + It 'rejects an existing multi-document source without changing the file' { + $path=Join-Path $workspace "$data/example.fa.yml" + $original=[IO.File]::ReadAllText($path)+"---`n- name: Hidden Contributor`n" + [IO.File]::WriteAllText($path,$original) + $hash=(Get-FileHash $path).Hash + $candidate=$original+"- name: New Reviewer`n role: reviewer`n contributions: [`"$editionId`"]`n" + {Add-GuideContribution $workspace $policy example fa $hash $candidate} | Should -Throw '*exactly one YAML document*' + (Get-FileHash $path).Hash | Should -Be $hash + [IO.File]::ReadAllText($path) | Should -BeExactly $original + } It 'rejects unsafe contributor appends without changing the file' { $path=Join-Path $workspace "$data/example.fa.yml" $original=[IO.File]::ReadAllText($path);$hash=(Get-FileHash $path).Hash diff --git a/tests/Core/SiteTranslationWork.Tests.ps1 b/tests/Core/SiteTranslationWork.Tests.ps1 index 401e4db..72cb8d2 100644 --- a/tests/Core/SiteTranslationWork.Tests.ps1 +++ b/tests/Core/SiteTranslationWork.Tests.ps1 @@ -38,6 +38,19 @@ Describe 'Site language work inventory' { Set-Content "$workspace/site/content/a/v1/index.kn.md" "---`ntitle: Kannada`n---`n" (Get-GuideSiteTranslationWork -WorkspaceRoot $workspace -Policy $policy -Language kn).Guides[0].State|Should -Be empty-stub } + It 'preserves explicitly excluded translations with missing target files' { + Set-Content "$workspace/site/hugo.production.yaml" "languages:`n kn:`n disabled: true" + $policy.guides[0].editions[0].translations=@(@{language='kn';intent='excluded';downloads=@()}) + $work=Get-GuideSiteTranslationWork -WorkspaceRoot $workspace -Policy $policy -Language kn + $work.Configuration.ProductionExplicitlyDisabled|Should -BeTrue + $work.Guides[0].State|Should -Be missing + $work.Guides[0].Intent|Should -Be excluded + $work.Guides[0].Excluded|Should -BeTrue + $work.Guides[0].WriteAllowed|Should -BeTrue + $work.Guides[0].CanCreateScaffold|Should -BeFalse + $work.Guides[1].CanCreateScaffold|Should -BeTrue + Test-Path "$workspace/site/content/a/v1/index.kn.md"|Should -BeFalse + } It 'allows discovered guide wrappers but never edition resources or unknown guide files' { Set-Content "$workspace/site/hugo.production.yaml" "languages:`n kn:`n disabled: true" $wrapperArgs=@{WorkspaceRoot=$workspace;Policy=$policy;Language='kn';CandidateContent="---`ntitle: Kannada`n---`n"} @@ -68,6 +81,47 @@ Describe 'Site language work inventory' { $wrapperArgs.JsonTextPaths=@('/title');$wrapperArgs.ExpectedSourceSha256='0'*64 {Set-GuideWrapperTranslation @wrapperArgs -CandidateContent $candidate}|Should -Throw '*source changed*' } + It 'reports unsupported JSON schema drift and refuses writes for ' -ForEach @( + @{Name='changed scalar type';Source='{"title":42,"color":"blue","items":["Text"]}'}, + @{Name='added source key';Source='{"title":"Hello","color":"blue","items":["Text"],"new":"New"}'}, + @{Name='removed source key';Source='{"title":"Hello","items":["Text"]}'}, + @{Name='changed array length';Source='{"title":"Hello","color":"blue","items":["Text","More"]}'}, + @{Name='changed object shape';Source='{"title":"Hello","color":"blue","items":{"label":"Text"}}'} + ) { + Set-Content "$workspace/site/hugo.production.yaml" "languages:`n kn:`n disabled: true" + $candidate='{"title":"Namaskara","color":"blue","items":["Translated"]}' + Set-Content "$workspace/site/data/home/kn.json" $candidate + $hash=(Get-FileHash "$workspace/site/data/home/kn.json").Hash + Set-Content "$workspace/site/data/home/en.json" $Source + $work=Get-GuideSiteTranslationWork -WorkspaceRoot $workspace -Policy $policy -Language kn + $json=@($work.Wrappers|Where-Object Kind -EQ json-text-selection-required)[0] + $json.State|Should -Be unsupported-json-schema-reconciliation + $json.SupportedOperation|Should -BeNullOrEmpty + $work.Findings.Code|Should -Contain WRAPPER_SCOPE_UNSUPPORTED + {Set-GuideWrapperTranslation -WorkspaceRoot $workspace -Policy $policy -Language kn -RelativePath site/data/home/kn.json -CandidateContent $candidate -ExpectedSha256 $hash -ExpectedSourceSha256 $json.SourceSha256 -JsonTextPaths '/title'}|Should -Throw '*Schema reconciliation is unsupported*' + (Get-FileHash "$workspace/site/data/home/kn.json").Hash|Should -Be $hash + } + It 'reports invalid JSON without aborting the inventory or writing a target' { + Set-Content "$workspace/site/data/home/en.json" '{invalid' + $work=Get-GuideSiteTranslationWork -WorkspaceRoot $workspace -Policy $policy -Language kn + $json=@($work.Wrappers|Where-Object Kind -EQ json-text-selection-required)[0] + $json.State|Should -Be unsupported-invalid-json + $json.SupportedOperation|Should -BeNullOrEmpty + $work.Guides.Count|Should -Be 2 + {Set-GuideWrapperTranslation -WorkspaceRoot $workspace -Policy $policy -Language kn -RelativePath site/data/home/kn.json -CandidateContent '{}' -ExpectedSourceSha256 $json.SourceSha256 -JsonTextPaths '/title'}|Should -Throw '*JSON is invalid*' + Test-Path "$workspace/site/data/home/kn.json"|Should -BeFalse + } + It 'preserves an existing malformed JSON target when refusing text changes' { + Set-Content "$workspace/site/data/home/kn.json" '{invalid' + $hash=(Get-FileHash "$workspace/site/data/home/kn.json").Hash + $work=Get-GuideSiteTranslationWork -WorkspaceRoot $workspace -Policy $policy -Language kn + $json=@($work.Wrappers|Where-Object Kind -EQ json-text-selection-required)[0] + $json.State|Should -Be unsupported-invalid-json + $json.SupportedOperation|Should -BeNullOrEmpty + $work.Findings.Code|Should -Contain WRAPPER_SCOPE_UNSUPPORTED + {Set-GuideWrapperTranslation -WorkspaceRoot $workspace -Policy $policy -Language kn -RelativePath site/data/home/kn.json -CandidateContent '{"title":"Translated","color":"blue","items":["Text"]}' -ExpectedSha256 $hash -ExpectedSourceSha256 $json.SourceSha256 -JsonTextPaths '/title'}|Should -Throw '*JSON is invalid*' + (Get-FileHash "$workspace/site/data/home/kn.json").Hash|Should -Be $hash + } It 'reports source ambiguity and preserves an existing catalogue extension' { Set-Content "$workspace/site/content/_index.en.md" "---`ntitle: Other`n---`n" Set-Content "$workspace/site/i18n/kn.yml" 'home: Translated' @@ -76,6 +130,24 @@ Describe 'Site language work inventory' { $work.Wrappers.TargetPath|Should -Contain 'site/i18n/kn.yml' $work.Findings.Code|Should -Contain WRAPPER_SCOPE_UNSUPPORTED } + It 'checks protection on the actual existing catalogue extension' { + Set-Content "$workspace/site/i18n/kn.yml" 'home: Translated' + $policy.protectedPaths=@('site/i18n/kn.yml') + $catalogue=@((Get-GuideSiteTranslationWork -WorkspaceRoot $workspace -Policy $policy -Language kn).Wrappers|Where-Object Kind -EQ catalogue)[0] + $catalogue.TargetPath|Should -Be site/i18n/kn.yml + $catalogue.WriteAllowed|Should -BeFalse + } + It 'reports duplicate catalogue extensions as unsupported for ' -ForEach @( + @{Location='target';LanguageCode='kn';Expected='ambiguous-target-catalogue'}, + @{Location='source';LanguageCode='en';Expected='ambiguous-source-catalogue'} + ) { + Set-Content "$workspace/site/i18n/$LanguageCode.yaml" 'home: Text' + Set-Content "$workspace/site/i18n/$LanguageCode.yml" 'home: Text' + $work=Get-GuideSiteTranslationWork -WorkspaceRoot $workspace -Policy $policy -Language kn + $catalogues=@($work.Wrappers|Where-Object Kind -EQ catalogue) + foreach($item in $catalogues){$item.State|Should -Be $Expected;$item.SupportedOperation|Should -BeNullOrEmpty} + $work.Findings.Code|Should -Contain WRAPPER_SCOPE_UNSUPPORTED + } It 'reads Hugo configuration casing and reports unsupported custom content directories' { Set-Content "$workspace/site/hugo.yaml" "defaultcontentlanguage: en`ncontentdir: pages`nlanguages:`n en:`n languageName: English" New-Item -ItemType Directory "$workspace/site/pages"|Out-Null @@ -92,4 +164,4 @@ Describe 'Site language work inventory' { $work.Guides[1].SourceLanguageSelected|Should -BeTrue $work.Guides[1].CanCreateScaffold|Should -BeFalse } -} \ No newline at end of file +} diff --git a/tests/Core/TranslationWorkflows.Tests.ps1 b/tests/Core/TranslationWorkflows.Tests.ps1 index e1e3255..04d4b04 100644 --- a/tests/Core/TranslationWorkflows.Tests.ps1 +++ b/tests/Core/TranslationWorkflows.Tests.ps1 @@ -50,6 +50,16 @@ Describe 'Human-operated translation workflows' { (Get-FileHash "$workspace/site/hugo.production.yaml").Hash|Should -Be $config (New-GuideTranslation @new).Status|Should -Be preserved } + It 'refuses explicitly excluded missing translations through both creation routes' { + $new=$selection.Clone();$new.Language='fa' + $edition.translations+=@{language='fa';intent='excluded';downloads=@()} + $excludedWork=Get-GuideTranslationWork @new + $excludedWork.CanCreateScaffold|Should -BeFalse + $excludedWork.Findings.Code|Should -Contain TRANSLATION_EXCLUDED + {New-GuideTranslation @new}|Should -Throw '*declared excluded*' + {New-GuideTranslationScaffold @new}|Should -Throw '*declared excluded*' + Test-Path "$directory/index.fa.md"|Should -BeFalse + } It 'refuses new-language creation without explicit production exclusion' { $selection.Language='ja' {New-GuideTranslation @selection}|Should -Throw '*disabled*' From 718c634143ca5304b8fd1c9b1c5bd975bf6143da Mon Sep 17 00:00:00 2001 From: Martin Hinshelwood Date: Sun, 27 Sep 2026 17:16:52 +0100 Subject: [PATCH 2/2] fix: recover empty releases using immutable tag identity --- .../Get-GuideSiteTranslationWork.ps1 | 31 +++++++- .../Set-GuideWrapperTranslation.ps1 | 11 ++- .../README.md | 2 + .../Release/Publish-PlatformRelease.ps1 | 23 +++++- .../Release/release.ps1 | 10 ++- tests/Core/NativeModulePublication.Tests.ps1 | 76 ++++++++++++++++++- tests/Core/PackagedRelease.Tests.ps1 | 24 +++++- tests/Core/SiteTranslationWork.Tests.ps1 | 69 +++++++++++++++++ tests/Core/WrapperPublishing.Tests.ps1 | 10 ++- 9 files changed, 241 insertions(+), 15 deletions(-) diff --git a/system/OpenGuidePlatform.PowerShell.Core/TranslationReadiness/Get-GuideSiteTranslationWork.ps1 b/system/OpenGuidePlatform.PowerShell.Core/TranslationReadiness/Get-GuideSiteTranslationWork.ps1 index 5573ee8..1cf3598 100644 --- a/system/OpenGuidePlatform.PowerShell.Core/TranslationReadiness/Get-GuideSiteTranslationWork.ps1 +++ b/system/OpenGuidePlatform.PowerShell.Core/TranslationReadiness/Get-GuideSiteTranslationWork.ps1 @@ -1,3 +1,30 @@ +function ConvertFrom-GuideTranslationJson { + param([Parameter(Mandatory)][string]$Content) + # ConvertFrom-Json in PowerShell 7.4 coerces ISO strings to DateTime. + # Preserve the JSON types used by the text-only translation contract. + function ReadJsonValue([System.Text.Json.JsonElement]$element) { + switch ($element.ValueKind.ToString()) { + 'Object' { + $value=[Collections.Specialized.OrderedDictionary]::new([StringComparer]::Ordinal) + foreach($property in $element.EnumerateObject()){$value.Add($property.Name,(ReadJsonValue $property.Value))} + return $value + } + 'Array' { + $value=@(foreach($item in $element.EnumerateArray()){ReadJsonValue $item}) + return ,$value + } + 'String' { return $element.GetString() } + 'Number' { return (ConvertFrom-Json $element.GetRawText() -NoEnumerate) } + 'True' { return $true } + 'False' { return $false } + 'Null' { return $null } + default { throw 'Unsupported JSON value.' } + } + } + $document=[System.Text.Json.JsonDocument]::Parse($Content) + try { return ,(ReadJsonValue $document.RootElement) } + finally { $document.Dispose() } +} function Get-GuideSiteTranslationWork { <# .SYNOPSIS Discover site-language work before selecting individual guide translations. @@ -63,9 +90,9 @@ function Get-GuideSiteTranslationWork { foreach($item in $wrappers){ if($item.Kind -eq 'json-text-selection-required'){ try { - $sourceJson=ConvertFrom-Json ([IO.File]::ReadAllText((Resolve-GuideWorkspacePath $WorkspaceRoot $item.SourcePath))) -AsHashtable -NoEnumerate -ErrorAction Stop + $sourceJson=ConvertFrom-GuideTranslationJson ([IO.File]::ReadAllText((Resolve-GuideWorkspacePath $WorkspaceRoot $item.SourcePath))) if($item.TargetSha256){ - $targetJson=ConvertFrom-Json ([IO.File]::ReadAllText((Resolve-GuideWorkspacePath $WorkspaceRoot $item.TargetPath))) -AsHashtable -NoEnumerate -ErrorAction Stop + $targetJson=ConvertFrom-GuideTranslationJson ([IO.File]::ReadAllText((Resolve-GuideWorkspacePath $WorkspaceRoot $item.TargetPath))) if(-not (SameJsonShape $sourceJson $targetJson)){$item.State='unsupported-json-schema-reconciliation';$item.SupportedOperation=$null} } }catch{$item.State='unsupported-invalid-json';$item.SupportedOperation=$null} diff --git a/system/OpenGuidePlatform.PowerShell.Core/TranslationReadiness/Set-GuideWrapperTranslation.ps1 b/system/OpenGuidePlatform.PowerShell.Core/TranslationReadiness/Set-GuideWrapperTranslation.ps1 index 9c8d91f..452d471 100644 --- a/system/OpenGuidePlatform.PowerShell.Core/TranslationReadiness/Set-GuideWrapperTranslation.ps1 +++ b/system/OpenGuidePlatform.PowerShell.Core/TranslationReadiness/Set-GuideWrapperTranslation.ps1 @@ -55,6 +55,11 @@ function Set-GuideWrapperTranslation { }else{$before.languages=@{}} if(-not (Test-GuideWrapperValueEqual $before $after)){throw 'Candidate changes unselected languages or unrelated wrapper configuration.'} }elseif($catalogue){ + $work=Get-GuideSiteTranslationWork -WorkspaceRoot $WorkspaceRoot -Policy $Policy -Language $Language + $discovered=@($work.Wrappers|Where-Object { $_.Kind -eq 'catalogue' }) + if($discovered.Count -ne 1 -or $discovered[0].TargetPath -cne $RelativePath -or $discovered[0].SupportedOperation -ne 'Set-GuideWrapperTranslation'){ + throw 'Select one uniquely discovered authoritative source and target catalogue; ambiguous catalogue extensions are unsupported.' + } $other=if($RelativePath.EndsWith('.yaml')){$RelativePath.Substring(0,$RelativePath.Length-5)+'.yml'}else{$RelativePath.Substring(0,$RelativePath.Length-4)+'.yaml'} if(Test-Path -LiteralPath (Resolve-GuideWorkspacePath $WorkspaceRoot $other)){throw 'Preserve one authoritative catalogue extension; both YAML extensions are ambiguous.'} $catalog=ConvertFrom-Yaml $CandidateContent @@ -72,9 +77,9 @@ function Set-GuideWrapperTranslation { $sourceFile=Resolve-GuideWorkspacePath $WorkspaceRoot $selected[0].SourcePath if(-not $ExpectedSourceSha256 -or (Get-FileHash -LiteralPath $sourceFile).Hash -ine $ExpectedSourceSha256){throw 'JSON source changed since review or ExpectedSourceSha256 is missing.'} if(-not $JsonTextPaths -or @($JsonTextPaths|Sort-Object -Unique).Count -ne $JsonTextPaths.Count){throw 'Select unique reviewed JSON string pointers using JsonTextPaths.'} - $sourceJson=ConvertFrom-Json ([IO.File]::ReadAllText($sourceFile)) -AsHashtable -NoEnumerate - $baseline=if($exists){ConvertFrom-Json ([IO.File]::ReadAllText($target)) -AsHashtable -NoEnumerate}else{$sourceJson} - $candidateJson=ConvertFrom-Json $CandidateContent -AsHashtable -NoEnumerate + $sourceJson=ConvertFrom-GuideTranslationJson ([IO.File]::ReadAllText($sourceFile)) + $baseline=if($exists){ConvertFrom-GuideTranslationJson ([IO.File]::ReadAllText($target))}else{$sourceJson} + $candidateJson=ConvertFrom-GuideTranslationJson $CandidateContent $seen=[Collections.Generic.HashSet[string]]::new([StringComparer]::Ordinal) function Compare-JsonText($source,$before,$after,[string]$pointer){ if($source -is [Collections.IDictionary]){ diff --git a/system/OpenGuidePlatform.PowerShell.PlatformBuild/README.md b/system/OpenGuidePlatform.PowerShell.PlatformBuild/README.md index a9f8d37..442bf3e 100644 --- a/system/OpenGuidePlatform.PowerShell.PlatformBuild/README.md +++ b/system/OpenGuidePlatform.PowerShell.PlatformBuild/README.md @@ -2,6 +2,8 @@ This module owns the platform repository build: tool checks, tests, packaging, candidate-sample acceptance and preview publication. It ships in the separate `OpenGuidePlatform-PlatformBuild.zip` archive at the same release version as `OpenGuidePlatform-GuideSite.zip`, which contains the composable GuideSiteBuild stages. The consumer module does not depend on this module. +Release identity comes from the immutable tag's resolved commit, including annotated tags; GitHub's `targetCommitish` may retain a branch name and is not authoritative. A rerun verifies complete existing assets without replacing them. An empty, non-draft release with the matching tag and channel can be completed using the tested coordinated packages, then verified. Partial or unexpected assets require a new version or explicit maintainer recovery; publication never overwrites assets or moves immutable tags. + Run `./build.ps1 -Version 0.0.0-local` from the platform checkout. All runs tests, packages and verifies the distribution, then starts fresh PowerShell processes to build and validate the sample in preview and production using the exact ZIP produced. It does not deploy or publish. Use `-Stage Sample -OutputPath ` to repeat candidate acceptance independently. `-Stage Release` is explicit and preserves coordinated platform/native-module tag publication. Sample acceptance also copies the reference site into disposable output, runs Prepare discovery, selects an existing writable guide through the packaged Core module, previews and applies a body correction, verifies other content is unchanged, and builds preview and production. Its `contributor-*/result.json` records the selection and hashes. It does not edit the source sample or consumer repositories. diff --git a/system/OpenGuidePlatform.PowerShell.PlatformBuild/Release/Publish-PlatformRelease.ps1 b/system/OpenGuidePlatform.PowerShell.PlatformBuild/Release/Publish-PlatformRelease.ps1 index fbc6d8a..2f5f8bb 100644 --- a/system/OpenGuidePlatform.PowerShell.PlatformBuild/Release/Publish-PlatformRelease.ps1 +++ b/system/OpenGuidePlatform.PowerShell.PlatformBuild/Release/Publish-PlatformRelease.ps1 @@ -22,20 +22,35 @@ $module=$manifest.nativeHugoModule if($module.path -cne 'github.com/nkdAgility/OpenGuidePlatform/system/OpenGuidePlatform.Hugo.Guides' -or $module.version -cne $tag -or $module.tag -cne "system/OpenGuidePlatform.Hugo.Guides/$tag" -or $module.sourceCommit -cne $commit){throw 'Native Hugo publication identity differs from the tested release.'} # A module under system/ requires its own subdirectory-prefixed tag. Never move it. $remote="https://github.com/$Repository.git" +$releaseRef="refs/tags/$tag" +$releaseRefs=@(& git ls-remote $remote $releaseRef "$releaseRef^{}") +if($LASTEXITCODE -ne 0){throw 'Cannot establish whether the immutable release tag already exists.'} +if($releaseRefs.Count){ + $peeled=@($releaseRefs|Where-Object {($_ -split '\s+')[1] -ceq "$releaseRef^{}"}) + $identity=if($peeled.Count -eq 1){($peeled[0] -split '\s+')[0]}elseif($releaseRefs.Count -eq 1){($releaseRefs[0] -split '\s+')[0]}else{$null} + if($identity -cne $commit){throw 'Existing immutable release tag differs; publish a new version.'} +} $ref="refs/tags/$($module.tag)" -$prior=@(& git ls-remote --refs $remote $ref) +$prior=@(& git ls-remote $remote $ref "$ref^{}") if($LASTEXITCODE -ne 0){throw 'Cannot establish whether the native Hugo tag already exists.'} if($prior.Count){ - if($prior.Count -ne 1 -or ($prior[0] -split '\s+')[0] -cne $commit){throw 'Existing native Hugo tag differs; publish a new version.'} + $peeled=@($prior|Where-Object {($_ -split '\s+')[1] -ceq "$ref^{}"}) + $identity=if($peeled.Count -eq 1){($peeled[0] -split '\s+')[0]}elseif($prior.Count -eq 1){($prior[0] -split '\s+')[0]}else{$null} + if($identity -cne $commit){throw 'Existing native Hugo tag differs; publish a new version.'} }else{ & gh api "repos/$Repository/git/refs" --method POST -f "ref=$ref" -f "sha=$commit" | Out-Null if($LASTEXITCODE -ne 0){throw 'Native Hugo tag publication failed; no platform release was created.'} } # Reruns verify an existing immutable release; they never replace assets or move tags. -$existing=& gh release view $tag --repo $Repository --json targetCommitish,isDraft,isPrerelease 2>$null +$existing=& gh release view $tag --repo $Repository --json isDraft,isPrerelease,assets 2>$null if($LASTEXITCODE -eq 0){ $release=$existing|ConvertFrom-Json - if($release.targetCommitish -cne $commit -or $release.isDraft -or [bool]$release.isPrerelease -ne $prerelease){throw 'Existing release identity differs.'} + if(-not $releaseRefs.Count -or $release.isDraft -or [bool]$release.isPrerelease -ne $prerelease){throw 'Existing release identity differs.'} + # Complete an empty shell once. Upload never replaces an existing asset. + if(@($release.assets).Count -eq 0){ + & gh release upload $tag "$OutputPath/OpenGuidePlatform-GuideSite.zip" "$OutputPath/OpenGuidePlatform-PlatformBuild.zip" "$OutputPath/release-manifest.json" --repo $Repository + if($LASTEXITCODE -ne 0){throw 'Empty release completion failed. Inspect partial assets; never overwrite them.'} + }elseif(@($release.assets).Count -ne $assets.Count -or @($release.assets|Where-Object {$_.name -cnotin $assets}).Count){throw 'Existing release assets are partial or unexpected; publish a new version, never overwrite.'} $verify=Join-Path $OutputPath ('existing-'+[guid]::NewGuid().ToString('N')) & gh release download $tag --repo $Repository --pattern OpenGuidePlatform-GuideSite.zip --pattern OpenGuidePlatform-PlatformBuild.zip --pattern release-manifest.json --dir $verify if($LASTEXITCODE -ne 0){throw 'Cannot verify existing release assets.'} diff --git a/system/OpenGuidePlatform.PowerShell.PlatformBuild/Release/release.ps1 b/system/OpenGuidePlatform.PowerShell.PlatformBuild/Release/release.ps1 index eb23de0..8e6d9f6 100644 --- a/system/OpenGuidePlatform.PowerShell.PlatformBuild/Release/release.ps1 +++ b/system/OpenGuidePlatform.PowerShell.PlatformBuild/Release/release.ps1 @@ -17,8 +17,14 @@ if($Stage -eq 'Release'){ & "$PSScriptRoot/system/OpenGuidePlatform.PowerShell.PlatformBuild/Release/Publish-PlatformRelease.ps1" -WorkspaceRoot $assets -OutputPath $assets -SourceCommit $SourceCommit -Repository $Repository return } -$published=& gh release view $tag --repo $Repository --json targetCommitish,isDraft,isPrerelease | ConvertFrom-Json -if($LASTEXITCODE -ne 0 -or $published.targetCommitish -cne $SourceCommit -or $published.isDraft -or [bool]$published.isPrerelease -ne $manifest.version.Contains('-')){throw 'Published release identity differs from the package.'} +$releaseRef="refs/tags/$tag" +$refs=@(& git ls-remote "https://github.com/$Repository.git" $releaseRef "$releaseRef^{}") +if($LASTEXITCODE -ne 0){throw 'Cannot inspect the immutable published release tag.'} +$peeled=@($refs|Where-Object {($_ -split '\s+')[1] -ceq "$releaseRef^{}"}) +$tagCommit=if($peeled.Count -eq 1){($peeled[0] -split '\s+')[0]}elseif($refs.Count -eq 1){($refs[0] -split '\s+')[0]}else{$null} +if($tagCommit -cne $SourceCommit){throw 'Published release tag differs from the package.'} +$published=& gh release view $tag --repo $Repository --json isDraft,isPrerelease | ConvertFrom-Json +if($LASTEXITCODE -ne 0 -or $published.isDraft -or [bool]$published.isPrerelease -ne $manifest.version.Contains('-')){throw 'Published release identity differs from the package.'} $verify=Join-Path $assets ('published-'+[guid]::NewGuid().ToString('N')) & gh release download $tag --repo $Repository --pattern OpenGuidePlatform-GuideSite.zip --pattern OpenGuidePlatform-PlatformBuild.zip --pattern release-manifest.json --dir $verify if($LASTEXITCODE -ne 0){throw 'Cannot download published release assets.'} diff --git a/tests/Core/NativeModulePublication.Tests.ps1 b/tests/Core/NativeModulePublication.Tests.ps1 index bf1b9e8..2e09a78 100644 --- a/tests/Core/NativeModulePublication.Tests.ps1 +++ b/tests/Core/NativeModulePublication.Tests.ps1 @@ -13,7 +13,13 @@ BeforeAll { function git { $global:LASTEXITCODE=0 if($args -contains 'rev-parse'){return 'a'*40} - if($args[0] -eq 'ls-remote'){return $global:OgpNativeTagExisting} + if($args[0] -eq 'ls-remote'){ + if($args[2] -like 'refs/tags/system/*'){return $global:OgpNativeTagExisting} + if($global:OgpMissingRootTag){return} + if($global:OgpRootTagExisting.Count){return $global:OgpRootTagExisting} + if($global:OgpExistingRelease){return $global:OgpReleaseTagCommit+"`t"+$args[2]} + return + } if($args -contains 'push'){return} throw 'Unexpected Git operation.' } @@ -21,12 +27,21 @@ BeforeAll { $global:LASTEXITCODE=0 $global:OgpNativeTagCalls.Add(($args -join ' ')) if($args[0] -eq 'api'){ + if($args[1] -like 'repos/*/commits/*'){return $global:OgpReleaseTagCommit} if($args[1] -like 'repos/*/git/matching-refs/tags/v'){return '[]'} if($global:OgpNativeTagFailure){$global:LASTEXITCODE=1} return } if($args[0] -eq 'release' -and $args[1] -eq 'view'){if($global:OgpExistingRelease){return ($global:OgpExistingRelease|ConvertTo-Json)};$global:LASTEXITCODE=1;return} if($args[0] -eq 'release' -and $args[1] -eq 'create'){return} + if($args[0] -eq 'release' -and $args[1] -eq 'upload'){return} + if($args[0] -eq 'release' -and $args[1] -eq 'download'){ + $destination=$args[[Array]::IndexOf($args,'--dir')+1] + [IO.Directory]::CreateDirectory($destination)|Out-Null + foreach($name in @('release-manifest.json','OpenGuidePlatform-GuideSite.zip','OpenGuidePlatform-PlatformBuild.zip')){Copy-Item (Join-Path $global:OgpReleaseAssets $name) $destination} + if($global:OgpReleaseCorrupt){Set-Content (Join-Path $destination 'OpenGuidePlatform-GuideSite.zip') 'corrupt'} + return + } throw 'Unexpected GitHub operation.' } } @@ -34,9 +49,12 @@ Describe 'Coordinated native module publication' { BeforeEach { $global:OgpNativeTagCalls=[Collections.Generic.List[string]]::new() $global:OgpNativeTagExisting=@();$global:OgpExistingRelease=$null + $global:OgpRootTagExisting=@() + $global:OgpMissingRootTag=$false $global:OgpNativeTagFailure=$false $assets=Join-Path $TestDrive ([guid]::NewGuid().ToString('N')) [IO.Directory]::CreateDirectory($assets)|Out-Null + $global:OgpReleaseAssets=$assets;$global:OgpReleaseTagCommit='a'*40;$global:OgpReleaseCorrupt=$false [IO.File]::WriteAllText("$assets/OpenGuidePlatform-GuideSite.zip",'already validated package bytes') [IO.File]::WriteAllText("$assets/OpenGuidePlatform-PlatformBuild.zip",'platform engineering bytes') $manifest=@{version='0.1.0-Preview.1';channel='preview';archive='OpenGuidePlatform-GuideSite.zip';sourceCommit=('a'*40);sha256=(Get-FileHash "$assets/OpenGuidePlatform-GuideSite.zip").Hash.ToLowerInvariant();nativeHugoModule=@{path='github.com/nkdAgility/OpenGuidePlatform/system/OpenGuidePlatform.Hugo.Guides';version='v0.1.0-Preview.1';tag='system/OpenGuidePlatform.Hugo.Guides/v0.1.0-Preview.1';sourceCommit=('a'*40)}} @@ -83,6 +101,55 @@ Describe 'Coordinated native module publication' { { & $publisher -WorkspaceRoot $root -OutputPath $assets }|Should -Throw '*Existing release identity differs*' @($global:OgpNativeTagCalls|Where-Object {$_ -match '^release create '}).Count|Should -Be 0 } + It 'completes an empty release whose immutable tag matches despite branch target metadata' { + $global:OgpExistingRelease=@{targetCommitish='main';isDraft=$false;isPrerelease=$true;assets=@()} + & $publisher -WorkspaceRoot $root -OutputPath $assets + @($global:OgpNativeTagCalls|Where-Object {$_ -like 'release upload *'}).Count|Should -Be 1 + @($global:OgpNativeTagCalls|Where-Object {$_ -match '--clobber|release create'}).Count|Should -Be 0 + } + It 'rejects an empty release whose actual tag differs before uploading' { + $global:OgpExistingRelease=@{targetCommitish=('a'*40);isDraft=$false;isPrerelease=$true;assets=@()} + $global:OgpReleaseTagCommit='b'*40 + {& $publisher -WorkspaceRoot $root -OutputPath $assets}|Should -Throw '*Existing immutable release tag differs*' + @($global:OgpNativeTagCalls|Where-Object {$_ -like 'release upload *'}).Count|Should -Be 0 + } + It 'refuses partial release assets without uploading' { + $global:OgpExistingRelease=@{isDraft=$false;isPrerelease=$true;assets=@(@{name='release-manifest.json'})} + {& $publisher -WorkspaceRoot $root -OutputPath $assets}|Should -Throw '*partial or unexpected*' + @($global:OgpNativeTagCalls|Where-Object {$_ -like 'release upload *'}).Count|Should -Be 0 + } + It 'refuses an existing release with no immutable version tag' { + $global:OgpExistingRelease=@{targetCommitish='main';isDraft=$false;isPrerelease=$true;assets=@()} + $global:OgpMissingRootTag=$true + {& $publisher -WorkspaceRoot $root -OutputPath $assets}|Should -Throw '*Existing release identity differs*' + @($global:OgpNativeTagCalls|Where-Object {$_ -like 'release upload *'}).Count|Should -Be 0 + } + It 'refuses an empty draft instead of publishing it implicitly' { + $global:OgpExistingRelease=@{isDraft=$true;isPrerelease=$true;assets=@()} + {& $publisher -WorkspaceRoot $root -OutputPath $assets}|Should -Throw '*Existing release identity differs*' + @($global:OgpNativeTagCalls|Where-Object {$_ -like 'release upload *'}).Count|Should -Be 0 + } + It 'rejects a complete release with a different coordinated manifest' { + $global:OgpExistingRelease=@{isDraft=$false;isPrerelease=$true;assets=@(@{name='release-manifest.json'},@{name='OpenGuidePlatform-GuideSite.zip'},@{name='OpenGuidePlatform-PlatformBuild.zip'})} + $other=Join-Path $TestDrive 'other-assets' + Copy-Item $assets $other -Recurse + Add-Content (Join-Path $other 'release-manifest.json') ' ' + $global:OgpReleaseAssets=$other + {& $publisher -WorkspaceRoot $root -OutputPath $assets}|Should -Throw '*Existing release manifest differs*' + @($global:OgpNativeTagCalls|Where-Object {$_ -like 'release upload *'}).Count|Should -Be 0 + } + It 'verifies complete release bytes without replacing assets' { + $global:OgpExistingRelease=@{targetCommitish='main';isDraft=$false;isPrerelease=$true;assets=@(@{name='release-manifest.json'},@{name='OpenGuidePlatform-GuideSite.zip'},@{name='OpenGuidePlatform-PlatformBuild.zip'})} + & $publisher -WorkspaceRoot $root -OutputPath $assets + @($global:OgpNativeTagCalls|Where-Object {$_ -like 'release upload *'}).Count|Should -Be 0 + $global:OgpReleaseCorrupt=$true + {& $publisher -WorkspaceRoot $root -OutputPath $assets}|Should -Throw '*Existing release bytes differ*' + } + It 'resolves an annotated native tag to its peeled commit' { + $global:OgpNativeTagExisting=@((('b'*40)+"`trefs/tags/system/OpenGuidePlatform.Hugo.Guides/v0.1.0-Preview.1"),(('a'*40)+"`trefs/tags/system/OpenGuidePlatform.Hugo.Guides/v0.1.0-Preview.1^{}")) + & $publisher -WorkspaceRoot $root -OutputPath $assets + @($global:OgpNativeTagCalls|Where-Object {$_ -match '^api .*/git/refs --method POST.*system/OpenGuidePlatform.Hugo.Guides/' }).Count|Should -Be 0 + } It 'publishes workspace-relative assets when invoked from another working directory' { $workspace=Split-Path $assets -Parent $relativeAssets=Split-Path $assets -Leaf @@ -102,6 +169,11 @@ Describe 'Coordinated native module publication' { { & $publisher -WorkspaceRoot $root -Repository example/platform -OutputPath $assets } | Should -Throw '*Existing native Hugo tag differs*' $global:OgpNativeTagCalls.Count | Should -Be 0 } + It 'refuses an existing root tag at another commit before creating a release or native tag' { + $global:OgpRootTagExisting=@(('b'*40)+"`trefs/tags/v0.1.0-Preview.1") + {& $publisher -WorkspaceRoot $root -OutputPath $assets}|Should -Throw '*Existing immutable release tag differs*' + $global:OgpNativeTagCalls.Count|Should -Be 0 + } It 'does not publish the platform when module publication fails' { $global:OgpNativeTagFailure=$true { & $publisher -WorkspaceRoot $root -Repository example/platform -OutputPath $assets } | Should -Throw '*Native Hugo tag publication failed*' @@ -114,4 +186,4 @@ Describe 'Coordinated native module publication' { $global:OgpNativeTagCalls.Count | Should -Be 0 } } -AfterAll { Remove-Variable OgpNativeTagCalls,OgpNativeTagExisting,OgpExistingRelease,OgpNativeTagFailure -Scope Global -ErrorAction SilentlyContinue } +AfterAll { Remove-Variable OgpNativeTagCalls,OgpNativeTagExisting,OgpExistingRelease,OgpNativeTagFailure,OgpReleaseAssets,OgpReleaseTagCommit,OgpReleaseCorrupt -Scope Global -ErrorAction SilentlyContinue } diff --git a/tests/Core/PackagedRelease.Tests.ps1 b/tests/Core/PackagedRelease.Tests.ps1 index 95cd66b..fa8b8cd 100644 --- a/tests/Core/PackagedRelease.Tests.ps1 +++ b/tests/Core/PackagedRelease.Tests.ps1 @@ -1,10 +1,17 @@ BeforeAll { $root=Split-Path (Split-Path $PSScriptRoot -Parent) -Parent $launcher=Join-Path $root 'system/OpenGuidePlatform.PowerShell.PlatformBuild/Release/release.ps1' + function git { + $global:LASTEXITCODE=0 + if($global:OgpPackagedMissingTag){return} + if($global:OgpPackagedAnnotatedTag){return @((('b'*40)+"`t"+$args[2]),($global:OgpPackagedReleaseCommit+"`t"+$args[3]))} + return $global:OgpPackagedReleaseCommit+"`t"+$args[2] + } function global:gh { $global:LASTEXITCODE=0 + if($args[0] -eq 'api' -and $args[1] -like 'repos/*/commits/*'){return $global:OgpPackagedReleaseCommit} if($args[0] -eq 'release' -and $args[1] -eq 'view'){ - return (@{targetCommitish=('a'*40);isDraft=$false;isPrerelease=$false}|ConvertTo-Json) + return (@{targetCommitish='main';isDraft=$false;isPrerelease=$false}|ConvertTo-Json) } if($args[0] -eq 'release' -and $args[1] -eq 'download'){ $index=[Array]::IndexOf($args,'--dir') @@ -23,6 +30,9 @@ Describe 'Packaged release launcher' { $global:OgpPackagedReleaseAssets=Join-Path $TestDrive ([guid]::NewGuid().ToString('N')) [IO.Directory]::CreateDirectory($global:OgpPackagedReleaseAssets)|Out-Null $global:OgpPackagedReleaseCorrupt=$false + $global:OgpPackagedReleaseCommit='a'*40 + $global:OgpPackagedMissingTag=$false + $global:OgpPackagedAnnotatedTag=$false @{version='1.2.3';sourceCommit=('a'*40)}|ConvertTo-Json|Set-Content "$global:OgpPackagedReleaseAssets/release-manifest.json" Set-Content "$global:OgpPackagedReleaseAssets/OpenGuidePlatform-GuideSite.zip" 'guide bytes' Set-Content "$global:OgpPackagedReleaseAssets/OpenGuidePlatform-PlatformBuild.zip" 'platform bytes' @@ -37,4 +47,16 @@ Describe 'Packaged release launcher' { $global:OgpPackagedReleaseCorrupt=$true { & $launcher -Stage Validate -AssetsPath $global:OgpPackagedReleaseAssets -SourceCommit ('a'*40) }|Should -Throw '*differs from the tested package*' } + It 'rejects an immutable published tag at another commit' { + $global:OgpPackagedReleaseCommit='b'*40 + {& $launcher -Stage Validate -AssetsPath $global:OgpPackagedReleaseAssets -SourceCommit ('a'*40)}|Should -Throw '*Published release tag differs*' + } + It 'rejects a missing tag even when release metadata and a branch could match' { + $global:OgpPackagedMissingTag=$true + {& $launcher -Stage Validate -AssetsPath $global:OgpPackagedReleaseAssets -SourceCommit ('a'*40)}|Should -Throw '*Published release tag differs*' + } + It 'validates the peeled commit of an annotated immutable version tag' { + $global:OgpPackagedAnnotatedTag=$true + & $launcher -Stage Validate -AssetsPath $global:OgpPackagedReleaseAssets -SourceCommit ('a'*40) + } } diff --git a/tests/Core/SiteTranslationWork.Tests.ps1 b/tests/Core/SiteTranslationWork.Tests.ps1 index 72cb8d2..4ba39d0 100644 --- a/tests/Core/SiteTranslationWork.Tests.ps1 +++ b/tests/Core/SiteTranslationWork.Tests.ps1 @@ -164,4 +164,73 @@ Describe 'Site language work inventory' { $work.Guides[1].SourceLanguageSelected|Should -BeTrue $work.Guides[1].CanCreateScaffold|Should -BeFalse } + It 'refuses ambiguous source catalogues for and preserves target bytes' -ForEach @( + @{TargetState='new target';Existing=$false}, + @{TargetState='existing target with a different extension';Existing=$true} + ) { + Set-Content "$workspace/site/hugo.production.yaml" "languages:`n kn:`n disabled: true" + Set-Content "$workspace/site/i18n/en.yml" 'home: Other source' + $target='site/i18n/kn.yaml' + $arguments=@{WorkspaceRoot=$workspace;Policy=$policy;Language='kn';CandidateContent='home: Translated'} + if($Existing){ + $target='site/i18n/kn.yml' + Set-Content "$workspace/$target" 'home: Existing translation' + $hash=(Get-FileHash "$workspace/$target").Hash + $arguments.ExpectedSha256=$hash + } + {Set-GuideWrapperTranslation @arguments -RelativePath $target}|Should -Throw '*uniquely discovered*' + if($Existing){(Get-FileHash "$workspace/$target").Hash|Should -Be $hash} + else{Test-Path "$workspace/$target"|Should -BeFalse} + } + It 'reports timestamp strings versus numbers as schema drift and preserves an existing target' { + Set-Content "$workspace/site/data/home/en.json" '{"title":"2026-09-27T12:00:00Z"}' + Set-Content "$workspace/site/data/home/kn.json" '{"title":42}' + $hash=(Get-FileHash "$workspace/site/data/home/kn.json").Hash + $json=@((Get-GuideSiteTranslationWork -WorkspaceRoot $workspace -Policy $policy -Language kn).Wrappers|Where-Object Kind -EQ json-text-selection-required)[0] + $json.State|Should -Be unsupported-json-schema-reconciliation + {Set-GuideWrapperTranslation -WorkspaceRoot $workspace -Policy $policy -Language kn -RelativePath site/data/home/kn.json -CandidateContent '{"title":"Translated"}' -ExpectedSha256 $hash -ExpectedSourceSha256 $json.SourceSha256 -JsonTextPaths '/title'}|Should -Throw '*Schema reconciliation is unsupported*' + (Get-FileHash "$workspace/site/data/home/kn.json").Hash|Should -Be $hash + } + It 'accepts compatible timestamp string leaves and preserves unselected literal strings' { + Set-Content "$workspace/site/hugo.production.yaml" "languages:`n kn:`n disabled: true" + $source='{"title":"2026-09-27T12:00:00Z","published":"2026-09-27T12:00:00+00:00","items":[null,true,1,"Text"]}' + Set-Content "$workspace/site/data/home/en.json" $source + Set-Content "$workspace/site/data/home/kn.json" $source + $json=@((Get-GuideSiteTranslationWork -WorkspaceRoot $workspace -Policy $policy -Language kn).Wrappers|Where-Object Kind -EQ json-text-selection-required)[0] + $json.State|Should -Be existing-review-required + $candidate=$source.Replace('2026-09-27T12:00:00Z','Translated date') + $arguments=@{WorkspaceRoot=$workspace;Policy=$policy;Language='kn';RelativePath='site/data/home/kn.json';ExpectedSha256=$json.TargetSha256;ExpectedSourceSha256=$json.SourceSha256;JsonTextPaths='/title'} + (Set-GuideWrapperTranslation @arguments -CandidateContent $candidate).Status|Should -Be updated + [IO.File]::ReadAllText("$workspace/site/data/home/kn.json")|Should -BeExactly $candidate + $arguments.ExpectedSha256=(Get-FileHash "$workspace/site/data/home/kn.json").Hash + {Set-GuideWrapperTranslation @arguments -CandidateContent $candidate.Replace('2026-09-27T12:00:00+00:00','2026-09-27T12:00:00Z')}|Should -Throw '*unselected*' + (Get-FileHash "$workspace/site/data/home/kn.json").Hash|Should -Be $arguments.ExpectedSha256 + } + It 'preserves case-distinct machine keys, nested arrays and null values' { + Set-Content "$workspace/site/hugo.production.yaml" "languages:`n kn:`n disabled: true" + $source='{"title":"Hello","Name":"Upper","name":"Lower","values":[[],[null],null,{"Null":null,"null":false}]}' + Set-Content "$workspace/site/data/home/en.json" $source + $candidate=$source.Replace('Hello','Translated') + $arguments=@{WorkspaceRoot=$workspace;Policy=$policy;Language='kn';RelativePath='site/data/home/kn.json';ExpectedSourceSha256=(Get-FileHash "$workspace/site/data/home/en.json").Hash;JsonTextPaths='/title'} + (Set-GuideWrapperTranslation @arguments -CandidateContent $candidate).Status|Should -Be created + [IO.File]::ReadAllText("$workspace/site/data/home/kn.json")|Should -BeExactly $candidate + $arguments.ExpectedSha256=(Get-FileHash "$workspace/site/data/home/kn.json").Hash + {Set-GuideWrapperTranslation @arguments -CandidateContent $candidate.Replace('Lower','Changed')}|Should -Throw '*unselected*' + {Set-GuideWrapperTranslation @arguments -CandidateContent $candidate.Replace('[null]','[]')}|Should -Throw '*array structure*' + (Get-FileHash "$workspace/site/data/home/kn.json").Hash|Should -Be $arguments.ExpectedSha256 + } + It 'reports duplicate identical JSON keys as invalid without writes' { + Set-Content "$workspace/site/data/home/en.json" '{"title":"First","title":"Second"}' + $json=@((Get-GuideSiteTranslationWork -WorkspaceRoot $workspace -Policy $policy -Language kn).Wrappers|Where-Object Kind -EQ json-text-selection-required)[0] + $json.State|Should -Be unsupported-invalid-json + {Set-GuideWrapperTranslation -WorkspaceRoot $workspace -Policy $policy -Language kn -RelativePath site/data/home/kn.json -CandidateContent '{"title":"Translated"}' -ExpectedSourceSha256 $json.SourceSha256 -JsonTextPaths '/title'}|Should -Throw '*JSON is invalid*' + Test-Path "$workspace/site/data/home/kn.json"|Should -BeFalse + } + It 'updates a uniquely discovered existing catalogue with its retained extension' { + Set-Content "$workspace/site/i18n/kn.yml" 'home: Existing translation' + $hash=(Get-FileHash "$workspace/site/i18n/kn.yml").Hash + (Set-GuideWrapperTranslation -WorkspaceRoot $workspace -Policy $policy -Language kn -RelativePath site/i18n/kn.yml -CandidateContent 'home: Reviewed translation' -ExpectedSha256 $hash).Status|Should -Be updated + [IO.File]::ReadAllText("$workspace/site/i18n/kn.yml")|Should -BeExactly 'home: Reviewed translation' + Test-Path "$workspace/site/i18n/kn.yaml"|Should -BeFalse + } } diff --git a/tests/Core/WrapperPublishing.Tests.ps1 b/tests/Core/WrapperPublishing.Tests.ps1 index d5390ef..9f20817 100644 --- a/tests/Core/WrapperPublishing.Tests.ps1 +++ b/tests/Core/WrapperPublishing.Tests.ps1 @@ -10,6 +10,7 @@ Describe 'Reviewed wrapper translations' { $policy=Get-Content "$root/tests/Contracts/fixtures/single-guide.site-policy.json" -Raw|ConvertFrom-Json -AsHashtable [IO.File]::WriteAllText("$workspace/site/hugo.yaml","title: Bespoke`nlanguages:`n en:`n languageName: English`n") [IO.File]::WriteAllText("$workspace/site/hugo.production.yaml","title: Bespoke`nlanguages:`n en:`n disabled: false`n fa:`n disabled: true`n") + [IO.File]::WriteAllText("$workspace/site/i18n/en.yaml","- id: home`n translation: Home`n- id: search`n translation: Search`n") $argsForWrapper=@{WorkspaceRoot=$workspace;Policy=$policy;Language='fa'} $candidate="---`ntitle: فارسی`n---`nمتن فارسی`n" } @@ -28,12 +29,14 @@ Describe 'Reviewed wrapper translations' { (Set-GuideWrapperTranslation @argsForWrapper -RelativePath site/content/_index.fa.md -ExpectedSha256 $hash -CandidateContent ($candidate+'more')).Status | Should -Be updated } It 'creates and reconciles catalogues with exact reviewed text' { + $sourceHash=(Get-FileHash "$workspace/site/i18n/en.yaml").Hash $catalogue="- id: home`n translation: خانه`n" Set-GuideWrapperTranslation @argsForWrapper -RelativePath site/i18n/fa.yaml -CandidateContent $catalogue | Out-Null $hash=(Get-FileHash "$workspace/site/i18n/fa.yaml").Hash $updated=$catalogue+"- id: search`n translation: جستجو`n" Set-GuideWrapperTranslation @argsForWrapper -RelativePath site/i18n/fa.yaml -ExpectedSha256 $hash -CandidateContent $updated | Out-Null Get-Content "$workspace/site/i18n/fa.yaml" -Raw | Should -Be $updated + (Get-FileHash "$workspace/site/i18n/en.yaml").Hash | Should -Be $sourceHash { Set-GuideWrapperTranslation @argsForWrapper -RelativePath site/i18n/fa.yml -CandidateContent $catalogue } | Should -Throw '*ambiguous*' } It 'changes only the selected language in existing configuration' { @@ -81,4 +84,9 @@ Describe 'Reviewed wrapper translations' { (Get-FileHash "$workspace/site/content/_index.fa.md").Hash | Should -Be $hash @(Get-ChildItem "$workspace/site/content" -Filter '*wrapper-lock').Count | Should -Be 0 } -} \ No newline at end of file + It 'refuses catalogue creation when no authoritative source catalogue is discovered' { + Remove-Item -LiteralPath "$workspace/site/i18n/en.yaml" + { Set-GuideWrapperTranslation @argsForWrapper -RelativePath site/i18n/fa.yaml -CandidateContent "- id: home`n translation: خانه`n" } | Should -Throw '*uniquely discovered authoritative source*' + Test-Path "$workspace/site/i18n/fa.yaml" | Should -BeFalse + } +}