diff --git a/.githooks/validate-a2ml.sh b/.githooks/validate-a2ml.sh index b053676..b16fa85 100755 --- a/.githooks/validate-a2ml.sh +++ b/.githooks/validate-a2ml.sh @@ -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) @@ -89,7 +89,7 @@ report_issue() { } # --------------------------------------------------------------------------- -# Validator: check a single .a2ml file +# Validator: check a single .a2ml or .deed file # --------------------------------------------------------------------------- validate_a2ml() { local file="$1" @@ -123,6 +123,7 @@ validate_a2ml() { # - 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 @@ -150,6 +151,29 @@ validate_a2ml() { 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 + # <>, 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 <>: + # `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 @@ -162,6 +186,19 @@ validate_a2ml() { 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 + # <>). 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 <> 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.) @@ -169,10 +206,14 @@ validate_a2ml() { 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 /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. @@ -203,27 +244,49 @@ validate_a2ml() { 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 + */machine-readable/*|machine-readable/*|*/.machine_readable/*|.machine_readable/*) + 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 + 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 @@ -259,7 +322,7 @@ validate_a2ml() { 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 @@ -281,15 +344,15 @@ validate_a2ml() { } # --------------------------------------------------------------------------- -# 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=() @@ -307,7 +370,7 @@ if [[ $SKIPPED -gt 0 ]]; then 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 @@ -315,7 +378,7 @@ if [[ ${#a2ml_files[@]} -eq 0 ]]; then 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 diff --git a/.github/workflows/dogfood-gate.yml b/.github/workflows/dogfood-gate.yml index 650f2fc..b772a40 100644 --- a/.github/workflows/dogfood-gate.yml +++ b/.github/workflows/dogfood-gate.yml @@ -33,10 +33,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 @@ -49,14 +49,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 # --------------------------------------------------------------------------- @@ -249,7 +249,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 @@ -295,7 +295,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 |