Skip to content

Commit 673f390

Browse files
ci: vendor validation scripts and remove remote action pins (#61)
Automated PR to fix CI after deleted actions. ---- ## Summary by Gitar - **CI / Workflows:** - Vendored A2ML and K9 validation scripts into `.githooks/` - Replaced remote action pins with local script execution in `dogfood-gate.yml` <sub>This will update automatically on new commits.</sub>
1 parent 0227b26 commit 673f390

3 files changed

Lines changed: 709 additions & 8 deletions

File tree

.githooks/validate-a2ml.sh

Lines changed: 350 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,350 @@
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

Comments
 (0)