Wave adap-6-broadcast W1 - 4 lane disjoint qua /hmw RUN-TRACE (wf_de36dea9-265, 4/4 lane 0-error).
Lead TU CHAY LAI ca 4 + tu fault-inject, KHONG tin return (gotcha #53 fire 2/2 @W0.6).
(1) governance-detectors.ps1 455->715
- GAP-3 +2 token canonical: menu=54 (STATUS.md:19); policy = row CHUA co (W2 land)
-> FAIL-LOUD + SKIP, khong im lang gia vo xanh.
- Detector H24-1 title-freshness: anchor_patterns[] = MANG 2 dang (fix C2). Bat buoc co dang
'**Status (post Session N <em-dash> YYYY-MM-DD):**' vi permission-matrix/SKILL.md
(positive-control SONG) dung dang do va KHONG co 'Last updated'. Neu 1 regex => detector
loai thang chinh positive-control cua no => W4 bat-kha-thi.
NEO-PHAM-VI load-bearing: STATUS.md:6 chua 2026-08-01 / 1987-03-07 / 2026-20-42 (cat tu
mark-id). Regex-ngay-bat-ky lay max => 2026-20-42 = ngay KHONG ton tai => moc-phai rac
=> trai<phai khong bao gio dung => 0 flag VINH VIEN ma van xanh.
- Detector H24-2 carry-age INFORM-only: M doc tu agent-memory/memory-budget.json
h24_cadence.light_every; thieu key => FAIL-LOUD, 0 so nhip hardcode.
- Bug lane tu bat: PS ten bien CASE-INSENSITIVE => $M (cadence) == $m (loop match) => ghi de
cadence => vo nhanh fail-loud. exit 0 CHE bug nay; chi stderr moi lo.
- M3 scope giu: 4 hang so owner-da-ky KHONG dung (walLineCount 40 / ratio / maxGotcha / liveVariants).
(2) agent-frontmatter-eol-check.ps1 (NEW) - HYGIENE-only
W0.6 spawn-probe @S121 do duoc CRLF-TOLERANT (agent CRLF 276 byte CR spawn an du 4 tang;
token 45248 LF vs 45240 CRLF, lech 8 = chenh chu trong prompt) => gia thuyet 'CRLF giet
registry' BI BAC => script ha cap xuong hygiene, khai that ca o header lan thong diep FLAG
runtime. Scope-chat 37 file (agents/commands/skills), KHONG repo-wide (188 = duong-gia
factory). Tu in GREEN-BUT-VACUOUS thay vi nhan cong.
(3) wal-recovery-test.ps1 (NEW) - 4/4 ca, 30 assert
FIX #3-bis: bien re nhanh = DAY RANGE, KHONG phai NONWAL. Counterfactual chay luat cu =>
tai hien DEADLOCK that voi rebase-exit=0 (hong ma bao thanh cong). Assert
sut-has-no-NONWAL-param bang Get-Command reflection, KHONG grep => ne tu-tham-chieu (W0.4#1).
(4) spawn-model-audit.ps1 (NEW) - PA-2b
FAIL-LOUD khi thieu TIER2_EXPECTED_FULL_ID (W2 chua land). Muc E liet 6 dieu no KHONG chung,
gom precedence spawn-param vs frontmatter (CHUA TEST, defer phien lead=Fable) + fix#8a chua thu.
VERIFY LEAD TU DO (khong phai so sub khai):
TOTAL FLAGS 42->49 (>=46) . permission-matrix/SKILL.md FIRE 3 dong (W4 kha thi; KHONG lane nao
cham file do) . exit 0 + stderr 0 x4 . non-ASCII 0/4 . teeth 2/2: inject 276 CR -> FLAG, go -> 0;
inject claude-opus-9-9 -> FLAG mismatch, sua -> het. Containment sach.
LOI LEAD @S121 (di vao adap-report, khong im):
(a) San-1 lech: marker wf: dang ky SAU khi phong (luat = TRUOC).
(b) Khang dinh fact-tren-dia ma KHONG do dia: lead phan 'backfill [carry:*] = orphan chua ai lam'
-> SAI, da co 7 khoa tu 4727d16 (S119, da push). Co che: ra BANG WAVE thay khong ai duoc
giao => suy ra chua lam. 'Khong wave nao so huu' != 'chua co tren dia'. Brief lead co 4 tien
de sai (config path / acceptance ngay 07-14 vs that 07-15 / carry / do-dai); lane (1) do dia va va het.
GAP THAT thay claim sai (lane 1 tim, lead verify dia): carry-age vacuous vi CAU TRUC - 45
logic-segment nhung carry-lines=1 => streak==1 => 0 fire vinh vien ke ca sau W2. Lo = thieu nghi
thuc RE-STAMP moi phien => giao W3 session-end.md, KHONG phai W2.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
716 lines
36 KiB
PowerShell
716 lines
36 KiB
PowerShell
<#
|
|
.SYNOPSIS
|
|
governance-detectors.ps1 - Harness-11 PHAN C + B3 governance drift detectors.
|
|
|
|
.DESCRIPTION
|
|
NO-API, DETECT-and-FLAG-only grep net (Harness-11 mandate):
|
|
(1) NO-API - only Select-String + byte/file-exist measure. NEVER calls model/API.
|
|
(2) FLAG-only - prints FLAGs, NEVER edits files (auto-WRITE of rules = top hazard, forbidden).
|
|
(3) PowerShell 5.1 compatible. Run offline. ASCII-only script body (gotcha #30);
|
|
target-file content is read -Encoding UTF8 so Vietnamese count-tokens
|
|
(bay / bang / Du tru) match correctly.
|
|
(5) DETECT-only LOWERING NET, not a hard build gate. Exit code always 0.
|
|
|
|
Detectors:
|
|
C2/B3 - derived-staleness : canonical counts from STATUS.md (cross-checked vs disk),
|
|
then derived docs scanned for stale count-tokens.
|
|
C1 - broken-pointer : (a) gotcha #N refs > max-gotcha or missing "### N." anchor
|
|
(b) dangling [[wikilink]] in user-memory / agent-memory.
|
|
C3 - vocab-fork : alias-sets where >=2 variants live side-by-side.
|
|
C4 - self-line exclusion: pattern-describing files removed from every scan
|
|
(else the detector self-matches).
|
|
H24-1 - title-freshness : doc's OWN title/status anchor date vs the newest
|
|
governance milestone date. ANCHOR-SCOPED parse.
|
|
H24-2 - carry-age : [carry:<slug>] keys alive across >= M consecutive
|
|
most-recent carry-lines. M read from config, never
|
|
hardcoded. INFORM-only.
|
|
|
|
Each FLAG line:
|
|
[DETECTOR] severity | file:line | description | resolve: <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"
|
|
|
|
# H24-1: EM DASH U+2014 (NOT hyphen-minus U+002D). Verified byte-level against
|
|
# .claude/skills/permission-matrix/SKILL.md:16 -> bytes "e2 80 94" = U+2014.
|
|
# Same runtime code-point trick as the VN tokens: an inline em-dash would be a
|
|
# non-ASCII byte in this .ps1 and would mojibake under the ANSI codepage decode.
|
|
$EM_DASH = U @(0x2014)
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# C4 - self-line exclusion (BUILT FIRST so every scan can apply it)
|
|
# These files DESCRIBE the patterns the detectors look for; without exclusion
|
|
# the detector would flag itself. Glob-style suffix/substring rules.
|
|
# ---------------------------------------------------------------------------
|
|
$ExcludeExact = @(
|
|
(Join-Path $RepoRoot 'scripts\governance-detectors.ps1'),
|
|
(Join-Path $RepoRoot 'docs\governance\harness-11-engine.md'),
|
|
(Join-Path $RepoRoot 'docs\governance\vocab-alias-map.md')
|
|
) | ForEach-Object { $_ -replace '/', '\' }
|
|
|
|
$ExcludeDirFragments = @(
|
|
'\broadcasts\inbox\',
|
|
'\broadcasts\outbox\',
|
|
'\.claude\workflows\runs\',
|
|
'\.claude\workflows\scripts\'
|
|
)
|
|
|
|
function Test-Excluded($full) {
|
|
$p = ($full -replace '/', '\')
|
|
foreach ($ex in $ExcludeExact) { if ($p -ieq $ex) { return $true } }
|
|
foreach ($frag in $ExcludeDirFragments) { if ($p -ilike "*$frag*") { return $true } }
|
|
return $false
|
|
}
|
|
|
|
# Resolve which excluded paths actually exist on disk (for the audit line).
|
|
$ExcludedActual = @()
|
|
foreach ($ex in $ExcludeExact) { if (Test-Path $ex) { $ExcludedActual += $ex } }
|
|
foreach ($frag in $ExcludeDirFragments) {
|
|
$probe = Join-Path $RepoRoot ($frag.Trim('\'))
|
|
if (Test-Path $probe) { $ExcludedActual += $probe }
|
|
}
|
|
|
|
# Gather governance MD set ONCE (docs/** + .claude/** *.md), minus excluded.
|
|
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 {
|
|
# GAP-3 (H24): canonical token -> STATUS.md CURRENT-STATE row label.
|
|
# Row labels are READ OFF DISK, not guessed: 'Menu keys' is docs/STATUS.md:19
|
|
# ("| Menu keys | **54** |"); 'Policies' does NOT exist yet -- W2 lands
|
|
# "| Policies | **216** |". A token whose row is absent must FAIL-LOUD and then
|
|
# be SKIPPED: silently resolving to $null would let the 'policy' half of GAP-3
|
|
# sit green while measuring nothing (a detector that cannot fire is worse than
|
|
# no detector -- it reads as coverage).
|
|
$canonRows = [ordered]@{
|
|
'mig' = 'Migrations'
|
|
'test' = 'Tests'
|
|
'gotcha' = 'Gotchas'
|
|
'table' = 'SQL tables'
|
|
'menu' = 'Menu keys'
|
|
'policy' = 'Policies'
|
|
}
|
|
foreach ($k in $canonRows.Keys) { $canonical[$k] = Get-StatusValue $statusPath $canonRows[$k] }
|
|
|
|
$canonShow = @()
|
|
foreach ($k in $canonRows.Keys) {
|
|
$v = $canonical[$k]
|
|
if ($null -eq $v) { $canonShow += ("{0}=MISSING" -f $k) } else { $canonShow += ("{0}={1}" -f $k, $v) }
|
|
}
|
|
Write-Host (" STATUS.md canonical: " + ($canonShow -join ' '))
|
|
|
|
foreach ($k in $canonRows.Keys) {
|
|
if ($null -eq $canonical[$k]) {
|
|
Write-Flag 'MED' (Rel $statusPath) `
|
|
("canonical row missing: no '| {0} | **<n>** |' row in CURRENT STATE -> token '{1}' UNRESOLVED and SKIPPED (scanning nothing, NOT green)" -f $canonRows[$k], $k) `
|
|
("add the '{0}' row to the docs/STATUS.md CURRENT STATE table" -f $canonRows[$k])
|
|
}
|
|
}
|
|
|
|
# ---- 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' },
|
|
# GAP-3 (H24): 'menu' + 'policy' were BLIND. Both resolve via $canonRows above;
|
|
# a token whose canonical row is missing is skipped by the $null guard below
|
|
# (already FAIL-LOUD flagged at resolve time), so 'policy' stays inert until W2.
|
|
@{ Rx = '(\d+)\s*menu'; Key = 'menu'; Label = 'menu' },
|
|
@{ Rx = '(\d+)\s*polic(?:y|ies)'; Key = 'policy'; Label = 'policy' }
|
|
)
|
|
|
|
# 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)'
|
|
}
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# H24-1 - title-freshness (do-tuoi-tieu-de)
|
|
# moc-phai (right edge) = newest governance milestone = MAX valid anchor date in corpus
|
|
# moc-trai (left edge) = the date carried by the doc's OWN title/status anchor
|
|
# FLAG when trai < phai. No anchor => SKIP (a doc that makes no freshness claim
|
|
# cannot make a STALE one).
|
|
#
|
|
# WHY ANCHOR-SCOPED, NOT "any date on the line" -- this is the load-bearing bit.
|
|
# Measured on disk: docs/STATUS.md:6 is a 69,870-char MEGA-LINE carrying 41
|
|
# date-shaped tokens. Most are NOT dates: owner-mark ids of the form
|
|
# RC-pqhuy1987-12-07-2026-11-43-45 get shredded by a bare \d{4}-\d{2}-\d{2} into
|
|
# GARBAGE pseudo-dates (1987-12-07 from the ...1987-12-07... slice, 2026-11-43,
|
|
# 2026-20-42 -- month 20, day 42). The line also holds a REAL future date
|
|
# (2026-08-01 = next monthly audit due). So:
|
|
# - date-max over the line -> 2026-20-42 (garbage) or 2026-08-01 (future)
|
|
# - either makes moc-phai unreachable => trai < phai never true => 0 flags forever
|
|
# => the detector silently strangles its OWN positive-control and reads green.
|
|
# Hence: capture ONLY the date bound to the anchor + strict calendar validation
|
|
# (TryParseExact rejects month-20/day-42 outright).
|
|
#
|
|
# WHY anchor_patterns IS A LIST (>=2 forms) -- also load-bearing:
|
|
# .claude/skills/*/SKILL.md carry NO "Last updated" anchor (grep "Last updated"
|
|
# in .claude/skills/ = 0 hit, verified). They use the OTHER form:
|
|
# "**Status (post Session N <em-dash> YYYY-MM-DD):**". A single "Last updated"
|
|
# regex + the "no anchor => skip" rule would drop permission-matrix/SKILL.md --
|
|
# the exact positive-control this detector exists to fire on.
|
|
# ---------------------------------------------------------------------------
|
|
Write-Section 'H24-1 - title-freshness'
|
|
|
|
$AnchorPatterns = @(
|
|
'\*\*Last updated:\*\*\s*(\d{4}-\d{2}-\d{2})',
|
|
('\*\*Status \(post Session \d+ ' + $EM_DASH + ' (\d{4}-\d{2}-\d{2})\)\:\*\*')
|
|
)
|
|
|
|
# docs/_archive/ is FROZEN-BY-DESIGN (verbatim pre-S40 snapshots kept as historical
|
|
# record). Their old anchor date is the POINT of the file, and the only "resolve"
|
|
# action -- refresh the date -- would destroy the record. A flag whose resolve is
|
|
# forbidden is permanent noise, so archives are out of THIS detector's scope.
|
|
# Scoped LOCALLY (not added to the global Test-Excluded) so C1/C3 keep scanning them
|
|
# exactly as before -- widening the global exclude would silently move other
|
|
# detectors' baselines, which is not this lane's call.
|
|
$TitleFreshSkip = @('\docs\_archive\')
|
|
|
|
function Get-AnchorDate {
|
|
param([string]$Path)
|
|
$ls = Get-Content -Path $Path -Encoding UTF8 -ErrorAction SilentlyContinue
|
|
if ($null -eq $ls) { return $null }
|
|
for ($i = 0; $i -lt $ls.Count; $i++) {
|
|
foreach ($rx in $AnchorPatterns) {
|
|
$m = [regex]::Match($ls[$i], $rx)
|
|
if ($m.Success) {
|
|
$raw = $m.Groups[1].Value
|
|
$dt = [datetime]::MinValue
|
|
$ok = [datetime]::TryParseExact($raw, 'yyyy-MM-dd',
|
|
[Globalization.CultureInfo]::InvariantCulture,
|
|
[Globalization.DateTimeStyles]::None, [ref]$dt)
|
|
if ($ok) {
|
|
return [pscustomobject]@{ Date = $dt; Raw = $raw; Line = ($i + 1) }
|
|
}
|
|
# anchor present but date not a real calendar date -> keep looking
|
|
}
|
|
}
|
|
}
|
|
return $null
|
|
}
|
|
|
|
$anchored = @()
|
|
foreach ($f in $GovMd) {
|
|
$p = ($f.FullName -replace '/', '\')
|
|
$skip = $false
|
|
foreach ($frag in $TitleFreshSkip) { if ($p -ilike "*$frag*") { $skip = $true } }
|
|
if ($skip) { continue }
|
|
$a = Get-AnchorDate $f.FullName
|
|
if ($null -ne $a) {
|
|
$anchored += [pscustomobject]@{
|
|
Rel = (Rel $f.FullName); Date = $a.Date; Raw = $a.Raw; Line = $a.Line
|
|
}
|
|
}
|
|
}
|
|
|
|
if ($anchored.Count -eq 0) {
|
|
Write-Host ' [skip] no doc carries a known title/status anchor - nothing to age-compare' -ForegroundColor DarkGray
|
|
}
|
|
else {
|
|
$newest = ($anchored | Sort-Object Date -Descending | Select-Object -First 1)
|
|
Write-Host (" anchors parsed: {0} doc(s) ; moc-phai (newest governance milestone) = {1} from {2}:{3}" -f `
|
|
$anchored.Count, $newest.Raw, $newest.Rel, $newest.Line)
|
|
foreach ($a in ($anchored | Sort-Object Date -Descending)) {
|
|
Write-Host (" anchor {0} {1}:{2}" -f $a.Raw, $a.Rel, $a.Line) -ForegroundColor DarkGray
|
|
}
|
|
foreach ($a in $anchored) {
|
|
if ($a.Date -lt $newest.Date) {
|
|
$age = [int]($newest.Date - $a.Date).TotalDays
|
|
Write-Flag 'LOW' ("{0}:{1}" -f $a.Rel, $a.Line) `
|
|
("title-stale: anchor says {0} but newest governance milestone is {1} ({2}d behind)" -f $a.Raw, $newest.Raw, $age) `
|
|
'refresh the title/status anchor date, or state explicitly that the doc is frozen-historical'
|
|
}
|
|
}
|
|
}
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# H24-2 - carry-age (INFORM-only)
|
|
# A [carry:<slug>] key that survives >= M CONSECUTIVE most-recent carry-lines is
|
|
# "aged" -- it has outlived a full review cadence without being closed.
|
|
#
|
|
# "carry-line" (dong-carry) = a LOGIC segment, NOT a physical line. docs/HANDOFF.md
|
|
# is 12 physical lines but line 5 alone is a ~46.5K-char mega-line holding 45
|
|
# "NEXT anh" / "NEXT em" blocks (newest-first). Get-Content -TotalCount would see
|
|
# ONE line and measure nothing, so we read -Raw and split on the NEXT markers.
|
|
#
|
|
# Streak counts only over lines that HAVE carry: a session that emitted no carry
|
|
# does NOT break a chain. A key resets ONLY by being absent from a line that HAS
|
|
# carry. Only keys on the newest carry-line can hold a live streak (a key gone from
|
|
# the newest one is closed, not aged).
|
|
#
|
|
# M comes from config, NEVER hardcoded: a hardcoded cadence is exactly the
|
|
# single-source violation H24 forbids. Missing config/key => FAIL-LOUD + measure
|
|
# nothing. Scope note: this "no hardcoded cadence" rule is about the CADENCE number
|
|
# only -- pre-existing constants elsewhere in this script are other detectors'
|
|
# owner-signed numbers and are out of scope.
|
|
# ---------------------------------------------------------------------------
|
|
Write-Section 'H24-2 - carry-age (INFORM-only)'
|
|
|
|
# Canonical config path per owner-decision Q2. Probe agent-memory/ FIRST (that is
|
|
# where the live memory-budget.json actually is on disk); the bare .claude/ path is
|
|
# a fallback in case W2 lands the key at the shorter path some docs abbreviate to.
|
|
$cfgCandidates = @(
|
|
(Join-Path $RepoRoot '.claude\agent-memory\memory-budget.json'),
|
|
(Join-Path $RepoRoot '.claude\memory-budget.json')
|
|
)
|
|
$cfgPath = $null
|
|
foreach ($c in $cfgCandidates) { if ($null -eq $cfgPath) { if (Test-Path $c) { $cfgPath = $c } } }
|
|
|
|
# NOTE: named $CadenceM, NOT $M. PowerShell variable names are CASE-INSENSITIVE, so a
|
|
# bare $M is the SAME variable as the $m used by the regex-match loops below -- the
|
|
# match object silently clobbered the cadence, making ($null -eq $M) false and turning
|
|
# the fail-loud path into "Could not compare 1 to [carry:bvaau]". Keep the long name.
|
|
$CadenceM = $null
|
|
if ($null -eq $cfgPath) {
|
|
Write-Flag 'MED' '.claude/agent-memory/memory-budget.json' `
|
|
'carry-age config NOT FOUND at any candidate path - cadence unresolved, carry-age measuring NOTHING' `
|
|
'create memory-budget.json carrying h24_cadence { light_every, deep_every, jump_on_class_repeat }'
|
|
}
|
|
else {
|
|
Write-Host (" config resolved: {0}" -f (Rel $cfgPath))
|
|
$cfg = $null
|
|
try { $cfg = (Get-Content -Path $cfgPath -Raw -Encoding UTF8 | ConvertFrom-Json) }
|
|
catch { $cfg = $null }
|
|
if ($null -eq $cfg) {
|
|
Write-Flag 'MED' (Rel $cfgPath) `
|
|
'carry-age config unparseable as JSON - cadence unresolved, carry-age measuring NOTHING' `
|
|
'fix the JSON syntax'
|
|
}
|
|
elseif ($null -eq $cfg.h24_cadence) {
|
|
Write-Flag 'MED' (Rel $cfgPath) `
|
|
'h24_cadence missing - W2 chua land => carry-age cadence UNRESOLVED, measuring NOTHING (no default is assumed: a hardcoded cadence would violate H24 single-source)' `
|
|
'W2: add h24_cadence { light_every, deep_every, jump_on_class_repeat }'
|
|
}
|
|
elseif ($null -eq $cfg.h24_cadence.light_every) {
|
|
Write-Flag 'MED' (Rel $cfgPath) `
|
|
'h24_cadence present but sub-key light_every missing - carry-age uses light_every as M, measuring NOTHING' `
|
|
'W2: add h24_cadence.light_every (the light-audit cadence a carry must not outlive)'
|
|
}
|
|
else {
|
|
$CadenceM = [int]$cfg.h24_cadence.light_every
|
|
Write-Host (" M = h24_cadence.light_every = {0} (read from config, not hardcoded)" -f $CadenceM)
|
|
}
|
|
}
|
|
|
|
$handoffPath = Join-Path $RepoRoot 'docs\HANDOFF.md'
|
|
if (-not (Test-Path $handoffPath)) {
|
|
Write-Host ' (no docs/HANDOFF.md - no carry surface, skip)' -ForegroundColor DarkGray
|
|
}
|
|
else {
|
|
$raw = Get-Content -Path $handoffPath -Raw -Encoding UTF8
|
|
# "NEXT anh" / "NEXT em" are pure ASCII (no diacritics) -> safe as a literal here.
|
|
$marks = [regex]::Matches($raw, 'NEXT\s+(?:anh|em)')
|
|
$segs = @()
|
|
for ($i = 0; $i -lt $marks.Count; $i++) {
|
|
$start = $marks[$i].Index
|
|
$end = if ($i + 1 -lt $marks.Count) { $marks[$i + 1].Index } else { $raw.Length }
|
|
$segs += $raw.Substring($start, $end - $start)
|
|
}
|
|
# Key charset excludes '<' so the FORMAT-SPEC literal "[carry:<slug>]" (prose in
|
|
# HANDOFF describing the convention) is never counted as a real key -- a detector
|
|
# that flags the sentence DEFINING its own pattern is the self-reference trap.
|
|
$carryRx = '\[carry:([a-z0-9][a-z0-9._-]*)\]'
|
|
$carryLines = @()
|
|
foreach ($s in $segs) {
|
|
$ks = @()
|
|
foreach ($cm in [regex]::Matches($s, $carryRx)) { $ks += $cm.Groups[1].Value }
|
|
if ($ks.Count -gt 0) { $carryLines += , (@($ks | Select-Object -Unique)) }
|
|
}
|
|
Write-Host (" HANDOFF logic-segments (NEXT anh/em) = {0} ; of those, carry-lines = {1}" -f `
|
|
$segs.Count, $carryLines.Count)
|
|
|
|
if ($carryLines.Count -eq 0) {
|
|
Write-Host ' (0 carry-line - no [carry:<slug>] stamped yet, nothing to age)' -ForegroundColor DarkGray
|
|
}
|
|
else {
|
|
foreach ($k in $carryLines[0]) {
|
|
$n = 0
|
|
for ($i = 0; $i -lt $carryLines.Count; $i++) {
|
|
if ($carryLines[$i] -contains $k) { $n++ } else { break }
|
|
}
|
|
if ($null -eq $CadenceM) {
|
|
Write-Host (" [inform] carry '{0}' streak={1} carry-line(s) ; M unresolved -> NO aged/not-aged verdict" -f $k, $n) -ForegroundColor DarkGray
|
|
}
|
|
elseif ($n -ge $CadenceM) {
|
|
Write-Flag 'LOW' ('docs/HANDOFF.md:5') `
|
|
("gap-carry-aged [INFORM]: carry '{0}' alive across {1} consecutive carry-lines (>= M={2}) - owner may be holding it deliberately" -f $k, $n, $CadenceM) `
|
|
("close it, or re-scope it; INFORM-only - no action forced")
|
|
}
|
|
else {
|
|
Write-Host (" [ok] carry '{0}' streak={1} < M={2}" -f $k, $n, $CadenceM) -ForegroundColor DarkGray
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 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
|