Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
113 changes: 88 additions & 25 deletions .githooks/validate-a2ml.sh
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
#
# validate-a2ml.sh — A2ML manifest validation script
#
# Scans for .a2ml files and validates:
# Scans for .a2ml and .deed files and validates:
# 1. Required fields: agent-id or pedigree name, version
# 2. SPDX-License-Identifier header presence
# 3. Attestation block structure (if present)
Expand Down Expand Up @@ -89,7 +89,7 @@
}

# ---------------------------------------------------------------------------
# Validator: check a single .a2ml file
# Validator: check a single .a2ml or .deed file
# ---------------------------------------------------------------------------
validate_a2ml() {
local file="$1"
Expand Down Expand Up @@ -123,6 +123,7 @@
# - project = "..." (for STATE.a2ml)
local has_identity=false
local has_version=false
local first_form_seen=false
line_num=0

while IFS= read -r line; do
Expand Down Expand Up @@ -150,6 +151,29 @@
if [[ "$line" =~ ^[[:space:]]*(agent[-_]id|name|project|id)[[:space:]]*: ]]; then
has_identity=true
fi
# DEED s-expression head form: `(estate-deed`, `(repo-deed`,
# `(estate-atlas-deed`, `(praxis-deed`. Per DEED-GRAMMAR-SPEC
# <<identity>>, a file whose first form is one of the four declared
# heads is a deed of that kind, and the head satisfies the structural
# half of identity. This is what lets ATLAS.deed — which carries
# :registry-version and legitimately no :canonical-name — validate.
# The head is the FIRST form (DEED-GRAMMAR-SPEC <<concrete-syntax>>:
# `Deed ::= Header Sep? Form Sep?` — one form, and it carries the head).
# Checking every line let a malformed file open with some other form and
# then append `(estate-deed ...)` lower down to buy identity. Only the
# first form is eligible.
if [[ "$first_form_seen" == "false" && "$line" =~ ^[[:space:]]*\( ]]; then
first_form_seen=true
if [[ "$line" =~ ^[[:space:]]*\((estate-deed|repo-deed|estate-atlas-deed|praxis-deed)([[:space:]]|$) ]]; then
has_identity=true
fi
fi
# DEED keyword identity form: `:canonical-name "..."` and the two other
# identity keywords the spec names. Note the leading colon: none of the
# three forms above match it, because they test the bare words.
if [[ "$line" =~ ^[[:space:]]*:(canonical-name|estate-authority|agent-id)[[:space:]] ]]; then
has_identity=true
fi
# Check for version field — TOML form
if [[ "$line" =~ ^[[:space:]]*(version|schema_version)[[:space:]]*= ]]; then
has_version=true
Expand All @@ -162,17 +186,34 @@
if [[ "$line" =~ ^[[:space:]]*(version|schema_version)[[:space:]]*: ]]; then
has_version=true
fi
# DEED keyword version form: `:schema-version "1.0.0"` — leading colon,
# hyphenated, REQUIRED on all four deed heads (DEED-GRAMMAR-SPEC
# <<version-field>>). All three patterns above spell it `schema_version`
# with no leading colon, so a conforming deed matched none of them.
# `:registry-version` is a distinct field, optional on the atlas.
# `:schema-version` ONLY. `:registry-version` is a distinct, optional
# atlas field (see the note above) and never satisfies the version
# requirement, which DEED-GRAMMAR-SPEC <<version-field>> makes REQUIRED
# on all four heads. Accepting it let a registry-only atlas head pass
# with no schema version at all.
if [[ "$line" =~ ^[[:space:]]*:schema-version[[:space:]] ]]; then
has_version=true
fi
done < "$file"

# AI manifest files (0-AI-MANIFEST.a2ml, 0.1-AI-MANIFEST.a2ml, etc.)
# use markdown-style headers and free text, so identity check is relaxed
local basename
basename="$(basename "$file")"
local is_manifest=false
if [[ "$basename" == *"AI-MANIFEST"* ]]; then
# `.a2ml` ONLY. The exemption exists because AI manifests are markdown-ish
# prose with no in-file identity; it is not a property of the name. Matching
# the bare basename meant `example-AI-MANIFEST.deed` was exempted from BOTH
# the identity and version checks — a deed that skipped the whole gate.
if [[ "$basename" == *"AI-MANIFEST"*.a2ml ]]; then
is_manifest=true
fi
# Canonical typed manifests under .machine_readable/descriptiles/ — identity comes
# Canonical typed manifests under <machine tree>/descriptiles/ — identity comes
# from the enclosing directory + filename, not an in-file field. Sibling
# files in the same directory (ECOSYSTEM.a2ml, STATE.a2ml) DO carry their
# own $name/project and continue to be validated normally.
Expand Down Expand Up @@ -203,27 +244,49 @@
is_contractile_shape=true
fi

# Canonical structured A2ML tree. Everything under a `.machine_readable/`
# directory is a typed agent-readable doc (CLADE, ANCHOR, STATE,
# ECOSYSTEM, bot_directives/{debt,coverage,methodology}, ai/AI,
# policies/*, integrations/*, …). Per the RSR convention these carry
# identity structurally — owning repo + path + filename — not via an
# in-file `name`/`agent-id`. This generalises the `.machine_readable/descriptiles/`
# rationale above to the whole tree: rsr-template-repo itself ships these
# files without an in-file identity key, so requiring one produces
# estate-wide false positives on every repo built from the canonical
# template. Files outside `.machine_readable/` are still validated.
# The structured A2ML tree. Everything under a repo's machine tree —
# `machine-readable/` canonically, `.machine_readable/` in the legacy
# layout — is a typed agent-readable doc (CLADE, ANCHOR, STATE, ECOSYSTEM,
# bot_directives/{debt,coverage,methodology}, ai/AI, policies/*,
# integrations/*, …). Per the RSR convention these carry identity
# structurally — owning repo + path + filename — not via an in-file
# `name`/`agent-id`. This generalises the `descriptiles/` rationale above
# to the whole tree: rsr-template-repo itself ships these files without an
# in-file identity key, so requiring one produces estate-wide false
# positives on every repo built from the canonical template. Files outside
# the machine tree are still validated.
#
# The machine tree is named `machine-readable/` canonically (un-hidden
# 2026-08); `.machine_readable/` is the LEGACY name. BOTH are matched: the
# canon, scaffoldia, the julia variant and ~300 minted repos still carry the
# dotted form, while rsr-template-repo has moved. Matching only one name
# makes whichever half of the estate has not migrated fail this check with
# 16 spurious "missing identity field" errors -- which is exactly what
# happened when the template renamed its tree and this action, being a
# separate implementation from the template's vendored copy, kept matching
# the old name only.
local is_structural_identity=false
if [[ "$file" == *"/.machine_readable/"* || "$file" == "./.machine_readable/"* || "$file" == ".machine_readable/"* ]]; then
is_structural_identity=true
fi
# `*` matches the empty string, so */machine-readable/* already covers the
# ./-prefixed form that `find .` emits; spelling it out separately (as the
# original three-branch test did) is redundant. Verified equivalent across
# ./-prefixed, bare and absolute paths, and on the negative cases.
case "$file" in

Check failure on line 273 in .githooks/validate-a2ml.sh

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Add a default case (*) to handle unexpected values.

See more on https://sonarcloud.io/project/issues?id=hyperpolymath_universal-extension-format&issues=AaCjICBiCs9Pu0hKBHqA&open=AaCjICBiCs9Pu0hKBHqA&pullRequest=65
*/machine-readable/*|machine-readable/*|*/.machine_readable/*|.machine_readable/*)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
is_structural_identity=true
;;
esac

if [[ "$has_identity" == "false" && "$is_manifest" == "false" && "$is_contractile_shape" == "false" && "$is_structural_identity" == "false" ]]; then
report_issue "error" "$file" 1 \
"Missing required identity field (agent-id, name, or project)"
fi

if [[ "$has_version" == "false" && "$is_manifest" == "false" && "$is_contractile_shape" == "false" && "$is_structural_identity" == "false" ]]; then
# DEED is the live, grammar-bearing format: its schema version is required even
# under a machine-readable tree. The structural-identity exemption below stays
# scoped to legacy *.a2ml, which is no longer authored. (CodeRabbit, PR review.)
local version_exempt_structural="$is_structural_identity"
case "$file" in *.deed) version_exempt_structural=false ;; esac

Check failure on line 288 in .githooks/validate-a2ml.sh

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Add a default case (*) to handle unexpected values.

See more on https://sonarcloud.io/project/issues?id=hyperpolymath_universal-extension-format&issues=AaCkJ9J9awNCBM-Uy5YZ&open=AaCkJ9J9awNCBM-Uy5YZ&pullRequest=65
if [[ "$has_version" == "false" && "$is_manifest" == "false" && "$is_contractile_shape" == "false" && "$version_exempt_structural" == "false" ]]; then
report_issue "warning" "$file" 1 \
"Missing version or schema_version field"
fi
Expand Down Expand Up @@ -259,7 +322,7 @@
fi
done < "$file"

if [[ $attestation_line -gt 0 && "$attestation_has_content" == "false" ]]; then
if [[ $attestation_line -gt 0 && "$attestation_has_content" == "false" && "$is_manifest" == "false" ]]; then
report_issue "warning" "$file" "$attestation_line" \
"Attestation block found but missing proof/signature/hash fields"
fi
Expand All @@ -281,15 +344,15 @@
}

# ---------------------------------------------------------------------------
# Main: discover and validate .a2ml files
# Main: discover and validate .a2ml and .deed files
# ---------------------------------------------------------------------------

echo "::group::A2ML Manifest Validation"
echo "Scanning ${SCAN_PATH} for .a2ml files..."
echo "Scanning ${SCAN_PATH} for .a2ml and .deed files..."
echo ""

# Find all .a2ml files, excluding .git directory
mapfile -t a2ml_candidates < <(find "$SCAN_PATH" -name '*.a2ml' -not -path '*/.git/*' -type f | sort)
# Find all .a2ml and .deed files, excluding .git directory
mapfile -t a2ml_candidates < <(find "$SCAN_PATH" \( -name '*.a2ml' -o -name '*.deed' \) -not -path '*/.git/*' -type f | sort)

# Apply paths-ignore filter
a2ml_files=()
Expand All @@ -307,15 +370,15 @@
fi

if [[ ${#a2ml_files[@]} -eq 0 ]]; then
echo "::notice::No .a2ml files found in ${SCAN_PATH}"
echo "::notice::No .a2ml or .deed files found in ${SCAN_PATH}"
echo "files_scanned=0" >> "$GITHUB_OUTPUT_FILE" 2>/dev/null || true
echo "errors=0" >> "$GITHUB_OUTPUT_FILE" 2>/dev/null || true
echo "warnings=0" >> "$GITHUB_OUTPUT_FILE" 2>/dev/null || true
echo "::endgroup::"
exit 0
fi

echo "Found ${#a2ml_files[@]} .a2ml file(s)"
echo "Found ${#a2ml_files[@]} .a2ml/.deed file(s)"
echo ""

for file in "${a2ml_files[@]}"; do
Expand Down
14 changes: 7 additions & 7 deletions .github/workflows/dogfood-gate.yml
Original file line number Diff line number Diff line change
Expand Up @@ -31,10 +31,10 @@ jobs:
- name: Check for A2ML files
id: detect
run: |
COUNT=$(find . -name '*.a2ml' -not -path './.git/*' | wc -l)
COUNT=$(find . -type f \( -name '*.a2ml' -o -name '*.deed' \) -not -path './.git/*' | wc -l)
echo "count=$COUNT" >> "$GITHUB_OUTPUT"
if [ "$COUNT" -eq 0 ]; then
echo "::warning::No .a2ml manifest files found. Every RSR repo should have 0-AI-MANIFEST.a2ml"
echo "::warning::No .deed manifest files found. Every RSR repo should have 0-AI-MANIFEST.deed (.a2ml is legacy and no longer authored)"
fi

- name: Validate A2ML manifests
Expand All @@ -47,14 +47,14 @@ jobs:
cat <<'EOF' >> "$GITHUB_STEP_SUMMARY"
## A2ML Validation

:warning: **No .a2ml files found.** Every RSR-compliant repo should have at least `0-AI-MANIFEST.a2ml`.
:warning: **No manifest found.** Every RSR-compliant repo should have at least `0-AI-MANIFEST.deed`. (`.a2ml` still validates as legacy but is no longer authored.)

Create one with: `a2mliser init` or copy from [rsr-template-repo](https://github.com/hyperpolymath/rsr-template-repo).
Copy one from [rsr-template-repo](https://github.com/hyperpolymath/rsr-template-repo).
EOF
else
echo "## A2ML Validation" >> "$GITHUB_STEP_SUMMARY"
echo "" >> "$GITHUB_STEP_SUMMARY"
echo "Scanned **${A2ML_COUNT}** .a2ml file(s). See step output for details." >> "$GITHUB_STEP_SUMMARY"
echo "Scanned **${A2ML_COUNT}** manifest file(s) (.deed, or legacy .a2ml). See step output for details." >> "$GITHUB_STEP_SUMMARY"
fi

# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -243,7 +243,7 @@ jobs:
MAX=5

# A2ML manifest present?
if find . -name '*.a2ml' -not -path './.git/*' | head -1 | grep -q .; then
if find . -type f \( -name '*.a2ml' -o -name '*.deed' \) -not -path './.git/*' | head -1 | grep -q .; then
SCORE=$((SCORE + 1))
A2ML_STATUS=":white_check_mark:"
else
Expand Down Expand Up @@ -289,7 +289,7 @@ jobs:

| Tool/Format | Status | Notes |
|-------------|--------|-------|
| A2ML manifest (0-AI-MANIFEST.a2ml) | ${A2ML_STATUS} | Required for all RSR repos |
| AI manifest (0-AI-MANIFEST.deed, or legacy .a2ml) | ${A2ML_STATUS} | Required for all RSR repos |
| K9 contracts | ${K9_STATUS} | Required for repos with config files |
| .editorconfig | ${EC_STATUS} | Required for all repos |
| Groove endpoint | ${GROOVE_STATUS} | Required for service repos |
Expand Down
Loading