<#
.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: <un-flag condition>   (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'),
    (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.
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:  | <label> | **<number>** |
    $pat = '^\|\s*' + [regex]::Escape($RowLabel) + '\s*\|\s*\*\*(\d+)'
    $m = Select-String -Path $StatusPath -Pattern $pat -Encoding UTF8 | Select-Object -First 1
    if ($m) { return [int]$m.Matches[0].Groups[1].Value }
    return $null
}

Write-Section 'C2/B3 - canonical resolve + disk cross-check'

$statusPath = Join-Path $RepoRoot 'docs\STATUS.md'
$canonical = [ordered]@{}
$canonicalOk = $true

if (-not (Test-Path $statusPath)) {
    Write-Flag 'HIGH' (Rel $statusPath) 'docs/STATUS.md not found - cannot resolve canonical counts' 'create docs/STATUS.md CURRENT STATE table'
    $canonicalOk = $false
}
else {
    $canonical['mig']    = Get-StatusValue $statusPath 'Migrations'
    $canonical['test']   = Get-StatusValue $statusPath 'Tests'
    $canonical['gotcha'] = Get-StatusValue $statusPath 'Gotchas'
    $canonical['table']  = Get-StatusValue $statusPath 'SQL tables'

    Write-Host ("  STATUS.md canonical: mig={0} test={1} gotcha={2} table={3}" -f `
            $canonical['mig'], $canonical['test'], $canonical['gotcha'], $canonical['table'])

    # ---- disk cross-check: canonical must not itself be stale ----
    # mig = migration .cs files (exclude *Designer.cs / *ModelSnapshot.cs), recursive
    #       so it survives Migrations/ vs Persistence/Migrations/ layout differences.
    $migDirs = Get-ChildItem -Path (Join-Path $RepoRoot 'src') -Recurse -Directory -Filter 'Migrations' -ErrorAction SilentlyContinue |
        Where-Object { $_.FullName -notmatch '\\(bin|obj|node_modules)\\' }
    $diskMig = 0
    foreach ($md in $migDirs) {
        $diskMig += (Get-ChildItem -Path $md.FullName -Filter *.cs -File -ErrorAction SilentlyContinue |
                Where-Object { $_.Name -notlike '*Designer.cs' -and $_.Name -notlike '*ModelSnapshot.cs' }).Count
    }

    # gotcha = highest N from "### N." headings in docs/gotchas.md
    $gotchasPath = Join-Path $RepoRoot 'docs\gotchas.md'
    $diskGotcha = $null
    if (Test-Path $gotchasPath) {
        $nums = Select-String -Path $gotchasPath -Pattern '^### (\d+)\.' -Encoding UTF8 |
            ForEach-Object { [int]$_.Matches[0].Groups[1].Value }
        if ($nums) { $diskGotcha = ($nums | Measure-Object -Maximum).Maximum }
    }

    Write-Host ("  disk cross-check:    mig={0} gotcha={1}" -f $diskMig, $diskGotcha)

    if ($null -ne $canonical['mig'] -and $diskMig -gt 0 -and $canonical['mig'] -ne $diskMig) {
        Write-Flag 'HIGH' (Rel $statusPath) `
            ("canonical-itself-stale: STATUS Migrations=**{0}** but disk has {1} migration .cs" -f $canonical['mig'], $diskMig) `
            ("re-ground STATUS.md Migrations row to {0}" -f $diskMig)
        $canonicalOk = $false
    }
    if ($null -ne $canonical['gotcha'] -and $null -ne $diskGotcha -and $canonical['gotcha'] -ne $diskGotcha) {
        Write-Flag 'HIGH' (Rel $statusPath) `
            ("canonical-itself-stale: STATUS Gotchas=**{0}** but docs/gotchas.md max anchor is {1}" -f $canonical['gotcha'], $diskGotcha) `
            ("re-ground STATUS.md Gotchas row to {0}" -f $diskGotcha)
        $canonicalOk = $false
    }
    if ($canonicalOk) {
        Write-Host '  [OK] canonical matches disk (mig + gotcha) - safe baseline for derived scan' -ForegroundColor Green
    }
}

# ---------------------------------------------------------------------------
# C2/B3 - derived-staleness scan
# Derived docs that summarize counts; each should match canonical OR be a pointer.
# ---------------------------------------------------------------------------
Write-Section 'C2/B3 - derived-doc staleness'

# token-regex -> canonical key. Vietnamese tokens built from code points (ASCII source).
$countPatterns = @(
    @{ Rx = '(\d+)\s*migration';                          Key = 'mig';    Label = 'migration' },
    @{ Rx = '(\d+)\s*test';                               Key = 'test';   Label = 'test' },
    @{ Rx = ('(\d+)\s*(?:' + $VN_BAY + '|gotcha)');       Key = 'gotcha'; Label = 'gotcha/bay' },
    @{ Rx = ('(\d+)\s*(?:' + $VN_BANG + '|table)');       Key = 'table';  Label = 'table/bang' }
)

# H18-A(b) scan-range follows the REAL rule-range (S100, Harness-18 adopt): the derived-doc
# set expands DYNAMICALLY to ALL skill SKILL.md + ALL command *.md. Rationale (real pain):
# a stale hard-count "(68)" survived ~11 sessions inside .claude/commands/session-start.md
# because commands/ sat OUTSIDE this fixed list (blind class, H1 F3 catch S99). A fixed list
# self-ages as the rule-set grows; the glob tracks the live set. Test-Excluded still applies.
$derivedDocs = @(
    'CLAUDE.md',
    'docs\CLAUDE.md',
    '.claude\skills\README.md'
) | ForEach-Object { Join-Path $RepoRoot $_ }
$derivedDocs += @(Get-ChildItem -Path (Join-Path $RepoRoot '.claude\skills') -Recurse -Filter 'SKILL.md' -File -ErrorAction SilentlyContinue | ForEach-Object { $_.FullName })
$derivedDocs += @(Get-ChildItem -Path (Join-Path $RepoRoot '.claude\commands') -Filter '*.md' -File -ErrorAction SilentlyContinue | ForEach-Object { $_.FullName })

foreach ($doc in $derivedDocs) {
    if (-not (Test-Path $doc)) { continue }
    if (Test-Excluded $doc) { continue }
    $lines = Get-Content -Path $doc -Encoding UTF8
    for ($i = 0; $i -lt $lines.Count; $i++) {
        $line = $lines[$i]
        # C2 FP-reduction (R2 refinement S75): per-item table rows + frozen-historical lines are NOT state-count claims
        if ($line -match '^\s*\|') { continue }
        if ($line -match '(?i)(baseline|\bS\d{2}\b|\(current\b)') { continue }
        foreach ($cp in $countPatterns) {
            $canon = $canonical[$cp.Key]
            if ($null -eq $canon) { continue }
            foreach ($m in [regex]::Matches($line, $cp.Rx)) {
                $pre = $line.Substring([Math]::Max(0, $m.Index - 12), [Math]::Min(12, $m.Index))
                if ($pre -match '(?i)(core|\.net|react|vite|node|mig|phase|session|version)\s*$') { continue }  # version/ordinal token, not a state-count
                $postIdx = $m.Index + $m.Length
                $post = $line.Substring($postIdx, [Math]::Min(10, $line.Length - $postIdx))
                if ($cp.Key -eq 'test' -and $post -match '^\s*project') { continue }  # "N test project" = project count, not test count
                $n = [int]$m.Groups[1].Value
                if ($n -ne $canon) {
                    # H18-A(a) mismatch-only CONFIDENCE band (S100): a stale TOTAL lags canonical
                    # by a few sessions so it lands NEAR canonical (ratio 0.5..2.0) -> MED.
                    # A module-local count ("6 test PeWorkflowDefinition" vs canonical 440) sits
                    # FAR from the total -> LOW advisory (likely a different quantity sharing the
                    # token word, NOT a stale claim). The old |diff|>=10 rule was BACKWARDS for
                    # that class (big diff = high sev = false alarm; S98 verify: 8/8 MED were FP).
                    # Correct restatement (n == canon) still never flags (mismatch-only base).
                    $ratio = if ($canon -gt 0) { [double]$n / [double]$canon } else { 0.0 }
                    $sev = if ($ratio -ge 0.5 -and $ratio -le 2.0) { 'MED' } else { 'LOW' }
                    Write-Flag $sev ("{0}:{1}" -f (Rel $doc), ($i + 1)) `
                        ("derived-stale: writes {0} {1} but canonical={2}" -f $n, $cp.Label, $canon) `
                        ("update to {0} OR replace with pointer '-> docs/STATUS.md'" -f $canon)
                }
            }
        }
    }
}
Write-Host '  (note: count-token grep is a soft net - module-local phrases like "4 bang Budget" / "71 test (Phase 8)" can false-positive; H18-A ratio-band demotes far-from-canonical counts to LOW = review-not-fail, near-canonical stale-totals stay MED)' -ForegroundColor DarkGray

# ---------------------------------------------------------------------------
# C1 - broken-pointer: gotcha #N refs
# ---------------------------------------------------------------------------
Write-Section 'C1 - broken gotcha-ref'

$maxGotcha = $canonical['gotcha']
$gotchasPath = Join-Path $RepoRoot 'docs\gotchas.md'
$gotchaAnchors = @{}
if (Test-Path $gotchasPath) {
    Select-String -Path $gotchasPath -Pattern '^### (\d+)\.' -Encoding UTF8 |
        ForEach-Object { $gotchaAnchors[[int]$_.Matches[0].Groups[1].Value] = $true }
}

if ($null -eq $maxGotcha -or $gotchaAnchors.Count -eq 0) {
    Write-Host '  [skip] no canonical max-gotcha or no anchors parsed - cannot validate gotcha refs' -ForegroundColor DarkGray
}
else {
    # Match "gotcha #N", "gotcha N", and bare "#N" tokens.
    $refRx = '(?:gotcha[s]?\s*#?(\d+))|(?<![A-Za-z0-9])#(\d+)'
    foreach ($f in $GovMd) {
        $lines = Get-Content -Path $f.FullName -Encoding UTF8
        for ($i = 0; $i -lt $lines.Count; $i++) {
            foreach ($m in [regex]::Matches($lines[$i], $refRx)) {
                $num = if ($m.Groups[1].Success) { [int]$m.Groups[1].Value } else { [int]$m.Groups[2].Value }
                $isGotchaWord = $m.Groups[1].Success
                # bare "#N": only treat as gotcha-ref candidate when N is in gotcha numeric range
                # to avoid PR/issue/run numbers. gotcha-word form always validated.
                if (-not $isGotchaWord) {
                    if ($num -le 0 -or $num -gt ($maxGotcha + 50)) { continue }
                    # bare #N with N <= maxGotcha and anchor exists -> fine, skip silently
                    if ($num -le $maxGotcha -and $gotchaAnchors.ContainsKey($num)) { continue }
                    # bare #N > maxGotcha is ambiguous (could be Run #312) -> skip to avoid noise
                    if ($num -gt $maxGotcha) { continue }
                }
                if ($num -gt $maxGotcha) {
                    Write-Flag 'MED' ("{0}:{1}" -f (Rel $f.FullName), ($i + 1)) `
                        ("broken-gotcha-ref: cites #{0} but max gotcha is {1}" -f $num, $maxGotcha) `
                        'fix the number or add the gotcha to docs/gotchas.md'
                }
                elseif ($isGotchaWord -and -not $gotchaAnchors.ContainsKey($num)) {
                    Write-Flag 'LOW' ("{0}:{1}" -f (Rel $f.FullName), ($i + 1)) `
                        ("broken-gotcha-ref: 'gotcha #{0}' has no '### {0}.' anchor in gotchas.md" -f $num) `
                        'fix ref or create the missing gotcha anchor'
                }
            }
        }
    }
}

# ---------------------------------------------------------------------------
# C1 - broken-pointer: dangling [[wikilink]]  (user-memory + agent-memory)
# ---------------------------------------------------------------------------
Write-Section 'C1 - dangling wikilink'

# user-memory dir (outside repo). Derive from this machine's project slug; if not
# reachable, fall back to in-repo agent-memory only + emit a note.
$userMemDir = 'C:\Users\pqhuy\.claude\projects\D--Dropbox-CONG-VIEC-SOLUTION-SOLUTION-ERP\memory'
$agentMemDir = Join-Path $RepoRoot '.claude\agent-memory'

$memScopes = @()
if (Test-Path $userMemDir)  { $memScopes += [pscustomobject]@{ Name = 'user-memory';  Dir = $userMemDir;  Recurse = $false } }
else { Write-Host "  [note] user-memory path not reachable ($userMemDir) - scanning in-repo agent-memory only" -ForegroundColor DarkGray }
if (Test-Path $agentMemDir) { $memScopes += [pscustomobject]@{ Name = 'agent-memory'; Dir = $agentMemDir; Recurse = $true } }

foreach ($scope in $memScopes) {
    $gp = if ($scope.Recurse) {
        Get-ChildItem -Path $scope.Dir -Recurse -Filter *.md -File -ErrorAction SilentlyContinue
    } else {
        Get-ChildItem -Path $scope.Dir -Filter *.md -File -ErrorAction SilentlyContinue
    }
    # Build the set of existing target basenames in this scope.
    $targets = @{}
    foreach ($g in $gp) { $targets[$g.BaseName] = $true; $targets[($g.BaseName -replace '[-_]', '')] = $true }  # C1 refinement (R2 S75): also index separator-normalized form (hyphen<->underscore fork)

    foreach ($g in $gp) {
        $lines = Get-Content -Path $g.FullName -Encoding UTF8
        for ($i = 0; $i -lt $lines.Count; $i++) {
            foreach ($m in [regex]::Matches($lines[$i], '\[\[([a-z0-9_-]+)\]\]')) {
                $tgt = $m.Groups[1].Value
                if (-not ($targets.ContainsKey($tgt) -or $targets.ContainsKey(($tgt -replace '[-_]', '')))) {
                    Write-Flag 'LOW' ("{0}/{1}:{2}" -f $scope.Name, $g.Name, ($i + 1)) `
                        ("dangling-wikilink: [[{0}]] -> {0}.md not found in {1}" -f $tgt, $scope.Name) `
                        'fix the link target or create the file (note: hyphen vs underscore basename fork is common)'
                }
            }
        }
    }
}

# ---------------------------------------------------------------------------
# C3 - vocab-fork
# ---------------------------------------------------------------------------
Write-Section 'C3 - vocab-fork'

# Seed alias-sets (hard-coded from audit; extend over time). Vietnamese variants
# built from code points so the .ps1 stays ASCII-only (gotcha #30) yet matches
# correctly-decoded UTF-8 content.
$aliasSets = @(
    @('wave-folder', 'run-trace'),
    @($VN_DUTRU_PRO, $VN_NGANSACH_PRO),
    @('two-tier', 'all-inherit')
)
# NOTE (Harness-16, S93): "memory-fidelity" is INTENTIONALLY NOT seeded here. It is
# NOT a vocab-fork (one concept / two names) -- it is two DISTINCT concepts sharing a
# word: H6.7 "memoryDelta-routing-fidelity" (delta lands in the right agent-memory)
# vs Harness-16 "memory-fidelity-EVAL / MFE" (coverage/retention). The alias-map is
# RECORDED in docs/governance/harness-11-engine.md PHAN H. Seeding it would FALSE-flag
# two legitimately-different terms as a fork-to-merge -- do not add it.
# NOTE (Harness-17, S95): "coverage" is likewise INTENTIONALLY NOT seeded. It is THREE
# distinct concepts sharing a word, not a fork: (1) H2 harvest-curator "Coverage"
# (0-silent-miss of spawned subs/runs) ; (2) H16 MFE SUB "coverage" (role-floor still
# carried in the diary) ; (3) H17 A1 "HCV / harvest-coverage" (per-run said-vs-kept).
# The alias-map is RECORDED in harness-11-engine.md PHAN I. Seeding would FALSE-flag three
# legitimately-different terms as a fork-to-merge -- do not add it. Same for "audit"
# (monthly-drift-audit vs H17 spec-audit) -- disambiguated by name in-doc, not seeded.

for ($s = 0; $s -lt $aliasSets.Count; $s++) {
    $variants = $aliasSets[$s]
    $perVariantFiles = @{}
    foreach ($v in $variants) { $perVariantFiles[$v] = New-Object System.Collections.Generic.List[string] }

    foreach ($f in $GovMd) {
        $content = Get-Content -Path $f.FullName -Raw -Encoding UTF8
        if ($null -eq $content) { continue }
        foreach ($v in $variants) {
            if ($content -match [regex]::Escape($v)) {
                $perVariantFiles[$v].Add((Rel $f.FullName)) | Out-Null
            }
        }
    }

    $liveVariants = @($variants | Where-Object { $perVariantFiles[$_].Count -gt 0 })
    if ($liveVariants.Count -ge 2) {
        $detail = ($liveVariants | ForEach-Object { "{0}={1}f" -f $_, $perVariantFiles[$_].Count }) -join ' vs '
        $sample = ($liveVariants | ForEach-Object {
                $first = $perVariantFiles[$_] | Select-Object -First 2
                "'$_' in [$($first -join ', ')]"
            }) -join ' | '
        Write-Flag 'MED' 'multiple files' `
            ("vocab-fork: $detail live side-by-side -- $sample") `
            'merge to ONE canonical term, or record an alias-map in docs/governance'
    }
}

# ---------------------------------------------------------------------------
# C5 - WAL guardrail (H22 S111, reviewer-m1): .claude/WAL.md hard cap 40 lines
# (N.1 overwrite-not-append). DETECT-only nhu moi detector khac.
# ---------------------------------------------------------------------------
Write-Section 'C5 - WAL guardrail (H22)'
$walPath = Join-Path $RepoRoot '.claude\WAL.md'
if (Test-Path $walPath) {
    $walLineCount = @(Get-Content $walPath).Count
    if ($walLineCount -gt 40) {
        Write-Flag 'MED' '.claude/WAL.md' `
            ("wal-overflow: {0} lines > 40-line hard cap (H22 N.1 overwrite-not-append)" -f $walLineCount) `
            'trim WAL to <= 40 lines: move long notes to run-folder/work-state; keep chain+next+verify only'
    } else {
        Write-Host ("  [OK] WAL.md = {0} lines (<= 40 hard cap)" -f $walLineCount)
    }
} else {
    Write-Host '  (no .claude/WAL.md - no active chain, skip)'
}

# ---------------------------------------------------------------------------
# Summary + C4 self-exclusion audit (RUNTIME proof)
# ---------------------------------------------------------------------------
Write-Section 'Summary'

# Confirm 0 self-match: the detector script must never appear in the scanned set.
$selfPath = (Join-Path $RepoRoot 'scripts\governance-detectors.ps1') -replace '/', '\'
$selfInScan = @($GovMd | Where-Object { ($_.FullName -replace '/', '\') -ieq $selfPath }).Count
# (governance-detectors.ps1 is .ps1 not .md so never in $GovMd; this asserts the
#  invariant explicitly. Also assert none of the excluded dirs leaked in.)
$leaked = @($GovMd | Where-Object { Test-Excluded $_.FullName }).Count

Write-Host ("self-exclusion: {0} paths excluded (exact+dir rules)" -f $ExcludedActual.Count)
foreach ($e in $ExcludedActual) { Write-Host ("  - excluded: {0}" -f (Rel $e)) -ForegroundColor DarkGray }
Write-Host ("self-match check: governance-detectors.ps1 in scan = {0} ; leaked excluded files in scan = {1}" -f $selfInScan, $leaked)
if ($selfInScan -eq 0 -and $leaked -eq 0) {
    Write-Host '  [OK] 0 self-match (C4 satisfied)' -ForegroundColor Green
} else {
    Write-Host '  [!] self-exclusion LEAK - investigate Test-Excluded rules' -ForegroundColor Red
}

Write-Host ''
Write-Host ("TOTAL FLAGS: {0}" -f $script:FlagCount) -ForegroundColor Cyan
Write-Host 'NOTE: DETECT-only lowering net. Exit 0 always (never fails build). FLAGs are advisory.' -ForegroundColor DarkGray

exit 0
