<# .SYNOPSIS governance-detectors.ps1 - Harness-11 PHAN C + B3 governance drift detectors. .DESCRIPTION NO-API, DETECT-and-FLAG-only grep net (Harness-11 mandate): (1) NO-API - only Select-String + byte/file-exist measure. NEVER calls model/API. (2) FLAG-only - prints FLAGs, NEVER edits files (auto-WRITE of rules = top hazard, forbidden). (3) PowerShell 5.1 compatible. Run offline. ASCII-only script body (gotcha #30); target-file content is read -Encoding UTF8 so Vietnamese count-tokens (bay / bang / Du tru) match correctly. (5) DETECT-only LOWERING NET, not a hard build gate. Exit code always 0. Detectors: C2/B3 - derived-staleness : canonical counts from STATUS.md (cross-checked vs disk), then derived docs scanned for stale count-tokens. C1 - broken-pointer : (a) gotcha #N refs > max-gotcha or missing "### N." anchor (b) dangling [[wikilink]] in user-memory / agent-memory. C3 - vocab-fork : alias-sets where >=2 variants live side-by-side. C4 - self-line exclusion: pattern-describing files removed from every scan (else the detector self-matches). Each FLAG line: [DETECTOR] severity | file:line | description | resolve: (C5) .PARAMETER RepoRoot Repo root. Default = resolved 2 levels up from this script (scripts/ -> repo root). .EXAMPLE powershell.exe -ExecutionPolicy Bypass -File scripts/governance-detectors.ps1 #> param( [string]$RepoRoot = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path ) $ErrorActionPreference = 'Continue' # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- $script:FlagCount = 0 function Write-Flag { param( [ValidateSet('HIGH', 'MED', 'LOW')] [string]$Severity, [string]$Where, # file:line [string]$Desc, [string]$Resolve ) $color = switch ($Severity) { 'HIGH' { 'Red' } 'MED' { 'Yellow' } default { 'Gray' } } Write-Host ("[DETECTOR] {0,-4} | {1} | {2} | resolve: {3}" -f $Severity, $Where, $Desc, $Resolve) -ForegroundColor $color $script:FlagCount++ } function Write-Section($title) { Write-Host '' Write-Host ("===== $title =====") -ForegroundColor Cyan } # Make a path repo-relative for readable FLAG output (forward slashes). function Rel($full) { $r = $full if ($full.StartsWith($RepoRoot, [StringComparison]::OrdinalIgnoreCase)) { $r = $full.Substring($RepoRoot.Length).TrimStart('\', '/') } return ($r -replace '\\', '/') } # --------------------------------------------------------------------------- # Unicode-token builder (gotcha #30 mojibake guard). # This .ps1 is ASCII-only on disk. PowerShell 5.1 decodes a BOM-less .ps1 with # the system ANSI codepage (NOT UTF-8) when launched via -File, which corrupts # any inline Vietnamese literal (e.g. "bay" -> mojibake) so it can no longer # match correctly-decoded UTF-8 file content. We therefore build every # Vietnamese token from Unicode code points at RUNTIME -> encoding-independent. function U { param([int[]]$cp) -join ($cp | ForEach-Object { [char]$_ }) } # Vietnamese tokens used by detectors: $VN_BAY = U @(0x62, 0x1EAB, 0x79) # "bay" (gotcha synonym) $VN_BANG = U @(0x62, 0x1EA3, 0x6E, 0x67) # "bang" (table synonym) $VN_DUTRU_PRO = U @(0x44, 0x1EF1, 0x20, 0x74, 0x72, 0xF9, 0x20, 0x50, 0x52, 0x4F) # "Du tru PRO" $VN_NGANSACH_PRO = U @(0x4E, 0x67, 0xE2, 0x6E, 0x20, 0x73, 0xE1, 0x63, 0x68, 0x20, 0x50, 0x52, 0x4F) # "Ngan sach PRO" # --------------------------------------------------------------------------- # C4 - self-line exclusion (BUILT FIRST so every scan can apply it) # These files DESCRIBE the patterns the detectors look for; without exclusion # the detector would flag itself. Glob-style suffix/substring rules. # --------------------------------------------------------------------------- $ExcludeExact = @( (Join-Path $RepoRoot 'scripts\governance-detectors.ps1'), (Join-Path $RepoRoot 'docs\governance\harness-11-engine.md') ) | ForEach-Object { $_ -replace '/', '\' } $ExcludeDirFragments = @( '\broadcasts\inbox\', '\broadcasts\outbox\', '\.claude\workflows\runs\', '\.claude\workflows\scripts\' ) function Test-Excluded($full) { $p = ($full -replace '/', '\') foreach ($ex in $ExcludeExact) { if ($p -ieq $ex) { return $true } } foreach ($frag in $ExcludeDirFragments) { if ($p -ilike "*$frag*") { return $true } } return $false } # Resolve which excluded paths actually exist on disk (for the audit line). $ExcludedActual = @() foreach ($ex in $ExcludeExact) { if (Test-Path $ex) { $ExcludedActual += $ex } } foreach ($frag in $ExcludeDirFragments) { $probe = Join-Path $RepoRoot ($frag.Trim('\')) if (Test-Path $probe) { $ExcludedActual += $probe } } # Gather governance MD set ONCE (docs/** + .claude/** *.md), minus excluded. function Get-GovernanceMd { $dirs = @((Join-Path $RepoRoot 'docs'), (Join-Path $RepoRoot '.claude')) $all = @() foreach ($d in $dirs) { if (Test-Path $d) { $all += Get-ChildItem -Path $d -Recurse -Filter *.md -File -ErrorAction SilentlyContinue } } return $all | Where-Object { -not (Test-Excluded $_.FullName) } } $GovMd = Get-GovernanceMd # --------------------------------------------------------------------------- # Canonical values from docs/STATUS.md + disk cross-check # --------------------------------------------------------------------------- function Get-StatusValue { param([string]$StatusPath, [string]$RowLabel) # Match a CURRENT-STATE table row: |