The previous commit only landed the research-folder rename — a multi-path git add silently failed and left CONTEXT.md, ADR-0012, and the actual skill files unstaged. This lands them: the agentsmd-audit skill itself (three deterministic validators for secrets, structure, and drift against a target repo's AGENTS.md), its bats test suite, provenance record, and the CONTEXT.md/ADR entries documenting why this lives in core rather than kyberforge. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
119 lines
4.3 KiB
Bash
Executable File
119 lines
4.3 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
set -euo pipefail
|
|
|
|
usage() {
|
|
cat <<EOF
|
|
Usage: validate-structure.sh <repo-root>
|
|
|
|
Check every AGENTS.md file in a repo (root and nested) for structural
|
|
completeness against the agents.md spec's common-sections checklist
|
|
(setup/build, code style, testing, security, commit/PR conventions).
|
|
Missing individual sections are informational (not every repo needs every
|
|
section) — only an empty or entirely unfilled file is a hard failure.
|
|
|
|
Arguments:
|
|
repo-root Path to the repository root to scan.
|
|
|
|
Exit codes:
|
|
0 No FAIL findings (INFO/SUGGESTION may still be printed)
|
|
1 One or more FAIL findings
|
|
EOF
|
|
}
|
|
|
|
if [[ "${1:-}" == "--help" || "${1:-}" == "-h" ]]; then
|
|
usage
|
|
exit 0
|
|
fi
|
|
|
|
if [[ $# -lt 1 ]]; then
|
|
echo "Error: repo-root is required." >&2
|
|
echo "" >&2
|
|
usage >&2
|
|
exit 1
|
|
fi
|
|
|
|
python3 -u - "$1" <<'PYTHON'
|
|
import sys
|
|
import os
|
|
import re
|
|
|
|
PLACEHOLDER_RE = re.compile(r'(?i)FILL IN:|TODO:\s*write|lorem ipsum')
|
|
|
|
COMMON_SECTIONS = [
|
|
("setup/build commands", re.compile(r'(?im)^#{1,3}\s*(setup|install|build|getting started)')),
|
|
("code style", re.compile(r'(?im)^#{1,3}\s*(code style|style guide|conventions)')),
|
|
("testing instructions", re.compile(r'(?im)^#{1,3}\s*(test|testing)')),
|
|
("security considerations", re.compile(r'(?im)^#{1,3}\s*security')),
|
|
("commit/PR conventions", re.compile(r'(?im)^#{1,3}\s*(commit|pr|pull request)')),
|
|
]
|
|
|
|
repo_root = os.path.abspath(sys.argv[1])
|
|
if not os.path.isdir(repo_root):
|
|
print(f"Error: '{repo_root}' is not a directory.", file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
EXCLUDE_DIRS = {".git", "node_modules", "vendor", ".venv", "venv", "dist", "build"}
|
|
|
|
def find_agents_md(root):
|
|
results = []
|
|
for dirpath, dirnames, filenames in os.walk(root):
|
|
dirnames[:] = [d for d in dirnames if d not in EXCLUDE_DIRS and not d.startswith(".")]
|
|
for fname in filenames:
|
|
if fname == "AGENTS.md":
|
|
results.append(os.path.join(dirpath, fname))
|
|
return sorted(results)
|
|
|
|
has_fail = False
|
|
file_contents = {} # rel path -> content, for the duplication pass below
|
|
|
|
for fpath in find_agents_md(repo_root):
|
|
rel = os.path.relpath(fpath, repo_root)
|
|
with open(fpath, encoding="utf-8", errors="replace") as f:
|
|
content = f.read()
|
|
file_contents[rel] = content
|
|
|
|
if not content.strip():
|
|
has_fail = True
|
|
print(f"FAIL AGENTS.md is empty — {rel}")
|
|
print(" Why: An empty file provides no instructions and gives agents nothing to act on.")
|
|
print(" Fix: Add at least a project overview and setup/test commands, per the agents.md common-sections checklist.")
|
|
print()
|
|
continue
|
|
|
|
if PLACEHOLDER_RE.search(content):
|
|
has_fail = True
|
|
print(f"FAIL Unfilled placeholder content — {rel}")
|
|
print(" Why: A 'FILL IN:' or template stub left in place means the file has no repo-specific instructions yet.")
|
|
print(" Fix: Replace the placeholder with real, repo-specific content.")
|
|
print()
|
|
continue
|
|
|
|
for label, pattern in COMMON_SECTIONS:
|
|
if not pattern.search(content):
|
|
print(f"INFO No {label} section — {rel}")
|
|
print(f" Note: The agents.md common-sections checklist includes {label}; not every repo needs every section, but confirm this omission is deliberate.")
|
|
print()
|
|
|
|
# --- Nested-vs-root duplication check ---
|
|
root_content = file_contents.get("AGENTS.md")
|
|
if root_content:
|
|
root_lines = {ln.strip() for ln in root_content.splitlines() if ln.strip()}
|
|
for rel, content in file_contents.items():
|
|
if rel == "AGENTS.md":
|
|
continue
|
|
nested_lines = [ln.strip() for ln in content.splitlines() if ln.strip()]
|
|
if not nested_lines:
|
|
continue
|
|
overlap = sum(1 for ln in nested_lines if ln in root_lines)
|
|
ratio = overlap / len(nested_lines)
|
|
if ratio >= 0.7:
|
|
print(f"SUGGESTION Nested AGENTS.md largely duplicates the root file — {rel}")
|
|
print(f" Why: {ratio:.0%} of this file's content lines already appear in the root AGENTS.md; per the spec's nearest-file-wins precedence, nested files don't inherit from the root, but they also shouldn't just restate it.")
|
|
print(f" Fix: Trim {rel} down to only what's specific to this package/directory.")
|
|
print()
|
|
|
|
if has_fail:
|
|
sys.exit(1)
|
|
sys.exit(0)
|
|
PYTHON
|