Skip to content

Portability: 74 path, encoding and git-output defects fixed across the guards and gates; portability suite as gate 11 - #173

Merged
pedrohcgs merged 24 commits into
mainfrom
fix/portability-hardening
Nov 4, 2026
Merged

pedrohcgs merged 24 commits into
mainfrom
fix/portability-hardening

Conversation

@pedrohcgs

Copy link
Copy Markdown
Owner

Summary

Changed defaults

  • .gitattributes: * text=auto eol=lf, with data/** -text so raw data keeps its bytes. An existing clone checked out with core.autocrlf=true needs a one-time renormalise (TROUBLESHOOTING).
  • issue-guard is also registered for Bash(gh.exe *).
  • Hygiene flags tracked paths that differ only by case or Unicode form. The quality gate now fails on a staged file it cannot find.
  • Cost: gate 11 adds about 20 s to a pre-commit run with no hook staged, and about a minute to a direct backtest or CI.

Not in this PR

  • git-guardrails misses destructive git inside ( … ), $( … ) and backticks #172 (git-guardrails misses git inside ( … ) / $( … ) / backticks, pre-existing): a flat-separator fix and a nested-substitution rework were both tried and backed out after review. The first cut comments short and split arguments; the second opened new bypasses. It stays open and is disclosed in the docstring and guide.
  • The Windows Python launcher (3 of the 78 findings) is deferred. The PowerShell tool is documented, not blocked (owner decision).

Type

  • Bug fix (fix/...)
  • New feature: skill / agent / rule / hook (feat/...)
  • Docs / guide / README (docs/...)
  • Chore / cleanup (chore/...)

Test plan

  • ./scripts/backtest.sh exits 0: 11 gates, hook battery 312 cases, portability suite 233 cases
  • PYTHONIOENCODING=cp932 ./scripts/backtest.sh: no crashes
  • Each fix group's cases shown to fail on the pre-fix code (counts in quality_reports/qualification/LEDGER.md); independent verifiers per group; a five-lens review of the whole diff (23 confirmed findings, all fixed or backed out)
  • Guide re-rendered and stamped; quality_score.py 100/100
  • ./scripts/install-hooks.sh not installed on this machine; the backtest was run by hand

Notes for reviewer

  • The largest diffs are .claude/hooks/root-of-trust-guard.py (scope check by identity and case folding) and the new tests/portability/.
  • Issues are referenced, not auto-closed.

pedrohcgs and others added 23 commits September 27, 2026 14:07
tests/portability/ holds cases that differ on other machines; _winsim.py loads a
real module with ntpath swapped in, so Windows path behaviour is testable on Linux
CI. The suite is registered in backtest.sh; count claims updated; ledger lists it
as not yet qualified until its cases are shown to fail on the pre-fix code.
Refs #171.
… rubric file, and checkouts stay LF

check-repo-hygiene.py reads `git ls-files -z` decoded as UTF-8, so an accented
name is no longer a phantom `"scripts/` directory and an accented draft name is
reported under its own name; it joins git paths with posixpath, flags tracked
paths that collide after NFC + casefold, and matches an archive README
case-blind. check-ledger-coverage.py compares relpaths with '/' (PR #152's two
hunks) and reads `ls-files -z` as UTF-8. The pre-commit reads its staged lists
NUL-delimited, scores renamed and type-changed files (ACMRT), and fails loudly on
a staged file missing from the working tree. quality_score.py dispatches on the
suffix case-blind, exits 1 on a file it cannot score, and passes the path to R as
an argument instead of splicing it into R source. The ledger gate and the scorer
print UTF-8, so an em dash on a cp932 pipe no longer decides their exit code.
.gitattributes forces LF in every checkout (`* text=auto eol=lf`, plus PR #152's
shell rules); the index is already all LF, so nothing is renormalised.

Findings fixed: ledger-coverage-relpath-backslash, ledger-coverage-relpath,
ledger-coverage-ls-files-no-z, hygiene-ls-files-no-z,
hygiene-lsfiles-quoted-nonascii, ls-files-locale-decode,
hygiene-numbered-duplicate-ospath-join, hygiene-misses-case-colliding-paths,
hygiene-archive-readme-case-divergence, precommit-rename-skips-quality-gate,
precommit-quoted-names-skip-quality-and-battery,
precommit-quality-gate-skips-non-ascii-names, precommit-quoted-staged-names,
quality-score-case-sensitive-suffix, quality-score-rscript-backslash-path,
no-gitattributes-crlf-shell, crlf-shell-scripts-break-non-msys-bash,
gitattributes-partial-autocrlf, and gate-stdout-emdash-codepage for the two gates
in this group (check-spec-conformance.py, file-issue.py, validate-findings.py and
the battery's helpers still print em dashes to the console encoding).

tests/portability/test_gitout.py: 29 cases. Against 08a7641, 26 fail and the 3
controls pass. All 29 pass now.

Refs #171
Refs #151

Co-authored-by: JonOM123 <54511210+JonOM123@users.noreply.github.com>
… spellings of a gate path as it denies the literal one

Every spelling below named .claude/hooks, .claude/settings*.json or
.githooks to the shell that runs it, and the guard ALLOWED it silently
while the literal twin denied. Now:

- a backslash is also a separator: the token is tested a second time
  with `\` read as `/` (a second form, so no standing deny is lost); the
  `_REDIR` backstop unquotes its target first, as bash does
- Git Bash drive paths (/c/..., /cygdrive/c/...) are read as the drive
  they name, in the scope half, the -C composition, the event cwd and
  CLAUDE_PROJECT_DIR
- -C values compose with "/" (PR #152's join), so `git -C .claude -C
  hooks rm ...` is .claude/hooks on Windows too
- program names go through one `_prog()`: basename, casefold, `.exe`
  dropped (rm.exe, GIT.EXE, bash.exe -c, and the r19 NICE/BASH residual)
- a CR is deleted before anything reads the command (MSYS bash does)
- the event is read as bytes and decoded UTF-8 with "replace"
  (utf-8-sig), not with the ANSI code page
- names compare with casefold(), not lower() (APFS folds U+017F to s)
- in_project compares NFC-casefolded forms, accepts the path as named or
  as resolved (a symlinked .claude/.claude/hooks/.githooks carrying the
  protected name is in scope), treats a root that ends in a separator as
  its own prefix (project at /, D:\, a share root), and falls back to
  file identity per ancestor (firmlinks, bind mounts, an aliased
  CLAUDE_PROJECT_DIR)

The docstring's residual list is updated: the case-varied wrapper
residual is closed; the PowerShell tool, drive-relative C:.claude, Win32
name aliasing (8.3 names, trailing dots) and NTFS's upcase table are
disclosed as unprobed.

Pinned by tests/portability/test_rot.py: 79 cases (native subprocess,
ntpath simulation, and disk-independent scope units). On 08a7641 62 fail
and the 17 that pass are all controls; all 79 pass now. A mutation run
reverting each repair alone fails at least one case per repair.
hook-battery 312/312.

Fixed: rot-dash-c-ospath-join, rot-backslash-separators,
rot-msys-drive-paths-out-of-scope, guard-stdin-codepage-fail-open,
guard-stdin-mojibake-scope-bypass, exe-suffix-bypasses-guards,
carriage-return-in-word-bypasses-guards,
msys-path-spellings-bypass-root-of-trust,
hook-stdin-ansi-codepage-fail-open, rot-unicode-casefold,
rot-scope-string-compare, rot-symlinked-claude-dir,
rot-windows-backslash-and-msys-paths, rot-project-at-filesystem-root,
rotg-backslash-token-bypass, rotg-gitbash-drive-path-bypass,
rotg-scope-case-sensitive-apfs, rotg-a54-compose-dash-C
(root-of-trust-guard.py parts only; the git-guardrails / issue-guard /
settings.json parts of the shared findings belong to other groups)

Refs #171
Refs #151

Co-authored-by: JonOM123 <54511210+JonOM123@users.noreply.github.com>
… paths and code pages

The guards now name the program by its basename on either separator with
any `.exe` dropped, so `git.exe reset --hard`, a quoted
'C:\Program Files\Git\cmd\git.exe' and `gh.exe issue create` are denied like
their bare twins; a carriage return inside a word is dropped the way Git
Bash drops it; issue-guard is registered a second time for `Bash(gh.exe *)`,
keeping the `Bash(gh *)` filter. Every python hook reads stdin as bytes
decoded as UTF-8 and does its file and subprocess I/O in UTF-8, so a Windows
code page no longer turns an accented path into mojibake or a CJK event into
unparseable JSON. claim-reconcile watches backslash paths, compares
project-relative paths as POSIX (PR #152's as_posix, applied to the current
file) and case- and NFC-insensitively. The hardcoded-path check catches
C:\Users\... with either slash in any case and .rmd / .DO extensions,
without a global (?i). log-reminder logs readable names; statusline, notify
and the -C Git Bash drive spelling work on Windows.

Findings fixed:
- guards-miss-dot-exe-program-names (git-guardrails, issue-guard, settings.json;
  the root-of-trust-guard sites are outside this change)
- claim-reconcile-watch-regex-slash-only
- claim-reconcile-watch-backslash (git-output and pr152-audit reports)
- claim-reconcile-relpath-backslash
- claim-reconcile-case-variant-paths
- git-guardrails-msys-dash-c-false-deny
- hardcoded-path-misses-single-backslash
- hardcoded-path-check-misses-windows-and-case
- log-reminder-quoted-and-cp1252
- nonguard-hooks-stdin-silent
- compaction-plan-read-locale
- statusline-stdin-locale
- statusline-ctx-hash-mismatch-cp1252
- statusline-plan-badge-from-subdir
- notify-sh-no-notification-on-windows

Pinned by tests/portability/test_hooks.py: 43 cases, 36 fail on 08a7641
and all 43 pass here (the other 7 are controls).

Refs #171

Co-authored-by: JonOM123 <54511210+JonOM123@users.noreply.github.com>
…hold on Windows paths and code pages

check-links and check-skill-integrity resolve a target against the tracked
files (git ls-files -z) in exact case, decode %20, and read <a b.md> and
titled destinations; only tracked markdown is scanned. check-staleness
matches its skip list against '/' paths, and it and stamp-render.sh hash
content with CRLF folded to LF, identically on both sides. A BOM before
frontmatter no longer hides it. validate-findings, file-issue and slide-qa
read, write and decode UTF-8 explicitly, and none of the touched gates can
be crashed by a cp932 pipe. validate-setup asks its own repository and
compares the folder core.hooksPath names; it checks +x only on the .sh hook
settings.json runs directly. sync_to_docs quotes the lecture name. The hook
battery cds to its repository and drops an inherited CLAUDE_PROJECT_DIR,
rewrites event paths to the native spelling under Git Bash with the sed
pattern and replacement escaped (PR #152's rewrite), sets nativestrict on the
two ln -s lines only, and reports c57-c64 UNREACHABLE on Windows while
Linux and macOS still run them (312 cases either way).

tests/portability/test_gates.py: 34 cases; 30 fail on 08a7641, all pass now
(the 4 others are controls).

Fixed: staleness-skip-regex-slash-only, staleness-skip-regex-vs-relpath,
staleness-skip-backslash, staleness-crlf-hash,
staleness-false-fail-on-autocrlf, links-percent-encoding-and-angle-brackets,
gates-read-disk-not-index (check-links and skill-integrity; the hygiene half
is another file's), links-gate-case-insensitive-exists,
bom-frontmatter-misreport, finding-id-separator-sensitive,
validate-findings-locale-read, file-issue-gh-decode, slide-qa-arrow-print,
validate-setup-hookspath-string-equality, validate-setup-reads-cwd-repo,
validate-setup-false-exec-bit-warning, sync-to-docs-unquoted-arg,
battery-depends-on-invoker-cwd, battery-symlink-cases-unreachable-on-windows,
battery-ev-sed-metachar; and the parts of gate-stdout-emdash-codepage in
these files (spec-conformance, the battery's ctx/h5 helpers).

Refs #171

Co-authored-by: JonOM123 <54511210+JonOM123@users.noreply.github.com>
… no rubric, and a dangling symlink is named as one

Fix-up of 82a4590 after independent verification:

- tests/portability/test_gitout.py: the two cp1252-locale cases patch
  locale.getencoding, which exists only from Python 3.11. On 3.9/3.10 (3.9.6 is
  macOS's /usr/bin/python3; README says 3.9+) both ERRORed, the portability gate
  went red and the installed pre-commit blocked every commit. They now skip below
  3.11, where subprocess does not decode through that function and the case would
  pin nothing; CI on 3.12 keeps the pin.
- scripts/quality_score.py: a file type with no rubric (.md, .py, ...) is skipped
  again, not exit 1. The case-blind suffix dispatch stays. The PR checklist's
  `quality_score.py <changed-files>` exited 1 whenever a non-rubric file changed;
  the pre-commit never passes such a file, so it gained nothing from the exit 1.
- .githooks/pre-commit: a staged symlink named *.R/.qmd/.tex whose target is
  missing is reported as a symlink, not as "missing from the working tree". It
  still blocks, as before this fix-up.

Tests: test_gitout.py now 30 cases (one replaced, one added). Against 82a4590's
quality_score.py and pre-commit, exactly the two new cases fail; all 30 pass now.
The suite under /usr/bin/python3 3.9.6: 82a4590 gave "2 of 31 cases FAILED";
now "ALL PASS (32 cases)", 2 skipped.

Not changed here: no qualification-ledger row yet for the new checker
behaviour (LEDGER.md is outside this group's files), and the staleness gate's
STALE-RENDER on the guide, which dates from 08a7641.

Refs #171
…t is one, and catches the ::$DATA and /proc/cygdrive spellings there

Fix-up of 4f05d34 after an independent verification of it. Now:

- the backslash form in matches_root_of_trust is added only where `\`
  really is a separator: native Windows Python (os.sep) or the
  Cygwin/MSYS runtime's own Python. Unconditional, it denied the
  ordinary escaped in-place script on macOS and Linux, which the
  in-place branch tests as an operand. Measured natively, each of
    sed -i '' 's/\.githooks/githooks/' docs/x.md
    sed -i '' 's/scripts\/hooks/.claude\/hooks/g' README.md
    sed -i 's/\.claude\/hooks\/old\.py/.claude\/hooks\/new.py/g' docs/guide.md
    perl -pi -e 's/old\/dir/.claude\/settings.json/g' README.md
    sed -i 's/foo/.claude\/hooks/' notes.txt
  was ALLOW at 08a7641, DENY at 4f05d34, and is ALLOW again. The
  docstring that called the POSIX cost "a quoted name that really
  contains a backslash" is corrected.
- on those platforms the Windows form also drops an NTFS default-stream
  suffix from the last segment (`::$DATA`, `::$INDEX_ALLOCATION`,
  `:$I30:$INDEX_ALLOCATION`), so `echo x > '.claude\settings.json::$DATA'`
  and `rm -rf '.claude\hooks:$I30:$INDEX_ALLOCATION'` deny under the
  ntpath simulation instead of allowing silently. Not run on real
  Windows; a stream suffix mid-path joins the unprobed residual list.
- a Git Bash drive path spelled through `/proc/cygdrive/c/...` is read
  as the drive it names, like `/c/...` and `/cygdrive/c/...`.
- the PowerShell residual bullet no longer claims the permission layer
  covers that tool: settings.json has no rule for it and an empty deny
  list, so nothing in the repository guards it, and bypass and auto
  mode do not show the prompt that default mode would.

tests/portability/test_rot.py: 100 cases (was 79). The native bs_*
deny cases moved to WindowsTest (win_bs_mixed, win_bs_git_dash_c,
win_bs_githooks_mv); natively they now pin that a backslash is part of
a name, with the five sed/perl forms above and two controls. On 4f05d34
16 fail, every one a case added here; on 08a7641 69 fail; all 100 pass
now. Reverting each repair alone fails at least one case (unconditional
backslash 10, MSYS/Cygwin Python 1, stream suffix 7, /proc/cygdrive 2).
hook-battery 312/312, portability 102/102; backtest fails only on the
CHANGELOG "10 gates" inventory count.

Refs #171
…names the gh spellings its filter never reaches

Fix-up of 9d90fc3 after an independent verification of it. Now:

- the hardcoded-path check's drive branch refuses a drive letter glued to
  a letter or digit. At 9d90fc3 a scheme's last letter read as a drive:
  `gs://users/x.csv`, `https://users/x`, `hdfs://users/x` and
  `{"key:/users/me": 1}` in a .py file warned, and denied under
  CLAUDE_STRICT_PATHS=1; 08a7641 never matched them. Every intended
  spelling still matches (a quote, space, `(`, `=`, `\\?\` or `file:///`
  before the drive).
- issue-guard's docstring lists `GH`, `GH.EXE` and a quoted
  'C:\Program Files\GitHub CLI\gh.exe' beside `/path/to/gh` as residuals.
  Claude Code 2.1.283 matches `Bash(gh *)` as a case-sensitive
  /^gh( .*)?$/ on the joined argv, so the hook is never spawned for them,
  though each runs gh on Windows (and `GH` on a case-insensitive macOS
  disk). The hook-level denies for them stay, marked as reachable only
  where the filter is ignored (Claude Code < 2.1.85).
- two incident notes say what was measured: at 08a7641 `C:/Users/me`
  matched through the `/Users/` branch and `c:/users/me` did not; an
  undecodable `git status` never refused a clean tree (it prints nothing)
  but denied a dirty one for the wrong reason and denied an allowed
  `--autostash` over tracked-only dirt.
- the test classes cite finding ids, not a gitignored plan's item numbers.

Also fixed by 9d90fc3 but missing from its list: the git-guardrails and
issue-guard halves of carriage-return-in-word-bypasses-guards,
guard-stdin-codepage-fail-open, guard-stdin-mojibake-scope-bypass and
hook-stdin-ansi-codepage-fail-open. Those findings were filed against
root-of-trust-guard.py, whose own halves are handled separately.

Not fixed here: it predates 9d90fc3 and is not a portability defect. A
`)` or backtick glued to a command's last word hides a destructive git
command on every platform. `(git reset --hard)`,
`(cd sub && git reset --hard)`, `(git push origin main --force)`,
`$(git reset --hard)` and echo `git reset --hard` in backticks are silent
on a dirty tree at 08a7641, 9d90fc3 and here. It needs its own issue and
battery cases.

tests/portability/test_hooks.py: 46 cases (was 43). On 9d90fc3 three
fail (test_filter_misses_are_disclosed, test_controls_stay_allowed, and
test_every_class_cites_a_finding_id against the old docstrings); on
08a7641 39 fail; all 46 pass here. test_autostash_over_tracked_dirt_is_allowed
reproduces the --autostash false deny with an ASCII locale (fails on
08a7641, passes since 9d90fc3). hook-battery 312/312, portability 48/48;
backtest fails only on the CHANGELOG "10 gates" inventory count.

Refs #171
…ong-case target fails; check-quotes reads the path the id names

A UTF-8 BOM in front of a file's first '# Heading' no longer hides that
heading's anchor: check-links (anchors_for and the scan read) and
check-skill-integrity (collect_anchors and the check-3 read) read with
utf-8-sig, as parse_frontmatter already stripped it.

The link gates keep judging by the index, and now give the reason. A target
that exists on disk but is untracked reads "not tracked by git (git add it; a
clone and GitHub do not have it)". One that differs only in letter case from a
tracked path names that path. check-links also names the untracked, unignored
markdown it left out of the scan (git ls-files --others --exclude-standard, so
a nested worktree counts as its own repository's). The verdict is unchanged.

validate-findings --check-quotes reads a finding's file (and the other paths its
evidence names) through the same canon_path the id uses. Slides\deck.tex and
Slides/sub/../deck.tex are checked on macOS and Linux instead of "file not
found".

The battery's (e) comment cites the total as TOTAL=$((PASS + FAIL + UNREACH)),
and says that c57-c64 on Windows are counted through UNREACH.

tests/portability/test_gates.py: 38 cases, 4 new and 4 adjusted to the new
reasons. 8 fail on the port/gates scripts (fcae2cc), and all 38 pass now.

Not changed here: the STALE-RENDER on guide/ and docs/ was already present at
08a7641 (the guide qmd changed without a re-render) and needs a render and
stamp outside this group. The cost of the two battery runs is unchanged:
measured on the same machine, one combined run took 34.4 s and the two
parallel runs 32.9 s. check-derived-counts.py still says PASS + FAIL, and the
overlap with PR #152's hook-battery.sh hunk is left for the owner.

Refs #171
… gates; Claude Code worktrees are gitignored

TROUBLESHOOTING: the guards are wired to the Bash tool; on Windows the PowerShell
tool is on by default (and the only shell tool without Git Bash), so commands run
through it pass no guard — how to keep shell work on the guarded route (owner:
document, do not deny). Refs #171.
…he portability work is recorded and qualified

- git-guardrails: `(`, `)` and backticks separate segments, so `(git reset --hard)`,
  `$(git reset --hard)`, backticks and a substitution-carried merge are denied (Refs #172).
  Battery c69–c75; the pre-fix hook fails exactly c69–c73. Guide and docstring updated:
  the substitution residual is closed.
- Cross-group leftovers from the #171 fix run: install-hooks.sh acts on its own repo, not
  the caller's cwd; /deploy quotes its argument; chmod advice covers only the shell hooks;
  finding-schema says ids read backslashes as '/'; a count comment cites PASS+FAIL+UNREACH;
  the guide names the Bash(gh.exe *) registration; repo-hygiene.md lists case/Unicode
  collisions.
- LEDGER: qualification rows for the four fix groups, #172 and the portability runner
  (moved out of "not yet qualified"); battery count 312 -> 319 everywhere.
- CHANGELOG v2.6.0: portability fixes and the eleventh gate (owner: still v2.6).
- Guide re-rendered and re-stamped (the stamp was stale since 08a7641).

Backtest exits 0: 11 gates, 319 battery cases, 216 portability cases. Refs #171, #151.
…e, and the records say 74 of 78 are fixed here

Review of the portability PR (Refs #171):
- .gitattributes: data/** -text, so a CRLF CSV under data/ is stored as deposited.
- TROUBLESHOOTING: one-time renormalise for a clone checked out with autocrlf=true.
- CHANGELOG: 74 of the 78 fixed here, 3 need the Windows launcher, PowerShell documented;
  timing stated per path (pre-commit ~20 s; direct backtest and CI ~1 min).
- README, CLAUDE.md, guide: the portability suite named as gate 11 everywhere; its cases
  are pins beside controls, not all pins.
… colliding paths, validate-setup reads its own identity

G3: check-links, check-staleness, check-spec-conformance and
check-derived-counts spliced the clone's own path into their glob
patterns unescaped, so a folder named "Paper [2026]" read as a character
class. glob.escape(ROOT) at all four sites (not root_dir=, which is
Python 3.10+). check-links and check-staleness now exit 2 when the scan
finds nothing, so an empty scan can no longer read as a pass.

G5: .githooks/pre-commit skips the stash --keep-index / pop cycle, with a
warning, when tracked paths differ only by case or Unicode form (NFC +
casefold over each path prefix, NUL-delimited: the hygiene gate's rule).
On a macOS or Windows disk that tree is dirty for good, the pop always
failed, and every commit left a "pre-commit-gate" stash holding the
user's unstaged work.

G8: validate-setup.sh asks git -C "$(dirname "$0")/.." for user.name and
user.email, as its hooksPath check already did.

Pinned in the new tests/portability/test_scripts.py (7 cases, each pin
fails on 6cbebe1): the four gates run from a working-tree clone under
"Paper [2026]", an empty scan exits 2, a gated commit on a case pair and
on an NFC/NFD pair leaves no stash, and the identity is read from outside
the clone and from another repository (plus an exported-copy control).

Refs #171
…and skips by name what a host cannot run

- G1, F1-portability-gate-git-index-leak: scripts/portability-tests.py drops the
  GIT_* variables it inherits (GIT_EXEC_PATH kept) before discovery. Under
  `commit -a`, `commit <paths>` and in a linked worktree the pre-commit hook
  exports an absolute GIT_INDEX_FILE (and GIT_DIR), and a gate run in-process
  through _winsim listed the user's index instead of its fixture's. Pinned by
  test_harness GateRunner.test_cases_do_not_read_the_callers_index (fails on
  the 6cbebe1 runner). In a throwaway repository committed through a copy of
  .githooks: 6cbebe1 `commit -a` aborted with "1 of 216 cases FAILED"; now
  `commit -a`, `commit -o <path>` and a linked-worktree commit all pass.
- G6: the verdict line counts skipped cases, "ALL PASS (N cases, K skipped)",
  with one "skipped K: <reason>" line per reason, and an unexpected success
  fails the gate. Pinned by two GateRunner cases, plus a control that a clean
  run prints the line it always did.
- F3-tests-vacuous-on-py39: the six test_hooks cases and the slide-qa case that
  rely on -X warn_default_encoding skip below Python 3.10; the file-issue case
  that patches locale.getencoding skips below 3.11 (test_gitout now shares the
  one definition in _winsim). FindingsEncoding still runs there: its cp1252
  pipe pins the stdout half. Against the 08a7641 code under /usr/bin/python3
  3.9.6, 7 of these 8 cases passed; now all 8 skip with a reason, and under
  3.12 they still fail on that code. HostMechanisms checks that wherever these
  skips let a case run, the mechanism bites.
- F4-gate11-unpassable-without-symlinks: _winsim probes os.symlink once.
  test_rot builds its link fixtures only where it can and skips the 7 cases
  that use them; the notify.sh fixture puts cat, tr and jq in as sh shims; the
  dangling-symlink pre-commit case skips on the probe. With os.symlink refused
  (WinError 1314): 6cbebe1 "6 of 116 cases FAILED" (setUpModule errored all of
  test_rot); now "ALL PASS (223 cases, 8 skipped)". Pinned by
  HostMechanisms.test_the_suite_runs_where_os_symlink_is_refused. The 8 NATIVE
  cases asserting POSIX backslash and stream rules also skip where the guard
  reads `\` as a separator (all 8 turn DENY under ntpath rules).
- F7: the runner's docstring says each case is a pin, a control or a harness
  check; test_gates and test_gitout no longer claim every case fails before.

Refs #171
…ty, and pops only a stash it made

G5 follow-up. The collision pre-check covered one cause of a tree that
`stash push --keep-index` cannot clean. Another is run.sh committed with CRLF
before `*.sh text eol=lf` (the template's own rule), and it happens on every
disk. The EXIT-trap pop failed, the commit went through anyway, and every commit
moved the user's unstaged work into another "pre-commit-gate" stash. The advised
`git stash pop` then refused as well.

Right after the push, the hook now checks `git diff`. If the tree is still
dirty it restores every path whose working copy differs from the stash's
(`git diff --name-only -z stash@{0}`), with
`git --literal-pathspecs restore --source=stash@{0} --worktree`, drops the
stash, and validates the working tree. It does not restore only the paths that
`git diff` no longer lists, as the verifier suggested: a still-dirty path the
user had also edited would lose that edit when the stash is dropped. The edited
subcase shows this on a copy of the hook built that way.

Also: `stash push` exits 0 without making a stash when nothing is staged
(`commit --amend`) and the only unstaged change is a submodule's dirty
content. The trap then popped the user's own newest stash into the tree. The
hook now counts a stash as its own only if refs/stash moved. The restore-and-drop
above needs that guarantee too.

Pinned in tests/portability/test_scripts.py by PreCommitDirtyForGood (plain
and edited-dirty-path subcases) and PreCommitMadeNoStash. Both fail on the
6cbebe1 and 679d5b2 hooks and pass here. HOOK now names the hook the cases copy.

Refs #171
…so they fail on the pre-fix gates in UTF-8 mode too

- F3 fix-up (verifier): under PYTHONUTF8=1, -X utf8 or Python 3.15's default
  (PEP 686), subprocess decodes text as UTF-8 without asking
  locale.getencoding. The two test_gitout cases that patched it in the test
  process (HygieneLocaleDecode, LedgerGitOutput ..._under_cp1252) then passed
  on the 08a7641 gates. The HostMechanisms probe held its child at
  PYTHONUTF8=0, a mode those cases never ran in, so it still passed.
  _winsim.in_code_page now runs the patch in a child held in locale mode. Both
  cases and the probe go through it, and needs_getencoding stays a 3.11 check,
  so the pins still run on 3.15. Pinned by
  HostMechanisms.test_the_code_page_cases_bite_in_utf8_mode. It runs the two
  cases under -X utf8 against a stand-in with the 08a7641 reader, and it fails
  on the fe746d2 test_gitout (both cases 'ok'). Against the 08a7641 gates, both
  cases now fail under 3.12 and 3.14, in locale mode, PYTHONUTF8=1 and -X utf8,
  and under a PEP 686 stand-in interpreter (children in UTF-8 mode unless
  PYTHONUTF8=0).
  Audit of the subprocess cases that drop PYTHONUTF8 without setting it to 0:
  the fe746d2 suite was run per case against 08a7641, each first-cut commit
  (4f05d34, 9d90fc3, 82a4590, fcae2cc) and 6cbebe1, in locale mode, -X utf8,
  the 3.15 stand-in and 3.12. Only these two cases changed outcome. The new
  suite on 08a7641 gives identical per-case outcomes in all four modes.
- F7 (the part in this group's files): the runner's docstring no longer says
  the qualification ledger gives the before/after counts, which it does not
  for test_harness.

Refs #171
…es, Python 3.9, no-symlink hosts; skips reported (Refs #171)
…losed residual

The flat-separator fix for #172 (6cbebe1) was reviewed and backed out: it cut comments
short, so git pull  # (daily sync) was refused on a clean tree, and split a command's own
arguments at a substitution, a new miss. A nested-substitution rework was tried and its
verifier found new bypasses, so it is not shipped. The docstring and guide now list the
subshell/substitution form as an open residual (Refs #172); battery cases c69–c75 removed.
LEDGER: #172 row removed, battery 312, hooks row 38/46 with 5 controls, grading cell
312/312. Backtest exits 0: 11 gates, 312 battery cases, 233 portability cases.
Copilot AI lite review requested due to automatic review settings September 27, 2026 22:27
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 27, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-09-27T22:32:49.473526Z 7884b8f PR opened
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 7884b8f046

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread scripts/check-links.py Outdated
return None
if r.returncode != 0:
return None
return {NFC(f) for f in r.stdout.decode("utf-8", "surrogateescape").split("\0") if f} or None

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve Unicode form when comparing tracked paths

When the index contains an NFD filename (possible on Linux or on macOS with core.precomposeunicode=false) and Markdown uses the visually equivalent NFC spelling, normalizing every ls-files entry collapses two byte-distinct paths. Consequently, an anchorless link such as [x](caf%C3%A9.md) passes resolves() when only cafe\u0301.md is tracked, even though GitHub and Linux treat the target as missing. Keep raw index spellings for resolution; normalization is suitable only for collision diagnostics.

Useful? React with 👍 / 👎.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🟡 Changes recommended

Moderate issues remain in pre-commit path handling and Quarto output decoding, with related documentation and test-harness cleanup needed.

Review effort: Lite
Findings: 1 Medium severity · 1 Low severity

Open (2)
What changed in this PR

This PR hardens repository hooks, gates, scripts, and documentation for Windows paths, Unicode, Git output, and line endings, adding portability testing as gate 11.

Changes:

  • Adds Windows-simulation portability tests and expands hook coverage.
  • Improves path, encoding, Git-output, and CRLF handling.
  • Updates documentation, configuration, changelog, and generated guides.
File Summary
TROUBLESHOOTING.md Documents PowerShell coverage and CRLF recovery.
tests/​portability/​test_scripts.py Adds portability regression tests.
tests/​portability/​test_harness.py Validates portability harness behavior.
tests/​portability/​_winsim.py Simulates Windows path behavior; contains a duplicate load implementation.
scripts/​validate-setup.sh Resolves repository-local configuration.
scripts/​validate-findings.py Canonicalizes paths and UTF-8 I/O.
scripts/​sync_to_docs.sh Safely handles lecture-name matching.
scripts/​stamp-render.sh Normalizes line endings for fingerprints.
scripts/​slide-qa.py Improves portable reporting and encoding.
scripts/​quality_score.py Improves extension and path handling; Quarto output still lacks explicit UTF-8 decoding.
scripts/​portability-tests.py Adds the portability gate runner.
scripts/​install-hooks.sh Installs hooks from the script repository.
scripts/​hook-battery.sh Improves cross-platform fixture execution.
scripts/​file-issue.py Decodes GitHub CLI output explicitly.
scripts/​check-staleness.py Fixes path, EOL, and output handling.
scripts/​check-spec-conformance.py Handles escaped roots, BOMs, and encoding.
scripts/​check-skill-integrity.py Validates tracked links and encoded paths.
scripts/​check-repo-hygiene.py Adds collision detection and NUL-safe Git reads.
scripts/​check-links.py Resolves tracked and encoded paths safely.
scripts/​check-ledger-coverage.py Improves Windows path and Git decoding.
scripts/​check-derived-counts.py Makes gate counts path-safe.
scripts/​backtest.sh Registers portability as gate 11.
README.md Documents the eleven-gate workflow.
guide/​workflow-guide.qmd Updates gate and hook documentation.
guide/​workflow-guide.html Regenerated guide output.
docs/​workflow-guide.html Regenerated published guide output.
docs/​index.html Updates the gate inventory.
CLAUDE.md Updates backtest documentation.
CHANGELOG.md Records portability changes; its #172 status needs correction.
.render-stamp Updates rendered-artifact fingerprints.
.gitignore Ignores Claude worktrees.
.github/​CONTRIBUTING.md Updates gate-count guidance.
.githooks/​pre-commit Improves staged-file and stash handling; path comparisons are not NUL-safe.
.gitattributes Enforces LF text checkout policy.
.claude/​skills/​deploy/​SKILL.md Quotes deployment arguments.
.claude/​skills/​commit/​SKILL.md Documents portability gate execution.
.claude/​settings.json Registers the Windows gh.exe matcher.
.claude/​scripts/​statusline.sh Improves UTF-8 parsing and root lookup.
.claude/​rules/​repo-hygiene.md Documents case and Unicode collisions.
.claude/​references/​finding-schema.json Documents canonical finding paths.
.claude/​hooks/​session-handoff.py Uses explicit UTF-8 state I/O.
.claude/​hooks/​pre-compact.py Uses explicit UTF-8 plan and state I/O.
.claude/​hooks/​post-compact-restore.py Uses explicit UTF-8 restoration I/O.
.claude/​hooks/​open-issues.py Improves UTF-8 event and CLI handling.
.claude/​hooks/​notify.sh Adds Windows notification fallback.
.claude/​hooks/​log-reminder.py Parses portable Git status output.
.claude/​hooks/​issue-guard.py Handles Windows executables and UTF-8 events.
.claude/​hooks/​git-guardrails.py Hardens Windows paths, executables, and encodings.
.claude/​hooks/​context-monitor.py Uses explicit UTF-8 cache I/O.
.claude/​hooks/​claim-reconcile.py Normalizes Windows and case-varied paths.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread .githooks/pre-commit Outdated
Comment on lines 42 to 44
_staged="$(git diff --cached --name-only -z 2>/dev/null | tr '\0' '\n' | sort)"
_unstaged="$(git diff --name-only -z 2>/dev/null | tr '\0' '\n' | sort)"
_overlap="$(comm -12 <(printf '%s\n' "$_staged") <(printf '%s\n' "$_unstaged") | grep -c . || true)"
Comment thread tests/portability/_winsim.py Outdated
Comment on lines +301 to +305
def load(path, name=None, argv=None, as_main=False):
"""Import the real module at `path` with os -> fake (ntpath)."""
path = _os.path.abspath(path)
name = name or ("__main__" if as_main else "mod_" + _os.path.basename(path).replace("-", "_").replace(".", "_"))
spec = importlib.util.spec_from_file_location(name, path)
…safe pre-commit arrays, dedupe _winsim.load
@pedrohcgs
pedrohcgs merged commit 7586127 into main Nov 4, 2026
2 checks passed
@pedrohcgs
pedrohcgs deleted the fix/portability-hardening branch November 4, 2026 20:00
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants