claude-skills/scripts/check_dual_publish.py
Claude c84e322a99
chore(registry,ci): derive counters, register 11 plugins, add CI guards
- marketplace.json: 11 shipped-but-unregistered plugins added (compliance-os,
  snowflake-development, behuman, claude-coach, grill-with-docs,
  llm-cost-optimizer, prompt-governance, business-investment-advisor,
  video-content-strategist, compliance-team-eu-ai-act, compliance-team-iso42001)
  -> 77/77 registered; self-contradicting counts fixed
- counters reconciled to derived ground truth across README.md, CLAUDE.md,
  agents/CLAUDE.md, marketplace.json (346 skills / 17 domains / 584 tools /
  701 references / 93 agents / 99 commands / 77 plugins)
- new guards: scripts/derive_counters.py (--check, gate G3),
  scripts/check_dual_publish.py (11 pairs, gate G4),
  scripts/smoke_scripts.py + smoke_exceptions.txt (gate G8)
- meta-tooling fixes (gate G9): generate-docs.py --help no longer rewrites
  docs/ as a side effect; audit_skills.py --help instant; markdown-html added
  to hermes/vibe/gemini sync scripts and generate-docs domain maps
- ci-quality-gate.yml: compileall completed 9 -> 17 domains; 4 new advisory
  gate steps (continue-on-error pending burn-in)

Verification (all green): check_plugin_json --all 77 OK; check_paths --all 0
findings; check_dual_publish 0 drifted; smoke 586 pass / 0 fail / 11 by-design
exceptions; derive_counters --check pass; compileall 17 domains rc=0.

https://claude.ai/code/session_019AJddAL1NADWMXsy1qNPQF
2026-06-10 14:33:19 +00:00

182 lines
6.4 KiB
Python

#!/usr/bin/env python3
"""Dual-publish drift guard (audit gate G4).
Several skills are published twice on purpose:
bundled: <domain>/skills/<name>/...
standalone: <domain>/<name>/skills/<name>/...
or <domain>/compliance-team-*/skills/<name>/... (ra-qm-team pattern)
The two copies are kept in sync by scripts/sync_skill_bundles.py. This guard
discovers every such pair programmatically and recursively compares the two
trees (a `diff -rq` equivalent built on os.walk + filecmp with full content
comparison). Any file that exists on only one side, or whose content differs,
is drift.
Exit codes:
0 all pairs identical
1 drift detected (drifted files listed on stdout)
2 unexpected error (e.g. repo root not found)
Usage:
python3 scripts/check_dual_publish.py # check all pairs
python3 scripts/check_dual_publish.py --list # show discovered pairs
python3 scripts/check_dual_publish.py --json # machine-readable report
"""
from __future__ import annotations
import argparse
import filecmp
import json
import os
import sys
REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
# Top-level directories that are never skill domains (sync copies, docs, VCS).
EXCLUDE_TOP_LEVEL = {
".git", ".github", ".codex", ".gemini", ".hermes", ".vibe", ".claude",
".claude-plugin", ".codex-plugin", "docs", "audit", "node_modules",
"integrations", "eval-workspace", "templates", "standards", "assets",
"scripts", "agents", "commands", "orchestration", "custom-gpt", "site",
}
# Directory names that may never appear inside a compared payload
# (none expected today; kept for future-proofing against editor litter).
IGNORE_NAMES = {"__pycache__", ".DS_Store"}
def is_skill_dir(path):
return os.path.isfile(os.path.join(path, "SKILL.md"))
def discover_pairs(repo_root):
"""Return [(bundled_rel, standalone_rel)] for every dual-publish pair."""
pairs = []
for domain in sorted(os.listdir(repo_root)):
domain_path = os.path.join(repo_root, domain)
if domain in EXCLUDE_TOP_LEVEL or domain.startswith("."):
continue
if not os.path.isdir(domain_path):
continue
bundled_root = os.path.join(domain_path, "skills")
if not os.path.isdir(bundled_root):
continue
for name in sorted(os.listdir(bundled_root)):
bundled = os.path.join(bundled_root, name)
if not is_skill_dir(bundled):
continue
candidates = [os.path.join(domain_path, name, "skills", name)]
for entry in sorted(os.listdir(domain_path)):
if entry.startswith("compliance-team-"):
candidates.append(
os.path.join(domain_path, entry, "skills", name))
for cand in candidates:
if is_skill_dir(cand):
pairs.append((
os.path.relpath(bundled, repo_root),
os.path.relpath(cand, repo_root),
))
return pairs
def _listdir(path):
try:
return sorted(e for e in os.listdir(path) if e not in IGNORE_NAMES)
except OSError:
return []
def compare_trees(left, right, rel=""):
"""Recursive diff -rq equivalent. Returns list of drift descriptions."""
drift = []
left_entries = set(_listdir(left))
right_entries = set(_listdir(right))
for entry in sorted(left_entries - right_entries):
drift.append(f"only-in-bundled: {os.path.join(rel, entry)}")
for entry in sorted(right_entries - left_entries):
drift.append(f"only-in-standalone: {os.path.join(rel, entry)}")
for entry in sorted(left_entries & right_entries):
lp = os.path.join(left, entry)
rp = os.path.join(right, entry)
sub_rel = os.path.join(rel, entry)
l_dir, r_dir = os.path.isdir(lp), os.path.isdir(rp)
if l_dir != r_dir:
drift.append(f"type-mismatch (file vs dir): {sub_rel}")
elif l_dir:
drift.extend(compare_trees(lp, rp, sub_rel))
else:
try:
same = filecmp.cmp(lp, rp, shallow=False)
except OSError as exc:
drift.append(f"unreadable: {sub_rel} ({exc})")
continue
if not same:
drift.append(f"differs: {sub_rel}")
return drift
def main(argv=None):
parser = argparse.ArgumentParser(
description="Verify dual-published skill pairs are byte-identical "
"(gate G4).")
parser.add_argument("--list", action="store_true",
help="list discovered pairs and exit")
parser.add_argument("--json", action="store_true",
help="emit a JSON report instead of human-readable output")
parser.add_argument("--root", default=REPO_ROOT,
help="repo root (default: parent of this script)")
args = parser.parse_args(argv)
if not os.path.isdir(args.root):
print(f"ERROR: repo root not found: {args.root}", file=sys.stderr)
return 2
pairs = discover_pairs(args.root)
if args.list:
if args.json:
print(json.dumps(
[{"bundled": b, "standalone": s} for b, s in pairs], indent=2))
else:
for bundled, standalone in pairs:
print(f"{bundled} <-> {standalone}")
print(f"\n{len(pairs)} dual-publish pair(s) discovered")
return 0
report = []
drifted_pairs = 0
for bundled, standalone in pairs:
drift = compare_trees(
os.path.join(args.root, bundled),
os.path.join(args.root, standalone))
report.append({"bundled": bundled, "standalone": standalone,
"drift": drift})
if drift:
drifted_pairs += 1
if args.json:
print(json.dumps({
"pairs": len(pairs),
"drifted": drifted_pairs,
"results": report,
}, indent=2))
else:
for item in report:
status = "DRIFT" if item["drift"] else "OK"
print(f"[{status}] {item['bundled']} <-> {item['standalone']}")
for line in item["drift"]:
print(f" {line}")
print(f"\n{len(pairs)} pair(s) checked, {drifted_pairs} drifted")
if not pairs:
print("WARNING: no dual-publish pairs discovered — discovery logic "
"may be stale", file=sys.stderr)
return 1 if drifted_pairs else 0
if __name__ == "__main__":
sys.exit(main())