diff --git a/scripts/generate_skill_index.py b/scripts/generate_skill_index.py index e63501a4..69df952c 100755 --- a/scripts/generate_skill_index.py +++ b/scripts/generate_skill_index.py @@ -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) @@ -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) diff --git a/tests/test_skill_docs.py b/tests/test_skill_docs.py index ed3d4aad..b1cfcee2 100644 --- a/tests/test_skill_docs.py +++ b/tests/test_skill_docs.py @@ -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: