[CLAUDE] Infra: W1 - 2 detector H24 + 3 script governance (eol-hygiene / wal-recovery 4-ca / spawn-model-audit)
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>
This commit is contained in:
335
scripts/agent-frontmatter-eol-check.ps1
Normal file
335
scripts/agent-frontmatter-eol-check.ps1
Normal file
@ -0,0 +1,335 @@
|
|||||||
|
<#
|
||||||
|
.SYNOPSIS
|
||||||
|
agent-frontmatter-eol-check.ps1 - HYGIENE-only EOL check for agent/command/skill frontmatter.
|
||||||
|
NOT a fatal-defect guard. The "CRLF kills the agent registry" claim was REFUTED by experiment
|
||||||
|
(S121 W0.6) and this script must not be read, cited, or gated on as if it enforced that claim.
|
||||||
|
|
||||||
|
.DESCRIPTION
|
||||||
|
WHAT IT DOES
|
||||||
|
Enforces the .gitattributes policy line '* text=auto eol=lf' (repo root) on the frontmatter
|
||||||
|
of the rule-set files:
|
||||||
|
.claude/agents/*.md
|
||||||
|
.claude/commands/*.md
|
||||||
|
.claude/skills/**/SKILL.md
|
||||||
|
It reads RAW BYTES and counts CR (0x0D) inside the YAML frontmatter block.
|
||||||
|
|
||||||
|
WHAT IT IS NOT - the honest grade of this check (read before trusting it)
|
||||||
|
The S121 W0.6 spawn-probe MEASURED the opposite of the assumption that motivated this
|
||||||
|
script. A fully CRLF-ised agent file (276 CR bytes) spawned CORRECTLY through all four
|
||||||
|
stages: discovery / parse / spawn / execute. Token accounting was 45248 (LF) vs 45240
|
||||||
|
(CRLF); that 8-token delta is explained by wording differences in the prompt, not by EOL.
|
||||||
|
Verdict: CRLF-TOLERANT. The "LF-only frontmatter reader" hypothesis was REFUTED.
|
||||||
|
=> This is therefore HYGIENE, not defect-prevention. No CR finding produced here is known
|
||||||
|
to break anything. Do not describe it as fatal. Do not gate a build on it.
|
||||||
|
|
||||||
|
WHY IT STILL EXISTS - the scope limit of that refutation
|
||||||
|
The W0.6 measurement covers: BEHAVIOUR level only / .claude/agents/ only / ONE win32 build.
|
||||||
|
NOT covered: .claude/commands/, .claude/skills/, hook .ps1 readers, any other build or
|
||||||
|
platform. LF hygiene on those surfaces remains unverified-by-experiment, and the
|
||||||
|
.gitattributes eol=lf policy applies to them regardless of whether any reader currently
|
||||||
|
chokes. That residual - plus keeping the worktree consistent with the committed blob -
|
||||||
|
is the entire value proposition. It is a small one. It is stated at its real size on purpose.
|
||||||
|
|
||||||
|
SEVERITY LADDER (a deliberate consequence of the refutation)
|
||||||
|
HIGH - reserved for SELF-BROKEN only: the synthetic control below failed, so the scan
|
||||||
|
result is meaningless. Never used for a CR finding - none is known to break anything.
|
||||||
|
MED - CR inside the frontmatter block: the surface a parser would read, and the surface
|
||||||
|
W0.6 probed on agents/ only.
|
||||||
|
LOW - CR in the body only: same eol=lf policy, further from any reader hot-path.
|
||||||
|
Exit code is ALWAYS 0. Inform/hygiene net, never a hard gate.
|
||||||
|
|
||||||
|
POSITIVE CONTROL - SYNTHETIC, and declared as such (spec v2 (4)(a))
|
||||||
|
The scoped set measured 0 CR at S121, so a green run is the expected outcome. A green run
|
||||||
|
therefore proves NOTHING by itself: a detector that is merely broken is also green. Every
|
||||||
|
run self-tests the byte-reader against two IN-MEMORY buffers BEFORE touching disk:
|
||||||
|
(+) CRLF synthetic agent file -> reader MUST report frontmatter CR > 0
|
||||||
|
(-) LF synthetic agent file -> reader MUST report frontmatter CR = 0
|
||||||
|
These controls are synthetic BY NECESSITY, not by preference: because W0.6 refuted
|
||||||
|
CRLF-fatality, no real "file killed by CRLF" exists to serve as a natural positive control.
|
||||||
|
Note the limit of what a synthetic control buys: it proves the READER works. It does NOT
|
||||||
|
prove that CRLF harms anything - the experiment says it does not.
|
||||||
|
If either control fails, a HIGH self-broken FLAG is raised and the scan is reported
|
||||||
|
UNTRUSTWORTHY.
|
||||||
|
|
||||||
|
DESIGN NOTES
|
||||||
|
- Byte-reader, not text-reader: Get-Content (default and -Raw) drops or normalises CR, so a
|
||||||
|
text-level check could never fail on a CRLF file - it would be vacuous BY CONSTRUCTION.
|
||||||
|
[System.IO.File]::ReadAllBytes is used instead. Side effect: no text decoding happens at
|
||||||
|
all, so target-file encoding is irrelevant and gotcha #30 (ANSI-vs-UTF8 mojibake) cannot
|
||||||
|
reach the measurement. This script body is still ASCII-only per gotcha #30 / #37.
|
||||||
|
- No self-reference hazard (W0.4 lesson): the test is STRUCTURAL - count 0x0D inside a
|
||||||
|
byte-delimited region - not a string-grep, so it cannot match a quoted claim written
|
||||||
|
about itself. It also lives in scripts/ and scans only .claude/{agents,commands,skills},
|
||||||
|
so it is path-disjoint from its own scan set as well.
|
||||||
|
- Counts come from globs, never hardcoded (spec v3 fix #9h). Every PASS/FAIL/GREEN word
|
||||||
|
printed below is COMPUTED from a measured number (W0.4 meta-count lesson: never print a
|
||||||
|
verdict label next to a number that says otherwise).
|
||||||
|
- agents/README.md is intentionally IN scope. R2-C1 excludes README from the ROSTER count
|
||||||
|
(it is not an agent); EOL policy is per-file, so README is governed like any other file.
|
||||||
|
The two rules differ on purpose - do not "fix" one by copying the other.
|
||||||
|
- Scope is deliberately NOT repo-wide. A repo-wide 'i/lf w/crlf' scan matches 188 files
|
||||||
|
(131 of them EF Migrations); an acceptance of "count == 0" over that set would be a
|
||||||
|
false-price factory, and mass rm+checkout is forbidden this session (spec v2 W0.2).
|
||||||
|
The 188 stay INFORM-only and are not this script's business.
|
||||||
|
|
||||||
|
.PARAMETER RepoRoot
|
||||||
|
Repo root. Default = resolved 1 level up from this script (scripts/ -> repo root).
|
||||||
|
Every path is derived from it, so the script can be pointed at a temp tree for fault-injection.
|
||||||
|
|
||||||
|
.EXAMPLE
|
||||||
|
powershell.exe -ExecutionPolicy Bypass -File scripts/agent-frontmatter-eol-check.ps1
|
||||||
|
|
||||||
|
.EXAMPLE
|
||||||
|
powershell.exe -ExecutionPolicy Bypass -File scripts/agent-frontmatter-eol-check.ps1 -RepoRoot C:\Temp\eol-faultinject
|
||||||
|
#>
|
||||||
|
param(
|
||||||
|
[string]$RepoRoot = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path
|
||||||
|
)
|
||||||
|
|
||||||
|
$ErrorActionPreference = 'Continue'
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Helpers - shape mirrors scripts/governance-detectors.ps1:43-67 so FLAG output
|
||||||
|
# from the two nets reads the same way.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
$script:FlagCount = 0
|
||||||
|
|
||||||
|
function Write-Flag {
|
||||||
|
param(
|
||||||
|
[ValidateSet('HIGH', 'MED', 'LOW')] [string]$Severity,
|
||||||
|
[string]$Where, # file:line, or file
|
||||||
|
[string]$Desc,
|
||||||
|
[string]$Resolve
|
||||||
|
)
|
||||||
|
$color = switch ($Severity) { 'HIGH' { 'Red' } 'MED' { 'Yellow' } default { 'Gray' } }
|
||||||
|
Write-Host ("[EOL] {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 '\\', '/')
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Byte-level frontmatter reader (the whole point - see DESIGN NOTES).
|
||||||
|
#
|
||||||
|
# Splits on 0x0A only, then strips one optional trailing 0x0D per line, so the
|
||||||
|
# frontmatter delimiters are located identically in LF and CRLF files. The
|
||||||
|
# returned offsets are BYTE offsets, which is what lets us attribute every single
|
||||||
|
# CR to either the frontmatter block or the body.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
function Measure-EolBytes {
|
||||||
|
param([byte[]]$Bytes)
|
||||||
|
|
||||||
|
if ($null -eq $Bytes) { $Bytes = New-Object byte[] 0 }
|
||||||
|
$len = $Bytes.Length
|
||||||
|
|
||||||
|
# Skip a UTF-8 BOM so a BOM'd file does not silently read as "no frontmatter".
|
||||||
|
$start = 0
|
||||||
|
$hasBom = $false
|
||||||
|
if ($len -ge 3 -and $Bytes[0] -eq 0xEF -and $Bytes[1] -eq 0xBB -and $Bytes[2] -eq 0xBF) {
|
||||||
|
$hasBom = $true
|
||||||
|
$start = 3
|
||||||
|
}
|
||||||
|
|
||||||
|
# Locate the frontmatter block: line 0 must be exactly '---'; the block ends at
|
||||||
|
# the next line that is exactly '---'. $fmEnd = byte offset one past that line.
|
||||||
|
$fmEnd = -1
|
||||||
|
$lineStart = $start
|
||||||
|
$lineIdx = 0
|
||||||
|
for ($i = $start; $i -le $len; $i++) {
|
||||||
|
$atEof = ($i -eq $len)
|
||||||
|
if (-not $atEof -and $Bytes[$i] -ne 0x0A) { continue }
|
||||||
|
|
||||||
|
$endExcl = $i
|
||||||
|
if ($endExcl -gt $lineStart -and $Bytes[$endExcl - 1] -eq 0x0D) { $endExcl-- }
|
||||||
|
|
||||||
|
# Byte -> char is Latin-1 here; harmless because the only comparison is the
|
||||||
|
# ASCII delimiter '---'. Multi-byte UTF-8 in frontmatter cannot false-match it.
|
||||||
|
$sb = New-Object System.Text.StringBuilder
|
||||||
|
for ($k = $lineStart; $k -lt $endExcl; $k++) { [void]$sb.Append([char]$Bytes[$k]) }
|
||||||
|
$content = $sb.ToString().TrimEnd()
|
||||||
|
|
||||||
|
if ($lineIdx -eq 0) {
|
||||||
|
if ($content -ne '---') { break } # no frontmatter block at all
|
||||||
|
}
|
||||||
|
elseif ($content -eq '---') {
|
||||||
|
$fmEnd = if ($atEof) { $len } else { $i + 1 }
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($atEof) { break } # opening '---' but never closed
|
||||||
|
$lineIdx++
|
||||||
|
$lineStart = $i + 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# Attribute every CR byte. With no frontmatter block, the whole file counts as body.
|
||||||
|
$split = if ($fmEnd -ge 0) { $fmEnd } else { $start }
|
||||||
|
$fmCr = 0
|
||||||
|
$bodyCr = 0
|
||||||
|
for ($i = $start; $i -lt $len; $i++) {
|
||||||
|
if ($Bytes[$i] -eq 0x0D) {
|
||||||
|
if ($i -lt $split) { $fmCr++ } else { $bodyCr++ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [pscustomobject]@{
|
||||||
|
HasFrontmatter = ($fmEnd -ge 0)
|
||||||
|
HasBom = $hasBom
|
||||||
|
FmCr = $fmCr
|
||||||
|
BodyCr = $bodyCr
|
||||||
|
TotalCr = ($fmCr + $bodyCr)
|
||||||
|
Bytes = $len
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function Measure-EolFile {
|
||||||
|
param([string]$Path)
|
||||||
|
try {
|
||||||
|
$bytes = [System.IO.File]::ReadAllBytes($Path)
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
Write-Host (" [warn] cannot read bytes: {0} - {1}" -f (Rel $Path), $_.Exception.Message) -ForegroundColor DarkGray
|
||||||
|
return $null
|
||||||
|
}
|
||||||
|
return (Measure-EolBytes $bytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# SYNTHETIC CONTROL - runs BEFORE the disk scan.
|
||||||
|
# Declared synthetic on purpose: W0.6 refuted CRLF-fatality, so there is no real
|
||||||
|
# dead file to use as a natural positive control (spec v2 (4)(a)).
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
Write-Section 'Synthetic control (DECLARED SYNTHETIC) - does the byte-reader have teeth?'
|
||||||
|
|
||||||
|
$ctrlCrlf = "---`r`nname: _synthetic-control`r`ndescription: synthetic positive control`r`n---`r`nbody line`r`n"
|
||||||
|
$ctrlLf = "---`nname: _synthetic-control`ndescription: synthetic negative control`n---`nbody line`n"
|
||||||
|
|
||||||
|
$ctrlPos = Measure-EolBytes ([System.Text.Encoding]::ASCII.GetBytes($ctrlCrlf))
|
||||||
|
$ctrlNeg = Measure-EolBytes ([System.Text.Encoding]::ASCII.GetBytes($ctrlLf))
|
||||||
|
|
||||||
|
$posOk = ($ctrlPos.HasFrontmatter -and $ctrlPos.FmCr -gt 0)
|
||||||
|
$negOk = ($ctrlNeg.HasFrontmatter -and $ctrlNeg.FmCr -eq 0 -and $ctrlNeg.BodyCr -eq 0)
|
||||||
|
$controlOk = ($posOk -and $negOk)
|
||||||
|
|
||||||
|
$posLabel = if ($posOk) { 'PASS' } else { 'FAIL' }
|
||||||
|
$negLabel = if ($negOk) { 'PASS' } else { 'FAIL' }
|
||||||
|
|
||||||
|
Write-Host (" (+) CRLF synthetic : fm={0} FmCr={1} BodyCr={2} expect fm=True,FmCr>0 -> {3}" -f `
|
||||||
|
$ctrlPos.HasFrontmatter, $ctrlPos.FmCr, $ctrlPos.BodyCr, $posLabel)
|
||||||
|
Write-Host (" (-) LF synthetic : fm={0} FmCr={1} BodyCr={2} expect fm=True,FmCr=0 -> {3}" -f `
|
||||||
|
$ctrlNeg.HasFrontmatter, $ctrlNeg.FmCr, $ctrlNeg.BodyCr, $negLabel)
|
||||||
|
|
||||||
|
if (-not $controlOk) {
|
||||||
|
Write-Flag 'HIGH' 'scripts/agent-frontmatter-eol-check.ps1 (self)' `
|
||||||
|
'self-broken: synthetic control FAILED - the byte-reader does not detect CR, or false-positives on LF. Every scan result below is MEANINGLESS.' `
|
||||||
|
'fix Measure-EolBytes; do NOT trust a green scan until both controls PASS'
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
Write-Host ' [OK] reader detects CRLF and stays silent on LF - a green scan below is at least meaningful' -ForegroundColor Green
|
||||||
|
}
|
||||||
|
Write-Host ' (reminder: the control proves the READER works. It does NOT prove CRLF harms anything - S121 W0.6 measured that it does not.)' -ForegroundColor DarkGray
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Scoped scan - .gitattributes 'eol=lf' hygiene over the rule-set files.
|
||||||
|
# Scope per spec v2 W0.2 + R2. NOT repo-wide (see DESIGN NOTES).
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
Write-Section 'Scoped scan - .gitattributes eol=lf hygiene (.claude/{agents,commands,skills})'
|
||||||
|
|
||||||
|
$scopeSpecs = @(
|
||||||
|
[pscustomobject]@{ Name = '.claude/agents'; Dir = (Join-Path $RepoRoot '.claude\agents'); Filter = '*.md'; Recurse = $false },
|
||||||
|
[pscustomobject]@{ Name = '.claude/commands'; Dir = (Join-Path $RepoRoot '.claude\commands'); Filter = '*.md'; Recurse = $false },
|
||||||
|
[pscustomobject]@{ Name = '.claude/skills'; Dir = (Join-Path $RepoRoot '.claude\skills'); Filter = 'SKILL.md'; Recurse = $true }
|
||||||
|
)
|
||||||
|
|
||||||
|
$totalFiles = 0
|
||||||
|
$totalFmCrFiles = 0
|
||||||
|
$totalBodyCrFiles = 0
|
||||||
|
$totalNoFm = 0
|
||||||
|
$totalCrBytes = 0
|
||||||
|
|
||||||
|
foreach ($spec in $scopeSpecs) {
|
||||||
|
if (-not (Test-Path $spec.Dir)) {
|
||||||
|
Write-Host (" [skip] {0,-18} not present under RepoRoot" -f $spec.Name) -ForegroundColor DarkGray
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
$files = @(Get-ChildItem -Path $spec.Dir -Filter $spec.Filter -File -Recurse:$spec.Recurse -ErrorAction SilentlyContinue)
|
||||||
|
$scopeScanned = 0
|
||||||
|
$scopeFmCr = 0
|
||||||
|
$scopeBodyCr = 0
|
||||||
|
$scopeCrBytes = 0
|
||||||
|
|
||||||
|
foreach ($f in $files) {
|
||||||
|
$m = Measure-EolFile $f.FullName
|
||||||
|
if ($null -eq $m) { continue }
|
||||||
|
|
||||||
|
$scopeScanned++
|
||||||
|
$totalFiles++
|
||||||
|
$scopeCrBytes += $m.TotalCr
|
||||||
|
$totalCrBytes += $m.TotalCr
|
||||||
|
if (-not $m.HasFrontmatter) { $totalNoFm++ }
|
||||||
|
|
||||||
|
if ($m.FmCr -gt 0) {
|
||||||
|
$scopeFmCr++
|
||||||
|
$totalFmCrFiles++
|
||||||
|
Write-Flag 'MED' (Rel $f.FullName) `
|
||||||
|
("eol-frontmatter-cr: {0} CR (0x0D) byte(s) inside the frontmatter block, {1} more in body - violates .gitattributes '* text=auto eol=lf'. HYGIENE only: S121 W0.6 measured a fully-CRLF agent file spawning correctly, so this is NOT a known breakage." -f $m.FmCr, $m.BodyCr) `
|
||||||
|
'rewrite the file with LF endings (git rm --cached <f> + git checkout -- <f>, or re-save as LF)'
|
||||||
|
}
|
||||||
|
elseif ($m.BodyCr -gt 0) {
|
||||||
|
$scopeBodyCr++
|
||||||
|
$totalBodyCrFiles++
|
||||||
|
Write-Flag 'LOW' (Rel $f.FullName) `
|
||||||
|
("eol-body-cr: frontmatter is clean but the body carries {0} CR (0x0D) byte(s) - same '* text=auto eol=lf' policy, further from any reader hot-path." -f $m.BodyCr) `
|
||||||
|
'rewrite the file with LF endings (hygiene / worktree-vs-blob consistency)'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host (" {0,-18} scanned={1,-3} frontmatter-CR-files={2,-3} body-only-CR-files={3,-3} CR-bytes={4}" -f `
|
||||||
|
$spec.Name, $scopeScanned, $scopeFmCr, $scopeBodyCr, $scopeCrBytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Summary. Every label below is computed from a measured number (W0.4 lesson).
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
Write-Section 'Summary'
|
||||||
|
|
||||||
|
Write-Host ("scoped set (counted by glob, never hardcoded): {0} file(s)" -f $totalFiles)
|
||||||
|
Write-Host (" files with CR in frontmatter : {0}" -f $totalFmCrFiles)
|
||||||
|
Write-Host (" files with CR in body only : {0}" -f $totalBodyCrFiles)
|
||||||
|
Write-Host (" total CR (0x0D) bytes in set : {0}" -f $totalCrBytes)
|
||||||
|
Write-Host (" files with no parseable frontmatter block: {0} (not a flag - body CR still counted)" -f $totalNoFm)
|
||||||
|
|
||||||
|
Write-Host ''
|
||||||
|
Write-Host ("TOTAL FLAGS: {0} (scoped files={1}, CR bytes measured={2})" -f $script:FlagCount, $totalFiles, $totalCrBytes) -ForegroundColor Cyan
|
||||||
|
|
||||||
|
if (-not $controlOk) {
|
||||||
|
Write-Host 'RESULT: UNTRUSTWORTHY - the synthetic control FAILED, so the scan above proves nothing.' -ForegroundColor Red
|
||||||
|
}
|
||||||
|
elseif ($script:FlagCount -gt 0) {
|
||||||
|
Write-Host ("RESULT: {0} hygiene FLAG(s) above. Advisory only - exit code stays 0, nothing is known to be broken." -f $script:FlagCount) -ForegroundColor Yellow
|
||||||
|
}
|
||||||
|
elseif ($totalCrBytes -eq 0) {
|
||||||
|
Write-Host 'RESULT: GREEN-BUT-VACUOUS - 0 CR bytes existed in the scoped set, so this run had nothing to catch.' -ForegroundColor Yellow
|
||||||
|
Write-Host ' Do NOT bank this as an achievement: the scoped gate was ALREADY green with 0 work to do' -ForegroundColor DarkGray
|
||||||
|
Write-Host ' (spec v2 W0.2 / R2-C4). The only thing a green run demonstrates is that the reader itself' -ForegroundColor DarkGray
|
||||||
|
Write-Host ' works (synthetic control PASSED above). Its forward value is regression-catching, not this run.' -ForegroundColor DarkGray
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
Write-Host 'RESULT: GREEN - CR bytes are present in the set but none in a flagged position.' -ForegroundColor Green
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host 'NOTE: HYGIENE/INFORM net. Exit 0 always. CRLF-fatality was REFUTED by the S121 W0.6 spawn-probe (a fully-CRLF agent spawned OK through all 4 stages); this check enforces the .gitattributes eol=lf policy and nothing stronger.' -ForegroundColor DarkGray
|
||||||
|
|
||||||
|
exit 0
|
||||||
@ -19,6 +19,11 @@
|
|||||||
C3 - vocab-fork : alias-sets where >=2 variants live side-by-side.
|
C3 - vocab-fork : alias-sets where >=2 variants live side-by-side.
|
||||||
C4 - self-line exclusion: pattern-describing files removed from every scan
|
C4 - self-line exclusion: pattern-describing files removed from every scan
|
||||||
(else the detector self-matches).
|
(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:
|
Each FLAG line:
|
||||||
[DETECTOR] severity | file:line | description | resolve: <un-flag condition> (C5)
|
[DETECTOR] severity | file:line | description | resolve: <un-flag condition> (C5)
|
||||||
@ -81,6 +86,12 @@ $VN_BANG = U @(0x62, 0x1EA3, 0x6E, 0x67) # "bang" (ta
|
|||||||
$VN_DUTRU_PRO = U @(0x44, 0x1EF1, 0x20, 0x74, 0x72, 0xF9, 0x20, 0x50, 0x52, 0x4F) # "Du tru PRO"
|
$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"
|
$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)
|
# C4 - self-line exclusion (BUILT FIRST so every scan can apply it)
|
||||||
# These files DESCRIBE the patterns the detectors look for; without exclusion
|
# These files DESCRIBE the patterns the detectors look for; without exclusion
|
||||||
@ -151,13 +162,37 @@ if (-not (Test-Path $statusPath)) {
|
|||||||
$canonicalOk = $false
|
$canonicalOk = $false
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
$canonical['mig'] = Get-StatusValue $statusPath 'Migrations'
|
# GAP-3 (H24): canonical token -> STATUS.md CURRENT-STATE row label.
|
||||||
$canonical['test'] = Get-StatusValue $statusPath 'Tests'
|
# Row labels are READ OFF DISK, not guessed: 'Menu keys' is docs/STATUS.md:19
|
||||||
$canonical['gotcha'] = Get-StatusValue $statusPath 'Gotchas'
|
# ("| Menu keys | **54** |"); 'Policies' does NOT exist yet -- W2 lands
|
||||||
$canonical['table'] = Get-StatusValue $statusPath 'SQL tables'
|
# "| 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] }
|
||||||
|
|
||||||
Write-Host (" STATUS.md canonical: mig={0} test={1} gotcha={2} table={3}" -f `
|
$canonShow = @()
|
||||||
$canonical['mig'], $canonical['test'], $canonical['gotcha'], $canonical['table'])
|
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 ----
|
# ---- disk cross-check: canonical must not itself be stale ----
|
||||||
# mig = migration .cs files (exclude *Designer.cs / *ModelSnapshot.cs), recursive
|
# mig = migration .cs files (exclude *Designer.cs / *ModelSnapshot.cs), recursive
|
||||||
@ -209,7 +244,12 @@ $countPatterns = @(
|
|||||||
@{ Rx = '(\d+)\s*migration'; Key = 'mig'; Label = 'migration' },
|
@{ Rx = '(\d+)\s*migration'; Key = 'mig'; Label = 'migration' },
|
||||||
@{ Rx = '(\d+)\s*test'; Key = 'test'; Label = 'test' },
|
@{ Rx = '(\d+)\s*test'; Key = 'test'; Label = 'test' },
|
||||||
@{ Rx = ('(\d+)\s*(?:' + $VN_BAY + '|gotcha)'); Key = 'gotcha'; Label = 'gotcha/bay' },
|
@{ Rx = ('(\d+)\s*(?:' + $VN_BAY + '|gotcha)'); Key = 'gotcha'; Label = 'gotcha/bay' },
|
||||||
@{ Rx = ('(\d+)\s*(?:' + $VN_BANG + '|table)'); Key = 'table'; Label = 'table/bang' }
|
@{ 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
|
# H18-A(b) scan-range follows the REAL rule-range (S100, Harness-18 adopt): the derived-doc
|
||||||
@ -427,6 +467,226 @@ if (Test-Path $walPath) {
|
|||||||
Write-Host ' (no .claude/WAL.md - no active chain, skip)'
|
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)
|
# Summary + C4 self-exclusion audit (RUNTIME proof)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
471
scripts/spawn-model-audit.ps1
Normal file
471
scripts/spawn-model-audit.ps1
Normal file
@ -0,0 +1,471 @@
|
|||||||
|
<#
|
||||||
|
.SYNOPSIS
|
||||||
|
spawn-model-audit.ps1 - H23 PA-2b: spawn-model audit, resolved-vs-expected. INFORMATIONAL.
|
||||||
|
|
||||||
|
.DESCRIPTION
|
||||||
|
Owner-decision S119 PA-2 (runs/2026-07-15-S119-adap-6-broadcast/owner-decisions-15-07-2026.md:59-60)
|
||||||
|
splits "anchor the model version" in two, because anchoring AT SPAWN is NOT possible at SE
|
||||||
|
(the spawn param takes an enum alias only; SE has 0 full-id in any definition file - H8 all-inherit):
|
||||||
|
PA-2a .claude/workflows/hmw.js declares TIER2_EXPECTED_FULL_ID = 'claude-opus-4-8'.
|
||||||
|
PA-2b THIS script compares the model actually RESOLVED at spawn against that constant.
|
||||||
|
Drift => print FLAG => owner decides re-pin. NO enforcement (H23 section 2(4)).
|
||||||
|
|
||||||
|
Design rules (mirror scripts/governance-detectors.ps1):
|
||||||
|
(1) NO-API - reads files only. NEVER calls a model/API.
|
||||||
|
(2) FLAG-only - prints FLAGs, NEVER edits files.
|
||||||
|
(3) PowerShell 5.1, offline. ASCII-only script body (gotcha #30); target files read -Encoding UTF8.
|
||||||
|
(4) DETECT-only. Exit code ALWAYS 0 (audit/inform, never a build gate).
|
||||||
|
|
||||||
|
---------------------------------------------------------------------------
|
||||||
|
MEASURABLE vs NOT-MEASURABLE - read before trusting any line of this report
|
||||||
|
---------------------------------------------------------------------------
|
||||||
|
MEASURED (this script really reads it, offline, from disk):
|
||||||
|
* expected = the TIER2_EXPECTED_FULL_ID constant in hmw.js [section A]
|
||||||
|
* resolved = "message":{"model":"..."} in the per-lane spawn transcripts
|
||||||
|
<TranscriptRoot>/<slug>/<session>/subagents/workflows/wf_*/agent-*.jsonl
|
||||||
|
Verified present S119: 9 lanes 'claude-opus-4-8' + 2 lanes 'claude-fable-5'. [section B]
|
||||||
|
|
||||||
|
NOT MEASURABLE HERE - declared, never silently passed:
|
||||||
|
(i) FORWARD drift. This audit is RETROSPECTIVE: it reads what ALREADY resolved in recorded
|
||||||
|
runs. It cannot pre-verify what the NEXT spawn will resolve to. A clean report does NOT
|
||||||
|
promise the next spawn stays on the expected version. This is exactly why H23 section 2(4)
|
||||||
|
makes it informational, not enforcement.
|
||||||
|
(ii) PRECEDENCE vs a frontmatter HARD-PIN (the hub form: 17/17 'model: claude-opus-4-8').
|
||||||
|
UNTESTED at SE and UNTESTABLE here: SE has 0 hard-pinned agents (12/12 'model: inherit',
|
||||||
|
owner order S66/H8). Only the inherit case has evidence - see the next block.
|
||||||
|
(iii)Whether the spawn param REJECTS a full-id (fix #8a). Per harness docs the param is an enum
|
||||||
|
alias (sonnet|opus|haiku|fable); passing a full-id has NEVER been tried. Not tried here.
|
||||||
|
(iv) Documentation coverage. This measures EXPLICITNESS IN TRANSCRIPTS only. A clean report must
|
||||||
|
NOT be read as "the floor is fully applied" (owner-decisions:65, honesty declaration 3).
|
||||||
|
|
||||||
|
PRECEDENCE - spawn-param vs frontmatter 'inherit': MEASURED 2026-07-15, no longer "never tested".
|
||||||
|
Spec v2:271 recorded this as CHUA TUNG DUOC TEST, reasoning that a discriminating experiment
|
||||||
|
needs lead=Fable. That reasoning only covered the tier:'opus' direction. The discriminating
|
||||||
|
condition is simply spawn-param family != lead family - and the tier:'fable' direction
|
||||||
|
satisfies it under lead=Opus. It ALREADY RAN, unplanned, in run wf_cb964f83-331:
|
||||||
|
lead model = claude-opus-4-8 (290/290 records, session 3d9bec56, zero Fable)
|
||||||
|
agentType = reviewer (.claude/agents/reviewer.md:5 = 'model: inherit')
|
||||||
|
spawn param = fable (/fable-real escape-hatch, hmw.js:44 returns the alias)
|
||||||
|
RESOLVED = claude-fable-5 (2/2 lanes, 39 + 42 assistant records)
|
||||||
|
If frontmatter 'inherit' had won, resolved would be the lead's claude-opus-4-8. It was not.
|
||||||
|
=> the spawn param TAKES EFFECT on a frontmatter-'inherit' agent => the GAP#6 fix is NOT a no-op.
|
||||||
|
HONEST LIMIT: this does not prove 'inherit' LOSES a precedence contest. 'inherit' may simply mean
|
||||||
|
"defer to the caller", in which case there is no contest at all. Both readings give the same
|
||||||
|
operative answer (the param takes effect); this data cannot separate them, and does not try.
|
||||||
|
|
||||||
|
.PARAMETER RepoRoot
|
||||||
|
Repo root. Default = 2 levels up from this script (scripts/ -> repo root).
|
||||||
|
Every path derives from this, so -RepoRoot <temp tree> makes fault-injection possible.
|
||||||
|
|
||||||
|
.PARAMETER TranscriptRoot
|
||||||
|
Root of the CLI project transcripts. Default derives from $env:USERPROFILE (NOT a hardcoded
|
||||||
|
absolute path). The per-project folder name is derived from $RepoRoot, so a fault-inject tree
|
||||||
|
resolves to a slug with no transcripts and section B reports NO-SCOPE instead of faking a number.
|
||||||
|
|
||||||
|
.PARAMETER SessionId
|
||||||
|
Audit one specific session folder. Default = most recent session that HAS spawn transcripts.
|
||||||
|
Default-to-current is deliberate: scanning old history surfaces pre-convention runs, which is
|
||||||
|
noise, not a bug storm (owner-decisions:64, honesty declaration 2).
|
||||||
|
|
||||||
|
.EXAMPLE
|
||||||
|
powershell.exe -ExecutionPolicy Bypass -File scripts/spawn-model-audit.ps1
|
||||||
|
#>
|
||||||
|
param(
|
||||||
|
[string]$RepoRoot = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path,
|
||||||
|
[string]$TranscriptRoot = (Join-Path $env:USERPROFILE '.claude\projects'),
|
||||||
|
[string]$SessionId = ''
|
||||||
|
)
|
||||||
|
|
||||||
|
$ErrorActionPreference = 'Continue'
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Owner-ratified expectation (S119 PA-2a, owner-decisions-15-07-2026.md:59).
|
||||||
|
# Deliberate 2-key design: the expected version is declared TWICE - once in hmw.js (PA-2a) and
|
||||||
|
# once here - so that a unilateral edit of ONE of them is FLAGGED instead of passing silently.
|
||||||
|
# Cost, stated plainly: an owner re-pin must touch BOTH files. That cost IS the check.
|
||||||
|
# NOT a fallback: if hmw.js has no constant, section A fails loud - it never borrows this value.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
$OWNER_RATIFIED_TIER2_FULL_ID = 'claude-opus-4-8'
|
||||||
|
|
||||||
|
# Family that the 'opus' alias maps to. Alias locks the FAMILY, not the version (H23 section 2(2)):
|
||||||
|
# same family + different version = the drift this audit exists to catch.
|
||||||
|
$TIER2_FAMILY_PREFIX = 'claude-opus-'
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Helpers (shape mirrors scripts/governance-detectors.ps1:43-67)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
$script:FlagCount = 0
|
||||||
|
|
||||||
|
function Write-Flag {
|
||||||
|
param(
|
||||||
|
[ValidateSet('HIGH', 'MED', 'LOW')] [string]$Severity,
|
||||||
|
[string]$Where,
|
||||||
|
[string]$Desc,
|
||||||
|
[string]$Resolve
|
||||||
|
)
|
||||||
|
$color = switch ($Severity) { 'HIGH' { 'Red' } 'MED' { 'Yellow' } default { 'Gray' } }
|
||||||
|
Write-Host ("[SPAWN-AUDIT] {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
|
||||||
|
}
|
||||||
|
|
||||||
|
function Rel($full) {
|
||||||
|
$r = $full
|
||||||
|
if ($full.StartsWith($RepoRoot, [StringComparison]::OrdinalIgnoreCase)) {
|
||||||
|
$r = $full.Substring($RepoRoot.Length).TrimStart('\', '/')
|
||||||
|
}
|
||||||
|
return ($r -replace '\\', '/')
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host ''
|
||||||
|
Write-Host '########## spawn-model-audit.ps1 - H23 PA-2b (INFORMATIONAL, exit 0 always) ##########' -ForegroundColor Cyan
|
||||||
|
Write-Host ("RepoRoot: {0}" -f $RepoRoot) -ForegroundColor DarkGray
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# SECTION A - PA-2a: the EXPECTED constant in hmw.js (STATIC, measurable)
|
||||||
|
# ===========================================================================
|
||||||
|
Write-Section 'A - expected constant (PA-2a, hmw.js)'
|
||||||
|
|
||||||
|
$hmwPath = Join-Path $RepoRoot '.claude\workflows\hmw.js'
|
||||||
|
$expected = $null
|
||||||
|
|
||||||
|
if (-not (Test-Path $hmwPath)) {
|
||||||
|
Write-Flag 'HIGH' (Rel $hmwPath) 'hmw.js not found - cannot resolve the expected model version' `
|
||||||
|
'run from a repo root that contains .claude/workflows/hmw.js'
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
# STRUCTURAL anchor, NOT a loose string grep (W0.4 lesson 1 - self-reference).
|
||||||
|
# Capture the VALUE of the assignment. A bare grep for the version string is NOT acceptable
|
||||||
|
# evidence here: hmw.js:36 already contains the literal 'claude-opus-4-8' inside a COMMENT
|
||||||
|
# ("KHONG con demote-pin ..."), so a naive grep returns >=1 hit and passes VACUOUSLY even when
|
||||||
|
# the constant is absent or holds a wrong value. The naive count is printed below as a
|
||||||
|
# counter-example only - it is never used as evidence.
|
||||||
|
$RX_CONST = 'TIER2_EXPECTED_FULL_ID\s*=\s*[''"]([^''"]+)[''"]'
|
||||||
|
$constHits = @(Select-String -Path $hmwPath -Pattern $RX_CONST -Encoding UTF8 -AllMatches)
|
||||||
|
|
||||||
|
$naiveHits = @(Select-String -Path $hmwPath -Pattern ([regex]::Escape($OWNER_RATIFIED_TIER2_FULL_ID)) -Encoding UTF8)
|
||||||
|
Write-Host (" anti-vacuous note: naive grep '{0}' in hmw.js = {1} hit(s) [comment text] -> NOT used as evidence" -f `
|
||||||
|
$OWNER_RATIFIED_TIER2_FULL_ID, $naiveHits.Count) -ForegroundColor DarkGray
|
||||||
|
|
||||||
|
if ($constHits.Count -eq 0) {
|
||||||
|
# FAIL-LOUD. No hardcoded default is substituted: an audit that invents the value it is
|
||||||
|
# meant to verify would report a PASS about itself.
|
||||||
|
Write-Flag 'HIGH' (Rel $hmwPath) `
|
||||||
|
'expected constant missing (W2 chua land): TIER2_EXPECTED_FULL_ID not declared in hmw.js' `
|
||||||
|
("land PA-2a: add TIER2_EXPECTED_FULL_ID = '{0}' next to resolveModel() in hmw.js" -f $OWNER_RATIFIED_TIER2_FULL_ID)
|
||||||
|
Write-Host ' -> section B comparison will report SKIPPED (no expected value to compare against)' -ForegroundColor DarkGray
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
$values = @()
|
||||||
|
foreach ($h in $constHits) {
|
||||||
|
foreach ($m in $h.Matches) { $values += $m.Groups[1].Value }
|
||||||
|
}
|
||||||
|
$distinctValues = @($values | Sort-Object -Unique)
|
||||||
|
|
||||||
|
Write-Host (" found {0} definition(s) of TIER2_EXPECTED_FULL_ID; distinct value(s): {1}" -f `
|
||||||
|
$values.Count, ($distinctValues -join ', '))
|
||||||
|
foreach ($h in $constHits) {
|
||||||
|
Write-Host (" {0}:{1}" -f (Rel $hmwPath), $h.LineNumber) -ForegroundColor DarkGray
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($distinctValues.Count -gt 1) {
|
||||||
|
Write-Flag 'HIGH' (Rel $hmwPath) `
|
||||||
|
("conflicting definitions: TIER2_EXPECTED_FULL_ID declared with {0} different values [{1}]" -f `
|
||||||
|
$distinctValues.Count, ($distinctValues -join ' vs ')) `
|
||||||
|
'keep exactly ONE authoritative definition of TIER2_EXPECTED_FULL_ID'
|
||||||
|
}
|
||||||
|
|
||||||
|
$expected = $distinctValues[0]
|
||||||
|
|
||||||
|
# PASS/FAIL is COMPUTED from the measured value (W0.4 lesson 2 - never a label pasted
|
||||||
|
# next to a number that says otherwise).
|
||||||
|
if ($expected -cne $OWNER_RATIFIED_TIER2_FULL_ID) {
|
||||||
|
Write-Flag 'HIGH' (Rel $hmwPath) `
|
||||||
|
("mismatch vs owner-ratified expectation: hmw.js declares '{0}' but S119 PA-2a ratified '{1}'" -f `
|
||||||
|
$expected, $OWNER_RATIFIED_TIER2_FULL_ID) `
|
||||||
|
("set TIER2_EXPECTED_FULL_ID = '{0}', or have the owner re-ratify and update this script" -f $OWNER_RATIFIED_TIER2_FULL_ID)
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
Write-Host (" [OK] hmw.js expected = '{0}' = owner-ratified (S119 PA-2a)" -f $expected) -ForegroundColor Green
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($expected -match '\[1m\]') {
|
||||||
|
Write-Flag 'MED' (Rel $hmwPath) `
|
||||||
|
("expected value carries a '[1m]' context-window suffix: '{0}'" -f $expected) `
|
||||||
|
'drop the [1m] suffix - it is not part of the model id (gotcha #37)'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# SECTION B - PA-2b: the RESOLVED model, from spawn transcripts (MEASURED)
|
||||||
|
# ===========================================================================
|
||||||
|
Write-Section 'B - resolved model (PA-2b, spawn transcripts)'
|
||||||
|
|
||||||
|
# Project folder name derives from $RepoRoot (path separators -> '-'), so a fault-inject tree
|
||||||
|
# resolves to a slug with no transcripts -> NO-SCOPE, never a faked measurement.
|
||||||
|
$slug = $RepoRoot -replace '[:\\/_]', '-'
|
||||||
|
$projDir = Join-Path $TranscriptRoot $slug
|
||||||
|
|
||||||
|
$dispatch = 0 # spawn lanes found (one agent-*.jsonl = one lane)
|
||||||
|
$explicit = 0 # lanes where a resolved model is actually recorded
|
||||||
|
$mismatch = 0 # lanes resolved to the tier-2 FAMILY but NOT the expected version = alias drift
|
||||||
|
$otherFam = 0 # lanes resolved outside the tier-2 family (escape-hatch / inherited lead)
|
||||||
|
$modelTally = @{}
|
||||||
|
|
||||||
|
Write-Host (" transcript root: {0}" -f $TranscriptRoot) -ForegroundColor DarkGray
|
||||||
|
Write-Host (" project slug : {0} (derived from RepoRoot)" -f $slug) -ForegroundColor DarkGray
|
||||||
|
|
||||||
|
if (-not (Test-Path $projDir)) {
|
||||||
|
Write-Host ' [NO-SCOPE] no transcript folder for this repo - nothing to measure, nothing claimed.' -ForegroundColor DarkGray
|
||||||
|
Write-Host ' (expected when running against a fault-inject / temp tree)' -ForegroundColor DarkGray
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
$sessionDir = $null
|
||||||
|
if ($SessionId -ne '') {
|
||||||
|
$cand = Join-Path $projDir $SessionId
|
||||||
|
if (Test-Path $cand) { $sessionDir = Get-Item $cand }
|
||||||
|
else { Write-Flag 'MED' (Rel $cand) 'requested -SessionId not found' 'pass an existing session id, or omit to use the most recent' }
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
$sessionDir = Get-ChildItem -Path $projDir -Directory -ErrorAction SilentlyContinue |
|
||||||
|
Where-Object { Test-Path (Join-Path $_.FullName 'subagents\workflows') } |
|
||||||
|
Sort-Object LastWriteTime -Descending | Select-Object -First 1
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($null -eq $sessionDir) {
|
||||||
|
Write-Host ' [NO-SCOPE] no session with spawn transcripts under this project - nothing measured.' -ForegroundColor DarkGray
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
Write-Host (" session scope : {0} (last write {1:yyyy-MM-dd HH:mm})" -f $sessionDir.Name, $sessionDir.LastWriteTime)
|
||||||
|
Write-Host ' scope = CURRENT session only, by design (old runs predate the convention; owner-decisions:64)' -ForegroundColor DarkGray
|
||||||
|
|
||||||
|
# STRUCTURAL anchor: the model key of an assistant record sits immediately inside
|
||||||
|
# "message":{ ... . Anchoring on the record shape (not a bare "model": grep) keeps quoted
|
||||||
|
# JSON inside message CONTENT - agents routinely paste transcript snippets - from being
|
||||||
|
# counted as if it were a real dispatch.
|
||||||
|
$RX_MODEL = '"message"\s*:\s*\{\s*"model"\s*:\s*"([^"]+)"'
|
||||||
|
|
||||||
|
$wfRoot = Join-Path $sessionDir.FullName 'subagents\workflows'
|
||||||
|
$laneFiles = @(Get-ChildItem -Path $wfRoot -Recurse -Filter 'agent-*.jsonl' -File -ErrorAction SilentlyContinue)
|
||||||
|
|
||||||
|
foreach ($lane in $laneFiles) {
|
||||||
|
$dispatch++
|
||||||
|
$hits = @(Select-String -Path $lane.FullName -Pattern $RX_MODEL -Encoding UTF8 -AllMatches)
|
||||||
|
$models = @()
|
||||||
|
foreach ($h in $hits) {
|
||||||
|
foreach ($m in $h.Matches) { $models += $m.Groups[1].Value }
|
||||||
|
}
|
||||||
|
$distinct = @($models | Sort-Object -Unique)
|
||||||
|
|
||||||
|
$wfName = Split-Path (Split-Path $lane.FullName -Parent) -Leaf
|
||||||
|
$agentType = '?'
|
||||||
|
$metaPath = ($lane.FullName -replace '\.jsonl$', '.meta.json')
|
||||||
|
if (Test-Path $metaPath) {
|
||||||
|
$metaTxt = Get-Content -Path $metaPath -Raw -Encoding UTF8
|
||||||
|
$mm = [regex]::Match($metaTxt, '"agentType"\s*:\s*"([^"]+)"')
|
||||||
|
if ($mm.Success) { $agentType = $mm.Groups[1].Value }
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($distinct.Count -eq 0) {
|
||||||
|
Write-Host (" {0}/{1} [{2}] -> no model recorded (lane produced no assistant record)" -f `
|
||||||
|
$wfName, $lane.Name, $agentType) -ForegroundColor DarkGray
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
$explicit++
|
||||||
|
|
||||||
|
foreach ($d in $distinct) {
|
||||||
|
if ($modelTally.ContainsKey($d)) { $modelTally[$d] = $modelTally[$d] + 1 }
|
||||||
|
else { $modelTally[$d] = 1 }
|
||||||
|
}
|
||||||
|
|
||||||
|
$verdict = 'other-family'
|
||||||
|
$inFamily = @($distinct | Where-Object { $_.StartsWith($TIER2_FAMILY_PREFIX) })
|
||||||
|
if ($null -eq $expected) {
|
||||||
|
$verdict = 'SKIPPED (no expected constant)'
|
||||||
|
}
|
||||||
|
elseif ($inFamily.Count -eq 0) {
|
||||||
|
$verdict = 'other-family (escape-hatch / inherited lead - not a drift signal)'
|
||||||
|
$otherFam++
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
$bad = @($inFamily | Where-Object { $_ -cne $expected })
|
||||||
|
if ($bad.Count -gt 0) {
|
||||||
|
$mismatch++
|
||||||
|
$verdict = 'MISMATCH'
|
||||||
|
Write-Flag 'HIGH' ("{0}/{1}" -f $wfName, $lane.Name) `
|
||||||
|
("alias-drift: resolved '{0}' is in the tier-2 family but expected '{1}'" -f ($bad -join ', '), $expected) `
|
||||||
|
'owner decides re-pin (H23 section 2(2)); update TIER2_EXPECTED_FULL_ID once ratified'
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
$verdict = 'match'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Write-Host (" {0}/{1} [{2}] -> {3} ({4} record(s)) : {5}" -f `
|
||||||
|
$wfName, $lane.Name, $agentType, ($distinct -join ', '), $models.Count, $verdict)
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host ''
|
||||||
|
Write-Host (" COUNTS: {0} dispatch | {1} explicit | {2} mismatch" -f $dispatch, $explicit, $mismatch)
|
||||||
|
Write-Host ' (dispatch = lanes found | explicit = lanes with a model actually recorded | mismatch = tier-2-family lanes off the expected version)' -ForegroundColor DarkGray
|
||||||
|
if ($modelTally.Count -gt 0) {
|
||||||
|
Write-Host ' resolved model tally (lanes per model):'
|
||||||
|
foreach ($k in ($modelTally.Keys | Sort-Object)) {
|
||||||
|
Write-Host (" {0,-24} {1} lane(s)" -f $k, $modelTally[$k])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ($null -eq $expected) {
|
||||||
|
Write-Host ' [SKIPPED] resolved-vs-expected NOT evaluated: section A found no expected constant.' -ForegroundColor Yellow
|
||||||
|
Write-Host ' This is NOT a pass. The lanes above are reported raw, uncompared.' -ForegroundColor Yellow
|
||||||
|
}
|
||||||
|
elseif ($dispatch -eq 0) {
|
||||||
|
Write-Host ' [NO-SCOPE] 0 lanes in scope - a 0-mismatch count here would be VACUOUS, not a pass.' -ForegroundColor Yellow
|
||||||
|
}
|
||||||
|
elseif ($mismatch -eq 0) {
|
||||||
|
Write-Host (" [OK] 0/{0} lane(s) drifted off expected '{1}' (retrospective only - says nothing about the NEXT spawn)" -f `
|
||||||
|
$explicit, $expected) -ForegroundColor Green
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# SECTION C - canonical cross-check: docs/STATUS.md, the Sub-agents row
|
||||||
|
# ===========================================================================
|
||||||
|
Write-Section 'C - canonical cross-check (STATUS.md Sub-agents row)'
|
||||||
|
|
||||||
|
# Row label read from the file, not guessed: docs/STATUS.md:24 = "| Sub-agents | **12** | ...".
|
||||||
|
# That row is the single-owner flip-chain canonical; hmw.js:34 and agents/README point at it.
|
||||||
|
$SUBAGENT_ROW_LABEL = 'Sub-agents'
|
||||||
|
$FLIPCHAIN_MARKER = 'CANONICAL single-owner flip-chain'
|
||||||
|
|
||||||
|
$statusPath = Join-Path $RepoRoot 'docs\STATUS.md'
|
||||||
|
if (-not (Test-Path $statusPath)) {
|
||||||
|
Write-Flag 'MED' (Rel $statusPath) 'docs/STATUS.md not found - cannot cross-check the roster canonical' `
|
||||||
|
'run against a repo root that contains docs/STATUS.md'
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
$rowPat = '^\|\s*' + [regex]::Escape($SUBAGENT_ROW_LABEL) + '\s*\|\s*\*\*(\d+)'
|
||||||
|
$rowHit = Select-String -Path $statusPath -Pattern $rowPat -Encoding UTF8 | Select-Object -First 1
|
||||||
|
|
||||||
|
if ($null -eq $rowHit) {
|
||||||
|
Write-Flag 'MED' (Rel $statusPath) `
|
||||||
|
("canonical row '| {0} | **N** |' not found in STATUS.md CURRENT STATE table" -f $SUBAGENT_ROW_LABEL) `
|
||||||
|
'restore the Sub-agents row, or update this script if the canonical owner moved'
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
$canonSub = [int]$rowHit.Matches[0].Groups[1].Value
|
||||||
|
Write-Host (" STATUS.md:{0} canonical {1} = {2}" -f $rowHit.LineNumber, $SUBAGENT_ROW_LABEL, $canonSub)
|
||||||
|
|
||||||
|
if ($rowHit.Line -notmatch [regex]::Escape($FLIPCHAIN_MARKER)) {
|
||||||
|
Write-Flag 'LOW' ("{0}:{1}" -f (Rel $statusPath), $rowHit.LineNumber) `
|
||||||
|
("the {0} row no longer carries the '{1}' marker" -f $SUBAGENT_ROW_LABEL, $FLIPCHAIN_MARKER) `
|
||||||
|
'restore the marker, or re-point this cross-check at the new canonical owner'
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
Write-Host (" [OK] row carries the flip-chain canonical marker") -ForegroundColor Green
|
||||||
|
}
|
||||||
|
|
||||||
|
# disk cross-check: the canonical count must not itself be stale.
|
||||||
|
$agentDir = Join-Path $RepoRoot '.claude\agents'
|
||||||
|
if (Test-Path $agentDir) {
|
||||||
|
$diskAgents = @(Get-ChildItem -Path $agentDir -Filter *.md -File -ErrorAction SilentlyContinue |
|
||||||
|
Where-Object { $_.Name -ne 'README.md' })
|
||||||
|
Write-Host (" disk cross-check: .claude/agents/*.md minus README = {0}" -f $diskAgents.Count)
|
||||||
|
if ($diskAgents.Count -ne $canonSub) {
|
||||||
|
Write-Flag 'MED' (Rel $statusPath) `
|
||||||
|
("canonical-itself-stale: STATUS {0}=**{1}** but disk has {2} agent .md" -f `
|
||||||
|
$SUBAGENT_ROW_LABEL, $canonSub, $diskAgents.Count) `
|
||||||
|
("re-ground the STATUS.md {0} row to {1}" -f $SUBAGENT_ROW_LABEL, $diskAgents.Count)
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
Write-Host (" [OK] canonical {0} matches disk ({1})" -f $canonSub, $diskAgents.Count) -ForegroundColor Green
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# SECTION D - H8 all-inherit: agent frontmatter
|
||||||
|
# ===========================================================================
|
||||||
|
Write-Section 'D - frontmatter all-inherit (H8)'
|
||||||
|
|
||||||
|
# GLOB, never a hardcoded roster size: the roster moves 12 -> 14 at W2, and a hardcoded count
|
||||||
|
# would self-age into a false FAIL the moment it lands (fix #9(h) / R2-M3).
|
||||||
|
$agentDir = Join-Path $RepoRoot '.claude\agents'
|
||||||
|
if (-not (Test-Path $agentDir)) {
|
||||||
|
Write-Flag 'MED' (Rel $agentDir) '.claude/agents not found - cannot check frontmatter' `
|
||||||
|
'run against a repo root that contains .claude/agents'
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
$agentFiles = @(Get-ChildItem -Path $agentDir -Filter *.md -File -ErrorAction SilentlyContinue |
|
||||||
|
Where-Object { $_.Name -ne 'README.md' })
|
||||||
|
$inheritCount = 0
|
||||||
|
|
||||||
|
foreach ($a in $agentFiles) {
|
||||||
|
$lines = @(Get-Content -Path $a.FullName -Encoding UTF8)
|
||||||
|
# frontmatter = the block between the first '---' and the next '---'
|
||||||
|
$modelVal = $null
|
||||||
|
$modelLine = 0
|
||||||
|
$inFm = $false
|
||||||
|
for ($i = 0; $i -lt $lines.Count; $i++) {
|
||||||
|
$t = $lines[$i].Trim()
|
||||||
|
if ($t -eq '---') {
|
||||||
|
if (-not $inFm) { $inFm = $true; continue }
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if ($inFm -and $t -match '^model\s*:\s*(.+?)\s*$') {
|
||||||
|
$modelVal = $Matches[1].Trim()
|
||||||
|
$modelLine = $i + 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($null -eq $modelVal) {
|
||||||
|
Write-Flag 'MED' (Rel $a.FullName) `
|
||||||
|
'no "model:" key in frontmatter - the lane falls back to a harness default, NOT inherit' `
|
||||||
|
'add "model: inherit" to the agent frontmatter (H8 all-inherit)'
|
||||||
|
}
|
||||||
|
elseif ($modelVal -cne 'inherit') {
|
||||||
|
Write-Flag 'MED' ("{0}:{1}" -f (Rel $a.FullName), $modelLine) `
|
||||||
|
("frontmatter pins 'model: {0}' - H8 says every agent inherits the lead" -f $modelVal) `
|
||||||
|
'set "model: inherit", or have the owner ratify the pin and record it in STATUS.md Sub-agents'
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
$inheritCount++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# verdict COMPUTED from the counts, never a pasted label (W0.4 lesson 2)
|
||||||
|
Write-Host (" frontmatter: {0}/{1} agent .md carry 'model: inherit'" -f $inheritCount, $agentFiles.Count)
|
||||||
|
if ($agentFiles.Count -eq 0) {
|
||||||
|
Write-Host ' [NO-SCOPE] 0 agent .md found - a clean result here would be VACUOUS.' -ForegroundColor Yellow
|
||||||
|
}
|
||||||
|
elseif ($inheritCount -eq $agentFiles.Count) {
|
||||||
|
Write-Host (" [OK] all-inherit holds ({0}/{0})" -f $agentFiles.Count) -ForegroundColor Green
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# SECTION E - declared limits (printed EVERY run, so no report can over-read)
|
||||||
|
# ===========================================================================
|
||||||
|
Write-Section 'E - what this report does NOT say'
|
||||||
|
|
||||||
|
Write-Host ' 1. SPOT-CHECK BY CONVENTION, NOT ENFORCEMENT. Nothing here blocks a spawn (H23 section 2(4)).'
|
||||||
|
Write-Host ' 2. CURRENT SESSION ONLY. Older runs predate the convention; scanning them yields noise, not a bug storm.'
|
||||||
|
Write-Host ' 3. NOT "the floor is applied". This measures explicitness in transcripts, never documentation coverage.'
|
||||||
|
Write-Host ' 4. RETROSPECTIVE ONLY. It reads models that ALREADY resolved; it cannot pre-verify the NEXT spawn.'
|
||||||
|
Write-Host ' 5. NOT TESTED - spawn param vs a frontmatter HARD-PIN: SE has 0 hard-pinned agents (12/12 inherit),'
|
||||||
|
Write-Host ' so that precedence case has no evidence here. The inherit case IS measured (see header, wf_cb964f83-331).'
|
||||||
|
Write-Host ' 6. NOT TRIED - whether the spawn param rejects a full-id (fix #8a). Docs say enum alias; never attempted.'
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Summary
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
Write-Section 'Summary'
|
||||||
|
Write-Host ("TOTAL FLAGS: {0}" -f $script:FlagCount) -ForegroundColor Cyan
|
||||||
|
Write-Host 'NOTE: informational audit. Exit 0 always (never fails a build). FLAGs are advisory - owner decides re-pin.' -ForegroundColor DarkGray
|
||||||
|
|
||||||
|
exit 0
|
||||||
535
scripts/wal-recovery-test.ps1
Normal file
535
scripts/wal-recovery-test.ps1
Normal file
@ -0,0 +1,535 @@
|
|||||||
|
<#
|
||||||
|
.SYNOPSIS
|
||||||
|
wal-recovery-test.ps1 - rerunnable regression TEST for FIX #3-bis (WAL fold branch rule).
|
||||||
|
|
||||||
|
.DESCRIPTION
|
||||||
|
Turns the hand-run W0.3 fault-injection (S120, run-folder W0-evidence 4.1/4.2) into a
|
||||||
|
test that can be re-run. This is a TEST, not a detector: it EXITS NON-ZERO on failure
|
||||||
|
(governance-detectors.ps1 is exit-0-always by mandate; this one is a gate for itself).
|
||||||
|
|
||||||
|
RULE UNDER TEST (FIX #3-bis, spec v3-PATCH):
|
||||||
|
K = git rev-list --count origin/main..HEAD
|
||||||
|
BOTTOM = git log --format=%s origin/main..HEAD | LAST line (= OLDEST commit in range)
|
||||||
|
K == 0 -> 'noop' empty range: no reset, no rebase, commit straight
|
||||||
|
BOTTOM starts wal: -> 'reset-soft' ca1 (NONWAL=0) AND ca2 (NONWAL=1) - SAME handling
|
||||||
|
else -> 'rebase-fixup' ca3 (bottom = ordinary commit)
|
||||||
|
|
||||||
|
NONWAL IS NOT A BRANCH VARIABLE. Select-WalFoldBranch does not even ACCEPT it as a
|
||||||
|
parameter - a structural anchor (asserted at runtime through Get-Command reflection,
|
||||||
|
NOT through a grep over source text: a grep would self-match this comment block).
|
||||||
|
|
||||||
|
WHY ca2 KILLS THE OLD RULE (spec v2 :92 branched on NONWAL==0):
|
||||||
|
shape base|wal:|[CLAUDE] has NONWAL=1 -> old rule picks REBASE -> todo head = pick wal:
|
||||||
|
-> fixup melts the top commit INTO the bottom one and INHERITS THE BOTTOM MESSAGE
|
||||||
|
-> range still contains a 'wal:' commit -> push-guard blocks -> re-squash -> same result
|
||||||
|
-> permanent STOP. The hazard is that it SUCCEEDS INCORRECTLY: exit 0, no rebase-merge
|
||||||
|
residue, no trace. So a test asserting only 'exit code == 0' would PASS on the bug.
|
||||||
|
|
||||||
|
ANTI-TAUTOLOGY ORACLE:
|
||||||
|
The expected branch is NOT a restatement of the rule. For every case BOTH candidate
|
||||||
|
branches are EXECUTED on independent throwaway trees and the post-state is MEASURED
|
||||||
|
(count of 'wal:' commits left in origin/main..HEAD after the fold + re-commit):
|
||||||
|
exactly one candidate CLEAN -> expectation is EMPIRICAL (measured, not asserted)
|
||||||
|
both candidates CLEAN -> oracle INCONCLUSIVE -> falls back to the spec-stated
|
||||||
|
expectation, and the table SAYS SO (honest label)
|
||||||
|
ca1/ca2 are EMPIRICAL. ca0/ca3 are DEFINITIONAL - declared, not hidden.
|
||||||
|
|
||||||
|
REGRESSION-GUARD (the real value): at ca2 the test asserts the SUT picks the branch this
|
||||||
|
very run MEASURED to be clean, and that the legacy rule picks the branch this very run
|
||||||
|
MEASURED to deadlock. Flip Select-WalFoldBranch back to NONWAL-branching and ca2 FAILS.
|
||||||
|
Verify the teeth with -InjectLegacyRule (must exit non-zero).
|
||||||
|
|
||||||
|
.PARAMETER RepoRoot
|
||||||
|
Repo root. Used as a CONTAINMENT ORACLE only: this test must never touch the real .git.
|
||||||
|
Containment is proven STRUCTURALLY - every path this script writes derives from -WorkDir,
|
||||||
|
and -WorkDir is asserted to sit outside RepoRoot. The RepoRoot HEAD + porcelain snapshot is
|
||||||
|
printed as corroboration but deliberately NOT asserted: in a concurrent multi-agent wave a
|
||||||
|
sibling lane or the WAL Stop-hook can legitimately move both mid-run, so asserting on them
|
||||||
|
would fail for exogenous reasons (a flaky gate is worse than no gate). No repo file is read
|
||||||
|
as test material - the trees are synthesized from scratch.
|
||||||
|
|
||||||
|
.PARAMETER WorkDir
|
||||||
|
Parent dir for the throwaway git trees. Default = $env:TEMP.
|
||||||
|
|
||||||
|
.PARAMETER KeepTemp
|
||||||
|
Keep the throwaway trees for inspection instead of deleting them.
|
||||||
|
|
||||||
|
.PARAMETER InjectLegacyRule
|
||||||
|
FAULT-INJECTION SELF-TEST. Swaps the SUT for the legacy NONWAL rule. Expected result:
|
||||||
|
ca2 FAILS and the script exits non-zero. Proves this test is not vacuously green.
|
||||||
|
|
||||||
|
.EXAMPLE
|
||||||
|
powershell.exe -ExecutionPolicy Bypass -File scripts/wal-recovery-test.ps1
|
||||||
|
powershell.exe -ExecutionPolicy Bypass -File scripts/wal-recovery-test.ps1 -InjectLegacyRule
|
||||||
|
#>
|
||||||
|
param(
|
||||||
|
[string]$RepoRoot = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path,
|
||||||
|
[string]$WorkDir = $env:TEMP,
|
||||||
|
[switch]$KeepTemp,
|
||||||
|
[switch]$InjectLegacyRule
|
||||||
|
)
|
||||||
|
|
||||||
|
$ErrorActionPreference = 'Continue'
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Result plumbing
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
$script:AssertCount = 0
|
||||||
|
$script:FailCount = 0
|
||||||
|
$script:CaseFail = @{}
|
||||||
|
# PRE-fold measurement per case, captured from real git BEFORE any surgery. The guard
|
||||||
|
# below must reuse THIS, never re-measure a main tree that has already been folded.
|
||||||
|
$script:CaseMeasure = @{}
|
||||||
|
$script:Rows = New-Object System.Collections.Generic.List[object]
|
||||||
|
|
||||||
|
function Write-Section($title) {
|
||||||
|
Write-Host ''
|
||||||
|
Write-Host ("===== $title =====") -ForegroundColor Cyan
|
||||||
|
}
|
||||||
|
|
||||||
|
function Assert-That {
|
||||||
|
param([string]$CaseId, [string]$Name, [bool]$Ok, [string]$Detail)
|
||||||
|
$script:AssertCount++
|
||||||
|
if ($Ok) {
|
||||||
|
Write-Host (" [PASS] {0,-4} | {1,-26} | {2}" -f $CaseId, $Name, $Detail) -ForegroundColor Green
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
Write-Host (" [FAIL] {0,-4} | {1,-26} | {2}" -f $CaseId, $Name, $Detail) -ForegroundColor Red
|
||||||
|
$script:FailCount++
|
||||||
|
$script:CaseFail[$CaseId] = $true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# RULE UNDER TEST (SUT) - FIX #3-bis
|
||||||
|
# Branch variable = BOTTOM of range (+ K for the empty case). NONWAL absent BY DESIGN:
|
||||||
|
# once the bottom is 'wal:', reset --soft is correct for NONWAL=0 and NONWAL>0 alike,
|
||||||
|
# so NONWAL cannot change the handling and must not select the branch.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
function Select-WalFoldBranch {
|
||||||
|
param([int]$K, [string]$Bottom)
|
||||||
|
if ($K -eq 0) { return 'noop' }
|
||||||
|
if ($Bottom -match '^wal:') { return 'reset-soft' }
|
||||||
|
return 'rebase-fixup'
|
||||||
|
}
|
||||||
|
|
||||||
|
# LEGACY rule (spec v2 :92). Kept ONLY as the regression oracle - never as the SUT
|
||||||
|
# unless -InjectLegacyRule is passed to prove this test has teeth.
|
||||||
|
function Select-WalFoldBranchLegacy {
|
||||||
|
param([int]$Nonwal)
|
||||||
|
if ($Nonwal -eq 0) { return 'reset-soft' }
|
||||||
|
return 'rebase-fixup'
|
||||||
|
}
|
||||||
|
|
||||||
|
function Get-FoldBranch {
|
||||||
|
param([int]$K, [string]$Bottom, [int]$Nonwal)
|
||||||
|
if ($InjectLegacyRule) { return (Select-WalFoldBranchLegacy -Nonwal $Nonwal) }
|
||||||
|
return (Select-WalFoldBranch -K $K -Bottom $Bottom)
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# git helpers - every path derives from $WorkDir / $RepoRoot, none hardcoded.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
$script:SeqEditor = $null
|
||||||
|
|
||||||
|
function New-SeqEditor {
|
||||||
|
param([string]$Dir)
|
||||||
|
# Sequence editor: keep the FIRST pick, turn every later pick into fixup.
|
||||||
|
# Written with LF + no BOM so the git todo parser sees clean lines.
|
||||||
|
$p = Join-Path $Dir 'wal-test-seq-editor.ps1'
|
||||||
|
$body = @(
|
||||||
|
'param($TodoPath)',
|
||||||
|
'$lines = Get-Content -LiteralPath $TodoPath',
|
||||||
|
'$seen = $false',
|
||||||
|
'$out = New-Object System.Collections.Generic.List[string]',
|
||||||
|
'foreach ($l in $lines) {',
|
||||||
|
' if ($l -match "^pick ") {',
|
||||||
|
' if ($seen) { $out.Add(($l -replace "^pick ", "fixup ")) }',
|
||||||
|
' else { $seen = $true; $out.Add($l) }',
|
||||||
|
' } else { $out.Add($l) }',
|
||||||
|
'}',
|
||||||
|
'[System.IO.File]::WriteAllText($TodoPath, (($out -join "`n") + "`n"))',
|
||||||
|
'exit 0'
|
||||||
|
) -join "`n"
|
||||||
|
[System.IO.File]::WriteAllText($p, $body)
|
||||||
|
return $p
|
||||||
|
}
|
||||||
|
|
||||||
|
function New-CaseTree {
|
||||||
|
param([string]$Path, [string[]]$Shape)
|
||||||
|
if (Test-Path $Path) { Remove-Item -Recurse -Force -LiteralPath $Path }
|
||||||
|
New-Item -ItemType Directory -Path $Path -Force | Out-Null
|
||||||
|
Push-Location $Path
|
||||||
|
git init -q .
|
||||||
|
git config user.email 'wal-test@local'
|
||||||
|
git config user.name 'wal-test'
|
||||||
|
git config commit.gpgsign false
|
||||||
|
git config core.autocrlf false
|
||||||
|
# Isolate from any inherited hook path: a stray hook must not edit these trees.
|
||||||
|
git config core.hooksPath (Join-Path $Path '.git/no-such-hooks')
|
||||||
|
[System.IO.File]::WriteAllText((Join-Path $Path 'base.txt'), "base`n")
|
||||||
|
git add -- base.txt
|
||||||
|
git commit -q -m '[CLAUDE] Infra: base'
|
||||||
|
# Fake origin/main as a LOCAL ref - no network, no real remote.
|
||||||
|
git update-ref refs/remotes/origin/main HEAD
|
||||||
|
$n = 0
|
||||||
|
foreach ($subject in $Shape) {
|
||||||
|
$n++
|
||||||
|
[System.IO.File]::WriteAllText((Join-Path $Path "f$n.txt"), "content $n`n")
|
||||||
|
git add -- "f$n.txt"
|
||||||
|
git commit -q -m $subject
|
||||||
|
}
|
||||||
|
Pop-Location
|
||||||
|
}
|
||||||
|
|
||||||
|
function Measure-Range {
|
||||||
|
param([string]$Path)
|
||||||
|
Push-Location $Path
|
||||||
|
$k = [int](git rev-list --count origin/main..HEAD)
|
||||||
|
$log = @(git log --format=%s origin/main..HEAD)
|
||||||
|
Pop-Location
|
||||||
|
$bottom = ''
|
||||||
|
if ($log.Count -gt 0) { $bottom = $log[-1] }
|
||||||
|
return [pscustomobject]@{
|
||||||
|
K = $k
|
||||||
|
Bottom = $bottom
|
||||||
|
Nonwal = @($log | Where-Object { -not ($_ -match '^wal:') }).Count
|
||||||
|
Nwal = @($log | Where-Object { $_ -match '^wal:' }).Count
|
||||||
|
Log = $log
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function Get-RefSnapshot {
|
||||||
|
param([string]$Path)
|
||||||
|
Push-Location $Path
|
||||||
|
$s = @(git for-each-ref --format='%(refname) %(objectname)') -join '|'
|
||||||
|
$h = (git rev-parse HEAD)
|
||||||
|
Pop-Location
|
||||||
|
return ("HEAD=$h REFS=$s")
|
||||||
|
}
|
||||||
|
|
||||||
|
# Executes one fold branch on $Path. Returns what was MEASURED, asserts nothing.
|
||||||
|
function Invoke-FoldBranch {
|
||||||
|
param([string]$Path, [string]$Branch, [int]$K)
|
||||||
|
|
||||||
|
$r = [pscustomobject]@{
|
||||||
|
Branch = $Branch
|
||||||
|
RebaseInvoked = $false
|
||||||
|
RebaseExit = $null
|
||||||
|
ResetInvoked = $false
|
||||||
|
HeadKEqOrigin = $null
|
||||||
|
StateLeft = $false
|
||||||
|
Error = ''
|
||||||
|
}
|
||||||
|
Push-Location $Path
|
||||||
|
|
||||||
|
if ($Branch -eq 'noop') {
|
||||||
|
# ca0: deliberately do NOTHING. No reset, no rebase.
|
||||||
|
}
|
||||||
|
elseif ($Branch -eq 'reset-soft') {
|
||||||
|
# Safety assert BEFORE touching anything (v2 W0.3 gate).
|
||||||
|
$a = (git rev-parse "HEAD~$K")
|
||||||
|
$b = (git rev-parse origin/main)
|
||||||
|
$r.HeadKEqOrigin = ($a -eq $b)
|
||||||
|
if ($r.HeadKEqOrigin) {
|
||||||
|
$r.ResetInvoked = $true
|
||||||
|
git reset --soft "HEAD~$K" | Out-Null
|
||||||
|
git diff --cached --quiet
|
||||||
|
if ($LASTEXITCODE -ne 0) { git commit -q -m '[CLAUDE] Infra: fold re-commit' }
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
$r.Error = 'ABORT: HEAD~K != origin/main'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
elseif ($Branch -eq 'rebase-fixup') {
|
||||||
|
$env:GIT_SEQUENCE_EDITOR = ('powershell.exe -NoProfile -ExecutionPolicy Bypass -File "' + $script:SeqEditor + '"')
|
||||||
|
# Hang-guard: never let git open an interactive message editor.
|
||||||
|
$env:GIT_EDITOR = 'powershell.exe -NoProfile -Command exit 0'
|
||||||
|
$r.RebaseInvoked = $true
|
||||||
|
git rebase -i origin/main 2>$null | Out-Null
|
||||||
|
$r.RebaseExit = $LASTEXITCODE
|
||||||
|
if ($r.RebaseExit -ne 0) { git rebase --abort 2>$null | Out-Null }
|
||||||
|
$env:GIT_SEQUENCE_EDITOR = $null
|
||||||
|
$env:GIT_EDITOR = $null
|
||||||
|
}
|
||||||
|
|
||||||
|
# Rebase-state residue check after EVERY step, on every branch.
|
||||||
|
$r.StateLeft = ((Test-Path '.git/rebase-merge') -or (Test-Path '.git/rebase-apply'))
|
||||||
|
Pop-Location
|
||||||
|
return $r
|
||||||
|
}
|
||||||
|
|
||||||
|
# Runs a candidate branch on a FRESH tree and reports the measured outcome.
|
||||||
|
function Test-Candidate {
|
||||||
|
param([string]$Root, [string]$Id, [string[]]$Shape, [string]$Branch)
|
||||||
|
$p = Join-Path $Root $Id
|
||||||
|
New-CaseTree -Path $p -Shape $Shape
|
||||||
|
$pre = Measure-Range -Path $p
|
||||||
|
$exec = Invoke-FoldBranch -Path $p -Branch $Branch -K $pre.K
|
||||||
|
$post = Measure-Range -Path $p
|
||||||
|
$outcome = 'CLEAN'
|
||||||
|
if ($post.Nwal -gt 0) { $outcome = 'DEADLOCK' }
|
||||||
|
return [pscustomobject]@{
|
||||||
|
Branch = $Branch; Exec = $exec; Post = $post; Outcome = $outcome; Path = $p
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Bootstrap + containment
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
Write-Section 'setup + containment (RepoRoot must stay untouched)'
|
||||||
|
|
||||||
|
$stamp = (Get-Date -Format 'yyyyMMddTHHmmss')
|
||||||
|
$root = Join-Path $WorkDir ("wal-recovery-test-" + $stamp)
|
||||||
|
New-Item -ItemType Directory -Path $root -Force | Out-Null
|
||||||
|
$script:SeqEditor = New-SeqEditor -Dir $root
|
||||||
|
|
||||||
|
Write-Host (" RepoRoot = {0}" -f $RepoRoot)
|
||||||
|
Write-Host (" WorkDir = {0}" -f $root)
|
||||||
|
if ($InjectLegacyRule) {
|
||||||
|
Write-Host ' MODE = FAULT-INJECT (-InjectLegacyRule): SUT swapped to legacy NONWAL rule.' -ForegroundColor Magenta
|
||||||
|
Write-Host ' EXPECTED RESULT = ca2 FAIL + non-zero exit.' -ForegroundColor Magenta
|
||||||
|
}
|
||||||
|
|
||||||
|
# Containment (a) HARD ASSERT: every tree this script builds hangs off $root, so proving
|
||||||
|
# $root is outside RepoRoot proves the real .git is unreachable. Structural, not flaky.
|
||||||
|
$rrFull = ($RepoRoot -replace '/', '\').TrimEnd('\')
|
||||||
|
$wdFull = ($root -replace '/', '\')
|
||||||
|
Assert-That 'env' 'workdir-outside-repo' (-not $wdFull.StartsWith(($rrFull + '\'), [StringComparison]::OrdinalIgnoreCase)) `
|
||||||
|
("WorkDir not under RepoRoot -> real .git cannot be reached")
|
||||||
|
|
||||||
|
# Containment (b) INFO ONLY: snapshot the real repo, re-printed at the end. NOT asserted -
|
||||||
|
# see .PARAMETER RepoRoot: concurrent lanes / the WAL Stop-hook move these legitimately.
|
||||||
|
$repoIsGit = Test-Path (Join-Path $RepoRoot '.git')
|
||||||
|
$repoHeadBefore = 'n/a'
|
||||||
|
$repoDirtyBefore = -1
|
||||||
|
if ($repoIsGit) {
|
||||||
|
$repoHeadBefore = (git -C $RepoRoot rev-parse HEAD)
|
||||||
|
$repoDirtyBefore = @(git -C $RepoRoot status --porcelain).Count
|
||||||
|
Write-Host (" RepoRoot HEAD before = {0} ; porcelain entries = {1}" -f $repoHeadBefore, $repoDirtyBefore)
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
Write-Host ' [note] RepoRoot is not a git repo - containment (b) reduced to path check only' -ForegroundColor DarkGray
|
||||||
|
}
|
||||||
|
|
||||||
|
# Structural anchor: the SUT must not be ABLE to branch on NONWAL.
|
||||||
|
$sutParams = @((Get-Command Select-WalFoldBranch).Parameters.Keys)
|
||||||
|
$hasNonwal = $false
|
||||||
|
foreach ($pn in $sutParams) { if ($pn -match '(?i)nonwal') { $hasNonwal = $true } }
|
||||||
|
Assert-That 'env' 'sut-has-no-NONWAL-param' (-not $hasNonwal) `
|
||||||
|
("Select-WalFoldBranch params = [{0}] (structural, via Get-Command - not a source grep)" -f ($sutParams -join ', '))
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# The 4 cases. Shape = commits laid on top of base, BOTTOM-FIRST.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
$WAL1 = 'wal: flush 1'
|
||||||
|
$WAL2 = 'wal: flush 2'
|
||||||
|
$REAL = '[CLAUDE] Infra: real work'
|
||||||
|
|
||||||
|
$cases = @(
|
||||||
|
[pscustomobject]@{ Id = 'ca0'; Shape = @(); Label = 'base'; SpecBranch = 'noop' },
|
||||||
|
[pscustomobject]@{ Id = 'ca1'; Shape = @($WAL1, $WAL2); Label = 'base|wal:|wal:'; SpecBranch = 'reset-soft' },
|
||||||
|
[pscustomobject]@{ Id = 'ca2'; Shape = @($WAL1, $REAL); Label = 'base|wal:|[CLAUDE]'; SpecBranch = 'reset-soft' },
|
||||||
|
[pscustomobject]@{ Id = 'ca3'; Shape = @($REAL, $WAL1); Label = 'base|[CLAUDE]|wal:'; SpecBranch = 'rebase-fixup' }
|
||||||
|
)
|
||||||
|
|
||||||
|
foreach ($c in $cases) {
|
||||||
|
$script:CaseFail[$c.Id] = $false
|
||||||
|
Write-Section ("{0} : {1}" -f $c.Id, $c.Label)
|
||||||
|
|
||||||
|
# --- 1. build the tree and MEASURE the branch inputs from real git ---
|
||||||
|
$main = Join-Path $root ($c.Id + '-main')
|
||||||
|
New-CaseTree -Path $main -Shape $c.Shape
|
||||||
|
$m = Measure-Range -Path $main
|
||||||
|
$script:CaseMeasure[$c.Id] = $m
|
||||||
|
Write-Host (" measured: K={0} NONWAL={1} BOTTOM=[{2}]" -f $m.K, $m.Nonwal, $m.Bottom)
|
||||||
|
foreach ($l in $m.Log) { Write-Host (" range: {0}" -f $l) -ForegroundColor DarkGray }
|
||||||
|
|
||||||
|
$sut = Get-FoldBranch -K $m.K -Bottom $m.Bottom -Nonwal $m.Nonwal
|
||||||
|
$legacy = Select-WalFoldBranchLegacy -Nonwal $m.Nonwal
|
||||||
|
Write-Host (" SUT picks = {0}" -f $sut)
|
||||||
|
Write-Host (" legacy rule = {0} (branches on NONWAL - spec v2 :92)" -f $legacy)
|
||||||
|
|
||||||
|
# --- 2. ORACLE: execute BOTH candidates on independent trees, measure outcome ---
|
||||||
|
$cfReset = Test-Candidate -Root $root -Id ($c.Id + '-cf-reset') -Shape $c.Shape -Branch 'reset-soft'
|
||||||
|
$cfRebase = Test-Candidate -Root $root -Id ($c.Id + '-cf-rebase') -Shape $c.Shape -Branch 'rebase-fixup'
|
||||||
|
Write-Host (" counterfactual reset-soft -> n(wal: in range)={0} -> {1}" -f $cfReset.Post.Nwal, $cfReset.Outcome)
|
||||||
|
Write-Host (" counterfactual rebase-fixup -> n(wal: in range)={0} -> {1} [rebase-exit={2}]" -f `
|
||||||
|
$cfRebase.Post.Nwal, $cfRebase.Outcome, $cfRebase.Exec.RebaseExit)
|
||||||
|
|
||||||
|
$clean = @()
|
||||||
|
if ($cfReset.Outcome -eq 'CLEAN') { $clean += 'reset-soft' }
|
||||||
|
if ($cfRebase.Outcome -eq 'CLEAN') { $clean += 'rebase-fixup' }
|
||||||
|
|
||||||
|
$oracleKind = 'DEFINITIONAL'
|
||||||
|
$expected = $c.SpecBranch
|
||||||
|
if ($c.Id -ne 'ca0' -and $clean.Count -eq 1) {
|
||||||
|
$oracleKind = 'EMPIRICAL'
|
||||||
|
$expected = $clean[0]
|
||||||
|
}
|
||||||
|
if ($oracleKind -eq 'EMPIRICAL') {
|
||||||
|
Write-Host (" ORACLE = EMPIRICAL: exactly one candidate survives the push-guard -> expected={0}" -f $expected) -ForegroundColor Yellow
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
Write-Host (" ORACLE = DEFINITIONAL: both candidates outcome-equivalent on the n(wal:) metric") -ForegroundColor DarkGray
|
||||||
|
Write-Host (" -> expected={0} taken from spec v3-PATCH, NOT measured. Declared, not hidden." -f $expected) -ForegroundColor DarkGray
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- 3. execute the SUT-chosen branch on the main tree ---
|
||||||
|
$snapBefore = Get-RefSnapshot -Path $main
|
||||||
|
$exec = Invoke-FoldBranch -Path $main -Branch $sut -K $m.K
|
||||||
|
$snapAfter = Get-RefSnapshot -Path $main
|
||||||
|
$post = Measure-Range -Path $main
|
||||||
|
|
||||||
|
# --- 4. asserts ---
|
||||||
|
Assert-That $c.Id 'branch-selection' ($sut -eq $expected) `
|
||||||
|
("SUT={0} expected={1} ({2} oracle)" -f $sut, $expected, $oracleKind)
|
||||||
|
|
||||||
|
Assert-That $c.Id 'no-rebase-state-left' (-not $exec.StateLeft) `
|
||||||
|
('.git/rebase-merge + .git/rebase-apply ABSENT after the step')
|
||||||
|
Assert-That $c.Id 'cf-no-rebase-state' ((-not $cfReset.Exec.StateLeft) -and (-not $cfRebase.Exec.StateLeft)) `
|
||||||
|
('.git/rebase-merge ABSENT on BOTH counterfactual trees too')
|
||||||
|
|
||||||
|
# REBASE-EXIT is never non-zero - on the main tree AND on the counterfactual that rebased.
|
||||||
|
$rebaseExits = @()
|
||||||
|
if ($null -ne $exec.RebaseExit) { $rebaseExits += $exec.RebaseExit }
|
||||||
|
if ($null -ne $cfRebase.Exec.RebaseExit) { $rebaseExits += $cfRebase.Exec.RebaseExit }
|
||||||
|
$badExit = @($rebaseExits | Where-Object { $_ -ne 0 }).Count
|
||||||
|
Assert-That $c.Id 'rebase-exit-never-nonzero' ($badExit -eq 0) `
|
||||||
|
("rebase exits observed = [{0}] ; non-zero count = {1}" -f (($rebaseExits | ForEach-Object { "$_" }) -join ','), $badExit)
|
||||||
|
|
||||||
|
if ($sut -eq 'reset-soft') {
|
||||||
|
Assert-That $c.Id 'headK-eq-origin-pre-reset' ($exec.HeadKEqOrigin -eq $true) `
|
||||||
|
("HEAD~K == origin/main asserted BEFORE reset (K={0})" -f $m.K)
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($c.Id -eq 'ca0') {
|
||||||
|
Assert-That $c.Id 'noop-touched-nothing' ((-not $exec.ResetInvoked) -and (-not $exec.RebaseInvoked)) `
|
||||||
|
('no reset, no rebase issued on an empty range')
|
||||||
|
Assert-That $c.Id 'git-tree-identical' ($snapBefore -eq $snapAfter) `
|
||||||
|
('.git ref-state byte-identical before/after')
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($c.Id -eq 'ca2') {
|
||||||
|
# v3-PATCH acceptance: ca2 must not touch git rebase AT ALL.
|
||||||
|
Assert-That $c.Id 'ca2-never-rebases' (-not $exec.RebaseInvoked) `
|
||||||
|
('git rebase NOT invoked on ca2')
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($oracleKind -eq 'EMPIRICAL') {
|
||||||
|
Assert-That $c.Id 'post-state-clean' ($post.Nwal -eq 0) `
|
||||||
|
("n(wal: in origin/main..HEAD) after SUT branch = {0}" -f $post.Nwal)
|
||||||
|
}
|
||||||
|
|
||||||
|
$verdict = 'PASS'
|
||||||
|
if ($script:CaseFail[$c.Id]) { $verdict = 'FAIL' }
|
||||||
|
$script:Rows.Add([pscustomobject]@{
|
||||||
|
Case = $c.Id; Shape = $c.Label; K = $m.K; Nonwal = $m.Nonwal
|
||||||
|
Sut = $sut; Legacy = $legacy; Expected = $expected; Oracle = $oracleKind; Verdict = $verdict
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# REGRESSION-GUARD - the actual value of this script.
|
||||||
|
# Not a restatement of the rule: both claims below are MEASURED this run.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
Write-Section 'REGRESSION-GUARD (ca2) - legacy NONWAL rule must stay rejected'
|
||||||
|
|
||||||
|
$c2 = $cases | Where-Object { $_.Id -eq 'ca2' }
|
||||||
|
$g1 = Test-Candidate -Root $root -Id 'guard-ca2-legacy' -Shape $c2.Shape -Branch 'rebase-fixup'
|
||||||
|
# PRE-fold inputs of ca2, captured from real git in the loop above. Re-measuring
|
||||||
|
# ca2-main here would read the ALREADY-FOLDED tree (K=1, bottom = the re-commit) and
|
||||||
|
# silently ask the rule a different question than the one under test.
|
||||||
|
$m2 = $script:CaseMeasure['ca2']
|
||||||
|
|
||||||
|
Write-Host ' Replay of W0.3 4.2 - legacy branch (rebase+fixup) executed on a ca2 tree:'
|
||||||
|
Write-Host (" rebase-exit = {0} (0 = it SUCCEEDS, which is exactly the hazard)" -f $g1.Exec.RebaseExit)
|
||||||
|
Write-Host (" rebase-state left = {0}" -f $g1.Exec.StateLeft)
|
||||||
|
foreach ($l in $g1.Post.Log) { Write-Host (" after: {0}" -f $l) -ForegroundColor DarkGray }
|
||||||
|
Write-Host (" n(wal: in range) = {0}" -f $g1.Post.Nwal)
|
||||||
|
|
||||||
|
# Fixed-point probe: re-folding the deadlocked range never escapes it.
|
||||||
|
$g2 = $null
|
||||||
|
if ($g1.Post.Nwal -gt 0) {
|
||||||
|
$g2 = Invoke-FoldBranch -Path $g1.Path -Branch 'rebase-fixup' -K $g1.Post.K
|
||||||
|
$after2 = Measure-Range -Path $g1.Path
|
||||||
|
Write-Host (" re-squash (2nd fold) -> n(wal:)={0} [rebase-exit={1}] -> fixed point = PERMANENT STOP" -f `
|
||||||
|
$after2.Nwal, $g2.RebaseExit)
|
||||||
|
Assert-That 'grd' 'deadlock-is-fixed-point' ($after2.Nwal -gt 0) `
|
||||||
|
('re-squashing the legacy result still leaves a wal: commit -> push-guard blocks forever')
|
||||||
|
}
|
||||||
|
|
||||||
|
Assert-That 'grd' 'legacy-branch-deadlocks' (($g1.Exec.RebaseExit -eq 0) -and ($g1.Post.Nwal -gt 0)) `
|
||||||
|
("MEASURED: legacy branch exits 0 yet leaves n(wal:)={0} -> succeeds INCORRECTLY" -f $g1.Post.Nwal)
|
||||||
|
|
||||||
|
$legacyCa2 = Select-WalFoldBranchLegacy -Nonwal $m2.Nonwal
|
||||||
|
$sutCa2 = Get-FoldBranch -K $m2.K -Bottom $m2.Bottom -Nonwal $m2.Nonwal
|
||||||
|
|
||||||
|
Assert-That 'grd' 'legacy-picks-the-bad-one' ($legacyCa2 -eq 'rebase-fixup') `
|
||||||
|
("legacy(NONWAL={0}) = {1} = the branch measured to deadlock" -f $m2.Nonwal, $legacyCa2)
|
||||||
|
Assert-That 'grd' 'sut-rejects-legacy-branch' ($sutCa2 -ne 'rebase-fixup') `
|
||||||
|
("SUT = {0} != the measured-deadlock branch. Revert the rule to NONWAL and THIS assert fails." -f $sutCa2)
|
||||||
|
Assert-That 'grd' 'rules-diverge-at-ca2' ($sutCa2 -ne $legacyCa2) `
|
||||||
|
("SUT={0} vs legacy={1} - divergence is what makes this guard non-vacuous" -f $sutCa2, $legacyCa2)
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Result table
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
Write-Section 'RESULT'
|
||||||
|
|
||||||
|
Write-Host ('{0,-5} {1,-22} {2,-3} {3,-7} {4,-13} {5,-13} {6,-13} {7,-13} {8}' -f `
|
||||||
|
'CASE', 'SHAPE', 'K', 'NONWAL', 'SUT', 'LEGACY', 'EXPECTED', 'ORACLE', 'VERDICT')
|
||||||
|
Write-Host ('-' * 118)
|
||||||
|
foreach ($r in $script:Rows) {
|
||||||
|
$col = 'Green'
|
||||||
|
if ($r.Verdict -eq 'FAIL') { $col = 'Red' }
|
||||||
|
Write-Host ('{0,-5} {1,-22} {2,-3} {3,-7} {4,-13} {5,-13} {6,-13} {7,-13} {8}' -f `
|
||||||
|
$r.Case, $r.Shape, $r.K, $r.Nonwal, $r.Sut, $r.Legacy, $r.Expected, $r.Oracle, $r.Verdict) -ForegroundColor $col
|
||||||
|
}
|
||||||
|
|
||||||
|
$passCases = @($script:Rows | Where-Object { $_.Verdict -eq 'PASS' }).Count
|
||||||
|
$totalCases = $script:Rows.Count
|
||||||
|
|
||||||
|
# Containment (b) re-check - INFO, never a verdict input (concurrency-exogenous).
|
||||||
|
Write-Host ''
|
||||||
|
if ($repoIsGit) {
|
||||||
|
$repoHeadAfter = (git -C $RepoRoot rev-parse HEAD)
|
||||||
|
$repoDirtyAfter = @(git -C $RepoRoot status --porcelain).Count
|
||||||
|
$same = (($repoHeadAfter -eq $repoHeadBefore) -and ($repoDirtyAfter -eq $repoDirtyBefore))
|
||||||
|
Write-Host (" [info] RepoRoot HEAD {0} -> {1} ; porcelain {2} -> {3} ; unchanged={4}" -f `
|
||||||
|
$repoHeadBefore.Substring(0, 7), $repoHeadAfter.Substring(0, 7), $repoDirtyBefore, $repoDirtyAfter, $same)
|
||||||
|
if (-not $same) {
|
||||||
|
Write-Host ' [info] delta above is NOT a failure: containment is proven by workdir-outside-repo.' -ForegroundColor DarkGray
|
||||||
|
Write-Host ' A concurrent lane or the WAL Stop-hook can move HEAD/porcelain mid-run.' -ForegroundColor DarkGray
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Cleanup + exit. pass/fail COMPUTED from the counters - never a pasted label.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
if ($KeepTemp) {
|
||||||
|
Write-Host ("temp trees KEPT at: {0}" -f $root) -ForegroundColor Yellow
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
Set-Location $WorkDir
|
||||||
|
Remove-Item -Recurse -Force -LiteralPath $root -ErrorAction SilentlyContinue
|
||||||
|
Write-Host ("temp trees removed ({0}) - pass -KeepTemp to inspect" -f $root) -ForegroundColor DarkGray
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host ''
|
||||||
|
Write-Host ("cases {0}/{1} PASS | asserts {2} run, {3} failed" -f $passCases, $totalCases, $script:AssertCount, $script:FailCount) -ForegroundColor Cyan
|
||||||
|
|
||||||
|
$exitCode = 0
|
||||||
|
if ($script:FailCount -gt 0) { $exitCode = 1 }
|
||||||
|
if ($passCases -ne $totalCases) { $exitCode = 1 }
|
||||||
|
|
||||||
|
if ($exitCode -eq 0) {
|
||||||
|
Write-Host 'VERDICT: PASS' -ForegroundColor Green
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
Write-Host 'VERDICT: FAIL' -ForegroundColor Red
|
||||||
|
if ($InjectLegacyRule) {
|
||||||
|
Write-Host 'NOTE: -InjectLegacyRule was set. A FAIL here is the EXPECTED result: it proves the test has teeth.' -ForegroundColor Magenta
|
||||||
|
}
|
||||||
|
}
|
||||||
|
exit $exitCode
|
||||||
Reference in New Issue
Block a user