mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-03 07:18:03 +01:00
v1.93.19.0 fix(receipt): --check-prose skips the Documentation exception line and closed code fences (GST-983, GST-984) (#3884)
* fix(receipt): --check-prose skips the Documentation exception line and closed code fences (GST-983, GST-984)
write --docs with an owner exception requires the PR body to carry the Documentation exception line word for word, and read --check-prose read the owner reason in it as counter claims, the deadlock GST-977 fixed for the Review-loop exception line. Pasted test output and quoted code in fenced blocks were read as receipt counts too (gstack-evals #142). The prose check now skips the exact docs exception line and every closed code fence; a fence that never closes stays prose. Failing-first: the three new cases fail on main d45c3501e.
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* fix(receipt): inline triple backticks open no fence, and nothing after an unclosed fence is skipped (GST-984 review)
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
8af121fd4c
commit
cb72cc13b1
8 files changed
+123
-26
No files matched your search
@@ -1,5 +1,16 @@
|
||||
# Changelog
|
||||
|
||||
## [1.93.19.0] - 2026-11-02
|
||||
|
||||
**`gstack-ship-receipt read --check-prose` no longer reads the Documentation exception line or code fences as counter claims (GST-983, GST-984).** `write --docs` with an owner exception requires the PR body to carry the whole `Documentation exception: …` line word for word, but the prose check read the owner's reason in it as prose, so a reason like "sections 1 and 2 skip the audit" was a `2 skip` claim and no body could pass both checks. That is the deadlock GST-977 fixed for the Review-loop exception line. Separately, on gstack-evals #142, test output pasted in a fenced block ("113 passed, 0 failed") and a quoted code comment ("the first 35 fail") were read as receipt counts and refused.
|
||||
|
||||
### Fixed
|
||||
- **`lib/ship-receipt-prose.ts`:** the line that matches the receipt's own docs exception exactly is skipped, like the Review-loop exception line. The same words anywhere else, or a line for a different exception, are still checked.
|
||||
- **`lib/ship-receipt-prose.ts`:** a closed code fence (backticks or tildes, closed by a run of its own character at least as long as the opener) is skipped. Counts outside the fence are still checked. A line that starts with inline code like ```` ```npm test``` ```` opens no fence, and a fence that never closes stays prose along with the rest of the body, so an unclosed opener cannot hide the claims after it.
|
||||
|
||||
### Tests
|
||||
- `test/ship-receipt-prose.test.ts`: the docs exception line passes `--check-prose` beside a matching `70 skip` claim; the #142 fenced evidence passes while a wrong count outside the fence still fails; an unclosed fence stays prose; inline triple backticks and a shorter fence inside an unclosed one hide no claim. On main the first three cases failed.
|
||||
|
||||
## [1.93.18.0] - 2026-11-02
|
||||
|
||||
**A green Free Tests run at the pregate tree now clears gstack's remote gate, so a slow Ubicloud VM no longer blocks a PR whose CI already passed (GST-1024).** docs/pregate.md described `gstack-pregate clear --remote pregate.json/remote_gate --run <id|url> --allow-repo-commands`, but gstack's own `.gstack/pregate.json` declared no `remote_gate_runs`, so the route always refused. On #3869, every check passed at 3f2bcf3 (Free Tests run 41484634592), while two remote gate runs at the same tree failed on bun timeouts in files the PR doesn't touch.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# gstack digest v1.93.18.0 — regenerate/re-copy after upgrading gstack
|
||||
# gstack digest v1.93.19.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,
|
||||
|
||||
+11
-7
@@ -1233,13 +1233,17 @@ both values; prose that quotes no counter is not a claim. The `## Approvals`
|
||||
block `gstack-approvals` renders (its heading, the Date | Asked | Exact words
|
||||
table and the `Source: gstack-approvals record` line) quotes the owner's
|
||||
words, not this PR's numbers, so it is skipped: "one Step 11 pass" in that table
|
||||
is not a `pass` claim. Text you write under the heading after that block, and
|
||||
a `## Approvals` example inside a code fence, are still checked. The
|
||||
`Review-loop exception: …` line the receipt's own exception requires is
|
||||
skipped the same way (GST-977): it quotes the owner's reason, so "outside 1 and
|
||||
2 skip detector" is not a `2 skip` claim. Only a line that matches the rendered
|
||||
line exactly is skipped, inside a code fence too, as the receipt check accepts
|
||||
a fenced copy; the same words inside a longer line are still checked. Data PRs (evals,
|
||||
is not a `pass` claim. Text you write under the heading after that block is
|
||||
still checked. The `Review-loop exception: …` line (GST-977) and the
|
||||
`Documentation exception: …` line (GST-983) the receipt's own exceptions
|
||||
require are skipped the same way: they quote the owner's reason, so "outside 1
|
||||
and 2 skip detector" is not a `2 skip` claim. Only a line that matches the
|
||||
rendered line exactly is skipped; the same words inside a longer line are still
|
||||
checked. A closed code fence is skipped too (GST-984): pasted test output
|
||||
("113 pass, 0 fail") and quoted code are evidence, not this PR's counters, so
|
||||
put a count you mean as a claim outside the fence. A line like ```` ```npm test``` ran ````
|
||||
opens no fence, and a fence that never closes stays prose, along with the rest
|
||||
of the body. Data PRs (evals,
|
||||
measurements, sweeps) run it before READY so the body's numbers are the run's.
|
||||
|
||||
<a id="review-scope"></a>
|
||||
|
||||
@@ -5921,8 +5921,9 @@ body that names a counter the receipt records (`passes=3` against
|
||||
against `gate.passed: 41`) with a different value. The receipt is written from
|
||||
the run; the prose was typed, so a reader of the body would learn a number the
|
||||
run did not produce. The `PROSE:` line names each field with both values. The
|
||||
`## Approvals` table `gstack-approvals` renders, and the `Review-loop exception: …`
|
||||
line the receipt's exception requires, are never read as claims.
|
||||
`## Approvals` table `gstack-approvals` renders, the `Review-loop exception: …`
|
||||
and `Documentation exception: …` lines the receipt's exceptions require, and
|
||||
the inside of a closed code fence are never read as claims.
|
||||
|
||||
**Fix.** Correct the body to the receipt's number. When the receipt is the
|
||||
stale one (the run was repeated), rerun `/ship` so the receipt is rewritten
|
||||
|
||||
+44
-14
@@ -8,6 +8,7 @@
|
||||
import { APPROVALS_FOOTER_END, APPROVALS_TABLE_HEADER } from './approval-record';
|
||||
import { RECEIPT_FENCE } from './ship-receipt';
|
||||
import { parseReviewLoopException, reviewLoopExceptionLine } from './ship-receipt-depth';
|
||||
import { docsExceptionLine, parseReceiptDocs } from './ship-receipt-docs';
|
||||
|
||||
export interface ProseClaim { field: string; prose: number; receipt: number }
|
||||
export interface ProseCheck { checked: number; mismatches: ProseClaim[] }
|
||||
@@ -29,6 +30,32 @@ export function receiptCounters(receipt: Record<string, unknown>): Map<string, n
|
||||
const BLOCK_RE = new RegExp('```' + RECEIPT_FENCE + '[ \\t]*\\r?\\n[\\s\\S]*?\\r?\\n```', 'g');
|
||||
const ESCAPE = /[.*+?^${}()|[\]\\]/g;
|
||||
const FENCE_OPEN = /^ {0,3}(`{3,}|~{3,})/;
|
||||
const fenceClose = (fence: string) => new RegExp(`^ {0,3}${fence[0] === '`' ? '`' : '~'}{${fence.length},}\\s*$`);
|
||||
|
||||
/**
|
||||
* The body without its closed code fences (GST-984): pasted test output (`113 pass, 0 fail`) and quoted code
|
||||
* (`the first 35 failing`) are evidence, not this PR's counters. The fence rules are withoutApprovalsBlocks' (a fence
|
||||
* closes only on a run of its own character at least as long as the opener with nothing after it). A backtick run with
|
||||
* another backtick later on its line (```npm test``` inline code) opens no fence, as in CommonMark. A fence that never
|
||||
* closes stays prose, and so does the rest of the body, so an unclosed opener cannot hide the claims after it or let a
|
||||
* shorter fence inside it hide them. A marker line takes each fence's place so the text on either side never joins
|
||||
* into a claim.
|
||||
*/
|
||||
function withoutFences(body: string): string {
|
||||
const lines = body.split(/(?<=\n)/);
|
||||
const out: string[] = [];
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i]!.replace(/\r?\n$/, '');
|
||||
const open = FENCE_OPEN.exec(line);
|
||||
if (!open || (open[1]![0] === '`' && line.slice(open[0].length).includes('`'))) { out.push(lines[i]!); continue; }
|
||||
const close = fenceClose(open[1]!);
|
||||
const end = lines.findIndex((l, j) => j > i && close.test(l.replace(/\r?\n$/, '')));
|
||||
if (end < 0) return out.join('') + lines.slice(i).join('');
|
||||
out.push('(code fence)\n');
|
||||
i = end;
|
||||
}
|
||||
return out.join('');
|
||||
}
|
||||
|
||||
/**
|
||||
* The body without the `## Approvals` blocks gstack-approvals renders (lib/approval-record.ts renderRecord): the
|
||||
@@ -55,7 +82,7 @@ function withoutApprovalsBlocks(body: string): string {
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = bare(i);
|
||||
if (fence !== null) {
|
||||
if (new RegExp(`^ {0,3}${fence[0] === '`' ? '`' : '~'}{${fence.length},}\\s*$`).test(line)) fence = null;
|
||||
if (fenceClose(fence).test(line)) fence = null;
|
||||
} else if (FENCE_OPEN.test(line)) fence = FENCE_OPEN.exec(line)![1]!;
|
||||
else if (/^ {0,3}## Approvals\s*$/.test(line)) {
|
||||
const end = blockEnd(i);
|
||||
@@ -67,23 +94,26 @@ function withoutApprovalsBlocks(body: string): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* The body without the `Review-loop exception: …` line the receipt's own exception requires (GST-977): write and the
|
||||
* receipt check want it word for word, and it quotes the owner's reason (`outside 1 and 2 skip detector`), not this
|
||||
* PR's counters. Only a line whose trimmed text is that rendered line, the test write applies, is left out (a fenced
|
||||
* copy too, as write accepts one); the same words inside a longer line stay prose. A marker line takes its place so
|
||||
* the text on either side never joins into a claim.
|
||||
* The body without the exception lines the receipt's own exceptions require: the `Review-loop exception: …` line
|
||||
* (GST-977) and the `Documentation exception: …` line (GST-983). write and the receipt check want each word for word,
|
||||
* and each quotes the owner's reason (`outside 1 and 2 skip detector`), not this PR's counters. Only a line whose
|
||||
* trimmed text is a rendered line, the test write applies, is left out; the same words inside a longer line stay
|
||||
* prose. A marker line takes its place so the text on either side never joins into a claim.
|
||||
*/
|
||||
function withoutExceptionLine(body: string, exception: unknown): string {
|
||||
if (exception === undefined) return body;
|
||||
const parsed = parseReviewLoopException(exception);
|
||||
if (!parsed.ok) return body;
|
||||
const line = reviewLoopExceptionLine(parsed.exception);
|
||||
return body.split(/(?<=\n)/).map(l => (l.trim() === line ? '(review-loop exception line)\n' : l)).join('');
|
||||
function withoutExceptionLines(body: string, receipt: Record<string, unknown>): string {
|
||||
const lines = new Set<string>();
|
||||
const loop = receipt.review_loop_exception === undefined ? undefined : parseReviewLoopException(receipt.review_loop_exception);
|
||||
if (loop?.ok) lines.add(reviewLoopExceptionLine(loop.exception));
|
||||
const docs = receipt.docs === undefined ? undefined : parseReceiptDocs(receipt.docs);
|
||||
const docsLine = docs?.ok ? docsExceptionLine(docs.docs) : null;
|
||||
if (docsLine) lines.add(docsLine);
|
||||
if (!lines.size) return body;
|
||||
return body.split(/(?<=\n)/).map(l => (lines.has(l.trim()) ? '(exception line)\n' : l)).join('');
|
||||
}
|
||||
|
||||
/**
|
||||
* Every `<name>=<n>`, `<name>: <n>` and `<n> <name>` in the prose (the body
|
||||
* without its receipt block, its rendered Approvals block and its review-loop exception line), where `<name>` is a counter's leaf key with `_`
|
||||
* without its receipt block, its code fences, its rendered Approvals block and its exception lines), where `<name>` is a counter's leaf key with `_`
|
||||
* matched as `_`, space or `-` and not part of a longer word (`3 pass-through`
|
||||
* is not a `pass` claim), compared with the receipt. A leaf key shared by
|
||||
* several counters (`pass` under every gate lane, `passed` under gate and ci)
|
||||
@@ -93,7 +123,7 @@ function withoutExceptionLine(body: string, exception: unknown): string {
|
||||
* `<n> <name>` claim about the next key (#3513).
|
||||
*/
|
||||
export function checkReceiptProse(receipt: Record<string, unknown>, body: string): ProseCheck {
|
||||
const prose = withoutExceptionLine(withoutApprovalsBlocks(body.replace(BLOCK_RE, '')), receipt.review_loop_exception);
|
||||
const prose = withoutExceptionLines(withoutApprovalsBlocks(withoutFences(body.replace(BLOCK_RE, ''))), receipt);
|
||||
const byLeaf = new Map<string, Array<{ field: string; value: number }>>();
|
||||
for (const [field, value] of receiptCounters(receipt)) {
|
||||
const leaf = field.split('.').at(-1)!;
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "gstack",
|
||||
"version": "1.93.18",
|
||||
"version": "1.93.19",
|
||||
"description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
|
||||
@@ -142,6 +142,57 @@ describe('the Review-loop exception line (GST-977, gstack-evals #139)', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('the Documentation exception line (GST-983)', () => {
|
||||
const excepted = {
|
||||
...receipt,
|
||||
gate: { status: 'green', run: '983', unit: { pass: 1181, skip: 70 } },
|
||||
docs: { status: 'blocked', scope: 'docs/', exception: { accepted_by: 'Garry', date: '2026-11-02', via: 'GSTA-36', reason: 'sections 1 and 2 skip the audit' } },
|
||||
};
|
||||
const line = 'Documentation exception: accepted by Garry 2026-11-02 via GSTA-36: sections 1 and 2 skip the audit';
|
||||
|
||||
// Value: protects=a body that carries the docs exception line write requires can also pass --check-prose; fails_when=the
|
||||
// owner's reason is read as counter claims ("2 skip" against 70); seam=lib/ship-receipt-prose.ts checkReceiptProse
|
||||
test('the exact line the receipt\'s docs exception requires is not prose; the rest of the body still is', () => {
|
||||
expect(checkReceiptProse(excepted, `## Documentation\n\n${line}\n\nGate: 70 skip.\n`)).toEqual({ checked: 1, mismatches: [] });
|
||||
expect(checkReceiptProse(excepted, ` ${line}\r\nGate: 69 skip.`).mismatches).toEqual([{ field: 'gate.unit.skip', prose: 69, receipt: 70 }]);
|
||||
});
|
||||
|
||||
test('the same words anywhere else, a line for a different exception, or a receipt with no exception stay prose', () => {
|
||||
const twoSkip = [{ field: 'gate.unit.skip', prose: 2, receipt: 70 }];
|
||||
expect(checkReceiptProse(excepted, `Note: ${line}`).mismatches).toEqual(twoSkip);
|
||||
expect(checkReceiptProse(excepted, line.replace('GSTA-36', 'GSTA-37')).mismatches).toEqual(twoSkip);
|
||||
expect(checkReceiptProse({ ...excepted, docs: { status: 'current', scope: 'docs/' } }, line).mismatches).toEqual(twoSkip);
|
||||
});
|
||||
});
|
||||
|
||||
describe('code fences are not prose (GST-984, gstack-evals #142)', () => {
|
||||
const lanes = { ...receipt, gate: { status: 'green', run: '142', passed: 1181, failed: 0, unit: { pass: 1181, fail: 0 } } };
|
||||
const evidence = '```text\n- // one none trial per band task (the first 35 fail) and one told trial per pilot task.\nr3-drop, r3, r3-sol: 113 passed, 0 failed with the real list\nunit: 113 pass, 0 fail\n```\n';
|
||||
|
||||
// Value: protects=pasted test output and quoted code in a fence never count as this PR's counters; fails_when=the
|
||||
// fenced "113 passed" or "35 fail" is read against the receipt; seam=lib/ship-receipt-prose.ts checkReceiptProse
|
||||
test('numbers inside a closed fence are skipped; counts outside it are still checked', () => {
|
||||
expect(checkReceiptProse(lanes, `Gate: 1181 passed.\n\n${evidence}\nUnit: 1181 pass.\n`)).toEqual({ checked: 2, mismatches: [] });
|
||||
expect(checkReceiptProse(lanes, `${evidence}\nUnit: 113 pass.\n`).mismatches).toEqual([{ field: 'gate.unit.pass', prose: 113, receipt: 1181 }]);
|
||||
expect(checkReceiptProse(lanes, evidence.replace(/```/g, '~~~~').replace(/~~~~\n$/, '~~~~~\n'))).toEqual({ checked: 0, mismatches: [] });
|
||||
});
|
||||
|
||||
test('a fence closes only on its own run, an unclosed fence stays prose, and the fence still separates the text around it', () => {
|
||||
const short = '````\n113 pass\n```\nstill fenced 113 pass\n````\n';
|
||||
expect(checkReceiptProse(lanes, short)).toEqual({ checked: 0, mismatches: [] });
|
||||
expect(checkReceiptProse(lanes, 'Intro\n```text\n113 pass\n').mismatches).toEqual([{ field: 'gate.unit.pass', prose: 113, receipt: 1181 }]);
|
||||
expect(checkReceiptProse(lanes, 'Shipped 3\n```\nx\n```\npass rates are below.\n')).toEqual({ checked: 0, mismatches: [] });
|
||||
});
|
||||
|
||||
// Value: protects=a line that only looks like a fence opener, or a shorter fence inside an unclosed one, cannot hide a
|
||||
// claim GitHub shows as prose; fails_when=either shape skips the "113 pass" after it; seam=lib/ship-receipt-prose.ts checkReceiptProse
|
||||
test('inline triple backticks open no fence, and nothing after an unclosed fence is skipped', () => {
|
||||
const wrong = [{ field: 'gate.unit.pass', prose: 113, receipt: 1181 }];
|
||||
expect(checkReceiptProse(lanes, '```npm test``` ran.\nUnit: 113 pass.\n\n```text\nlog\n```\n').mismatches).toEqual(wrong);
|
||||
expect(checkReceiptProse(lanes, '````\n```\nUnit: 113 pass.\n```\n').mismatches).toEqual(wrong);
|
||||
});
|
||||
});
|
||||
|
||||
describe('gstack-ship-receipt read --check-prose', () => {
|
||||
const run = (body: string) => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'receipt-prose-'));
|
||||
|
||||
Reference in new issue
Block a user