Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions scripts/generate_skill_index.py
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,34 @@ def build_catalog() -> dict:
}


def load_existing_catalog() -> dict | None:
"""Return the currently committed catalog, or None if it doesn't exist/parse."""
if not INDEX_PATH.exists():
return None
try:
return json.loads(INDEX_PATH.read_text())
except json.JSONDecodeError:
return None


def pin_unchanged_generated_at(catalog: dict, existing: dict | None) -> None:
"""Reuse the committed `generated_at` when the actual catalog content
(schema_version + skills) hasn't changed.

`generated_at` is a "last regenerated" timestamp, not build-time metadata:
it should only advance when a skill doc actually changes. Without this,
every run stamps today's date, which makes `--check` fail on pure
calendar drift (e.g. a PR branch cut before today, or CI running a day
after the last regeneration) even though no skill content is stale.
"""
if (
existing is not None
and existing.get("schema_version") == catalog["schema_version"]
and existing.get("skills") == catalog["skills"]
):
catalog["generated_at"] = existing.get("generated_at", catalog["generated_at"])


def validate_catalog(catalog: dict) -> None:
schema = json.loads(SCHEMA_PATH.read_text())
validator = Draft202012Validator(schema)
Expand Down Expand Up @@ -137,6 +165,7 @@ def main() -> int:

try:
catalog = build_catalog()
pin_unchanged_generated_at(catalog, load_existing_catalog())
validate_catalog(catalog)
except ValueError as e:
print(f"error: {e}", file=sys.stderr)
Expand Down
56 changes: 56 additions & 0 deletions tests/test_skill_docs.py
Original file line number Diff line number Diff line change
Expand Up @@ -251,6 +251,62 @@ def test_generate_skill_index_round_trip(tmp_path: Path, monkeypatch: pytest.Mon
assert mod.main() == 0


def test_generate_skill_index_check_tolerates_stale_generated_at(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""A `--check` run must not fail purely because `generated_at` no longer
matches today's date. Real-world PR CI runs on a different calendar day
than the last regeneration on main, and that alone is not staleness.
"""
repo_root = make_skill_tree(tmp_path)
mod = load_generate_skill_index()
patch_skill_index_paths(mod, repo_root)

monkeypatch.setattr(sys, "argv", ["generate_skill_index.py", "--write"])
assert mod.main() == 0

index_path = repo_root / "docs" / "skills" / "index.json"
md_path = repo_root / "docs" / "skills" / "index.md"

# Simulate calendar drift: back-date the committed files without
# touching any skill content.
data = json.loads(index_path.read_text())
data["generated_at"] = "2020-01-01"
index_path.write_text(json.dumps(data, indent=2) + "\n")
md_text = md_path.read_text().replace(
f"Generated: {mod.date.today().isoformat()}", "Generated: 2020-01-01"
)
md_path.write_text(md_text)

monkeypatch.setattr(sys, "argv", ["generate_skill_index.py", "--check"])
assert mod.main() == 0


def test_generate_skill_index_check_still_fails_on_real_drift(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
) -> None:
"""A genuine content change without regeneration must still fail
`--check` — the date-drift tolerance must not weaken this protection.
"""
repo_root = make_skill_tree(tmp_path)
mod = load_generate_skill_index()
patch_skill_index_paths(mod, repo_root)

monkeypatch.setattr(sys, "argv", ["generate_skill_index.py", "--write"])
assert mod.main() == 0

# Modify a skill doc's front matter without regenerating the index.
zeta_path = repo_root / "docs" / "skills" / "zeta.md"
zeta_path.write_text(
zeta_path.read_text().replace("Zeta purpose", "Totally different purpose")
)

monkeypatch.setattr(sys, "argv", ["generate_skill_index.py", "--check"])
assert mod.main() == 1
captured = capsys.readouterr()
assert "index.json is stale" in captured.err


def test_generate_skill_index_rejects_missing_entry_point(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
) -> None:
Expand Down
Loading