mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-01 06:24:41 +01:00
fix(ship-receipt): --check-prose skips the rendered Approvals block (GST-412)
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
This commit is contained in:
1 parent
2f5088dc94
commit
bed1ff0245
3 files changed
+85
-4
No files matched your search
@@ -219,16 +219,20 @@ export function cell(text: string, cap = 400): string {
|
||||
.replace(/(approved)(\s+)(by)/gi, '$1 $3');
|
||||
}
|
||||
|
||||
/** The rendered table's header row and the footer's closing words; lib/ship-receipt-prose.ts recognizes the block by them. */
|
||||
export const APPROVALS_TABLE_HEADER = '| Date | Asked | Exact words | Outcome | Covers | Still pending |';
|
||||
export const APPROVALS_FOOTER_END = '(display only; not an approval citation).';
|
||||
|
||||
export function renderRecord(read: ReadResult, loc: StoreLocation, o: { heading?: boolean } = {}): string {
|
||||
const head = o.heading === false ? [] : ['## Approvals', ''];
|
||||
if (read.state === 'unavailable') return [...head, `Record unavailable on this machine: no approval record for branch ${cell(loc.branch)} here, and the state root is ephemeral with no Drive mirror, so this is not proof that nothing was approved.`, ''].join('\n');
|
||||
if (!read.rows.length) return [...head, `No approvals recorded for branch ${cell(loc.branch)}.`, ''].join('\n');
|
||||
const lines = ['| Date | Asked | Exact words | Outcome | Covers | Still pending |', '|---|---|---|---|---|---|'];
|
||||
const lines = [APPROVALS_TABLE_HEADER, '|---|---|---|---|---|---|'];
|
||||
for (const r of read.rows) {
|
||||
const outcome = r.outcome === 'standing' ? 'standing approval' : r.outcome;
|
||||
lines.push(`| ${r.date} | ${cell(r.ask)} | “${cell(r.words, 280)}” (${cell(r.by)}) | ${outcome} | ${cell(r.scope)} | ${cell(r.pending)} |`);
|
||||
}
|
||||
return [...head, ...lines, '', `Source: gstack-approvals record for ${cell(loc.branch)} (display only; not an approval citation).`, ''].join('\n');
|
||||
return [...head, ...lines, '', `Source: gstack-approvals record for ${cell(loc.branch)} ${APPROVALS_FOOTER_END}`, ''].join('\n');
|
||||
}
|
||||
|
||||
interface DecisionLike { id?: string; title?: string; status?: string; chosen?: string | null; recommended?: string | null; gate_rev?: number; answered_at?: string; answered_by?: string; reply?: string; owner_reply?: string; carried_from?: string; label?: string; chosen_text?: string;
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
* `gstack-ship-receipt read --check-prose` runs it; a disagreement is
|
||||
* RECEIPT_PROSE_MISMATCH. Prose that quotes no receipt counter is not a claim.
|
||||
*/
|
||||
import { APPROVALS_FOOTER_END, APPROVALS_TABLE_HEADER } from './approval-record';
|
||||
import { RECEIPT_FENCE } from './ship-receipt';
|
||||
|
||||
export interface ProseClaim { field: string; prose: number; receipt: number }
|
||||
@@ -26,10 +27,46 @@ 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,})/;
|
||||
|
||||
/**
|
||||
* The body without the `## Approvals` blocks gstack-approvals renders (lib/approval-record.ts renderRecord): the
|
||||
* heading, the Date | Asked | Exact words table and its `Source: gstack-approvals record for …` footer. Those rows
|
||||
* quote the owner's words (`one Step 11 pass`), not this PR's counters (GST-412). A heading inside a code fence is
|
||||
* not a heading, with the fence rules of splitSections in lib/gstack-post.ts (a fence closes only on a run of its own
|
||||
* character at least as long as the opener with nothing after it), and CRLF reads as LF. A heading not followed by
|
||||
* exactly that table and footer, and any text after the footer, stays prose.
|
||||
*/
|
||||
function withoutApprovalsBlocks(body: string): string {
|
||||
const lines = body.split(/(?<=\n)/);
|
||||
const bare = (i: number) => (lines[i] ?? '').replace(/\r?\n$/, '');
|
||||
const skipBlank = (i: number) => { while (i < lines.length && bare(i).trim() === '') i++; return i; };
|
||||
const blockEnd = (heading: number): number | undefined => {
|
||||
let i = skipBlank(heading + 1);
|
||||
if (bare(i) !== APPROVALS_TABLE_HEADER || !/^\|(?:---\|)+$/.test(bare(i + 1))) return undefined;
|
||||
for (i += 2; bare(i).startsWith('|'); i++);
|
||||
i = skipBlank(i);
|
||||
return bare(i).startsWith('Source: gstack-approvals record for ') && bare(i).endsWith(APPROVALS_FOOTER_END) ? i : undefined;
|
||||
};
|
||||
const out: string[] = [];
|
||||
let fence: string | null = null;
|
||||
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;
|
||||
} else if (FENCE_OPEN.test(line)) fence = FENCE_OPEN.exec(line)![1]!;
|
||||
else if (/^ {0,3}## Approvals\s*$/.test(line)) {
|
||||
const end = blockEnd(i);
|
||||
if (end !== undefined) { i = end; continue; }
|
||||
}
|
||||
out.push(lines[i]!);
|
||||
}
|
||||
return out.join('');
|
||||
}
|
||||
|
||||
/**
|
||||
* Every `<name>=<n>`, `<name>: <n>` and `<n> <name>` in the prose (the body
|
||||
* without its receipt block), where `<name>` is a counter's leaf key with `_`
|
||||
* without its receipt block and its rendered Approvals block), 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)
|
||||
@@ -39,7 +76,7 @@ const ESCAPE = /[.*+?^${}()|[\]\\]/g;
|
||||
* `<n> <name>` claim about the next key (#3513).
|
||||
*/
|
||||
export function checkReceiptProse(receipt: Record<string, unknown>, body: string): ProseCheck {
|
||||
const prose = body.replace(BLOCK_RE, '');
|
||||
const prose = withoutApprovalsBlocks(body.replace(BLOCK_RE, ''));
|
||||
const byLeaf = new Map<string, Array<{ field: string; value: number }>>();
|
||||
for (const [field, value] of receiptCounters(receipt)) {
|
||||
const leaf = field.split('.').at(-1)!;
|
||||
|
||||
@@ -13,6 +13,7 @@ import { spawnSync } from 'node:child_process';
|
||||
import { checkReceiptProse, proseLine, receiptCounters } from '../lib/ship-receipt-prose';
|
||||
import { renderReceiptBlock } from '../lib/ship-receipt';
|
||||
import { EXIT } from '../lib/headless-artifacts';
|
||||
import { renderRecord, type ApprovalRow, type StoreLocation } from '../lib/approval-record';
|
||||
|
||||
const ROOT = path.resolve(import.meta.dir, '..');
|
||||
const BIN = path.join(ROOT, 'bin/gstack-ship-receipt');
|
||||
@@ -72,6 +73,45 @@ describe('checkReceiptProse', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// GST-412: the `## Approvals` block gstack-approvals renders quotes the owner's words, which can hold
|
||||
// numbers (`one Step 11 pass` read as a gate `pass` claim of 11). Only that rendered block is skipped.
|
||||
describe('the rendered Approvals block is not prose', () => {
|
||||
const lanes = { ...receipt, gate: { unit: { pass: 1181, fail: 0, skip: 3 } } };
|
||||
const row = (words: string): ApprovalRow => ({
|
||||
schema_version: 1, id: 'a1', date: '2026-10-30', ask: 'Ship it after the gate', words, by: 'garry', scope: 'PR B',
|
||||
pending: 'none', outcome: 'approved', source: 'thread', branch: 'b', recorded_at: '2026-10-30T00:00:00Z',
|
||||
} as ApprovalRow);
|
||||
const loc = { file: '/nonexistent', branch: 'gsta195-evidence-prose', slug: 's', ephemeral: false } as StoreLocation;
|
||||
const block = renderRecord({ rows: [row('do it, one Step 11 pass is enough')], state: 'present', restored: false }, loc);
|
||||
|
||||
test('a number inside the rendered table is not a claim; the same sentence outside the block still is', () => {
|
||||
expect(block).toContain('one Step 11 pass');
|
||||
const body = 'Unit lane: 1181 pass.\n\n' + block + '\n' + renderReceiptBlock(lanes);
|
||||
expect(checkReceiptProse(lanes, body)).toEqual({ checked: 1, mismatches: [] });
|
||||
const outside = checkReceiptProse(lanes, 'Garry said one Step 11 pass is enough.\n\n' + block);
|
||||
expect(outside.mismatches).toEqual([{ field: 'gate.unit.pass', prose: 11, receipt: 1181 }]);
|
||||
});
|
||||
|
||||
test('text after the footer is checked, a fenced "## Approvals" example hides nothing, and CRLF bodies work', () => {
|
||||
const after = checkReceiptProse(lanes, block + '\nFollow-up: one Step 11 pass again.\n');
|
||||
expect(after.mismatches).toEqual([{ field: 'gate.unit.pass', prose: 11, receipt: 1181 }]);
|
||||
const fenced = '```md\n## Approvals\n\n| Date | Asked |\n```\n\nThe lane ran 11 pass.\n';
|
||||
expect(checkReceiptProse(lanes, fenced).mismatches).toEqual([{ field: 'gate.unit.pass', prose: 11, receipt: 1181 }]);
|
||||
const fencedThenTable = '````md\n## Approvals\n```\nstill fenced\n```\n````\n\n7 pass\n\n' + block;
|
||||
expect(checkReceiptProse(lanes, fencedThenTable).mismatches).toEqual([{ field: 'gate.unit.pass', prose: 7, receipt: 1181 }]);
|
||||
const crlf = ('Unit lane: 1181 pass.\n\n' + block).replace(/\n/g, '\r\n');
|
||||
expect(checkReceiptProse(lanes, crlf)).toEqual({ checked: 1, mismatches: [] });
|
||||
expect(checkReceiptProse(lanes, crlf + 'Then 11 pass.\r\n').mismatches).toEqual([{ field: 'gate.unit.pass', prose: 11, receipt: 1181 }]);
|
||||
});
|
||||
|
||||
test('a heading that is not followed by the rendered table and footer is ordinary prose', () => {
|
||||
const handWritten = '## Approvals\n\n- Garry: one Step 11 pass is enough\n';
|
||||
expect(checkReceiptProse(lanes, handWritten).mismatches).toEqual([{ field: 'gate.unit.pass', prose: 11, receipt: 1181 }]);
|
||||
const noFooter = block.replace(/Source: gstack-approvals record.*\n/, '');
|
||||
expect(checkReceiptProse(lanes, noFooter).mismatches).toEqual([{ field: 'gate.unit.pass', prose: 11, receipt: 1181 }]);
|
||||
});
|
||||
});
|
||||
|
||||
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