param([switch]$Preview=$true,[switch]$Execute,[switch]$KeepJsonl) # --- Paths & Policy --- $Root="\\DS-918\chatgpt\ChatGPT-Gouvernance-Projets\_registry" $BugDir=Join-Path $Root "bug_kb" $Jsonl =Join-Path $BugDir "BUG_KB.json.txt" $JsonTxt=Join-Path $BugDir "BUG_KB.json.txt" $Boot =Join-Path $Root "bootpack\bootpack.txt" $Logs =Join-Path $Root "logs" $Upd =Join-Path $Root "updates\2025\10\bugs" $Old =Join-Path $Root "_old_versions\bug_kb" $Thr =Join-Path $Root "threads_archive" $Stage ="C:\Temp_Gouvernance" $Iso =Get-Date -Format "yyyy-MM-ddTHH:mm:ssK" $Date =Get-Date -Format "yyyy-MM-dd" $Stamp =Get-Date -Format "yyyyMMdd_HHmmss" $Policy="TXT-ONLY v1.0; SAFE-WRITE v1.1; GOV_SCRIPT_GATE v1.3" $Utf8NoBom=New-Object Text.UTF8Encoding($false) foreach($d in @($BugDir,$Logs,$Upd,$Old,$Thr,$Stage)){ if(!(Test-Path $d)){ New-Item -ItemType Directory -Force -Path $d | Out-Null } } # --- Helpers --- function Ensure-Parent([string]$p){ $dir = Split-Path $p -Parent if($dir -and -not (Test-Path $dir)){ New-Item -ItemType Directory -Force -Path $dir | Out-Null } } function Get-FileSHA256([string]$p){ $sha=[Security.Cryptography.SHA256]::Create() Ensure-Parent $p $fs=[IO.File]::Open($p,'Open','Read','ReadWrite') try{([BitConverter]::ToString($sha.ComputeHash($fs))).Replace('-','').ToLower()}finally{$fs.Dispose();$sha.Dispose()} } function Write-SafeTxt([string]$Dest,[string[]]$Lines,[string]$SourceTag){ Ensure-Parent $Dest if(!(Test-Path $Stage)){ New-Item -ItemType Directory -Force -Path $Stage | Out-Null } $footer=@('','--- DOC-VERSION-FOOTER ---',"Generated: $Iso",'SHA-256: ',"Policy: $Policy","Source: "+$SourceTag) $tmpLocal=Join-Path $Stage ([IO.Path]::GetFileName($Dest)) [IO.File]::WriteAllText($tmpLocal,(($Lines+$footer)-join "`r`n"),$Utf8NoBom) $h0=Get-FileSHA256 $tmpLocal [IO.File]::WriteAllText($tmpLocal,([IO.File]::ReadAllText($tmpLocal,$Utf8NoBom).Replace('SHA-256: ',"SHA-256: $h0")),$Utf8NoBom) $h1=Get-FileSHA256 $tmpLocal $tmp="$Dest.tmp" Ensure-Parent $tmp Copy-Item $tmpLocal $tmp -Force if((Get-FileSHA256 $tmp) -ne $h1){ throw "SAFE-WRITE mismatch for $Dest" } if($Preview -and -not $Execute){ Remove-Item $tmp -Force; return @{Path=$Dest;Hash=$h1;Preview=$true} } if(Test-Path $Dest){ Copy-Item $Dest ($Dest+".bak_"+$Stamp) -Force } Move-Item $tmp $Dest -Force @{Path=$Dest;Hash=$h1;Preview=$false} } function Write-PS1([string]$Dest,[string]$Text){ $Utf8BOM=New-Object Text.UTF8Encoding($true) Ensure-Parent $Dest $tmp="$Dest.tmp"; [IO.File]::WriteAllText($tmp,$Text,$Utf8BOM) if($Preview -and -not $Execute){ Remove-Item $tmp -Force; return } if(Test-Path $Dest){ Copy-Item $Dest ($Dest+".bak_"+$Stamp) -Force } Move-Item $tmp $Dest -Force } function Force-Array($x){ if($null -eq $x){ return @() } if($x -is [System.Array]){ return $x } return ,$x } # --- Parsers & merge --- function Parse-Jsonl([string]$Path){ $arr=@(); if(!(Test-Path $Path)){ return $arr } $i=0 Get-Content -LiteralPath $Path -ErrorAction Stop | ForEach-Object { $line=$_.Trim(); if([string]::IsNullOrWhiteSpace($line)){ return } if($line -match '^\s*//'){ return } try{ $obj = $line | ConvertFrom-Json; if($obj.entries){ $arr += $obj.entries } else { $arr += ,$obj } } catch{ $arr += [pscustomobject]@{ id=("KB-LEGACY-"+$i); title="UNPARSEABLE JSONL LINE"; note=$line; blocking=$false }; $i++ } } $arr } function Parse-JsonTxt([string]$Path){ if(!(Test-Path $Path)){ return @() } $raw=Get-Content -LiteralPath $Path -Raw -ErrorAction Stop try{ $obj=$raw | ConvertFrom-Json if($obj.entries -ne $null){ $e = $obj.entries if($e -is [System.Array]){ return @($e) } else { return @($e) } # absorbe le cas {} (objet) en unitaire } elseif($obj.issues){ return @($obj.issues) } else { return @() } } catch { return @() } } function Normalize-Entry($e){ $id = if($e.PSObject.Properties.Name -contains 'id' -and $e.id){ [string]$e.id } else { $null } $title = if($e.PSObject.Properties.Name -contains 'title'){ [string]$e.title } else { $null } $blocking = if($e.PSObject.Properties.Name -contains 'blocking'){ [bool]$e.blocking } else { $false } $workaround = if($e.PSObject.Properties.Name -contains 'workaround'){ [string]$e.workaround } else { $null } $note = if($e.PSObject.Properties.Name -contains 'note'){ [string]$e.note } else { $null } $tags = @(); if($e.PSObject.Properties.Name -contains 'tags'){ if($e.tags -is [array]){ $tags=@($e.tags) } elseif($e.tags){ $tags=@([string]$e.tags) } } $seen = @(); if($e.PSObject.Properties.Name -contains 'seen_in_threads'){ if($e.seen_in_threads -is [array]){ $seen=@($e.seen_in_threads) } elseif($e.seen_in_threads){ $seen=@([string]$e.seen_in_threads) } } [pscustomobject]@{ id=$id; title=$title; blocking=$blocking; workaround=$workaround; note=$note; tags=$tags; seen_in_threads=$seen; last_seen=$Iso } } function Merge-Entries([array]$A,[array]$B){ $map=@{}; $seq=0 foreach($e in ($A+$B)){ $n=Normalize-Entry $e if(-not $n.id){ $n.id = 'KB-LEGACY-'+($seq); $seq++ } if(-not $map.ContainsKey($n.id)){ $map[$n.id]=$n; continue } $m=$map[$n.id] if(-not $m.title -and $n.title){ $m.title=$n.title } if($n.blocking){ $m.blocking=$true } if($n.workaround){ $m.workaround=$n.workaround } if($n.note){ $m.note=$n.note } if($n.seen_in_threads){ $m.seen_in_threads = @([string[]]($m.seen_in_threads + $n.seen_in_threads | Select-Object -Unique)) } if($n.tags){ $m.tags = @([string[]]($m.tags + $n.tags | Select-Object -Unique)) } $m.last_seen=$Iso $map[$n.id]=$m } @($map.Values | Sort-Object id) } function Update-Bootpack-BugJson([string]$BootpackPath,[string]$JsonString){ if(!(Test-Path $BootpackPath)){ return @{Path=$BootpackPath;Hash='(missing)';Preview=$true} } $bp = Get-Content -LiteralPath $BootpackPath -Raw $nl = "`r`n" $start = [regex]::Match($bp,'(?ms)^\[BUG_KB_JSON\]\s*') if($start.Success){ $before = $bp.Substring(0,$start.Index + $start.Length) $afterMatch = [regex]::Match($bp.Substring($start.Index + $start.Length),'(?ms)^\[\w[^\]]*\]') if($afterMatch.Success){ $afterIdx = $start.Index + $start.Length + $afterMatch.Index; $new = $before + $JsonString + $nl + $bp.Substring($afterIdx) } else { $new = $before + $JsonString + $nl } } else { $new = $bp.TrimEnd() + $nl + "[BUG_KB_JSON]" + $nl + $JsonString + $nl } $tmp="$BootpackPath.tmp" Ensure-Parent $tmp [IO.File]::WriteAllText($tmp,$new,$Utf8NoBom) $h0=Get-FileSHA256 $tmp if($Preview -and -not $Execute){ Remove-Item $tmp -Force; return @{Path=$BootpackPath;Hash=$h0;Preview=$true} } if(Test-Path $BootpackPath){ Copy-Item $BootpackPath ($BootpackPath+".bak_"+$Stamp) -Force } Move-Item $tmp $BootpackPath -Force @{Path=$BootpackPath;Hash=$h0;Preview=$false} } # --- Load and merge --- $fromJsonl = Parse-Jsonl $Jsonl $fromTxt = Parse-JsonTxt $JsonTxt $idsTxt = @($fromTxt | ForEach-Object { $_.id }) | Where-Object { $_ } $idsJsonl = @($fromJsonl | ForEach-Object { $_.id }) | Where-Object { $_ } $onlyJsonl = $idsJsonl | Where-Object { $_ -notin $idsTxt } | Sort-Object -Unique $onlyTxt = $idsTxt | Where-Object { $_ -notin $idsJsonl } | Sort-Object -Unique $merged = Merge-Entries -A (Force-Array $fromTxt) -B (Force-Array $fromJsonl) # --- Preview report --- "=== PREVIEW converge_bug_kb_canonical ===" "Jsonl exists: $(Test-Path $Jsonl) ; JsonTxt exists: $(Test-Path $JsonTxt)" "Entries: jsonl=$($fromJsonl.Count) ; json.txt=$($fromTxt.Count) ; merged=$($merged.Count)" if($onlyJsonl.Count -gt 0){ "IDs only in .jsonl: "+($onlyJsonl -join ', ') } else { "IDs only in .jsonl: none" } if($onlyTxt.Count -gt 0){ "IDs only in .json.txt: "+($onlyTxt -join ', ') } else { "IDs only in .json.txt: none" } if($Preview -and -not $Execute){ return } # --- Apply (force entries as array) --- $entries = Force-Array $merged $jsonObj = [pscustomobject]@{ entries = $entries; updated = $Iso } $json = $jsonObj | ConvertTo-Json -Depth 20 $lines = $json -split "`r?`n" $rKB = Write-SafeTxt -Dest $JsonTxt -Lines $lines -SourceTag "BUG_KB_CANONICALIZE" $rBP = Update-Bootpack-BugJson -BootpackPath $Boot -JsonString $json # Deprecate jsonl unless kept $rJL = $null if((Test-Path $Jsonl) -and -not $KeepJsonl){ $dstOld = Join-Path $Old ("BUG_KB.json.txt.bak_"+$Stamp) Ensure-Parent $dstOld Copy-Item $Jsonl $dstOld -Force Remove-Item $Jsonl -Force $rJL = $dstOld } # Patch script references .jsonl -> .json.txt (PS 5.1: no -Recurse on Select-String) $ScriptsRoot = Join-Path $Root "scripts" $paths = Get-ChildItem -Path $ScriptsRoot -Recurse -File | ForEach-Object { $_.FullName } $hit = @() if($paths){ $hit = Select-String -Path $paths -SimpleMatch 'BUG_KB.json.txt' -ErrorAction SilentlyContinue } $patched=0 foreach($m in $hit){ $p=$m.Path $text=[IO.File]::ReadAllText($p,[Text.Encoding]::UTF8) -replace 'BUG_KB\.jsonl','BUG_KB.json.txt' if([IO.Path]::GetExtension($p).ToLower() -eq '.ps1'){ Write-PS1 -Dest $p -Text $text } else { $tmp="$p.tmp"; Ensure-Parent $tmp; [IO.File]::WriteAllText($tmp,$text,$Utf8NoBom) if($Preview -and -not $Execute){ Remove-Item $tmp -Force } else { if(Test-Path $p){ Copy-Item $p ($p+".bak_"+$Stamp) -Force }; Move-Item $tmp $p -Force } } $patched++ } # Update note + archive + log $updFile = Join-Path $Upd ("BUG_KB_CANONICALIZE_"+$Date+".txt") $jlText = '(kept)'; if($rJL){ $jlText = $rJL } $updBody=@( "Date = "+$Date, "MergedEntries = "+$entries.Count, "OnlyFromJsonl = "+($onlyJsonl -join ', '), "OnlyFromJsonTxt = "+($onlyTxt -join ', '), "JsonlDeprecatedTo = "+$jlText, "PatchedScripts = "+$patched, "Policy = "+$Policy ) $rUPD = Write-SafeTxt -Dest $updFile -Lines $updBody -SourceTag "BUG_KB_CANONICALIZE" $archDir = Join-Path (Join-Path $Thr $Date) (Get-Date -Format 'HHmmss') $rARCH = Write-SafeTxt -Dest (Join-Path $archDir 'bug_kb_converge_snapshot.txt') -Lines @( "ArchivedAt = "+$Iso, "KB = "+$rKB.Path, "Bootpack = "+$rBP.Path, "UpdateNote = "+$rUPD.Path ) -SourceTag "BUG_KB_CANONICALIZE" "[OK] BUG_KB canonique ecrite ; bootpack synchronise ; scripts corriges: $patched"