Add governance and identity registry scaffolding
This commit is contained in:
parent
1da00ecdf3
commit
f6a7f0922d
13 changed files with 612 additions and 21 deletions
133
Scripts/bep
Executable file
133
Scripts/bep
Executable file
|
|
@ -0,0 +1,133 @@
|
|||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
repo_root=$(git rev-parse --show-toplevel)
|
||||
proposals_dir="$repo_root/evolution/proposals"
|
||||
|
||||
auto_browse() {
|
||||
if command -v wisu >/dev/null 2>&1; then
|
||||
exec wisu -i -g --icons "$repo_root/evolution"
|
||||
fi
|
||||
exec ls -la "$repo_root/evolution"
|
||||
}
|
||||
|
||||
usage() {
|
||||
cat <<'USAGE'
|
||||
Usage: bep [command]
|
||||
|
||||
Commands:
|
||||
list [--status <Status>] List BEPs, optionally filtered by status.
|
||||
open <BEP-XXXX|XXXX|X> Open a BEP in $EDITOR.
|
||||
help Show this help.
|
||||
|
||||
If no command is provided, bep launches a simple browser for evolution/.
|
||||
USAGE
|
||||
}
|
||||
|
||||
normalize_id() {
|
||||
local raw="$1"
|
||||
if [[ "$raw" =~ ^BEP-[0-9]+$ ]]; then
|
||||
printf '%s' "$raw"
|
||||
return 0
|
||||
fi
|
||||
if [[ "$raw" =~ ^[0-9]+$ ]]; then
|
||||
printf 'BEP-%04d' "$raw"
|
||||
return 0
|
||||
fi
|
||||
return 1
|
||||
}
|
||||
|
||||
read_status() {
|
||||
local file="$1"
|
||||
awk -F ': ' '/^Status:/ {print $2; exit}' "$file"
|
||||
}
|
||||
|
||||
read_title() {
|
||||
local file="$1"
|
||||
local line
|
||||
line=$(head -n 1 "$file" || true)
|
||||
printf '%s' "$line" | sed -E 's/^# `[^`]+`[[:space:]]+//; s/^[^A-Za-z0-9]+//'
|
||||
}
|
||||
|
||||
list_bep() {
|
||||
local filter="${1:-}"
|
||||
local filter_lower=""
|
||||
if [[ -n "$filter" ]]; then
|
||||
filter_lower=$(printf '%s' "$filter" | tr '[:upper:]' '[:lower:]')
|
||||
fi
|
||||
|
||||
printf '%-10s %-18s %s\n' "BEP" "Status" "Title"
|
||||
local file
|
||||
local entries=()
|
||||
for file in "$proposals_dir"/BEP-*.md; do
|
||||
[[ -e "$file" ]] || continue
|
||||
local base
|
||||
base=$(basename "$file")
|
||||
local id
|
||||
id=$(printf '%s' "$base" | cut -d- -f1-2)
|
||||
local status
|
||||
status=$(read_status "$file")
|
||||
local status_lower
|
||||
status_lower=$(printf '%s' "$status" | tr '[:upper:]' '[:lower:]')
|
||||
if [[ -n "$filter_lower" && "$status_lower" != "$filter_lower" ]]; then
|
||||
continue
|
||||
fi
|
||||
local title
|
||||
title=$(read_title "$file")
|
||||
entries+=("$(printf '%-10s %-18s %s' "$id" "$status" "$title")")
|
||||
done
|
||||
if [[ ${#entries[@]} -gt 0 ]]; then
|
||||
printf '%s\n' "${entries[@]}" | sort
|
||||
fi
|
||||
}
|
||||
|
||||
open_bep() {
|
||||
local raw="$1"
|
||||
local id
|
||||
if ! id=$(normalize_id "$raw"); then
|
||||
echo "Unknown BEP id: $raw" >&2
|
||||
exit 1
|
||||
fi
|
||||
local matches
|
||||
matches=("$proposals_dir"/"$id"-*.md)
|
||||
if [[ ${#matches[@]} -eq 0 || ! -e "${matches[0]}" ]]; then
|
||||
echo "No proposal found for $id" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ ${#matches[@]} -gt 1 ]]; then
|
||||
echo "Multiple proposals match $id:" >&2
|
||||
printf ' %s\n' "${matches[@]}" >&2
|
||||
exit 1
|
||||
fi
|
||||
local editor="${EDITOR:-vi}"
|
||||
exec "$editor" "${matches[0]}"
|
||||
}
|
||||
|
||||
command=${1:-}
|
||||
case "$command" in
|
||||
"")
|
||||
auto_browse
|
||||
;;
|
||||
list)
|
||||
if [[ ${2:-} == "--status" && -n ${3:-} ]]; then
|
||||
list_bep "$3"
|
||||
else
|
||||
list_bep
|
||||
fi
|
||||
;;
|
||||
open)
|
||||
if [[ -z ${2:-} ]]; then
|
||||
echo "bep open requires an id" >&2
|
||||
exit 1
|
||||
fi
|
||||
open_bep "$2"
|
||||
;;
|
||||
help|-h|--help)
|
||||
usage
|
||||
;;
|
||||
*)
|
||||
echo "Unknown command: $command" >&2
|
||||
usage
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
94
Scripts/check-bep-metadata.py
Executable file
94
Scripts/check-bep-metadata.py
Executable file
|
|
@ -0,0 +1,94 @@
|
|||
#!/usr/bin/env python3
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pathlib
|
||||
import re
|
||||
import sys
|
||||
|
||||
|
||||
REPO_ROOT = pathlib.Path(__file__).resolve().parent.parent
|
||||
PROPOSALS_DIR = REPO_ROOT / "evolution" / "proposals"
|
||||
ALLOWED_STATUSES = {
|
||||
"Pitch",
|
||||
"Draft",
|
||||
"In Review",
|
||||
"Accepted",
|
||||
"Implemented",
|
||||
"Rejected",
|
||||
"Returned for Revision",
|
||||
"Superseded",
|
||||
"Archived",
|
||||
}
|
||||
REQUIRED_FIELDS = [
|
||||
"Status",
|
||||
"Proposal",
|
||||
"Authors",
|
||||
"Coordinator",
|
||||
"Reviewers",
|
||||
"Constitution Sections",
|
||||
"Implementation PRs",
|
||||
"Decision Date",
|
||||
]
|
||||
|
||||
|
||||
def text_block_lines(path: pathlib.Path) -> list[str]:
|
||||
content = path.read_text(encoding="utf-8")
|
||||
match = re.search(r"```text\n(.*?)\n```", content, re.DOTALL)
|
||||
if not match:
|
||||
raise ValueError("missing leading ```text metadata block")
|
||||
return [line.rstrip() for line in match.group(1).splitlines() if line.strip()]
|
||||
|
||||
|
||||
def validate(path: pathlib.Path) -> list[str]:
|
||||
errors: list[str] = []
|
||||
proposal_id = path.name.split("-", 2)[:2]
|
||||
expected_id = "-".join(proposal_id).removesuffix(".md")
|
||||
|
||||
try:
|
||||
lines = text_block_lines(path)
|
||||
except ValueError as exc:
|
||||
return [f"{path}: {exc}"]
|
||||
|
||||
field_names = [line.split(":", 1)[0] for line in lines]
|
||||
if field_names != REQUIRED_FIELDS:
|
||||
errors.append(
|
||||
f"{path}: metadata fields must appear in order {', '.join(REQUIRED_FIELDS)}"
|
||||
)
|
||||
return errors
|
||||
|
||||
fields = dict(line.split(":", 1) for line in lines)
|
||||
fields = {key.strip(): value.strip() for key, value in fields.items()}
|
||||
|
||||
if fields["Status"] not in ALLOWED_STATUSES:
|
||||
errors.append(f"{path}: invalid Status {fields['Status']!r}")
|
||||
|
||||
if fields["Proposal"] != expected_id:
|
||||
errors.append(
|
||||
f"{path}: Proposal field {fields['Proposal']!r} does not match filename id {expected_id!r}"
|
||||
)
|
||||
|
||||
if fields["Status"] in {"Accepted", "Implemented", "Superseded", "Rejected", "Archived"} and fields["Decision Date"] == "Pending":
|
||||
errors.append(
|
||||
f"{path}: Decision Date must not be Pending once status is {fields['Status']}"
|
||||
)
|
||||
|
||||
return errors
|
||||
|
||||
|
||||
def main() -> int:
|
||||
errors: list[str] = []
|
||||
for path in sorted(PROPOSALS_DIR.glob("BEP-*.md")):
|
||||
errors.extend(validate(path))
|
||||
|
||||
if errors:
|
||||
for error in errors:
|
||||
print(error, file=sys.stderr)
|
||||
return 1
|
||||
|
||||
print(f"checked {len(list(PROPOSALS_DIR.glob('BEP-*.md')))} BEPs")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Loading…
Add table
Add a link
Reference in a new issue