<# .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:' | $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: 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