mirror of
https://github.com/glittercowboy/get-shit-done
synced 2026-05-02 04:27:28 +02:00
Compare commits
52 Commits
fix/2911-a
...
fix/2997-s
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cb98a88139 | ||
|
|
fb92d1e596 | ||
|
|
7424271aa0 | ||
|
|
7a416b10d4 | ||
|
|
ef43f5161f | ||
|
|
e9a66da1e7 | ||
|
|
b8d9bd69b2 | ||
|
|
0d25ef0c47 | ||
|
|
a346779213 | ||
|
|
0d6abb87ac | ||
|
|
c5dfdbe42e | ||
|
|
9d0d085a17 | ||
|
|
53cda93a01 | ||
|
|
ec07861228 | ||
|
|
3ba17e872e | ||
|
|
4d628b306a | ||
|
|
b328f3269f | ||
|
|
e2792536d9 | ||
|
|
7cc6358f91 | ||
|
|
8de8acee46 | ||
|
|
2cc8796265 | ||
|
|
faee0287a0 | ||
|
|
7e9477bb30 | ||
|
|
5abf46ac1c | ||
|
|
372d3453f5 | ||
|
|
c9d6306981 | ||
|
|
1168e9f59a | ||
|
|
3ed8980519 | ||
|
|
c3aef27aa6 | ||
|
|
ace61869d0 | ||
|
|
80f14cac1f | ||
|
|
2256e4c9a3 | ||
|
|
e5cd523e7b | ||
|
|
b5777572f7 | ||
|
|
861a7d972b | ||
|
|
bd0511988b | ||
|
|
4a5f36df5e | ||
|
|
840f2b349e | ||
|
|
140d334dab | ||
|
|
6e4fad7acc | ||
|
|
4e2f1105d9 | ||
|
|
4ce72cdee7 | ||
|
|
198022f58d | ||
|
|
ac100ae17b | ||
|
|
002db4dd2b | ||
|
|
0e0f6952c5 | ||
|
|
bdead2ee6a | ||
|
|
e107bb35d4 | ||
|
|
294564b951 | ||
|
|
9a13d2fc0b | ||
|
|
d29822c1da | ||
|
|
b126c0579a |
26
.coderabbit.yaml
Normal file
26
.coderabbit.yaml
Normal file
@@ -0,0 +1,26 @@
|
||||
# CodeRabbit configuration — gsd-build/get-shit-done
|
||||
#
|
||||
# Schema: https://docs.coderabbit.ai/reference/yaml-template/
|
||||
#
|
||||
# Project context: GSD ships a CLI tool + an agent runtime, not a documented
|
||||
# public library. We carry rich JSDoc on internal helpers that warrant it
|
||||
# (see bin/install.js, get-shit-done/bin/lib/*.cjs) but we do not enforce a
|
||||
# blanket docstring coverage bar — see issue #2932 for rationale.
|
||||
|
||||
reviews:
|
||||
pre_merge_checks:
|
||||
# Disable docstring coverage check.
|
||||
#
|
||||
# The check produces false-positive warnings on PRs whose new code is
|
||||
# entirely test files: it counts test(...) / beforeEach / afterEach
|
||||
# arrow-function callbacks as functions and then reports 0% coverage
|
||||
# because nothing has JSDoc. There is no per-check path filter in CR's
|
||||
# documented schema that would let us exclude tests/** while keeping
|
||||
# the check active elsewhere, and the top-level path_filters approach
|
||||
# would silence ALL CR review on tests (security scans, out-of-scope
|
||||
# checks, line-level findings) which we want to keep.
|
||||
#
|
||||
# All other CR pre-merge checks (out-of-scope, security, title) remain
|
||||
# at their defaults.
|
||||
docstrings:
|
||||
mode: off
|
||||
366
.github/workflows/hotfix.yml
vendored
366
.github/workflows/hotfix.yml
vendored
@@ -1,5 +1,27 @@
|
||||
name: Hotfix Release
|
||||
|
||||
# Hotfix flow for X.YY.Z patch releases (Z > 0).
|
||||
#
|
||||
# create:
|
||||
# - Branches hotfix/X.YY.Z from the highest existing vX.YY.* tag (1.27.2 from
|
||||
# v1.27.1, 1.27.1 from v1.27.0). The base IS the cumulative-fix anchor for
|
||||
# the previous patch.
|
||||
# - Auto-cherry-picks every fix:/chore: commit on origin/main that isn't
|
||||
# already in the base, oldest-first. Patch-equivalents (already applied)
|
||||
# are skipped via `git cherry`. feat:/refactor: are NEVER auto-included.
|
||||
# - Conflicts fail the workflow with the offending SHA so the operator can
|
||||
# resolve manually on the branch and re-run finalize with auto_cherry_pick=false.
|
||||
# - Step summary lists every included SHA so the eventual vX.YY.Z tag
|
||||
# self-documents what shipped.
|
||||
#
|
||||
# finalize:
|
||||
# - install-smoke gate (cross-platform, parity with release.yml/release-sdk.yml)
|
||||
# - Bundles SDK as both loose tree (sdk/dist/cli.js) and recoverable tarball
|
||||
# (sdk-bundle/gsd-sdk.tgz) — parity with release-sdk.yml so a hotfix shipped
|
||||
# during the @gsd-build-token outage carries the same payload shape.
|
||||
# - Publishes to @latest, tags vX.YY.Z, re-points @next → vX.YY.Z, opens
|
||||
# merge-back PR.
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
@@ -14,6 +36,11 @@ on:
|
||||
description: 'Patch version (e.g., 1.27.1)'
|
||||
required: true
|
||||
type: string
|
||||
auto_cherry_pick:
|
||||
description: 'Auto-cherry-pick fix:/chore: commits from origin/main since base tag (create only)'
|
||||
required: false
|
||||
type: boolean
|
||||
default: true
|
||||
dry_run:
|
||||
description: 'Dry run (skip npm publish, tagging, and push)'
|
||||
required: false
|
||||
@@ -54,10 +81,13 @@ jobs:
|
||||
MAJOR_MINOR=$(echo "$VERSION" | cut -d. -f1-2)
|
||||
TARGET_TAG="v${VERSION}"
|
||||
BRANCH="hotfix/${VERSION}"
|
||||
BASE_TAG=$(git tag -l "v${MAJOR_MINOR}.*" \
|
||||
| grep -E "^v[0-9]+\.[0-9]+\.[0-9]+$" \
|
||||
# Append TARGET_TAG to the candidate list, then sort -V, then walk the
|
||||
# sorted list and print whatever immediately precedes TARGET_TAG. This
|
||||
# is semver-correct for multi-digit patches (v1.27.10 > v1.27.9) where
|
||||
# a plain `awk '$1 < target'` lexicographic compare would mis-order.
|
||||
BASE_TAG=$( ( git tag -l "v${MAJOR_MINOR}.*" | grep -E "^v[0-9]+\.[0-9]+\.[0-9]+$"; echo "$TARGET_TAG" ) \
|
||||
| sort -V \
|
||||
| awk -v target="$TARGET_TAG" '$1 < target { last=$1 } END { if (last != "") print last }')
|
||||
| awk -v target="$TARGET_TAG" '$1 == target { print prev; exit } { prev = $1 }')
|
||||
if [ -z "$BASE_TAG" ]; then
|
||||
echo "::error::No prior stable tag found for ${MAJOR_MINOR}.x before $TARGET_TAG"
|
||||
exit 1
|
||||
@@ -95,29 +125,160 @@ jobs:
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||
|
||||
- name: Create hotfix branch
|
||||
if: inputs.dry_run != 'true'
|
||||
- name: Create hotfix branch from base tag and push (skeleton)
|
||||
env:
|
||||
BRANCH: ${{ needs.validate-version.outputs.branch }}
|
||||
BASE_TAG: ${{ needs.validate-version.outputs.base_tag }}
|
||||
DRY_RUN: ${{ inputs.dry_run }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git checkout -b "$BRANCH" "$BASE_TAG"
|
||||
# Push the skeleton branch up-front so any subsequent cherry-pick
|
||||
# conflict leaves a remote artefact the operator can fetch, resolve,
|
||||
# and re-push. Skipped on dry-run — local checkout still exercises
|
||||
# the same cherry-pick + bump flow so conflicts are caught.
|
||||
if [ "$DRY_RUN" != "true" ]; then
|
||||
git push -u origin "$BRANCH"
|
||||
fi
|
||||
|
||||
- name: Cherry-pick fix/chore commits from origin/main since base tag
|
||||
if: ${{ inputs.auto_cherry_pick }}
|
||||
env:
|
||||
BRANCH: ${{ needs.validate-version.outputs.branch }}
|
||||
BASE_TAG: ${{ needs.validate-version.outputs.base_tag }}
|
||||
DRY_RUN: ${{ inputs.dry_run }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git fetch origin main:refs/remotes/origin/main
|
||||
|
||||
# `git cherry $BASE_TAG origin/main` lists every commit on main not
|
||||
# patch-equivalent in BASE_TAG. + means needs picking, - means
|
||||
# already applied (skipped silently).
|
||||
CANDIDATES=$(git cherry "$BASE_TAG" origin/main | awk '/^\+ / {print $2}')
|
||||
|
||||
if [ -z "$CANDIDATES" ]; then
|
||||
echo "No commits on origin/main beyond $BASE_TAG."
|
||||
echo "## Cherry-pick summary" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "Base: \`$BASE_TAG\` — no commits to consider." >> "$GITHUB_STEP_SUMMARY"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Re-order chronologically (oldest first) for predictable application.
|
||||
ORDERED=$(git log --reverse --format='%H' "$BASE_TAG..origin/main" \
|
||||
| grep -F -f <(echo "$CANDIDATES") || true)
|
||||
|
||||
INCLUDED=""
|
||||
SKIPPED=""
|
||||
while IFS= read -r SHA; do
|
||||
[ -z "$SHA" ] && continue
|
||||
SUBJECT=$(git log -1 --format='%s' "$SHA")
|
||||
# fix: or chore:, optional scope, optional ! breaking marker
|
||||
if echo "$SUBJECT" | grep -qE '^(fix|chore)(\([^)]+\))?!?: '; then
|
||||
echo "→ cherry-picking $SHA $SUBJECT"
|
||||
if ! git cherry-pick -x "$SHA"; then
|
||||
# Abort restores HEAD to the last successful pick. On real
|
||||
# runs, push that state so the operator can fetch, resolve
|
||||
# $SHA manually, and finalize with auto_cherry_pick=false.
|
||||
git cherry-pick --abort || true
|
||||
if [ "$DRY_RUN" != "true" ]; then
|
||||
git push --force-with-lease origin "$BRANCH" || git push origin "$BRANCH" || true
|
||||
fi
|
||||
{
|
||||
echo "## Cherry-pick conflict"
|
||||
echo ""
|
||||
echo "Failed at: \`${SHA}\` — \`${SUBJECT}\`"
|
||||
echo ""
|
||||
if [ "$DRY_RUN" = "true" ]; then
|
||||
echo "**Dry run:** branch was not pushed, so the picks below were discarded with the runner."
|
||||
if [ -n "$INCLUDED" ]; then
|
||||
echo ""
|
||||
echo "Already-applied picks (lost — must be re-applied before resolving \`${SHA}\`):"
|
||||
echo ""
|
||||
echo "$INCLUDED"
|
||||
fi
|
||||
echo ""
|
||||
echo "**To resolve:** re-run \`create\` with \`auto_cherry_pick=true\` (real, not dry-run) to materialize the partial branch on origin, then resolve \`${SHA}\` manually. Re-running with \`auto_cherry_pick=false\` would recreate the branch from \`${BASE_TAG}\` and lose every pick listed above."
|
||||
else
|
||||
echo "Branch \`${BRANCH}\` was pushed with picks applied up to (but not including) the conflicting commit."
|
||||
echo ""
|
||||
echo "**To resolve:** \`git fetch origin && git checkout ${BRANCH} && git cherry-pick -x ${SHA}\`, fix the conflict, push, then re-run \`finalize\` with \`auto_cherry_pick=false\`."
|
||||
fi
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "::error::Cherry-pick of $SHA failed. See summary."
|
||||
exit 1
|
||||
fi
|
||||
INCLUDED="${INCLUDED}- \`${SHA}\` ${SUBJECT}"$'\n'
|
||||
else
|
||||
echo " skip $SHA $SUBJECT (not fix/chore)"
|
||||
SKIPPED="${SKIPPED}- \`${SHA}\` ${SUBJECT}"$'\n'
|
||||
fi
|
||||
done <<< "$ORDERED"
|
||||
|
||||
{
|
||||
echo "## Cherry-pick summary"
|
||||
echo ""
|
||||
echo "Base: \`$BASE_TAG\`"
|
||||
echo ""
|
||||
if [ -n "$INCLUDED" ]; then
|
||||
echo "### Included (fix/chore)"
|
||||
echo ""
|
||||
echo "$INCLUDED"
|
||||
else
|
||||
echo "_No fix/chore commits to include._"
|
||||
echo ""
|
||||
fi
|
||||
if [ -n "$SKIPPED" ]; then
|
||||
echo "### Skipped (feat/refactor/etc — not auto-included)"
|
||||
echo ""
|
||||
echo "$SKIPPED"
|
||||
fi
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
- name: Bump version and push
|
||||
env:
|
||||
BRANCH: ${{ needs.validate-version.outputs.branch }}
|
||||
BASE_TAG: ${{ needs.validate-version.outputs.base_tag }}
|
||||
VERSION: ${{ inputs.version }}
|
||||
DRY_RUN: ${{ inputs.dry_run }}
|
||||
run: |
|
||||
git checkout -b "$BRANCH" "$BASE_TAG"
|
||||
# Bump version in package.json
|
||||
set -euo pipefail
|
||||
npm version "$VERSION" --no-git-tag-version
|
||||
git add package.json package-lock.json
|
||||
# Keep sdk/package.json in lockstep (parity with release-sdk.yml).
|
||||
if [ -f sdk/package.json ]; then
|
||||
(cd sdk && npm version "$VERSION" --no-git-tag-version)
|
||||
git add sdk/package.json
|
||||
[ -f sdk/package-lock.json ] && git add sdk/package-lock.json
|
||||
fi
|
||||
git commit -m "chore: bump version to $VERSION for hotfix"
|
||||
git push origin "$BRANCH"
|
||||
echo "## Hotfix branch created" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "- Branch: \`$BRANCH\`" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "- Based on: \`$BASE_TAG\`" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "- Apply your fix, push, then run this workflow again with \`finalize\`" >> "$GITHUB_STEP_SUMMARY"
|
||||
if [ "$DRY_RUN" != "true" ]; then
|
||||
git push origin "$BRANCH"
|
||||
else
|
||||
echo "DRY RUN — branch not pushed. Local checkout exercised the cherry-pick and bump flow."
|
||||
fi
|
||||
{
|
||||
echo "## Hotfix branch created"
|
||||
echo ""
|
||||
echo "- Branch: \`$BRANCH\`"
|
||||
echo "- Based on: \`$BASE_TAG\`"
|
||||
echo "- Apply additional manual fixes if needed, then run \`finalize\`."
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
finalize:
|
||||
install-smoke:
|
||||
needs: validate-version
|
||||
if: inputs.action == 'finalize'
|
||||
permissions:
|
||||
contents: read
|
||||
uses: ./.github/workflows/install-smoke.yml
|
||||
with:
|
||||
ref: ${{ needs.validate-version.outputs.branch }}
|
||||
|
||||
finalize:
|
||||
needs: [validate-version, install-smoke]
|
||||
if: inputs.action == 'finalize'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
timeout-minutes: 15
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
@@ -140,31 +301,83 @@ jobs:
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||
|
||||
- name: Detect prior publish (reconciliation mode)
|
||||
id: prior_publish
|
||||
env:
|
||||
VERSION: ${{ inputs.version }}
|
||||
run: |
|
||||
EXISTING=$(npm view get-shit-done-cc@"$VERSION" version 2>/dev/null || true)
|
||||
if [ -n "$EXISTING" ]; then
|
||||
echo "::warning::get-shit-done-cc@${VERSION} is already on the registry — entering reconciliation mode (skip publish, continue with tag/release/PR/dist-tag)."
|
||||
echo "skip_publish=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "skip_publish=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Install and test
|
||||
run: |
|
||||
npm ci
|
||||
npm run test:coverage
|
||||
|
||||
- name: Create PR to merge hotfix back to main
|
||||
if: ${{ !inputs.dry_run }}
|
||||
- name: Build SDK dist for tarball
|
||||
run: npm run build:sdk
|
||||
|
||||
- name: Verify CC tarball ships sdk/dist/cli.js (bug #2647 guard)
|
||||
run: bash scripts/verify-tarball-sdk-dist.sh
|
||||
|
||||
- name: Pack SDK as tarball and bundle into CC source tree
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
BRANCH: ${{ needs.validate-version.outputs.branch }}
|
||||
VERSION: ${{ inputs.version }}
|
||||
run: |
|
||||
EXISTING_PR=$(gh pr list --base main --head "$BRANCH" --state open --json number --jq '.[0].number')
|
||||
if [ -n "$EXISTING_PR" ]; then
|
||||
echo "PR #$EXISTING_PR already exists; updating"
|
||||
gh pr edit "$EXISTING_PR" \
|
||||
--title "chore: merge hotfix v${VERSION} back to main" \
|
||||
--body "Merge hotfix changes back to main after v${VERSION} release."
|
||||
else
|
||||
gh pr create \
|
||||
--base main \
|
||||
--head "$BRANCH" \
|
||||
--title "chore: merge hotfix v${VERSION} back to main" \
|
||||
--body "Merge hotfix changes back to main after v${VERSION} release."
|
||||
set -e
|
||||
cd sdk
|
||||
npm pack
|
||||
TARBALL="gsd-build-sdk-${VERSION}.tgz"
|
||||
if [ ! -f "$TARBALL" ]; then
|
||||
echo "::error::Expected $TARBALL but npm pack did not produce it."
|
||||
ls -la
|
||||
exit 1
|
||||
fi
|
||||
mkdir -p ../sdk-bundle
|
||||
mv "$TARBALL" ../sdk-bundle/gsd-sdk.tgz
|
||||
cd ..
|
||||
ls -la sdk-bundle/
|
||||
|
||||
- name: Add sdk-bundle to CC files whitelist (in-tree, not committed)
|
||||
run: |
|
||||
node <<'NODE'
|
||||
const fs = require('fs');
|
||||
const pkg = JSON.parse(fs.readFileSync('package.json', 'utf8'));
|
||||
if (!Array.isArray(pkg.files)) {
|
||||
console.error('::error::package.json files is not an array');
|
||||
process.exit(1);
|
||||
}
|
||||
if (!pkg.files.includes('sdk-bundle')) {
|
||||
pkg.files.push('sdk-bundle');
|
||||
fs.writeFileSync('package.json', JSON.stringify(pkg, null, 2) + '\n');
|
||||
console.log('Added sdk-bundle/ to package.json files whitelist');
|
||||
}
|
||||
NODE
|
||||
|
||||
- name: Verify CC tarball will contain sdk-bundle/gsd-sdk.tgz
|
||||
run: |
|
||||
set -e
|
||||
TARBALL=$(npm pack --ignore-scripts 2>/dev/null | tail -1)
|
||||
if [ -z "$TARBALL" ] || [ ! -f "$TARBALL" ]; then
|
||||
echo "::error::npm pack produced no tarball"
|
||||
exit 1
|
||||
fi
|
||||
if ! tar -tzf "$TARBALL" | grep -q "package/sdk-bundle/gsd-sdk.tgz"; then
|
||||
echo "::error::CC tarball is missing package/sdk-bundle/gsd-sdk.tgz"
|
||||
exit 1
|
||||
fi
|
||||
echo "✅ CC tarball contains sdk-bundle/gsd-sdk.tgz"
|
||||
rm -f "$TARBALL"
|
||||
|
||||
- name: Dry-run publish validation
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
run: npm publish --dry-run --tag latest
|
||||
|
||||
- name: Tag and push
|
||||
if: ${{ !inputs.dry_run }}
|
||||
@@ -185,55 +398,98 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: Publish to npm (latest)
|
||||
if: ${{ !inputs.dry_run }}
|
||||
run: npm publish --provenance --access public
|
||||
if: ${{ !inputs.dry_run && steps.prior_publish.outputs.skip_publish != 'true' }}
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
run: npm publish --provenance --access public --tag latest
|
||||
|
||||
- name: Create GitHub Release
|
||||
- name: Re-point next dist-tag at this hotfix
|
||||
if: ${{ !inputs.dry_run }}
|
||||
env:
|
||||
VERSION: ${{ inputs.version }}
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
run: |
|
||||
npm dist-tag add "get-shit-done-cc@${VERSION}" next
|
||||
echo "✅ next dist-tag re-pointed to v${VERSION} (matches latest)"
|
||||
|
||||
- name: Create GitHub Release (idempotent)
|
||||
if: ${{ !inputs.dry_run }}
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
VERSION: ${{ inputs.version }}
|
||||
run: |
|
||||
gh release create "v${VERSION}" \
|
||||
--title "v${VERSION} (hotfix)" \
|
||||
--generate-notes
|
||||
if gh release view "v${VERSION}" >/dev/null 2>&1; then
|
||||
echo "GitHub Release v${VERSION} already exists; ensuring --latest flag is set"
|
||||
gh release edit "v${VERSION}" --latest || true
|
||||
else
|
||||
gh release create "v${VERSION}" \
|
||||
--title "v${VERSION} (hotfix)" \
|
||||
--generate-notes \
|
||||
--latest
|
||||
fi
|
||||
|
||||
- name: Clean up next dist-tag
|
||||
- name: Create PR to merge hotfix back to main
|
||||
if: ${{ !inputs.dry_run }}
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
BRANCH: ${{ needs.validate-version.outputs.branch }}
|
||||
VERSION: ${{ inputs.version }}
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
run: |
|
||||
# Point next to the stable release so @next never returns something
|
||||
# older than @latest. This prevents stale pre-release installs.
|
||||
npm dist-tag add "get-shit-done-cc@${VERSION}" next 2>/dev/null || true
|
||||
echo "✓ next dist-tag updated to v${VERSION}"
|
||||
EXISTING_PR=$(gh pr list --base main --head "$BRANCH" --state open --json number --jq '.[0].number')
|
||||
if [ -n "$EXISTING_PR" ]; then
|
||||
gh pr edit "$EXISTING_PR" \
|
||||
--title "chore: merge hotfix v${VERSION} back to main" \
|
||||
--body "Merge hotfix changes back to main after v${VERSION} release."
|
||||
else
|
||||
gh pr create \
|
||||
--base main \
|
||||
--head "$BRANCH" \
|
||||
--title "chore: merge hotfix v${VERSION} back to main" \
|
||||
--body "Merge hotfix changes back to main after v${VERSION} release."
|
||||
fi
|
||||
|
||||
- name: Verify publish
|
||||
- name: Verify publish landed on registry
|
||||
if: ${{ !inputs.dry_run }}
|
||||
env:
|
||||
VERSION: ${{ inputs.version }}
|
||||
run: |
|
||||
sleep 10
|
||||
PUBLISHED=$(npm view get-shit-done-cc@"$VERSION" version 2>/dev/null || echo "NOT_FOUND")
|
||||
PUBLISHED="NOT_FOUND"
|
||||
for delay in 5 10 20 30 45; do
|
||||
PUBLISHED=$(npm view get-shit-done-cc@"$VERSION" version 2>/dev/null || echo "NOT_FOUND")
|
||||
if [ "$PUBLISHED" = "$VERSION" ]; then
|
||||
break
|
||||
fi
|
||||
echo "Waiting ${delay}s for registry to catch up (saw: $PUBLISHED)..."
|
||||
sleep "$delay"
|
||||
done
|
||||
if [ "$PUBLISHED" != "$VERSION" ]; then
|
||||
echo "::error::Published version verification failed. Expected $VERSION, got $PUBLISHED"
|
||||
echo "::error::Version $VERSION did not appear on the registry within timeout"
|
||||
exit 1
|
||||
fi
|
||||
echo "✓ Verified: get-shit-done-cc@$VERSION is live on npm"
|
||||
LATEST_VER=$(npm view get-shit-done-cc dist-tags.latest 2>/dev/null || echo "NOT_FOUND")
|
||||
if [ "$LATEST_VER" != "$VERSION" ]; then
|
||||
echo "::error::dist-tag 'latest' resolves to '$LATEST_VER', expected '$VERSION'"
|
||||
exit 1
|
||||
fi
|
||||
echo "✓ Verified: get-shit-done-cc@$VERSION is live on @latest"
|
||||
|
||||
- name: Summary
|
||||
env:
|
||||
VERSION: ${{ inputs.version }}
|
||||
BASE_TAG: ${{ needs.validate-version.outputs.base_tag }}
|
||||
DRY_RUN: ${{ inputs.dry_run }}
|
||||
run: |
|
||||
echo "## Hotfix v${VERSION}" >> "$GITHUB_STEP_SUMMARY"
|
||||
if [ "$DRY_RUN" = "true" ]; then
|
||||
echo "**DRY RUN** — npm publish, tagging, and push skipped" >> "$GITHUB_STEP_SUMMARY"
|
||||
else
|
||||
echo "- Published to npm as \`latest\`" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "- Tagged \`v${VERSION}\`" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "- PR created to merge back to main" >> "$GITHUB_STEP_SUMMARY"
|
||||
fi
|
||||
{
|
||||
echo "## Hotfix v${VERSION}"
|
||||
echo ""
|
||||
echo "- Base (cumulative-fix anchor): \`${BASE_TAG}\`"
|
||||
if [ "$DRY_RUN" = "true" ]; then
|
||||
echo "- **DRY RUN** — npm publish, tagging, and push skipped"
|
||||
else
|
||||
echo "- Published to npm as \`latest\`"
|
||||
echo "- \`next\` dist-tag re-pointed to v${VERSION}"
|
||||
echo "- Tagged \`v${VERSION}\` (anchor for the next hotfix's cherry-pick base)"
|
||||
echo "- SDK bundled at \`sdk-bundle/gsd-sdk.tgz\` inside CC tarball"
|
||||
echo "- Merge-back PR opened against main"
|
||||
fi
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
790
.github/workflows/release-sdk.yml
vendored
Normal file
790
.github/workflows/release-sdk.yml
vendored
Normal file
@@ -0,0 +1,790 @@
|
||||
# Release SDK Bundle
|
||||
#
|
||||
# Stopgap workflow_dispatch publish path: builds get-shit-done-cc with the
|
||||
# compiled SDK and the SDK .tgz bundled inside the CC tarball, then
|
||||
# publishes the CC package to ONE chosen dist-tag (dev | next | latest)
|
||||
# per run.
|
||||
#
|
||||
# Why this exists: @gsd-build/sdk publishes from canary.yml and release.yml
|
||||
# fail because the @gsd-build npm token is currently unavailable. CC users
|
||||
# do not consume @gsd-build/sdk directly — bin/gsd-sdk.js resolves
|
||||
# sdk/dist/cli.js from inside the installed CC package, so the bundled
|
||||
# copy is sufficient for full functionality. This workflow ships CC alone
|
||||
# (no separate @gsd-build/sdk publish attempt) and additionally bakes a
|
||||
# bundled gsd-sdk-<version>.tgz at sdk-bundle/gsd-sdk.tgz inside the CC
|
||||
# tarball as a recoverable npm-installable artifact.
|
||||
#
|
||||
# Existing canary.yml and release.yml are intentionally untouched. They
|
||||
# remain the canonical two-package publish path; restore them to primary
|
||||
# use once @gsd-build/sdk ownership is recovered.
|
||||
#
|
||||
# Tracking issues: #2925 (initial workflow), #2929 (CI-gate parity with release.yml)
|
||||
|
||||
name: Release SDK Bundle
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
action:
|
||||
description: 'publish = normal dev/next/latest publish; hotfix = create hotfix/X.YY.Z branch from latest vX.YY.* tag, cherry-pick fix:/chore: from main, publish to @latest'
|
||||
required: true
|
||||
type: choice
|
||||
default: publish
|
||||
options:
|
||||
- publish
|
||||
- hotfix
|
||||
tag:
|
||||
description: 'npm dist-tag (publish action only; hotfix forces latest)'
|
||||
required: false
|
||||
type: choice
|
||||
default: latest
|
||||
options:
|
||||
- dev
|
||||
- next
|
||||
- latest
|
||||
version:
|
||||
description: 'Version. publish: explicit (e.g. 1.50.0-dev.3) or empty to derive. hotfix: REQUIRED patch (e.g. 1.27.1, Z>0).'
|
||||
required: false
|
||||
type: string
|
||||
ref:
|
||||
description: 'Branch or ref to build from. Ignored for hotfix (workflow uses hotfix/X.YY.Z).'
|
||||
required: false
|
||||
type: string
|
||||
auto_cherry_pick:
|
||||
description: 'Hotfix only: auto-cherry-pick fix:/chore: commits from origin/main since base tag.'
|
||||
required: false
|
||||
type: boolean
|
||||
default: true
|
||||
dry_run:
|
||||
description: 'Dry run (skip npm publish, git tag, and push). Hotfix branch creation/push also skipped.'
|
||||
required: false
|
||||
type: boolean
|
||||
default: false
|
||||
|
||||
# Per stream (dist-tag for publish, version for hotfix) — no concurrent publishes for the same stream.
|
||||
concurrency:
|
||||
group: release-sdk-${{ inputs.action == 'hotfix' && format('hotfix-{0}', inputs.version) || inputs.tag }}
|
||||
cancel-in-progress: false
|
||||
|
||||
env:
|
||||
NODE_VERSION: 24
|
||||
|
||||
jobs:
|
||||
# Resolves the effective git ref for this run.
|
||||
#
|
||||
# action=publish → outputs inputs.ref verbatim (may be empty = workflow ref)
|
||||
# action=hotfix → branches hotfix/X.YY.Z from highest existing vX.YY.* tag,
|
||||
# auto-cherry-picks fix:/chore: from origin/main, pushes,
|
||||
# and outputs the new branch as ref. Idempotent: if branch
|
||||
# already exists (operator pre-prepared it via hotfix.yml),
|
||||
# we just check it out and re-run the cherry-pick step
|
||||
# no-ops since `git cherry` will report nothing new.
|
||||
prepare:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
permissions:
|
||||
contents: write
|
||||
outputs:
|
||||
ref: ${{ steps.out.outputs.ref }}
|
||||
base_tag: ${{ steps.hotfix.outputs.base_tag }}
|
||||
steps:
|
||||
- name: Validate hotfix inputs
|
||||
if: inputs.action == 'hotfix'
|
||||
env:
|
||||
VERSION: ${{ inputs.version }}
|
||||
run: |
|
||||
if [ -z "$VERSION" ]; then
|
||||
echo "::error::action=hotfix requires the 'version' input (e.g. 1.27.1)"
|
||||
exit 1
|
||||
fi
|
||||
if ! echo "$VERSION" | grep -qE '^[0-9]+\.[0-9]+\.[1-9][0-9]*$'; then
|
||||
echo "::error::Hotfix version must match X.YY.Z with Z>0 (got: $VERSION)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
if: inputs.action == 'hotfix'
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Configure git identity
|
||||
if: inputs.action == 'hotfix'
|
||||
run: |
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||
|
||||
- name: Prepare hotfix branch
|
||||
id: hotfix
|
||||
if: inputs.action == 'hotfix'
|
||||
env:
|
||||
VERSION: ${{ inputs.version }}
|
||||
AUTO_CHERRY_PICK: ${{ inputs.auto_cherry_pick }}
|
||||
DRY_RUN: ${{ inputs.dry_run }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# Stash the shipped-paths classifier from the dispatched ref's
|
||||
# working tree BEFORE `git checkout -b ... "$BASE_TAG"` below
|
||||
# overwrites it. Base tags predating #2980 don't have the
|
||||
# classifier in their tree, so the loop must reference a
|
||||
# location that survives the working-tree swap. Bug #2983.
|
||||
CLASSIFIER_SRC="scripts/diff-touches-shipped-paths.cjs"
|
||||
if [ ! -f "$CLASSIFIER_SRC" ]; then
|
||||
echo "::error::shipped-paths classifier not found at $CLASSIFIER_SRC in dispatched ref — refusing to run"
|
||||
exit 1
|
||||
fi
|
||||
CLASSIFIER="${RUNNER_TEMP}/diff-touches-shipped-paths.cjs"
|
||||
cp "$CLASSIFIER_SRC" "$CLASSIFIER"
|
||||
if [ ! -f "$CLASSIFIER" ]; then
|
||||
echo "::error::failed to stage classifier at $CLASSIFIER"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
MAJOR_MINOR=$(echo "$VERSION" | cut -d. -f1-2)
|
||||
TARGET_TAG="v${VERSION}"
|
||||
BRANCH="hotfix/${VERSION}"
|
||||
# Semver-correct selection: append TARGET_TAG, sort -V, take preceding entry.
|
||||
# Plain lexicographic compare mis-orders multi-digit patches (v1.27.10 vs v1.27.9).
|
||||
BASE_TAG=$( ( git tag -l "v${MAJOR_MINOR}.*" | grep -E "^v[0-9]+\.[0-9]+\.[0-9]+$"; echo "$TARGET_TAG" ) \
|
||||
| sort -V \
|
||||
| awk -v target="$TARGET_TAG" '$1 == target { print prev; exit } { prev = $1 }')
|
||||
if [ -z "$BASE_TAG" ]; then
|
||||
echo "::error::No prior stable tag found for ${MAJOR_MINOR}.x before $TARGET_TAG"
|
||||
exit 1
|
||||
fi
|
||||
echo "base_tag=$BASE_TAG" >> "$GITHUB_OUTPUT"
|
||||
echo "branch=$BRANCH" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# Idempotent branch creation — operator may have pre-prepared via hotfix.yml.
|
||||
git fetch origin main:refs/remotes/origin/main
|
||||
if git ls-remote --exit-code origin "refs/heads/$BRANCH" >/dev/null 2>&1; then
|
||||
echo "Branch $BRANCH already exists on origin; checking out"
|
||||
git fetch origin "$BRANCH"
|
||||
git checkout "$BRANCH"
|
||||
BRANCH_PRE_EXISTED=1
|
||||
else
|
||||
git checkout -b "$BRANCH" "$BASE_TAG"
|
||||
BRANCH_PRE_EXISTED=0
|
||||
# Push the skeleton up-front (real runs only) so cherry-pick conflicts
|
||||
# leave a remote artefact the operator can resolve. Dry-run keeps
|
||||
# everything local — no orphan branch created on origin.
|
||||
if [ "$DRY_RUN" != "true" ]; then
|
||||
git push -u origin "$BRANCH"
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ "$AUTO_CHERRY_PICK" = "true" ]; then
|
||||
CANDIDATES=$(git cherry HEAD origin/main | awk '/^\+ / {print $2}')
|
||||
if [ -n "$CANDIDATES" ]; then
|
||||
ORDERED=$(git log --reverse --format='%H' "${BASE_TAG}..origin/main" \
|
||||
| grep -F -f <(echo "$CANDIDATES") || true)
|
||||
INCLUDED=""
|
||||
# POLICY_SKIPPED — commits intentionally not picked because they
|
||||
# don't match the fix/chore filter (feat/refactor/docs/etc).
|
||||
# CONFLICT_SKIPPED — fix/chore commits whose cherry-pick failed
|
||||
# and were skipped per the full-automation policy (#2968).
|
||||
# NON_SHIPPED_SKIPPED — fix/chore commits whose diff doesn't
|
||||
# touch any path in the npm tarball's `files` whitelist
|
||||
# (CI / test / docs / planning-only changes). They can't
|
||||
# affect the published package's behavior, so picking them
|
||||
# into a hotfix is meaningless — and picking workflow-file
|
||||
# changes specifically would also fail the push step because
|
||||
# the default GITHUB_TOKEN lacks the `workflow` scope. The
|
||||
# shipped-paths filter is the precise root cause: bug #2980.
|
||||
# Operators reviewing the run summary need these distinct so
|
||||
# the manual-review queue (CONFLICT_SKIPPED) isn't buried in
|
||||
# the noise from the other two buckets.
|
||||
POLICY_SKIPPED=""
|
||||
CONFLICT_SKIPPED=""
|
||||
NON_SHIPPED_SKIPPED=""
|
||||
while IFS= read -r SHA; do
|
||||
[ -z "$SHA" ] && continue
|
||||
SUBJECT=$(git log -1 --format='%s' "$SHA")
|
||||
if echo "$SUBJECT" | grep -qE '^(fix|chore)(\([^)]+\))?!?: '; then
|
||||
# Merge commits with fix:/chore: titles can't be cherry-picked
|
||||
# without `-m <parent>` and we can't pick the parent
|
||||
# automatically. They fail BEFORE entering cherry-pick state
|
||||
# (no CHERRY_PICK_HEAD), so an unconditional `--skip` would
|
||||
# then fail and brick the loop. Skip them upfront with a
|
||||
# distinct reason. Bug #2968 / CodeRabbit on PR #2970.
|
||||
PARENT_COUNT=$(git rev-list --parents -n 1 "$SHA" | awk '{print NF - 1}')
|
||||
if [ "$PARENT_COUNT" -gt 1 ]; then
|
||||
REASON="merge commit — manual -m parent selection required"
|
||||
echo "↷ skipping $SHA — $REASON"
|
||||
CONFLICT_SKIPPED="${CONFLICT_SKIPPED}- \`${SHA}\` ${SUBJECT} ($REASON)"$'\n'
|
||||
continue
|
||||
fi
|
||||
# Pre-pick guard: a hotfix release can only be affected
|
||||
# by commits whose diff intersects the npm tarball's
|
||||
# shipped paths (package.json `files` whitelist plus
|
||||
# package.json itself, which `npm pack` always
|
||||
# includes). Commits that touch only CI workflows,
|
||||
# tests, docs, or planning artifacts cannot change what
|
||||
# ships, so picking them into a hotfix is meaningless.
|
||||
# As a side benefit, this excludes
|
||||
# `.github/workflows/*` changes whose push would
|
||||
# otherwise be rejected by GitHub because the default
|
||||
# GITHUB_TOKEN lacks the `workflow` scope. The filter
|
||||
# is implemented in
|
||||
# scripts/diff-touches-shipped-paths.cjs rather than
|
||||
# inline so the rules (read package.json `files`,
|
||||
# treat entries as file-OR-directory prefix, the
|
||||
# `package.json`-always-shipped rule) are
|
||||
# unit-testable. Bug #2980.
|
||||
#
|
||||
# Use $CLASSIFIER (staged at workflow-start, before
|
||||
# `git checkout -b ... "$BASE_TAG"` swapped the working
|
||||
# tree) rather than `scripts/...` directly — base tags
|
||||
# older than #2980 don't have the classifier in their
|
||||
# tree. Capture the exit code via PIPESTATUS and
|
||||
# dispatch on it: 0 = shipped, 1 = not shipped, 2+ =
|
||||
# classifier error → fail-fast (don't silently treat
|
||||
# tooling errors as informational skips). Bug #2983.
|
||||
#
|
||||
# PIPESTATUS capture must happen IMMEDIATELY after the
|
||||
# pipeline — the previous form (`pipeline || true; RC=
|
||||
# ${PIPESTATUS[1]}`) had a subtle bug: when the
|
||||
# pipeline fails (exit 1 or 2 — exactly the cases we
|
||||
# care about), `|| true` runs `true` as a one-command
|
||||
# pipeline, overwriting PIPESTATUS to (0). The fix is
|
||||
# to wrap the pipeline in `set +e`/`set -e` and snapshot
|
||||
# PIPESTATUS into a local array on the very next line.
|
||||
# CodeRabbit on PR #2984.
|
||||
set +e
|
||||
git diff-tree --no-commit-id --name-only -r "$SHA" \
|
||||
| node "$CLASSIFIER"
|
||||
PIPE_RC=("${PIPESTATUS[@]}")
|
||||
set -e
|
||||
DIFFTREE_RC="${PIPE_RC[0]}"
|
||||
CLASSIFIER_RC="${PIPE_RC[1]}"
|
||||
if [ "$DIFFTREE_RC" -ne 0 ]; then
|
||||
echo "::error::git diff-tree failed for $SHA (exit $DIFFTREE_RC) — refusing to classify on incomplete input."
|
||||
exit "$DIFFTREE_RC"
|
||||
fi
|
||||
case "$CLASSIFIER_RC" in
|
||||
0) ;;
|
||||
1)
|
||||
REASON="touches no shipped paths (CI / test / docs / planning only)"
|
||||
echo "↷ skipping $SHA — $REASON"
|
||||
NON_SHIPPED_SKIPPED="${NON_SHIPPED_SKIPPED}- \`${SHA}\` ${SUBJECT}"$'\n'
|
||||
continue
|
||||
;;
|
||||
*)
|
||||
echo "::error::shipped-paths classifier failed for $SHA (exit $CLASSIFIER_RC). Refusing to silently skip — bug #2983."
|
||||
exit "$CLASSIFIER_RC"
|
||||
;;
|
||||
esac
|
||||
echo "→ cherry-picking $SHA $SUBJECT"
|
||||
# Pin merge.conflictStyle=merge on the cherry-pick so the
|
||||
# awk classifier below sees deterministic marker shapes —
|
||||
# diff3/zdiff3 would inject `||||||| ancestor` lines into
|
||||
# the HEAD section and cause context-missing conflicts to
|
||||
# misclassify as real. Bug #2966.
|
||||
if ! git -c merge.conflictStyle=merge cherry-pick -x --allow-empty --keep-redundant-commits "$SHA"; then
|
||||
# Full automation policy (bug #2968): any conflict the
|
||||
# cherry-pick can't auto-resolve is skipped, not aborted.
|
||||
# The hotfix run completes with whatever applies cleanly;
|
||||
# the CONFLICT_SKIPPED list below becomes the operator's
|
||||
# review queue (see "Cherry-pick summary" in the run
|
||||
# summary).
|
||||
#
|
||||
# Classify the conflict for the skip reason (operator-
|
||||
# facing diagnostic — doesn't change control flow):
|
||||
# - context absent at base: HEAD section in every
|
||||
# conflict marker is empty (the picked commit modifies
|
||||
# code that doesn't exist at the base). Bug #2966.
|
||||
# - merge conflict: HEAD section has content (both base
|
||||
# and patch want different content for the same
|
||||
# region). Typical when the base tag was cut from a
|
||||
# branch that has diverged from main. Bug #2968.
|
||||
UNMERGED=$(git diff --name-only --diff-filter=U)
|
||||
REASON="merge conflict — manual review"
|
||||
if [ -n "$UNMERGED" ]; then
|
||||
ALL_EMPTY_HEAD=true
|
||||
while IFS= read -r CONFLICTED; do
|
||||
[ -z "$CONFLICTED" ] && continue
|
||||
# Guard the classifier against degenerate cases that
|
||||
# would otherwise skew toward "context absent" (the
|
||||
# auto-skip path) when they're actually unsafe to skip:
|
||||
# - file missing or unreadable: don't pretend the
|
||||
# conflict is benign; treat as real.
|
||||
# - file listed as unmerged but no conflict markers
|
||||
# present: anomalous git state; treat as real so
|
||||
# the pick goes to the manual-review queue.
|
||||
# CodeRabbit on PR #2970.
|
||||
if [ ! -r "$CONFLICTED" ] || ! grep -q '^<<<<<<< ' "$CONFLICTED" 2>/dev/null; then
|
||||
ALL_EMPTY_HEAD=false
|
||||
break
|
||||
fi
|
||||
REAL=$(awk '
|
||||
/^<<<<<<< / { in_head=1; head=""; next }
|
||||
/^=======$/ && in_head { in_head=0; next }
|
||||
/^>>>>>>> / {
|
||||
if (head ~ /[^[:space:]]/) { print "real"; exit }
|
||||
head=""
|
||||
next
|
||||
}
|
||||
in_head { head = head $0 "\n" }
|
||||
' "$CONFLICTED" 2>/dev/null || echo "real")
|
||||
if [ "$REAL" = "real" ]; then
|
||||
ALL_EMPTY_HEAD=false
|
||||
break
|
||||
fi
|
||||
done <<< "$UNMERGED"
|
||||
if [ "$ALL_EMPTY_HEAD" = "true" ]; then
|
||||
REASON="context absent at base"
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "↷ skipping $SHA — $REASON"
|
||||
# Guard `--skip`: cherry-pick can fail before entering the
|
||||
# conflict state (e.g. unreadable commit, empty-without-
|
||||
# --allow-empty edge cases the flag misses). Calling
|
||||
# `--skip` outside an in-progress cherry-pick exits non-
|
||||
# zero and would brick the loop. CodeRabbit on PR #2970.
|
||||
if git rev-parse -q --verify CHERRY_PICK_HEAD >/dev/null 2>&1; then
|
||||
git cherry-pick --skip
|
||||
fi
|
||||
CONFLICT_SKIPPED="${CONFLICT_SKIPPED}- \`${SHA}\` ${SUBJECT} ($REASON)"$'\n'
|
||||
continue
|
||||
fi
|
||||
INCLUDED="${INCLUDED}- \`${SHA}\` ${SUBJECT}"$'\n'
|
||||
else
|
||||
POLICY_SKIPPED="${POLICY_SKIPPED}- \`${SHA}\` ${SUBJECT}"$'\n'
|
||||
fi
|
||||
done <<< "$ORDERED"
|
||||
{
|
||||
echo "## Cherry-pick summary"
|
||||
echo ""
|
||||
echo "Base: \`$BASE_TAG\` → Branch: \`$BRANCH\`$([ "$DRY_RUN" = "true" ] && echo " (DRY RUN — local only)")"
|
||||
echo ""
|
||||
if [ -n "$INCLUDED" ]; then
|
||||
echo "### Included (fix/chore)"
|
||||
echo ""
|
||||
echo "$INCLUDED"
|
||||
else
|
||||
echo "_No fix/chore commits to include._"
|
||||
fi
|
||||
if [ -n "$NON_SHIPPED_SKIPPED" ]; then
|
||||
echo "### Skipped — touches no shipped paths (informational)"
|
||||
echo ""
|
||||
echo "These fix/chore commits don't touch any path in the npm tarball's \`files\` whitelist (or \`package.json\`), so they cannot change the published package's behavior. CI / test / docs / planning-only changes belong on \`main\`, not in a hotfix. No action needed."
|
||||
echo ""
|
||||
echo "$NON_SHIPPED_SKIPPED"
|
||||
fi
|
||||
if [ -n "$CONFLICT_SKIPPED" ]; then
|
||||
echo "### Skipped — cherry-pick conflict (manual review)"
|
||||
echo ""
|
||||
echo "$CONFLICT_SKIPPED"
|
||||
fi
|
||||
if [ -n "$POLICY_SKIPPED" ]; then
|
||||
echo "### Not auto-included (feat/refactor/docs/etc)"
|
||||
echo ""
|
||||
echo "$POLICY_SKIPPED"
|
||||
fi
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Bump version on the branch (committed) so downstream install-smoke +
|
||||
# release jobs build the correct version. The release job's own in-tree
|
||||
# bump becomes a no-op when the file already has the right version.
|
||||
CURRENT=$(node -p "require('./package.json').version")
|
||||
if [ "$CURRENT" != "$VERSION" ]; then
|
||||
npm version "$VERSION" --no-git-tag-version
|
||||
git add package.json package-lock.json
|
||||
if [ -f sdk/package.json ]; then
|
||||
(cd sdk && npm version "$VERSION" --no-git-tag-version)
|
||||
git add sdk/package.json
|
||||
[ -f sdk/package-lock.json ] && git add sdk/package-lock.json
|
||||
fi
|
||||
git commit -m "chore: bump version to $VERSION for hotfix"
|
||||
fi
|
||||
if [ "$DRY_RUN" != "true" ]; then
|
||||
git push origin "$BRANCH"
|
||||
else
|
||||
echo "DRY RUN — cherry-picks applied locally; branch not pushed. Downstream install-smoke will run against \`$BASE_TAG\` (the cherry-pick verification above is the dry-run signal)."
|
||||
fi
|
||||
|
||||
- name: Determine effective ref
|
||||
id: out
|
||||
env:
|
||||
ACTION: ${{ inputs.action }}
|
||||
INPUT_REF: ${{ inputs.ref }}
|
||||
DRY_RUN: ${{ inputs.dry_run }}
|
||||
BASE_TAG: ${{ steps.hotfix.outputs.base_tag }}
|
||||
BRANCH: ${{ steps.hotfix.outputs.branch }}
|
||||
run: |
|
||||
if [ "$ACTION" = "hotfix" ]; then
|
||||
if [ "$DRY_RUN" = "true" ]; then
|
||||
echo "ref=$BASE_TAG" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "ref=$BRANCH" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
else
|
||||
echo "ref=$INPUT_REF" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
# Cross-platform install validation gate (parity with release.yml).
|
||||
install-smoke:
|
||||
needs: prepare
|
||||
permissions:
|
||||
contents: read
|
||||
uses: ./.github/workflows/install-smoke.yml
|
||||
with:
|
||||
ref: ${{ needs.prepare.outputs.ref }}
|
||||
|
||||
release:
|
||||
needs: [prepare, install-smoke]
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
permissions:
|
||||
contents: write # tag + push + GitHub Release
|
||||
id-token: write # provenance
|
||||
# The merge-back PR step (and the pull-request scope it required)
|
||||
# was removed in #2983 — auto-cherry-pick hotfix flow only picks
|
||||
# commits already on main, so there's nothing to merge back.
|
||||
environment: npm-publish
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
ref: ${{ needs.prepare.outputs.ref }}
|
||||
|
||||
- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
registry-url: 'https://registry.npmjs.org'
|
||||
cache: 'npm'
|
||||
|
||||
- name: Determine version
|
||||
id: ver
|
||||
env:
|
||||
ACTION: ${{ inputs.action }}
|
||||
INPUT_TAG: ${{ inputs.tag }}
|
||||
INPUT_OVERRIDE: ${{ inputs.version }}
|
||||
run: |
|
||||
set -e
|
||||
# Hotfix forces version=inputs.version and dist-tag=latest.
|
||||
if [ "$ACTION" = "hotfix" ]; then
|
||||
if [ -z "$INPUT_OVERRIDE" ]; then
|
||||
echo "::error::action=hotfix requires the 'version' input"
|
||||
exit 1
|
||||
fi
|
||||
VERSION="$INPUT_OVERRIDE"
|
||||
EFFECTIVE_TAG="latest"
|
||||
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
|
||||
echo "tag=$EFFECTIVE_TAG" >> "$GITHUB_OUTPUT"
|
||||
echo "→ Hotfix: will publish v${VERSION} to dist-tag '${EFFECTIVE_TAG}'"
|
||||
exit 0
|
||||
fi
|
||||
RAW=$(node -p "require('./package.json').version")
|
||||
BASE=$(echo "$RAW" | sed 's/-.*//')
|
||||
if [ -n "$INPUT_OVERRIDE" ]; then
|
||||
VERSION="$INPUT_OVERRIDE"
|
||||
else
|
||||
case "$INPUT_TAG" in
|
||||
dev)
|
||||
N=1
|
||||
while git tag -l "v${BASE}-dev.${N}" | grep -q .; do
|
||||
N=$((N + 1))
|
||||
done
|
||||
VERSION="${BASE}-dev.${N}"
|
||||
;;
|
||||
next)
|
||||
N=1
|
||||
while git tag -l "v${BASE}-rc.${N}" | grep -q .; do
|
||||
N=$((N + 1))
|
||||
done
|
||||
VERSION="${BASE}-rc.${N}"
|
||||
;;
|
||||
latest)
|
||||
VERSION="$BASE"
|
||||
;;
|
||||
*)
|
||||
echo "::error::Unknown tag '$INPUT_TAG' (expected dev|next|latest)"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
fi
|
||||
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
|
||||
echo "tag=$INPUT_TAG" >> "$GITHUB_OUTPUT"
|
||||
echo "→ Will publish v${VERSION} to dist-tag '${INPUT_TAG}'"
|
||||
|
||||
# Reconciliation mode: if version is already on npm (a prior run
|
||||
# published successfully but a downstream step failed), don't hard-fail.
|
||||
# Set a flag and skip the publish step below; tag/release/PR/dist-tag
|
||||
# steps still execute so the rerun can finish reconciling state.
|
||||
- name: Detect prior publish (reconciliation mode)
|
||||
id: prior_publish
|
||||
env:
|
||||
VERSION: ${{ steps.ver.outputs.version }}
|
||||
run: |
|
||||
EXISTING=$(npm view get-shit-done-cc@"$VERSION" version 2>/dev/null || true)
|
||||
if [ -n "$EXISTING" ]; then
|
||||
echo "::warning::get-shit-done-cc@${VERSION} is already on the registry — entering reconciliation mode (skip publish, continue with tag/release/PR/dist-tag)."
|
||||
echo "skip_publish=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "skip_publish=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
# Tolerant tag-existence check (matches release.yml pattern). An
|
||||
# operator re-running after a mid-flight publish-step failure should
|
||||
# not be blocked just because the tag step succeeded last time. Only
|
||||
# error if the existing tag points at a different commit than HEAD.
|
||||
- name: Check git tag (skip if matches HEAD, error if mismatched)
|
||||
env:
|
||||
VERSION: ${{ steps.ver.outputs.version }}
|
||||
run: |
|
||||
if git rev-parse -q --verify "refs/tags/v${VERSION}" >/dev/null; then
|
||||
EXISTING_SHA=$(git rev-parse "refs/tags/v${VERSION}")
|
||||
HEAD_SHA=$(git rev-parse HEAD)
|
||||
if [ "$EXISTING_SHA" != "$HEAD_SHA" ]; then
|
||||
echo "::error::git tag v${VERSION} already exists pointing at ${EXISTING_SHA}, but HEAD is ${HEAD_SHA}"
|
||||
exit 1
|
||||
fi
|
||||
echo "::notice::tag v${VERSION} already exists at HEAD; tag step will skip"
|
||||
fi
|
||||
|
||||
- name: Configure git identity
|
||||
run: |
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||
|
||||
- name: Bump in-tree version (not committed)
|
||||
env:
|
||||
VERSION: ${{ steps.ver.outputs.version }}
|
||||
run: |
|
||||
# --allow-same-version: prepare may have already committed this bump
|
||||
# on the hotfix branch (release checks out BRANCH in real runs,
|
||||
# BASE_TAG in dry-runs — only the latter has the older version).
|
||||
npm version "$VERSION" --no-git-tag-version --allow-same-version
|
||||
cd sdk && npm version "$VERSION" --no-git-tag-version --allow-same-version
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Run full test suite with coverage (parity with release.yml)
|
||||
run: npm run test:coverage
|
||||
|
||||
- name: Build SDK dist for tarball
|
||||
run: npm run build:sdk
|
||||
|
||||
- name: Verify CC tarball ships sdk/dist/cli.js (bug #2647 guard)
|
||||
run: bash scripts/verify-tarball-sdk-dist.sh
|
||||
|
||||
- name: Pack SDK as tarball and bundle into CC source tree
|
||||
env:
|
||||
VERSION: ${{ steps.ver.outputs.version }}
|
||||
run: |
|
||||
set -e
|
||||
cd sdk
|
||||
npm pack
|
||||
# npm pack emits gsd-build-sdk-<version>.tgz in the cwd
|
||||
TARBALL="gsd-build-sdk-${VERSION}.tgz"
|
||||
if [ ! -f "$TARBALL" ]; then
|
||||
echo "::error::Expected $TARBALL but npm pack did not produce it. Listing sdk/:"
|
||||
ls -la
|
||||
exit 1
|
||||
fi
|
||||
mkdir -p ../sdk-bundle
|
||||
mv "$TARBALL" ../sdk-bundle/gsd-sdk.tgz
|
||||
cd ..
|
||||
ls -la sdk-bundle/
|
||||
|
||||
- name: Add sdk-bundle to CC files whitelist (in-tree, not committed)
|
||||
run: |
|
||||
node <<'NODE'
|
||||
const fs = require('fs');
|
||||
const pkg = JSON.parse(fs.readFileSync('package.json', 'utf8'));
|
||||
if (!Array.isArray(pkg.files)) {
|
||||
console.error('::error::package.json files is not an array');
|
||||
process.exit(1);
|
||||
}
|
||||
if (!pkg.files.includes('sdk-bundle')) {
|
||||
pkg.files.push('sdk-bundle');
|
||||
fs.writeFileSync('package.json', JSON.stringify(pkg, null, 2) + '\n');
|
||||
console.log('Added sdk-bundle/ to package.json files whitelist');
|
||||
} else {
|
||||
console.log('sdk-bundle/ already in files whitelist');
|
||||
}
|
||||
NODE
|
||||
|
||||
- name: Verify CC tarball will contain sdk-bundle/gsd-sdk.tgz
|
||||
run: |
|
||||
set -e
|
||||
TARBALL=$(npm pack --ignore-scripts 2>/dev/null | tail -1)
|
||||
if [ -z "$TARBALL" ] || [ ! -f "$TARBALL" ]; then
|
||||
echo "::error::npm pack produced no tarball"
|
||||
exit 1
|
||||
fi
|
||||
echo "Inspecting $TARBALL for sdk-bundle/gsd-sdk.tgz:"
|
||||
if ! tar -tzf "$TARBALL" | grep -q "package/sdk-bundle/gsd-sdk.tgz"; then
|
||||
echo "::error::CC tarball is missing package/sdk-bundle/gsd-sdk.tgz"
|
||||
tar -tzf "$TARBALL" | grep -E "sdk-bundle|sdk/dist" | head -20
|
||||
exit 1
|
||||
fi
|
||||
echo "✅ CC tarball contains sdk-bundle/gsd-sdk.tgz"
|
||||
rm -f "$TARBALL"
|
||||
|
||||
- name: Dry-run publish validation
|
||||
# Skip the rehearsal when the version is already on npm
|
||||
# (reconciliation mode). `npm publish --dry-run` contacts the
|
||||
# registry and fails with "You cannot publish over the
|
||||
# previously published versions" if the version exists, even
|
||||
# though no actual publish would be attempted. The real publish
|
||||
# step (further down) is gated on the same condition; gate the
|
||||
# rehearsal too so re-runs of an already-published hotfix don't
|
||||
# fail here on a check that doesn't apply. Bug #2987.
|
||||
if: ${{ steps.prior_publish.outputs.skip_publish != 'true' }}
|
||||
env:
|
||||
TAG: ${{ steps.ver.outputs.tag }}
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
run: npm publish --dry-run --tag "$TAG"
|
||||
|
||||
- name: Tag and push
|
||||
if: ${{ !inputs.dry_run }}
|
||||
env:
|
||||
VERSION: ${{ steps.ver.outputs.version }}
|
||||
run: |
|
||||
if git rev-parse -q --verify "refs/tags/v${VERSION}" >/dev/null; then
|
||||
echo "Tag v${VERSION} already exists at HEAD (per pre-flight check); skipping git tag step"
|
||||
else
|
||||
git tag "v${VERSION}"
|
||||
fi
|
||||
git push origin "v${VERSION}"
|
||||
|
||||
- name: Publish to npm (CC bundle, SDK included as both loose tree and .tgz)
|
||||
if: ${{ !inputs.dry_run && steps.prior_publish.outputs.skip_publish != 'true' }}
|
||||
env:
|
||||
TAG: ${{ steps.ver.outputs.tag }}
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
run: npm publish --provenance --access public --tag "$TAG"
|
||||
|
||||
# Keep `next` from going stale relative to `latest`. When publishing a
|
||||
# stable release, also point `next` at it so users on `@next` don't
|
||||
# get stuck on an older pre-release than what's now stable. Parity
|
||||
# with release.yml#finalize "Clean up next dist-tag" step.
|
||||
- name: Re-point next dist-tag at the new latest (only when tag=latest)
|
||||
if: ${{ !inputs.dry_run && steps.ver.outputs.tag == 'latest' }}
|
||||
env:
|
||||
VERSION: ${{ steps.ver.outputs.version }}
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
run: |
|
||||
npm dist-tag add "get-shit-done-cc@${VERSION}" next
|
||||
echo "✅ next dist-tag re-pointed to v${VERSION} (matches latest)"
|
||||
|
||||
- name: Create GitHub Release (idempotent)
|
||||
if: ${{ !inputs.dry_run }}
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
VERSION: ${{ steps.ver.outputs.version }}
|
||||
TAG: ${{ steps.ver.outputs.tag }}
|
||||
run: |
|
||||
# Per-tag release flags:
|
||||
# dev, next → --prerelease (won't be highlighted as the latest release on the repo page)
|
||||
# latest → --latest (becomes the highlighted release)
|
||||
# Idempotent: if release already exists (rerun after a transient
|
||||
# downstream failure), edit the latest flag instead of failing.
|
||||
if gh release view "v${VERSION}" >/dev/null 2>&1; then
|
||||
echo "GitHub Release v${VERSION} already exists; reconciling --latest flag"
|
||||
if [ "$TAG" = "latest" ]; then
|
||||
gh release edit "v${VERSION}" --latest || true
|
||||
fi
|
||||
elif [ "$TAG" = "latest" ]; then
|
||||
gh release create "v${VERSION}" \
|
||||
--title "v${VERSION}" \
|
||||
--generate-notes \
|
||||
--latest
|
||||
else
|
||||
gh release create "v${VERSION}" \
|
||||
--title "v${VERSION}" \
|
||||
--generate-notes \
|
||||
--prerelease
|
||||
fi
|
||||
echo "✅ GitHub Release v${VERSION} ready"
|
||||
|
||||
# Merge-back PR step removed — bug #2983.
|
||||
#
|
||||
# The auto-cherry-pick hotfix flow only picks commits already on
|
||||
# main (`git cherry HEAD origin/main` outputs unmerged commits;
|
||||
# we filter to fix:/chore: from main). By construction every code
|
||||
# commit on the hotfix branch is already on main. The only
|
||||
# hotfix-branch-only commit is `chore: bump version to X.Y.Z for
|
||||
# hotfix`, which would either no-op against main (already past
|
||||
# X.Y.Z) or rewind main's in-progress version — strictly
|
||||
# counterproductive in either case.
|
||||
#
|
||||
# The original merge-back step also failed in production with
|
||||
# `GitHub Actions is not permitted to create or approve pull
|
||||
# requests (createPullRequest)` (org policy), but even if the
|
||||
# policy were lifted the PR would have nothing useful to merge.
|
||||
# Run 25232968975 was the trigger for removal.
|
||||
|
||||
- name: Verify publish landed on registry
|
||||
if: ${{ !inputs.dry_run }}
|
||||
env:
|
||||
VERSION: ${{ steps.ver.outputs.version }}
|
||||
TAG: ${{ steps.ver.outputs.tag }}
|
||||
run: |
|
||||
PUBLISHED="NOT_FOUND"
|
||||
for delay in 5 10 20 30 45; do
|
||||
PUBLISHED=$(npm view get-shit-done-cc@"$VERSION" version 2>/dev/null || echo "NOT_FOUND")
|
||||
if [ "$PUBLISHED" = "$VERSION" ]; then
|
||||
break
|
||||
fi
|
||||
echo "Waiting ${delay}s for registry to catch up (saw: $PUBLISHED)..."
|
||||
sleep "$delay"
|
||||
done
|
||||
if [ "$PUBLISHED" != "$VERSION" ]; then
|
||||
echo "::error::Version $VERSION did not appear on the registry within timeout"
|
||||
exit 1
|
||||
fi
|
||||
TAG_VERSION=$(npm view get-shit-done-cc dist-tags."$TAG" 2>/dev/null || echo "NOT_FOUND")
|
||||
if [ "$TAG_VERSION" != "$VERSION" ]; then
|
||||
echo "::error::dist-tag '$TAG' resolves to '$TAG_VERSION', expected '$VERSION'"
|
||||
exit 1
|
||||
fi
|
||||
echo "✅ get-shit-done-cc@${VERSION} live on dist-tag '${TAG}'"
|
||||
|
||||
- name: Summary
|
||||
env:
|
||||
ACTION: ${{ inputs.action }}
|
||||
VERSION: ${{ steps.ver.outputs.version }}
|
||||
TAG: ${{ steps.ver.outputs.tag }}
|
||||
BASE_TAG: ${{ needs.prepare.outputs.base_tag }}
|
||||
BRANCH: ${{ needs.prepare.outputs.ref }}
|
||||
DRY_RUN: ${{ inputs.dry_run }}
|
||||
run: |
|
||||
{
|
||||
if [ "$ACTION" = "hotfix" ]; then
|
||||
echo "## Release SDK Bundle (hotfix): v${VERSION} → @${TAG}"
|
||||
echo ""
|
||||
echo "- Base (cumulative-fix anchor): \`${BASE_TAG}\`"
|
||||
echo "- Branch: \`${BRANCH}\`"
|
||||
else
|
||||
echo "## Release SDK Bundle: v${VERSION} → @${TAG}"
|
||||
fi
|
||||
echo ""
|
||||
if [ "$DRY_RUN" = "true" ]; then
|
||||
echo "**DRY RUN** — npm publish, git tag, push, and GitHub Release were skipped."
|
||||
else
|
||||
echo "- Published \`get-shit-done-cc@${VERSION}\` to dist-tag \`${TAG}\`"
|
||||
echo "- SDK bundled inside the CC tarball at:"
|
||||
echo " - \`sdk/dist/cli.js\` (loose tree, consumed by \`bin/gsd-sdk.js\` shim)"
|
||||
echo " - \`sdk-bundle/gsd-sdk.tgz\` (npm-installable artifact)"
|
||||
echo "- Git tag \`v${VERSION}\` pushed"
|
||||
echo "- GitHub Release \`v${VERSION}\` created"
|
||||
if [ "$TAG" = "latest" ]; then
|
||||
echo "- \`next\` dist-tag re-pointed at \`v${VERSION}\` (kept current with \`latest\`)"
|
||||
fi
|
||||
if [ "$ACTION" = "hotfix" ]; then
|
||||
# Auto-cherry-pick hotfixes only pick commits already on
|
||||
# main, so there's nothing to merge back. The merge-back
|
||||
# PR step was removed in #2983; this line surfaces the
|
||||
# explicit non-action so operators don't expect a PR
|
||||
# that was never opened.
|
||||
echo "- No merge-back PR (auto-picked commits are already on main)"
|
||||
fi
|
||||
echo "- Install: \`npm install -g get-shit-done-cc@${TAG}\`"
|
||||
fi
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
19
CHANGELOG.md
19
CHANGELOG.md
@@ -6,6 +6,18 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
||||
|
||||
## [Unreleased](https://github.com/gsd-build/get-shit-done/compare/v1.38.5...HEAD)
|
||||
|
||||
### Fixed
|
||||
|
||||
- **`release-sdk` hotfix re-run no longer fails at `Dry-run publish validation` when the version is already on npm** — the `Detect prior publish (reconciliation mode)` step sets `skip_publish=true` when the package version is already on the registry, and the actual publish step honors that gate. The `Dry-run publish validation` step was missing the same guard, so any operator re-run of an already-published hotfix (the typical recovery path when later steps fail mid-flight) hit `npm publish --dry-run` first and got `npm error You cannot publish over the previously published versions: X.Y.Z` — `npm publish --dry-run` contacts the registry and rejects existing-version targets even though it doesn't actually publish. The dry-run validation step is now gated on the same `steps.prior_publish.outputs.skip_publish != 'true'` condition as the publish step. The rehearsal still runs on first publishes (where it has value); it skips only in the specific reconciliation case where the publish itself would be skipped. Trigger run: [25233855236](https://github.com/gsd-build/get-shit-done/actions/runs/25233855236/job/73995605643). Regression covered by `tests/bug-2987-dry-run-validation-skip-on-reconciliation.test.cjs`. (#2987)
|
||||
- **`release-sdk` hotfix flow hardened against silent classifier failures, missing-classifier-at-base-tag, and a vestigial merge-back PR step** — three issues surfaced by CodeRabbit's post-merge review of #2981 plus a production failure on the v1.39.1 release run. **(1)** `scripts/diff-touches-shipped-paths.cjs` reused exit code `1` for both the legitimate "no shipped paths" classifier result and Node's default uncaught-throw exit, so any tooling failure was indistinguishable from a normal skip. The script now uses `0` (shipped), `1` (not shipped), `2` (classifier error) with `try`/`catch` + `uncaughtException`/`unhandledRejection` handlers routing all failure paths to exit `2`. **(2)** The workflow's `git checkout -b "$BRANCH" "$BASE_TAG"` overwrote the working tree with the base tag's contents *before* the cherry-pick loop ran the classifier — but base tags predating the classifier's introduction (notably v1.39.0) don't have the file in their tree, so `node scripts/diff-touches-shipped-paths.cjs` would exit non-zero and silently drop every commit, producing an empty hotfix release. The classifier is now staged into `$RUNNER_TEMP` at the top of `Prepare hotfix branch` (before any working-tree-mutating git command), and the loop references that staged copy. The cherry-pick loop snapshots `$PIPESTATUS` into a local array (`PIPE_RC=("${PIPESTATUS[@]}")`) immediately after the classifier pipeline — under bracketed `set +e`/`set -e` — and dispatches via explicit `case`: `0` proceeds, `1` skips into `NON_SHIPPED_SKIPPED`, anything else emits `::error::shipped-paths classifier failed for $SHA (exit N)` and fails the workflow. CodeRabbit on PR #2984 caught a subtler bug in the first iteration: `pipeline \|\| true; RC=${PIPESTATUS[1]}` is broken because `\|\| true` runs `true` as its own one-command pipeline on the failure paths, overwriting `PIPESTATUS` to `(0)` and leaving `${PIPESTATUS[1]}` unset. The array-snapshot form is invariant against this. The same hardening also surfaces `git diff-tree`'s exit code (via `PIPE_RC[0]`); a non-zero diff-tree result now also fails the workflow rather than feeding partial input to the classifier. **(3)** Removed the `Open merge-back PR (hotfix only)` step. The auto-cherry-pick hotfix flow only picks commits already on main (`git cherry HEAD origin/main` outputs the unmerged ones), so by construction every code commit on the hotfix branch is already on main. The only hotfix-branch-only commit is the version-bump chore, which would either no-op against main or rewind main's in-progress version. The step also failed in production with `GitHub Actions is not permitted to create or approve pull requests (createPullRequest)` (org policy) on run [25232968975](https://github.com/gsd-build/get-shit-done/actions/runs/25232968975). The `pull-requests: write` permission previously granted to the release job has been dropped in line with least-privilege. The run-summary line that previously echoed `Merge-back PR opened against main` has been replaced with `No merge-back PR (auto-picked commits are already on main)` so operators reading the summary see an accurate non-action statement (CodeRabbit on PR #2984). Regression covered by `tests/bug-2983-classifier-exit-codes-and-base-tag-staging.test.cjs` (15 assertions across exit-code semantics, classifier staging, error dispatch, PIPESTATUS-snapshot hardening, diff-tree fail-fast, merge-back removal, and run-summary accuracy). (#2983)
|
||||
- **`release-sdk` hotfix only cherry-picks commits that change what actually ships** — the `fix:`/`chore:` filter in `Prepare hotfix branch` was too broad: it picked any commit with that conventional-commit type regardless of whether the diff could affect the published npm package. CI-only fixes (release-sdk.yml itself, hotfix tooling, test-only commits) were getting cherry-picked into hotfix branches even though they cannot change the tarball — and the subset touching `.github/workflows/*` then caused the prepare job's `git push` to be rejected by GitHub because the default `GITHUB_TOKEN` lacks the `workflow` scope, aborting the run. v1.39.1 hit this on PR #2977 (run [25232010071](https://github.com/gsd-build/get-shit-done/actions/runs/25232010071)). The loop now pre-skips any candidate commit whose `git diff-tree` output doesn't intersect the npm tarball's shipped paths (entries in `package.json` `files`, plus `package.json` itself, which `npm pack` always includes). Skipped commits land in a new `NON_SHIPPED_SKIPPED` summary bucket framed as informational — non-shipping commits cannot affect the package, so the skip needs no operator action. The shipped-paths classifier lives in `scripts/diff-touches-shipped-paths.cjs` so its rules (file-OR-directory prefix matching `npm pack` semantics, the always-shipped rule for `package.json`, the lockfile-not-shipped rule) are unit-testable. Regression covered by `tests/bug-2980-hotfix-only-picks-shipping-changes.test.cjs`. (#2980)
|
||||
- **`release-sdk` hotfix workflow fails on real run with `npm error Version not changed`** — the `release` job's `Bump in-tree version (not committed)` step ran `npm version "$VERSION"` without `--allow-same-version`, so it errored on real (non-dry-run) hotfix runs because `prepare` had already committed the bump on the hotfix branch. The release job's checkout `ref` is asymmetric — `BRANCH` (already bumped) on real runs vs `BASE_TAG` (older version) on dry-runs — which is why dry-run never caught the bug. Both `npm version` calls in that step now pass `--allow-same-version`, matching the existing pattern in `release.yml:326`. (#2976)
|
||||
- **`gsd-sdk query agent-skills` emits raw `<agent_skills>` block instead of JSON-wrapped string** — workflows that embed via `$(gsd-sdk query agent-skills <agent>)` were receiving a JSON-quoted string literal mid-prompt (e.g. `"<agent_skills>\n…"`), silently breaking all `<agent_skills>` injection into spawned subagents. The CLI dispatcher now honors an opt-in `format: 'text'` field on `QueryResult` and writes such results raw via `process.stdout.write`; `--pick` always returns JSON regardless. (#2917)
|
||||
- **`sketch --wrap-up` now dispatches correctly** — `/gsd-sketch --wrap-up` was silently no-oping because the flag dispatch wiring was omitted when the micro-skill entry point was absorbed in #2790. (#2949)
|
||||
- **`help.md` no longer advertises eight slash commands removed by the #2824 consolidation** — `/gsd-do`, `/gsd-note`, `/gsd-check-todos`, `/gsd-plant-seed`, `/gsd-research-phase`, `/gsd-list-phase-assumptions`, `/gsd-plan-milestone-gaps`, and `/gsd-join-discord` were removed when 86 skills were folded into 59. `help.md` was not updated alongside, so users typing the documented commands hit *Unknown command*. Each entry is now either rewritten to the surviving flag-based dispatcher (e.g., `/gsd-do …` → `/gsd-progress --do "…"`, `/gsd-note` → `/gsd-capture --note`, `/gsd-plant-seed` → `/gsd-capture --seed`, `/gsd-check-todos` → `/gsd-capture --list`) or removed for skills with no replacement. A regression test now asserts every `/gsd-*` reference in `help.md` has a matching `commands/gsd/*.md` stub. (#2954)
|
||||
- **`--sdk` install on Windows now writes a callable `gsd-sdk` shim** — `npx get-shit-done-cc@latest --claude --global --sdk` on Windows previously left `gsd-sdk` off PATH because `trySelfLinkGsdSdk` returned `null` unconditionally on `win32` (a missed gap from #2775's POSIX self-link, not an intentional deferral). The function now dispatches to a Windows counterpart that writes the standard npm shim triple (`gsd-sdk.cmd`, `gsd-sdk.ps1`, and a Bash wrapper) to npm's global bin, so `gsd-sdk` resolves in a fresh shell across cmd.exe, PowerShell, and Cygwin/MSYS/Git-Bash. A new regression guard in `tests/no-unconditional-win32-skip.test.cjs` blocks any future `if (process.platform === 'win32') return null;` skip-only branches in `bin/install.js`. (#2962)
|
||||
- **`/gsd-reapply-patches` Step 5 gate is now deterministic — no more silent content drops** — the prior gate parsed a Claude-generated *Hunk Verification Table* whose `verified: yes` rows were filled in without actually checking content presence, leading to merged files that lost user-added blocks (e.g., a `<visual_companion>` section, an `--execute-only` flag block) while the workflow reported success. The gate now invokes a Node script (`scripts/verify-reapply-patches.cjs`) that diffs each backup against the pristine baseline, computes the user-added significant lines, and asserts each one is present in the merged file. Exits non-zero with a per-file diagnostic on any miss; the workflow halts and surfaces the JSON output to the user. The verifier ignores low-signal lines (too short, pure whitespace, decorative comments) so trivial differences don't trigger false failures. Out of scope here: the manifest-baseline tightening described in #2969 Failure 1 — that's separate work. (#2969)
|
||||
|
||||
### Added — 1.40.0-rc.1
|
||||
- **Six namespace meta-skills with keyword-tag descriptions** — replace the flat 86-skill
|
||||
listing with two-stage hierarchical routing. Model sees 6 namespace routers
|
||||
@@ -26,6 +38,7 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
||||
RC. (#2833)
|
||||
|
||||
### Changed — 1.40.0-rc.1
|
||||
- **Hotfix release flow now auto-incorporates fixes from `main` and bundles the SDK** — `hotfix.yml create` auto-cherry-picks every `fix:`/`chore:` commit on `origin/main` not yet shipped (oldest-first; patch-equivalents skipped via `git cherry`; `feat:`/`refactor:` excluded; conflicts halt with the offending SHA; run summary lists every included SHA). `hotfix.yml finalize` adds the `install-smoke` cross-platform gate, bundles `sdk-bundle/gsd-sdk.tgz` inside the CC tarball (parity with `release-sdk.yml`), tightens the `next` dist-tag re-point, and marks the GitHub Release `--latest`. `release-sdk.yml` gains `action: publish | hotfix` plus an `auto_cherry_pick` toggle, with a new `prepare` job that branches `hotfix/X.YY.Z` from the highest existing `vX.YY.*` tag and runs the same cherry-pick logic — idempotent if the branch was pre-prepared via `hotfix.yml`. Hotfix `vX.YY.Z` is now defined as everything in `vX.YY.{Z-1}` plus every `fix:`/`chore:` since that base, so each tag is the cumulative-fix anchor for the next. (#2955)
|
||||
- **Planning workspace seam extracted from `core.cjs` into `planning-workspace.cjs`** — path/workstream/lock behavior now lives in a dedicated module (`planningDir`, `planningPaths`, `planningRoot`, active-workstream routing, `withPlanningLock`). `core.cjs` keeps compatibility re-exports while call-sites migrate to direct imports, improving locality and reducing coupling. (#2900)
|
||||
- **Skill surface consolidated 86 → 59 `commands/gsd/*.md` entries** — four new
|
||||
grouped skills (`capture`, `phase`, `config`, `workspace`) replace clusters of
|
||||
@@ -38,7 +51,13 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
||||
now auto-closes PRs opened without a closing keyword that links a tracking issue,
|
||||
posting a comment that points to the contribution guide. (#2872)
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Stale deleted command references updated across workflow files** — `help.md`, `do.md`, `settings.md`, `discuss-phase.md`, `new-project.md`, `plan-phase.md`, `spike.md`, and `sketch.md` referenced command names removed in #2790; updated to new consolidated equivalents. (#2950)
|
||||
|
||||
### Fixed — 1.40.0-rc.1
|
||||
- **`spike --wrap-up` now dispatches correctly** — `/gsd-spike --wrap-up` was silently no-oping because the flag dispatch wiring was omitted when the micro-skill entry point was absorbed in #2790. (#2948)
|
||||
- **`config-get context_window` returns `200000` when key absent** — querying an unset `context_window` previously exited 1 with "Key not found", surfacing a confusing error in planning logs even though the workflow fallback worked correctly. `cmdConfigGet` now consults a `SCHEMA_DEFAULTS` map and returns the documented default (`200000`, exit 0) for absent schema-defaulted keys; unknown absent keys still error as before. (#2943)
|
||||
- **`gap-analysis` now parses non-`REQ-` requirement IDs and ignores traceability table headers** — `parseRequirements()` no longer hard-codes the `REQ-` prefix and now accepts uppercase prefixed IDs such as `TST-01`, `BACK-07`, and `INSP-04`; markdown table header rows (for example `| REQ-ID | ... |`) are excluded so header tokens are not reported as phantom uncovered requirements. Added regression coverage for mixed-prefix REQUIREMENTS files with traceability tables. (#2897)
|
||||
- **Gemini slash commands namespaced as `/gsd:<cmd>` instead of `/gsd-<cmd>`** —
|
||||
Gemini CLI namespaces commands under `gsd:`, so `/gsd-plan-phase` was unexecutable.
|
||||
|
||||
@@ -281,6 +281,7 @@ Some tests legitimately read source files. There are six recognized categories:
|
||||
| `docs-parity` | A reference doc must stay in sync with source-defined constants (e.g., `CONFIG_DEFAULTS`). The source is the canonical list; there is no runtime API to enumerate it. |
|
||||
| `integration-test-input` | A source file is used as a real fixture input to a transformation function under test — the file is not inspected for strings but passed as data. |
|
||||
| `structural-implementation-guard` | A feature's interception or wiring point is not reachable end-to-end via `runGsdTools`. Used temporarily until a behavioral path exists. |
|
||||
| `pending-migration-to-typed-ir` | **Tracked for correction, not exempted.** Test was identified by the lint as carrying a raw-text-matching pattern that contradicts the rule above. Each annotated file MUST cite the open migration issue (e.g. `// allow-test-rule: pending-migration-to-typed-ir [#NNNN]`) so the tracking is auditable. New tests cannot use this category — they must refactor production to expose typed IR. The annotation is removed when the test is corrected. |
|
||||
|
||||
Annotate with a standalone `//` comment before the file's opening block comment:
|
||||
|
||||
@@ -296,6 +297,68 @@ Annotate with a standalone `//` comment before the file's opening block comment:
|
||||
|
||||
The annotation **must** be a standalone `// allow-test-rule:` line, not inside a `/** */` block comment — the CI linter scans for the pattern `// allow-test-rule:`.
|
||||
|
||||
### Prohibited: Raw Text Matching on Test Outputs (file content, stdout, stderr)
|
||||
|
||||
**Source-grep is not just `readFileSync` of a `.cjs` file.** The same anti-pattern shows up wherever a test pattern-matches against text that a system-under-test produced, regardless of whether that text came from a source file, a rendered shim, a child process's stdout, or a free-form `reason` string. **All forms are forbidden.**
|
||||
|
||||
The following are all violations of the same rule:
|
||||
|
||||
```javascript
|
||||
// BAD — substring match on text written by the code under test
|
||||
const cmdContent = fs.readFileSync(path.join(tmpDir, 'gsd-sdk.cmd'), 'utf8');
|
||||
assert.ok(cmdContent.includes(`@node ${jsonQuoted} %*`), '.cmd embeds shim path');
|
||||
|
||||
// BAD — regex match on a child process's human-readable stdout formatter
|
||||
const r = cp.spawnSync(SCRIPT, ['--patches-dir', dir]);
|
||||
assert.match(r.stdout, /Failures: 1/);
|
||||
assert.match(r.stdout, /not a regular file/);
|
||||
|
||||
// BAD — "structured parser" that hides string ops behind a function wrapper
|
||||
function parseCmdShim(content) {
|
||||
const lines = content.split('\r\n').filter((l) => l.length > 0);
|
||||
return { header: lines[0], usesCRLF: content.includes('\r\n') };
|
||||
}
|
||||
|
||||
// BAD — assert.match on a free-form `reason` string from a JSON report
|
||||
assert.ok(/not a regular file/.test(report.results[0].reason));
|
||||
```
|
||||
|
||||
Each of these passes on accidental near-matches (a comment containing `@node` somewhere, a stack trace that happens to say `Failures: 1`, a mis-typed reason that still contains the substring you're matching) and fails on harmless reformatting (changing `Failures: 1` to `1 failure`, swapping CRLF rendering style, rewording the error prose).
|
||||
|
||||
#### The rule
|
||||
|
||||
> **Tests assert on typed structured values. If the code under test produces text, the code under test must also expose a structured intermediate representation, and the test must assert on that IR — never on the rendered text.**
|
||||
|
||||
Concretely: for any system-under-test that produces text output (a file renderer, a CLI formatter, an error-message builder), the production code MUST expose a typed alternative that the test consumes:
|
||||
|
||||
| Output kind | Required structured surface | What the test asserts on |
|
||||
|---|---|---|
|
||||
| Rendered file (shim, template, generated code) | A pure builder function returning the IR (`{ invocation, eol, fileNames, render }`) | `triple.invocation.target === expected`, `triple.eol.cmd === '\r\n'` |
|
||||
| CLI human-formatter output | A `--json` mode that emits the same data structurally | `report.results[0].reason === REASON.FAIL_INSTALLED_NOT_REGULAR_FILE` |
|
||||
| Error / status / reason | A frozen enum (`Object.freeze({ FAIL_X: 'fail_x', ... })`) | `assert.equal(result.reason, REASON.FAIL_X)` |
|
||||
| File presence after a write | `fs.statSync().isFile()`, `.size > 0`, `.mtimeMs` advances | Filesystem facts; never read the file content back |
|
||||
|
||||
#### Concrete examples from this repo
|
||||
|
||||
`buildWindowsShimTriple(shimSrc)` in `bin/install.js` is the canonical IR pattern: pure function, no I/O, returns `{ invocation, eol, fileNames, render }`. `trySelfLinkGsdSdkWindows` calls it and writes `triple.render[kind]()` to disk. Tests assert on `triple.invocation.target`, `triple.eol.cmd`, `Object.keys(triple).sort()` — never on the rendered text. Filesystem-level tests assert `fs.statSync(target).size === Buffer.byteLength(triple.render.cmd())` to prove the writer writes what the renderer produces, **without comparing content**.
|
||||
|
||||
`scripts/verify-reapply-patches.cjs` exposes a frozen `REASON` enum and emits it through `--json`. Tests assert `report.results[0].reason === REASON.FAIL_USER_LINES_MISSING`. The human formatter exists for operator console output only — tests must not depend on its prose. Adding a new reason code requires updating the `REASON` enum, the `--json` output, AND the test that locks `Object.keys(REASON).sort()` — three coordinated changes that prevent the code surface from drifting from the test surface.
|
||||
|
||||
#### Hiding grep behind a function is still grep
|
||||
|
||||
`parseCmdShim`, `parsePs1Invocation`, etc. that internally do `content.split(...)`, `lines[1].trim()`, `content.includes(...)` are still string manipulation. The fact that the entry point looks like a parser doesn't change what's happening underneath — the test is still asserting on the lexical shape of rendered text. The fix is not "wrap the grep in a function with a typed-looking return value." The fix is to **eliminate the rendered text from the test path entirely** by surfacing the IR.
|
||||
|
||||
#### When you cannot eliminate text matching
|
||||
|
||||
There are exactly two cases where text content is the legitimate object of a test, both already covered by the existing exemption matrix:
|
||||
|
||||
1. `source-text-is-the-product` — workflow `.md` / agent `.md` / command `.md` files where the deployed text IS what the runtime loads.
|
||||
2. `docs-parity` — a reference doc must mirror source-defined constants and there is no runtime enumeration API.
|
||||
|
||||
For everything else, if a test reaches for `.includes()` / `.startsWith()` / `assert.match(text, /…/)`, the production code is missing a typed surface. **Add the typed surface; do not work around it.**
|
||||
|
||||
**CI enforcement:** `scripts/lint-no-source-grep.cjs` is being extended (see issue tracker for the latest scope) to flag `String#includes`/`String#startsWith`/`String#endsWith`/`assert.match` on `readFileSync` results and on `cp.spawnSync` stdout/stderr in test files, with the same `// allow-test-rule:` exemption mechanism.
|
||||
|
||||
### Node.js Version Compatibility
|
||||
|
||||
**Node 22 is the minimum supported version.** Node 24 is the primary CI target. All tests must pass on both.
|
||||
|
||||
@@ -75,15 +75,17 @@ GSDはそれを解決します。Claude Codeを信頼性の高いものにする
|
||||
|
||||
ビルトインの品質ゲートが本当の問題を検出します:スキーマドリフト検出はマイグレーション漏れのORM変更をフラグし、セキュリティ強制は検証を脅威モデルに紐付け、スコープ削減検出はプランナーが要件を暗黙的に落とすのを防止します。
|
||||
|
||||
### v1.32.0 ハイライト
|
||||
### v1.39.0 ハイライト
|
||||
|
||||
- **STATE.md整合性ゲート** — `state validate`がSTATE.mdとファイルシステムの差分を検出、`state sync`が実際のプロジェクト状態から再構築
|
||||
- **`--to N`フラグ** — 自律実行を特定のフェーズ完了後に停止
|
||||
- **リサーチゲート** — RESEARCH.mdに未解決の質問がある場合、計画をブロック
|
||||
- **検証マイルストーンスコープフィルタリング** — 後のフェーズで対処されるギャップは「ギャップ」ではなく「延期」としてマーク
|
||||
- **読み取り後編集ガード** — 非Claudeランタイムでの無限リトライループを防止するアドバイザリーフック
|
||||
- **コンテキスト削減** — Markdownのトランケーションとキャッシュフレンドリーなプロンプト順序でトークン使用量を削減
|
||||
- **4つの新ランタイム** — Trae、Kilo、Augment、Cline(合計12ランタイム)
|
||||
完全なリストは [v1.39.0 リリースノート](https://github.com/gsd-build/get-shit-done/releases/tag/v1.39.0) を参照してください。
|
||||
|
||||
- **`--minimal` インストールプロファイル** — エイリアス `--core-only`。メインループの6スキル(`new-project`、`discuss-phase`、`plan-phase`、`execute-phase`、`help`、`update`)のみをインストールし、`gsd-*` サブエージェントはゼロ。コールドスタート時のシステムプロンプトのオーバーヘッドを ~12kトークンから ~700トークンへ削減(≥94%減)。32K〜128Kコンテキストのローカル LLM やトークン課金 API に有効。
|
||||
- **`/gsd-edit-phase`** — `ROADMAP.md` 上の既存フェーズの任意フィールドをその場で編集(番号や位置は変更されない)。`--force` で確認 diff をスキップ、`depends_on` の参照を検証し、書き込み時に `STATE.md` も更新。
|
||||
- **マージ後ビルド & テストゲート** — `execute-phase` のステップ 5.6 が `workflow.build_command` の設定を自動検出し、無ければ Xcode(`.xcodeproj`)、Makefile、Justfile、Cargo、Go、Python、npm の順にフォールバック。Xcode/iOS プロジェクトでは `xcodebuild build` と `xcodebuild test` を自動実行。並列・直列両モードで動作。
|
||||
- **ランタイム別レビューモデル選択** — `review.models.<cli>` で各外部レビュー CLI(codex、gemini など)が使うモデルをプランナー/実行プロファイルとは独立に指定可能。
|
||||
- **ワークストリーム設定の継承** — `GSD_WORKSTREAM` が設定されている場合、ルートの `.planning/config.json` を先に読み込み、ワークストリーム設定をディープマージ(衝突時はワークストリーム側が優先)。ワークストリーム設定で明示的に `null` を指定するとルート値を上書き可能。
|
||||
- **手動カナリアリリースワークフロー** — `.github/workflows/canary.yml` が `workflow_dispatch` 経由で `dev` ブランチから `{base}-canary.{N}` ビルドを `@canary` dist-tag に手動公開(`get-shit-done-cc` と `@gsd-build/sdk`)。
|
||||
- **スキルの統合:86 → 59** — 4つの新しいグループ化スキル(`capture`、`phase`、`config`、`workspace`)が31のマイクロスキルを吸収。既存の親スキル6つはラップアップやサブ操作をフラグ化:`update --sync/--reapply`、`sketch --wrap-up`、`spike --wrap-up`、`map-codebase --fast/--query`、`code-review --fix`、`progress --do/--next`。機能の欠損なし。
|
||||
|
||||
---
|
||||
|
||||
@@ -597,6 +599,7 @@ lmn012o feat(08-02): create registration endpoint
|
||||
|---------|--------------|
|
||||
| `/gsd-add-phase` | ロードマップにフェーズを追加 |
|
||||
| `/gsd-insert-phase [N]` | フェーズ間に緊急作業を挿入 |
|
||||
| `/gsd-edit-phase [N] [--force]` | 既存フェーズの任意フィールドをその場で編集 — 番号と位置は変更されない |
|
||||
| `/gsd-remove-phase [N]` | 将来のフェーズを削除し番号を振り直し |
|
||||
| `/gsd-list-phase-assumptions [N]` | 計画前にClaudeの意図するアプローチを確認 |
|
||||
| `/gsd-plan-milestone-gaps` | 監査で見つかったギャップを埋めるフェーズを作成 |
|
||||
|
||||
@@ -75,15 +75,17 @@ GSD가 그걸 고칩니다. Claude Code를 신뢰할 수 있게 만드는 컨텍
|
||||
|
||||
내장 품질 게이트가 실제 문제를 잡아냅니다: 스키마 드리프트 감지는 마이그레이션 누락된 ORM 변경을 플래그하고, 보안 강제는 검증을 위협 모델에 고정시키고, 스코프 축소 감지는 플래너가 요구사항을 몰래 빠뜨리는 걸 방지합니다.
|
||||
|
||||
### v1.32.0 하이라이트
|
||||
### v1.39.0 하이라이트
|
||||
|
||||
- **STATE.md 일관성 게이트** — `state validate`가 STATE.md와 파일시스템 간 드리프트를 감지, `state sync`가 실제 프로젝트 상태에서 재구성
|
||||
- **`--to N` 플래그** — 자율 실행을 특정 단계 완료 후 중지
|
||||
- **리서치 게이트** — RESEARCH.md에 미해결 질문이 있으면 기획을 차단
|
||||
- **검증 마일스톤 스코프 필터링** — 이후 단계에서 처리될 격차는 "격차"가 아닌 "지연됨"으로 표시
|
||||
- **읽기-후-편집 가드** — 비Claude 런타임에서 무한 재시도 루프를 방지하는 어드바이저리 훅
|
||||
- **컨텍스트 축소** — 마크다운 잘라내기 및 캐시 친화적 프롬프트 순서로 토큰 사용량 절감
|
||||
- **4개의 새 런타임** — Trae, Kilo, Augment, Cline (총 12개 런타임)
|
||||
전체 목록은 [v1.39.0 릴리스 노트](https://github.com/gsd-build/get-shit-done/releases/tag/v1.39.0)를 참고하세요.
|
||||
|
||||
- **`--minimal` 설치 프로파일** — 별칭 `--core-only`. 메인 루프 6개 스킬(`new-project`, `discuss-phase`, `plan-phase`, `execute-phase`, `help`, `update`)만 설치하고 `gsd-*` 서브에이전트는 설치하지 않음. 콜드 스타트 시스템 프롬프트 오버헤드를 ~12k 토큰에서 ~700 토큰으로 축소(≥94% 감소). 32K–128K 컨텍스트의 로컬 LLM이나 토큰 과금 API에 유용.
|
||||
- **`/gsd-edit-phase`** — `ROADMAP.md`에 있는 기존 단계의 임의 필드를 그 자리에서 수정(번호와 위치는 변경되지 않음). `--force`는 확인 diff를 건너뛰고, `depends_on` 참조를 검증하며 쓰기 시 `STATE.md`도 갱신.
|
||||
- **머지 후 빌드 & 테스트 게이트** — `execute-phase` 5.6 단계가 `workflow.build_command` 설정을 우선 자동 감지하고, 없으면 Xcode(`.xcodeproj`), Makefile, Justfile, Cargo, Go, Python, npm 순으로 폴백. Xcode/iOS 프로젝트는 `xcodebuild build` 및 `xcodebuild test`를 자동 실행. 병렬·직렬 모드 모두에서 동작.
|
||||
- **런타임별 리뷰 모델 선택** — `review.models.<cli>`로 각 외부 리뷰 CLI(codex, gemini 등)가 플래너/실행 프로파일과 독립적으로 자체 모델을 선택할 수 있음.
|
||||
- **워크스트림 설정 상속** — `GSD_WORKSTREAM`이 설정되면 루트 `.planning/config.json`을 먼저 로드한 뒤 워크스트림 설정을 딥 머지(충돌 시 워크스트림 우선). 워크스트림 설정에서 명시적 `null`은 루트 값을 덮어씀.
|
||||
- **수동 카나리 릴리스 워크플로** — `.github/workflows/canary.yml`이 `workflow_dispatch`로 `dev` 브랜치에서 `{base}-canary.{N}` 빌드를 `@canary` dist-tag로 수동 게시(`get-shit-done-cc`와 `@gsd-build/sdk`).
|
||||
- **스킬 통합: 86 → 59** — 4개의 새로운 그룹 스킬(`capture`, `phase`, `config`, `workspace`)이 31개의 마이크로 스킬을 흡수. 기존 6개의 부모 스킬은 래퍼업/하위 동작을 플래그로 흡수: `update --sync/--reapply`, `sketch --wrap-up`, `spike --wrap-up`, `map-codebase --fast/--query`, `code-review --fix`, `progress --do/--next`. 기능 손실 없음.
|
||||
|
||||
---
|
||||
|
||||
@@ -594,6 +596,7 @@ lmn012o feat(08-02): create registration endpoint
|
||||
|---------|------------|
|
||||
| `/gsd-add-phase` | 로드맵에 단계 추가 |
|
||||
| `/gsd-insert-phase [N]` | 단계 사이에 긴급 작업 삽입 |
|
||||
| `/gsd-edit-phase [N] [--force]` | 기존 단계의 임의 필드를 그 자리에서 수정 — 번호와 위치는 그대로 |
|
||||
| `/gsd-remove-phase [N]` | 미래 단계 제거, 번호 재정렬 |
|
||||
| `/gsd-list-phase-assumptions [N]` | 기획 전 Claude의 의도된 접근 방식 확인 |
|
||||
| `/gsd-plan-milestone-gaps` | 감사에서 발견된 갭을 해소하기 위한 단계 생성 |
|
||||
|
||||
34
README.md
34
README.md
@@ -4,7 +4,7 @@
|
||||
|
||||
**English** · [Português](README.pt-BR.md) · [简体中文](README.zh-CN.md) · [日本語](README.ja-JP.md) · [한국어](README.ko-KR.md)
|
||||
|
||||
**A light-weight and powerful meta-prompting, context engineering and spec-driven development system for Claude Code, OpenCode, Gemini CLI, Kilo, Codex, Copilot, Cursor, Windsurf, Antigravity, Augment, Trae, Qwen Code, Cline, and CodeBuddy.**
|
||||
**A light-weight and powerful meta-prompting, context engineering and spec-driven development system for Claude Code, OpenCode, Gemini CLI, Kilo, Codex, Copilot, Cursor, Windsurf, Antigravity, Augment, Trae, Qwen Code, Hermes Agent, Cline, and CodeBuddy.**
|
||||
|
||||
**Solves context rot — the quality degradation that happens as Claude fills its context window.**
|
||||
|
||||
@@ -89,11 +89,17 @@ People who want to describe what they want and have it built correctly — witho
|
||||
|
||||
Built-in quality gates catch real problems: schema drift detection flags ORM changes missing migrations, security enforcement anchors verification to threat models, and scope reduction detection prevents the planner from silently dropping your requirements.
|
||||
|
||||
### v1.37.0 Highlights
|
||||
### v1.39.0 Highlights
|
||||
|
||||
- **Spiking & sketching** — `/gsd-spike` runs 2–5 focused experiments with Given/When/Then verdicts; `/gsd-sketch` produces 2–3 interactive HTML mockup variants per design question — both store artifacts in `.planning/` and pair with wrap-up commands to package findings into project-local skills
|
||||
- **Agent size-budget enforcement** — Tiered line-count limits (XL: 1 600, Large: 1 000, Default: 500) keep agent prompts lean; violations surface in CI
|
||||
- **Shared boilerplate extraction** — Mandatory-initial-read and project-skills-discovery logic extracted to reference files, reducing duplication across a dozen agents
|
||||
See the [v1.39.0 release notes](https://github.com/gsd-build/get-shit-done/releases/tag/v1.39.0) for the full list.
|
||||
|
||||
- **`--minimal` install profile** — alias `--core-only`, writes only the six main-loop skills (`new-project`, `discuss-phase`, `plan-phase`, `execute-phase`, `help`, `update`) and zero `gsd-*` subagents. Cuts cold-start system-prompt overhead from ~12k tokens to ~700 (≥94% reduction). Useful for local LLMs with 32K–128K context and token-billed APIs.
|
||||
- **`/gsd-edit-phase`** — modify any field of an existing phase in `ROADMAP.md` in place, without changing its number or position. `--force` skips the confirmation diff; `depends_on` references are validated and `STATE.md` is updated on write.
|
||||
- **Post-merge build & test gate** — `execute-phase` step 5.6 now auto-detects the build command from `workflow.build_command`, then falls back to Xcode (`.xcodeproj`), Makefile, Justfile, Cargo, Go, Python, or npm. Xcode/iOS projects get `xcodebuild build` + `xcodebuild test` automatically. Runs in both parallel and serial mode.
|
||||
- **Per-runtime review-model selection** — `review.models.<cli>` lets each external review CLI (codex, gemini, etc.) pick its own model independently of the planner/executor profile.
|
||||
- **Workstream config inheritance** — when `GSD_WORKSTREAM` is set, the root `.planning/config.json` is loaded first and deep-merged with the workstream config (workstream wins on conflict). Explicit `null` in a workstream config now correctly overrides a root value.
|
||||
- **Manual canary release workflow** — `.github/workflows/canary.yml` publishes `{base}-canary.{N}` builds of `get-shit-done-cc` and `@gsd-build/sdk` to the `@canary` dist-tag from `dev` on demand via `workflow_dispatch`.
|
||||
- **Skill consolidation: 86 → 59** — four new grouped skills (`capture`, `phase`, `config`, `workspace`) absorb 31 micro-skills. Six existing parents absorb wrap-up and sub-operations as flags: `update --sync/--reapply`, `sketch --wrap-up`, `spike --wrap-up`, `map-codebase --fast/--query`, `code-review --fix`, `progress --do/--next`. Zero functional loss.
|
||||
|
||||
---
|
||||
|
||||
@@ -104,11 +110,11 @@ npx get-shit-done-cc@latest
|
||||
```
|
||||
|
||||
The installer prompts you to choose:
|
||||
1. **Runtime** — Claude Code, OpenCode, Gemini, Kilo, Codex, Copilot, Cursor, Windsurf, Antigravity, Augment, Trae, Qwen Code, CodeBuddy, Cline, or all (interactive multi-select — pick multiple runtimes in a single install session)
|
||||
1. **Runtime** — Claude Code, OpenCode, Gemini, Kilo, Codex, Copilot, Cursor, Windsurf, Antigravity, Augment, Trae, Qwen Code, Hermes Agent, CodeBuddy, Cline, or all (interactive multi-select — pick multiple runtimes in a single install session)
|
||||
2. **Location** — Global (all projects) or local (current project only)
|
||||
|
||||
Verify with:
|
||||
- Claude Code / Gemini / Copilot / Antigravity / Qwen Code: `/gsd-help`
|
||||
- Claude Code / Gemini / Copilot / Antigravity / Qwen Code / Hermes Agent: `/gsd-help`
|
||||
- OpenCode / Kilo / Augment / Trae / CodeBuddy: `/gsd-help`
|
||||
- Codex: `$gsd-help`
|
||||
- Cline: GSD installs via `.clinerules` — verify by checking `.clinerules` exists
|
||||
@@ -179,6 +185,10 @@ npx get-shit-done-cc --trae --local # Install to ./.trae/
|
||||
npx get-shit-done-cc --qwen --global # Install to ~/.qwen/
|
||||
npx get-shit-done-cc --qwen --local # Install to ./.qwen/
|
||||
|
||||
# Hermes Agent
|
||||
npx get-shit-done-cc --hermes --global # Install to ~/.hermes/ (honors $HERMES_HOME)
|
||||
npx get-shit-done-cc --hermes --local # Install to ./.hermes/
|
||||
|
||||
# CodeBuddy
|
||||
npx get-shit-done-cc --codebuddy --global # Install to ~/.codebuddy/
|
||||
npx get-shit-done-cc --codebuddy --local # Install to ./.codebuddy/
|
||||
@@ -192,7 +202,7 @@ npx get-shit-done-cc --all --global # Install to all directories
|
||||
```
|
||||
|
||||
Use `--global` (`-g`) or `--local` (`-l`) to skip the location prompt.
|
||||
Use `--claude`, `--opencode`, `--gemini`, `--kilo`, `--codex`, `--copilot`, `--cursor`, `--windsurf`, `--antigravity`, `--augment`, `--trae`, `--qwen`, `--codebuddy`, `--cline`, or `--all` to skip the runtime prompt.
|
||||
Use `--claude`, `--opencode`, `--gemini`, `--kilo`, `--codex`, `--copilot`, `--cursor`, `--windsurf`, `--antigravity`, `--augment`, `--trae`, `--qwen`, `--hermes`, `--codebuddy`, `--cline`, or `--all` to skip the runtime prompt.
|
||||
The GSD SDK CLI (`gsd-sdk`) is installed automatically (required by `/gsd-*` commands). Pass `--no-sdk` to skip the SDK install, or `--sdk` to force a reinstall.
|
||||
|
||||
</details>
|
||||
@@ -685,6 +695,7 @@ You're never locked in. The system adapts.
|
||||
|---------|--------------|
|
||||
| `/gsd-add-phase` | Append phase to roadmap |
|
||||
| `/gsd-insert-phase [N]` | Insert urgent work between phases |
|
||||
| `/gsd-edit-phase [N] [--force]` | Modify any field of an existing phase in place — number and position unchanged |
|
||||
| `/gsd-remove-phase [N]` | Remove future phase, renumber |
|
||||
| `/gsd-list-phase-assumptions [N]` | See Claude's intended approach before planning |
|
||||
| `/gsd-plan-milestone-gaps` | Create phases to close gaps from audit |
|
||||
@@ -746,6 +757,8 @@ You're never locked in. The system adapts.
|
||||
|
||||
GSD stores project settings in `.planning/config.json`. Configure during `/gsd-new-project` or update later with `/gsd-settings`. For the full config schema, workflow toggles, git branching options, and per-agent model breakdown, see the [User Guide](docs/USER-GUIDE.md#configuration-reference).
|
||||
|
||||
When `GSD_WORKSTREAM` is set, GSD loads the root `.planning/config.json` first and deep-merges the workstream's `config.json` on top — workstream values win on conflict, and an explicit `null` in a workstream config overrides a root value.
|
||||
|
||||
### Core Settings
|
||||
|
||||
| Setting | Options | Default | What it controls |
|
||||
@@ -774,6 +787,8 @@ Use `inherit` when using non-Anthropic providers (OpenRouter, local models) or t
|
||||
|
||||
Or configure via `/gsd-settings`.
|
||||
|
||||
Per-runtime review-model overrides live under `review.models.<cli>` (e.g. `review.models.codex`, `review.models.gemini`) and let each external review CLI pick its own model independently of the planner/executor profile.
|
||||
|
||||
### Workflow Agents
|
||||
|
||||
These spawn additional agents during planning/execution. They improve quality but add tokens and time.
|
||||
@@ -789,6 +804,7 @@ These spawn additional agents during planning/execution. They improve quality bu
|
||||
| `workflow.skip_discuss` | `false` | Skip discuss-phase in autonomous mode |
|
||||
| `workflow.text_mode` | `false` | Text-only mode for remote sessions (no TUI menus) |
|
||||
| `workflow.use_worktrees` | `true` | Toggle worktree isolation for execution |
|
||||
| `workflow.build_command` | _(auto-detect)_ | Override the post-merge build gate command. Falls back to Xcode (`.xcodeproj`), Makefile, Justfile, Cargo, Go, Python, or npm; Xcode/iOS projects also run `xcodebuild test`. |
|
||||
|
||||
Use `/gsd-settings` to toggle these, or override per-invocation:
|
||||
- `/gsd-plan-phase --skip-research`
|
||||
@@ -919,6 +935,7 @@ npx get-shit-done-cc --antigravity --global --uninstall
|
||||
npx get-shit-done-cc --augment --global --uninstall
|
||||
npx get-shit-done-cc --trae --global --uninstall
|
||||
npx get-shit-done-cc --qwen --global --uninstall
|
||||
npx get-shit-done-cc --hermes --global --uninstall
|
||||
npx get-shit-done-cc --codebuddy --global --uninstall
|
||||
npx get-shit-done-cc --cline --global --uninstall
|
||||
|
||||
@@ -935,6 +952,7 @@ npx get-shit-done-cc --antigravity --local --uninstall
|
||||
npx get-shit-done-cc --augment --local --uninstall
|
||||
npx get-shit-done-cc --trae --local --uninstall
|
||||
npx get-shit-done-cc --qwen --local --uninstall
|
||||
npx get-shit-done-cc --hermes --local --uninstall
|
||||
npx get-shit-done-cc --codebuddy --local --uninstall
|
||||
npx get-shit-done-cc --cline --local --uninstall
|
||||
```
|
||||
|
||||
@@ -73,15 +73,17 @@ Para quem quer descrever o que precisa e receber isso construído do jeito certo
|
||||
|
||||
Quality gates embutidos capturam problemas reais: detecção de schema drift sinaliza mudanças ORM sem migrations, segurança ancora verificação a modelos de ameaça, e detecção de redução de escopo impede o planner de descartar requisitos silenciosamente.
|
||||
|
||||
### Destaques v1.32.0
|
||||
### Destaques v1.39.0
|
||||
|
||||
- **Gates de consistência STATE.md** — `state validate` detecta divergência entre STATE.md e o filesystem; `state sync` reconstrói a partir do estado real do projeto
|
||||
- **Flag `--to N`** — Para a execução autônoma após completar uma fase específica
|
||||
- **Research gate** — Bloqueia planejamento quando RESEARCH.md tem perguntas abertas não resolvidas
|
||||
- **Filtro de escopo do verificador** — Lacunas abordadas em fases posteriores são marcadas como "adiadas", não como lacunas
|
||||
- **Guard de leitura antes de edição** — Hook consultivo previne loops de retry infinitos em runtimes não-Claude
|
||||
- **Redução de contexto** — Truncamento de Markdown e ordenação de prompts cache-friendly para menor uso de tokens
|
||||
- **4 novos runtimes** — Trae, Kilo, Augment e Cline (12 runtimes no total)
|
||||
Lista completa nas [notas de release v1.39.0](https://github.com/gsd-build/get-shit-done/releases/tag/v1.39.0).
|
||||
|
||||
- **Perfil de instalação `--minimal`** — alias `--core-only`. Instala apenas os 6 skills do loop principal (`new-project`, `discuss-phase`, `plan-phase`, `execute-phase`, `help`, `update`) e nenhum subagente `gsd-*`. Reduz o overhead do system prompt no cold-start de ~12k para ~700 tokens (≥94% de redução). Útil para LLMs locais com contexto de 32K–128K e APIs cobradas por token.
|
||||
- **`/gsd-edit-phase`** — edita qualquer campo de uma fase existente em `ROADMAP.md` no lugar, sem alterar o número ou a posição. `--force` pula o diff de confirmação; referências em `depends_on` são validadas e o `STATE.md` é atualizado na escrita.
|
||||
- **Build & test gate pós-merge** — o passo 5.6 de `execute-phase` agora detecta automaticamente o comando de build em `workflow.build_command`, com fallback para Xcode (`.xcodeproj`), Makefile, Justfile, Cargo, Go, Python ou npm. Projetos Xcode/iOS rodam `xcodebuild build` e `xcodebuild test` automaticamente. Funciona em modo paralelo e serial.
|
||||
- **Modelo de review por runtime** — `review.models.<cli>` permite que cada CLI externa de review (codex, gemini, etc.) escolha seu próprio modelo, independente do perfil de planner/executor.
|
||||
- **Herança de configuração de workstream** — quando `GSD_WORKSTREAM` está definido, o `.planning/config.json` raiz é carregado primeiro e merge-deep com o config da workstream (workstream vence em conflito). Um `null` explícito no config da workstream sobrescreve corretamente o valor raiz.
|
||||
- **Workflow manual de canary release** — `.github/workflows/canary.yml` publica builds `{base}-canary.{N}` de `get-shit-done-cc` e `@gsd-build/sdk` na dist-tag `@canary` a partir de `dev`, sob demanda via `workflow_dispatch`.
|
||||
- **Consolidação de skills: 86 → 59** — 4 novos skills agrupados (`capture`, `phase`, `config`, `workspace`) absorvem 31 micro-skills. 6 skills pais existentes absorvem wrap-up e sub-operações como flags: `update --sync/--reapply`, `sketch --wrap-up`, `spike --wrap-up`, `map-codebase --fast/--query`, `code-review --fix`, `progress --do/--next`. Sem perda funcional.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -73,15 +73,17 @@ GSD 解决的就是这个问题。它是让 Claude Code 变得可靠的上下文
|
||||
|
||||
适合那些想把自己的需求说明白,然后让系统正确构建出来的人,而不是假装自己在运营一个 50 人工程组织的人。
|
||||
|
||||
### v1.32.0 亮点
|
||||
### v1.39.0 亮点
|
||||
|
||||
- **STATE.md 一致性检查** — `state validate` 检测 STATE.md 与文件系统之间的偏差;`state sync` 从实际项目状态重建
|
||||
- **`--to N` 标志** — 在完成特定阶段后停止自主执行
|
||||
- **研究门控** — 当 RESEARCH.md 有未解决的开放问题时阻止规划
|
||||
- **验证里程碑范围过滤** — 后续阶段将处理的差距标记为"延迟"而非差距
|
||||
- **读取后编辑保护** — 咨询性 hook 防止非 Claude 运行时的无限重试循环
|
||||
- **上下文缩减** — Markdown 截断和缓存友好的 prompt 排序,降低 token 使用量
|
||||
- **4 个新运行时** — Trae、Kilo、Augment 和 Cline(共 12 个运行时)
|
||||
完整列表请参阅 [v1.39.0 发行说明](https://github.com/gsd-build/get-shit-done/releases/tag/v1.39.0)。
|
||||
|
||||
- **`--minimal` 安装档** — 别名 `--core-only`。仅安装主循环的 6 个核心技能(`new-project`、`discuss-phase`、`plan-phase`、`execute-phase`、`help`、`update`),不安装任何 `gsd-*` 子代理。将冷启动系统提示开销从 ~12k token 降至 ~700 token(≥94% 减少)。适合 32K–128K 上下文的本地 LLM 和按 token 计费的 API。
|
||||
- **`/gsd-edit-phase`** — 就地修改 `ROADMAP.md` 中已有阶段的任意字段,不改变其编号或位置。`--force` 跳过确认 diff,验证 `depends_on` 引用,并在写入时更新 `STATE.md`。
|
||||
- **合并后构建与测试门** — `execute-phase` 步骤 5.6 优先自动检测 `workflow.build_command` 配置,否则按 Xcode(`.xcodeproj`)、Makefile、Justfile、Cargo、Go、Python、npm 顺序回退。Xcode/iOS 项目自动运行 `xcodebuild build` 和 `xcodebuild test`。在并行与串行模式下均生效。
|
||||
- **每运行时评审模型选择** — `review.models.<cli>` 让每个外部评审 CLI(codex、gemini 等)独立于规划/执行档选择自己的模型。
|
||||
- **工作流设置继承** — 设置 `GSD_WORKSTREAM` 后,先加载根 `.planning/config.json`,再与该工作流的配置进行深合并(冲突时工作流优先)。工作流配置中显式 `null` 会覆盖根值。
|
||||
- **手动 canary 发布工作流** — `.github/workflows/canary.yml` 通过 `workflow_dispatch` 从 `dev` 分支按需将 `{base}-canary.{N}` 构建(`get-shit-done-cc` 与 `@gsd-build/sdk`)发布到 `@canary` dist-tag。
|
||||
- **技能整合:86 → 59** — 4 个新分组技能(`capture`、`phase`、`config`、`workspace`)吸收了 31 个微技能。6 个已有父技能将收尾与子操作合并为标志:`update --sync/--reapply`、`sketch --wrap-up`、`spike --wrap-up`、`map-codebase --fast/--query`、`code-review --fix`、`progress --do/--next`。功能无损失。
|
||||
|
||||
---
|
||||
|
||||
@@ -589,6 +591,7 @@ lmn012o feat(08-02): create registration endpoint
|
||||
|------|------|
|
||||
| `/gsd-add-phase` | 在路线图末尾追加 phase |
|
||||
| `/gsd-insert-phase [N]` | 在 phase 之间插入紧急工作 |
|
||||
| `/gsd-edit-phase [N] [--force]` | 就地修改已有 phase 的任意字段 — 编号与位置保持不变 |
|
||||
| `/gsd-remove-phase [N]` | 删除未来 phase,并重编号 |
|
||||
| `/gsd-list-phase-assumptions [N]` | 在规划前查看 Claude 打算采用的方案 |
|
||||
| `/gsd-plan-milestone-gaps` | 为 audit 发现的缺口创建 phase |
|
||||
|
||||
@@ -67,15 +67,38 @@ main ← stable, always deployable
|
||||
|
||||
### Patch Release (Hotfix)
|
||||
|
||||
For critical bugs that can't wait for the next minor release.
|
||||
For fixes that need to ship without waiting for the next minor.
|
||||
|
||||
1. Trigger `hotfix.yml` with version (e.g., `1.27.1`)
|
||||
2. Workflow creates `hotfix/1.27.1` branch from the latest patch tag for that minor version (e.g., `v1.27.0` or `v1.27.1`)
|
||||
3. Cherry-pick or apply fix on the hotfix branch
|
||||
4. Push — CI runs tests automatically
|
||||
5. Trigger `hotfix.yml` finalize action
|
||||
6. Workflow runs full test suite, bumps version, tags, publishes to `latest`
|
||||
7. Merge hotfix branch back to main
|
||||
A hotfix `vX.YY.Z` cumulatively includes everything in `vX.YY.{Z-1}` plus every `fix:`/`chore:` commit landed on `main` since that base. The base tag is the anchor — `git cherry $BASE_TAG main` reveals exactly which commits are still unshipped, and the new `vX.YY.Z` tag becomes the next hotfix's base, so the cycle is self-documenting.
|
||||
|
||||
#### Two paths
|
||||
|
||||
**Path A — `hotfix.yml` (canonical, two-step):**
|
||||
|
||||
1. Trigger `hotfix.yml` with `action=create`, `version=1.27.1`, `auto_cherry_pick=true` (default).
|
||||
- Workflow detects `BASE_TAG` = highest `v1.27.*` < `v1.27.1` (so `1.27.1` branches from `v1.27.0`; `1.27.2` would branch from `v1.27.1`).
|
||||
- Branches `hotfix/1.27.1` from `BASE_TAG`.
|
||||
- Auto-cherry-picks every `fix:`/`chore:` commit on `origin/main` not already in the base, oldest-first. Patch-equivalents are skipped via `git cherry`. `feat:`/`refactor:` are **never** auto-included.
|
||||
- On conflict the workflow halts with the offending SHA. Resolve manually on the branch, then re-run finalize with `auto_cherry_pick=false`.
|
||||
- Bumps `package.json` (and `sdk/package.json`), pushes the branch, and lists every included SHA in the run summary.
|
||||
2. (Optional) push additional manual commits to `hotfix/1.27.1`.
|
||||
3. Trigger `hotfix.yml` with `action=finalize`. The workflow:
|
||||
- Runs `install-smoke` cross-platform gate.
|
||||
- Runs full test suite + coverage.
|
||||
- Builds SDK, bundles `sdk-bundle/gsd-sdk.tgz` inside the CC tarball (parity with `release-sdk.yml`).
|
||||
- Tags `v1.27.1`, publishes to `@latest`, re-points `@next → v1.27.1`.
|
||||
- Opens merge-back PR against `main`.
|
||||
|
||||
**Path B — `release-sdk.yml` (stopgap, one-shot):**
|
||||
|
||||
Active while the `@gsd-build/sdk` npm token is unavailable; bundles the SDK inside the CC tarball.
|
||||
|
||||
1. Trigger `release-sdk.yml` with `action=hotfix`, `version=1.27.1`, `auto_cherry_pick=true`.
|
||||
- The `prepare` job creates the branch and cherry-picks (same logic as Path A).
|
||||
- `install-smoke` runs against the new branch.
|
||||
- The `release` job tags, publishes to `@latest`, re-points `@next`, opens merge-back PR.
|
||||
- Idempotent: if `hotfix/1.27.1` already exists (e.g. you ran `hotfix.yml create` first), the prepare job checks it out and re-runs cherry-pick as a no-op.
|
||||
2. `dry_run=true` exercises the full pipeline without pushing the branch or publishing.
|
||||
|
||||
### Minor Release (Standard Cycle)
|
||||
|
||||
|
||||
@@ -358,6 +358,30 @@ If RED or GREEN gate commits are missing, add a warning to SUMMARY.md under a `#
|
||||
<task_commit_protocol>
|
||||
After each task completes (verification passed, done criteria met), commit immediately.
|
||||
|
||||
**0. Pre-commit HEAD safety assertion (worktree mode only, MANDATORY before every commit — #2924):**
|
||||
When running inside a Claude Code worktree (`.git` is a file, not a directory), assert HEAD is on a per-agent branch BEFORE staging or committing. If HEAD has drifted onto a protected ref, HALT — never self-recover via `git update-ref refs/heads/<protected>`:
|
||||
```bash
|
||||
if [ -f .git ]; then # worktree
|
||||
HEAD_REF=$(git symbolic-ref --quiet HEAD || echo "DETACHED")
|
||||
ACTUAL_BRANCH=$(git rev-parse --abbrev-ref HEAD)
|
||||
# Deny-list: never commit on a protected ref.
|
||||
if [ "$HEAD_REF" = "DETACHED" ] || \
|
||||
echo "$ACTUAL_BRANCH" | grep -Eq '^(main|master|develop|trunk|release/.*)$'; then
|
||||
echo "FATAL: refusing to commit — worktree HEAD is on '$ACTUAL_BRANCH' (expected per-agent branch)." >&2
|
||||
echo "DO NOT use 'git update-ref' to rewind the protected branch — surface as blocker (#2924)." >&2
|
||||
exit 1
|
||||
fi
|
||||
# Positive allow-list: HEAD must be on the canonical Claude Code worktree-agent
|
||||
# branch namespace (`worktree-agent-<id>`). This catches feature/* and any other
|
||||
# arbitrary branch that the deny-list would silently allow (#2924).
|
||||
if ! echo "$ACTUAL_BRANCH" | grep -Eq '^worktree-agent-[A-Za-z0-9._/-]+$'; then
|
||||
echo "FATAL: refusing to commit — worktree HEAD '$ACTUAL_BRANCH' is not in the worktree-agent-* namespace." >&2
|
||||
echo "Agent commits must live on per-agent branches; surface as blocker (#2924)." >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
```
|
||||
|
||||
**1. Check modified files:** `git status --short`
|
||||
|
||||
**2. Stage task-related files individually** (NEVER `git add .` or `git add -A`):
|
||||
@@ -426,6 +450,15 @@ back, those deletions appear on the main branch, destroying prior-wave work (#20
|
||||
- `git rm` on files not explicitly created by the current task
|
||||
- `git checkout -- .` or `git restore .` (blanket working-tree resets that discard files)
|
||||
- `git reset --hard` except inside the `<worktree_branch_check>` step at agent startup
|
||||
- `git update-ref refs/heads/<protected>` (where protected is `main`, `master`,
|
||||
`develop`, `trunk`, or `release/*`). This is an absolute prohibition (#2924).
|
||||
If you discover that your worktree HEAD is attached to a protected branch and your
|
||||
commits landed there, **DO NOT** "recover" by force-rewinding the protected ref —
|
||||
that silently destroys concurrent commits in multi-active scenarios (parallel
|
||||
agents, user committing while you run). HALT and surface a blocker. The setup-time
|
||||
`<worktree_branch_check>` and per-commit `<pre_commit_head_assertion>` are the
|
||||
correct prevention; if either fails, the workflow MUST stop, not self-heal.
|
||||
- `git push --force` / `git push -f` to any branch you did not create.
|
||||
|
||||
If you need to discard changes to a specific file you modified during this task, use:
|
||||
```bash
|
||||
|
||||
488
bin/install.js
488
bin/install.js
File diff suppressed because one or more lines are too long
@@ -30,6 +30,7 @@ Does not require `/gsd-new-project` — auto-creates `.planning/sketches/` if ne
|
||||
|
||||
<execution_context>
|
||||
@~/.claude/get-shit-done/workflows/sketch.md
|
||||
@~/.claude/get-shit-done/workflows/sketch-wrap-up.md
|
||||
@~/.claude/get-shit-done/references/ui-brand.md
|
||||
@~/.claude/get-shit-done/references/sketch-theme-system.md
|
||||
@~/.claude/get-shit-done/references/sketch-interactivity.md
|
||||
@@ -50,6 +51,9 @@ Design idea: $ARGUMENTS
|
||||
</context>
|
||||
|
||||
<process>
|
||||
Execute the sketch workflow from @~/.claude/get-shit-done/workflows/sketch.md end-to-end.
|
||||
Parse the first token of $ARGUMENTS:
|
||||
- If it is `--wrap-up`: strip the flag, execute the sketch-wrap-up workflow from @~/.claude/get-shit-done/workflows/sketch-wrap-up.md end-to-end.
|
||||
- Otherwise: execute the sketch workflow from @~/.claude/get-shit-done/workflows/sketch.md end-to-end.
|
||||
|
||||
Preserve all workflow gates (intake, decomposition, target stack research, variant evaluation, MANIFEST updates, commit patterns).
|
||||
</process>
|
||||
|
||||
@@ -30,6 +30,7 @@ Does not require `/gsd-new-project` — auto-creates `.planning/spikes/` if need
|
||||
|
||||
<execution_context>
|
||||
@~/.claude/get-shit-done/workflows/spike.md
|
||||
@~/.claude/get-shit-done/workflows/spike-wrap-up.md
|
||||
@~/.claude/get-shit-done/references/ui-brand.md
|
||||
</execution_context>
|
||||
|
||||
@@ -47,6 +48,9 @@ Idea: $ARGUMENTS
|
||||
</context>
|
||||
|
||||
<process>
|
||||
Execute the spike workflow from @~/.claude/get-shit-done/workflows/spike.md end-to-end.
|
||||
Parse the first token of $ARGUMENTS:
|
||||
- If it is `--wrap-up`: strip the flag, execute the spike-wrap-up workflow from @~/.claude/get-shit-done/workflows/spike-wrap-up.md.
|
||||
- Otherwise: pass all of $ARGUMENTS as the idea to the spike workflow from @~/.claude/get-shit-done/workflows/spike.md end-to-end.
|
||||
|
||||
Preserve all workflow gates (prior spike check, decomposition, research, risk ordering, observability assessment, verification, MANIFEST updates, commit patterns).
|
||||
</process>
|
||||
|
||||
@@ -191,6 +191,7 @@ All workflow toggles follow the **absent = enabled** pattern. If a key is missin
|
||||
| `workflow.skip_discuss` | boolean | `false` | When `true`, `/gsd-autonomous` bypasses the discuss-phase entirely, writing minimal CONTEXT.md from the ROADMAP phase goal. Useful for projects where developer preferences are fully captured in PROJECT.md/REQUIREMENTS.md. Added in v1.28 |
|
||||
| `workflow.text_mode` | boolean | `false` | Replaces AskUserQuestion TUI menus with plain-text numbered lists. Required for Claude Code remote sessions (`/rc` mode) where TUI menus don't render. Can also be set per-session with `--text` flag on discuss-phase. Added in v1.28 |
|
||||
| `workflow.use_worktrees` | boolean | `true` | When `false`, disables git worktree isolation for parallel execution. Users who prefer sequential execution or whose environment does not support worktrees can disable this. Added in v1.31 |
|
||||
| `workflow.worktree_skip_hooks` | boolean | `false` | When `true`, executor agents in worktree mode pass `--no-verify` (skipping pre-commit hooks) and post-wave hook validation runs against the merged result instead. Opt-in escape hatch for projects whose hooks cannot run in agent worktrees. Default `false` runs hooks on every commit (#2924). |
|
||||
| `workflow.code_review` | boolean | `true` | Enable `/gsd-code-review` and `/gsd-code-review-fix` commands. When `false`, the commands exit with a configuration gate message. Added in v1.34 |
|
||||
| `workflow.code_review_depth` | string | `standard` | Default review depth for `/gsd-code-review`: `quick` (pattern-matching only), `standard` (per-file analysis), or `deep` (cross-file with import graphs). Can be overridden per-run with `--depth=`. Added in v1.34 |
|
||||
| `workflow.plan_bounce` | boolean | `false` | Run external validation script against generated plans. When enabled, the plan-phase orchestrator pipes each PLAN.md through the script specified by `plan_bounce_script` and blocks on non-zero exit. Added in v1.36 |
|
||||
|
||||
@@ -18,7 +18,7 @@ Get Shit Done(GSD)フレームワークの包括的なドキュメントで
|
||||
|
||||
## クイックリンク
|
||||
|
||||
- **v1.32 の新機能:** STATE.md 整合性ゲート、`--to N` 自律モード、リサーチゲート、ベリファイヤーマイルストーンスコープフィルタリング、read-before-edit ガード、コンテキスト削減、新規ランタイム(Trae, Cline, Augment Code)、レスポンス言語設定、`--power`/`--diagnose` フラグ、`/gsd-analyze-dependencies`
|
||||
- **v1.39 の新機能:** `--minimal` インストールプロファイル(≥94% コールドスタート削減)、`/gsd-edit-phase`、マージ後ビルド & テストゲート、`review.models.<cli>` ランタイム別レビューモデル、ワークストリーム設定の継承、手動カナリアリリースワークフロー、スキル統合(86 → 59)
|
||||
- **はじめに:** [README](../README.md) → インストール → `/gsd-new-project`
|
||||
- **ワークフロー完全ガイド:** [ユーザーガイド](USER-GUIDE.md)
|
||||
- **コマンド一覧:** [コマンドリファレンス](COMMANDS.md)
|
||||
|
||||
@@ -20,7 +20,7 @@ Get Shit Done (GSD) 프레임워크의 종합 문서입니다. GSD는 AI 코딩
|
||||
|
||||
## 빠른 링크
|
||||
|
||||
- **v1.32의 새로운 기능:** STATE.md 일관성 게이트, `--to N` 자율 모드, 리서치 게이트, 검증자 마일스톤 범위 필터링, read-before-edit 가드, 컨텍스트 축소, 신규 런타임(Trae, Cline, Augment Code), 응답 언어 설정, `--power`/`--diagnose` 플래그, `/gsd-analyze-dependencies`
|
||||
- **v1.39의 새로운 기능:** `--minimal` 설치 프로파일(콜드 스타트 ≥94% 감소), `/gsd-edit-phase`, 머지 후 빌드 & 테스트 게이트, `review.models.<cli>` 런타임별 리뷰 모델, 워크스트림 설정 상속, 수동 카나리 릴리스 워크플로, 스킬 통합(86 → 59)
|
||||
- **시작하기:** [README](../README.md) → 설치 → `/gsd-new-project`
|
||||
- **전체 워크플로우 안내:** [User Guide](USER-GUIDE.md)
|
||||
- **모든 명령어 한눈에 보기:** [Command Reference](COMMANDS.md)
|
||||
|
||||
@@ -18,9 +18,9 @@ Documentação abrangente do framework Get Shit Done (GSD) — um sistema de met
|
||||
| [Referências](references/) | Todos os usuários | Guias complementares de decisão, verificação e padrões |
|
||||
| [Superpowers](superpowers/) | Contribuidores | Planos e specs avançadas do projeto |
|
||||
|
||||
## Novidades v1.32
|
||||
## Novidades v1.39
|
||||
|
||||
STATE.md consistency gates, `--to N` para execução autônoma parcial, research gate, verifier milestone scope filtering, read-before-edit guard, context reduction, novos runtimes (Trae, Cline, Augment Code), `response_language`, `--power`/`--diagnose` flags, `/gsd-analyze-dependencies`.
|
||||
Perfil de instalação `--minimal` (≥94% de redução no cold-start), `/gsd-edit-phase`, build & test gate pós-merge, `review.models.<cli>` para escolha de modelo de review por runtime, herança de configuração de workstream, workflow manual de canary release, consolidação de skills (86 → 59).
|
||||
|
||||
## Links rápidos
|
||||
|
||||
|
||||
@@ -703,12 +703,15 @@ async function runCommand(command, args, cwd, raw, defaultValue) {
|
||||
|
||||
case 'audit-open': {
|
||||
const { auditOpenArtifacts, formatAuditReport } = require('./lib/audit.cjs');
|
||||
const includeRaw = args.includes('--json');
|
||||
const wantJson = args.includes('--json');
|
||||
const result = auditOpenArtifacts(cwd);
|
||||
if (includeRaw) {
|
||||
if (wantJson) {
|
||||
// core.output JSON-stringifies its first arg; pass the object directly.
|
||||
core.output(result, raw);
|
||||
} else {
|
||||
core.output(formatAuditReport(result), raw);
|
||||
// Human-readable report must bypass JSON encoding — use the rawValue
|
||||
// form (third arg) which core.output emits verbatim.
|
||||
core.output(null, true, formatAuditReport(result));
|
||||
}
|
||||
break;
|
||||
}
|
||||
@@ -1067,6 +1070,7 @@ async function runCommand(command, args, cwd, raw, defaultValue) {
|
||||
'agents',
|
||||
path.join('commands', 'gsd'),
|
||||
'hooks',
|
||||
'skills',
|
||||
];
|
||||
|
||||
function walkDir(dir, baseDir) {
|
||||
|
||||
@@ -26,6 +26,7 @@ const VALID_CONFIG_KEYS = new Set([
|
||||
'workflow.skip_discuss',
|
||||
'workflow.auto_prune_state',
|
||||
'workflow.use_worktrees',
|
||||
'workflow.worktree_skip_hooks',
|
||||
'workflow.code_review',
|
||||
'workflow.code_review_depth',
|
||||
'workflow.code_review_command',
|
||||
|
||||
@@ -377,6 +377,15 @@ function cmdConfigSet(cwd, keyPath, value, raw) {
|
||||
output(setConfigValueResult, raw, `${keyPath}=${parsedValue}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Schema-level defaults for well-known config keys.
|
||||
* When a key is absent from config.json and no --default flag was supplied,
|
||||
* cmdConfigGet checks here before emitting "Key not found".
|
||||
*/
|
||||
const SCHEMA_DEFAULTS = {
|
||||
'context_window': 200000,
|
||||
};
|
||||
|
||||
function cmdConfigGet(cwd, keyPath, raw, defaultValue) {
|
||||
const configPath = path.join(planningDir(cwd), 'config.json');
|
||||
const hasDefault = defaultValue !== undefined;
|
||||
@@ -406,6 +415,11 @@ function cmdConfigGet(cwd, keyPath, raw, defaultValue) {
|
||||
for (const key of keys) {
|
||||
if (current === undefined || current === null || typeof current !== 'object') {
|
||||
if (hasDefault) { output(defaultValue, raw, String(defaultValue)); return; }
|
||||
if (Object.prototype.hasOwnProperty.call(SCHEMA_DEFAULTS, keyPath)) {
|
||||
const def = SCHEMA_DEFAULTS[keyPath];
|
||||
output(def, raw, String(def));
|
||||
return;
|
||||
}
|
||||
error(`Key not found: ${keyPath}`);
|
||||
}
|
||||
current = current[key];
|
||||
@@ -413,6 +427,11 @@ function cmdConfigGet(cwd, keyPath, raw, defaultValue) {
|
||||
|
||||
if (current === undefined) {
|
||||
if (hasDefault) { output(defaultValue, raw, String(defaultValue)); return; }
|
||||
if (Object.prototype.hasOwnProperty.call(SCHEMA_DEFAULTS, keyPath)) {
|
||||
const def = SCHEMA_DEFAULTS[keyPath];
|
||||
output(def, raw, String(def));
|
||||
return;
|
||||
}
|
||||
error(`Key not found: ${keyPath}`);
|
||||
}
|
||||
|
||||
|
||||
@@ -1293,6 +1293,16 @@ const RUNTIME_PROFILE_MAP = {
|
||||
sonnet: { model: 'claude-sonnet-4-6' },
|
||||
haiku: { model: 'claude-haiku-4-5' },
|
||||
},
|
||||
hermes: {
|
||||
// Hermes Agent is provider-agnostic; users pick any provider in ~/.hermes/config.yaml.
|
||||
// Defaults use OpenRouter slugs because (a) OpenRouter is Hermes' default provider and
|
||||
// (b) the same slugs resolve on OpenRouter, native Anthropic, and Copilot via Hermes'
|
||||
// aggregator-aware resolver. Users on a different provider override per-tier via
|
||||
// model_profile_overrides.hermes.{opus,sonnet,haiku} in .planning/config.json.
|
||||
opus: { model: 'anthropic/claude-opus-4-7' },
|
||||
sonnet: { model: 'anthropic/claude-sonnet-4-6' },
|
||||
haiku: { model: 'anthropic/claude-haiku-4-5' },
|
||||
},
|
||||
};
|
||||
|
||||
const RUNTIMES_WITH_REASONING_EFFORT = new Set(['codex']);
|
||||
@@ -1315,7 +1325,7 @@ const RUNTIME_OVERRIDE_TIERS = new Set(['opus', 'sonnet', 'haiku']);
|
||||
const KNOWN_RUNTIMES = new Set([
|
||||
'claude', 'codex', 'opencode', 'kilo', 'gemini', 'qwen',
|
||||
'copilot', 'cursor', 'windsurf', 'augment', 'trae', 'codebuddy',
|
||||
'antigravity', 'cline',
|
||||
'antigravity', 'cline', 'hermes',
|
||||
]);
|
||||
|
||||
const _warnedConfigKeys = new Set();
|
||||
|
||||
@@ -62,8 +62,11 @@ gsd-sdk query commit "docs: initialize [project-name] ([N] phases)" --files .pla
|
||||
Each task gets its own commit immediately after completion.
|
||||
|
||||
> **Parallel agents:** When running as a parallel executor (spawned by execute-phase),
|
||||
> use `--no-verify` on all commits to avoid pre-commit hook lock contention.
|
||||
> The orchestrator validates hooks once after all agents complete.
|
||||
> run commits normally — let pre-commit hooks run. Do NOT pass `--no-verify` by default
|
||||
> (#2924). Hooks should fire on the introducing commit; silent bypass violates project
|
||||
> CLAUDE.md guidance. If a project explicitly opts out via
|
||||
> `workflow.worktree_skip_hooks=true`, the orchestrator surfaces that flag in the
|
||||
> executor prompt; absent that signal, hooks run normally.
|
||||
|
||||
```
|
||||
{type}({phase}-{plan}): {task-name}
|
||||
|
||||
@@ -252,7 +252,7 @@ RAW_SKETCHES=$(ls .planning/sketches/MANIFEST.md 2>/dev/null)
|
||||
|
||||
If findings skills exist, read SKILL.md and reference files; extract validated patterns, landmines, constraints, design decisions. Add them to `<prior_decisions>`.
|
||||
|
||||
If raw spikes/sketches exist but no findings skill, note: `⚠ Unpackaged spikes/sketches detected — run /gsd-spike-wrap-up or /gsd-sketch-wrap-up to make findings available.`
|
||||
If raw spikes/sketches exist but no findings skill, note: `⚠ Unpackaged spikes/sketches detected — run /gsd-spike --wrap-up or /gsd-sketch --wrap-up to make findings available.`
|
||||
|
||||
Build internal `<prior_decisions>` with sections for Project-Level (from PROJECT.md / REQUIREMENTS.md), From Prior Phases (per-phase decisions), and From Spike/Sketch Findings (validated patterns, landmines, design decisions).
|
||||
|
||||
|
||||
@@ -44,29 +44,29 @@ Evaluate `$ARGUMENTS` against these routing rules. Apply the **first matching**
|
||||
| A bug, error, crash, failure, or something broken | `/gsd-debug` | Needs systematic investigation |
|
||||
| Spiking, "test if", "will this work", "experiment", "prove this out", validate feasibility | `/gsd-spike` | Throwaway experiment to validate feasibility |
|
||||
| Sketching, "mockup", "what would this look like", "prototype the UI", "design this", explore visual direction | `/gsd-sketch` | Throwaway HTML mockups to explore design |
|
||||
| Wrapping up spikes, "package the spikes", "consolidate spike findings" | `/gsd-spike-wrap-up` | Package spike findings into reusable skill |
|
||||
| Wrapping up sketches, "package the designs", "consolidate sketch findings" | `/gsd-sketch-wrap-up` | Package sketch findings into reusable skill |
|
||||
| Exploring, researching, comparing, or "how does X work" | `/gsd-research-phase` | Domain research before planning |
|
||||
| Wrapping up spikes, "package the spikes", "consolidate spike findings" | `/gsd-spike --wrap-up` | Package spike findings into reusable skill |
|
||||
| Wrapping up sketches, "package the designs", "consolidate sketch findings" | `/gsd-sketch --wrap-up` | Package sketch findings into reusable skill |
|
||||
| Exploring, researching, comparing, or "how does X work" | `/gsd-explore` | Socratic ideation and idea routing |
|
||||
| Discussing vision, "how should X look", brainstorming | `/gsd-discuss-phase` | Needs context gathering |
|
||||
| A complex task: refactoring, migration, multi-file architecture, system redesign | `/gsd-add-phase` | Needs a full phase with plan/build cycle |
|
||||
| A complex task: refactoring, migration, multi-file architecture, system redesign | `/gsd-phase` | Needs a full phase with plan/build cycle |
|
||||
| Planning a specific phase or "plan phase N" | `/gsd-plan-phase` | Direct planning request |
|
||||
| Executing a phase or "build phase N", "run phase N" | `/gsd-execute-phase` | Direct execution request |
|
||||
| Running all remaining phases automatically | `/gsd-autonomous` | Full autonomous execution |
|
||||
| A review or quality concern about existing work | `/gsd-verify-work` | Needs verification |
|
||||
| Checking progress, status, "where am I" | `/gsd-progress` | Status check |
|
||||
| Resuming work, "pick up where I left off" | `/gsd-resume-work` | Session restoration |
|
||||
| A note, idea, or "remember to..." | `/gsd-add-todo` | Capture for later |
|
||||
| A note, idea, or "remember to..." | `/gsd-capture` | Capture for later |
|
||||
| Adding tests, "write tests", "test coverage" | `/gsd-add-tests` | Test generation |
|
||||
| Completing a milestone, shipping, releasing | `/gsd-complete-milestone` | Milestone lifecycle |
|
||||
| A specific, actionable, small task (add feature, fix typo, update config) | `/gsd-quick` | Self-contained, single executor |
|
||||
|
||||
**Requires `.planning/` directory:** All routes except `/gsd-new-project`, `/gsd-map-codebase`, `/gsd-spike`, `/gsd-sketch`, `/gsd-help`, and `/gsd-join-discord`. If the project doesn't exist and the route requires it, suggest `/gsd-new-project` first.
|
||||
**Requires `.planning/` directory:** All routes except `/gsd-new-project`, `/gsd-map-codebase`, `/gsd-spike`, `/gsd-sketch`, and `/gsd-help`. If the project doesn't exist and the route requires it, suggest `/gsd-new-project` first.
|
||||
|
||||
**Ambiguity handling:** If the text could reasonably match multiple routes, ask the user via AskUserQuestion with the top 2-3 options. For example:
|
||||
|
||||
```
|
||||
"Refactor the authentication system" could be:
|
||||
1. /gsd-add-phase — Full planning cycle (recommended for multi-file refactors)
|
||||
1. /gsd-phase — Full planning cycle (recommended for multi-file refactors)
|
||||
2. /gsd-quick — Quick execution (if scope is small and clear)
|
||||
|
||||
Which approach fits better?
|
||||
|
||||
@@ -217,9 +217,33 @@ Check `branching_strategy` from init:
|
||||
|
||||
**"none":** Skip, continue on current branch.
|
||||
|
||||
**"phase" or "milestone":** Use pre-computed `branch_name` from init:
|
||||
**"phase" or "milestone":** Use pre-computed `branch_name` from init.
|
||||
|
||||
Fork the new phase branch off `origin/HEAD` (the project's default branch), not the current HEAD — otherwise consecutive phases compound and stay unpushed (#2916). If `$BRANCH_NAME` already exists locally, reuse it as-is.
|
||||
|
||||
```bash
|
||||
git checkout -b "$BRANCH_NAME" 2>/dev/null || git checkout "$BRANCH_NAME"
|
||||
DEFAULT_BRANCH=$(git symbolic-ref --quiet --short refs/remotes/origin/HEAD 2>/dev/null | sed 's|^origin/||')
|
||||
DEFAULT_BRANCH=${DEFAULT_BRANCH:-main}
|
||||
|
||||
if git show-ref --verify --quiet "refs/heads/$BRANCH_NAME"; then
|
||||
git switch "$BRANCH_NAME" || { echo "ERROR: Could not switch to existing branch '$BRANCH_NAME'." >&2; exit 1; }
|
||||
else
|
||||
if ! git fetch --quiet origin "$DEFAULT_BRANCH"; then # #2916
|
||||
git show-ref --verify --quiet "refs/remotes/origin/$DEFAULT_BRANCH" \
|
||||
|| { echo "ERROR: fetch origin/$DEFAULT_BRANCH failed and no local copy exists. Refusing to create '$BRANCH_NAME' off current HEAD (#2916)." >&2; exit 1; }
|
||||
echo "WARNING: fetch origin/$DEFAULT_BRANCH failed; using local copy as base." >&2
|
||||
fi
|
||||
if [ -n "$(git status --porcelain)" ]; then
|
||||
echo "WARNING: Uncommitted changes will be carried onto '$BRANCH_NAME' (branched off origin/$DEFAULT_BRANCH, not previous HEAD)."
|
||||
else
|
||||
git switch --quiet "$DEFAULT_BRANCH" 2>/dev/null && git merge --ff-only --quiet "origin/$DEFAULT_BRANCH" 2>/dev/null || true
|
||||
fi
|
||||
# Pinned base + fail-fast: on success HEAD is exactly at origin/$DEFAULT_BRANCH,
|
||||
# so a post-creation merge-base or "ahead-of" guard would be unreachable. The
|
||||
# explicit base argument here is the single source of correctness for #2916.
|
||||
git checkout -b "$BRANCH_NAME" "origin/$DEFAULT_BRANCH" \
|
||||
|| { echo "ERROR: Could not create '$BRANCH_NAME' from origin/$DEFAULT_BRANCH (#2916)." >&2; exit 1; }
|
||||
fi
|
||||
```
|
||||
|
||||
All subsequent commits go to this branch. User handles merging.
|
||||
@@ -482,40 +506,37 @@ increases monotonically across waves. `{status}` is `complete` (success),
|
||||
</objective>
|
||||
|
||||
<worktree_branch_check>
|
||||
FIRST ACTION before any other work: verify this worktree's branch is based on the correct commit.
|
||||
|
||||
Run:
|
||||
FIRST ACTION: HEAD assertion MUST run before any reset/checkout. Worktrees
|
||||
spawned by Claude Code's `isolation="worktree"` use the `worktree-agent-<id>`
|
||||
namespace. If HEAD is on a protected ref (main/master/develop/trunk/release/*)
|
||||
or detached, HALT — do NOT self-recover by force-rewinding via `git update-ref`,
|
||||
that destroys concurrent commits in multi-active scenarios (#2924). Only after
|
||||
Step 1 passes is `git reset --hard` safe (#2015 — affects all platforms).
|
||||
```bash
|
||||
ACTUAL_BASE=$(git merge-base HEAD {EXPECTED_BASE})
|
||||
```
|
||||
|
||||
If `ACTUAL_BASE` != `{EXPECTED_BASE}` (i.e. the worktree branch was created from an older
|
||||
base such as `main` instead of the feature branch HEAD), hard-reset to the correct base:
|
||||
```bash
|
||||
# Safe: this runs before any agent work, so no uncommitted changes to lose
|
||||
git reset --hard {EXPECTED_BASE}
|
||||
# Verify correction succeeded
|
||||
if [ "$(git rev-parse HEAD)" != "{EXPECTED_BASE}" ]; then
|
||||
echo "ERROR: Could not correct worktree base — aborting to prevent data loss"
|
||||
HEAD_REF=$(git symbolic-ref --quiet HEAD || echo "DETACHED")
|
||||
ACTUAL_BRANCH=$(git rev-parse --abbrev-ref HEAD)
|
||||
if [ "$HEAD_REF" = "DETACHED" ] || echo "$ACTUAL_BRANCH" | grep -Eq '^(main|master|develop|trunk|release/.*)$'; then
|
||||
echo "FATAL: worktree HEAD on '$ACTUAL_BRANCH' (expected worktree-agent-*); refusing to self-recover via 'git update-ref' (#2924)." >&2
|
||||
exit 1
|
||||
fi
|
||||
if ! echo "$ACTUAL_BRANCH" | grep -Eq '^worktree-agent-[A-Za-z0-9._/-]+$'; then
|
||||
echo "FATAL: worktree HEAD '$ACTUAL_BRANCH' is not in the worktree-agent-* namespace; refusing to commit (#2924)." >&2
|
||||
exit 1
|
||||
fi
|
||||
ACTUAL_BASE=$(git merge-base HEAD {EXPECTED_BASE})
|
||||
if [ "$ACTUAL_BASE" != "{EXPECTED_BASE}" ]; then
|
||||
git reset --hard {EXPECTED_BASE}
|
||||
[ "$(git rev-parse HEAD)" != "{EXPECTED_BASE}" ] && { echo "ERROR: could not correct worktree base"; exit 1; }
|
||||
fi
|
||||
```
|
||||
|
||||
`reset --hard` is safe here because this is a fresh worktree with no user changes. It
|
||||
resets both the HEAD pointer AND the working tree to the correct base commit (#2015).
|
||||
|
||||
If `ACTUAL_BASE` == `{EXPECTED_BASE}`: the branch base is correct, proceed immediately.
|
||||
|
||||
This check fixes a known issue where `EnterWorktree` creates branches from
|
||||
`main` instead of the current feature branch HEAD (affects all platforms).
|
||||
Per-commit HEAD assertion lives in `agents/gsd-executor.md` `<task_commit_protocol>` step 0.
|
||||
</worktree_branch_check>
|
||||
|
||||
<parallel_execution>
|
||||
You are running as a PARALLEL executor agent in a git worktree.
|
||||
Use --no-verify on all git commits to avoid pre-commit hook contention
|
||||
with other agents. The orchestrator validates hooks once after all agents complete.
|
||||
For `gsd-sdk query commit` (or legacy `gsd-tools.cjs` commit): add --no-verify flag when needed.
|
||||
For direct git commits: use git commit --no-verify -m "..."
|
||||
Run `git commit` normally — hooks run by default. Do NOT pass `--no-verify`
|
||||
unless the orchestrator surfaces `workflow.worktree_skip_hooks=true` in this
|
||||
prompt; silent bypass violates project CLAUDE.md guidance (#2924).
|
||||
|
||||
IMPORTANT: Do NOT modify STATE.md or ROADMAP.md. execute-plan.md
|
||||
auto-detects worktree mode (`.git` is a file, not a directory) and skips
|
||||
@@ -527,6 +548,7 @@ increases monotonically across waves. `{status}` is `complete` (success),
|
||||
only (STATE.md and ROADMAP.md are excluded automatically). Do NOT skip or defer
|
||||
this commit — the orchestrator force-removes the worktree after you return, and
|
||||
any uncommitted SUMMARY.md will be permanently lost (#2070).
|
||||
REQUIRED ORDER: Write SUMMARY.md → commit → only then any narration. No text between Write and commit (truncation risk; #2070 rescue is not primary defense).
|
||||
</parallel_execution>
|
||||
|
||||
<execution_context>
|
||||
@@ -581,6 +603,7 @@ increases monotonically across waves. `{status}` is `complete` (success),
|
||||
<sequential_execution>
|
||||
You are running as a SEQUENTIAL executor agent on the main working tree.
|
||||
Use normal git commits (with hooks). Do NOT use --no-verify.
|
||||
REQUIRED ORDER: Write SUMMARY.md → commit → only then any narration. No text between Write and commit (truncation risk; #2070 rescue is not primary defense).
|
||||
</sequential_execution>
|
||||
```
|
||||
|
||||
@@ -632,13 +655,16 @@ increases monotonically across waves. `{status}` is `complete` (success),
|
||||
**This fallback applies automatically to all runtimes.** Claude Code's Task() normally
|
||||
returns synchronously, but the fallback ensures resilience if it doesn't.
|
||||
|
||||
5. **Post-wave hook validation (parallel mode only):**
|
||||
|
||||
When agents committed with `--no-verify`, run pre-commit hooks once after the wave:
|
||||
5. **Post-wave hook validation (parallel mode only):** Hooks run on every executor commit by default (#2924); this post-wave run only fires when `workflow.worktree_skip_hooks=true` opted out of per-commit hooks:
|
||||
```bash
|
||||
# Run project's pre-commit hooks on the current state
|
||||
git diff --cached --quiet || git stash # stash any unstaged changes
|
||||
git hook run pre-commit 2>&1 || echo "⚠ Pre-commit hooks failed — review before continuing"
|
||||
SKIP_HOOKS=$(gsd-sdk query config-get workflow.worktree_skip_hooks 2>/dev/null || echo "false")
|
||||
if [ "$SKIP_HOOKS" = "true" ]; then
|
||||
# Stash uncommitted changes under a named ref so we always pop (bare `git stash` strands them on hook/script failure).
|
||||
STASHED=false
|
||||
if (! git diff --quiet || ! git diff --cached --quiet) && git stash push -u -m "gsd-post-wave-hook-$$" >/dev/null 2>&1; then STASHED=true; fi
|
||||
git hook run pre-commit 2>&1 || echo "⚠ Pre-commit hooks failed — review before continuing"
|
||||
[ "$STASHED" = "true" ] && (git stash pop >/dev/null 2>&1 || echo "⚠ Could not pop gsd-post-wave-hook stash — recover manually")
|
||||
fi
|
||||
```
|
||||
If hooks fail: report the failure and ask "Fix hook issues now?" or "Continue to next wave?"
|
||||
|
||||
|
||||
@@ -81,7 +81,7 @@ Otherwise: Apply checkpoint-based routing below.
|
||||
| Verify-only | B (segmented) | Segments between checkpoints. After none/human-verify → SUBAGENT. After decision/human-action → MAIN |
|
||||
| Decision | C (main) | Execute entirely in main context |
|
||||
|
||||
**Pattern A:** init_agent_tracking → capture `EXPECTED_BASE=$(git rev-parse HEAD)` → spawn Task(subagent_type="gsd-executor", model=executor_model) with prompt: execute plan at [path], autonomous, all tasks + SUMMARY + commit, follow deviation/auth rules, report: plan name, tasks, SUMMARY path, commit hash → track agent_id → wait → update tracking → report. **Include `isolation="worktree"` only if `workflow.use_worktrees` is not `false`** (read via `config-get workflow.use_worktrees`). **When using `isolation="worktree"`, include a `<worktree_branch_check>` block in the prompt** instructing the executor to run `git merge-base HEAD {EXPECTED_BASE}` and, if the result differs from `{EXPECTED_BASE}`, hard-reset the branch with `git reset --hard {EXPECTED_BASE}` before starting work (safe — runs before any agent work), then verify with `[ "$(git rev-parse HEAD)" != "{EXPECTED_BASE}" ] && exit 1`. This corrects a known issue where `EnterWorktree` creates branches from `main` instead of the feature branch HEAD (affects all platforms).
|
||||
**Pattern A:** init_agent_tracking → capture `EXPECTED_BASE=$(git rev-parse HEAD)` → spawn Task(subagent_type="gsd-executor", model=executor_model) with prompt: execute plan at [path], autonomous, all tasks + SUMMARY + commit, follow deviation/auth rules, report: plan name, tasks, SUMMARY path, commit hash → track agent_id → wait → update tracking → report. **Include `isolation="worktree"` only if `workflow.use_worktrees` is not `false`** (read via `config-get workflow.use_worktrees`). **When using `isolation="worktree"`, include a `<worktree_branch_check>` block in the prompt** instructing the executor to: (1) FIRST assert `git symbolic-ref HEAD` resolves to a per-agent branch (NOT a protected ref like `main`/`master`/`develop`/`trunk`/`release/*`) and HALT with a blocker if not — never self-recover via `git update-ref refs/heads/<protected>` (#2924); (2) only after that assertion passes, run `git merge-base HEAD {EXPECTED_BASE}` and, if the result differs from `{EXPECTED_BASE}`, hard-reset the branch with `git reset --hard {EXPECTED_BASE}` before starting work, then verify with `[ "$(git rev-parse HEAD)" != "{EXPECTED_BASE}" ] && exit 1`. The HEAD assertion (Step 1) MUST run before any reset/checkout. This corrects a known issue where `EnterWorktree` creates branches from `main` instead of the feature branch HEAD (affects all platforms — #2015) and prevents the destructive HEAD-on-master self-recovery path (#2924).
|
||||
|
||||
**Pattern B:** Execute segment-by-segment. Autonomous segments: spawn subagent for assigned tasks only (no SUMMARY/commit). Checkpoints: main context. After all segments: aggregate, create SUMMARY, commit. See segment_execution.
|
||||
|
||||
@@ -116,12 +116,18 @@ Pattern B only (verify-only checkpoints). Skip for A/C.
|
||||
2. Per segment:
|
||||
- Subagent route: spawn gsd-executor for assigned tasks only. Prompt: task range, plan path, read full plan for context, execute assigned tasks, track deviations, NO SUMMARY/commit. Track via agent protocol.
|
||||
- Main route: execute tasks using standard flow (step name="execute")
|
||||
3. After ALL segments: aggregate files/deviations/decisions → create SUMMARY.md → commit → self-check:
|
||||
3. **Critical ordering — write and commit SUMMARY.md as one atomic block.** Do NOT
|
||||
emit narrative output between the Write tool call and the commit tool call.
|
||||
Truncation at this boundary is a known failure mode (see #2070 rescue logic in
|
||||
execute-phase.md step 5.5).
|
||||
|
||||
After ALL segments: aggregate files/deviations/decisions → create SUMMARY.md → self-check:
|
||||
- Verify key-files.created exist on disk with `[ -f ]`
|
||||
- Check `git log --oneline --all --grep="{phase}-{plan}"` returns ≥1 commit
|
||||
- Re-run ALL `<acceptance_criteria>` from every task — if any fail, fix before finalizing SUMMARY
|
||||
- Re-run the plan-level `<verification>` commands — log results in SUMMARY
|
||||
- Append `## Self-Check: PASSED` or `## Self-Check: FAILED` to SUMMARY
|
||||
Then commit (no narrative between Write and commit).
|
||||
|
||||
**Known Claude Code bug (classifyHandoffIfNeeded):** If any segment agent reports "failed" with `classifyHandoffIfNeeded is not defined`, this is a Claude Code runtime bug — not a real failure. Run spot-checks; if they pass, treat as successful.
|
||||
|
||||
@@ -239,7 +245,12 @@ See `~/.claude/get-shit-done/references/tdd.md` for structure.
|
||||
Your commits may trigger pre-commit hooks. Auto-fix hooks handle themselves transparently — files get fixed and re-staged automatically.
|
||||
|
||||
**If running as a parallel executor agent (spawned by execute-phase):**
|
||||
Use `--no-verify` on all commits. Pre-commit hooks cause build lock contention when multiple agents commit simultaneously (e.g., cargo lock fights in Rust projects). The orchestrator validates once after all agents complete.
|
||||
Run commits normally — let pre-commit hooks run. Do NOT use `--no-verify` by default
|
||||
(#2924). Hooks should run so issues surface at the introducing commit, and silent
|
||||
bypass violates project CLAUDE.md guidance. If a project explicitly opts out via
|
||||
`workflow.worktree_skip_hooks=true`, the orchestrator will surface that flag in the
|
||||
prompt; absent that signal, hooks run normally. If a hook fails, follow the
|
||||
sequential-mode handling below.
|
||||
|
||||
**If running as the sole executor (sequential mode):**
|
||||
If a commit is BLOCKED by a hook:
|
||||
@@ -331,6 +342,11 @@ If user_setup exists: create `{phase}-USER-SETUP.md` using template `~/.claude/g
|
||||
</step>
|
||||
|
||||
<step name="create_summary">
|
||||
**Critical ordering — write and commit SUMMARY.md as one atomic block.** Do NOT
|
||||
emit narrative output between the Write tool call and the commit tool call.
|
||||
Truncation at this boundary is a known failure mode (see #2070 rescue logic in
|
||||
execute-phase.md step 5.5).
|
||||
|
||||
Create `{phase}-{plan}-SUMMARY.md` at `.planning/phases/XX-name/`. Use `~/.claude/get-shit-done/templates/summary.md`.
|
||||
|
||||
**Frontmatter:** phase, plan, subsystem, tags | requires/provides/affects | tech-stack.added/patterns | key-files.created/modified | key-decisions | requirements-completed (**MUST** copy `requirements` array from PLAN.md frontmatter verbatim) | duration ($DURATION), completed ($PLAN_END_TIME date).
|
||||
@@ -432,6 +448,11 @@ Extract requirement IDs from the plan's frontmatter (e.g., `requirements: [AUTH-
|
||||
</step>
|
||||
|
||||
<step name="git_commit_metadata">
|
||||
**Critical ordering — write and commit SUMMARY.md as one atomic block.** Do NOT
|
||||
emit narrative output between the Write tool call and the commit tool call.
|
||||
Truncation at this boundary is a known failure mode (see #2070 rescue logic in
|
||||
execute-phase.md step 5.5).
|
||||
|
||||
Task code already committed per-task. Commit plan metadata:
|
||||
|
||||
```bash
|
||||
|
||||
@@ -48,9 +48,13 @@ Creates all `.planning/` artifacts:
|
||||
|
||||
Usage: `/gsd-new-project`
|
||||
|
||||
**`/gsd-map-codebase`**
|
||||
**`/gsd-map-codebase [--fast] [--focus <area>] [--query <term>]`**
|
||||
Map an existing codebase for brownfield projects.
|
||||
|
||||
- `--fast` — rapid lightweight assessment (replaces the former `gsd-scan`)
|
||||
- `--focus <area>` — scope the map to a specific area
|
||||
- `--query <term>` — query the codebase intelligence index in `.planning/intel/` (replaces the former `gsd-intel`)
|
||||
|
||||
- Analyzes codebase with parallel Explore agents
|
||||
- Creates `.planning/codebase/` with 7 focused documents
|
||||
- Covers stack, architecture, structure, conventions, testing, integrations, concerns
|
||||
@@ -60,9 +64,13 @@ Usage: `/gsd-map-codebase`
|
||||
|
||||
### Phase Planning
|
||||
|
||||
**`/gsd-discuss-phase <number>`**
|
||||
**`/gsd-discuss-phase <number> [--chain | --analyze | --power] [--batch[=N]]`**
|
||||
Help articulate your vision for a phase before planning.
|
||||
|
||||
- `--chain` — chained-prompt discuss flow
|
||||
- `--analyze` — deep assumption analysis pass
|
||||
- `--power` — power-user mode with extended question set
|
||||
|
||||
- Captures how you imagine this phase working
|
||||
- Creates CONTEXT.md with your vision, essentials, and boundaries
|
||||
- Use when you have ideas about how something should look/feel
|
||||
@@ -72,28 +80,15 @@ Usage: `/gsd-discuss-phase 2`
|
||||
Usage: `/gsd-discuss-phase 2 --batch`
|
||||
Usage: `/gsd-discuss-phase 2 --batch=3`
|
||||
|
||||
**`/gsd-research-phase <number>`**
|
||||
Comprehensive ecosystem research for niche/complex domains.
|
||||
|
||||
- Discovers standard stack, architecture patterns, pitfalls
|
||||
- Creates RESEARCH.md with "how experts build this" knowledge
|
||||
- Use for 3D, games, audio, shaders, ML, and other specialized domains
|
||||
- Goes beyond "which library" to ecosystem knowledge
|
||||
|
||||
Usage: `/gsd-research-phase 3`
|
||||
|
||||
**`/gsd-list-phase-assumptions <number>`**
|
||||
See what Claude is planning to do before it starts.
|
||||
|
||||
- Shows Claude's intended approach for a phase
|
||||
- Lets you course-correct if Claude misunderstood your vision
|
||||
- No files created - conversational output only
|
||||
|
||||
Usage: `/gsd-list-phase-assumptions 3`
|
||||
|
||||
**`/gsd-plan-phase <number>`**
|
||||
**`/gsd-plan-phase <number> [--skip-research] [--gaps] [--skip-verify] [--tdd] [--mvp]`**
|
||||
Create detailed execution plan for a specific phase.
|
||||
|
||||
- `--skip-research` — bypass the research subagent
|
||||
- `--gaps` — focus only on closing gaps from a prior plan-check
|
||||
- `--skip-verify` — skip the post-plan verifier loop
|
||||
- `--tdd` — plan in test-driven order (tests before code)
|
||||
- `--mvp` — vertical-slice MVP planning mode
|
||||
|
||||
- Generates `.planning/phases/XX-phase-name/XX-YY-PLAN.md`
|
||||
- Breaks phase into concrete, actionable tasks
|
||||
- Includes verification criteria and success measures
|
||||
@@ -106,9 +101,13 @@ Result: Creates `.planning/phases/01-foundation/01-01-PLAN.md`
|
||||
|
||||
### Execution
|
||||
|
||||
**`/gsd-execute-phase <phase-number>`**
|
||||
**`/gsd-execute-phase <phase-number> [--wave N] [--gaps-only] [--tdd]`**
|
||||
Execute all plans in a phase, or run a specific wave.
|
||||
|
||||
- `--wave N` — execute only wave N (see *Plans within each wave* below)
|
||||
- `--gaps-only` — re-run only plans flagged as gaps by a prior verifier
|
||||
- `--tdd` — enforce test-driven order during execution
|
||||
|
||||
- Groups plans by wave (from frontmatter), executes waves sequentially
|
||||
- Plans within each wave run in parallel via Task tool
|
||||
- Optional `--wave N` flag executes only Wave `N` and stops unless the phase is now fully complete
|
||||
@@ -120,7 +119,7 @@ Usage: `/gsd-execute-phase 5 --wave 2`
|
||||
|
||||
### Smart Router
|
||||
|
||||
**`/gsd-do <description>`**
|
||||
**`/gsd-progress --do "<description>"`**
|
||||
Route freeform text to the right GSD command automatically.
|
||||
|
||||
- Analyzes natural language input to find the best matching GSD command
|
||||
@@ -128,9 +127,9 @@ Route freeform text to the right GSD command automatically.
|
||||
- Resolves ambiguity by asking you to pick between top matches
|
||||
- Use when you know what you want but don't know which `/gsd-*` command to run
|
||||
|
||||
Usage: `/gsd-do fix the login button`
|
||||
Usage: `/gsd-do refactor the auth system`
|
||||
Usage: `/gsd-do I want to start a new milestone`
|
||||
Usage: `/gsd-progress --do "fix the login button"`
|
||||
Usage: `/gsd-progress --do "refactor the auth system"`
|
||||
Usage: `/gsd-progress --do "I want to start a new milestone"`
|
||||
|
||||
### Quick Mode
|
||||
|
||||
@@ -172,26 +171,26 @@ Usage: `/gsd-fast "add .env to gitignore"`
|
||||
|
||||
### Roadmap Management
|
||||
|
||||
**`/gsd-add-phase <description>`**
|
||||
**`/gsd-phase <description>`**
|
||||
Add new phase to end of current milestone.
|
||||
|
||||
- Appends to ROADMAP.md
|
||||
- Uses next sequential number
|
||||
- Updates phase directory structure
|
||||
|
||||
Usage: `/gsd-add-phase "Add admin dashboard"`
|
||||
Usage: `/gsd-phase "Add admin dashboard"`
|
||||
|
||||
**`/gsd-insert-phase <after> <description>`**
|
||||
**`/gsd-phase --insert <after> <description>`**
|
||||
Insert urgent work as decimal phase between existing phases.
|
||||
|
||||
- Creates intermediate phase (e.g., 7.1 between 7 and 8)
|
||||
- Useful for discovered work that must happen mid-milestone
|
||||
- Maintains phase ordering
|
||||
|
||||
Usage: `/gsd-insert-phase 7 "Fix critical auth bug"`
|
||||
Usage: `/gsd-phase --insert 7 "Fix critical auth bug"`
|
||||
Result: Creates Phase 7.1
|
||||
|
||||
**`/gsd-remove-phase <number>`**
|
||||
**`/gsd-phase --remove <number>`**
|
||||
Remove a future phase and renumber subsequent phases.
|
||||
|
||||
- Deletes phase directory and all references
|
||||
@@ -199,9 +198,15 @@ Remove a future phase and renumber subsequent phases.
|
||||
- Only works on future (unstarted) phases
|
||||
- Git commit preserves historical record
|
||||
|
||||
Usage: `/gsd-remove-phase 17`
|
||||
Usage: `/gsd-phase --remove 17`
|
||||
Result: Phase 17 deleted, phases 18-20 become 17-19
|
||||
|
||||
**`/gsd-phase --edit <number> [--force]`**
|
||||
Edit any field of an existing roadmap phase in place, preserving number and position.
|
||||
|
||||
- Updates title, description, requirements, dependencies in `ROADMAP.md`
|
||||
- `--force` allows editing already-started phases (use with caution)
|
||||
|
||||
### Milestone Management
|
||||
|
||||
**`/gsd-new-milestone <name>`**
|
||||
@@ -230,7 +235,7 @@ Usage: `/gsd-complete-milestone 1.0.0`
|
||||
|
||||
### Progress Tracking
|
||||
|
||||
**`/gsd-progress`**
|
||||
**`/gsd-progress [--next | --forensic | --do "<description>"]`**
|
||||
Check project status and intelligently route to next action.
|
||||
|
||||
- Shows visual progress bar and completion percentage
|
||||
@@ -240,7 +245,15 @@ Check project status and intelligently route to next action.
|
||||
- Offers to execute next plan or create it if missing
|
||||
- Detects 100% milestone completion
|
||||
|
||||
Modes:
|
||||
- **default** — progress report + intelligent routing
|
||||
- **`--next`** — auto-advance to the next logical step (use `--next --force` to bypass safety gates)
|
||||
- **`--forensic`** — append a 6-check integrity audit after the progress report
|
||||
- **`--do "<text>"`** — smart router: dispatch freeform intent to the matching `/gsd-*` command (see *Smart Router* above)
|
||||
|
||||
Usage: `/gsd-progress`
|
||||
Usage: `/gsd-progress --next`
|
||||
Usage: `/gsd-progress --forensic`
|
||||
|
||||
### Session Management
|
||||
|
||||
@@ -264,9 +277,11 @@ Usage: `/gsd-pause-work`
|
||||
|
||||
### Debugging
|
||||
|
||||
**`/gsd-debug [issue description]`**
|
||||
**`/gsd-debug [issue description] [--diagnose]`**
|
||||
Systematic debugging with persistent state across context resets.
|
||||
|
||||
- `--diagnose` — run a one-shot diagnostic pass without opening a persistent debug session
|
||||
|
||||
- Gathers symptoms through adaptive questioning
|
||||
- Creates `.planning/debug/[slug].md` to track investigation
|
||||
- Investigates using scientific method (evidence → hypothesis → test)
|
||||
@@ -305,7 +320,7 @@ Rapidly sketch UI/design ideas using throwaway HTML mockups with multi-variant e
|
||||
Usage: `/gsd-sketch "dashboard layout for the admin panel"`
|
||||
Usage: `/gsd-sketch --quick "form card grouping"`
|
||||
|
||||
**`/gsd-spike-wrap-up`**
|
||||
**`/gsd-spike --wrap-up`**
|
||||
Package spike findings into a persistent project skill.
|
||||
|
||||
- Curates each spike one-at-a-time (include/exclude/partial/UAT)
|
||||
@@ -314,9 +329,9 @@ Package spike findings into a persistent project skill.
|
||||
- Writes summary to `.planning/spikes/WRAP-UP-SUMMARY.md`
|
||||
- Adds auto-load routing line to project CLAUDE.md
|
||||
|
||||
Usage: `/gsd-spike-wrap-up`
|
||||
Usage: `/gsd-spike --wrap-up`
|
||||
|
||||
**`/gsd-sketch-wrap-up`**
|
||||
**`/gsd-sketch --wrap-up`**
|
||||
Package sketch design findings into a persistent project skill.
|
||||
|
||||
- Curates each sketch one-at-a-time (include/exclude/partial/revisit)
|
||||
@@ -325,27 +340,12 @@ Package sketch design findings into a persistent project skill.
|
||||
- Writes summary to `.planning/sketches/WRAP-UP-SUMMARY.md`
|
||||
- Adds auto-load routing line to project CLAUDE.md
|
||||
|
||||
Usage: `/gsd-sketch-wrap-up`
|
||||
Usage: `/gsd-sketch --wrap-up`
|
||||
|
||||
### Quick Notes
|
||||
### Capturing Ideas, Notes, and Todos
|
||||
|
||||
**`/gsd-note <text>`**
|
||||
Zero-friction idea capture — one command, instant save, no questions.
|
||||
|
||||
- Saves timestamped note to `.planning/notes/` (or `~/.claude/notes/` globally)
|
||||
- Three subcommands: append (default), list, promote
|
||||
- Promote converts a note into a structured todo
|
||||
- Works without a project (falls back to global scope)
|
||||
|
||||
Usage: `/gsd-note refactor the hook system`
|
||||
Usage: `/gsd-note list`
|
||||
Usage: `/gsd-note promote 3`
|
||||
Usage: `/gsd-note --global cross-project idea`
|
||||
|
||||
### Todo Management
|
||||
|
||||
**`/gsd-add-todo [description]`**
|
||||
Capture idea or task as todo from current conversation.
|
||||
**`/gsd-capture [description]`**
|
||||
Capture an idea or task as a structured todo from current conversation.
|
||||
|
||||
- Extracts context from conversation (or uses provided description)
|
||||
- Creates structured todo file in `.planning/todos/pending/`
|
||||
@@ -353,20 +353,33 @@ Capture idea or task as todo from current conversation.
|
||||
- Checks for duplicates before creating
|
||||
- Updates STATE.md todo count
|
||||
|
||||
Usage: `/gsd-add-todo` (infers from conversation)
|
||||
Usage: `/gsd-add-todo Add auth token refresh`
|
||||
Usage: `/gsd-capture` (infers from conversation)
|
||||
Usage: `/gsd-capture Add auth token refresh`
|
||||
|
||||
**`/gsd-check-todos [area]`**
|
||||
**`/gsd-capture --note <text>`**
|
||||
Zero-friction note capture — one command, instant save, no questions.
|
||||
|
||||
- Saves timestamped note to `.planning/notes/` (or `~/.claude/notes/` globally)
|
||||
- Three subcommands: append (default), list, promote
|
||||
- Promote converts a note into a structured todo
|
||||
- Works without a project (falls back to global scope)
|
||||
|
||||
Usage: `/gsd-capture --note refactor the hook system`
|
||||
Usage: `/gsd-capture --note list`
|
||||
Usage: `/gsd-capture --note promote 3`
|
||||
Usage: `/gsd-capture --note --global cross-project idea`
|
||||
|
||||
**`/gsd-capture --list [area]`**
|
||||
List pending todos and select one to work on.
|
||||
|
||||
- Lists all pending todos with title, area, age
|
||||
- Optional area filter (e.g., `/gsd-check-todos api`)
|
||||
- Optional area filter (e.g., `/gsd-capture --list api`)
|
||||
- Loads full context for selected todo
|
||||
- Routes to appropriate action (work now, add to phase, brainstorm)
|
||||
- Moves todo to done/ when work begins
|
||||
|
||||
Usage: `/gsd-check-todos`
|
||||
Usage: `/gsd-check-todos api`
|
||||
Usage: `/gsd-capture --list`
|
||||
Usage: `/gsd-capture --list api`
|
||||
|
||||
### User Acceptance Testing
|
||||
|
||||
@@ -420,14 +433,23 @@ Usage: `/gsd-pr-branch` or `/gsd-pr-branch main`
|
||||
|
||||
---
|
||||
|
||||
**`/gsd-plant-seed [idea]`**
|
||||
**`/gsd-capture --seed [idea]`**
|
||||
Capture a forward-looking idea with trigger conditions for automatic surfacing.
|
||||
|
||||
- Seeds preserve WHY, WHEN to surface, and breadcrumbs to related code
|
||||
- Auto-surfaces during `/gsd-new-milestone` when trigger conditions match
|
||||
- Better than deferred items — triggers are checked, not forgotten
|
||||
|
||||
Usage: `/gsd-plant-seed "add real-time notifications when we build the events system"`
|
||||
Usage: `/gsd-capture --seed "add real-time notifications when we build the events system"`
|
||||
|
||||
**`/gsd-capture --backlog [description]`**
|
||||
Add an idea to the backlog parking lot for future milestones.
|
||||
|
||||
- Creates a backlog item under 999.x numbering in ROADMAP.md
|
||||
- Reserves ideas without committing to the current milestone
|
||||
- Surface and promote later via `/gsd-review-backlog`
|
||||
|
||||
Usage: `/gsd-capture --backlog "real-time notifications when events ship"`
|
||||
|
||||
---
|
||||
|
||||
@@ -452,16 +474,6 @@ Audit milestone completion against original intent.
|
||||
|
||||
Usage: `/gsd-audit-milestone`
|
||||
|
||||
**`/gsd-plan-milestone-gaps`**
|
||||
Create phases to close gaps identified by audit.
|
||||
|
||||
- Reads MILESTONE-AUDIT.md and groups gaps into phases
|
||||
- Prioritizes by requirement priority (must/should/nice)
|
||||
- Adds gap closure phases to ROADMAP.md
|
||||
- Ready for `/gsd-plan-phase` on new phases
|
||||
|
||||
Usage: `/gsd-plan-milestone-gaps`
|
||||
|
||||
### Configuration
|
||||
|
||||
**`/gsd-settings`**
|
||||
@@ -473,15 +485,19 @@ Configure workflow toggles and model profile interactively.
|
||||
|
||||
Usage: `/gsd-settings`
|
||||
|
||||
**`/gsd-set-profile <profile>`**
|
||||
Quick switch model profile for GSD agents.
|
||||
**`/gsd-config [--profile <profile> | --advanced | --integrations]`**
|
||||
Configure GSD beyond the basic settings: model profile, advanced tuning, and third-party integrations.
|
||||
|
||||
- `--profile <profile>` — quick switch model profile (`quality | balanced | budget | inherit`)
|
||||
- `--advanced` — power-user tuning: plan bounce, timeouts, branch templates, cross-AI execution (replaces the former `gsd-settings-advanced`)
|
||||
- `--integrations` — third-party API keys, code-review CLI routing, agent-skill injection (replaces the former `gsd-settings-integrations`)
|
||||
|
||||
- `quality` — Opus everywhere except verification
|
||||
- `balanced` — Opus for planning, Sonnet for execution (default)
|
||||
- `budget` — Sonnet for writing, Haiku for research/verification
|
||||
- `inherit` — Use current session model for all agents (OpenCode `/model`)
|
||||
|
||||
Usage: `/gsd-set-profile budget`
|
||||
Usage: `/gsd-config --profile budget`
|
||||
|
||||
### Utility Commands
|
||||
|
||||
@@ -498,9 +514,12 @@ Usage: `/gsd-cleanup`
|
||||
**`/gsd-help`**
|
||||
Show this command reference.
|
||||
|
||||
**`/gsd-update`**
|
||||
**`/gsd-update [--sync] [--reapply]`**
|
||||
Update GSD to latest version with changelog preview.
|
||||
|
||||
- `--sync` — sync managed GSD skills across runtime roots (replaces the former `gsd-sync-skills`)
|
||||
- `--reapply` — reapply local modifications after an update (replaces the former `gsd-reapply-patches`)
|
||||
|
||||
- Shows installed vs latest version comparison
|
||||
- Displays changelog entries for versions you've missed
|
||||
- Highlights breaking changes
|
||||
@@ -509,13 +528,72 @@ Update GSD to latest version with changelog preview.
|
||||
|
||||
Usage: `/gsd-update`
|
||||
|
||||
**`/gsd-join-discord`**
|
||||
Join the GSD Discord community.
|
||||
## Additional Commands
|
||||
|
||||
- Get help, share what you're building, stay updated
|
||||
- Connect with other GSD users
|
||||
The commands above cover the most common day-to-day flows. Every command listed here is also a live `/gsd-*` slash command and is grouped by purpose.
|
||||
|
||||
Usage: `/gsd-join-discord`
|
||||
### Discovery & Specification
|
||||
|
||||
- **`/gsd-explore`** — Socratic ideation and idea routing. Think through ideas before committing to plans.
|
||||
- **`/gsd-spec-phase <phase> [--auto] [--text]`** — Clarify WHAT a phase delivers with ambiguity scoring; produces a SPEC.md before discuss-phase.
|
||||
- **`/gsd-ai-integration-phase [phase]`** — Generate an AI-SPEC.md design contract for phases that involve building AI systems.
|
||||
- **`/gsd-ui-phase [phase]`** — Generate UI design contract (UI-SPEC.md) for frontend phases.
|
||||
- **`/gsd-import --from <filepath>`** — Ingest external plans with conflict detection against project decisions before writing anything.
|
||||
- **`/gsd-ingest-docs [path] [--mode new|merge] [--manifest <file>] [--resolve auto|interactive]`** — Bootstrap or merge a `.planning/` setup from existing ADRs, PRDs, SPECs, and docs in a repo.
|
||||
|
||||
### Planning & Execution
|
||||
|
||||
- **`/gsd-ultraplan-phase [phase]`** — [BETA] Offload plan phase to Claude Code's ultraplan cloud; review in browser and import back.
|
||||
- **`/gsd-plan-review-convergence <phase> [--codex] [--gemini] [--claude] [--opencode] [--ollama] [--lm-studio] [--llama-cpp] [--all] [--text] [--ws <name>] [--max-cycles N]`** — Cross-AI plan convergence loop — replan with review feedback until no HIGH concerns remain. Supports both cloud reviewers (Codex/Gemini/Claude/OpenCode) and local model runtimes (Ollama, LM Studio, llama.cpp).
|
||||
- **`/gsd-autonomous [--from N] [--to N] [--only N] [--interactive]`** — Run all remaining phases autonomously: discuss → plan → execute per phase.
|
||||
|
||||
### Quality, Review & Verification
|
||||
|
||||
- **`/gsd-code-review <phase> [--depth=quick|standard|deep] [--files file1,file2,...] [--fix [--all] [--auto]]`** — Review source files changed during a phase for bugs, security issues, and code quality problems.
|
||||
- **`/gsd-secure-phase [phase]`** — Retroactively verify threat mitigations for a completed phase.
|
||||
- **`/gsd-validate-phase [phase]`** — Retroactively audit and fill Nyquist validation gaps for a completed phase.
|
||||
- **`/gsd-ui-review [phase]`** — Retroactive 6-pillar visual audit of implemented frontend code.
|
||||
- **`/gsd-eval-review [phase]`** — Audit an executed AI phase's evaluation coverage and produce an EVAL-REVIEW.md remediation plan.
|
||||
- **`/gsd-audit-fix --source <audit-uat> [--severity medium|high|all] [--max N] [--dry-run]`** — Autonomous audit-to-fix pipeline: find issues, classify, fix, test, commit.
|
||||
- **`/gsd-add-tests <phase> [additional instructions]`** — Generate tests for a completed phase based on UAT criteria and implementation.
|
||||
|
||||
### Diagnostics & Maintenance
|
||||
|
||||
- **`/gsd-health [--repair] [--context]`** — Diagnose planning directory health and optionally repair issues.
|
||||
- **`/gsd-forensics [problem description]`** — Post-mortem investigation for failed GSD workflows; diagnoses what went wrong.
|
||||
- **`/gsd-undo --last N | --phase NN | --plan NN-MM`** — Safe git revert. Roll back phase or plan commits using the phase manifest with dependency checks.
|
||||
- **`/gsd-docs-update [--force] [--verify-only]`** — Generate or update project documentation verified against the codebase.
|
||||
- **`/gsd-extract-learnings <phase>`** — Extract decisions, lessons, patterns, and surprises from completed phase artifacts.
|
||||
|
||||
### Knowledge & Context
|
||||
|
||||
- **`/gsd-graphify [build|query <term>|status|diff]`** — Build, query, and inspect the project knowledge graph in `.planning/graphs/`.
|
||||
- **`/gsd-thread [list [--open|--resolved] | close <slug> | status <slug> | name | description]`** — Manage persistent context threads for cross-session work.
|
||||
- **`/gsd-profile-user [--questionnaire] [--refresh]`** — Generate developer behavioral profile and create Claude-discoverable artifacts.
|
||||
- **`/gsd-stats`** — Display project statistics: phases, plans, requirements, git metrics, and timeline.
|
||||
|
||||
### Workflow & Orchestration
|
||||
|
||||
- **`/gsd-manager`** — Interactive command center for managing multiple phases from one terminal.
|
||||
- **`/gsd-workspace [--new | --list | --remove] [name]`** — Manage GSD workspaces: create, list, or remove isolated workspace environments.
|
||||
- **`/gsd-workstreams`** — Manage parallel workstreams: list, create, switch, status, progress, complete, and resume.
|
||||
- **`/gsd-review-backlog`** — Review and promote backlog items to active milestone.
|
||||
- **`/gsd-milestone-summary [version]`** — Generate a comprehensive project summary from milestone artifacts for team onboarding and review.
|
||||
|
||||
### Repository Integration
|
||||
|
||||
- **`/gsd-inbox [--issues] [--prs] [--label] [--close-incomplete] [--repo owner/repo]`** — Triage and review open GitHub issues and PRs against project templates and contribution guidelines.
|
||||
|
||||
### Namespace Routers (model-facing meta-skills)
|
||||
|
||||
These six skills exist primarily for the model to perform two-stage hierarchical routing across 60+ skills. You can invoke them directly when you want to browse a category interactively.
|
||||
|
||||
- **`/gsd-context`** — Codebase intelligence routing (map, graphify, docs, learnings).
|
||||
- **`/gsd-ideate`** — Exploration / capture routing (explore, sketch, spike, spec, capture).
|
||||
- **`/gsd-manage`** — Configuration and workspace routing (workstreams, thread, update, ship, inbox).
|
||||
- **`/gsd-project`** — Project-lifecycle routing (milestones, audits, summary).
|
||||
- **`/gsd-review`** — Quality-gate routing (code review, debug, audit, security, eval, ui).
|
||||
- **`/gsd-workflow`** — Phase-pipeline routing (discuss, plan, execute, verify, phase, progress).
|
||||
|
||||
## Files & Structure
|
||||
|
||||
@@ -627,7 +705,7 @@ Example config:
|
||||
**Adding urgent mid-milestone work:**
|
||||
|
||||
```
|
||||
/gsd-insert-phase 5 "Critical security fix"
|
||||
/gsd-phase --insert 5 "Critical security fix"
|
||||
/gsd-plan-phase 5.1
|
||||
/gsd-execute-phase 5.1
|
||||
```
|
||||
@@ -643,10 +721,12 @@ Example config:
|
||||
**Capturing ideas during work:**
|
||||
|
||||
```
|
||||
/gsd-add-todo # Capture from conversation context
|
||||
/gsd-add-todo Fix modal z-index # Capture with explicit description
|
||||
/gsd-check-todos # Review and work on todos
|
||||
/gsd-check-todos api # Filter by area
|
||||
/gsd-capture # Capture from conversation context
|
||||
/gsd-capture Fix modal z-index # Capture with explicit description
|
||||
/gsd-capture --note refactor auth system # Quick friction-free note
|
||||
/gsd-capture --seed "real-time notifications" # Forward-looking idea with triggers
|
||||
/gsd-capture --list # Review and work on todos
|
||||
/gsd-capture --list api # Filter by area
|
||||
```
|
||||
|
||||
**Debugging an issue:**
|
||||
|
||||
@@ -269,8 +269,8 @@ If any of these exist, surface them before questioning:
|
||||
⚡ Prior exploration detected:
|
||||
{if SPIKE_SKILL} ✓ Spike findings skill: {path} — validated patterns from experiments
|
||||
{if SKETCH_SKILL} ✓ Sketch findings skill: {path} — validated design decisions
|
||||
{if HAS_SPIKES && !SPIKE_SKILL} ◆ Raw spikes in .planning/spikes/ — consider `/gsd-spike-wrap-up` to package findings
|
||||
{if HAS_SKETCHES && !SKETCH_SKILL} ◆ Raw sketches in .planning/sketches/ — consider `/gsd-sketch-wrap-up` to package findings
|
||||
{if HAS_SPIKES && !SPIKE_SKILL} ◆ Raw spikes in .planning/spikes/ — consider `/gsd-spike --wrap-up` to package findings
|
||||
{if HAS_SKETCHES && !SKETCH_SKILL} ◆ Raw sketches in .planning/sketches/ — consider `/gsd-sketch --wrap-up` to package findings
|
||||
|
||||
These findings will be incorporated into project context and available to planning agents.
|
||||
```
|
||||
|
||||
@@ -996,7 +996,7 @@ rest become a follow-up phase
|
||||
|
||||
Use AskUserQuestion with these 3 options.
|
||||
|
||||
**If "Split":** Use `/gsd-insert-phase` to create the sub-phases, then replan each.
|
||||
**If "Split":** Use `/gsd-phase --insert` to create the sub-phases, then replan each.
|
||||
**If "Proceed":** Return to planner with instruction to attempt all items at full fidelity, accepting more plans/tasks.
|
||||
**If "Prioritize":** Use AskUserQuestion (multiSelect) to let user pick which items are "now" vs "later". Create CONTEXT.md for each sub-phase with the selected items.
|
||||
|
||||
@@ -1025,7 +1025,7 @@ Options:
|
||||
Use AskUserQuestion for each gap (or batch if multiple gaps).
|
||||
|
||||
**If "Add plan":** Return to planner (step 8) with instruction to add plans covering the missing items, preserving existing plans.
|
||||
**If "Split":** Use `/gsd-insert-phase` for overflow items, then replan.
|
||||
**If "Split":** Use `/gsd-phase --insert` for overflow items, then replan.
|
||||
**If "Defer":** Record in CONTEXT.md `## Deferred Ideas` with developer's confirmation. Proceed to step 10.
|
||||
|
||||
## 10. Spawn gsd-plan-checker Agent
|
||||
|
||||
@@ -89,6 +89,11 @@ Use this instead of manually reading/parsing ROADMAP.md.
|
||||
</step>
|
||||
|
||||
<step name="report">
|
||||
> ⚠️ Context authority: PROJECT.md, STATE.md, and ROADMAP.md are the authoritative sources
|
||||
> for project name, milestone, current phase, and next-step routing. CLAUDE.md ## Project
|
||||
> blocks are a secondary config aid that may be significantly stale — do NOT use the
|
||||
> CLAUDE.md project description as a source for any progress report field.
|
||||
|
||||
**Generate progress bar from `gsd-sdk query progress` / `progress.json`, then present rich status report:**
|
||||
|
||||
```bash
|
||||
|
||||
@@ -180,10 +180,52 @@ Quick tasks can run mid-phase - validation only checks ROADMAP.md exists, not ph
|
||||
|
||||
**If `branch_name` is empty/null:** Skip and continue on the current branch.
|
||||
|
||||
**If `branch_name` is set:** Check out the quick-task branch before any planning commits:
|
||||
**If `branch_name` is set:** Check out the quick-task branch before any planning commits.
|
||||
|
||||
The new branch must fork off the project's default branch (`origin/HEAD`), not
|
||||
off whatever HEAD happens to be checked out — otherwise consecutive quick tasks
|
||||
compound on top of each other and stay unpushed (#2916). If `$branch_name`
|
||||
already exists locally, reuse it as-is so resumed work is not rebased.
|
||||
|
||||
```bash
|
||||
git checkout -b "$branch_name" 2>/dev/null || git checkout "$branch_name"
|
||||
DEFAULT_BRANCH=$(git symbolic-ref --quiet --short refs/remotes/origin/HEAD 2>/dev/null | sed 's|^origin/||')
|
||||
DEFAULT_BRANCH=${DEFAULT_BRANCH:-main}
|
||||
|
||||
if git show-ref --verify --quiet "refs/heads/$branch_name"; then
|
||||
git switch "$branch_name" \
|
||||
|| { echo "ERROR: Could not switch to existing quick-task branch '$branch_name'." >&2; exit 1; }
|
||||
else
|
||||
# Fetch the default branch so origin/$DEFAULT_BRANCH is current. If the fetch
|
||||
# fails (offline, no remote, auth failure) AND we have no local copy of
|
||||
# origin/$DEFAULT_BRANCH to fall back on, abort — creating the branch off
|
||||
# arbitrary HEAD is exactly the bug #2916 fixed.
|
||||
if ! git fetch --quiet origin "$DEFAULT_BRANCH"; then
|
||||
if ! git show-ref --verify --quiet "refs/remotes/origin/$DEFAULT_BRANCH"; then
|
||||
echo "ERROR: Could not fetch origin/$DEFAULT_BRANCH and no local copy exists. Refusing to create '$branch_name' off the current HEAD (#2916). Resolve the remote/network issue and retry." >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "WARNING: git fetch origin $DEFAULT_BRANCH failed; using the local copy of origin/$DEFAULT_BRANCH as base." >&2
|
||||
fi
|
||||
|
||||
if [ -n "$(git status --porcelain)" ]; then
|
||||
echo "WARNING: Uncommitted changes present. Carrying them onto the new quick-task branch — they will be branched off origin/$DEFAULT_BRANCH (not the previous-task HEAD)."
|
||||
else
|
||||
# Best-effort: fast-forward the local default branch so subsequent local
|
||||
# work sees the latest tip. Failure here is non-fatal because we always
|
||||
# create the new branch directly from origin/$DEFAULT_BRANCH below.
|
||||
git switch --quiet "$DEFAULT_BRANCH" 2>/dev/null \
|
||||
&& git merge --ff-only --quiet "origin/$DEFAULT_BRANCH" 2>/dev/null \
|
||||
|| true
|
||||
fi
|
||||
|
||||
# Pin the new branch to origin/$DEFAULT_BRANCH so the start point is
|
||||
# deterministic regardless of which branch we are currently on (#2916).
|
||||
# On success HEAD is exactly at origin/$DEFAULT_BRANCH, so a post-creation
|
||||
# merge-base / "ahead-of" guard would be unreachable — the explicit base
|
||||
# argument here is the single source of correctness for #2916.
|
||||
git checkout -b "$branch_name" "origin/$DEFAULT_BRANCH" \
|
||||
|| { echo "ERROR: Could not create '$branch_name' from origin/$DEFAULT_BRANCH (#2916)." >&2; exit 1; }
|
||||
fi
|
||||
```
|
||||
|
||||
All quick-task commits for this run stay on that branch. User handles merge/rebase afterward.
|
||||
@@ -595,7 +637,21 @@ if [ "${USE_WORKTREES}" != "false" ]; then
|
||||
COMMIT_DOCS=$(gsd-sdk query config-get commit_docs 2>/dev/null || echo "true")
|
||||
if [ "$COMMIT_DOCS" != "false" ]; then
|
||||
git add "${QUICK_DIR}/${quick_id}-PLAN.md"
|
||||
git commit --no-verify -m "docs(${quick_id}): pre-dispatch plan for ${DESCRIPTION}" -- "${QUICK_DIR}/${quick_id}-PLAN.md" || true
|
||||
# No-op skip if nothing actually staged (idempotent re-runs).
|
||||
if git diff --cached --quiet -- "${QUICK_DIR}/${quick_id}-PLAN.md"; then
|
||||
echo "ℹ Pre-dispatch PLAN.md commit skipped (no staged changes)"
|
||||
else
|
||||
# Run hooks normally (#2924). If a project opts out via
|
||||
# workflow.worktree_skip_hooks=true, honor that opt-in only.
|
||||
SKIP_HOOKS=$(gsd-sdk query config-get workflow.worktree_skip_hooks 2>/dev/null || echo "false")
|
||||
if [ "$SKIP_HOOKS" = "true" ]; then
|
||||
git commit --no-verify -m "docs(${quick_id}): pre-dispatch plan for ${DESCRIPTION}" -- "${QUICK_DIR}/${quick_id}-PLAN.md" \
|
||||
|| { echo "ERROR: pre-dispatch PLAN.md commit failed (--no-verify path). Aborting before executor dispatch." >&2; exit 1; }
|
||||
else
|
||||
git commit -m "docs(${quick_id}): pre-dispatch plan for ${DESCRIPTION}" -- "${QUICK_DIR}/${quick_id}-PLAN.md" \
|
||||
|| { echo "ERROR: pre-dispatch PLAN.md commit failed — likely a pre-commit hook failure. Fix the hook output above (or set workflow.worktree_skip_hooks=true to bypass) and re-run." >&2; exit 1; }
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
```
|
||||
@@ -618,12 +674,31 @@ Execute quick task ${quick_id}.
|
||||
|
||||
${USE_WORKTREES !== "false" ? `
|
||||
<worktree_branch_check>
|
||||
FIRST ACTION before any other work: verify this worktree branch is based on the correct commit.
|
||||
Run: git merge-base HEAD ${EXPECTED_BASE}
|
||||
If the result differs from ${EXPECTED_BASE}, hard-reset to the correct base (safe — runs before any agent work):
|
||||
git reset --hard ${EXPECTED_BASE}
|
||||
Then verify: if [ "$(git rev-parse HEAD)" != "${EXPECTED_BASE}" ]; then echo "ERROR: Could not correct worktree base"; exit 1; fi
|
||||
This corrects a known issue where EnterWorktree creates branches from main instead of the feature branch HEAD (affects all platforms).
|
||||
FIRST ACTION before any other work: verify this worktree's HEAD is bound to a per-agent
|
||||
branch and that the branch is based on the correct commit.
|
||||
|
||||
Step 1 — HEAD attachment assertion (MANDATORY, runs before any reset/commit):
|
||||
HEAD_REF=$(git symbolic-ref --quiet HEAD || echo "DETACHED")
|
||||
ACTUAL_BRANCH=$(git rev-parse --abbrev-ref HEAD)
|
||||
if [ "$HEAD_REF" = "DETACHED" ] || echo "$ACTUAL_BRANCH" | grep -Eq '^(main|master|develop|trunk|release/.*)$'; then
|
||||
echo "FATAL: worktree HEAD is on '$ACTUAL_BRANCH' (expected per-agent branch like worktree-agent-*)." >&2
|
||||
echo "Refusing to commit/reset on a protected ref. DO NOT self-recover via 'git update-ref refs/heads/$ACTUAL_BRANCH' — that destroys concurrent work (#2924)." >&2
|
||||
echo "Aborting before any commits. Surface as a blocker for human review." >&2
|
||||
exit 1
|
||||
fi
|
||||
if ! echo "$ACTUAL_BRANCH" | grep -Eq '^worktree-agent-[A-Za-z0-9._/-]+$'; then
|
||||
echo "FATAL: worktree HEAD '$ACTUAL_BRANCH' is not in the worktree-agent-* namespace (Claude Code's per-agent worktree branch namespace)." >&2
|
||||
echo "Refusing to commit; surface as blocker (#2924)." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
Step 2 — Base correctness (only after Step 1 passes):
|
||||
Run: git merge-base HEAD ${EXPECTED_BASE}
|
||||
If the result differs from ${EXPECTED_BASE}, hard-reset to the correct base (safe — Step 1 confirmed HEAD is on a per-agent branch and the worktree is fresh):
|
||||
git reset --hard ${EXPECTED_BASE}
|
||||
Then verify: if [ "$(git rev-parse HEAD)" != "${EXPECTED_BASE}" ]; then echo "ERROR: Could not correct worktree base"; exit 1; fi
|
||||
|
||||
This corrects a known issue where EnterWorktree creates branches from main instead of the feature branch HEAD (#2015) and prevents the destructive HEAD-on-master self-recovery path (#2924).
|
||||
</worktree_branch_check>
|
||||
` : ''}
|
||||
|
||||
|
||||
@@ -269,17 +269,80 @@ After writing each merged file, verify that user modifications survived the merg
|
||||
|
||||
## Step 5: Hunk Verification Gate
|
||||
|
||||
Before proceeding to cleanup, evaluate the Hunk Verification Table produced in Step 4.
|
||||
Two layered gates. Both must pass before proceeding to cleanup.
|
||||
|
||||
**If the Hunk Verification Table is absent** (Step 4 did not produce it), STOP immediately and report to the user:
|
||||
```
|
||||
ERROR: Hunk Verification Table is missing. Post-merge verification was not completed.
|
||||
Rerun /gsd-update --reapply to retry with full verification.
|
||||
### 5a: Deterministic verifier (binding gate, #2969)
|
||||
|
||||
Run the deterministic verifier script. Do NOT rely solely on the free-text `verified: yes/no` Hunk Verification Table from Step 4 — bug #2969 traced repeated false-positive `verified: yes` reports to that table being filled in without an actual content-presence check. The script performs the check structurally and exits non-zero on any miss.
|
||||
|
||||
Run the verifier as a child process (the gsd-tools binary directory is not required — the script ships under `scripts/` in the source repo and is also exposed via the SDK at `sdk/dist/cli.js verify-reapply` when present):
|
||||
|
||||
```bash
|
||||
PRISTINE_DIR="${CONFIG_DIR}/gsd-pristine"
|
||||
|
||||
# Build args as a bash array so paths with spaces survive expansion intact
|
||||
# (string-concat + unquoted expansion would split incorrectly on whitespace).
|
||||
VERIFY_ARGS=(
|
||||
--patches-dir "$PATCHES_DIR"
|
||||
--config-dir "$CONFIG_DIR"
|
||||
)
|
||||
if [ -d "$PRISTINE_DIR" ]; then
|
||||
VERIFY_ARGS+=(--pristine-dir "$PRISTINE_DIR")
|
||||
fi
|
||||
VERIFY_ARGS+=(--json)
|
||||
|
||||
# Capture stdout (the structured JSON report) separately from stderr so that
|
||||
# Node warnings, deprecation notices, or stack traces do not corrupt the
|
||||
# JSON parse downstream. Stderr is preserved on the controlling terminal
|
||||
# for operator visibility.
|
||||
VERIFY_OUTPUT="$(node "${GSD_HOME}/scripts/verify-reapply-patches.cjs" "${VERIFY_ARGS[@]}")"
|
||||
VERIFY_STATUS=$?
|
||||
```
|
||||
|
||||
**If any row in the Hunk Verification Table shows `verified: no`**, STOP and report to the user:
|
||||
**If `VERIFY_STATUS` is non-zero**, STOP and report to the user, parsing the JSON output:
|
||||
|
||||
```text
|
||||
ERROR: {failures} file(s) failed deterministic post-merge verification (#2969 gate).
|
||||
|
||||
The verifier compared user-added lines (computed from the diff between
|
||||
the backup and the pristine baseline) against the merged installed file.
|
||||
Lines listed below are present in the backup but absent from the merged result.
|
||||
|
||||
For each failed file:
|
||||
{file}
|
||||
missing: {first significant missing line, up to 5 per file}
|
||||
backup: {patches_dir}/{file}
|
||||
|
||||
Resolve before proceeding:
|
||||
(a) Re-merge the missing content into the installed file by hand, or
|
||||
(b) Restore from backup: cp {patches_dir}/{file} {installed_path}
|
||||
|
||||
Then re-run /gsd-update --reapply to re-verify.
|
||||
```
|
||||
ERROR: {N} hunk(s) failed verification — content may have been dropped during merge.
|
||||
|
||||
Do not proceed to cleanup until the verifier exits 0.
|
||||
|
||||
**Only when `VERIFY_STATUS` is 0** (or when all files had zero significant user-added lines, which the verifier reports as `Failures: 0`) may execution continue to gate 5b.
|
||||
|
||||
### 5b: Hunk Verification Table review (advisory gate, #1999)
|
||||
|
||||
The Hunk Verification Table produced in Step 4 must also be reviewed before proceeding. This is advisory after the script gate but is preserved as a defense-in-depth check — if the script ever has a bug or the pristine baseline is unavailable, the table-based gate still catches obvious regressions.
|
||||
|
||||
**If the Hunk Verification Table is absent** (Step 4 silently produced nothing), STOP and report:
|
||||
|
||||
```
|
||||
ERROR: Hunk Verification Table is missing — Step 4 did not produce it.
|
||||
The deterministic verifier (5a) may still have passed, but a missing table
|
||||
means post-merge verification was not fully completed. Rerun
|
||||
/gsd-update --reapply to retry with full verification.
|
||||
```
|
||||
|
||||
A missing table absent from the workflow output cannot bypass this gate.
|
||||
|
||||
**If any row in the Hunk Verification Table shows `verified: no`**, STOP and report:
|
||||
|
||||
```
|
||||
ERROR: {N} hunk(s) failed Step 5b verification — content may have been dropped during merge.
|
||||
|
||||
Unverified hunks:
|
||||
{file} hunk {hunk_id}: signature line "{signature_line}" not found in merged output
|
||||
@@ -290,9 +353,9 @@ Review the merged file manually, then either:
|
||||
(b) Restore from backup: cp {patches_dir}/{file} {installed_path}
|
||||
```
|
||||
|
||||
Do not proceed to cleanup until the user confirms they have resolved all unverified hunks.
|
||||
Do not proceed to cleanup until both gates (5a and 5b) pass.
|
||||
|
||||
**Only when all rows show `verified: yes`** (or when all files had zero user-added hunks) may execution continue to Step 6.
|
||||
**Why both gates?** 5a (the script) is the binding gate — it does the actual substring check structurally and cannot be shortcut by the LLM. 5b (the table review) is the advisory gate — it provides a redundant safety net via the Step 4 prose summary, ensuring that even a script regression or absent pristine baseline cannot silently allow a `verified: no` row to slip past, nor can a missing table go unnoticed. Layered gates favour false-positive halts (recoverable) over silent successes on lost content (unrecoverable).
|
||||
|
||||
## Step 6: Cleanup option
|
||||
|
||||
|
||||
@@ -45,7 +45,7 @@ Parse current values (default to `true` if not present):
|
||||
- `workflow.ui_safety_gate` — prompt to run /gsd-ui-phase before planning frontend phases (default: true if absent)
|
||||
- `workflow.ai_integration_phase` — framework selection + eval strategy for AI phases (default: true if absent)
|
||||
- `workflow.tdd_mode` — enforce RED/GREEN/REFACTOR gate sequence during execute-phase (default: false if absent)
|
||||
- `workflow.code_review` — enable /gsd-code-review and /gsd-code-review-fix commands (default: true if absent)
|
||||
- `workflow.code_review` — enable /gsd-code-review and /gsd-code-review --fix commands (default: true if absent)
|
||||
- `workflow.code_review_depth` — default depth for /gsd-code-review: `quick`, `standard`, or `deep` (default: `"standard"` if absent; only relevant when `code_review` is on)
|
||||
- `workflow.ui_review` — run visual quality audit (/gsd-ui-review) in autonomous mode (default: true if absent)
|
||||
- `commit_docs` — whether `.planning/` files are committed to git (default: true if absent)
|
||||
@@ -150,7 +150,7 @@ AskUserQuestion([
|
||||
]
|
||||
},
|
||||
{
|
||||
question: "Enable Code Review? (/gsd-code-review and /gsd-code-review-fix commands)",
|
||||
question: "Enable Code Review? (/gsd-code-review and /gsd-code-review --fix commands)",
|
||||
header: "Code Review",
|
||||
multiSelect: false,
|
||||
options: [
|
||||
@@ -457,12 +457,12 @@ Display:
|
||||
These settings apply to future /gsd-plan-phase and /gsd-execute-phase runs.
|
||||
|
||||
Quick commands:
|
||||
- /gsd-settings-integrations — configure API keys (Brave/Firecrawl/Exa), review.models CLI routing, and agent_skills injection
|
||||
- /gsd-set-profile <profile> — switch model profile
|
||||
- /gsd-config --integrations — configure API keys (Brave/Firecrawl/Exa), review.models CLI routing, and agent_skills injection
|
||||
- /gsd-config --profile <profile> — switch model profile
|
||||
- /gsd-plan-phase --research — force research
|
||||
- /gsd-plan-phase --skip-research — skip research
|
||||
- /gsd-plan-phase --skip-verify — skip plan check
|
||||
- /gsd-settings-advanced — power-user tuning (plan bounce, timeouts, branch templates, cross-AI, context window)
|
||||
- /gsd-config --advanced — power-user tuning (plan bounce, timeouts, branch templates, cross-AI, context window)
|
||||
```
|
||||
</step>
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<purpose>
|
||||
Explore design directions through throwaway HTML mockups before committing to implementation.
|
||||
Each sketch produces 2-3 variants for comparison. Saves artifacts to `.planning/sketches/`.
|
||||
Companion to `/gsd-sketch-wrap-up`.
|
||||
Companion to `/gsd-sketch --wrap-up`.
|
||||
|
||||
Supports two modes:
|
||||
- **Idea mode** (default) — user describes a design idea to sketch
|
||||
@@ -331,7 +331,7 @@ After all sketches complete:
|
||||
|
||||
**Package findings** — wrap design decisions into a reusable skill
|
||||
|
||||
`/gsd-sketch-wrap-up`
|
||||
`/gsd-sketch --wrap-up`
|
||||
|
||||
───────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<purpose>
|
||||
Spike an idea through experiential exploration — build focused experiments to feel the pieces
|
||||
of a future app, validate feasibility, and produce verified knowledge for the real build.
|
||||
Saves artifacts to `.planning/spikes/`. Companion to `/gsd-spike-wrap-up`.
|
||||
Saves artifacts to `.planning/spikes/`. Companion to `/gsd-spike --wrap-up`.
|
||||
|
||||
Supports two modes:
|
||||
- **Idea mode** (default) — user describes an idea to spike
|
||||
@@ -421,7 +421,7 @@ gsd-sdk query commit "docs(spikes): update conventions" --files .planning/spikes
|
||||
|
||||
**Package findings** — wrap spike knowledge into an implementation blueprint
|
||||
|
||||
`/gsd-spike-wrap-up`
|
||||
`/gsd-spike --wrap-up`
|
||||
|
||||
───────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
101
scripts/diff-touches-shipped-paths.cjs
Normal file
101
scripts/diff-touches-shipped-paths.cjs
Normal file
@@ -0,0 +1,101 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Used by the release-sdk hotfix cherry-pick loop to decide whether a
|
||||
* candidate commit can possibly change what ships in the npm package.
|
||||
*
|
||||
* Reads a newline-separated list of paths from stdin (typically the
|
||||
* output of `git diff-tree --no-commit-id --name-only -r <SHA>`) and
|
||||
* exits with one of three codes so the workflow can distinguish a
|
||||
* legitimate "skip this commit" signal from a classifier failure.
|
||||
*
|
||||
* "Shipped" = the union of:
|
||||
* - package.json (always included by `npm pack`, regardless of `files`)
|
||||
* - every entry in package.json `files`, treated as either an exact
|
||||
* file match or a directory prefix (matching `npm pack` semantics).
|
||||
*
|
||||
* `package-lock.json` is intentionally NOT considered shipped — `npm pack`
|
||||
* excludes it from the tarball unless it's explicitly in `files`, and at
|
||||
* the time of writing this repo's `files` whitelist does not include it.
|
||||
*
|
||||
* Exit codes (the workflow MUST treat these distinctly — bug #2983):
|
||||
* 0 at least one path is shipped → cherry-pick is meaningful
|
||||
* 1 no shipped paths → CI / test / docs / planning
|
||||
* only; hotfix loop skips
|
||||
* 2 classifier error → bad/missing package.json,
|
||||
* I/O failure, or any
|
||||
* uncaught exception. The
|
||||
* workflow MUST fail-fast on
|
||||
* this code rather than
|
||||
* treating it as a skip.
|
||||
*
|
||||
* Why distinct codes: Node's default exit code for uncaught throws is 1,
|
||||
* which would otherwise be indistinguishable from the legitimate "no
|
||||
* shipped paths" result. CodeRabbit on PR #2981 / bug #2983.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const EXIT_SHIPPED = 0;
|
||||
const EXIT_NOT_SHIPPED = 1;
|
||||
const EXIT_ERROR = 2;
|
||||
|
||||
function loadShipPrefixes(pkgPath) {
|
||||
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
|
||||
const files = Array.isArray(pkg.files) ? pkg.files : [];
|
||||
return ['package.json', ...files];
|
||||
}
|
||||
|
||||
function isShipped(diffPath, shipPrefixes) {
|
||||
// Normalize Windows-style separators just in case (git always emits
|
||||
// forward slashes, but a developer running this locally on a different
|
||||
// tool's output shouldn't get a false negative).
|
||||
const p = diffPath.replace(/\\/g, '/');
|
||||
return shipPrefixes.some((s) => p === s || p.startsWith(s + '/'));
|
||||
}
|
||||
|
||||
function fail(message, err) {
|
||||
process.stderr.write(`diff-touches-shipped-paths: ${message}\n`);
|
||||
if (err && err.stack) process.stderr.write(`${err.stack}\n`);
|
||||
process.exit(EXIT_ERROR);
|
||||
}
|
||||
|
||||
function main() {
|
||||
// Surface ANY uncaught failure as exit 2 (classifier error) rather
|
||||
// than letting Node's default-1 shadow the legitimate
|
||||
// "no shipped paths" result. Bug #2983.
|
||||
process.on('uncaughtException', (err) => fail('uncaught exception', err));
|
||||
process.on('unhandledRejection', (err) => fail('unhandled rejection', err));
|
||||
|
||||
let shipPrefixes;
|
||||
try {
|
||||
const pkgPath = path.resolve(process.cwd(), 'package.json');
|
||||
shipPrefixes = loadShipPrefixes(pkgPath);
|
||||
} catch (err) {
|
||||
return fail(`failed to read package.json from ${process.cwd()}`, err);
|
||||
}
|
||||
|
||||
let buf = '';
|
||||
process.stdin.setEncoding('utf8');
|
||||
process.stdin.on('error', (err) => fail('stdin read error', err));
|
||||
process.stdin.on('data', (chunk) => {
|
||||
buf += chunk;
|
||||
});
|
||||
process.stdin.on('end', () => {
|
||||
try {
|
||||
const paths = buf.split('\n').map((s) => s.trim()).filter(Boolean);
|
||||
const hit = paths.some((p) => isShipped(p, shipPrefixes));
|
||||
process.exit(hit ? EXIT_SHIPPED : EXIT_NOT_SHIPPED);
|
||||
} catch (err) {
|
||||
fail('classification failed', err);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
main();
|
||||
}
|
||||
|
||||
module.exports = { loadShipPrefixes, isShipped, EXIT_SHIPPED, EXIT_NOT_SHIPPED, EXIT_ERROR };
|
||||
@@ -39,6 +39,29 @@ const READ_WITH_CONST_RE = /readFileSync\s*\(\s*([A-Za-z_][A-Za-z0-9_]*)\s*,/gm;
|
||||
// Matches readFileSync with an inline path.join(.cjs) as first arg
|
||||
const READ_WITH_INLINE_CJS_RE = /readFileSync\s*\([^,)]*path\.join\s*\([^)]*(?:'bin'|"bin"|'lib'|"lib"|'get-shit-done'|"get-shit-done")[^)]*['"][^'"]*\.cjs['"]/;
|
||||
|
||||
/**
|
||||
* #2962-class violations: raw text matching against process output or file
|
||||
* content. The rule from CONTRIBUTING.md "Prohibited: Raw Text Matching on
|
||||
* Test Outputs": tests assert on typed structured fields, never on rendered
|
||||
* text. Patterns below are the obvious anti-patterns; subtler hidden forms
|
||||
* (e.g. wrapping the same logic in a parser function) are still forbidden
|
||||
* by the prose rule but cannot be detected lexically without an AST.
|
||||
*/
|
||||
const RAW_MATCH_PATTERNS = [
|
||||
{
|
||||
re: /assert\.(?:match|doesNotMatch)\s*\(\s*[A-Za-z_$][A-Za-z0-9_$]*\.(?:stdout|stderr)\b/,
|
||||
label: 'assert.match/doesNotMatch on .stdout/.stderr (emit --json from the SUT and assert on typed fields)',
|
||||
},
|
||||
{
|
||||
re: /\.(?:stdout|stderr)\.(?:includes|startsWith|endsWith)\s*\(/,
|
||||
label: '.stdout/.stderr substring match (emit --json and assert on typed fields)',
|
||||
},
|
||||
{
|
||||
re: /readFileSync\s*\([^)]*\)\s*\.(?:includes|startsWith|endsWith)\s*\(/,
|
||||
label: 'readFileSync(...).<includes|startsWith|endsWith> (expose an IR from production code; assert on its fields)',
|
||||
},
|
||||
];
|
||||
|
||||
function setFromMatches(content, re) {
|
||||
const found = new Set();
|
||||
let m;
|
||||
@@ -53,13 +76,14 @@ function check(filepath) {
|
||||
|
||||
if (ALLOW_ANNOTATION.test(content)) return null;
|
||||
|
||||
const violations = [];
|
||||
|
||||
// Pattern A: readFileSync(path.join(..., 'foo.cjs'), ...)
|
||||
if (READ_WITH_INLINE_CJS_RE.test(content)) {
|
||||
return {
|
||||
file: rel,
|
||||
violations.push({
|
||||
reason: 'readFileSync with inline .cjs path literal',
|
||||
fix: 'Replace with runGsdTools() behavioral test, or add // allow-test-rule: <reason>',
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// Pattern B: const FOO_PATH = path.join(..., 'foo.cjs') + readFileSync(FOO_PATH, ...)
|
||||
@@ -68,15 +92,26 @@ function check(filepath) {
|
||||
const readConsts = setFromMatches(content, READ_WITH_CONST_RE);
|
||||
const overlap = [...cjsConsts].filter(c => readConsts.has(c));
|
||||
if (overlap.length > 0) {
|
||||
return {
|
||||
file: rel,
|
||||
violations.push({
|
||||
reason: `source .cjs path constant(s) used in readFileSync: ${overlap.join(', ')}`,
|
||||
fix: 'Replace with runGsdTools() behavioral test, or add // allow-test-rule: <reason>',
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
// Patterns C..E: raw text matching against process output or file content.
|
||||
// See CONTRIBUTING.md "Prohibited: Raw Text Matching on Test Outputs".
|
||||
for (const { re, label } of RAW_MATCH_PATTERNS) {
|
||||
if (re.test(content)) {
|
||||
violations.push({
|
||||
reason: label,
|
||||
fix: 'Expose typed IR from production code; assert on structured fields. Or add // allow-test-rule: <reason>',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (violations.length === 0) return null;
|
||||
return { file: rel, violations };
|
||||
}
|
||||
|
||||
function findTestFiles(dir) {
|
||||
@@ -101,12 +136,17 @@ if (violations.length === 0) {
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
process.stderr.write(`\nERROR lint-no-source-grep: ${violations.length} violation(s) found\n\n`);
|
||||
for (const v of violations) {
|
||||
process.stderr.write(` ${v.file}\n`);
|
||||
process.stderr.write(` Problem : ${v.reason}\n`);
|
||||
process.stderr.write(` Fix : ${v.fix}\n\n`);
|
||||
const totalIssues = violations.reduce((n, v) => n + v.violations.length, 0);
|
||||
process.stderr.write(`\nERROR lint-no-source-grep: ${totalIssues} violation(s) across ${violations.length} file(s)\n\n`);
|
||||
for (const f of violations) {
|
||||
process.stderr.write(` ${f.file}\n`);
|
||||
for (const v of f.violations) {
|
||||
process.stderr.write(` Problem : ${v.reason}\n`);
|
||||
process.stderr.write(` Fix : ${v.fix}\n`);
|
||||
}
|
||||
process.stderr.write('\n');
|
||||
}
|
||||
process.stderr.write('See CONTRIBUTING.md "Prohibited: Source-Grep Tests" for guidance.\n');
|
||||
process.stderr.write('See CONTRIBUTING.md "Prohibited: Source-Grep Tests" and\n');
|
||||
process.stderr.write('"Prohibited: Raw Text Matching on Test Outputs" for guidance.\n');
|
||||
process.stderr.write('Structural tests that legitimately read source files: add // allow-test-rule: <reason>\n\n');
|
||||
process.exit(1);
|
||||
|
||||
247
scripts/verify-reapply-patches.cjs
Executable file
247
scripts/verify-reapply-patches.cjs
Executable file
@@ -0,0 +1,247 @@
|
||||
#!/usr/bin/env node
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Deterministic verifier for the /gsd-reapply-patches Step 5 "Hunk Verification
|
||||
* Gate". For each backed-up patch file, asserts that the user's added lines
|
||||
* (computed from a real diff against the pristine baseline, not from the
|
||||
* LLM's prose summary) survive into the merged output.
|
||||
*
|
||||
* Usage:
|
||||
* node scripts/verify-reapply-patches.cjs \
|
||||
* --patches-dir <path> \ # gsd-local-patches/
|
||||
* --config-dir <path> \ # ~/.claude (or runtime equivalent)
|
||||
* [--pristine-dir <path>] # gsd-pristine/; if absent, falls back to
|
||||
* # treating every significant backup line as
|
||||
* # required (over-broad but safe for #2969:
|
||||
* # false-positive halts beat silent successes
|
||||
* # on lost content)
|
||||
* [--json] # emit JSON report instead of human text
|
||||
*
|
||||
* Exit codes:
|
||||
* 0 — every user-added line is present in the merged file (gate passes)
|
||||
* 1 — at least one missing line in at least one file (gate fails)
|
||||
* 2 — usage / structural error (e.g. patches dir missing)
|
||||
*
|
||||
* Bug #2969: the Step 5 gate previously trusted Claude's free-text "verified:
|
||||
* yes/no" reporting per hunk. The LLM was filling in `yes` even when content
|
||||
* had been silently dropped. Moving the check to a deterministic script is the
|
||||
* durability fix.
|
||||
*/
|
||||
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const SIGNIFICANT_MIN_CHARS = 12;
|
||||
|
||||
function parseArgs(argv) {
|
||||
const opts = { patchesDir: null, configDir: null, pristineDir: null, json: false };
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
const arg = argv[i];
|
||||
if (arg === '--patches-dir') opts.patchesDir = argv[++i];
|
||||
else if (arg === '--config-dir') opts.configDir = argv[++i];
|
||||
else if (arg === '--pristine-dir') opts.pristineDir = argv[++i];
|
||||
else if (arg === '--json') opts.json = true;
|
||||
else if (arg === '--help' || arg === '-h') {
|
||||
process.stdout.write(
|
||||
'usage: verify-reapply-patches.cjs --patches-dir <path> --config-dir <path> [--pristine-dir <path>] [--json]\n',
|
||||
);
|
||||
process.exit(0);
|
||||
} else {
|
||||
process.stderr.write(`unknown argument: ${arg}\n`);
|
||||
process.exit(2);
|
||||
}
|
||||
}
|
||||
return opts;
|
||||
}
|
||||
|
||||
function isSignificantLine(line) {
|
||||
const trimmed = line.trim();
|
||||
if (trimmed.length < SIGNIFICANT_MIN_CHARS) return false;
|
||||
// Pure punctuation / closing brackets carry too little structural info to
|
||||
// reliably distinguish a survived hunk from incidental similarity.
|
||||
if (/^[\s})\];,]+$/.test(trimmed)) return false;
|
||||
// Generic decorative comments like `// ----` similarly fail the test.
|
||||
if (/^[\s\-=#*/]+$/.test(trimmed)) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Walk a directory, returning every file's path relative to the root.
|
||||
*/
|
||||
function walk(rootDir, relPrefix = '') {
|
||||
const out = [];
|
||||
if (!fs.existsSync(rootDir)) return out;
|
||||
for (const entry of fs.readdirSync(rootDir, { withFileTypes: true })) {
|
||||
const rel = relPrefix ? path.join(relPrefix, entry.name) : entry.name;
|
||||
const abs = path.join(rootDir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
out.push(...walk(abs, rel));
|
||||
} else if (entry.isFile()) {
|
||||
out.push(rel);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the set of "user-added" lines: lines present in the backup but
|
||||
* absent from the pristine baseline. If no pristine is provided, falls back
|
||||
* to using every significant line in the backup (over-broad but safe — favours
|
||||
* false-positive failures over silent successes, which is the right side to
|
||||
* err on for #2969).
|
||||
*/
|
||||
function computeUserAddedLines(backupContent, pristineContent) {
|
||||
const backupLines = backupContent.split(/\r?\n/);
|
||||
if (!pristineContent) {
|
||||
return backupLines.filter(isSignificantLine);
|
||||
}
|
||||
const pristineSet = new Set(pristineContent.split(/\r?\n/));
|
||||
return backupLines.filter((line) => isSignificantLine(line) && !pristineSet.has(line));
|
||||
}
|
||||
|
||||
/**
|
||||
* Stable reason codes for the per-file result. Tests assert via
|
||||
* `assert.equal(result.reason, REASON.X)` rather than regex-matching prose,
|
||||
* so the diagnostic surface is a typed enum, not free text.
|
||||
*
|
||||
* Adding a new reason requires updating the REASON map AND the tests'
|
||||
* shape assertion that locks the documented set of codes.
|
||||
*/
|
||||
const REASON = Object.freeze({
|
||||
OK_NO_USER_LINES_VS_PRISTINE: 'ok_no_user_lines_vs_pristine',
|
||||
OK_NO_SIGNIFICANT_BACKUP_LINES: 'ok_no_significant_backup_lines',
|
||||
FAIL_INSTALLED_MISSING: 'fail_installed_missing',
|
||||
FAIL_INSTALLED_NOT_REGULAR_FILE: 'fail_installed_not_regular_file',
|
||||
FAIL_READ_ERROR: 'fail_read_error',
|
||||
FAIL_USER_LINES_MISSING: 'fail_user_lines_missing',
|
||||
});
|
||||
|
||||
function verifyFile({ relPath, patchesDir, configDir, pristineDir }) {
|
||||
const backupPath = path.join(patchesDir, relPath);
|
||||
const installedPath = path.join(configDir, relPath);
|
||||
const result = { file: relPath, status: 'ok', missing: [], reason: null };
|
||||
|
||||
if (!fs.existsSync(backupPath) || !fs.statSync(backupPath).isFile()) {
|
||||
return result; // walked entry no longer exists — non-fatal
|
||||
}
|
||||
|
||||
// Installed path checks: must exist, must be a regular file, must be
|
||||
// readable. Anything else is a fail-with-diagnostic, not a crash that
|
||||
// aborts the whole gate run and drops structured output.
|
||||
let installedStat;
|
||||
try {
|
||||
installedStat = fs.statSync(installedPath);
|
||||
} catch {
|
||||
result.status = 'fail';
|
||||
result.reason = REASON.FAIL_INSTALLED_MISSING;
|
||||
return result;
|
||||
}
|
||||
if (!installedStat.isFile()) {
|
||||
result.status = 'fail';
|
||||
result.reason = REASON.FAIL_INSTALLED_NOT_REGULAR_FILE;
|
||||
return result;
|
||||
}
|
||||
|
||||
let backupContent;
|
||||
let installedContent;
|
||||
try {
|
||||
backupContent = fs.readFileSync(backupPath, 'utf8');
|
||||
installedContent = fs.readFileSync(installedPath, 'utf8');
|
||||
} catch {
|
||||
result.status = 'fail';
|
||||
result.reason = REASON.FAIL_READ_ERROR;
|
||||
return result;
|
||||
}
|
||||
|
||||
let pristineContent = null;
|
||||
if (pristineDir) {
|
||||
const pristinePath = path.join(pristineDir, relPath);
|
||||
try {
|
||||
const stat = fs.statSync(pristinePath);
|
||||
if (stat.isFile()) {
|
||||
pristineContent = fs.readFileSync(pristinePath, 'utf8');
|
||||
}
|
||||
} catch {
|
||||
// Pristine missing or unreadable — fall through to over-broad mode.
|
||||
}
|
||||
}
|
||||
|
||||
const userAdded = computeUserAddedLines(backupContent, pristineContent);
|
||||
if (userAdded.length === 0) {
|
||||
// Backup and pristine match exactly (or no significant content) — nothing
|
||||
// to verify but also nothing to lose. Report as ok with diagnostic code.
|
||||
result.reason = pristineContent
|
||||
? REASON.OK_NO_USER_LINES_VS_PRISTINE
|
||||
: REASON.OK_NO_SIGNIFICANT_BACKUP_LINES;
|
||||
return result;
|
||||
}
|
||||
|
||||
for (const line of userAdded) {
|
||||
if (!installedContent.includes(line)) {
|
||||
result.missing.push(line.trim());
|
||||
}
|
||||
}
|
||||
if (result.missing.length > 0) {
|
||||
result.status = 'fail';
|
||||
result.reason = REASON.FAIL_USER_LINES_MISSING;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function main() {
|
||||
const opts = parseArgs(process.argv.slice(2));
|
||||
if (!opts.patchesDir || !opts.configDir) {
|
||||
process.stderr.write('--patches-dir and --config-dir are required\n');
|
||||
process.exit(2);
|
||||
}
|
||||
if (!fs.existsSync(opts.patchesDir)) {
|
||||
process.stderr.write(`patches dir not found: ${opts.patchesDir}\n`);
|
||||
process.exit(2);
|
||||
}
|
||||
if (!fs.existsSync(opts.configDir)) {
|
||||
process.stderr.write(`config dir not found: ${opts.configDir}\n`);
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
const files = walk(opts.patchesDir).filter((f) => !f.endsWith('backup-meta.json'));
|
||||
const results = files.map((relPath) =>
|
||||
verifyFile({
|
||||
relPath,
|
||||
patchesDir: opts.patchesDir,
|
||||
configDir: opts.configDir,
|
||||
pristineDir: opts.pristineDir,
|
||||
}),
|
||||
);
|
||||
|
||||
const failures = results.filter((r) => r.status === 'fail');
|
||||
|
||||
if (opts.json) {
|
||||
process.stdout.write(JSON.stringify({ checked: results.length, failures: failures.length, results }, null, 2) + '\n');
|
||||
} else {
|
||||
process.stdout.write(`# Hunk Verification Gate (#2969)\n\n`);
|
||||
process.stdout.write(`Checked: ${results.length} file(s)\n`);
|
||||
process.stdout.write(`Failures: ${failures.length}\n\n`);
|
||||
if (failures.length > 0) {
|
||||
process.stdout.write(`## Files with missing user-added content\n\n`);
|
||||
for (const r of failures) {
|
||||
process.stdout.write(`- ${r.file}\n`);
|
||||
if (r.reason) process.stdout.write(` reason: ${r.reason}\n`);
|
||||
for (const line of r.missing.slice(0, 5)) {
|
||||
process.stdout.write(` missing: ${line}\n`);
|
||||
}
|
||||
if (r.missing.length > 5) {
|
||||
process.stdout.write(` …and ${r.missing.length - 5} more line(s)\n`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
process.exit(failures.length > 0 ? 1 : 0);
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
main();
|
||||
}
|
||||
|
||||
module.exports = { computeUserAddedLines, isSignificantLine, verifyFile, walk, REASON };
|
||||
@@ -443,7 +443,13 @@ export async function main(argv: string[] = process.argv.slice(2)): Promise<void
|
||||
output = extractField(output, pickField);
|
||||
}
|
||||
|
||||
console.log(JSON.stringify(output, null, 2));
|
||||
// Handlers can signal format:'text' to emit a raw string (e.g. agent-skills
|
||||
// emits an <agent_skills> XML block workflows embed via $(...) substitution).
|
||||
if (!pickField && result.format === 'text' && typeof output === 'string') {
|
||||
process.stdout.write(output);
|
||||
} else {
|
||||
console.log(JSON.stringify(output, null, 2));
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
if (err instanceof GSDError) {
|
||||
|
||||
@@ -28,6 +28,7 @@ export const VALID_CONFIG_KEYS: ReadonlySet<string> = new Set([
|
||||
'workflow.skip_discuss',
|
||||
'workflow.auto_prune_state',
|
||||
'workflow.use_worktrees',
|
||||
'workflow.worktree_skip_hooks',
|
||||
'workflow.code_review',
|
||||
'workflow.code_review_depth',
|
||||
'workflow.code_review_command',
|
||||
|
||||
@@ -8,11 +8,15 @@
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { execSync } from 'node:child_process';
|
||||
import { join, resolve } from 'node:path';
|
||||
import { tmpdir, homedir } from 'node:os';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import { agentSkills } from './skills.js';
|
||||
|
||||
const CLI = resolve(fileURLToPath(import.meta.url), '../../../dist/cli.js');
|
||||
|
||||
async function writeSkill(rootDir: string, name: string) {
|
||||
const skillDir = join(rootDir, name);
|
||||
await mkdir(skillDir, { recursive: true });
|
||||
@@ -120,4 +124,75 @@ describe('agentSkills', () => {
|
||||
const r = await agentSkills(['gsd-planner'], tmpDir);
|
||||
expect(r.data).toBe('');
|
||||
});
|
||||
|
||||
it('signals format:"text" for non-empty blocks (used by CLI dispatcher)', async () => {
|
||||
await writeSkill(join(tmpDir, '.claude', 'skills'), 'a-skill');
|
||||
await writeConfig(tmpDir, {
|
||||
agent_skills: { 'gsd-planner': '.claude/skills/a-skill' },
|
||||
});
|
||||
|
||||
const r = await agentSkills(['gsd-planner'], tmpDir);
|
||||
expect(r.format).toBe('text');
|
||||
});
|
||||
|
||||
it('does not signal format:"text" for empty result', async () => {
|
||||
const r = await agentSkills(['gsd-planner'], tmpDir);
|
||||
expect(r.format).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── CLI stdout integration ─────────────────────────────────────────────────
|
||||
// Regression guard for the JSON-wrapping bug (#2914): the CLI must emit the
|
||||
// raw <agent_skills> block to stdout, not a JSON-quoted string. Spawns the
|
||||
// CLI as a child process so the full dispatch path (including cli.ts format
|
||||
// handling) is exercised.
|
||||
|
||||
describe('agentSkills CLI stdout', () => {
|
||||
let tmpDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
tmpDir = await mkdtemp(join(tmpdir(), 'gsd-skills-cli-'));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('writes raw <agent_skills> block to stdout — not JSON-wrapped', async () => {
|
||||
const skillDir = join(tmpDir, '.claude', 'skills', 'cli-skill');
|
||||
await mkdir(skillDir, { recursive: true });
|
||||
await writeFile(join(skillDir, 'SKILL.md'), '# cli-skill\n');
|
||||
await mkdir(join(tmpDir, '.planning'), { recursive: true });
|
||||
await writeFile(
|
||||
join(tmpDir, '.planning', 'config.json'),
|
||||
JSON.stringify({ agent_skills: { 'gsd-planner': '.claude/skills/cli-skill' } }),
|
||||
);
|
||||
|
||||
const stdout = execSync(
|
||||
`node "${CLI}" query --project-dir "${tmpDir}" agent-skills gsd-planner`,
|
||||
{ encoding: 'utf-8' },
|
||||
);
|
||||
|
||||
expect(stdout).toBe(
|
||||
'<agent_skills>\nRead these user-configured skills:\n- @.claude/skills/cli-skill/SKILL.md\n</agent_skills>',
|
||||
);
|
||||
});
|
||||
|
||||
it('emits empty output (no JSON null) when agent type is unmapped', async () => {
|
||||
await mkdir(join(tmpDir, '.planning'), { recursive: true });
|
||||
await writeFile(
|
||||
join(tmpDir, '.planning', 'config.json'),
|
||||
JSON.stringify({ agent_skills: { 'gsd-executor': ['.claude/skills/foo'] } }),
|
||||
);
|
||||
|
||||
const stdout = execSync(
|
||||
`node "${CLI}" query --project-dir "${tmpDir}" agent-skills gsd-planner`,
|
||||
{ encoding: 'utf-8' },
|
||||
);
|
||||
|
||||
// Unmapped agent → empty string → CLI falls through to JSON (""), not raw
|
||||
// text. This is acceptable: workflows that embed an empty var are no-ops.
|
||||
// The important invariant is that a MAPPED agent never gets JSON-wrapped.
|
||||
expect(stdout.trim()).toBe('""');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -123,7 +123,9 @@ export const agentSkills: QueryHandler = async (args, projectDir) => {
|
||||
if (validEntries.length === 0) return { data: '' };
|
||||
|
||||
const lines = validEntries.map((e) => `- @${e.ref}`).join('\n');
|
||||
return {
|
||||
data: `<agent_skills>\nRead these user-configured skills:\n${lines}\n</agent_skills>`,
|
||||
};
|
||||
const block = `<agent_skills>\nRead these user-configured skills:\n${lines}\n</agent_skills>`;
|
||||
// Signal the CLI dispatcher to write raw text — workflows embed the result
|
||||
// with `$(gsd-sdk query agent-skills …)` and need the XML block verbatim, not
|
||||
// a JSON-quoted string (see cli.ts QueryResult.format handling).
|
||||
return { data: block, format: 'text' };
|
||||
};
|
||||
|
||||
@@ -24,6 +24,16 @@ import { GSDError, ErrorClassification } from '../errors.js';
|
||||
/** Structured result returned by all query handlers. */
|
||||
export interface QueryResult<T = unknown> {
|
||||
data: T;
|
||||
/**
|
||||
* Output format hint for the CLI dispatcher.
|
||||
* `'text'` — write `data` as-is to stdout (no JSON-stringify).
|
||||
* `'json'` (default) — JSON-stringify as usual.
|
||||
*
|
||||
* Only meaningful when `data` is a string and the consumer is the CLI.
|
||||
* Used by `agent-skills` so workflows embedding `$(gsd-sdk query …)` receive
|
||||
* a raw `<agent_skills>` XML block rather than a JSON-quoted string.
|
||||
*/
|
||||
format?: 'json' | 'text';
|
||||
}
|
||||
|
||||
/** Signature for a query handler function. */
|
||||
|
||||
@@ -11,6 +11,10 @@
|
||||
|
||||
'use strict';
|
||||
|
||||
// allow-test-rule: pending-migration-to-typed-ir [#2974]
|
||||
// Tracked in #2974 for migration to typed-IR assertions per CONTRIBUTING.md
|
||||
// "Prohibited: Raw Text Matching on Test Outputs". Do not copy this pattern.
|
||||
|
||||
const { test, describe, before } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('fs');
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
'use strict';
|
||||
|
||||
// allow-test-rule: pending-migration-to-typed-ir [#2974]
|
||||
// Tracked in #2974 for migration to typed-IR assertions per CONTRIBUTING.md
|
||||
// "Prohibited: Raw Text Matching on Test Outputs". Do not copy this pattern.
|
||||
|
||||
/**
|
||||
* Regression test for #2687 — loadConfig must not emit "unknown config key"
|
||||
* warnings for keys that are registered in DYNAMIC_KEY_PATTERNS (e.g. review,
|
||||
|
||||
@@ -17,6 +17,10 @@
|
||||
|
||||
'use strict';
|
||||
|
||||
// allow-test-rule: pending-migration-to-typed-ir [#2974]
|
||||
// Tracked in #2974 for migration to typed-IR assertions per CONTRIBUTING.md
|
||||
// "Prohibited: Raw Text Matching on Test Outputs". Do not copy this pattern.
|
||||
|
||||
const { describe, test, beforeEach, afterEach } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
|
||||
@@ -24,6 +24,10 @@
|
||||
|
||||
'use strict';
|
||||
|
||||
// allow-test-rule: pending-migration-to-typed-ir [#2974]
|
||||
// Tracked in #2974 for migration to typed-IR assertions per CONTRIBUTING.md
|
||||
// "Prohibited: Raw Text Matching on Test Outputs". Do not copy this pattern.
|
||||
|
||||
const { describe, test, before, after } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('fs');
|
||||
|
||||
120
tests/bug-2911-audit-open-output-shape.test.cjs
Normal file
120
tests/bug-2911-audit-open-output-shape.test.cjs
Normal file
@@ -0,0 +1,120 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Regression test for #2911.
|
||||
*
|
||||
* Two bugs in the `audit-open` dispatch case in bin/gsd-tools.cjs:
|
||||
*
|
||||
* 1. Bare `output(...)` calls (only `core.output` is in scope) → ReferenceError.
|
||||
* 2. Even after switching to `core.output(formatted, raw)`, the human-readable
|
||||
* branch JSON-stringifies the formatted string because `core.output` only
|
||||
* bypasses JSON encoding when called as `core.output(null, true, rawValue)`.
|
||||
* Result: stdout contains `"━━━…\n Milestone Close: …\n…"` (a JSON string
|
||||
* literal) instead of the rendered report.
|
||||
*
|
||||
* The shape assertions below catch both regressions structurally — never via
|
||||
* substring matching on serialized output:
|
||||
*
|
||||
* - text mode: parse stdout as a sequence of lines and assert the expected
|
||||
* section headers exist as standalone lines (i.e. raw text, not escaped).
|
||||
* If the report is JSON-stringified, the stdout is a single line wrapped
|
||||
* in double quotes with `\n` escapes — line-array assertions fail.
|
||||
* - --json mode: JSON.parse the stdout and assert the keys returned by
|
||||
* `auditOpenArtifacts(cwd)` (scanned_at, has_open_items, counts, items)
|
||||
* are present and well-typed.
|
||||
*/
|
||||
|
||||
const { test, describe, beforeEach, afterEach } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs');
|
||||
|
||||
describe('audit-open — output shape (#2911)', () => {
|
||||
let tmpDir;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = createTempProject('gsd-bug-2911-');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup(tmpDir);
|
||||
});
|
||||
|
||||
test('text mode emits the formatted report as raw text (not JSON-encoded)', () => {
|
||||
const result = runGsdTools('audit-open', tmpDir);
|
||||
assert.ok(
|
||||
result.success,
|
||||
`audit-open must not crash. stderr: ${result.error}`
|
||||
);
|
||||
|
||||
const lines = result.output.split('\n').map(l => l.trim()).filter(Boolean);
|
||||
|
||||
// The first non-empty line must be the divider character row, *not* a
|
||||
// JSON-encoded string starting with a quote. If core.output JSON-stringified
|
||||
// the formatted report, the entire payload sits on one line wrapped in
|
||||
// double quotes ("━━━…\n…").
|
||||
assert.ok(
|
||||
!result.output.startsWith('"'),
|
||||
'text-mode stdout must not begin with a JSON quote (would mean the report was JSON.stringified)'
|
||||
);
|
||||
assert.ok(
|
||||
!result.output.includes('\\n'),
|
||||
'text-mode stdout must not contain literal "\\n" sequences (would mean the report was JSON.stringified)'
|
||||
);
|
||||
|
||||
// Section headers from formatAuditReport that must appear as standalone lines.
|
||||
assert.ok(
|
||||
lines.includes('Milestone Close: Open Artifact Audit'),
|
||||
`expected report title as a standalone line; got lines: ${JSON.stringify(lines.slice(0, 5))}`
|
||||
);
|
||||
assert.ok(
|
||||
lines.includes('All artifact types clear. Safe to proceed.'),
|
||||
`expected the empty-state line as standalone text; got lines: ${JSON.stringify(lines)}`
|
||||
);
|
||||
});
|
||||
|
||||
test('--json mode emits parseable JSON matching auditOpenArtifacts shape', () => {
|
||||
const result = runGsdTools(['audit-open', '--json'], tmpDir);
|
||||
assert.ok(
|
||||
result.success,
|
||||
`audit-open --json must not crash. stderr: ${result.error}`
|
||||
);
|
||||
|
||||
let parsed;
|
||||
assert.doesNotThrow(
|
||||
() => { parsed = JSON.parse(result.output); },
|
||||
'audit-open --json must emit valid JSON (not a doubly-stringified string)'
|
||||
);
|
||||
|
||||
assert.equal(typeof parsed, 'object', 'parsed payload must be an object');
|
||||
assert.ok(parsed !== null, 'parsed payload must not be null');
|
||||
|
||||
// Shape contract from auditOpenArtifacts() in get-shit-done/bin/lib/audit.cjs.
|
||||
assert.equal(typeof parsed.scanned_at, 'string', 'must include scanned_at ISO timestamp');
|
||||
assert.equal(typeof parsed.has_open_items, 'boolean', 'must include has_open_items boolean');
|
||||
assert.equal(typeof parsed.counts, 'object', 'must include counts object');
|
||||
assert.equal(typeof parsed.items, 'object', 'must include items object');
|
||||
|
||||
const expectedCountKeys = [
|
||||
'debug_sessions', 'quick_tasks', 'threads', 'todos',
|
||||
'seeds', 'uat_gaps', 'verification_gaps', 'context_questions', 'total',
|
||||
];
|
||||
for (const key of expectedCountKeys) {
|
||||
assert.equal(
|
||||
typeof parsed.counts[key], 'number',
|
||||
`counts.${key} must be a number`
|
||||
);
|
||||
}
|
||||
|
||||
const expectedItemKeys = [
|
||||
'debug_sessions', 'quick_tasks', 'threads', 'todos',
|
||||
'seeds', 'uat_gaps', 'verification_gaps', 'context_questions',
|
||||
];
|
||||
for (const key of expectedItemKeys) {
|
||||
assert.ok(
|
||||
Array.isArray(parsed.items[key]),
|
||||
`items.${key} must be an array`
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
154
tests/bug-2912-progress-context-authority.test.cjs
Normal file
154
tests/bug-2912-progress-context-authority.test.cjs
Normal file
@@ -0,0 +1,154 @@
|
||||
/**
|
||||
* Tests for issue #2912 — /gsd-progress can use stale CLAUDE.md project block
|
||||
* instead of GSD tracking files as authoritative source.
|
||||
*
|
||||
* Fix: the `report` step in get-shit-done/workflows/progress.md must contain
|
||||
* an explicit "context authority" directive establishing PROJECT.md, STATE.md,
|
||||
* and ROADMAP.md as the authoritative sources for the progress report, and
|
||||
* forbidding the use of CLAUDE.md `## Project` blocks as a source for any
|
||||
* report field.
|
||||
*
|
||||
* These tests parse the workflow markdown structurally (locate the
|
||||
* <step name="report"> ... </step> block, then locate the blockquote-style
|
||||
* directive inside it). They do NOT use `.includes()` over the whole file.
|
||||
*/
|
||||
|
||||
const { test, describe } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const WORKFLOW_PATH = path.join(
|
||||
__dirname,
|
||||
'..',
|
||||
'get-shit-done',
|
||||
'workflows',
|
||||
'progress.md'
|
||||
);
|
||||
|
||||
/** Extract the body of a <step name="..."> ... </step> block by parsing tags. */
|
||||
function extractStep(workflow, stepName) {
|
||||
const openTag = `<step name="${stepName}">`;
|
||||
const start = workflow.indexOf(openTag);
|
||||
if (start === -1) return null;
|
||||
const bodyStart = start + openTag.length;
|
||||
// Find the matching </step> — workflow steps in this file do not nest.
|
||||
const end = workflow.indexOf('</step>', bodyStart);
|
||||
if (end === -1) return null;
|
||||
return workflow.slice(bodyStart, end);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract contiguous markdown blockquote blocks from a chunk of markdown.
|
||||
* A blockquote is a run of consecutive lines starting with '>' (after any
|
||||
* leading whitespace). Returns the joined text of each blockquote with the
|
||||
* leading '>' markers stripped.
|
||||
*/
|
||||
function extractBlockquotes(md) {
|
||||
const lines = md.split(/\r?\n/);
|
||||
const blocks = [];
|
||||
let current = null;
|
||||
for (const line of lines) {
|
||||
const m = line.match(/^\s*>\s?(.*)$/);
|
||||
if (m) {
|
||||
if (current === null) current = [];
|
||||
current.push(m[1]);
|
||||
} else {
|
||||
if (current !== null) {
|
||||
blocks.push(current.join('\n'));
|
||||
current = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (current !== null) blocks.push(current.join('\n'));
|
||||
return blocks;
|
||||
}
|
||||
|
||||
describe('#2912: progress report step has explicit context-authority directive', () => {
|
||||
test('progress.md workflow file exists and is readable', () => {
|
||||
const stat = fs.statSync(WORKFLOW_PATH);
|
||||
assert.ok(stat.isFile(), 'workflow file should exist');
|
||||
});
|
||||
|
||||
test('progress.md has a <step name="report"> section', () => {
|
||||
const workflow = fs.readFileSync(WORKFLOW_PATH, 'utf8');
|
||||
const reportStep = extractStep(workflow, 'report');
|
||||
assert.ok(reportStep, 'workflow should contain a report step');
|
||||
assert.ok(reportStep.length > 0, 'report step body should not be empty');
|
||||
});
|
||||
|
||||
test('report step contains a blockquote directive about context authority', () => {
|
||||
const workflow = fs.readFileSync(WORKFLOW_PATH, 'utf8');
|
||||
const reportStep = extractStep(workflow, 'report');
|
||||
assert.ok(reportStep, 'report step must be present');
|
||||
|
||||
const blockquotes = extractBlockquotes(reportStep);
|
||||
assert.ok(
|
||||
blockquotes.length > 0,
|
||||
'report step should contain at least one blockquote (the context-authority directive)'
|
||||
);
|
||||
|
||||
const authorityBlock = blockquotes.find((b) => /context\s+authority/i.test(b));
|
||||
assert.ok(
|
||||
authorityBlock,
|
||||
'report step should contain a blockquote whose text includes "Context authority"'
|
||||
);
|
||||
});
|
||||
|
||||
test('context-authority directive names PROJECT.md, STATE.md, and ROADMAP.md as authoritative', () => {
|
||||
const workflow = fs.readFileSync(WORKFLOW_PATH, 'utf8');
|
||||
const reportStep = extractStep(workflow, 'report');
|
||||
assert.ok(reportStep, 'report step must exist');
|
||||
const blockquotes = extractBlockquotes(reportStep);
|
||||
const authorityBlock = blockquotes.find((b) => /context\s+authority/i.test(b));
|
||||
assert.ok(authorityBlock, 'authority blockquote must exist');
|
||||
|
||||
assert.match(
|
||||
authorityBlock,
|
||||
/PROJECT\.md/,
|
||||
'directive should name PROJECT.md as authoritative'
|
||||
);
|
||||
assert.match(
|
||||
authorityBlock,
|
||||
/STATE\.md/,
|
||||
'directive should name STATE.md as authoritative'
|
||||
);
|
||||
assert.match(
|
||||
authorityBlock,
|
||||
/ROADMAP\.md/,
|
||||
'directive should name ROADMAP.md as authoritative'
|
||||
);
|
||||
assert.match(
|
||||
authorityBlock,
|
||||
/authoritative/i,
|
||||
'directive should describe these files as authoritative'
|
||||
);
|
||||
});
|
||||
|
||||
test('context-authority directive forbids using CLAUDE.md project block as a source', () => {
|
||||
const workflow = fs.readFileSync(WORKFLOW_PATH, 'utf8');
|
||||
const reportStep = extractStep(workflow, 'report');
|
||||
assert.ok(reportStep, 'report step must exist');
|
||||
const blockquotes = extractBlockquotes(reportStep);
|
||||
const authorityBlock = blockquotes.find((b) => /context\s+authority/i.test(b));
|
||||
assert.ok(authorityBlock, 'authority blockquote must exist');
|
||||
|
||||
assert.match(
|
||||
authorityBlock,
|
||||
/CLAUDE\.md/,
|
||||
'directive should explicitly mention CLAUDE.md'
|
||||
);
|
||||
// Must explicitly forbid CLAUDE.md as a source — look for a NOT/do not directive
|
||||
// co-located with the CLAUDE.md mention.
|
||||
assert.match(
|
||||
authorityBlock,
|
||||
/(do\s+NOT|do\s+not|must\s+NOT|must\s+not|never)/i,
|
||||
'directive should contain an explicit prohibition (do NOT / must not / never)'
|
||||
);
|
||||
assert.match(
|
||||
authorityBlock,
|
||||
/## Project/,
|
||||
'directive should call out the CLAUDE.md "## Project" block specifically'
|
||||
);
|
||||
});
|
||||
});
|
||||
246
tests/bug-2916-handle-branching-default-base.test.cjs
Normal file
246
tests/bug-2916-handle-branching-default-base.test.cjs
Normal file
@@ -0,0 +1,246 @@
|
||||
/**
|
||||
* Regression test for #2916: execute-phase `handle_branching` step creates the
|
||||
* per-phase branch off whatever HEAD is currently checked out (typically the
|
||||
* previous phase's unmerged branch) instead of off `origin/HEAD`.
|
||||
*
|
||||
* The bug compounded phases on top of each other and stranded them unpushed
|
||||
* for weeks. The fix:
|
||||
* 1. Detect the default branch via `git symbolic-ref refs/remotes/origin/HEAD`.
|
||||
* 2. If $BRANCH_NAME exists, switch to it (preserve existing behavior).
|
||||
* 3. Otherwise, ff-update the default branch from origin and create the new
|
||||
* phase branch off the default-branch tip.
|
||||
* 4. Refuse-or-warn on dirty working tree.
|
||||
* 5. Post-creation, assert `git rev-list --count $DEFAULT_BRANCH..HEAD == 0`.
|
||||
*
|
||||
* This test extracts the bash payload from the <step name="handle_branching">
|
||||
* block in execute-phase.md (parsed structurally — no regex on prose), executes
|
||||
* it inside a fixture git repo where HEAD sits on a previous-phase branch with
|
||||
* extra commits, and asserts that the new phase branch's tip equals
|
||||
* `origin/main` (no commits inherited from the previous phase).
|
||||
*/
|
||||
|
||||
const { describe, test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const { execFileSync } = require('node:child_process');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
|
||||
const EXECUTE_PHASE_PATH = path.join(
|
||||
__dirname,
|
||||
'..',
|
||||
'get-shit-done',
|
||||
'workflows',
|
||||
'execute-phase.md'
|
||||
);
|
||||
|
||||
const GIT_ENV = Object.freeze({
|
||||
...process.env,
|
||||
GIT_AUTHOR_NAME: 'Test',
|
||||
GIT_AUTHOR_EMAIL: 'test@test.com',
|
||||
GIT_COMMITTER_NAME: 'Test',
|
||||
GIT_COMMITTER_EMAIL: 'test@test.com',
|
||||
});
|
||||
|
||||
function git(cwd, ...args) {
|
||||
return execFileSync('git', args, {
|
||||
cwd,
|
||||
env: GIT_ENV,
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
})
|
||||
.toString()
|
||||
.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Structurally extract the bash code that the handle_branching step instructs
|
||||
* the agent to run. We:
|
||||
* 1. Locate the <step name="handle_branching"> ... </step> block.
|
||||
* 2. Walk its body looking for fenced ```bash blocks.
|
||||
* 3. Concatenate every bash block in the step (the fix may use more than one).
|
||||
*
|
||||
* No `.includes()` content checks — we parse fence-delimited code blocks the
|
||||
* same way a markdown parser would.
|
||||
*/
|
||||
function extractHandleBranchingBash() {
|
||||
const content = fs.readFileSync(EXECUTE_PHASE_PATH, 'utf-8');
|
||||
const lines = content.split(/\r?\n/);
|
||||
|
||||
let start = -1;
|
||||
let end = -1;
|
||||
for (let i = 0; i < lines.length; i += 1) {
|
||||
if (start === -1 && /^<step\s+name="handle_branching">\s*$/.test(lines[i])) {
|
||||
start = i + 1;
|
||||
} else if (start !== -1 && /^<\/step>\s*$/.test(lines[i])) {
|
||||
end = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (start === -1 || end === -1) {
|
||||
throw new Error(
|
||||
'execute-phase.md does not contain a <step name="handle_branching"> ... </step> block'
|
||||
);
|
||||
}
|
||||
|
||||
const bashBlocks = [];
|
||||
let inBash = false;
|
||||
let buffer = [];
|
||||
for (let i = start; i < end; i += 1) {
|
||||
const line = lines[i];
|
||||
if (!inBash && /^```bash\s*$/.test(line)) {
|
||||
inBash = true;
|
||||
buffer = [];
|
||||
continue;
|
||||
}
|
||||
if (inBash && /^```\s*$/.test(line)) {
|
||||
bashBlocks.push(buffer.join('\n'));
|
||||
inBash = false;
|
||||
continue;
|
||||
}
|
||||
if (inBash) buffer.push(line);
|
||||
}
|
||||
if (bashBlocks.length === 0) {
|
||||
throw new Error(
|
||||
'handle_branching step contains no ```bash code blocks to execute'
|
||||
);
|
||||
}
|
||||
return bashBlocks.join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a fixture: a bare "origin" repo with the named default branch (one
|
||||
* commit), a clone with `origin/HEAD` pointed at it, and a checked-out
|
||||
* previous-phase branch carrying its own unmerged commit.
|
||||
*
|
||||
* `defaultBranch` is parameterized so callers can lock in that the workflow
|
||||
* honors `git symbolic-ref refs/remotes/origin/HEAD` rather than silently
|
||||
* defaulting to `main` (#2921 CR feedback — quick-branching.test.cjs got the
|
||||
* same treatment in 80f14cac; this test deserves the same coverage).
|
||||
*/
|
||||
function setupFixture(defaultBranch = 'main') {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2916-'));
|
||||
const seedPath = path.join(root, 'seed');
|
||||
const originPath = path.join(root, 'origin.git');
|
||||
const clonePath = path.join(root, 'clone');
|
||||
|
||||
fs.mkdirSync(seedPath);
|
||||
git(seedPath, 'init', '-b', defaultBranch);
|
||||
git(seedPath, 'config', 'commit.gpgsign', 'false');
|
||||
fs.writeFileSync(path.join(seedPath, 'README.md'), '# seed\n');
|
||||
git(seedPath, 'add', 'README.md');
|
||||
git(seedPath, 'commit', '-m', 'initial');
|
||||
|
||||
git(root, 'clone', '--bare', seedPath, originPath);
|
||||
git(originPath, 'symbolic-ref', 'HEAD', `refs/heads/${defaultBranch}`);
|
||||
|
||||
git(root, 'clone', originPath, clonePath);
|
||||
git(clonePath, 'config', 'commit.gpgsign', 'false');
|
||||
git(clonePath, 'config', 'user.email', 'test@test.com');
|
||||
git(clonePath, 'config', 'user.name', 'Test');
|
||||
|
||||
// Simulate finishing a previous phase: branch off the default branch, add
|
||||
// a commit, and *stay* on it (the failure scenario described in the bug).
|
||||
git(clonePath, 'checkout', '-b', 'feature/phase-01-foundation');
|
||||
fs.writeFileSync(path.join(clonePath, 'phase01.txt'), 'phase 1 work\n');
|
||||
git(clonePath, 'add', 'phase01.txt');
|
||||
git(clonePath, 'commit', '-m', 'phase 01 work');
|
||||
|
||||
return { root, clonePath, defaultBranch };
|
||||
}
|
||||
|
||||
function runHandleBranchingStep(bash, cwd, branchName) {
|
||||
// Write the script to a sibling tempdir, not inside the repo — putting it in
|
||||
// `cwd` would create an untracked file that trips `git status --porcelain`
|
||||
// and steers the step into its dirty-tree fallback path.
|
||||
const scriptDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2916-step-'));
|
||||
const scriptPath = path.join(scriptDir, 'handle-branching.sh');
|
||||
const script = `#!/usr/bin/env bash\nset -uo pipefail\nBRANCH_NAME="${branchName}"\n${bash}\n`;
|
||||
fs.writeFileSync(scriptPath, script, { mode: 0o755 });
|
||||
try {
|
||||
return execFileSync('bash', [scriptPath], {
|
||||
cwd,
|
||||
env: GIT_ENV,
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
}).toString();
|
||||
} finally {
|
||||
fs.rmSync(scriptDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
describe('handle_branching branches off origin/HEAD, not current HEAD (#2916)', () => {
|
||||
// Run against `main` (conventional default) and `trunk` (non-main default
|
||||
// exercising the symbolic-ref code path) so a regression that hard-codes
|
||||
// `main` instead of consulting origin/HEAD will fail the trunk variant.
|
||||
for (const defaultBranch of ['main', 'trunk']) {
|
||||
test(`new phase branch branches off origin/${defaultBranch} with 0 inherited commits`, () => {
|
||||
const bash = extractHandleBranchingBash();
|
||||
const { root, clonePath } = setupFixture(defaultBranch);
|
||||
|
||||
try {
|
||||
const upstream = `origin/${defaultBranch}`;
|
||||
|
||||
assert.equal(
|
||||
git(clonePath, 'rev-parse', '--abbrev-ref', 'HEAD'),
|
||||
'feature/phase-01-foundation'
|
||||
);
|
||||
assert.equal(
|
||||
git(clonePath, 'rev-list', '--count', `${upstream}..HEAD`),
|
||||
'1',
|
||||
`fixture should be 1 commit ahead of ${upstream}`
|
||||
);
|
||||
|
||||
runHandleBranchingStep(bash, clonePath, 'feature/phase-02-content-sync');
|
||||
|
||||
assert.equal(
|
||||
git(clonePath, 'rev-parse', '--abbrev-ref', 'HEAD'),
|
||||
'feature/phase-02-content-sync',
|
||||
'handle_branching should switch to the new phase branch'
|
||||
);
|
||||
|
||||
const inherited = git(clonePath, 'rev-list', '--count', `${upstream}..HEAD`);
|
||||
assert.equal(
|
||||
inherited,
|
||||
'0',
|
||||
`new phase branch must branch off ${upstream}, but inherited ${inherited} commit(s) from previous-phase HEAD`
|
||||
);
|
||||
assert.equal(
|
||||
git(clonePath, 'rev-parse', 'HEAD'),
|
||||
git(clonePath, 'rev-parse', upstream),
|
||||
`new phase branch tip must equal ${upstream} tip`
|
||||
);
|
||||
} finally {
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
test('handle_branching reuses an existing branch instead of forking again', () => {
|
||||
const bash = extractHandleBranchingBash();
|
||||
const { root, clonePath } = setupFixture();
|
||||
|
||||
try {
|
||||
// Pre-create the target branch off origin/main with its own commit, then
|
||||
// walk away to a different branch — the step must switch back to it.
|
||||
git(clonePath, 'checkout', '-B', 'feature/phase-02-content-sync', 'origin/main');
|
||||
fs.writeFileSync(path.join(clonePath, 'phase02.txt'), 'phase 2 work\n');
|
||||
git(clonePath, 'add', 'phase02.txt');
|
||||
git(clonePath, 'commit', '-m', 'phase 02 wip');
|
||||
const phase02Sha = git(clonePath, 'rev-parse', 'HEAD');
|
||||
git(clonePath, 'checkout', 'feature/phase-01-foundation');
|
||||
|
||||
runHandleBranchingStep(bash, clonePath, 'feature/phase-02-content-sync');
|
||||
|
||||
assert.equal(
|
||||
git(clonePath, 'rev-parse', '--abbrev-ref', 'HEAD'),
|
||||
'feature/phase-02-content-sync'
|
||||
);
|
||||
assert.equal(
|
||||
git(clonePath, 'rev-parse', 'HEAD'),
|
||||
phase02Sha,
|
||||
'existing-branch tip must be preserved (no rebase/reset)'
|
||||
);
|
||||
} finally {
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
463
tests/bug-2924-worktree-head-attachment.test.cjs
Normal file
463
tests/bug-2924-worktree-head-attachment.test.cjs
Normal file
@@ -0,0 +1,463 @@
|
||||
/**
|
||||
* Regression tests for #2924: worktree HEAD attaches to a protected branch
|
||||
* (master/main) so agent commits land there; the workflow then "self-recovers"
|
||||
* by force-rewinding the protected branch via `git update-ref refs/heads/master`,
|
||||
* destroying concurrent work in multi-active scenarios.
|
||||
*
|
||||
* Fixes asserted by these tests (parsed structurally — not via raw content
|
||||
* regex/includes — per project test policy):
|
||||
*
|
||||
* 1. The <worktree_branch_check> block in execute-phase.md and quick.md
|
||||
* contains a HEAD-attachment assertion (symbolic-ref + protected-branch
|
||||
* check) that runs BEFORE any `git reset --hard`.
|
||||
* 2. The parallel-execution prompt in execute-phase.md and execute-plan.md
|
||||
* no longer mandates `--no-verify` as the default for worktree-mode commits.
|
||||
* 3. gsd-executor.md prohibits `git update-ref refs/heads/<protected>` as a
|
||||
* "recovery" path and includes a pre-commit HEAD assertion in the task
|
||||
* commit protocol.
|
||||
* 4. No workflow file in get-shit-done/workflows/ contains an unconditional
|
||||
* `git update-ref refs/heads/master` (or main/develop/trunk) call.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const { describe, test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const REPO_ROOT = path.join(__dirname, '..');
|
||||
const EXECUTE_PHASE_PATH = path.join(REPO_ROOT, 'get-shit-done', 'workflows', 'execute-phase.md');
|
||||
const EXECUTE_PLAN_PATH = path.join(REPO_ROOT, 'get-shit-done', 'workflows', 'execute-plan.md');
|
||||
const QUICK_PATH = path.join(REPO_ROOT, 'get-shit-done', 'workflows', 'quick.md');
|
||||
const EXECUTOR_AGENT_PATH = path.join(REPO_ROOT, 'agents', 'gsd-executor.md');
|
||||
const GIT_INTEGRATION_PATH = path.join(REPO_ROOT, 'get-shit-done', 'references', 'git-integration.md');
|
||||
|
||||
/**
|
||||
* Extract the inner body of a named XML-like block (e.g. <worktree_branch_check>...</worktree_branch_check>)
|
||||
* from a markdown document. Returns null when not found.
|
||||
*/
|
||||
function extractNamedBlock(markdown, blockName) {
|
||||
const open = `<${blockName}>`;
|
||||
const close = `</${blockName}>`;
|
||||
const start = markdown.indexOf(open);
|
||||
if (start === -1) return null;
|
||||
const end = markdown.indexOf(close, start + open.length);
|
||||
if (end === -1) return null;
|
||||
return markdown.slice(start + open.length, end);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract all fenced code blocks (```...```) from a markdown chunk.
|
||||
* Returns array of { lang, body } objects.
|
||||
*/
|
||||
function extractFencedCodeBlocks(markdown) {
|
||||
const blocks = [];
|
||||
const lines = markdown.split('\n');
|
||||
let inFence = false;
|
||||
let fenceLang = '';
|
||||
let buffer = [];
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trimStart();
|
||||
if (trimmed.startsWith('```')) {
|
||||
if (!inFence) {
|
||||
inFence = true;
|
||||
fenceLang = trimmed.slice(3).trim();
|
||||
buffer = [];
|
||||
} else {
|
||||
blocks.push({ lang: fenceLang, body: buffer.join('\n') });
|
||||
inFence = false;
|
||||
fenceLang = '';
|
||||
buffer = [];
|
||||
}
|
||||
} else if (inFence) {
|
||||
buffer.push(line);
|
||||
}
|
||||
}
|
||||
return blocks;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tokenize a shell-like script into individual statements (split on `;`, `&&`, `||`, newlines)
|
||||
* and return commands as arrays of word tokens. Handles `$(cmd ...)` command substitution
|
||||
* and `VAR=$(cmd ...)` assignments by extracting the inner command. This is intentionally
|
||||
* simple — adequate for asserting on the presence of well-known git invocations.
|
||||
*/
|
||||
function shellStatements(script) {
|
||||
const statements = [];
|
||||
const lines = script.split('\n');
|
||||
for (let raw of lines) {
|
||||
const line = raw.replace(/#.*$/, '').trim();
|
||||
if (!line) continue;
|
||||
// Split on shell statement separators
|
||||
const parts = line.split(/(?:&&|\|\||;)/);
|
||||
for (const part of parts) {
|
||||
let trimmed = part.trim();
|
||||
if (!trimmed) continue;
|
||||
// Strip leading `VAR=` assignments so the substituted command surfaces as cmd[0].
|
||||
// Then unwrap `$(...)` command substitution.
|
||||
const assignMatch = trimmed.match(/^[A-Za-z_][A-Za-z0-9_]*=(.*)$/);
|
||||
if (assignMatch) trimmed = assignMatch[1];
|
||||
const subMatch = trimmed.match(/^\$\((.*?)\)?$/);
|
||||
if (subMatch) trimmed = subMatch[1];
|
||||
// Also handle leading `$(` without closing paren (paren may have been split off)
|
||||
if (trimmed.startsWith('$(')) trimmed = trimmed.slice(2);
|
||||
// Strip trailing closing parens left over from substitution
|
||||
trimmed = trimmed.replace(/\)+\s*$/, '').trim();
|
||||
if (!trimmed) continue;
|
||||
// Strip surrounding quotes on the leading word
|
||||
statements.push(trimmed.split(/\s+/).filter(Boolean));
|
||||
}
|
||||
}
|
||||
return statements;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the line index of the first command matching a predicate.
|
||||
* Returns -1 when not found.
|
||||
*/
|
||||
function findCommandIndex(statements, predicate) {
|
||||
for (let i = 0; i < statements.length; i++) {
|
||||
if (predicate(statements[i])) return i;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
describe('bug #2924: worktree HEAD attachment + destructive recovery', () => {
|
||||
describe('execute-phase.md worktree_branch_check', () => {
|
||||
const content = fs.readFileSync(EXECUTE_PHASE_PATH, 'utf-8');
|
||||
const block = extractNamedBlock(content, 'worktree_branch_check');
|
||||
|
||||
test('block exists', () => {
|
||||
assert.ok(block, 'execute-phase.md must contain a <worktree_branch_check> block');
|
||||
});
|
||||
|
||||
test('block invokes `git symbolic-ref` to inspect HEAD attachment', () => {
|
||||
const codeBlocks = extractFencedCodeBlocks(block);
|
||||
const allStatements = codeBlocks.flatMap(({ body }) => shellStatements(body));
|
||||
const idx = findCommandIndex(allStatements, (cmd) =>
|
||||
cmd[0] === 'git' && cmd[1] === 'symbolic-ref' && cmd.includes('HEAD')
|
||||
);
|
||||
assert.notStrictEqual(
|
||||
idx, -1,
|
||||
'worktree_branch_check must run `git symbolic-ref ... HEAD` to verify HEAD attachment before any reset'
|
||||
);
|
||||
});
|
||||
|
||||
test('HEAD-attachment assertion runs BEFORE `git reset --hard`', () => {
|
||||
const codeBlocks = extractFencedCodeBlocks(block);
|
||||
const allStatements = codeBlocks.flatMap(({ body }) => shellStatements(body));
|
||||
const symbolicRefIdx = findCommandIndex(allStatements, (cmd) =>
|
||||
cmd[0] === 'git' && cmd[1] === 'symbolic-ref' && cmd.includes('HEAD')
|
||||
);
|
||||
const resetHardIdx = findCommandIndex(allStatements, (cmd) =>
|
||||
cmd[0] === 'git' && cmd[1] === 'reset' && cmd.includes('--hard')
|
||||
);
|
||||
assert.notStrictEqual(symbolicRefIdx, -1, 'symbolic-ref check must exist');
|
||||
assert.notStrictEqual(resetHardIdx, -1, 'reset --hard must exist');
|
||||
assert.ok(
|
||||
symbolicRefIdx < resetHardIdx,
|
||||
'HEAD attachment assertion (symbolic-ref) must precede `git reset --hard` so a stale HEAD never moves a protected branch'
|
||||
);
|
||||
});
|
||||
|
||||
test('block names protected branches that must NOT be the agent branch', () => {
|
||||
// The protected-branch list must be enforced by name. Parse it out of the
|
||||
// shell scripts and verify required names are present.
|
||||
const codeBlocks = extractFencedCodeBlocks(block);
|
||||
const scripts = codeBlocks.map(({ body }) => body).join('\n');
|
||||
// Look for an assignment whose value is a regex/list naming protected refs.
|
||||
// Acceptable forms: PROTECTED_BRANCHES_RE='...' or grep -Eq '^(main|...)$'
|
||||
// Parse the alternation list out of the grep -E pattern so we assert
|
||||
// structurally on the protected-branch enumeration rather than via
|
||||
// raw substring matching (release/* contains regex-special chars and
|
||||
// can't be safely tested with `\b...\b`).
|
||||
const altMatch = scripts.match(/grep\s+-Eq?\s+'\^\(([^)]+)\)\$'/);
|
||||
assert.ok(
|
||||
altMatch,
|
||||
'worktree_branch_check must contain a `grep -Eq` protected-branch alternation pattern'
|
||||
);
|
||||
const branches = altMatch[1].split('|').map((b) => b.trim());
|
||||
const required = ['main', 'master', 'develop', 'trunk', 'release/.*'];
|
||||
for (const name of required) {
|
||||
assert.ok(
|
||||
branches.includes(name),
|
||||
`worktree_branch_check protected-branch alternation must include '${name}' (found: ${branches.join(', ')})`
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('block enforces positive worktree-agent-* allow-list (#2924 hardening)', () => {
|
||||
const codeBlocks = extractFencedCodeBlocks(block);
|
||||
const scripts = codeBlocks.map(({ body }) => body).join('\n');
|
||||
// Allow-list must reference the canonical Claude Code worktree-agent-<id>
|
||||
// namespace via a regex assertion (grep -Eq '^worktree-agent-...').
|
||||
const allowListRe = /grep\s+-Eq?\s+'\^worktree-agent-/;
|
||||
assert.ok(
|
||||
allowListRe.test(scripts),
|
||||
'worktree_branch_check must enforce a positive allow-list matching ^worktree-agent-* (#2924 hardening)'
|
||||
);
|
||||
});
|
||||
|
||||
test('block forbids `git update-ref` self-recovery in its guidance text', () => {
|
||||
// The forbidding statement is documentation text, not a shell command,
|
||||
// so structural shell parsing does not apply. Verify the prohibition
|
||||
// appears as standalone guidance somewhere in the block.
|
||||
assert.ok(
|
||||
block.includes('update-ref'),
|
||||
'worktree_branch_check must explicitly forbid `git update-ref` self-recovery'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('execute-phase.md no longer defaults to --no-verify in parallel mode', () => {
|
||||
const content = fs.readFileSync(EXECUTE_PHASE_PATH, 'utf-8');
|
||||
const block = extractNamedBlock(content, 'parallel_execution');
|
||||
|
||||
test('parallel_execution block exists', () => {
|
||||
assert.ok(block, 'execute-phase.md must contain a <parallel_execution> block');
|
||||
});
|
||||
|
||||
test('parallel_execution does NOT instruct agents to use --no-verify by default', () => {
|
||||
// Tokenize the block as plain words and look for an unconditional
|
||||
// imperative naming `--no-verify`. The acceptable presence is in a
|
||||
// negated/opt-out context (e.g. "Do NOT pass --no-verify"); reject
|
||||
// any sentence whose first verb is "Use --no-verify".
|
||||
const sentences = block
|
||||
.replace(/\n+/g, ' ')
|
||||
.split(/(?<=[.!?])\s+/);
|
||||
for (const sentence of sentences) {
|
||||
if (!sentence.includes('--no-verify')) continue;
|
||||
const lower = sentence.toLowerCase();
|
||||
const isProhibition =
|
||||
/\b(do not|don't|never|no longer)\b/.test(lower) ||
|
||||
/\bopt[\s-]?out\b/.test(lower) ||
|
||||
/\bopt[\s-]?in\b/.test(lower) ||
|
||||
/\bif\b/.test(lower);
|
||||
assert.ok(
|
||||
isProhibition,
|
||||
`parallel_execution sentence appears to mandate --no-verify by default: "${sentence.trim()}"`
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('execute-plan.md no longer mandates --no-verify for parallel executor', () => {
|
||||
const content = fs.readFileSync(EXECUTE_PLAN_PATH, 'utf-8');
|
||||
const block = extractNamedBlock(content, 'precommit_failure_handling');
|
||||
test('precommit_failure_handling block exists', () => {
|
||||
assert.ok(block, 'execute-plan.md must contain a <precommit_failure_handling> block');
|
||||
});
|
||||
|
||||
test('parallel-executor sub-section does not unconditionally mandate --no-verify', () => {
|
||||
// Locate the parallel-executor sub-section heading and parse the
|
||||
// sentences under it.
|
||||
const headingIdx = block.indexOf('parallel executor');
|
||||
assert.notStrictEqual(headingIdx, -1, 'must contain a parallel-executor sub-section');
|
||||
const endIdx = block.indexOf('**If running as the sole', headingIdx);
|
||||
assert.notStrictEqual(endIdx, -1, 'parallel-executor sub-section terminator must exist');
|
||||
const subBlock = block.slice(headingIdx, endIdx);
|
||||
assert.ok(subBlock.length > 0, 'sub-section must have content');
|
||||
const sentences = subBlock.replace(/\n+/g, ' ').split(/(?<=[.!?])\s+/);
|
||||
for (const sentence of sentences) {
|
||||
if (!sentence.includes('--no-verify')) continue;
|
||||
const lower = sentence.toLowerCase();
|
||||
const isProhibition =
|
||||
/\b(do not|don't|never|no longer)\b/.test(lower) ||
|
||||
/\bopt[\s-]?out\b/.test(lower) ||
|
||||
/\bopt[\s-]?in\b/.test(lower) ||
|
||||
/\bif\b/.test(lower);
|
||||
assert.ok(
|
||||
isProhibition,
|
||||
`parallel-executor guidance sentence appears to mandate --no-verify: "${sentence.trim()}"`
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('quick.md worktree_branch_check', () => {
|
||||
const content = fs.readFileSync(QUICK_PATH, 'utf-8');
|
||||
const block = extractNamedBlock(content, 'worktree_branch_check');
|
||||
|
||||
test('block exists', () => {
|
||||
assert.ok(block, 'quick.md must contain a <worktree_branch_check> block');
|
||||
});
|
||||
|
||||
test('block references `git symbolic-ref` for HEAD attachment assertion', () => {
|
||||
// quick.md uses inline `git symbolic-ref ... HEAD` rather than a fenced
|
||||
// block, so search the block as a token stream of statements.
|
||||
const statements = shellStatements(block);
|
||||
const idx = findCommandIndex(statements, (cmd) =>
|
||||
cmd[0] === 'git' && cmd[1] === 'symbolic-ref' && cmd.includes('HEAD')
|
||||
);
|
||||
assert.notStrictEqual(
|
||||
idx, -1,
|
||||
'quick.md worktree_branch_check must run `git symbolic-ref ... HEAD`'
|
||||
);
|
||||
});
|
||||
|
||||
test('HEAD assertion precedes `git reset --hard`', () => {
|
||||
const symbolicRefByteIdx = block.indexOf('symbolic-ref');
|
||||
const resetHardByteIdx = block.indexOf('reset --hard');
|
||||
assert.notStrictEqual(symbolicRefByteIdx, -1);
|
||||
assert.notStrictEqual(resetHardByteIdx, -1);
|
||||
assert.ok(
|
||||
symbolicRefByteIdx < resetHardByteIdx,
|
||||
'symbolic-ref HEAD assertion must appear before `git reset --hard` in quick.md worktree_branch_check'
|
||||
);
|
||||
});
|
||||
|
||||
test('block forbids `git update-ref` self-recovery', () => {
|
||||
assert.ok(
|
||||
block.includes('update-ref'),
|
||||
'quick.md worktree_branch_check must explicitly forbid `git update-ref` self-recovery'
|
||||
);
|
||||
});
|
||||
|
||||
test('block enforces positive worktree-agent-* allow-list (#2924 hardening)', () => {
|
||||
const allowListRe = /grep\s+-Eq?\s+'\^worktree-agent-/;
|
||||
assert.ok(
|
||||
allowListRe.test(block),
|
||||
'quick.md worktree_branch_check must enforce a positive allow-list matching ^worktree-agent-* (#2924 hardening)'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('quick.md pre-dispatch plan commit no longer hard-codes --no-verify', () => {
|
||||
const content = fs.readFileSync(QUICK_PATH, 'utf-8');
|
||||
const codeBlocks = extractFencedCodeBlocks(content);
|
||||
// Find the bash block containing the pre-dispatch plan commit
|
||||
const target = codeBlocks.find(({ body }) =>
|
||||
body.includes('pre-dispatch plan') && body.includes('git commit')
|
||||
);
|
||||
test('pre-dispatch plan commit block exists', () => {
|
||||
assert.ok(target, 'quick.md must contain the pre-dispatch plan commit block');
|
||||
});
|
||||
|
||||
test('pre-dispatch plan commit gates --no-verify behind a config flag', () => {
|
||||
// The block must contain BOTH a `git commit` without --no-verify AND
|
||||
// gate any --no-verify variant inside an `if` block reading a config
|
||||
// value (workflow.worktree_skip_hooks).
|
||||
const statements = shellStatements(target.body);
|
||||
const noVerifyCommits = statements.filter((cmd) =>
|
||||
cmd[0] === 'git' && cmd[1] === 'commit' && cmd.includes('--no-verify')
|
||||
);
|
||||
const cleanCommits = statements.filter((cmd) =>
|
||||
cmd[0] === 'git' && cmd[1] === 'commit' && !cmd.includes('--no-verify')
|
||||
);
|
||||
assert.ok(
|
||||
cleanCommits.length >= 1,
|
||||
'must include at least one `git commit` without --no-verify (default path)'
|
||||
);
|
||||
// If --no-verify still appears, the block must reference the opt-in flag.
|
||||
if (noVerifyCommits.length > 0) {
|
||||
assert.ok(
|
||||
target.body.includes('worktree_skip_hooks'),
|
||||
'--no-verify commits must be gated behind workflow.worktree_skip_hooks config flag'
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('gsd-executor.md prohibits update-ref self-recovery', () => {
|
||||
const content = fs.readFileSync(EXECUTOR_AGENT_PATH, 'utf-8');
|
||||
const block = extractNamedBlock(content, 'destructive_git_prohibition');
|
||||
|
||||
test('destructive_git_prohibition block exists', () => {
|
||||
assert.ok(block, 'gsd-executor.md must contain a <destructive_git_prohibition> block');
|
||||
});
|
||||
|
||||
test('block prohibits `git update-ref refs/heads/<protected>`', () => {
|
||||
assert.ok(
|
||||
block.includes('update-ref'),
|
||||
'destructive_git_prohibition must enumerate `git update-ref` as a prohibited command'
|
||||
);
|
||||
assert.ok(
|
||||
block.includes('protected') || block.includes('main') || block.includes('master'),
|
||||
'destructive_git_prohibition must call out protected branches in the update-ref prohibition'
|
||||
);
|
||||
});
|
||||
|
||||
test('block references issue #2924', () => {
|
||||
assert.ok(
|
||||
block.includes('#2924'),
|
||||
'destructive_git_prohibition should cite #2924 as the source of the update-ref prohibition'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('gsd-executor.md task_commit_protocol enforces worktree-agent-* allow-list', () => {
|
||||
const content = fs.readFileSync(EXECUTOR_AGENT_PATH, 'utf-8');
|
||||
const block = extractNamedBlock(content, 'task_commit_protocol');
|
||||
|
||||
test('task_commit_protocol block exists', () => {
|
||||
assert.ok(block, 'gsd-executor.md must contain a <task_commit_protocol> block');
|
||||
});
|
||||
|
||||
test('step 0 enforces positive worktree-agent-* allow-list (#2924 hardening)', () => {
|
||||
const codeBlocks = extractFencedCodeBlocks(block);
|
||||
const scripts = codeBlocks.map(({ body }) => body).join('\n');
|
||||
const allowListRe = /grep\s+-Eq?\s+'\^worktree-agent-/;
|
||||
assert.ok(
|
||||
allowListRe.test(scripts),
|
||||
'task_commit_protocol step 0 must enforce a positive allow-list matching ^worktree-agent-* in addition to the protected-ref deny-list (#2924 hardening)'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('no workflow file performs unconditional update-ref on a protected branch', () => {
|
||||
const workflowsDir = path.join(REPO_ROOT, 'get-shit-done', 'workflows');
|
||||
const workflowFiles = fs
|
||||
.readdirSync(workflowsDir, { recursive: true })
|
||||
.filter((f) => typeof f === 'string' && f.endsWith('.md'))
|
||||
.map((f) => path.join(workflowsDir, f));
|
||||
|
||||
for (const filePath of workflowFiles) {
|
||||
test(`${path.basename(filePath)} contains no update-ref of a protected ref`, () => {
|
||||
const content = fs.readFileSync(filePath, 'utf-8');
|
||||
const blocks = extractFencedCodeBlocks(content);
|
||||
for (const { body } of blocks) {
|
||||
const statements = shellStatements(body);
|
||||
for (const cmd of statements) {
|
||||
if (cmd[0] !== 'git') continue;
|
||||
if (cmd[1] !== 'update-ref') continue;
|
||||
// Reject any update-ref that targets a protected ref.
|
||||
const target = cmd[2] || '';
|
||||
const protectedRe = /^refs\/heads\/(main|master|develop|trunk|release\/.+)$/;
|
||||
assert.ok(
|
||||
!protectedRe.test(target),
|
||||
`${path.basename(filePath)} contains forbidden 'git update-ref ${target}' (#2924)`
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe('git-integration.md guidance reflects new default', () => {
|
||||
const content = fs.readFileSync(GIT_INTEGRATION_PATH, 'utf-8');
|
||||
test('parallel-agents guidance no longer mandates --no-verify', () => {
|
||||
// Find the parallel-agents callout and parse its sentences.
|
||||
const idx = content.indexOf('Parallel agents');
|
||||
assert.notStrictEqual(idx, -1, 'must contain a "Parallel agents" callout');
|
||||
const section = content.slice(idx);
|
||||
const endMatch = section.slice(1).match(/\n#{1,6}\s/);
|
||||
assert.ok(endMatch, 'Parallel agents section must terminate at the next heading');
|
||||
const tail = section.slice(0, 1 + endMatch.index);
|
||||
const sentences = tail.replace(/\n+/g, ' ').split(/(?<=[.!?])\s+/);
|
||||
for (const sentence of sentences) {
|
||||
if (!sentence.includes('--no-verify')) continue;
|
||||
const lower = sentence.toLowerCase();
|
||||
const isProhibition =
|
||||
/\b(do not|don't|never|no longer)\b/.test(lower) ||
|
||||
/\bopt[\s-]?out\b/.test(lower) ||
|
||||
/\bopt[\s-]?in\b/.test(lower) ||
|
||||
/\bif\b/.test(lower);
|
||||
assert.ok(
|
||||
isProhibition,
|
||||
`git-integration.md "Parallel agents" sentence appears to mandate --no-verify: "${sentence.trim()}"`
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
177
tests/bug-2942-detect-custom-skills.test.cjs
Normal file
177
tests/bug-2942-detect-custom-skills.test.cjs
Normal file
@@ -0,0 +1,177 @@
|
||||
/**
|
||||
* GSD Tools Tests — detect-custom-files misses skills/ directory (#2942)
|
||||
*
|
||||
* After v1.39.0 skill consolidation (#2790), skills/ became a GSD-managed root.
|
||||
* GSD_MANAGED_DIRS was missing 'skills', so user-added skill directories like
|
||||
* skills/custom-skill/SKILL.md were never walked and got silently destroyed
|
||||
* during /gsd-update.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const { describe, test, beforeEach, afterEach } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const crypto = require('crypto');
|
||||
const { runGsdTools, createTempDir, cleanup } = require('./helpers.cjs');
|
||||
|
||||
function sha256(content) {
|
||||
return crypto.createHash('sha256').update(content).digest('hex');
|
||||
}
|
||||
|
||||
/**
|
||||
* Write a fake gsd-file-manifest.json into configDir with the given file entries.
|
||||
* Each entry is also written to disk so the directory structure exists.
|
||||
*/
|
||||
function writeManifest(configDir, files) {
|
||||
const manifest = {
|
||||
version: '1.39.0',
|
||||
timestamp: new Date().toISOString(),
|
||||
files: {}
|
||||
};
|
||||
for (const [relPath, content] of Object.entries(files)) {
|
||||
const fullPath = path.join(configDir, relPath);
|
||||
fs.mkdirSync(path.dirname(fullPath), { recursive: true });
|
||||
fs.writeFileSync(fullPath, content);
|
||||
manifest.files[relPath] = sha256(content);
|
||||
}
|
||||
fs.writeFileSync(
|
||||
path.join(configDir, 'gsd-file-manifest.json'),
|
||||
JSON.stringify(manifest, null, 2)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Write a file inside configDir (creating parent dirs), but do NOT add it to the manifest.
|
||||
*/
|
||||
function writeCustomFile(configDir, relPath, content) {
|
||||
const fullPath = path.join(configDir, relPath);
|
||||
fs.mkdirSync(path.dirname(fullPath), { recursive: true });
|
||||
fs.writeFileSync(fullPath, content);
|
||||
}
|
||||
|
||||
describe('detect-custom-files — skills/ directory missing from GSD_MANAGED_DIRS (#2942)', () => {
|
||||
let tmpDir;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = createTempDir('gsd-2942-skills-');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup(tmpDir);
|
||||
});
|
||||
|
||||
// Test 1: detects custom skill in skills/<name>/SKILL.md
|
||||
test('detects custom skill file at skills/<name>/SKILL.md', () => {
|
||||
writeManifest(tmpDir, {
|
||||
'skills/gsd-planner/SKILL.md': '# GSD Planner Skill\n',
|
||||
});
|
||||
|
||||
// User-added custom skill — NOT in manifest
|
||||
writeCustomFile(tmpDir, 'skills/test-custom/SKILL.md', '# My Custom Skill\n');
|
||||
|
||||
const result = runGsdTools(
|
||||
['detect-custom-files', '--config-dir', tmpDir],
|
||||
tmpDir
|
||||
);
|
||||
|
||||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||
|
||||
const json = JSON.parse(result.output);
|
||||
assert.ok(Array.isArray(json.custom_files), 'custom_files should be an array');
|
||||
assert.ok(json.custom_count >= 1, `custom_count should be >= 1, got ${json.custom_count}`);
|
||||
assert.ok(
|
||||
json.custom_files.includes('skills/test-custom/SKILL.md'),
|
||||
`skills/test-custom/SKILL.md should be in custom_files; got: ${JSON.stringify(json.custom_files)}`
|
||||
);
|
||||
});
|
||||
|
||||
// Test 2: does not flag GSD-owned skills as custom (manifest-tracked path NOT in custom_files)
|
||||
test('does not flag GSD-owned skill as custom when it is tracked in manifest', () => {
|
||||
writeManifest(tmpDir, {
|
||||
'skills/gsd-planner/SKILL.md': '# GSD Planner Skill\n',
|
||||
});
|
||||
|
||||
// No extra files — only the manifest-tracked skill exists
|
||||
|
||||
const result = runGsdTools(
|
||||
['detect-custom-files', '--config-dir', tmpDir],
|
||||
tmpDir
|
||||
);
|
||||
|
||||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||
|
||||
const json = JSON.parse(result.output);
|
||||
assert.ok(Array.isArray(json.custom_files), 'custom_files should be an array');
|
||||
assert.ok(
|
||||
!json.custom_files.includes('skills/gsd-planner/SKILL.md'),
|
||||
`GSD-owned skill should NOT be in custom_files; got: ${JSON.stringify(json.custom_files)}`
|
||||
);
|
||||
});
|
||||
|
||||
// Test 3: regression guard — still detects custom files in get-shit-done/workflows/
|
||||
test('regression: still detects custom files in get-shit-done/workflows/', () => {
|
||||
writeManifest(tmpDir, {
|
||||
'get-shit-done/workflows/plan-phase.md': '# Plan Phase\n',
|
||||
'skills/gsd-planner/SKILL.md': '# GSD Planner Skill\n',
|
||||
});
|
||||
|
||||
writeCustomFile(tmpDir, 'get-shit-done/workflows/custom-workflow.md', '# My Custom Workflow\n');
|
||||
|
||||
const result = runGsdTools(
|
||||
['detect-custom-files', '--config-dir', tmpDir],
|
||||
tmpDir
|
||||
);
|
||||
|
||||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||
|
||||
const json = JSON.parse(result.output);
|
||||
assert.ok(
|
||||
json.custom_files.includes('get-shit-done/workflows/custom-workflow.md'),
|
||||
`custom workflow should still be detected; got: ${JSON.stringify(json.custom_files)}`
|
||||
);
|
||||
});
|
||||
|
||||
// Test 4: custom_count matches custom_files.length
|
||||
test('custom_count matches custom_files.length when multiple custom skills exist', () => {
|
||||
writeManifest(tmpDir, {
|
||||
'skills/gsd-planner/SKILL.md': '# GSD Planner Skill\n',
|
||||
});
|
||||
|
||||
writeCustomFile(tmpDir, 'skills/test-custom/SKILL.md', '# Custom Skill One\n');
|
||||
writeCustomFile(tmpDir, 'skills/another-custom/SKILL.md', '# Custom Skill Two\n');
|
||||
|
||||
const result = runGsdTools(
|
||||
['detect-custom-files', '--config-dir', tmpDir],
|
||||
tmpDir
|
||||
);
|
||||
|
||||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||
|
||||
const json = JSON.parse(result.output);
|
||||
assert.strictEqual(
|
||||
json.custom_count,
|
||||
json.custom_files.length,
|
||||
`custom_count (${json.custom_count}) should equal custom_files.length (${json.custom_files.length})`
|
||||
);
|
||||
assert.strictEqual(json.custom_count, 2, 'should detect exactly 2 custom skill files');
|
||||
});
|
||||
|
||||
// Test 5: manifest_found: true when manifest is present
|
||||
test('manifest_found is true when manifest is present', () => {
|
||||
writeManifest(tmpDir, {
|
||||
'skills/gsd-planner/SKILL.md': '# GSD Planner Skill\n',
|
||||
});
|
||||
|
||||
const result = runGsdTools(
|
||||
['detect-custom-files', '--config-dir', tmpDir],
|
||||
tmpDir
|
||||
);
|
||||
|
||||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||
|
||||
const json = JSON.parse(result.output);
|
||||
assert.strictEqual(json.manifest_found, true, 'manifest_found should be true');
|
||||
});
|
||||
});
|
||||
132
tests/bug-2943-config-get-context-window-default.test.cjs
Normal file
132
tests/bug-2943-config-get-context-window-default.test.cjs
Normal file
@@ -0,0 +1,132 @@
|
||||
/**
|
||||
* Regression test for bug #2943
|
||||
*
|
||||
* `gsd-tools.cjs config-get context_window` (and the SDK equivalent) threw
|
||||
* "Key not found: context_window" when the key was absent from config.json,
|
||||
* even though context_window has a documented schema default of 200000.
|
||||
*
|
||||
* Fix: `cmdConfigGet` in bin/lib/config.cjs now consults a SCHEMA_DEFAULTS map
|
||||
* before emitting "Key not found", so schema-defaulted keys always return the
|
||||
* default value (exit 0) when not explicitly set in the project config.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
// allow-test-rule: pending-migration-to-typed-ir [#2974]
|
||||
// Tracked in #2974 for migration to typed-IR assertions per CONTRIBUTING.md
|
||||
// "Prohibited: Raw Text Matching on Test Outputs". Do not copy this pattern.
|
||||
|
||||
const { describe, test, beforeEach, afterEach } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const os = require('node:os');
|
||||
const { execFileSync } = require('node:child_process');
|
||||
|
||||
const GSD_TOOLS = path.join(__dirname, '..', 'get-shit-done', 'bin', 'gsd-tools.cjs');
|
||||
|
||||
describe('bug-2943: config-get returns schema default for context_window', () => {
|
||||
let tmpDir;
|
||||
let planningDir;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-test-2943-'));
|
||||
planningDir = path.join(tmpDir, '.planning');
|
||||
fs.mkdirSync(planningDir, { recursive: true });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
/**
|
||||
* Run config-get with optional extra args. Returns { exitCode, stdout, stderr }.
|
||||
* Uses --raw so we get the plain scalar value, not JSON-wrapped.
|
||||
*/
|
||||
function runConfigGet(keyPath, extraArgs = []) {
|
||||
const args = [GSD_TOOLS, 'config-get', keyPath, '--raw', '--cwd', tmpDir, ...extraArgs];
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
let exitCode = 0;
|
||||
try {
|
||||
stdout = execFileSync(process.execPath, args, {
|
||||
encoding: 'utf-8',
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
timeout: 5000,
|
||||
});
|
||||
} catch (err) {
|
||||
exitCode = err.status ?? 1;
|
||||
stdout = err.stdout?.toString() ?? '';
|
||||
stderr = err.stderr?.toString() ?? '';
|
||||
}
|
||||
return { exitCode, stdout: stdout.trim(), stderr: stderr.trim() };
|
||||
}
|
||||
|
||||
test('returns "200000" (exit 0) when context_window absent from config.json', () => {
|
||||
// Fixture A: config with unrelated keys, no context_window
|
||||
fs.writeFileSync(
|
||||
path.join(planningDir, 'config.json'),
|
||||
JSON.stringify({ workflow: { auto_advance: false } })
|
||||
);
|
||||
|
||||
const result = runConfigGet('context_window');
|
||||
|
||||
assert.strictEqual(result.exitCode, 0, 'should exit 0 (schema default applied)');
|
||||
assert.strictEqual(result.stdout, '200000', 'should return schema default of 200000');
|
||||
});
|
||||
|
||||
test('returns configured value when context_window is explicitly set', () => {
|
||||
// Fixture B: config has context_window: 1000000
|
||||
fs.writeFileSync(
|
||||
path.join(planningDir, 'config.json'),
|
||||
JSON.stringify({ context_window: 1000000 })
|
||||
);
|
||||
|
||||
const result = runConfigGet('context_window');
|
||||
|
||||
assert.strictEqual(result.exitCode, 0, 'should exit 0 for found key');
|
||||
assert.strictEqual(result.stdout, '1000000', 'should return configured value not schema default');
|
||||
});
|
||||
|
||||
test('--default flag overrides schema default', () => {
|
||||
// config has context_window but we pass --default with a different value —
|
||||
// when key IS present, real value wins over any default
|
||||
fs.writeFileSync(
|
||||
path.join(planningDir, 'config.json'),
|
||||
JSON.stringify({ workflow: { auto_advance: false } })
|
||||
);
|
||||
|
||||
const result = runConfigGet('context_window', ['--default', '123456']);
|
||||
|
||||
assert.strictEqual(result.exitCode, 0, 'should exit 0 when --default provided');
|
||||
assert.strictEqual(result.stdout, '123456', 'should return the --default value, not schema default');
|
||||
});
|
||||
|
||||
test('errors with "Key not found" (exit 1) for an unknown absent key — no regression', () => {
|
||||
// An unrecognised key with no schema default still errors as before
|
||||
fs.writeFileSync(
|
||||
path.join(planningDir, 'config.json'),
|
||||
JSON.stringify({ workflow: { auto_advance: false } })
|
||||
);
|
||||
|
||||
const result = runConfigGet('totally_unknown_key_xyz');
|
||||
|
||||
assert.strictEqual(result.exitCode, 1, 'should exit 1 for unknown absent key');
|
||||
assert.ok(
|
||||
result.stderr.includes('Key not found') || result.stdout.includes('Key not found'),
|
||||
`expected "Key not found" in output, got stderr="${result.stderr}" stdout="${result.stdout}"`
|
||||
);
|
||||
});
|
||||
|
||||
test('--default flag still works for arbitrary absent keys', () => {
|
||||
fs.writeFileSync(
|
||||
path.join(planningDir, 'config.json'),
|
||||
JSON.stringify({})
|
||||
);
|
||||
|
||||
const result = runConfigGet('some.missing.key', ['--default', '200000']);
|
||||
|
||||
assert.strictEqual(result.exitCode, 0, 'should exit 0 when --default supplied');
|
||||
assert.strictEqual(result.stdout, '200000', 'should return the explicit --default value');
|
||||
});
|
||||
});
|
||||
162
tests/bug-2948-spike-wrap-up-dispatch.test.cjs
Normal file
162
tests/bug-2948-spike-wrap-up-dispatch.test.cjs
Normal file
@@ -0,0 +1,162 @@
|
||||
/**
|
||||
* Regression test for bug #2948
|
||||
*
|
||||
* `/gsd-spike --wrap-up` was silently no-oping because:
|
||||
* 1. `commands/gsd/spike.md` listed `--wrap-up` as a flag but had no dispatch block.
|
||||
* 2. `workflows/spike.md` still referenced the deleted `/gsd-spike-wrap-up` entry-point
|
||||
* instead of the correct `/gsd-spike --wrap-up` form.
|
||||
*
|
||||
* Fix:
|
||||
* - `commands/gsd/spike.md` now has a dispatch block that routes `--wrap-up` to
|
||||
* spike-wrap-up.md, and spike-wrap-up.md is listed in execution_context so the
|
||||
* runtime can find it.
|
||||
* - `workflows/spike.md` companion references updated from `/gsd-spike-wrap-up` to
|
||||
* `/gsd-spike --wrap-up`.
|
||||
*/
|
||||
|
||||
// allow-test-rule: source-text-is-the-product
|
||||
// commands/gsd/*.md files ARE what the runtime loads — testing their
|
||||
// frontmatter and section content tests the deployed system-prompt contract.
|
||||
|
||||
'use strict';
|
||||
|
||||
const { describe, test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const SPIKE_CMD_PATH = path.join(__dirname, '..', 'commands', 'gsd', 'spike.md');
|
||||
const SPIKE_WORKFLOW_PATH = path.join(__dirname, '..', 'get-shit-done', 'workflows', 'spike.md');
|
||||
|
||||
/**
|
||||
* Parse YAML frontmatter + body from a markdown file.
|
||||
* Returns a shallow { key: value } map of frontmatter fields plus `_body`.
|
||||
* Mirrors the parseFrontmatter utility used in enh-2792-namespace-skills.test.cjs.
|
||||
*/
|
||||
function parseFrontmatter(content) {
|
||||
const lines = content.split(/\r?\n/);
|
||||
|
||||
// Frontmatter must start at the very first line; a mid-file '---' is a
|
||||
// horizontal rule, not a frontmatter delimiter.
|
||||
if (lines[0]?.trim() !== '---') {
|
||||
return { _body: content };
|
||||
}
|
||||
|
||||
let closeIdx = -1;
|
||||
for (let i = 1; i < lines.length; i += 1) {
|
||||
if (lines[i].trim() === '---') {
|
||||
closeIdx = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
assert.ok(closeIdx !== -1, 'frontmatter block must be delimited by --- on its own lines');
|
||||
const fm = {};
|
||||
for (const line of lines.slice(1, closeIdx)) {
|
||||
const m = line.match(/^([A-Za-z][A-Za-z0-9_-]*):\s*(.*)$/);
|
||||
if (!m) continue;
|
||||
const [, key, raw] = m;
|
||||
fm[key] = raw.trim().replace(/^["']|["']$/g, '');
|
||||
}
|
||||
fm._body = lines.slice(closeIdx + 1).join('\n');
|
||||
return fm;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the text content of a named XML-like section from a markdown body.
|
||||
* Returns null if the section is absent.
|
||||
*/
|
||||
function extractSection(body, tag) {
|
||||
const open = `<${tag}>`;
|
||||
const close = `</${tag}>`;
|
||||
const start = body.indexOf(open);
|
||||
const end = body.indexOf(close);
|
||||
if (start === -1 || end === -1) return null;
|
||||
return body.slice(start + open.length, end);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the @-prefixed workflow references out of an execution_context section.
|
||||
* Returns an array of resolved reference strings (@ stripped).
|
||||
*/
|
||||
function parseExecutionContextRefs(section) {
|
||||
return section
|
||||
.split(/\r?\n/)
|
||||
.map(l => l.trim())
|
||||
.filter(l => l.startsWith('@'))
|
||||
.map(l => l.slice(1).trim());
|
||||
}
|
||||
|
||||
describe('bug-2948: /gsd-spike --wrap-up dispatch wiring', () => {
|
||||
describe('commands/gsd/spike.md — frontmatter and section contract', () => {
|
||||
test('spike.md command file exists and has valid frontmatter', () => {
|
||||
assert.ok(fs.existsSync(SPIKE_CMD_PATH), 'commands/gsd/spike.md should exist');
|
||||
const fm = parseFrontmatter(fs.readFileSync(SPIKE_CMD_PATH, 'utf-8'));
|
||||
assert.ok(fm.name, 'frontmatter must have a name field');
|
||||
});
|
||||
|
||||
test('argument-hint frontmatter field advertises --wrap-up flag', () => {
|
||||
const fm = parseFrontmatter(fs.readFileSync(SPIKE_CMD_PATH, 'utf-8'));
|
||||
assert.ok(
|
||||
fm['argument-hint'] && fm['argument-hint'].includes('--wrap-up'),
|
||||
`argument-hint must advertise --wrap-up; got: "${fm['argument-hint']}"`
|
||||
);
|
||||
});
|
||||
|
||||
test('execution_context section includes spike-wrap-up workflow reference', () => {
|
||||
const fm = parseFrontmatter(fs.readFileSync(SPIKE_CMD_PATH, 'utf-8'));
|
||||
const execSection = extractSection(fm._body, 'execution_context');
|
||||
assert.ok(execSection !== null, 'spike.md must have an <execution_context> section');
|
||||
const refs = parseExecutionContextRefs(execSection);
|
||||
assert.ok(
|
||||
refs.some(r => r.includes('spike-wrap-up')),
|
||||
`execution_context must declare a spike-wrap-up reference so the runtime can load the workflow; ` +
|
||||
`declared refs: ${JSON.stringify(refs)}`
|
||||
);
|
||||
});
|
||||
|
||||
test('process section dispatches first-token --wrap-up to spike-wrap-up workflow', () => {
|
||||
const fm = parseFrontmatter(fs.readFileSync(SPIKE_CMD_PATH, 'utf-8'));
|
||||
const processSection = extractSection(fm._body, 'process');
|
||||
assert.ok(processSection, 'spike.md must have a <process> section');
|
||||
|
||||
const rules = processSection
|
||||
.split(/\r?\n/)
|
||||
.map(line => line.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
const wrapUpRule = rules.find(line => line.startsWith('- If it is `--wrap-up`:'));
|
||||
const fallbackRule = rules.find(line => line.startsWith('- Otherwise:'));
|
||||
|
||||
assert.ok(
|
||||
wrapUpRule && wrapUpRule.includes('strip the flag') && wrapUpRule.includes('spike-wrap-up'),
|
||||
'process must define a --wrap-up branch that strips the flag and routes to spike-wrap-up'
|
||||
);
|
||||
assert.ok(
|
||||
fallbackRule && fallbackRule.includes('spike workflow'),
|
||||
'process must define an Otherwise fallback to the normal spike workflow'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('get-shit-done/workflows/spike.md — companion references', () => {
|
||||
test('spike workflow file exists', () => {
|
||||
assert.ok(fs.existsSync(SPIKE_WORKFLOW_PATH), 'get-shit-done/workflows/spike.md should exist');
|
||||
});
|
||||
|
||||
test('does NOT reference the deleted /gsd-spike-wrap-up entry-point', () => {
|
||||
const fm = parseFrontmatter(fs.readFileSync(SPIKE_WORKFLOW_PATH, 'utf-8'));
|
||||
assert.ok(
|
||||
!fm._body.includes('/gsd-spike-wrap-up'),
|
||||
'workflows/spike.md must not reference the deleted /gsd-spike-wrap-up command; use /gsd-spike --wrap-up instead'
|
||||
);
|
||||
});
|
||||
|
||||
test('references /gsd-spike --wrap-up as the canonical wrap-up invocation', () => {
|
||||
const fm = parseFrontmatter(fs.readFileSync(SPIKE_WORKFLOW_PATH, 'utf-8'));
|
||||
assert.ok(
|
||||
fm._body.includes('/gsd-spike --wrap-up'),
|
||||
'workflows/spike.md must reference /gsd-spike --wrap-up as the canonical wrap-up command'
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
61
tests/bug-2949-sketch-wrap-up-dispatch.test.cjs
Normal file
61
tests/bug-2949-sketch-wrap-up-dispatch.test.cjs
Normal file
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* GSD Tests — /gsd-sketch --wrap-up silently no-ops (#2949)
|
||||
*
|
||||
* The --wrap-up flag was documented in commands/gsd/sketch.md but never dispatched.
|
||||
* The sketch-wrap-up.md micro-skill entry point was deleted in #2790 and the dispatch
|
||||
* wiring was never added to the command or workflow.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const { describe, test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const ROOT = path.resolve(__dirname, '..');
|
||||
const SKETCH_COMMAND = path.join(ROOT, 'commands/gsd/sketch.md');
|
||||
const SKETCH_WORKFLOW = path.join(ROOT, 'get-shit-done/workflows/sketch.md');
|
||||
|
||||
describe('bug-2949: sketch --wrap-up dispatch wiring', () => {
|
||||
test('commands/gsd/sketch.md contains --wrap-up dispatch logic', () => {
|
||||
const content = fs.readFileSync(SKETCH_COMMAND, 'utf8');
|
||||
assert.ok(
|
||||
content.includes('--wrap-up'),
|
||||
'sketch.md should contain --wrap-up dispatch logic'
|
||||
);
|
||||
// The dispatch should route to sketch-wrap-up workflow
|
||||
assert.ok(
|
||||
content.includes('sketch-wrap-up'),
|
||||
'sketch.md should reference sketch-wrap-up in dispatch logic'
|
||||
);
|
||||
});
|
||||
|
||||
test('commands/gsd/sketch.md has sketch-wrap-up in execution_context section', () => {
|
||||
const content = fs.readFileSync(SKETCH_COMMAND, 'utf8');
|
||||
// Find execution_context block
|
||||
const execCtxMatch = content.match(/<execution_context>([\s\S]*?)<\/execution_context>/);
|
||||
assert.ok(execCtxMatch, 'sketch.md must have an <execution_context> block');
|
||||
const execCtx = execCtxMatch[1];
|
||||
assert.ok(
|
||||
execCtx.includes('sketch-wrap-up'),
|
||||
`execution_context block should include sketch-wrap-up workflow; got: ${execCtx}`
|
||||
);
|
||||
});
|
||||
|
||||
test('workflows/sketch.md does NOT contain old /gsd-sketch-wrap-up form', () => {
|
||||
const content = fs.readFileSync(SKETCH_WORKFLOW, 'utf8');
|
||||
assert.ok(
|
||||
!content.includes('/gsd-sketch-wrap-up'),
|
||||
'workflows/sketch.md must not reference the old /gsd-sketch-wrap-up command'
|
||||
);
|
||||
});
|
||||
|
||||
test('workflows/sketch.md DOES contain new /gsd-sketch --wrap-up form', () => {
|
||||
const content = fs.readFileSync(SKETCH_WORKFLOW, 'utf8');
|
||||
assert.ok(
|
||||
content.includes('/gsd-sketch --wrap-up'),
|
||||
'workflows/sketch.md should reference /gsd-sketch --wrap-up (the new form)'
|
||||
);
|
||||
});
|
||||
});
|
||||
140
tests/bug-2950-stale-command-refs.test.cjs
Normal file
140
tests/bug-2950-stale-command-refs.test.cjs
Normal file
@@ -0,0 +1,140 @@
|
||||
/**
|
||||
* Bug #2950: Stale deleted command references in workflow files
|
||||
*
|
||||
* Multiple workflow files referenced command names removed in #2790
|
||||
* (gsd-add-phase, gsd-insert-phase, gsd-remove-phase, gsd-add-todo,
|
||||
* gsd-set-profile, gsd-settings-integrations, gsd-settings-advanced,
|
||||
* gsd-spike-wrap-up, gsd-sketch-wrap-up, gsd-code-review-fix).
|
||||
*
|
||||
* Fix: Update every occurrence to the new consolidated forms:
|
||||
* /gsd-phase (no flag | --insert | --remove)
|
||||
* /gsd-capture
|
||||
* /gsd-config (--profile | --integrations | --advanced)
|
||||
* /gsd-spike --wrap-up
|
||||
* /gsd-sketch --wrap-up
|
||||
* /gsd-code-review --fix
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const { describe, test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const WORKFLOWS_DIR = path.join(__dirname, '..', 'get-shit-done', 'workflows');
|
||||
|
||||
function read(filename) {
|
||||
return fs.readFileSync(path.join(WORKFLOWS_DIR, filename), 'utf-8');
|
||||
}
|
||||
|
||||
// Deleted command names that must not appear anywhere in the fixed files.
|
||||
const DELETED_COMMANDS = [
|
||||
'/gsd-add-phase',
|
||||
'/gsd-insert-phase',
|
||||
'/gsd-remove-phase',
|
||||
'/gsd-add-todo',
|
||||
'/gsd-set-profile',
|
||||
'/gsd-settings-integrations',
|
||||
'/gsd-settings-advanced',
|
||||
'/gsd-spike-wrap-up',
|
||||
'/gsd-sketch-wrap-up',
|
||||
'/gsd-code-review-fix',
|
||||
];
|
||||
|
||||
// Per-file assertions: [file, deletedCmd, newForm]
|
||||
const FILE_ASSERTIONS = [
|
||||
// help.md
|
||||
['help.md', '/gsd-add-phase', '/gsd-phase "Add admin dashboard"'],
|
||||
['help.md', '/gsd-insert-phase', '/gsd-phase --insert 7 "Fix critical auth bug"'],
|
||||
['help.md', '/gsd-remove-phase', '/gsd-phase --remove 17'],
|
||||
['help.md', '/gsd-spike-wrap-up', '/gsd-spike --wrap-up'],
|
||||
['help.md', '/gsd-sketch-wrap-up', '/gsd-sketch --wrap-up'],
|
||||
['help.md', '/gsd-add-todo', '/gsd-capture'],
|
||||
['help.md', '/gsd-set-profile', '/gsd-config --profile budget'],
|
||||
|
||||
// do.md
|
||||
['do.md', '/gsd-spike-wrap-up', '/gsd-spike --wrap-up'],
|
||||
['do.md', '/gsd-sketch-wrap-up', '/gsd-sketch --wrap-up'],
|
||||
['do.md', '/gsd-add-phase', '/gsd-phase'],
|
||||
['do.md', '/gsd-add-todo', '/gsd-capture'],
|
||||
|
||||
// settings.md
|
||||
['settings.md', '/gsd-code-review-fix', '/gsd-code-review --fix'],
|
||||
['settings.md', '/gsd-settings-integrations', '/gsd-config --integrations'],
|
||||
['settings.md', '/gsd-set-profile', '/gsd-config --profile'],
|
||||
['settings.md', '/gsd-settings-advanced', '/gsd-config --advanced'],
|
||||
|
||||
// discuss-phase.md
|
||||
['discuss-phase.md', '/gsd-spike-wrap-up', '/gsd-spike --wrap-up'],
|
||||
['discuss-phase.md', '/gsd-sketch-wrap-up', '/gsd-sketch --wrap-up'],
|
||||
|
||||
// new-project.md
|
||||
['new-project.md', '/gsd-spike-wrap-up', '/gsd-spike --wrap-up'],
|
||||
['new-project.md', '/gsd-sketch-wrap-up', '/gsd-sketch --wrap-up'],
|
||||
|
||||
// plan-phase.md
|
||||
['plan-phase.md', '/gsd-insert-phase', '/gsd-phase --insert'],
|
||||
|
||||
// spike.md
|
||||
['spike.md', '/gsd-spike-wrap-up', '/gsd-spike --wrap-up'],
|
||||
|
||||
// sketch.md
|
||||
['sketch.md', '/gsd-sketch-wrap-up', '/gsd-sketch --wrap-up'],
|
||||
];
|
||||
|
||||
describe('bug #2950: stale deleted-command references removed from workflow files', () => {
|
||||
// Build a map of file → content to avoid re-reading
|
||||
const files = [...new Set(FILE_ASSERTIONS.map(([f]) => f))];
|
||||
const contentMap = {};
|
||||
for (const f of files) {
|
||||
contentMap[f] = read(f);
|
||||
}
|
||||
|
||||
// For each (file, deletedCmd) pair, assert the old name is absent
|
||||
for (const [file, deletedCmd] of FILE_ASSERTIONS) {
|
||||
test(`${file}: does not contain deleted command "${deletedCmd}"`, () => {
|
||||
const content = contentMap[file];
|
||||
assert.ok(
|
||||
!content.includes(deletedCmd),
|
||||
`${file} still contains deleted command "${deletedCmd}" — update to new form`
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
// For each (file, deletedCmd, newForm) triple, assert the new form is present
|
||||
for (const [file, , newForm] of FILE_ASSERTIONS) {
|
||||
test(`${file}: contains new form "${newForm}"`, () => {
|
||||
const content = contentMap[file];
|
||||
assert.ok(
|
||||
content.includes(newForm),
|
||||
`${file} is missing expected new form "${newForm}"`
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
// Blanket check: no affected workflow file contains any of the deleted command names
|
||||
// (catches any we might have missed in per-file assertions above)
|
||||
const affectedFiles = [
|
||||
'help.md',
|
||||
'do.md',
|
||||
'settings.md',
|
||||
'discuss-phase.md',
|
||||
'new-project.md',
|
||||
'plan-phase.md',
|
||||
'spike.md',
|
||||
'sketch.md',
|
||||
];
|
||||
|
||||
for (const file of affectedFiles) {
|
||||
const content = read(file);
|
||||
for (const deleted of DELETED_COMMANDS) {
|
||||
test(`${file}: blanket check — "${deleted}" not present`, () => {
|
||||
assert.ok(
|
||||
!content.includes(deleted),
|
||||
`${file} contains deleted command "${deleted}"`
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
165
tests/bug-2954-help-md-slash-command-stubs.test.cjs
Normal file
165
tests/bug-2954-help-md-slash-command-stubs.test.cjs
Normal file
@@ -0,0 +1,165 @@
|
||||
'use strict';
|
||||
|
||||
process.env.GSD_TEST_MODE = '1';
|
||||
|
||||
/**
|
||||
* Bug #2954: keep `help.md` and the live `commands/gsd/*` slash surface
|
||||
* in lockstep. Two regression tests:
|
||||
*
|
||||
* 1. help.md must not advertise any /gsd-<name> that has no shipped
|
||||
* slash command. (Caught the original #2954 regression: #2824 deleted
|
||||
* 31 stubs without updating help.md.)
|
||||
*
|
||||
* 2. Every shipped /gsd-<name> command must appear in help.md. (Caught
|
||||
* the inverse: a command lands without docs, so users never discover it.)
|
||||
*
|
||||
* The shipped slash name is parsed from frontmatter `name:` (which can be
|
||||
* either `gsd:foo` or `gsd-foo` — Claude Code surfaces both as `/gsd-foo`),
|
||||
* NOT from the filename, because some files (e.g. `ns-context.md`) ship a
|
||||
* different slash name (`gsd-context`) than their filename suggests.
|
||||
*
|
||||
* Also covers `do.md`, the dispatcher invoked at runtime by
|
||||
* `/gsd-progress --do`: any `/gsd-<name>` token in its routing table must
|
||||
* resolve to a live command, otherwise the dispatcher emits "Unknown command".
|
||||
*/
|
||||
|
||||
const { test, describe } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const ROOT = path.join(__dirname, '..');
|
||||
const COMMANDS_DIR = path.join(ROOT, 'commands', 'gsd');
|
||||
const HELP_MD = path.join(ROOT, 'get-shit-done', 'workflows', 'help.md');
|
||||
const DO_MD = path.join(ROOT, 'get-shit-done', 'workflows', 'do.md');
|
||||
|
||||
function parseFrontmatter(content) {
|
||||
const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
|
||||
if (!match) return null;
|
||||
const fields = {};
|
||||
for (const line of match[1].split(/\r?\n/)) {
|
||||
const fieldMatch = line.match(/^([a-zA-Z0-9_-]+):\s*(.*)$/);
|
||||
if (!fieldMatch) continue;
|
||||
const value = fieldMatch[2].trim().replace(/^["']|["']$/g, '');
|
||||
fields[fieldMatch[1]] = value;
|
||||
}
|
||||
return fields;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the set of slash-base-names actually shipped under commands/gsd/.
|
||||
* A "slash-base-name" is the part after `/gsd-` — e.g. for frontmatter
|
||||
* `name: gsd:foo` or `name: gsd-foo`, the slash-base-name is `foo`.
|
||||
*/
|
||||
function listShippedSlashBaseNames() {
|
||||
const names = new Set();
|
||||
for (const entry of fs.readdirSync(COMMANDS_DIR, { withFileTypes: true })) {
|
||||
if (!entry.isFile() || !entry.name.endsWith('.md')) continue;
|
||||
const content = fs.readFileSync(path.join(COMMANDS_DIR, entry.name), 'utf8');
|
||||
const fm = parseFrontmatter(content);
|
||||
if (!fm || !fm.name) continue;
|
||||
const fmName = fm.name;
|
||||
let base = null;
|
||||
if (fmName.startsWith('gsd:')) base = fmName.slice(4);
|
||||
else if (fmName.startsWith('gsd-')) base = fmName.slice(4);
|
||||
if (base && /^[a-z][a-z0-9-]*$/.test(base)) names.add(base);
|
||||
}
|
||||
return names;
|
||||
}
|
||||
|
||||
function extractSlashReferences(contents) {
|
||||
const names = new Set();
|
||||
const tokenRe = /\/gsd-([a-z][a-z0-9-]*)/g;
|
||||
let match;
|
||||
while ((match = tokenRe.exec(contents)) !== null) {
|
||||
names.add(match[1]);
|
||||
}
|
||||
return names;
|
||||
}
|
||||
|
||||
/**
|
||||
* For every shipped command with an `argument-hint:` frontmatter entry,
|
||||
* collect the `--flag` tokens it advertises. Returns a Map<slashBaseName,
|
||||
* Set<flagName>>. Flags are recorded without their leading `--`.
|
||||
*/
|
||||
function listShippedFlagsByCommand() {
|
||||
const out = new Map();
|
||||
for (const entry of fs.readdirSync(COMMANDS_DIR, { withFileTypes: true })) {
|
||||
if (!entry.isFile() || !entry.name.endsWith('.md')) continue;
|
||||
const content = fs.readFileSync(path.join(COMMANDS_DIR, entry.name), 'utf8');
|
||||
const fm = parseFrontmatter(content);
|
||||
if (!fm || !fm.name || !fm['argument-hint']) continue;
|
||||
const fmName = fm.name;
|
||||
let base = null;
|
||||
if (fmName.startsWith('gsd:')) base = fmName.slice(4);
|
||||
else if (fmName.startsWith('gsd-')) base = fmName.slice(4);
|
||||
if (!base || !/^[a-z][a-z0-9-]*$/.test(base)) continue;
|
||||
const flags = new Set();
|
||||
for (const m of fm['argument-hint'].matchAll(/--([a-z][a-z0-9-]*)/g)) {
|
||||
flags.add(m[1]);
|
||||
}
|
||||
if (flags.size) out.set(base, flags);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
describe('Bug #2954: help.md ↔ commands/gsd/ bidirectional parity', () => {
|
||||
test('every /gsd-<name> referenced in help.md is a shipped command', () => {
|
||||
const helpContents = fs.readFileSync(HELP_MD, 'utf8');
|
||||
const referenced = extractSlashReferences(helpContents);
|
||||
const shipped = listShippedSlashBaseNames();
|
||||
const dangling = [...referenced].filter((n) => !shipped.has(n)).sort();
|
||||
assert.deepEqual(
|
||||
dangling,
|
||||
[],
|
||||
`help.md advertises /gsd-<name> commands that are not shipped: ${dangling.join(', ')}`,
|
||||
);
|
||||
});
|
||||
|
||||
test('every shipped /gsd-<name> command is documented in help.md', () => {
|
||||
const helpContents = fs.readFileSync(HELP_MD, 'utf8');
|
||||
const referenced = extractSlashReferences(helpContents);
|
||||
const shipped = listShippedSlashBaseNames();
|
||||
const undocumented = [...shipped].filter((n) => !referenced.has(n)).sort();
|
||||
assert.deepEqual(
|
||||
undocumented,
|
||||
[],
|
||||
`commands shipped under commands/gsd/ with no /gsd-<name> reference in help.md: ${undocumented.join(', ')}`,
|
||||
);
|
||||
});
|
||||
|
||||
test('every /gsd-<name> in do.md (live dispatcher) is a shipped command', () => {
|
||||
const doContents = fs.readFileSync(DO_MD, 'utf8');
|
||||
const referenced = extractSlashReferences(doContents);
|
||||
const shipped = listShippedSlashBaseNames();
|
||||
const dangling = [...referenced].filter((n) => !shipped.has(n)).sort();
|
||||
assert.deepEqual(
|
||||
dangling,
|
||||
[],
|
||||
`do.md routing table references /gsd-<name> that is not shipped: ${dangling.join(', ')}`,
|
||||
);
|
||||
});
|
||||
|
||||
test('every --flag in a command\'s argument-hint appears in help.md', () => {
|
||||
const helpContents = fs.readFileSync(HELP_MD, 'utf8');
|
||||
const flagsByCommand = listShippedFlagsByCommand();
|
||||
const gaps = [];
|
||||
for (const [command, flags] of flagsByCommand) {
|
||||
for (const flag of flags) {
|
||||
// Accept `/gsd-<command> --<flag>` (precise) OR a bare `--<flag>` token
|
||||
// anywhere in help.md (good enough for shared flags like `--force` that
|
||||
// appear under multiple commands' descriptions).
|
||||
const preciseToken = `/gsd-${command} --${flag}`;
|
||||
const flagToken = `--${flag}`;
|
||||
if (!helpContents.includes(preciseToken) && !helpContents.includes(flagToken)) {
|
||||
gaps.push(`/gsd-${command} --${flag}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
assert.deepEqual(
|
||||
gaps.sort(),
|
||||
[],
|
||||
`commands ship --flag(s) in argument-hint that are absent from help.md: ${gaps.join(', ')}`,
|
||||
);
|
||||
});
|
||||
});
|
||||
70
tests/bug-2957-claude-global-postinstall-message.test.cjs
Normal file
70
tests/bug-2957-claude-global-postinstall-message.test.cjs
Normal file
@@ -0,0 +1,70 @@
|
||||
'use strict';
|
||||
|
||||
process.env.GSD_TEST_MODE = '1';
|
||||
|
||||
/**
|
||||
* Bug #2957: post-install message for `--claude --global` must instruct
|
||||
* users to restart Claude Code and offer the skill-name fallback, since
|
||||
* the skills-only install layout (CC 2.1.88+) leaves nothing in
|
||||
* commands/gsd/ for the slash menu to read on older configurations.
|
||||
*
|
||||
* Captures the call to finishInstall(runtime='claude', isGlobal=true) and
|
||||
* asserts the printed message contains both invocation paths.
|
||||
*/
|
||||
|
||||
const { test, describe } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const path = require('node:path');
|
||||
|
||||
const ROOT = path.join(__dirname, '..');
|
||||
const installModule = require(path.join(ROOT, 'bin', 'install.js'));
|
||||
|
||||
function captureFinishInstallOutput(runtime, isGlobal) {
|
||||
const original = console.log;
|
||||
const lines = [];
|
||||
console.log = (...args) => { lines.push(args.join(' ')); };
|
||||
try {
|
||||
installModule.finishInstall(
|
||||
'/tmp/gsd-test-settings.json',
|
||||
{},
|
||||
null,
|
||||
false,
|
||||
runtime,
|
||||
isGlobal,
|
||||
null,
|
||||
);
|
||||
} finally {
|
||||
console.log = original;
|
||||
}
|
||||
// Strip ANSI color escapes so message-content assertions don't couple to colors.
|
||||
return lines.join('\n').replace(/\x1B\[[0-9;]*m/g, '');
|
||||
}
|
||||
|
||||
describe('Bug #2957: claude+global post-install message', () => {
|
||||
test('claude+global message tells the user to restart and offers skill-name fallback', () => {
|
||||
const output = captureFinishInstallOutput('claude', true);
|
||||
|
||||
assert.match(output, /restart claude code/i, 'should mention restart');
|
||||
assert.match(output, /\/gsd-new-project/, 'should still mention /gsd-new-project');
|
||||
assert.match(output, /gsd-new-project skill/i, 'should mention the skill name fallback');
|
||||
assert.doesNotMatch(
|
||||
output,
|
||||
/open a blank directory/i,
|
||||
'global claude install should replace, not extend, the legacy generic instruction',
|
||||
);
|
||||
});
|
||||
|
||||
test('claude+local message keeps the original /gsd-new-project instruction', () => {
|
||||
const output = captureFinishInstallOutput('claude', false);
|
||||
|
||||
assert.match(output, /\/gsd-new-project/, 'should still mention /gsd-new-project');
|
||||
assert.doesNotMatch(output, /restart claude code/i, 'local install does not require the skills restart note');
|
||||
});
|
||||
|
||||
test('non-claude runtimes keep their original message format', () => {
|
||||
const output = captureFinishInstallOutput('opencode', true);
|
||||
|
||||
assert.match(output, /Open a blank directory/, 'opencode message should be unchanged');
|
||||
assert.doesNotMatch(output, /restart/i, 'opencode message should not have the claude-specific restart note');
|
||||
});
|
||||
});
|
||||
150
tests/bug-2962-windows-sdk-shim.test.cjs
Normal file
150
tests/bug-2962-windows-sdk-shim.test.cjs
Normal file
@@ -0,0 +1,150 @@
|
||||
'use strict';
|
||||
|
||||
process.env.GSD_TEST_MODE = '1';
|
||||
|
||||
/**
|
||||
* Bug #2962: --sdk install flag on Windows leaves gsd-sdk un-shimmed.
|
||||
*
|
||||
* Tests are split into two layers, each at the right level of abstraction:
|
||||
*
|
||||
* 1. buildWindowsShimTriple — pure IR builder. Tests assert on TYPED
|
||||
* FIELDS of the returned record (interpreter, target, eol, fileNames).
|
||||
* No filesystem, no spawn, no text reads. This is the level where
|
||||
* structural correctness lives.
|
||||
*
|
||||
* 2. trySelfLinkGsdSdkWindows — fs/spawn driver that calls the IR builder
|
||||
* and writes the rendered shims to disk. Tests assert FILESYSTEM FACTS
|
||||
* (file exists, file is non-empty, file mtime advanced after replace,
|
||||
* function return value). No reads, no parsing, no substring matching.
|
||||
*
|
||||
* Per the repo's no-source-grep testing standard (CONTRIBUTING.md): the
|
||||
* test must NEVER read shim file contents and pattern-match against them.
|
||||
* The IR is the contract; the rendered text is an implementation detail of
|
||||
* the renderer.
|
||||
*/
|
||||
|
||||
const { test, describe, before, after } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const cp = require('node:child_process');
|
||||
|
||||
const ROOT = path.join(__dirname, '..');
|
||||
const installModule = require(path.join(ROOT, 'bin', 'install.js'));
|
||||
|
||||
describe('Bug #2962: buildWindowsShimTriple — pure IR builder', () => {
|
||||
test('resolves shimSrc to an absolute path on the invocation.target field', () => {
|
||||
const shimSrc = path.join(ROOT, 'bin', 'gsd-sdk.js');
|
||||
const triple = installModule.buildWindowsShimTriple(shimSrc);
|
||||
assert.equal(triple.invocation.target, path.resolve(shimSrc));
|
||||
assert.equal(triple.invocation.interpreter, 'node');
|
||||
});
|
||||
|
||||
test('produces a structured IR with the documented shape', () => {
|
||||
const triple = installModule.buildWindowsShimTriple(path.join(ROOT, 'bin', 'gsd-sdk.js'));
|
||||
// Lock the public IR shape — adding/removing a key requires updating this assertion.
|
||||
assert.deepEqual(Object.keys(triple).sort(), ['eol', 'fileNames', 'invocation', 'render']);
|
||||
assert.deepEqual(Object.keys(triple.invocation).sort(), ['interpreter', 'target']);
|
||||
assert.deepEqual(Object.keys(triple.eol).sort(), ['cmd', 'ps1', 'sh']);
|
||||
assert.deepEqual(Object.keys(triple.fileNames).sort(), ['cmd', 'ps1', 'sh']);
|
||||
assert.deepEqual(Object.keys(triple.render).sort(), ['cmd', 'ps1', 'sh']);
|
||||
});
|
||||
|
||||
test('declares CRLF line endings on the .cmd file, LF on .ps1 and bash wrapper', () => {
|
||||
const triple = installModule.buildWindowsShimTriple(path.join(ROOT, 'bin', 'gsd-sdk.js'));
|
||||
assert.deepEqual(triple.eol, { cmd: '\r\n', ps1: '\n', sh: '\n' });
|
||||
});
|
||||
|
||||
test('declares the standard npm-style filenames for the shim triple', () => {
|
||||
const triple = installModule.buildWindowsShimTriple(path.join(ROOT, 'bin', 'gsd-sdk.js'));
|
||||
assert.deepEqual(triple.fileNames, { cmd: 'gsd-sdk.cmd', ps1: 'gsd-sdk.ps1', sh: 'gsd-sdk' });
|
||||
});
|
||||
|
||||
test('IR is purely a function of shimSrc — no fs / spawn side effects', () => {
|
||||
// If buildWindowsShimTriple touched the filesystem, calling it twice with
|
||||
// different shimSrc paths would leave two different artifacts. Asserting
|
||||
// pure-function behavior structurally: same input → identical IR.
|
||||
const shimSrc = path.join(ROOT, 'bin', 'gsd-sdk.js');
|
||||
const a = installModule.buildWindowsShimTriple(shimSrc);
|
||||
const b = installModule.buildWindowsShimTriple(shimSrc);
|
||||
assert.deepEqual(a.invocation, b.invocation);
|
||||
assert.deepEqual(a.eol, b.eol);
|
||||
assert.deepEqual(a.fileNames, b.fileNames);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Bug #2962: trySelfLinkGsdSdkWindows — fs/spawn driver', () => {
|
||||
let tmpDir;
|
||||
let origExecSync;
|
||||
|
||||
before(() => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2962-'));
|
||||
origExecSync = cp.execSync;
|
||||
cp.execSync = (cmd) => {
|
||||
if (typeof cmd === 'string' && cmd.trim() === 'npm prefix -g') {
|
||||
return tmpDir + '\n';
|
||||
}
|
||||
return origExecSync.call(cp, cmd);
|
||||
};
|
||||
});
|
||||
|
||||
after(() => {
|
||||
cp.execSync = origExecSync;
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test('returns the .cmd path on success and writes all three shim files', () => {
|
||||
const shimSrc = path.join(ROOT, 'bin', 'gsd-sdk.js');
|
||||
const triple = installModule.buildWindowsShimTriple(shimSrc);
|
||||
const result = installModule.trySelfLinkGsdSdkWindows(shimSrc);
|
||||
|
||||
assert.equal(result, path.join(tmpDir, triple.fileNames.cmd));
|
||||
for (const fileName of Object.values(triple.fileNames)) {
|
||||
const target = path.join(tmpDir, fileName);
|
||||
const stat = fs.statSync(target);
|
||||
assert.ok(stat.isFile(), `${fileName} must be a regular file`);
|
||||
assert.ok(stat.size > 0, `${fileName} must be non-empty`);
|
||||
}
|
||||
});
|
||||
|
||||
test('the rendered file size matches the IR renderer\'s output length (renderer drives the writer)', () => {
|
||||
// Asserts the writer writes exactly what the renderer produces — no mutation,
|
||||
// no double-write, no truncation. We compare BYTE LENGTHS, not contents:
|
||||
// length is a structural property; content equality would re-introduce text matching.
|
||||
const shimSrc = path.join(ROOT, 'bin', 'gsd-sdk.js');
|
||||
const triple = installModule.buildWindowsShimTriple(shimSrc);
|
||||
installModule.trySelfLinkGsdSdkWindows(shimSrc);
|
||||
for (const kind of ['cmd', 'ps1', 'sh']) {
|
||||
const target = path.join(tmpDir, triple.fileNames[kind]);
|
||||
const expected = Buffer.byteLength(triple.render[kind](), 'utf8');
|
||||
assert.equal(fs.statSync(target).size, expected, `${kind} byte length matches renderer`);
|
||||
}
|
||||
});
|
||||
|
||||
test('replaces stale shims atomically (mtime advances on rewrite)', () => {
|
||||
const shimSrc = path.join(ROOT, 'bin', 'gsd-sdk.js');
|
||||
installModule.trySelfLinkGsdSdkWindows(shimSrc);
|
||||
const cmdPath = path.join(tmpDir, 'gsd-sdk.cmd');
|
||||
const beforeMtime = fs.statSync(cmdPath).mtimeMs;
|
||||
|
||||
// Wait at least 10ms so mtime granularity (1ms on most fs, 1s on some) records the change.
|
||||
const wait = Date.now() + 20;
|
||||
while (Date.now() < wait) { /* busy-wait, intentional */ }
|
||||
|
||||
installModule.trySelfLinkGsdSdkWindows(shimSrc);
|
||||
const afterMtime = fs.statSync(cmdPath).mtimeMs;
|
||||
assert.ok(afterMtime > beforeMtime, `mtime must advance: before=${beforeMtime} after=${afterMtime}`);
|
||||
});
|
||||
|
||||
test('returns null when npm prefix -g fails', () => {
|
||||
const restore = cp.execSync;
|
||||
cp.execSync = () => { throw new Error('npm not on PATH'); };
|
||||
try {
|
||||
const result = installModule.trySelfLinkGsdSdkWindows(path.join(ROOT, 'bin', 'gsd-sdk.js'));
|
||||
assert.equal(result, null);
|
||||
} finally {
|
||||
cp.execSync = restore;
|
||||
}
|
||||
});
|
||||
});
|
||||
144
tests/bug-2964-release-sdk-empty-cherry-pick.test.cjs
Normal file
144
tests/bug-2964-release-sdk-empty-cherry-pick.test.cjs
Normal file
@@ -0,0 +1,144 @@
|
||||
/**
|
||||
* Regression test for bug #2964
|
||||
*
|
||||
* The release-sdk hotfix workflow's auto_cherry_pick loop aborted the entire
|
||||
* run if any commit between the base tag and origin/main had an empty diff
|
||||
* against its parent (e.g. a squash-merge whose contents were already merged
|
||||
* via an earlier PR). `git cherry-pick -x` exits non-zero on empty commits
|
||||
* with "The previous cherry-pick is now empty", and the workflow's loop
|
||||
* (`if ! git cherry-pick -x "$SHA"; then ... exit 1`) treated any non-zero
|
||||
* as a hard conflict — bricking every hotfix the moment a no-op commit
|
||||
* landed on main.
|
||||
*
|
||||
* Fix: pass `--allow-empty --keep-redundant-commits` so empty picks are
|
||||
* preserved on the hotfix branch (with `-x` provenance, matching main 1:1)
|
||||
* and picks whose diff resolves to empty after applying to the new base
|
||||
* also pass cleanly. Real conflicts still surface — the flags only change
|
||||
* the empty-commit exit code.
|
||||
*
|
||||
* This test asserts both:
|
||||
* 1. Static — the workflow YAML carries the flags on the cherry-pick call
|
||||
* inside the auto_cherry_pick loop. If a future edit drops them, this
|
||||
* regresses immediately.
|
||||
* 2. Behavioral — `git cherry-pick -x --allow-empty --keep-redundant-commits`
|
||||
* against a real empty commit in a throwaway repo exits 0 (proves the
|
||||
* flags semantically do what we claim), while plain `git cherry-pick -x`
|
||||
* exits non-zero against the same commit (proves the bug exists without
|
||||
* the flags).
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
// allow-test-rule: source-text-is-the-product
|
||||
// The release-sdk.yml workflow IS the product for hotfix automation —
|
||||
// GitHub Actions executes the YAML's shell verbatim. Testing the text
|
||||
// content tests the deployed contract: if the flags are absent, the
|
||||
// empty-commit guarantee is absent.
|
||||
|
||||
const { describe, test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const { spawnSync } = require('node:child_process');
|
||||
|
||||
const WORKFLOW_PATH = path.join(__dirname, '..', '.github', 'workflows', 'release-sdk.yml');
|
||||
|
||||
function git(cwd, args, env = {}) {
|
||||
// Force-disable signing inline so a developer's global gpgsign / sshsign
|
||||
// config can't fail commits in this throwaway repo. Don't rely on env
|
||||
// because gpg.format/user.signingkey live in gitconfig, not env vars.
|
||||
const signingOff = ['-c', 'commit.gpgsign=false', '-c', 'tag.gpgsign=false', '-c', 'gpg.format=openpgp', '-c', 'user.signingkey='];
|
||||
return spawnSync('git', [...signingOff, ...args], {
|
||||
cwd,
|
||||
encoding: 'utf8',
|
||||
env: { ...process.env, ...env, GIT_AUTHOR_NAME: 'test', GIT_AUTHOR_EMAIL: 't@t', GIT_COMMITTER_NAME: 'test', GIT_COMMITTER_EMAIL: 't@t' },
|
||||
});
|
||||
}
|
||||
|
||||
describe('bug-2964: release-sdk hotfix cherry-pick survives empty commits', () => {
|
||||
test('release-sdk.yml passes --allow-empty --keep-redundant-commits in the auto_cherry_pick loop', () => {
|
||||
const yaml = fs.readFileSync(WORKFLOW_PATH, 'utf8');
|
||||
|
||||
// Find the auto_cherry_pick block by anchoring on a line unique to it,
|
||||
// then assert the cherry-pick invocation inside that block carries both
|
||||
// flags. We deliberately scope to the loop — a stray `git cherry-pick`
|
||||
// elsewhere in the file (none today) would not satisfy this contract.
|
||||
const loopAnchor = yaml.indexOf('CANDIDATES=$(git cherry HEAD origin/main');
|
||||
assert.ok(
|
||||
loopAnchor !== -1,
|
||||
'release-sdk.yml must contain the auto_cherry_pick loop that derives candidates via `git cherry HEAD origin/main` (#2964)'
|
||||
);
|
||||
|
||||
// The cherry-pick call lives within the auto_cherry_pick loop. Bound
|
||||
// the slice generously after the anchor so future pre-skip guards /
|
||||
// classification scaffolding (e.g. the merge-commit pre-skip added
|
||||
// on PR #2970, the workflow-file pre-skip added on PR for #2980,
|
||||
// the PIPESTATUS-snapshot hardening added on PR for #2984's CR
|
||||
// findings) don't push the call out of range, but still tight
|
||||
// enough to avoid matching unrelated cherry-pick refs elsewhere in
|
||||
// the workflow file.
|
||||
// Allow arbitrary git options between `git` and `cherry-pick` (e.g.
|
||||
// `git -c merge.conflictStyle=merge cherry-pick ...` added for #2966)
|
||||
// so this test doesn't false-fail on legitimate option additions.
|
||||
const window = yaml.slice(loopAnchor, loopAnchor + 8000);
|
||||
const pickMatch = /git\b[^\n]*?cherry-pick[^\n]*"\$SHA"/.exec(window);
|
||||
assert.ok(
|
||||
pickMatch,
|
||||
'auto_cherry_pick loop must invoke `git ... cherry-pick ... "$SHA"` (#2964)'
|
||||
);
|
||||
|
||||
const pickLine = pickMatch[0];
|
||||
assert.ok(
|
||||
pickLine.includes('--allow-empty'),
|
||||
`auto_cherry_pick must pass --allow-empty so empty no-op commits on main do not abort the hotfix (#2964). Found: ${pickLine}`
|
||||
);
|
||||
assert.ok(
|
||||
pickLine.includes('--keep-redundant-commits'),
|
||||
`auto_cherry_pick must pass --keep-redundant-commits so commits whose diff resolves to empty after rebasing onto the base tag do not abort the hotfix (#2964). Found: ${pickLine}`
|
||||
);
|
||||
});
|
||||
|
||||
test('git cherry-pick with --allow-empty --keep-redundant-commits succeeds on an empty commit; without them it fails', () => {
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'bug-2964-'));
|
||||
try {
|
||||
// Build a synthetic repo with one real commit on main and one truly
|
||||
// empty commit on top — same shape as the real upstream artifact
|
||||
// (b328f326 on origin/main has tree == its parent's tree).
|
||||
assert.equal(git(tmp, ['init', '-q', '-b', 'main']).status, 0, 'git init');
|
||||
fs.writeFileSync(path.join(tmp, 'README.md'), 'base\n');
|
||||
assert.equal(git(tmp, ['add', 'README.md']).status, 0, 'git add');
|
||||
assert.equal(git(tmp, ['commit', '-q', '-m', 'base']).status, 0, 'base commit');
|
||||
assert.equal(git(tmp, ['tag', 'v0.0.0']).status, 0, 'tag base');
|
||||
// Make a genuinely empty commit on main.
|
||||
assert.equal(git(tmp, ['commit', '--allow-empty', '-q', '-m', 'fix: noop on main']).status, 0, 'empty commit');
|
||||
const empty = git(tmp, ['rev-parse', 'HEAD']).stdout.trim();
|
||||
assert.ok(empty.length === 40, `expected sha, got ${empty}`);
|
||||
|
||||
// Reset to the base tag (simulates the hotfix branch starting from v0.0.0).
|
||||
assert.equal(git(tmp, ['checkout', '-q', '-b', 'hotfix/0.0.1', 'v0.0.0']).status, 0, 'checkout hotfix');
|
||||
|
||||
// Without the flags: cherry-pick of an empty commit fails.
|
||||
const without = git(tmp, ['cherry-pick', '-x', empty]);
|
||||
assert.notEqual(
|
||||
without.status,
|
||||
0,
|
||||
'plain `git cherry-pick -x` MUST fail on an empty commit — if this passes, git semantics changed and the bug premise is gone (#2964)'
|
||||
);
|
||||
// Reset cherry-pick state for the next run.
|
||||
git(tmp, ['cherry-pick', '--abort']);
|
||||
// git may have already auto-resolved to a clean state; ensure we're back to v0.0.0.
|
||||
git(tmp, ['reset', '--hard', 'v0.0.0']);
|
||||
|
||||
// With the flags (matching what the workflow now uses): success.
|
||||
const withFlags = git(tmp, ['cherry-pick', '-x', '--allow-empty', '--keep-redundant-commits', empty]);
|
||||
assert.equal(
|
||||
withFlags.status,
|
||||
0,
|
||||
`git cherry-pick -x --allow-empty --keep-redundant-commits MUST succeed on an empty commit (#2964). stderr: ${withFlags.stderr}`
|
||||
);
|
||||
} finally {
|
||||
fs.rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
289
tests/bug-2966-cherry-pick-context-missing.test.cjs
Normal file
289
tests/bug-2966-cherry-pick-context-missing.test.cjs
Normal file
@@ -0,0 +1,289 @@
|
||||
/**
|
||||
* Regression test for bug #2966
|
||||
*
|
||||
* The release-sdk hotfix workflow's auto_cherry_pick loop aborts when a
|
||||
* `fix:`/`chore:` commit's patch is rooted in code that doesn't exist at
|
||||
* the hotfix's base tag (e.g. the surrounding block was added later in a
|
||||
* feat/refactor commit excluded by the filter). The conflict is
|
||||
* unresolvable — the patch literally cannot be applied to a tree that
|
||||
* lacks the surrounding infrastructure — but the workflow treats it as
|
||||
* an operator-resolvable conflict and exits.
|
||||
*
|
||||
* Fix: after `git cherry-pick` exits non-zero, inspect each unmerged
|
||||
* file's conflict markers. If every conflict block in every file has an
|
||||
* empty `<<<<<<< HEAD ... =======` HEAD section, run `git cherry-pick
|
||||
* --skip` and add the SHA to the skipped list with reason
|
||||
* "context absent at base". Else, fall through to the existing abort/
|
||||
* push-partial/error path.
|
||||
*
|
||||
* This test asserts both:
|
||||
* 1. Static — the auto_cherry_pick loop in release-sdk.yml carries the
|
||||
* context-missing detection (matching `git cherry-pick --skip` and
|
||||
* `context absent at base` semantics) so the no-source-grep static
|
||||
* check is still meaningful for future edits.
|
||||
* 2. Behavioral — using a synthetic git repo that reproduces the exact
|
||||
* shape of the failure on origin/main:
|
||||
* a. A patch whose target context doesn't exist at base produces
|
||||
* empty-HEAD conflict markers AND a non-zero exit from
|
||||
* cherry-pick. (Proves the bug premise.)
|
||||
* b. The `awk` predicate in the workflow correctly classifies the
|
||||
* empty-HEAD case as "context-missing" (skippable) and the
|
||||
* both-sides-have-content case as "real" (must abort).
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
// allow-test-rule: source-text-is-the-product
|
||||
// release-sdk.yml IS the product for hotfix automation; GitHub Actions
|
||||
// executes the YAML's shell verbatim. The static check uses structured
|
||||
// extraction (extractStepRun) rather than raw-text grep, scoped to the
|
||||
// "Prepare hotfix branch" step's run block.
|
||||
|
||||
const { describe, test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const { spawnSync } = require('node:child_process');
|
||||
|
||||
const WORKFLOW_PATH = path.join(__dirname, '..', '.github', 'workflows', 'release-sdk.yml');
|
||||
|
||||
/**
|
||||
* Extract the `run:` literal block of a named step from a GitHub Actions
|
||||
* workflow using indentation-aware parsing — no raw-text grep across the
|
||||
* whole document. Walks lines once, recognises `- name:` step headers and
|
||||
* `run: |` literal-block markers, and returns the unindented script body.
|
||||
*
|
||||
* No YAML library is used; the repo has none in dependencies and adding
|
||||
* one for a single test isn't justified.
|
||||
*/
|
||||
function extractStepRun(workflowText, stepName) {
|
||||
const lines = workflowText.split('\n');
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const m = lines[i].match(/^(\s*)- name:\s*(.+?)\s*$/);
|
||||
if (!m || m[2] !== stepName) continue;
|
||||
const stepIndent = m[1].length;
|
||||
let j = i + 1;
|
||||
while (j < lines.length) {
|
||||
const peek = lines[j];
|
||||
if (/^\s*- /.test(peek)) {
|
||||
const peekIndent = peek.match(/^(\s*)/)[1].length;
|
||||
if (peekIndent <= stepIndent) break;
|
||||
}
|
||||
const runMatch = peek.match(/^(\s*)run:\s*\|(?:[+-])?\s*$/);
|
||||
if (runMatch) {
|
||||
const blockIndent = runMatch[1].length + 2;
|
||||
const body = [];
|
||||
for (let k = j + 1; k < lines.length; k++) {
|
||||
const bodyLine = lines[k];
|
||||
if (bodyLine.length === 0) {
|
||||
body.push('');
|
||||
continue;
|
||||
}
|
||||
const lead = bodyLine.match(/^(\s*)/)[1].length;
|
||||
if (lead < blockIndent && bodyLine.trim() !== '') break;
|
||||
body.push(bodyLine.slice(blockIndent));
|
||||
}
|
||||
return body.join('\n');
|
||||
}
|
||||
j++;
|
||||
}
|
||||
throw new Error(`step "${stepName}" found but no run: | block before step end`);
|
||||
}
|
||||
throw new Error(`step "${stepName}" not found in workflow`);
|
||||
}
|
||||
|
||||
function git(cwd, args) {
|
||||
// Force-disable signing inline — a developer's global gpgsign config
|
||||
// can't be allowed to fail commits in this throwaway repo. Also pin
|
||||
// merge.conflictStyle=merge so the cherry-pick reproducer below sees
|
||||
// the same marker shape the workflow guards against (diff3/zdiff3 in
|
||||
// the developer or CI runner's global config would inject `|||||||`
|
||||
// sections and break the empty-HEAD assertion).
|
||||
const inlineConfig = [
|
||||
'-c', 'commit.gpgsign=false',
|
||||
'-c', 'tag.gpgsign=false',
|
||||
'-c', 'gpg.format=openpgp',
|
||||
'-c', 'user.signingkey=',
|
||||
'-c', 'merge.conflictStyle=merge',
|
||||
];
|
||||
return spawnSync('git', [...inlineConfig, ...args], {
|
||||
cwd,
|
||||
encoding: 'utf8',
|
||||
env: { ...process.env, GIT_AUTHOR_NAME: 'test', GIT_AUTHOR_EMAIL: 't@t', GIT_COMMITTER_NAME: 'test', GIT_COMMITTER_EMAIL: 't@t' },
|
||||
});
|
||||
}
|
||||
|
||||
describe('bug-2966: release-sdk hotfix cherry-pick classifies context-missing vs real conflicts for skip-reason annotation', () => {
|
||||
test('Prepare hotfix branch step classifies and annotates context-missing conflicts', () => {
|
||||
const yaml = fs.readFileSync(WORKFLOW_PATH, 'utf8');
|
||||
const script = extractStepRun(yaml, 'Prepare hotfix branch');
|
||||
|
||||
// The loop must detect unmerged paths after a failed cherry-pick.
|
||||
assert.match(
|
||||
script,
|
||||
/git diff --name-only --diff-filter=U/,
|
||||
'auto_cherry_pick must read the unmerged path list after a failed cherry-pick to classify the conflict (#2966)'
|
||||
);
|
||||
// The empty-HEAD-section detector must be present.
|
||||
assert.match(
|
||||
script,
|
||||
/<<<<<<< /,
|
||||
'auto_cherry_pick must inspect conflict markers to classify context-missing vs real conflicts (#2966)'
|
||||
);
|
||||
// The skip path must call `git cherry-pick --skip` so the loop continues
|
||||
// past commits whose target context doesn't exist at the base tag.
|
||||
assert.match(
|
||||
script,
|
||||
/git cherry-pick --skip/,
|
||||
'auto_cherry_pick must invoke `git cherry-pick --skip` for context-missing conflicts so they don\'t brick the run (#2966)'
|
||||
);
|
||||
// The skipped list must annotate the reason so operators see it in the
|
||||
// run summary (not silently disappear).
|
||||
assert.match(
|
||||
script,
|
||||
/context absent at base/,
|
||||
'auto_cherry_pick must annotate skipped picks with "context absent at base" so the run summary surfaces them (#2966)'
|
||||
);
|
||||
// The cherry-pick must pin merge.conflictStyle=merge so the awk
|
||||
// classifier sees deterministic marker shapes regardless of the
|
||||
// runner's git config (diff3/zdiff3 would inject `||||||| ancestor`
|
||||
// lines into the HEAD section and misclassify context-missing
|
||||
// conflicts as real ones).
|
||||
assert.match(
|
||||
script,
|
||||
/git -c merge\.conflictStyle=merge cherry-pick/,
|
||||
'auto_cherry_pick must pin `merge.conflictStyle=merge` on the cherry-pick command so marker parsing is deterministic across runner git configs (#2966)'
|
||||
);
|
||||
});
|
||||
|
||||
test('cherry-pick of a patch whose target section is absent at base produces empty-HEAD conflict markers and exits non-zero', () => {
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'bug-2966-ctx-missing-'));
|
||||
try {
|
||||
assert.equal(git(tmp, ['init', '-q', '-b', 'main']).status, 0, 'git init');
|
||||
|
||||
// Base — file exists but does NOT contain the section the patch will modify.
|
||||
fs.mkdirSync(path.join(tmp, '.github', 'workflows'), { recursive: true });
|
||||
fs.writeFileSync(path.join(tmp, '.github/workflows/x.yml'), 'name: base\njobs:\n release:\n runs-on: ubuntu-latest\n');
|
||||
assert.equal(git(tmp, ['add', '.']).status, 0, 'git add base');
|
||||
assert.equal(git(tmp, ['commit', '-q', '-m', 'base']).status, 0, 'commit base');
|
||||
assert.equal(git(tmp, ['tag', 'v0.0.0']).status, 0, 'tag base');
|
||||
|
||||
// feat (excluded by fix/chore filter) — adds the prepare block.
|
||||
fs.writeFileSync(path.join(tmp, '.github/workflows/x.yml'),
|
||||
'name: base\njobs:\n prepare:\n run: |\n git cherry-pick -x "$SHA"\n release:\n runs-on: ubuntu-latest\n');
|
||||
assert.equal(git(tmp, ['commit', '-qam', 'feat: add prepare block']).status, 0, 'commit feat');
|
||||
|
||||
// fix — modifies the line inside the prepare block.
|
||||
const yaml = fs.readFileSync(path.join(tmp, '.github/workflows/x.yml'), 'utf8')
|
||||
.replace('git cherry-pick -x "$SHA"', 'git cherry-pick -x --allow-empty "$SHA"');
|
||||
fs.writeFileSync(path.join(tmp, '.github/workflows/x.yml'), yaml);
|
||||
assert.equal(git(tmp, ['commit', '-qam', 'fix: tweak cherry-pick']).status, 0, 'commit fix');
|
||||
const fixSha = git(tmp, ['rev-parse', 'HEAD']).stdout.trim();
|
||||
|
||||
// Cherry-pick fix onto v0.0.0 — must conflict because target context isn't there.
|
||||
assert.equal(git(tmp, ['checkout', '-q', '-b', 'hotfix', 'v0.0.0']).status, 0, 'checkout hotfix');
|
||||
const pick = git(tmp, ['cherry-pick', '-x', '--allow-empty', '--keep-redundant-commits', fixSha]);
|
||||
assert.notEqual(
|
||||
pick.status,
|
||||
0,
|
||||
'cherry-pick of a patch whose target section is absent at base MUST exit non-zero (the bug premise: workflow currently treats this as a real conflict and aborts) (#2966)'
|
||||
);
|
||||
|
||||
// Confirm conflict markers exist and the HEAD section is empty in every block.
|
||||
const conflicted = fs.readFileSync(path.join(tmp, '.github/workflows/x.yml'), 'utf8');
|
||||
assert.match(conflicted, /<<<<<<< /, 'conflict markers must be written to the file');
|
||||
// Every <<<<<<< HEAD ... ======= block must have empty HEAD content.
|
||||
const blocks = [];
|
||||
let inHead = false;
|
||||
let head = '';
|
||||
for (const line of conflicted.split('\n')) {
|
||||
if (/^<<<<<<< /.test(line)) { inHead = true; head = ''; continue; }
|
||||
if (/^=======$/.test(line) && inHead) { inHead = false; continue; }
|
||||
if (/^>>>>>>> /.test(line)) { blocks.push(head); head = ''; continue; }
|
||||
if (inHead) head += line + '\n';
|
||||
}
|
||||
assert.ok(blocks.length > 0, 'expected at least one conflict marker block');
|
||||
for (const b of blocks) {
|
||||
assert.equal(b.trim(), '', `expected every HEAD section to be empty (context-missing signal), got: ${JSON.stringify(b)}`);
|
||||
}
|
||||
} finally {
|
||||
fs.rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('the awk predicate from the workflow classifies empty-HEAD as skippable and non-empty-HEAD as real', () => {
|
||||
// Pull the awk script out of the deployed workflow so this test
|
||||
// exercises the exact predicate that runs in CI — not a copy.
|
||||
const yaml = fs.readFileSync(WORKFLOW_PATH, 'utf8');
|
||||
const script = extractStepRun(yaml, 'Prepare hotfix branch');
|
||||
const awkMatch = script.match(/awk '\n([\s\S]+?)' "\$CONFLICTED"/);
|
||||
assert.ok(awkMatch, 'expected to find the conflict-classifying awk script in the workflow');
|
||||
const awkProgram = awkMatch[1];
|
||||
|
||||
function classify(conflictText) {
|
||||
const tmpFile = path.join(os.tmpdir(), `bug-2966-awk-${process.pid}-${Date.now()}-${Math.random()}.txt`);
|
||||
fs.writeFileSync(tmpFile, conflictText);
|
||||
try {
|
||||
const r = spawnSync('awk', [awkProgram, tmpFile], { encoding: 'utf8' });
|
||||
// Fail loudly on awk execution errors — silently consuming an
|
||||
// empty stdout from a crashed/missing awk would let context-missing
|
||||
// assertions falsely pass.
|
||||
assert.ok(!r.error, `awk failed to launch: ${r.error && r.error.message}`);
|
||||
assert.equal(r.status, 0, `awk predicate exited non-zero: ${r.stderr || '(no stderr)'}`);
|
||||
return r.stdout.trim();
|
||||
} finally {
|
||||
fs.rmSync(tmpFile, { force: true });
|
||||
}
|
||||
}
|
||||
|
||||
// Empty HEAD section → context-missing → no "real" emitted.
|
||||
const ctxMissing = [
|
||||
'unrelated line',
|
||||
'<<<<<<< HEAD',
|
||||
'=======',
|
||||
'patch wants this content',
|
||||
'and this',
|
||||
'>>>>>>> sha (msg)',
|
||||
'tail',
|
||||
].join('\n');
|
||||
assert.equal(classify(ctxMissing), '', 'awk must classify empty-HEAD blocks as context-missing (no "real" emitted) (#2966)');
|
||||
|
||||
// Non-empty HEAD section → real conflict.
|
||||
const realConflict = [
|
||||
'<<<<<<< HEAD',
|
||||
'VALUE=existing',
|
||||
'=======',
|
||||
'VALUE=patched',
|
||||
'>>>>>>> sha (msg)',
|
||||
].join('\n');
|
||||
assert.equal(classify(realConflict), 'real', 'awk must classify non-empty-HEAD blocks as real conflicts (#2966)');
|
||||
|
||||
// Mixed — first block empty-HEAD, second block real → real wins (overall classification).
|
||||
const mixed = [
|
||||
'<<<<<<< HEAD',
|
||||
'=======',
|
||||
'patch content',
|
||||
'>>>>>>> sha (msg)',
|
||||
'spacer',
|
||||
'<<<<<<< HEAD',
|
||||
'something existing',
|
||||
'=======',
|
||||
'something patched',
|
||||
'>>>>>>> sha (msg)',
|
||||
].join('\n');
|
||||
assert.equal(classify(mixed), 'real', 'awk must report "real" if any conflict block has non-empty HEAD content (#2966)');
|
||||
|
||||
// Whitespace-only HEAD section → context-missing (the awk predicate
|
||||
// treats blank/whitespace HEAD content the same as empty).
|
||||
const whitespaceHead = [
|
||||
'<<<<<<< HEAD',
|
||||
' ',
|
||||
'\t',
|
||||
'=======',
|
||||
'patch content',
|
||||
'>>>>>>> sha (msg)',
|
||||
].join('\n');
|
||||
assert.equal(classify(whitespaceHead), '', 'awk must classify whitespace-only HEAD blocks as context-missing (#2966)');
|
||||
});
|
||||
});
|
||||
295
tests/bug-2968-cherry-pick-skip-on-any-conflict.test.cjs
Normal file
295
tests/bug-2968-cherry-pick-skip-on-any-conflict.test.cjs
Normal file
@@ -0,0 +1,295 @@
|
||||
/**
|
||||
* Regression test for bug #2968
|
||||
*
|
||||
* Full-automation policy: any cherry-pick conflict in the release-sdk
|
||||
* hotfix loop — context-missing OR real merge conflict — must be
|
||||
* skipped, logged to the SKIPPED list with a classified reason, and
|
||||
* the loop continues. The hotfix run completes with whatever applies
|
||||
* cleanly; the SKIPPED list is the operator's post-hoc review queue.
|
||||
*
|
||||
* Pre-#2968 behavior: real conflicts (HEAD section non-empty)
|
||||
* triggered the abort/push-partial/error path, blocking every hotfix
|
||||
* run whose base tag had diverged from main. v1.39.1 hit this on
|
||||
* commit 0fb992d (run 25227493387) because v1.39.0 was tagged on the
|
||||
* `feat/hermes-runtime-2841` branch, which had restructured files that
|
||||
* pre-hermes fixes still patched against the old structure.
|
||||
*
|
||||
* This test asserts the workflow:
|
||||
* 1. No longer carries the abort-on-real-conflict control flow
|
||||
* (no `git cherry-pick --abort` followed by `exit 1` for picks
|
||||
* that have unmerged paths).
|
||||
* 2. Calls `git cherry-pick --skip` unconditionally on any
|
||||
* cherry-pick failure inside the auto_cherry_pick loop.
|
||||
* 3. Annotates the SKIPPED list with `merge conflict` for real
|
||||
* conflicts (so operators can find them in the run summary).
|
||||
* 4. Still records `context absent at base` for the empty-HEAD case
|
||||
* — the classifier's diagnostic value is preserved even though
|
||||
* the control flow no longer branches on it.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
// allow-test-rule: source-text-is-the-product
|
||||
// release-sdk.yml IS the product for hotfix automation; the static
|
||||
// assertions extract the "Prepare hotfix branch" run block via
|
||||
// indentation-aware YAML parsing rather than raw-text grep across the
|
||||
// whole document.
|
||||
|
||||
const { describe, test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const WORKFLOW_PATH = path.join(__dirname, '..', '.github', 'workflows', 'release-sdk.yml');
|
||||
|
||||
function extractStepRun(workflowText, stepName) {
|
||||
const lines = workflowText.split('\n');
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const m = lines[i].match(/^(\s*)- name:\s*(.+?)\s*$/);
|
||||
if (!m || m[2] !== stepName) continue;
|
||||
const stepIndent = m[1].length;
|
||||
let j = i + 1;
|
||||
while (j < lines.length) {
|
||||
const peek = lines[j];
|
||||
if (/^\s*- /.test(peek)) {
|
||||
const peekIndent = peek.match(/^(\s*)/)[1].length;
|
||||
if (peekIndent <= stepIndent) break;
|
||||
}
|
||||
const runMatch = peek.match(/^(\s*)run:\s*\|(?:[+-])?\s*$/);
|
||||
if (runMatch) {
|
||||
const blockIndent = runMatch[1].length + 2;
|
||||
const body = [];
|
||||
for (let k = j + 1; k < lines.length; k++) {
|
||||
const bodyLine = lines[k];
|
||||
if (bodyLine.length === 0) {
|
||||
body.push('');
|
||||
continue;
|
||||
}
|
||||
const lead = bodyLine.match(/^(\s*)/)[1].length;
|
||||
if (lead < blockIndent && bodyLine.trim() !== '') break;
|
||||
body.push(bodyLine.slice(blockIndent));
|
||||
}
|
||||
return body.join('\n');
|
||||
}
|
||||
j++;
|
||||
}
|
||||
throw new Error(`step "${stepName}" found but no run: | block before step end`);
|
||||
}
|
||||
throw new Error(`step "${stepName}" not found in workflow`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract just the body of the `if ! git ... cherry-pick ... ; then ... fi`
|
||||
* conditional inside the auto_cherry_pick loop, so assertions can target
|
||||
* the failure path without matching unrelated cherry-pick references
|
||||
* (e.g. the operator-recovery hint in `$GITHUB_STEP_SUMMARY` echoes).
|
||||
*
|
||||
* Walks bash `if`/`fi` nesting to find the matching `fi` for the failure
|
||||
* branch — naïve string matching wouldn't survive nested conditionals.
|
||||
*/
|
||||
function extractCherryPickFailureBlock(script) {
|
||||
const lines = script.split('\n');
|
||||
const startIdx = lines.findIndex(l => /if ! git[^\n]*cherry-pick[^\n]*"\$SHA"; then/.test(l));
|
||||
if (startIdx === -1) throw new Error('cherry-pick failure conditional not found in auto_cherry_pick loop');
|
||||
let depth = 1;
|
||||
for (let i = startIdx + 1; i < lines.length; i++) {
|
||||
if (/^\s*if[\s(]/.test(lines[i]) || /;\s*then\s*$/.test(lines[i])) depth++;
|
||||
if (/^\s*fi\s*$/.test(lines[i])) {
|
||||
depth--;
|
||||
if (depth === 0) return lines.slice(startIdx + 1, i).join('\n');
|
||||
}
|
||||
}
|
||||
throw new Error('matching `fi` for cherry-pick failure conditional not found');
|
||||
}
|
||||
|
||||
describe('bug-2968: release-sdk hotfix cherry-pick skips all conflicts (full automation)', () => {
|
||||
test('cherry-pick failure path no longer carries abort-on-real-conflict control flow', () => {
|
||||
const yaml = fs.readFileSync(WORKFLOW_PATH, 'utf8');
|
||||
const script = extractStepRun(yaml, 'Prepare hotfix branch');
|
||||
const failureBlock = extractCherryPickFailureBlock(script);
|
||||
|
||||
// The failure block must NOT call `git cherry-pick --abort` — that was
|
||||
// the pre-#2968 behavior on real conflicts. Skip-on-any-conflict means
|
||||
// we never abort; we always --skip.
|
||||
assert.doesNotMatch(
|
||||
failureBlock,
|
||||
/git cherry-pick --abort/,
|
||||
'auto_cherry_pick failure path must not call `git cherry-pick --abort` — full-automation policy is to skip all conflicts (#2968)'
|
||||
);
|
||||
// The failure block must NOT exit 1 — that bricked every hotfix on
|
||||
// a divergent base tag. The workflow continues past conflicts now.
|
||||
assert.doesNotMatch(
|
||||
failureBlock,
|
||||
/exit 1/,
|
||||
'auto_cherry_pick failure path must not `exit 1` on cherry-pick conflicts — full-automation policy is to log and continue (#2968)'
|
||||
);
|
||||
// The failure block must NOT push --force-with-lease — that was the
|
||||
// recovery-state push for operator-resolvable conflicts. With
|
||||
// skip-on-any-conflict there's no partial-pick state to preserve.
|
||||
assert.doesNotMatch(
|
||||
failureBlock,
|
||||
/git push --force-with-lease/,
|
||||
'auto_cherry_pick failure path must not push partial state — full-automation policy is to skip and continue, no recovery state needed (#2968)'
|
||||
);
|
||||
});
|
||||
|
||||
test('cherry-pick failure path always calls `git cherry-pick --skip` and appends to CONFLICT_SKIPPED', () => {
|
||||
const yaml = fs.readFileSync(WORKFLOW_PATH, 'utf8');
|
||||
const script = extractStepRun(yaml, 'Prepare hotfix branch');
|
||||
const failureBlock = extractCherryPickFailureBlock(script);
|
||||
|
||||
// All assertions on `failureBlock` are line-anchored (`^\s*...`, `m`
|
||||
// flag) so a comment that mentions a command — e.g. "Calling `--skip`
|
||||
// outside an in-progress cherry-pick exits non-zero" — can't satisfy
|
||||
// the assertion. Only executable shell lines count. CodeRabbit on
|
||||
// PR #2970.
|
||||
assert.match(
|
||||
failureBlock,
|
||||
/^\s*git cherry-pick --skip\b/m,
|
||||
'auto_cherry_pick failure path must call `git cherry-pick --skip` to clear cherry-pick state and continue the loop (#2968)'
|
||||
);
|
||||
// Conflict skips MUST go into a dedicated bucket — operators reviewing
|
||||
// the run summary need to find manual-review items without scanning
|
||||
// through policy-excluded feat/refactor/etc commits. Bug #2968.
|
||||
assert.match(
|
||||
failureBlock,
|
||||
/^\s*CONFLICT_SKIPPED="\$\{CONFLICT_SKIPPED\}/m,
|
||||
'auto_cherry_pick failure path must append to CONFLICT_SKIPPED (a separate bucket from POLICY_SKIPPED) so operators can find manual-review items in the run summary (#2968)'
|
||||
);
|
||||
assert.doesNotMatch(
|
||||
failureBlock,
|
||||
/^\s*SKIPPED="\$\{SKIPPED\}/m,
|
||||
'auto_cherry_pick failure path must NOT append to the legacy SKIPPED bucket — that buries manual-review conflicts under "feat/refactor/etc — not auto-included" (#2968)'
|
||||
);
|
||||
assert.match(
|
||||
failureBlock,
|
||||
/^\s*continue\s*$/m,
|
||||
'auto_cherry_pick failure path must `continue` the loop after skipping — full-automation policy is best-effort cherry-pick (#2968)'
|
||||
);
|
||||
});
|
||||
|
||||
test('merge commits are pre-skipped before cherry-pick is attempted', () => {
|
||||
// Cherry-picking a merge commit requires `-m <parent>` which the loop
|
||||
// can't choose automatically. Without it, `git cherry-pick <merge-sha>`
|
||||
// fails BEFORE entering cherry-pick state — no CHERRY_PICK_HEAD — so
|
||||
// the unconditional `--skip` would also fail and brick the loop.
|
||||
// The loop must detect parent count > 1 and skip with a distinct
|
||||
// reason BEFORE invoking cherry-pick. CodeRabbit on PR #2970.
|
||||
const yaml = fs.readFileSync(WORKFLOW_PATH, 'utf8');
|
||||
const script = extractStepRun(yaml, 'Prepare hotfix branch');
|
||||
|
||||
assert.match(
|
||||
script,
|
||||
/git rev-list --parents -n 1 "\$SHA"/,
|
||||
'auto_cherry_pick must inspect parent count before invoking cherry-pick — merge commits need `-m <parent>` and we can\'t pick the parent automatically (#2968)'
|
||||
);
|
||||
assert.match(
|
||||
script,
|
||||
/merge commit — manual -m parent selection required/,
|
||||
'auto_cherry_pick must annotate merge-commit skips with a distinct reason so operators understand why the pick wasn\'t attempted (#2968)'
|
||||
);
|
||||
});
|
||||
|
||||
test('classifier guards against unreadable / markerless unmerged paths', () => {
|
||||
// A degenerate unmerged file (missing, unreadable, or no conflict
|
||||
// markers) must NOT be misclassified as "context absent at base" — the
|
||||
// auto-skip path. Treat as real so the operator can investigate.
|
||||
// Also: `awk` runs under `set -e`; a non-zero exit on a missing file
|
||||
// would terminate the step. CodeRabbit on PR #2970.
|
||||
const yaml = fs.readFileSync(WORKFLOW_PATH, 'utf8');
|
||||
const script = extractStepRun(yaml, 'Prepare hotfix branch');
|
||||
const failureBlock = extractCherryPickFailureBlock(script);
|
||||
|
||||
// Readability check before invoking the marker classifier.
|
||||
assert.match(
|
||||
failureBlock,
|
||||
/\[\s*!\s*-r\s+"\$CONFLICTED"\s*\]/,
|
||||
'auto_cherry_pick must check `[ ! -r "$CONFLICTED" ]` before running the awk classifier so an unreadable unmerged path does not terminate the step under `set -e` (#2968)'
|
||||
);
|
||||
// Marker-presence check before invoking the marker classifier — a file
|
||||
// listed as unmerged but with no `<<<<<<< ` header is anomalous.
|
||||
assert.match(
|
||||
failureBlock,
|
||||
/grep -q '\^<<<<<<< '\s+"\$CONFLICTED"/,
|
||||
'auto_cherry_pick must verify `<<<<<<< ` markers exist in the file before running the awk classifier so a markerless unmerged file is not misclassified as context-missing (#2968)'
|
||||
);
|
||||
// The awk invocation must tolerate non-zero exits (e.g. via 2>/dev/null
|
||||
// and `|| echo "real"`) so a transient awk failure can't slip the file
|
||||
// into the auto-skip bucket.
|
||||
assert.match(
|
||||
failureBlock,
|
||||
/awk[\s\S]+?\|\|\s*echo\s+"real"/,
|
||||
'awk classifier must default to "real" on non-zero exit so transient awk failures do not auto-skip a real conflict (#2968)'
|
||||
);
|
||||
});
|
||||
|
||||
test('git cherry-pick --skip is guarded by CHERRY_PICK_HEAD existence', () => {
|
||||
// If cherry-pick fails for a reason that doesn't enter conflict state
|
||||
// (e.g. unreadable commit, ref problem), CHERRY_PICK_HEAD doesn't exist
|
||||
// and `git cherry-pick --skip` exits non-zero — bricking the loop.
|
||||
// The skip call must be guarded. CodeRabbit on PR #2970.
|
||||
const yaml = fs.readFileSync(WORKFLOW_PATH, 'utf8');
|
||||
const script = extractStepRun(yaml, 'Prepare hotfix branch');
|
||||
const failureBlock = extractCherryPickFailureBlock(script);
|
||||
|
||||
assert.match(
|
||||
failureBlock,
|
||||
/git rev-parse[^\n]*CHERRY_PICK_HEAD/,
|
||||
'auto_cherry_pick must check CHERRY_PICK_HEAD exists before calling `git cherry-pick --skip` — calling --skip outside an in-progress cherry-pick fails (#2968)'
|
||||
);
|
||||
});
|
||||
|
||||
test('run summary uses distinct sections for conflict skips vs policy skips', () => {
|
||||
const yaml = fs.readFileSync(WORKFLOW_PATH, 'utf8');
|
||||
const script = extractStepRun(yaml, 'Prepare hotfix branch');
|
||||
|
||||
// The summary must surface both buckets with distinct headings so
|
||||
// operators can act on the right one. Conflict skips are the review
|
||||
// queue; policy skips are informational.
|
||||
assert.match(
|
||||
script,
|
||||
/Skipped — cherry-pick conflict \(manual review\)/,
|
||||
'run summary must show conflict skips under a "manual review" heading distinct from policy skips (#2968)'
|
||||
);
|
||||
assert.match(
|
||||
script,
|
||||
/Not auto-included \(feat\/refactor\/docs\/etc\)/,
|
||||
'run summary must show policy skips under a heading that names the excluded categories — they are not failures (#2968)'
|
||||
);
|
||||
// Both buckets must be referenced when emitting the summary so a
|
||||
// future edit can't silently drop one section.
|
||||
assert.match(
|
||||
script,
|
||||
/\$CONFLICT_SKIPPED/,
|
||||
'run summary must echo $CONFLICT_SKIPPED so the manual-review queue actually appears (#2968)'
|
||||
);
|
||||
assert.match(
|
||||
script,
|
||||
/\$POLICY_SKIPPED/,
|
||||
'run summary must echo $POLICY_SKIPPED so policy-excluded commits remain visible to operators (#2968)'
|
||||
);
|
||||
});
|
||||
|
||||
test('skip reason annotates real merge conflicts distinctly from context-missing', () => {
|
||||
const yaml = fs.readFileSync(WORKFLOW_PATH, 'utf8');
|
||||
const script = extractStepRun(yaml, 'Prepare hotfix branch');
|
||||
const failureBlock = extractCherryPickFailureBlock(script);
|
||||
|
||||
// Operators must be able to find real conflicts in the run summary —
|
||||
// the "merge conflict" string is the discriminator.
|
||||
assert.match(
|
||||
failureBlock,
|
||||
/merge conflict/i,
|
||||
'auto_cherry_pick must annotate real-conflict skips with "merge conflict" so operators can find them in the run summary (#2968)'
|
||||
);
|
||||
// The empty-HEAD/context-missing classification (#2966) is preserved
|
||||
// — its diagnostic value (operator can tell the conflict was "fix
|
||||
// patched code that doesn't exist here" vs "fix patched code we
|
||||
// restructured") survives the policy change.
|
||||
assert.match(
|
||||
failureBlock,
|
||||
/context absent at base/,
|
||||
'auto_cherry_pick must still annotate context-missing skips distinctly from real merge conflicts so operators can distinguish the diagnostic (#2966 + #2968)'
|
||||
);
|
||||
});
|
||||
});
|
||||
205
tests/bug-2969-verify-reapply-patches.test.cjs
Normal file
205
tests/bug-2969-verify-reapply-patches.test.cjs
Normal file
@@ -0,0 +1,205 @@
|
||||
'use strict';
|
||||
|
||||
process.env.GSD_TEST_MODE = '1';
|
||||
|
||||
/**
|
||||
* Bug #2969: /gsd-reapply-patches Step 5 hunk verification gate reports
|
||||
* success on lost content because the LLM-driven workflow fills in
|
||||
* "verified: yes" without actually checking content presence.
|
||||
*
|
||||
* Fix: deterministic verifier script (scripts/verify-reapply-patches.cjs)
|
||||
* that the workflow calls.
|
||||
*
|
||||
* Per the repo's no-source-grep testing standard (CONTRIBUTING.md):
|
||||
* tests must assert on TYPED structured fields — not regex/substring
|
||||
* matching against script output, formatter prose, or file content.
|
||||
*
|
||||
* The script's --json mode emits a structured report whose `reason`
|
||||
* field is a stable enum (exposed as REASON), and whose `missing` field
|
||||
* is an array of typed strings (exact set membership, not substring).
|
||||
* Every assertion below is a deepEqual / equal / Array.includes against
|
||||
* those typed fields. Zero regex, zero String#includes on text.
|
||||
*/
|
||||
|
||||
const { test, describe, before, after } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const cp = require('node:child_process');
|
||||
|
||||
const ROOT = path.join(__dirname, '..');
|
||||
const SCRIPT = path.join(ROOT, 'scripts', 'verify-reapply-patches.cjs');
|
||||
const { REASON } = require(SCRIPT);
|
||||
|
||||
let tmpRoot;
|
||||
let patchesDir;
|
||||
let configDir;
|
||||
let pristineDir;
|
||||
|
||||
function writeFile(absPath, content) {
|
||||
fs.mkdirSync(path.dirname(absPath), { recursive: true });
|
||||
fs.writeFileSync(absPath, content);
|
||||
}
|
||||
|
||||
function resetFixture({ withPristine = true } = {}) {
|
||||
for (const dir of [patchesDir, configDir, pristineDir]) {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
fs.mkdirSync(patchesDir);
|
||||
fs.mkdirSync(configDir);
|
||||
if (withPristine) fs.mkdirSync(pristineDir);
|
||||
}
|
||||
|
||||
/** Runs the verifier with --json. Returns parsed structured report. */
|
||||
function runVerifier({ includePristine = true } = {}) {
|
||||
const args = [
|
||||
SCRIPT,
|
||||
'--patches-dir', patchesDir,
|
||||
'--config-dir', configDir,
|
||||
...(includePristine ? ['--pristine-dir', pristineDir] : []),
|
||||
'--json',
|
||||
];
|
||||
const r = cp.spawnSync(process.execPath, args, { encoding: 'utf8' });
|
||||
return {
|
||||
status: r.status,
|
||||
report: r.stdout && r.stdout.length ? JSON.parse(r.stdout) : null,
|
||||
};
|
||||
}
|
||||
|
||||
before(() => {
|
||||
tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2969-'));
|
||||
patchesDir = path.join(tmpRoot, 'patches');
|
||||
configDir = path.join(tmpRoot, 'installed');
|
||||
pristineDir = path.join(tmpRoot, 'pristine');
|
||||
resetFixture();
|
||||
});
|
||||
|
||||
after(() => {
|
||||
fs.rmSync(tmpRoot, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('Bug #2969: deterministic Step 5 verification gate', () => {
|
||||
test('REASON enum exposes the documented set of stable codes', () => {
|
||||
// Locks the public diagnostic surface — adding a code requires updating
|
||||
// this assertion, removing one breaks consumers that switch on the enum.
|
||||
assert.deepEqual(
|
||||
Object.keys(REASON).sort(),
|
||||
[
|
||||
'FAIL_INSTALLED_MISSING',
|
||||
'FAIL_INSTALLED_NOT_REGULAR_FILE',
|
||||
'FAIL_READ_ERROR',
|
||||
'FAIL_USER_LINES_MISSING',
|
||||
'OK_NO_SIGNIFICANT_BACKUP_LINES',
|
||||
'OK_NO_USER_LINES_VS_PRISTINE',
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
test('exits 0 with status=ok when every user-added line is present in the merged file', () => {
|
||||
resetFixture();
|
||||
const pristine = 'line one of stock content here\nline two of stock content here\nline three of stock content here\n';
|
||||
const userAdded = 'a custom line the user added for behavior X\nanother substantial line that the user inserted\n';
|
||||
|
||||
writeFile(path.join(pristineDir, 'skills', 'foo', 'SKILL.md'), pristine);
|
||||
writeFile(path.join(patchesDir, 'skills', 'foo', 'SKILL.md'), pristine + userAdded);
|
||||
writeFile(path.join(configDir, 'skills', 'foo', 'SKILL.md'), pristine + userAdded);
|
||||
|
||||
const { status, report } = runVerifier();
|
||||
assert.equal(status, 0);
|
||||
assert.equal(report.failures, 0);
|
||||
assert.equal(report.checked, 1);
|
||||
assert.equal(report.results[0].status, 'ok');
|
||||
assert.deepEqual(report.results[0].missing, []);
|
||||
});
|
||||
|
||||
test('reason=FAIL_USER_LINES_MISSING with the exact dropped line in .missing[]', () => {
|
||||
resetFixture();
|
||||
const pristine = 'first stock line in the original file here\nsecond stock line in the original file here\n';
|
||||
const lostLine = 'this is the visual companion block that must survive';
|
||||
writeFile(path.join(pristineDir, 'skills', 'discuss-phase', 'SKILL.md'), pristine);
|
||||
writeFile(path.join(patchesDir, 'skills', 'discuss-phase', 'SKILL.md'), `${pristine}${lostLine}\n`);
|
||||
writeFile(path.join(configDir, 'skills', 'discuss-phase', 'SKILL.md'), pristine);
|
||||
|
||||
const { status, report } = runVerifier();
|
||||
assert.equal(status, 1);
|
||||
assert.equal(report.failures, 1);
|
||||
const r0 = report.results[0];
|
||||
assert.equal(r0.file, 'skills/discuss-phase/SKILL.md');
|
||||
assert.equal(r0.status, 'fail');
|
||||
assert.equal(r0.reason, REASON.FAIL_USER_LINES_MISSING);
|
||||
assert.ok(
|
||||
r0.missing.includes(lostLine),
|
||||
`dropped line should be in .missing[]; got ${JSON.stringify(r0.missing)}`,
|
||||
);
|
||||
});
|
||||
|
||||
test('reason=FAIL_INSTALLED_NOT_REGULAR_FILE when installed path is a directory', () => {
|
||||
resetFixture();
|
||||
writeFile(path.join(pristineDir, 'a.md'), 'pristine line of substantial content here\n');
|
||||
writeFile(path.join(patchesDir, 'a.md'), 'pristine line of substantial content here\nuser added line that is substantial\n');
|
||||
fs.mkdirSync(path.join(configDir, 'a.md')); // EISDIR trap
|
||||
|
||||
const { status, report } = runVerifier();
|
||||
assert.equal(status, 1);
|
||||
assert.equal(report.results[0].status, 'fail');
|
||||
assert.equal(report.results[0].reason, REASON.FAIL_INSTALLED_NOT_REGULAR_FILE);
|
||||
});
|
||||
|
||||
test('reason=FAIL_INSTALLED_MISSING when the merged file has been deleted', () => {
|
||||
resetFixture();
|
||||
const pristine = 'stock line one with substantial content for the test\n';
|
||||
writeFile(path.join(pristineDir, 'workflow.md'), pristine);
|
||||
writeFile(path.join(patchesDir, 'workflow.md'), `${pristine}user line that should survive but does not\n`);
|
||||
// configDir intentionally missing the file.
|
||||
|
||||
const { status, report } = runVerifier();
|
||||
assert.equal(status, 1);
|
||||
assert.equal(report.results[0].status, 'fail');
|
||||
assert.equal(report.results[0].reason, REASON.FAIL_INSTALLED_MISSING);
|
||||
});
|
||||
|
||||
test('--json report has the documented shape: { checked, failures, results: [{ file, status, missing, reason }] }', () => {
|
||||
resetFixture();
|
||||
const pristine = 'pristine line that is sufficiently long to be significant\n';
|
||||
const userAdded = 'extra line the user wrote for their workflow customisation';
|
||||
writeFile(path.join(pristineDir, 'a.md'), pristine);
|
||||
writeFile(path.join(patchesDir, 'a.md'), `${pristine}${userAdded}\n`);
|
||||
writeFile(path.join(configDir, 'a.md'), pristine);
|
||||
|
||||
const { status, report } = runVerifier();
|
||||
assert.equal(status, 1);
|
||||
assert.deepEqual(Object.keys(report).sort(), ['checked', 'failures', 'results']);
|
||||
const r0 = report.results[0];
|
||||
assert.deepEqual(Object.keys(r0).sort(), ['file', 'missing', 'reason', 'status']);
|
||||
assert.equal(typeof r0.file, 'string');
|
||||
assert.equal(typeof r0.status, 'string');
|
||||
assert.equal(typeof r0.reason, 'string');
|
||||
assert.ok(Array.isArray(r0.missing));
|
||||
});
|
||||
|
||||
test('ignores backup-meta.json — it is metadata, not a patched file', () => {
|
||||
resetFixture();
|
||||
writeFile(path.join(patchesDir, 'backup-meta.json'), JSON.stringify({ files: [] }));
|
||||
|
||||
const { status, report } = runVerifier();
|
||||
assert.equal(status, 0);
|
||||
assert.equal(report.checked, 0);
|
||||
assert.equal(report.failures, 0);
|
||||
assert.deepEqual(report.results, []);
|
||||
});
|
||||
|
||||
test('without --pristine-dir, treats every significant backup line as required (safe over-broad fallback)', () => {
|
||||
resetFixture({ withPristine: false });
|
||||
const presentLine = 'this is a substantial line of user content here';
|
||||
const droppedLine = 'another substantial line that should survive';
|
||||
writeFile(path.join(patchesDir, 'b.md'), `${presentLine}\n${droppedLine}\n`);
|
||||
writeFile(path.join(configDir, 'b.md'), `${presentLine}\n`);
|
||||
|
||||
const { status, report } = runVerifier({ includePristine: false });
|
||||
assert.equal(status, 1);
|
||||
assert.equal(report.results[0].reason, REASON.FAIL_USER_LINES_MISSING);
|
||||
assert.ok(report.results[0].missing.includes(droppedLine));
|
||||
assert.ok(!report.results[0].missing.includes(presentLine));
|
||||
});
|
||||
});
|
||||
305
tests/bug-2980-hotfix-only-picks-shipping-changes.test.cjs
Normal file
305
tests/bug-2980-hotfix-only-picks-shipping-changes.test.cjs
Normal file
@@ -0,0 +1,305 @@
|
||||
/**
|
||||
* Regression test for bug #2980
|
||||
*
|
||||
* The release-sdk hotfix cherry-pick loop's `fix:`/`chore:` filter is
|
||||
* too broad: it picks anything with that conventional-commit type
|
||||
* regardless of whether the diff can affect the published npm package.
|
||||
* That caused two compounding problems:
|
||||
*
|
||||
* 1. CI-only fixes (release-sdk.yml, hotfix tooling) were cherry-picked
|
||||
* into hotfix branches even though they cannot change what ships.
|
||||
* 2. The subset of those CI-only fixes touching `.github/workflows/*`
|
||||
* caused the prepare job's `git push` to be rejected by GitHub —
|
||||
* the default GITHUB_TOKEN lacks the `workflow` scope:
|
||||
*
|
||||
* ! [remote rejected] hotfix/X.YY.Z -> hotfix/X.YY.Z
|
||||
* (refusing to allow a GitHub App to create or update workflow
|
||||
* ... without `workflows` permission)
|
||||
*
|
||||
* v1.39.1 hit this on PR #2977 (run 25232010071): #2977 cherry-
|
||||
* picked cleanly because earlier workflow-file fixes had been
|
||||
* skipped on conflict, then the push exploded.
|
||||
*
|
||||
* Fix (root cause): pre-pick guard that checks whether the candidate
|
||||
* commit's diff intersects the npm tarball's shipped paths (entries in
|
||||
* `package.json` `files` plus `package.json` itself). Non-shipping
|
||||
* commits are skipped with an informational summary entry; the
|
||||
* workflow-file rejection is now a non-issue because workflow files
|
||||
* are not in `files`.
|
||||
*
|
||||
* The shipped-paths classifier lives in
|
||||
* `scripts/diff-touches-shipped-paths.cjs` rather than inline in the
|
||||
* workflow YAML so its rules are unit-testable.
|
||||
*
|
||||
* This test covers two layers:
|
||||
* - Static workflow assertions (the loop calls the script before
|
||||
* attempting the pick, the result drives a NON_SHIPPED_SKIPPED
|
||||
* bucket, and the run summary surfaces it).
|
||||
* - Behavioral assertions on the classifier script itself (matches
|
||||
* `npm pack` semantics for `files` entries).
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
// allow-test-rule: source-text-is-the-product
|
||||
// release-sdk.yml IS the product for hotfix automation; the static
|
||||
// assertions extract the "Prepare hotfix branch" run block via
|
||||
// indentation-aware YAML parsing rather than raw-text grep across the
|
||||
// whole document.
|
||||
|
||||
const { describe, test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const { spawnSync } = require('node:child_process');
|
||||
|
||||
const REPO_ROOT = path.join(__dirname, '..');
|
||||
const WORKFLOW_PATH = path.join(REPO_ROOT, '.github', 'workflows', 'release-sdk.yml');
|
||||
const CLASSIFIER_PATH = path.join(REPO_ROOT, 'scripts', 'diff-touches-shipped-paths.cjs');
|
||||
|
||||
function extractStepRun(workflowText, stepName) {
|
||||
const lines = workflowText.split('\n');
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const m = lines[i].match(/^(\s*)- name:\s*(.+?)\s*$/);
|
||||
if (!m || m[2] !== stepName) continue;
|
||||
const stepIndent = m[1].length;
|
||||
let j = i + 1;
|
||||
while (j < lines.length) {
|
||||
const peek = lines[j];
|
||||
if (/^\s*- /.test(peek)) {
|
||||
const peekIndent = peek.match(/^(\s*)/)[1].length;
|
||||
if (peekIndent <= stepIndent) break;
|
||||
}
|
||||
const runMatch = peek.match(/^(\s*)run:\s*\|(?:[+-])?\s*$/);
|
||||
if (runMatch) {
|
||||
const blockIndent = runMatch[1].length + 2;
|
||||
const body = [];
|
||||
for (let k = j + 1; k < lines.length; k++) {
|
||||
const bodyLine = lines[k];
|
||||
if (bodyLine.length === 0) {
|
||||
body.push('');
|
||||
continue;
|
||||
}
|
||||
const lead = bodyLine.match(/^(\s*)/)[1].length;
|
||||
if (lead < blockIndent && bodyLine.trim() !== '') break;
|
||||
body.push(bodyLine.slice(blockIndent));
|
||||
}
|
||||
return body.join('\n');
|
||||
}
|
||||
j++;
|
||||
}
|
||||
throw new Error(`step "${stepName}" found but no run: | block before step end`);
|
||||
}
|
||||
throw new Error(`step "${stepName}" not found in workflow`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Slice the lines from the merge-commit pre-skip guard up to (but not
|
||||
* including) the cherry-pick attempt. Any new pre-pick guard MUST live
|
||||
* in this region to fire before the pick.
|
||||
*/
|
||||
function extractPrePickRegion(script) {
|
||||
const lines = script.split('\n');
|
||||
const startIdx = lines.findIndex(l => /merge commit — manual -m parent selection required/.test(l));
|
||||
if (startIdx === -1) throw new Error('merge-commit pre-skip guard not found — sentinel for pre-pick region');
|
||||
const endIdx = lines.findIndex((l, i) => i > startIdx && /git[^\n]*cherry-pick[^\n]*"\$SHA"/.test(l));
|
||||
if (endIdx === -1) throw new Error('cherry-pick attempt not found after merge-commit guard');
|
||||
return lines.slice(startIdx, endIdx).join('\n');
|
||||
}
|
||||
|
||||
describe('bug-2980: release-sdk hotfix only picks commits that touch shipped paths', () => {
|
||||
test('pre-pick guard runs the shipped-paths classifier before attempting the pick', () => {
|
||||
const yaml = fs.readFileSync(WORKFLOW_PATH, 'utf8');
|
||||
const script = extractStepRun(yaml, 'Prepare hotfix branch');
|
||||
const prePick = extractPrePickRegion(script);
|
||||
|
||||
// Must call the classifier script. Inline grep on `.github/workflows/`
|
||||
// would only catch the workflow-file subset of the bug — the broader
|
||||
// root cause is "any non-shipping commit in a hotfix is meaningless"
|
||||
// and the classifier encodes the precise `files`-whitelist rule.
|
||||
assert.match(
|
||||
prePick,
|
||||
/git diff-tree --no-commit-id --name-only -r "\$SHA"/,
|
||||
'pre-pick region must extract the candidate SHA\'s file list with `git diff-tree` so the classifier has accurate input (#2980)'
|
||||
);
|
||||
// After #2983 the classifier is invoked via the staged $CLASSIFIER
|
||||
// variable (not the in-tree path), to survive the working-tree swap
|
||||
// performed by `git checkout -b "$BRANCH" "$BASE_TAG"`. Either form
|
||||
// proves the classifier participates; the bug-2983 test enforces
|
||||
// the staged-path form specifically.
|
||||
assert.match(
|
||||
prePick,
|
||||
/node "\$CLASSIFIER"/,
|
||||
'pre-pick region must invoke `node "$CLASSIFIER"` (the staged classifier) — the in-tree path is unsafe after the base-tag checkout (#2980, #2983)'
|
||||
);
|
||||
// Skip-on-exit-1 dispatch: pre-#2983 used `if ! ... ; then skip`,
|
||||
// but that conflated classifier errors (exit 2+) with the
|
||||
// legitimate "not shipped" signal. Post-#2983 the dispatch is
|
||||
// explicit `case "$CLASSIFIER_RC" in 1) skip ;; *) error ;; esac`.
|
||||
// This test accepts the modern form; bug-2983 enforces it.
|
||||
assert.match(
|
||||
prePick,
|
||||
/case "\$CLASSIFIER_RC" in[\s\S]+?1\)[\s\S]+?continue/,
|
||||
'pre-pick region must skip on exit 1 via case-dispatch on $CLASSIFIER_RC — the post-#2983 shape that distinguishes "not shipped" from classifier errors (#2980, #2983)'
|
||||
);
|
||||
});
|
||||
|
||||
test('non-shipped skips land in NON_SHIPPED_SKIPPED, distinct from CONFLICT_SKIPPED and POLICY_SKIPPED', () => {
|
||||
const yaml = fs.readFileSync(WORKFLOW_PATH, 'utf8');
|
||||
const script = extractStepRun(yaml, 'Prepare hotfix branch');
|
||||
const prePick = extractPrePickRegion(script);
|
||||
|
||||
assert.match(
|
||||
prePick,
|
||||
/^\s*NON_SHIPPED_SKIPPED="\$\{NON_SHIPPED_SKIPPED\}/m,
|
||||
'non-shipped skip must append to NON_SHIPPED_SKIPPED — distinct from CONFLICT_SKIPPED (manual-review queue) and POLICY_SKIPPED (feat/refactor exclusions) (#2980)'
|
||||
);
|
||||
// The bucket must be initialized at the top of the loop alongside
|
||||
// the other two — so a future `set -u` doesn't silently break it.
|
||||
assert.match(
|
||||
script,
|
||||
/^\s*NON_SHIPPED_SKIPPED=""\s*$/m,
|
||||
'NON_SHIPPED_SKIPPED must be initialized to empty alongside POLICY_SKIPPED and CONFLICT_SKIPPED (#2980)'
|
||||
);
|
||||
});
|
||||
|
||||
test('non-shipped skip emits no ::warning:: — the change cannot affect the package', () => {
|
||||
// A non-shipped commit is by definition incapable of changing what
|
||||
// ships, so the skip needs no operator alert. The summary bucket is
|
||||
// informational; a yellow warning would imply remediation is
|
||||
// possible, which would mislead operators.
|
||||
const yaml = fs.readFileSync(WORKFLOW_PATH, 'utf8');
|
||||
const script = extractStepRun(yaml, 'Prepare hotfix branch');
|
||||
const prePick = extractPrePickRegion(script);
|
||||
|
||||
assert.doesNotMatch(
|
||||
prePick,
|
||||
/::warning::/,
|
||||
'non-shipped skip must NOT emit a ::warning:: — the commit cannot change what ships, so a warning would falsely imply remediation is needed (#2980)'
|
||||
);
|
||||
});
|
||||
|
||||
test('run summary surfaces NON_SHIPPED_SKIPPED in its own section, framed as informational', () => {
|
||||
const yaml = fs.readFileSync(WORKFLOW_PATH, 'utf8');
|
||||
const script = extractStepRun(yaml, 'Prepare hotfix branch');
|
||||
|
||||
assert.match(
|
||||
script,
|
||||
/if \[ -n "\$NON_SHIPPED_SKIPPED" \]/,
|
||||
'run summary must conditionally render the NON_SHIPPED_SKIPPED bucket so empty hotfixes don\'t print an empty section (#2980)'
|
||||
);
|
||||
// The header must NOT use "manual review" framing — that's the
|
||||
// CONFLICT_SKIPPED queue. Non-shipped skips need no manual action.
|
||||
assert.doesNotMatch(
|
||||
script,
|
||||
/Skipped — touches no shipped paths[^\n]*manual review/,
|
||||
'NON_SHIPPED_SKIPPED summary header must NOT imply manual review — non-shipping commits need no remediation (#2980)'
|
||||
);
|
||||
assert.match(
|
||||
script,
|
||||
/Skipped — touches no shipped paths[^\n]*informational/,
|
||||
'NON_SHIPPED_SKIPPED summary header must signal "informational" so operators don\'t mistake it for the manual-review queue (#2980)'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('bug-2980: scripts/diff-touches-shipped-paths.cjs classifier semantics', () => {
|
||||
function runClassifier(stdin, cwd) {
|
||||
return spawnSync('node', [CLASSIFIER_PATH], {
|
||||
cwd,
|
||||
input: stdin,
|
||||
encoding: 'utf8',
|
||||
});
|
||||
}
|
||||
|
||||
function makeFixtureRepo(filesArray) {
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'bug-2980-'));
|
||||
fs.writeFileSync(
|
||||
path.join(tmp, 'package.json'),
|
||||
JSON.stringify({ name: 'fixture', version: '0.0.0', files: filesArray }, null, 2)
|
||||
);
|
||||
return tmp;
|
||||
}
|
||||
|
||||
test('directory entry in `files` matches paths under that directory but not sibling prefixes', () => {
|
||||
const tmp = makeFixtureRepo(['bin', 'sdk/dist']);
|
||||
try {
|
||||
// bin/foo.js is shipped (under bin/).
|
||||
assert.equal(runClassifier('bin/foo.js\n', tmp).status, 0, 'bin/foo.js must be shipped');
|
||||
// bin alone (the directory entry itself) is shipped.
|
||||
assert.equal(runClassifier('bin\n', tmp).status, 0, 'bin (exact match) must be shipped');
|
||||
// binaries/foo.js must NOT match bin (prefix-without-slash bug).
|
||||
assert.equal(runClassifier('binaries/foo.js\n', tmp).status, 1, 'binaries/foo.js must NOT match bin/ — prefix without slash boundary is a classic bug');
|
||||
// sdk/dist/cli.js is shipped.
|
||||
assert.equal(runClassifier('sdk/dist/cli.js\n', tmp).status, 0, 'sdk/dist/cli.js must be shipped');
|
||||
// sdk/src/cli.ts is NOT shipped (only sdk/dist is in `files`).
|
||||
assert.equal(runClassifier('sdk/src/cli.ts\n', tmp).status, 1, 'sdk/src/cli.ts must NOT be shipped when only sdk/dist is whitelisted');
|
||||
} finally {
|
||||
fs.rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('package.json is always shipped even when not in `files`', () => {
|
||||
// `npm pack` always includes package.json regardless of `files`. The
|
||||
// classifier must mirror that, so a version-bump-only commit isn't
|
||||
// wrongly skipped.
|
||||
const tmp = makeFixtureRepo([]);
|
||||
try {
|
||||
assert.equal(runClassifier('package.json\n', tmp).status, 0, 'package.json must be classified as shipped — `npm pack` always includes it');
|
||||
} finally {
|
||||
fs.rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('package-lock.json is NOT shipped unless explicitly in `files`', () => {
|
||||
// `npm pack` does NOT include package-lock.json by default. A
|
||||
// lockfile-only commit can't change the published package's runtime
|
||||
// behavior (consumers resolve their own lockfile from `dependencies`).
|
||||
const tmp = makeFixtureRepo(['bin']);
|
||||
try {
|
||||
assert.equal(runClassifier('package-lock.json\n', tmp).status, 1, 'package-lock.json must NOT be classified as shipped when absent from `files` — `npm pack` excludes it by default');
|
||||
} finally {
|
||||
fs.rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('mixed diff is shipped if ANY path is shipped', () => {
|
||||
// A commit that touches both a shipped file and a non-shipped file
|
||||
// must be classified as shipped — the non-shipped paths are along
|
||||
// for the ride, but the commit can still affect what ships.
|
||||
const tmp = makeFixtureRepo(['bin']);
|
||||
try {
|
||||
const stdin = '.github/workflows/release-sdk.yml\nbin/foo.js\ntests/bar.test.cjs\n';
|
||||
assert.equal(runClassifier(stdin, tmp).status, 0, 'mixed diff with at least one shipped path must classify as shipped');
|
||||
} finally {
|
||||
fs.rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('purely CI/test/docs commit is NOT shipped (the actual #2980 case)', () => {
|
||||
// The classic case: a fix(release-sdk): commit that touches only
|
||||
// .github/workflows/release-sdk.yml and a regression test under
|
||||
// tests/. Pre-#2980 the loop picked it; the cherry-pick succeeded;
|
||||
// the push then failed because of the workflow-file scope rule.
|
||||
// Post-#2980 the loop skips it pre-pick — the push problem and the
|
||||
// "meaningless pick" problem dissolve together.
|
||||
const tmp = makeFixtureRepo(['bin', 'commands', 'sdk/dist']);
|
||||
try {
|
||||
const stdin = '.github/workflows/release-sdk.yml\ntests/bug-2980-shipped-paths.test.cjs\nCHANGELOG.md\n';
|
||||
assert.equal(runClassifier(stdin, tmp).status, 1, 'CI-only commit (workflow + test + changelog) must classify as NOT shipped — the canonical #2980 case');
|
||||
} finally {
|
||||
fs.rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('empty stdin classifies as not-shipped (defensive — empty diff means no candidate paths)', () => {
|
||||
const tmp = makeFixtureRepo(['bin']);
|
||||
try {
|
||||
assert.equal(runClassifier('', tmp).status, 1, 'empty stdin must classify as not-shipped — no paths can\'t intersect any whitelist');
|
||||
assert.equal(runClassifier('\n\n\n', tmp).status, 1, 'whitespace-only stdin must classify as not-shipped');
|
||||
} finally {
|
||||
fs.rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,388 @@
|
||||
/**
|
||||
* Regression test for bug #2983
|
||||
*
|
||||
* Two compounding bugs surfaced by CodeRabbit's post-merge review of
|
||||
* PR #2981 (which shipped #2980's shipped-paths cherry-pick filter):
|
||||
*
|
||||
* 1. Overloaded exit code: scripts/diff-touches-shipped-paths.cjs
|
||||
* used exit 1 for the legitimate classifier result "no shipped
|
||||
* paths." Node's default exit on uncaught throw is also 1, so any
|
||||
* classifier failure was indistinguishable from a normal skip.
|
||||
* The workflow's `if ! ... ; then skip` idiom would silently drop
|
||||
* a commit on tool failure.
|
||||
*
|
||||
* 2. Classifier missing at the base tag: the workflow runs
|
||||
* `git checkout -b "$BRANCH" "$BASE_TAG"` BEFORE the cherry-pick
|
||||
* loop, which replaces the working tree with the base tag's
|
||||
* contents. Base tags predating #2980 (notably v1.39.0, the most
|
||||
* likely next hotfix base) don't have
|
||||
* `scripts/diff-touches-shipped-paths.cjs` at all. `node <missing>`
|
||||
* exits non-zero → workflow treats as "not shipped" → every
|
||||
* commit gets silently dropped → empty hotfix branch published.
|
||||
* This is strictly worse than the original #2980 push-rejection,
|
||||
* which at least failed loudly.
|
||||
*
|
||||
* Fix:
|
||||
* - Script: distinct exit codes (0 = shipped, 1 = not shipped,
|
||||
* 2 = classifier error). All uncaught failure paths
|
||||
* (uncaughtException, unhandledRejection, fs/JSON errors) route
|
||||
* to exit 2.
|
||||
* - Workflow: stage the classifier into $RUNNER_TEMP at the top of
|
||||
* `Prepare hotfix branch` (before `git checkout -b "$BASE_TAG"`)
|
||||
* and reference $CLASSIFIER in the loop. Capture exit code via
|
||||
* ${PIPESTATUS[1]} and dispatch via case: 0 → proceed, 1 → skip
|
||||
* (NON_SHIPPED_SKIPPED), anything else → ::error:: + exit. The
|
||||
* workflow refuses to start if the classifier source is missing
|
||||
* in the dispatched ref.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
// allow-test-rule: source-text-is-the-product
|
||||
// release-sdk.yml IS the product for hotfix automation; the static
|
||||
// assertions extract the "Prepare hotfix branch" run block via
|
||||
// indentation-aware YAML parsing rather than raw-text grep across the
|
||||
// whole document.
|
||||
|
||||
const { describe, test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const { spawnSync } = require('node:child_process');
|
||||
|
||||
const REPO_ROOT = path.join(__dirname, '..');
|
||||
const WORKFLOW_PATH = path.join(REPO_ROOT, '.github', 'workflows', 'release-sdk.yml');
|
||||
const CLASSIFIER_PATH = path.join(REPO_ROOT, 'scripts', 'diff-touches-shipped-paths.cjs');
|
||||
|
||||
function extractStepRun(workflowText, stepName) {
|
||||
const lines = workflowText.split('\n');
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const m = lines[i].match(/^(\s*)- name:\s*(.+?)\s*$/);
|
||||
if (!m || m[2] !== stepName) continue;
|
||||
const stepIndent = m[1].length;
|
||||
let j = i + 1;
|
||||
while (j < lines.length) {
|
||||
const peek = lines[j];
|
||||
if (/^\s*- /.test(peek)) {
|
||||
const peekIndent = peek.match(/^(\s*)/)[1].length;
|
||||
if (peekIndent <= stepIndent) break;
|
||||
}
|
||||
const runMatch = peek.match(/^(\s*)run:\s*\|(?:[+-])?\s*$/);
|
||||
if (runMatch) {
|
||||
const blockIndent = runMatch[1].length + 2;
|
||||
const body = [];
|
||||
for (let k = j + 1; k < lines.length; k++) {
|
||||
const bodyLine = lines[k];
|
||||
if (bodyLine.length === 0) {
|
||||
body.push('');
|
||||
continue;
|
||||
}
|
||||
const lead = bodyLine.match(/^(\s*)/)[1].length;
|
||||
if (lead < blockIndent && bodyLine.trim() !== '') break;
|
||||
body.push(bodyLine.slice(blockIndent));
|
||||
}
|
||||
return body.join('\n');
|
||||
}
|
||||
j++;
|
||||
}
|
||||
throw new Error(`step "${stepName}" found but no run: | block before step end`);
|
||||
}
|
||||
throw new Error(`step "${stepName}" not found in workflow`);
|
||||
}
|
||||
|
||||
describe('bug-2983: shipped-paths classifier exit-code discipline', () => {
|
||||
function runClassifier({ stdin, cwd }) {
|
||||
return spawnSync('node', [CLASSIFIER_PATH], {
|
||||
cwd,
|
||||
input: stdin,
|
||||
encoding: 'utf8',
|
||||
});
|
||||
}
|
||||
|
||||
function makeFixtureRepo({ files, raw }) {
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'bug-2983-'));
|
||||
if (raw !== undefined) {
|
||||
fs.writeFileSync(path.join(tmp, 'package.json'), raw);
|
||||
} else if (files !== undefined) {
|
||||
fs.writeFileSync(
|
||||
path.join(tmp, 'package.json'),
|
||||
JSON.stringify({ name: 'fixture', version: '0.0.0', files }, null, 2)
|
||||
);
|
||||
}
|
||||
return tmp;
|
||||
}
|
||||
|
||||
test('exit 0 still means "at least one shipped path"', () => {
|
||||
const tmp = makeFixtureRepo({ files: ['bin'] });
|
||||
try {
|
||||
const r = runClassifier({ stdin: 'bin/foo.js\n', cwd: tmp });
|
||||
assert.equal(r.status, 0, `expected exit 0 for shipped path; stderr=${r.stderr}`);
|
||||
} finally {
|
||||
fs.rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('exit 1 still means "no shipped paths" — preserved across the #2983 refactor', () => {
|
||||
const tmp = makeFixtureRepo({ files: ['bin'] });
|
||||
try {
|
||||
const r = runClassifier({ stdin: 'tests/foo.test.cjs\n.github/workflows/release-sdk.yml\n', cwd: tmp });
|
||||
assert.equal(r.status, 1, `expected exit 1 for non-shipping diff; stderr=${r.stderr}`);
|
||||
} finally {
|
||||
fs.rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('exit 2 when package.json is missing — distinguishable from "not shipped"', () => {
|
||||
// Run in a temp dir with no package.json. Pre-#2983 this would
|
||||
// surface as exit 1 (Node default for uncaught throw), which the
|
||||
// workflow would have silently treated as "not shipped." Post-fix
|
||||
// it's exit 2, which the workflow MUST treat as a hard error.
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'bug-2983-no-pkg-'));
|
||||
try {
|
||||
const r = runClassifier({ stdin: 'bin/foo.js\n', cwd: tmp });
|
||||
assert.equal(r.status, 2, `expected exit 2 for missing package.json; got ${r.status}; stderr=${r.stderr}`);
|
||||
assert.match(r.stderr, /diff-touches-shipped-paths/, 'classifier error must be tagged in stderr so the workflow operator can find it');
|
||||
} finally {
|
||||
fs.rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('exit 2 when package.json is malformed JSON', () => {
|
||||
const tmp = makeFixtureRepo({ raw: '{ this is not json' });
|
||||
try {
|
||||
const r = runClassifier({ stdin: 'bin/foo.js\n', cwd: tmp });
|
||||
assert.equal(r.status, 2, `expected exit 2 for malformed package.json; got ${r.status}; stderr=${r.stderr}`);
|
||||
} finally {
|
||||
fs.rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('module exports the exit-code constants so workflow tests can reference them by name', () => {
|
||||
// Decoupling intent (EXIT_NOT_SHIPPED) from value (1) is what makes
|
||||
// a future "let's renumber" edit safe. Importers should reference
|
||||
// the constants, not the literals.
|
||||
const mod = require(CLASSIFIER_PATH);
|
||||
assert.equal(mod.EXIT_SHIPPED, 0, 'EXIT_SHIPPED must be 0');
|
||||
assert.equal(mod.EXIT_NOT_SHIPPED, 1, 'EXIT_NOT_SHIPPED must be 1');
|
||||
assert.equal(mod.EXIT_ERROR, 2, 'EXIT_ERROR must be 2 (distinct from 0 and 1)');
|
||||
});
|
||||
});
|
||||
|
||||
describe('bug-2983: workflow stages the classifier and dispatches on exit code', () => {
|
||||
test('classifier is staged into $RUNNER_TEMP before any working-tree-mutating git command', () => {
|
||||
const yaml = fs.readFileSync(WORKFLOW_PATH, 'utf8');
|
||||
const script = extractStepRun(yaml, 'Prepare hotfix branch');
|
||||
|
||||
// The staging cp must appear before `git checkout -b ... "$BASE_TAG"`
|
||||
// — that's the operation that overwrites the working tree with the
|
||||
// base tag's contents, which may not contain the classifier.
|
||||
const stageIdx = script.search(/cp "\$CLASSIFIER_SRC" "\$CLASSIFIER"/);
|
||||
const checkoutIdx = script.search(/git checkout -b "\$BRANCH" "\$BASE_TAG"/);
|
||||
assert.ok(
|
||||
stageIdx !== -1,
|
||||
'workflow must `cp` the classifier from its in-tree path to a stable location ($CLASSIFIER) before the working tree is swapped (#2983)'
|
||||
);
|
||||
assert.ok(
|
||||
checkoutIdx !== -1,
|
||||
'workflow must contain the base-tag checkout — sentinel that establishes the staging-must-precede ordering constraint'
|
||||
);
|
||||
assert.ok(
|
||||
stageIdx < checkoutIdx,
|
||||
`classifier staging must precede \`git checkout -b ... "$BASE_TAG"\` so the source file isn't already gone (#2983). Found stage at offset ${stageIdx}, checkout at ${checkoutIdx}.`
|
||||
);
|
||||
});
|
||||
|
||||
test('staging targets $RUNNER_TEMP — survives the working-tree swap and is auto-cleaned by the runner', () => {
|
||||
const yaml = fs.readFileSync(WORKFLOW_PATH, 'utf8');
|
||||
const script = extractStepRun(yaml, 'Prepare hotfix branch');
|
||||
|
||||
assert.match(
|
||||
script,
|
||||
/CLASSIFIER="\$\{RUNNER_TEMP\}\/diff-touches-shipped-paths\.cjs"/,
|
||||
'$CLASSIFIER must point at $RUNNER_TEMP — that path survives `git checkout` (lives outside the repo) and is cleaned automatically by the runner (#2983)'
|
||||
);
|
||||
});
|
||||
|
||||
test('workflow refuses to run if the classifier source is missing in the dispatched ref', () => {
|
||||
// Defense in depth: if a future edit reorders the steps so the
|
||||
// first checkout doesn't put the classifier on disk, the workflow
|
||||
// must fail loudly rather than skip every commit.
|
||||
const yaml = fs.readFileSync(WORKFLOW_PATH, 'utf8');
|
||||
const script = extractStepRun(yaml, 'Prepare hotfix branch');
|
||||
|
||||
assert.match(
|
||||
script,
|
||||
/if \[ ! -f "\$CLASSIFIER_SRC" \][\s\S]{0,200}::error::shipped-paths classifier not found/,
|
||||
'workflow must fail-fast if scripts/diff-touches-shipped-paths.cjs is missing in the dispatched ref (#2983)'
|
||||
);
|
||||
assert.match(
|
||||
script,
|
||||
/if \[ ! -f "\$CLASSIFIER" \][\s\S]{0,200}failed to stage classifier/,
|
||||
'workflow must fail-fast if cp didn\'t actually produce $CLASSIFIER (defense against silent cp failure on RUNNER_TEMP corner cases) (#2983)'
|
||||
);
|
||||
});
|
||||
|
||||
test('cherry-pick loop captures classifier exit code via $PIPESTATUS and dispatches on the value', () => {
|
||||
const yaml = fs.readFileSync(WORKFLOW_PATH, 'utf8');
|
||||
const script = extractStepRun(yaml, 'Prepare hotfix branch');
|
||||
|
||||
// The pre-#2983 form was `if ! ... | node ...; then skip; fi` which
|
||||
// collapses every non-zero exit (including missing-script and
|
||||
// uncaught-throw cases) into the skip path. The required new shape
|
||||
// is: run the pipeline, snapshot $PIPESTATUS into a local array
|
||||
// immediately, dispatch via case.
|
||||
//
|
||||
// CodeRabbit on PR #2984 caught a subtler bug in the first iteration
|
||||
// of this fix: `pipeline || true; RC=${PIPESTATUS[1]}` doesn't work
|
||||
// because `|| true` runs `true` as a one-command pipeline when the
|
||||
// pipeline fails (exit 1 or 2 — exactly the cases we care about),
|
||||
// overwriting PIPESTATUS to (0). The hardened form snapshots
|
||||
// PIPESTATUS into a local array on the line immediately after the
|
||||
// pipeline, with no intervening commands.
|
||||
assert.match(
|
||||
script,
|
||||
/PIPE_RC=\("\$\{PIPESTATUS\[@\]\}"\)/,
|
||||
'cherry-pick loop must snapshot the entire $PIPESTATUS array via `PIPE_RC=("${PIPESTATUS[@]}")` immediately after the classifier pipeline — `${PIPESTATUS[1]}` direct-read is unsafe under any subsequent simple command, and `|| true; ${PIPESTATUS[1]}` is broken because `|| true` runs `true` as its own pipeline on the failure paths (CodeRabbit on PR #2984)'
|
||||
);
|
||||
// The pipeline must run under `set +e` to allow the snapshot — at
|
||||
// the workflow's top-level `set -euo pipefail`, a non-zero exit
|
||||
// from the pipeline would otherwise terminate the step before the
|
||||
// snapshot line runs.
|
||||
assert.match(
|
||||
script,
|
||||
/set \+e[\s\S]{0,200}node "\$CLASSIFIER"[\s\S]{0,80}PIPE_RC=\("\$\{PIPESTATUS\[@\]\}"\)[\s\S]{0,40}set -e/,
|
||||
'classifier pipeline must be wrapped `set +e` ... pipeline ... `PIPE_RC=("${PIPESTATUS[@]}")` ... `set -e` — any other shape either misses the snapshot or terminates the step early (#2983, CodeRabbit on PR #2984)'
|
||||
);
|
||||
// Must NOT use the broken `pipeline || true; RC=${PIPESTATUS[1]}` form.
|
||||
// The `|| true` rewrites PIPESTATUS on the failure paths.
|
||||
assert.doesNotMatch(
|
||||
script,
|
||||
/node "\$CLASSIFIER"\s*\|\|\s*true\s*\n\s*CLASSIFIER_RC="\$\{PIPESTATUS\[1\]\}"/,
|
||||
'classifier pipeline must NOT use `|| true` followed by `${PIPESTATUS[1]}` — `|| true` runs `true` as a one-command pipeline on the failure paths and overwrites PIPESTATUS to (0), so PIPESTATUS[1] becomes unset (CodeRabbit on PR #2984)'
|
||||
);
|
||||
// Must NOT use the original `if ! ... | node ...; then` shape either.
|
||||
assert.doesNotMatch(
|
||||
script,
|
||||
/if ! git diff-tree[\s\S]{0,200}node[\s\S]{0,200}\.cjs[^|\n]*; then/,
|
||||
'cherry-pick loop must NOT use `if ! ... | node classifier; then skip` — that shape silently treats classifier errors as skips (#2983)'
|
||||
);
|
||||
// The case dispatch must explicitly handle 0, 1, and a default branch.
|
||||
assert.match(
|
||||
script,
|
||||
/case "\$CLASSIFIER_RC" in[\s\S]+?0\)[\s\S]+?1\)[\s\S]+?\*\)/,
|
||||
'case dispatch on $CLASSIFIER_RC must list 0, 1, and a default-error branch in that order so each is handled explicitly (#2983)'
|
||||
);
|
||||
});
|
||||
|
||||
test('git diff-tree failure is also fail-fast (not silently classified as not-shipped)', () => {
|
||||
// The new array-snapshot form gives us $DIFFTREE_RC for free.
|
||||
// git diff-tree is unlikely to fail on a known-good $SHA, but if
|
||||
// it does (e.g., $SHA is corrupt or fetch was incomplete), we must
|
||||
// not pipe partial/empty output into the classifier and call it
|
||||
// "not shipped." Fail-fast with ::error:: instead.
|
||||
const yaml = fs.readFileSync(WORKFLOW_PATH, 'utf8');
|
||||
const script = extractStepRun(yaml, 'Prepare hotfix branch');
|
||||
|
||||
assert.match(
|
||||
script,
|
||||
/DIFFTREE_RC="\$\{PIPE_RC\[0\]\}"/,
|
||||
'workflow must extract git diff-tree\'s exit from PIPE_RC[0] so a partial-pipeline failure can be distinguished from a clean classifier result (CodeRabbit on PR #2984)'
|
||||
);
|
||||
assert.match(
|
||||
script,
|
||||
/if \[ "\$DIFFTREE_RC" -ne 0 \][\s\S]{0,200}::error::git diff-tree failed/,
|
||||
'workflow must emit ::error:: and exit when git diff-tree itself fails — silently passing partial input to the classifier would defeat the whole point of #2983 (CodeRabbit on PR #2984)'
|
||||
);
|
||||
});
|
||||
|
||||
test('hotfix run summary no longer falsely advertises a merge-back PR', () => {
|
||||
// CodeRabbit on PR #2984: the Summary block still printed
|
||||
// "Merge-back PR opened against main" even though the merge-back
|
||||
// step was removed. Operators reading the summary would expect a PR
|
||||
// that was never opened. Replace with explicit non-action text so
|
||||
// the summary accurately describes what happened.
|
||||
const yaml = fs.readFileSync(WORKFLOW_PATH, 'utf8');
|
||||
|
||||
assert.doesNotMatch(
|
||||
yaml,
|
||||
/echo "- Merge-back PR opened against main"/,
|
||||
'run summary must NOT advertise a merge-back PR — the step was removed in #2983 and the line is stale (CodeRabbit on PR #2984)'
|
||||
);
|
||||
assert.match(
|
||||
yaml,
|
||||
/No merge-back PR \(auto-picked commits are already on main\)/,
|
||||
'run summary must explicitly state that no merge-back PR exists, with the rationale, so operators understand it\'s intentional rather than missing (CodeRabbit on PR #2984)'
|
||||
);
|
||||
});
|
||||
|
||||
test('default-error branch fails the workflow with ::error:: rather than continuing', () => {
|
||||
const yaml = fs.readFileSync(WORKFLOW_PATH, 'utf8');
|
||||
const script = extractStepRun(yaml, 'Prepare hotfix branch');
|
||||
|
||||
// Must emit ::error:: AND exit non-zero. Either alone is
|
||||
// insufficient: ::error:: without exit just decorates the log;
|
||||
// exit without ::error:: hides the cause.
|
||||
assert.match(
|
||||
script,
|
||||
/\*\)[\s\S]+?::error::shipped-paths classifier failed[\s\S]+?exit "\$CLASSIFIER_RC"/,
|
||||
'classifier-error branch must emit ::error:: AND `exit "$CLASSIFIER_RC"` — silently continuing would defeat the whole point of #2983 (#2983)'
|
||||
);
|
||||
});
|
||||
|
||||
test('merge-back PR step is removed (auto-cherry-pick hotfix has nothing to merge back)', () => {
|
||||
// Auto-cherry-pick only picks commits already on main, so by
|
||||
// construction every code change on the hotfix branch is already
|
||||
// there. The only hotfix-branch-only commit is `chore: bump version
|
||||
// ... for hotfix`, which either no-ops or rewinds main's
|
||||
// in-progress version. The merge-back step was vestigial and was
|
||||
// additionally blocked by org policy ("GitHub Actions is not
|
||||
// permitted to create or approve pull requests"). Run 25232968975
|
||||
// was the trigger.
|
||||
const yaml = fs.readFileSync(WORKFLOW_PATH, 'utf8');
|
||||
assert.doesNotMatch(
|
||||
yaml,
|
||||
/Open merge-back PR \(hotfix only\)/,
|
||||
'merge-back PR step must be removed — nothing to merge back when every commit is already on main (#2983)'
|
||||
);
|
||||
assert.doesNotMatch(
|
||||
yaml,
|
||||
/chore: merge hotfix v\$\{VERSION\} back to main/,
|
||||
'merge-back PR title must be gone — vestigial from a different release flow (#2983)'
|
||||
);
|
||||
// Job-level pull-requests permission was granted solely for the
|
||||
// merge-back step. Removing the step means revoking the permission
|
||||
// (least-privilege).
|
||||
assert.doesNotMatch(
|
||||
yaml,
|
||||
/pull-requests: write/,
|
||||
'release job must NOT request `pull-requests: write` after the merge-back removal — least-privilege requires dropping the unused scope (#2983)'
|
||||
);
|
||||
});
|
||||
|
||||
test('the staged path is what the loop invokes, not the in-tree path', () => {
|
||||
const yaml = fs.readFileSync(WORKFLOW_PATH, 'utf8');
|
||||
const script = extractStepRun(yaml, 'Prepare hotfix branch');
|
||||
|
||||
// Find the cherry-pick loop's classifier invocation and ensure it
|
||||
// references "$CLASSIFIER", not scripts/diff-touches-shipped-paths.cjs
|
||||
// directly. Allowing the in-tree path here would re-introduce the
|
||||
// base-tag-missing bug.
|
||||
const loopAnchor = script.indexOf('CANDIDATES=$(git cherry HEAD origin/main');
|
||||
assert.ok(loopAnchor !== -1, 'cherry-pick loop sentinel not found');
|
||||
// 8 KB window matching the bug-2964 test's bound (raised from 6 KB
|
||||
// when the PIPESTATUS-snapshot hardening on PR for #2984's CR
|
||||
// findings pushed the cherry-pick call further past the loop anchor).
|
||||
const window = script.slice(loopAnchor, loopAnchor + 8000);
|
||||
assert.match(
|
||||
window,
|
||||
/node "\$CLASSIFIER"/,
|
||||
'cherry-pick loop must invoke `node "$CLASSIFIER"` (the staged copy), not `node scripts/diff-touches-shipped-paths.cjs` (the in-tree path) — the in-tree path may have been replaced by `git checkout -b "$BASE_TAG"` (#2983)'
|
||||
);
|
||||
assert.doesNotMatch(
|
||||
window,
|
||||
/node scripts\/diff-touches-shipped-paths\.cjs/,
|
||||
'cherry-pick loop must NOT invoke `node scripts/diff-touches-shipped-paths.cjs` — base tags predating #2980 don\'t have that file in their tree (#2983)'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,140 @@
|
||||
/**
|
||||
* Regression test for bug #2987
|
||||
*
|
||||
* The release-sdk workflow's `Dry-run publish validation` step ran
|
||||
* `npm publish --dry-run --tag "$TAG"` unconditionally. `npm publish
|
||||
* --dry-run` contacts the registry and exits 1 when the version is
|
||||
* already published:
|
||||
*
|
||||
* npm error You cannot publish over the previously published
|
||||
* versions: 1.39.1.
|
||||
*
|
||||
* The earlier `Detect prior publish (reconciliation mode)` step
|
||||
* already detects this case and sets
|
||||
* `steps.prior_publish.outputs.skip_publish=true` — and the real
|
||||
* publish step at line ~648 is gated on that. The dry-run validation
|
||||
* was missing the same gate, so re-runs of an already-published
|
||||
* hotfix (the operator's typical recovery path when a later step
|
||||
* like merge-back fails) blew up at the rehearsal before reaching
|
||||
* any of the reconciliation logic.
|
||||
*
|
||||
* Trigger run: 25233855236 — re-attempted v1.39.1 hotfix after the
|
||||
* prior run had landed v1.39.1 on npm.
|
||||
*
|
||||
* Fix: gate the dry-run validation step on
|
||||
* `steps.prior_publish.outputs.skip_publish != 'true'`, matching the
|
||||
* publish step.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
// allow-test-rule: source-text-is-the-product
|
||||
// release-sdk.yml IS the product for hotfix automation; the assertions
|
||||
// extract the workflow text and check the step-level `if:` guard via
|
||||
// indentation-aware YAML parsing rather than raw-text grep.
|
||||
|
||||
const { describe, test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const WORKFLOW_PATH = path.join(__dirname, '..', '.github', 'workflows', 'release-sdk.yml');
|
||||
|
||||
/**
|
||||
* Find a step by name and return the lines belonging to it (from the
|
||||
* `- name:` line up to but not including the next `- name:` at the
|
||||
* same indent or the next dedent-back-to-job).
|
||||
*/
|
||||
function extractStepBlock(workflowText, stepName) {
|
||||
const lines = workflowText.split('\n');
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const m = lines[i].match(/^(\s*)- name:\s*(.+?)\s*$/);
|
||||
if (!m || m[2] !== stepName) continue;
|
||||
const stepIndent = m[1].length;
|
||||
const start = i;
|
||||
let end = lines.length;
|
||||
for (let j = i + 1; j < lines.length; j++) {
|
||||
const peek = lines[j];
|
||||
if (peek.length === 0) continue;
|
||||
const lead = peek.match(/^(\s*)/)[1].length;
|
||||
// Next sibling step or dedent past step indent terminates this block.
|
||||
if (lead <= stepIndent && peek.trim().length > 0) {
|
||||
if (/^\s*- /.test(peek) || lead < stepIndent) {
|
||||
end = j;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return lines.slice(start, end).join('\n');
|
||||
}
|
||||
throw new Error(`step "${stepName}" not found in workflow`);
|
||||
}
|
||||
|
||||
describe('bug-2987: dry-run publish validation skips when reconciliation mode is active', () => {
|
||||
test('Dry-run publish validation step has an `if:` guard tied to skip_publish', () => {
|
||||
const yaml = fs.readFileSync(WORKFLOW_PATH, 'utf8');
|
||||
const block = extractStepBlock(yaml, 'Dry-run publish validation');
|
||||
|
||||
// The guard must reference steps.prior_publish.outputs.skip_publish
|
||||
// — the exact output set by the `Detect prior publish` step.
|
||||
// Loosely accepting any boolean expression here would risk a future
|
||||
// edit that gates on the wrong signal (e.g., inputs.dry_run, which
|
||||
// is the user-facing dry-run flag, not registry reconciliation).
|
||||
assert.match(
|
||||
block,
|
||||
/^\s*if:\s*\$\{\{\s*steps\.prior_publish\.outputs\.skip_publish\s*!=\s*'true'\s*\}\}\s*$/m,
|
||||
"Dry-run publish validation must be gated on `steps.prior_publish.outputs.skip_publish != 'true'` so reconciliation re-runs (version already on npm) don't fail at the rehearsal (#2987)"
|
||||
);
|
||||
});
|
||||
|
||||
test('the gate matches the actual publish step\'s gate (consistency with downstream skip)', () => {
|
||||
// The publish step ("Publish to npm (CC bundle, ...)" further
|
||||
// down) ALSO honors skip_publish. The rehearsal must honor it too;
|
||||
// otherwise reconciliation runs always fail at the rehearsal.
|
||||
// This test reads both gates and asserts the skip_publish
|
||||
// sub-expression is identical between them. It allows the publish
|
||||
// step to ALSO check inputs.dry_run (which it does, and which the
|
||||
// rehearsal correctly does NOT — the rehearsal is the dry-run).
|
||||
const yaml = fs.readFileSync(WORKFLOW_PATH, 'utf8');
|
||||
const dryRunBlock = extractStepBlock(yaml, 'Dry-run publish validation');
|
||||
const publishBlock = extractStepBlock(yaml, 'Publish to npm (CC bundle, SDK included as both loose tree and .tgz)');
|
||||
|
||||
const skipPattern = /steps\.prior_publish\.outputs\.skip_publish\s*!=\s*'true'/;
|
||||
assert.match(
|
||||
dryRunBlock,
|
||||
skipPattern,
|
||||
'Dry-run validation must check skip_publish (#2987)'
|
||||
);
|
||||
assert.match(
|
||||
publishBlock,
|
||||
skipPattern,
|
||||
'Publish step must check skip_publish (sentinel — if this fails the workflow has changed and the test\'s premise needs review)'
|
||||
);
|
||||
});
|
||||
|
||||
test('the workflow still runs the rehearsal in normal flows (gate is skip-only, not always-skip)', () => {
|
||||
// Defense against the wrong fix: someone could pass-through-fix
|
||||
// this by gating on `false` (always skip) which would silently
|
||||
// disable the rehearsal even on first publishes. The gate must
|
||||
// be specifically tied to the skip_publish signal, not a generic
|
||||
// `false` or `inputs.action == 'something'` discriminator.
|
||||
const yaml = fs.readFileSync(WORKFLOW_PATH, 'utf8');
|
||||
const block = extractStepBlock(yaml, 'Dry-run publish validation');
|
||||
|
||||
// The gate string itself must contain a comparison against 'true' —
|
||||
// i.e., it's an opt-out for the prior-publish case, not an
|
||||
// unconditional skip.
|
||||
const ifLine = block.split('\n').find((l) => /^\s*if:/.test(l));
|
||||
assert.ok(ifLine, 'Dry-run validation must have an `if:` line (#2987)');
|
||||
assert.match(
|
||||
ifLine,
|
||||
/skip_publish\s*!=\s*'true'/,
|
||||
'gate must be `skip_publish != true` (run when not skipping), not an unconditional skip — the rehearsal still has value on first publishes (#2987)'
|
||||
);
|
||||
assert.doesNotMatch(
|
||||
ifLine,
|
||||
/:\s*false\s*\}\}/,
|
||||
'gate must not be `if: false` — the rehearsal is meaningful when the version isn\'t yet on npm (#2987)'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,9 @@
|
||||
'use strict';
|
||||
|
||||
// allow-test-rule: pending-migration-to-typed-ir [#2974]
|
||||
// Tracked in #2974 for migration to typed-IR assertions per CONTRIBUTING.md
|
||||
// "Prohibited: Raw Text Matching on Test Outputs". Do not copy this pattern.
|
||||
|
||||
/**
|
||||
* Tests for get-shit-done/bin/lib/graphify.cjs
|
||||
*
|
||||
|
||||
@@ -201,11 +201,15 @@ describe('gsd-settings-advanced — VALID_CONFIG_KEYS coverage', () => {
|
||||
// ─── /gsd-settings mentions /gsd-settings-advanced ────────────────────────────
|
||||
|
||||
describe('/gsd-settings advertises /gsd-settings-advanced', () => {
|
||||
test('settings workflow confirmation mentions gsd-settings-advanced', () => {
|
||||
test('settings workflow mentions canonical /gsd-config --advanced', () => {
|
||||
const text = fs.readFileSync(SETTINGS_WORKFLOW_PATH, 'utf-8');
|
||||
assert.ok(
|
||||
text.includes('gsd-settings-advanced') || text.includes('gsd:settings-advanced'),
|
||||
'get-shit-done/workflows/settings.md must mention /gsd-settings-advanced or /gsd:settings-advanced'
|
||||
text.includes('gsd-config --advanced'),
|
||||
'get-shit-done/workflows/settings.md must mention /gsd-config --advanced'
|
||||
);
|
||||
assert.ok(
|
||||
!text.includes('gsd-settings-advanced') && !text.includes('gsd:settings-advanced'),
|
||||
'get-shit-done/workflows/settings.md must not mention legacy /gsd-settings-advanced variants'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
395
tests/hermes-install.test.cjs
Normal file
395
tests/hermes-install.test.cjs
Normal file
@@ -0,0 +1,395 @@
|
||||
process.env.GSD_TEST_MODE = '1';
|
||||
|
||||
const { test, describe, beforeEach, afterEach } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const os = require('node:os');
|
||||
const { createTempDir, cleanup, parseFrontmatter } = require('./helpers.cjs');
|
||||
const pkg = require('../package.json');
|
||||
|
||||
const {
|
||||
getDirName,
|
||||
getGlobalDir,
|
||||
getConfigDirFromHome,
|
||||
install,
|
||||
uninstall,
|
||||
writeManifest,
|
||||
} = require('../bin/install.js');
|
||||
|
||||
describe('Hermes Agent runtime directory mapping', () => {
|
||||
test('maps Hermes to .hermes for local installs', () => {
|
||||
assert.strictEqual(getDirName('hermes'), '.hermes');
|
||||
});
|
||||
|
||||
test('maps Hermes to ~/.hermes for global installs', () => {
|
||||
// Isolate from any HERMES_HOME exported on the developer's machine —
|
||||
// otherwise this test asserts the env-derived path, not the default.
|
||||
const originalHermesHome = process.env.HERMES_HOME;
|
||||
delete process.env.HERMES_HOME;
|
||||
try {
|
||||
assert.strictEqual(getGlobalDir('hermes'), path.join(os.homedir(), '.hermes'));
|
||||
} finally {
|
||||
if (originalHermesHome === undefined) delete process.env.HERMES_HOME;
|
||||
else process.env.HERMES_HOME = originalHermesHome;
|
||||
}
|
||||
});
|
||||
|
||||
test('returns .hermes config fragments for local and global installs', () => {
|
||||
assert.strictEqual(getConfigDirFromHome('hermes', false), "'.hermes'");
|
||||
assert.strictEqual(getConfigDirFromHome('hermes', true), "'.hermes'");
|
||||
});
|
||||
});
|
||||
|
||||
describe('getGlobalDir (Hermes Agent)', () => {
|
||||
let originalHermesConfigDir;
|
||||
|
||||
beforeEach(() => {
|
||||
originalHermesConfigDir = process.env.HERMES_HOME;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (originalHermesConfigDir !== undefined) {
|
||||
process.env.HERMES_HOME = originalHermesConfigDir;
|
||||
} else {
|
||||
delete process.env.HERMES_HOME;
|
||||
}
|
||||
});
|
||||
|
||||
test('returns ~/.hermes with no env var or explicit dir', () => {
|
||||
delete process.env.HERMES_HOME;
|
||||
const result = getGlobalDir('hermes');
|
||||
assert.strictEqual(result, path.join(os.homedir(), '.hermes'));
|
||||
});
|
||||
|
||||
test('returns explicit dir when provided', () => {
|
||||
const result = getGlobalDir('hermes', '/custom/hermes-path');
|
||||
assert.strictEqual(result, '/custom/hermes-path');
|
||||
});
|
||||
|
||||
test('respects HERMES_HOME env var', () => {
|
||||
process.env.HERMES_HOME = '~/custom-hermes';
|
||||
const result = getGlobalDir('hermes');
|
||||
assert.strictEqual(result, path.join(os.homedir(), 'custom-hermes'));
|
||||
});
|
||||
|
||||
test('explicit dir takes priority over HERMES_HOME', () => {
|
||||
process.env.HERMES_HOME = '~/from-env';
|
||||
const result = getGlobalDir('hermes', '/explicit/path');
|
||||
assert.strictEqual(result, '/explicit/path');
|
||||
});
|
||||
|
||||
test('does not break other runtimes', () => {
|
||||
assert.strictEqual(getGlobalDir('claude'), path.join(os.homedir(), '.claude'));
|
||||
assert.strictEqual(getGlobalDir('codex'), path.join(os.homedir(), '.codex'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('Hermes Agent local install/uninstall', () => {
|
||||
let tmpDir;
|
||||
let previousCwd;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = createTempDir('gsd-hermes-install-');
|
||||
previousCwd = process.cwd();
|
||||
process.chdir(tmpDir);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.chdir(previousCwd);
|
||||
cleanup(tmpDir);
|
||||
});
|
||||
|
||||
test('installs GSD into ./.hermes and removes it cleanly', () => {
|
||||
const result = install(false, 'hermes');
|
||||
const targetDir = path.join(tmpDir, '.hermes');
|
||||
|
||||
assert.strictEqual(result.runtime, 'hermes');
|
||||
assert.strictEqual(result.configDir, fs.realpathSync(targetDir));
|
||||
|
||||
// Nested layout per spec #2841: all GSD skills collapse into a single
|
||||
// skills/gsd/ category so Hermes' system prompt sees one entry, not 86.
|
||||
assert.ok(fs.existsSync(path.join(targetDir, 'skills', 'gsd', 'gsd-help', 'SKILL.md')));
|
||||
assert.ok(fs.existsSync(path.join(targetDir, 'skills', 'gsd', 'DESCRIPTION.md')),
|
||||
'DESCRIPTION.md exists at category root');
|
||||
assert.ok(fs.existsSync(path.join(targetDir, 'get-shit-done', 'VERSION')));
|
||||
assert.ok(fs.existsSync(path.join(targetDir, 'agents')));
|
||||
|
||||
const manifest = writeManifest(targetDir, 'hermes');
|
||||
assert.ok(Object.keys(manifest.files).some(file => file.startsWith('skills/gsd/gsd-help/')), manifest);
|
||||
|
||||
uninstall(false, 'hermes');
|
||||
|
||||
assert.ok(!fs.existsSync(path.join(targetDir, 'skills', 'gsd', 'gsd-help')), 'Hermes skill directory removed');
|
||||
assert.ok(!fs.existsSync(path.join(targetDir, 'skills', 'gsd')), 'Hermes gsd category dir removed');
|
||||
assert.ok(!fs.existsSync(path.join(targetDir, 'get-shit-done')), 'get-shit-done removed');
|
||||
});
|
||||
|
||||
test('installed SKILL.md frontmatter conforms to Hermes spec', () => {
|
||||
install(false, 'hermes');
|
||||
const targetDir = path.join(tmpDir, '.hermes');
|
||||
// Nested layout: skills live under skills/gsd/gsd-*/SKILL.md.
|
||||
const categoryDir = path.join(targetDir, 'skills', 'gsd');
|
||||
const skillDirs = fs.readdirSync(categoryDir, { withFileTypes: true })
|
||||
.filter(e => e.isDirectory() && e.name.startsWith('gsd-'))
|
||||
.map(e => e.name);
|
||||
|
||||
assert.ok(skillDirs.length > 0, 'at least one gsd-* skill installed');
|
||||
|
||||
// Parse every SKILL.md and assert structural shape required by Hermes.
|
||||
for (const dir of skillDirs) {
|
||||
const content = fs.readFileSync(path.join(categoryDir, dir, 'SKILL.md'), 'utf8');
|
||||
const fm = parseFrontmatter(content);
|
||||
assert.strictEqual(fm.name, dir, `${dir}/SKILL.md name matches dir`);
|
||||
assert.ok(typeof fm.description === 'string' && fm.description.length > 0,
|
||||
`${dir}/SKILL.md has non-empty description`);
|
||||
assert.strictEqual(fm.version, pkg.version,
|
||||
`${dir}/SKILL.md declares version ${pkg.version} (got ${JSON.stringify(fm.version)})`);
|
||||
}
|
||||
|
||||
// The category DESCRIPTION.md is part of the spec — verify it parses too.
|
||||
const desc = fs.readFileSync(path.join(categoryDir, 'DESCRIPTION.md'), 'utf8');
|
||||
const descFm = parseFrontmatter(desc);
|
||||
assert.strictEqual(descFm.name, 'gsd', 'category DESCRIPTION.md name is "gsd"');
|
||||
assert.ok(typeof descFm.description === 'string' && descFm.description.length > 0,
|
||||
'category DESCRIPTION.md has description');
|
||||
assert.strictEqual(descFm.version, pkg.version,
|
||||
'category DESCRIPTION.md declares version');
|
||||
|
||||
uninstall(false, 'hermes');
|
||||
});
|
||||
|
||||
test('replaces CLAUDE.md references with HERMES.md', () => {
|
||||
install(false, 'hermes');
|
||||
const targetDir = path.join(tmpDir, '.hermes');
|
||||
const skillsDir = path.join(targetDir, 'skills');
|
||||
|
||||
// Walk all skill files and confirm no `CLAUDE.md` token leaks; any
|
||||
// skill body that referenced project context should now point at
|
||||
// `HERMES.md` per the issue spec.
|
||||
let referencedHermesMd = false;
|
||||
const walk = (dir) => {
|
||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
const full = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) { walk(full); continue; }
|
||||
if (!entry.name.endsWith('.md')) continue;
|
||||
const content = fs.readFileSync(full, 'utf8');
|
||||
assert.ok(!/\bCLAUDE\.md\b/.test(content),
|
||||
`${path.relative(targetDir, full)} still references CLAUDE.md`);
|
||||
if (/\bHERMES\.md\b/.test(content)) referencedHermesMd = true;
|
||||
}
|
||||
};
|
||||
walk(skillsDir);
|
||||
// Sanity: at least one skill in the GSD set references the project
|
||||
// context filename, so the substitution actually exercises.
|
||||
assert.ok(referencedHermesMd, 'at least one skill references HERMES.md after substitution');
|
||||
|
||||
uninstall(false, 'hermes');
|
||||
});
|
||||
});
|
||||
|
||||
describe('E2E: Hermes Agent uninstall skills cleanup', () => {
|
||||
let tmpDir;
|
||||
let previousCwd;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = createTempDir('gsd-hermes-uninstall-');
|
||||
previousCwd = process.cwd();
|
||||
process.chdir(tmpDir);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.chdir(previousCwd);
|
||||
cleanup(tmpDir);
|
||||
});
|
||||
|
||||
test('removes all gsd-* skill directories on --hermes --uninstall', () => {
|
||||
const targetDir = path.join(tmpDir, '.hermes');
|
||||
install(false, 'hermes');
|
||||
|
||||
const skillsDir = path.join(targetDir, 'skills');
|
||||
const categoryDir = path.join(skillsDir, 'gsd');
|
||||
assert.ok(fs.existsSync(categoryDir), 'skills/gsd/ category dir exists after install');
|
||||
|
||||
const installedSkills = fs.readdirSync(categoryDir, { withFileTypes: true })
|
||||
.filter(e => e.isDirectory() && e.name.startsWith('gsd-'));
|
||||
assert.ok(installedSkills.length > 0, `found ${installedSkills.length} gsd-* skill dirs before uninstall`);
|
||||
|
||||
uninstall(false, 'hermes');
|
||||
|
||||
assert.ok(!fs.existsSync(categoryDir), 'skills/gsd/ category dir removed by uninstall');
|
||||
if (fs.existsSync(skillsDir)) {
|
||||
const remainingFlat = fs.readdirSync(skillsDir, { withFileTypes: true })
|
||||
.filter(e => e.isDirectory() && e.name.startsWith('gsd-'));
|
||||
assert.strictEqual(remainingFlat.length, 0,
|
||||
`Expected 0 stray flat gsd-* skill dirs after uninstall, found: ${remainingFlat.map(e => e.name).join(', ')}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('preserves non-GSD skill directories during --hermes --uninstall', () => {
|
||||
const targetDir = path.join(tmpDir, '.hermes');
|
||||
install(false, 'hermes');
|
||||
|
||||
const customSkillDir = path.join(targetDir, 'skills', 'my-custom-skill');
|
||||
fs.mkdirSync(customSkillDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(customSkillDir, 'SKILL.md'), '# My Custom Skill\n');
|
||||
|
||||
assert.ok(fs.existsSync(path.join(customSkillDir, 'SKILL.md')), 'custom skill exists before uninstall');
|
||||
|
||||
uninstall(false, 'hermes');
|
||||
|
||||
assert.ok(fs.existsSync(path.join(customSkillDir, 'SKILL.md')),
|
||||
'Non-GSD skill directory should be preserved after Hermes uninstall');
|
||||
});
|
||||
|
||||
test('removes engine directory on --hermes --uninstall', () => {
|
||||
const targetDir = path.join(tmpDir, '.hermes');
|
||||
install(false, 'hermes');
|
||||
|
||||
assert.ok(fs.existsSync(path.join(targetDir, 'get-shit-done', 'VERSION')),
|
||||
'engine exists before uninstall');
|
||||
|
||||
uninstall(false, 'hermes');
|
||||
|
||||
assert.ok(!fs.existsSync(path.join(targetDir, 'get-shit-done')),
|
||||
'get-shit-done engine should be removed after Hermes uninstall');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Regression: no Claude references leak into Hermes install (parity with Qwen regression #2112) ──────────
|
||||
|
||||
describe('Hermes install contains no leaked Claude references (parity with Qwen regression #2112)', () => {
|
||||
let tmpDir;
|
||||
let previousCwd;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = createTempDir('gsd-hermes-refs-');
|
||||
previousCwd = process.cwd();
|
||||
process.chdir(tmpDir);
|
||||
install(false, 'hermes');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.chdir(previousCwd);
|
||||
cleanup(tmpDir);
|
||||
});
|
||||
|
||||
/**
|
||||
* Recursively walk a directory and return all file paths.
|
||||
*/
|
||||
function walk(dir) {
|
||||
const results = [];
|
||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
const full = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
results.push(...walk(full));
|
||||
} else {
|
||||
results.push(full);
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return files under .hermes/ that contain Claude references,
|
||||
* excluding CHANGELOG.md (historical accuracy) and VERSION (no prose).
|
||||
*/
|
||||
function findClaudeLeaks() {
|
||||
const hermesDir = path.join(tmpDir, '.hermes');
|
||||
const allFiles = walk(hermesDir);
|
||||
const textFiles = allFiles.filter(f =>
|
||||
f.endsWith('.md') || f.endsWith('.cjs') || f.endsWith('.js')
|
||||
);
|
||||
const excluded = ['CHANGELOG.md'];
|
||||
const candidates = textFiles.filter(f =>
|
||||
!excluded.includes(path.basename(f))
|
||||
);
|
||||
const leaks = [];
|
||||
for (const file of candidates) {
|
||||
const content = fs.readFileSync(file, 'utf8');
|
||||
if (/\bCLAUDE\.md\b/.test(content) ||
|
||||
/\bClaude Code\b/.test(content) ||
|
||||
/\.claude\//.test(content)) {
|
||||
leaks.push(path.relative(tmpDir, file));
|
||||
}
|
||||
}
|
||||
return leaks;
|
||||
}
|
||||
|
||||
test('skills contain no CLAUDE.md or Claude Code references', () => {
|
||||
const hermesDir = path.join(tmpDir, '.hermes');
|
||||
const skillsDir = path.join(hermesDir, 'skills');
|
||||
assert.ok(fs.existsSync(skillsDir), 'skills directory exists');
|
||||
|
||||
const skillFiles = walk(skillsDir).filter(f => f.endsWith('.md'));
|
||||
assert.ok(skillFiles.length > 0, 'at least one skill file exists');
|
||||
|
||||
const leaks = [];
|
||||
for (const file of skillFiles) {
|
||||
const content = fs.readFileSync(file, 'utf8');
|
||||
if (/\bCLAUDE\.md\b/.test(content) || /\bClaude Code\b/.test(content)) {
|
||||
leaks.push(path.relative(tmpDir, file));
|
||||
}
|
||||
}
|
||||
assert.strictEqual(leaks.length, 0,
|
||||
[
|
||||
'Skills should not contain Claude references after Hermes install.',
|
||||
'Leaking files:',
|
||||
...leaks,
|
||||
].join('\n'));
|
||||
});
|
||||
|
||||
test('agents contain no CLAUDE.md or Claude Code references', () => {
|
||||
const agentsDir = path.join(tmpDir, '.hermes', 'agents');
|
||||
assert.ok(fs.existsSync(agentsDir), 'agents directory exists');
|
||||
|
||||
const agentFiles = walk(agentsDir).filter(f => f.endsWith('.md'));
|
||||
assert.ok(agentFiles.length > 0, 'at least one agent file exists');
|
||||
|
||||
const leaks = [];
|
||||
for (const file of agentFiles) {
|
||||
const content = fs.readFileSync(file, 'utf8');
|
||||
if (/\bCLAUDE\.md\b/.test(content) || /\bClaude Code\b/.test(content)) {
|
||||
leaks.push(path.relative(tmpDir, file));
|
||||
}
|
||||
}
|
||||
assert.strictEqual(leaks.length, 0,
|
||||
[
|
||||
'Agents should not contain Claude references after Hermes install.',
|
||||
'Leaking files:',
|
||||
...leaks,
|
||||
].join('\n'));
|
||||
});
|
||||
|
||||
test('hooks contain no .claude/ path references', () => {
|
||||
const hooksDir = path.join(tmpDir, '.hermes', 'hooks');
|
||||
if (!fs.existsSync(hooksDir)) {
|
||||
return; // hooks may not be present in local installs
|
||||
}
|
||||
|
||||
const hookFiles = walk(hooksDir).filter(f => f.endsWith('.js'));
|
||||
const leaks = [];
|
||||
for (const file of hookFiles) {
|
||||
const content = fs.readFileSync(file, 'utf8');
|
||||
if (/\.claude\//.test(content)) {
|
||||
leaks.push(path.relative(tmpDir, file));
|
||||
}
|
||||
}
|
||||
assert.strictEqual(leaks.length, 0,
|
||||
[
|
||||
'Hooks should not contain .claude/ path references after Hermes install.',
|
||||
'Leaking files:',
|
||||
...leaks,
|
||||
].join('\n'));
|
||||
});
|
||||
|
||||
test('full tree scan finds zero Claude references outside CHANGELOG.md', () => {
|
||||
const leaks = findClaudeLeaks();
|
||||
assert.strictEqual(leaks.length, 0,
|
||||
[
|
||||
'No files under .hermes/ (except CHANGELOG.md) should contain Claude references.',
|
||||
`Found ${leaks.length} leaking file(s):`,
|
||||
...leaks,
|
||||
].join('\n'));
|
||||
});
|
||||
});
|
||||
305
tests/hermes-skills-migration.test.cjs
Normal file
305
tests/hermes-skills-migration.test.cjs
Normal file
@@ -0,0 +1,305 @@
|
||||
/**
|
||||
* GSD Tools Tests - Hermes Agent Skills Migration
|
||||
*
|
||||
* Tests for installing GSD for Hermes Agent using the standard
|
||||
* skills/gsd-xxx/SKILL.md format (same open standard as Claude Code 2.1.88+).
|
||||
*
|
||||
* Uses node:test and node:assert (NOT Jest).
|
||||
*/
|
||||
|
||||
process.env.GSD_TEST_MODE = '1';
|
||||
|
||||
const { test, describe, beforeEach, afterEach } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const path = require('path');
|
||||
const os = require('os');
|
||||
const fs = require('fs');
|
||||
|
||||
const {
|
||||
convertClaudeCommandToClaudeSkill,
|
||||
copyCommandsAsClaudeSkills,
|
||||
} = require('../bin/install.js');
|
||||
const { parseFrontmatter } = require('./helpers.cjs');
|
||||
const pkg = require('../package.json');
|
||||
|
||||
// ─── convertClaudeCommandToClaudeSkill (used by Hermes via copyCommandsAsClaudeSkills) ──
|
||||
|
||||
describe('Hermes Agent: convertClaudeCommandToClaudeSkill', () => {
|
||||
test('preserves allowed-tools multiline YAML list', () => {
|
||||
const input = [
|
||||
'---',
|
||||
'name: gsd:next',
|
||||
'description: Advance to the next step',
|
||||
'allowed-tools:',
|
||||
' - Read',
|
||||
' - Bash',
|
||||
' - Grep',
|
||||
'---',
|
||||
'',
|
||||
'Body content here.',
|
||||
].join('\n');
|
||||
|
||||
const result = convertClaudeCommandToClaudeSkill(input, 'gsd-next');
|
||||
assert.ok(result.includes('allowed-tools:'), 'allowed-tools field is present');
|
||||
assert.ok(result.includes('Read'), 'Read tool preserved');
|
||||
assert.ok(result.includes('Bash'), 'Bash tool preserved');
|
||||
assert.ok(result.includes('Grep'), 'Grep tool preserved');
|
||||
});
|
||||
|
||||
test('preserves argument-hint', () => {
|
||||
const input = [
|
||||
'---',
|
||||
'name: gsd:debug',
|
||||
'description: Debug issues',
|
||||
'argument-hint: "[issue description]"',
|
||||
'allowed-tools:',
|
||||
' - Read',
|
||||
' - Bash',
|
||||
'---',
|
||||
'',
|
||||
'Debug body.',
|
||||
].join('\n');
|
||||
|
||||
const result = convertClaudeCommandToClaudeSkill(input, 'gsd-debug');
|
||||
assert.ok(result.includes('argument-hint:'), 'argument-hint field is present');
|
||||
assert.ok(
|
||||
result.includes('[issue description]'),
|
||||
'argument-hint value preserved'
|
||||
);
|
||||
});
|
||||
|
||||
test('emits hyphen-form name (gsd-<cmd>) from hyphen-form dir (#2808)', () => {
|
||||
const input = [
|
||||
'---',
|
||||
'name: gsd:next',
|
||||
'description: Advance workflow',
|
||||
'---',
|
||||
'',
|
||||
'Body.',
|
||||
].join('\n');
|
||||
|
||||
// Directory name is gsd-next (hyphen, Windows-safe), frontmatter name is
|
||||
// gsd-next (hyphen, #2808 — canonical invocation form for Claude Code autocomplete).
|
||||
const result = convertClaudeCommandToClaudeSkill(input, 'gsd-next');
|
||||
assert.ok(result.includes('name: gsd-next'), 'frontmatter name uses hyphen form (#2808)');
|
||||
});
|
||||
|
||||
test('preserves body content unchanged', () => {
|
||||
const body = '\n<objective>\nDo the thing.\n</objective>\n\n<process>\nStep 1.\nStep 2.\n</process>\n';
|
||||
const input = [
|
||||
'---',
|
||||
'name: gsd:test',
|
||||
'description: Test command',
|
||||
'---',
|
||||
body,
|
||||
].join('');
|
||||
|
||||
const result = convertClaudeCommandToClaudeSkill(input, 'gsd-test');
|
||||
assert.ok(result.includes('<objective>'), 'objective tag preserved');
|
||||
assert.ok(result.includes('Do the thing.'), 'body text preserved');
|
||||
assert.ok(result.includes('<process>'), 'process tag preserved');
|
||||
});
|
||||
|
||||
test('produces valid SKILL.md frontmatter starting with ---', () => {
|
||||
const input = [
|
||||
'---',
|
||||
'name: gsd:plan',
|
||||
'description: Plan a phase',
|
||||
'---',
|
||||
'',
|
||||
'Plan body.',
|
||||
].join('\n');
|
||||
|
||||
const result = convertClaudeCommandToClaudeSkill(input, 'gsd-plan');
|
||||
assert.ok(result.startsWith('---\n'), 'frontmatter starts with ---');
|
||||
assert.ok(result.includes('\n---\n'), 'frontmatter closes with ---');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── copyCommandsAsClaudeSkills (used for Hermes skills install) ─────────────
|
||||
|
||||
describe('Hermes Agent: copyCommandsAsClaudeSkills', () => {
|
||||
let tmpDir;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-hermes-test-'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (fs.existsSync(tmpDir)) {
|
||||
fs.rmSync(tmpDir, { recursive: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('creates skills/gsd-xxx/SKILL.md directory structure', () => {
|
||||
// Create source command files
|
||||
const srcDir = path.join(tmpDir, 'src', 'commands', 'gsd');
|
||||
fs.mkdirSync(srcDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(srcDir, 'quick.md'), [
|
||||
'---',
|
||||
'name: gsd:quick',
|
||||
'description: Execute a quick task',
|
||||
'allowed-tools:',
|
||||
' - Read',
|
||||
' - Bash',
|
||||
'---',
|
||||
'',
|
||||
'<objective>Quick task body</objective>',
|
||||
].join('\n'));
|
||||
|
||||
const skillsDir = path.join(tmpDir, 'dest', 'skills');
|
||||
copyCommandsAsClaudeSkills(srcDir, skillsDir, 'gsd', '/test/prefix/', 'hermes', false);
|
||||
|
||||
// Verify SKILL.md was created
|
||||
const skillPath = path.join(skillsDir, 'gsd-quick', 'SKILL.md');
|
||||
assert.ok(fs.existsSync(skillPath), 'gsd-quick/SKILL.md exists');
|
||||
|
||||
// Verify content (structural — parse frontmatter, don't substring-grep)
|
||||
const content = fs.readFileSync(skillPath, 'utf8');
|
||||
const fm = parseFrontmatter(content);
|
||||
assert.strictEqual(fm.name, 'gsd-quick', 'frontmatter name uses hyphen form (#2808)');
|
||||
assert.ok(fm.description && fm.description.length > 0, 'description present and non-empty');
|
||||
assert.strictEqual(fm.version, pkg.version,
|
||||
`Hermes SKILL.md must declare version (got ${JSON.stringify(fm.version)})`);
|
||||
assert.ok(/^allowed-tools:\s*\n(?:\s+-\s+\S+\n?)+/m.test(content),
|
||||
'allowed-tools rendered as YAML block list');
|
||||
assert.ok(content.includes('<objective>'), 'body content preserved');
|
||||
});
|
||||
|
||||
test('replaces ~/.claude/ paths with pathPrefix', () => {
|
||||
const srcDir = path.join(tmpDir, 'src', 'commands', 'gsd');
|
||||
fs.mkdirSync(srcDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(srcDir, 'next.md'), [
|
||||
'---',
|
||||
'name: gsd:next',
|
||||
'description: Next step',
|
||||
'---',
|
||||
'',
|
||||
'Reference: @~/.claude/get-shit-done/workflows/next.md',
|
||||
].join('\n'));
|
||||
|
||||
const skillsDir = path.join(tmpDir, 'dest', 'skills');
|
||||
copyCommandsAsClaudeSkills(srcDir, skillsDir, 'gsd', '$HOME/.hermes/', 'hermes', false);
|
||||
|
||||
const content = fs.readFileSync(path.join(skillsDir, 'gsd-next', 'SKILL.md'), 'utf8');
|
||||
assert.ok(content.includes('$HOME/.hermes/'), 'path replaced to .hermes/');
|
||||
assert.ok(!content.includes('~/.claude/'), 'old claude path removed');
|
||||
});
|
||||
|
||||
test('replaces $HOME/.claude/ paths with pathPrefix', () => {
|
||||
const srcDir = path.join(tmpDir, 'src', 'commands', 'gsd');
|
||||
fs.mkdirSync(srcDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(srcDir, 'plan.md'), [
|
||||
'---',
|
||||
'name: gsd:plan',
|
||||
'description: Plan phase',
|
||||
'---',
|
||||
'',
|
||||
'Reference: $HOME/.claude/get-shit-done/workflows/plan.md',
|
||||
].join('\n'));
|
||||
|
||||
const skillsDir = path.join(tmpDir, 'dest', 'skills');
|
||||
copyCommandsAsClaudeSkills(srcDir, skillsDir, 'gsd', '$HOME/.hermes/', 'hermes', false);
|
||||
|
||||
const content = fs.readFileSync(path.join(skillsDir, 'gsd-plan', 'SKILL.md'), 'utf8');
|
||||
assert.ok(content.includes('$HOME/.hermes/'), 'path replaced to .hermes/');
|
||||
assert.ok(!content.includes('$HOME/.claude/'), 'old claude path removed');
|
||||
});
|
||||
|
||||
test('removes stale gsd- skills before installing new ones', () => {
|
||||
const srcDir = path.join(tmpDir, 'src', 'commands', 'gsd');
|
||||
fs.mkdirSync(srcDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(srcDir, 'quick.md'), [
|
||||
'---',
|
||||
'name: gsd:quick',
|
||||
'description: Quick task',
|
||||
'---',
|
||||
'',
|
||||
'Body',
|
||||
].join('\n'));
|
||||
|
||||
const skillsDir = path.join(tmpDir, 'dest', 'skills');
|
||||
// Pre-create a stale skill
|
||||
fs.mkdirSync(path.join(skillsDir, 'gsd-old-skill'), { recursive: true });
|
||||
fs.writeFileSync(path.join(skillsDir, 'gsd-old-skill', 'SKILL.md'), 'old');
|
||||
|
||||
copyCommandsAsClaudeSkills(srcDir, skillsDir, 'gsd', '/test/', 'hermes', false);
|
||||
|
||||
assert.ok(!fs.existsSync(path.join(skillsDir, 'gsd-old-skill')), 'stale skill removed');
|
||||
assert.ok(fs.existsSync(path.join(skillsDir, 'gsd-quick', 'SKILL.md')), 'new skill installed');
|
||||
});
|
||||
|
||||
test('preserves agent field in frontmatter', () => {
|
||||
const srcDir = path.join(tmpDir, 'src', 'commands', 'gsd');
|
||||
fs.mkdirSync(srcDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(srcDir, 'execute.md'), [
|
||||
'---',
|
||||
'name: gsd:execute',
|
||||
'description: Execute phase',
|
||||
'agent: gsd-executor',
|
||||
'allowed-tools:',
|
||||
' - Read',
|
||||
' - Bash',
|
||||
' - Task',
|
||||
'---',
|
||||
'',
|
||||
'Execute body',
|
||||
].join('\n'));
|
||||
|
||||
const skillsDir = path.join(tmpDir, 'dest', 'skills');
|
||||
copyCommandsAsClaudeSkills(srcDir, skillsDir, 'gsd', '/test/', 'hermes', false);
|
||||
|
||||
const content = fs.readFileSync(path.join(skillsDir, 'gsd-execute', 'SKILL.md'), 'utf8');
|
||||
const fm = parseFrontmatter(content);
|
||||
assert.strictEqual(fm.agent, 'gsd-executor', 'agent field preserved');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Integration: SKILL.md format validation ────────────────────────────────
|
||||
|
||||
describe('Hermes Agent: SKILL.md format validation', () => {
|
||||
test('SKILL.md frontmatter parses with required Hermes fields', () => {
|
||||
const input = [
|
||||
'---',
|
||||
'name: gsd:review',
|
||||
'description: Code review with quality checks',
|
||||
'argument-hint: "[PR number or branch]"',
|
||||
'agent: gsd-code-reviewer',
|
||||
'allowed-tools:',
|
||||
' - Read',
|
||||
' - Grep',
|
||||
' - Bash',
|
||||
'---',
|
||||
'',
|
||||
'<objective>Review code</objective>',
|
||||
].join('\n');
|
||||
|
||||
// Pass runtime='hermes' so the version field is injected per Hermes spec.
|
||||
const result = convertClaudeCommandToClaudeSkill(input, 'gsd-review', 'hermes');
|
||||
const fm = parseFrontmatter(result);
|
||||
|
||||
assert.strictEqual(fm.name, 'gsd-review', 'name uses hyphen form');
|
||||
assert.ok(fm.description && fm.description.length > 0, 'description non-empty');
|
||||
assert.strictEqual(fm.version, pkg.version, 'version matches package.json');
|
||||
assert.strictEqual(fm.agent, 'gsd-code-reviewer', 'agent preserved');
|
||||
assert.strictEqual(fm['argument-hint'], '[PR number or branch]', 'argument-hint preserved and unquoted');
|
||||
assert.ok(/^allowed-tools:\s*\n(?:\s+-\s+\S+\n?)+/m.test(result),
|
||||
'allowed-tools rendered as YAML block list');
|
||||
});
|
||||
|
||||
test('omits version field when runtime is not hermes (parity with non-Hermes skill consumers)', () => {
|
||||
const input = [
|
||||
'---',
|
||||
'name: gsd:plan',
|
||||
'description: Plan a phase',
|
||||
'---',
|
||||
'',
|
||||
'Body.',
|
||||
].join('\n');
|
||||
|
||||
const result = convertClaudeCommandToClaudeSkill(input, 'gsd-plan');
|
||||
const fm = parseFrontmatter(result);
|
||||
assert.strictEqual(fm.version, undefined, 'no version key for non-hermes skills');
|
||||
assert.strictEqual(fm.name, 'gsd-plan');
|
||||
});
|
||||
});
|
||||
@@ -1,3 +1,7 @@
|
||||
// allow-test-rule: pending-migration-to-typed-ir [#2974]
|
||||
// Tracked in #2974 for migration to typed-IR assertions per CONTRIBUTING.md
|
||||
// "Prohibited: Raw Text Matching on Test Outputs". Do not copy this pattern.
|
||||
|
||||
/**
|
||||
* GSD Tools Tests - Community Hooks (opt-in)
|
||||
*
|
||||
|
||||
337
tests/install-minimal-all-runtimes.test.cjs
Normal file
337
tests/install-minimal-all-runtimes.test.cjs
Normal file
@@ -0,0 +1,337 @@
|
||||
/**
|
||||
* Per-runtime regression test for `--minimal` install profile (#2923).
|
||||
*
|
||||
* Background: #2923 reported that `--opencode --local --minimal` silently
|
||||
* installed the full surface. While auditing the central gate
|
||||
* (`stageSkillsForMode` in get-shit-done/bin/lib/install-profiles.cjs),
|
||||
* we found that:
|
||||
* - Skills are correctly filtered for every runtime in both `--global`
|
||||
* and `--local` modes (the dispatch sites in install.js all call
|
||||
* stageSkillsForMode unconditionally).
|
||||
* - Agents are correctly suppressed under --minimal.
|
||||
* - HOWEVER, the install manifest only recorded `commands/gsd/` for
|
||||
* Gemini, leaving Claude Code local installs with an incomplete
|
||||
* manifest. saveLocalPatches() then couldn't detect user edits and
|
||||
* a minimal-mode reinstall couldn't be verified manifest-side.
|
||||
*
|
||||
* This test pins per-runtime behavior end-to-end: spawn the installer
|
||||
* with --minimal for each runtime in each scope, parse the resulting
|
||||
* manifest JSON, assert that mode === 'minimal', the recorded skill set
|
||||
* equals MINIMAL_SKILL_ALLOWLIST, and zero gsd-* agents are present.
|
||||
*
|
||||
* Cline is rules-based and embeds the workflow in `.clinerules` rather
|
||||
* than emitting per-skill files. Asserted separately: mode === 'minimal',
|
||||
* zero agents, .clinerules exists.
|
||||
*
|
||||
* No regex / `.includes()` against file contents — every assertion
|
||||
* either parses JSON or walks a directory tree.
|
||||
*/
|
||||
|
||||
const { test, describe } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const os = require('os');
|
||||
const { spawnSync } = require('child_process');
|
||||
|
||||
const {
|
||||
MINIMAL_SKILL_ALLOWLIST,
|
||||
} = require('../get-shit-done/bin/lib/install-profiles.cjs');
|
||||
|
||||
const INSTALL_SCRIPT = path.join(__dirname, '..', 'bin', 'install.js');
|
||||
const MANIFEST_NAME = 'gsd-file-manifest.json';
|
||||
|
||||
// Per-runtime config dir name for local installs. Mirrors getDirName() in
|
||||
// bin/install.js; kept as a fixture to avoid coupling the test to that
|
||||
// internal helper.
|
||||
const LOCAL_DIR_NAME = {
|
||||
claude: '.claude',
|
||||
opencode: '.opencode',
|
||||
gemini: '.gemini',
|
||||
kilo: '.kilo',
|
||||
codex: '.codex',
|
||||
copilot: '.github',
|
||||
antigravity: '.agent',
|
||||
cursor: '.cursor',
|
||||
windsurf: '.windsurf',
|
||||
augment: '.augment',
|
||||
trae: '.trae',
|
||||
qwen: '.qwen',
|
||||
codebuddy: '.codebuddy',
|
||||
cline: '.', // Cline writes to project root
|
||||
};
|
||||
|
||||
// Skill-emitting runtimes (everything except Cline, which is rules-based).
|
||||
const SKILL_RUNTIMES = [
|
||||
'claude',
|
||||
'opencode',
|
||||
'gemini',
|
||||
'kilo',
|
||||
'codex',
|
||||
'copilot',
|
||||
'antigravity',
|
||||
'cursor',
|
||||
'windsurf',
|
||||
'augment',
|
||||
'trae',
|
||||
'qwen',
|
||||
'codebuddy',
|
||||
];
|
||||
|
||||
const ALL_RUNTIMES = [...SKILL_RUNTIMES, 'cline'];
|
||||
|
||||
/**
|
||||
* Run the installer in either global or local mode and return the parsed
|
||||
* manifest (or null if no manifest was written).
|
||||
*/
|
||||
function runInstall({ runtime, scope, extraArgs = [] }) {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), `gsd-${runtime}-${scope}-`));
|
||||
try {
|
||||
let configDir;
|
||||
let cwd = process.cwd();
|
||||
const args = [INSTALL_SCRIPT, `--${runtime}`];
|
||||
|
||||
if (scope === 'global') {
|
||||
args.push('--global', '--config-dir', root);
|
||||
configDir = root;
|
||||
} else {
|
||||
args.push('--local');
|
||||
cwd = root;
|
||||
configDir = runtime === 'cline'
|
||||
? root
|
||||
: path.join(root, LOCAL_DIR_NAME[runtime]);
|
||||
}
|
||||
args.push(...extraArgs);
|
||||
|
||||
const result = spawnSync(process.execPath, args, {
|
||||
cwd,
|
||||
encoding: 'utf8',
|
||||
});
|
||||
|
||||
assert.strictEqual(
|
||||
result.status,
|
||||
0,
|
||||
`installer exited with status ${result.status} for ${runtime} --${scope}` +
|
||||
`\nstdout: ${result.stdout}\nstderr: ${result.stderr}`,
|
||||
);
|
||||
|
||||
const manifestPath = path.join(configDir, MANIFEST_NAME);
|
||||
let manifest = null;
|
||||
if (fs.existsSync(manifestPath)) {
|
||||
manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
|
||||
}
|
||||
return { manifest, configDir, root, stdout: result.stdout, stderr: result.stderr };
|
||||
} catch (err) {
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Walk the manifest's `files` keys and project them onto a per-runtime
|
||||
* "skill set". Each runtime emits skills under one of three keyspaces:
|
||||
* skills/<name>/... (Claude global, Codex, Copilot, Antigravity,
|
||||
* Cursor, Windsurf, Augment, Trae, Qwen,
|
||||
* CodeBuddy)
|
||||
* command/gsd-<name>.md (OpenCode, Kilo)
|
||||
* commands/gsd/<name>.md (Gemini, Claude local — fixed in #2923)
|
||||
*
|
||||
* Returns the unique set of skill basenames recorded in the manifest.
|
||||
*/
|
||||
function manifestSkillSet(manifest) {
|
||||
if (!manifest || !manifest.files) return new Set();
|
||||
const out = new Set();
|
||||
for (const key of Object.keys(manifest.files)) {
|
||||
if (key.startsWith('skills/')) {
|
||||
// Strip both the optional `gsd-` prefix (used by Claude/Codex/etc as
|
||||
// a per-skill subdir name) and any trailing `.md` (Codex flat layout).
|
||||
const seg = key.split('/')[1].replace(/^gsd-/, '').replace(/\.md$/, '');
|
||||
out.add(seg);
|
||||
} else if (key.startsWith('command/')) {
|
||||
const file = key.split('/')[1];
|
||||
// Strip `gsd-` prefix and `.md` suffix. Subdirs flatten with `-`,
|
||||
// but our minimal allowlist is flat (top-level files only) so this
|
||||
// is safe here.
|
||||
const base = file.replace(/^gsd-/, '').replace(/\.md$/, '');
|
||||
out.add(base);
|
||||
} else if (key.startsWith('commands/gsd/')) {
|
||||
// Gemini transforms .md → .toml on emit; Claude local keeps .md.
|
||||
const file = key.split('/')[2];
|
||||
out.add(file.replace(/\.(md|toml)$/, ''));
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function manifestAgentCount(manifest) {
|
||||
if (!manifest || !manifest.files) return 0;
|
||||
return Object.keys(manifest.files).filter((k) => k.startsWith('agents/')).length;
|
||||
}
|
||||
|
||||
function expectedSkillSet() {
|
||||
return new Set([...MINIMAL_SKILL_ALLOWLIST]);
|
||||
}
|
||||
|
||||
describe('install: --minimal honoured for every runtime in --global mode', () => {
|
||||
for (const runtime of SKILL_RUNTIMES) {
|
||||
test(`${runtime} --global --minimal emits exactly the core skill set, zero agents`, () => {
|
||||
const { manifest, root } = runInstall({
|
||||
runtime,
|
||||
scope: 'global',
|
||||
extraArgs: ['--minimal'],
|
||||
});
|
||||
try {
|
||||
assert.ok(manifest, `${runtime} global install must produce a manifest`);
|
||||
assert.strictEqual(manifest.mode, 'minimal',
|
||||
`${runtime} global manifest.mode should be "minimal"`);
|
||||
assert.deepStrictEqual(
|
||||
[...manifestSkillSet(manifest)].sort(),
|
||||
[...expectedSkillSet()].sort(),
|
||||
`${runtime} global should record exactly the MINIMAL allowlist in the manifest`,
|
||||
);
|
||||
assert.strictEqual(manifestAgentCount(manifest), 0,
|
||||
`${runtime} global --minimal should record zero gsd-* agents`);
|
||||
} finally {
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe('install: --minimal honoured for every runtime in --local mode', () => {
|
||||
for (const runtime of SKILL_RUNTIMES) {
|
||||
test(`${runtime} --local --minimal emits exactly the core skill set, zero agents`, () => {
|
||||
const { manifest, root } = runInstall({
|
||||
runtime,
|
||||
scope: 'local',
|
||||
extraArgs: ['--minimal'],
|
||||
});
|
||||
try {
|
||||
assert.ok(manifest, `${runtime} local install must produce a manifest`);
|
||||
assert.strictEqual(manifest.mode, 'minimal',
|
||||
`${runtime} local manifest.mode should be "minimal"`);
|
||||
assert.deepStrictEqual(
|
||||
[...manifestSkillSet(manifest)].sort(),
|
||||
[...expectedSkillSet()].sort(),
|
||||
`${runtime} local should record exactly the MINIMAL allowlist in the manifest (regression guard for #2923)`,
|
||||
);
|
||||
assert.strictEqual(manifestAgentCount(manifest), 0,
|
||||
`${runtime} local --minimal should record zero gsd-* agents`);
|
||||
} finally {
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe('install: Cline --minimal (rules-based runtime — no skills/ dir)', () => {
|
||||
for (const scope of ['global', 'local']) {
|
||||
test(`cline --${scope} --minimal records mode=minimal and zero agents`, () => {
|
||||
const { manifest, configDir, root } = runInstall({
|
||||
runtime: 'cline',
|
||||
scope,
|
||||
extraArgs: ['--minimal'],
|
||||
});
|
||||
try {
|
||||
assert.ok(manifest, `cline ${scope} install must produce a manifest`);
|
||||
assert.strictEqual(manifest.mode, 'minimal');
|
||||
assert.strictEqual(manifestAgentCount(manifest), 0,
|
||||
`cline ${scope} --minimal should record zero gsd-* agents`);
|
||||
|
||||
// .clinerules exists (Cline embeds the workflow there in lieu of
|
||||
// per-skill files).
|
||||
const clinerules = path.join(configDir, '.clinerules');
|
||||
assert.ok(fs.existsSync(clinerules),
|
||||
`cline install should emit .clinerules at ${clinerules}`);
|
||||
} finally {
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe('install: directory-on-disk matches manifest for --minimal', () => {
|
||||
// Cross-check that the manifest isn't lying — actually walk the install
|
||||
// dir and verify the gsd-* surface on disk equals what the manifest claims.
|
||||
// This catches the inverse of #2923: manifest says minimal, but disk has
|
||||
// full surface (or vice versa).
|
||||
for (const runtime of SKILL_RUNTIMES) {
|
||||
for (const scope of ['global', 'local']) {
|
||||
test(`${runtime} --${scope} --minimal: on-disk skill files match manifest`, () => {
|
||||
const { manifest, configDir, root } = runInstall({
|
||||
runtime,
|
||||
scope,
|
||||
extraArgs: ['--minimal'],
|
||||
});
|
||||
try {
|
||||
assert.ok(
|
||||
manifest,
|
||||
`${runtime} ${scope} --minimal: manifest must exist before parity check`,
|
||||
);
|
||||
const onDisk = collectSkillBasenamesOnDisk(configDir);
|
||||
const inManifest = manifestSkillSet(manifest);
|
||||
assert.deepStrictEqual(
|
||||
[...onDisk].sort(),
|
||||
[...inManifest].sort(),
|
||||
`${runtime} ${scope}: on-disk skills must match manifest record`,
|
||||
);
|
||||
// And no gsd-*.md agent file should exist on disk either:
|
||||
const agentsDir = path.join(configDir, 'agents');
|
||||
if (fs.existsSync(agentsDir)) {
|
||||
const gsdAgents = fs.readdirSync(agentsDir).filter(
|
||||
(f) => f.startsWith('gsd-') && f.endsWith('.md'),
|
||||
);
|
||||
assert.deepStrictEqual(gsdAgents, [],
|
||||
`${runtime} ${scope} --minimal should not write gsd-*.md agents on disk`);
|
||||
}
|
||||
} finally {
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Walk the per-runtime install destination and return the set of skill
|
||||
* basenames found on disk. Mirrors manifestSkillSet but reads the
|
||||
* filesystem, not the manifest — used to verify the two agree.
|
||||
*/
|
||||
function collectSkillBasenamesOnDisk(configDir) {
|
||||
const out = new Set();
|
||||
|
||||
// skills/<name>/SKILL.md (or SKILL.toml/.md depending on runtime)
|
||||
const skillsDir = path.join(configDir, 'skills');
|
||||
if (fs.existsSync(skillsDir)) {
|
||||
for (const entry of fs.readdirSync(skillsDir, { withFileTypes: true })) {
|
||||
if (entry.isDirectory() && entry.name.startsWith('gsd-')) {
|
||||
out.add(entry.name.replace(/^gsd-/, ''));
|
||||
} else if (entry.isFile() && entry.name.startsWith('gsd-') && entry.name.endsWith('.md')) {
|
||||
// Codex flat skills/ layout: skills/gsd-<name>.md
|
||||
out.add(entry.name.replace(/^gsd-/, '').replace(/\.md$/, ''));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// command/gsd-<name>.md (OpenCode, Kilo)
|
||||
const commandDir = path.join(configDir, 'command');
|
||||
if (fs.existsSync(commandDir)) {
|
||||
for (const file of fs.readdirSync(commandDir)) {
|
||||
if (file.startsWith('gsd-') && file.endsWith('.md')) {
|
||||
out.add(file.replace(/^gsd-/, '').replace(/\.md$/, ''));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// commands/gsd/<name>.{md,toml} (Claude local emits .md; Gemini emits .toml)
|
||||
const commandsGsdDir = path.join(configDir, 'commands', 'gsd');
|
||||
if (fs.existsSync(commandsGsdDir)) {
|
||||
for (const file of fs.readdirSync(commandsGsdDir)) {
|
||||
if (file.endsWith('.md') || file.endsWith('.toml')) {
|
||||
out.add(file.replace(/\.(md|toml)$/, ''));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
@@ -224,13 +224,30 @@ describe('Source code integration (Kilo)', () => {
|
||||
assert.ok(src.includes("'kilo'"), '--all includes kilo runtime');
|
||||
});
|
||||
|
||||
test('promptRuntime runtimeMap has Kilo as option 10', () => {
|
||||
assert.ok(src.includes("'10': 'kilo'"), 'runtimeMap has 10 -> kilo');
|
||||
test('promptRuntime runtimeMap has Kilo as option 11', () => {
|
||||
// Structural assertion against exported runtimeMap rather than source-grep.
|
||||
process.env.GSD_TEST_MODE = '1';
|
||||
delete require.cache[require.resolve(path.join(__dirname, '..', 'bin', 'install.js'))];
|
||||
const { runtimeMap } = require(path.join(__dirname, '..', 'bin', 'install.js'));
|
||||
assert.strictEqual(runtimeMap['11'], 'kilo', 'runtimeMap has 11 -> kilo');
|
||||
});
|
||||
|
||||
test('prompt text shows Kilo above OpenCode without marketing copy', () => {
|
||||
assert.ok(src.includes('10${reset}) Kilo'), 'prompt lists Kilo as option 10');
|
||||
assert.ok(!src.includes('the #1 AI coding platform on OpenRouter'), 'prompt does not include marketing tagline');
|
||||
// Call the exported prompt builder; assert against rendered text, not raw source.
|
||||
process.env.GSD_TEST_MODE = '1';
|
||||
delete require.cache[require.resolve(path.join(__dirname, '..', 'bin', 'install.js'))];
|
||||
const { buildRuntimePromptText } = require(path.join(__dirname, '..', 'bin', 'install.js'));
|
||||
const promptText = buildRuntimePromptText();
|
||||
// Strip ANSI color codes so assertions don't depend on terminal escapes.
|
||||
// eslint-disable-next-line no-control-regex
|
||||
const plain = promptText.replace(/\x1b\[[0-9;]*m/g, '');
|
||||
assert.ok(/\b11\)\s*Kilo\b/.test(plain), 'prompt lists Kilo as option 11');
|
||||
const kiloIdx = plain.indexOf('11) Kilo');
|
||||
const opencodeIdx = plain.indexOf('OpenCode');
|
||||
assert.ok(kiloIdx > -1 && opencodeIdx > -1 && kiloIdx < opencodeIdx,
|
||||
'Kilo appears above OpenCode in prompt');
|
||||
assert.ok(!plain.includes('the #1 AI coding platform on OpenRouter'),
|
||||
'prompt does not include marketing tagline');
|
||||
});
|
||||
|
||||
test('hooks are skipped for Kilo', () => {
|
||||
|
||||
@@ -3,59 +3,29 @@
|
||||
* Verifies that promptRuntime accepts comma-separated, space-separated,
|
||||
* and single-choice inputs, deduplicates, and falls back to claude.
|
||||
* See issue #1281.
|
||||
*
|
||||
* Per CONTRIBUTING.md "no-source-grep" testing standard, prompt + parser
|
||||
* behavior is asserted via the install module's exported pure functions
|
||||
* (`runtimeMap`, `allRuntimes`, `parseRuntimeInput`, `buildRuntimePromptText`)
|
||||
* instead of regexing bin/install.js source text.
|
||||
*/
|
||||
|
||||
process.env.GSD_TEST_MODE = '1';
|
||||
|
||||
const { test, describe } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
// Read install.js source to extract the runtimeMap and parsing logic
|
||||
const installSrc = fs.readFileSync(
|
||||
path.join(__dirname, '..', 'bin', 'install.js'),
|
||||
'utf8'
|
||||
);
|
||||
const {
|
||||
runtimeMap,
|
||||
allRuntimes,
|
||||
parseRuntimeInput,
|
||||
buildRuntimePromptText,
|
||||
} = require('../bin/install.js');
|
||||
|
||||
// Extract runtimeMap from source for validation
|
||||
const runtimeMap = {
|
||||
'1': 'claude',
|
||||
'2': 'antigravity',
|
||||
'3': 'augment',
|
||||
'4': 'cline',
|
||||
'5': 'codebuddy',
|
||||
'6': 'codex',
|
||||
'7': 'copilot',
|
||||
'8': 'cursor',
|
||||
'9': 'gemini',
|
||||
'10': 'kilo',
|
||||
'11': 'opencode',
|
||||
'12': 'qwen',
|
||||
'13': 'trae',
|
||||
'14': 'windsurf'
|
||||
};
|
||||
const allRuntimes = ['claude', 'antigravity', 'augment', 'cline', 'codebuddy', 'codex', 'copilot', 'cursor', 'gemini', 'kilo', 'opencode', 'qwen', 'trae', 'windsurf'];
|
||||
|
||||
/**
|
||||
* Simulate the parsing logic from promptRuntime without requiring readline.
|
||||
* This mirrors the exact logic in the rl.question callback.
|
||||
*/
|
||||
function parseRuntimeInput(input) {
|
||||
input = input.trim() || '1';
|
||||
|
||||
if (input === '15') {
|
||||
return allRuntimes;
|
||||
}
|
||||
|
||||
const choices = input.split(/[\s,]+/).filter(Boolean);
|
||||
const selected = [];
|
||||
for (const c of choices) {
|
||||
const runtime = runtimeMap[c];
|
||||
if (runtime && !selected.includes(runtime)) {
|
||||
selected.push(runtime);
|
||||
}
|
||||
}
|
||||
|
||||
return selected.length > 0 ? selected : ['claude'];
|
||||
// Strip ANSI color codes for human-readable assertions on prompt text.
|
||||
function stripAnsi(s) {
|
||||
// eslint-disable-next-line no-control-regex
|
||||
return s.replace(/\x1b\[[0-9;]*m/g, '');
|
||||
}
|
||||
|
||||
describe('multi-runtime selection parsing', () => {
|
||||
@@ -78,7 +48,7 @@ describe('multi-runtime selection parsing', () => {
|
||||
|
||||
test('space-separated choices return multiple runtimes', () => {
|
||||
assert.deepStrictEqual(parseRuntimeInput('1 7 9'), ['claude', 'copilot', 'gemini']);
|
||||
assert.deepStrictEqual(parseRuntimeInput('8 10'), ['cursor', 'kilo']);
|
||||
assert.deepStrictEqual(parseRuntimeInput('8 11'), ['cursor', 'kilo']);
|
||||
});
|
||||
|
||||
test('mixed comma and space separators work', () => {
|
||||
@@ -86,24 +56,43 @@ describe('multi-runtime selection parsing', () => {
|
||||
assert.deepStrictEqual(parseRuntimeInput('2 , 8'), ['antigravity', 'cursor']);
|
||||
});
|
||||
|
||||
test('single choice for hermes', () => {
|
||||
assert.deepStrictEqual(parseRuntimeInput('10'), ['hermes']);
|
||||
});
|
||||
|
||||
test('single choice for kilo', () => {
|
||||
assert.deepStrictEqual(parseRuntimeInput('11'), ['kilo']);
|
||||
});
|
||||
|
||||
test('single choice for opencode', () => {
|
||||
assert.deepStrictEqual(parseRuntimeInput('11'), ['opencode']);
|
||||
assert.deepStrictEqual(parseRuntimeInput('12'), ['opencode']);
|
||||
});
|
||||
|
||||
test('single choice for qwen', () => {
|
||||
assert.deepStrictEqual(parseRuntimeInput('12'), ['qwen']);
|
||||
assert.deepStrictEqual(parseRuntimeInput('13'), ['qwen']);
|
||||
});
|
||||
|
||||
test('single choice for trae', () => {
|
||||
assert.deepStrictEqual(parseRuntimeInput('13'), ['trae']);
|
||||
assert.deepStrictEqual(parseRuntimeInput('14'), ['trae']);
|
||||
});
|
||||
|
||||
test('single choice for windsurf', () => {
|
||||
assert.deepStrictEqual(parseRuntimeInput('14'), ['windsurf']);
|
||||
assert.deepStrictEqual(parseRuntimeInput('15'), ['windsurf']);
|
||||
});
|
||||
|
||||
test('choice 15 returns all runtimes', () => {
|
||||
assert.deepStrictEqual(parseRuntimeInput('15'), allRuntimes);
|
||||
test('choice 16 returns all runtimes', () => {
|
||||
assert.deepStrictEqual(parseRuntimeInput('16'), allRuntimes);
|
||||
});
|
||||
|
||||
test('choice 16 returns all runtimes when mixed with separators or other tokens', () => {
|
||||
// CR feedback: tokenized inputs that include 16 (e.g. trailing comma, or
|
||||
// alongside other choices) must still expand to all-runtimes — previously
|
||||
// only the bare "16" matched, so "16," or "16 1" silently installed a
|
||||
// subset.
|
||||
assert.deepStrictEqual(parseRuntimeInput('16,'), allRuntimes);
|
||||
assert.deepStrictEqual(parseRuntimeInput('16 1'), allRuntimes);
|
||||
assert.deepStrictEqual(parseRuntimeInput('1,16'), allRuntimes);
|
||||
assert.deepStrictEqual(parseRuntimeInput(' 16 '), allRuntimes);
|
||||
});
|
||||
|
||||
test('empty input defaults to claude', () => {
|
||||
@@ -112,13 +101,13 @@ describe('multi-runtime selection parsing', () => {
|
||||
});
|
||||
|
||||
test('invalid choices are ignored, falls back to claude if all invalid', () => {
|
||||
assert.deepStrictEqual(parseRuntimeInput('16'), ['claude']);
|
||||
assert.deepStrictEqual(parseRuntimeInput('17'), ['claude']);
|
||||
assert.deepStrictEqual(parseRuntimeInput('0'), ['claude']);
|
||||
assert.deepStrictEqual(parseRuntimeInput('abc'), ['claude']);
|
||||
});
|
||||
|
||||
test('invalid choices mixed with valid are filtered out', () => {
|
||||
assert.deepStrictEqual(parseRuntimeInput('1,16,7'), ['claude', 'copilot']);
|
||||
assert.deepStrictEqual(parseRuntimeInput('1,17,7'), ['claude', 'copilot']);
|
||||
assert.deepStrictEqual(parseRuntimeInput('abc 3 xyz'), ['augment']);
|
||||
});
|
||||
|
||||
@@ -129,68 +118,79 @@ describe('multi-runtime selection parsing', () => {
|
||||
|
||||
test('preserves selection order', () => {
|
||||
assert.deepStrictEqual(parseRuntimeInput('9,1,7'), ['gemini', 'claude', 'copilot']);
|
||||
assert.deepStrictEqual(parseRuntimeInput('10,2,8'), ['kilo', 'antigravity', 'cursor']);
|
||||
assert.deepStrictEqual(parseRuntimeInput('11,2,8'), ['kilo', 'antigravity', 'cursor']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('install.js source contains multi-select support', () => {
|
||||
test('runtimeMap is defined with all 14 runtimes', () => {
|
||||
for (const [key, name] of Object.entries(runtimeMap)) {
|
||||
assert.ok(
|
||||
installSrc.includes(`'${key}': '${name}'`),
|
||||
`runtimeMap has ${key} -> ${name}`
|
||||
);
|
||||
describe('install.js exports multi-select runtime metadata', () => {
|
||||
const expectedRuntimeMap = {
|
||||
'1': 'claude',
|
||||
'2': 'antigravity',
|
||||
'3': 'augment',
|
||||
'4': 'cline',
|
||||
'5': 'codebuddy',
|
||||
'6': 'codex',
|
||||
'7': 'copilot',
|
||||
'8': 'cursor',
|
||||
'9': 'gemini',
|
||||
'10': 'hermes',
|
||||
'11': 'kilo',
|
||||
'12': 'opencode',
|
||||
'13': 'qwen',
|
||||
'14': 'trae',
|
||||
'15': 'windsurf',
|
||||
};
|
||||
const expectedRuntimes = [
|
||||
'claude', 'antigravity', 'augment', 'cline', 'codebuddy', 'codex',
|
||||
'copilot', 'cursor', 'gemini', 'hermes', 'kilo', 'opencode', 'qwen',
|
||||
'trae', 'windsurf',
|
||||
];
|
||||
|
||||
test('runtimeMap exports every option key bound to the right runtime', () => {
|
||||
assert.deepStrictEqual(runtimeMap, expectedRuntimeMap,
|
||||
'exported runtimeMap matches the canonical option list');
|
||||
});
|
||||
|
||||
test('allRuntimes contains every runtime exactly once', () => {
|
||||
assert.strictEqual(allRuntimes.length, expectedRuntimes.length);
|
||||
for (const rt of expectedRuntimes) {
|
||||
assert.ok(allRuntimes.includes(rt), `allRuntimes contains ${rt}`);
|
||||
}
|
||||
assert.strictEqual(new Set(allRuntimes).size, allRuntimes.length,
|
||||
'allRuntimes has no duplicates');
|
||||
});
|
||||
|
||||
test('allRuntimes array contains all runtimes', () => {
|
||||
const match = installSrc.match(/const allRuntimes = \[([^\]]+)\]/);
|
||||
assert.ok(match, 'allRuntimes array found');
|
||||
for (const rt of allRuntimes) {
|
||||
assert.ok(match[1].includes(`'${rt}'`), `allRuntimes includes ${rt}`);
|
||||
}
|
||||
test('"All" shortcut (option 16) selects every runtime', () => {
|
||||
assert.deepStrictEqual(parseRuntimeInput('16'), allRuntimes);
|
||||
});
|
||||
|
||||
test('all shortcut uses option 15', () => {
|
||||
assert.ok(
|
||||
installSrc.includes("if (input === '15')"),
|
||||
'all shortcut uses option 15'
|
||||
);
|
||||
});
|
||||
|
||||
test('prompt lists Qwen Code as option 12, Trae as option 13 and All as option 15', () => {
|
||||
assert.ok(
|
||||
installSrc.includes('12${reset}) Qwen Code'),
|
||||
'prompt lists Qwen Code as option 12'
|
||||
);
|
||||
assert.ok(
|
||||
installSrc.includes('13${reset}) Trae'),
|
||||
'prompt lists Trae as option 13'
|
||||
);
|
||||
assert.ok(
|
||||
installSrc.includes('15${reset}) All'),
|
||||
'prompt lists All as option 15'
|
||||
);
|
||||
test('prompt lists Hermes Agent (10), Qwen Code (13), Trae (14), and All (16)', () => {
|
||||
const prompt = stripAnsi(buildRuntimePromptText());
|
||||
assert.ok(/\b10\)\s*Hermes Agent\b/.test(prompt),
|
||||
'prompt lists Hermes Agent as option 10');
|
||||
assert.ok(/\b13\)\s*Qwen Code\b/.test(prompt),
|
||||
'prompt lists Qwen Code as option 13');
|
||||
assert.ok(/\b14\)\s*Trae\b/.test(prompt),
|
||||
'prompt lists Trae as option 14');
|
||||
assert.ok(/\b16\)\s*All\b/.test(prompt),
|
||||
'prompt lists All as option 16');
|
||||
});
|
||||
|
||||
test('prompt text shows multi-select hint', () => {
|
||||
assert.ok(
|
||||
installSrc.includes('Select multiple'),
|
||||
'prompt includes multi-select instructions'
|
||||
);
|
||||
const prompt = stripAnsi(buildRuntimePromptText());
|
||||
assert.ok(/Select multiple/i.test(prompt),
|
||||
'prompt includes multi-select instructions');
|
||||
});
|
||||
|
||||
test('parsing uses split with comma and space regex', () => {
|
||||
assert.ok(
|
||||
installSrc.includes("split(/[\\s,]+/)"),
|
||||
'input is split on commas and whitespace'
|
||||
);
|
||||
});
|
||||
|
||||
test('deduplication check exists', () => {
|
||||
assert.ok(
|
||||
installSrc.includes('!selected.includes(runtime)'),
|
||||
'deduplication guard exists'
|
||||
test('parser splits on commas and whitespace and deduplicates', () => {
|
||||
// Behavioral assertion: same set of choices in different separators
|
||||
// produces the same selection, and duplicates collapse.
|
||||
assert.deepStrictEqual(
|
||||
parseRuntimeInput('1,7,9'),
|
||||
parseRuntimeInput('1 7 9'),
|
||||
'comma- and space-separated input yield identical selections'
|
||||
);
|
||||
assert.deepStrictEqual(parseRuntimeInput('1,1,7,7'), ['claude', 'copilot'],
|
||||
'duplicates collapsed in order');
|
||||
});
|
||||
});
|
||||
|
||||
70
tests/no-unconditional-win32-skip.test.cjs
Normal file
70
tests/no-unconditional-win32-skip.test.cjs
Normal file
@@ -0,0 +1,70 @@
|
||||
'use strict';
|
||||
|
||||
process.env.GSD_TEST_MODE = '1';
|
||||
|
||||
/**
|
||||
* Behavior-based regression guard for #2962-class bugs.
|
||||
*
|
||||
* "Nothing for Windows should be deferred — if it wasn't in, it was missed
|
||||
* not deferred." (maintainer guidance, 2026-05-01.)
|
||||
*
|
||||
* Specifically guards against trySelfLinkGsdSdk silently no-op'ing on
|
||||
* Windows. Rather than regex-scanning bin/install.js source (which would
|
||||
* fail on harmless refactors and conflicts with the repo's no-source-grep
|
||||
* testing standard), this test exercises the function under a simulated
|
||||
* `process.platform === 'win32'` and asserts shim files actually land on
|
||||
* disk — i.e., the Windows branch dispatches, doesn't early-return null.
|
||||
*/
|
||||
|
||||
const { test, describe, before, after } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const cp = require('node:child_process');
|
||||
|
||||
const ROOT = path.join(__dirname, '..');
|
||||
const installModule = require(path.join(ROOT, 'bin', 'install.js'));
|
||||
|
||||
describe('Windows parity guard for trySelfLinkGsdSdk (#2962)', () => {
|
||||
let tmpDir;
|
||||
let origPlatform;
|
||||
let origExecSync;
|
||||
|
||||
before(() => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-win32-guard-'));
|
||||
origPlatform = process.platform;
|
||||
origExecSync = cp.execSync;
|
||||
// Override process.platform to simulate Windows. process.platform is a
|
||||
// configurable property in Node — Object.defineProperty can swap it.
|
||||
Object.defineProperty(process, 'platform', { value: 'win32', configurable: true });
|
||||
cp.execSync = (cmd) => {
|
||||
if (typeof cmd === 'string' && cmd.trim() === 'npm prefix -g') {
|
||||
return tmpDir + '\n';
|
||||
}
|
||||
throw new Error(`unexpected execSync: ${cmd}`);
|
||||
};
|
||||
});
|
||||
|
||||
after(() => {
|
||||
Object.defineProperty(process, 'platform', { value: origPlatform, configurable: true });
|
||||
cp.execSync = origExecSync;
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test('trySelfLinkGsdSdk dispatches to the Windows handler and writes shims (does NOT silently return null)', () => {
|
||||
const shimSrc = path.join(ROOT, 'bin', 'gsd-sdk.js');
|
||||
const result = installModule.trySelfLinkGsdSdk(shimSrc);
|
||||
|
||||
assert.notEqual(
|
||||
result,
|
||||
null,
|
||||
'trySelfLinkGsdSdk must not silently return null on Windows; ' +
|
||||
'a no-op skip is a missed-parity regression (see #2962, #2775).',
|
||||
);
|
||||
assert.ok(
|
||||
fs.existsSync(path.join(tmpDir, 'gsd-sdk.cmd')),
|
||||
'Windows dispatch must materialize gsd-sdk.cmd in the npm global bin',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,39 +1,293 @@
|
||||
/**
|
||||
* Quick task branching tests
|
||||
*
|
||||
* Validates that /gsd-quick exposes branch_name from init and that the
|
||||
* workflow checks out a dedicated quick-task branch when configured.
|
||||
* Validates that /gsd-quick exposes branch_name from init and that the Step 2.5
|
||||
* "Handle quick-task branching" block:
|
||||
* 1. Reuses an existing branch as-is (no rebase / no reset).
|
||||
* 2. When the branch does not exist, creates it from origin/HEAD's default
|
||||
* branch — never off the previous task's HEAD (#2916).
|
||||
*
|
||||
* Assertions are behavioral (run the bash block in a fixture git repo and
|
||||
* inspect git state) and structural (parse the markdown for the step's bash
|
||||
* block). No `.includes()` / regex grepping of raw markdown content — see
|
||||
* CONTRIBUTING.md "no-source-grep" testing standard.
|
||||
*/
|
||||
|
||||
const { test, describe } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { execFileSync } = require('node:child_process');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
|
||||
const QUICK_PATH = path.join(__dirname, '..', 'get-shit-done', 'workflows', 'quick.md');
|
||||
|
||||
const GIT_ENV = Object.freeze({
|
||||
...process.env,
|
||||
GIT_AUTHOR_NAME: 'Test',
|
||||
GIT_AUTHOR_EMAIL: 'test@test.com',
|
||||
GIT_COMMITTER_NAME: 'Test',
|
||||
GIT_COMMITTER_EMAIL: 'test@test.com',
|
||||
});
|
||||
|
||||
function git(cwd, ...args) {
|
||||
return execFileSync('git', args, {
|
||||
cwd,
|
||||
env: GIT_ENV,
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
})
|
||||
.toString()
|
||||
.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Structurally extract the bash code under the "Step 2.5: Handle quick-task
|
||||
* branching" heading. We:
|
||||
* 1. Locate the Step 2.5 heading.
|
||||
* 2. Find the next horizontal rule (`---`) that ends the section.
|
||||
* 3. Concatenate every fenced ```bash block in between.
|
||||
*
|
||||
* No `.includes()` content checks — fenced code blocks are parsed the same way
|
||||
* a markdown parser would.
|
||||
*/
|
||||
function extractStep25Bash() {
|
||||
const content = fs.readFileSync(QUICK_PATH, 'utf-8');
|
||||
const lines = content.split(/\r?\n/);
|
||||
|
||||
let start = -1;
|
||||
let end = -1;
|
||||
for (let i = 0; i < lines.length; i += 1) {
|
||||
if (start === -1 && /^\*\*Step 2\.5:\s*Handle quick-task branching\*\*\s*$/.test(lines[i])) {
|
||||
start = i + 1;
|
||||
} else if (start !== -1 && /^---\s*$/.test(lines[i])) {
|
||||
end = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (start === -1) {
|
||||
throw new Error('quick.md does not contain a "Step 2.5: Handle quick-task branching" section');
|
||||
}
|
||||
if (end === -1) end = lines.length;
|
||||
|
||||
const bashBlocks = [];
|
||||
let inBash = false;
|
||||
let buffer = [];
|
||||
for (let i = start; i < end; i += 1) {
|
||||
const line = lines[i];
|
||||
if (!inBash && /^```bash\s*$/.test(line)) {
|
||||
inBash = true;
|
||||
buffer = [];
|
||||
continue;
|
||||
}
|
||||
if (inBash && /^```\s*$/.test(line)) {
|
||||
bashBlocks.push(buffer.join('\n'));
|
||||
inBash = false;
|
||||
continue;
|
||||
}
|
||||
if (inBash) buffer.push(line);
|
||||
}
|
||||
if (bashBlocks.length === 0) {
|
||||
throw new Error('Step 2.5 contains no ```bash code blocks to execute');
|
||||
}
|
||||
return bashBlocks.join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a fixture: a bare "origin" repo with a non-`main` default branch
|
||||
* (`trunk`) so the test fails if the workflow silently falls back to "main"
|
||||
* instead of consulting `origin/HEAD`. The clone has `origin/HEAD` pointed at
|
||||
* `trunk` and a checked-out previous-task branch carrying its own unmerged
|
||||
* commit.
|
||||
*
|
||||
* Using `trunk` here locks in the symbolic-ref code path: if the
|
||||
* implementation skips `git symbolic-ref refs/remotes/origin/HEAD` and just
|
||||
* defaults to `main`, every assertion below collapses (#2921 CR nitpick).
|
||||
*/
|
||||
function setupFixture(defaultBranch = 'trunk') {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-quick-branching-'));
|
||||
const seedPath = path.join(root, 'seed');
|
||||
const originPath = path.join(root, 'origin.git');
|
||||
const clonePath = path.join(root, 'clone');
|
||||
|
||||
fs.mkdirSync(seedPath);
|
||||
git(seedPath, 'init', '-b', defaultBranch);
|
||||
git(seedPath, 'config', 'commit.gpgsign', 'false');
|
||||
fs.writeFileSync(path.join(seedPath, 'README.md'), '# seed\n');
|
||||
git(seedPath, 'add', 'README.md');
|
||||
git(seedPath, 'commit', '-m', 'initial');
|
||||
|
||||
git(root, 'clone', '--bare', seedPath, originPath);
|
||||
git(originPath, 'symbolic-ref', 'HEAD', `refs/heads/${defaultBranch}`);
|
||||
|
||||
git(root, 'clone', originPath, clonePath);
|
||||
git(clonePath, 'config', 'commit.gpgsign', 'false');
|
||||
git(clonePath, 'config', 'user.email', 'test@test.com');
|
||||
git(clonePath, 'config', 'user.name', 'Test');
|
||||
|
||||
// Simulate finishing a previous quick task: branch off the default branch,
|
||||
// add a commit, and stay on it (this is the failure scenario from #2916).
|
||||
git(clonePath, 'checkout', '-b', 'quick/01-prev-task');
|
||||
fs.writeFileSync(path.join(clonePath, 'prev.txt'), 'prev work\n');
|
||||
git(clonePath, 'add', 'prev.txt');
|
||||
git(clonePath, 'commit', '-m', 'prev quick task work');
|
||||
|
||||
return { root, clonePath, defaultBranch };
|
||||
}
|
||||
|
||||
function runStep(bash, cwd, branchName) {
|
||||
// Write the script to a sibling tempdir, not inside the repo — putting it in
|
||||
// `cwd` would create an untracked file that trips `git status --porcelain`
|
||||
// and steers the step into the dirty-tree path.
|
||||
const scriptDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-quick-step-'));
|
||||
const scriptPath = path.join(scriptDir, 'step25.sh');
|
||||
const script = `#!/usr/bin/env bash\nset -uo pipefail\nbranch_name="${branchName}"\n${bash}\n`;
|
||||
fs.writeFileSync(scriptPath, script, { mode: 0o755 });
|
||||
try {
|
||||
return execFileSync('bash', [scriptPath], {
|
||||
cwd,
|
||||
env: GIT_ENV,
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
}).toString();
|
||||
} finally {
|
||||
fs.rmSync(scriptDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
describe('quick workflow: branching support', () => {
|
||||
const workflowPath = path.join(__dirname, '..', 'get-shit-done', 'workflows', 'quick.md');
|
||||
let content;
|
||||
|
||||
test('workflow file exists', () => {
|
||||
assert.ok(fs.existsSync(workflowPath), 'workflows/quick.md should exist');
|
||||
assert.ok(fs.existsSync(QUICK_PATH), 'workflows/quick.md should exist');
|
||||
});
|
||||
|
||||
test('init parse list includes branch_name', () => {
|
||||
content = fs.readFileSync(workflowPath, 'utf-8');
|
||||
assert.ok(content.includes('branch_name'), 'quick workflow should parse branch_name from init JSON');
|
||||
// Structural: the workflow's init step (Step 2) must declare branch_name as
|
||||
// a parseable field of the init JSON. Restrict the scan to the init step's
|
||||
// section only — a global walk over every bash fence could be fooled by an
|
||||
// unrelated step that happens to mention branch_name (#2921 CR).
|
||||
const content = fs.readFileSync(QUICK_PATH, 'utf-8');
|
||||
const lines = content.split(/\r?\n/);
|
||||
|
||||
// Locate the "Step 2: Initialize" heading and the next "Step N" heading
|
||||
// that ends the section. We match the markdown bold-step convention used
|
||||
// throughout quick.md: `**Step N[.M]: Title**`.
|
||||
let start = -1;
|
||||
let end = -1;
|
||||
for (let i = 0; i < lines.length; i += 1) {
|
||||
if (start === -1 && /^\*\*Step 2:\s*Initialize\*\*\s*$/.test(lines[i])) {
|
||||
start = i + 1;
|
||||
} else if (start !== -1 && /^\*\*Step \d+(?:\.\d+)?:\s/.test(lines[i])) {
|
||||
end = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
assert.notEqual(start, -1, 'quick.md should contain a "Step 2: Initialize" section');
|
||||
if (end === -1) end = lines.length;
|
||||
|
||||
// Within that section, look for the branch_name token inside fenced bash
|
||||
// blocks AND in the surrounding markdown prose that documents the JSON
|
||||
// fields. Both are part of the init contract.
|
||||
let found = false;
|
||||
for (let i = start; i < end; i += 1) {
|
||||
if (/\bbranch_name\b/.test(lines[i])) { found = true; break; }
|
||||
}
|
||||
assert.ok(
|
||||
found,
|
||||
'Step 2 (Initialize) of quick workflow should expose branch_name as part of the init contract'
|
||||
);
|
||||
});
|
||||
|
||||
test('workflow includes quick-task branching step', () => {
|
||||
content = fs.readFileSync(workflowPath, 'utf-8');
|
||||
assert.ok(content.includes('Step 2.5: Handle quick-task branching'));
|
||||
assert.ok(content.includes('git checkout -b "$branch_name" 2>/dev/null || git checkout "$branch_name"'));
|
||||
test('Step 2.5 section is present and contains executable bash', () => {
|
||||
const bash = extractStep25Bash();
|
||||
assert.ok(bash.length > 0, 'Step 2.5 should contain at least one bash block');
|
||||
});
|
||||
|
||||
test('branching step runs before task directory creation', () => {
|
||||
content = fs.readFileSync(workflowPath, 'utf-8');
|
||||
test('Step 2.5 runs before Step 3 (task directory creation)', () => {
|
||||
const content = fs.readFileSync(QUICK_PATH, 'utf-8');
|
||||
const branchingIndex = content.indexOf('Step 2.5: Handle quick-task branching');
|
||||
const createDirIndex = content.indexOf('Step 3: Create task directory');
|
||||
assert.ok(branchingIndex !== -1 && createDirIndex !== -1, 'workflow should contain both branching and directory steps');
|
||||
assert.ok(branchingIndex < createDirIndex, 'branching should happen before quick task directories and commits');
|
||||
assert.ok(
|
||||
branchingIndex !== -1 && createDirIndex !== -1,
|
||||
'workflow should contain both branching and directory steps'
|
||||
);
|
||||
assert.ok(
|
||||
branchingIndex < createDirIndex,
|
||||
'branching should happen before quick task directories and commits'
|
||||
);
|
||||
});
|
||||
|
||||
// Run against both `main` (the conventional default) and `trunk` (a non-
|
||||
// main default that exercises the symbolic-ref code path). Keeping both
|
||||
// restores main coverage that was removed when the fixture switched
|
||||
// wholesale to trunk in 80f14cac.
|
||||
for (const defaultBranch of ['main', 'trunk']) {
|
||||
test(`new quick-task branch branches off origin/${defaultBranch} (#2916)`, () => {
|
||||
const bash = extractStep25Bash();
|
||||
const { root, clonePath } = setupFixture(defaultBranch);
|
||||
|
||||
try {
|
||||
const upstream = `origin/${defaultBranch}`;
|
||||
|
||||
assert.equal(
|
||||
git(clonePath, 'rev-parse', '--abbrev-ref', 'HEAD'),
|
||||
'quick/01-prev-task'
|
||||
);
|
||||
assert.equal(
|
||||
git(clonePath, 'rev-list', '--count', `${upstream}..HEAD`),
|
||||
'1',
|
||||
`fixture should be 1 commit ahead of ${upstream}`
|
||||
);
|
||||
|
||||
runStep(bash, clonePath, 'quick/02-new-task');
|
||||
|
||||
assert.equal(
|
||||
git(clonePath, 'rev-parse', '--abbrev-ref', 'HEAD'),
|
||||
'quick/02-new-task',
|
||||
'Step 2.5 should switch to the new quick-task branch'
|
||||
);
|
||||
|
||||
const inherited = git(clonePath, 'rev-list', '--count', `${upstream}..HEAD`);
|
||||
assert.equal(
|
||||
inherited,
|
||||
'0',
|
||||
`new quick-task branch must branch off ${upstream}, but inherited ${inherited} commit(s) from previous-task HEAD`
|
||||
);
|
||||
assert.equal(
|
||||
git(clonePath, 'rev-parse', 'HEAD'),
|
||||
git(clonePath, 'rev-parse', upstream),
|
||||
`new quick-task branch tip must equal ${upstream} tip`
|
||||
);
|
||||
} finally {
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
test('Step 2.5 reuses an existing quick-task branch instead of forking again', () => {
|
||||
const bash = extractStep25Bash();
|
||||
const { root, clonePath } = setupFixture();
|
||||
|
||||
try {
|
||||
// Pre-create the target branch off origin/trunk with its own commit, then
|
||||
// walk away to a different branch — the step must switch back to it.
|
||||
git(clonePath, 'checkout', '-B', 'quick/02-new-task', 'origin/trunk');
|
||||
fs.writeFileSync(path.join(clonePath, 'task02.txt'), 'task 2 work\n');
|
||||
git(clonePath, 'add', 'task02.txt');
|
||||
git(clonePath, 'commit', '-m', 'task 02 wip');
|
||||
const task02Sha = git(clonePath, 'rev-parse', 'HEAD');
|
||||
git(clonePath, 'checkout', 'quick/01-prev-task');
|
||||
|
||||
runStep(bash, clonePath, 'quick/02-new-task');
|
||||
|
||||
assert.equal(
|
||||
git(clonePath, 'rev-parse', '--abbrev-ref', 'HEAD'),
|
||||
'quick/02-new-task'
|
||||
);
|
||||
assert.equal(
|
||||
git(clonePath, 'rev-parse', 'HEAD'),
|
||||
task02Sha,
|
||||
'existing-branch tip must be preserved (no rebase/reset)'
|
||||
);
|
||||
} finally {
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -12,6 +12,10 @@
|
||||
*/
|
||||
'use strict';
|
||||
|
||||
// allow-test-rule: pending-migration-to-typed-ir [#2974]
|
||||
// Tracked in #2974 for migration to typed-IR assertions per CONTRIBUTING.md
|
||||
// "Prohibited: Raw Text Matching on Test Outputs". Do not copy this pattern.
|
||||
|
||||
const { describe, test, before, after } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const { execFileSync, execSync } = require('child_process');
|
||||
|
||||
@@ -265,11 +265,15 @@ describe('#2529 config merge safety', () => {
|
||||
// ─── /gsd-settings mentions /gsd-settings-integrations ──────────────────────
|
||||
|
||||
describe('#2529 /gsd-settings mentions new command', () => {
|
||||
test('settings workflow mentions /gsd-settings-integrations in its confirmation output', () => {
|
||||
test('settings workflow mentions canonical /gsd-config --integrations', () => {
|
||||
const src = fs.readFileSync(SETTINGS_WORKFLOW_PATH, 'utf-8');
|
||||
assert.ok(
|
||||
src.includes('/gsd-settings-integrations'),
|
||||
'settings.md must mention /gsd-settings-integrations as a follow-up'
|
||||
src.includes('gsd-config --integrations'),
|
||||
'settings.md must mention /gsd-config --integrations'
|
||||
);
|
||||
assert.ok(
|
||||
!src.includes('/gsd-settings-integrations'),
|
||||
'settings.md must not mention the legacy /gsd-settings-integrations variant'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -199,16 +199,17 @@ describe('config-get context_window (#1472)', () => {
|
||||
assert.strictEqual(output, 1000000);
|
||||
});
|
||||
|
||||
test('config-get context_window errors when key is absent', () => {
|
||||
test('config-get context_window returns schema default (200000) when key is absent', () => {
|
||||
// Bug #2943: context_window has a schema-level default of 200000.
|
||||
// config-get must return it (exit 0) rather than "Key not found" (exit 1).
|
||||
const configPath = path.join(tmpDir, '.planning', 'config.json');
|
||||
fs.writeFileSync(configPath, JSON.stringify({}, null, 2));
|
||||
|
||||
const result = runGsdTools('config-get context_window', tmpDir);
|
||||
assert.strictEqual(result.success, false);
|
||||
assert.ok(
|
||||
result.error.includes('Key not found'),
|
||||
`Expected "Key not found" in error: ${result.error}`
|
||||
);
|
||||
assert.ok(result.success, `Expected success but got: ${result.error}`);
|
||||
|
||||
const output = JSON.parse(result.output);
|
||||
assert.strictEqual(output, 200000, 'schema default for context_window should be 200000');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -226,19 +226,20 @@ describe('detect-custom-files — update workflow backup detection (#1997)', ()
|
||||
);
|
||||
});
|
||||
|
||||
// #2505 — installer does NOT wipe skills/ or command/; scanning them produces
|
||||
// false-positive "custom file" reports for every skill the user has installed
|
||||
// from other packages.
|
||||
test('does not scan skills/ directory (installer does not wipe it)', () => {
|
||||
// After v1.39.0 skill consolidation (#2790), the installer wipes skills/ on
|
||||
// update. skills/ is now a GSD-managed directory and must be scanned so that
|
||||
// user-added skill directories are backed up before the wipe (#2942).
|
||||
// GSD-owned skills (tracked in manifest) must NOT be flagged as custom.
|
||||
test('scans skills/ directory and detects user-added skills not in manifest (#2942)', () => {
|
||||
writeManifest(tmpDir, {
|
||||
'get-shit-done/workflows/execute-phase.md': '# Execute Phase\n',
|
||||
'skills/gsd-planner/SKILL.md': '# GSD Planner\n',
|
||||
});
|
||||
|
||||
// Simulate user having third-party skills installed — none in manifest
|
||||
const skillsDir = path.join(tmpDir, 'skills');
|
||||
fs.mkdirSync(skillsDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(skillsDir, 'my-custom-skill.md'), '# My Skill\n');
|
||||
fs.writeFileSync(path.join(skillsDir, 'another-plugin-skill.md'), '# Another\n');
|
||||
// Simulate user having a custom skill installed — NOT in manifest
|
||||
const customSkillDir = path.join(tmpDir, 'skills', 'my-custom-skill');
|
||||
fs.mkdirSync(customSkillDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(customSkillDir, 'SKILL.md'), '# My Custom Skill\n');
|
||||
|
||||
const result = runGsdTools(
|
||||
['detect-custom-files', '--config-dir', tmpDir],
|
||||
@@ -248,10 +249,17 @@ describe('detect-custom-files — update workflow backup detection (#1997)', ()
|
||||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||
|
||||
const json = JSON.parse(result.output);
|
||||
const skillFiles = json.custom_files.filter(f => f.startsWith('skills/'));
|
||||
assert.strictEqual(
|
||||
skillFiles.length, 0,
|
||||
`skills/ should not be scanned; got false positives: ${JSON.stringify(skillFiles)}`
|
||||
|
||||
// The user's custom skill should be detected
|
||||
assert.ok(
|
||||
json.custom_files.includes('skills/my-custom-skill/SKILL.md'),
|
||||
`custom skill should be detected; got: ${JSON.stringify(json.custom_files)}`
|
||||
);
|
||||
|
||||
// The GSD-owned skill (in manifest) should NOT be flagged as custom
|
||||
assert.ok(
|
||||
!json.custom_files.includes('skills/gsd-planner/SKILL.md'),
|
||||
`GSD-owned skill should not be flagged as custom; got: ${JSON.stringify(json.custom_files)}`
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user