mirror of
https://github.com/JuliusBrussee/caveman.git
synced 2026-08-11 13:21:09 +02:00
fix(compress): UTF-8 stdout, empty/identical guards, inline-code validation, frontmatter cleanup
Five user-contributed fixes consolidated: * UTF-8 stdout (#289 by @ousamabenyounes) — reconfigure stdout/stderr at the top of cli.py so Windows cp1252 consoles don't crash on the ❌ glyph in error/validation branches and mask the real error. * Empty / unchanged compression guards (#292 by @ousamabenyounes, closes #237) — refuse empty input, refuse empty/whitespace/identical Claude output, read back the backup before touching the input. Five regression tests in tests/test_compress_safety.py. * Inline backtick validation (#309 by @hireblackout) — validate_inline_codes closes the silent-overwrite gap where `npm install` → `yarn install` passed validation. Wired into validate(); 11 unit tests in tests/test_validate_inline.py. * Frontmatter angle-bracket fix (#268 by @Bortlesboat, closes #266) — caveman-compress/SKILL.md description now uses FILEPATH instead of <filepath>, plus verify_repo gains a new verify_skill_frontmatter_upload_compatibility check, UTF-8 hardening for Windows, and the activation-banner regex no longer requires a trailing period. * Two test fixtures (claude-md-project.md, mixed-with-code.md) updated so the new inline-backtick validator passes — they were silently dropping `server/src/`, `type(scope): description`, and `status` references in compression. The fixture is documentation of "good" compression, so the fix is to preserve those references. Co-Authored-By: Ben Younes <ousama.benyounes@oratelecom.net> Co-Authored-By: hireblackout <hireblackout@users.noreply.github.com> Co-Authored-By: Andrew Barnes <Bortlesboat@users.noreply.github.com> Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Ben Younes
hireblackout
Andrew Barnes
Claude Opus 4.7
parent
84cc3c14fa
commit
57a9b489a1
@@ -4,7 +4,7 @@ description: >
|
||||
Compress natural language memory files (CLAUDE.md, todos, preferences) into caveman format
|
||||
to save input tokens. Preserves all technical substance, code, URLs, and structure.
|
||||
Compressed version overwrites the original file. Human-readable backup saved as FILE.original.md.
|
||||
Trigger: /caveman:compress <filepath> or "compress memory file"
|
||||
Trigger: /caveman:compress FILEPATH or "compress memory file"
|
||||
---
|
||||
|
||||
# Caveman Compress
|
||||
|
||||
@@ -7,6 +7,18 @@ Usage:
|
||||
"""
|
||||
|
||||
import sys
|
||||
|
||||
# Force UTF-8 on stdout/stderr before any code can print. Windows consoles
|
||||
# default to cp1252 and crash on the ❌ glyphs in error/validation branches,
|
||||
# masking the real error and leaving the user with a half-compressed file.
|
||||
for _stream in (sys.stdout, sys.stderr):
|
||||
reconfigure = getattr(_stream, "reconfigure", None)
|
||||
if callable(reconfigure):
|
||||
try:
|
||||
reconfigure(encoding="utf-8", errors="replace")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from .compress import compress_file
|
||||
|
||||
@@ -182,6 +182,10 @@ def compress_file(filepath: Path) -> bool:
|
||||
original_text = filepath.read_text(errors="ignore")
|
||||
backup_path = filepath.with_name(filepath.stem + ".original.md")
|
||||
|
||||
if not original_text.strip():
|
||||
print("❌ Refusing to compress: file is empty or whitespace-only.")
|
||||
return False
|
||||
|
||||
# Check if backup already exists to prevent accidental overwriting
|
||||
if backup_path.exists():
|
||||
print(f"⚠️ Backup file already exists: {backup_path}")
|
||||
@@ -193,8 +197,31 @@ def compress_file(filepath: Path) -> bool:
|
||||
print("Compressing with Claude...")
|
||||
compressed = call_claude(build_compress_prompt(original_text))
|
||||
|
||||
# Save original as backup, write compressed to original path
|
||||
if compressed is None or not compressed.strip():
|
||||
print("❌ Compression aborted: Claude returned an empty response.")
|
||||
print(" Original file is untouched (no backup created).")
|
||||
return False
|
||||
|
||||
if compressed.strip() == original_text.strip():
|
||||
print("❌ Compression aborted: output is identical to input.")
|
||||
print(" Likely causes: Claude refused, returned the prompt verbatim, or the file is")
|
||||
print(" already in caveman form. Original file is untouched (no backup created).")
|
||||
return False
|
||||
|
||||
# Save original as backup, then verify the backup readback before
|
||||
# touching the input file. If the filesystem dropped bytes (encoding,
|
||||
# antivirus, disk full), unlink the bad backup and abort instead of
|
||||
# leaving the user with a corrupt backup + compressed primary.
|
||||
backup_path.write_text(original_text)
|
||||
backup_readback = backup_path.read_text(errors="ignore")
|
||||
if backup_readback != original_text:
|
||||
print(f"❌ Backup write verification failed: {backup_path}")
|
||||
print(" In-memory original differs from on-disk backup. Aborting before touching the input file.")
|
||||
try:
|
||||
backup_path.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
return False
|
||||
filepath.write_text(compressed)
|
||||
|
||||
# Step 2: Validate + Retry
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
#!/usr/bin/env python3
|
||||
import re
|
||||
from collections import Counter
|
||||
from pathlib import Path
|
||||
|
||||
URL_REGEX = re.compile(r"https?://[^\s)]+")
|
||||
@@ -93,6 +94,12 @@ def count_bullets(text):
|
||||
return len(BULLET_REGEX.findall(text))
|
||||
|
||||
|
||||
def extract_inline_codes(text):
|
||||
text_without_fences = re.sub(r"^```[\s\S]*?^```", "", text, flags=re.MULTILINE)
|
||||
text_without_fences = re.sub(r"^~~~[\s\S]*?^~~~", "", text_without_fences, flags=re.MULTILINE)
|
||||
return re.findall(r"`([^`]+)`", text_without_fences)
|
||||
|
||||
|
||||
# ---------- Validators ----------
|
||||
|
||||
|
||||
@@ -144,6 +151,22 @@ def validate_bullets(orig, comp, result):
|
||||
result.add_warning(f"Bullet count changed too much: {b1} -> {b2}")
|
||||
|
||||
|
||||
def validate_inline_codes(orig, comp, result):
|
||||
c1 = Counter(extract_inline_codes(orig))
|
||||
c2 = Counter(extract_inline_codes(comp))
|
||||
|
||||
if c1 != c2:
|
||||
lost = set(c1.keys()) - set(c2.keys())
|
||||
added = set(c2.keys()) - set(c1.keys())
|
||||
for code, count in c1.items():
|
||||
if code in c2 and c2[code] < count:
|
||||
lost.add(f"{code} (lost {count - c2[code]} of {count} occurrences)")
|
||||
if lost:
|
||||
result.add_error(f"Inline code lost: {lost}")
|
||||
if added:
|
||||
result.add_warning(f"Inline code added: {added}")
|
||||
|
||||
|
||||
# ---------- Main ----------
|
||||
|
||||
|
||||
@@ -158,6 +181,7 @@ def validate(original_path: Path, compressed_path: Path) -> ValidationResult:
|
||||
validate_urls(orig, comp, result)
|
||||
validate_paths(orig, comp, result)
|
||||
validate_bullets(orig, comp, result)
|
||||
validate_inline_codes(orig, comp, result)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@@ -7,6 +7,18 @@ Usage:
|
||||
"""
|
||||
|
||||
import sys
|
||||
|
||||
# Force UTF-8 on stdout/stderr before any code can print. Windows consoles
|
||||
# default to cp1252 and crash on the ❌ glyphs in error/validation branches,
|
||||
# masking the real error and leaving the user with a half-compressed file.
|
||||
for _stream in (sys.stdout, sys.stderr):
|
||||
reconfigure = getattr(_stream, "reconfigure", None)
|
||||
if callable(reconfigure):
|
||||
try:
|
||||
reconfigure(encoding="utf-8", errors="replace")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from .compress import compress_file
|
||||
|
||||
@@ -182,6 +182,10 @@ def compress_file(filepath: Path) -> bool:
|
||||
original_text = filepath.read_text(errors="ignore")
|
||||
backup_path = filepath.with_name(filepath.stem + ".original.md")
|
||||
|
||||
if not original_text.strip():
|
||||
print("❌ Refusing to compress: file is empty or whitespace-only.")
|
||||
return False
|
||||
|
||||
# Check if backup already exists to prevent accidental overwriting
|
||||
if backup_path.exists():
|
||||
print(f"⚠️ Backup file already exists: {backup_path}")
|
||||
@@ -193,8 +197,31 @@ def compress_file(filepath: Path) -> bool:
|
||||
print("Compressing with Claude...")
|
||||
compressed = call_claude(build_compress_prompt(original_text))
|
||||
|
||||
# Save original as backup, write compressed to original path
|
||||
if compressed is None or not compressed.strip():
|
||||
print("❌ Compression aborted: Claude returned an empty response.")
|
||||
print(" Original file is untouched (no backup created).")
|
||||
return False
|
||||
|
||||
if compressed.strip() == original_text.strip():
|
||||
print("❌ Compression aborted: output is identical to input.")
|
||||
print(" Likely causes: Claude refused, returned the prompt verbatim, or the file is")
|
||||
print(" already in caveman form. Original file is untouched (no backup created).")
|
||||
return False
|
||||
|
||||
# Save original as backup, then verify the backup readback before
|
||||
# touching the input file. If the filesystem dropped bytes (encoding,
|
||||
# antivirus, disk full), unlink the bad backup and abort instead of
|
||||
# leaving the user with a corrupt backup + compressed primary.
|
||||
backup_path.write_text(original_text)
|
||||
backup_readback = backup_path.read_text(errors="ignore")
|
||||
if backup_readback != original_text:
|
||||
print(f"❌ Backup write verification failed: {backup_path}")
|
||||
print(" In-memory original differs from on-disk backup. Aborting before touching the input file.")
|
||||
try:
|
||||
backup_path.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
return False
|
||||
filepath.write_text(compressed)
|
||||
|
||||
# Step 2: Validate + Retry
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
#!/usr/bin/env python3
|
||||
import re
|
||||
from collections import Counter
|
||||
from pathlib import Path
|
||||
|
||||
URL_REGEX = re.compile(r"https?://[^\s)]+")
|
||||
@@ -93,6 +94,12 @@ def count_bullets(text):
|
||||
return len(BULLET_REGEX.findall(text))
|
||||
|
||||
|
||||
def extract_inline_codes(text):
|
||||
text_without_fences = re.sub(r"^```[\s\S]*?^```", "", text, flags=re.MULTILINE)
|
||||
text_without_fences = re.sub(r"^~~~[\s\S]*?^~~~", "", text_without_fences, flags=re.MULTILINE)
|
||||
return re.findall(r"`([^`]+)`", text_without_fences)
|
||||
|
||||
|
||||
# ---------- Validators ----------
|
||||
|
||||
|
||||
@@ -144,6 +151,22 @@ def validate_bullets(orig, comp, result):
|
||||
result.add_warning(f"Bullet count changed too much: {b1} -> {b2}")
|
||||
|
||||
|
||||
def validate_inline_codes(orig, comp, result):
|
||||
c1 = Counter(extract_inline_codes(orig))
|
||||
c2 = Counter(extract_inline_codes(comp))
|
||||
|
||||
if c1 != c2:
|
||||
lost = set(c1.keys()) - set(c2.keys())
|
||||
added = set(c2.keys()) - set(c1.keys())
|
||||
for code, count in c1.items():
|
||||
if code in c2 and c2[code] < count:
|
||||
lost.add(f"{code} (lost {count - c2[code]} of {count} occurrences)")
|
||||
if lost:
|
||||
result.add_error(f"Inline code lost: {lost}")
|
||||
if added:
|
||||
result.add_warning(f"Inline code added: {added}")
|
||||
|
||||
|
||||
# ---------- Main ----------
|
||||
|
||||
|
||||
@@ -158,6 +181,7 @@ def validate(original_path: Path, compressed_path: Path) -> ValidationResult:
|
||||
validate_urls(orig, comp, result)
|
||||
validate_paths(orig, comp, result)
|
||||
validate_bullets(orig, comp, result)
|
||||
validate_inline_codes(orig, comp, result)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@@ -7,6 +7,18 @@ Usage:
|
||||
"""
|
||||
|
||||
import sys
|
||||
|
||||
# Force UTF-8 on stdout/stderr before any code can print. Windows consoles
|
||||
# default to cp1252 and crash on the ❌ glyphs in error/validation branches,
|
||||
# masking the real error and leaving the user with a half-compressed file.
|
||||
for _stream in (sys.stdout, sys.stderr):
|
||||
reconfigure = getattr(_stream, "reconfigure", None)
|
||||
if callable(reconfigure):
|
||||
try:
|
||||
reconfigure(encoding="utf-8", errors="replace")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from .compress import compress_file
|
||||
|
||||
@@ -182,6 +182,10 @@ def compress_file(filepath: Path) -> bool:
|
||||
original_text = filepath.read_text(errors="ignore")
|
||||
backup_path = filepath.with_name(filepath.stem + ".original.md")
|
||||
|
||||
if not original_text.strip():
|
||||
print("❌ Refusing to compress: file is empty or whitespace-only.")
|
||||
return False
|
||||
|
||||
# Check if backup already exists to prevent accidental overwriting
|
||||
if backup_path.exists():
|
||||
print(f"⚠️ Backup file already exists: {backup_path}")
|
||||
@@ -193,8 +197,31 @@ def compress_file(filepath: Path) -> bool:
|
||||
print("Compressing with Claude...")
|
||||
compressed = call_claude(build_compress_prompt(original_text))
|
||||
|
||||
# Save original as backup, write compressed to original path
|
||||
if compressed is None or not compressed.strip():
|
||||
print("❌ Compression aborted: Claude returned an empty response.")
|
||||
print(" Original file is untouched (no backup created).")
|
||||
return False
|
||||
|
||||
if compressed.strip() == original_text.strip():
|
||||
print("❌ Compression aborted: output is identical to input.")
|
||||
print(" Likely causes: Claude refused, returned the prompt verbatim, or the file is")
|
||||
print(" already in caveman form. Original file is untouched (no backup created).")
|
||||
return False
|
||||
|
||||
# Save original as backup, then verify the backup readback before
|
||||
# touching the input file. If the filesystem dropped bytes (encoding,
|
||||
# antivirus, disk full), unlink the bad backup and abort instead of
|
||||
# leaving the user with a corrupt backup + compressed primary.
|
||||
backup_path.write_text(original_text)
|
||||
backup_readback = backup_path.read_text(errors="ignore")
|
||||
if backup_readback != original_text:
|
||||
print(f"❌ Backup write verification failed: {backup_path}")
|
||||
print(" In-memory original differs from on-disk backup. Aborting before touching the input file.")
|
||||
try:
|
||||
backup_path.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
return False
|
||||
filepath.write_text(compressed)
|
||||
|
||||
# Step 2: Validate + Retry
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
#!/usr/bin/env python3
|
||||
import re
|
||||
from collections import Counter
|
||||
from pathlib import Path
|
||||
|
||||
URL_REGEX = re.compile(r"https?://[^\s)]+")
|
||||
@@ -93,6 +94,12 @@ def count_bullets(text):
|
||||
return len(BULLET_REGEX.findall(text))
|
||||
|
||||
|
||||
def extract_inline_codes(text):
|
||||
text_without_fences = re.sub(r"^```[\s\S]*?^```", "", text, flags=re.MULTILINE)
|
||||
text_without_fences = re.sub(r"^~~~[\s\S]*?^~~~", "", text_without_fences, flags=re.MULTILINE)
|
||||
return re.findall(r"`([^`]+)`", text_without_fences)
|
||||
|
||||
|
||||
# ---------- Validators ----------
|
||||
|
||||
|
||||
@@ -144,6 +151,22 @@ def validate_bullets(orig, comp, result):
|
||||
result.add_warning(f"Bullet count changed too much: {b1} -> {b2}")
|
||||
|
||||
|
||||
def validate_inline_codes(orig, comp, result):
|
||||
c1 = Counter(extract_inline_codes(orig))
|
||||
c2 = Counter(extract_inline_codes(comp))
|
||||
|
||||
if c1 != c2:
|
||||
lost = set(c1.keys()) - set(c2.keys())
|
||||
added = set(c2.keys()) - set(c1.keys())
|
||||
for code, count in c1.items():
|
||||
if code in c2 and c2[code] < count:
|
||||
lost.add(f"{code} (lost {count - c2[code]} of {count} occurrences)")
|
||||
if lost:
|
||||
result.add_error(f"Inline code lost: {lost}")
|
||||
if added:
|
||||
result.add_warning(f"Inline code added: {added}")
|
||||
|
||||
|
||||
# ---------- Main ----------
|
||||
|
||||
|
||||
@@ -158,6 +181,7 @@ def validate(original_path: Path, compressed_path: Path) -> ValidationResult:
|
||||
validate_urls(orig, comp, result)
|
||||
validate_paths(orig, comp, result)
|
||||
validate_bullets(orig, comp, result)
|
||||
validate_inline_codes(orig, comp, result)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@@ -32,7 +32,7 @@ Pattern: controller-service-repository.
|
||||
* services contain business logic
|
||||
* repositories manage DB access
|
||||
|
||||
Structure:
|
||||
Structure under `server/src/`:
|
||||
|
||||
* `server/src/controllers/` — route handlers + validation
|
||||
* `server/src/services/` — business logic
|
||||
@@ -95,7 +95,7 @@ Short-lived feature branches → PR → merge to `main`.
|
||||
Branch format: `<type>/<ticket-id>-<short-description>`
|
||||
Example: `feat/TF-123-add-slack-integration`
|
||||
|
||||
Commits: Conventional Commits
|
||||
Commits: Conventional Commits — `type(scope): description`
|
||||
Types: feat, fix, refactor, test, docs, chore, perf
|
||||
|
||||
Rules:
|
||||
|
||||
@@ -69,7 +69,7 @@ const createTask = async (payload: CreateTaskPayload, token: string) => {
|
||||
};
|
||||
```
|
||||
|
||||
Response includes: `id`, `createdAt`, `status="todo"`.
|
||||
Response includes: `id`, `createdAt`, `status` (default `"todo"`).
|
||||
|
||||
## Error Handling
|
||||
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
"""Tests for the data-loss guards in `compress_file` (issue #237).
|
||||
|
||||
The compress orchestrator used to overwrite the input even when Claude
|
||||
returned an empty string or a no-op echo, and used to write a backup
|
||||
without verifying that the bytes survived the round-trip. These tests
|
||||
pin the new defensive checks: nothing on disk changes when the compressed
|
||||
output is empty or identical to the input, and a backup-write that drops
|
||||
bytes is detected before the input is overwritten.
|
||||
"""
|
||||
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
sys.path.insert(0, str(REPO_ROOT))
|
||||
|
||||
from skills.compress.scripts import compress as compress_mod # noqa: E402
|
||||
|
||||
|
||||
class CompressSafetyTests(unittest.TestCase):
|
||||
def _file_with(self, dirpath: Path, text: str) -> Path:
|
||||
path = dirpath / "task.md"
|
||||
path.write_text(text)
|
||||
return path
|
||||
|
||||
def test_empty_input_refused(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
path = self._file_with(Path(tmp), "")
|
||||
with mock.patch.object(compress_mod, "call_claude") as call:
|
||||
ok = compress_mod.compress_file(path)
|
||||
self.assertFalse(ok)
|
||||
call.assert_not_called()
|
||||
self.assertEqual(path.read_text(), "")
|
||||
self.assertFalse((Path(tmp) / "task.original.md").exists())
|
||||
|
||||
def test_empty_compressed_output_does_not_touch_disk(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
original = "# Heading\n\nSome long natural language paragraph that should be compressed.\n"
|
||||
path = self._file_with(Path(tmp), original)
|
||||
with mock.patch.object(compress_mod, "call_claude", return_value=""):
|
||||
ok = compress_mod.compress_file(path)
|
||||
self.assertFalse(ok)
|
||||
self.assertEqual(path.read_text(), original)
|
||||
self.assertFalse((Path(tmp) / "task.original.md").exists())
|
||||
|
||||
def test_whitespace_only_compressed_output_does_not_touch_disk(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
original = "# Heading\n\nProse that should change.\n"
|
||||
path = self._file_with(Path(tmp), original)
|
||||
with mock.patch.object(compress_mod, "call_claude", return_value=" \n "):
|
||||
ok = compress_mod.compress_file(path)
|
||||
self.assertFalse(ok)
|
||||
self.assertEqual(path.read_text(), original)
|
||||
self.assertFalse((Path(tmp) / "task.original.md").exists())
|
||||
|
||||
def test_identical_compressed_output_does_not_touch_disk(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
original = "# Heading\n\nProse.\n"
|
||||
path = self._file_with(Path(tmp), original)
|
||||
with mock.patch.object(compress_mod, "call_claude", return_value=original):
|
||||
ok = compress_mod.compress_file(path)
|
||||
self.assertFalse(ok)
|
||||
self.assertEqual(path.read_text(), original)
|
||||
self.assertFalse((Path(tmp) / "task.original.md").exists())
|
||||
|
||||
def test_real_compression_writes_backup_and_target(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
original = "# Heading\n\nThe quick brown fox jumps over the lazy dog.\n"
|
||||
compressed = "# Heading\n\nFox jump dog.\n"
|
||||
path = self._file_with(Path(tmp), original)
|
||||
with mock.patch.object(compress_mod, "call_claude", return_value=compressed), \
|
||||
mock.patch.object(compress_mod, "validate") as v:
|
||||
v.return_value = mock.Mock(is_valid=True, errors=[], warnings=[])
|
||||
ok = compress_mod.compress_file(path)
|
||||
self.assertTrue(ok)
|
||||
self.assertEqual(path.read_text(), compressed)
|
||||
backup = Path(tmp) / "task.original.md"
|
||||
self.assertEqual(backup.read_text(), original)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,87 @@
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
sys.path.insert(0, str(REPO_ROOT))
|
||||
|
||||
from skills.compress.scripts.validate import ( # noqa: E402
|
||||
ValidationResult,
|
||||
extract_inline_codes,
|
||||
validate,
|
||||
validate_inline_codes,
|
||||
)
|
||||
|
||||
|
||||
class TestExtractInlineCodes(unittest.TestCase):
|
||||
def test_fenced_blocks_excluded(self):
|
||||
text = "```\ncode here\n```\n`inline code`"
|
||||
result = extract_inline_codes(text)
|
||||
self.assertEqual(result, ["inline code"])
|
||||
|
||||
def test_inline_only(self):
|
||||
text = "Use `rm -rf /` to delete everything"
|
||||
result = extract_inline_codes(text)
|
||||
self.assertEqual(result, ["rm -rf /"])
|
||||
|
||||
def test_mixed_content(self):
|
||||
text = """
|
||||
Some text with `inline1` and `inline2`.
|
||||
|
||||
```
|
||||
code block
|
||||
```
|
||||
|
||||
More text with `inline3`.
|
||||
"""
|
||||
result = extract_inline_codes(text)
|
||||
self.assertEqual(set(result), {"inline1", "inline2", "inline3"})
|
||||
|
||||
def test_empty(self):
|
||||
self.assertEqual(extract_inline_codes("no backticks here"), [])
|
||||
|
||||
|
||||
class TestValidateInlineCodes(unittest.TestCase):
|
||||
def test_match(self):
|
||||
result = ValidationResult()
|
||||
validate_inline_codes("use `cmd` here", "use `cmd` here", result)
|
||||
self.assertTrue(result.is_valid)
|
||||
|
||||
def test_lost(self):
|
||||
result = ValidationResult()
|
||||
validate_inline_codes("use `cmd` here", "use here", result)
|
||||
self.assertFalse(result.is_valid)
|
||||
self.assertIn("Inline code lost", result.errors[0])
|
||||
|
||||
def test_added(self):
|
||||
result = ValidationResult()
|
||||
validate_inline_codes("use here", "use `new` here", result)
|
||||
self.assertTrue(result.is_valid)
|
||||
self.assertIn("Inline code added", result.warnings[0])
|
||||
|
||||
def test_empty_orig(self):
|
||||
result = ValidationResult()
|
||||
validate_inline_codes("no codes", "use `new` here", result)
|
||||
self.assertTrue(result.is_valid)
|
||||
|
||||
def test_both_empty(self):
|
||||
result = ValidationResult()
|
||||
validate_inline_codes("plain text", "also plain", result)
|
||||
self.assertTrue(result.is_valid)
|
||||
|
||||
|
||||
class TestValidateIntegration(unittest.TestCase):
|
||||
def test_validate_inline_codes_wired(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
orig = Path(tmp) / "original.md"
|
||||
comp = Path(tmp) / "compressed.md"
|
||||
orig.write_text("Run `rm -rf /` to delete")
|
||||
comp.write_text("Run to delete")
|
||||
result = validate(orig, comp)
|
||||
self.assertFalse(result.is_valid)
|
||||
self.assertTrue(any("Inline code lost" in e for e in result.errors))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
+110
-30
@@ -37,6 +37,8 @@ def run(
|
||||
check: bool = True,
|
||||
) -> subprocess.CompletedProcess[str]:
|
||||
merged_env = os.environ.copy()
|
||||
# Keep Python subprocess output decodable on Windows when the CLI prints Unicode.
|
||||
merged_env.setdefault("PYTHONIOENCODING", "utf-8")
|
||||
if env:
|
||||
merged_env.update(env)
|
||||
result = subprocess.run(
|
||||
@@ -44,6 +46,7 @@ def run(
|
||||
cwd=cwd,
|
||||
env=merged_env,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
capture_output=True,
|
||||
check=False,
|
||||
)
|
||||
@@ -57,7 +60,65 @@ def run(
|
||||
|
||||
|
||||
def read_json(path: Path) -> object:
|
||||
return json.loads(path.read_text())
|
||||
return json.loads(path.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def shell_path(path: Path) -> str:
|
||||
return str(path).replace("\\", "/") if os.name == "nt" else str(path)
|
||||
|
||||
|
||||
def _frontmatter_description(path: Path) -> str:
|
||||
lines = path.read_text(encoding="utf-8").splitlines()
|
||||
ensure(lines and lines[0] == "---", f"{path} missing YAML frontmatter")
|
||||
|
||||
description_lines: list[str] = []
|
||||
collecting = False
|
||||
block_indent: int | None = None
|
||||
for line in lines[1:]:
|
||||
if line == "---":
|
||||
break
|
||||
if collecting:
|
||||
stripped = line.strip()
|
||||
if not stripped:
|
||||
description_lines.append("")
|
||||
continue
|
||||
indent = len(line) - len(line.lstrip(" \t"))
|
||||
if block_indent is None:
|
||||
if indent == 0:
|
||||
break
|
||||
block_indent = indent
|
||||
elif indent < block_indent:
|
||||
break
|
||||
description_lines.append(stripped)
|
||||
continue
|
||||
if line.startswith("description:"):
|
||||
value = line.split(":", 1)[1].strip()
|
||||
# Folded (>) and literal (|) block scalars, with optional chomping (-/+).
|
||||
if value and value[0] in ("|", ">"):
|
||||
collecting = True
|
||||
continue
|
||||
return value.strip("'\"")
|
||||
return " ".join(part for part in description_lines if part)
|
||||
|
||||
|
||||
def verify_skill_frontmatter_upload_compatibility() -> None:
|
||||
section("Skill Frontmatter Upload Compatibility")
|
||||
|
||||
skill_paths = [
|
||||
ROOT / "skills/caveman/SKILL.md",
|
||||
ROOT / "skills/caveman-commit/SKILL.md",
|
||||
ROOT / "skills/caveman-help/SKILL.md",
|
||||
ROOT / "skills/caveman-review/SKILL.md",
|
||||
ROOT / "caveman-compress/SKILL.md",
|
||||
]
|
||||
for path in skill_paths:
|
||||
description = _frontmatter_description(path)
|
||||
ensure(
|
||||
"<" not in description and ">" not in description,
|
||||
f"{path} description contains XML-like angle brackets",
|
||||
)
|
||||
|
||||
print("Skill frontmatter descriptions avoid XML-like tags")
|
||||
|
||||
|
||||
def verify_synced_files() -> None:
|
||||
@@ -72,19 +133,26 @@ def verify_synced_files() -> None:
|
||||
ROOT / ".windsurf/skills/caveman/SKILL.md",
|
||||
]
|
||||
for copy in skill_copies:
|
||||
ensure(copy.read_text() == skill_source.read_text(), f"Skill copy mismatch: {copy}")
|
||||
ensure(
|
||||
copy.read_text(encoding="utf-8") == skill_source.read_text(encoding="utf-8"),
|
||||
f"Skill copy mismatch: {copy}",
|
||||
)
|
||||
|
||||
rule_copies = [
|
||||
ROOT / ".clinerules/caveman.md",
|
||||
ROOT / ".github/copilot-instructions.md",
|
||||
]
|
||||
for copy in rule_copies:
|
||||
ensure(copy.read_text() == rule_source.read_text(), f"Rule copy mismatch: {copy}")
|
||||
ensure(
|
||||
copy.read_text(encoding="utf-8") == rule_source.read_text(encoding="utf-8"),
|
||||
f"Rule copy mismatch: {copy}",
|
||||
)
|
||||
|
||||
with zipfile.ZipFile(ROOT / "caveman.skill") as archive:
|
||||
ensure("caveman/SKILL.md" in archive.namelist(), "caveman.skill missing caveman/SKILL.md")
|
||||
ensure(
|
||||
archive.read("caveman/SKILL.md").decode("utf-8") == skill_source.read_text(),
|
||||
archive.read("caveman/SKILL.md").decode("utf-8")
|
||||
== skill_source.read_text(encoding="utf-8"),
|
||||
"caveman.skill payload mismatch",
|
||||
)
|
||||
|
||||
@@ -113,8 +181,8 @@ def verify_manifests_and_syntax() -> None:
|
||||
run(["bash", "-n", "hooks/caveman-statusline.sh"])
|
||||
|
||||
# Ensure install/uninstall scripts include caveman-config.js
|
||||
install_sh = (ROOT / "hooks/install.sh").read_text()
|
||||
uninstall_sh = (ROOT / "hooks/uninstall.sh").read_text()
|
||||
install_sh = (ROOT / "hooks/install.sh").read_text(encoding="utf-8")
|
||||
uninstall_sh = (ROOT / "hooks/uninstall.sh").read_text(encoding="utf-8")
|
||||
ensure("caveman-config.js" in install_sh, "install.sh missing caveman-config.js")
|
||||
ensure("caveman-config.js" in uninstall_sh, "uninstall.sh missing caveman-config.js")
|
||||
|
||||
@@ -123,9 +191,9 @@ def verify_manifests_and_syntax() -> None:
|
||||
|
||||
def verify_powershell_static() -> None:
|
||||
section("PowerShell Static Checks")
|
||||
install_text = (ROOT / "hooks/install.ps1").read_text()
|
||||
uninstall_text = (ROOT / "hooks/uninstall.ps1").read_text()
|
||||
statusline_text = (ROOT / "hooks/caveman-statusline.ps1").read_text()
|
||||
install_text = (ROOT / "hooks/install.ps1").read_text(encoding="utf-8")
|
||||
uninstall_text = (ROOT / "hooks/uninstall.ps1").read_text(encoding="utf-8")
|
||||
statusline_text = (ROOT / "hooks/caveman-statusline.ps1").read_text(encoding="utf-8")
|
||||
|
||||
ensure("caveman-config.js" in install_text, "install.ps1 missing caveman-config.js")
|
||||
ensure("caveman-config.js" in uninstall_text, "uninstall.ps1 missing caveman-config.js")
|
||||
@@ -212,8 +280,9 @@ def verify_hook_install_flow() -> None:
|
||||
"hooks": {"Notification": [{"hooks": [{"type": "command", "command": "echo keep-me"}]}]},
|
||||
}
|
||||
(claude_dir / "settings.json").write_text(json.dumps(existing_settings, indent=2) + "\n")
|
||||
hook_env = {"HOME": shell_path(home), "CLAUDE_CONFIG_DIR": shell_path(claude_dir)}
|
||||
|
||||
run(["bash", "hooks/install.sh"], env={"HOME": str(home)})
|
||||
run(["bash", "hooks/install.sh"], env=hook_env)
|
||||
|
||||
settings = read_json(claude_dir / "settings.json")
|
||||
hooks = settings["hooks"]
|
||||
@@ -223,33 +292,37 @@ def verify_hook_install_flow() -> None:
|
||||
|
||||
activate = run(
|
||||
["node", "hooks/caveman-activate.js"],
|
||||
env={"HOME": str(home)},
|
||||
env=hook_env,
|
||||
)
|
||||
ensure("CAVEMAN MODE ACTIVE." in activate.stdout, "activation output missing caveman banner")
|
||||
ensure("CAVEMAN MODE ACTIVE" in activate.stdout, "activation output missing caveman banner")
|
||||
ensure("STATUSLINE SETUP NEEDED" not in activate.stdout, "activation should stay quiet when custom statusline exists")
|
||||
ensure((claude_dir / ".caveman-active").read_text() == "full", "activation flag should default to full")
|
||||
ensure((claude_dir / ".caveman-active").read_text(encoding="utf-8") == "full", "activation flag should default to full")
|
||||
|
||||
# Test configurable default mode via CAVEMAN_DEFAULT_MODE env var
|
||||
activate_custom = run(
|
||||
["node", "hooks/caveman-activate.js"],
|
||||
env={"HOME": str(home), "CAVEMAN_DEFAULT_MODE": "ultra"},
|
||||
env={**hook_env, "CAVEMAN_DEFAULT_MODE": "ultra"},
|
||||
)
|
||||
ensure("CAVEMAN MODE ACTIVE" in activate_custom.stdout, "activation with custom default missing banner")
|
||||
ensure(
|
||||
(claude_dir / ".caveman-active").read_text(encoding="utf-8") == "ultra",
|
||||
"CAVEMAN_DEFAULT_MODE=ultra should set flag to ultra",
|
||||
)
|
||||
ensure("CAVEMAN MODE ACTIVE." in activate_custom.stdout, "activation with custom default missing banner")
|
||||
ensure((claude_dir / ".caveman-active").read_text() == "ultra", "CAVEMAN_DEFAULT_MODE=ultra should set flag to ultra")
|
||||
# Test "off" mode — activation skipped, flag removed
|
||||
activate_off = run(
|
||||
["node", "hooks/caveman-activate.js"],
|
||||
env={"HOME": str(home), "CAVEMAN_DEFAULT_MODE": "off"},
|
||||
env={**hook_env, "CAVEMAN_DEFAULT_MODE": "off"},
|
||||
)
|
||||
ensure("CAVEMAN MODE ACTIVE." not in activate_off.stdout, "off mode should not emit caveman banner")
|
||||
ensure("CAVEMAN MODE ACTIVE" not in activate_off.stdout, "off mode should not emit caveman banner")
|
||||
ensure(not (claude_dir / ".caveman-active").exists(), "off mode should remove flag file")
|
||||
|
||||
# Test mode tracker with /caveman when default is off — should NOT write flag
|
||||
subprocess.run(
|
||||
["node", "hooks/caveman-mode-tracker.js"],
|
||||
cwd=ROOT,
|
||||
env={**os.environ, "HOME": str(home), "CAVEMAN_DEFAULT_MODE": "off"},
|
||||
env={**os.environ, **hook_env, "CAVEMAN_DEFAULT_MODE": "off"},
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
input='{"prompt":"/caveman"}',
|
||||
capture_output=True,
|
||||
check=True,
|
||||
@@ -261,27 +334,32 @@ def verify_hook_install_flow() -> None:
|
||||
|
||||
run(
|
||||
["node", "hooks/caveman-mode-tracker.js"],
|
||||
env={"HOME": str(home)},
|
||||
env=hook_env,
|
||||
check=True,
|
||||
)
|
||||
|
||||
ultra_prompt = subprocess.run(
|
||||
["node", "hooks/caveman-mode-tracker.js"],
|
||||
cwd=ROOT,
|
||||
env={**os.environ, "HOME": str(home)},
|
||||
env={**os.environ, **hook_env},
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
input='{"prompt":"/caveman ultra"}',
|
||||
capture_output=True,
|
||||
check=True,
|
||||
)
|
||||
ensure(ultra_prompt.stdout == "", "mode tracker should stay silent")
|
||||
ensure((claude_dir / ".caveman-active").read_text() == "ultra", "mode tracker did not record ultra")
|
||||
ensure(
|
||||
"CAVEMAN MODE ACTIVE (ultra)" in ultra_prompt.stdout,
|
||||
"mode tracker should emit active-mode reinforcement",
|
||||
)
|
||||
ensure((claude_dir / ".caveman-active").read_text(encoding="utf-8") == "ultra", "mode tracker did not record ultra")
|
||||
|
||||
subprocess.run(
|
||||
["node", "hooks/caveman-mode-tracker.js"],
|
||||
cwd=ROOT,
|
||||
env={**os.environ, "HOME": str(home)},
|
||||
env={**os.environ, **hook_env},
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
input='{"prompt":"normal mode"}',
|
||||
capture_output=True,
|
||||
check=True,
|
||||
@@ -291,27 +369,28 @@ def verify_hook_install_flow() -> None:
|
||||
(claude_dir / ".caveman-active").write_text("wenyan-ultra")
|
||||
statusline = run(
|
||||
["bash", "hooks/caveman-statusline.sh"],
|
||||
env={"HOME": str(home)},
|
||||
env=hook_env,
|
||||
)
|
||||
ensure("[CAVEMAN:WENYAN-ULTRA]" in statusline.stdout, "statusline badge output mismatch")
|
||||
|
||||
reinstall = run(["bash", "hooks/install.sh"], env={"HOME": str(home)})
|
||||
reinstall = run(["bash", "hooks/install.sh"], env=hook_env)
|
||||
ensure("Nothing to do" in reinstall.stdout, "install.sh should be idempotent")
|
||||
|
||||
run(["bash", "hooks/uninstall.sh"], env={"HOME": str(home)})
|
||||
run(["bash", "hooks/uninstall.sh"], env=hook_env)
|
||||
settings_after = read_json(claude_dir / "settings.json")
|
||||
ensure(settings_after == existing_settings, "uninstall.sh did not restore non-caveman settings")
|
||||
ensure(not (claude_dir / ".caveman-active").exists(), "uninstall.sh should remove flag file")
|
||||
|
||||
with tempfile.TemporaryDirectory(prefix="caveman-verify-fresh-") as temp_root:
|
||||
home = Path(temp_root) / "home"
|
||||
run(["bash", "hooks/install.sh"], env={"HOME": str(home)})
|
||||
claude_dir = home / ".claude"
|
||||
hook_env = {"HOME": shell_path(home), "CLAUDE_CONFIG_DIR": shell_path(claude_dir)}
|
||||
run(["bash", "hooks/install.sh"], env=hook_env)
|
||||
settings = read_json(claude_dir / "settings.json")
|
||||
ensure("statusLine" in settings, "fresh install should configure statusline")
|
||||
activate = run(["node", "hooks/caveman-activate.js"], env={"HOME": str(home)})
|
||||
activate = run(["node", "hooks/caveman-activate.js"], env=hook_env)
|
||||
ensure("STATUSLINE SETUP NEEDED" not in activate.stdout, "fresh install should not nudge for statusline")
|
||||
run(["bash", "hooks/uninstall.sh"], env={"HOME": str(home)})
|
||||
run(["bash", "hooks/uninstall.sh"], env=hook_env)
|
||||
ensure(read_json(claude_dir / "settings.json") == {}, "fresh uninstall should leave empty settings")
|
||||
|
||||
print("Claude hook install/uninstall flow OK")
|
||||
@@ -319,6 +398,7 @@ def verify_hook_install_flow() -> None:
|
||||
|
||||
def main() -> int:
|
||||
checks = [
|
||||
verify_skill_frontmatter_upload_compatibility,
|
||||
verify_synced_files,
|
||||
verify_manifests_and_syntax,
|
||||
verify_powershell_static,
|
||||
|
||||
Reference in New Issue
Block a user