#1492 was a duplicate "version" member in plugins/skill-quality/.claude-plugin/plugin.json that shipped to main through a fully green 27-context CI suite. The manifest fix is #1494. This issue is the systemic half: no gate in CI can detect a duplicate key in a plugin manifest.
Verified, not assumed
Each of these was run against a probe manifest carrying two version members (0.11.0 then 0.12.0):
| Check |
Result |
Why it cannot catch it |
check-jsonschema + json.schemastore.org/claude-code-plugin-manifest.json (the hygiene job's Validate plugin manifests step) |
ok -- validation done, exit 0 |
JSON Schema has no vocabulary for duplicate object keys. Duplicates are a document-level concern, not a schema-level one, so no schemafile can express the constraint. |
claude plugin validate <dir> (via scripts/validate-plugins.sh) |
exit 0, warned only about a missing author |
Parses last-wins; never inspects key uniqueness. |
scripts/validate-plugin-contracts.mjs, scripts/generate-catalog.mjs |
pass |
JSON.parse is last-wins per ECMA-262. |
changelog-parity-gate (scripts/check-changelog-parity.sh) |
pass |
Reads the version with jq -r '.version' — last-wins. This gate is also the one most harmed by the defect, since it compares a version it cannot trust. |
So every consumer in the pipeline resolves the same last-wins value and reports the intended version whether or not the duplicate is present. The green suite was not a fluke — it is the expected behaviour of every tool involved.
Why this defect class keeps appearing
The conflict shape that produces it recurred three times in one night across #1085, #1096, and the CHANGELOG resolutions alongside them: two branches independently bump the same plugin to the same version, so the version line becomes common context outside the conflict region while only the neighbouring differing line lands inside it. Any resolution that re-emits the field rather than editing the surviving one produces a duplicate key. That is a mechanical trap, not a lapse of attention, and it is exactly what a gate is for.
Impact when it does ship
The advertised release becomes parser-dependent: last-wins readers resolve the newer version, first-wins readers retain the stale one, and duplicate-rejecting consumers can reject the manifest outright — so a plugin update can be misidentified or blocked.
Which gate should own the check, and why
The hygiene job, adjacent to its existing Validate plugin manifests step.
- It already enumerates exactly the right file set (
plugins/*/.claude-plugin/plugin.json) for schema validation, so the glob and the ownership already exist there — no new file-discovery logic is needed.
- The defect class is document-level JSON integrity, the same tier as "does it parse" and "does it match the schema." That is hygiene's concern;
plugin-gate owns manifest semantics (contracts, catalog parity), which is a different tier.
- Most importantly, the schema step is the check a reader would assume already covers this. Putting the duplicate-key check next to it removes the false assurance at the point where the assurance is currently implied, rather than burying it in a semantics job.
- Wiring is cheap: the job's step outcomes are already aggregated by
scripts/aggregate-hygiene-results.sh, where any non-success outcome fails the job. It is one step plus one line in the checks block.
Not implemented here, per direction. Two notes for whoever picks it up:
- The detector is cheap and needs no new toolchain: Python's
json.load(..., object_pairs_hook=...) sees the raw key/value pairs before de-duplication, and Python is already present in that job because check-jsonschema is a Python tool.
- Worth scoping to
.claude-plugin/marketplace.json as well as the plugin manifests — same defect class, same conflict shape, same blast radius.
Related
#1492 was a duplicate
"version"member inplugins/skill-quality/.claude-plugin/plugin.jsonthat shipped tomainthrough a fully green 27-context CI suite. The manifest fix is #1494. This issue is the systemic half: no gate in CI can detect a duplicate key in a plugin manifest.Verified, not assumed
Each of these was run against a probe manifest carrying two
versionmembers (0.11.0then0.12.0):check-jsonschema+json.schemastore.org/claude-code-plugin-manifest.json(thehygienejob's Validate plugin manifests step)ok -- validation done, exit 0claude plugin validate <dir>(viascripts/validate-plugins.sh)authorscripts/validate-plugin-contracts.mjs,scripts/generate-catalog.mjsJSON.parseis last-wins per ECMA-262.changelog-parity-gate(scripts/check-changelog-parity.sh)jq -r '.version'— last-wins. This gate is also the one most harmed by the defect, since it compares a version it cannot trust.So every consumer in the pipeline resolves the same last-wins value and reports the intended version whether or not the duplicate is present. The green suite was not a fluke — it is the expected behaviour of every tool involved.
Why this defect class keeps appearing
The conflict shape that produces it recurred three times in one night across #1085, #1096, and the CHANGELOG resolutions alongside them: two branches independently bump the same plugin to the same version, so the version line becomes common context outside the conflict region while only the neighbouring differing line lands inside it. Any resolution that re-emits the field rather than editing the surviving one produces a duplicate key. That is a mechanical trap, not a lapse of attention, and it is exactly what a gate is for.
Impact when it does ship
The advertised release becomes parser-dependent: last-wins readers resolve the newer version, first-wins readers retain the stale one, and duplicate-rejecting consumers can reject the manifest outright — so a plugin update can be misidentified or blocked.
Which gate should own the check, and why
The
hygienejob, adjacent to its existing Validate plugin manifests step.plugins/*/.claude-plugin/plugin.json) for schema validation, so the glob and the ownership already exist there — no new file-discovery logic is needed.plugin-gateowns manifest semantics (contracts, catalog parity), which is a different tier.scripts/aggregate-hygiene-results.sh, where any non-successoutcome fails the job. It is one step plus one line in thechecksblock.Not implemented here, per direction. Two notes for whoever picks it up:
json.load(..., object_pairs_hook=...)sees the raw key/value pairs before de-duplication, and Python is already present in that job becausecheck-jsonschemais a Python tool..claude-plugin/marketplace.jsonas well as the plugin manifests — same defect class, same conflict shape, same blast radius.Related
versionmembers (0.11.0 and 0.12.0) — advertised release is parser-dependent #1492 — the shipped duplicate-key instance.