From a9686fe517f94231f7f492e6d9e9a0fdf3ba4780 Mon Sep 17 00:00:00 2001 From: sksizer Date: Tue, 7 Apr 2026 13:51:00 -0500 Subject: [PATCH] feat(scripts): add cousin workflow for opted-in Rust subpaths in third-party repos MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces the concept of 'cousin' repositories — third-party projects that contain Rust code in explicitly whitelisted subpaths, but are not forks of this template. Cousins follow their own conventions and may have unrelated tooling (Bazel, custom CI, etc.), so the workflow is hyper-conservative: nothing is touched unless it is explicitly opted-in in scripts/cousins.json, and a 'forbidden_paths' hard deny list acts as a belt-and-suspenders safety net. Schema (scripts/cousins.json): - cousins[].name / url / notes - cousins[].targets[].path / applies[] / check (per-crate) - cousins[].global_applies[] (rare; repo-root files) - cousins[].forbidden_paths[] (glob deny list) Four new scripts: - cousin_review.sh / cousin_review_all.sh — read-only, produce a markdown report classifying diffs as SUGGEST-ADOPT, SUGGEST-NEW-FILE, KEEP-COUSIN, or NOT-APPLICABLE. Allowed tools: Read Bash. Never modifies anything. - cousin_apply.sh / cousin_apply_all.sh — write mode. Clones the cousin fresh into a tempdir, copies template bytes verbatim into opted-in slots only (no retyping via Write), runs per-target checks with priority 1) targets[].check 2) 'cargo check --manifest-path /Cargo.toml' 3) skip, reverts failed targets, and opens a single PR per cousin. Reuses scripts/lib/pr_prompt.sh for deferred PR URL prompting. A new scripts/lib/cousins.sh helper handles jq parsing and cousin lookup. justfile: adds cousin-review (cr), cousin-review-all (cra), cousin-apply (ca), cousin-apply-all (caa). --- justfile | 24 ++++++ scripts/cousin_apply.sh | 137 ++++++++++++++++++++++++++++++++++ scripts/cousin_apply/role.md | 3 + scripts/cousin_apply/task.md | 108 +++++++++++++++++++++++++++ scripts/cousin_apply_all.sh | 81 ++++++++++++++++++++ scripts/cousin_review.sh | 131 ++++++++++++++++++++++++++++++++ scripts/cousin_review/role.md | 5 ++ scripts/cousin_review/task.md | 118 +++++++++++++++++++++++++++++ scripts/cousin_review_all.sh | 79 ++++++++++++++++++++ scripts/cousins.json | 22 ++++++ scripts/lib/cousins.sh | 30 ++++++++ 11 files changed, 738 insertions(+) create mode 100755 scripts/cousin_apply.sh create mode 100644 scripts/cousin_apply/role.md create mode 100644 scripts/cousin_apply/task.md create mode 100755 scripts/cousin_apply_all.sh create mode 100755 scripts/cousin_review.sh create mode 100644 scripts/cousin_review/role.md create mode 100644 scripts/cousin_review/task.md create mode 100755 scripts/cousin_review_all.sh create mode 100644 scripts/cousins.json create mode 100644 scripts/lib/cousins.sh diff --git a/justfile b/justfile index 5b57e1d..6900bad 100644 --- a/justfile +++ b/justfile @@ -158,3 +158,27 @@ alias tb := template-backport template-backport-all *args: bash scripts/template_backport_all.sh {{args}} alias tba := template-backport-all + +# ---------------------------------------------------------------------------- # +# COUSINS # +# ---------------------------------------------------------------------------- # + +# Review one cousin repo (by name from scripts/cousins.json) for template-sourced improvements (dry-run by default; --execute to run) +cousin-review *args: + bash scripts/cousin_review.sh {{args}} +alias cr := cousin-review + +# Review every cousin in scripts/cousins.json in parallel (dry-run by default; --execute to run) +cousin-review-all *args: + bash scripts/cousin_review_all.sh {{args}} +alias cra := cousin-review-all + +# Apply template-sourced changes to a cousin's opted-in paths and open a PR (dry-run by default; --execute to run) +cousin-apply *args: + bash scripts/cousin_apply.sh {{args}} +alias ca := cousin-apply + +# Apply changes to every cousin, one PR each (dry-run by default; --execute to run) +cousin-apply-all *args: + bash scripts/cousin_apply_all.sh {{args}} +alias caa := cousin-apply-all diff --git a/scripts/cousin_apply.sh b/scripts/cousin_apply.sh new file mode 100755 index 0000000..9dc6cf2 --- /dev/null +++ b/scripts/cousin_apply.sh @@ -0,0 +1,137 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROMPT_DIR="${SCRIPT_DIR}/cousin_apply" +DEFAULT_TEMPLATE_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)" + +# shellcheck source=lib/cousins.sh +source "${SCRIPT_DIR}/lib/cousins.sh" +# shellcheck source=lib/pr_prompt.sh +source "${SCRIPT_DIR}/lib/pr_prompt.sh" +cousins__require_jq + +# --- Locate a JS package runner (pnpm dlx preferred, npx fallback) ---------- +find_runner() { + if command -v pnpm &>/dev/null; then + echo "pnpm dlx" + elif command -v npx &>/dev/null; then + echo "npx" + else + echo "Error: neither pnpm nor npx found. Install one of them first." >&2 + exit 1 + fi +} +RUNNER="$(find_runner)" + +# --- Parse arguments -------------------------------------------------------- +EXECUTE=false +COUSIN_NAME="" + +usage() { + cat >&2 < + +Applies high-confidence template-sourced changes to a cousin's opted-in paths, +runs per-target checks, and opens a single PR against the cousin repo. + +This script ALWAYS clones the cousin fresh into a tempdir so your local +working copies are never touched. There is no local-path override. +EOF +} + +while [[ $# -gt 0 ]]; do + case "$1" in + --execute) EXECUTE=true; shift ;; + --cousin) COUSIN_NAME="$2"; shift 2 ;; + -h|--help) usage; exit 0 ;; + *) echo "Unknown arg: $1" >&2; usage; exit 2 ;; + esac +done + +if [[ -z "$COUSIN_NAME" ]]; then + usage + exit 2 +fi + +CONFIG_PATH="$(cousins_config_path "$SCRIPT_DIR")" +if [[ ! -f "$CONFIG_PATH" ]]; then + echo "Error: cousins config not found at ${CONFIG_PATH}" >&2 + exit 1 +fi + +COUSIN_CONFIG_JSON="$(cousins_get_by_name "$CONFIG_PATH" "$COUSIN_NAME")" +if [[ -z "$COUSIN_CONFIG_JSON" ]]; then + echo "Error: no cousin named '${COUSIN_NAME}' in ${CONFIG_PATH}" >&2 + exit 1 +fi + +# Refuse to run if the cousin has no opted-in targets — this is a no-op. +TARGET_COUNT="$(jq '(.targets // []) | length + ((.global_applies // []) | length)' <<<"$COUSIN_CONFIG_JSON")" +if [[ "$TARGET_COUNT" -eq 0 ]]; then + echo "Cousin '${COUSIN_NAME}' has no targets or global_applies — nothing to do." + exit 0 +fi + +COUSIN_URL="$(jq -r '.url' <<<"$COUSIN_CONFIG_JSON")" +TEMPLATE_DIR="${TEMPLATE_DIR:-$DEFAULT_TEMPLATE_DIR}" + +compose_prompt() { + local prompt="" + if [[ -f "${PROMPT_DIR}/role.md" ]]; then + prompt+="$(cat "${PROMPT_DIR}/role.md")" + prompt+=$'\n\n' + fi + for f in "${PROMPT_DIR}"/*.md; do + [[ "$(basename "$f")" == "role.md" ]] && continue + prompt+="$(cat "$f")" + prompt+=$'\n\n' + done + echo "$prompt" +} +PROMPT="$(compose_prompt)" + +# Apply mode: Claude edits files, runs checks, and creates a PR via gh. +ALLOWED_TOOLS="Read Edit Write Bash" + +if [[ "$EXECUTE" == true ]]; then + WORK_DIR="$(mktemp -d)" + CLONE_PATH="${WORK_DIR}/${COUSIN_NAME}" + echo "Runner: ${RUNNER}" + echo "Cousin: ${COUSIN_NAME} (${COUSIN_URL})" + echo "Clone path: ${CLONE_PATH}" + echo "Template: ${TEMPLATE_DIR}" + echo "Prompt length: ${#PROMPT} chars" + echo "Allowed tools: ${ALLOWED_TOOLS}" + echo "---" + + git clone --quiet "$COUSIN_URL" "$CLONE_PATH" + + cd "$CLONE_PATH" + export TEMPLATE_DIR COUSIN_CONFIG_JSON COUSIN_NAME + OUTPUT="$(echo "${PROMPT}" | ${RUNNER} @anthropic-ai/claude-code --print \ + --allowed-tools ${ALLOWED_TOOLS})" + echo "$OUTPUT" + + PR_URL="$(pr_prompt_extract_url "$OUTPUT")" + if [[ -n "$PR_URL" ]]; then + pr_prompt_finalize "$PR_URL" + else + echo "No PR opened (no changes survived, or run was a no-op)." + fi + + echo "Clone left at: ${CLONE_PATH}" +else + echo "=== DRY RUN ===" + echo + echo "${PROMPT}" + echo "---" + echo "Runner: ${RUNNER}" + echo "Cousin: ${COUSIN_NAME} (${COUSIN_URL})" + echo "Template: ${TEMPLATE_DIR}" + echo "Config: ${CONFIG_PATH}" + echo "Prompt length: ${#PROMPT} chars" + echo "Allowed tools: ${ALLOWED_TOOLS}" + echo + echo "Pass --execute to actually run (clones the cousin fresh into a tempdir)." +fi diff --git a/scripts/cousin_apply/role.md b/scripts/cousin_apply/role.md new file mode 100644 index 0000000..dc46c6b --- /dev/null +++ b/scripts/cousin_apply/role.md @@ -0,0 +1,3 @@ +You are a pragmatic senior Rust engineer applying a small number of template-sourced improvements to explicitly-whitelisted Rust subpaths inside a third-party "cousin" repository. + +The cousin is NOT a fork of the template. It is its own project with its own conventions. You are a guest here. You may only touch files that the cousin's config file has explicitly opted-in, and you must never touch anything outside those slots. When in doubt, SKIP — it is much better to open a tiny, obviously-correct PR than a large or ambiguous one. diff --git a/scripts/cousin_apply/task.md b/scripts/cousin_apply/task.md new file mode 100644 index 0000000..da2dbd9 --- /dev/null +++ b/scripts/cousin_apply/task.md @@ -0,0 +1,108 @@ +## Context + +You are running inside a **clone of a cousin repository** (your current working directory). Your job is to apply a small set of high-confidence, template-sourced improvements — limited strictly to the opted-in slots in `COUSIN_CONFIG_JSON` — and open a single PR for this cousin containing all surviving changes. + +Environment variables: + +- `TEMPLATE_DIR` — absolute path to a local checkout of the template repo (the source of bytes to copy). +- `COUSIN_CONFIG_JSON` — full JSON object describing THIS cousin. Schema documented in `scripts/cousin_review/task.md`. +- `COUSIN_NAME` — short name, safe for branch names and PR titles. + +## Hard safety rules + +1. **Opt-in only.** The only file slots you may read, diff, or modify: + - For each `targets[i]`: files listed in `targets[i].applies`, resolved at path `targets[i].path/` inside the cousin. + - Files in `global_applies`, resolved at the cousin repo root. + Anything else is off-limits. Do not edit, delete, rename, or create anything outside these slots. + +2. **Forbidden paths win.** If a candidate path matches any pattern in `forbidden_paths`, drop it immediately. + +3. **Never retype bytes.** Propagate changes by `cp "$TEMPLATE_DIR/" "/"`. Never use the `Write` tool to reconstruct a template file from memory — that produces bogus full-file diffs from subtle formatting drift. + +4. **One PR per cousin, total.** All surviving changes across all targets go in a single branch and a single PR. + +5. **If a check fails, revert the offending change.** Do not try to "fix up" the cousin's surrounding code. Your mandate is limited to the opted-in slots. + +## Task + +### 1. Parse the config and verify constraints + +``` +echo "$COUSIN_CONFIG_JSON" | jq . +``` + +Build two flat lists: +- `SLOTS` — the full set of allowed file slots from rule 1 above, as absolute paths inside the cousin. +- `FORBIDDEN` — the `forbidden_paths` globs. + +Remove any SLOT whose path matches any FORBIDDEN glob. If the SLOTS list is now empty, print `No opted-in slots for ${COUSIN_NAME}.` and exit without branching or committing. + +### 2. Diff each slot against the template + +For each slot, run: + +``` +diff -u "" "$TEMPLATE_DIR/