Repository navigation
Expand file tree
/
Copy pathvalidate-a2ml.sh
More file actions
executable file
·335 lines (298 loc) · 13.5 KB
/
Copy pathvalidate-a2ml.sh
File metadata and controls
executable file
·335 lines (298 loc) · 13.5 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
#!/usr/bin/env bash
# SPDX-License-Identifier: MPL-2.0
# Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk>
#
# validate-a2ml.sh — A2ML manifest validation script
#
# Scans for .a2ml files and validates:
# 1. Identity presence (warning — see below)
# 2. SPDX-License-Identifier header presence
# 3. Attestation block structure (if present)
# 4. Section heading syntax ([section] or ## section)
#
# On identity (standards#435): the A2ML SPEC's only identity requirement is
# per-record (`@record` needs author/tool/kind, SPEC §7) — there is NO
# normative file-level "must have agent-id/name" rule. An earlier version of
# this script required `agent-id|name|project =` as a hard error and flagged
# the majority of canonical estate files (scorecards identify via `spec_id`,
# contractile Xfiles via `@abstract` + filename, the six-file set via
# `[metadata]`). Identity is therefore checked as a lint WARNING against the
# real estate shapes, and skipped entirely for classes that are identity-free
# by design:
# - AI manifests (AI-MANIFEST*.a2ml, AI.a2ml): markdown prose
# - design-rationale/example trees (INPUT_DESIGN_TREES)
# - templates/scaffolds: basename contains "template", or the body carries
# {{PLACEHOLDER}} markers — a scaffold cannot validate as concrete
#
# Environment variables:
# INPUT_PATH — Directory to scan (default: .)
# INPUT_STRICT — Promote warnings to errors (default: false)
# INPUT_DESIGN_TREES — Space-separated path fragments exempt from
# identity/version checks (default:
# "machine-readable-design/ self-validating/examples/
# docs/templates/")
#
# Exit codes:
# 0 — All files valid (or only warnings in non-strict mode)
# 1 — Validation errors found
set -euo pipefail
# ---------------------------------------------------------------------------
# Configuration
# ---------------------------------------------------------------------------
SCAN_PATH="${INPUT_PATH:-.}"
STRICT="${INPUT_STRICT:-false}"
DESIGN_TREES="${INPUT_DESIGN_TREES:-machine-readable-design/ self-validating/examples/ docs/templates/}"
# Outside GitHub Actions GITHUB_OUTPUT is unset; under `set -u` an unset
# expansion inside a redirection aborts the whole script (the `|| true`
# cannot catch an expansion error). Default to /dev/null for local runs.
GITHUB_OUTPUT="${GITHUB_OUTPUT:-/dev/null}"
# Counters
FILES_SCANNED=0
ERRORS=0
WARNINGS=0
# ---------------------------------------------------------------------------
# Helper: emit GitHub annotation
# ---------------------------------------------------------------------------
# Usage: annotate <level> <file> <line> <message>
# level: error | warning | notice
annotate() {
local level="$1" file="$2" line="$3" message="$4"
echo "::${level} file=${file},line=${line}::${message}"
}
# ---------------------------------------------------------------------------
# Helper: report issue (respects strict mode)
# ---------------------------------------------------------------------------
# Usage: report_issue <severity> <file> <line> <message>
# severity: error | warning
report_issue() {
local severity="$1" file="$2" line="$3" message="$4"
if [[ "$severity" == "warning" && "$STRICT" == "true" ]]; then
severity="error"
fi
annotate "$severity" "$file" "$line" "$message"
if [[ "$severity" == "error" ]]; then
ERRORS=$((ERRORS + 1))
else
WARNINGS=$((WARNINGS + 1))
fi
}
# ---------------------------------------------------------------------------
# Validator: check a single .a2ml file
# ---------------------------------------------------------------------------
validate_a2ml() {
local file="$1"
FILES_SCANNED=$((FILES_SCANNED + 1))
# --- Check 1: SPDX header ---
# The SPDX-License-Identifier should appear in the first 10 lines
local has_spdx=false
local line_num=0
while IFS= read -r line; do
line_num=$((line_num + 1))
if [[ $line_num -gt 10 ]]; then
break
fi
if [[ "$line" == *"SPDX-License-Identifier"* ]]; then
has_spdx=true
break
fi
done < "$file"
if [[ "$has_spdx" == "false" ]]; then
report_issue "warning" "$file" 1 \
"Missing SPDX-License-Identifier in first 10 lines"
fi
# --- Check 2: Identity presence (lint warning; see header) ---
# Identity shapes actually used across the estate:
# - agent-id / agent_id / name / project / spec_id = "..." (TOML-ish)
# - name: "..." (colon dialect)
# - a [metadata] or [scorecard] section (the six-file set and
# scorecards: the filename + section carry the identity)
# - an @abstract directive (contractile Xfile dialect)
local has_identity=false
local has_version=false
local has_placeholders=false
line_num=0
while IFS= read -r line; do
line_num=$((line_num + 1))
# Check for identity fields (various A2ML patterns)
if [[ "$line" =~ ^[[:space:]]*(agent[-_]id|name|project|spec_id)[[:space:]]*= ]] \
|| [[ "$line" =~ ^[[:space:]]*name[[:space:]]*: ]] \
|| [[ "$line" =~ ^\[(metadata|scorecard)\] ]] \
|| [[ "$line" =~ ^@abstract ]]; then
has_identity=true
fi
# DEED keyword identity form: `:canonical-name "..."`. Leading colon,
# hyphenated, SPACE-separated — so it matches none of the forms above,
# which all require `=` or `:` as a separator. Adding the `.deed` glob
# without this would discover every deed and then fail it.
if [[ "$line" =~ ^[[:space:]]*:(canonical-name|estate-authority|agent-id)[[:space:]] ]]; then
has_identity=true
fi
# Check for version field (either separator)
if [[ "$line" =~ ^[[:space:]]*(version|schema_version)[[:space:]]*[=:] ]]; then
has_version=true
fi
# DEED keyword version form: `:schema-version "0.1.0"`. Matched
# deliberately narrowly: `:registry-version` is a DISTINCT optional
# field and must NOT satisfy the required-version check.
if [[ "$line" =~ ^[[:space:]]*:schema-version[[:space:]] ]]; then
has_version=true
fi
# S-expression dialect (the sanctioned fourth surface): identity and
# version are NESTED FORMS — `(metadata (name "…") (version "…"))` —
# so they match neither the TOML `key =` nor the `[metadata]` bracket
# patterns above. The conformance corpus lists
# valid/s-expression-state.a2ml as expect="pass", and it did not.
# ⛔ NOT anchored to line start: the nested form is frequently INLINED
# as `(state (metadata (name "…") (version "…")))`, putting both
# `(metadata` and `(version` mid-line. A `^[[:space:]]*` anchor here
# silently failed every inlined deed -- and in strict mode that
# rejected a VALID manifest. The `(` prefix and the trailing
# whitespace/EOL keep the token boundary, so `:registry-version`
# and `(versioning` still do not match.
if [[ "$line" =~ \((metadata|scorecard)([[:space:]]|$) ]]; then
has_identity=true
fi
if [[ "$line" =~ \(version[[:space:]] ]]; then
has_version=true
fi
# Template placeholder marker ({{PROJECT_NAME}}, {{VERSION}}, …)
if [[ "$line" == *"{{"*"}}"* ]]; then
has_placeholders=true
fi
done < "$file"
# DEED head form as identity. Not every deed carries `:canonical-name` —
# `ATLAS.deed` identifies itself by its head alone, exactly as the six-file
# set identifies itself by a `[metadata]` section. Only the FIRST
# s-expression in the file is consulted: a recognised head appearing after
# some other form is a MISPLACED head, not identity, and must still warn.
# The four heads are enumerated rather than matched as `*-deed` so that an
# invented head is not silently accepted as a fifth.
local first_form
first_form="$(grep -m1 '^(' "$file" || true)"
if [[ "$first_form" =~ ^\((estate-deed|estate-atlas-deed|repo-deed|praxis-deed)([[:space:]]|$) ]]; then
has_identity=true
fi
# Classes that are identity-free by design (see header):
local basename
basename="$(basename "$file")"
local identity_exempt=false
# AI manifests: markdown prose (0-AI-MANIFEST.a2ml, AI.a2ml, …)
# `AI.deed` is listed alongside `AI.a2ml` so that renaming a file does not
# silently TIGHTEN the gate on it. A rename must be behaviour-preserving.
if [[ "$basename" == *"AI-MANIFEST"* || "$basename" == "AI.a2ml" || "$basename" == "AI.deed" ]]; then
identity_exempt=true
fi
# Templates/scaffolds
if [[ "${basename,,}" == *"template"* || "$has_placeholders" == "true" ]]; then
identity_exempt=true
fi
# Design-rationale / example trees (standards#435 option a)
local tree
for tree in $DESIGN_TREES; do
if [[ "$file" == *"$tree"* ]]; then
identity_exempt=true
break
fi
done
if [[ "$has_identity" == "false" && "$identity_exempt" == "false" ]]; then
report_issue "warning" "$file" 1 \
"No identity found (agent-id/name/project/spec_id field, [metadata] or [scorecard] section, or @abstract directive)"
fi
if [[ "$has_version" == "false" && "$identity_exempt" == "false" ]]; then
report_issue "warning" "$file" 1 \
"Missing version or schema_version field"
fi
# --- Check 3: Attestation block structure ---
# If file contains [attestation] or ## ATTESTATION, validate it has
# required sub-fields: proof or signature
local in_attestation=false
local attestation_line=0
local attestation_has_content=false
line_num=0
while IFS= read -r line; do
line_num=$((line_num + 1))
# Detect attestation section start
if [[ "$line" =~ ^\[attestation\] ]] || [[ "$line" =~ ^##[[:space:]]+[Aa]ttestation ]] || [[ "$line" =~ ^##[[:space:]]+ATTESTATION ]]; then
in_attestation=true
attestation_line=$line_num
continue
fi
# Detect next section (ends attestation block)
if [[ "$in_attestation" == "true" ]]; then
if [[ "$line" =~ ^\[.+\] ]] || [[ "$line" =~ ^##[[:space:]] ]]; then
in_attestation=false
continue
fi
# Check for content in attestation block
if [[ "$line" =~ (proof|signature|verified|hash)[[:space:]]*= ]]; then
attestation_has_content=true
fi
fi
done < "$file"
if [[ $attestation_line -gt 0 && "$attestation_has_content" == "false" ]]; then
report_issue "warning" "$file" "$attestation_line" \
"Attestation block found but missing proof/signature/hash fields"
fi
# --- Check 4: Section heading syntax ---
# Validate that [section] headings are well-formed (no unclosed brackets)
line_num=0
while IFS= read -r line; do
line_num=$((line_num + 1))
# Lines starting with [ should have a matching ]
if [[ "$line" =~ ^\[ && ! "$line" =~ ^\[.+\] ]]; then
# Exclude markdown-style links and multi-line values
if [[ ! "$line" =~ ^\[.*\]\( && ! "$line" =~ ^\[TODO && ! "$line" =~ ^\[YOUR ]]; then
report_issue "warning" "$file" "$line_num" \
"Possibly malformed section heading: unclosed bracket"
fi
fi
done < "$file"
}
# ---------------------------------------------------------------------------
# Main: discover and validate .a2ml files
# ---------------------------------------------------------------------------
echo "::group::A2ML Manifest Validation"
echo "Scanning ${SCAN_PATH} for .deed and .a2ml files..."
echo ""
# Find all .a2ml files, excluding .git directory
# Discovery globs BOTH extensions. `.deed` is the current format name; `.a2ml`
# is the superseded one and is still present in the wild, so both are scanned.
# NOTE: globbing `.a2ml` alone meant this action scanned nothing in a converted
# repository and exited 0 — a total gate bypass that reports success. Assert on
# the discovery COUNT, never on the exit code.
mapfile -t a2ml_files < <(find "$SCAN_PATH" \( -name '*.a2ml' -o -name '*.deed' \) -not -path '*/.git/*' -type f | sort)
if [[ ${#a2ml_files[@]} -eq 0 ]]; then
echo "::notice::No .deed or .a2ml files found in ${SCAN_PATH}"
echo "files_scanned=0" >> "$GITHUB_OUTPUT" 2>/dev/null || true
echo "errors=0" >> "$GITHUB_OUTPUT" 2>/dev/null || true
echo "warnings=0" >> "$GITHUB_OUTPUT" 2>/dev/null || true
echo "::endgroup::"
exit 0
fi
echo "Found ${#a2ml_files[@]} DEED-family file(s)"
echo ""
for file in "${a2ml_files[@]}"; do
echo " Validating: ${file}"
validate_a2ml "$file"
done
echo ""
echo "────────────────────────────────────────"
echo "Files scanned: ${FILES_SCANNED}"
echo "Errors: ${ERRORS}"
echo "Warnings: ${WARNINGS}"
echo "Strict mode: ${STRICT}"
echo "────────────────────────────────────────"
# Write outputs for GitHub Actions
{
echo "files_scanned=${FILES_SCANNED}"
echo "errors=${ERRORS}"
echo "warnings=${WARNINGS}"
} >> "$GITHUB_OUTPUT" 2>/dev/null || true
echo "::endgroup::"
# Exit with failure if errors were found
if [[ $ERRORS -gt 0 ]]; then
echo "::error::DEED validation failed with ${ERRORS} error(s)"
exit 1
fi
echo "DEED validation passed."
exit 0