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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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.'}
Expand Down
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -47,20 +74,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'}}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
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-GuideTranslationJson ([IO.File]::ReadAllText((Resolve-GuideWorkspacePath $WorkspaceRoot $item.SourcePath)))
if($item.TargetSha256){
$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}
}
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}}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Enforce source-catalogue ambiguity in the writer

When both source catalogues (for example, i18n/en.yaml and i18n/en.yml) exist but neither target catalogue exists, this marks both work items unsupported, yet the exported Set-GuideWrapperTranslation catalogue branch only checks whether the alternate target extension exists and will still create either requested target. A caller can therefore bypass the newly reported blocker and translate from an arbitrarily chosen source; make the writer reject catalogue work unless discovery yields one supported catalogue.

AGENTS.md reference: AGENTS.md:L14-L14

Useful? React with 👍 / 👎.

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'))}
}})
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.'}}
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -67,12 +72,14 @@ 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.'}
$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]){
Expand Down
2 changes: 2 additions & 0 deletions system/OpenGuidePlatform.PowerShell.PlatformBuild/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <existing-package-output>` 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.
Expand Down
Loading
Loading