mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-02 06:54:50 +01:00
Merge remote-tracking branch 'origin/main' into gst-407-ship-rereview-triggers
This commit is contained in:
commit
d25fd4a51a
16 files changed
+220
-20
No files matched your search
@@ -1,5 +1,27 @@
|
||||
# Changelog
|
||||
|
||||
## [1.91.357.0] - 2026-10-31
|
||||
|
||||
**pregate `literals` no longer flags a version-history table that the branch extended.** A test that keeps one row per past version of a constant (`[45, "<digest>"]`, then `[46, "<digest>"]`) used to fail `literals` on every bump, because the old row still holds the old value. Truther turned the check down to a warning over it, which also hid real stale tests.
|
||||
|
||||
### Fixed
|
||||
- `lib/pregate/checks/literals.ts`: a row that starts with the old value is skipped when the same table holds a new-value row the branch added (the file at the merge base lacks it). Only the row itself is exempt (the old value after its closing bracket on the same line still fails), and the scan continues to the next line holding the old value, so `expect(PROOF_CHECKS).toBe(45)` below an extended table still fails. A table the branch did not extend, a file that already held a new-value row at the merge base (read through a rename git detects), and a merge base that cannot be read still fail.
|
||||
- docs/pregate.md describes the history-row rule and the table shapes it recognizes.
|
||||
|
||||
### Tests
|
||||
- `test/pregate-selection.test.ts`: an extended table, a newest-first table and a new file pass; an unextended table, a stale assertion below an extended table, an unextended table beside an extended one, and a table that already held the new row fail at the right line; string-valued and quoted semver histories pass; a stale assertion on the same line as a history row fails; a 20,000-row table is checked in linear time; and an end-to-end `gstack-pregate --only literals` run fails before the table is extended, passes after, and still fails for a renamed file whose table already held the new row.
|
||||
|
||||
## [1.91.356.0] - 2026-10-31
|
||||
|
||||
**A reviewer's install no longer upgrades the gstack checkout a lane shares.** On a Capy machine, an /autoplan reviewer runs `install.sh --for review-voice` against the same checkout as the lane that dispatched it. Once the lane's workflow lease was more than 120 minutes old, that install fast-forwarded the checkout and rewrote the install verdict as `for=review-voice`. The lane's next workflow was then refused with `INSTALL_UNVERIFIED`.
|
||||
|
||||
### Fixed
|
||||
- **`--for review-voice` never self-upgrades the checkout.** When origin is ahead, the session prints `SELF_UPGRADE: skipped (review-voice never self-upgrades a shared checkout; fix: <root>/bin/gstack-upgrade when no other session uses this checkout (UPGRADE_SKIPPED))`, leaves HEAD where it was, and the trailer stays ok with a `behind=` note. This holds with or without a lease, and `GSTACK_UPGRADE_FORCE=1` does not override it. If `bin/gstack-doctor-components.sh` cannot be read, the check still treats `review-voice` as a reviewer install. The bootstrapper still fast-forwards a checkout below the supported minimum.
|
||||
- **`--check --for review-voice` names `bin/gstack-upgrade` as the fix** instead of the install rerun, which would not move a reviewer checkout.
|
||||
|
||||
### Changed
|
||||
- **The Captain's digest drops an archived thread from In progress in the same turn** (docs/captain.md, the DIGEST.md template and /captain).
|
||||
|
||||
## [1.91.355.0] - 2026-10-31
|
||||
|
||||
**A driven `/ship` on Capy no longer pays one agent turn per finished reviewer, and it can count its own calls.** Replays of gstack-evals #34 and #36 (GSTA-167) made 30 to about 92 parent calls against a target of 12. In the 9-reviewer replay, 23 of 25 waits were the parent waking once per finished reviewer, because the driver told it to end its turn on `wait` while reviewers ran. What reviewers see, which reviewers run, their depth and the blocking floor are unchanged.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# gstack digest v1.91.355.0 — regenerate/re-copy after upgrading gstack
|
||||
# gstack digest v1.91.357.0 — regenerate/re-copy after upgrading gstack
|
||||
|
||||
Behavioral rules from gstack (https://github.com/garrytan/gstack), compressed
|
||||
for agent hosts without a full skill install. The full skills add workflows,
|
||||
|
||||
+13
-2
@@ -29,7 +29,10 @@
|
||||
# inside a workflow (/ship's docs step) must not change gstack under it. The age
|
||||
# bound matters on Capy, where the leased harness pid is the machine's
|
||||
# long-lived capy-server and a live lease alone would never end the deferral;
|
||||
# each gstack-skill-start renews it (UPGRADE_BUSY; GSTACK_UPGRADE_FORCE=1 overrides). Lines: `SELF_UPGRADE: done <old> -> <new>` | `skipped (<reason>)`
|
||||
# each gstack-skill-start renews it (UPGRADE_BUSY; GSTACK_UPGRADE_FORCE=1 overrides).
|
||||
# An install profile (`--for review-voice`, a dispatched reviewer on a machine
|
||||
# whose checkout a lane shares) never self-upgrades, lease or not, and
|
||||
# GSTACK_UPGRADE_FORCE does not override it (UPGRADE_SKIPPED, GST-290). Lines: `SELF_UPGRADE: done <old> -> <new>` | `skipped (<reason>)`
|
||||
# | `none (current)` | `disabled (<the variable: GSTACK_CAPY_SELF_UPGRADE=off or
|
||||
# GSTACK_CAPY_AUTO_UPGRADE=0>)`.
|
||||
# --check never moves the checkout; behind origin it prints
|
||||
@@ -70,6 +73,8 @@ fi
|
||||
[ "${GSTACK_CAPY_REMOTE_MIN:-}" = unknown ] && echo "gstack-capy-session: remote minimum unknown (fetch failed); the revision row is local only"
|
||||
# shellcheck source=bin/gstack-install-verdict.sh
|
||||
. "$ROOT/bin/gstack-install-verdict.sh"
|
||||
# shellcheck source=bin/gstack-doctor-components.sh
|
||||
. "$ROOT/bin/gstack-doctor-components.sh" 2>/dev/null || gstack_doctor_install_profile() { [ "$1" = review-voice ]; }
|
||||
MIN_WRAPPER=$(gstack_support_env_get "$(gstack_source_root "$ROOT/bin")" GSTACK_CAPY_MIN_WRAPPER)
|
||||
if [ -n "$MIN_WRAPPER" ] && [ "${GSTACK_CAPY_WRAPPER_REV:-0}" -lt "$MIN_WRAPPER" ] 2>/dev/null; then
|
||||
echo "gstack-capy-skill: owner action — Drive copy is wrapper ${GSTACK_CAPY_WRAPPER_REV:-0}, minimum $MIN_WRAPPER; copy $ROOT/capy/skill/gstack/ (CAPY_WRAPPER_STALE)"
|
||||
@@ -108,7 +113,8 @@ if [ "$CHECK" -eq 1 ]; then
|
||||
if behind; then
|
||||
# The rerun fast-forwards only what gstack-upgrade would move; otherwise
|
||||
# gstack-upgrade itself names the refusal and its fix.
|
||||
if [ "${GSTACK_CAPY_SELF_UPGRADE:-}" = off ]; then report_behind "unset GSTACK_CAPY_SELF_UPGRADE, then $RERUN"
|
||||
if gstack_doctor_install_profile "$SKILL"; then report_behind "$ROOT/bin/gstack-upgrade"
|
||||
elif [ "${GSTACK_CAPY_SELF_UPGRADE:-}" = off ]; then report_behind "unset GSTACK_CAPY_SELF_UPGRADE, then $RERUN"
|
||||
elif [ "${GSTACK_CAPY_AUTO_UPGRADE:-1}" = 0 ]; then report_behind "unset GSTACK_CAPY_AUTO_UPGRADE, then $RERUN"
|
||||
elif [ "$(git -C "$ROOT" symbolic-ref -q --short HEAD 2>/dev/null)" != "$(sed -n "1s/.*branch '\([^']*\)' of .*/\1/p" "$(git -C "$ROOT" rev-parse --absolute-git-dir 2>/dev/null)/FETCH_HEAD" 2>/dev/null)" ] \
|
||||
|| [ -n "$(git -C "$ROOT" status --porcelain --untracked-files=no 2>/dev/null)" ] \
|
||||
@@ -123,6 +129,11 @@ elif [ "${GSTACK_CAPY_SELF_UPGRADED:-}" != 1 ]; then
|
||||
elif [ "${GSTACK_CAPY_AUTO_UPGRADE:-1}" = 0 ]; then disabled GSTACK_CAPY_AUTO_UPGRADE
|
||||
elif [ -z "${GSTACK_CAPY_REMOTE_MIN:-}" ] || [ "$GSTACK_CAPY_REMOTE_MIN" = unknown ]; then echo "SELF_UPGRADE: none (remote minimum unknown)"
|
||||
elif ! behind; then echo "SELF_UPGRADE: none (current)"
|
||||
elif gstack_doctor_install_profile "$SKILL"; then
|
||||
REASON="$SKILL never self-upgrades a shared checkout; fix: $ROOT/bin/gstack-upgrade when no other session uses this checkout (UPGRADE_SKIPPED)"
|
||||
echo "SELF_UPGRADE: skipped ($REASON)"
|
||||
report_behind "$ROOT/bin/gstack-upgrade"
|
||||
export GSTACK_CAPY_BEHIND_BLOCKED="$REASON"
|
||||
elif [ ! -x "$ROOT/bin/gstack-upgrade" ]; then echo "SELF_UPGRADE: skipped ($ROOT/bin/gstack-upgrade is missing)"; report_behind "$RERUN"
|
||||
export GSTACK_CAPY_BEHIND_BLOCKED="$ROOT/bin/gstack-upgrade is missing; fix: $RERUN (UPGRADE_REFUSED)"
|
||||
elif [ "${GSTACK_UPGRADE_FORCE:-}" != 1 ] && [ -x "$ROOT/bin/gstack-lease" ] \
|
||||
|
||||
+2
-1
@@ -329,7 +329,8 @@ turn, three sections in this order, each row with its thread, one line, and an a
|
||||
2. **New**: what landed or changed since the last digest (merges with versions, READYs
|
||||
received, trains built, incidents), newest first.
|
||||
3. **In progress**: every working lane, what it owns, its state (building, READY, in
|
||||
train slot i, blocked on …) and how long since it last reported.
|
||||
train slot i, blocked on …) and how long since it last reported. A thread the Captain
|
||||
archives leaves this list in the same turn.
|
||||
|
||||
Ages make stalls visible: a lane in progress with no report for hours, or a READY waiting
|
||||
longer than `FILL_HOURS`, is the controller's next action.
|
||||
|
||||
@@ -257,7 +257,8 @@ turn, three sections in this order, each row with its thread, one line, and an a
|
||||
2. **New**: what landed or changed since the last digest (merges with versions, READYs
|
||||
received, trains built, incidents), newest first.
|
||||
3. **In progress**: every working lane, what it owns, its state (building, READY, in
|
||||
train slot i, blocked on …) and how long since it last reported.
|
||||
train slot i, blocked on …) and how long since it last reported. A thread the Captain
|
||||
archives leaves this list in the same turn.
|
||||
|
||||
Ages make stalls visible: a lane in progress with no report for hours, or a READY waiting
|
||||
longer than `FILL_HOURS`, is the controller's next action.
|
||||
|
||||
@@ -27,5 +27,7 @@ ask format is gstack docs/captain.md, The ask format.
|
||||
|---|---|---|---|
|
||||
| <code> | <files or area> | <building / READY / train slot i / blocked on …> | <age> |
|
||||
|
||||
A thread the controller archives leaves this table in the same turn.
|
||||
|
||||
Stalls to act on: a lane with no report for longer than <N hours>, a READY older than
|
||||
`FILL_HOURS` with no train, a slot call unanswered for longer than <N minutes>.
|
||||
+2
-1
@@ -377,7 +377,8 @@ field").
|
||||
`DIGEST.md` is rewritten every controller turn: **Needs you** (decisions only the owner
|
||||
can make, in [the ask format](#the-ask-format); empty is the goal), **New** (what landed or
|
||||
changed, newest first), **In progress** (every lane, what it owns, its state, the age of
|
||||
its last report). Ages turn stalls into the controller's next action.
|
||||
its last report). Ages turn stalls into the controller's next action. When the controller
|
||||
archives a thread, it drops that thread from **In progress** in the same turn.
|
||||
|
||||
## The daily flaky pass
|
||||
|
||||
|
||||
+12
-1
@@ -47,7 +47,13 @@ upgrade: a reviewer later asked to run a workflow runs the same installer
|
||||
install in place to --for <skill> (same checkout ...; no second clone)`),
|
||||
applies the project's pins, runs `./setup` and both host profiles, and writes
|
||||
the workflow's verdict over the reviewer's. Nothing is removed and nothing is
|
||||
cloned twice. Every /autoplan reviewer prompt
|
||||
cloned twice. A review-voice session never self-upgrades the checkout, because
|
||||
the lane on the same machine may be in the middle of a workflow on it: when
|
||||
origin is ahead it prints `SELF_UPGRADE: skipped (review-voice never
|
||||
self-upgrades a shared checkout; …)`, certifies the installed revision and
|
||||
stays ok with `behind=<n> … (UPGRADE_SKIPPED); fix: <root>/bin/gstack-upgrade`.
|
||||
The bootstrapper still fast-forwards a checkout below the supported minimum.
|
||||
Every /autoplan reviewer prompt
|
||||
ends with this command (`GSTACK TRAILER`); the warm run's budget, under 20 s,
|
||||
is measured by `test/capy-review-voice.test.ts`.
|
||||
|
||||
@@ -101,6 +107,11 @@ there is refused with the usual `--for <skill>` fix.
|
||||
note, and a project that needs the newer gstack says so with a floor
|
||||
(next section).
|
||||
`SELF_UPGRADE: none (current)` when nothing is ahead;
|
||||
`--for review-voice` prints `SELF_UPGRADE: skipped (review-voice never
|
||||
self-upgrades a shared checkout; fix: <root>/bin/gstack-upgrade when no other
|
||||
session uses this checkout (UPGRADE_SKIPPED))` whenever origin is ahead,
|
||||
with or without a lease, and `GSTACK_UPGRADE_FORCE=1` does not change that
|
||||
([reviewer machines](#reviewer-machines---for-review-voice));
|
||||
`GSTACK_CAPY_SELF_UPGRADE=off` (or `GSTACK_CAPY_AUTO_UPGRADE=0`) prints
|
||||
`SELF_UPGRADE: disabled (<variable>)`. `--check` never moves the checkout;
|
||||
behind origin it prints `gstack-capy-session: behind origin/<branch>:
|
||||
|
||||
+9
-1
@@ -169,7 +169,15 @@ Two stages with separate clocks, both printed:
|
||||
- `literals`: for every changed exported constant or named timeout/retry
|
||||
literal, the whole test tree (nightly- and Heavy-only files included) is
|
||||
searched for the old value; a consumer the branch did not update fails with
|
||||
`file:line`.
|
||||
`file:line`. A version-history row, a line that starts a bracketed row with
|
||||
the old value (`[45, "<digest>"]`), is skipped when the same table (a run of
|
||||
consecutive lines starting with `[`) also holds a row for the new value that
|
||||
the branch added (the file at the merge base, read through a rename git
|
||||
detects, has no new-value row). A table the branch did not extend, a file that already held a
|
||||
new-value row at the merge base, a merge base that cannot be read, and every
|
||||
other line holding the old value still fail. Only the row itself is exempt:
|
||||
the old value after its closing bracket on the same line still fails. Rows in object form (`{ v: 45 }`) or spread over several
|
||||
lines are not recognized as history rows.
|
||||
|
||||
- `timing`: added or changed test lines that compare an elapsed wall-clock or
|
||||
CPU time against a bound, or sleep and then assert, print
|
||||
|
||||
@@ -1525,7 +1525,8 @@ yourself; or change the pin in the project.
|
||||
**Meaning.** An install-mode session start above the supported minimum found
|
||||
the checkout behind origin and could not fast-forward it: the checkout is
|
||||
dirty, diverged or pinned (`UPGRADE_REFUSED`), another live session holds a
|
||||
lease (`UPGRADE_BUSY`), or `bin/gstack-upgrade` is missing
|
||||
lease (`UPGRADE_BUSY`), `bin/gstack-upgrade` is missing, or the session is a
|
||||
`--for review-voice` reviewer, which never moves a checkout a lane may share
|
||||
(`UPGRADE_SKIPPED`). Nothing was changed, the doctor certified the installed
|
||||
revision, and the trailer stays `gstack: ok` with the notes above: a gap
|
||||
above the minimum is never a hard fail. The verdict records the installed
|
||||
|
||||
@@ -66,10 +66,21 @@ function unquote(v: string): string { return /^['"`]/.test(v) ? v.slice(1, -1) :
|
||||
* English word in half the test tree, and a one-character old value (`2`)
|
||||
* sits in every test, so that value needs the constant's name (a wrapper
|
||||
* version 2 → 3 flagged 575 consumers through the stem alone).
|
||||
*
|
||||
* A version-history row (`[45, "<digest>"]`, the old value first in a
|
||||
* bracketed row) is skipped when the same table, a run of consecutive
|
||||
* `[…` lines, holds a new-value row and `baseText` (the file at the merge
|
||||
* base; null for a file the base lacks, undefined when it cannot be read)
|
||||
* has no new-value row: the branch extended the table. A table it did not
|
||||
* extend, a file that already held a new-value row, and an unreadable base
|
||||
* still count, and the scan goes on past skipped rows to the first real consumer.
|
||||
*/
|
||||
export function consumersOf(repoRoot: string, testFiles: readonly string[], change: ChangedLiteral): string[] {
|
||||
export function consumersOf(repoRoot: string, testFiles: readonly string[], change: ChangedLiteral, baseText?: (rel: string) => string | null | undefined): string[] {
|
||||
const value = unquote(change.old);
|
||||
const valueRe = /^-?\d/.test(value) ? new RegExp(`(?<![\\w.])${escape(value)}(?![\\w.])`) : new RegExp(`['"\`]${escape(value)}['"\`]`);
|
||||
const row = (raw: string) => new RegExp(`^[ \\t]*\\[[ \\t]*${/^['"`]/.test(raw) ? `['"\`]${escape(unquote(raw))}['"\`]` : escape(raw)}[ \\t]*,`, 'm');
|
||||
const oldRowRe = row(change.old);
|
||||
const newRowRe = row(change.new);
|
||||
const base = path.basename(change.file);
|
||||
const stem = base.replace(/\.[^.]+$/, '');
|
||||
const moduleRe = new RegExp(`(?:(?:^|[/\\\\])${escape(stem)}|(?<![\\w-])${escape(base)})(?![\\w-])`, 'm');
|
||||
@@ -81,7 +92,22 @@ export function consumersOf(repoRoot: string, testFiles: readonly string[], chan
|
||||
try { text = fs.readFileSync(path.join(repoRoot, rel), 'utf8'); } catch { continue; }
|
||||
if (!(nameRe.test(text) || (!nameRequired && moduleRe.test(text)))) continue;
|
||||
const lines = text.split('\n');
|
||||
for (let i = 0; i < lines.length; i++) if (valueRe.test(lines[i]!)) { hits.push(`${rel}:${i + 1}`); break; }
|
||||
const table: number[] = [];
|
||||
const tableHasNew: boolean[] = [];
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
if (!/^[ \t]*\[/.test(lines[i]!)) { table.push(-1); continue; }
|
||||
if (table[i - 1] === undefined || table[i - 1] === -1) tableHasNew.push(false);
|
||||
table.push(tableHasNew.length - 1);
|
||||
if (newRowRe.test(lines[i]!)) tableHasNew[tableHasNew.length - 1] = true;
|
||||
}
|
||||
let extended: boolean | undefined;
|
||||
const skip = (i: number) => {
|
||||
const line = lines[i]!;
|
||||
if (!oldRowRe.test(line) || !tableHasNew[table[i]!] || valueRe.test(line.slice(line.indexOf(']') + 1))) return false;
|
||||
if (extended === undefined) { const base = baseText?.(rel); extended = base === null || (base !== undefined && !newRowRe.test(base)); }
|
||||
return extended;
|
||||
};
|
||||
for (let i = 0; i < lines.length; i++) if (valueRe.test(lines[i]!) && !skip(i)) { hits.push(`${rel}:${i + 1}`); break; }
|
||||
}
|
||||
return hits;
|
||||
}
|
||||
@@ -101,7 +127,13 @@ function run(ctx: PregateContext): CheckResult {
|
||||
const testFiles = listRepoFiles(ctx.repoRoot).filter(f => scansLiterals(f) && !f.startsWith('bin/'));
|
||||
const lines: string[] = [];
|
||||
for (const c of changes) {
|
||||
const hits = consumersOf(ctx.repoRoot, testFiles, c);
|
||||
const hits = consumersOf(ctx.repoRoot, testFiles, c, rel => {
|
||||
const from = ctx.touched.find(t => t.path === rel)?.renamedFrom ?? rel;
|
||||
const r = spawnSync('git', ['show', `${ctx.mergeBase}:${from}`], { cwd: ctx.repoRoot, encoding: 'utf8', timeout: 30_000, maxBuffer: 64 * 1024 * 1024, env: ctx.env });
|
||||
if (r.status === 0) return r.stdout;
|
||||
const ls = spawnSync('git', ['ls-tree', ctx.mergeBase, '--', from], { cwd: ctx.repoRoot, encoding: 'utf8', timeout: 30_000, env: ctx.env });
|
||||
return ls.status === 0 && !ls.stdout.trim() ? null : undefined;
|
||||
});
|
||||
ctx.explain.push(`literals: ${c.file} ${c.name} ${c.old} → ${c.new}: ${hits.length} test file(s) still carry ${c.old}`);
|
||||
for (const h of hits) lines.push(`${h}: ${c.name} still ${c.old} (now ${c.new} in ${c.file})`);
|
||||
}
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "gstack",
|
||||
"version": "1.91.355",
|
||||
"version": "1.91.357",
|
||||
"description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
|
||||
@@ -150,16 +150,19 @@ describe('--for review-voice through the no-Drive brief', () => {
|
||||
expect(warm.ms).toBeLessThan(WARM_BUDGET_MS);
|
||||
}, 300_000);
|
||||
|
||||
test('origin moves: the same brief fast-forwards the checkout before it certifies', () => {
|
||||
// GST-290: the reviewer's machine may share this checkout with a lane mid-workflow.
|
||||
test('origin moves: the same brief never moves the checkout; it certifies the installed revision and reports behind', () => {
|
||||
const head = git(['rev-parse', 'HEAD'], root);
|
||||
fs.writeFileSync(path.join(origin, 'REVIEW_VOICE_FIXTURE.md'), 'moved\n');
|
||||
git(['add', '-A'], origin);
|
||||
git(['commit', '-qm', 'origin moves'], origin);
|
||||
const tip = git(['rev-parse', 'HEAD'], origin);
|
||||
const moved = brief();
|
||||
expect(moved.status, moved.out + moved.stderr).toBe(0);
|
||||
expect(moved.out).toContain(`SELF_UPGRADE: done ${VERSION} -> ${VERSION}`);
|
||||
expect(git(['rev-parse', 'HEAD'], root)).toBe(tip);
|
||||
expect(trailer(moved.out)).toBe(OK_TRAILER);
|
||||
expect(moved.out).toContain(`SELF_UPGRADE: skipped (review-voice never self-upgrades a shared checkout; fix: ${root}/bin/gstack-upgrade when no other session uses this checkout (UPGRADE_SKIPPED))`);
|
||||
expect(moved.out).not.toContain('SELF_UPGRADE: done');
|
||||
expect(git(['rev-parse', 'HEAD'], root)).toBe(head);
|
||||
expect(trailer(moved.out)).toStartWith(`gstack: ok ${VERSION} for=review-voice (pins: skipped (helper runtime); behind=1 `);
|
||||
expect(trailer(moved.out)).toContain('(UPGRADE_SKIPPED)');
|
||||
}, 300_000);
|
||||
|
||||
test('a review-voice check never narrows an ok verdict a fuller install wrote for this HEAD and project', () => {
|
||||
|
||||
@@ -85,7 +85,7 @@ function fixture(opts: { minWrapper?: number; cloneAt?: 'A' | 'B' } = {}) {
|
||||
if ((opts.cloneAt ?? 'A') === 'A') git(['clone', '-q', '--single-branch', '--depth', '1', `file://${bare}`, root], host.base);
|
||||
write(path.join(seed, 'VERSION'), '1.0.1\n');
|
||||
write(path.join(seed, 'capy/support.env'), `GSTACK_MIN_REVISION=1.0.1\nGSTACK_CAPY_MIN_WRAPPER=${opts.minWrapper ?? 1}\n`);
|
||||
for (const rel of ['capy/skill/gstack/scripts/install.sh', 'capy/skill/gstack/SKILL.md', 'bin/gstack-capy-session', 'bin/gstack-install-verdict.sh', ...UPGRADE_DEPS]) {
|
||||
for (const rel of ['capy/skill/gstack/scripts/install.sh', 'capy/skill/gstack/SKILL.md', 'bin/gstack-capy-session', 'bin/gstack-install-verdict.sh', 'bin/gstack-doctor-components.sh', ...UPGRADE_DEPS]) {
|
||||
if (fs.existsSync(path.join(REPO, rel))) write(path.join(seed, rel), fs.readFileSync(path.join(REPO, rel), 'utf8'), 0o755);
|
||||
}
|
||||
write(path.join(seed, 'bin/gstack-capy-install'), STUB_INSTALLER, 0o755);
|
||||
@@ -420,6 +420,58 @@ describe('capy skill: the session hands the installer GSTACK_CAPY_BEHIND', () =>
|
||||
expect(f.git(['rev-parse', 'HEAD'])).toBe(f.c);
|
||||
});
|
||||
|
||||
// GST-290: a dispatched reviewer's install ran with no lease of its own, fast-forwarded the
|
||||
// shared checkout and rewrote the verdict to for=review-voice, so the lane's next autoplan
|
||||
// admission refused (INSTALL_UNVERIFIED). A review-voice install never moves the checkout.
|
||||
describe('--for review-voice never self-upgrades a shared checkout', () => {
|
||||
const blocked = (root: string) => `review-voice never self-upgrades a shared checkout; fix: ${root}/bin/gstack-upgrade when no other session uses this checkout (UPGRADE_SKIPPED)`;
|
||||
const behindFixture = () => {
|
||||
const f = fixture({ cloneAt: 'B' });
|
||||
write(path.join(f.root, 'bin/gstack-capy-install'), ENV_INSTALLER, 0o755);
|
||||
f.git(['update-index', '--assume-unchanged', 'bin/gstack-capy-install']);
|
||||
return f;
|
||||
};
|
||||
|
||||
test('install mode behind origin with no lease: skipped with the reason, HEAD stays, and the installer gets behind, gstack-upgrade as the fix and the blocked reason', () => {
|
||||
const f = behindFixture();
|
||||
const r = f.run(['--for', 'review-voice']);
|
||||
expect(r.status, r.stdout + r.stderr).toBe(0);
|
||||
expect(r.stdout).toContain(`SELF_UPGRADE: skipped (${blocked(f.root)})\n`);
|
||||
expect(r.stdout).not.toContain('SELF_UPGRADE: done');
|
||||
expect(r.last).toBe(`gstack: ok 1.0.1 for=autoplan behind=1 to=1.0.2 fix=${f.root}/bin/gstack-upgrade blocked=${blocked(f.root)}`);
|
||||
expect(f.git(['rev-parse', 'HEAD'])).toBe(f.b);
|
||||
expect(f.run(['--for', 'review-voice'], { GSTACK_UPGRADE_FORCE: '1' }).stdout).toContain(`SELF_UPGRADE: skipped (${blocked(f.root)})\n`);
|
||||
expect(f.git(['rev-parse', 'HEAD'])).toBe(f.b);
|
||||
});
|
||||
|
||||
test('install mode behind origin with bin/gstack-doctor-components.sh missing: still skipped (the profile check fails closed for review-voice)', () => {
|
||||
const f = behindFixture();
|
||||
f.git(['update-index', '--assume-unchanged', 'bin/gstack-doctor-components.sh']);
|
||||
fs.rmSync(path.join(f.root, 'bin/gstack-doctor-components.sh'));
|
||||
const r = f.run(['--for', 'review-voice']);
|
||||
expect(r.stdout).toContain(`SELF_UPGRADE: skipped (${blocked(f.root)})\n`);
|
||||
expect(f.git(['rev-parse', 'HEAD'])).toBe(f.b);
|
||||
});
|
||||
|
||||
test('install mode on a current checkout: none (current), and no blocked reason', () => {
|
||||
const f = fixture({ cloneAt: 'B' });
|
||||
f.git(['fetch', '-q', 'origin', 'main']);
|
||||
f.git(['merge', '--ff-only', '-q', 'FETCH_HEAD']);
|
||||
write(path.join(f.root, 'bin/gstack-capy-install'), ENV_INSTALLER, 0o755);
|
||||
f.git(['update-index', '--assume-unchanged', 'bin/gstack-capy-install']);
|
||||
const r = f.run(['--for', 'review-voice']);
|
||||
expect(r.stdout).toMatch(/^SELF_UPGRADE: none \(current\)$/m);
|
||||
expect(r.last).toBe('gstack: ok 1.0.2 for=autoplan behind=none to=none fix=none');
|
||||
});
|
||||
|
||||
test('--check behind origin: the fix is gstack-upgrade, not the install rerun (which would not move a review-voice checkout)', () => {
|
||||
const f = behindFixture();
|
||||
const r = f.run(['--check', '--for', 'review-voice']);
|
||||
expect(r.last).toBe(`gstack: ok 1.0.1 for=autoplan behind=1 to=1.0.2 fix=${f.root}/bin/gstack-upgrade`);
|
||||
expect(f.git(['rev-parse', 'HEAD'])).toBe(f.b);
|
||||
});
|
||||
});
|
||||
|
||||
test('an older bootstrapper handing --for none to the session in install mode: the session certifies nothing and moves nothing (no self-upgrade, HEAD unchanged), and reports behind like --check', () => {
|
||||
const f = fixture({ cloneAt: 'B' });
|
||||
write(path.join(f.root, 'bin/gstack-capy-install'), ENV_INSTALLER, 0o755);
|
||||
|
||||
@@ -223,6 +223,42 @@ describe('literals (pure)', () => {
|
||||
expect(consumersOf(root, all, { name: 'TIMEOUT_MS', old: '5000', new: '9000', file: 'lib/retry.js' })).toEqual(['test/retry.test.ts:1', 'test/retry-path.test.ts:2', 'test/retry-bare.test.ts:2']);
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
});
|
||||
test('consumersOf: a history row is skipped only when this branch added the new-value row to the same table; other old-value lines still count (GST-344)', () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-literals-history-'));
|
||||
const nl = String.fromCharCode(10);
|
||||
const head = "import { PROOF_CHECKS } from '../src/proof-checks.ts';";
|
||||
const rows = (...ns: number[]) => ns.map(n => ` [${n}, "digest-${n}"],`);
|
||||
const files: Record<string, [string[], string[] | null]> = {
|
||||
'test/history.test.ts': [[head, 'const versions = [', ...rows(44, 45, 46), '];', 'expect(PROOF_CHECKS).toBe(46);'], [head, 'const versions = [', ...rows(44, 45), '];', 'expect(PROOF_CHECKS).toBe(45);']],
|
||||
'test/newest-first.test.ts': [[head, 'const versions = [', ...rows(46, 45, 44), '];'], [head, 'const versions = [', ...rows(45, 44), '];']],
|
||||
'test/unextended.test.ts': [[head, 'const versions = [', ...rows(44, 45), '];'], [head, 'const versions = [', ...rows(44, 45), '];']],
|
||||
'test/stale-later.test.ts': [[head, 'const versions = [', ...rows(44, 45, 46), '];', 'expect(PROOF_CHECKS).toBe(45);'], [head, 'const versions = [', ...rows(44, 45), '];', 'expect(PROOF_CHECKS).toBe(45);']],
|
||||
'test/two-tables.test.ts': [[head, 'const a = [', ...rows(44, 45), '];', 'const b = [', ...rows(45, 46), '];'], [head, 'const a = [', ...rows(44, 45), '];', 'const b = [', ...rows(45), '];']],
|
||||
'test/boundary.test.ts': [[head, 'test.each([', ...rows(45, 46), '])("%d", () => {});'], [head, 'test.each([', ...rows(45, 46), '])("%d", () => {});']],
|
||||
'test/new-file.test.ts': [[head, 'const versions = [', ...rows(45, 46), '];'], null],
|
||||
'test/same-line.test.ts': [[head, 'const versions = [', ' [45, "digest-45"], expect(PROOF_CHECKS).toBe(45),', ' [46, "digest-46"],', '];'], [head, 'const versions = [', ' [45, "digest-45"], expect(PROOF_CHECKS).toBe(45),', '];']],
|
||||
};
|
||||
for (const [rel, [now]] of Object.entries(files)) { fs.mkdirSync(path.dirname(path.join(root, rel)), { recursive: true }); fs.writeFileSync(path.join(root, rel), now.join(nl) + nl); }
|
||||
const baseText = (rel: string) => files[rel]?.[1]?.join(nl) ?? null;
|
||||
const lineOf = (rel: string, text: string) => `${rel}:${files[rel]![0].indexOf(text) + 1}`;
|
||||
expect(consumersOf(root, Object.keys(files), { name: 'PROOF_CHECKS', old: '45', new: '46', file: 'src/proof-checks.ts' }, baseText).sort()).toEqual([
|
||||
lineOf('test/boundary.test.ts', ' [45, "digest-45"],'),
|
||||
lineOf('test/same-line.test.ts', ' [45, "digest-45"], expect(PROOF_CHECKS).toBe(45),'),
|
||||
lineOf('test/stale-later.test.ts', 'expect(PROOF_CHECKS).toBe(45);'),
|
||||
lineOf('test/two-tables.test.ts', ' [45, "digest-45"],'),
|
||||
lineOf('test/unextended.test.ts', ' [45, "digest-45"],'),
|
||||
].sort());
|
||||
fs.writeFileSync(path.join(root, 'test/versions.test.ts'), ["import { SCHEMA } from '../src/schema.ts';", 'const history = [', " ['v3', 'a'],", ' ["v4", \'b\'],', '];', ''].join(nl));
|
||||
const strings = (rel: string) => rel === 'test/versions.test.ts' ? ["import { SCHEMA } from '../src/schema.ts';", 'const history = [', " ['v3', 'a'],", '];'].join(nl) : null;
|
||||
expect(consumersOf(root, ['test/versions.test.ts'], { name: 'SCHEMA', old: "'v3'", new: "'v4'", file: 'src/schema.ts' }, strings)).toEqual([]);
|
||||
expect(consumersOf(root, ['test/versions.test.ts'], { name: 'SCHEMA', old: "'v3'", new: "'v4'", file: 'src/schema.ts' }, () => undefined)).toEqual(['test/versions.test.ts:3']);
|
||||
fs.writeFileSync(path.join(root, 'test/semver.test.ts'), ["import { RELEASE } from '../src/release.ts';", 'const history = [', " ['1.2.3', 'a'],", " ['1.2.4', 'b'],", '];', ''].join(nl));
|
||||
expect(consumersOf(root, ['test/semver.test.ts'], { name: 'RELEASE', old: "'1.2.3'", new: "'1.2.4'", file: 'src/release.ts' }, () => null)).toEqual([]);
|
||||
expect(consumersOf(root, ['test/semver.test.ts'], { name: 'RELEASE', old: "'1.2.3'", new: "'1.2.4'", file: 'src/release.ts' }, () => " ['1.2.4', 'b'],")).toEqual(['test/semver.test.ts:3']);
|
||||
fs.writeFileSync(path.join(root, 'test/big.test.ts'), [head, 'const versions = [', ...Array.from({ length: 20_000 }, () => ' [45, "x"],'), ' [46, "y"],', '];', ...Array.from({ length: 20_000 }, () => ''), ''].join(nl));
|
||||
expect(consumersOf(root, ['test/big.test.ts'], { name: 'PROOF_CHECKS', old: '45', new: '46', file: 'src/proof-checks.ts' }, () => ['', ...Array.from({ length: 20_000 }, () => '')].join(nl))).toEqual([]);
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
});
|
||||
test('runner summaries: bun, node --test, vitest, jest, pytest, zero-run', () => {
|
||||
expect(parseSummary(' 12 pass\n 1 fail\nRan 13 tests across 2 files.')).toEqual({ ran: 13, failed: 1 });
|
||||
expect(parseSummary('# tests 4\n# pass 4\n# fail 0')).toEqual({ ran: 4, failed: 0 });
|
||||
@@ -369,6 +405,25 @@ describe('gstack-pregate (bin)', () => {
|
||||
expect(pregate(repo, ['--stage', 'preflight', '--only', 'literals']).status).toBe(EXIT.ok);
|
||||
});
|
||||
|
||||
test('GST-344: a history table the branch extends passes literals through the merge-base read; one it does not extend fails', () => {
|
||||
const history = (...ns: number[]) => ["const { MAX_RETRIES } = require('../lib/retry.js');", 'const versions = [', ...ns.map(n => ` [${n}, 'digest-${n}'],`), '];', ''].join('\n');
|
||||
const repo = fixture({ baseFiles: { 'test/history.test.js': history(2, 3) } });
|
||||
const bump = { 'lib/retry.js': "exports.MAX_RETRIES = 4;\nexports.TIMEOUT_MS = 5000;\nexports.retry = (fn) => fn();\n", 'test/retry.test.js': "const test = require('node:test');\nconst assert = require('node:assert');\nconst { MAX_RETRIES } = require('../lib/retry.js');\ntest('retries four times', () => assert.strictEqual(MAX_RETRIES, 4));\n" };
|
||||
commit(repo, bump);
|
||||
const stale = pregate(repo, ['--stage', 'preflight', '--only', 'literals']);
|
||||
expect(stale.status).toBe(EXIT.fail);
|
||||
expect(stale.out).toContain('literals: test/history.test.js:4: MAX_RETRIES still 3 (now 4 in lib/retry.js)');
|
||||
commit(repo, { 'test/history.test.js': history(2, 3, 4) });
|
||||
const r = pregate(repo, ['--stage', 'preflight', '--only', 'literals']);
|
||||
expect(r.out).toMatch(/^preflight {2}literals {2}pass /m);
|
||||
expect(r.status).toBe(EXIT.ok);
|
||||
const renamed = fixture({ baseFiles: { 'test/history.test.js': history(2, 3, 4) } });
|
||||
commit(renamed, { ...bump, 'test/history.test.js': null, 'test/versions.test.js': history(2, 3, 4) });
|
||||
const held = pregate(renamed, ['--stage', 'preflight', '--only', 'literals']);
|
||||
expect(held.status).toBe(EXIT.fail);
|
||||
expect(held.out).toContain('literals: test/versions.test.js:4: MAX_RETRIES still 3 (now 4 in lib/retry.js)');
|
||||
});
|
||||
|
||||
test('secrets: a credential in an added line fails and cannot be downgraded; a committed-then-removed line is clean', () => {
|
||||
const repo = fixture({ pregate: { ...BASE_PREGATE, warn: ['secrets'] } });
|
||||
const bad = pregate(repo, ['--stage', 'preflight', '--only', 'secrets']);
|
||||
|
||||
Reference in new issue
Block a user