fix(plugin): an early-exiting head no longer voids its own output under pipefail (#4042)
CI & Build / Python lint (push) Successful in 2s
CI & Build / Plugin hooks (push) Successful in 13s
CI & Build / TypeScript typecheck (push) Successful in 53s
CI & Build / integration (push) Successful in 56s
CI & Build / Python tests (push) Successful in 1m31s
CI & Build / Build & push image (push) Successful in 14s

In a repo where 3,000 files define `slug`, scribe_local_dups printed
nothing; with 3 files it printed the duplicate line. Every hook runs under
`set -uo pipefail`, and `hits=$(git grep -l … | head -4) || hits=""` lost
head's four lines whenever git grep was still writing when head exited. That
is a SIGPIPE, the substitution fails, and the outer fallback wipes the result.
So the by-name duplicate arm went silent for exactly the most-duplicated
names. Found while fixing the same trap in the new Stop hook (#4041).

- The fallback moves inside the substitution, `$(… | head -N || true)`, at all
  five sites: scribe_defs.sh (local dups), scribe_prior_art.sh (names,
  old_first, shapes) and scribe_after_write.sh (names). A real upstream
  failure still yields empty output.
- check_plugin gains a known-bad pattern for the shape, so no hook can bring
  it back.
- Tests: the real function against a 3,000-file repo (past the pipe buffer),
  and the pattern shown to flag the old shape and pass the fix. Plugin
  version minted.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-14 18:54:49 -04:00
co-authored by Claude Opus 5
parent c7531d3700
commit 0fab08276c
6 changed files with 85 additions and 6 deletions
+59
View File
@@ -0,0 +1,59 @@
"""Under `pipefail`, an early-exiting `head` must not void the output it kept (#4042).
The hooks run with `set -uo pipefail`. `hits=$(git grep -l … | head -4) ||
hits=""` lost its four hits whenever git grep was still writing when head
exited: SIGPIPE, a failed substitution, and the fallback wiped the result. It
only showed on big outputs, so the by-name duplicate arm went silent for
exactly the most-duplicated names.
Two halves: the real function on a repo big enough to overflow the pipe, and
the check_plugin pattern that keeps the shape out of every hook (shown able to
fail, rule #167).
"""
from __future__ import annotations
import os
import shutil
import subprocess
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[1]
DEFS = ROOT / "plugin" / "hooks" / "scribe_defs.sh"
def test_the_duplicate_arm_still_names_a_name_defined_in_thousands_of_files(tmp_path):
for tool in ("git", "bash"):
if shutil.which(tool) is None:
pytest.skip(f"{tool!r} not installed")
env = {"PATH": os.environ["PATH"], "HOME": str(tmp_path),
"GIT_AUTHOR_NAME": "t", "GIT_AUTHOR_EMAIL": "t@x",
"GIT_COMMITTER_NAME": "t", "GIT_COMMITTER_EMAIL": "t@x"}
repo = tmp_path / "repo"
repo.mkdir()
subprocess.run(["git", "init", "-q"], cwd=repo, check=True, env=env)
# ~3000 × ~60 bytes of `git grep -l` output: well past a 64 KiB pipe buffer,
# so git grep is still writing when head has its four lines.
for i in range(3000):
(repo / f"module_with_a_fairly_long_descriptive_name_{i}.py").write_text("def slug(t):\n return t\n")
subprocess.run(["git", "add", "."], cwd=repo, check=True, env=env)
subprocess.run(["git", "commit", "-q", "-m", "base"], cwd=repo, check=True, env=env)
script = f'set -uo pipefail\n. "{DEFS}"\nprintf "sym\\tslug\\n" | scribe_local_dups "{repo}" new.py\n'
out = subprocess.run(["bash", "-c", script], capture_output=True, text=True, env=env, timeout=60)
assert out.returncode == 0, out.stderr
assert "`slug` is already defined in 4 other file(s)" in out.stdout
def test_the_lint_pattern_catches_the_shape_and_passes_the_fix():
from scripts.check_plugin import PATTERNS
pattern = next(p for p, label, _why in PATTERNS if label.startswith("early-exit consumer"))
for bad in ('hits=$(git grep -l x | head -4) || hits=""',
" | grep -m1 -v '^[[:space:]]*$') || old_first=\"\"",
'x=$(a | grep -q b) || x=""'):
assert pattern.search(bad), bad
for good in ('hits=$(git grep -l x | head -4 || true)',
'ln=$(grep -nF -m1 -- "$a" "$f" | cut -d: -f1) || ln=""'):
assert not pattern.search(good), good