mirror of
https://github.com/garrytan/gstack.git
synced 2026-10-31 22:14:25 +01:00
v1.91.363.0 Evidence ignores Bun's .bun/ cache, --check-prose skips the Approvals table, pr-body drops stale intros (GST-421, GST-412) (#3675)
* fix(evidence): leave an untracked root .bun/ out of the fingerprint and name mid-run changes (GST-421) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * 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> * fix(gstack-post): pr-body with sections replaces stale text above the first heading (GST-412) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(review): withhold flagged dropped-preamble excerpts, keep forged Approvals rows checked, sanitize wtree_changed (GST-412, GST-421) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs: GST-421/GST-412 review follow-ups in TODOS, fragment and troubleshooting 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
71af16c60b
commit
807aaa8da5
17 files changed
+367
-48
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;
|
||||
|
||||
+44
-13
@@ -9,7 +9,8 @@
|
||||
* `pr-body` merges by `## ` section into the PR's current body (read through
|
||||
* the same runner): a section in the file replaces the same-titled one, the
|
||||
* ship-receipt block is a section of its own, sections the file does not
|
||||
* rewrite are kept, new ones are appended; `--replace` overwrites instead.
|
||||
* rewrite are kept, new ones are appended, and a file with a `## ` section
|
||||
* replaces the text above the first heading; `--replace` overwrites instead.
|
||||
* The scan runs on the merged bytes, which are the bytes sent.
|
||||
*
|
||||
* Exit codes: 0 posted; 1 HIGH finding (or oversize input) refused;
|
||||
@@ -211,24 +212,54 @@ function splitSections(body: string): Section[] {
|
||||
* A file section whose title the current body holds more than once (an old
|
||||
* description pasted under the new one) matches no single section, so the
|
||||
* merge refuses instead of replacing one copy and keeping the stale other.
|
||||
* A file with at least one `## ` section manages the preamble (GST-412): every
|
||||
* non-blank text section before the current body's first heading (the leading
|
||||
* text, text after a receipt block) is dropped, the file's own text before its
|
||||
* first heading takes its place (leading text first, text after the file's
|
||||
* receipt right after the receipt), a dropped piece the file brings no text for
|
||||
* is listed in `dropped` with its first line (withheld when the redaction scan
|
||||
* flags it, since the merged bytes no longer hold it), and the receipt keeps the rules
|
||||
* above. A file without a `## ` section merges as described above.
|
||||
*/
|
||||
export function mergeBodySections(current: string, incoming: string): { body: string; replaced: string[]; kept: string[]; added: string[] } {
|
||||
export function mergeBodySections(current: string, incoming: string): { body: string; replaced: string[]; kept: string[]; added: string[]; dropped: string[] } {
|
||||
const now = splitSections(current);
|
||||
const next = splitSections(incoming).filter(n => !(n.key.startsWith("text:") && n.text.trim() === ""));
|
||||
const ambiguous = next.filter(n => now.filter(s => s.key === n.key).length > 1).map(n => `"${n.label}"`);
|
||||
if (ambiguous.length) throw new UsageError(`the PR's current body holds ${[...new Set(ambiguous)].join(", ")} more than once, so the file's copy matches no single section; nothing was posted. Rerun with --replace and the whole body`);
|
||||
const used = new Set<Section>();
|
||||
const replaced: string[] = [], kept: string[] = [], added: string[] = [];
|
||||
const merged: Section[] = now.map(s => {
|
||||
const managed = next.some(n => n.key.startsWith("h2:"));
|
||||
const preamble = (list: Section[]) => {
|
||||
const end = list.findIndex(s => s.key.startsWith("h2:"));
|
||||
return list.slice(0, end < 0 ? list.length : end).filter(s => s.key.startsWith("text:") && s.text.trim() !== "");
|
||||
};
|
||||
const stale = managed ? preamble(now) : [];
|
||||
const ours = managed ? preamble(next) : [];
|
||||
const role = (s: Section) => (s.key === "text:lead" ? "lead" : "trailing");
|
||||
const paired = new Set<Section>();
|
||||
for (const r of ["lead", "trailing"]) {
|
||||
const theirs = ours.filter(n => role(n) === r);
|
||||
stale.filter(s => role(s) === r).slice(0, theirs.length).forEach((s, i) => { paired.add(s); paired.add(theirs[i]!); });
|
||||
}
|
||||
const used = new Set<Section>(ours);
|
||||
const replaced: string[] = [], kept: string[] = [], added: string[] = [], dropped: string[] = [];
|
||||
const merged: Section[] = [];
|
||||
for (const s of now) {
|
||||
if (stale.includes(s)) {
|
||||
const line = s.text.trim().split(/\r?\n/)[0]!;
|
||||
if (paired.has(s)) replaced.push(s.label);
|
||||
else if (scan(line).findings.length) dropped.push(`${s.label} (first line withheld: the redaction scan flagged it)`);
|
||||
else dropped.push(`${s.label} ("${line.length > 60 ? line.slice(0, 59).trimEnd() + "…" : line}")`);
|
||||
continue;
|
||||
}
|
||||
const match = s.key === "unclosed-receipt" ? undefined : next.find(n => !used.has(n) && n.key === s.key);
|
||||
if (!match) { if (s.text.trim()) kept.push(s.label); return s; }
|
||||
used.add(match); replaced.push(s.label); return match;
|
||||
});
|
||||
if (!match) { if (s.text.trim()) kept.push(s.label); merged.push(s); continue; }
|
||||
used.add(match); replaced.push(s.label); merged.push(match);
|
||||
}
|
||||
const fresh = next.filter(n => !used.has(n));
|
||||
for (const n of fresh) added.push(n.label);
|
||||
for (const n of next) if (fresh.includes(n) || (ours.includes(n) && !paired.has(n))) added.push(n.label);
|
||||
const first = fresh.filter(n => !n.key.startsWith("h2:"));
|
||||
if (now.length === 0) return { body: incoming, replaced, kept, added };
|
||||
const all = [...first, ...merged, ...fresh.filter(n => n.key.startsWith("h2:"))];
|
||||
if (now.length === 0) return { body: incoming, replaced, kept, added, dropped };
|
||||
const all = [...ours.filter(n => role(n) === "lead"), ...first, ...merged, ...fresh.filter(n => n.key.startsWith("h2:"))];
|
||||
for (const t of ours.filter(n => role(n) === "trailing").reverse()) all.splice(all.indexOf(next[next.indexOf(t) - 1]!) + 1, 0, t);
|
||||
const fromFile = (s: Section) => next.includes(s);
|
||||
const body = all.map((s, i) => {
|
||||
const after = all[i + 1];
|
||||
@@ -236,7 +267,7 @@ export function mergeBodySections(current: string, incoming: string): { body: st
|
||||
const seam = (fromFile(s) || fromFile(after)) && s.text.trim() !== "" && !/^\r?\n/.test(after.text);
|
||||
return seam ? s.text.replace(/\n*$/, "\n\n") : s.text.endsWith("\n") ? s.text : s.text + "\n";
|
||||
}).join("");
|
||||
return { body, replaced, kept, added };
|
||||
return { body, replaced, kept, added, dropped };
|
||||
}
|
||||
|
||||
/** The PR's current body for a merge; GitHub's `--jq .body` prints it raw, GitLab's JSON carries `description`. */
|
||||
@@ -508,7 +539,7 @@ export function runPost(argv: string[], env: PostEnv, configBin = "gstack-config
|
||||
const m = mergeBodySections(current.body, body!);
|
||||
body = m.body;
|
||||
const list = (names: string[]) => (names.length ? names.join(", ") : "none");
|
||||
env.err(`gstack-post: pr-body merged by section: replaced ${list(m.replaced)}; kept ${list(m.kept)}; added ${list(m.added)}\n`);
|
||||
env.err(`gstack-post: pr-body merged by section: replaced ${list(m.replaced)}; kept ${list(m.kept)}; added ${list(m.added)}${m.dropped.length ? `; dropped ${m.dropped.join(", ")}` : ""}\n`);
|
||||
}
|
||||
if (body !== undefined && req.attribute) body = attributionCallout(req.attribute) + body;
|
||||
const parts: Array<[Part, string]> = [];
|
||||
|
||||
@@ -243,8 +243,8 @@ export type WtreeResult = { tree: string } | { error: string; kind: 'launch' | '
|
||||
|
||||
/**
|
||||
* The working-tree content fingerprint (bin/gstack-wtree): tracked and
|
||||
* untracked non-ignored files, as a 40- or 64-hex git tree id (SHA-1 or
|
||||
* SHA-256 repositories). `launch` means gstack-wtree never ran (no bash, a
|
||||
* untracked non-ignored files except an untracked root .bun/ folder, as a
|
||||
* 40- or 64-hex git tree id (SHA-1 or SHA-256 repositories). `launch` means gstack-wtree never ran (no bash, a
|
||||
* spawn error; `tried` lists each Windows bash candidate when none was
|
||||
* found); `exit` means it ran and failed (its stderr says why).
|
||||
*/
|
||||
|
||||
@@ -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,47 @@ 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, a row the renderer would not write (six cells, a date first), and any text after
|
||||
* the footer stay prose. A marker line takes the block's place so the text on either side never joins into a claim.
|
||||
*/
|
||||
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++) if (!/^\| \d{4}-\d{2}-\d{2} \|/.test(bare(i)) || bare(i).split(/(?<!\\)\|/).length !== 8) return undefined;
|
||||
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) { out.push('(gstack-approvals block)\n'); 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 +77,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)!;
|
||||
|
||||
Reference in new issue
Block a user