<#
.SYNOPSIS
  loi-hua-may-scan.ps1 - C-12 machine-promise scanner (khoan 4.4, broadcast
  2026-07-19-Governance-goi-chot-owner-nam-khoan).

.DESCRIPTION
  Class scanned: sentences that ASSERT a machine capability ("X chan/quet/bat/
  dam-nhiem Y") in governance docs. Law (goi-chot:52): such a sentence MUST carry
  existence-proof AT THE POINT OF CLAIM = {machine path + runnable command +
  test/positive-control}. Missing any piece => the sentence MUST be written in the
  intent form ("se build - CHUA co may"). Writing it in the have-it form is banned.

  Until today this gate was HUMAN-ONLY: both hub (goi-chot:54) and SE
  (adap-report 4.4 + runs/2026-07-22-S144-adap-dot-11/C6-cua-loi-hua-may.md:60)
  declared the automatic scanner as INTENT, not existing. This script is that
  scanner. It is DETECT-and-FLAG-only: it never edits a file, exit code is
  always 0 in scan mode (teeth live in -SelfTest, which exits 1 on failure).

  FOUR design constraints inherited from C6 (violating any = the machine is worse
  than nothing, because a green scan would then mean "not measured"):

    (b) REGEX IS NOT THE DENOMINATOR. C6 measured: literal "may se" -> 0 hits
        (blind: a fault-injected fake promise stayed green), loose regex -> 19 hits
        with 4/19 = 21% false positives. Regex here is a CANDIDATE SIEVE only; the
        denominator is the ENUMERATED baseline file (loi-hua-may-baseline.json).
    (c) use ORTHOGONAL-TO mention, split by ENCLOSURE (not by shape, not by
        charset). A line that DEFINES the rule or QUOTES an anti-pattern is not a
        violation. Enclosure = fenced code block, blockquote, or quote-pair
        around the verb. Same class SE paid for 3 generations in one wave (S123
        citation-trap).
    (d) MEASURE THE GATE, NOT THE SWEEP. goi-chot:53 - "chan tu luc VIET".
        This script reports lines NOT in the baseline (i.e. written after the gate
        went in). It NEVER claims the corpus is clean.
    (e) BACKFILL IS TIME-BOXED. goi-chot:55 floor covers governance docs that are
        NEW. Legacy lines live in the baseline with an explicit disposition.

  FP-guard, tested: Vietnamese "bat buoc" = MANDATORY, not CATCH. The catch verb
  carries a negative lookahead so it can never swallow it. This exact substring
  bug produced the 21% inflation in C6 and is control #2 of -SelfTest.

  ASCII-only script body (gotcha #30): PowerShell 5.1 decodes a BOM-less .ps1 with
  the system ANSI codepage under -File, which mojibakes any inline Vietnamese
  literal so it stops matching correctly-decoded UTF-8 content. Every Vietnamese
  token is therefore built from Unicode code points at RUNTIME.

.PARAMETER RepoRoot
  Repo root. Default = one level up from scripts/. Point it at a COPY of the tree
  to fault-inject without touching the real repo.

.PARAMETER BaselinePath
  Enumerated denominator. Default = scripts/loi-hua-may-baseline.json.

.PARAMETER All
  Widen scope from the C6 corpus (docs/governance/*.md) to the wider governance
  surface (rules/CLAUDE/commands/skills/gotchas).

.PARAMETER EmitBaseline
  Print the current candidate set as baseline JSON with disposition
  "TODO-classify". Entries left at TODO-classify are STILL FLAGGED (LOW) - so
  emitting a baseline can never silence anything on its own.

.PARAMETER SelfTest
  Run the 7 controls on a throwaway tree under $env:TEMP. Exits 1 if any control
  fails. Nothing is written inside the repo.

.EXAMPLE
  powershell.exe -ExecutionPolicy Bypass -File scripts/loi-hua-may-scan.ps1
.EXAMPLE
  powershell.exe -ExecutionPolicy Bypass -File scripts/loi-hua-may-scan.ps1 -SelfTest
#>
param(
    [string]$RepoRoot = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path,
    [string]$BaselinePath,
    [switch]$All,
    [switch]$EmitBaseline,
    [switch]$SelfTest,
    [switch]$NoProximity   # audit hatch: run the pre-proximity sieve so anyone can
                           # diff the two flag sets and check what the rule removed
)

$ErrorActionPreference = 'Continue'

# ---------------------------------------------------------------------------
# Unicode-token builder (gotcha #30 mojibake guard) - see .DESCRIPTION.
# ---------------------------------------------------------------------------
function U { param([int[]]$cp) -join ($cp | ForEach-Object { [char]$_ }) }

$T_CHAN  = U @(0x63, 0x68, 0x1EB7, 0x6E)                   # chan   (block)
$T_QUET  = U @(0x71, 0x75, 0xE9, 0x74)                     # quet   (scan)
$T_BAT   = U @(0x62, 0x1EAF, 0x74)                         # bat    (catch)
$T_BUOC  = U @(0x62, 0x75, 0x1ED9, 0x63)                   # buoc   (in "bat buoc" = mandatory)
$T_DAM   = U @(0x111, 0x1EA3, 0x6D)                        # dam    (dam-nhiem / dam bao)
$T_CANH  = U @(0x63, 0x61, 0x6E, 0x68)                     # canh   (watch)
$T_TUDONG= U @(0x74, 0x1EF1, 0x20, 0x111, 0x1ED9, 0x6E, 0x67)   # tu dong (automatic)
$T_MAY   = U @(0x6D, 0xE1, 0x79)                           # may    (machine)
$T_LUOI  = U @(0x6C, 0x1B0, 0x1EDB, 0x69)                  # luoi   (net)
$T_SE    = U @(0x73, 0x1EBD)                               # se     (will) - fixtures only

# ---------------------------------------------------------------------------
# Stage 1 - CANDIDATE SIEVE (explicitly NOT the denominator; see constraint b)
# A candidate = line carrying a MACHINE NOUN and a CAPABILITY VERB.
# ---------------------------------------------------------------------------
$NounPattern = '(?i)(detector|guard|hook|script|checker|scanner|\.ps1|\.py\b|' +
               [regex]::Escape($T_MAY) + '|' + [regex]::Escape($T_LUOI) + ')'

# "bat" carries a negative lookahead so "bat buoc" (mandatory) can never be read
# as "bat" (catch). This is the exact 21% inflation bug from C6, control #2.
# The separator class is [\s-]* , NOT \s* : this repo writes the word BOTH ways
# ("bat buoc" and "bat-buoc"). A \s*-only guard leaks on every hyphenated
# occurrence - found live at fable-real-runbook.md:571, where only the proximity
# rule was accidentally masking it. Controls #2 and #9 pin both spellings.
$VerbPattern = '(?i)(' +
               [regex]::Escape($T_CHAN) + '|' +
               [regex]::Escape($T_QUET) + '|' +
               [regex]::Escape($T_BAT) + '(?![\s-]*' + [regex]::Escape($T_BUOC) + ')|' +
               [regex]::Escape($T_DAM) + '|' +
               [regex]::Escape($T_CANH) + '|' +
               [regex]::Escape($T_TUDONG) + '|enforce|detect)'

# Stage 1b - PROXIMITY. A capability assertion keeps its subject next to its
# verb ("detector X chan Y"). Without this, any long table row or narrative
# paragraph that happens to contain a machine noun in one cell and a verb 300
# chars later in another cell is swept in - which is how the C6 loose regex got
# to 21% false positives. The line is therefore split on the markdown cell
# separator and the pair must co-occur inside ONE chunk, within MaxGap chars.
# Verified against the real corpus by diffing the flag set before/after and
# reading every line that dropped out (see run artifact) - the rule removes
# unrelated co-occurrences, not witnesses.
$ProxMaxGap = 80

# ---------------------------------------------------------------------------
# Stage 3 - EXISTENCE-PROOF tokens ("ma nao lam viec do?" answered inline).
# ---------------------------------------------------------------------------
$PathPattern = '(?i)([\w./-]+\.(?:ps1|py|js|sh|yml|yaml))'
$CmdPattern  = '(?i)(powershell\.exe|pwsh|dotnet\s|node\s|python\s|npm\s|git\s)'

# ---------------------------------------------------------------------------
# C4-style self-line exclusion: files that DESCRIBE the pattern must never be
# scanned for it, or the detector flags itself (and, via quotation, its own
# children - the S123 citation-trap went 3 generations in one wave).
# ---------------------------------------------------------------------------
$SelfExclude = @(
    'scripts/loi-hua-may-scan.ps1',
    'scripts/loi-hua-may-baseline.json',
    '.claude/workflows/runs/'
)

function Rel($full) {
    $r = $full
    if ($full.StartsWith($RepoRoot, [StringComparison]::OrdinalIgnoreCase)) {
        $r = $full.Substring($RepoRoot.Length).TrimStart('\', '/')
    }
    return ($r -replace '\\', '/')
}

function Test-SelfExcluded($relPath) {
    foreach ($x in $SelfExclude) { if ($relPath -like ('*' + $x + '*')) { return $true } }
    return $false
}

# ---------------------------------------------------------------------------
# Scope. Default = the C6 corpus (docs/governance top level) so the number this
# script prints is comparable with the C6 measurement. -All widens it.
# adap-reports/ and adap-requests/ stay out by default: they are reports ABOUT
# hub broadcasts and are quotation-heavy by construction (mention, not use).
# ---------------------------------------------------------------------------
function Get-ScopeFiles {
    param([string]$Root, [switch]$Wide)
    $files = @()
    $gov = Join-Path $Root 'docs/governance'
    if (Test-Path $gov) {
        $files += Get-ChildItem -LiteralPath $gov -Filter '*.md' -File -ErrorAction SilentlyContinue
    }
    if ($Wide) {
        foreach ($p in @('docs/rules.md', 'docs/gotchas.md', 'CLAUDE.md', 'docs/CLAUDE.md')) {
            $f = Join-Path $Root $p
            if (Test-Path $f) { $files += Get-Item -LiteralPath $f }
        }
        $cmd = Join-Path $Root '.claude/commands'
        if (Test-Path $cmd) {
            $files += Get-ChildItem -LiteralPath $cmd -Filter '*.md' -File -Recurse -ErrorAction SilentlyContinue
        }
        $sk = Join-Path $Root '.claude/skills'
        if (Test-Path $sk) {
            $files += Get-ChildItem -LiteralPath $sk -Filter 'SKILL.md' -File -Recurse -ErrorAction SilentlyContinue
        }
    }
    return ($files | Where-Object { -not (Test-SelfExcluded (Rel $_.FullName)) })
}

# ---------------------------------------------------------------------------
# Stage 2 - use ORTHOGONAL-TO mention, by ENCLOSURE (constraint c).
# Returns the mention reason, or $null when the line is a genuine ASSERTION.
#   m1 fenced code block   m2 blockquote   m3 verb sits inside a quote pair
# ---------------------------------------------------------------------------
function Get-EnclosureSpans {
    param([string]$Line)
    $spans = @()
    # Straight double quote, curly quotes, guillemets, backtick code span.
    $pairs = @(
        @{ open = [char]0x0022; close = [char]0x0022 },
        @{ open = [char]0x201C; close = [char]0x201D },
        @{ open = [char]0x00AB; close = [char]0x00BB },
        @{ open = [char]0x2018; close = [char]0x2019 },
        @{ open = [char]0x0060; close = [char]0x0060 }
    )
    foreach ($p in $pairs) {
        $i = 0
        while ($true) {
            $s = $Line.IndexOf([string]$p.open, $i)
            if ($s -lt 0) { break }
            $e = $Line.IndexOf([string]$p.close, $s + 1)
            if ($e -lt 0) { break }
            $spans += , @($s, $e)
            $i = $e + 1
        }
    }
    return $spans
}

function Get-MentionReason {
    param([string]$Line, [bool]$InFence)
    if ($InFence) { return 'm1-code-fence' }
    if ($Line -match '^\s{0,3}>') { return 'm2-blockquote' }
    $m = [regex]::Match($Line, $VerbPattern)
    if ($m.Success) {
        foreach ($sp in (Get-EnclosureSpans $Line)) {
            if ($m.Index -gt $sp[0] -and $m.Index -lt $sp[1]) { return 'm3-quoted' }
        }
    }
    return $null
}

# ---------------------------------------------------------------------------
# Stage 3 - proof AT THE POINT OF CLAIM ("tai cho", goi-chot:52).
# Returns: 'proof-ok' | 'proof-dead:<path>' | $null (no proof at all).
#
# Window rule, and why it is this narrow: the first cut used +/-2 lines and
# -SelfTest failed 2/8 - adjacent claims stole each other's proof (a promise
# inherited the proof of the bullet above it, and a clean line inherited the
# DEAD path of the bullet above it, i.e. contamination in both directions).
# "Tai cho" therefore means: the claim's own line, the line directly under it
# (sub-bullet / wrapped continuation), and the line directly above ONLY when
# that line is not itself a claim - proof already spoken for by another
# assertion is not proof of this one.
#
# 'proof-dead' (HIGH) is raised ONLY from the claim's OWN line: citing a machine
# that does not exist on disk is the sharpest failure of this class, but a dead
# path on a NEIGHBOURING line belongs to the neighbour, not here.
# ---------------------------------------------------------------------------
function Test-IsCandidate {
    param([string]$Line)
    if ($Line -notmatch $NounPattern) { return $false }
    if ($Line -notmatch $VerbPattern) { return $false }
    if ($NoProximity) { return $true }
    foreach ($chunk in ($Line -split '\|')) {
        $n = [regex]::Match($chunk, $NounPattern)
        if (-not $n.Success) { continue }
        foreach ($v in [regex]::Matches($chunk, $VerbPattern)) {
            foreach ($nn in [regex]::Matches($chunk, $NounPattern)) {
                if ([Math]::Abs($v.Index - $nn.Index) -le $ProxMaxGap) { return $true }
            }
        }
    }
    return $false
}

# Machine index: every runnable file under scripts/ and .claude/. This repo
# routinely names a machine WITHOUT its directory (`governance-detectors.ps1`,
# `hmw.js:171-186`), and such a citation does answer the reviewer's one question
# ("ma nao lam viec do?"). A bare name that RESOLVES therefore counts as proof;
# a bare name that resolves nowhere is NOT escalated to HIGH (it may be a generic
# word), it simply fails to prove anything.
function New-MachineIndex {
    param([string]$Root)
    $set = @{}
    foreach ($d in @('scripts', '.claude')) {
        $p = Join-Path $Root $d
        if (-not (Test-Path $p)) { continue }
        Get-ChildItem -Path $p -Recurse -File -ErrorAction SilentlyContinue |
            Where-Object { $_.FullName -notmatch '[\\/]node_modules[\\/]' -and
                           @('.ps1', '.py', '.js', '.sh') -contains $_.Extension.ToLowerInvariant() } |
            ForEach-Object { $set[$_.Name.ToLowerInvariant()] = $true }
    }
    return $set
}

function Get-PathVerdict {
    param([string]$Line, [string]$Root, $Index)
    $dead = @()
    foreach ($pm in [regex]::Matches($Line, $PathPattern)) {
        # Trim wrappers only. NEVER TrimStart('.') - it eats the leading dot of
        # ".claude/hooks/..." and turns a live path into a phantom dead one.
        # That exact bug produced this scanner's first HIGH flag, on a path that
        # exists (naming-standard.md:50 -> .claude/hooks/wal-flush.ps1).
        $cand = $pm.Groups[1].Value.Trim('`', '(', ')').TrimEnd(',', '.', ';', ':')
        if ($cand -notmatch '[\\/]') {
            # bare filename: proof only if it resolves to a real machine
            if ($Index -and $Index.ContainsKey($cand.ToLowerInvariant())) { return 'ok' }
            continue
        }
        $full = Join-Path $Root ($cand -replace '/', '\')
        if (Test-Path -LiteralPath $full) { return 'ok' }
        if ($Index -and $Index.ContainsKey(([System.IO.Path]::GetFileName($cand)).ToLowerInvariant())) {
            return 'ok'   # path stale but the machine exists elsewhere -> not a phantom
        }
        $dead += $cand
    }
    if ($dead.Count -gt 0) { return ('dead:' + $dead[0]) }
    return $null
}

function Get-ProofState {
    param([string[]]$Lines, [int]$Idx, [string]$Root, $Index)
    # (1) own line
    if ($Lines[$Idx] -match $CmdPattern) { return 'proof-ok' }
    $own = Get-PathVerdict -Line $Lines[$Idx] -Root $Root -Index $Index
    if ($own -eq 'ok') { return 'proof-ok' }

    # (2) neighbours - proof only, never dead-path escalation
    $neighbours = @()
    if ($Idx + 1 -le $Lines.Count - 1) { $neighbours += ($Idx + 1) }
    if ($Idx - 1 -ge 0 -and -not (Test-IsCandidate $Lines[$Idx - 1])) { $neighbours += ($Idx - 1) }
    foreach ($n in $neighbours) {
        if ($Lines[$n] -match $CmdPattern) { return 'proof-ok' }
        if ((Get-PathVerdict -Line $Lines[$n] -Root $Root -Index $Index) -eq 'ok') { return 'proof-ok' }
    }

    if ($own) { return ('proof-' + $own) }   # dead:<path> on the claim's own line
    return $null
}

# ---------------------------------------------------------------------------
# Baseline (THE DENOMINATOR - enumerated by hand, constraint b).
# Matching is by NORMALISED TEXT FRAGMENT, never by line number: line numbers
# drift on every edit above them, and a drifting anchor would silently re-flag
# (or silently silence) the wrong line.
# ---------------------------------------------------------------------------
function Get-NormText {
    param([string]$s)
    $t = $s -replace '[`*_>#|]', ' '
    $t = $t -replace '\s+', ' '
    return $t.Trim().ToLowerInvariant()
}

function Load-Baseline {
    param([string]$Path)
    $result = @{ entries = @(); loaded = $false; rejected = @() }
    if (-not $Path -or -not (Test-Path -LiteralPath $Path)) { return $result }
    try {
        $raw = Get-Content -LiteralPath $Path -Raw -Encoding UTF8 | ConvertFrom-Json
    } catch {
        Write-Host ("[LOI-HUA-MAY] WARN | baseline unreadable: " + $Path) -ForegroundColor Yellow
        return $result
    }
    $result.loaded = $true
    foreach ($e in $raw.entries) {
        # Teeth: a too-short fragment would match half the corpus and silence it.
        if (-not $e.fragment -or $e.fragment.Length -lt 25) {
            $result.rejected += $e
            continue
        }
        $result.entries += [pscustomobject]@{
            file        = $e.file
            fragment    = (Get-NormText $e.fragment)
            disposition = $e.disposition
            note        = $e.note
        }
    }
    return $result
}

function Find-BaselineEntry {
    param($Baseline, [string]$RelFile, [string]$NormLine)
    foreach ($e in $Baseline.entries) {
        if ($e.file -ne $RelFile) { continue }
        if ($NormLine.Contains($e.fragment)) { return $e }
    }
    return $null
}

# ---------------------------------------------------------------------------
# Core scan.
# ---------------------------------------------------------------------------
function Invoke-Scan {
    param([string]$Root, $Files, $Baseline)
    $out = @()
    $index = New-MachineIndex -Root $Root
    foreach ($f in $Files) {
        $rel = Rel $f.FullName
        $lines = @(Get-Content -LiteralPath $f.FullName -Encoding UTF8 -ErrorAction SilentlyContinue)
        $inFence = $false
        for ($i = 0; $i -lt $lines.Count; $i++) {
            $line = $lines[$i]
            if ($line -match '^\s*```') { $inFence = -not $inFence; continue }
            if (-not (Test-IsCandidate $line)) { continue }

            $mention = Get-MentionReason -Line $line -InFence $inFence
            if ($mention) {
                $out += [pscustomobject]@{ File = $rel; Line = ($i + 1); Text = $line
                    Verdict = 'mention'; Severity = 'INFO'; Reason = $mention }
                continue
            }

            $proof = Get-ProofState -Lines $lines -Idx $i -Root $Root -Index $index
            if ($proof -eq 'proof-ok') {
                $out += [pscustomobject]@{ File = $rel; Line = ($i + 1); Text = $line
                    Verdict = 'proved'; Severity = 'INFO'; Reason = 'proof-ok' }
                continue
            }
            if ($proof -and $proof.StartsWith('proof-dead')) {
                $out += [pscustomobject]@{ File = $rel; Line = ($i + 1); Text = $line
                    Verdict = 'flag'; Severity = 'HIGH'; Reason = $proof }
                continue
            }

            $known = Find-BaselineEntry -Baseline $Baseline -RelFile $rel -NormLine (Get-NormText $line)
            if ($known -and $known.disposition -ne 'TODO-classify') {
                $out += [pscustomobject]@{ File = $rel; Line = ($i + 1); Text = $line
                    Verdict = 'baseline'; Severity = 'INFO'; Reason = ('baseline:' + $known.disposition) }
                continue
            }
            if ($known) {
                $out += [pscustomobject]@{ File = $rel; Line = ($i + 1); Text = $line
                    Verdict = 'flag'; Severity = 'LOW'; Reason = 'baseline-TODO-classify' }
                continue
            }
            $out += [pscustomobject]@{ File = $rel; Line = ($i + 1); Text = $line
                Verdict = 'flag'; Severity = 'MED'; Reason = 'no-proof-not-in-baseline' }
        }
    }
    return $out
}

# ---------------------------------------------------------------------------
# -SelfTest : 7 controls on a throwaway tree. Positive controls prove the net
# catches; negative controls prove it does not catch what it must not. A net
# that only ever passes happy-path proves nothing (house rule:
# feedback_faultinjection_proves_teeth).
# ---------------------------------------------------------------------------
function Invoke-SelfTest {
    $tmp = Join-Path $env:TEMP ('loi-hua-may-selftest-' + [guid]::NewGuid().ToString('N').Substring(0, 8))
    $gov = Join-Path $tmp 'docs\governance'
    $scr = Join-Path $tmp 'scripts'
    New-Item -ItemType Directory -Path $gov -Force | Out-Null
    New-Item -ItemType Directory -Path $scr -Force | Out-Null
    Set-Content -LiteralPath (Join-Path $scr 'real-machine.ps1') -Value '# fixture' -Encoding UTF8

    $q = [char]0x0022
    $fx = @()
    $fx += '# fixture'
    $fx += ('C1 detector ' + $T_CHAN + ' moi cau hua sai trong doc governance.')                   # 1 must flag MED
    $fx += ('C2 3 GAP-guard ' + $T_BAT + ' ' + $T_BUOC + ' CA 2 mode - pin de stamp co nghia.')    # 2 must NOT flag
    $fx += ('> C3 luat: cau dang ' + $q + $T_MAY + ' ' + $T_SE + ' ' + $T_CHAN + ' X' + $q + ' phai co existence-proof.') # 3 must NOT
    $fx += ('C4 detector ' + $T_QUET + ' drift: `scripts/real-machine.ps1` chay duoc.')            # 4 must NOT flag
    $fx += ('C5 detector ' + $T_QUET + ' drift: `scripts/khong-ton-tai.ps1` chay duoc.')           # 5 must flag HIGH
    $fx += 'C6 doan van thuong khong nhac may moc gi ca, chi la mo ta quy trinh.'                  # 6 must NOT flag
    $fx += ('C7 legacy detector ' + $T_CHAN + ' cau hua cu tu truoc khi cong nay duoc cai dat.')   # 7 baseline-suppressed
    $fx += ('C9 RETURN guard ' + $T_BAT + '-' + $T_BUOC + ' theo SCHEMA - dau noi la gach ngang.') # 9 must NOT flag
    $fx += ('C10 detector ' + $T_QUET + ' drift: `real-machine.ps1` - ten tran khong kem thu muc.') # 10 must NOT flag
    $fx += ('C11 detector ' + $T_QUET + ' drift: `khong-he-ton-tai.ps1` - ten tran khong resolve.')  # 11 must flag MED
    Set-Content -LiteralPath (Join-Path $gov 'selftest.md') -Value $fx -Encoding UTF8

    $blPath = Join-Path $tmp 'baseline.json'
    $frag = ('legacy detector ' + $T_CHAN + ' cau hua cu tu truoc khi cong nay')
    $bl = [pscustomobject]@{
        note    = 'selftest fixture baseline'
        entries = @([pscustomobject]@{ file = 'docs/governance/selftest.md'; fragment = $frag
                                       disposition = 'legacy-pre-gate'; note = 'fixture' })
    }
    Set-Content -LiteralPath $blPath -Value ($bl | ConvertTo-Json -Depth 5) -Encoding UTF8

    $script:RepoRoot = $tmp
    $files = Get-ScopeFiles -Root $tmp
    $res = Invoke-Scan -Root $tmp -Files $files -Baseline (Load-Baseline $blPath)

    function Get-Row($tag) { return ($res | Where-Object { $_.Text -match ('^' + $tag + '\b') } | Select-Object -First 1) }
    function Get-QuotedRow($tag) { return ($res | Where-Object { $_.Text -match $tag } | Select-Object -First 1) }

    $checks = @()
    $r1 = Get-Row 'C1'; $checks += @{ n = 'C1 positive-control: cau hua khong proof'
        ok = ($r1 -and $r1.Verdict -eq 'flag' -and $r1.Severity -eq 'MED'); got = $r1 }
    $r2 = Get-Row 'C2'; $checks += @{ n = 'C2 FP-guard: bat-buoc KHONG duoc doc thanh bat'
        ok = (-not $r2); got = $r2 }
    $r3 = Get-QuotedRow 'C3'; $checks += @{ n = 'C3 use/mention: dong dinh-nghia-luat trong blockquote'
        ok = ($r3 -and $r3.Verdict -eq 'mention'); got = $r3 }
    $r4 = Get-Row 'C4'; $checks += @{ n = 'C4 proof-ok: script duoc cite CO tren dia'
        ok = ($r4 -and $r4.Verdict -eq 'proved'); got = $r4 }
    $r5 = Get-Row 'C5'; $checks += @{ n = 'C5 positive-control: cite script KHONG ton tai'
        ok = ($r5 -and $r5.Verdict -eq 'flag' -and $r5.Severity -eq 'HIGH'); got = $r5 }
    $r6 = Get-Row 'C6'; $checks += @{ n = 'C6 negative-control: van thuong khong bi hut vao'
        ok = (-not $r6); got = $r6 }
    $r7 = Get-Row 'C7'; $checks += @{ n = 'C7 baseline: dong legacy da liet ke -> khong flag'
        ok = ($r7 -and $r7.Verdict -eq 'baseline'); got = $r7 }

    # C8: anti-Goodhart - the same line with disposition TODO-classify must STILL flag.
    $bl2 = [pscustomobject]@{ note = 'todo'; entries = @([pscustomobject]@{
        file = 'docs/governance/selftest.md'; fragment = $frag; disposition = 'TODO-classify'; note = 'x' }) }
    $bl2Path = Join-Path $tmp 'baseline-todo.json'
    Set-Content -LiteralPath $bl2Path -Value ($bl2 | ConvertTo-Json -Depth 5) -Encoding UTF8
    $res2 = Invoke-Scan -Root $tmp -Files $files -Baseline (Load-Baseline $bl2Path)
    $r8 = $res2 | Where-Object { $_.Text -match '^C7\b' } | Select-Object -First 1
    $checks += @{ n = 'C8 anti-Goodhart: TODO-classify KHONG duoc lam tat den'
        ok = ($r8 -and $r8.Verdict -eq 'flag'); got = $r8 }
    $r9 = Get-Row 'C9'; $checks += @{ n = 'C9 FP-guard: bat-buoc CO GACH NGANG cung khong duoc doc thanh bat'
        ok = (-not $r9); got = $r9 }
    $r10 = Get-Row 'C10'; $checks += @{ n = 'C10 proof: ten may TRAN resolve duoc = da tra loi "ma nao lam viec do"'
        ok = ($r10 -and $r10.Verdict -eq 'proved'); got = $r10 }
    $r11 = Get-Row 'C11'; $checks += @{ n = 'C11 positive-control: ten may TRAN khong resolve -> van flag'
        ok = ($r11 -and $r11.Verdict -eq 'flag'); got = $r11 }

    Write-Host ''
    Write-Host '===== SELF-TEST (tree: ' -NoNewline -ForegroundColor Cyan
    Write-Host $tmp -NoNewline; Write-Host ') =====' -ForegroundColor Cyan
    $fail = 0
    foreach ($c in $checks) {
        if ($c.ok) {
            Write-Host ('  PASS  ' + $c.n) -ForegroundColor Green
        } else {
            $fail++
            $g = 'ABSENT'
            if ($c.got) { $g = ($c.got.Verdict + '/' + $c.got.Severity + '/' + $c.got.Reason) }
            Write-Host ('  FAIL  ' + $c.n + '   [got: ' + $g + ']') -ForegroundColor Red
        }
    }
    Write-Host ''
    Write-Host ('SELF-TEST: ' + ($checks.Count - $fail) + '/' + $checks.Count + ' PASS') -ForegroundColor (& { if ($fail) { 'Red' } else { 'Green' } })
    Remove-Item -LiteralPath $tmp -Recurse -Force -ErrorAction SilentlyContinue
    if ($fail) { exit 1 }
    exit 0
}

# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
if ($SelfTest) { Invoke-SelfTest }

if (-not $BaselinePath) { $BaselinePath = Join-Path $PSScriptRoot 'loi-hua-may-baseline.json' }
$baseline = Load-Baseline $BaselinePath
$files = Get-ScopeFiles -Root $RepoRoot -Wide:$All
$res = Invoke-Scan -Root $RepoRoot -Files $files -Baseline $baseline

if ($EmitBaseline) {
    $entries = @()
    foreach ($r in ($res | Where-Object { $_.Verdict -eq 'flag' })) {
        $norm = (Get-NormText $r.Text)
        $len = [Math]::Min(90, $norm.Length)
        $entries += [pscustomobject]@{ file = $r.File; fragment = $norm.Substring(0, $len)
                                       disposition = 'TODO-classify'; note = ('auto-emitted line ' + $r.Line) }
    }
    ([pscustomobject]@{
        note        = 'DENOMINATOR (enumerated by hand). TODO-classify still flags - emitting cannot silence.'
        scope       = ($files | ForEach-Object { Rel $_.FullName })
        generatedAt = (Get-Date -Format 'yyyy-MM-dd')
        entries     = $entries
    } | ConvertTo-Json -Depth 6)
    exit 0
}

$flags   = @($res | Where-Object { $_.Verdict -eq 'flag' })
$mention = @($res | Where-Object { $_.Verdict -eq 'mention' })
$proved  = @($res | Where-Object { $_.Verdict -eq 'proved' })
$known   = @($res | Where-Object { $_.Verdict -eq 'baseline' })

Write-Host ''
Write-Host '===== LOI-HUA-MAY (khoan 4.4) =====' -ForegroundColor Cyan
Write-Host ('scope      : ' + $files.Count + ' file' + $(if ($All) { ' (-All wide)' } else { ' (C6 corpus: docs/governance/*.md)' }))
Write-Host ('baseline   : ' + $(if ($baseline.loaded) { ($baseline.entries.Count.ToString() + ' entry - ' + (Rel $BaselinePath)) } else { 'KHONG LOAD DUOC - moi dong deu se bi flag' }))
if ($baseline.rejected.Count -gt 0) {
    Write-Host ('  WARN     : ' + $baseline.rejected.Count + ' entry bi loai (fragment < 25 ky tu, qua ngan -> se lam tat den nham)') -ForegroundColor Yellow
}
Write-Host ('candidate  : ' + $res.Count + '  ->  mention ' + $mention.Count + ' | proved ' + $proved.Count + ' | baseline ' + $known.Count + ' | FLAG ' + $flags.Count)
# The baseline total is printed BROKEN DOWN, never as one number: "30 known" would
# read as 30 settled lines, while "debt-open 1 / draft-proposal 3" keeps the debt
# visible. Same reason the emitter cannot self-classify.
if ($known.Count -gt 0) {
    $bd = $known | Group-Object Reason | Sort-Object Count -Descending |
          ForEach-Object { ($_.Name -replace '^baseline:', '') + ' ' + $_.Count }
    Write-Host ('  breakdown: ' + ($bd -join ' | '))
}
Write-Host ''
foreach ($r in ($flags | Sort-Object Severity, File, Line)) {
    $color = 'Yellow'; if ($r.Severity -eq 'HIGH') { $color = 'Red' }; if ($r.Severity -eq 'LOW') { $color = 'Gray' }
    $txt = $r.Text.Trim(); if ($txt.Length -gt 120) { $txt = $txt.Substring(0, 120) + '...' }
    Write-Host ("[LOI-HUA-MAY] {0,-4} | {1}:{2} | {3} | {4}" -f $r.Severity, $r.File, $r.Line, $r.Reason, $txt) -ForegroundColor $color
    Write-Host ("               resolve: them existence-proof tai cho {path may + lenh + test}, HOAC viet lai dang du-dinh, HOAC ghi vao baseline kem disposition") -ForegroundColor DarkGray
}
Write-Host ''
Write-Host 'DOC DUNG CON SO NAY: day la so dong CHUA-CO-PROOF va CHUA-CO-TRONG-BASELINE.' -ForegroundColor DarkCyan
Write-Host 'FLAG 0 != corpus sach. Cua nay do GATE (dong viet moi), khong do SWEEP (goi-chot:53).' -ForegroundColor DarkCyan
exit 0
