mirror of
https://github.com/garrytan/gstack.git
synced 2026-10-31 22:14:25 +01:00
* 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>
242 lines
10 KiB
Bash
Executable File
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
|