diff --git a/1-formats/deed/mappings/launcher-standard-to-praxis-deed.adoc b/1-formats/deed/mappings/launcher-standard-to-praxis-deed.adoc index f84e6b6a..ae5d8296 100644 --- a/1-formats/deed/mappings/launcher-standard-to-praxis-deed.adoc +++ b/1-formats/deed/mappings/launcher-standard-to-praxis-deed.adoc @@ -334,25 +334,44 @@ surface and must follow. They are listed so that "the translation is done" is never mistaken for "the consumers are done" — `implemented` does not imply `wired` (AGENTS.md §6). -. **`launch-scaffolder`** — `crates/launcher-common/src/standard.rs` parses the - standard with `toml::from_str` and bakes a vendored copy via `include_str!()`. - ⚠ **There is no Rust `.deed` parser in the estate.** `deed-ecosystem/rs` is - the unmigrated A2ML markdown crate (`[package] name = "a2ml"`, with - `Block::Heading` / `Block::Paragraph`) and is not a starting point. The work - is a purpose-built recursive-descent parser in `launcher-common`; the - acceptance test is that the old TOML and the new deed parse to an **equal** - `Standard` value, and the vendored copy is made byte-identical to the - committed deed with a drift test asserting it. `[resolution]` joins the - required-section set — the vendored copy's omission of it is the live defect - this closes. +. **`launch-scaffolder`** — ✅ **DONE 2026-09-22** (D73-C part 2). This item was + written while `crates/launcher-common/src/standard.rs` parsed the standard with + `toml::from_str` over a vendored `include_str!()` copy, and while it was true + that **no Rust `.deed` parser existed in the estate**. Both of those facts have + since changed; the original wording survives in git history. ++ + Measured 2026-09-22: `crates/launcher-common/src/deed.rs` is a purpose-built + recursive-descent DEED parser (37,039 B), exported as `pub mod deed;` + (`lib.rs:23`), with `pub mod standard;` (`lib.rs:30`) reading + `launcher-standard_praxis.deed` on top of it. The vendored copy at + `launch-scaffolder/standards/launcher-standard_praxis.deed` is **byte-identical + to the canonical file** (`diff -q` reports no difference; 13,803 B, + `:standard-version "0.4.0"`, `:standard-date "2026-09-22"`), so the + `[resolution]`-omission defect this item described is closed. ++ + ⚠ Still true, and still the reason `deed-ecosystem/rs` was not the starting + point: it remains the unmigrated A2ML markdown crate (`[package] name = + "a2ml"`, with `Block::Heading` / `Block::Paragraph`). Leave it alone — + `deed-ecosystem#67`. . **`trigger`** — `scripts/trigger-launcher.sh`, `dev-notes/HANDOVER.adoc`, `docs/IMPLEMENTATION-STATUS.adoc` name the standard. Textual. -Out of scope, recorded so it is not lost: **10 downstream `*.launcher.a2ml` -files** under `metadatastician/` and `_ESOTERIC_GROUP _SET/`. These are +Out of scope, recorded so it is not lost: the downstream `*.launcher.a2ml` +descriptors under `metadatastician/` and `_ESOTERIC_GROUP _SET/`. These are per-application *instances* of the metadata block, a different family from this standard, and they need their own spec before anything touches them. +⚠ This was first recorded as **10 files**. That is an undercount by more than +5×. Measured 2026-09-22 (`rg --files -g '*.launcher.a2ml'`, excluding +`.claude/worktrees/` and `developer/worktrees/`): **23 distinct descriptors +across 52 on-disk paths** — the path count exceeds the descriptor count because +several repos are cloned more than once — and **39 of those paths cite the +deleted `launcher-standard.a2ml`**. Two of the 23 (`_DATABASE`, +`_ESOTERIC_GROUP`) are set-level rather than per-application, which is why an +app-name census returns 21. The figure decays as clones are added, so it is +dated rather than stated as standing fact; `hyperpolymath/standards#960` carries +the decision on what happens to these descriptors. + == 8. What this spec does not claim * That the `praxis-deed` head is the only defensible choice. `repo-deed` would @@ -361,6 +380,9 @@ standard, and they need their own spec before anything touches them. names that exact failure. * That the `:priority` step of 10 is meaningful. It is insertion headroom and nothing else; only the relative order is semantic. -* That the vendored `launch-scaffolder` copy is now correct. It is not: it is - still v0.2.0, still missing `[resolution]`, and still `.a2ml`. That is §7 - item 1 and it is not done. +* ⚠ **WITHDRAWN 2026-09-22.** This spec previously claimed the vendored + `launch-scaffolder` copy was "still v0.2.0, still missing `[resolution]`, and + still `.a2ml`". Measured, that is false in all three parts: the vendored file + is `standards/launcher-standard_praxis.deed`, 13,803 B, **byte-identical to + the canonical deed**, at `:standard-version "0.4.0"`. D73-C part 2 closed it. + See §7 item 1. diff --git a/scripts/check-launcher-standard-currency.sh b/scripts/check-launcher-standard-currency.sh new file mode 100755 index 00000000..447abe5d --- /dev/null +++ b/scripts/check-launcher-standard-currency.sh @@ -0,0 +1,256 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 +# SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell +# +# check-launcher-standard-currency.sh -- fail if a repo claims compliance with a +# launcher standard filename or version that no longer exists upstream. +# +# standards#960 acceptance criterion 4. +# +# WHY THIS EXISTS +# --------------- +# `launcher/launcher-standard.a2ml` was DELETED from `standards` on 2026-09-22 +# (#952, be6c9580) and replaced by `launcher/launcher-standard_praxis.deed`. +# Nothing told the downstream repos. A census on 2026-09-22 found 21 distinct +# `*.launcher.a2ml` descriptors, plus `trigger/scripts/trigger-launcher.sh`, +# still declaring "Compliant with launcher-standard.a2ml v0.3.0" -- a filename +# that is gone and a version that no longer exists. Both claims read as +# compliance and neither is checkable, which is the vacuous-gate pattern the +# estate rules against. +# +# THE TWO DEFECTS ARE INDEPENDENT +# ------------------------------- +# A reference fails on the FILENAME or on the VERSION, separately: +# +# launcher-standard.a2ml any version -> FAIL (retired filename) +# launcher-standard_praxis.deed v0.3.0 -> FAIL (stale version) +# launcher-standard.a2ml v0.4.0 -> FAIL (filename only) +# launcher-standard_praxis.deed v0.4.0 -> PASS +# +# A reference carrying no version token is checked on the filename alone; that +# is not a defect in itself, because plenty of prose names the standard without +# pinning it. +# +# `launcher-standard.adoc` is a DIFFERENT document (the human-readable UX +# standard) and is deliberately NOT checked here. It declares no version of its +# own, so a version claim against it is a separate defect and belongs in its +# own gate rather than being smuggled into this one. +# +# ANTI-DRIFT: THE EXPECTED VERSION IS NOT A MAGIC NUMBER +# ------------------------------------------------------ +# CURRENT_VERSION below is a default for consumer repos, which do not carry the +# standard. It is NOT the authority. `scripts/tests/` asserts that this default +# equals `:standard-version` in the real deed, so bumping the standard without +# updating this script turns `standards`' own CI red. A checker whose expected +# value can silently drift from the thing it checks is worse than no checker. +# Consumers that DO vendor the deed should pass `--standard ` and read the +# version from the artefact instead of trusting this default. +# +# HISTORICAL RECORDS ARE ALLOWLISTED BY PATH, NEVER BY CONTENT +# ------------------------------------------------------------ +# standards#960 AC1 exempts "a dated historical record". That exemption is a +# PATH allowlist (see ALLOWLIST below), not a content heuristic: a heuristic +# that spares any line mentioning "formerly" or "translated from" would spare +# the live defect too, because that is exactly how a stale descriptor comment +# is worded. +# +# EXIT CODES +# 0 no defects +# 1 at least one defect +# 2 usage error, or a seeded mutant survived --self-test +set -uo pipefail + +CANONICAL_FILE="launcher-standard_praxis.deed" +RETIRED_FILE="launcher-standard.a2ml" +CURRENT_VERSION="0.4.0" +SELF_TEST_TMP="" + +# Path globs exempt as historical records. Matched against the repo-relative +# path. Each entry is justified; do not add one without a reason. +ALLOWLIST=( + 'docs/audits/*' # dated audit records (AC1's exemption) + '1-formats/deed/mappings/*' # the .a2ml -> .deed conversion record; naming both is its job + 'dev-notes/*' # working notes, not compliance claims + '*HANDOVER*' # handover documents record prior state + '*CHANGELOG*' # a changelog that cannot name the old file is useless + 'launcher/launcher-standard_praxis.deed' # the canon itself; its ;; header records its own provenance + 'scripts/check-launcher-standard-currency.sh' # this file + 'scripts/tests/check-launcher-standard-currency-test.sh' +) + +usage() { + cat < --standard > built-in default (${CURRENT_VERSION}) +USAGE +} + +# Read :standard-version out of a praxis deed. The DEED grammar carries TWO +# versions and they are not interchangeable: :schema-version is the GRAMMAR +# (1.0.0) and :standard-version is the DOCUMENT. Reading the first yields a +# number that looks like a newer spec, so the resulting error reads as an +# upgrade rather than as drift. +read_standard_version() { + local f="$1" v + [ -r "$f" ] || { echo "ERROR: cannot read standard file: $f" >&2; return 2; } + v=$(command grep -m1 -oE ':standard-version[[:space:]]+"[0-9]+\.[0-9]+\.[0-9]+"' "$f" \ + | command grep -oE '[0-9]+\.[0-9]+\.[0-9]+') + [ -n "$v" ] || { echo "ERROR: no :standard-version in $f" >&2; return 2; } + printf '%s' "$v" +} + +is_allowlisted() { + local path="$1" glob + for glob in "${ALLOWLIST[@]}"; do + # shellcheck disable=SC2053 + [[ "$path" == $glob ]] && return 0 + done + return 1 +} + +# Scan a tree. Prints one defect per line; returns 1 if any were found. +scan() { + local root="$1" expect="$2" defects=0 hit file lineno text rel found + + while IFS= read -r hit; do + file="${hit%%:*}"; hit="${hit#*:}" + lineno="${hit%%:*}"; text="${hit#*:}" + rel="${file#"$root"/}"; rel="${rel#./}" + is_allowlisted "$rel" && continue + + if [[ "$text" == *"$RETIRED_FILE"* ]]; then + printf 'DEFECT retired-filename %s:%s names %s (deleted upstream 2026-09-22, #952)\n' \ + "$rel" "$lineno" "$RETIRED_FILE" + defects=$((defects + 1)) + fi + + if [[ "$text" =~ launcher-standard(\.a2ml|_praxis\.deed)[^0-9]{0,24}v?([0-9]+\.[0-9]+\.[0-9]+) ]]; then + found="${BASH_REMATCH[2]}" + if [ "$found" != "$expect" ]; then + printf 'DEFECT stale-version %s:%s claims v%s, current is v%s\n' \ + "$rel" "$lineno" "$found" "$expect" + defects=$((defects + 1)) + fi + fi + done < <(command grep -rInF -e "$RETIRED_FILE" -e "$CANONICAL_FILE" "$root" --exclude-dir=.git 2>/dev/null) + + [ "$defects" -eq 0 ] && return 0 + printf '\n%s defect(s).\n' "$defects" + return 1 +} + +# Seeded-mutant self-test. A passing check proves nothing until a mutant dies, +# so every defect class gets a mutant AND there is a clean negative control. +self_test() { + local rc=0 out seeded=0 + # NOTE: a `trap ... RETURN` here would NOT be scoped to this function -- bash + # installs it globally, so it fires on every later function return and, since + # $tmp is local, dies under `set -u`. Use a global + EXIT trap instead. + SELF_TEST_TMP="$(mktemp -d)" + trap 'rm -rf "${SELF_TEST_TMP:-}"' EXIT + local tmp="$SELF_TEST_TMP" + + mkdir -p "$tmp/docs/audits" + # mutant 1: retired filename, no version + printf '# Compliant with %s\n' "$RETIRED_FILE" > "$tmp/m1.toml"; seeded=$((seeded+1)) + # mutant 2: canonical filename, stale version + printf '# Compliant with %s v0.3.0\n' "$CANONICAL_FILE" > "$tmp/m2.toml"; seeded=$((seeded+1)) + # mutant 3: retired filename WITH the current version -- filename must fail on its own + printf '# Compliant with %s v%s\n' "$RETIRED_FILE" "$CURRENT_VERSION" > "$tmp/m3.toml"; seeded=$((seeded+1)) + # negative control: fully current, must NOT be reported + printf '# Compliant with %s v%s\n' "$CANONICAL_FILE" "$CURRENT_VERSION" > "$tmp/clean.toml" + # allowlist control: the worst mutant, under a dated-audit path -- must NOT be reported + printf '# Compliant with %s v0.1.0\n' "$RETIRED_FILE" > "$tmp/docs/audits/old-2026-05-26.adoc" + + if [ "$seeded" -eq 0 ]; then + echo "SELF-TEST ERROR: zero fixtures seeded -- the self-test is vacuous." >&2 + return 2 + fi + + out="$(scan "$tmp" "$CURRENT_VERSION")" || rc=1 + + local fail=0 + check_detects() { + if ! printf '%s' "$out" | command grep -q "$1"; then + echo "SELF-TEST FAIL: mutant survived -- expected to detect: $1" >&2 + fail=1 + fi + } + check_absent() { + if printf '%s' "$out" | command grep -q "$1"; then + echo "SELF-TEST FAIL: false positive on: $1" >&2 + fail=1 + fi + } + + check_detects 'retired-filename m1.toml' + check_detects 'stale-version m2.toml' + check_detects 'retired-filename m3.toml' + check_absent 'm3.toml.*stale-version' # m3 is current; only the filename is wrong + check_absent 'clean.toml' + check_absent 'docs/audits' + + if [ "$rc" -ne 1 ]; then + echo "SELF-TEST FAIL: scan returned 0 with mutants present." >&2 + fail=1 + fi + + if [ "$fail" -ne 0 ]; then + echo "--- scan output was ---" >&2 + printf '%s\n' "$out" >&2 + return 2 + fi + + printf 'self-test: %s mutants killed, 2 controls clean, OK\n' "$seeded" + return 0 +} + +main() { + local root="." standard="" expect="" do_self_test=0 + while [ $# -gt 0 ]; do + case "$1" in + --root) root="${2:-}"; shift 2 ;; + --standard) standard="${2:-}"; shift 2 ;; + --expect-version) expect="${2:-}"; shift 2 ;; + --self-test) do_self_test=1; shift ;; + -h|--help) usage; return 0 ;; + *) echo "ERROR: unknown argument: $1" >&2; usage >&2; return 2 ;; + esac + done + + [ "$do_self_test" -eq 1 ] && { self_test; return $?; } + + if [ -z "$expect" ] && [ -n "$standard" ]; then + expect="$(read_standard_version "$standard")" || return 2 + fi + [ -z "$expect" ] && expect="$CURRENT_VERSION" + + if [ ! -d "$root" ]; then + echo "ERROR: --root is not a directory: $root" >&2 + return 2 + fi + + echo "check-launcher-standard-currency: canonical=${CANONICAL_FILE} expected=v${expect}" + if scan "$root" "$expect"; then + echo "No stale launcher-standard references." + return 0 + fi + cat >&2 < +assert_rc() { + local label="$1" want="$2"; shift 2 + local got=0 + "$@" >/dev/null 2>&1 || got=$? + if [ "$got" -eq "$want" ]; then ok "$label"; else no "$label (rc=$got, want $want)"; fi +} + +echo "=== 0. the gate exists and is syntactically valid ===" +if [ -r "$GATE" ]; then ok "gate is readable"; else no "gate missing: $GATE"; fi +assert_rc "gate parses" 0 bash -n "$GATE" + +echo +echo "=== 1. ANTI-DRIFT: the gate's default version tracks the real deed ===" +# This is the assertion that stops the checker from silently drifting away from +# the thing it checks. If the standard is bumped and this default is not, THIS +# test goes red in standards' own CI -- which is the only place that can notice. +deed_version="$(command grep -m1 -oE ':standard-version[[:space:]]+"[0-9]+\.[0-9]+\.[0-9]+"' "$DEED" \ + | command grep -oE '[0-9]+\.[0-9]+\.[0-9]+')" +gate_default="$(command grep -m1 -oE '^CURRENT_VERSION="[0-9]+\.[0-9]+\.[0-9]+"' "$GATE" \ + | command grep -oE '[0-9]+\.[0-9]+\.[0-9]+')" +if [ -n "$deed_version" ] && [ "$deed_version" = "$gate_default" ]; then + ok "gate default v$gate_default == deed :standard-version v$deed_version" +else + no "DRIFT: gate default is v${gate_default:-?} but the deed says v${deed_version:-?} -- update CURRENT_VERSION in $GATE" +fi + +echo +echo "=== 2. the gate's premise still holds on disk ===" +if [ -f "$DEED" ]; then + ok "canonical standard exists at launcher/launcher-standard_praxis.deed" +else + no "canonical standard is MISSING -- the gate's cure text points at nothing" +fi +if [ -e "$REPO_ROOT/launcher/launcher-standard.a2ml" ]; then + no "the retired launcher-standard.a2ml is back on disk -- the gate's premise is void" +else + ok "retired launcher-standard.a2ml is absent, as #952 left it" +fi + +echo +echo "=== 3. :standard-version is read, NOT :schema-version ===" +# A deed carries TWO versions. :schema-version is the DEED GRAMMAR (1.0.0); +# :standard-version is the document. Reading the first yields a number that +# looks like a NEWER spec, so the resulting error reads as an upgrade rather +# than as drift -- it is the quietest possible way to get this wrong. The +# fixture below makes the two impossible to confuse. +cat > "$WORK/fake_praxis.deed" <<'DEED' +(praxis-deed + :schema-version "1.0.0" + :canonical-name "launcher-standard" + :standard-version "0.9.9") +DEED +out="$("$GATE" --root "$WORK" --standard "$WORK/fake_praxis.deed" 2>&1)" || true +if printf '%s' "$out" | command grep -q 'expected=v0.9.9'; then + ok "--standard read :standard-version (0.9.9)" +elif printf '%s' "$out" | command grep -q 'expected=v1.0.0'; then + no "--standard read :schema-version (1.0.0) -- WRONG FIELD" +else + no "--standard produced no recognisable expected version: $out" +fi + +echo +echo "=== 4. the two defect classes fail INDEPENDENTLY ===" +mkdir -p "$WORK/tree/docs/audits" +printf '# Compliant with launcher-standard.a2ml\n' > "$WORK/tree/retired-only.toml" +printf '# Compliant with launcher-standard_praxis.deed v0.3.0\n' > "$WORK/tree/stale-only.toml" +printf '# Compliant with launcher-standard.a2ml v0.4.0\n' > "$WORK/tree/retired-but-current.toml" +printf '# Compliant with launcher-standard_praxis.deed v0.4.0\n' > "$WORK/tree/clean.toml" +printf '# Compliant with launcher-standard.a2ml v0.1.0\n' > "$WORK/tree/docs/audits/rec-2026-05-26.adoc" + +out="$("$GATE" --root "$WORK/tree" --expect-version 0.4.0 2>&1)" || true + +expect_hit() { + if printf '%s' "$out" | command grep -q "$1"; then ok "detects: $2"; else no "MUTANT SURVIVED: $2"; fi +} +expect_miss() { + if printf '%s' "$out" | command grep -q "$1"; then no "FALSE POSITIVE: $2"; else ok "clean: $2"; fi +} + +expect_hit 'retired-filename retired-only.toml' 'retired filename with no version' +expect_hit 'stale-version stale-only.toml' 'stale version on the canonical filename' +expect_hit 'retired-filename retired-but-current.toml' 'retired filename even at the current version' +expect_miss 'stale-version retired-but-current' 'no spurious version defect when the version is current' +expect_miss 'clean.toml' 'fully-current reference' +expect_miss 'docs/audits' 'dated historical record is allowlisted' + +echo +echo "=== 5. exit codes ===" +assert_rc "defects present -> rc 1" 1 "$GATE" --root "$WORK/tree" --expect-version 0.4.0 +mkdir -p "$WORK/empty" +assert_rc "no references -> rc 0" 0 "$GATE" --root "$WORK/empty" --expect-version 0.4.0 +assert_rc "unknown argument -> rc 2" 2 "$GATE" --bogus +assert_rc "missing --root dir -> rc 2" 2 "$GATE" --root "$WORK/does-not-exist" +assert_rc "--help -> rc 0" 0 "$GATE" --help + +echo +echo "=== 6. the gate's own self-test passes ===" +assert_rc "--self-test" 0 "$GATE" --self-test + +echo +echo "=== 7. standards' own tree is clean under the allowlist ===" +# Not a tautology: the tree really does carry launcher-standard.a2ml in four +# places (the conversion mapping doc, two dated audit records, and the deed's +# own ;; provenance header). This asserts the allowlist absorbs exactly those +# and has not been widened into a blanket. +assert_rc "standards tree clean" 0 "$GATE" --root "$REPO_ROOT" --standard "$DEED" + +echo +echo "=== summary: $pass passed, $fail failed ===" +[ "$fail" -eq 0 ] || exit 1