mirror of
https://github.com/wshobson/agents.git
synced 2026-09-14 18:04:20 +08:00
fix(garden): keep indentation without frontmatter, reject malformed manifest entries
Three findings on PR #676, all reproduced first. normalized_agent_text stripped leading whitespace before deciding whether frontmatter existed, so two frontmatter-free agents differing only by indentation compared equal. Blank lines ahead of a frontmatter block are formatting; ahead of anything else they are body content. Same mistake as the body.strip() fix in the previous commit, one level up. A plugins list holding a non-object entry was counted as valid, letting garbage drive an error-severity stale-count finding. The count is unknown unless every entry is an object. check_marketplace_consistency then crashed outright on that input with AttributeError, and on a non-object root as well. Both now report MARKETPLACE_SHAPE and carry on. That crash predates this PR.
This commit is contained in:
+29
-3
@@ -392,7 +392,26 @@ def check_marketplace_consistency(report: Report) -> None:
|
||||
# missing LOCAL entries as orphans — externals legitimately don't have a plugins/<name>/.
|
||||
local_entries: dict[str, dict] = {}
|
||||
external_names: set[str] = set()
|
||||
for entry in data.get("plugins", []):
|
||||
entries = data.get("plugins", []) if isinstance(data, dict) else None
|
||||
if not isinstance(entries, list):
|
||||
report.add(
|
||||
kind="MARKETPLACE_SHAPE",
|
||||
severity="error",
|
||||
path=MARKETPLACE_JSON,
|
||||
message="expected an object with a `plugins` list at the top level",
|
||||
fix='Restore the manifest shape: {"plugins": [ ... ]}.',
|
||||
)
|
||||
return
|
||||
for position, entry in enumerate(entries):
|
||||
if not isinstance(entry, dict):
|
||||
report.add(
|
||||
kind="MARKETPLACE_SHAPE",
|
||||
severity="error",
|
||||
path=MARKETPLACE_JSON,
|
||||
message=f"plugins[{position}] is {type(entry).__name__}, expected an object",
|
||||
fix="Remove the entry or give it the usual name/source/description fields.",
|
||||
)
|
||||
continue
|
||||
name = entry.get("name")
|
||||
if not name:
|
||||
continue
|
||||
@@ -437,7 +456,12 @@ def normalized_agent_text(text: str) -> str:
|
||||
a closing `---` at end of file, and a trailing space after a delimiter from
|
||||
reading as drift. A `name:` line in the body is body content and still counts.
|
||||
"""
|
||||
text = text.lstrip(BOM).lstrip()
|
||||
text = text.lstrip(BOM)
|
||||
trimmed = text.lstrip()
|
||||
# Blank lines ahead of a frontmatter block are formatting. Ahead of anything else
|
||||
# they are body content, and stripping them would hide an indentation difference.
|
||||
if trimmed.startswith("---"):
|
||||
text = trimmed
|
||||
fields, body = parse_frontmatter(text)
|
||||
fields.pop("name", None)
|
||||
rendered = "\n".join(f"{key}: {fields[key]!r}" for key in sorted(fields))
|
||||
@@ -465,7 +489,9 @@ def actual_counts(report: Report) -> dict[str, int | None]:
|
||||
# Valid JSON of the wrong shape is still an unknown count, not a crash.
|
||||
if isinstance(manifest, dict):
|
||||
entries = manifest.get("plugins")
|
||||
if isinstance(entries, list):
|
||||
# A list holding a non-object entry is malformed. Counting it would let
|
||||
# garbage drive an error-severity finding.
|
||||
if isinstance(entries, list) and all(isinstance(e, dict) for e in entries):
|
||||
plugins = len(entries)
|
||||
return {
|
||||
"plugins": plugins,
|
||||
|
||||
@@ -9,6 +9,7 @@ import pytest
|
||||
from tools.doc_gardener import (
|
||||
CHECKS,
|
||||
Report,
|
||||
actual_counts,
|
||||
check_agent_divergence,
|
||||
check_codex_skill_caps,
|
||||
check_dead_links,
|
||||
@@ -491,6 +492,20 @@ class TestDocCounts:
|
||||
assert len(stale) == 1
|
||||
assert "30 agents" in stale[0].message
|
||||
|
||||
def test_malformed_plugin_entry_makes_the_count_unknown(
|
||||
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
):
|
||||
"""A plugins list holding a non-object must not drive an error-severity count."""
|
||||
_patch_paths(monkeypatch, tmp_path)
|
||||
_write_counts_fixture(tmp_path, plugins=12, agents=34)
|
||||
(tmp_path / ".claude-plugin" / "marketplace.json").write_text('{"plugins": [null, null]}')
|
||||
(tmp_path / "README.md").write_text("We ship 99 plugins today.\n")
|
||||
|
||||
report = Report()
|
||||
check_doc_counts(report)
|
||||
assert [f for f in report.findings if f.kind == "STALE_COUNT"] == []
|
||||
assert actual_counts(Report())["plugins"] is None
|
||||
|
||||
def test_docs_subtotals_are_not_scanned(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
|
||||
"""Per-category subtotals under docs/ legitimately differ from the totals."""
|
||||
_patch_paths(monkeypatch, tmp_path)
|
||||
@@ -679,6 +694,20 @@ class TestAgentDivergence:
|
||||
check_agent_divergence(report)
|
||||
assert report.findings == []
|
||||
|
||||
def test_indentation_without_frontmatter_is_content(
|
||||
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
):
|
||||
"""Leading blanks are only formatting when they sit ahead of frontmatter."""
|
||||
_patch_paths(monkeypatch, tmp_path)
|
||||
for plugin, body in (("alpha", " prose here\n"), ("beta", "prose here\n")):
|
||||
agents_dir = tmp_path / "plugins" / plugin / "agents"
|
||||
agents_dir.mkdir(parents=True, exist_ok=True)
|
||||
(agents_dir / "reviewer.md").write_text(body)
|
||||
|
||||
report = Report()
|
||||
check_agent_divergence(report)
|
||||
assert [f.kind for f in report.findings] == ["AGENT_BODY_DIVERGENT"]
|
||||
|
||||
def test_groups_variants_in_message(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
|
||||
"""Three copies sharing two bodies report as 3 copies / 2 versions."""
|
||||
_patch_paths(monkeypatch, tmp_path)
|
||||
@@ -732,3 +761,35 @@ class TestUnreadableFiles:
|
||||
unreadable = [f for f in report.findings if f.kind == "UNREADABLE_FILE"]
|
||||
assert unreadable
|
||||
assert unreadable[0].severity == "error"
|
||||
|
||||
|
||||
# ── Marketplace shape ────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestMarketplaceShape:
|
||||
def test_non_object_entry_is_reported_not_raised(
|
||||
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
):
|
||||
_patch_paths(monkeypatch, tmp_path)
|
||||
mp = tmp_path / ".claude-plugin"
|
||||
mp.mkdir(parents=True, exist_ok=True)
|
||||
(mp / "marketplace.json").write_text('{"plugins": [null]}')
|
||||
(tmp_path / "plugins").mkdir(exist_ok=True)
|
||||
|
||||
report = Report()
|
||||
check_marketplace_consistency(report) # must not raise
|
||||
shape = [f for f in report.findings if f.kind == "MARKETPLACE_SHAPE"]
|
||||
assert shape and "plugins[0] is NoneType" in shape[0].message
|
||||
|
||||
def test_non_object_root_is_reported_not_raised(
|
||||
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
):
|
||||
_patch_paths(monkeypatch, tmp_path)
|
||||
mp = tmp_path / ".claude-plugin"
|
||||
mp.mkdir(parents=True, exist_ok=True)
|
||||
(mp / "marketplace.json").write_text("[]")
|
||||
(tmp_path / "plugins").mkdir(exist_ok=True)
|
||||
|
||||
report = Report()
|
||||
check_marketplace_consistency(report) # must not raise
|
||||
assert [f for f in report.findings if f.kind == "MARKETPLACE_SHAPE"]
|
||||
|
||||
Reference in New Issue
Block a user