Files
gstack/bin/gstack-diff-scope
T
Garry Tanandcapy-ai[bot] edf4c2e581 v1.91.307.0 ship: proportional review depth (light, standard, full) with depth-checked receipts (#3567)
* feat(ship-receipt): carry across regenerated outputs and onto a reviewed tree after a rebase

A base merge whose only product-side change is a registered output the
pregate regen check proved fresh now carries the receipt. A rebased head
carries when its tree is one the receipt covers and its product patch-id
against the current base matches (RECEIPT_CARRY_TREE_MISMATCH otherwise).
Docs: merge main into a reviewed branch, never rebase it.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix: regenerate doctor anchors; type the head_tree assert

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* feat(review): proportional /ship review depth (light, standard, full)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix: carry sets aside only outputs the regen check proved it recreates

The regen check deletes each touched output in its scratch worktree before
the command runs and records the ones recreated byte-identically as proven.
gstack-ship-receipt carry exempts only those paths, and only when the check
ran origin/<base>'s registry at the commit the carry reads. A file under an
output glob that the generator never writes stays product (pre-landing
review, #3564 pass 1).

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* feat(ship-receipt): record review depth, check it in the queue, structured review-loop exception (#3559)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix: prove generated outputs in a second regen run; literal names only

The regen verdict comes from the unmodified run again, so a generator that
reads its own registered output (gen-skill-docs and
qa/templates/functional-report-template.md) passes as before; the proof is a
second run with the touched outputs deleted, and its failure only means not
proven. The carry ignores a proven path with glob syntax, since it uses the
paths as pathspec globs (pre-landing review, #3564 pass 2).

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* docs: receipt evidence keys and carry record fields; carry refusal fix line

Documentation audit for #3564 (/document-release, audit ship3564-docs-1).

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix: pre-landing review fixes (depth scan binding, queue scope, carry checks)

- carry refuses a --head other than the checked-out HEAD, a receipt with
  gstack_version but no review_depth, and an owner exception that no longer
  stands; a dirty or foreign checkout makes the write-time gate scan
  unavailable (full)
- the queue binds a gate scan only to the full bound commit and recomputes
  the scope signals with gstack-diff-scope <base> --head <sha>, the same
  range the writer uses
- the queue checks the review-loop exception's fix-check ancestry
- a package.json version line counts as stamp-only only when it is a bare
  version key
- docs and help name what review_depth is and which registry the queue reads

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* docs: CHANGELOG for the review fixes; defer two plan items to TODOS

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* docs: queue and carry refusal lists name the depth and exception codes

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* test: approval-classes summary-line regexes allow the receipt's depth field

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(ship-receipt): summary line prints depth before approval

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* Revert "test: approval-classes summary-line regexes allow the receipt's depth field"

This reverts commit e9ec264358.

---------

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
2026-10-29 04:55:01 +00:00

242 lines
10 KiB
Bash
Executable File

#!/usr/bin/env bash
# gstack-diff-scope — categorize what changed in the diff against a base branch
# Usage: source <(gstack-diff-scope main) → sets SCOPE_FRONTEND=true SCOPE_BACKEND=false ...
# Or: gstack-diff-scope main → prints SCOPE_*=... lines
# Or: gstack-diff-scope <base> --head <sha> → the committed range <base>...<sha> only
# (no working tree, no untracked files): what a receipt writer and the
# merge queue compute for one commit, from any checkout (GSTA-147).
#
# Output contract (#2526 — all-false must be distinguishable from "we could
# not look" and from "nothing matched"):
# exit 0 changed-file set empty → all false, legitimately nothing
# exit 0 changed files, >=1 category match → flags
# exit 2 changed files, ZERO matches → flags + SCOPE_ERROR=unmatched
# (+ the unmatched paths as comment lines, so a new top-level layout
# trips loudly instead of silently disabling reviewers)
# exit 2 base ref unresolvable → all false + SCOPE_ERROR=no_base
# (shallow CI checkout / missing fetch — a green here would mean
# "we could not look")
# Every line is shell-safe for `source <(...)` consumers: assignments or
# `#`-comments only.
#
# The changed-file set is the UNION of committed diff + working tree +
# untracked files (#2299): /ship detects scope in Step 9, BEFORE it commits in
# Step 15, so uncommitted work must be visible or every scope-gated reviewer
# is skipped on the common start-work-then-ship flow.
set -euo pipefail
# Detect the repo's default branch when no arg is given (#703-class
# platform-agnostic rule): origin/HEAD -> origin/main -> origin/master -> main.
_default_base() {
git symbolic-ref refs/remotes/origin/HEAD 2>/dev/null | sed 's|refs/remotes/origin/||' && return
git rev-parse --verify -q origin/main >/dev/null 2>&1 && { echo "main"; return; }
git rev-parse --verify -q origin/master >/dev/null 2>&1 && { echo "master"; return; }
echo "main"
}
BASE="${1:-$(_default_base)}"
HEAD_REF=""
if [ "${2:-}" = "--head" ]; then HEAD_REF="${3:-}"; fi
FRONTEND=false
BACKEND=false
PROMPTS=false
TESTS=false
DOCS=false
CONFIG=false
MIGRATIONS=false
API=false
AUTH=false
# Test-file paths, the same rule the test-bootstrap resolver counts as
# TESTFILES (scripts/resolvers/testing.ts; test/diff-scope.test.ts keeps the two
# identical). Nested test dirs and Python's test_*.py / tests.py were missed by
# the root-anchored globs alone (#3335).
TEST_PATH_RE='(^|/)(tests?|spec|__tests__)/|(^|/)tests?\.py$|(^|/)test_[^/]+\.py$|_test\.(go|py|rb|ts|js|exs|dart)$|\.(test|spec)\.[jt]sx?$|_spec\.rb$|Test\.(java|kt)$'
_print_flags() {
echo "SCOPE_FRONTEND=$FRONTEND"
echo "SCOPE_BACKEND=$BACKEND"
echo "SCOPE_PROMPTS=$PROMPTS"
echo "SCOPE_TESTS=$TESTS"
echo "SCOPE_DOCS=$DOCS"
echo "SCOPE_CONFIG=$CONFIG"
echo "SCOPE_MIGRATIONS=$MIGRATIONS"
echo "SCOPE_API=$API"
echo "SCOPE_AUTH=$AUTH"
}
# Base reachability (#2526): a shallow CI checkout or an unfetched ref makes
# `git diff` return an empty list — all-false with exit 0, a green that means
# "we could not look". Distinguish it before diffing.
if ! git rev-parse --verify -q "${BASE}^{commit}" >/dev/null 2>&1; then
_print_flags
echo "SCOPE_ERROR=no_base"
echo "# base ref '${BASE}' is not resolvable — shallow checkout or missing fetch. Run: git fetch origin ${BASE}"
exit 2
fi
# Changed files, NUL-delimited (#2526 minor: `git diff --name-only` octal-quotes
# non-ASCII paths, and the trailing quote defeats extension globs; -z avoids it).
FILES_LIST=()
_collect() {
local f
while IFS= read -r -d '' f; do
[ -n "$f" ] && FILES_LIST+=("$f")
done
}
if [ -n "$HEAD_REF" ] || [ "${2:-}" = "--head" ]; then
if ! git rev-parse --verify -q "${HEAD_REF}^{commit}" >/dev/null 2>&1; then
_print_flags
echo "SCOPE_ERROR=no_head"
echo "# --head '${HEAD_REF}' is not a commit in this repository"
exit 2
fi
if ! git merge-base "${BASE}" "${HEAD_REF}" >/dev/null 2>&1; then
_print_flags
echo "SCOPE_ERROR=no_merge_base"
echo "# '${BASE}' and '${HEAD_REF}' share no merge base"
exit 2
fi
_collect < <(git diff -z "${BASE}...${HEAD_REF}" --name-only 2>/dev/null)
else
# Committed diff vs base (merge-base form; two-dot fallback when no merge base).
_collect < <(git diff -z "${BASE}...HEAD" --name-only 2>/dev/null || git diff -z "${BASE}" --name-only 2>/dev/null || true)
# Working-tree changes (staged + unstaged). `git diff HEAD` fails on a repo
# with no commits; tolerated.
_collect < <(git diff -z HEAD --name-only 2>/dev/null || true)
# Untracked files: a brand-new component/migration/test is exactly what a
# reviewer should see, and /ship commits it in Step 15 regardless.
_collect < <(git ls-files -z --others --exclude-standard 2>/dev/null || true)
fi
if [ "${#FILES_LIST[@]}" -eq 0 ]; then
_print_flags
exit 0
fi
UNMATCHED=()
# Categories are INDEPENDENT booleans (#2299): a single first-match-wins case
# made them mutually exclusive, so Button.test.jsx set FRONTEND but not TESTS
# while util.test.ts set TESTS but not BACKEND — same intent, opposite result,
# purely from arm ordering. Each category now gets its own case; only BACKEND
# stays deliberately exclusive of frontend component/view files.
for f in ${FILES_LIST[@]+"${FILES_LIST[@]}"}; do
m_frontend=false; m_prompts=false; m_tests=false; m_docs=false
m_config=false; m_migrations=false; m_api=false; m_auth=false; m_backend=false
# Frontend: CSS, views, components, templates
case "$f" in
*.css|*.scss|*.less|*.sass|*.pcss) m_frontend=true ;;
*.tsx|*.jsx|*.vue|*.svelte|*.astro) m_frontend=true ;;
*.erb|*.haml|*.slim|*.hbs|*.ejs) m_frontend=true ;;
*.html) m_frontend=true ;;
# Dart: the extension cannot tell a Flutter widget from a repository or a
# pure-Dart server, so it is BOTH frontend and backend (BACKEND set below).
# A skipped reviewer is invisible; an extra one (design-lite included) is a
# visible pass that costs a review.
*.dart) m_frontend=true ;;
tailwind.config.*|postcss.config.*) m_frontend=true ;;
app/views/*|*/components/*|styles/*|css/*|app/assets/stylesheets/*) m_frontend=true ;;
esac
# Prompts: prompt builders, system prompts, generation services
case "$f" in
*prompt_builder*|*generation_service*|*writer_service*|*designer_service*) m_prompts=true ;;
*evaluator*|*scorer*|*classifier_service*|*analyzer*) m_prompts=true ;;
*voice*.rb|*writing*.rb|*prompt*.rb|*token*.rb) m_prompts=true ;;
app/services/chat_tools/*|app/services/x_thread_tools/*) m_prompts=true ;;
config/system_prompts/*) m_prompts=true ;;
esac
# Tests
case "$f" in
*.test.*|*.spec.*|*_test.*|*_spec.*) m_tests=true ;;
test/*|tests/*|spec/*|__tests__/*|cypress/*|e2e/*) m_tests=true ;;
conftest.py|*/conftest.py) m_tests=true ;;
esac
if [[ "$f" =~ $TEST_PATH_RE ]]; then m_tests=true; fi
# Docs
case "$f" in
*.md) m_docs=true ;;
esac
# Config
case "$f" in
package.json|package-lock.json|yarn.lock|bun.lock|bun.lockb) m_config=true ;;
Gemfile|Gemfile.lock) m_config=true ;;
*.yml|*.yaml) m_config=true ;;
.github/*) m_config=true ;;
requirements.txt|pyproject.toml|go.mod|Cargo.toml|composer.json) m_config=true ;;
pubspec.lock|*/pubspec.lock) m_config=true ;;
esac
# Migrations: database migration files. Bare migrations/* covers a
# root-level migrations dir (#2526); db/data covers the Rails data_migrate
# gem's DATA migrations (#2455) — arbitrary Ruby run unattended against
# production data, strictly higher-risk than a schema migration (they also
# match BACKEND below via their extension, as ordinary app code should).
case "$f" in
db/migrate/*|migrations/*|*/migrations/*|alembic/*|prisma/migrations/*) m_migrations=true ;;
db/data/*|data_migrations/*|*/data_migrations/*) m_migrations=true ;;
esac
# API: routes, controllers, endpoints, GraphQL/OpenAPI schemas. Bare api/*
# covers root-level serverless layouts (Vercel functions, Next.js pages/api
# at root) that */api/* silently missed (#2526).
case "$f" in
api/*|*/api/*|*controller*|*route*|*endpoint*) m_api=true ;;
*.graphql|*.gql|openapi.*|swagger.*) m_api=true ;;
esac
# Auth: authentication, authorization, sessions, permissions
case "$f" in
*auth*|*session*|*jwt*|*oauth*|*permission*|*role*) m_auth=true ;;
esac
# Backend: code that isn't a frontend component/view file. Includes ESM/CJS
# (.mjs/.cjs) and explicit-module TS (.mts/.cts) — #1810: these matched no
# category, so an ESM/CJS-only PR skipped the backend reviewer entirely.
if [ "$m_frontend" = false ]; then
case "$f" in
*.rb|*.py|*.go|*.rs|*.java|*.php|*.ex|*.exs) m_backend=true ;;
*.ts|*.js|*.mjs|*.cjs|*.mts|*.cts) m_backend=true ;;
esac
fi
case "$f" in
*.dart) m_backend=true ;;
esac
[ "$m_frontend" = true ] && FRONTEND=true
[ "$m_prompts" = true ] && PROMPTS=true
[ "$m_tests" = true ] && TESTS=true
[ "$m_docs" = true ] && DOCS=true
[ "$m_config" = true ] && CONFIG=true
[ "$m_migrations" = true ] && MIGRATIONS=true
[ "$m_api" = true ] && API=true
[ "$m_auth" = true ] && AUTH=true
[ "$m_backend" = true ] && BACKEND=true
if [ "$m_frontend" = false ] && [ "$m_prompts" = false ] && [ "$m_tests" = false ] \
&& [ "$m_docs" = false ] && [ "$m_config" = false ] && [ "$m_migrations" = false ] \
&& [ "$m_api" = false ] && [ "$m_auth" = false ] && [ "$m_backend" = false ]; then
UNMATCHED+=("$f")
fi
done
_print_flags
# Changed files but ZERO category matches (#2526): a classifier bug, an
# unrecognised layout, or a new top-level directory would otherwise present
# as "no reviewers needed" with the skip invisible. Trip loudly instead.
if [ "$FRONTEND" = false ] && [ "$BACKEND" = false ] && [ "$PROMPTS" = false ] \
&& [ "$TESTS" = false ] && [ "$DOCS" = false ] && [ "$CONFIG" = false ] \
&& [ "$MIGRATIONS" = false ] && [ "$API" = false ] && [ "$AUTH" = false ]; then
echo "SCOPE_ERROR=unmatched"
printf '%s\n' ${UNMATCHED[@]+"${UNMATCHED[@]}"} | sort -u | head -50 | while IFS= read -r u; do
[ -n "$u" ] && printf '# unmatched: %s\n' "$u"
done
exit 2
fi