From 80935820fab7f84f06333bbbcc39d72e091f7511 Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Mon, 24 Aug 2026 17:49:24 +0100 Subject: [PATCH] feat(hypatia): define implementation-inside-canon rule --- .machine_readable/REGISTRY.a2ml | 2 +- hypatia-rules/README.adoc | 17 +- .../implementation-inside-canon.a2ml | 110 ++++++++ scripts/check-implementation-inside-canon.sh | 242 +++++++++--------- 4 files changed, 242 insertions(+), 129 deletions(-) create mode 100644 hypatia-rules/implementation-inside-canon.a2ml diff --git a/.machine_readable/REGISTRY.a2ml b/.machine_readable/REGISTRY.a2ml index 3eab5811..96aaba9d 100644 --- a/.machine_readable/REGISTRY.a2ml +++ b/.machine_readable/REGISTRY.a2ml @@ -279,7 +279,7 @@ name = "Standards Hypatia Rules" stream = "integration" home = "hypatia-rules/" canonical_doc = "hypatia-rules/README.adoc" -source_hash = "sha256:1cfcad4499645de9b99d6689ec22fa6d290dc741b1d426ac8e64f4a77017f75e" +source_hash = "sha256:d6df48bcd28acc10dc67feb2b39c9415ce975a8b35131545dd5b82d0bbc571e8" route = "the dogfooding rules that scan THIS repo (incl. drift detection)" [[spec]] diff --git a/hypatia-rules/README.adoc b/hypatia-rules/README.adoc index b212ea11..ad3682f4 100644 --- a/hypatia-rules/README.adoc +++ b/hypatia-rules/README.adoc @@ -1,9 +1,9 @@ // SPDX-License-Identifier: CC-BY-SA-4.0 = Standards-Specific Hypatia Rules -:status: Draft v0.2.0 -:updated: 2026-04-18 +:status: Draft v0.3.0 +:updated: 2026-08-24 -Eight Hypatia rules specific to the standards-repo dogfooding loop. +Nine Hypatia rules specific to the standards-repo dogfooding loop. Each rule is defined in A2ML, consumes VeriSimDB octads or the repo file tree, and emits Groove `compliance.finding.new` signals. @@ -19,6 +19,7 @@ tree, and emits Groove `compliance.finding.new` signals. | HYP-S006 | `registry-staleness` | Alert when REGISTRY.a2ml source hashes go stale or a DERIVED doc (TOPOLOGY.md) drifts | | HYP-S007 | `profile-drift-detector` | Flag a 6a2 file whose content drifts from its declared A2ML `@profile` | | HYP-S008 | `workflow-allowlist-gap` | Flag a workflow `uses:` an action/reusable not permitted by the repo Actions allowlist (would `startup_failure`) | +| HYP-S009 | `implementation-inside-canon` | Flag product/build manifests below a local (non-external) canonical spec home | The CRG rule pair (S001 + S005) together enforce grade-honesty: S001 catches backwards moves, S005 catches forwards-overshoots. Both read from @@ -50,6 +51,15 @@ so the gap is a legible finding, not a dark run. It is FLAG-ONLY workflow `GITHUB_TOKEN`; its recipe runs `scripts/check-allowed-actions.sh` and the paired `scripts/set-allowed-actions.sh` performs the admin fix. +HYP-S009 is the recurrence guard for the standards carve-out campaign. It uses +`.machine_readable/REGISTRY.a2ml` as the authoritative canon boundary and scans +only local entries (those not marked `kind = "external"`); it does not guess +from broad directory names or the mere presence of an A2ML file. Product/build +manifests below a local spec home emit review-tier findings. The rule is +deliberately flag-only: +moving an implementation can cross history, ownership, and licence boundaries +and therefore requires a reviewed carve-out rather than scanner autofix. + == Implementation Rules live as `.a2ml` files in this directory. They are consumed by @@ -76,6 +86,7 @@ Rules read from: - `a2ml/profiles/REGISTRY.a2ml` + `a2ml/profiles/*/PROFILE.a2ml` and the 6a2 files under `.machine_readable/` (HYP-S007) - `.github/workflows/*` and `rhodium-standard-repositories/actions-allowlist/allowed-actions.json` (HYP-S008) +- `.machine_readable/REGISTRY.a2ml` LOCAL homes and their product/build manifests (HYP-S009) And emit: - Groove `compliance.finding.new` signals with the rule's ID diff --git a/hypatia-rules/implementation-inside-canon.a2ml b/hypatia-rules/implementation-inside-canon.a2ml new file mode 100644 index 00000000..177a9ae8 --- /dev/null +++ b/hypatia-rules/implementation-inside-canon.a2ml @@ -0,0 +1,110 @@ +# SPDX-License-Identifier: MPL-2.0 +# HYP-S009 — Implementation Inside Canon +# +# A REGISTRY.a2ml entry whose home is LOCAL declares that the directory is the +# in-repository source of truth for a standard. Product/build manifests below +# that home are therefore architectural drift: an implementation has started +# growing inside the canon again. This is the recurrence guard for the +# standards carve-out campaign (#479 / #498). +# +# This rule deliberately does NOT infer canon from "contains an .a2ml file" or +# from a broad directory name such as docs/. Those heuristics made the earlier +# shell prototype flag legitimate examples, migrations, and documentation +# tooling. REGISTRY.a2ml is the authoritative boundary. + +@rule(version="1.0"): +id: HYP-S009 +name: "Implementation inside canon" +description: "Flag product/build manifests below a local (non-external) REGISTRY.a2ml spec home" +severity: medium +category: StandardsCompliance +auto_fixable: false +source: standards/hypatia-rules +@end + +@parameters: +registry: .machine_readable/REGISTRY.a2ml +implementation_basenames: + - Cargo.toml + - deno.json + - package.json + - Containerfile + - compose.yaml + - CNAME + - ads.txt +conditional_basenames: + - guix.scm +path_exemptions: + - "**/examples/**" + - "**/templates/**" + - "**/test/fixtures/**" + - "**/tests/fixtures/**" +@end + +@scanner(type="file-tree"): +find: + - glob: ".machine_readable/REGISTRY.a2ml" + - glob: "**/Cargo.toml" + - glob: "**/deno.json" + - glob: "**/package.json" + - glob: "**/Containerfile" + - glob: "**/compose.yaml" + - glob: "**/guix.scm" + - glob: "**/CNAME" + - glob: "**/ads.txt" +@end + +@logic(engine="built-in"): +steps: + - parse_registry: ".machine_readable/REGISTRY.a2ml" + extract: + - key: "id" + as: spec_id + - key: "home" + as: home + - key: "kind" + as: kind + # Local entries omit `kind`; external pointers explicitly use + # `kind = external`. This matches the registry generator contract. + - for_each_spec where "kind != external": + require: "home is a normalized repository-relative directory" + reject_if: "home is absolute or contains a '..' path component" + scan_descendants: home + emit_if: "path not in path_exemptions && basename in implementation_basenames" + finding_kind: implementation-inside-canon + emit_if: "path not in path_exemptions && basename == guix.scm && file is not a documented non-building stub" + finding_kind: implementation-inside-canon + - deduplicate_by: "spec_id, normalized_path" +@end + +@router: +default_strategy: review +# Moving an implementation changes repository ownership/topology and may cross +# licence boundaries. It is never safe for a scanner to perform that move. +strategy_caps: + - when: "finding_kind == implementation-inside-canon" + cap: review + reason: "Carve-out requires history, ownership, licence, and destination-repo review" +@end + +@action: +emit_signal: compliance.finding.new +message_template: >- + Implementation manifest {normalized_path} is inside LOCAL canonical spec + home '{home}' ({spec_id}). Move the product/build surface to its owning repo + and leave only normative spec, policy, registry, or template-as-spec content. +recipe: review-canon-carve-out +halt_on_violation: false +dedupe_window_days: 7 +@end + +@recipe(id="review-canon-carve-out"): +description: "Produce an audit-only carve-out report; never move or delete files automatically" +command: "bash scripts/check-implementation-inside-canon.sh ." +auto_applicable: false +@end + +@sibling_rules: +- HYP-S006 registry-staleness — verifies registry pointers and derived docs +- HYP-S008 workflow-allowlist-gap — reifies zero-job Actions failures +@end diff --git a/scripts/check-implementation-inside-canon.sh b/scripts/check-implementation-inside-canon.sh index ceccada9..6754c94d 100755 --- a/scripts/check-implementation-inside-canon.sh +++ b/scripts/check-implementation-inside-canon.sh @@ -1,144 +1,136 @@ #!/usr/bin/env bash # SPDX-License-Identifier: MPL-2.0 -# check-implementation-inside-canon.sh — Implementation-inside-Canon Detector +# check-implementation-inside-canon.sh — executable reference for HYP-S009. # -# Part of Issue #498: After-eviction hygiene. +# A directory is canonical only when .machine_readable/REGISTRY.a2ml names it +# as a local spec home. Local entries omit `kind`; external pointers explicitly +# set `kind = "external"`. Only tracked files are inspected, matching what a +# clean CI checkout and a pull-request scanner can observe. # -# This script detects when implementation files (Cargo.toml, deno.json, -# Containerfile, CNAME, Justfile, etc.) appear in directories that should -# contain only canonical spec content. This prevents the "implementation -# creep" problem where product code gradually re-enters the canon repo. -# -# Usage: check-implementation-inside-canon.sh [DIRECTORY] -# DIRECTORY defaults to current directory -# -# Exit codes: -# 0 = no implementation files found in canon directories -# 1 = implementation files detected -# 2 = error -# -# Implementation files that trigger detection: -# - Cargo.toml (Rust package manifest) -# - deno.json (Deno configuration) -# - Containerfile (container image build) -# - Dockerfile (container image build - legacy) -# - CNAME (GitHub Pages custom domain) -# - Justfile (just build system) -# - Makefile (legacy build system) -# - package.json (Node.js - banned but still detected) -# - go.mod (Go module - banned but still detected) -# - *.sh (shell scripts in spec directories) -# - *.rs, *.py, *.ts (source code files in spec directories) -# -# Canon directories (should NOT contain implementation files): -# - Any directory containing SPEC.adoc, REGISTRY.a2ml, or *.a2ml files -# - docs/ -# - standards/ (if it exists) -# - Any directory under .machine_readable/ -# -set -euo pipefail +# Usage: check-implementation-inside-canon.sh [REPOSITORY] +# Exit 0: no findings; 1: implementation manifests found; 2: bad input/schema. -TARGET_DIR="${1:-.}" - -# Implementation file patterns -IMPLEMENTATION_PATTERNS=( - "Cargo.toml" - "deno.json" - "Containerfile" - "Dockerfile" - "CNAME" - "Justfile" - "Makefile" - "package.json" - "go.mod" - "*.sh" - "*.rs" - "*.py" - "*.ts" -) +set -euo pipefail -# Canon directory indicators (if these exist, the dir is canon and shouldn't have impl files) -CANON_INDICATORS=( - "SPEC.adoc" - "REGISTRY.a2ml" - "*.a2ml" -) +repo=${1:-.} +registry="$repo/.machine_readable/REGISTRY.a2ml" -# Directories that are always canon -CANON_DIRS=( - "docs" - ".machine_readable" -) +if ! git -C "$repo" rev-parse --git-dir >/dev/null 2>&1; then + echo "implementation-inside-canon: not a git repository: $repo" >&2 + exit 2 +fi -errors=0 -warnings=0 +if [ ! -f "$registry" ]; then + echo "implementation-inside-canon: missing registry: $registry" >&2 + exit 2 +fi -# Function to check if a directory is a canon directory -is_canon_dir() { - local dir="$1" - - # Check if it's in the explicit canon dirs list - for canon_dir in "${CANON_DIRS[@]}"; do - if [[ "$dir" == "$canon_dir" || "$dir" == "$canon_dir/"* ]]; then +is_product_manifest() { + case "$1" in + Cargo.toml|deno.json|package.json|Containerfile|compose.yaml|CNAME|ads.txt) return 0 - fi - done - - # Check for canon indicators - for pattern in "${CANON_INDICATORS[@]}"; do - if find "$dir" -maxdepth 2 -name "$pattern" -not -path "*/.git/*" | grep -q .; then - return 0 - fi - done - - return 1 + ;; + *) + return 1 + ;; + esac } -# Function to check for implementation files in a directory -check_dir() { - local dir="$1" - - # Skip if not a canon directory - if ! is_canon_dir "$dir"; then - return 0 - fi - - # Check for implementation files - for pattern in "${IMPLEMENTATION_PATTERNS[@]}"; do - local files - files=$(find "$dir" -maxdepth 2 -name "$pattern" -not -path "*/.git/*" 2>/dev/null) - if [ -n "$files" ]; then - echo "::error file=$dir::Implementation file detected in canon directory: $files" - echo " Canon directories should contain only spec, policy, and template files." - echo " Implementation files belong in separate repos per Issue #479." +is_template_or_fixture() { + case "/$1/" in + */examples/*|*/templates/*|*/test/fixtures/*|*/tests/fixtures/*) + return 0 + ;; + *) return 1 - fi - done - - return 0 + ;; + esac } -# Main check -echo "Checking for implementation files in canon directories..." +guix_is_non_stub() { + # A comment/whitespace-only guix.scm is a documented placeholder, not a + # build surface. Any executable Scheme form makes it a product manifest. + awk ' + /^[[:space:]]*($|;)/ { next } + { substantive = 1 } + END { exit(substantive ? 0 : 1) } + ' "$1" +} -# Check the target directory -if ! check_dir "$TARGET_DIR"; then - errors=$((errors + 1)) -fi +findings=0 -# Check all subdirectories -while IFS= read -r -d '' dir; do - if ! check_dir "$dir"; then - errors=$((errors + 1)) - fi -done < <(find "$TARGET_DIR" -mindepth 1 -maxdepth 2 -type d -not -path "*/.git/*" -print0) - -if [ $errors -gt 0 ]; then - echo "" - echo "❌ Found $errors canon directories with implementation files." - echo " See Issue #479 for the carve-out campaign." +while IFS=$'\t' read -r spec_id home; do + [ -n "$home" ] || continue + + case "/$home/" in + //*|*/../*|*/./*) + echo "implementation-inside-canon: unsafe registry home for $spec_id: $home" >&2 + exit 2 + ;; + esac + + while IFS= read -r -d '' tracked; do + base=${tracked##*/} + report=false + + if is_template_or_fixture "$tracked"; then + continue + fi + + if is_product_manifest "$base"; then + report=true + elif [ "$base" = guix.scm ] && guix_is_non_stub "$repo/$tracked"; then + report=true + fi + + if [ "$report" = true ]; then + printf 'HYP-S009\t%s\t%s\n' "$spec_id" "$tracked" + findings=$((findings + 1)) + fi + done < <(git -C "$repo" ls-files -z -- "$home") +done < <( + awk ' + function unquote(value) { + sub(/^[[:space:]]*"/, "", value) + sub(/"[[:space:]]*$/, "", value) + return value + } + function emit() { + if (in_spec && id != "" && home != "" && tolower(kind) != "external") { + print id "\t" home + } + } + /^\[\[spec\]\][[:space:]]*$/ { + emit() + in_spec = 1 + id = home = kind = "" + next + } + in_spec && /^[[:space:]]*id[[:space:]]*=/ { + value = $0 + sub(/^[^=]*=[[:space:]]*/, "", value) + id = unquote(value) + next + } + in_spec && /^[[:space:]]*home[[:space:]]*=/ { + value = $0 + sub(/^[^=]*=[[:space:]]*/, "", value) + home = unquote(value) + next + } + in_spec && /^[[:space:]]*kind[[:space:]]*=/ { + value = $0 + sub(/^[^=]*=[[:space:]]*/, "", value) + kind = unquote(value) + next + } + END { emit() } + ' "$registry" +) + +if [ "$findings" -gt 0 ]; then + echo "implementation-inside-canon: $findings tracked product/build manifest(s) in local canonical homes" >&2 exit 1 -else - echo "✅ No implementation files found in canon directories." - exit 0 fi + +echo "implementation-inside-canon: clean"