Files
ai-job-search/tools/check_framework_version.py
T
Timofei ErokhinandClaude Sonnet 5 27a9b4a91d fix(check_framework_version): decode git diff output as UTF-8 (#511)
* fix(check_framework_version): decode git diff output as UTF-8

run_git() passed subprocess.run(text=True) with no explicit encoding,
so it fell back to the host locale's default codec - cp1252 on
Windows - instead of UTF-8. Any non-ASCII byte in a modified
framework file's diff (e.g. an em dash in a profile edit) then raised
UnicodeDecodeError before the gate could evaluate the change, instead
of a pass/fail verdict. Reproduced on a real Windows checkout.

Pin encoding="utf-8" on the subprocess.run() call. Added a regression
test that patches subprocess.run and asserts run_git() always passes
encoding="utf-8" - confirmed it fails against the pre-fix code and
passes against the fix.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* fix(check_framework_version): address review on UTF-8 decode fix

Per MadsLorentzen's review on PR #511:
- Add errors="replace" alongside encoding="utf-8" on the subprocess.run()
  call, matching the convention used elsewhere in this repo (robots_check.py).
- Correct the docstring: an em dash does not crash the un-pinned decode
  (cp1252 maps it fine); the actual trigger is a byte cp1252 leaves
  undefined, e.g. Cyrillic Ё/ё, much CJK, or Á-class Latin.
- Correct "profile file" to "framework file" - the tool only diffs the
  framework's own directories, not arbitrary profile files.
- Add a CHANGELOG entry under [Unreleased] / Fixed naming the tool and test.

---------

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-02 20:03:35 +02:00

155 lines
5.2 KiB
Python
Executable File

#!/usr/bin/env python3
"""CI check: ensure that modified framework files have updated version markers.
Fails if any markdown file under .claude/skills/job-application-assistant/ is
modified in git without a change/bump to its 'framework_version' frontmatter key.
Also ensures all framework files have a valid 'framework_version' frontmatter key.
"""
from __future__ import annotations
import os
import re
import subprocess
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
SKILL_DIR = ROOT / ".claude/skills/job-application-assistant"
FRAMEWORK_FILES = sorted(SKILL_DIR.glob("*.md"))
# Add root AGENTS.md if it exists
root_agents = ROOT / "AGENTS.md"
if root_agents.exists():
FRAMEWORK_FILES.append(root_agents)
def run_git(args: list[str]) -> tuple[int, str, str]:
res = subprocess.run(
["git"] + args,
cwd=str(ROOT),
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
)
return res.returncode, res.stdout, res.stderr
def get_base_commit() -> str | None:
# If in GitHub Actions PR, use the target branch's base ref
base_ref = os.environ.get("GITHUB_BASE_REF")
if base_ref:
# Check if origin/base_ref is fetched
rc, _, _ = run_git(["rev-parse", "--verify", f"origin/{base_ref}"])
if rc == 0:
return f"origin/{base_ref}"
# Try without origin/
rc, _, _ = run_git(["rev-parse", "--verify", base_ref])
if rc == 0:
return base_ref
# If running in GitHub Actions but not a PR (e.g. push to master)
if os.environ.get("GITHUB_ACTIONS"):
rc, _, _ = run_git(["rev-parse", "--verify", "HEAD~1"])
if rc == 0:
return "HEAD~1"
# Otherwise (running locally), check uncommitted changes against HEAD
rc, _, _ = run_git(["rev-parse", "--verify", "HEAD"])
if rc == 0:
return "HEAD"
return None
def parse_frontmatter(path: Path) -> dict:
if not path.exists():
return {}
text = path.read_text(encoding="utf-8")
if not text.startswith("---\n"):
return {}
end = text.find("\n---", 4)
if end == -1:
return {}
# Simple parser for YAML/frontmatter
data = {}
for line in text[4:end].splitlines():
if ":" in line:
k, v = line.split(":", 1)
data[k.strip()] = v.strip().strip('"').strip("'")
return data
def has_non_trivial_changes(file_path: Path, base_commit: str) -> bool:
# Get diff of the file from base_commit to HEAD
rel_path = str(file_path.relative_to(ROOT))
rc, stdout, stderr = run_git(["diff", "-U0", base_commit, "--", rel_path])
if rc != 0:
# If diff fails (e.g. file is new/untracked), it's a change
return True
# Parse diff lines
# We want to count lines added/removed that:
# - do not match framework_version line
# - are not empty/whitespace only
meaningful_changes = 0
version_changed = False
for line in stdout.splitlines():
if line.startswith("+++") or line.startswith("---") or line.startswith("@@"):
continue
if line.startswith("+") or line.startswith("-"):
content = line[1:].strip()
if not content:
continue
if re.match(r"^framework_version\s*:", content):
version_changed = True
continue
# Check if it's just frontmatter syntax (e.g. ---)
if content == "---":
continue
meaningful_changes += 1
# If the version key itself was modified, we don't fail, regardless of other changes
if version_changed:
return False
# If there are meaningful changes but the version was not changed
return meaningful_changes > 0
def main() -> int:
errors = []
# 1. Lint: Check that all framework files have framework_version in frontmatter
for path in FRAMEWORK_FILES:
rel_path = str(path.relative_to(ROOT))
fm = parse_frontmatter(path)
if "framework_version" not in fm:
errors.append(f"{rel_path}: missing 'framework_version' in frontmatter")
# 2. Check for missing version bumps in modified files
base_commit = get_base_commit()
if base_commit:
print(f"Comparing HEAD against base commit: {base_commit}")
for path in FRAMEWORK_FILES:
rel_path = str(path.relative_to(ROOT))
if "framework_version" not in parse_frontmatter(path):
# Skip checking changes if it doesn't even have frontmatter (already reported above)
continue
if has_non_trivial_changes(path, base_commit):
errors.append(
f"{rel_path}: modified without bumping 'framework_version'. "
f"Please update the version in the frontmatter."
)
else:
print("No base commit found (e.g. initial commit or shallow clone without base branch). Skipping diff checks.")
if errors:
print("Framework Version Check Failed:")
for err in errors:
print(f" - {err}")
return 1
print("Framework Version Check: OK")
return 0
if __name__ == "__main__":
sys.exit(main())