From 24b9b3d5ee375ec7e19a619624bcfc4a7ddb887d Mon Sep 17 00:00:00 2001 From: stunspot Date: Sun, 2 Aug 2026 13:15:32 -0500 Subject: [PATCH] feat: add TestForge line-ending policy gate --- .editorconfig | 9 + .gitattributes | 7 + .github/workflows/test.yml | 10 + line-ending-policy/README.md | 45 ++++ line-ending-policy/action.yml | 8 + release-manifest.json | 73 ++++++- tests/test_line_ending_policy.py | 159 ++++++++++++++ tests/test_public_distribution.py | 5 + tools/verify_line_endings.py | 351 ++++++++++++++++++++++++++++++ 9 files changed, 658 insertions(+), 9 deletions(-) create mode 100644 .editorconfig create mode 100644 line-ending-policy/README.md create mode 100644 line-ending-policy/action.yml create mode 100644 tests/test_line_ending_policy.py create mode 100644 tools/verify_line_endings.py diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..d0a8f0c --- /dev/null +++ b/.editorconfig @@ -0,0 +1,9 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true + +[*.{bat,cmd}] +end_of_line = crlf diff --git a/.gitattributes b/.gitattributes index 3a1178a..51174f1 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,5 +1,12 @@ * text=auto eol=lf +*.bat text eol=crlf +*.cmd text eol=crlf + +*.docx binary +*.pdf binary +*.pptx binary +*.xlsx binary *.gif binary *.ico binary *.jpg binary diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index c2e163a..e180742 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -8,6 +8,16 @@ permissions: contents: read jobs: + line-ending-policy: + name: line-ending-policy + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - run: python -B tools/verify_line_endings.py --root . + test: strategy: fail-fast: false diff --git a/line-ending-policy/README.md b/line-ending-policy/README.md new file mode 100644 index 0000000..5d76ee4 --- /dev/null +++ b/line-ending-policy/README.md @@ -0,0 +1,45 @@ +# TestForge line-ending policy gate + +This composite action verifies the repository's committed Git line-ending contract before a change can merge. It supports two profiles: + +- Standard: LF-authored text, CRLF `.bat`/`.cmd` working-tree exceptions, binary `-text` rules, and a matching EditorConfig baseline. +- Byte custody: a root `* -text` contract for repositories whose tracked bytes must remain untouched. + +The verifier inspects effective Git attributes and the Git index. Scoped `-text` paths remain exempt from CR-byte rejection, so release snapshots and canonical byte trees can retain intentional bytes. + +## Caller workflow + +Pin the action to a reviewed TestForge commit: + +```yaml +name: TestForge line-ending policy + +on: + pull_request: + push: + branches: [main] + +permissions: + contents: read + +jobs: + line-ending-policy: + name: line-ending-policy + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - uses: Stunspot/TestForge/line-ending-policy@REPLACE_WITH_REVIEWED_COMMIT_SHA +``` + +Make the `line-ending-policy` job a required status check on the default branch. The workflow detects violations; the GitHub ruleset prevents them from landing. + +## Local command + +```text +python -B tools/verify_line_endings.py --root PATH_TO_REPOSITORY +``` + +The command returns JSON and exits nonzero for a policy violation or inspection error. diff --git a/line-ending-policy/action.yml b/line-ending-policy/action.yml new file mode 100644 index 0000000..5b6f743 --- /dev/null +++ b/line-ending-policy/action.yml @@ -0,0 +1,8 @@ +name: TestForge line-ending policy +description: Verify the Collaborative Dynamics LF, Windows-command, binary, and byte-custody contract. +runs: + using: composite + steps: + - name: Verify line-ending policy + shell: bash + run: python "$GITHUB_ACTION_PATH/../tools/verify_line_endings.py" --root "$GITHUB_WORKSPACE" diff --git a/release-manifest.json b/release-manifest.json index a20e3b6..c656d36 100644 --- a/release-manifest.json +++ b/release-manifest.json @@ -3,22 +3,32 @@ "package": "testforge-public-repository", "version": "1.1.1", "release_date": "2026-07-20", - "artifact_count": 687, + "artifact_count": 698, "artifacts": [ { "path": ".agents/plugins/marketplace.json", "size": 397, "sha256": "4f1010e892c432ba62b62c2d611af692b19cdfaeb24a7d1e8aa0d0d034421ed7" }, + { + "path": ".editorconfig", + "size": 112, + "sha256": "3f98ae7d1a00751db3e32bdb4b2b3f53df8417d08946469bae79db6fb6e15334" + }, { "path": ".gitattributes", - "size": 125, - "sha256": "a6e4397b4120000046fa9bfacb31df844160cab51b0fb7a45d084241c0c04910" + "size": 221, + "sha256": "138131eed771585ddd21d950e40821fc669a4b48210d879a43dfdf8fabdbe712" + }, + { + "path": ".github/workflows/deploy-pages.yml", + "size": 781, + "sha256": "7a060c30a1d7bd0995ab6ddbe75a4140402f0eea4227637095cc814f0874a6c4" }, { "path": ".github/workflows/test.yml", - "size": 828, - "sha256": "c63e6515daf1b0d1af1f9de91d9c0ac2bccc0d99ce758364231f18aa85747c0a" + "size": 1098, + "sha256": "95c53e37b928d33c264b01e69cd35e68d34a33046673d72b3bc69301b5d86652" }, { "path": ".gitignore", @@ -85,6 +95,31 @@ "size": 959, "sha256": "57bf58033a9570984271c4e39ec79387ff09af9cbd9e81bb2736ade080452399" }, + { + "path": "docs/.nojekyll", + "size": 0, + "sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + }, + { + "path": "docs/assets/testforge-hero.png", + "size": 1481766, + "sha256": "d327ba98cb45afbe7a939e0f265eb51db98a50258d5ba8c117e6573e98c7d05b" + }, + { + "path": "docs/index.html", + "size": 17593, + "sha256": "aab19b819b6c772cb70f2604e65001715b081005d280750eed042c198867e9cc" + }, + { + "path": "docs/SITE-SOURCE.md", + "size": 1835, + "sha256": "428deaaf6ae81cde85aae1551c3afa51d84d95f256672df17b20298e884c4cf5" + }, + { + "path": "docs/style.css", + "size": 16589, + "sha256": "1c9cd32a619426ba4c0925dc710c3c9944beabf0b3c7979b188010f5452de1e4" + }, { "path": "documentation-manifest.json", "size": 1677, @@ -120,6 +155,16 @@ "size": 2770, "sha256": "ed7874c404bbcf284cb8e3a10ffe2f4c13b0dffec6238215d2171df658fb124e" }, + { + "path": "line-ending-policy/action.yml", + "size": 327, + "sha256": "b0160dbc726839c2a8433e877ff6d486d09c2016f8091192da33bba1afe0704d" + }, + { + "path": "line-ending-policy/README.md", + "size": 1455, + "sha256": "7652ef8b6cf2f63fed3416445692b36c442fc767369bc7c0dd353d69b452b002" + }, { "path": "NOTICE.md", "size": 680, @@ -662,8 +707,8 @@ }, { "path": "README.md", - "size": 8721, - "sha256": "692953a48b51e18853fe19bc2626ce1bc9d24cf9a8d4c36c11176ccefd164a80" + "size": 8905, + "sha256": "6c0bf259a9c61e94a9e9704d448d0ba92133c05a6f3acc073091dc9aac4251c3" }, { "path": "RELEASE-NOTES-v1.1.0.md", @@ -3135,10 +3180,15 @@ "size": 3729, "sha256": "1b47b54c993d72d2bc8ebd5921794a3f83ab993fd25ad49d2ab53d97b2947091" }, + { + "path": "tests/test_line_ending_policy.py", + "size": 5373, + "sha256": "c41bd426a3573101df09d45cb59e6f14f988da7c5d809dfd345ad485b349cb9a" + }, { "path": "tests/test_public_distribution.py", - "size": 6286, - "sha256": "8027ec37dfb3c4381c4eb4c30ef3d5e264af886614dd287483cc5ba3350479b2" + "size": 6559, + "sha256": "f4361101c9359066f3e9963124acfc01a0d14b72e3b7611aaffe40f9a34efb1a" }, { "path": "tools/augment-evals/.gitignore", @@ -3255,6 +3305,11 @@ "size": 4289, "sha256": "fac03f5b5329481dc97a5a7fc204b5f7ad5bb9c266006be8ecd46a89feec4394" }, + { + "path": "tools/verify_line_endings.py", + "size": 13672, + "sha256": "e225276be3d42734cfbed5c25f7c33e840f4fb7531e6126545339bf2daaa3ec1" + }, { "path": "TRADEMARKS.md", "size": 762, diff --git a/tests/test_line_ending_policy.py b/tests/test_line_ending_policy.py new file mode 100644 index 0000000..1be4aae --- /dev/null +++ b/tests/test_line_ending_policy.py @@ -0,0 +1,159 @@ +from __future__ import annotations + +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "tools")) + +from verify_line_endings import verify as verify_line_endings + + +STANDARD_ATTRIBUTES = """* text=auto eol=lf +*.bat text eol=crlf +*.cmd text eol=crlf +*.zip binary +*.docx binary +*.xlsx binary +*.pptx binary +*.pdf binary +*.png binary +*.jpg binary +*.jpeg binary +*.gif binary +*.ico binary +""" + +STANDARD_EDITORCONFIG = """root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true + +[*.{bat,cmd}] +end_of_line = crlf +""" + + +class LineEndingPolicyTests(unittest.TestCase): + def setUp(self): + self.temporary = tempfile.TemporaryDirectory() + self.root = Path(self.temporary.name) + subprocess.run(["git", "init", "-q"], cwd=self.root, check=True) + + def tearDown(self): + self.temporary.cleanup() + + def git_add(self, *paths: str): + subprocess.run( + ["git", "-c", "core.autocrlf=false", "add", *paths], + cwd=self.root, + check=True, + capture_output=True, + ) + + def add_raw_blob(self, path: str, data: bytes): + object_id = subprocess.run( + ["git", "hash-object", "-w", "--stdin"], + cwd=self.root, + check=True, + capture_output=True, + input=data, + ).stdout.decode("ascii").strip() + subprocess.run( + ["git", "update-index", "--add", "--cacheinfo", f"100644,{object_id},{path}"], + cwd=self.root, + check=True, + capture_output=True, + ) + + def write_standard_policy(self): + (self.root / ".gitattributes").write_text(STANDARD_ATTRIBUTES, encoding="utf-8", newline="\n") + (self.root / ".editorconfig").write_text(STANDARD_EDITORCONFIG, encoding="utf-8", newline="\n") + + def test_standard_policy_passes_effective_and_blob_checks(self): + self.write_standard_policy() + (self.root / "README.md").write_bytes(b"one\ntwo\n") + self.git_add(".") + result = verify_line_endings(self.root) + self.assertEqual("PASS", result["status"]) + self.assertEqual("standard", result["profile"]) + self.assertEqual([], result["indexed_text_cr_paths"]) + + def test_missing_windows_and_binary_rules_fail_effective_probes(self): + (self.root / ".gitattributes").write_text("* text=auto eol=lf\n", encoding="utf-8") + (self.root / ".editorconfig").write_text(STANDARD_EDITORCONFIG, encoding="utf-8") + self.git_add(".") + result = verify_line_endings(self.root) + self.assertEqual("FAIL", result["status"]) + self.assertTrue(any(item["code"] == "ATTRIBUTE_MISMATCH" for item in result["findings"])) + + def test_crlf_already_in_index_is_rejected(self): + (self.root / "bad.txt").write_bytes(b"one\r\ntwo\r\n") + self.add_raw_blob("bad.txt", b"one\r\ntwo\r\n") + self.write_standard_policy() + self.git_add(".gitattributes", ".editorconfig") + result = verify_line_endings(self.root) + self.assertEqual("FAIL", result["status"]) + self.assertEqual(["bad.txt"], result["indexed_text_cr_paths"]) + + def test_scoped_byte_custody_exempts_canonical_cr_bytes(self): + self.write_standard_policy() + with (self.root / ".gitattributes").open("a", encoding="utf-8", newline="\n") as stream: + stream.write("/release/** -text\n") + release = self.root / "release" + release.mkdir() + (release / "receipt.md").write_bytes(b"one\r\ntwo\r\n") + self.git_add(".") + result = verify_line_endings(self.root) + self.assertEqual("PASS", result["status"]) + self.assertEqual([], result["indexed_text_cr_paths"]) + + def test_gitlink_is_not_scanned_as_a_blob(self): + commit = ( + b"tree 4b825dc642cb6eb9a060e54bf8d69288fbee4904\n" + b"author Test 0 +0000\n" + b"committer Test 0 +0000\n" + b"\nsubmodule fixture\n" + ) + object_id = subprocess.run( + ["git", "hash-object", "-t", "commit", "-w", "--stdin"], + cwd=self.root, + check=True, + capture_output=True, + input=commit, + ).stdout.decode("ascii").strip() + subprocess.run( + [ + "git", + "update-index", + "--add", + "--cacheinfo", + f"160000,{object_id},vendor/example", + ], + cwd=self.root, + check=True, + capture_output=True, + ) + self.write_standard_policy() + self.git_add(".gitattributes", ".editorconfig") + result = verify_line_endings(self.root) + self.assertEqual("PASS", result["status"]) + self.assertEqual([], result["indexed_text_cr_paths"]) + + def test_root_byte_custody_is_a_separate_passing_profile(self): + (self.root / ".gitattributes").write_text("* -text\n", encoding="utf-8") + (self.root / "canonical.bin").write_bytes(b"one\r\ntwo\r\n") + self.git_add(".") + result = verify_line_endings(self.root) + self.assertEqual("PASS", result["status"]) + self.assertEqual("byte-custody", result["profile"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_public_distribution.py b/tests/test_public_distribution.py index fd2b79b..d0f2b6e 100644 --- a/tests/test_public_distribution.py +++ b/tests/test_public_distribution.py @@ -148,6 +148,11 @@ def test_public_tree_has_no_cache_debris(self): ] self.assertEqual([], debris) + def test_public_line_ending_action_uses_canonical_verifier(self): + action = (ROOT / "line-ending-policy" / "action.yml").read_text(encoding="utf-8") + self.assertIn("tools/verify_line_endings.py", action) + self.assertIn("GITHUB_WORKSPACE", action) + def test_declared_customer_documents_exist(self): documentation = json.loads( (ROOT / "documentation-manifest.json").read_text(encoding="utf-8") diff --git a/tools/verify_line_endings.py b/tools/verify_line_endings.py new file mode 100644 index 0000000..6049b85 --- /dev/null +++ b/tools/verify_line_endings.py @@ -0,0 +1,351 @@ +#!/usr/bin/env python3 +"""Verify the Collaborative Dynamics line-ending contract in a Git index.""" + +from __future__ import annotations + +import argparse +import json +import os +import re +import subprocess +import sys +from pathlib import Path +from typing import Any + + +SCHEMA = "testforge-line-ending-policy/v1" +ROOT_BYTE_CUSTODY = re.compile(r"^\*\s+-text(?:\s*(?:#.*)?)?$", re.MULTILINE) +BINARY_PROBES = ("zip", "docx", "xlsx", "pptx", "pdf", "png", "jpg", "jpeg", "gif", "ico") + + +class VerificationError(RuntimeError): + """Raised when the verifier cannot inspect the requested repository.""" + + +def run_git(root: Path, *arguments: str) -> subprocess.CompletedProcess[bytes]: + environment = os.environ.copy() + environment["GIT_OPTIONAL_LOCKS"] = "0" + return subprocess.run( + ["git", "-c", "core.autocrlf=false", *arguments], + cwd=root, + check=False, + capture_output=True, + env=environment, + ) + + +def git_output(root: Path, *arguments: str) -> str: + completed = run_git(root, *arguments) + if completed.returncode != 0: + detail = (completed.stderr or completed.stdout).decode("utf-8", errors="replace").strip() + raise VerificationError(f"git {' '.join(arguments)} failed: {detail}") + return completed.stdout.decode("utf-8", errors="replace") + + +def finding(code: str, message: str, path: str | None = None) -> dict[str, str]: + result = {"code": code, "message": message} + if path is not None: + result["path"] = path + return result + + +def verify_lf_policy_file(path: Path, findings: list[dict[str, str]]) -> None: + data = path.read_bytes() + if b"\r" in data: + findings.append(finding("POLICY_FILE_CR", "Policy file contains CR bytes.", path.name)) + if not data.endswith(b"\n"): + findings.append(finding("POLICY_FILE_FINAL_LF", "Policy file lacks a final LF.", path.name)) + + +def resolved_attributes(root: Path, probe: str) -> dict[str, str]: + output = git_output(root, "check-attr", "text", "eol", "binary", "--", probe) + result: dict[str, str] = {} + prefix = f"{probe}: " + for line in output.splitlines(): + if not line.startswith(prefix): + continue + attribute, separator, value = line[len(prefix) :].partition(": ") + if separator: + result[attribute] = value + return result + + +def editorconfig_values(data: bytes) -> dict[tuple[str | None, str], str]: + try: + text = data.decode("utf-8-sig") + except UnicodeDecodeError as error: + raise VerificationError(".editorconfig is not UTF-8") from error + section: str | None = None + values: dict[tuple[str | None, str], str] = {} + for raw_line in text.splitlines(): + line = raw_line.strip() + if not line or line.startswith(("#", ";")): + continue + if line.startswith("[") and line.endswith("]"): + section = line.casefold() + continue + key, separator, value = line.partition("=") + if separator: + values[(section, key.strip().casefold())] = value.strip().casefold() + return values + + +def editorconfig_windows_crlf(values: dict[tuple[str | None, str], str], extension: str) -> bool: + combined = values.get(("[*.{bat,cmd}]", "end_of_line")) + specific = values.get((f"[*.{extension}]", "end_of_line")) + return combined == "crlf" or specific == "crlf" + + +def verify_editorconfig(path: Path, findings: list[dict[str, str]]) -> None: + verify_lf_policy_file(path, findings) + try: + values = editorconfig_values(path.read_bytes()) + except VerificationError as error: + findings.append(finding("EDITORCONFIG_ENCODING", str(error), path.name)) + return + required = { + (None, "root"): "true", + ("[*]", "charset"): "utf-8", + ("[*]", "end_of_line"): "lf", + ("[*]", "insert_final_newline"): "true", + } + for key, expected in required.items(): + if values.get(key) != expected: + findings.append( + finding( + "EDITORCONFIG_BASELINE", + f"Expected {key[1]}={expected} in {key[0] or 'root'}, found {values.get(key)!r}.", + path.name, + ) + ) + for extension in ("bat", "cmd"): + if not editorconfig_windows_crlf(values, extension): + findings.append( + finding( + "EDITORCONFIG_WINDOWS_CRLF", + f"No CRLF EditorConfig override applies to *.{extension}.", + path.name, + ) + ) + + +def cached_text_attributes(root: Path, paths: list[str]) -> dict[str, str]: + values: dict[str, str] = {} + for offset in range(0, len(paths), 100): + chunk = paths[offset : offset + 100] + completed = run_git(root, "check-attr", "--cached", "-z", "text", "--", *chunk) + if completed.returncode != 0: + detail = (completed.stderr or completed.stdout).decode("utf-8", errors="replace").strip() + raise VerificationError(f"git check-attr --cached failed: {detail}") + fields = [field.decode("utf-8", errors="replace") for field in completed.stdout.split(b"\0") if field] + if len(fields) % 3: + raise VerificationError("git check-attr --cached returned malformed output") + for index in range(0, len(fields), 3): + path, attribute, value = fields[index : index + 3] + if attribute == "text": + values[path] = value + return values + + +def indexed_blobs(root: Path) -> list[tuple[str, str]]: + completed = run_git(root, "ls-files", "--stage", "-z") + if completed.returncode != 0: + detail = (completed.stderr or completed.stdout).decode("utf-8", errors="replace").strip() + raise VerificationError(f"git ls-files --stage failed: {detail}") + blobs: list[tuple[str, str]] = [] + for record in completed.stdout.split(b"\0"): + if not record: + continue + metadata, separator, path_bytes = record.partition(b"\t") + fields = metadata.split() + if not separator or len(fields) != 3: + raise VerificationError("git ls-files --stage returned malformed output") + mode, object_id, stage = fields + if stage != b"0": + continue + if mode == b"160000": + continue + if mode not in {b"100644", b"100755", b"120000"}: + raise VerificationError( + f"git ls-files --stage returned unsupported mode {mode.decode('ascii', errors='replace')}" + ) + blobs.append( + ( + path_bytes.decode("utf-8", errors="replace"), + object_id.decode("ascii"), + ) + ) + return blobs + + +def blob_cr_paths(root: Path, blobs: list[tuple[str, str]]) -> list[str]: + if not blobs: + return [] + process = subprocess.Popen( + ["git", "-c", "core.autocrlf=false", "cat-file", "--batch"], + cwd=root, + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + env={**os.environ, "GIT_OPTIONAL_LOCKS": "0"}, + ) + request = "".join(f"{object_id}\n" for _path, object_id in blobs).encode("ascii") + stdout, stderr = process.communicate(request) + if process.returncode != 0: + detail = (stderr or stdout).decode("utf-8", errors="replace").strip() + raise VerificationError(f"git cat-file --batch failed: {detail}") + + offset = 0 + paths: list[str] = [] + for path, expected_id in blobs: + header_end = stdout.find(b"\n", offset) + if header_end < 0: + raise VerificationError("git cat-file --batch returned a truncated header") + header = stdout[offset:header_end].split() + if len(header) != 3: + raise VerificationError("git cat-file --batch returned malformed object metadata") + object_id, object_type, size_bytes = header + if object_id.decode("ascii") != expected_id or object_type != b"blob": + raise VerificationError("git cat-file --batch returned an unexpected object") + try: + size = int(size_bytes) + except ValueError as error: + raise VerificationError("git cat-file --batch returned an invalid blob size") from error + content_start = header_end + 1 + content_end = content_start + size + if content_end >= len(stdout) or stdout[content_end : content_end + 1] != b"\n": + raise VerificationError("git cat-file --batch returned truncated blob content") + if b"\r" in stdout[content_start:content_end]: + paths.append(path) + offset = content_end + 1 + if offset != len(stdout): + raise VerificationError("git cat-file --batch returned unexpected trailing output") + return paths + + +def indexed_text_with_cr(root: Path) -> list[str]: + blobs = indexed_blobs(root) + attributes = cached_text_attributes(root, [path for path, _object_id in blobs]) + text_blobs = [ + (path, object_id) + for path, object_id in blobs + if attributes.get(path) != "unset" + ] + return sorted(blob_cr_paths(root, text_blobs)) + + +def verify(root: Path) -> dict[str, Any]: + root = root.resolve() + if run_git(root, "rev-parse", "--is-inside-work-tree").returncode != 0: + raise VerificationError(f"not a Git worktree: {root}") + + findings: list[dict[str, str]] = [] + attributes_path = root / ".gitattributes" + if not attributes_path.is_file(): + return { + "schema": SCHEMA, + "status": "FAIL", + "profile": "missing", + "root": str(root), + "findings": [finding("GITATTRIBUTES_MISSING", "Repository lacks .gitattributes.", ".gitattributes")], + "probes": {}, + "indexed_text_cr_paths": [], + } + + try: + attributes_text = attributes_path.read_text(encoding="utf-8-sig") + except UnicodeDecodeError: + attributes_text = "" + findings.append(finding("GITATTRIBUTES_ENCODING", ".gitattributes is not UTF-8.", ".gitattributes")) + + profile = "byte-custody" if ROOT_BYTE_CUSTODY.search(attributes_text) else "standard" + probes: dict[str, dict[str, str]] = {} + if profile == "byte-custody": + probe = ".testforge-line-ending-probe.txt" + probes[probe] = resolved_attributes(root, probe) + if probes[probe].get("text") != "unset": + findings.append( + finding("BYTE_CUSTODY_INEFFECTIVE", f"Root byte custody did not resolve text=unset: {probes[probe]}") + ) + cr_paths: list[str] = [] + else: + verify_lf_policy_file(attributes_path, findings) + editorconfig_path = root / ".editorconfig" + if editorconfig_path.is_file(): + verify_editorconfig(editorconfig_path, findings) + else: + findings.append(finding("EDITORCONFIG_MISSING", "Repository lacks .editorconfig.", ".editorconfig")) + + expected_probes: dict[str, dict[str, set[str]]] = { + ".testforge-line-ending-probe.txt": {"text": {"auto", "set"}, "eol": {"lf"}}, + ".testforge-line-ending-probe.bat": {"text": {"set"}, "eol": {"crlf"}}, + ".testforge-line-ending-probe.cmd": {"text": {"set"}, "eol": {"crlf"}}, + } + for extension in BINARY_PROBES: + expected_probes[f".testforge-line-ending-probe.{extension}"] = {"text": {"unset"}} + + for probe, expected in expected_probes.items(): + resolved = resolved_attributes(root, probe) + probes[probe] = resolved + for attribute, allowed in expected.items(): + if resolved.get(attribute) not in allowed: + findings.append( + finding( + "ATTRIBUTE_MISMATCH", + f"{probe} expected {attribute} in {sorted(allowed)}, resolved {resolved}.", + ".gitattributes", + ) + ) + + cr_paths = indexed_text_with_cr(root) + for path in cr_paths: + findings.append( + finding( + "INDEXED_TEXT_CR", + "Tracked non-binary blob contains CR bytes; normalize it to LF in the Git index.", + path, + ) + ) + + return { + "schema": SCHEMA, + "status": "PASS" if not findings else "FAIL", + "profile": profile, + "root": str(root), + "findings": findings, + "probes": probes, + "indexed_text_cr_paths": cr_paths, + } + + +def parse_args(argv: list[str]) -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--root", type=Path, default=Path.cwd()) + parser.add_argument("--json-out", type=Path) + return parser.parse_args(argv) + + +def main(argv: list[str] | None = None) -> int: + args = parse_args(argv or sys.argv[1:]) + try: + report = verify(args.root) + except (OSError, VerificationError) as error: + report = { + "schema": SCHEMA, + "status": "ERROR", + "profile": "unknown", + "root": str(args.root.resolve()), + "findings": [finding("VERIFIER_ERROR", str(error))], + "probes": {}, + "indexed_text_cr_paths": [], + } + rendered = json.dumps(report, ensure_ascii=False, indent=2, sort_keys=True) + "\n" + if args.json_out: + args.json_out.parent.mkdir(parents=True, exist_ok=True) + args.json_out.write_text(rendered, encoding="utf-8", newline="\n") + sys.stdout.write(rendered) + return 0 if report["status"] == "PASS" else 1 + + +if __name__ == "__main__": + raise SystemExit(main())