diff --git a/system/OpenGuidePlatform.Agents.Integration/instructions/guide-site.md b/system/OpenGuidePlatform.Agents.Integration/instructions/guide-site.md index dcdaae62..cb150baf 100644 --- a/system/OpenGuidePlatform.Agents.Integration/instructions/guide-site.md +++ b/system/OpenGuidePlatform.Agents.Integration/instructions/guide-site.md @@ -13,9 +13,10 @@ Update them using ./build.ps1 Update -WhatIf, then ./build.ps1 Update on a revie For first installation, use the remote bootstrap command documented in the platform README. Shared skills are in .agents/skills. To load the installed Core module in PowerShell: - $platform = ./.OpenGuidePlatform/Resolve-OpenGuidePlatform.ps1 -WorkspaceRoot $PWD + $platform = ./.OpenGuidePlatform/Resolve-OpenGuidePlatform.ps1 -WorkspaceRoot $PWD -UseInstalled Import-Module "$platform/system/OpenGuidePlatform.PowerShell.Core/OpenGuidePlatform.PowerShell.Core.psd1" Run Prepare and use its generated discovered-site.json inventory for Core operations. Review any intended publishing change before applying it. +For source-language guide body corrections, use Get-GuideContent to select the discovered guide, edition and its source language; use Set-GuideContent with the reviewed SHA-256 and candidate body. For translated documents, including typo fixes, use Set-GuideTranslation with that edition's reviewed source and target hashes. Translation availability is per edition: never infer, create or require a translation in another version merely because it exists in the selected version. Front matter and protected resources must remain intact. Follow the complete human-operated workflow in the installed Core README; the same commands and build checks apply with or without an agent. These instructions guide Codex, Claude and GitHub Copilot; they do not enforce permissions. Independent managed agent controls remain an explicit adoption blocker. diff --git a/system/OpenGuidePlatform.Agents.Integration/skills/USAGE.md b/system/OpenGuidePlatform.Agents.Integration/skills/USAGE.md index fdcad0af..a7a39fc5 100644 --- a/system/OpenGuidePlatform.Agents.Integration/skills/USAGE.md +++ b/system/OpenGuidePlatform.Agents.Integration/skills/USAGE.md @@ -1,12 +1,12 @@ # Using the shared publishing commands -Preview installation distributes these skills through bootstrap.ps1. In the consumer root, run $platform = ./Resolve-OpenGuidePlatform.ps1 -WorkspaceRoot $PWD and import "$platform/system/OpenGuidePlatform.PowerShell.Core/OpenGuidePlatform.PowerShell.Core.psd1". Stable adoption and independent agent controls remain unfinished in E07/E08. Do not import an arbitrary globally installed Core version or invent policy from test fixtures. +Installation and Update distribute these skills and the matching Core module. Follow the "Correct an existing guide" workflow in the resolved package's `system/OpenGuidePlatform.PowerShell.Core/README.md` for module loading, discovery, exact selection, editing and verification. The same commands work without an agent. Do not import an arbitrary globally installed Core version or invent inventory from test fixtures. -In the platform development checkout, load `system/OpenGuidePlatform.PowerShell.Core/OpenGuidePlatform.PowerShell.Core.psd1`. In an adopted site, load Core through its version-locked bootstrap. Set WorkspaceRoot to the consumer repository root and load its reviewed site policy with `Import-GuidePolicy -Path $PolicyPath`. +In an adopted site, resolve `$platform` through `./.OpenGuidePlatform/Resolve-OpenGuidePlatform.ps1 -WorkspaceRoot $PWD -UseInstalled`, then import Core from that package. Run Prepare with a fresh output directory and `-PlatformSource Path -PlatformPath $platform` to use that same version. Load `/discovered-site.json` with Import-GuidePolicy. Keep WorkspaceRoot set to the consumer repository root. Explicit policy inputs remain supported for callers that use them; ordinary contributors use discovery. -Site policy describes an unrestricted collection of guides; counts in fixtures are examples. Core decisions have no agent dependency. Agent instructions do not grant write authority or replace the independent E06 gate. Mutation commands support WhatIf and refuse protected resources under the supplied policy. +The discovered inventory describes an unrestricted collection of guides; counts in fixtures are examples. Core decisions have no agent dependency. Agent instructions do not grant write authority. Independent enforcement requires an externally configured AgentControls evaluator or managed client; installation alone does not enable it. Mutation commands support WhatIf and refuse protected resources under the supplied policy. -Use the shared Prepare report for readiness. Core supports reviewed wrapper Markdown, YAML catalogue and language-configuration edits; preserve the consumer's multilingual structure and bespoke wrapper. Build wiring for PDF environment receipts belongs to E04. +Use the shared Prepare report for readiness. Core supports reviewed wrapper Markdown, YAML catalogue and language-configuration edits; preserve the consumer's multilingual structure and bespoke wrapper. Prepare validates declared generated-PDF receipts; generation and receipt recording are explicit operations. ## Readiness shared with Prepare @@ -14,17 +14,19 @@ For translation status, creation and reconciliation, run the installed consumer ```powershell $readinessOutput = '.processing/translation-status/' + [guid]::NewGuid().ToString('N') -./build.ps1 -Stage Prepare -Target preview -OutputPath $readinessOutput +./build.ps1 -Stage Prepare -Target preview -OutputPath $readinessOutput -PlatformSource Path -PlatformPath $platform ``` Even when Prepare fails, inspect `$readinessOutput/prepare/assessment.json` and `assessment.md` if they exist. Report the outcome, wrapper findings and the selected guide/edition/language inventory. Missing reports or effective evidence are blocked/unknown, never a substitute local-only pass. Required runtime routes/integration points remain pending Build/Validate; text resolution is not translation quality or complete plural-form coverage. -Get-GuideInventory and Get-GuideWrapperStatus are useful detailed diagnostics. A local catalogue-only observation must not replace the effective readiness result above. The platform development equivalent is `./build.ps1 -Product GuideSite -PolicyPath -Stage Prepare -Target preview -OutputPath `. +Get-GuideInventory and Get-GuideWrapperStatus are useful detailed diagnostics. Get-GuideContent provides exact guide/edition/language selection for content corrections. A local catalogue-only observation must not replace the effective readiness result above. The platform development equivalent is `./build.ps1 -Product GuideSite -SourcePath examples/reference-guide-site -Stage Prepare -Target preview -OutputPath `. ## Reviewed wrapper translation edits +For guide translation creation and reconciliation, read `system/OpenGuidePlatform.PowerShell.Core/TranslationReadiness/README.md` in the resolved package. Get-GuideTranslationWork reports source/target content and optional explicit Git comparisons; New-GuideTranslation starts scaffolding; Test-GuideTranslation checks candidates; Set-GuideTranslation applies against reviewed source/target hashes. Both humans and skills use these commands. Refresh Prepare after scaffolding rather than editing discovered inventory. Source comparisons are review evidence, not inferred translator provenance. + Use Set-GuideWrapperTranslation for exact candidate text in a language-specific wrapper Markdown file, its i18n YAML catalogue, or a selected language entry in hugo.yaml/hugo.production.yaml. Supply WorkspaceRoot, Policy, Language, RelativePath and CandidateContent. Existing files require their reviewed ExpectedSha256; scaffolding never silently replaces a populated file. The command checks supplied protected-path policy and refuses guide content. For a new language, first apply a reviewed production configuration candidate with that language disabled, then its main language configuration and wrapper/catalogue files. Configuration edits preserve all unrelated settings and other languages. Preserve the site's existing wrapper paths, metadata, rendering conventions and existing legacy aliases; do not create new shared download aliases. Translate the candidate text within the requested scope rather than inventing a universal wrapper layout. -Each file operation supports WhatIf and publishes through a staged write. Several files are not one transaction: inspect partial progress if an operation fails and rerun Prepare before claiming readiness. Resolved hashes prevent observed stale edits; cooperative locks are not independent enforcement. Run Build/Validate after the complete reviewed change. \ No newline at end of file +Each file operation supports WhatIf and publishes through a staged write. Several files are not one transaction: inspect partial progress if an operation fails and rerun Prepare before claiming readiness. Resolved hashes prevent observed stale edits; cooperative locks are not independent enforcement. Run Build/Validate after the complete reviewed change. diff --git a/system/OpenGuidePlatform.Agents.Integration/skills/guide.transcreate/SKILL.md b/system/OpenGuidePlatform.Agents.Integration/skills/guide.transcreate/SKILL.md index 7b4f8e36..2b4685fc 100644 --- a/system/OpenGuidePlatform.Agents.Integration/skills/guide.transcreate/SKILL.md +++ b/system/OpenGuidePlatform.Agents.Integration/skills/guide.transcreate/SKILL.md @@ -1,12 +1,16 @@ --- name: guide.transcreate -description: "Scaffold an empty guide translation while preserving existing content and production exclusion." +description: "Create a guide translation from discovered source content, or create only an empty scaffold when requested, using shared PowerShell checks and publishing workflows." --- -Read [Core usage](../USAGE.md). Select the consumer's declared guide and edition and the requested language; do not assume English or a latest/ directory. +Read [Core usage](../USAGE.md). Follow the resolved package's `system/OpenGuidePlatform.PowerShell.Core/TranslationReadiness/README.md` for the same complete procedure available without an agent. -Run `New-GuideTranslationScaffold -WorkspaceRoot $WorkspaceRoot -Policy $policy -GuideId $GuideId -EditionId $EditionId -Language $Language` for the explicitly selected editions. Existing files are preserved. The operation requires an explicit disabled production language entry before it creates anything. If that prerequisite is missing, report the exact configuration change needed; never enable production as a workaround. +Select the actual guide, edition and requested language from discovery and user intent. Use Get-GuideTranslationWork to inspect the source, existing target, wrapper observations, downloads and remaining work. Never assume English, a latest/ directory or a fixed guide count. -New files contain source front matter and an empty body. Translate metadata only within the user's requested scope; never copy/translate the guide body as scaffolding. Do not add lang front matter or language-prefix existing aliases. The historical /download/, /downloads/ and /translationsdirectory/ aliases remain only where already implemented; do not copy them into new language scaffolds or require them for new languages. Preserve existing legacy declarations and unrelated guide-specific aliases. Keep structural metadata, fonts, slugs and edition relationships intact. Create and reconcile bespoke-wrapper/catalogue/configuration candidates with Set-GuideWrapperTranslation following [Core usage](../USAGE.md). Keep production disabled and use the existing wrapper conventions; guide scaffolding alone is not a complete wrapper translation. +Use New-GuideTranslation for an absent target. It delegates to the retained New-GuideTranslationScaffold, requires explicit production exclusion and preserves existing content. If the request is only for scaffolding, leave the body empty. If the request authorizes a translation, refresh Prepare, start a candidate from the discovered target and translate against the source. A missing production exclusion requires the scoped configuration prerequisite; never enable production as a workaround. -Run the shared Prepare readiness procedure in Core usage, then Build/Validate after the complete change. Report remaining findings from the same assessment as CI. +The candidate may translate the body, title, description and summary. Preserve structural/custom metadata, aliases, fonts and edition relationships. Do not add lang or extend legacy shared download aliases. Use Test-GuideTranslation and review its findings, source/candidate outlines and the actual language. Preserve links, shortcodes, code examples and deliberate multilingual behavior. Source-identical passages are a review signal, not permission to delete content. + +Apply authorized candidates through Set-GuideTranslation using the source and target hashes captured before editing. A stale input requires reconciliation, not a new hash applied to an old candidate. Existing populated translations should be revised only within the user's requested scope; use source comparisons where relevant. + +Use Set-GuideWrapperTranslation for authorized wrapper/i18n/configuration work. Preserve supplied/protected PDFs; generated PDFs use the existing explicit plan, generation and receipt workflow. Rerun Prepare and full preview/production builds and inspect rendered output. Report changed files, editorial limitations, verification results and remaining work separately. A candidate check never certifies translation quality or approves publication. diff --git a/system/OpenGuidePlatform.Agents.Integration/skills/guide.transreconcile/SKILL.md b/system/OpenGuidePlatform.Agents.Integration/skills/guide.transreconcile/SKILL.md index 08fdc024..30054cdf 100644 --- a/system/OpenGuidePlatform.Agents.Integration/skills/guide.transreconcile/SKILL.md +++ b/system/OpenGuidePlatform.Agents.Integration/skills/guide.transreconcile/SKILL.md @@ -1,14 +1,16 @@ --- name: guide.transreconcile -description: "Audit guide translations and optionally create missing scaffolds without replacing populated translations." +description: "Audit translation readiness or reconcile an existing guide translation against an explicitly selected source revision, preserving translated content and publication intent." --- -Read [Core usage](../USAGE.md) and follow its shared Prepare readiness procedure. Default to reporting. An explicit repair request authorizes the scoped repair operations. +Read [Core usage](../USAGE.md). Follow the resolved package's `system/OpenGuidePlatform.PowerShell.Core/TranslationReadiness/README.md`. An audit remains read-only; an explicit repair or translation-update request authorizes scoped edits. -For authorized missing guide scaffolds, use `New-GuideTranslationScaffold` with explicit guide, edition and language. It preserves existing files and requires production to be explicitly disabled for new scaffolds. A populated translation is not an accidental English copy merely because it has text. Do not delete its body. +Use Get-GuideTranslationWork for the selected guide, edition and language. For source-change reconciliation, obtain the user's explicit comparison revision or an already agreed revision from task context and pass SourceRevision. If missing, ask for the comparison point while continuing the readiness audit. Do not infer the last translated source revision from file dates, Git history or a populated body. If the source moved, use its explicitly identified SourcePathAtRevision; do not silently select another guide. -Do not add lang front matter, prefix aliases automatically, reorder languages by global speaker counts, or enable production. Use Set-GuideWrapperTranslation for reviewed wrapper/i18n candidates following Core usage; existing files require the reviewed source hash. Report unresolved findings after Prepare rather than inferring readiness from file creation. Supplied PDFs and declared fallbacks remain valid. +Review the historical/current source diff alongside the existing target. Start the candidate from the target, preserving useful translated passages and incorporating only authorized changes. Use Test-GuideTranslation to check metadata/body constraints and inspect review findings. Heading differences and source-identical passages are not grounds for automatic deletion. The check does not establish semantic accuracy or full Markdown correctness. -Rerun inventory and the consumer build after authorized edits. Report what was created, what was preserved, and what remains unresolved. +Apply with Set-GuideTranslation using the captured source and target hashes and resolved comparison commit/path. Preserve all metadata except authorized title, description and summary translations. Never add lang, prefix aliases automatically, reorder languages by popularity, or enable production. Report the comparison used as evidence of this review, without claiming it was the translation's prior baseline. -Follow Core usage for effective Prepare evidence. Get-GuideWrapperStatus is an additional diagnostic; local-only catalogue findings must not replace the shared assessment. +For an authorized missing translation, use New-GuideTranslation and refresh Prepare before applying content. New-GuideTranslationScaffold remains available for scaffold-only repairs. Existing populated targets are preserved by creation operations. + +Review wrapper/i18n and download findings. Use Set-GuideWrapperTranslation for scoped wrapper candidates. Preserve supplied PDFs and declared fallback/PDF-only intent. Rerun Prepare and both target builds after the complete change, inspect rendered output, and report completed changes, unresolved findings and remaining editorial review separately. The commands do not deploy or approve production publication. diff --git a/system/OpenGuidePlatform.PowerShell.Core/ContentEditing/Set-GuideContent.ps1 b/system/OpenGuidePlatform.PowerShell.Core/ContentEditing/Set-GuideContent.ps1 new file mode 100644 index 00000000..04cfa003 --- /dev/null +++ b/system/OpenGuidePlatform.PowerShell.Core/ContentEditing/Set-GuideContent.ps1 @@ -0,0 +1,60 @@ +function Set-GuideContent { + <# + .SYNOPSIS + Apply a reviewed body correction to one existing source-language guide document. + .DESCRIPTION + Requires exact discovered identifiers and the SHA-256 of the reviewed file. + Language must be the selected edition's source language. For any translated + document, including typo corrections, use Set-GuideTranslation with both hashes. + Preserves the original UTF-8 front matter bytes, including its delimiters and + any BOM. Does not change metadata, other translations, configuration or PDFs. + Rejects protected, missing or stale files and empty candidate bodies. Restore + missing source documents before correcting them. Run the site build after applying a change; + an updated result does not certify publication readiness or editorial quality. + .EXAMPLE + Set-GuideContent -WorkspaceRoot $PWD.Path -Policy $policy -GuideId my-guide -EditionId 2026 -Language en -ExpectedSha256 $document.Sha256 -CandidateBody $body -WhatIf + #> + [CmdletBinding(SupportsShouldProcess)] + param( + [Parameter(Mandatory)][string]$WorkspaceRoot, + [Parameter(Mandatory)][Collections.IDictionary]$Policy, + [Parameter(Mandatory)][ValidateNotNullOrEmpty()][string]$GuideId, + [Parameter(Mandatory)][ValidateNotNullOrEmpty()][string]$EditionId, + [Parameter(Mandatory)][ValidateNotNullOrEmpty()][string]$Language, + [Parameter(Mandatory)][ValidatePattern('^[a-fA-F0-9]{64}$')][string]$ExpectedSha256, + [Parameter(Mandatory)][ValidateNotNullOrEmpty()][string]$CandidateBody + ) + $selection=@(Get-GuideContent -WorkspaceRoot $WorkspaceRoot -Policy $Policy -GuideId $GuideId -EditionId $EditionId -Language $Language) + if($selection.Count -ne 1){throw 'Select exactly one discovered guide document.'} + $document=$selection[0] + Assert-GuideDocumentOperation -Policy $Policy -GuideId $GuideId -EditionId $EditionId -Language $Language -Operation Source -RelativePath $document.Path + Assert-GuideWriteAllowed -Policy $Policy -RelativePath $document.Path + if(-not $document.Exists){throw 'Source guide document is missing. Restore or create the selected edition source before using Set-GuideContent.'} + if([string]::IsNullOrWhiteSpace($CandidateBody)){throw 'A guide correction must retain a nonempty body.'} + $target=Resolve-GuideWorkspacePath $WorkspaceRoot $document.Path + $original=[IO.File]::ReadAllBytes($target) + $hash=[Convert]::ToHexString([Security.Cryptography.SHA256]::HashData($original)) + if($hash -ine $ExpectedSha256){throw 'Guide file changed since review. Read the current document and review the correction again.'} + $encoding=[Text.UTF8Encoding]::new($false,$true) + $text=$encoding.GetString($original) + $match=[regex]::Match($text,'\A\uFEFF?---\r?\n.*?\r?\n---(?:\r?\n|\z)',[Text.RegularExpressions.RegexOptions]::Singleline) + if(-not $match.Success){throw 'Expected UTF-8 guide Markdown with YAML front matter.'} + $prefix=$match.Value + # A document whose closing delimiter ends at EOF needs a newline before its body. + if(-not $prefix.EndsWith("`n")){throw 'The front matter closing delimiter needs a newline before editing the body.'} + $bytes=$encoding.GetBytes($prefix+$CandidateBody) + $digest=[Convert]::ToHexString([Security.Cryptography.SHA256]::HashData($bytes)).ToLowerInvariant() + $status='unchanged' + if($digest -ine $ExpectedSha256){ + $status='planned' + if($PSCmdlet.ShouldProcess($target,'Apply reviewed guide body correction; preserve front matter')){ + Write-GuideReviewedFile -WorkspaceRoot $WorkspaceRoot -Policy $Policy -RelativePath $document.Path -CandidateBytes $bytes -ExpectedSha256 $ExpectedSha256 -Operation Source -GuideId $GuideId -EditionId $EditionId -Language $Language + $status='updated' + } + } + [pscustomobject]@{ + Status=$status;GuideId=$GuideId;EditionId=$EditionId;Language=$Language + Path=$document.Path;PreviousSha256=$ExpectedSha256.ToLowerInvariant();CandidateSha256=$digest + VerificationRequired=$true + } +} diff --git a/system/OpenGuidePlatform.PowerShell.Core/GuideInventory/Get-GuideContent.ps1 b/system/OpenGuidePlatform.PowerShell.Core/GuideInventory/Get-GuideContent.ps1 new file mode 100644 index 00000000..1ebe40a5 --- /dev/null +++ b/system/OpenGuidePlatform.PowerShell.Core/GuideInventory/Get-GuideContent.ps1 @@ -0,0 +1,59 @@ +function Get-GuideContent { + <# + .SYNOPSIS + List discovered guide documents or inspect an exact selection. + .DESCRIPTION + Uses the same policy-shaped inventory as other Core commands. Load Prepare's + discovered-site.json with Import-GuidePolicy. Filters are exact, case-sensitive + identifiers, not wildcard patterns. Missing documents remain visible. + Returned hashes identify current file bytes, not publication readiness. + .EXAMPLE + Get-GuideContent -WorkspaceRoot $PWD.Path -Policy $policy | + Format-Table GuideId, EditionId, Language, State, WriteAllowed, Path + .EXAMPLE + Get-GuideContent -WorkspaceRoot $PWD.Path -Policy $policy -GuideId my-guide -EditionId 2026 -Language en + #> + [CmdletBinding()] + param( + [Parameter(Mandatory)][string]$WorkspaceRoot, + [Parameter(Mandatory)][Collections.IDictionary]$Policy, + [ValidateNotNullOrEmpty()][string]$GuideId, + [ValidateNotNullOrEmpty()][string]$EditionId, + [ValidateNotNullOrEmpty()][string]$Language + ) + $inventory=Get-GuideInventory -WorkspaceRoot $WorkspaceRoot -Policy $Policy + $results=@(foreach($guide in $inventory.Guides){ + if($GuideId -and $guide.Id -cne $GuideId){continue} + foreach($edition in $guide.Editions){ + if($EditionId -and $edition.Id -cne $EditionId){continue} + foreach($translation in $edition.Translations){ + if($Language -and $translation.Language -cne $Language){continue} + $path=Resolve-GuideWorkspacePath $WorkspaceRoot $translation.Source + $exists=[IO.File]::Exists($path) + $decision=Test-GuideWritePolicy -Policy $Policy -RelativePath $translation.Source + $digest=$null;$body=$null + if($exists){ + # The body and review hash must describe the same byte snapshot. + $bytes=[IO.File]::ReadAllBytes($path) + $text=[Text.UTF8Encoding]::new($false,$true).GetString($bytes) + $match=[regex]::Match($text,'\A\uFEFF?---\r?\n.*?\r?\n---(?:\r?\n|\z)(?.*)\z',[Text.RegularExpressions.RegexOptions]::Singleline) + if(-not $match.Success){throw "Expected UTF-8 guide Markdown with YAML front matter: $($translation.Source)"} + $digest=[Convert]::ToHexString([Security.Cryptography.SHA256]::HashData($bytes)).ToLowerInvariant() + $body=$match.Groups['body'].Value + } + [pscustomobject]@{ + GuideId=$guide.Id;EditionId=$edition.Id;Language=$translation.Language + SourceLanguage=$edition.SourceLanguage;Path=$translation.Source + Exists=$exists;State=$translation.State;Intent=$translation.Intent + WriteAllowed=$decision.Allowed;WriteReason=$decision.Reason + Sha256=$digest;Body=$body + Downloads=$translation.Downloads + } + } + } + }) + if(-not $results.Count -and ($GuideId -or $EditionId -or $Language)){ + throw 'No discovered guide content matches those exact identifiers. Run Get-GuideContent without filters to list available selections.' + } + $results +} diff --git a/system/OpenGuidePlatform.PowerShell.Core/Internal/GuideDocuments.ps1 b/system/OpenGuidePlatform.PowerShell.Core/Internal/GuideDocuments.ps1 new file mode 100644 index 00000000..84b90044 --- /dev/null +++ b/system/OpenGuidePlatform.PowerShell.Core/Internal/GuideDocuments.ps1 @@ -0,0 +1,81 @@ +function ConvertFrom-GuideMarkdown { + param([Parameter(Mandatory)][string]$Content) + $match=[regex]::Match($Content,'\A\uFEFF?---\r?\n(?.*?)\r?\n---(?:\r?\n|\z)(?.*)\z',[Text.RegularExpressions.RegexOptions]::Singleline) + if(-not $match.Success){throw 'Expected UTF-8 guide Markdown with YAML front matter.'} + Import-Module powershell-yaml -MinimumVersion 0.4.12 -ErrorAction Stop + $metadata=ConvertFrom-Yaml $match.Groups['yaml'].Value -ErrorAction Stop + if($metadata -isnot [Collections.IDictionary]){throw 'Expected mapping front matter.'} + [pscustomobject]@{Content=$Content;Metadata=$metadata;Body=$match.Groups['body'].Value} +} +function Read-GuideSnapshot { + param([string]$WorkspaceRoot,[string]$RelativePath) + $path=Resolve-GuideWorkspacePath $WorkspaceRoot $RelativePath + $bytes=[IO.File]::ReadAllBytes($path) + $document=ConvertFrom-GuideMarkdown -Content ([Text.UTF8Encoding]::new($false,$true).GetString($bytes)) + [pscustomobject]@{ + Path=$RelativePath;Content=$document.Content;Metadata=$document.Metadata;Body=$document.Body + Sha256=[Convert]::ToHexString([Security.Cryptography.SHA256]::HashData($bytes)).ToLowerInvariant() + } +} +function Assert-GuideDocumentOperation { + param([Collections.IDictionary]$Policy,[string]$GuideId,[string]$EditionId,[string]$Language, + [ValidateSet('Source','Translation')][string]$Operation,[string]$RelativePath, + [string]$SourcePath,[string]$ExpectedSourceSha256) + $selection=Get-GuideSelection $Policy $GuideId $EditionId + $source="$($selection.RelativePath)/index.md" + if($Operation -eq 'Source'){ + if($Language -cne $selection.Edition.sourceLanguage){throw 'Wrong command for a translation. Use Set-GuideTranslation with this guide, edition, language and reviewed source and target hashes.'} + if($RelativePath -cne $source -or $SourcePath -or $ExpectedSourceSha256){throw 'Source correction must target the selected edition source only. Use Set-GuideContent with its source language.'} + }elseif($Operation -eq 'Translation'){ + if($Language -ieq $selection.Edition.sourceLanguage){throw 'Wrong command for source content. Use Set-GuideContent with this edition source language and reviewed target hash.'} + $translations=@($selection.Edition.translations|Where-Object language -CEQ $Language) + if($translations.Count -ne 1){throw 'Translation is not uniquely declared for this guide and edition. Use New-GuideTranslation for a missing target, then rerun Prepare.'} + if($RelativePath -cne "$($selection.RelativePath)/index.$Language.md" -or $SourcePath -cne $source){throw 'Translation target and source must belong to the selected guide and edition. Use Set-GuideTranslation with that exact selection.'} + if($ExpectedSourceSha256 -notmatch '^[a-fA-F0-9]{64}$'){throw 'Set-GuideTranslation requires the reviewed source hash as well as the target hash.'} + }else{throw 'A source or translation operation is required; use the corresponding public command.'} +} +function Write-GuideReviewedFile { + param([string]$WorkspaceRoot,[Collections.IDictionary]$Policy,[string]$RelativePath, + [byte[]]$CandidateBytes,[string]$ExpectedSha256,[string]$SourcePath,[string]$ExpectedSourceSha256, + [Parameter(Mandatory)][ValidateSet('Source','Translation')][string]$Operation, + [Parameter(Mandatory)][string]$GuideId,[Parameter(Mandatory)][string]$EditionId,[Parameter(Mandatory)][string]$Language) + function Confirm-Inputs { + Assert-GuideDocumentOperation -Policy $Policy -GuideId $GuideId -EditionId $EditionId -Language $Language -Operation $Operation -RelativePath $RelativePath -SourcePath $SourcePath -ExpectedSourceSha256 $ExpectedSourceSha256 + $checked=Resolve-GuideWorkspacePath $WorkspaceRoot $RelativePath + Assert-GuideWriteAllowed -Policy $Policy -RelativePath $RelativePath + if(-not [IO.File]::Exists($checked) -or (Get-FileHash -LiteralPath $checked -Algorithm SHA256).Hash -ine $ExpectedSha256){throw 'Guide file changed during preparation; correction not applied.'} + if($SourcePath){ + $source=Resolve-GuideWorkspacePath $WorkspaceRoot $SourcePath + if(-not [IO.File]::Exists($source) -or (Get-FileHash -LiteralPath $source -Algorithm SHA256).Hash -ine $ExpectedSourceSha256){throw 'Translation source changed during preparation; candidate not applied.'} + } + $checked + } + $target=Confirm-Inputs + $lockPath=$target+'.content-lock' + $lock=[IO.File]::Open($lockPath,[IO.FileMode]::CreateNew,[IO.FileAccess]::Write,[IO.FileShare]::None) + $temporary=$target+'.candidate-'+[guid]::NewGuid().ToString('N') + try{ + $stream=[IO.File]::Open($temporary,[IO.FileMode]::CreateNew,[IO.FileAccess]::Write,[IO.FileShare]::None) + try{$stream.Write($CandidateBytes,0,$CandidateBytes.Length)}finally{$stream.Dispose()} + $checked=Confirm-Inputs + [IO.File]::Replace($temporary,$checked,[System.Management.Automation.Language.NullString]::Value) + }finally{ + try{if([IO.File]::Exists($temporary)){[IO.File]::Delete($temporary)}}finally{$lock.Dispose();[IO.File]::Delete($lockPath)} + } +} +function Test-GuideValueEqual { + param($Left,$Right) + if($null -eq $Left -or $null -eq $Right){return $null -eq $Left -and $null -eq $Right} + if($Left -is [Collections.IDictionary]){ + if($Right -isnot [Collections.IDictionary] -or $Left.Count -ne $Right.Count){return $false} + foreach($key in $Left.Keys){if(-not $Right.Contains($key) -or -not (Test-GuideValueEqual $Left[$key] $Right[$key])){return $false}} + return $true + } + if($Left -is [Collections.IList]){ + if($Right -isnot [Collections.IList] -or $Left.Count -ne $Right.Count){return $false} + for($index=0;$index -lt $Left.Count;$index++){if(-not (Test-GuideValueEqual $Left[$index] $Right[$index])){return $false}} + return $true + } + return $Left.GetType() -eq $Right.GetType() -and $Left -ceq $Right +} + diff --git a/system/OpenGuidePlatform.PowerShell.Core/OpenGuidePlatform.PowerShell.Core.psd1 b/system/OpenGuidePlatform.PowerShell.Core/OpenGuidePlatform.PowerShell.Core.psd1 index 571e9b7c..eae6f49a 100644 --- a/system/OpenGuidePlatform.PowerShell.Core/OpenGuidePlatform.PowerShell.Core.psd1 +++ b/system/OpenGuidePlatform.PowerShell.Core/OpenGuidePlatform.PowerShell.Core.psd1 @@ -4,7 +4,7 @@ GUID='67db96a0-183a-452a-a09a-494526b6cf60' Author='OpenGuidePlatform contributors' PowerShellVersion='7.4' - FunctionsToExport=@('Read-GuideDocument','Get-GuideLegacyAliasTargets','Test-GuideLegacyAliases','Get-GuideForbiddenPaths','Save-GuidePdfReceipt','Get-GuidePdfReceipts','Resolve-GuideWorkspacePath','Get-GuideLanguage','Test-GuideWritePolicy','Get-GuideTranslationState','Get-GuidePolicyFinding','Test-GuidePublicationPolicy','Import-GuidePolicy','Get-GuideInventory','New-GuideTranslationScaffold','Set-GuideWrapperTranslation','Get-GuideGravatar','New-GuideEdition','New-GuideContributions','Update-GuideContributions','Get-GuidePdfPlan','Get-GuidePdfToolchain','New-GuidePdf','Test-GuidePdfCache','Get-GuideWrapperStatus','Get-GuideAssessment','Get-GuideDownloadRequirements','Test-GuideDownloadPublication') + FunctionsToExport=@('Get-GuideTranslationWork','New-GuideTranslation','Test-GuideTranslation','Set-GuideTranslation','Get-GuideContent','Set-GuideContent','Read-GuideDocument','Get-GuideLegacyAliasTargets','Test-GuideLegacyAliases','Get-GuideForbiddenPaths','Save-GuidePdfReceipt','Get-GuidePdfReceipts','Resolve-GuideWorkspacePath','Get-GuideLanguage','Test-GuideWritePolicy','Get-GuideTranslationState','Get-GuidePolicyFinding','Test-GuidePublicationPolicy','Import-GuidePolicy','Get-GuideInventory','New-GuideTranslationScaffold','Set-GuideWrapperTranslation','Get-GuideGravatar','New-GuideEdition','New-GuideContributions','Update-GuideContributions','Get-GuidePdfPlan','Get-GuidePdfToolchain','New-GuidePdf','Test-GuidePdfCache','Get-GuideWrapperStatus','Get-GuideAssessment','Get-GuideDownloadRequirements','Test-GuideDownloadPublication') CmdletsToExport=@() VariablesToExport=@() AliasesToExport=@() diff --git a/system/OpenGuidePlatform.PowerShell.Core/OpenGuidePlatform.PowerShell.Core.psm1 b/system/OpenGuidePlatform.PowerShell.Core/OpenGuidePlatform.PowerShell.Core.psm1 index bca29610..09ec582e 100644 --- a/system/OpenGuidePlatform.PowerShell.Core/OpenGuidePlatform.PowerShell.Core.psm1 +++ b/system/OpenGuidePlatform.PowerShell.Core/OpenGuidePlatform.PowerShell.Core.psm1 @@ -27,3 +27,11 @@ $script:CoreRoot=$PSScriptRoot . (Join-Path $PSScriptRoot 'PdfPublishing/Get-GuidePdfReceipts.ps1') . (Join-Path $PSScriptRoot 'PublicationPolicy/Get-GuideLegacyAliases.ps1') + +. (Join-Path $PSScriptRoot 'GuideInventory/Get-GuideContent.ps1') +. (Join-Path $PSScriptRoot 'ContentEditing/Set-GuideContent.ps1') + +. (Join-Path $PSScriptRoot 'Internal/GuideDocuments.ps1') + +. (Join-Path $PSScriptRoot 'TranslationReadiness/Get-GuideTranslationWork.ps1') +. (Join-Path $PSScriptRoot 'TranslationReadiness/Edit-GuideTranslation.ps1') diff --git a/system/OpenGuidePlatform.PowerShell.Core/README.md b/system/OpenGuidePlatform.PowerShell.Core/README.md index 4b4ef763..2fe26ee4 100644 --- a/system/OpenGuidePlatform.PowerShell.Core/README.md +++ b/system/OpenGuidePlatform.PowerShell.Core/README.md @@ -1,23 +1,89 @@ -# Publishing Core (candidate) +# Publishing Core -This PowerShell 7.4 module contains publishing operations organised by capability. It accepts an explicit workspace and reviewed site policy; it has no GitHub or agent dependency. Guide and edition collections are not limited to fixture counts. +This PowerShell 7.4 module contains publishing operations organised by capability. It accepts an explicit workspace and policy-shaped inventory, normally produced by Prepare's discovery; explicit reviewed policies remain supported. It has no GitHub or agent dependency. Guide and edition collections are not limited to fixture counts. + +Use the complete workflow below to load the site's installed module and discover its content. Platform developers can import the module from this directory directly. + +Policy loading and document operations use powershell-yaml 0.4.12. Only PDF generation requires Pandoc and XeLaTeX; explicit font choices require font diagnostics. No command installs fonts automatically. Tests require Pester 5.7.1; run `./build.ps1 -Version 0.0.0-local` from the platform root for tests, packaging and sample acceptance. + +Capabilities include inventory and translation readiness, publication exclusions, write-policy decisions, empty translation scaffolding, guide body corrections, draft edition snapshots, contributor records, Gravatar hashing, and PDF generation. Mutation commands support WhatIf. Supplied and protected downloads cannot be regenerated. Supported replacements require reviewed hashes; creation operations refuse or preserve existing destinations. + +PDF language comes from the filename suffix or declared source language and is passed explicitly to Pandoc metadata. Source front matter does not need a lang field. Generation checks native failures and PDF format before publishing a new file; returned evidence includes input, policy, executable and output hashes. Visual review remains necessary. + +This module enforces the supplied policy, not the authenticity of that policy. GuideSite packages distribute these commands through installation and Update. Independent enforcement requires the AgentControls evaluator to be configured with an external trusted baseline; installing skills does not configure that enforcement. + +## Correct an existing guide + +This workflow works in an adopted guide-site repository without an agent. It covers source-language body corrections while preserving front matter exactly. Use Set-GuideTranslation for translated documents, including typo corrections, with both reviewed source and target hashes. Metadata changes and new translations are separate tasks. Use your normal editor to prepare the correction; no editor or AI provider is required by Core. + +### Load the installed commands and discover content + +Run from the repository root in PowerShell 7.4 or later: ```powershell -Import-Module ./system/OpenGuidePlatform.PowerShell.Core/OpenGuidePlatform.PowerShell.Core.psd1 -$policy = Import-GuidePolicy -Path ./path/to/reviewed-site-policy.json -Get-GuideInventory -WorkspaceRoot $PWD.Path -Policy $policy +$workspace = $PWD.Path +$platform = ./.OpenGuidePlatform/Resolve-OpenGuidePlatform.ps1 -WorkspaceRoot $workspace -UseInstalled +Import-Module "$platform/system/OpenGuidePlatform.PowerShell.Core/OpenGuidePlatform.PowerShell.Core.psd1" -Force +$output = '.processing/content-edit/' + [guid]::NewGuid().ToString('N') +./build.ps1 -Stage Prepare -Target preview -OutputPath $output -PlatformSource Path -PlatformPath $platform +$policy = Import-GuidePolicy -Path "$output/discovered-site.json" +Get-GuideContent -WorkspaceRoot $workspace -Policy $policy | + Format-Table GuideId, EditionId, Language, State, WriteAllowed, Path ``` -Policy loading and document operations use powershell-yaml 0.4.12. Only PDF generation requires Pandoc and XeLaTeX; explicit font choices require font diagnostics. No command installs fonts automatically. Tests require Pester 5.7.1; run `./.build/Test-PlatformCore.ps1` from the platform root. +The explicit platform path keeps Prepare on the same installed package as Core, including when settings select a floating release family. If the installed package predates these commands, use the coordinated Update workflow first. If Prepare fails, read `/prepare/assessment.md` and the reported error. Its discovered inventory may still support repairing the reported problem, but it is not evidence of a successful build. If discovery did not produce a valid file, resolve that failure before proceeding. Do not reuse an older run's inventory. Refresh Prepare after adding/removing guides, editions or languages or changing configuration. -Capabilities include inventory and translation readiness, publication exclusions, write-policy decisions, empty translation scaffolding, draft edition snapshots, new contributor records, Gravatar hashing, and new PDF generation. Mutation commands support WhatIf. Supplied and protected downloads cannot be regenerated. Existing destinations are refused or preserved, never force-overwritten. +In the OGP checkout, import `./system/OpenGuidePlatform.PowerShell.Core/OpenGuidePlatform.PowerShell.Core.psd1` instead of invoking the installed resolver, and use `./build.ps1 -Product GuideSite -SourcePath examples/reference-guide-site -Stage Prepare -Target preview -OutputPath $output`. Continue to use the repository root as WorkspaceRoot. -PDF language comes from the filename suffix or declared source language and is passed explicitly to Pandoc metadata. Source front matter does not need a lang field. Generation checks native failures and PDF format before publishing a new file; returned evidence includes input, policy, executable and output hashes. Visual review remains necessary. +### Select, edit and apply -This module enforces the supplied policy, not the authenticity of that policy. The independent trusted gate is E06. Preview installation distributes these commands; native module publication and complete coordinated adoption remain E07/E08 work. +Replace these identifiers with values from the discovery table; there are no fixed guide counts or language lists: + +```powershell +$selection = @{ GuideId = 'your-guide'; EditionId = 'your-edition'; Language = 'en' } +$document = Get-GuideContent -WorkspaceRoot $workspace -Policy $policy @selection +$document | Format-List GuideId, EditionId, Language, Path, WriteAllowed, WriteReason, Sha256 +$candidatePath = Join-Path $workspace "$output/candidate-body.md" +[IO.File]::WriteAllText($candidatePath, $document.Body, [Text.UTF8Encoding]::new($false)) +# Open $candidatePath in your preferred editor. Edit only the body, then save. +$body = [IO.File]::ReadAllText($candidatePath) +$change = @{ + WorkspaceRoot = $workspace; Policy = $policy + ExpectedSha256 = $document.Sha256; CandidateBody = $body +} +Set-GuideContent @selection @change -WhatIf +# After reviewing the candidate and intended destination: +Set-GuideContent @selection @change +git diff -- $document.Path +``` + +Select an existing writable source document using that edition's SourceLanguage from discovery. Set-GuideContent refuses translations and protected content. Missing translations use New-GuideTranslation or the retained New-GuideTranslationScaffold. Keep the original reviewed hash: a stale-file rejection requires reading the new content and reconciling your correction, not just replacing the expected hash. + +Get-GuideContent returns one object per matching translation, including missing files and protected selections. Filters match exact case-sensitive identifiers; unmatched filters fail with guidance. Set-GuideContent requires all three identifiers, preserves UTF-8 front matter bytes and accepts a nonempty replacement body. It returns `planned`, `unchanged` or `updated`, the path and hashes, and `VerificationRequired`. WhatIf does not write a file. Cooperative locks and a second hash check detect observed conflicts; they do not prevent every race with external editors. No other guide, metadata, configuration or PDF is rewritten. + +### Verify the result + +In the adopted site, run the full entry point for both publication targets: + +```powershell +./build.ps1 -Target preview -PlatformSource Path -PlatformPath $platform +./build.ps1 -Target production -PlatformSource Path -PlatformPath $platform +``` + +In OGP, use `./build.ps1 -Product GuideSite -SourcePath examples/reference-guide-site -Target preview` and repeat with `-Target production`. Review failures and warnings, the content diff and rendered output. These builds do not deploy the site. Content corrections may make generated-PDF receipts stale; use the PDF workflow if the assessment requires regeneration. Supplied/protected PDFs remain untouched. A successful write or WhatIf is not a successful build or editorial approval. + +Use `Get-Help Get-GuideContent -Full` and `Get-Help Set-GuideContent -Full` for command help. Agents use this same workflow and verification, with any proposed editorial changes scoped to the user's request. + +### Command boundaries and edition independence + +Commands operate on the exact guide, edition and language selected. A translation in one edition neither supplies a missing translation in another nor requires every edition to be translated. Source language is resolved per edition. Creation preserves existing targets; editing requires an existing target in that selected edition. + +Set-GuideContent edits only the selected edition's source body. Set-GuideTranslation edits its translated document and requires both hashes. The shared document writer rechecks the operation, destination and same-edition source before staging and replacement. Wrapper commands reject guide content, and PDF commands accept only eligible declared PDF downloads. There is no Force switch to bypass these boundaries. These are command correctness checks under the supplied inventory, not restrictions on direct filesystem access by other tools. ## Wrapper publishing and readiness +For complete translation creation and source-change reconciliation, follow [Create and reconcile guide translations](TranslationReadiness/README.md). `Get-GuideTranslationWork`, `New-GuideTranslation`, `Test-GuideTranslation` and `Set-GuideTranslation` provide the same workflow to people and agents, including explicit Git source comparisons and reviewed source/target hashes. Existing scaffold and content commands remain available. + Set-GuideWrapperTranslation creates or applies exact reviewed candidate text to language-specific wrapper Markdown, YAML catalogues and selected Hugo language configuration entries. Existing files require ExpectedSha256; guide content and supplied-policy protected paths are refused. New languages must be disabled in production, unrelated configuration is preserved, and legacy shared download aliases cannot be extended. Changes are staged per file; a multi-file adoption is not one transaction. Use the installed `./build.ps1 -Stage Prepare` assessment for both human/skill translation status and CI. It supplies effective Hugo catalogue/fallback evidence to Core. Local catalogue diagnostics alone must not replace that assessment. See [shared skill usage](../OpenGuidePlatform.Agents.Integration/skills/USAGE.md). diff --git a/system/OpenGuidePlatform.PowerShell.Core/TranslationReadiness/Edit-GuideTranslation.ps1 b/system/OpenGuidePlatform.PowerShell.Core/TranslationReadiness/Edit-GuideTranslation.ps1 new file mode 100644 index 00000000..72adaa0d --- /dev/null +++ b/system/OpenGuidePlatform.PowerShell.Core/TranslationReadiness/Edit-GuideTranslation.ps1 @@ -0,0 +1,145 @@ +function New-GuideTranslation { + <# + .SYNOPSIS + Start a translation with the existing safe scaffold operation and a work report. + .DESCRIPTION + Creates an empty body only when production explicitly disables the language. + Existing translations are preserved. Refresh Prepare after creation; the + returned work report is not a replacement for discovered inventory. + .EXAMPLE + New-GuideTranslation -WorkspaceRoot $PWD.Path -Policy $policy -GuideId guide-a -EditionId 2026 -Language fr -WhatIf + #> + [CmdletBinding(SupportsShouldProcess)] + param([Parameter(Mandatory)][string]$WorkspaceRoot,[Parameter(Mandatory)][Collections.IDictionary]$Policy, + [Parameter(Mandatory)][string]$GuideId,[Parameter(Mandatory)][string]$EditionId, + [Parameter(Mandatory)][string]$Language) + $argsForWork=@{WorkspaceRoot=$WorkspaceRoot;Policy=$Policy;GuideId=$GuideId;EditionId=$EditionId;Language=$Language} + $work=Get-GuideTranslationWork @argsForWork + if($work.Target){return [pscustomobject]@{Status='preserved';Work=$work;RefreshDiscoveryRequired=(-not $work.TargetDeclared)}} + if(-not $work.CanCreateScaffold){throw "Cannot create this translation. $($work.Findings.Action -join ' ')"} + $status='planned' + if($PSCmdlet.ShouldProcess($work.TargetPath,'Create an empty translation scaffold')){ + $created=New-GuideTranslationScaffold @argsForWork -Confirm:$false + $status=$created.Status + $work=Get-GuideTranslationWork @argsForWork + } + [pscustomobject]@{Status=$status;Work=$work;RefreshDiscoveryRequired=($status -eq 'created')} +} +function Get-GuideTranslationOutline { + param([string]$Body) + # Review aid for ATX headings only; this is deliberately not a Markdown parser. + $fence=$null + foreach($line in ($Body -split '\r?\n')){ + if($line -match '^ {0,3}(`{3,}|~{3,})'){ + $token=$Matches[1] + if(-not $fence){$fence=$token} + elseif($token[0] -eq $fence[0] -and $token.Length -ge $fence.Length){$fence=$null} + continue + } + if(-not $fence -and $line -match '^ {0,3}(#{1,6})(?:\s+|$)(.*)$'){ + [pscustomobject]@{Level=$Matches[1].Length;Text=$Matches[2]} + } + } +} +function Test-GuideTranslation { + <# + .SYNOPSIS + Check a complete translation candidate without writing it. + .DESCRIPTION + Validates YAML metadata and a nonempty body. Only title, description and summary + metadata may differ from the selected target. ATX heading outlines are review + aids, not a full Markdown or translation-quality check. Run the site build for + rendering, effective wrapper/i18n and publication checks. + .EXAMPLE + Test-GuideTranslation -WorkspaceRoot $PWD.Path -Policy $policy -GuideId guide-a -EditionId 2026 -Language fr -CandidateContent $candidate + #> + [CmdletBinding()] + param([Parameter(Mandatory)][string]$WorkspaceRoot,[Parameter(Mandatory)][Collections.IDictionary]$Policy, + [Parameter(Mandatory)][string]$GuideId,[Parameter(Mandatory)][string]$EditionId, + [Parameter(Mandatory)][string]$Language,[Parameter(Mandatory)][string]$CandidateContent) + $work=Get-GuideTranslationWork -WorkspaceRoot $WorkspaceRoot -Policy $Policy -GuideId $GuideId -EditionId $EditionId -Language $Language + $findings=[Collections.Generic.List[object]]::new() + function Add-Finding([string]$Code,[string]$Severity,[string]$Action){$findings.Add([pscustomobject]@{Code=$Code;Severity=$Severity;Action=$Action})} + if(-not $work.Target){Add-Finding 'TARGET_MISSING' blocker 'Create a scaffold, then rerun Prepare.'} + if(-not $work.TargetDeclared){Add-Finding 'REFRESH_DISCOVERY' blocker 'Rerun Prepare; the target is not in this inventory.'} + if(-not $work.WriteAllowed){Add-Finding 'PROTECTED_RESOURCE' blocker 'The supplied policy protects the selected translation.'} + $candidate=$null + try{$candidate=ConvertFrom-GuideMarkdown -Content $CandidateContent}catch{Add-Finding 'INVALID_GUIDE_MARKDOWN' blocker $_.Exception.Message} + $sourceOutline=@(Get-GuideTranslationOutline $work.Source.Body);$candidateOutline=@() + if($candidate){ + if($candidate.Metadata.Contains('lang')){Add-Finding 'FRONT_MATTER_LANG' blocker 'Remove lang from Hugo front matter; PDF language is supplied separately.'} + if([string]::IsNullOrWhiteSpace($candidate.Body)){Add-Finding 'TRANSLATION_BODY_EMPTY' blocker 'Supply the translated body.'} + if($work.Target){ + $before=@{};$after=@{} + foreach($key in $work.Target.Metadata.Keys){if($key -cnotin @('title','description','summary')){$before[$key]=$work.Target.Metadata[$key]}} + foreach($key in $candidate.Metadata.Keys){ + if($key -cin @('title','description','summary')){ + if($candidate.Metadata[$key] -isnot [string] -or [string]::IsNullOrWhiteSpace($candidate.Metadata[$key])){Add-Finding 'INVALID_TRANSLATED_METADATA' blocker "Supply nonempty text for $key."} + }else{$after[$key]=$candidate.Metadata[$key]} + } + foreach($key in @('title','description','summary')){ + if($work.Target.Metadata.Contains($key) -and -not $candidate.Metadata.Contains($key)){Add-Finding 'TRANSLATED_METADATA_REMOVED' blocker "Preserve and translate the existing $key field."} + } + if(-not (Test-GuideValueEqual $before $after)){Add-Finding 'STRUCTURAL_METADATA_CHANGED' blocker 'Preserve all metadata except title, description and summary, including aliases, version, layout, fonts and custom fields.'} + } + $candidateOutline=@(Get-GuideTranslationOutline $candidate.Body) + if((@($sourceOutline|ForEach-Object {$_.Level}) -join ',') -cne (@($candidateOutline|ForEach-Object {$_.Level}) -join ',')){Add-Finding 'HEADING_STRUCTURE_REVIEW' review 'ATX heading levels differ from the source. Review omissions and intentional restructuring; setext headings and embedded markup are not assessed.'} + if($candidate.Body.Trim() -ceq $work.Source.Body.Trim()){Add-Finding 'SOURCE_BODY_UNCHANGED' review 'The candidate body matches the source. Confirm this is intentional; text similarity does not establish translation quality.'} + } + [pscustomobject]@{ + Outcome=if(@($findings|Where-Object Severity -EQ blocker).Count){'blocked'}else{'review-required'} + Findings=@($findings);SourceSha256=$work.Source.Sha256 + TargetSha256=if($work.Target){$work.Target.Sha256}else{$null} + SourceOutline=$sourceOutline;CandidateOutline=$candidateOutline + TranslationQualityAssessed=$false;BuildRequired=$true + } +} +function Set-GuideTranslation { + <# + .SYNOPSIS + Apply a reviewed translation document against exact source and target hashes. + .DESCRIPTION + Preserves structural metadata; only the body and title/description/summary may + change. Both current-source and target hashes are mandatory. Optional Git + source comparison is returned as evidence, not stored in front matter and not + interpreted as the revision previously translated. No language is enabled, + PDF generated, or publication approved. Rerun Prepare and both target builds. + .EXAMPLE + Set-GuideTranslation -WorkspaceRoot $PWD.Path -Policy $policy -GuideId guide-a -EditionId 2026 -Language fr -CandidateContent $candidate -ExpectedSourceSha256 $work.Source.Sha256 -ExpectedSha256 $work.Target.Sha256 -WhatIf + #> + [CmdletBinding(SupportsShouldProcess)] + param([Parameter(Mandatory)][string]$WorkspaceRoot,[Parameter(Mandatory)][Collections.IDictionary]$Policy, + [Parameter(Mandatory)][string]$GuideId,[Parameter(Mandatory)][string]$EditionId, + [Parameter(Mandatory)][string]$Language,[Parameter(Mandatory)][string]$CandidateContent, + [Parameter(Mandatory)][ValidatePattern('^[a-fA-F0-9]{64}$')][string]$ExpectedSha256, + [Parameter(Mandatory)][ValidatePattern('^[a-fA-F0-9]{64}$')][string]$ExpectedSourceSha256, + [ValidateNotNullOrEmpty()][string]$SourceRevision,[string]$SourcePathAtRevision) + $selection=@{WorkspaceRoot=$WorkspaceRoot;Policy=$Policy;GuideId=$GuideId;EditionId=$EditionId;Language=$Language} + $history=@{} + if($SourceRevision){$history.SourceRevision=$SourceRevision} + if($SourcePathAtRevision){$history.SourcePathAtRevision=$SourcePathAtRevision} + $work=Get-GuideTranslationWork @selection @history + Assert-GuideDocumentOperation -Policy $Policy -GuideId $GuideId -EditionId $EditionId -Language $Language -Operation Translation -RelativePath $work.TargetPath -SourcePath $work.Source.Path -ExpectedSourceSha256 $ExpectedSourceSha256 + if($work.Source.Sha256 -ine $ExpectedSourceSha256){throw 'Translation source changed since review. Compare the new source and revise the candidate.'} + if(-not $work.Target -or $work.Target.Sha256 -ine $ExpectedSha256){throw 'Translation target changed since review or is missing. Reconcile the candidate with the current target.'} + $check=Test-GuideTranslation @selection -CandidateContent $CandidateContent + if($check.Outcome -eq 'blocked'){throw "Translation candidate blocked: $($check.Findings.Action -join ' ')"} + if($check.SourceSha256 -ine $ExpectedSourceSha256 -or $check.TargetSha256 -ine $ExpectedSha256){throw 'Translation inputs changed during review; candidate not applied.'} + $bytes=[Text.UTF8Encoding]::new($false,$true).GetBytes($CandidateContent) + $digest=[Convert]::ToHexString([Security.Cryptography.SHA256]::HashData($bytes)).ToLowerInvariant() + $status='unchanged' + if($digest -ine $ExpectedSha256){ + $status='planned' + if($PSCmdlet.ShouldProcess($work.TargetPath,'Apply reviewed translation body and editorial metadata')){ + Write-GuideReviewedFile -WorkspaceRoot $WorkspaceRoot -Policy $Policy -RelativePath $work.TargetPath -CandidateBytes $bytes -ExpectedSha256 $ExpectedSha256 -SourcePath $work.Source.Path -ExpectedSourceSha256 $ExpectedSourceSha256 -Operation Translation -GuideId $GuideId -EditionId $EditionId -Language $Language + $status='updated' + } + } + [pscustomobject]@{ + Status=$status;GuideId=$GuideId;EditionId=$EditionId;Language=$Language;Path=$work.TargetPath + SourcePath=$work.Source.Path;SourceSha256=$ExpectedSourceSha256.ToLowerInvariant() + PreviousSha256=$ExpectedSha256.ToLowerInvariant();CandidateSha256=$digest + Comparison=$work.Comparison;Findings=$check.Findings + TranslationQualityAssessed=$false;VerificationRequired=$true + } +} diff --git a/system/OpenGuidePlatform.PowerShell.Core/TranslationReadiness/Get-GuideTranslationWork.ps1 b/system/OpenGuidePlatform.PowerShell.Core/TranslationReadiness/Get-GuideTranslationWork.ps1 new file mode 100644 index 00000000..6eb7b6ae --- /dev/null +++ b/system/OpenGuidePlatform.PowerShell.Core/TranslationReadiness/Get-GuideTranslationWork.ps1 @@ -0,0 +1,109 @@ +function Invoke-GuideGitRead { + param([string]$WorkspaceRoot,[string[]]$Arguments,[int[]]$AllowedExitCodes=@(0)) + $start=[Diagnostics.ProcessStartInfo]::new('git') + $start.UseShellExecute=$false;$start.CreateNoWindow=$true + $start.RedirectStandardOutput=$true;$start.RedirectStandardError=$true + foreach($argument in @('--no-pager','-C',$WorkspaceRoot)+$Arguments){$start.ArgumentList.Add($argument)} + $process=[Diagnostics.Process]::Start($start) + $buffer=[IO.MemoryStream]::new() + try{ + $errors=$process.StandardError.ReadToEndAsync() + $process.StandardOutput.BaseStream.CopyTo($buffer) + $process.WaitForExit() + $detail=$errors.GetAwaiter().GetResult() + if($process.ExitCode -notin $AllowedExitCodes){throw "Cannot read source comparison from Git. Check the revision and source path. $detail"} + return ,$buffer.ToArray() + }finally{$buffer.Dispose();$process.Dispose()} +} +function Get-GuideSourceComparison { + param([string]$WorkspaceRoot,[string]$SourceRevision,[string]$SourcePathAtRevision,$CurrentSource) + $encoding=[Text.UTF8Encoding]::new($false,$true) + $gitRoot=$encoding.GetString((Invoke-GuideGitRead $WorkspaceRoot @('rev-parse','--show-toplevel'))).Trim() + if([IO.Path]::GetFullPath($gitRoot).TrimEnd('/','\') -ne [IO.Path]::GetFullPath($WorkspaceRoot).TrimEnd('/','\')){throw 'Source comparison requires WorkspaceRoot to be the Git repository root.'} + $commit=$encoding.GetString((Invoke-GuideGitRead $WorkspaceRoot @('rev-parse','--verify','--end-of-options',"$SourceRevision^{commit}"))).Trim() + if($commit -notmatch '^[a-f0-9]{40,64}$'){throw 'Git did not resolve one source commit.'} + $historicalPath=if($SourcePathAtRevision){$SourcePathAtRevision}else{$CurrentSource.Path} + $null=Resolve-GuideWorkspacePath $WorkspaceRoot $historicalPath + $bytes=Invoke-GuideGitRead $WorkspaceRoot @('cat-file','blob',"${commit}:$historicalPath") + $baseline=ConvertFrom-GuideMarkdown -Content $encoding.GetString($bytes) + $baselineHash=[Convert]::ToHexString([Security.Cryptography.SHA256]::HashData($bytes)).ToLowerInvariant() + $temporary=Join-Path ([IO.Path]::GetTempPath()) ('ogp-comparison-'+[guid]::NewGuid().ToString('N')) + [IO.Directory]::CreateDirectory($temporary)|Out-Null + $before=Join-Path $temporary 'source-before.md';$after=Join-Path $temporary 'source-current.md' + try{ + [IO.File]::WriteAllBytes($before,$bytes) + [IO.File]::WriteAllText($after,$CurrentSource.Content,$encoding) + $diff=$encoding.GetString((Invoke-GuideGitRead $WorkspaceRoot @('diff','--no-index','--no-ext-diff','--no-textconv','--no-color','--',$before,$after) @(0,1))) + }finally{ + foreach($file in @($before,$after)){if([IO.File]::Exists($file)){[IO.File]::Delete($file)}} + [IO.Directory]::Delete($temporary) + } + [pscustomobject]@{ + Commit=$commit;Path=$historicalPath;Sha256=$baselineHash;Content=$baseline.Content;Body=$baseline.Body + CurrentSha256=$CurrentSource.Sha256;Changed=($baselineHash -cne $CurrentSource.Sha256);Diff=$diff + Meaning='User-selected source comparison; not evidence of the revision previously translated.' + } +} +function Get-GuideTranslationWork { + <# + .SYNOPSIS + Inspect source, translation, optional Git comparison, and remaining publishing work. + .DESCRIPTION + Selects an actual guide and edition from Prepare's discovered inventory. An + absent target is a proposed scaffold path, not a new inventory declaration. + SourceRevision is an explicit Git comparison baseline, never inferred from + the translation. Current source and target hashes include uncommitted edits. + Wrapper observations are local diagnostics; Prepare and builds supply effective + readiness. No translation-quality or publication approval is inferred. + .EXAMPLE + Get-GuideTranslationWork -WorkspaceRoot $PWD.Path -Policy $policy -GuideId guide-a -EditionId 2026 -Language fr + .EXAMPLE + Get-GuideTranslationWork -WorkspaceRoot $PWD.Path -Policy $policy -GuideId guide-a -EditionId 2026 -Language fr -SourceRevision HEAD~1 + #> + [CmdletBinding()] + param( + [Parameter(Mandatory)][string]$WorkspaceRoot, + [Parameter(Mandatory)][Collections.IDictionary]$Policy, + [Parameter(Mandatory)][string]$GuideId,[Parameter(Mandatory)][string]$EditionId, + [Parameter(Mandatory)][ValidatePattern('^[A-Za-z]{2,8}(?:-[A-Za-z0-9]{1,8})*$')][string]$Language, + [ValidateNotNullOrEmpty()][string]$SourceRevision,[string]$SourcePathAtRevision + ) + if($SourcePathAtRevision -and -not $SourceRevision){throw 'SourcePathAtRevision requires an explicit SourceRevision.'} + $selection=Get-GuideSelection $Policy $GuideId $EditionId + if($Language -ieq $selection.Edition.sourceLanguage){throw 'Select a target language different from the source language. Use Set-GuideContent for source corrections in this edition.'} + $source=Read-GuideSnapshot $WorkspaceRoot "$($selection.RelativePath)/index.md" + if([string]::IsNullOrWhiteSpace($source.Body)){throw 'The selected source guide has no body to translate.'} + $targetPath="$($selection.RelativePath)/index.$Language.md" + $targetFile=Resolve-GuideWorkspacePath $WorkspaceRoot $targetPath + $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.'} + $write=Test-GuideWritePolicy -Policy $Policy -RelativePath $targetPath + $productionPath=Resolve-GuideWorkspacePath $WorkspaceRoot "$($Policy.wrapper.sourcePath)/hugo.production.yaml" + $disabled=$false + if([IO.File]::Exists($productionPath)){ + $production=ConvertFrom-Yaml ([IO.File]::ReadAllText($productionPath)) + $disabled=$production -is [Collections.IDictionary] -and $production.Contains('languages') -and + $production.languages -is [Collections.IDictionary] -and $production.languages.Contains($Language) -and + $production.languages[$Language] -is [Collections.IDictionary] -and $production.languages[$Language]['disabled'] -eq $true + } + $comparison=if($SourceRevision){Get-GuideSourceComparison $WorkspaceRoot $SourceRevision $SourcePathAtRevision $source}else{$null} + $findings=@( + 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.'}} + [pscustomobject]@{Code='EDITORIAL_REVIEW_REQUIRED';Action='Review translated body, title, description and summary against the source, including terminology, links, shortcodes and code examples.'} + [pscustomobject]@{Code='EFFECTIVE_READINESS_REQUIRED';Action='Rerun Prepare and full preview/production builds after the complete change; review wrapper, routes, i18n and download findings.'} + if($declared.Count -and @($declared[0].downloads).Count){[pscustomobject]@{Code='DOWNLOAD_REVIEW_REQUIRED';Action='Review declared downloads. Generate only permitted generated PDFs and retain their receipts; preserve supplied/protected PDFs.'}} + ) + [pscustomobject]@{ + GuideId=$GuideId;EditionId=$EditionId;Language=$Language;SourceLanguage=$selection.Edition.sourceLanguage + 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) + 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/README.md b/system/OpenGuidePlatform.PowerShell.Core/TranslationReadiness/README.md new file mode 100644 index 00000000..23a351fb --- /dev/null +++ b/system/OpenGuidePlatform.PowerShell.Core/TranslationReadiness/README.md @@ -0,0 +1,129 @@ +# Create and reconcile guide translations + +These workflows work in PowerShell without an agent. A person supplies translated Markdown through their editor; an agent may propose the same candidate. Both use the same discovery, checks, hash protections and builds. + +## Load and select + +Follow [Core setup](../README.md#load-the-installed-commands-and-discover-content) to resolve `$platform`, import Core, run Prepare into a fresh `$output`, and load `$policy` from its `discovered-site.json`. Use the repository root as `$workspace`. Keep Prepare and subsequent builds on that same package with `-PlatformSource Path -PlatformPath $platform`. + +List available guides and editions with `Get-GuideContent`, then select their actual identifiers and the requested target language: + +```powershell +$selection = @{ + WorkspaceRoot = $workspace; Policy = $policy + GuideId = 'your-guide'; EditionId = 'your-edition'; Language = 'fr' +} +$work = Get-GuideTranslationWork @selection +$work | Format-List GuideId, EditionId, SourceLanguage, Language, TargetIntent, TargetDeclared, CanCreateScaffold +$work.Findings | Format-Table Code, Action -Wrap +$work.Wrapper | Format-List +``` + +The source language comes from the edition. An absent target's proposed path does not create an inventory entry. Wrapper files, local i18n observations and declared downloads are included. Local observations do not establish effective fallback, complete wrapper coverage or publication readiness; use Prepare and later build results. + +Translation availability is independent for each guide edition. A French translation of v1 does not imply a French translation of v2. Editing v1 requires no v2 translation and never edits or creates one. Explicit creation in v2 creates only that target. Source corrections use Set-GuideContent; all translated-document corrections use Set-GuideTranslation with that edition's source and target hashes. The shared writer rejects a source path from another edition and cannot substitute an existing translation from elsewhere. + +## Create a new translation + +```powershell +New-GuideTranslation @selection -WhatIf +$created = New-GuideTranslation @selection +$created.Status +``` + +This delegates to the retained `New-GuideTranslationScaffold`: an empty body with source metadata, existing alias rules and preservation of existing targets. Creation requires an explicit `disabled: true` entry for the language in `hugo.production.yaml`. If missing, review the main/preview settings and apply the production exclusion through the existing wrapper configuration workflow. Never enable production as a workaround. Source/protected paths remain protected. + +The wrapper configuration command updates existing configuration files. If `hugo.yaml` or `hugo.production.yaml` itself is absent, report that setup prerequisite and restore or create the appropriate site-owned configuration within the authorized scope; do not claim the wrapper command can create it or invent a generic site configuration. + +After creation, refresh discovery rather than editing its JSON: + +```powershell +$output = '.processing/translation/' + [guid]::NewGuid().ToString('N') +./build.ps1 -Stage Prepare -Target preview -OutputPath $output -PlatformSource Path -PlatformPath $platform +$policy = Import-GuidePolicy -Path "$output/discovered-site.json" +$selection.Policy = $policy +$work = Get-GuideTranslationWork @selection +``` + +Prepare can report missing translation or wrapper work here. Inspect the assessment: valid discovery can support repairs, but a failed Prepare is not a readiness pass. If discovery failed, resolve it first. Explicit policy callers must review declared intent separately; these commands do not rewrite policy or convert fallback/PDF-only intent. + +## Write, check and apply a candidate + +Start from the existing target, then edit the candidate in your preferred editor: + +```powershell +$candidatePath = Join-Path $workspace "$output/candidate.md" +[IO.File]::WriteAllText($candidatePath, $work.Target.Content, [Text.UTF8Encoding]::new($false)) +# Open $candidatePath in your editor. Use $work.Source.Content as the source. +# Translate the body and, where present, title, description and summary. Save. +$candidate = [IO.File]::ReadAllText($candidatePath) +$check = Test-GuideTranslation @selection -CandidateContent $candidate +$check | Format-List Outcome, TranslationQualityAssessed, BuildRequired +$check.Findings | Format-Table Code, Severity, Action -Wrap +$check.SourceOutline | Format-Table +$check.CandidateOutline | Format-Table +``` + +`blocked` means a prerequisite, metadata constraint or body check failed. `review-required` means no such blocker was found; it does not approve translation quality. Review terminology, omissions, links, shortcodes, code examples and rendered output. ATX heading outlines exclude fenced code but do not fully parse Markdown, setext headings or embedded markup. Outline differences and source-identical text are review findings, never automatic deletion rules. + +Only `title`, `description` and `summary` metadata may change; existing editorial fields must remain present and supplied fields must be nonempty strings. Other metadata must remain semantically unchanged, including aliases, identifiers, dates, version, layout, fonts and custom fields. No `lang` is permitted. Custom metadata translation requires separate explicit review. The exact candidate is written, so review comments and formatting too. + +Use hashes captured when the source and target were read: + +```powershell +$change = @{ + CandidateContent = $candidate + ExpectedSourceSha256 = $work.Source.Sha256 + ExpectedSha256 = $work.Target.Sha256 +} +Set-GuideTranslation @selection @change -WhatIf +$result = Set-GuideTranslation @selection @change +git diff -- $result.Path +``` + +Both hashes are checked before staging and again before replacement. A stale rejection requires reconciling the candidate, not simply substituting new hashes. Cooperative locks coordinate OGP content operations but are not an OS-level transaction with every editor. The command writes one existing target only; source, other translations, wrapper, policy, configuration and PDFs remain untouched. Results contain `planned`, `unchanged` or `updated`, hashes, findings and verification requirements. + +## Reconcile against source changes + +Choose an explicit Git revision useful for the review. It may be the known source revision previously translated, if the contributor has that evidence, or another explicitly selected comparison point. The tool does not infer history from dates, translation commits or a populated body. + +```powershell +$sourceRevision = 'full-commit-id-selected-for-review' +$work = Get-GuideTranslationWork @selection -SourceRevision $sourceRevision +$work.Comparison | Format-List Commit, Path, Sha256, CurrentSha256, Changed, Meaning +$work.Comparison.Diff +$work.Target.Content +``` + +The report includes historical/current source, current translation and a unified source diff. Current content includes uncommitted changes; hash changes include metadata and line endings. If the source moved, pass its exact old repository-relative `-SourcePathAtRevision`. Missing commits/files fail explicitly. Git is required only for historical comparison, not ordinary creation or application. + +Start the candidate from `$work.Target.Content`, preserving translated passages, and revise it against the source diff. Use the candidate check above. To record the comparison in the returned application result, use the resolved immutable commit: + +```powershell +$change = @{ + CandidateContent = $candidate + ExpectedSourceSha256 = $work.Source.Sha256 + ExpectedSha256 = $work.Target.Sha256 + SourceRevision = $work.Comparison.Commit + SourcePathAtRevision = $work.Comparison.Path +} +Set-GuideTranslation @selection @change -WhatIf +$result = Set-GuideTranslation @selection @change +``` + +The result records the comparison and current source hash checked. It does not claim every difference was translated or identify the translation's prior baseline. No tracked provenance file or front matter field is imposed. Include relevant result fields in the change review; optionally save the result under `.processing` using `ConvertTo-Json -Depth 30`. + +## Finish wrapper, downloads and verification + +Review the work report and effective Prepare assessment. Use `Set-GuideWrapperTranslation` for authorized wrapper Markdown, i18n and language configuration, preserving site conventions. Files are separate operations: after a partial failure, inspect completed changes and resume with fresh discovery and hashes. + +Declared generated PDFs use the existing plan/generation/receipt workflow when required. Supplied/protected PDFs stay untouched. PDF generation and visual review remain explicit steps. + +```powershell +./build.ps1 -Target preview -PlatformSource Path -PlatformPath $platform +./build.ps1 -Target production -PlatformSource Path -PlatformPath $platform +``` + +Inspect reports and rendered output, including navigation, links, fallback and downloads. A production build verifies configured exclusion, not approval to enable a language. No deployment occurs. Report changed files, candidate checks, editorial review still needed, build outcomes and unresolved work separately. + +For the OGP reference site, use the development equivalents in the Core README: import the local module and invoke root `build.ps1 -Product GuideSite -SourcePath examples/reference-guide-site` with the same stages and fresh output directories. Shipped skills follow this same procedure. diff --git a/system/OpenGuidePlatform.PowerShell.Core/TranslationReadiness/Set-GuideWrapperTranslation.ps1 b/system/OpenGuidePlatform.PowerShell.Core/TranslationReadiness/Set-GuideWrapperTranslation.ps1 index 4375e1d9..563892d1 100644 --- a/system/OpenGuidePlatform.PowerShell.Core/TranslationReadiness/Set-GuideWrapperTranslation.ps1 +++ b/system/OpenGuidePlatform.PowerShell.Core/TranslationReadiness/Set-GuideWrapperTranslation.ps1 @@ -1,17 +1,6 @@ function Test-GuideWrapperValueEqual { param($Left,$Right) - if($null -eq $Left -or $null -eq $Right){return $null -eq $Left -and $null -eq $Right} - if($Left -is [Collections.IDictionary]){ - if($Right -isnot [Collections.IDictionary] -or $Left.Count -ne $Right.Count){return $false} - foreach($key in $Left.Keys){if(-not $Right.Contains($key) -or -not (Test-GuideWrapperValueEqual $Left[$key] $Right[$key])){return $false}} - return $true - } - if($Left -is [Collections.IList]){ - if($Right -isnot [Collections.IList] -or $Left.Count -ne $Right.Count){return $false} - for($index=0;$index -lt $Left.Count;$index++){if(-not (Test-GuideWrapperValueEqual $Left[$index] $Right[$index])){return $false}} - return $true - } - return $Left.GetType() -eq $Right.GetType() -and $Left -ceq $Right + Test-GuideValueEqual $Left $Right } function Set-GuideWrapperTranslation { [CmdletBinding(SupportsShouldProcess)] diff --git a/system/OpenGuidePlatform.PowerShell.PlatformBuild/OpenGuidePlatform.PowerShell.PlatformBuild.psm1 b/system/OpenGuidePlatform.PowerShell.PlatformBuild/OpenGuidePlatform.PowerShell.PlatformBuild.psm1 index 980c5a67..564d2d32 100644 --- a/system/OpenGuidePlatform.PowerShell.PlatformBuild/OpenGuidePlatform.PowerShell.PlatformBuild.psm1 +++ b/system/OpenGuidePlatform.PowerShell.PlatformBuild/OpenGuidePlatform.PowerShell.PlatformBuild.psm1 @@ -36,6 +36,10 @@ function Test-PlatformCandidateSample { & (Join-Path $PSHOME $(if($IsWindows){'pwsh.exe'}else{'pwsh'})) @arguments if($LASTEXITCODE -ne 0){throw "Packaged sample $target failed."} } + # Exercise human-operated editing against discovered content in a disposable + # sample copy; never edit the source sample or a consumer repository. + & (Join-Path $PSHOME $(if($IsWindows){'pwsh.exe'}else{'pwsh'})) -NoProfile -File "$PSScriptRoot/Testing/Test-ContributorWorkflow.ps1" -WorkspaceRoot $WorkspaceRoot -CandidateRoot $candidate -OutputPath $OutputPath + if($LASTEXITCODE -ne 0){throw 'Packaged contributor workflow failed.'} } function Invoke-PlatformBuild { [CmdletBinding()] diff --git a/system/OpenGuidePlatform.PowerShell.PlatformBuild/README.md b/system/OpenGuidePlatform.PowerShell.PlatformBuild/README.md index d59b84f0..a9f8d374 100644 --- a/system/OpenGuidePlatform.PowerShell.PlatformBuild/README.md +++ b/system/OpenGuidePlatform.PowerShell.PlatformBuild/README.md @@ -1,9 +1,13 @@ # OpenGuidePlatform platform build -This module owns the platform repository build: tool checks, tests, packaging, candidate-sample acceptance and preview publication. It ships in the same package and version as `OpenGuidePlatform.PowerShell.GuideSiteBuild`, which owns composable guide-site stages. The consumer module does not depend on this module. +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. 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. + +The same exercise simulates a missing translation in that disposable copy, scaffolds and rediscovers it, applies a complete candidate, and reconciles it against an explicit source commit/path. It checks source/configuration/PDF preservation and both publication targets. Existing sample translated text is a fixture; this verifies mechanics, not translation quality. + `Invoke-PlatformBuild` accepts WorkspaceRoot, Stage, OutputPath and Version explicitly. `Invoke-PlatformBuildOperation` exposes individual platform operations to existing thin `.build/` entry points. Repository identity for release publication is an explicit parameter, independent of the CI runner. Packaging includes module implementations. Platform tests and the sample are inputs from WorkspaceRoot, not embedded fixtures in the distribution. Actual Azure upload still belongs to the existing workflow adapter; cross-provider deployment and macOS/TeamCity/Azure Pipelines acceptance remain separate gaps, not claims made by this module split. diff --git a/system/OpenGuidePlatform.PowerShell.PlatformBuild/Testing/Test-ContributorWorkflow.ps1 b/system/OpenGuidePlatform.PowerShell.PlatformBuild/Testing/Test-ContributorWorkflow.ps1 new file mode 100644 index 00000000..4eb00259 --- /dev/null +++ b/system/OpenGuidePlatform.PowerShell.PlatformBuild/Testing/Test-ContributorWorkflow.ps1 @@ -0,0 +1,81 @@ +#Requires -Version 7.4 +[CmdletBinding()] +param([Parameter(Mandatory)][string]$WorkspaceRoot,[Parameter(Mandatory)][string]$CandidateRoot,[Parameter(Mandatory)][string]$OutputPath) +$ErrorActionPreference='Stop' +Import-Module "$CandidateRoot/system/OpenGuidePlatform.PowerShell.Core/OpenGuidePlatform.PowerShell.Core.psd1" -Force +$scratch="$OutputPath/contributor-"+[guid]::NewGuid().ToString('N') +$source="$scratch/site" +$destination=Resolve-GuideWorkspacePath $WorkspaceRoot $source +[IO.Directory]::CreateDirectory([IO.Path]::GetDirectoryName($destination))|Out-Null +Copy-Item -LiteralPath "$WorkspaceRoot/examples/reference-guide-site" -Destination $destination -Recurse +$shell=Join-Path $PSHOME $(if($IsWindows){'pwsh.exe'}else{'pwsh'}) +function Invoke-CandidateBuild([string]$Stage,[string]$Target,[string]$Output) { + & $shell -NoProfile -File "$CandidateRoot/build.ps1" -Product GuideSite -WorkspaceRoot $WorkspaceRoot -SourcePath $source -Stage $Stage -Target $Target -OutputPath $Output + if($LASTEXITCODE -ne 0){throw "Contributor workflow $Stage/$Target failed. See $Output."} +} +Invoke-CandidateBuild Prepare preview "$scratch/discovery" +$policy=Import-GuidePolicy -Path "$WorkspaceRoot/$scratch/discovery/discovered-site.json" +$available=@(Get-GuideContent -WorkspaceRoot $WorkspaceRoot -Policy $policy) +$document=$available|Where-Object { $_.Exists -and $_.WriteAllowed -and $_.Language -eq $_.SourceLanguage -and $_.Intent -eq 'web' }|Select-Object -First 1 +if(-not $document){throw 'Contributor acceptance needs a discovered writable source document in the sample.'} +$before=@{} +foreach($file in Get-ChildItem "$destination/content" -Recurse -File){$before[$file.FullName]=(Get-FileHash -LiteralPath $file.FullName).Hash} +$selected=@{WorkspaceRoot=$WorkspaceRoot;Policy=$policy;GuideId=$document.GuideId;EditionId=$document.EditionId;Language=$document.Language} +$marker='Contributor workflow acceptance correction.' +$body=$document.Body+"`n`n$marker`n" +$preview=Set-GuideContent @selected -CandidateBody $body -ExpectedSha256 $document.Sha256 -WhatIf +if($preview.Status -ne 'planned' -or (Get-GuideContent @selected).Sha256 -cne $document.Sha256){throw 'Contributor WhatIf changed content or did not report the plan.'} +$result=Set-GuideContent @selected -CandidateBody $body -ExpectedSha256 $document.Sha256 +if($result.Status -ne 'updated' -or (Get-GuideContent @selected).Body -cne $body){throw 'Contributor correction was not applied to the selected document.'} +$selectedPath=Resolve-GuideWorkspacePath $WorkspaceRoot $document.Path +foreach($path in $before.Keys){ + if($path -ne $selectedPath -and (Get-FileHash -LiteralPath $path).Hash -cne $before[$path]){throw "Contributor workflow changed an unrelated file: $path"} +} +# Simulate a missing translation only in the disposable copy. Choose a language +# already configured as disabled in production; no fixed guide/language inventory. +$productionFile="$destination/hugo.production.yaml" +$productionHash=(Get-FileHash $productionFile).Hash +$production=Get-Content $productionFile -Raw|ConvertFrom-Yaml +$language=@($production.languages.Keys|Where-Object {$production.languages[$_].disabled -eq $true}|Sort-Object)|Select-Object -First 1 +if(-not $language){throw 'Translation acceptance needs a production-disabled sample language.'} +$translationSelection=@{WorkspaceRoot=$WorkspaceRoot;Policy=$policy;GuideId=$document.GuideId;EditionId=$document.EditionId;Language=$language} +$initial=Get-GuideTranslationWork @translationSelection +if(-not $initial.Target -or [string]::IsNullOrWhiteSpace($initial.Target.Body)){throw 'Translation acceptance needs existing sample text to use as its fixture candidate.'} +$fixtureBody=$initial.Target.Body +$translationPath=Resolve-GuideWorkspacePath $WorkspaceRoot $initial.TargetPath +[IO.File]::Delete($translationPath) +Invoke-CandidateBuild Prepare preview "$scratch/translation-missing" +$translationSelection.Policy=Import-GuidePolicy "$WorkspaceRoot/$scratch/translation-missing/discovered-site.json" +if((New-GuideTranslation @translationSelection -WhatIf).Status -ne 'planned' -or [IO.File]::Exists($translationPath)){throw 'Translation creation WhatIf did not preserve the missing target.'} +if((New-GuideTranslation @translationSelection).Status -ne 'created'){throw 'Translation scaffold was not created.'} +Invoke-CandidateBuild Prepare preview "$scratch/translation-scaffold" +$translationSelection.Policy=Import-GuidePolicy "$WorkspaceRoot/$scratch/translation-scaffold/discovered-site.json" +$translationWork=Get-GuideTranslationWork @translationSelection +$translationCandidate=$translationWork.Target.Content+$fixtureBody +if((Test-GuideTranslation @translationSelection -CandidateContent $translationCandidate).Outcome -eq 'blocked'){throw 'Discovered translation candidate was blocked.'} +$translationChange=@{CandidateContent=$translationCandidate;ExpectedSha256=$translationWork.Target.Sha256;ExpectedSourceSha256=$translationWork.Source.Sha256} +if((Set-GuideTranslation @translationSelection @translationChange -WhatIf).Status -ne 'planned'){throw 'Translation application preview failed.'} +$createdTranslation=Set-GuideTranslation @translationSelection @translationChange +if($createdTranslation.Status -ne 'updated'){throw 'Translation content was not applied.'} +$sourceCommit=(& git -C $WorkspaceRoot rev-parse HEAD).Trim() +if($LASTEXITCODE -ne 0){throw 'Cannot resolve sample source comparison commit.'} +$historicalPath='examples/reference-guide-site'+$document.Path.Substring($source.Length) +$reconciliation=Get-GuideTranslationWork @translationSelection -SourceRevision $sourceCommit -SourcePathAtRevision $historicalPath +if(-not $reconciliation.Comparison.Changed -or $reconciliation.Comparison.Diff -notmatch [regex]::Escape($marker)){throw 'Source comparison did not report the sample correction.'} +$revised=$reconciliation.Target.Content+"`nTranslation reconciliation acceptance note.`n" +$reconciled=Set-GuideTranslation @translationSelection -CandidateContent $revised -ExpectedSha256 $reconciliation.Target.Sha256 -ExpectedSourceSha256 $reconciliation.Source.Sha256 -SourceRevision $sourceCommit -SourcePathAtRevision $historicalPath +if($reconciled.Status -ne 'updated' -or -not [IO.File]::ReadAllText($translationPath).StartsWith($translationCandidate,[StringComparison]::Ordinal)){throw 'Reconciliation did not preserve existing translated content.'} +if((Get-FileHash $productionFile).Hash -cne $productionHash){throw 'Translation workflow changed production configuration.'} +if((Get-FileHash $selectedPath).Hash -ine $result.CandidateSha256){throw 'Translation workflow changed source content.'} +foreach($path in $before.Keys){ + if($path -notin @($selectedPath,$translationPath) -and (Get-FileHash -LiteralPath $path).Hash -cne $before[$path]){throw "Translation workflow changed an unrelated file: $path"} +} +foreach($target in @('preview','production')){Invoke-CandidateBuild All $target "$scratch/$target"} +[ordered]@{ + schemaVersion=1;outcome='pass';packageRoot=$CandidateRoot + guide=$document.GuideId;edition=$document.EditionId;language=$document.Language + path=$document.Path;previousSha256=$document.Sha256;candidateSha256=$result.CandidateSha256 + translation=@{language=$language;path=$initial.TargetPath;sourceComparisonCommit=$sourceCommit;sourceComparisonPath=$historicalPath;sha256=$reconciled.CandidateSha256;qualityAssessed=$false} + verification=@('discovered inventory','WhatIf preserves bytes','body applied','other content preserved','translation scaffold and rediscovery','translation application','explicit source comparison and reconciliation','production configuration preserved','preview build','production build') +}|ConvertTo-Json -Depth 5|Set-Content "$WorkspaceRoot/$scratch/result.json" +Write-Host "PASS packaged contributor workflow. Evidence: $scratch/result.json" diff --git a/tests/Core/CommandBoundaries.Tests.ps1 b/tests/Core/CommandBoundaries.Tests.ps1 new file mode 100644 index 00000000..86171dbd --- /dev/null +++ b/tests/Core/CommandBoundaries.Tests.ps1 @@ -0,0 +1,109 @@ +BeforeAll { + $root=Split-Path (Split-Path $PSScriptRoot -Parent) -Parent + Import-Module "$root/system/OpenGuidePlatform.PowerShell.Core/OpenGuidePlatform.PowerShell.Core.psd1" -Force +} +Describe 'Edition-specific command ownership' { + BeforeEach { + $workspace=Join-Path $TestDrive ([guid]::NewGuid().ToString('N')) + $policy=Get-Content "$root/tests/Contracts/fixtures/single-guide.site-policy.json" -Raw|ConvertFrom-Json -AsHashtable + $policy.protectedPaths=@();$guide=$policy.guides[0];$guide.protectSource=$false + $first=$guide.editions[0];$first.id='v1';$first.path='v1';$first.translations=@(@{language='en';intent='web';downloads=@()},@{language='fr';intent='web';downloads=@()}) + $second=@{id='v2';path='v2';sourceLanguage='es';translations=@(@{language='es';intent='web';downloads=@()},@{language='ja';intent='web';downloads=@()})} + $guide.editions+= $second + $prefix="---`ntitle: Guide`ntype: guide`n---`n" + foreach($edition in $guide.editions){ + $directory="$workspace/$($guide.contentRoot)/$($edition.path)" + [IO.Directory]::CreateDirectory($directory)|Out-Null + foreach($translation in $edition.translations){ + $file=if($translation.language -eq $edition.sourceLanguage){'index.md'}else{"index.$($translation.language).md"} + [IO.File]::WriteAllText("$directory/$file",$prefix+"$($edition.id) $($translation.language) body") + } + } + [IO.File]::WriteAllText("$workspace/site/hugo.production.yaml","languages:`n fr:`n disabled: true`n") + [IO.File]::WriteAllText("$workspace/$($guide.contentRoot)/v1/supplied.pdf",'%PDF-preserve') + $first.translations[1].downloads=@(@{path='supplied.pdf';handling='supplied'}) + $before=@{} + foreach($file in Get-ChildItem $workspace -Recurse -File){$before[$file.FullName]=(Get-FileHash $file.FullName).Hash} + $selection=@{WorkspaceRoot=$workspace;Policy=$policy;GuideId=$guide.id;EditionId='v1';Language='fr'} + $work=Get-GuideTranslationWork @selection + } + It 'rejects source-edit commands for a translation before writing, including WhatIf' { + foreach($preview in @($false,$true)){ + {Set-GuideContent @selection -CandidateBody 'Wrong command' -ExpectedSha256 $work.Target.Sha256 -WhatIf:$preview}|Should -Throw '*Set-GuideTranslation*' + } + foreach($file in $before.Keys){(Get-FileHash $file).Hash|Should -Be $before[$file]} + } + It 'rejects translation and wrapper commands for a source document' { + $selection.Language='en' + {Set-GuideTranslation @selection -CandidateContent $work.Source.Content -ExpectedSha256 $work.Source.Sha256 -ExpectedSourceSha256 $work.Source.Sha256}|Should -Throw '*Set-GuideContent*' + {New-GuideTranslationScaffold @selection}|Should -Throw '*source language*' + {Set-GuideWrapperTranslation -WorkspaceRoot $workspace -Policy $policy -Language en -RelativePath $work.Source.Path -ExpectedSha256 $work.Source.Sha256 -CandidateContent $work.Source.Content}|Should -Throw '*guide content*' + foreach($file in $before.Keys){(Get-FileHash $file).Hash|Should -Be $before[$file]} + } + It 'does not borrow a translation from another edition and creates only the requested missing edition' { + $selection.EditionId='v2' + $missing=Get-GuideTranslationWork @selection + $missing.Target|Should -BeNullOrEmpty + $missing.TargetDeclared|Should -BeFalse + {Set-GuideTranslation @selection -CandidateContent $work.Target.Content -ExpectedSha256 $work.Target.Sha256 -ExpectedSourceSha256 $missing.Source.Sha256}|Should -Throw '*New-GuideTranslation*' + Test-Path "$workspace/$($guide.contentRoot)/v2/index.fr.md"|Should -BeFalse + foreach($file in $before.Keys){(Get-FileHash $file).Hash|Should -Be $before[$file]} + (New-GuideTranslation @selection).Status|Should -Be created + (Read-GuideDocument "$workspace/$($guide.contentRoot)/v2/index.fr.md").Body|Should -BeNullOrEmpty + foreach($file in $before.Keys){(Get-FileHash $file).Hash|Should -Be $before[$file]} + $second.translations.language|Should -Not -Contain fr + } + It 'updates an existing translation without requiring it in other editions' { + $candidate=$work.Target.Content+' corrected' + (Set-GuideTranslation @selection -CandidateContent $candidate -ExpectedSha256 $work.Target.Sha256 -ExpectedSourceSha256 $work.Source.Sha256).Status|Should -Be updated + Test-Path "$workspace/$($guide.contentRoot)/v2/index.fr.md"|Should -BeFalse + $target=[IO.Path]::GetFullPath("$workspace/$($work.TargetPath)") + foreach($file in $before.Keys){if($file -ne $target){(Get-FileHash $file).Hash|Should -Be $before[$file]}} + } + It 'uses each edition source language independently' { + $selection.EditionId='v2';$selection.Language='es' + $source=Get-GuideContent @selection + (Set-GuideContent @selection -CandidateBody 'Corrected Spanish source' -ExpectedSha256 $source.Sha256).Status|Should -Be updated + $target=[IO.Path]::GetFullPath("$workspace/$($source.Path)") + foreach($file in $before.Keys){if($file -ne $target){(Get-FileHash $file).Hash|Should -Be $before[$file]}} + } + It 'does not suggest translation scaffolding to replace a missing source' { + $selection.Language='en' + $sourceFile=[IO.Path]::GetFullPath("$workspace/$($work.Source.Path)") + [IO.File]::Delete($sourceFile) + {Set-GuideContent @selection -CandidateBody 'Source' -ExpectedSha256 $work.Source.Sha256}|Should -Throw '*Restore or create the selected edition source*' + Test-Path $sourceFile|Should -BeFalse + foreach($file in $before.Keys){if($file -ne $sourceFile){(Get-FileHash $file).Hash|Should -Be $before[$file]}} + } + It 'retains both creation command names without replacing populated translations' { + (New-GuideTranslation @selection).Status|Should -Be preserved + (New-GuideTranslationScaffold @selection).Status|Should -Be preserved + foreach($file in $before.Keys){(Get-FileHash $file).Hash|Should -Be $before[$file]} + } + It 'rejects PDF generation against supplied PDFs or guide Markdown before invoking rendering' { + {New-GuidePdf @selection -DownloadPath supplied.pdf}|Should -Throw '*supplied and protected*' + $first.translations[1].downloads+=@{path='index.fr.md';handling='generated'} + {New-GuidePdf @selection -DownloadPath index.fr.md}|Should -Throw '*.pdf extension*' + {Set-GuideWrapperTranslation -WorkspaceRoot $workspace -Policy $policy -Language fr -RelativePath $work.TargetPath -ExpectedSha256 $work.Target.Sha256 -CandidateContent $work.Target.Content}|Should -Throw '*guide content*' + foreach($file in $before.Keys){(Get-FileHash $file).Hash|Should -Be $before[$file]} + } + It 'enforces destination and dependency ownership inside the shared writer' { + $base=@{WorkspaceRoot=$workspace;Policy=$policy;GuideId=$guide.id;EditionId='v1';Language='fr';Operation='Translation';RelativePath=$work.TargetPath;CandidateBytes=[Text.Encoding]::UTF8.GetBytes('wrong');ExpectedSha256=$work.Target.Sha256;SourcePath=$work.Source.Path;ExpectedSourceSha256=$work.Source.Sha256} + $cases=@( + @{Operation='Source'}, + @{RelativePath=$work.Source.Path}, + @{SourcePath="$($guide.contentRoot)/v2/index.md"}, + @{RelativePath="$($guide.contentRoot)/v2/index.ja.md"}, + @{RelativePath="$($guide.contentRoot)/v1/supplied.pdf"}, + @{RelativePath='site/hugo.production.yaml'}, + @{ExpectedSourceSha256=''} + ) + foreach($change in $cases){ + $attempt=$base.Clone();foreach($key in $change.Keys){$attempt[$key]=$change[$key]} + {& (Get-Module OpenGuidePlatform.PowerShell.Core) {param($arguments) Write-GuideReviewedFile @arguments} $attempt}|Should -Throw + foreach($file in $before.Keys){(Get-FileHash $file).Hash|Should -Be $before[$file]} + } + @(Get-ChildItem $workspace -Filter '*.content-lock' -Recurse).Count|Should -Be 0 + @(Get-ChildItem $workspace -Filter '*.candidate-*' -Recurse).Count|Should -Be 0 + } +} diff --git a/tests/Core/ContentEditing.Tests.ps1 b/tests/Core/ContentEditing.Tests.ps1 new file mode 100644 index 00000000..a5ca02c6 --- /dev/null +++ b/tests/Core/ContentEditing.Tests.ps1 @@ -0,0 +1,98 @@ +BeforeAll { + $root=Split-Path (Split-Path $PSScriptRoot -Parent) -Parent + Import-Module "$root/system/OpenGuidePlatform.PowerShell.Core/OpenGuidePlatform.PowerShell.Core.psd1" -Force +} +Describe 'Human-operated guide content corrections' { + BeforeEach { + $workspace=Join-Path $TestDrive ([guid]::NewGuid().ToString('N')) + $policy=Get-Content "$root/tests/Contracts/fixtures/single-guide.site-policy.json" -Raw|ConvertFrom-Json -AsHashtable + $policy.protectedPaths=@();$policy.guides[0].protectSource=$false + $guide=$policy.guides[0];$edition=$guide.editions[0] + $directory="$workspace/$($guide.contentRoot)/$($edition.path)" + [IO.Directory]::CreateDirectory("$directory/pdf")|Out-Null + $prefix="---`r`n# Preserve comments and spelling`r`ntitle: 'A guide'`r`nversion: 2024.8`r`naliases: ['/existing/']`r`n---`r`n" + $original=$prefix+"Original body café.`r`n" + [IO.File]::WriteAllText("$directory/index.md",$original,[Text.UTF8Encoding]::new($true)) + $pdf="$directory/$($edition.translations[0].downloads[0].path)" + [IO.File]::WriteAllText($pdf,'%PDF-supplied-preserve') + $edition.translations+=@{language='fr';intent='web';downloads=@()} + [IO.File]::WriteAllText("$directory/index.fr.md",$prefix+'Texte français.') + $edition.translations+=@{language='ja';intent='scaffold';downloads=@()} + $select=@{WorkspaceRoot=$workspace;Policy=$policy;GuideId=$guide.id;EditionId=$edition.id;Language='en'} + $document=Get-GuideContent @select + $edit=$select.Clone();$edit.ExpectedSha256=$document.Sha256;$edit.CandidateBody="Corrected body café.`r`n" + } + It 'supports discover, select, preview, apply and reread without an agent' { + $all=@(Get-GuideContent -WorkspaceRoot $workspace -Policy $policy) + $all.Count|Should -Be 3 + ($all|Where-Object Language -EQ ja).Exists|Should -BeFalse + $before=[IO.File]::ReadAllBytes("$directory/index.md") + $translationHash=(Get-FileHash "$directory/index.fr.md").Hash + $pdfHash=(Get-FileHash $pdf).Hash + (Set-GuideContent @edit -WhatIf).Status|Should -Be planned + (Get-FileHash "$directory/index.md").Hash|Should -Be $document.Sha256 + $result=Set-GuideContent @edit + $result.Status|Should -Be updated + $result.VerificationRequired|Should -BeTrue + $after=[IO.File]::ReadAllBytes("$directory/index.md") + $prefixLength=3+[Text.Encoding]::UTF8.GetByteCount($prefix) + [Convert]::ToBase64String($after[0..($prefixLength-1)])|Should -Be ([Convert]::ToBase64String($before[0..($prefixLength-1)])) + (Get-GuideContent @select).Body|Should -Be $edit.CandidateBody + (Get-GuideContent @select).Sha256|Should -Be $result.CandidateSha256 + (Get-FileHash "$directory/index.fr.md").Hash|Should -Be $translationHash + (Get-FileHash $pdf).Hash|Should -Be $pdfHash + @(Get-ChildItem $directory -Filter '*.content-lock').Count|Should -Be 0 + @(Get-ChildItem $directory -Filter '*.candidate-*').Count|Should -Be 0 + } + It 'retains stable existing files for unchanged bodies' { + $edit.CandidateBody=$document.Body + (Set-GuideContent @edit).Status|Should -Be unchanged + (Get-FileHash "$directory/index.md").Hash|Should -Be $document.Sha256 + } + It 'rejects stale candidates without overwriting subsequent edits' { + [IO.File]::WriteAllText("$directory/index.md",$prefix+'Another editor changed this.') + {Set-GuideContent @edit}|Should -Throw '*changed since review*' + (Read-GuideDocument "$directory/index.md").Body|Should -Be 'Another editor changed this.' + } + It 'reports protected content and refuses mutation even with WhatIf' { + $policy.guides[0].protectSource=$true + (Get-GuideContent @select).WriteAllowed|Should -BeFalse + {Set-GuideContent @edit -WhatIf}|Should -Throw '*PROTECTED_RESOURCE*' + (Get-FileHash "$directory/index.md").Hash|Should -Be $document.Sha256 + } + It 'rejects unmatched identifiers, missing documents and blank bodies' { + $edit.GuideId='does-not-exist' + {Set-GuideContent @edit}|Should -Throw '*No discovered*' + $edit.GuideId=$guide.id;$edit.Language='ja' + {Set-GuideContent @edit}|Should -Throw '*Set-GuideTranslation*' + $edit.Language='en';$edit.CandidateBody=" `r`n " + {Set-GuideContent @edit}|Should -Throw '*nonempty body*' + (Get-FileHash "$directory/index.md").Hash|Should -Be $document.Sha256 + } + It 'supports multiple guides and exact selection without choosing the first match' { + $second=($guide|ConvertTo-Json -Depth 30|ConvertFrom-Json -AsHashtable) + $second.id='another-guide';$second.contentRoot='site/content/another-guide' + $policy.guides+= $second + @(Get-GuideContent -WorkspaceRoot $workspace -Policy $policy -Language en).Count|Should -Be 2 + (Get-GuideContent @select).GuideId|Should -Be $guide.id + $edit.GuideId=$guide.id.ToUpperInvariant() + {Set-GuideContent @edit}|Should -Throw '*No discovered*' + } + It 'does not bypass an existing cooperative lock' { + [IO.File]::WriteAllText("$directory/index.md.content-lock",'another operation') + {Set-GuideContent @edit}|Should -Throw + (Get-FileHash "$directory/index.md").Hash|Should -Be $document.Sha256 + [IO.File]::ReadAllText("$directory/index.md.content-lock")|Should -Be 'another operation' + } + It 'rejects a conflict observed immediately before replacement and cleans staging' { + Mock Get-FileHash -ModuleName OpenGuidePlatform.PowerShell.Core { [pscustomobject]@{Hash=('0'*64)} } + {Set-GuideContent @edit}|Should -Throw '*changed during preparation*' + (Get-FileHash "$directory/index.md").Hash|Should -Be $document.Sha256 + @(Get-ChildItem $directory -Filter '*.content-lock').Count|Should -Be 0 + @(Get-ChildItem $directory -Filter '*.candidate-*').Count|Should -Be 0 + } + It 'provides discoverable command help' { + (Get-Help Set-GuideContent).Synopsis|Should -Match 'reviewed body correction' + (Get-Help Get-GuideContent).examples.example.Count|Should -BeGreaterThan 0 + } +} diff --git a/tests/Core/TranslationWorkflows.Tests.ps1 b/tests/Core/TranslationWorkflows.Tests.ps1 new file mode 100644 index 00000000..e1e3255d --- /dev/null +++ b/tests/Core/TranslationWorkflows.Tests.ps1 @@ -0,0 +1,169 @@ +BeforeAll { + $root=Split-Path (Split-Path $PSScriptRoot -Parent) -Parent + Import-Module "$root/system/OpenGuidePlatform.PowerShell.Core/OpenGuidePlatform.PowerShell.Core.psd1" -Force +} +Describe 'Human-operated translation workflows' { + BeforeEach { + $workspace=Join-Path $TestDrive ([guid]::NewGuid().ToString('N')) + $policy=Get-Content "$root/tests/Contracts/fixtures/single-guide.site-policy.json" -Raw|ConvertFrom-Json -AsHashtable + $policy.protectedPaths=@();$policy.guides[0].protectSource=$false + $guide=$policy.guides[0];$edition=$guide.editions[0] + $edition.sourceLanguage='es';$edition.translations[0].language='es' + $edition.translations+=@{language='fr';intent='web';downloads=@()} + $directory="$workspace/$($guide.contentRoot)/$($edition.path)" + [IO.Directory]::CreateDirectory("$directory/pdf")|Out-Null + $sourcePath="$directory/index.md";$targetPath="$directory/index.fr.md" + $source="---`ntitle: Guía`nversion: 2024.8`ntype: guide`naliases: ['/guide/latest']`n---`n# Introducción`nTexto original.`n" + $target="---`ntitle: Guide`nversion: 2024.8`ntype: guide`naliases: ['/guide/latest']`n---`n# Introduction`nTraduction existante.`n" + [IO.File]::WriteAllText($sourcePath,$source) + [IO.File]::WriteAllText($targetPath,$target) + [IO.File]::WriteAllText("$workspace/site/hugo.production.yaml","languages:`n es:`n disabled: false`n fr:`n disabled: true`n fa:`n disabled: true`n") + [IO.File]::WriteAllText("$directory/pdf/supplied.fr.pdf",'%PDF-protected fixture') + $selection=@{WorkspaceRoot=$workspace;Policy=$policy;GuideId=$guide.id;EditionId=$edition.id;Language='fr'} + $candidate=$target.Replace('title: Guide','title: Guide traduit').Replace('Traduction existante.','Traduction révisée.') + $work=Get-GuideTranslationWork @selection + $edit=$selection.Clone();$edit.CandidateContent=$candidate;$edit.ExpectedSha256=$work.Target.Sha256;$edit.ExpectedSourceSha256=$work.Source.Sha256 + } + It 'reports source, target, wrapper work and an explicit lack of quality approval' { + $work.SourceLanguage|Should -Be es + $work.Source.Body|Should -Match 'Texto original' + $work.Target.Body|Should -Match 'Traduction existante' + $work.Comparison|Should -BeNullOrEmpty + $work.Findings.Code|Should -Contain EFFECTIVE_READINESS_REQUIRED + $work.TranslationQualityAssessed|Should -BeFalse + $work.PublicationVerified|Should -BeFalse + } + It 'creates a missing translation through the retained scaffold without inventing inventory' { + $new=$selection.Clone();$new.Language='fa' + $before=(Get-FileHash $sourcePath).Hash + $config=(Get-FileHash "$workspace/site/hugo.production.yaml").Hash + (New-GuideTranslation @new -WhatIf).Status|Should -Be planned + Test-Path "$directory/index.fa.md"|Should -BeFalse + $created=New-GuideTranslation @new + $created.Status|Should -Be created + $created.Work.Target.Body|Should -BeNullOrEmpty + $created.RefreshDiscoveryRequired|Should -BeTrue + $created.Work.TargetDeclared|Should -BeFalse + $created.Work.Findings.Code|Should -Contain REFRESH_DISCOVERY + $policy.guides[0].editions[0].translations.Count|Should -Be 2 + (Get-FileHash $sourcePath).Hash|Should -Be $before + (Get-FileHash "$workspace/site/hugo.production.yaml").Hash|Should -Be $config + (New-GuideTranslation @new).Status|Should -Be preserved + } + It 'refuses new-language creation without explicit production exclusion' { + $selection.Language='ja' + {New-GuideTranslation @selection}|Should -Throw '*disabled*' + Test-Path "$directory/index.ja.md"|Should -BeFalse + } + It 'previews and applies complete candidates while preserving source, configuration and PDFs' { + $before=@{} + foreach($path in @($sourcePath,"$workspace/site/hugo.production.yaml","$directory/pdf/supplied.fr.pdf")){$before[$path]=(Get-FileHash $path).Hash} + (Test-GuideTranslation @selection -CandidateContent $candidate).Outcome|Should -Be review-required + (Set-GuideTranslation @edit -WhatIf).Status|Should -Be planned + [IO.File]::ReadAllText($targetPath)|Should -BeExactly $target + $result=Set-GuideTranslation @edit + $result.Status|Should -Be updated + $result.VerificationRequired|Should -BeTrue + [IO.File]::ReadAllText($targetPath)|Should -BeExactly $candidate + foreach($path in $before.Keys){(Get-FileHash $path).Hash|Should -Be $before[$path]} + } + It 'preserves populated translations when asked to start them again' { + (New-GuideTranslation @selection).Status|Should -Be preserved + [IO.File]::ReadAllText($targetPath)|Should -BeExactly $target + } + It 'blocks stale source and target candidates' { + [IO.File]::WriteAllText($sourcePath,$source+'new source') + {Set-GuideTranslation @edit}|Should -Throw '*source changed*' + [IO.File]::ReadAllText($targetPath)|Should -BeExactly $target + [IO.File]::WriteAllText($sourcePath,$source) + [IO.File]::WriteAllText($targetPath,$target+'another translator') + {Set-GuideTranslation @edit}|Should -Throw '*target changed*' + [IO.File]::ReadAllText($targetPath)|Should -BeExactly ($target+'another translator') + } + It 'blocks changes to structural metadata, lang, empty bodies and removed editorial fields' { + foreach($invalid in @($candidate.Replace('2024.8','2025.1'),$candidate.Replace('/guide/latest','/downloads/'),$candidate.Replace('type: guide',"type: guide`nlang: fr"),$candidate.Replace('title: Guide traduit' + "`n",''),"---`ntitle: Guide`n---`n")){ + (Test-GuideTranslation @selection -CandidateContent $invalid).Outcome|Should -Be blocked + $edit.CandidateContent=$invalid + {Set-GuideTranslation @edit}|Should -Throw '*blocked*' + } + [IO.File]::ReadAllText($targetPath)|Should -BeExactly $target + } + It 'requires discovery refresh for existing but undeclared targets' { + $edition.translations=@($edition.translations|Where-Object language -NE fr) + (Test-GuideTranslation @selection -CandidateContent $candidate).Findings.Code|Should -Contain REFRESH_DISCOVERY + {Set-GuideTranslation @edit}|Should -Throw '*rerun Prepare*' + } + It 'preserves intentional fallback and PDF-only intent without claiming publication readiness' { + $edition.translations[1].intent='fallback';$edition.translations[1].fallbackLanguage='es' + (Get-GuideTranslationWork @selection).TargetIntent|Should -Be fallback + Set-GuideTranslation @edit|Out-Null + $edition.translations[1].intent|Should -Be fallback + $edition.translations[1].intent='pdf-only';$edition.translations[1].downloads=@(@{path='pdf/supplied.fr.pdf';handling='supplied'}) + $observed=Get-GuideTranslationWork @selection + $observed.Findings.Code|Should -Contain DOWNLOAD_REVIEW_REQUIRED + $observed.PublicationVerified|Should -BeFalse + } + It 'rejects protected targets and source-language selections' { + $policy.protectedPaths=@("$($guide.contentRoot)/$($edition.path)/index.fr.md") + {Set-GuideTranslation @edit}|Should -Throw '*protects*' + $selection.Language='es' + {Get-GuideTranslationWork @selection}|Should -Throw '*different*' + } + It 'provides heading differences as review findings without rewriting content' { + $check=Test-GuideTranslation @selection -CandidateContent $candidate.Replace('# Introduction','## Introduction') + $check.Outcome|Should -Be review-required + $check.Findings.Code|Should -Contain HEADING_STRUCTURE_REVIEW + $check.TranslationQualityAssessed|Should -BeFalse + [IO.File]::ReadAllText($targetPath)|Should -BeExactly $target + } + It 'selects exact guide and edition identities when several are available' { + $other=($guide|ConvertTo-Json -Depth 30|ConvertFrom-Json -AsHashtable) + $other.id='another-guide';$other.contentRoot='site/content/another-guide' + $policy.guides+=$other + $extra=($edition|ConvertTo-Json -Depth 30|ConvertFrom-Json -AsHashtable) + $extra.id='another-edition';$extra.path='another-edition';$guide.editions+=$extra + (Get-GuideTranslationWork @selection).Target.Sha256|Should -Be $work.Target.Sha256 + $selection.EditionId='unknown' + {Get-GuideTranslationWork @selection}|Should -Throw '*exactly one declared edition*' + } + It 'rejects a source conflict observed during staging and preserves the translation' { + $script:sourceChecks=0 + Mock Get-FileHash -ModuleName OpenGuidePlatform.PowerShell.Core -ParameterFilter {[IO.Path]::GetFileName($LiteralPath) -eq 'index.md'} { + $script:sourceChecks++ + [pscustomobject]@{Hash=if($script:sourceChecks -eq 1){$work.Source.Sha256}else{'0'*64}} + } + {Set-GuideTranslation @edit}|Should -Throw '*source changed during preparation*' + [IO.File]::ReadAllText($targetPath)|Should -BeExactly $target + @(Get-ChildItem $directory -Filter '*.candidate-*').Count|Should -Be 0 + @(Get-ChildItem $directory -Filter '*.content-lock').Count|Should -Be 0 + } + It 'compares an explicit historical source with working changes, never inferring translator provenance' { + & git init -q $workspace + & git -C $workspace add . + & git -C $workspace -c user.name=Test -c user.email=test@example.invalid commit -qm baseline + if($LASTEXITCODE -ne 0){throw 'Cannot create Git history fixture.'} + $commit=(& git -C $workspace rev-parse HEAD).Trim() + [IO.File]::WriteAllText($sourcePath,$source.Replace('Texto original.','Texto cambiado.')) + $compared=Get-GuideTranslationWork @selection -SourceRevision $commit + $compared.Comparison.Commit|Should -Be $commit + $compared.Comparison.Diff|Should -Match '\+Texto cambiado' + $compared.Comparison.Diff|Should -Match '\-Texto original' + $compared.Comparison.Changed|Should -BeTrue + $compared.Comparison.Body|Should -Match 'Texto original' + $compared.Target.Body|Should -Match 'Traduction existante' + $compared.Comparison.Meaning|Should -Match 'not evidence' + $edit.ExpectedSourceSha256=$compared.Source.Sha256;$edit.SourceRevision=$commit + (Set-GuideTranslation @edit).Comparison.Commit|Should -Be $commit + $currentRelative="$($guide.contentRoot)/$($edition.path)/index.md" + $historical="$($guide.contentRoot)/$($edition.path)/previous-source.md" + & git -C $workspace mv -- $currentRelative $historical + & git -C $workspace -c user.name=Test -c user.email=test@example.invalid commit -qm moved-source + if($LASTEXITCODE -ne 0){throw 'Cannot create renamed-source history fixture.'} + $renamedCommit=(& git -C $workspace rev-parse HEAD).Trim() + [IO.File]::WriteAllText($sourcePath,$source) + (Get-GuideTranslationWork @selection -SourceRevision $renamedCommit -SourcePathAtRevision $historical).Comparison.Path|Should -Be $historical + {Get-GuideTranslationWork @selection -SourceRevision nonexistent}|Should -Throw '*Git*' + {Get-GuideTranslationWork @selection -SourceRevision $commit -SourcePathAtRevision '../outside.md'}|Should -Throw '*Unsafe*' + {Get-GuideTranslationWork @selection -SourcePathAtRevision 'old.md'}|Should -Throw '*requires*' + } +}