--- a/reuse_surface/cli.py +++ b/reuse_surface/cli.py @@ -1,6 +1,7 @@ from __future__ import annotations import argparse +from collections import Counter import json import sys from pathlib import Path @@ -87,6 +88,7 @@ parse_vector, registry_paths, resolve_repo_root, + vectors_match, ) @@ -120,6 +122,31 @@ warnings.append(f"index drift: entry file not indexed: {path}") for path in sorted(indexed_paths - file_paths): warnings.append(f"index drift: index references missing file: {path}") + rows = index.get("capabilities", []) + for field in ("id", "path"): + for value, count in sorted(Counter(row[field] for row in rows).items()): + if count > 1: + warnings.append(f"index drift: duplicate {field}: {value} ({count} rows)") + for row in rows: + path = repo_root / row["path"] + if not path.is_file(): + continue + try: + entry = parse_front_matter(path) + except (ValueError, yaml.YAMLError): + continue # The entry validation pass reports malformed source. + # The index projects the owner entry; index-only changes cannot replace it. + for field in ("id", "name", "summary", "domain", "status", "owner", "tags"): + if row.get(field) != entry.get(field): + warnings.append(f"index drift: {row['path']}: {field} differs from entry") + modes = entry.get("availability", {}).get("consumption_modes") + if row.get("consumption_modes") != modes: + warnings.append(f"index drift: {row['path']}: consumption_modes differs from entry") + try: + if not vectors_match(row["vector"], entry): + warnings.append(f"index drift: {row['path']}: vector differs from entry") + except (KeyError, TypeError, AttributeError): + warnings.append(f"index drift: {row['path']}: vector cannot be compared to entry") return warnings --- /dev/null +++ b/tests/test_index_parity.py @@ -0,0 +1,69 @@ +"""Exercise the same strict native validator used by the existing Forgejo job.""" +from __future__ import annotations + +import copy +import subprocess +import sys +from pathlib import Path + +import pytest +import yaml + +ROOT = Path(__file__).resolve().parent.parent + + +def fixture_registry(tmp_path): + index = yaml.safe_load((ROOT / "registry/indexes/capabilities.yaml").read_text()) + row = copy.deepcopy(index["capabilities"][0]) + source = ROOT / row["path"] + target = tmp_path / row["path"] + target.parent.mkdir(parents=True) + target.write_bytes(source.read_bytes()) + index["capabilities"] = [row] + index_path = tmp_path / "registry/indexes/capabilities.yaml" + index_path.parent.mkdir(parents=True) + return index_path, index + + +def validate(root): + return subprocess.run( + [sys.executable, "-m", "reuse_surface.cli", "validate", "--root", str(root), + "--fail-on-warnings"], cwd=ROOT, capture_output=True, text=True, + ) + + +@pytest.mark.parametrize("field,value", [ + ("id", "capability.synthetic.stale"), ("name", "stale name"), + ("summary", "stale summary"), ("domain", "stale-domain"), + ("status", "retired"), ("owner", "wrong-owner"), + ("tags", ["stale"]), ("consumption_modes", ["stale"]), + ("vector", "D0 / A0 / C0 / R0"), +]) +def test_existing_strict_validator_refuses_stale_projection(tmp_path, field, value): + path, index = fixture_registry(tmp_path) + path.write_text(yaml.safe_dump(index)) + assert validate(tmp_path).returncode == 0 + original_entry = (tmp_path / index["capabilities"][0]["path"]).read_bytes() + index["capabilities"][0][field] = value + path.write_text(yaml.safe_dump(index)) + result = validate(tmp_path) + assert result.returncode == 1, result.stdout + result.stderr + assert f"{field} differs from entry" in result.stderr + assert (tmp_path / index["capabilities"][0]["path"]).read_bytes() == original_entry + + +def test_duplicate_id_and_path_cannot_satisfy_exactly_once(tmp_path): + path, index = fixture_registry(tmp_path) + index["capabilities"].append(copy.deepcopy(index["capabilities"][0])) + path.write_text(yaml.safe_dump(index)) + result = validate(tmp_path) + assert result.returncode == 1 + assert "duplicate id:" in result.stderr + assert "duplicate path:" in result.stderr + + +def test_equivalent_vector_whitespace_is_allowed(tmp_path): + path, index = fixture_registry(tmp_path) + index["capabilities"][0]["vector"] = index["capabilities"][0]["vector"].replace(" ", "") + path.write_text(yaml.safe_dump(index)) + assert validate(tmp_path).returncode == 0