592 lines
30 KiB
PowerShell
592 lines
30 KiB
PowerShell
<#
|
|
.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
|