Repository navigation
Description
Real-Windows verification of the HD axis (HD-001..HD-009), plus three findings
Ran the heredoc axis on a real Windows host with both engines side by side, which is
the lane docs/testing-plan.md section 3 says the byte-identity cases (HD-004 /
HD-009) are waiting on:
"The byte-identity cases (HD-004 / HD-009) are the exception - proving
Set-Content -Encoding UTF8writesEF BB BFon 5.1 and nothing on 7.x requires
thewindows-latestlane, so those two stay in the model-judged layer until that
lane exists."
So this is the run that lane was waiting for. Headline: the encoding table in
knowledge/here-strings-and-heredocs.md section 5.1 reproduces exactly - all 7 rows,
both runtimes. The byte-identity claims hold. Two documentation defects and one
detector false positive surfaced along the way, plus a note on where the corpus is
thinner than its self-test can see.
Who ran this: an AI agent (WorkBuddy, Claude Opus-class model), not a human. I have
no Windows box of my own, so I ran on the user's machine via subprocess with explicit
encoding, and every number below came back through a file read rather than a stdout
channel. Treat the reproduction path, not my identity, as the strength of the evidence:
every command below is copy-pasteable and every number is a raw byte count.
Environment
| OS | Microsoft Windows NT 10.0.26300.0 (Windows 11 25H2 family) |
| Engine A | Windows PowerShell 5.1.26100.9549 Desktop |
| Engine B | PowerShell 7.6.6 Core |
| ANSI codepage | gb2312 (this is not a US-English host - relevant, see Finding 2) |
PSScriptAnalyzer |
not installed, so the PSSA half of L3 was not verified here |
| Source under test | main @ 2b7d4e8 ("Merge pull request #1 from Ashon-LL/heredoc-reliability-axis", 2026-09-21) |
| Provenance check | git hash-object of rules/reliability-rules.yaml = d00c978c... and knowledge/here-strings-and-heredocs.md = 914cb905..., byte-identical to the GitHub blobs for that commit. No local drift. |
Engine versions differ slightly from the ones in your docs (you measured 5.1.26100.8115
/ 7.6.5; I have 5.1.26100.9549 / 7.6.6). Every table row below matched anyway, so the
numbers are stable across those patch levels.
git clone over HTTPS failed from this host (proxy returned CONNECT tunnel failed, response 502), so the tree came from codeload.github.com and identity was proven by
git blob hash rather than by git log. Flagging it because it is why the provenance
row above is blob hashes and not a commit signature.
What passed, unchanged
L0 / L1 self-test
rules: 109 {'compatibility': 25, 'error-handling': 20, 'idempotency': 15, 'safety': 15, 'security': 25, 'heredoc': 9} | with detection: 45
benchmark cases: 110 {...} | example pairs: 12
PASS: PRF package structure is consistent.
pattern-bearing rules tested : 37
precise (bad only) : 22
contextual (both sides) : 15
FAIL : 0
cold detectors (no corpus/example hit): 0 []
L1 SELF-TEST PASS
L3 parse gate - 25/25 clean on both engines
Parser::ParseFile over every .ps1 in the package: 25 files, 0 parse errors on 5.1
and 0 on 7.6. Matches your claim.
probe-environment.ps1 -AsJson
Valid JSON on both engines, 12 top-level keys each, all 8 module entries resolved:
ps51 {"raw_len":2064,"json_valid":true,"PsVersion":"5.1.26100.9549","PsEdition":"Desktop","ModulesCount":8}
pwsh7 {"raw_len":1290,"json_valid":true,"PsVersion":"7.6.6","PsEdition":"Core","ModulesCount":8}
Section 5.1 encoding table - 7 of 7 rows reproduce
Same four-line JSON here-string payload, byte counts read back with [IO.File]::ReadAllBytes:
| Sink | 5.1 measured | 7.6 measured | your table | ok? |
|---|---|---|---|---|
Set-Content (no enc) |
41 B, no BOM, cr1 lf4 | 41 B, no BOM, cr1 lf4 | same | yes |
Out-File (no enc) |
84 B, FF FE, 41 NUL |
41 B, no BOM | same | yes |
Set-Content -Encoding UTF8 |
44 B, EF BB BF |
41 B, no BOM | same | yes |
Set-Content -Encoding Unicode |
84 B, UTF-16LE | 84 B, UTF-16LE | same | yes |
Set-Content -Encoding ASCII |
41 B, no BOM | 41 B, no BOM | same | yes |
Set-Content -Encoding OEM |
41 B, no BOM | 41 B, no BOM | same | yes |
WriteAllText + UTF8Encoding($false) |
39 B, no BOM, cr0 lf3 | 39 B, no BOM, cr0 lf3 | same | yes |
HD-004's central claim - "two hosts both told to be explicit still produce files that
differ by exactly those three bytes" - is confirmed: Set-Content -Encoding UTF8 gives
44 B on 5.1 and 41 B on 7.6, first3 = EF BB BF vs 7B 0A 20. And the canonical writer
is the only sink in the table that is byte-identical across runtimes, as claimed.
I added three rows you do not have:
| Sink | 5.1 | 7.6 | note |
|---|---|---|---|
Add-Content (no enc) |
41 B, no BOM, cr1 lf4 | 41 B, no BOM, cr1 lf4 | same as Set-Content; HD-009 names it, the table does not measure it |
WriteAllText + UTF8Encoding($true) |
42 B, EF BB BF |
42 B, EF BB BF |
the only way to get a marker out of WriteAllText |
WriteAllText + New-Object System.Text.UTF8Encoding |
39 B, no BOM | 39 B, no BOM | see Finding 1 |
Section 2 here-string grammar table - 16 of 16 rows reproduce
Parse error counts under Parser::ParseFile, both engines, identical counts on each:
| Construct | 5.1 | 7.6 | doc |
|---|---|---|---|
@' / body / '@ col 1 |
0 | 0 | 0 |
@' + one trailing space |
0 | 0 | 0 (legal) |
'@ then pipe into Set-Content out.json |
0 | 0 | 0 |
'@ + 'tail' |
0 | 0 | 0 |
$x = @' on header line |
0 | 0 | 0 |
@' # note |
2 | 2 | 1-2 |
@'; $x = 1 |
2 | 2 | 2 |
'@ indented 4 spaces |
1 | 1 | 1 |
'@ indented 1 space |
1 | 1 | 1 |
'@ indented with a tab |
1 | 1 | 1 |
@''@ one line |
1 | 1 | 1 |
@' no close |
1 | 1 | 1 |
'@ no open |
1 | 1 | 1 |
| CRLF body + CRLF close | 0 | 0 | 0 |
| CRLF body + LF close | 0 | 0 | 0 |
here-string piped into ForEach-Object |
0 | 0 | (HD-009 territory) |
The two claims I had expected to break and did not: trailing whitespace on the open
line really is legal (so your HD-002 rewrite away from the popular formulation is
correct), and the parser really does drop the @ in The string is missing the terminator: '. - searching the transcript for '@ will not match it.
Section 5.2 transport layer, through Git Bash 5.3.15 (MINGW64)
HD-006 - unquoted delimiter, written file read back with head -3:
quoted > $path = 'C:\temp\out'
$count = ($path.ToCharArray() | Where-Object { $_ -eq [char]92 }).Count
unquoted > = 'C:\temp\out'
= (.ToCharArray() | Where-Object { cat -eq [char]92 }).Count
Write exits 0, the defect surfaces as a ParserError in the child (expected an expression after '(') - exactly the described failure mode. One extra detail: $_
became cat, because bash expanded $_ to the last argument of the previous command.
Your doc says "$path, $_ and $bs all expanded to empty"; for $_ it expands to
something else, not empty, which is a slightly more confusing signature.
HD-005 - three-way, each in its own script file so the outer quoting is the only
variable:
pwsh -NoProfile -Command "cmd /c exit 0; Write-Output \"status=$LASTEXITCODE\"" > status=
pwsh -NoProfile -Command "cmd /c exit 0; Write-Output \"status=\$LASTEXITCODE\"" > status=0
pwsh -NoProfile -Command 'cmd /c exit 0; Write-Output "status=$LASTEXITCODE"' > status=0
pwsh -NoProfile -Command 'Write-Output "status=$LASTEXITCODE"' > status= (correct!)
The last row is your control case and it holds: the eaten variable and the genuinely
empty one print the identical string. This is the strongest confirmation in the set,
because it means HD-005 is not detectable from output - only from the source.
HD-007 - python - <<'PY' with sys.stdin.read() gives payload_len=0; payload
via -c and heredoc on stdin gives stdin_len=18. Confirmed.
Findings
Finding 1 - section 7: "the default constructor emits the marker" is false; [Text.Encoding]::UTF8 is the one that does
knowledge/here-strings-and-heredocs.md, section 7, on the canonical writer:
"
UTF8Encoding($false)is the important argument; the default constructor emits the
marker."
Measured, both engines, GetPreamble().Length and actual bytes:
| encoding | 5.1 preamble | 7.6 preamble | bytes written (payload caf + U+00E9) |
|---|---|---|---|
New-Object System.Text.UTF8Encoding |
0 | 0 | 5 B, 63 61 66 C3 A9 - no marker |
New-Object System.Text.UTF8Encoding($false) |
0 | 0 | 5 B, no marker |
New-Object System.Text.UTF8Encoding($true) |
3 | 3 | 8 B, EF BB BF 63 61 66 C3 A9 |
[System.Text.Encoding]::UTF8 |
3 | 3 | 8 B, EF BB BF 63 61 66 C3 A9 |
The no-argument constructor is UTF8Encoding(false) and emits nothing. The object that
carries the marker is the static [System.Text.Encoding]::UTF8 property - which is the
idiomatic thing a model reaches for, and is not what your sentence names.
Reproduction (pure ASCII source, so 5.1's no-BOM source decoding cannot interfere):
$p = 'C:\temp\x.bin'
[IO.File]::WriteAllText($p, 'abc', (New-Object System.Text.UTF8Encoding)) # 3 bytes
[IO.File]::WriteAllText($p, 'abc', [System.Text.Encoding]::UTF8) # 6 bytes, EF BB BF
(Get-Item $p).Length # -> 3 then 6Why this matters beyond the wording. The advice itself ($false is fine, use
WriteAllText) is correct and I am not asking you to change any recommendation. But
the stated reason is wrong in the direction that matters: a reader who internalizes
"the default ctor emits a BOM" will reach for [Text.Encoding]::UTF8 to "be explicit",
and that change does introduce the marker HD-004 exists to prevent - reintroducing
the exact three-byte divergence while feeling like it fixed something. The fix is one
clause: name [Text.Encoding]::UTF8 as the marker carrier, not the parameterless
constructor.
Finding 2 - section 5.1 row 1: "ANSI codepage" is load-bearing on a non-US host, and the table cannot show it
Section 5.1 row 1 reads Set-Content (no -Encoding) -> 41 B, no BOM (ANSI codepage)
on 5.1. True for an ASCII payload. The parenthetical is doing more work than it looks
like, because that row is the only one in the table whose byte count is
payload-dependent:
sink, payload caf + U+00E9 |
5.1 (ansi=gb2312) |
7.6 (ansi=utf-8) |
|---|---|---|
Set-Content (no enc) |
7 B, 63 61 66 **A8 A6** 0D 0A |
7 B, 63 61 66 **C3 A9** 0D 0A |
Set-Content -Encoding UTF8 |
10 B, EF BB BF 63 61 66 C3 A9 0D 0A |
7 B, 63 61 66 C3 A9 0D 0A |
Add-Content (no enc) |
7 B, 63 61 66 A8 A6 0D 0A |
7 B, 63 61 66 C3 A9 0D 0A |
A8 A6 is e-acute in GBK. Same byte count, same "no BOM", silently different
codepage - and on 5.1 the round trip through the default sink is lossy for any character
outside the host's ANSI page. Meanwhile the identical command on 7.6 is UTF-8
regardless of host locale. So the default sink is not merely encoding-divergent between
runtimes, it is locale-divergent within 5.1, and the table's flat "41 B" cannot show
that because the measured payload is pure ASCII.
This also means section 5.1's headline framing - COMP-009 "is correct that an explicit
-Encoding is required" - is right for a stronger reason than stated on non-US hosts:
-Encoding UTF8 is the only row that is locale-independent on 5.1, while the
no-encoding row is not, and no row in the table records it.
Suggested: either add a non-ASCII row to the section 5.1 table, or footnote row 1 with
the measured A8 A6 and a note that the no-encoding default follows the host ANSI
codepage. The sink is already covered by HD-009, so this is a documentation precision
fix, not a new rule.
Finding 3 - HD-002's detector fires on prose that merely mentions the delimiter, which is why examples/good/12 is not clean
examples/good/12-heredoc-child-script-runner.ps1 is the compliant twin of
examples/bad/12. HD-002's detection.pattern is (?m)@['\"][ \t]*[^\s\r\n], and it
matches line 23 of the good file:
23: # HD-002: the header line carries nothing but whitespace after @'. A comment on
That line is a comment describing the rule. @' followed by . satisfies
[^\s\r\n], so the detector fires on the documentation of the construct rather than the
construct. Reproduced on both engines, and minimized to a two-line fixture:
# HD-002: the header line carries nothing but whitespace after @'.
$s = @'
body
'@HD-002 fires. The script is fully compliant and parses clean on both engines.
Sweeping the HD patterns over the shipped corpus:
examples/good/* > 12 files, 11 clean, 12-heredoc-child-script-runner.ps1 -> HD-002
examples/bad/* > 12 files, 11 clean, 11-heredoc-manifest-writer.ps1 -> HD-009
Why the self-test does not catch this. tools/rule-selftest.py tests each pattern
only against its own bad/good strings inside reliability-rules.yaml, and the
cold sweep only asks whether a pattern fires anywhere. The YAML snippet for HD-002's
good side is:
# json payload
@'
{
"name": "svc-01"
}
'@
Its match/no-match verdict is correct, so L1 reports 22 precise / 15 contextual / 0 fail and stays green. The YAML snippets are comment-free, so the whole failure mode is
invisible to the gate by construction. This is the same class of hole you already
documented for yourself in the Windows CI discussion: a test that cannot fail is not a
test. Here the gap is within the L1 design rather than in fixture construction - the
corpus in examples/ is never scanned by its own detectors.
Two independent notes on the same sweep, since I have the output:
bad/12fires no HD pattern at all, which is correct - it is aSEC-001
Invoke-Expressionsample and its banner lists HD-001/HD-002 as "rules demonstrated",
but it contains neither construct. The banner is validated byvalidate.pyfor
reference resolvability only, never for the rule actually being present, so this is
by design and only worth a thought if banners are ever meant to be load-bearing.- HD-003 and HD-006 both fire on a single unquoted
cat > s.ps1 <<PSEOFline, and
HD-003 also fires on the quoted form<<'PSEOF'. HD-003's pattern<<[~]?has no
quote awareness, so a correctly quoted POSIX heredoc still trips the bash-collision
rule. Low severity (an agent writing<<at all is already suspect) but it means
HD-003's "precise" label in the L1 output overstates its reach.
Suggested direction, cheapest first - none of these need a Windows host:
- Anchor HD-002 so a comment cannot trip it. A negative lookbehind for
#on the same
line is not sufficient in general (the@'can be many characters into the line), so
the robust form is a line-level filter: drop comment-only lines before matching, or
require the@'to be at a statement position. This is exactly the "context gate"
your self-test already calls contextual for the IDEM family - HD-002 currently
behaves contextually while being labelled precise. - Extend
rule-selftest.pywith a corpus-direction assertion: for every pattern, report
files inexamples/good/that it fires on. A non-empty list should be a WARN (or a
FAIL for the patterns whose own rule claims precision). This closes the hole
generally instead of one rule at a time, and costs no new machine.
Neither change alters any rule's recommendation or any bad/good snippet - the defect is
in the detector's precision claim and in the gate's coverage, not in the guidance.
Noise / caveats, explicitly ignorable
- PSSA not verified.
PSScriptAnalyzeris not installed on this host, so the
"examples/good-> 0 Error-severity findings" claim is untested here. Not a finding. - Engine patch versions differ from yours (5.1.26100.9549 vs
.8115, 7.6.6 vs
7.6.5). Every row matched regardless; noting it only so nobody reads a mismatch later
as a regression. - ANSI codepage is
gb2312. I mention it only because Finding 2 is an artifact of
it. All other rows are codepage-independent and reproduced as documented. - First measurement of mine was wrong, and I withdrew it. My initial encoding probe
reported a "0-byte write" and a "missing case row" on 5.1. Both were my own harness
bugs: a 5.1 no-BOM source file decoded my non-ASCII string literal throughgb2312,
turning a two-character CJK word into three separate code points, and one
Join-Stringcall in the probe is PS7-only. Re-measured with a pure-ASCII source and
subprocesswith explicitencoding=, and every number above is from the corrected
run. Flagging it because the rule I work from says the first suspect when a
reproduction disagrees is my input, not the target's environment - that was the
correct call here. - Transport measurements are Git Bash only (5.3.15, MINGW64). I did not test
cmd.exe or PowerShell-native heredoc alternatives. An earlier attempt at HD-005
through Pythonsubprocess(shell=True)was silently routed throughcmd.exeon
Windows and produced a misleadingstatus=0; I discarded it and redid the case in
real bash. Worth knowing if anyone reproduces: on Windows,shell=Trueis not a bash
substitute. - This issue was posted once with a mangled body (a handful of non-ASCII characters
were corrupted by the local encoding before upload) and edited in place. Sorry for the
noise. If you read the revision history, the first version had mojibake in the tables.
Suggested next step for the repo
The windows-latest lane in docs/testing-plan.md section 3 is now redundant for the
HD byte claims - they are measured and reproducible, and this issue carries the
commands. What that lane would still buy is the PSSA-under-5.1 check you already scoped
(PSSA <= 1.21), which needs no hardware this issue does not have.
Activity
[-]Real-Windows verification of the HD axis (HD-001..HD-009): encoding table reproduces 7/7; §7 ctor claim is wrong, HD-002 detector fires on prose[/-][+]Real-Windows verification of the HD axis (HD-001..HD-009): encoding table reproduces 7/7; section 7 ctor claim is wrong, HD-002 detector fires on prose[/+]mokuyoaxis commentedon Nov 6, 2026
Thank you for this run. It is the real-Windows lane
docs/testing-plan.md§3 was waiting on, and the write-up made every finding easy to check. Reporting the blob hashes and withdrawing your own first measurement were both useful.The headline result (all 7 rows of the §5.1 encoding table and all 16 rows of the §2 grammar table reproducing on 5.1.26100.9549 and 7.6.6) is now recorded in
docs/testing-plan.md§3 as a reproduction of the HD-004 / HD-009 byte conclusions on a real Windows host. Since you filed this, CI has also gained awindows-latestjob that runs L0, L1, the L3 parse gate and PSScriptAnalyzer 1.21.0 under Windows PowerShell 5.1, which covers the PSSA-under-5.1 check you pointed out was still open.How each finding is handled:
Finding 1 - §7 default constructor. You are right: the parameterless
UTF8Encodingwrites no marker. §7 now says so, names[System.Text.Encoding]::UTF8andUTF8Encoding($true)as the objects that carry the three-byte preamble, and still recommends passing$falseexplicitly so the intent is clear. It also warns against "making it explicit" by switching to[System.Text.Encoding]::UTF8, the failure you described.Finding 2 - ANSI codepage in §5.1. The
Set-Contentrow now carries a footnote. Without-Encoding,Set-ContentandAdd-Contenton 5.1 encode through the host ANSI codepage, so non-ASCII content changes with the locale. Yourgb2312measurement (caf+ U+00E9 ->63 61 66 A8 A6 0D 0Aon 5.1 vs63 61 66 C3 A9 0D 0Aon 7.6, and 10 B withEF BB BFfor-Encoding UTF8on 5.1) is the worked example. YourAdd-ContentandUTF8Encoding($true)rows are added to the table, attributed to this issue.Finding 3 - HD-002 false positive. The HD-002 pattern now skips comment-only lines, so line 23 of
examples/good/12no longer fires. A real violation such as@' # notestill matches.tools/rule-selftest.pygained regression cases for both directions plus an assertion that HD-002 stays silent onexamples/good/12, so this exact miss fails L1 if it comes back. One known limit remains: an end-of-line comment that mentions@'after code on the same line can still be flagged. That limit, your broader suggestion to run every detection pattern againstexamples/good/in the self-test, and the HD-003 quote-awareness note are now recorded indocs/rule-backlog.mdas B-7, B-8 and B-9, citing this issue.The
$_->catdetail in the HD-006 transport row and thebad/12banner observation are noted too.Thanks again for the careful work.