fix(registry): reject owner index drift and duplicate discovery rows
Assistant: codex Assistant-Model: gpt-6-astra Assistant-Session: 01a140c4-354a-7823-8f0d-8f717397f786
This commit is contained in:
parent
662782e65a
commit
dd00b27b4c
3 changed files with 168 additions and 4 deletions
|
|
@ -1,6 +1,7 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
|
from collections import Counter
|
||||||
import json
|
import json
|
||||||
import sys
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
@ -87,6 +88,7 @@ from reuse_surface.registry import (
|
||||||
parse_vector,
|
parse_vector,
|
||||||
registry_paths,
|
registry_paths,
|
||||||
resolve_repo_root,
|
resolve_repo_root,
|
||||||
|
vectors_match,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -114,12 +116,48 @@ def _check_index_drift(
|
||||||
repo_root: Path,
|
repo_root: Path,
|
||||||
) -> list[str]:
|
) -> list[str]:
|
||||||
warnings: list[str] = []
|
warnings: list[str] = []
|
||||||
indexed_paths = {item["path"] for item in index.get("capabilities", [])}
|
if not isinstance(index, dict) or not isinstance(index.get("capabilities"), list):
|
||||||
|
return ["index drift: index must contain a capabilities list"]
|
||||||
|
rows = []
|
||||||
|
for position, row in enumerate(index["capabilities"]):
|
||||||
|
if not isinstance(row, dict) or any(
|
||||||
|
not isinstance(row.get(field), str) or not row[field]
|
||||||
|
for field in ("id", "path")
|
||||||
|
):
|
||||||
|
warnings.append(f"index drift: row {position} requires nonempty string id and path")
|
||||||
|
continue
|
||||||
|
rows.append(row)
|
||||||
|
indexed_paths = {item["path"] for item in rows}
|
||||||
file_paths = {str(path.relative_to(repo_root)) for path in entry_paths}
|
file_paths = {str(path.relative_to(repo_root)) for path in entry_paths}
|
||||||
for path in sorted(file_paths - indexed_paths):
|
for path in sorted(file_paths - indexed_paths):
|
||||||
warnings.append(f"index drift: entry file not indexed: {path}")
|
warnings.append(f"index drift: entry file not indexed: {path}")
|
||||||
for path in sorted(indexed_paths - file_paths):
|
for path in sorted(indexed_paths - file_paths):
|
||||||
warnings.append(f"index drift: index references missing file: {path}")
|
warnings.append(f"index drift: index references missing file: {path}")
|
||||||
|
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")
|
||||||
|
availability = entry.get("availability")
|
||||||
|
modes = availability.get("consumption_modes") if isinstance(availability, dict) else None
|
||||||
|
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
|
return warnings
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -145,7 +183,7 @@ def _run_validate(
|
||||||
for path in paths:
|
for path in paths:
|
||||||
try:
|
try:
|
||||||
data = parse_front_matter(path)
|
data = parse_front_matter(path)
|
||||||
except ValueError as exc:
|
except (ValueError, yaml.YAMLError) as exc:
|
||||||
errors.append(str(exc))
|
errors.append(str(exc))
|
||||||
continue
|
continue
|
||||||
for error in sorted(validator.iter_errors(data), key=lambda e: e.path):
|
for error in sorted(validator.iter_errors(data), key=lambda e: e.path):
|
||||||
|
|
@ -155,8 +193,12 @@ def _run_validate(
|
||||||
if not target:
|
if not target:
|
||||||
index_path = registry_paths(repo_root)["index"]
|
index_path = registry_paths(repo_root)["index"]
|
||||||
if index_path.exists():
|
if index_path.exists():
|
||||||
index = load_index_at(index_path)
|
try:
|
||||||
warnings.extend(_check_index_drift(paths, index, repo_root))
|
index = load_index_at(index_path)
|
||||||
|
except (ValueError, yaml.YAMLError) as exc:
|
||||||
|
errors.append(f"{index_path}: {exc}")
|
||||||
|
else:
|
||||||
|
warnings.extend(_check_index_drift(paths, index, repo_root))
|
||||||
if relations and repo_root == ROOT:
|
if relations and repo_root == ROOT:
|
||||||
warnings.extend(check_relations())
|
warnings.extend(check_relations())
|
||||||
return errors, warnings, paths
|
return errors, warnings, paths
|
||||||
|
|
|
||||||
91
tests/test_index_parity.py
Normal file
91
tests/test_index_parity.py
Normal file
|
|
@ -0,0 +1,91 @@
|
||||||
|
"""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
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("source", ["null", "[]", "availability: null", "availability: []", "availability: ["])
|
||||||
|
def test_malformed_frontmatter_returns_failure_without_traceback(tmp_path, source):
|
||||||
|
path, index = fixture_registry(tmp_path)
|
||||||
|
path.write_text(yaml.safe_dump(index))
|
||||||
|
(tmp_path / index["capabilities"][0]["path"]).write_text("---\n" + source + "\n---\n")
|
||||||
|
result = validate(tmp_path)
|
||||||
|
assert result.returncode == 1
|
||||||
|
assert "Traceback" not in result.stderr
|
||||||
|
assert "error:" in result.stderr
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("rows", [None, {}, [None], [[]], [{}], [{"id": [], "path": "x"}], [{"id": "x", "path": None}]])
|
||||||
|
def test_unvalidated_index_shape_returns_failure_without_traceback(tmp_path, rows):
|
||||||
|
path, index = fixture_registry(tmp_path)
|
||||||
|
index["capabilities"] = rows
|
||||||
|
path.write_text(yaml.safe_dump(index))
|
||||||
|
result = validate(tmp_path)
|
||||||
|
assert result.returncode == 1
|
||||||
|
assert "Traceback" not in result.stderr
|
||||||
|
assert "index drift:" in result.stderr
|
||||||
31
workplans/ADHOC-2026-10-16.md
Normal file
31
workplans/ADHOC-2026-10-16.md
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
---
|
||||||
|
id: REUSE-WP-ADHOC-2026-10-16
|
||||||
|
type: workplan
|
||||||
|
title: "Refuse owner/index drift in the existing native registry validator"
|
||||||
|
domain: infotech
|
||||||
|
repo: reuse-surface
|
||||||
|
status: finished
|
||||||
|
owner: codex
|
||||||
|
created: "2026-10-16"
|
||||||
|
updated: "2026-10-16"
|
||||||
|
flavor: implementation
|
||||||
|
---
|
||||||
|
|
||||||
|
## Refuse owner/index drift in the existing native registry validator
|
||||||
|
|
||||||
|
```task
|
||||||
|
id: REUSE-WP-ADHOC-2026-10-16-T01
|
||||||
|
status: done
|
||||||
|
priority: medium
|
||||||
|
```
|
||||||
|
|
||||||
|
The existing strict validator accepted stale index metadata and duplicate IDs/paths. Extend its existing parity warnings and add native CLI regression tests, including malformed frontmatter/index shape without tracebacks. Existing mandatory Forgejo validation consumes this source; fleet adoption and hosted freshness are unverified.
|
||||||
|
|
||||||
|
Completed 2026-10-16 under the founder's NetKingdom target implementation demand
|
||||||
|
(CUST-WP-0080, TSA-11). Source review and exact before/after native CLI evidence:
|
||||||
|
`the-custodian/docs/changes/netkingdom-target-2026-10-16/improvement-loop-next/`.
|
||||||
|
The current 60-provider cohort has all 70 reviewed IDs exactly once; 3 field
|
||||||
|
mismatches across 2 capabilities become zero with the source corrections.
|
||||||
|
Native hosted refresh/readback and the accepted Activity/rein schedule remain
|
||||||
|
separate gates. No production change, model call, credential access or external
|
||||||
|
message was performed.
|
||||||
Loading…
Add table
Add a link
Reference in a new issue