122 lines
4.6 KiB
Diff
122 lines
4.6 KiB
Diff
--- 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
|