<# .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). H24-1 - title-freshness : doc's OWN title/status anchor date vs the newest governance milestone date. ANCHOR-SCOPED parse. H24-2 - carry-age : [carry:] keys alive across >= M consecutive most-recent carry-lines. M read from config, never hardcoded. INFORM-only. 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 } # INFORM-only flag sink for NEW detectors (C6 cite-2-tier, H24-4 pending-flip). # Counted SEPARATELY in $script:InformCount and NEVER folded into $script:FlagCount # (= the baseline TOTAL). Rationale (anti-Goodhart, owner-set): a brand-new net # whose LOW hits are read by judgement on day one must not move the audited TOTAL # down/up -- folding it would let "flag count fell" masquerade as progress and would # hide whether the thing that changed was a false positive or a real witness. Fold + # severity-raise is a POST-triage, owner-gated decision (see detector headers). Same # [DETECTOR] line shape as Write-Flag so `comm before/after` cleanly isolates new lines. $script:InformCount = 0 function Write-InformFlag { param( [string]$Where, # file:line [string]$Desc, [string]$Resolve ) Write-Host ("[DETECTOR] {0,-4} | {1} | {2} | resolve: {3}" -f 'LOW', $Where, $Desc, $Resolve) -ForegroundColor Gray $script:InformCount++ } # 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" # H24-1: EM DASH U+2014 (NOT hyphen-minus U+002D). Verified byte-level against # .claude/skills/permission-matrix/SKILL.md:16 -> bytes "e2 80 94" = U+2014. # Same runtime code-point trick as the VN tokens: an inline em-dash would be a # non-ASCII byte in this .ps1 and would mojibake under the ANSI codepage decode. $EM_DASH = U @(0x2014) # --------------------------------------------------------------------------- # 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'), (Join-Path $RepoRoot 'docs\governance\vocab-alias-map.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. # VENDOR-SKIP (S123 review): this walks the FILESYSTEM, so it does NOT honour .gitignore. # Measured: docs/_user-guide/node_modules/ holds 30 gitignored .md that were being scanned # as if they were governance docs. Zero anchors in them today => zero impact so far, but # H24-1 sets moc-phai to the MAX anchor in the corpus: ONE vendor README carrying a future # "**Last updated:** 2027-xx-xx" would push the right edge past every real doc and silence # the detector for good -- the exact strangle documented at the H24-1 banner below. # The '(bin|obj|node_modules)' filter already existed further down for the code-scan; it # was simply never applied here. Same class, one site patched, sibling missed -- which is # the S123 lesson in miniature: patch a class, then grep every same-class site. 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 { $_.FullName -notmatch '[\\/](bin|obj|node_modules)[\\/]' } | 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: |