diff --git a/.github/workflows/deed-conformance.yml b/.github/workflows/deed-conformance.yml new file mode 100644 index 0000000..0e7914f --- /dev/null +++ b/.github/workflows/deed-conformance.yml @@ -0,0 +1,40 @@ +# SPDX-License-Identifier: MPL-2.0 +# Behavioural test for .deed support in actions/validate/validate-a2ml.sh. +# +# Deliberately bash-only: it must not depend on the Idris2 toolchain, so that +# it reports on the shell validator independently of the CLI build. +name: Deed Conformance + +# This workflow only reads the tree and runs a bash test script: it does not +# post statuses, comment, or write to the repository. read-all grants every +# read scope there is (actions, packages, deployments, security events...); +# contents: read is the only one actually exercised. +permissions: + contents: read + +on: + push: + paths: + - 'actions/validate/**' + - '.github/workflows/deed-conformance.yml' + pull_request: + paths: + - 'actions/validate/**' + - '.github/workflows/deed-conformance.yml' + workflow_dispatch: + +jobs: + deed-conformance: + name: Deed fixtures — all four ruled heads + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - name: Checkout repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v4 + with: + # No step here pushes or otherwise uses the token, and this workflow + # runs on pull_request against repository-controlled code, so leaving + # the credential in .git/config is exposure with no upside (CWE-522). + persist-credentials: false + - name: Run deed conformance tests + run: bash actions/validate/conformance/run-deed-tests.sh diff --git a/actions/validate/conformance/README.adoc b/actions/validate/conformance/README.adoc new file mode 100644 index 0000000..2e17cbd --- /dev/null +++ b/actions/validate/conformance/README.adoc @@ -0,0 +1,44 @@ += Deed Conformance Fixtures + +// SPDX-License-Identifier: MPL-2.0 + +Behavioural fixtures for `.deed` support in link:../validate-a2ml.sh[validate-a2ml.sh]. + +== Why these exist + +The gap they guard was invisible to every source-level survey. The discovery +glob matched only `'*.a2ml'`, so `.deed` files were never opened at all: the +validator reported no errors and exited 0 having validated nothing. String +presence of a regex is not behavioural acceptance — only running it settles it. + +link:run-deed-tests.sh[run-deed-tests.sh] therefore asserts on the discovery +COUNT as well as the exit code, because `exit 0` is exactly what the defect +produced. + +== Layout + +* `valid/` — one fixture per ruled deed head (DEED-GRAMMAR-SPEC + `<>`): `estate-deed`, `repo-deed`, `praxis-deed`, + `estate-atlas-deed`. All four must pass, strict and non-strict. ++ +`ATLAS.deed` carries `:registry-version` and deliberately no +`:canonical-name`: per `<>` the head symbol itself satisfies the +structural half of identity, so the atlas form must pass on its head alone. + +* `invalid/` — `deed-missing-head.deed` (no ruled head and no identity keyword) + and `deed-missing-version.deed` (no `:schema-version`). Both are *deliberate* + negatives; a repo-wide scan will report them, which is the intended behaviour. + +== Severity + +This fork treats identity as a lint *warning* (see the script header on +standards#435), where `a2ml-ecosystem/validate-action` treats it as an error. +The tests here assert that a problem is *flagged*, not at which severity, so +they hold under either policy. Reconciling the two forks is a separate matter. + +== Run + +[source,sh] +---- +bash actions/validate/conformance/run-deed-tests.sh +---- diff --git a/actions/validate/conformance/invalid/deed-abstract-identity.deed b/actions/validate/conformance/invalid/deed-abstract-identity.deed new file mode 100644 index 0000000..7d18e41 --- /dev/null +++ b/actions/validate/conformance/invalid/deed-abstract-identity.deed @@ -0,0 +1,8 @@ +;; SPDX-License-Identifier: MPL-2.0 +;; Negative fixture: @abstract is a contractile Xfile directive, not a deed +;; identity form. The head here is NOT one of the four ruled heads, so this +;; document must be reported as identity-less even though @abstract appears. +(not-a-ruled-head + :schema-version "1.0.0") + +@abstract "this is not a deed identity" diff --git a/actions/validate/conformance/invalid/deed-head-not-first.deed b/actions/validate/conformance/invalid/deed-head-not-first.deed new file mode 100644 index 0000000..48d6b04 --- /dev/null +++ b/actions/validate/conformance/invalid/deed-head-not-first.deed @@ -0,0 +1,8 @@ +;; SPDX-FileCopyrightText: © 2026 Jonathan D.A. Jewell (hyperpolymath) +;; SPDX-License-Identifier: MPL-2.0 + +(some-other-form + :x "y") + +(estate-deed + :schema-version "1.0.0") diff --git a/actions/validate/conformance/invalid/deed-missing-head.deed b/actions/validate/conformance/invalid/deed-missing-head.deed new file mode 100644 index 0000000..111410f --- /dev/null +++ b/actions/validate/conformance/invalid/deed-missing-head.deed @@ -0,0 +1,4 @@ +;; SPDX-FileCopyrightText: © 2026 Jonathan D.A. Jewell (hyperpolymath) +;; SPDX-License-Identifier: CC-BY-SA-4.0 +(some-other-form + :unrelated "value") diff --git a/actions/validate/conformance/invalid/deed-missing-version.deed b/actions/validate/conformance/invalid/deed-missing-version.deed new file mode 100644 index 0000000..e9562c4 --- /dev/null +++ b/actions/validate/conformance/invalid/deed-missing-version.deed @@ -0,0 +1,4 @@ +;; SPDX-FileCopyrightText: © 2026 Jonathan D.A. Jewell (hyperpolymath) +;; SPDX-License-Identifier: CC-BY-SA-4.0 +(repo-deed + :canonical-name "no-version") diff --git a/actions/validate/conformance/invalid/deed-registry-version-only.deed b/actions/validate/conformance/invalid/deed-registry-version-only.deed new file mode 100644 index 0000000..aac7fa6 --- /dev/null +++ b/actions/validate/conformance/invalid/deed-registry-version-only.deed @@ -0,0 +1,5 @@ +;; SPDX-FileCopyrightText: © 2026 Jonathan D.A. Jewell (hyperpolymath) +;; SPDX-License-Identifier: MPL-2.0 + +(estate-atlas-deed + :registry-version "3") diff --git a/actions/validate/conformance/invalid/example-AI-MANIFEST.deed b/actions/validate/conformance/invalid/example-AI-MANIFEST.deed new file mode 100644 index 0000000..a9a5b1a --- /dev/null +++ b/actions/validate/conformance/invalid/example-AI-MANIFEST.deed @@ -0,0 +1,5 @@ +;; SPDX-FileCopyrightText: © 2026 Jonathan D.A. Jewell (hyperpolymath) +;; SPDX-License-Identifier: MPL-2.0 + +(not-a-head + :nothing "here") diff --git a/actions/validate/conformance/run-deed-tests.sh b/actions/validate/conformance/run-deed-tests.sh new file mode 100755 index 0000000..658517d --- /dev/null +++ b/actions/validate/conformance/run-deed-tests.sh @@ -0,0 +1,103 @@ +#!/usr/bin/env bash +# SPDX-FileCopyrightText: © 2026 Jonathan D.A. Jewell (hyperpolymath) +# SPDX-License-Identifier: MPL-2.0 +# +# Behavioural test for .deed support in validate-action/validate-a2ml.sh. +# +# This exists because the gap it guards was invisible to every source-level +# survey. The discovery glob matched only '*.a2ml', so .deed files were never +# opened at all: the validator reported "no errors" and exited 0 having +# validated nothing. String presence of a regex is not behavioural acceptance — +# only running it settles it. So this runs the validator, and it asserts on the +# discovery COUNT, because "exit 0" is exactly what the bug produced. +# +# The four valid fixtures are one per ruled deed head (DEED-GRAMMAR-SPEC +# <>): estate-deed, repo-deed, praxis-deed, estate-atlas-deed. +set -uo pipefail + +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +VALIDATOR="${HERE}/../validate-a2ml.sh" +FAILURES=0 + +ok() { local msg="$1"; echo " PASS: $msg"; return 0; } +fail() { local msg="$1"; echo " FAIL: $msg" >&2; FAILURES=$((FAILURES + 1)); return 0; } + +# The two diagnostics under test, named once so a wording change cannot leave +# an assertion quietly matching nothing. +NO_IDENTITY='No identity found' +NO_VERSION='Missing version or schema_version field' + +# The conformance directories hold .a2ml fixtures too. Isolate the .deed ones +# so the discovery count is exact rather than incidental. +WORK="$(mktemp -d)" +trap 'rm -rf "$WORK"' EXIT +mkdir -p "$WORK/valid" "$WORK/invalid" +cp "$HERE"/valid/*.deed "$WORK/valid/" +cp "$HERE"/invalid/*.deed "$WORK/invalid/" + +echo "1. valid deeds — all four ruled heads, non-strict" +out="$(INPUT_PATH="$WORK/valid" bash "$VALIDATOR" 2>&1)"; rc=$? +grep -q 'Found 4 ' <<<"$out" && ok "discovered 4 deed files" \ + || fail "discovery: '$(grep -o 'Found [0-9]* [^ ]* file(s)' <<<"$out")' (expected 4)" +[[ $rc -eq 0 ]] && ok "exit 0" || fail "exit $rc (expected 0)" +grep -q '::error' <<<"$out" && fail "unexpected error annotation" || ok "no errors" + +echo "2. valid deeds — strict" +out="$(INPUT_PATH="$WORK/valid" INPUT_STRICT=true bash "$VALIDATOR" 2>&1)"; rc=$? +[[ $rc -eq 0 ]] && ok "exit 0 under strict" || fail "exit $rc under strict (expected 0)" + +# assert_ann +# Anchored on the fixture AND the message: "the filename appears somewhere in +# the output" was satisfiable by any annotation at all, so a broken version +# check still passed. `file=[^,]*` keeps the path free but stops the regex +# wandering into the next annotation. +assert_ann() { + local severity="$1" fixture="$2" message="$3" + grep -qE "::$severity file=[^,]*$fixture,line=[0-9]+::.*$message" <<<"$out" \ + && ok "$fixture -> ::$severity ($message)" \ + || fail "$fixture: expected ::$severity matching '$message'" + return 0 +} + +echo "3. invalid deeds — non-strict: every diagnostic, per fixture" +out="$(INPUT_PATH="$WORK/invalid" bash "$VALIDATOR" 2>&1)"; rc=$? +grep -q 'Found 6 ' <<<"$out" && ok "discovered 6 deed files" \ + || fail "discovery: '$(grep -o 'Found [0-9]* [^ ]* file(s)' <<<"$out")' (expected 5)" +[[ $rc -eq 0 ]] && ok "exit 0 (identity and version are warnings here)" \ + || fail "exit $rc non-strict (expected 0)" +assert_ann warning 'deed-missing-head\.deed' "$NO_IDENTITY" +assert_ann warning 'deed-missing-head\.deed' "$NO_VERSION" +assert_ann warning 'deed-missing-version\.deed' "$NO_VERSION" +# :registry-version is optional atlas metadata, never a schema version. +assert_ann warning 'deed-registry-version-only\.deed' "$NO_VERSION" +# A head that is not the FIRST form does not identify the document. +assert_ann warning 'deed-head-not-first\.deed' "$NO_IDENTITY" +# @abstract is a contractile Xfile directive with no deed production. Matched in +# a .deed it satisfied identity for a document with no ruled head at all. +assert_ann warning 'deed-abstract-identity\.deed' "$NO_IDENTITY" +# The AI-MANIFEST exemption is for .a2ml prose; a .deed is still a deed. +assert_ann warning 'example-AI-MANIFEST\.deed' "$NO_IDENTITY" +assert_ann warning 'example-AI-MANIFEST\.deed' "$NO_VERSION" +# Negative control: a diagnostic naming a fixture that has none would mean the +# anchoring above is matching across annotation boundaries. +grep -qE "::warning file=[^,]*deed-registry-version-only\.deed,line=[0-9]+::$NO_IDENTITY" <<<"$out" \ + && fail "atlas head wrongly reported identity-less" || ok "atlas head still identifies" + +echo "4. invalid deeds — strict promotes every warning to an error" +out="$(INPUT_PATH="$WORK/invalid" INPUT_STRICT=true bash "$VALIDATOR" 2>&1)"; rc=$? +[[ $rc -ne 0 ]] && ok "non-zero exit under strict" || fail "exit 0 under strict (expected non-zero)" +assert_ann error 'deed-missing-head\.deed' "$NO_IDENTITY" +assert_ann error 'deed-missing-version\.deed' "$NO_VERSION" +assert_ann error 'deed-registry-version-only\.deed' "$NO_VERSION" +assert_ann error 'deed-head-not-first\.deed' "$NO_IDENTITY" +assert_ann error 'deed-abstract-identity\.deed' "$NO_IDENTITY" +assert_ann error 'example-AI-MANIFEST\.deed' "$NO_VERSION" +grep -q '::warning' <<<"$out" && fail "::warning survived strict" || ok "no ::warning survives strict" + +echo +if [[ $FAILURES -eq 0 ]]; then + echo "All deed validator tests passed." +else + echo "${FAILURES} deed validator test(s) failed." >&2 + exit 1 +fi diff --git a/actions/validate/conformance/valid/ATLAS.deed b/actions/validate/conformance/valid/ATLAS.deed new file mode 100644 index 0000000..c0b0210 --- /dev/null +++ b/actions/validate/conformance/valid/ATLAS.deed @@ -0,0 +1,5 @@ +;; SPDX-FileCopyrightText: © 2026 Jonathan D.A. Jewell (hyperpolymath) +;; SPDX-License-Identifier: CC-BY-SA-4.0 +(estate-atlas-deed + :schema-version "1.0.0" + :registry-version "3") diff --git a/actions/validate/conformance/valid/cadastre_praxis.deed b/actions/validate/conformance/valid/cadastre_praxis.deed new file mode 100644 index 0000000..ee3514e --- /dev/null +++ b/actions/validate/conformance/valid/cadastre_praxis.deed @@ -0,0 +1,8 @@ +;; SPDX-FileCopyrightText: © 2026 Jonathan D.A. Jewell (hyperpolymath) +;; SPDX-License-Identifier: CC-BY-SA-4.0 +(praxis-deed + :schema-version "1.0.0" + :canonical-name "cadastre" + :beholding-chora #u5"estate/chora" + (rules + (rule :priority 10))) diff --git a/actions/validate/conformance/valid/estate_chora.deed b/actions/validate/conformance/valid/estate_chora.deed new file mode 100644 index 0000000..066792c --- /dev/null +++ b/actions/validate/conformance/valid/estate_chora.deed @@ -0,0 +1,7 @@ +;; SPDX-FileCopyrightText: © 2026 Jonathan D.A. Jewell (hyperpolymath) +;; SPDX-License-Identifier: CC-BY-SA-4.0 +(estate-deed + :schema-version "1.0.0" + :canonical-name "estate" + (vocabulary + (term :name "chora" :gloss "a place with a role"))) diff --git a/actions/validate/conformance/valid/vexometer_chora.deed b/actions/validate/conformance/valid/vexometer_chora.deed new file mode 100644 index 0000000..a6fb80c --- /dev/null +++ b/actions/validate/conformance/valid/vexometer_chora.deed @@ -0,0 +1,6 @@ +;; SPDX-FileCopyrightText: © 2026 Jonathan D.A. Jewell (hyperpolymath) +;; SPDX-License-Identifier: CC-BY-SA-4.0 +(repo-deed + :schema-version "1.0.0" + :canonical-name "vexometer" + :beholding-chora #u5"estate/chora") diff --git a/actions/validate/validate-a2ml.sh b/actions/validate/validate-a2ml.sh index fbdd88c..3348d29 100755 --- a/actions/validate/validate-a2ml.sh +++ b/actions/validate/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. Identity presence (warning — see below) # 2. SPDX-License-Identifier header presence # 3. Attestation block structure (if present) @@ -88,7 +88,7 @@ report_issue() { } # --------------------------------------------------------------------------- -# Validator: check a single .a2ml file +# Validator: check a single .a2ml or .deed file # --------------------------------------------------------------------------- validate_a2ml() { local file="$1" @@ -124,20 +124,59 @@ validate_a2ml() { local has_identity=false local has_version=false local has_placeholders=false + local first_form_seen=false + # The legacy identity forms below (TOML-ish `key =`, `name:`, `[metadata]` + # sections, and the `@abstract` contractile directive) are A2ML surfaces. + # The DEED grammar has NO `=` production, NO `[section]` production and no + # `@abstract` directive — its only bracket is `(`. Accepting them for a + # .deed let any document satisfy identity without ever presenting a ruled + # head, which is a gate bypass of exactly the kind this file already fixes + # three of. + local is_deed=false + [[ "$file" == *.deed ]] && is_deed=true 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 + # Check for identity fields (various A2ML patterns). + # The last two clauses are the deed surface (DEED-GRAMMAR-SPEC + # <>): the s-expression document head, and the + # leading-colon identity keywords. Per <> the head symbol is + # itself identifying, which is what lets ATLAS.deed — :registry-version + # and legitimately no :canonical-name — pass on its head alone. + # Deed surface: legitimate for .deed AND .a2ml. + 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 + # Legacy A2ML surfaces: never identifying for a .deed (see is_deed above). + if [[ "$is_deed" == "false" ]] \ + && { [[ "$line" =~ ^[[:space:]]*(agent[-_]id|name|project|spec_id)[[:space:]]*= ]] \ + || [[ "$line" =~ ^[[:space:]]*name[[:space:]]*: ]] \ + || [[ "$line" =~ ^\[(metadata|scorecard)\] ]] \ + || [[ "$line" =~ ^@abstract ]]; }; then + has_identity=true + fi + # The document head identifies only where the grammar puts it: as the + # FIRST form in the file (DEED-GRAMMAR-SPEC <>). Matched + # anywhere in the file, a nested or trailing `(estate-deed ...)` + # satisfied identity for a document whose actual head was something + # else entirely. + 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 + # Check for version field (either separator). The second clause is the + # deed keyword form: `:schema-version "1.0.0"` — leading colon and a + # hyphen, so the first clause (which spells it schema_version with no + # colon) never matched it. :registry-version is a distinct, OPTIONAL + # atlas field and never satisfies the version requirement, which + # <> makes REQUIRED on all four heads. Accepting it let a + # registry-only atlas head pass carrying no schema version at all. + if [[ "$line" =~ ^[[:space:]]*(version|schema_version)[[:space:]]*[=:] ]] \ + || [[ "$line" =~ ^[[:space:]]*:schema-version[[:space:]] ]]; then has_version=true fi # Template placeholder marker ({{PROJECT_NAME}}, {{VERSION}}, …) @@ -151,7 +190,7 @@ validate_a2ml() { basename="$(basename "$file")" local identity_exempt=false # AI manifests: markdown prose (0-AI-MANIFEST.a2ml, AI.a2ml, …) - if [[ "$basename" == *"AI-MANIFEST"* || "$basename" == "AI.a2ml" ]]; then + if [[ "$basename" == *"AI-MANIFEST"*.a2ml || "$basename" == "AI.a2ml" ]]; then identity_exempt=true fi # Templates/scaffolds @@ -168,8 +207,17 @@ validate_a2ml() { 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)" + # Name the forms that are actually valid FOR THIS SURFACE. The generic + # message listed [metadata] and @abstract, which are A2ML-only: told to + # a deed author (human or bot) it invites them to invent a form the + # grammar does not have, and an invented form is worse than no message. + if [[ "$is_deed" == "true" ]]; then + report_issue "warning" "$file" 1 \ + "No identity found (deed: the first form must head with (estate-deed, (repo-deed, (estate-atlas-deed or (praxis-deed, or the document must carry a :canonical-name, :estate-authority or :agent-id field)" + else + report_issue "warning" "$file" 1 \ + "No identity found (agent-id/name/project/spec_id field, [metadata] or [scorecard] section, or @abstract directive)" + fi fi if [[ "$has_version" == "false" && "$identity_exempt" == "false" ]]; then @@ -230,18 +278,18 @@ 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_files < <(find "$SCAN_PATH" -name '*.a2ml' -not -path '*/.git/*' -type f | sort) +# Find all .a2ml and .deed files, excluding .git directory +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 .a2ml files found in ${SCAN_PATH}" + echo "::notice::No .a2ml or .deed 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 @@ -249,7 +297,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