Skip to content

Commit 636b994

Browse files
Merge pull request #28 from alfredolopez80/fix/ci-pytest-fail-loud
fix(ci): make pytest tests/ fail-loud (remove || true)
2 parents 248d4ff + 99fdbad commit 636b994

19 files changed

Lines changed: 993 additions & 303 deletions

.claude/hooks/lib/worktree-utils.sh

Lines changed: 36 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -207,12 +207,45 @@ checkWorktreeTTL() {
207207
return 1
208208
fi
209209

210-
# Get creation time from worktree directory metadata
211-
local created_epoch
212-
created_epoch=$(stat -f "%B" "$wt_dir" 2>/dev/null || stat -c "%W" "$wt_dir" 2>/dev/null || echo "0")
210+
# Get creation time from worktree directory metadata.
211+
#
212+
# Cross-platform AND CI-safe:
213+
# * macOS/BSD stat uses `-f <fmt>`: %B = birth (create) time, %m = mtime.
214+
# * GNU/Linux stat uses `-c <fmt>`: %W = birth time, %Y = mtime.
215+
#
216+
# Two failures this guards against (both seen on Ubuntu CI, never on macOS):
217+
# 1. The old `stat -f "%B"` was tried FIRST on Linux, where `-f` means
218+
# "filesystem mode" — GNU stat then printed multi-line, NON-NUMERIC
219+
# filesystem info into created_epoch, and `$(( now - created_epoch ))`
220+
# aborted the function under `set -uo pipefail` -> EMPTY stdout ->
221+
# JSONDecodeError in the tests.
222+
# 2. Many Linux filesystems (ext4/overlayfs on CI) report birth time as 0
223+
# (`%W` == 0). created_epoch=0 made a FRESH worktree look ~29M minutes
224+
# old -> expired:true. We fall back to mtime when birth time is 0/empty.
225+
local created_epoch=""
226+
if stat -c '%W' / >/dev/null 2>&1; then
227+
# GNU/Linux stat
228+
created_epoch=$(stat -c '%W' "$wt_dir" 2>/dev/null || echo "")
229+
if [[ -z "$created_epoch" || ! "$created_epoch" =~ ^[0-9]+$ || "$created_epoch" -eq 0 ]]; then
230+
# Birth time unavailable on this fs -> use mtime (always populated).
231+
created_epoch=$(stat -c '%Y' "$wt_dir" 2>/dev/null || echo "")
232+
fi
233+
else
234+
# macOS/BSD stat
235+
created_epoch=$(stat -f '%B' "$wt_dir" 2>/dev/null || echo "")
236+
if [[ -z "$created_epoch" || ! "$created_epoch" =~ ^[0-9]+$ || "$created_epoch" -eq 0 ]]; then
237+
created_epoch=$(stat -f '%m' "$wt_dir" 2>/dev/null || echo "")
238+
fi
239+
fi
240+
# Final guard: never let a non-numeric value reach the arithmetic below
241+
# (a non-numeric $(( )) aborts the function under set -e and yields empty stdout).
242+
[[ "$created_epoch" =~ ^[0-9]+$ ]] || created_epoch=$(date +%s)
243+
213244
local now_epoch
214245
now_epoch=$(date +%s)
215246
local elapsed_minutes=$(( (now_epoch - created_epoch) / 60 ))
247+
# Clamp negative drift (clock skew between birth time and now) to 0.
248+
(( elapsed_minutes < 0 )) && elapsed_minutes=0
216249
local expired="false"
217250

218251
if [[ "$elapsed_minutes" -ge "$ttl_minutes" ]]; then

.claude/hooks/session-end-handoff.sh

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -304,7 +304,7 @@ gc_cleanup() {
304304
member_count=$(find "$team_dir" -type f -name "member-*.json" 2>/dev/null | wc -l | tr -d ' ')
305305
if [[ "$member_count" -eq 0 ]]; then
306306
rm -rf "$team_dir" 2>/dev/null && {
307-
((cleaned_teams++))
307+
cleaned_teams=$((cleaned_teams + 1))
308308
log "GC: Removed empty team dir: $team_dir" >> "$gc_log"
309309
}
310310
fi
@@ -321,7 +321,7 @@ gc_cleanup() {
321321
dir_date=$(basename "$task_dir" | grep -oE '[0-9]{8}' || echo "")
322322
if [[ -n "$dir_date" ]] && [[ "$dir_date" -lt "$cutoff_date" ]]; then
323323
rm -rf "$task_dir" 2>/dev/null && {
324-
((cleaned_tasks++))
324+
cleaned_tasks=$((cleaned_tasks + 1))
325325
log "GC: Removed old task dir: $task_dir" >> "$gc_log"
326326
}
327327
fi
@@ -338,7 +338,7 @@ gc_cleanup() {
338338
sort -n | head -n "$to_delete" | cut -d' ' -f2- | \
339339
while IFS= read -r file; do
340340
rm -f "$file" 2>/dev/null && {
341-
((cleaned_handoffs++))
341+
cleaned_handoffs=$((cleaned_handoffs + 1))
342342
log "GC: Removed old handoff: $file" >> "$gc_log"
343343
}
344344
done

.claude/hooks/vault-writeback.sh

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -124,7 +124,13 @@ for i in $(seq 0 $((QUEUE_LENGTH - 1)) 2>/dev/null); do
124124
fi
125125

126126
# Sanitize summary (no secrets, no absolute paths)
127-
SAFE_SUMMARY=$(echo "$SUMMARY" | sed "s|${HOME}/|~/|g" | tr -cd ' a-zA-Z0-9_.:/-()[]{}?!,;' | head -c 2000)
127+
# NOTE: the literal '-' MUST be last in the tr set. Inside '...:/-()...' the
128+
# substring '/-(' is parsed by GNU tr (Linux/CI) as a range from '/' (0x2F)
129+
# to '(' (0x28) — a reversed range — and GNU tr aborts with a non-zero exit
130+
# ("range-endpoints ... in reverse collating sequence order"). Under
131+
# `set -euo pipefail` that killed the whole hook (rc=1) on Ubuntu while BSD
132+
# tr on macOS tolerated it. Trailing '-' is a literal dash on both.
133+
SAFE_SUMMARY=$(echo "$SUMMARY" | sed "s|${HOME}/|~/|g" | tr -cd ' a-zA-Z0-9_.:/()[]{}?!,;-' | head -c 2000)
128134

129135
# Create draft wiki article
130136
NOW=$(date -u +"%Y-%m-%dT%H:%M:%SZ")

.github/workflows/ci.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,7 @@ jobs:
9090
- name: Run pytest
9191
run: |
9292
if [ -d "tests" ]; then
93-
python -m pytest tests/ -v --tb=short || true
93+
python -m pytest tests/ -v --tb=short
9494
fi
9595
9696
- name: Run harness tests

tests/conftest.py

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import sys
77
import tempfile
88
import shutil
9+
from pathlib import Path
910
import pytest
1011

1112
# Add project root to path
@@ -292,3 +293,79 @@ def _validate(skill_path: str) -> dict:
292293
return result
293294

294295
return _validate
296+
297+
298+
# ============================================================
299+
# CI-safe isolation fixtures (PR #28: make tests/ fail-loud-able)
300+
# ============================================================
301+
302+
@pytest.fixture
303+
def isolated_home(tmp_path, monkeypatch):
304+
"""Isolate $HOME to a temp dir seeded with the minimal Ralph layout.
305+
306+
Redirects BOTH ``$HOME`` (for shell hooks that read ``${HOME}``) and
307+
``Path.home()`` (for Python code), so a test never touches the developer's
308+
real ~/.claude or ~/.ralph. The repo's hooks are symlinked in so they
309+
resolve to the real, versioned scripts under the temp HOME.
310+
"""
311+
import json
312+
313+
home = tmp_path / "home"
314+
for sub in (
315+
".claude/state", ".claude/teams", ".claude/tasks", ".claude/skills", ".claude/scripts",
316+
".ralph/logs", ".ralph/state", ".ralph/handoffs", ".ralph/ledgers",
317+
".ralph/config", ".ralph/temp", ".ralph/markers",
318+
):
319+
(home / sub).mkdir(parents=True, exist_ok=True)
320+
321+
# Point ~/.claude/hooks at the repo's real hooks (no duplication, no dev HOME).
322+
repo_hooks = Path(PROJECT_ROOT) / ".claude" / "hooks"
323+
hooks_link = home / ".claude" / "hooks"
324+
if repo_hooks.is_dir() and not hooks_link.exists():
325+
hooks_link.symlink_to(repo_hooks)
326+
327+
# Minimal valid config so settings/feature readers find a non-empty file.
328+
(home / ".claude" / "settings.json").write_text(
329+
json.dumps({"hooks": {}}), encoding="utf-8"
330+
)
331+
(home / ".ralph" / "config" / "features.json").write_text(
332+
json.dumps({"RALPH_ENABLE_HANDOFF": True}), encoding="utf-8"
333+
)
334+
335+
monkeypatch.setenv("HOME", str(home))
336+
monkeypatch.setattr(Path, "home", lambda: home)
337+
return home
338+
339+
340+
@pytest.fixture
341+
def requires_tool():
342+
"""Return a helper that skips the test when a named CLI tool is absent.
343+
344+
Usage: ``def test_x(requires_tool): requires_tool("rsync")``.
345+
"""
346+
def _require(name):
347+
if shutil.which(name) is None:
348+
pytest.skip(f"required tool not available on PATH: {name}")
349+
return _require
350+
351+
352+
@pytest.fixture
353+
def git_repo(tmp_path):
354+
"""Create a throwaway git repo for hook/worktree tests (skips if git absent)."""
355+
import subprocess
356+
357+
if shutil.which("git") is None:
358+
pytest.skip("git not available on PATH")
359+
360+
repo = tmp_path / "repo"
361+
repo.mkdir()
362+
env = {**os.environ, "GIT_CONFIG_GLOBAL": os.devnull, "GIT_CONFIG_SYSTEM": os.devnull}
363+
run = lambda *a: subprocess.run(a, cwd=repo, check=True, env=env,
364+
capture_output=True, text=True)
365+
run("git", "init", "-q")
366+
run("git", "config", "user.email", "test@example.com")
367+
run("git", "config", "user.name", "ralph-test")
368+
(repo / "README.md").write_text("test\n", encoding="utf-8")
369+
run("git", "add", "-A")
370+
run("git", "commit", "-qm", "init")
371+
return repo

tests/layers/test_layer_stack.py

Lines changed: 27 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -97,12 +97,20 @@ def sample_rules_json(tmp_path: Path) -> Path:
9797
class TestLayer0:
9898
"""Tests for the identity layer (L0)."""
9999

100-
def test_exists_returns_true_for_real_file(self, real_l0_path: Path):
101-
"""L0.exists() returns True when the real identity file exists."""
102-
layer = Layer0(path=real_l0_path)
100+
def test_exists_returns_true_for_real_file(self, tmp_path: Path):
101+
"""L0.exists() returns True when an identity file is present.
102+
103+
CI-safe: build the artifact in tmp_path instead of depending on the
104+
developer's ``~/.ralph/layers/L0_identity.md`` (absent on CI runners).
105+
"""
106+
l0_path = tmp_path / "L0_identity.md"
107+
l0_path.write_text(
108+
"# Ralph Identity\n\n## Principles\nBuilt for the test.\n",
109+
encoding="utf-8",
110+
)
111+
layer = Layer0(path=l0_path)
103112
assert layer.exists() is True, (
104-
f"L0 identity file missing at {real_l0_path}. "
105-
"Run W2.2 setup to create ~/.ralph/layers/L0_identity.md"
113+
f"L0 identity file should exist after writing it at {l0_path}"
106114
)
107115

108116
def test_exists_returns_false_for_missing_file(self, tmp_layers_dir: Path):
@@ -230,12 +238,21 @@ def test_load_raises_when_file_missing(self, tmp_layers_dir: Path):
230238
with pytest.raises(FileNotFoundError):
231239
layer.load()
232240

233-
def test_real_l1_exists(self, real_l1_path: Path):
234-
"""Real L1_essential.md was built by W2.2 setup."""
235-
layer = Layer1(path=real_l1_path)
241+
def test_real_l1_exists(self, tmp_path: Path, sample_rules_json: Path, monkeypatch):
242+
"""Layer1.build() produces an L1_essential.md that exists().
243+
244+
CI-safe: build the artifact in tmp_path from the sample rules fixture
245+
(same pattern as test_build_creates_md_file) instead of asserting on the
246+
developer's ``~/.ralph/layers/L1_essential.md`` (absent on CI runners).
247+
"""
248+
l1_path = tmp_path / "L1_essential.md"
249+
_patch_rules_json(monkeypatch, sample_rules_json)
250+
251+
layer = Layer1(path=l1_path, rule_count=15)
252+
layer.build()
253+
236254
assert layer.exists() is True, (
237-
f"L1 file missing at {real_l1_path}. "
238-
"Run: python3 .claude/lib/layers.py --build-l1"
255+
f"L1 file should exist after build() at {l1_path}"
239256
)
240257

241258
def test_real_l1_has_substantive_rules(self, real_l1_path: Path):

0 commit comments

Comments
 (0)