|
| 1 | +#!/usr/bin/env bash |
| 2 | +# SPDX-License-Identifier: MPL-2.0 |
| 3 | +# Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> |
| 4 | +# |
| 5 | +# validate-a2ml.sh — A2ML manifest validation script |
| 6 | +# |
| 7 | +# Scans for .a2ml files and validates: |
| 8 | +# 1. Required fields: agent-id or pedigree name, version |
| 9 | +# 2. SPDX-License-Identifier header presence |
| 10 | +# 3. Attestation block structure (if present) |
| 11 | +# 4. Section heading syntax ([section] or ## section) |
| 12 | +# |
| 13 | +# Environment variables: |
| 14 | +# INPUT_PATH — Directory to scan (default: .) |
| 15 | +# INPUT_STRICT — Promote warnings to errors (default: false) |
| 16 | +# |
| 17 | +# Exit codes: |
| 18 | +# 0 — All files valid (or only warnings in non-strict mode) |
| 19 | +# 1 — Validation errors found |
| 20 | + |
| 21 | +set -euo pipefail |
| 22 | + |
| 23 | +# --------------------------------------------------------------------------- |
| 24 | +# Configuration |
| 25 | +# --------------------------------------------------------------------------- |
| 26 | + |
| 27 | +SCAN_PATH="${INPUT_PATH:-.}" |
| 28 | +STRICT="${INPUT_STRICT:-false}" |
| 29 | +PATHS_IGNORE_RAW="${INPUT_PATHS_IGNORE:-}" |
| 30 | +GITHUB_OUTPUT_FILE="${GITHUB_OUTPUT:-/dev/null}" |
| 31 | + |
| 32 | +# Parse paths-ignore: newline-separated fragments, blank lines and # comments |
| 33 | +# stripped. Each fragment is a substring match against the file path. Pattern |
| 34 | +# adopted from hyperpolymath/hypatia#243 — content-pattern validators must |
| 35 | +# distinguish a target from a vendored / fixture file that legitimately |
| 36 | +# contains the very pattern being checked. |
| 37 | +PATHS_IGNORE=() |
| 38 | +while IFS= read -r _frag; do |
| 39 | + # Strip leading and trailing whitespace (canonical bash idiom). |
| 40 | + _frag="${_frag#"${_frag%%[![:space:]]*}"}" |
| 41 | + _frag="${_frag%"${_frag##*[![:space:]]}"}" |
| 42 | + [[ -z "$_frag" || "$_frag" == \#* ]] && continue |
| 43 | + PATHS_IGNORE+=("$_frag") |
| 44 | +done <<< "$PATHS_IGNORE_RAW" |
| 45 | + |
| 46 | +# Returns 0 if path should be skipped (matches any ignore fragment) |
| 47 | +path_ignored() { |
| 48 | + local p="$1" frag |
| 49 | + for frag in "${PATHS_IGNORE[@]}"; do |
| 50 | + [[ "$p" == *"$frag"* ]] && return 0 |
| 51 | + done |
| 52 | + return 1 |
| 53 | +} |
| 54 | + |
| 55 | +# Counters |
| 56 | +FILES_SCANNED=0 |
| 57 | +ERRORS=0 |
| 58 | +WARNINGS=0 |
| 59 | + |
| 60 | +# --------------------------------------------------------------------------- |
| 61 | +# Helper: emit GitHub annotation |
| 62 | +# --------------------------------------------------------------------------- |
| 63 | +# Usage: annotate <level> <file> <line> <message> |
| 64 | +# level: error | warning | notice |
| 65 | +annotate() { |
| 66 | + local level="$1" file="$2" line="$3" message="$4" |
| 67 | + echo "::${level} file=${file},line=${line}::${message}" |
| 68 | +} |
| 69 | + |
| 70 | +# --------------------------------------------------------------------------- |
| 71 | +# Helper: report issue (respects strict mode) |
| 72 | +# --------------------------------------------------------------------------- |
| 73 | +# Usage: report_issue <severity> <file> <line> <message> |
| 74 | +# severity: error | warning |
| 75 | +report_issue() { |
| 76 | + local severity="$1" file="$2" line="$3" message="$4" |
| 77 | + |
| 78 | + if [[ "$severity" == "warning" && "$STRICT" == "true" ]]; then |
| 79 | + severity="error" |
| 80 | + fi |
| 81 | + |
| 82 | + annotate "$severity" "$file" "$line" "$message" |
| 83 | + |
| 84 | + if [[ "$severity" == "error" ]]; then |
| 85 | + ERRORS=$((ERRORS + 1)) |
| 86 | + else |
| 87 | + WARNINGS=$((WARNINGS + 1)) |
| 88 | + fi |
| 89 | +} |
| 90 | + |
| 91 | +# --------------------------------------------------------------------------- |
| 92 | +# Validator: check a single .a2ml file |
| 93 | +# --------------------------------------------------------------------------- |
| 94 | +validate_a2ml() { |
| 95 | + local file="$1" |
| 96 | + FILES_SCANNED=$((FILES_SCANNED + 1)) |
| 97 | + |
| 98 | + # --- Check 1: SPDX header --- |
| 99 | + # The SPDX-License-Identifier should appear in the first 10 lines |
| 100 | + local has_spdx=false |
| 101 | + local line_num=0 |
| 102 | + while IFS= read -r line; do |
| 103 | + line_num=$((line_num + 1)) |
| 104 | + if [[ $line_num -gt 10 ]]; then |
| 105 | + break |
| 106 | + fi |
| 107 | + if [[ "$line" == *"SPDX-License-Identifier"* ]]; then |
| 108 | + has_spdx=true |
| 109 | + break |
| 110 | + fi |
| 111 | + done < "$file" |
| 112 | + |
| 113 | + if [[ "$has_spdx" == "false" ]]; then |
| 114 | + report_issue "warning" "$file" 1 \ |
| 115 | + "Missing SPDX-License-Identifier in first 10 lines" |
| 116 | + fi |
| 117 | + |
| 118 | + # --- Check 2: Required identity fields --- |
| 119 | + # A2ML files must contain either: |
| 120 | + # - agent-id = "..." or agent_id = "..." |
| 121 | + # - pedigree block with name field |
| 122 | + # - name = "..." at top level (for AI manifests) |
| 123 | + # - project = "..." (for STATE.a2ml) |
| 124 | + local has_identity=false |
| 125 | + local has_version=false |
| 126 | + line_num=0 |
| 127 | + |
| 128 | + while IFS= read -r line; do |
| 129 | + line_num=$((line_num + 1)) |
| 130 | + |
| 131 | + # Check for identity fields (various A2ML patterns) |
| 132 | + # TOML/kv form: `name = "..."`, `project = "..."`, `agent-id = "..."` |
| 133 | + if [[ "$line" =~ ^[[:space:]]*(agent[-_]id|name|project)[[:space:]]*= ]]; then |
| 134 | + has_identity=true |
| 135 | + fi |
| 136 | + # S-expression form: `(name "...")`, `(project "...")`, |
| 137 | + # `(agent-id "...")`. Some A2ML dialects (audit registries, |
| 138 | + # classification stores) use Lisp-style s-expressions for the |
| 139 | + # metadata block instead of TOML. Identity carries the same |
| 140 | + # semantics; only the syntax differs. Match at any indent so it |
| 141 | + # also picks up entries nested under `(metadata ...)`. |
| 142 | + if [[ "$line" =~ ^[[:space:]]*\([[:space:]]*(agent[-_]id|name|project)[[:space:]]+\" ]]; then |
| 143 | + has_identity=true |
| 144 | + fi |
| 145 | + # Colon / brace-block form: `name: "..."`, `id: "..."`, `project: "..."`. |
| 146 | + # YAML-ish and brace-block A2ML dialects (e.g. `Trust { name: "..." }`, |
| 147 | + # `id: "tsdm-standard"`) carry the same identity semantics; only the |
| 148 | + # delimiter (`:` vs `=`) differs. `id` is the brace-block spelling of an |
| 149 | + # identity key. |
| 150 | + if [[ "$line" =~ ^[[:space:]]*(agent[-_]id|name|project|id)[[:space:]]*: ]]; then |
| 151 | + has_identity=true |
| 152 | + fi |
| 153 | + # Check for version field — TOML form |
| 154 | + if [[ "$line" =~ ^[[:space:]]*(version|schema_version)[[:space:]]*= ]]; then |
| 155 | + has_version=true |
| 156 | + fi |
| 157 | + # Version field — s-expression form |
| 158 | + if [[ "$line" =~ ^[[:space:]]*\([[:space:]]*(version|schema_version)[[:space:]]+\" ]]; then |
| 159 | + has_version=true |
| 160 | + fi |
| 161 | + # Version field — colon / brace-block form |
| 162 | + if [[ "$line" =~ ^[[:space:]]*(version|schema_version)[[:space:]]*: ]]; then |
| 163 | + has_version=true |
| 164 | + fi |
| 165 | + done < "$file" |
| 166 | + |
| 167 | + # AI manifest files (0-AI-MANIFEST.a2ml, 0.1-AI-MANIFEST.a2ml, etc.) |
| 168 | + # use markdown-style headers and free text, so identity check is relaxed |
| 169 | + local basename |
| 170 | + basename="$(basename "$file")" |
| 171 | + local is_manifest=false |
| 172 | + if [[ "$basename" == *"AI-MANIFEST"* ]]; then |
| 173 | + is_manifest=true |
| 174 | + fi |
| 175 | + # Canonical typed manifests under .machine_readable/descriptiles/ — identity comes |
| 176 | + # from the enclosing directory + filename, not an in-file field. Sibling |
| 177 | + # files in the same directory (ECOSYSTEM.a2ml, STATE.a2ml) DO carry their |
| 178 | + # own $name/project and continue to be validated normally. |
| 179 | + case "$basename" in |
| 180 | + AGENTIC.a2ml|META.a2ml|NEUROSYM.a2ml|PLAYBOOK.a2ml|AI.a2ml) |
| 181 | + # AI.a2ml = free-text "AI Assistant Instructions" manifest, the same |
| 182 | + # doc type as 0-AI-MANIFEST.a2ml but with the bare name; identity is |
| 183 | + # carried by the enclosing repo/plugin dir, not an in-file field. |
| 184 | + is_manifest=true |
| 185 | + ;; |
| 186 | + # Dockerfile-style top-level typed manifests (Intentfile, Trustfile, …) |
| 187 | + # use markdown-flavoured A2ML; identity is carried by the parent repo. |
| 188 | + *file.a2ml) |
| 189 | + is_manifest=true |
| 190 | + ;; |
| 191 | + esac |
| 192 | + |
| 193 | + # Contractile-shape A2ML files use `@directive:` syntax instead of |
| 194 | + # TOML `key = value`. Trustfile.a2ml, Intentfile.a2ml, Mustfile.a2ml, |
| 195 | + # Adjustfile.a2ml etc. are policy / trust / intent / abstract files |
| 196 | + # whose identity is implicit in their @-prefixed directives |
| 197 | + # (`@trust-level`, `@intent`, ...) rather than a TOML name/version |
| 198 | + # pair. Treating them as manifest-shape produces 100% false positives — |
| 199 | + # they're a different A2ML doc type. Detected by the presence of any |
| 200 | + # contractile directive in the file body. |
| 201 | + local is_contractile_shape=false |
| 202 | + if grep -qE '^@(abstract|trust-level|trust-boundary|trust-actions|trust-deny|intent|must|adjust|end)([[:space:]]*:|$)' "$file"; then |
| 203 | + is_contractile_shape=true |
| 204 | + fi |
| 205 | + |
| 206 | + # Canonical structured A2ML tree. Everything under a `.machine_readable/` |
| 207 | + # directory is a typed agent-readable doc (CLADE, ANCHOR, STATE, |
| 208 | + # ECOSYSTEM, bot_directives/{debt,coverage,methodology}, ai/AI, |
| 209 | + # policies/*, integrations/*, …). Per the RSR convention these carry |
| 210 | + # identity structurally — owning repo + path + filename — not via an |
| 211 | + # in-file `name`/`agent-id`. This generalises the `.machine_readable/descriptiles/` |
| 212 | + # rationale above to the whole tree: rsr-template-repo itself ships these |
| 213 | + # files without an in-file identity key, so requiring one produces |
| 214 | + # estate-wide false positives on every repo built from the canonical |
| 215 | + # template. Files outside `.machine_readable/` are still validated. |
| 216 | + local is_structural_identity=false |
| 217 | + if [[ "$file" == *"/.machine_readable/"* || "$file" == "./.machine_readable/"* || "$file" == ".machine_readable/"* ]]; then |
| 218 | + is_structural_identity=true |
| 219 | + fi |
| 220 | + |
| 221 | + if [[ "$has_identity" == "false" && "$is_manifest" == "false" && "$is_contractile_shape" == "false" && "$is_structural_identity" == "false" ]]; then |
| 222 | + report_issue "error" "$file" 1 \ |
| 223 | + "Missing required identity field (agent-id, name, or project)" |
| 224 | + fi |
| 225 | + |
| 226 | + if [[ "$has_version" == "false" && "$is_manifest" == "false" && "$is_contractile_shape" == "false" && "$is_structural_identity" == "false" ]]; then |
| 227 | + report_issue "warning" "$file" 1 \ |
| 228 | + "Missing version or schema_version field" |
| 229 | + fi |
| 230 | + |
| 231 | + # --- Check 3: Attestation block structure --- |
| 232 | + # If file contains [attestation] or ## ATTESTATION, validate it has |
| 233 | + # required sub-fields: proof or signature |
| 234 | + local in_attestation=false |
| 235 | + local attestation_line=0 |
| 236 | + local attestation_has_content=false |
| 237 | + line_num=0 |
| 238 | + |
| 239 | + while IFS= read -r line; do |
| 240 | + line_num=$((line_num + 1)) |
| 241 | + |
| 242 | + # Detect attestation section start |
| 243 | + if [[ "$line" =~ ^\[attestation\] ]] || [[ "$line" =~ ^##[[:space:]]+[Aa]ttestation ]] || [[ "$line" =~ ^##[[:space:]]+ATTESTATION ]]; then |
| 244 | + in_attestation=true |
| 245 | + attestation_line=$line_num |
| 246 | + continue |
| 247 | + fi |
| 248 | + |
| 249 | + # Detect next section (ends attestation block) |
| 250 | + if [[ "$in_attestation" == "true" ]]; then |
| 251 | + if [[ "$line" =~ ^\[.+\] ]] || [[ "$line" =~ ^##[[:space:]] ]]; then |
| 252 | + in_attestation=false |
| 253 | + continue |
| 254 | + fi |
| 255 | + # Check for content in attestation block |
| 256 | + if [[ "$line" =~ (proof|signature|verified|hash)[[:space:]]*= ]]; then |
| 257 | + attestation_has_content=true |
| 258 | + fi |
| 259 | + fi |
| 260 | + done < "$file" |
| 261 | + |
| 262 | + if [[ $attestation_line -gt 0 && "$attestation_has_content" == "false" ]]; then |
| 263 | + report_issue "warning" "$file" "$attestation_line" \ |
| 264 | + "Attestation block found but missing proof/signature/hash fields" |
| 265 | + fi |
| 266 | + |
| 267 | + # --- Check 4: Section heading syntax --- |
| 268 | + # Validate that [section] headings are well-formed (no unclosed brackets) |
| 269 | + line_num=0 |
| 270 | + while IFS= read -r line; do |
| 271 | + line_num=$((line_num + 1)) |
| 272 | + # Lines starting with [ should have a matching ] |
| 273 | + if [[ "$line" =~ ^\[ && ! "$line" =~ ^\[.+\] ]]; then |
| 274 | + # Exclude markdown-style links and multi-line values |
| 275 | + if [[ ! "$line" =~ ^\[.*\]\( && ! "$line" =~ ^\[TODO && ! "$line" =~ ^\[YOUR ]]; then |
| 276 | + report_issue "warning" "$file" "$line_num" \ |
| 277 | + "Possibly malformed section heading: unclosed bracket" |
| 278 | + fi |
| 279 | + fi |
| 280 | + done < "$file" |
| 281 | +} |
| 282 | + |
| 283 | +# --------------------------------------------------------------------------- |
| 284 | +# Main: discover and validate .a2ml files |
| 285 | +# --------------------------------------------------------------------------- |
| 286 | + |
| 287 | +echo "::group::A2ML Manifest Validation" |
| 288 | +echo "Scanning ${SCAN_PATH} for .a2ml files..." |
| 289 | +echo "" |
| 290 | + |
| 291 | +# Find all .a2ml files, excluding .git directory |
| 292 | +mapfile -t a2ml_candidates < <(find "$SCAN_PATH" -name '*.a2ml' -not -path '*/.git/*' -type f | sort) |
| 293 | + |
| 294 | +# Apply paths-ignore filter |
| 295 | +a2ml_files=() |
| 296 | +SKIPPED=0 |
| 297 | +for _f in "${a2ml_candidates[@]}"; do |
| 298 | + if path_ignored "$_f"; then |
| 299 | + SKIPPED=$((SKIPPED + 1)) |
| 300 | + continue |
| 301 | + fi |
| 302 | + a2ml_files+=("$_f") |
| 303 | +done |
| 304 | + |
| 305 | +if [[ $SKIPPED -gt 0 ]]; then |
| 306 | + echo "::notice::Skipped ${SKIPPED} file(s) matching paths-ignore" |
| 307 | +fi |
| 308 | + |
| 309 | +if [[ ${#a2ml_files[@]} -eq 0 ]]; then |
| 310 | + echo "::notice::No .a2ml files found in ${SCAN_PATH}" |
| 311 | + echo "files_scanned=0" >> "$GITHUB_OUTPUT_FILE" 2>/dev/null || true |
| 312 | + echo "errors=0" >> "$GITHUB_OUTPUT_FILE" 2>/dev/null || true |
| 313 | + echo "warnings=0" >> "$GITHUB_OUTPUT_FILE" 2>/dev/null || true |
| 314 | + echo "::endgroup::" |
| 315 | + exit 0 |
| 316 | +fi |
| 317 | + |
| 318 | +echo "Found ${#a2ml_files[@]} .a2ml file(s)" |
| 319 | +echo "" |
| 320 | + |
| 321 | +for file in "${a2ml_files[@]}"; do |
| 322 | + echo " Validating: ${file}" |
| 323 | + validate_a2ml "$file" |
| 324 | +done |
| 325 | + |
| 326 | +echo "" |
| 327 | +echo "────────────────────────────────────────" |
| 328 | +echo "Files scanned: ${FILES_SCANNED}" |
| 329 | +echo "Errors: ${ERRORS}" |
| 330 | +echo "Warnings: ${WARNINGS}" |
| 331 | +echo "Strict mode: ${STRICT}" |
| 332 | +echo "────────────────────────────────────────" |
| 333 | + |
| 334 | +# Write outputs for GitHub Actions |
| 335 | +{ |
| 336 | + echo "files_scanned=${FILES_SCANNED}" |
| 337 | + echo "errors=${ERRORS}" |
| 338 | + echo "warnings=${WARNINGS}" |
| 339 | +} >> "$GITHUB_OUTPUT_FILE" 2>/dev/null || true |
| 340 | + |
| 341 | +echo "::endgroup::" |
| 342 | + |
| 343 | +# Exit with failure if errors were found |
| 344 | +if [[ $ERRORS -gt 0 ]]; then |
| 345 | + echo "::error::A2ML validation failed with ${ERRORS} error(s)" |
| 346 | + exit 1 |
| 347 | +fi |
| 348 | + |
| 349 | +echo "A2ML validation passed." |
| 350 | +exit 0 |
0 commit comments