mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-01 22:45:02 +01:00
v1.92.24.0 fix(ship): finish accepts an owner-accepted documentation exception (GST-713) (#3776)
* fix(ship): base merges, adjacent base edits, test-only and audit-requested fixes no longer force a second review loop (GST-407, GST-384, GST-391)
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* ship driver: trim two phrases for headroom with #3662
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* ship driver: keep the Next steps heading phrase the handoff check reads; trim elsewhere
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* test: regenerate ship goldens
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* fix: pre-landing review fixes
comparePath returns same only when every hunk sits where the base change puts it (patch-ids ignore positions) and treats a file-mode change as changed; the scoped-review packet says when a fence is truncated; --docs-result must be the posted audit's result; the --context-reviewed record goes beside the packet, outside the worktree. Negative controls for each.
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* fix: pass-2 review fixes
A test edit routes as tests= only when it adds lines with no gate tag; a removed or changed line or an unreadable test diff goes through the full loop. Diffs skip textconv. A hunkless patch (binary, empty, mode-only) is same when its patch-id matches. Without --reviewed-base test files route as before. finish's packet path and attempt's state directory have fallbacks.
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* fix: Step 11 review fixes; a version-only stamp_files edit after review is release metadata (GBRA-251)
gateEditedPaths reads removed lines only inside hunks; carry refuses when a path listing fails; docs freshness stops on a failed listing; attempt counts an unreadable state file. releasePaths includes stamp_files paths whose edit is a version swap under stamp_patterns.
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* fix: no test edit skips review; guard packet writes; exclusive attempt state
A test-only edit with no gate tag gets the scoped fix-check of its test hunks instead of a lanes-only rerun. Packet write failures print a STOP or RECEIPT_CARRY_REFUSED. attempt's allowance uses an exclusive create.
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* fix: attempt prints gstack's install command; carry checks the product diff outside context paths
The attempt STOP no longer echoes the audit child's fix text. A scoped-review carry now requires the product patch-id without the context paths to match at both heads.
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* fix: oversized packets take the full loop; exact --docs-requested match; name-only test globs only in test dirs
A scoped-review packet section over 200 lines sends that path to the full loop instead of a truncated packet. The docs-result path match is exact. test_*.py, *Test.java and similar count as test code only under a test directory.
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* docs(unattended): carry's scoped-review refusal and what it keeps
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* docs: carry's scoped-review route in ship-policy, review-policy and troubleshooting (docs-a-1)
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* test(windows): curate ship-rereview-triggers out of the Windows lane like ship-release (run 41193336209)
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* fix: restore bin/gstack-ship-receipt after the main merge
The merge commit b10e2db wrote a truncated copy of the file. This is the
three-way merge of both sides with the refused() union: main's codes plus
RECEIPT_CARRY_SCOPED_REVIEW.
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* fix(ship): finish runs after an owner-accepted Step 14.5 documentation exception (GST-713)
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* test(ship): the exception match and every snapshot --exception refusal are covered (GST-713)
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* fix(ship): the exception snapshot uses the receipt's docs parser; finish also matches scope and stops on a corrupt docs-audit record (GST-713)
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* docs: DOCS_AUDIT_BLOCKED names the --exception snapshot finish needs (GST-713)
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* chore(changelog): drop #3684's fragment, already stamped into CHANGELOG.md on main
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
---------
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
This commit is contained in:
1 parent
b15492a148
commit
a2ce5bd888
11 files changed
+184
-11
No files matched your search
@@ -1,5 +1,9 @@
|
||||
# Changelog
|
||||
|
||||
## [1.92.24.0] - 2026-11-01
|
||||
|
||||
- `/ship` can finish after the owner accepts a Step 14.5 documentation risk. `gstack-docs-candidate snapshot --exception .gstack/tmp/docs-audit.json` writes the post-audit snapshot from the blocked record, naming who accepted it, the date, how and the base SHA, and `gstack-ship-release finish` prints that exception on its `DOCS:` line. Finish still stops on a blocked audit with no accepted exception, and on an exception snapshot whose docs-audit record no longer carries it (GST-713).
|
||||
|
||||
## [1.92.23.0] - 2026-11-01
|
||||
|
||||
- **OpenClaw and Hermes: opt-in start hook.** `gstack-harness register openclaw|hermes --start-hook` (or `gstack-config set harness_start_hook on`, or `GSTACK_HARNESS_START_HOOK=1`) adds a `gstack-start` plugin that puts a one-line note on every user turn telling the agent to run a gstack skill's start command as written. Agents in these harnesses read gstack's skills but ran that command on 1 of 29 chances. gstack turns the plugin on through each harness's own CLI, checks that it is active, and exits 4 when it is not. It is off by default, the choice is saved across upgrades, a plugin you disable in the harness stays off, and `--no-start-hook`, unregister and uninstall take it out. See docs/hosts.md#start-hook.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# gstack digest v1.92.23.0 — regenerate/re-copy after upgrading gstack
|
||||
# gstack digest v1.92.24.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,
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
*
|
||||
* Usage (run inside the repository):
|
||||
* gstack-docs-candidate snapshot --out <candidate.json> --audit-id <id> --mode edit|read-only
|
||||
* --base <rev> [--select <path>]... [--docs <path>]...
|
||||
* --base <rev> [--select <path>]... [--docs <path>]... [--exception <docs-audit.json>]
|
||||
* gstack-docs-candidate compare <candidate.json>
|
||||
* gstack-docs-candidate attempt <child-output> --state <attempts.json>
|
||||
* gstack-docs-candidate [snapshot|compare] --help
|
||||
@@ -16,6 +16,9 @@
|
||||
* selected paths are the committed, staged, unstaged and untracked paths.
|
||||
* --docs takes files or directories (directories expand to their tracked and
|
||||
* untracked files). It prints a short summary; the JSON file is the record.
|
||||
* --exception names the blocked .gstack/tmp/docs-audit.json an owner accepted
|
||||
* (Step 14.5 Blocked recovery): the record then carries that exception, so
|
||||
* Step 16 can tell an accepted risk from a skipped audit.
|
||||
*
|
||||
* compare re-reads the same state and prints one JSON object: whether HEAD or
|
||||
* the index changed, which recorded paths' content changed or disappeared, and
|
||||
@@ -32,6 +35,7 @@
|
||||
import { spawnSync } from 'child_process';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { parseReceiptDocs, type ReceiptDocs } from '../lib/ship-receipt-docs';
|
||||
|
||||
function git(args: string[]): string {
|
||||
const r = spawnSync('git', args, { encoding: 'utf8', env: { ...process.env, GIT_OPTIONAL_LOCKS: '0' }, maxBuffer: 256 * 1024 * 1024 });
|
||||
@@ -90,10 +94,20 @@ function one(args: string[], name: string): string {
|
||||
return values[0]!;
|
||||
}
|
||||
|
||||
const USAGE = `usage: gstack-docs-candidate snapshot --out <candidate.json> --audit-id <id> --mode edit|read-only --base <rev> [--select <path>]... [--docs <path>]...
|
||||
const USAGE = `usage: gstack-docs-candidate snapshot --out <candidate.json> --audit-id <id> --mode edit|read-only --base <rev> [--select <path>]... [--docs <path>]... [--exception <docs-audit.json>]
|
||||
gstack-docs-candidate compare <candidate.json>
|
||||
gstack-docs-candidate attempt <child-output> --state <attempts.json>`;
|
||||
|
||||
/** The owner's accepted Step 14.5 exception from the blocked .gstack/tmp/docs-audit.json record, read by the receipt's own parser; throws when the record is not blocked or names no complete exception. */
|
||||
function acceptedException(file: string): NonNullable<ReceiptDocs['exception']> & { scope: string } {
|
||||
let audit: unknown;
|
||||
try { audit = JSON.parse(fs.readFileSync(file, 'utf8')); } catch (error) { throw new Error(`--exception ${file} is not readable JSON: ${(error as Error).message}`); }
|
||||
const parsed = parseReceiptDocs(audit);
|
||||
const problem = !parsed.ok ? parsed.message : parsed.docs.status !== 'blocked' ? `its status is ${parsed.docs.status}, not blocked` : !parsed.docs.exception ? 'it names no accepted exception' : null;
|
||||
if (problem || !parsed.ok || !parsed.docs.exception) throw new Error(`--exception ${file}: ${problem}. Only an owner-accepted documentation risk writes this snapshot (ship/sections/documentation.md, Blocked recovery step 3)`);
|
||||
return { ...parsed.docs.exception, scope: parsed.docs.scope };
|
||||
}
|
||||
|
||||
/** Each field compare needs that the record lacks or types wrongly; empty for a valid record. */
|
||||
function recordProblems(record: any): string[] {
|
||||
if (!record || typeof record !== 'object' || Array.isArray(record)) return ['not a JSON object'];
|
||||
@@ -113,7 +127,7 @@ function main(argv: string[]): void {
|
||||
return;
|
||||
}
|
||||
if (command === 'snapshot') {
|
||||
const known = new Set(['--out', '--audit-id', '--mode', '--base', '--select', '--docs']);
|
||||
const known = new Set(['--out', '--audit-id', '--mode', '--base', '--select', '--docs', '--exception']);
|
||||
for (let i = 0; i < args.length; i += 2) if (!known.has(args[i]!)) throw new Error(`unknown argument: ${args[i]}`);
|
||||
const out = path.resolve(one(args, '--out'));
|
||||
const top = fs.realpathSync(git(['rev-parse', '--show-toplevel']).trim());
|
||||
@@ -121,6 +135,8 @@ function main(argv: string[]): void {
|
||||
const mode = one(args, '--mode');
|
||||
if (mode !== 'edit' && mode !== 'read-only') throw new Error('--mode must be edit or read-only');
|
||||
const base = git(['rev-parse', '--verify', one(args, '--base') + '^{commit}']).trim();
|
||||
const exceptionFile = flag(args, '--exception')[0];
|
||||
const exception = exceptionFile === undefined ? undefined : acceptedException(exceptionFile);
|
||||
process.chdir(top);
|
||||
const now = state(base);
|
||||
const listed = nul(git(['ls-files', '-z', '--cached', '--others', '--exclude-standard']));
|
||||
@@ -128,10 +144,11 @@ function main(argv: string[]): void {
|
||||
: [...new Set([...now.committed, ...now.staged, ...now.unstaged, ...now.untracked])].sort();
|
||||
const docs = expand(listed, flag(args, '--docs'));
|
||||
const tracked = [...new Set([...selected, ...docs, ...now.staged, ...now.unstaged, ...now.untracked])].sort();
|
||||
const record = { schema_version: 1, audit_id: one(args, '--audit-id'), mode, base, ...now, selected, docs, hashes: hashes(tracked) };
|
||||
const record = { schema_version: 1, audit_id: one(args, '--audit-id'), mode, base, ...now, selected, docs, hashes: hashes(tracked), ...(exception ? { exception } : {}) };
|
||||
fs.writeFileSync(out, JSON.stringify(record, null, 2) + '\n', { mode: 0o600 });
|
||||
console.log(`CANDIDATE: ${out}`);
|
||||
console.log(`base=${base} head=${now.head} branch=${now.branch || '(detached)'} mode=${mode}`);
|
||||
if (exception) console.log(`EXCEPTION: accepted by ${exception.accepted_by} ${exception.date} via ${exception.via}`);
|
||||
console.log(`committed=${now.committed.length} staged=${now.staged.length} unstaged=${now.unstaged.length} untracked=${now.untracked.length} selected=${selected.length} docs=${docs.length} hashed=${tracked.length}`);
|
||||
for (const [label, list] of [['committed', now.committed], ['staged', now.staged], ['unstaged', now.unstaged], ['untracked', now.untracked], ['docs', docs]] as const) {
|
||||
if (list.length) console.log(`${label}: ${list.map(p => JSON.stringify(p)).join(' ')}`);
|
||||
|
||||
@@ -5661,7 +5661,10 @@ over` line names (or raise its cap on the base branch). When the owner accepts
|
||||
the named risk instead, record it as `docs.exception: { accepted_by, date, via,
|
||||
reason }` and carry the `Documentation exception: accepted by <owner> <date> via
|
||||
<ref>: <reason>` line in the PR body's `## Documentation` section, word for word
|
||||
([ship-policy.md](ship-policy.md#ship-receipt)).
|
||||
([ship-policy.md](ship-policy.md#ship-receipt)). Then save the post-audit snapshot with
|
||||
`gstack-docs-candidate snapshot --exception .gstack/tmp/docs-audit.json`;
|
||||
`gstack-ship-release finish` stops on a blocked audit whose snapshot lacks the
|
||||
exception.
|
||||
<a id="gstack-pr-wait"></a>
|
||||
## gstack-pr-wait
|
||||
|
||||
|
||||
+23
-1
@@ -13,6 +13,7 @@ import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { classifyPath } from './review-depth';
|
||||
import { comparePath, contextPacket, gateEditedPaths, isTestCode, PACKET_SECTION_CAP, readScopedReview, touchedPaths, type PatchSides } from './ship-patch';
|
||||
import { parseReceiptDocs, type ReceiptDocs } from './ship-receipt-docs';
|
||||
import { resolveStateRoot } from './state-root';
|
||||
import { bumpVersion, cmpVersion, fmtVersion, parseVersion, versionWidth, type Bump } from './version-source';
|
||||
import { bin, evidenceExempt, firstLine, flagReader, fragmentPath, gh, git, hostedOrigin, laneLine, lanes, releasePaths, Report, runLanes, sh, shipPolicy, short, stampedFiles } from './ship-bin-common';
|
||||
@@ -223,6 +224,26 @@ function docsFresh(cwd: string, base: string, postFile: string, moved: readonly
|
||||
try { stale = JSON.parse(cmp.stdout).content_changed; } catch { stale = null; }
|
||||
if (stale === null) { r.stop(`gstack-docs-candidate compare failed: ${firstLine(cmp.stderr)}; documentation freshness is unknown (stage 3)`); return false; }
|
||||
const record = JSON.parse(fs.readFileSync(postFile, 'utf8'));
|
||||
const auditFile = path.join(cwd, '.gstack/tmp/docs-audit.json');
|
||||
let docsAudit: ReceiptDocs | null = null;
|
||||
if (fs.existsSync(auditFile)) {
|
||||
let raw: unknown;
|
||||
try { raw = JSON.parse(fs.readFileSync(auditFile, 'utf8')); } catch { raw = undefined; }
|
||||
const parsed = parseReceiptDocs(raw);
|
||||
if (!parsed.ok) { r.stop(`.gstack/tmp/docs-audit.json: ${parsed.message}; documentation status is unknown (stage 3)`); return false; }
|
||||
docsAudit = parsed.docs;
|
||||
}
|
||||
const accepted = docsAudit?.exception;
|
||||
if (accepted || record.exception) {
|
||||
const same = !!accepted && !!record.exception && docsAudit!.status === 'blocked' && record.exception.scope === docsAudit!.scope
|
||||
&& (['accepted_by', 'date', 'via', 'reason'] as const).every(k => record.exception[k] === accepted[k]);
|
||||
if (!same) {
|
||||
r.stop(accepted
|
||||
? `the Step 14.5 audit is blocked and ${postFile} names no matching owner-accepted exception: snapshot with --exception .gstack/tmp/docs-audit.json (documentation.md Blocked recovery step 3)`
|
||||
: `${postFile} names a documentation exception but .gstack/tmp/docs-audit.json is not the blocked record carrying it: rerun Step 14.5`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
const baseRef = `origin/${base.replace(/^origin\//, '')}`;
|
||||
const nul = (out: string) => out.split('\0').filter(Boolean);
|
||||
const auditBase = git(cwd, ['merge-base', baseRef, record.head]).stdout.trim();
|
||||
@@ -252,7 +273,8 @@ function docsFresh(cwd: string, base: string, postFile: string, moved: readonly
|
||||
if (carried.requested.length && bin(cwd, 'gstack-ship-receipt', ['docs-caps', '--base', base], 60_000).status !== 0) { r.stop('a --docs-requested fix breaks a doc cap (gstack-ship-receipt docs-caps): trim the file, then rerun finish'); return false; }
|
||||
const left = stale.filter(p => !carried.tests.includes(p) && !carried.base.includes(p) && !carried.requested.includes(p));
|
||||
const note = left.length || stale.length === 0 ? '' : ` (carried: tests=${carried.tests.length} base=${carried.base.length} requested=${carried.requested.length})`;
|
||||
r.add(`DOCS: ${left.length ? `stale (${short(left)})` : `fresh against the Step 14.5 post-audit snapshot${note}`}`);
|
||||
const exception = record.exception ? ` (blocked; documentation exception accepted by ${record.exception.accepted_by} ${record.exception.date} via ${record.exception.via})` : '';
|
||||
r.add(`DOCS: ${left.length ? `stale (${short(left)})` : `fresh against the Step 14.5 post-audit snapshot${note}${exception}`}`);
|
||||
if (left.length) { r.stop('documentation inputs changed since the accepted audit: use stage 3 recovery (the remaining re-audit, a fix the audit\'s result asked for via --docs-requested <path> --docs-result <audit-id>-result.json, or a named risk); never refresh hashes'); return false; }
|
||||
return true;
|
||||
}
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "gstack",
|
||||
"version": "1.92.23",
|
||||
"version": "1.92.24",
|
||||
"description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
|
||||
@@ -137,4 +137,6 @@ auto-commit or push unexpected child commits.
|
||||
`.gstack/tmp/docs-audit.json` as `{"status":"blocked","scope":"<scope>","exception":{"accepted_by":"<owner>","date":"<YYYY-MM-DD>","via":"<their reply or review reference>","reason":"<their words, one line>"}}`;
|
||||
the PR body's Documentation section repeats it word for word, and
|
||||
`gstack-ship-receipt write --docs` refuses a blocked record without one
|
||||
(`DOCS_AUDIT_BLOCKED`). An unattended run has no owner to accept: it stops here.
|
||||
(`DOCS_AUDIT_BLOCKED`). Then save Step 16's post snapshot from it: the Prepare
|
||||
`snapshot` with `--out <audit-id>-post.json --exception .gstack/tmp/docs-audit.json`;
|
||||
finish refuses a blocked audit whose post snapshot lacks it. An unattended run has no owner to accept: it stops here.
|
||||
@@ -135,4 +135,6 @@ auto-commit or push unexpected child commits.
|
||||
`.gstack/tmp/docs-audit.json` as `{"status":"blocked","scope":"<scope>","exception":{"accepted_by":"<owner>","date":"<YYYY-MM-DD>","via":"<their reply or review reference>","reason":"<their words, one line>"}}`;
|
||||
the PR body's Documentation section repeats it word for word, and
|
||||
`gstack-ship-receipt write --docs` refuses a blocked record without one
|
||||
(`DOCS_AUDIT_BLOCKED`). An unattended run has no owner to accept: it stops here.
|
||||
(`DOCS_AUDIT_BLOCKED`). Then save Step 16's post snapshot from it: the Prepare
|
||||
`snapshot` with `--out <audit-id>-post.json --exception .gstack/tmp/docs-audit.json`;
|
||||
finish refuses a blocked audit whose post snapshot lacks it. An unattended run has no owner to accept: it stops here.
|
||||
@@ -0,0 +1,119 @@
|
||||
/**
|
||||
* gstack-ship-release finish after an owner-accepted Step 14.5 documentation exception (GST-713, GEVA-84): the audit
|
||||
* reader was skipped, so no post-audit snapshot existed and finish refused for want of a gstack-docs-candidate record.
|
||||
* The accepted exception now writes that record (gstack-docs-candidate snapshot --exception), and finish accepts it only
|
||||
* when it matches the blocked docs-audit.json the owner's answer was written into.
|
||||
*/
|
||||
import { afterEach, describe, expect, setDefaultTimeout, test } from 'bun:test';
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { lineOf, shipFixture, type ShipFx } from './helpers/ship-bin-fixture';
|
||||
|
||||
setDefaultTimeout(120_000);
|
||||
const RELEASE = path.join(import.meta.dir, '..', 'bin', 'gstack-ship-release');
|
||||
const CANDIDATE = path.join(import.meta.dir, '..', 'bin', 'gstack-docs-candidate');
|
||||
const WTREE = path.join(import.meta.dir, '..', 'bin', 'gstack-wtree');
|
||||
const fixtures: ShipFx[] = [];
|
||||
afterEach(() => { while (fixtures.length) fixtures.pop()!.cleanup(); });
|
||||
const LANE = 'tests=printf " 3 pass\\n 0 fail\\nRan 3 tests across 1 file. [5.00ms]\\n"';
|
||||
const EXCEPTION = { accepted_by: 'Garry', date: '2026-10-31', via: 'GStack Minder relay', reason: 'ship with the docs audit skipped' };
|
||||
|
||||
/** A branch at Step 16 whose Step 14.5 audit is blocked: the run's .gstack/tmp exclude and docs-audit.json, the reviewed tree. */
|
||||
function blockedAudit(audit: object): { f: ShipFx; reviewed: string } {
|
||||
const f = shipFixture(); fixtures.push(f);
|
||||
fs.appendFileSync(path.join(f.work, '.git', 'info', 'exclude'), '/.gstack/tmp/\n');
|
||||
f.write({ '.gstack/tmp/docs-audit.json': JSON.stringify(audit) + '\n' });
|
||||
const reviewed = f.run(WTREE, []).stdout.trim();
|
||||
expect(f.run(RELEASE, ['version', '--base', 'main', '--bump', 'micro']).status).toBe(0);
|
||||
return { f, reviewed };
|
||||
}
|
||||
const snapshot = (f: ShipFx, extra: string[] = []) => {
|
||||
const out = path.join(f.root, 'post.json');
|
||||
return { out, r: f.run(CANDIDATE, ['snapshot', '--out', out, '--audit-id', 'a1', '--mode', 'edit', '--base', 'main', '--docs', 'README.md', ...extra]) };
|
||||
};
|
||||
|
||||
describe('finish after an accepted documentation exception (GST-713)', () => {
|
||||
// Value: protects=an owner-accepted Step 14.5 risk lets finish run, and the record names who accepted it and the base;
|
||||
// fails_when=finish needs a docs-candidate record only the skipped reader writes, so an accepted exception can never finish;
|
||||
// why_new=GEVA-84 hit it on gstack-evals#102; seam=gstack-docs-candidate snapshot --exception; no_claim=the PR body's exception line
|
||||
test('the exception snapshot names the owner and base, and finish passes with the exception on its DOCS line', () => {
|
||||
const { f, reviewed } = blockedAudit({ status: 'blocked', scope: 'README.md', exception: EXCEPTION });
|
||||
const s = snapshot(f, ['--exception', '.gstack/tmp/docs-audit.json']);
|
||||
expect(s.r.status, s.r.stderr).toBe(0);
|
||||
expect(s.r.stdout).toContain('EXCEPTION: accepted by Garry 2026-10-31 via GStack Minder relay');
|
||||
const record = JSON.parse(fs.readFileSync(s.out, 'utf8'));
|
||||
expect([record.schema_version, record.base, record.exception]).toEqual([1, f.git('rev-parse', 'main').stdout.trim(), { ...EXCEPTION, scope: 'README.md' }]);
|
||||
const ok = f.run(RELEASE, ['finish', '--base', 'main', '--reviewed-tree', reviewed, '--docs-post', s.out, '--lane', LANE]);
|
||||
expect(lineOf(ok.stdout, 'DOCS')).toBe('DOCS: fresh against the Step 14.5 post-audit snapshot (blocked; documentation exception accepted by Garry 2026-10-31 via GStack Minder relay)');
|
||||
expect([ok.status, ok.stdout.split('\n').find(l => l.startsWith('STOP:'))]).toEqual([0, undefined]);
|
||||
});
|
||||
|
||||
// Value: protects=finish never passes a blocked audit nobody accepted; fails_when=a blocked record with no exception, or a plain
|
||||
// snapshot taken over a blocked audit, gets past stage 3; seam=gstack-docs-candidate snapshot, finish stage 3; no_claim=who may accept
|
||||
test('no accepted exception: the exception snapshot refuses, and a plain snapshot over a blocked audit stops finish', () => {
|
||||
const { f, reviewed } = blockedAudit({ status: 'blocked', scope: 'README.md' });
|
||||
const refused = snapshot(f, ['--exception', '.gstack/tmp/docs-audit.json']);
|
||||
expect(refused.r.status).not.toBe(0);
|
||||
expect(refused.r.stderr).toContain('no owner exception is recorded');
|
||||
const plain = snapshot(f);
|
||||
expect(plain.r.status).toBe(0);
|
||||
const stop = f.run(RELEASE, ['finish', '--base', 'main', '--reviewed-tree', reviewed, '--docs-post', plain.out, '--lane', LANE]);
|
||||
expect(stop.status).toBe(3);
|
||||
expect(stop.stdout).toContain('STOP: .gstack/tmp/docs-audit.json: the documentation audit is blocked (README.md) and no owner exception is recorded');
|
||||
});
|
||||
|
||||
// Value: protects=an exception record cannot be reused after the docs-audit record changes; fails_when=finish trusts the snapshot's
|
||||
// exception without the blocked docs-audit.json carrying the same one; seam=finish stage 3; no_claim=receipt docs field
|
||||
test('an exception snapshot whose docs-audit.json no longer carries it stops finish', () => {
|
||||
const { f, reviewed } = blockedAudit({ status: 'blocked', scope: 'README.md', exception: EXCEPTION });
|
||||
const s = snapshot(f, ['--exception', '.gstack/tmp/docs-audit.json']);
|
||||
expect(s.r.status).toBe(0);
|
||||
f.write({ '.gstack/tmp/docs-audit.json': JSON.stringify({ status: 'current', scope: 'README.md' }) + '\n' });
|
||||
const stop = f.run(RELEASE, ['finish', '--base', 'main', '--reviewed-tree', reviewed, '--docs-post', s.out, '--lane', LANE]);
|
||||
expect(stop.status).toBe(3);
|
||||
expect(stop.stdout).toContain('names a documentation exception but .gstack/tmp/docs-audit.json is not the blocked record carrying it');
|
||||
});
|
||||
|
||||
// Value: protects=finish matches the snapshot's exception to the blocked record field by field; fails_when=any blocked record
|
||||
// with any exception passes, so another owner's or an edited answer finishes; seam=finish stage 3; no_claim=receipt docs field
|
||||
test('a blocked audit carrying a different exception than the snapshot stops finish', () => {
|
||||
const { f, reviewed } = blockedAudit({ status: 'blocked', scope: 'README.md', exception: EXCEPTION });
|
||||
expect(snapshot(f, ['--exception', '.gstack/tmp/docs-audit.json']).r.status).toBe(0);
|
||||
f.write({ '.gstack/tmp/docs-audit.json': JSON.stringify({ status: 'blocked', scope: 'README.md', exception: { ...EXCEPTION, accepted_by: 'Someone else' } }) + '\n' });
|
||||
const stop = f.run(RELEASE, ['finish', '--base', 'main', '--reviewed-tree', reviewed, '--docs-post', path.join(f.root, 'post.json'), '--lane', LANE]);
|
||||
expect(stop.status).toBe(3);
|
||||
expect(stop.stdout).toContain('names no matching owner-accepted exception');
|
||||
});
|
||||
|
||||
// Value: protects=finish stops on a docs-audit.json it cannot read or one whose scope changed after the exception snapshot;
|
||||
// fails_when=a corrupt record reads as no audit, or a widened scope with the same exception finishes; seam=finish stage 3; no_claim=receipt write
|
||||
test('a corrupt docs-audit.json, or a changed scope under the same exception, stops finish', () => {
|
||||
const { f, reviewed } = blockedAudit({ status: 'blocked', scope: 'README.md', exception: EXCEPTION });
|
||||
expect(snapshot(f, ['--exception', '.gstack/tmp/docs-audit.json']).r.status).toBe(0);
|
||||
const post = path.join(f.root, 'post.json');
|
||||
f.write({ '.gstack/tmp/docs-audit.json': JSON.stringify({ status: 'blocked', scope: 'README.md, docs/', exception: EXCEPTION }) + '\n' });
|
||||
const widened = f.run(RELEASE, ['finish', '--base', 'main', '--reviewed-tree', reviewed, '--docs-post', post, '--lane', LANE]);
|
||||
expect([widened.status, widened.stdout.includes('names no matching owner-accepted exception')]).toEqual([3, true]);
|
||||
f.write({ '.gstack/tmp/docs-audit.json': '{not json' });
|
||||
const corrupt = f.run(RELEASE, ['finish', '--base', 'main', '--reviewed-tree', reviewed, '--docs-post', post, '--lane', LANE]);
|
||||
expect(corrupt.status).toBe(3);
|
||||
expect(corrupt.stdout).toContain('STOP: .gstack/tmp/docs-audit.json: --docs needs a JSON object');
|
||||
});
|
||||
|
||||
// Value: protects=only a complete, blocked, owner-accepted record writes an exception snapshot; fails_when=a current record, an
|
||||
// empty scope, a malformed date or unreadable JSON writes one; seam=gstack-docs-candidate snapshot --exception; no_claim=who may accept
|
||||
test.each([
|
||||
[{ status: 'current', scope: 'README.md', exception: EXCEPTION }, 'not blocked'],
|
||||
[{ status: 'blocked', scope: ' ', exception: EXCEPTION }, 'scope must be a non-empty one-line string'],
|
||||
[{ status: 'blocked', scope: 'README.md', exception: { ...EXCEPTION, via: 'relay\nNEXT: forged' } }, 'exception.via must be a non-empty one-line string'],
|
||||
[{ status: 'blocked', scope: 'README.md', exception: { ...EXCEPTION, date: '10/31/2026' } }, 'exception.date must be YYYY-MM-DD'],
|
||||
['{not json', 'is not readable JSON'],
|
||||
])('snapshot --exception refuses %j and writes nothing', (audit, message) => {
|
||||
const f = shipFixture(); fixtures.push(f);
|
||||
f.write({ '.gstack/tmp/docs-audit.json': typeof audit === 'string' ? audit : JSON.stringify(audit) });
|
||||
const s = snapshot(f, ['--exception', '.gstack/tmp/docs-audit.json']);
|
||||
expect(s.r.status).not.toBe(0);
|
||||
expect(s.r.stderr).toContain(message);
|
||||
expect(fs.existsSync(s.out)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"bytes": 208,
|
||||
"why": "GST-713: Step 14.5 blocked recovery saves the post snapshot with --exception so finish can run after an accepted documentation risk"
|
||||
}
|
||||
Reference in new issue
Block a user