mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-05 16:34:00 +01:00
feat(ship): Step 9 fix-range re-review with a mandatory final full pass (#3339)
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
This commit is contained in:
1 parent
e66c43c572
commit
4e54d98c00
7 files changed
+311
-63
No files matched your search
@@ -1,5 +1,23 @@
|
||||
# Changelog
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
**/ship's Step 9 can re-review just the fixes after a fixing pass, and a narrowed pass can never be the review that ships (#3339).** Before, every fix cycle reran every specialist and the Red Team over the whole branch. On a large branch each full pass tends to find something new in code earlier passes already read, so the loop often ran to the three-cycle stop without converging. Now the pass after a fixing pass can review only what changed since that pass. One full pass still has to come last: a fix-range record is never CURRENT, and Step 11.5 can't bind it. Garry approved this on 2026-10-24 ("only with a mandatory final full pass").
|
||||
|
||||
### Added
|
||||
- **`bin/gstack-review-log --start review --fix-range`** prints the start token, then `FIX_BASE=<sha>`. The base comes from the review log, never from the caller or the working tree, so `gstack-wtree`'s fingerprint is untouched. It is the commit of the latest pass of the same skill. The start refuses (exit 1, "run a full pass") in each of these cases: there is no earlier pass, that pass didn't complete (restoring coverage always takes a full pass), it made no fixes, its content changed while it ran, it ran on another branch, a Step 11 review followed it, or its commit no longer resolves or is no longer an ancestor of HEAD (after a rebase).
|
||||
- **`lib/review-evidence.ts`**: the start token carries `scope: "fix-range"` and `fix_base`. The logged row takes `scope` and `fix_base` only from the token (a caller's values are dropped either way). On unchanged content its `review_binding` is `{state: "fix-range", range_start_wtree, range_end_wtree, …}`, with no `start_wtree`/`end_wtree` and no root `wtree`. `reviewFreshness` returns UNVERIFIED for any fix-range row ("fix-range pass; only a later full pass can clear review"), even one with a verified-looking binding.
|
||||
- **`bin/gstack-specialist-stats`** skips fix-range rows, so narrowed dispatches don't push specialists toward `[GATE_CANDIDATE]`.
|
||||
|
||||
### Changed
|
||||
- **`ship/sections/review-army.md.tmpl`**: the core checklist explains the fix-range start, and that `FIX_BASE` stands in for `origin/<base>` and `<base>` for that pass. Decision 3 allows the repeat to be a fix-range pass. Decision 4 continues to Step 10 only from a full pass. Nearby prose was tightened to pay for the growth, so ship's skeleton plus sections is 313,246 bytes against its 313,281 cap (35 bytes below main).
|
||||
|
||||
### Tests
|
||||
- `test/review-fix-range.test.ts` (15 cases) covers the base, chained fix-range passes, the seven refusals, a caller claiming `scope: "full"` on a fix-range token (it is still recorded as fix-range) or `"fix-range"` on a full one (it is dropped), Step 11.5's missing binding fields, UNVERIFIED freshness followed by CURRENT after a full pass, shared-libs reuse refusing a fix-range row, the stats exclusion, and the rendered Step 9 rules.
|
||||
|
||||
### Gate integrity
|
||||
- One gate change to review: Step 9 may narrow a repeat pass. Verdict: not a weakening of what ships, because the narrowed pass can't be the final review. Three things enforce that: its row is never CURRENT, Step 11.5 has no `start_wtree`/`end_wtree` to bind, and decision 4 requires a full pass before Step 10. Mutation checks, each caught by the suite: dropping the freshness check, giving a narrowed row the normal binding, allowing a fix-range start after a clean, an incomplete or a Step 11-followed pass, accepting a non-ancestor base, and counting fix-range rows in stats. No threshold, budget, timeout, trial count or case kind changed.
|
||||
|
||||
## [1.91.191.0] - 2026-10-25
|
||||
|
||||
**`gstack-version-bump write --changelog` moves the branch's `[Unreleased]` entry under the new version, and /ship Step 12 stops treating a provisional queue pick as a verified slot.** Stamping by hand meant writing VERSION with `gstack-version-bump write` and then renaming the `## [Unreleased]` heading yourself, because only `gstack-restamp` re-headed the CHANGELOG. Separately, when `gstack-next-version` could not read some open PRs' VERSION it returned `complete:false` and a provisional pick, and Step 12 printed the warning and then wrote that version as if the queue had been checked (left over from #3258, noted in GSTA-45).
|
||||
|
||||
@@ -3,6 +3,10 @@
|
||||
# Usage: gstack-review-log '{"skill":"...","timestamp":"...","status":"..."}'
|
||||
#
|
||||
# Before reading a diff: gstack-review-log --start review
|
||||
# Fix-range repeat (#3339): gstack-review-log --start review --fix-range
|
||||
# prints the token, then FIX_BASE=<sha>; refuses (exit 1) unless the latest
|
||||
# pass completed with fixes on unchanged content and its commit is an
|
||||
# ancestor of HEAD. Its row is never CURRENT; a later full pass must follow.
|
||||
# After that pass: gstack-review-log '{...,"completed":true,"converged":true}' --finish TOKEN
|
||||
# With a findings file: gstack-review-log '{...}' --findings findings.jsonl
|
||||
# status/unresolved/issues_found/critical_gaps are DERIVED from the file (B7);
|
||||
@@ -72,9 +76,11 @@ fi
|
||||
SESSION_KIND=$("$SCRIPT_DIR/gstack-session-kind" 2>/dev/null || echo interactive)
|
||||
export GSTACK_STAMP_COMMIT_FULL="$COMMIT_FULL" GSTACK_STAMP_TREE="$TREE" GSTACK_STAMP_WTREE="$WTREE" GSTACK_STAMP_DIRTY="$DIRTY" GSTACK_STAMP_SESSION_KIND="$SESSION_KIND"
|
||||
if [ "$INPUT" = --start ]; then
|
||||
GSTACK_REVIEW_SKILL="${2:-}" bun --no-env-file --config=/dev/null -e '
|
||||
case "${3:-}" in ''|--fix-range) ;; *) echo 'Usage: gstack-review-log --start SKILL [--fix-range]' >&2; exit 2 ;; esac
|
||||
GSTACK_REVIEW_SKILL="${2:-}" GSTACK_REVIEW_FIX_RANGE="${3:-}" bun --no-env-file --config=/dev/null -e '
|
||||
const { captureReviewStart } = await import(process.env.GSTACK_REVIEW_LIB);
|
||||
console.log(captureReviewStart(process.env.GSTACK_REVIEW_SKILL));
|
||||
try { console.log(captureReviewStart(process.env.GSTACK_REVIEW_SKILL, process.env, process.env.GSTACK_REVIEW_FIX_RANGE === "--fix-range")); }
|
||||
catch (e) { console.error(e.message); process.exit(1); }
|
||||
'
|
||||
exit $?
|
||||
fi
|
||||
@@ -86,7 +92,7 @@ if [ "$INPUT" = --check-shared-libs ] && [ "$#" -eq 2 ]; then
|
||||
exit $?
|
||||
fi
|
||||
usage() {
|
||||
echo 'Usage: gstack-review-log JSON [--finish TOKEN] [--findings FILE] | --start SKILL | --check-shared-libs TOKEN < finding.json' >&2
|
||||
echo 'Usage: gstack-review-log JSON [--finish TOKEN] [--findings FILE] | --start SKILL [--fix-range] | --check-shared-libs TOKEN < finding.json' >&2
|
||||
echo 'Exit codes: 0 written · 1 rejected · 2 usage' >&2
|
||||
exit 2
|
||||
}
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
# Reads all *-reviews.jsonl files across branches, parses specialist fields,
|
||||
# and outputs hit rates. Tags specialists as GATE_CANDIDATE (0 findings in 10+
|
||||
# dispatches) or NEVER_GATE (security, data-migration, state-machines — insurance policy).
|
||||
# Fix-range passes (scope "fix-range", #3339) are skipped: a narrowed dispatch
|
||||
# says nothing about a specialist's hit rate on a whole branch.
|
||||
set -euo pipefail
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
eval "$("$SCRIPT_DIR/gstack-slug" 2>/dev/null)"
|
||||
@@ -40,7 +42,7 @@ let reviewed = 0;
|
||||
for (const line of lines) {
|
||||
try {
|
||||
const e = JSON.parse(line);
|
||||
if (!e.specialists) continue;
|
||||
if (!e.specialists || e.scope === 'fix-range') continue;
|
||||
reviewed++;
|
||||
for (const [name, info] of Object.entries(e.specialists)) {
|
||||
if (!stats[name]) stats[name] = { dispatched: 0, findings: 0 };
|
||||
|
||||
+54
-9
@@ -163,18 +163,54 @@ export function checkSharedLibsReuse(finding: unknown, token: string, env = proc
|
||||
return result;
|
||||
}
|
||||
|
||||
export function captureReviewStart(skill: string, env = process.env): string {
|
||||
/**
|
||||
* #3339: a fix-range repeat reviews only what changed since the latest pass of
|
||||
* the same skill, and only when that pass completed, made fixes, reviewed
|
||||
* unchanged content on this branch, and no Step 11 review followed it. The
|
||||
* base is that pass's commit, held in the review log rather than the working
|
||||
* tree, and it must still be an ancestor of HEAD. Anything else needs a full pass.
|
||||
*/
|
||||
function fixRangeBase(skill: string, env: NodeJS.ProcessEnv): string {
|
||||
const refuse = (reason: string): never => {
|
||||
throw new Error(`gstack-review-log: fix-range refused: ${reason}; run a full pass (gstack-review-log --start ${skill})`);
|
||||
};
|
||||
let rows: Record<string, any>[] = [];
|
||||
try {
|
||||
rows = readFileSync(env.GSTACK_REVIEW_LOG!, 'utf8').split('\n').filter(Boolean).flatMap(line => {
|
||||
try { const row = JSON.parse(line); return record(row) ? [row] : []; } catch { return []; }
|
||||
});
|
||||
} catch {}
|
||||
const at = rows.findLastIndex(row => row.skill === skill);
|
||||
if (at < 0) refuse('no earlier pass on this branch');
|
||||
if (rows.slice(at + 1).some(row => row.skill === 'adversarial-review' || row.skill === 'codex-review')) refuse('a Step 11 review followed the latest pass');
|
||||
const prior = rows[at]!, binding = prior.review_binding;
|
||||
if (prior.completed !== true) refuse('the latest pass did not complete');
|
||||
if (prior.converged !== false) refuse('the latest pass made no fixes');
|
||||
const reviewed = binding?.state === 'incomplete' ? [binding.start_wtree, binding.end_wtree]
|
||||
: binding?.state === 'fix-range' ? [binding.range_start_wtree, binding.range_end_wtree] : [];
|
||||
if (!reviewed[0] || reviewed[0] !== reviewed[1]) refuse('the latest pass has no unchanged captured content');
|
||||
if (binding.branch_id !== sha256(env.GSTACK_REVIEW_BRANCH ?? '')) refuse('the latest pass is from another branch');
|
||||
const base = prior.commit_full;
|
||||
const git = (...args: string[]) => spawnSync('git', args, { cwd: env.GSTACK_REVIEW_REPO, timeout: 10_000 }).status === 0;
|
||||
if (typeof base !== 'string' || !/^(?:[0-9a-f]{40}|[0-9a-f]{64})$/.test(base) || !git('cat-file', '-e', `${base}^{commit}`)) refuse('the latest pass has no resolvable commit');
|
||||
if (!git('merge-base', '--is-ancestor', base, 'HEAD')) refuse(`${base} is not an ancestor of HEAD`);
|
||||
return base;
|
||||
}
|
||||
|
||||
export function captureReviewStart(skill: string, env = process.env, fixRange = false): string {
|
||||
if (!DIFF_REVIEWS.has(skill) || !env.GSTACK_STAMP_WTREE || !env.GSTACK_REVIEW_REPO) {
|
||||
throw new Error('cannot capture a diff review without a working-tree fingerprint');
|
||||
}
|
||||
const fixBase = fixRange ? fixRangeBase(skill, env) : undefined;
|
||||
const dir = join(env.GSTACK_REVIEW_DIR!, '.review-starts');
|
||||
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
||||
const token = crypto.randomUUID();
|
||||
writeFileSync(join(dir, `${token}.json`), JSON.stringify({
|
||||
skill, repo: env.GSTACK_REVIEW_REPO, branch: env.GSTACK_REVIEW_BRANCH,
|
||||
wtree: env.GSTACK_STAMP_WTREE, started_at: new Date().toISOString(),
|
||||
...(fixBase ? { scope: 'fix-range', fix_base: fixBase } : {}),
|
||||
}), { mode: 0o600, flag: 'wx' });
|
||||
return token;
|
||||
return fixBase ? `${token}\nFIX_BASE=${fixBase}` : token;
|
||||
}
|
||||
|
||||
const GATE_ACTIONS = new Set(['kept', 'restored', 'open']);
|
||||
@@ -226,7 +262,7 @@ export function deriveFromFindings(rec: Record<string, any>, findingsPath: strin
|
||||
}
|
||||
|
||||
export function bindReview(rec: Record<string, any>, token: string, env = process.env, findingsPath?: string): Record<string, any> {
|
||||
for (const key of ['commit_full', 'tree', 'wtree', 'dirty', 'review_binding', 'review_freshness', 'shared_libs_coverage_version', 'status_source', 'findings_total', 'findings_open', 'findings_resolved', 'findings_file']) delete rec[key];
|
||||
for (const key of ['commit_full', 'tree', 'wtree', 'dirty', 'review_binding', 'review_freshness', 'shared_libs_coverage_version', 'status_source', 'findings_total', 'findings_open', 'findings_resolved', 'findings_file', 'scope', 'fix_base']) delete rec[key];
|
||||
// The findings file is validated BEFORE the start token is consumed below:
|
||||
// a rejected row must leave the token for the retry.
|
||||
rec = findingsPath ? deriveFromFindings(rec, findingsPath) : { ...rec, status_source: 'claimed' };
|
||||
@@ -256,11 +292,19 @@ export function bindReview(rec: Record<string, any>, token: string, env = proces
|
||||
const state = !start || !end ? 'uncaptured'
|
||||
: start.wtree !== end ? 'changed'
|
||||
: rec.completed !== true || rec.converged !== true ? 'incomplete' : 'verified';
|
||||
rec.review_binding = {
|
||||
state, start_wtree: start?.wtree, end_wtree: end, started_at: start?.started_at,
|
||||
...(typeof start?.branch === 'string' && start.branch.length > 0 ? { branch_id: sha256(start.branch) } : {}),
|
||||
};
|
||||
if (state === 'verified') rec.wtree = end;
|
||||
const branchId = typeof start?.branch === 'string' && start.branch.length > 0 ? { branch_id: sha256(start.branch) } : {};
|
||||
const narrowed = start?.scope === 'fix-range';
|
||||
if (narrowed) {
|
||||
rec.scope = 'fix-range';
|
||||
rec.fix_base = start.fix_base;
|
||||
}
|
||||
// A narrowed pass on unchanged content never carries start_wtree/end_wtree,
|
||||
// so Step 11.5 cannot bind it and it never becomes CURRENT; only a later full pass can.
|
||||
rec.review_binding = narrowed && (state === 'incomplete' || state === 'verified')
|
||||
? { state: 'fix-range', fix_base: start.fix_base, range_start_wtree: start.wtree, range_end_wtree: end, started_at: start.started_at, ...branchId }
|
||||
: { state, start_wtree: start?.wtree, end_wtree: end, started_at: start?.started_at, ...branchId };
|
||||
const verified = rec.review_binding.state === 'verified';
|
||||
if (verified) rec.wtree = end;
|
||||
if (rec.skill === 'review' && Array.isArray(rec.findings)) {
|
||||
rec.shared_libs_coverage_version = SHARED_LIBS_COVERAGE_VERSION;
|
||||
for (const finding of rec.findings) {
|
||||
@@ -270,7 +314,7 @@ export function bindReview(rec: Record<string, any>, token: string, env = proces
|
||||
const fingerprint = sharedLibsFingerprint(finding);
|
||||
if (!fingerprint) continue;
|
||||
finding.fingerprint = fingerprint;
|
||||
finding.snapshot_covered_paths = state === 'verified' && finding.action === 'skipped'
|
||||
finding.snapshot_covered_paths = verified && finding.action === 'skipped'
|
||||
? sharedLibsSnapshotCoverage(env.GSTACK_REVIEW_REPO!, end!, finding.evidence_paths, env) : [];
|
||||
}
|
||||
}
|
||||
@@ -282,6 +326,7 @@ export function reviewFreshness(rec: Record<string, any>, currentWtree: string):
|
||||
if (rec.skill === 'ship') return { status: 'UNVERIFIED', reason: 'ship telemetry is not a review pass' };
|
||||
const binding = rec.review_binding;
|
||||
if (binding?.state === 'changed') return { status: 'STALE', reason: 'content changed during review' };
|
||||
if (rec.scope === 'fix-range') return { status: 'UNVERIFIED', reason: 'fix-range pass; only a later full pass can clear review' };
|
||||
if (binding?.state !== 'verified' || rec.completed !== true || rec.converged !== true ||
|
||||
!rec.wtree || binding.start_wtree !== rec.wtree || binding.end_wtree !== rec.wtree) {
|
||||
return { status: 'UNVERIFIED', reason: 'missing start capture or incomplete/nonconverged pass' };
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
## Step 9: Pre-Landing Review
|
||||
|
||||
Set CYCLES to 0 on first entry only. Keep existing approvals; changed finding scope
|
||||
needs a new decision. Run checklist/design, specialists (9.1), merge/Red Team (9.2),
|
||||
exploratory QA (9.2.1), dedup (9.3), then fixes and logging (9.4).
|
||||
needs a new decision. Order: checklist/design, specialists (9.1), Red Team (9.2),
|
||||
QA (9.2.1), dedup (9.3), fixes and logging (9.4).
|
||||
Gated/unsupported specialists skip only their dispatch, never QA or Step 11.
|
||||
Steps 10–11 queue findings without editing; include those findings in this pass.
|
||||
Every repeat starts before the checklist read and captures a fresh REVIEW_START.
|
||||
@@ -78,6 +78,10 @@ This pass is static; defer product probes to Step 9.2.1.
|
||||
|
||||
2. Before reading the diff, run `~/.claude/skills/gstack/bin/gstack-review-log --start review` and save its token as REVIEW_START. Then run `git diff origin/<base>`. Read non-ignored untracked source files too (`git ls-files --others --exclude-standard`); the snapshot includes them.
|
||||
|
||||
**Fix-range repeat** (decision 3): start with `--start review --fix-range`. It prints
|
||||
REVIEW_START, then `FIX_BASE=<sha>`, which stands in for `origin/<base>` and `<base>` in this pass.
|
||||
If it refuses, start a full pass.
|
||||
|
||||
2.5. **Gate scan.** Before applying the checklist, run the gate scan on the same candidate:
|
||||
|
||||
List every gate edit in the candidate (test, CI, runner/lint-config, snapshot and
|
||||
@@ -121,9 +125,9 @@ fi
|
||||
|
||||
### Design-lite checklist
|
||||
|
||||
Its numbering is local to this checklist. When frontend review applies, `/ship`
|
||||
automatically attempts this optional design check; `enabled` expresses that choice,
|
||||
not a new user question. Step 11 has its own outside-review switch and required native pass.
|
||||
Its numbering is local. `/ship` attempts this design check automatically when frontend
|
||||
review applies; `enabled` is that choice, not a user question. Step 11 has its own
|
||||
outside-review switch and required native pass.
|
||||
|
||||
## Design Review (conditional, diff-scoped)
|
||||
|
||||
@@ -257,10 +261,9 @@ Use the original DESIGN_START token. COMPLETED is true only when the native chec
|
||||
|
||||
Substitute: TIMESTAMP = ISO 8601 datetime, STATUS = "clean" if 0 findings or "issues_found", N = total findings, M = auto-fixed count, D = counted detector findings from step 0 (0 when the detector did not run), COMMIT = output of `git rev-parse --short HEAD`.
|
||||
|
||||
The parent owns design-lite; the Design specialist is an independent read.
|
||||
Before final counting/Fix-First, merge the same evidenced design defect at the same path/line
|
||||
into one item with both sources and stricter ASK. Retain actual specialist stats;
|
||||
distinct defects stay separate and neither pass substitutes for the other.
|
||||
Design-lite (parent) and the Design specialist are independent reads. Before counting
|
||||
and Fix-First, merge one evidenced design defect at the same path/line into one item
|
||||
with both sources and the stricter ASK. Keep actual specialist stats and distinct defects.
|
||||
|
||||
## Step 9.1: Review Army — Specialist Dispatch
|
||||
|
||||
@@ -623,11 +626,9 @@ but missing dispatched output still blocks continuation, even with a QA exceptio
|
||||
2. **Auto-fix all AUTO-FIX items.** Apply each fix. Output one line per fix:
|
||||
`[AUTO-FIXED] [file:line] Problem → what you did`
|
||||
|
||||
3. **If ASK items remain,** present them in ONE AskUserQuestion:
|
||||
- List each with number, severity, problem, recommended fix
|
||||
- Per-item options: A) Fix B) Skip
|
||||
- Overall RECOMMENDATION
|
||||
- If 3 or fewer ASK items, you may use individual AskUserQuestion calls instead
|
||||
3. **If ASK items remain,** present them in ONE AskUserQuestion (or one per item for 3 or
|
||||
fewer): each item's number, severity, problem and recommended fix with A) Fix B) Skip,
|
||||
plus an overall RECOMMENDATION.
|
||||
|
||||
Save each explicit Skip immediately in the invocation action list with its
|
||||
identity, scope and supporting source evidence; keep it across repeats.
|
||||
@@ -674,10 +675,10 @@ but missing dispatched output still blocks continuation, even with a QA exceptio
|
||||
```bash
|
||||
~/.claude/skills/gstack/bin/gstack-review-log '{"skill":"review","timestamp":"TIMESTAMP","status":"STATUS","issues_found":N,"critical":N,"informational":N,"quality_score":SCORE,"specialists":SPECIALISTS_JSON,"findings":FINDINGS_JSON,"commit":"'"$(git rev-parse --short HEAD)"'","via":"ship","completed":COMPLETED,"converged":CONVERGED,"cycles":CYCLES}' --finish REVIEW_START
|
||||
```
|
||||
- `TIMESTAMP`: ISO 8601. `STATUS`: `unavailable` for missing dispatched reviewer output;
|
||||
otherwise `clean` only for completed coverage with no
|
||||
unresolved non-advisory defects; otherwise `issues_found`. N counts current
|
||||
unresolved defects, not original totals. Missing coverage is not a defect.
|
||||
- `TIMESTAMP`: ISO 8601. `STATUS`: `unavailable` for missing dispatched reviewer output,
|
||||
else `clean` only for completed coverage with no unresolved non-advisory defects,
|
||||
else `issues_found`. N counts unresolved defects now, not original totals; missing
|
||||
coverage is not a defect.
|
||||
- `REVIEW_START`: this pass's Step 9 token captured before reading the diff;
|
||||
never recapture at persistence to certify unreviewed fixes.
|
||||
- `COMPLETED`: checklist and dispatched specialists/Red Team finish, and all required probes pass.
|
||||
@@ -699,13 +700,13 @@ but missing dispatched output still blocks continuation, even with a QA exceptio
|
||||
`open` counts in `issues_found` and by severity. The logger stamps `actor` from
|
||||
`gstack-session-kind` (a supplied `actor` is discarded) and forces `"open"` on
|
||||
gate findings in a non-interactive session.
|
||||
Save the review output and the `Gate edits:` line — they go into the PR body in Step 19.
|
||||
Any `open` gate finding also decides Step 19's draft state.
|
||||
Save the review output and `Gate edits:` line for Step 19's PR body; an `open` gate
|
||||
finding makes that PR a draft.
|
||||
|
||||
### Decide whether to repeat Step 9
|
||||
|
||||
After persistence, record missing dispatched output, CYCLES and applied fixes in
|
||||
the invocation record. Apply these decisions in order:
|
||||
After persistence, record missing output, CYCLES and fixes in the invocation record.
|
||||
Apply these decisions in order:
|
||||
|
||||
1. **Dispatched reviewer output missing:** STOP and name each failed specialist or
|
||||
Red Team. Retain queued fixes and restore coverage. If this pass made edits,
|
||||
@@ -715,10 +716,10 @@ the invocation record. Apply these decisions in order:
|
||||
`converged:false`; do not run a fourth fixing cycle.
|
||||
3. **Fixes applied below the cap:** Insert Step 5, affected Steps 6–8 and all of
|
||||
Step 9 before the pending Step 10 in the work list. Tests must pass or retain approval for the same verified pre-existing
|
||||
failures and scope. Keep CYCLES and scoped approvals across this repeat.
|
||||
failures and scope. Keep CYCLES and scoped approvals across this repeat; it may be a fix-range repeat.
|
||||
4. **No edits in this pass:** Resolve the required-probe gate below. Only after it
|
||||
clears may you continue to Step 10. Undispatched gated/unsupported specialists
|
||||
do not block independently, but never replace QA or required native review.
|
||||
clears may you continue to Step 10, and only from a full pass: after a fix-range
|
||||
pass, run one full Step 9 first.
|
||||
|
||||
**Required-probe parent gate:** With completed checklist and dispatched reviewers,
|
||||
failed/unavailable required probes block continuation.
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
## Step 9: Pre-Landing Review
|
||||
|
||||
Set CYCLES to 0 on first entry only. Keep existing approvals; changed finding scope
|
||||
needs a new decision. Run checklist/design, specialists (9.1), merge/Red Team (9.2),
|
||||
exploratory QA (9.2.1), dedup (9.3), then fixes and logging (9.4).
|
||||
needs a new decision. Order: checklist/design, specialists (9.1), Red Team (9.2),
|
||||
QA (9.2.1), dedup (9.3), fixes and logging (9.4).
|
||||
Gated/unsupported specialists skip only their dispatch, never QA or Step 11.
|
||||
Steps 10–11 queue findings without editing; include those findings in this pass.
|
||||
Every repeat starts before the checklist read and captures a fresh REVIEW_START.
|
||||
@@ -18,6 +18,10 @@ This pass is static; defer product probes to Step 9.2.1.
|
||||
|
||||
2. Before reading the diff, run `~/.claude/skills/gstack/bin/gstack-review-log --start review` and save its token as REVIEW_START. Then run `git diff origin/<base>`. Read non-ignored untracked source files too (`git ls-files --others --exclude-standard`); the snapshot includes them.
|
||||
|
||||
**Fix-range repeat** (decision 3): start with `--start review --fix-range`. It prints
|
||||
REVIEW_START, then `FIX_BASE=<sha>`, which stands in for `origin/<base>` and `<base>` in this pass.
|
||||
If it refuses, start a full pass.
|
||||
|
||||
2.5. **Gate scan.** Before applying the checklist, run the gate scan on the same candidate:
|
||||
|
||||
{{GATE_SCAN_BLOCK}}
|
||||
@@ -33,16 +37,15 @@ This pass is static; defer product probes to Step 9.2.1.
|
||||
|
||||
### Design-lite checklist
|
||||
|
||||
Its numbering is local to this checklist. When frontend review applies, `/ship`
|
||||
automatically attempts this optional design check; `enabled` expresses that choice,
|
||||
not a new user question. Step 11 has its own outside-review switch and required native pass.
|
||||
Its numbering is local. `/ship` attempts this design check automatically when frontend
|
||||
review applies; `enabled` is that choice, not a user question. Step 11 has its own
|
||||
outside-review switch and required native pass.
|
||||
|
||||
{{DESIGN_REVIEW_LITE}}
|
||||
|
||||
The parent owns design-lite; the Design specialist is an independent read.
|
||||
Before final counting/Fix-First, merge the same evidenced design defect at the same path/line
|
||||
into one item with both sources and stricter ASK. Retain actual specialist stats;
|
||||
distinct defects stay separate and neither pass substitutes for the other.
|
||||
Design-lite (parent) and the Design specialist are independent reads. Before counting
|
||||
and Fix-First, merge one evidenced design defect at the same path/line into one item
|
||||
with both sources and the stricter ASK. Keep actual specialist stats and distinct defects.
|
||||
|
||||
{{REVIEW_ARMY}}
|
||||
|
||||
@@ -63,11 +66,9 @@ but missing dispatched output still blocks continuation, even with a QA exceptio
|
||||
2. **Auto-fix all AUTO-FIX items.** Apply each fix. Output one line per fix:
|
||||
`[AUTO-FIXED] [file:line] Problem → what you did`
|
||||
|
||||
3. **If ASK items remain,** present them in ONE AskUserQuestion:
|
||||
- List each with number, severity, problem, recommended fix
|
||||
- Per-item options: A) Fix B) Skip
|
||||
- Overall RECOMMENDATION
|
||||
- If 3 or fewer ASK items, you may use individual AskUserQuestion calls instead
|
||||
3. **If ASK items remain,** present them in ONE AskUserQuestion (or one per item for 3 or
|
||||
fewer): each item's number, severity, problem and recommended fix with A) Fix B) Skip,
|
||||
plus an overall RECOMMENDATION.
|
||||
|
||||
Save each explicit Skip immediately in the invocation action list with its
|
||||
identity, scope and supporting source evidence; keep it across repeats.
|
||||
@@ -114,10 +115,10 @@ but missing dispatched output still blocks continuation, even with a QA exceptio
|
||||
```bash
|
||||
~/.claude/skills/gstack/bin/gstack-review-log '{"skill":"review","timestamp":"TIMESTAMP","status":"STATUS","issues_found":N,"critical":N,"informational":N,"quality_score":SCORE,"specialists":SPECIALISTS_JSON,"findings":FINDINGS_JSON,"commit":"'"$(git rev-parse --short HEAD)"'","via":"ship","completed":COMPLETED,"converged":CONVERGED,"cycles":CYCLES}' --finish REVIEW_START
|
||||
```
|
||||
- `TIMESTAMP`: ISO 8601. `STATUS`: `unavailable` for missing dispatched reviewer output;
|
||||
otherwise `clean` only for completed coverage with no
|
||||
unresolved non-advisory defects; otherwise `issues_found`. N counts current
|
||||
unresolved defects, not original totals. Missing coverage is not a defect.
|
||||
- `TIMESTAMP`: ISO 8601. `STATUS`: `unavailable` for missing dispatched reviewer output,
|
||||
else `clean` only for completed coverage with no unresolved non-advisory defects,
|
||||
else `issues_found`. N counts unresolved defects now, not original totals; missing
|
||||
coverage is not a defect.
|
||||
- `REVIEW_START`: this pass's Step 9 token captured before reading the diff;
|
||||
never recapture at persistence to certify unreviewed fixes.
|
||||
- `COMPLETED`: checklist and dispatched specialists/Red Team finish, and all required probes pass.
|
||||
@@ -139,13 +140,13 @@ but missing dispatched output still blocks continuation, even with a QA exceptio
|
||||
`open` counts in `issues_found` and by severity. The logger stamps `actor` from
|
||||
`gstack-session-kind` (a supplied `actor` is discarded) and forces `"open"` on
|
||||
gate findings in a non-interactive session.
|
||||
Save the review output and the `Gate edits:` line — they go into the PR body in Step 19.
|
||||
Any `open` gate finding also decides Step 19's draft state.
|
||||
Save the review output and `Gate edits:` line for Step 19's PR body; an `open` gate
|
||||
finding makes that PR a draft.
|
||||
|
||||
### Decide whether to repeat Step 9
|
||||
|
||||
After persistence, record missing dispatched output, CYCLES and applied fixes in
|
||||
the invocation record. Apply these decisions in order:
|
||||
After persistence, record missing output, CYCLES and fixes in the invocation record.
|
||||
Apply these decisions in order:
|
||||
|
||||
1. **Dispatched reviewer output missing:** STOP and name each failed specialist or
|
||||
Red Team. Retain queued fixes and restore coverage. If this pass made edits,
|
||||
@@ -155,10 +156,10 @@ the invocation record. Apply these decisions in order:
|
||||
`converged:false`; do not run a fourth fixing cycle.
|
||||
3. **Fixes applied below the cap:** Insert Step 5, affected Steps 6–8 and all of
|
||||
Step 9 before the pending Step 10 in the work list. Tests must pass or retain approval for the same verified pre-existing
|
||||
failures and scope. Keep CYCLES and scoped approvals across this repeat.
|
||||
failures and scope. Keep CYCLES and scoped approvals across this repeat; it may be a fix-range repeat.
|
||||
4. **No edits in this pass:** Resolve the required-probe gate below. Only after it
|
||||
clears may you continue to Step 10. Undispatched gated/unsupported specialists
|
||||
do not block independently, but never replace QA or required native review.
|
||||
clears may you continue to Step 10, and only from a full pass: after a fix-range
|
||||
pass, run one full Step 9 first.
|
||||
|
||||
**Required-probe parent gate:** With completed checklist and dispatched reviewers,
|
||||
failed/unavailable required probes block continuation.
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
/**
|
||||
* #3339: /ship Step 9 may re-review only the fix range, but a fix-range pass
|
||||
* can never be the last review. The fix base comes from the review log (never
|
||||
* the working tree or the caller), a narrowed row is never CURRENT and carries
|
||||
* no start_wtree/end_wtree for Step 11.5 to bind, and narrowed dispatches stay
|
||||
* out of specialist hit rates.
|
||||
*/
|
||||
import { describe, test, expect, beforeEach, afterEach } from 'bun:test';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import * as fs from 'node:fs';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
import { canReuseSharedLibsAdvisory, reviewFreshness } from '../lib/review-evidence';
|
||||
import { gitIn } from './helpers/scratch-repo';
|
||||
|
||||
const ROOT = path.resolve(import.meta.dir, '..');
|
||||
const BIN = path.join(ROOT, 'bin');
|
||||
const W = 'a'.repeat(40);
|
||||
|
||||
let root: string, repo: string, env: NodeJS.ProcessEnv;
|
||||
beforeEach(() => {
|
||||
root = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-fix-range-'));
|
||||
repo = path.join(root, 'repo');
|
||||
fs.mkdirSync(repo);
|
||||
env = { ...process.env, GSTACK_STATE_ROOT: path.join(root, 'state') };
|
||||
delete env.GSTACK_HOME;
|
||||
gitIn(repo, 'init -q -b feature');
|
||||
fs.writeFileSync(path.join(repo, 'a.ts'), 'export const a = 1;\n');
|
||||
gitIn(repo, 'add a.ts');
|
||||
gitIn(repo, 'commit -q -m init');
|
||||
});
|
||||
afterEach(() => { fs.rmSync(root, { recursive: true, force: true }); });
|
||||
|
||||
function bin(name: string, args: string[]) {
|
||||
const r = spawnSync(path.join(BIN, name), args, { cwd: repo, encoding: 'utf8', timeout: 60_000, env });
|
||||
return { code: r.status, out: r.stdout.trim(), err: r.stderr };
|
||||
}
|
||||
const head = () => gitIn(repo, 'rev-parse HEAD').trim();
|
||||
function fix(text: string) {
|
||||
fs.writeFileSync(path.join(repo, 'a.ts'), text);
|
||||
gitIn(repo, 'commit -q -am fix');
|
||||
}
|
||||
function pass(row: Record<string, unknown>, start: string[] = ['--start', 'review']) {
|
||||
const started = bin('gstack-review-log', start);
|
||||
expect(started.code).toBe(0);
|
||||
const [token, base] = started.out.split('\n');
|
||||
const logged = bin('gstack-review-log', [JSON.stringify({ skill: 'review', via: 'ship', status: 'clean', issues_found: 0, critical: 0, informational: 0, ...row }), '--finish', token!]);
|
||||
expect(logged.code).toBe(0);
|
||||
return base;
|
||||
}
|
||||
const fixing = { status: 'issues_found', completed: true, converged: false };
|
||||
const clean = { completed: true, converged: true };
|
||||
function rows(): any[] {
|
||||
return bin('gstack-review-read', []).out.split('\n---CONFIG---')[0]!.split('\n').filter(line => line.startsWith('{')).map(line => JSON.parse(line));
|
||||
}
|
||||
const refused = () => bin('gstack-review-log', ['--start', 'review', '--fix-range']);
|
||||
|
||||
describe('fix-range start', () => {
|
||||
test('after a completed fixing pass it prints the token and that pass commit as FIX_BASE', () => {
|
||||
const reviewed = head();
|
||||
pass(fixing);
|
||||
fix('export const a = 2;\n');
|
||||
expect(pass(clean, ['--start', 'review', '--fix-range'])).toBe(`FIX_BASE=${reviewed}`);
|
||||
});
|
||||
|
||||
test('chained fix-range passes take the latest fixing pass as base', () => {
|
||||
pass(fixing);
|
||||
fix('export const a = 2;\n');
|
||||
const second = head();
|
||||
pass(fixing, ['--start', 'review', '--fix-range']);
|
||||
fix('export const a = 3;\n');
|
||||
expect(pass(clean, ['--start', 'review', '--fix-range'])).toBe(`FIX_BASE=${second}`);
|
||||
});
|
||||
|
||||
test.each([
|
||||
['no earlier pass', () => {}, 'no earlier pass on this branch'],
|
||||
['a clean latest pass', () => pass(clean), 'the latest pass made no fixes'],
|
||||
['an incomplete latest pass (coverage restoration stays full)', () => pass({ ...fixing, status: 'unavailable', completed: false }), 'the latest pass did not complete'],
|
||||
['a Step 11 review after the latest pass', () => { pass(fixing); pass({ skill: 'adversarial-review', ...clean }, ['--start', 'adversarial-review']); }, 'a Step 11 review followed the latest pass'],
|
||||
['content that changed during the latest pass', () => {
|
||||
const token = bin('gstack-review-log', ['--start', 'review']).out;
|
||||
fs.writeFileSync(path.join(repo, 'a.ts'), 'export const a = 9;\n');
|
||||
bin('gstack-review-log', [JSON.stringify({ skill: 'review', ...fixing }), '--finish', token]);
|
||||
}, 'the latest pass has no unchanged captured content'],
|
||||
['a base that is no longer an ancestor of HEAD', () => {
|
||||
fix('export const a = 2;\n');
|
||||
pass(fixing);
|
||||
gitIn(repo, 'reset -q --hard HEAD~1');
|
||||
fix('export const a = 3;\n');
|
||||
}, 'is not an ancestor of HEAD'],
|
||||
])('refuses %s', (_, setup, reason) => {
|
||||
setup();
|
||||
const r = refused();
|
||||
expect(r.code).toBe(1);
|
||||
expect(r.out).toBe('');
|
||||
expect(r.err).toContain('fix-range refused: ');
|
||||
expect(r.err).toContain(reason);
|
||||
expect(r.err).toContain('run a full pass (gstack-review-log --start review)');
|
||||
});
|
||||
|
||||
test('rejects an unknown start flag with usage', () => {
|
||||
expect(bin('gstack-review-log', ['--start', 'review', '--narrow']).code).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('a fix-range pass is never the last review', () => {
|
||||
test('its row is fix-range scoped whatever the caller claims, unbindable at Step 11.5 and never CURRENT', () => {
|
||||
const reviewed = head();
|
||||
pass(fixing);
|
||||
fix('export const a = 2;\n');
|
||||
pass({ ...clean, scope: 'full', fix_base: 'f'.repeat(40), review_binding: { state: 'verified', start_wtree: W, end_wtree: W }, wtree: W },
|
||||
['--start', 'review', '--fix-range']);
|
||||
const row = rows().at(-1);
|
||||
expect(row.scope).toBe('fix-range');
|
||||
expect(row.fix_base).toBe(reviewed);
|
||||
expect(row.review_binding.state).toBe('fix-range');
|
||||
expect(row.review_binding.range_start_wtree).toBe(row.review_binding.range_end_wtree);
|
||||
expect(row.review_binding).not.toHaveProperty('start_wtree');
|
||||
expect(row.review_binding).not.toHaveProperty('end_wtree');
|
||||
expect(row).not.toHaveProperty('wtree');
|
||||
expect(row.review_freshness).toEqual({ status: 'UNVERIFIED', reason: 'fix-range pass; only a later full pass can clear review' });
|
||||
expect(refused().err).toContain('the latest pass made no fixes');
|
||||
pass(clean);
|
||||
expect(rows().at(-1).review_freshness.status).toBe('CURRENT');
|
||||
});
|
||||
|
||||
test('a caller cannot narrow a full pass by claiming fix-range', () => {
|
||||
pass({ ...clean, scope: 'fix-range', fix_base: W });
|
||||
const row = rows().at(-1);
|
||||
expect(row).not.toHaveProperty('scope');
|
||||
expect(row).not.toHaveProperty('fix_base');
|
||||
expect(row.review_freshness.status).toBe('CURRENT');
|
||||
});
|
||||
|
||||
test('freshness rejects a fix-range row even with a verified-looking binding', () => {
|
||||
const rec = { skill: 'review', scope: 'fix-range', status: 'clean', issues_found: 0, critical: 0, completed: true, converged: true, wtree: W,
|
||||
review_binding: { state: 'verified', start_wtree: W, end_wtree: W } };
|
||||
expect(reviewFreshness(rec, W)?.status).toBe('UNVERIFIED');
|
||||
expect(reviewFreshness({ ...rec, scope: undefined }, W)?.status).toBe('CURRENT');
|
||||
});
|
||||
|
||||
test('shared-libs advisory reuse never trusts a fix-range row', () => {
|
||||
const finding = { severity: 'INFORMATIONAL', advisory: true, action: 'skipped', evidence_paths: ['a.ts'], helper_target: { path: 'b.ts', symbol: 'b' } };
|
||||
const fp = 'shared-libs:' + 'c'.repeat(64);
|
||||
const prior = { skill: 'review', completed: true, converged: true, wtree: W, scope: 'fix-range',
|
||||
review_binding: { state: 'fix-range', range_start_wtree: W, range_end_wtree: W, branch_id: 'd'.repeat(64) } };
|
||||
expect(canReuseSharedLibsAdvisory({ ...finding, fingerprint: fp, snapshot_covered_paths: ['a.ts'] }, finding, prior,
|
||||
{ wtree: W, branch_id: 'd'.repeat(64), covered_paths: ['a.ts'] })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('gstack-specialist-stats', () => {
|
||||
test('fix-range passes do not count toward hit rates or gate candidacy', () => {
|
||||
const entry = (scope?: string) => JSON.stringify({ ...(scope ? { scope } : {}), specialists: { testing: { dispatched: true, findings: 0 } } });
|
||||
const slug = bin('gstack-slug', []).out.match(/^SLUG=(.+)$/m)![1]!;
|
||||
const dir = path.join(root, 'state', 'projects', slug);
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
fs.writeFileSync(path.join(dir, 'feature-reviews.jsonl'), [...Array(9).fill(entry()), ...Array(5).fill(entry('fix-range'))].join('\n') + '\n');
|
||||
const out = bin('gstack-specialist-stats', []).out;
|
||||
expect(out).toContain('SPECIALIST_STATS: 9 reviews analyzed');
|
||||
expect(out).toContain('testing: 9/9 dispatched, 0 findings (0%)');
|
||||
expect(out).not.toContain('GATE_CANDIDATE');
|
||||
});
|
||||
});
|
||||
|
||||
describe('ship Step 9 instructions', () => {
|
||||
const army = fs.readFileSync(path.join(ROOT, 'ship/sections/review-army.md'), 'utf8').replace(/\s+/g, ' ');
|
||||
test('the fix-range start, its base substitution and the mandatory full pass are stated', () => {
|
||||
expect(army).toContain('start with `--start review --fix-range`');
|
||||
expect(army).toContain('`FIX_BASE=<sha>`, which stands in for `origin/<base>` and `<base>` in this pass');
|
||||
expect(army).toContain('If it refuses, start a full pass.');
|
||||
expect(army).toContain('it may be a fix-range repeat');
|
||||
expect(army).toContain('continue to Step 10, and only from a full pass: after a fix-range pass, run one full Step 9 first.');
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user