diff --git a/CHANGELOG.md b/CHANGELOG.md index 86e3a04e1..3f8294030 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,20 +5,20 @@ **A PR body opens with the problem it solves, says what else it touches, and the prose /ship publishes is checked for autopilot tells before it goes out.** A /ship PR body began with a list of what changed, so a reader had to work out the problem from the inventory; nothing in the body said what else the change could break or how sure the author was that it would not; and a CHANGELOG entry or PR body could carry the words a model writes when it is not thinking (`a vibrant tapestry of enhancements`, `not just X, but Y`, an arrow where a sentence belongs) because nothing checked prose mechanically. Garry approved the tstack and pstack absorb plans on 2026-10-26 ("Do it, send"); this release carries tstack item 4 and pstack items 2(a) and 11. ### Added -- **`bin/gstack-prose-lint`, `lib/prose-lint.ts`**: the AI tells a regular expression can decide, as a free check over the files it is given. Seven rules: `vocabulary` (the autopilot word list), `not-just-but`, `metaphor-noun` (each with the plain word to use), `mid-sentence-colon` (a colon doing a conjunction's job; a label, a list introducer or a time is not one), `inline-header-list` (a bold label that restates its line), `arrow` (a measurement such as `1.6740 → 1.7010` and the `before → after` heading pass) and `dropped-article` (a bare noun opening a sentence). Fenced code, inline code, URLs and HTML comments are never read. `--unreleased` reads only a CHANGELOG's `## [Unreleased]` block and `--base ` only the lines the working tree added since the merge base, so an older entry is never judged; `--gate` turns a finding into exit 1 with `PROSE_LINT_FOUND` ([docs/troubleshooting.md#prose-lint-found](docs/troubleshooting.md#prose-lint-found)); without it the run is a report and exits 0. gstack's own templates and docs keep their em dashes, because the lint gates only the CHANGELOG entry and the PR body drafts /ship writes. The word lists come from pstack's unslop skill (Lauren Tan, MIT; NOTICE.md), the rules are written in gstack's words. -- **`## Blast radius` in the /ship PR body** (`ship/sections/pr-body.md`, and `capy-pr.md` after `## Why` on the Capy path): one or two sentences on what else the change touches and the one fact that makes that safe, with the rung of the evidence ladder the fact reached (a file:line read, a walked failure path, a script that ran, a repro). The ladder itself lives in `investigate/sections/blast-radius.md`. +- **`bin/gstack-prose-lint`, `lib/prose-lint.ts`**: the AI tells a regular expression can decide, as a free check over the files it is given. Seven rules: `vocabulary` (the autopilot word list), `not-just-but`, `metaphor-noun` (each with the plain word to use), `mid-sentence-colon` (a colon doing a conjunction's job; a label, a list introducer or a time is not one), `inline-header-list` (a bold label that restates its line), `arrow` (a measurement such as `1.6740 → 1.7010` and the `before → after` heading pass) and `dropped-article` (a bare noun opening a sentence). Fenced code, inline code, URLs and HTML comments are never read. `--unreleased` reads only a CHANGELOG's `## [Unreleased]` block, `--base ` only the lines the working tree added since the merge base, and `--sections ` only the text under those `## ` headings, so an older entry or a verbatim tool section is never judged; `--gate` turns a finding into exit 1 with `PROSE_LINT_FOUND` ([docs/troubleshooting.md#prose-lint-found](docs/troubleshooting.md#prose-lint-found)); without it the run is a report and exits 0. gstack's own templates and docs keep their em dashes, because the lint gates only the CHANGELOG entry and the PR body sections the author writes. The word lists come from pstack's unslop skill (Lauren Tan, MIT; NOTICE.md), the rules are written in gstack's words. +- **`## Blast radius` in the /ship PR body** (`ship/sections/pr-body.md`, and `capy-pr.md` after `## Why` on the Capy path): one or two sentences on what else the change touches and the one fact that makes that safe, with the rung of the evidence ladder the fact reached (a file:line read, a walked failure path, a script that ran, a repro). The ladder itself lives in `~/.claude/skills/gstack/investigate/sections/blast-radius.md` once its own lane lands; until then the item's inline rung list is the ladder. - **Quality gate workflow step**: `gstack-prose-lint CHANGELOG.md --unreleased --gate` runs on every PR over the `[Unreleased]` block, which is the entry the PR adds until the train stamps it; a stamped or absent block is `none` and passes. ### Changed -- **`ship/sections/pr-body.md` Step 19 `## Summary`**: the first paragraph states the problem in the user's words, then the fix and what they get now; commit themes follow, and the body is never an inventory or a commit list. The instruction carries one BAD/GOOD pair. Step 18's new-PR title keeps its `v` prefix and the text after it names what the user gets, not the mechanism. The compose block runs `gstack-prose-lint` over the two body drafts before they are joined, and a finding stops composition with the lines to rewrite. +- **`ship/sections/pr-body.md` Step 19 `## Summary`**: the first paragraph states the problem in the user's words, then the fix and what they get now; commit themes follow, and the body is never an inventory or a commit list. The instruction carries one BAD/GOOD pair. Step 18's new-PR title keeps its `v` prefix and the text after it names what the user gets, not the mechanism. The compose block runs `gstack-prose-lint --sections 'What changed,Why,Summary,Blast radius'` over the first body draft before the drafts are joined, so a review line such as `Problem → what you did` or an approvals quote is never read, and a finding in a section the author wrote stops composition with the lines to rewrite. - **`ship/sections/capy-pr.md` `## Why`**: the first sentence quotes the person's problem, the rest say what changes for them, with its own BAD/GOOD pair; the section order gains `## Blast radius`. - **`ship/sections/changelog.md` Step 13**: item 7 lints the lines this branch added to `CHANGELOG.md` (`--base origin/ --gate`) and the entry is rewritten until the trailer is `PROSE_LINT: ok`. ### Tests -- `test/prose-lint.test.ts`: one positive and one negative case per rule, masking of code, URLs and comments with line numbers held, the `--unreleased` and `--base` selections against a fixture CHANGELOG and a temporary repository, the exit codes and the `PROSE_LINT_FOUND` line, the template and workflow wiring, and the repository's own `[Unreleased]` block gating clean. Fixtures: `test/fixtures/prose-lint/{slopped,clean}-changelog.md`. +- `test/prose-lint.test.ts`: one positive and one negative case per rule, masking of code, URLs and comments with line numbers held, the `--unreleased` and `--base` selections against a fixture CHANGELOG and a temporary repository, the exit codes and the `PROSE_LINT_FOUND` line, the `--sections` selection, a multi-line HTML comment masked through its close, the template and workflow wiring, and the templates themselves getting a report rather than a gate. Fixtures: `test/fixtures/prose-lint/{slopped,clean}-changelog.md`. ### Gate integrity -- No threshold, timeout, trial count or case kind changed. The ship parity `maxSizeRatio` in `test/helpers/carve-guards.ts` moves 1.7150 → 1.7250 (measured 323,295 / 187,706 = 1.7223) for the pr-body, capy-pr and changelog section text above; the ship skeleton is unchanged at 82,645 bytes, so no host pin in `test/fixtures/skill-md-size-allowlist.json` moves. Owner decision for the raise: Garry, 2026-10-26, "Do it, send" on the two absorb plans. +- No threshold, timeout, trial count or case kind changed. The ship parity `maxSizeRatio` in `test/helpers/carve-guards.ts` moves 1.7150 → 1.7250 (measured 323,371 / 187,706 = 1.7228) for the pr-body, capy-pr and changelog section text above; the ship skeleton is unchanged at 82,645 bytes, so no host pin in `test/fixtures/skill-md-size-allowlist.json` moves. Owner decision for the raise: Garry, 2026-10-26, "Do it, send" on the two absorb plans. ## [1.91.256.0] - 2026-10-26 diff --git a/bin/gstack-prose-lint b/bin/gstack-prose-lint index 4475258d9..96dbe9a41 100755 --- a/bin/gstack-prose-lint +++ b/bin/gstack-prose-lint @@ -3,13 +3,14 @@ * gstack-prose-lint — the AI tells a regular expression can decide, over the * prose /ship writes (docs/troubleshooting.md#prose-lint-found). * - * gstack-prose-lint ... [--unreleased | --base ] [--gate] [--json] + * gstack-prose-lint ... [--unreleased | --base | --sections ] [--gate] [--json] * * Scans each file (fenced code, inline code, URLs and comments excluded) and * prints one `PROSE:` line per finding, then `PROSE_LINT: ok | found= | none`. - * --unreleased reads only each file's `## [Unreleased]` CHANGELOG block and + * --unreleased reads only each file's `## [Unreleased]` CHANGELOG block, * --base only the lines the working tree adds since the merge base with - * (nothing to scan is `none`). Exit 0 unless --gate is set and something was found + * , and --sections only the text under those `## ` headings (nothing + * to scan is `none`). Exit 0 unless --gate is set and something was found * (exit 1); unreadable input is exit 2. Report-only everywhere else: a * template, a doc or a README gets the report and keeps its em dashes. * Logic: lib/prose-lint.ts. Called from /ship Steps 13 and 19 and the @@ -25,6 +26,7 @@ const HELP = `gstack-prose-lint — AI tells a regex can decide, over CHANGELOG ... files to scan; fenced code, inline code, URLs and HTML comments are skipped --unreleased scan only each file's ## [Unreleased] CHANGELOG block (none present: PROSE_LINT: none) --base scan only the lines the working tree adds to each file since the merge base with + --sections scan only the text under these ## headings (the sections an author writes in a PR body) --gate exit 1 when anything was found (default: report and exit 0) --json findings as JSON instead of PROSE: lines @@ -37,18 +39,20 @@ function main(argv: string[]): number { const flags = new Set(); const files: string[] = []; let base: string | undefined; + let sections: string[] | undefined; for (let i = 0; i < argv.length; i++) { const a = argv[i]!; if (a === '--base') { base = argv[++i]; if (!base || base.startsWith('--')) { process.stderr.write('gstack-prose-lint: --base needs a ref\n'); return EXIT.usage; } } + else if (a === '--sections') { const v = argv[++i]; if (!v || v.startsWith('--')) { process.stderr.write('gstack-prose-lint: --sections needs a comma-separated list of headings\n'); return EXIT.usage; } sections = v.split(','); } else if (['--unreleased', '--gate', '--json'].includes(a)) flags.add(a); else if (a.startsWith('--')) { process.stderr.write(`gstack-prose-lint: unknown flag ${a}\n${HELP}\n`); return EXIT.usage; } else files.push(a); } if (files.length === 0) { process.stderr.write('gstack-prose-lint: name at least one file\n'); return EXIT.usage; } - if (base && flags.has('--unreleased')) { process.stderr.write('gstack-prose-lint: --unreleased and --base are different selections; pass one\n'); return EXIT.usage; } + if ([base, sections, flags.has('--unreleased') || undefined].filter(Boolean).length > 1) { process.stderr.write('gstack-prose-lint: --unreleased, --base and --sections are different selections; pass one\n'); return EXIT.usage; } for (const f of files) if (!fs.existsSync(f) || fs.statSync(f).isDirectory()) { process.stderr.write(`gstack-prose-lint: cannot read ${f}\n`); return EXIT.usage; } let result; - try { result = lintFiles(files, { unreleased: flags.has('--unreleased'), base }); } + try { result = lintFiles(files, { unreleased: flags.has('--unreleased'), base, sections }); } catch (e: any) { process.stderr.write(`gstack-prose-lint: ${e.message}\n`); return EXIT.usage; } if (flags.has('--json')) process.stdout.write(JSON.stringify({ findings: result.findings, scanned: result.scanned, empty: result.empty }, null, 2) + '\n'); else process.stdout.write(renderProseLint(result)); diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index cc6563855..d83656b09 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -4469,8 +4469,10 @@ every flag and code. ## Prose lint (gstack-prose-lint) `bin/gstack-prose-lint` reads the prose /ship is about to publish, a CHANGELOG -entry (Step 13) and the PR body drafts (Step 19), and the quality-gate workflow -runs it over `CHANGELOG.md`'s `[Unreleased]` block. It decides only what a +entry (Step 13) and the sections the author writes in the PR body (Step 19: +`## What changed`, `## Why`, `## Summary` and `## Blast radius`, through +`--sections`), and the quality-gate workflow runs it over `CHANGELOG.md`'s +`[Unreleased]` block. It decides only what a regular expression can: autopilot vocabulary, "not just X, but Y", metaphor nouns, a colon used as a conjunction, a bold label that restates its line, an arrow or a dropped article standing in for a sentence. Fenced code, inline code, @@ -4484,15 +4486,16 @@ file) it is a report: run it, read it, keep your em dashes. given. Each `PROSE: : "" ()` line above the trailer names the rule and what to write instead; `PROSE_LINT: found=` counts them. With `--unreleased` only the `## [Unreleased]` block was read; with -`--base ` only the lines the working tree added since the merge base. +`--base ` only the lines the working tree added since the merge base; with +`--sections ` only the text under those `## ` headings. **Fix.** Rewrite each named line in the draft the way its hint says (a measurement such as `1.6740 → 1.7010` and the `before → after` heading are not findings), then -rerun the same command. A finding inside a verbatim tool section of a PR body -(an approvals table, issue links, a rendered review note) is fixed at its source -or left as it is and named in the completion message, never edited by hand in the -body. A rule that keeps flagging correct prose is a bug in `lib/prose-lint.ts`: -file it with the sentence. +rerun the same command. The PR body's verbatim tool sections (the approvals +table, issue links, review findings, the coverage diagram) are outside +`--sections` and are never read, so a finding there means the compose block was +edited: restore its `--sections` list. A rule that keeps flagging correct prose +is a bug in `lib/prose-lint.ts`: file it with the sentence. ## Quick answers diff --git a/lib/prose-lint.ts b/lib/prose-lint.ts index 93e71e00f..a844ec350 100644 --- a/lib/prose-lint.ts +++ b/lib/prose-lint.ts @@ -71,9 +71,9 @@ const LANDSCAPE_RE = /\b(?:landscape|tapestry)\b(?!\s+(?:mode|orientation|image| const METAPHOR_RE = new RegExp(String.raw`\b(?:${METAPHOR_NOUNS.map(m => m.word.replace('-', '[- ]')).join('|')})s?\b`, 'gi'); const NOT_JUST_BUT_RE = /(?:\bnot|n['’]t)\s+(?:just|only|merely|simply)\b[^.!?\n]{0,120}?\bbut\b/gi; /** A sentence of four or more words, a colon, then a lowercase clause: the colon is doing a conjunction's job. */ -const MID_COLON_RE = /(?:^\s*(?:[-*+]|\d+\.)?\s*|[.!?]\s+)((?:[A-Za-z][\w'’-]*[,\s]+){3,}[A-Za-z][\w'’-]*): ([a-z][\w'’-]*(?:\s+[\w'’-]+){2,})/gm; +const MID_COLON_RE = /(?:^[ \t]*(?:(?:[-*+]|\d+\.)[ \t]+)?|[.!?]\s+)((?:[A-Za-z][\w'’-]*[,\s]+){3,}[A-Za-z][\w'’-]*): ([a-z][\w'’-]*(?:\s+[\w'’-]+){2,})/gm; const INLINE_HEADER_RE = /^\s*(?:[-*+]|\d+\.)\s+\*\*([^*\n]{1,60}?):?\*\*:?\s+(\S+)/; -const ARROW_RE = /(\S+)\s*(→|->|=>|⇒)\s*(\S+)/g; +const ARROW_RE = /(\S{1,80})[ \t]*(→|->|=>|⇒)[ \t]*(\S{1,80})/g; const SENTENCE_START_RE = /(?:^|[.!?]\s+|^\s*(?:[-*+]|\d+\.)\s+)([A-Z][a-z]+)\s+([a-z]+s)\b(?=\s+[a-z])/gm; const BARE_NOUNS = new Set([ 'parser', 'user', 'agent', 'reviewer', 'runner', 'command', 'script', 'test', 'file', 'branch', 'build', 'gate', @@ -100,14 +100,18 @@ const NUMERIC_SIDE = /^[\d$£€.,%+-]|^v?\d|^CODE$|^URL$|^(?:before|after)$/i; const LIST_INTRODUCER = /\b(?:examples?|following|these|this|below|measurements?|two|three|four|five|six)$/i; const trimSide = (s: string) => s.replace(/^[(\[`"'*_]+|[)\]`"'*_,.;:]+$/g, ''); -/** Replace fenced blocks, inline code, URLs and HTML comments with same-length placeholders so line numbers hold. */ +/** Replace fenced blocks, inline code, URLs and HTML comments (multi-line ones too) with same-length placeholders so line numbers hold. */ export function maskProse(text: string): string { const lines = text.split('\n'); let fence: string | null = null; + let comment = false; const out = lines.map((line) => { const open = /^\s*(`{3,}|~{3,})/.exec(line); if (fence) { if (open && open[1]!.startsWith(fence[0]!) && open[1]!.length >= fence.length) fence = null; return ''; } + if (comment) { const end = line.indexOf('-->'); if (end < 0) return ''; comment = false; line = ' '.repeat(end + 3) + line.slice(end + 3); } if (open) { fence = open[1]!; return ''; } + const tail = line.lastIndexOf('', tail) < 0) { comment = true; line = line.slice(0, tail); } return line .replace(//g, (m) => ' '.repeat(m.length)) .replace(/`[^`\n]*`/g, (m) => 'CODE' + ' '.repeat(Math.max(0, m.length - 4))) @@ -164,9 +168,23 @@ export function unreleasedBlock(text: string): { text: string; line: number } | return { text: section.lines.join('\n'), line: section.start + 1 }; } +/** The text with every line outside the named `## ` sections blanked, so line numbers hold; null when none of them is present. */ +export function sectionsOnly(text: string, names: string[]): string | null { + const wanted = new Set(names.map(n => n.trim().toLowerCase()).filter(Boolean)); + let keep = false, found = false; + const out = text.split('\n').map((line) => { + const h = /^##\s+(.+?)\s*$/.exec(line); + if (h) { keep = wanted.has(h[1]!.toLowerCase()); found ||= keep; return ''; } + return keep ? line : ''; + }); + return found ? out.join('\n') : null; +} + export interface LintFilesOptions { /** Scan only each file's `[Unreleased]` CHANGELOG block. */ unreleased?: boolean; + /** Scan only the text under these `## ` headings (the sections an author writes in a PR body). */ + sections?: string[]; /** Scan only the lines the working tree adds to each file since the merge base with this ref (the ref itself when no merge base resolves). */ base?: string; } @@ -196,6 +214,13 @@ export function lintFiles(files: string[], opts: LintFilesOptions = {}): ProseLi result.findings.push(...lintProse(added, file)); continue; } + if (opts.sections) { + const picked = sectionsOnly(text, opts.sections); + if (picked === null) { result.empty.push(file); continue; } + result.scanned.push(file); + result.findings.push(...lintProse(picked, file)); + continue; + } if (opts.unreleased) { const block = unreleasedBlock(text); if (!block) { result.empty.push(file); continue; } diff --git a/ship/sections/capy-pr.md b/ship/sections/capy-pr.md index 5d7f64b04..90c8b0c44 100644 --- a/ship/sections/capy-pr.md +++ b/ship/sections/capy-pr.md @@ -37,7 +37,7 @@ commit themes; do not repeat the captions. the failed run, or their request, quoted); the rest say what changes for them. No inventory: BAD "Adds a lint and a CI step." GOOD "'The review loop is too overzealous.' Pass 2 now reads only pass 1's range." 6. **`## Blast radius`:** the body template's item, written once, here: what else the change touches and - the one fact that makes that safe, with the evidence-ladder rung it reached (`investigate/sections/blast-radius.md`). + the one fact that makes that safe, with the evidence-ladder rung it reached (`~/.claude/skills/gstack/investigate/sections/blast-radius.md`, when present). 7. **`## Approvals`:** the output of `~/.claude/skills/gstack/bin/gstack-approvals render`, verbatim. It escapes its cells, and the receipt never reads it as an approval citation. On `APPROVAL_RECORD_UNAVAILABLE` keep its text and say so in the READY report. Never type rows by hand. diff --git a/ship/sections/capy-pr.md.tmpl b/ship/sections/capy-pr.md.tmpl index cdfb2103d..00fa467fa 100644 --- a/ship/sections/capy-pr.md.tmpl +++ b/ship/sections/capy-pr.md.tmpl @@ -35,7 +35,7 @@ commit themes; do not repeat the captions. the failed run, or their request, quoted); the rest say what changes for them. No inventory: BAD "Adds a lint and a CI step." GOOD "'The review loop is too overzealous.' Pass 2 now reads only pass 1's range." 6. **`## Blast radius`:** the body template's item, written once, here: what else the change touches and - the one fact that makes that safe, with the evidence-ladder rung it reached (`investigate/sections/blast-radius.md`). + the one fact that makes that safe, with the evidence-ladder rung it reached (`~/.claude/skills/gstack/investigate/sections/blast-radius.md`, when present). 7. **`## Approvals`:** the output of `~/.claude/skills/gstack/bin/gstack-approvals render`, verbatim. It escapes its cells, and the receipt never reads it as an approval citation. On `APPROVAL_RECORD_UNAVAILABLE` keep its text and say so in the READY report. Never type rows by hand. diff --git a/ship/sections/pr-body.md b/ship/sections/pr-body.md index 8830bd604..3c2374c81 100644 --- a/ship/sections/pr-body.md +++ b/ship/sections/pr-body.md @@ -86,7 +86,7 @@ before the commit and names each line to rewrite."> +ladder is `~/.claude/skills/gstack/investigate/sections/blast-radius.md`, when present.> ## Test Coverage @@ -205,7 +205,7 @@ PR_BODY_FILE=$(mktemp "${TMPDIR:-/tmp}/gstack-pr-body.XXXXXX") || { echo "ERROR: BODY_TOP_FILE="$(git rev-parse --show-toplevel 2>/dev/null || pwd)/.gstack/tmp/" BODY_REST_FILE="$(git rev-parse --show-toplevel 2>/dev/null || pwd)/.gstack/tmp/" [ -s "$BODY_TOP_FILE" ] && [ -s "$BODY_REST_FILE" ] || { echo "Not composed: $BODY_TOP_FILE or $BODY_REST_FILE is empty, so the body was never written. Write both, then rerun." >&2; exit 1; } -~/.claude/skills/gstack/bin/gstack-prose-lint "$BODY_TOP_FILE" "$BODY_REST_FILE" --gate || { echo "Not composed: rewrite each PROSE: line above in the drafts (never a verbatim tool section), then rerun." >&2; exit 1; } +~/.claude/skills/gstack/bin/gstack-prose-lint "$BODY_TOP_FILE" --sections 'What changed,Why,Summary,Blast radius' --gate || { echo "Not composed: rewrite each PROSE: line above in the draft, then rerun." >&2; exit 1; } { awk 1 "$BODY_TOP_FILE" && cat -- "$DOCS_SECTION_FILE" && echo && awk 1 "$BODY_REST_FILE"; } > "$PR_BODY_FILE" || exit 1 rm -f "$BODY_TOP_FILE" "$BODY_REST_FILE" echo "PR_BODY_FILE: $PR_BODY_FILE" diff --git a/ship/sections/pr-body.md.tmpl b/ship/sections/pr-body.md.tmpl index 7b27280c6..88fd1b67a 100644 --- a/ship/sections/pr-body.md.tmpl +++ b/ship/sections/pr-body.md.tmpl @@ -77,7 +77,7 @@ before the commit and names each line to rewrite."> +ladder is `~/.claude/skills/gstack/investigate/sections/blast-radius.md`, when present.> ## Test Coverage @@ -188,7 +188,7 @@ PR_BODY_FILE=$(mktemp "${TMPDIR:-/tmp}/gstack-pr-body.XXXXXX") || { echo "ERROR: BODY_TOP_FILE="$(git rev-parse --show-toplevel 2>/dev/null || pwd)/.gstack/tmp/" BODY_REST_FILE="$(git rev-parse --show-toplevel 2>/dev/null || pwd)/.gstack/tmp/" [ -s "$BODY_TOP_FILE" ] && [ -s "$BODY_REST_FILE" ] || { echo "Not composed: $BODY_TOP_FILE or $BODY_REST_FILE is empty, so the body was never written. Write both, then rerun." >&2; exit 1; } -~/.claude/skills/gstack/bin/gstack-prose-lint "$BODY_TOP_FILE" "$BODY_REST_FILE" --gate || { echo "Not composed: rewrite each PROSE: line above in the drafts (never a verbatim tool section), then rerun." >&2; exit 1; } +~/.claude/skills/gstack/bin/gstack-prose-lint "$BODY_TOP_FILE" --sections 'What changed,Why,Summary,Blast radius' --gate || { echo "Not composed: rewrite each PROSE: line above in the draft, then rerun." >&2; exit 1; } { awk 1 "$BODY_TOP_FILE" && cat -- "$DOCS_SECTION_FILE" && echo && awk 1 "$BODY_REST_FILE"; } > "$PR_BODY_FILE" || exit 1 rm -f "$BODY_TOP_FILE" "$BODY_REST_FILE" echo "PR_BODY_FILE: $PR_BODY_FILE" diff --git a/test/helpers/carve-guards.ts b/test/helpers/carve-guards.ts index 175b44ea2..88b9233ad 100644 --- a/test/helpers/carve-guards.ts +++ b/test/helpers/carve-guards.ts @@ -168,7 +168,7 @@ export const CARVE_GUARDS: Record = { // wave's headline capability) grows the union to 1.195x. Deliberate: // the section is on-demand (loads only for Apple store targets), so // per-invocation cost for non-iOS ships is one manifest line. - maxSizeRatio: 1.7250, // Shared advisory identity/dedup + critical-severity validation: 248,065 union bytes / 187,706 baseline = 1.3216 (2026-09-17). + test value bar in the lazy Step 7 section (value cards, weak paths, gate table, base control, machine checks; ~13.6KB): measured 1.396 (2026-09-29). + W1 guarded state-root resolution (`eval gstack-paths; : "${GSTACK_STATE_ROOT:?…}"`) in the Context Recovery preamble, the eureka log and each state-writing bash block; measured 1.401 (2026-09-30). + the shared QA review step's plan-check timing rule (plan checks and their revalidation run on --timeout-ms after smoke expiry); measured 1.4022 (2026-09-30). + the shared TEST_BOOTSTRAP owned-change undo rules (s03 H1-H3, M1) and the value-bar coverage wording (s03 M2/M3); measured 1.4044 (2026-10-02). + severe fix wave ship-review lane: G1 NO_VERSION dispatch, title and CHANGELOG arms (versionless repos ship without inventing a version), F1 parent-side plan binding before the audit dispatch (binding, docs/designs candidates, exact not-run line) and D3's data-not-instructions line; +2,126 union bytes, measured 1.4135 (2026-10-03). + INV-1 consumer lines (outside review `unverified`/`unavailable` is missing coverage in the readiness note and PR body); measured 1.4148 (2026-10-03). + v1.91.19.0 severe fix wave: verdict-form outside-review callers with sandbox preflight and first-use notice (INV-1/B1/Q2), P0/P1 gate and unverified wording, bound-plan discovery (F1), visible LEARNINGS failures (B5), carved external pointers; measured 267,752 / 187,706 = 1.4264 (2026-10-03). + v1.91.18.0 (#3023) merged into the v1.91.19.0 wave; measured 267,874 / 187,706 = 1.4271 (2026-10-04). + Oct 6 fix wave: quota_exhausted preflight branch (#3051) and quoted-heredoc issue template (#3046); measured 268,199 / 187,706 = 1.4288 (2026-10-06). + Step 10's inline no-PR check before the Greptile dispatch (#3020); measured 268,655 / 187,706 = 1.4313 (2026-10-06). + Oct 6 fix wave A3/CEO-12: free text reaches commands only through agent-written mktemp files (mktemp block, write rule, path rebuild and refusal at each posting, prompt and brief site); measured 275,422 / 187,706 = 1.4673 (2026-10-06). Combined Oct 6 wave head (B free-text files + D measure loop + A rework): measured 280,363 / 187,706 = 1.4936 (2026-10-06). + gate-once push rule (no second paid gate after a CI push); measured 280,573 / 187,706 = 1.4947 (2026-10-06). + the measurement bar's decision step (exit-code branches for MEETS, MEETS-qualified, needs-classify with the qualifying and fix-round classes, EXTEND once; the measure prose was tightened to offset most of it); measured 281,058 / 187,706 = 1.4973 (2026-10-06). + Oct 7 wave B3: the Codex probe runs as a command (select-model capture lines, check-sandbox/probe-model/run-with-timeout subcommands) instead of being sourced; measured 281,438 / 187,706 = 1.4994 (2026-10-07). + Oct 7 wave CEO-28/DX-11: review prompts ask for a severity per finding and design proposals use the proposal gate; measured 281,556 / 187,706 = 1.5000 (2026-10-07). + Oct 7 wave B4: /ship publishes through gstack-post (title prepared in a file in the pr-body section, compose block plus gstack-post exit-code branches replacing the two scans); measured 281,894 / 187,706 = 1.5018 (2026-10-07). + B2 early Greptile PR (lazy Step 6.5 section: detect, public-repo consent, push, provisional title file, open; Step 10's bounded wait; the stop-leaves-the-draft rule); measured 286,626 / 187,706 = 1.5270 (2026-10-07). B3, the classifier, B4 and B2 merged: measured 287,128 / 187,706 = 1.5297 (2026-10-07). + Oct 7 wave guard (CEO-20): conditional `run_in_background` request plus the background-run recovery at each foreground dispatch site; measured 281,370 / 187,706 = 1.4990 (2026-10-07). Merged with the guard area: measured 287,440 / 187,706 = 1.5313 (2026-10-07). + Oct 8 wave: the defensive-security framing and fixture summary mode reach the security specialist and the Red Team (stated once in the dispatch, referenced by the Red Team); measured 288,783 / 187,706 = 1.5385 (2026-10-08). + gate integrity (HONEST_WORK_GATE_INTEGRITY W3): the shared GATE_SCAN_BLOCK in the core checklist (2.5) and after Step 7's child, the four-option gate question and record fields in 9.4, tests_ran states in Step 5, Gate edits and per-lane counts in the PR body with draft-while-open; measured 300,143 / 187,706 = 1.5990 (2026-10-10). + Honest Work wave merged (W2/W3 gate integrity in review/ship, W4 kernel + DONE line in every tier-2+ skill, W5/W7 in spec/ship): re-measured on the integrated tree (2026-10-10). + Oct 10 multi-agent wave PR E2 (ship/sections/handoff.md: the no-PR automation handoff, with its Section-index row and STOP pointer in the skeleton), offset by tightening Steps 1–3, 12, 14–17 and 20–21 prose so the skeleton stays under 81,000 bytes (80,997); C-pregate landed 1,123 union bytes in tests.md in between; measured 308,619 / 187,706 = 1.6442 on the integrated tree (2026-10-10). + next-wave Lane D (GBRA-40 item 12): the pr-body Issue links step (promotion candidates and `fix #N`, owner-confirmed Supersedes/credit, `--author` for app-identity gh, lookup failures, ISSUE_LINKS_MISSING) and the handoff's issue-links run, with the `## Issues` placeholder shortened; Step 15's `Fixes:` trailer rule offset by tightening Step 15 so the skeleton stays at 80,998; measured 309,483 / 187,706 = 1.6488 (2026-10-14). + GSTA-61 Capy merge asks: the on-demand capy-pr section (What changed with before/after images, Why, Approvals; loads only when Step 19 prints CAPY_PR: on, so a non-Capy ship pays one manifest line and the three-line CAPY_PR check); measured 313,265 / 187,706 = 1.6689 (2026-10-20). + rule 22 / GBRA-108 (2026-10-25): Step 22 terminal line and Step 18 unattended routing in the skeleton, the pr-body section's receipt-check sentence and the handoff's Step 22 pointer; measured 314,027 / 187,706 = 1.6730. + harness lane E (2026-10-26, issue row 1: five Captains hit the review-loop cap): the review-army section's frozen finding scope (PASS/RANGE_BASE, gstack-review-scope classify/defer/render in Step 9.4, deferred and noted actions, the cap rule naming blocking findings), the pr-body section's Deferred findings / Review notes sections and --review-loop receipt input, Step 1's carry check; measured 318,010 / 187,706 = 1.6942. + the same lane's pass-3 cap rule in Step 9.4 decision 2 (a blocking finding still in fix scope on pass 3 stops for the owner; below the floor ships) and the adversarial step's RANGE_BASE scoping of queued fixes; measured 318,476 / 187,706 = 1.6967. + the skeleton's Review policy paragraph and the diff-plus-blocking scope value in Step 9.4; measured 319,071 / 187,706 = 1.6998. + the same lane's pass-1 review fixes: the lead-judgment stage in the finding merge (nit gravity, hypothetical vs actual, premature structure, a different design, missing context; absorbed from pstack's interrogate), the `cause` anchor in every specialist prompt, the blocker/should/nit glossary in Step 9.4 and the two stop rules in the repeat decisions (absorbed from tstack's sol-loop), pass 2's range in the core read; measured 321,630 / 187,706 = 1.7135 (2026-10-26). + absorb lane B (2026-10-26, Garry: "Do it, send" on plans/absorb-tstack.md item 4 and absorb-pstack.md items 2(a) and 11): the pr-body section's lead-with-the-problem Summary instruction with its BAD/GOOD pair, the `## Blast radius` item in pr-body and capy-pr, capy-pr's `## Why` pair, and one gstack-prose-lint command line each in the changelog and pr-body sections; skeleton unchanged at 82,645; measured 323,295 / 187,706 = 1.7223. + maxSizeRatio: 1.7250, // Shared advisory identity/dedup + critical-severity validation: 248,065 union bytes / 187,706 baseline = 1.3216 (2026-09-17). + test value bar in the lazy Step 7 section (value cards, weak paths, gate table, base control, machine checks; ~13.6KB): measured 1.396 (2026-09-29). + W1 guarded state-root resolution (`eval gstack-paths; : "${GSTACK_STATE_ROOT:?…}"`) in the Context Recovery preamble, the eureka log and each state-writing bash block; measured 1.401 (2026-09-30). + the shared QA review step's plan-check timing rule (plan checks and their revalidation run on --timeout-ms after smoke expiry); measured 1.4022 (2026-09-30). + the shared TEST_BOOTSTRAP owned-change undo rules (s03 H1-H3, M1) and the value-bar coverage wording (s03 M2/M3); measured 1.4044 (2026-10-02). + severe fix wave ship-review lane: G1 NO_VERSION dispatch, title and CHANGELOG arms (versionless repos ship without inventing a version), F1 parent-side plan binding before the audit dispatch (binding, docs/designs candidates, exact not-run line) and D3's data-not-instructions line; +2,126 union bytes, measured 1.4135 (2026-10-03). + INV-1 consumer lines (outside review `unverified`/`unavailable` is missing coverage in the readiness note and PR body); measured 1.4148 (2026-10-03). + v1.91.19.0 severe fix wave: verdict-form outside-review callers with sandbox preflight and first-use notice (INV-1/B1/Q2), P0/P1 gate and unverified wording, bound-plan discovery (F1), visible LEARNINGS failures (B5), carved external pointers; measured 267,752 / 187,706 = 1.4264 (2026-10-03). + v1.91.18.0 (#3023) merged into the v1.91.19.0 wave; measured 267,874 / 187,706 = 1.4271 (2026-10-04). + Oct 6 fix wave: quota_exhausted preflight branch (#3051) and quoted-heredoc issue template (#3046); measured 268,199 / 187,706 = 1.4288 (2026-10-06). + Step 10's inline no-PR check before the Greptile dispatch (#3020); measured 268,655 / 187,706 = 1.4313 (2026-10-06). + Oct 6 fix wave A3/CEO-12: free text reaches commands only through agent-written mktemp files (mktemp block, write rule, path rebuild and refusal at each posting, prompt and brief site); measured 275,422 / 187,706 = 1.4673 (2026-10-06). Combined Oct 6 wave head (B free-text files + D measure loop + A rework): measured 280,363 / 187,706 = 1.4936 (2026-10-06). + gate-once push rule (no second paid gate after a CI push); measured 280,573 / 187,706 = 1.4947 (2026-10-06). + the measurement bar's decision step (exit-code branches for MEETS, MEETS-qualified, needs-classify with the qualifying and fix-round classes, EXTEND once; the measure prose was tightened to offset most of it); measured 281,058 / 187,706 = 1.4973 (2026-10-06). + Oct 7 wave B3: the Codex probe runs as a command (select-model capture lines, check-sandbox/probe-model/run-with-timeout subcommands) instead of being sourced; measured 281,438 / 187,706 = 1.4994 (2026-10-07). + Oct 7 wave CEO-28/DX-11: review prompts ask for a severity per finding and design proposals use the proposal gate; measured 281,556 / 187,706 = 1.5000 (2026-10-07). + Oct 7 wave B4: /ship publishes through gstack-post (title prepared in a file in the pr-body section, compose block plus gstack-post exit-code branches replacing the two scans); measured 281,894 / 187,706 = 1.5018 (2026-10-07). + B2 early Greptile PR (lazy Step 6.5 section: detect, public-repo consent, push, provisional title file, open; Step 10's bounded wait; the stop-leaves-the-draft rule); measured 286,626 / 187,706 = 1.5270 (2026-10-07). B3, the classifier, B4 and B2 merged: measured 287,128 / 187,706 = 1.5297 (2026-10-07). + Oct 7 wave guard (CEO-20): conditional `run_in_background` request plus the background-run recovery at each foreground dispatch site; measured 281,370 / 187,706 = 1.4990 (2026-10-07). Merged with the guard area: measured 287,440 / 187,706 = 1.5313 (2026-10-07). + Oct 8 wave: the defensive-security framing and fixture summary mode reach the security specialist and the Red Team (stated once in the dispatch, referenced by the Red Team); measured 288,783 / 187,706 = 1.5385 (2026-10-08). + gate integrity (HONEST_WORK_GATE_INTEGRITY W3): the shared GATE_SCAN_BLOCK in the core checklist (2.5) and after Step 7's child, the four-option gate question and record fields in 9.4, tests_ran states in Step 5, Gate edits and per-lane counts in the PR body with draft-while-open; measured 300,143 / 187,706 = 1.5990 (2026-10-10). + Honest Work wave merged (W2/W3 gate integrity in review/ship, W4 kernel + DONE line in every tier-2+ skill, W5/W7 in spec/ship): re-measured on the integrated tree (2026-10-10). + Oct 10 multi-agent wave PR E2 (ship/sections/handoff.md: the no-PR automation handoff, with its Section-index row and STOP pointer in the skeleton), offset by tightening Steps 1–3, 12, 14–17 and 20–21 prose so the skeleton stays under 81,000 bytes (80,997); C-pregate landed 1,123 union bytes in tests.md in between; measured 308,619 / 187,706 = 1.6442 on the integrated tree (2026-10-10). + next-wave Lane D (GBRA-40 item 12): the pr-body Issue links step (promotion candidates and `fix #N`, owner-confirmed Supersedes/credit, `--author` for app-identity gh, lookup failures, ISSUE_LINKS_MISSING) and the handoff's issue-links run, with the `## Issues` placeholder shortened; Step 15's `Fixes:` trailer rule offset by tightening Step 15 so the skeleton stays at 80,998; measured 309,483 / 187,706 = 1.6488 (2026-10-14). + GSTA-61 Capy merge asks: the on-demand capy-pr section (What changed with before/after images, Why, Approvals; loads only when Step 19 prints CAPY_PR: on, so a non-Capy ship pays one manifest line and the three-line CAPY_PR check); measured 313,265 / 187,706 = 1.6689 (2026-10-20). + rule 22 / GBRA-108 (2026-10-25): Step 22 terminal line and Step 18 unattended routing in the skeleton, the pr-body section's receipt-check sentence and the handoff's Step 22 pointer; measured 314,027 / 187,706 = 1.6730. + harness lane E (2026-10-26, issue row 1: five Captains hit the review-loop cap): the review-army section's frozen finding scope (PASS/RANGE_BASE, gstack-review-scope classify/defer/render in Step 9.4, deferred and noted actions, the cap rule naming blocking findings), the pr-body section's Deferred findings / Review notes sections and --review-loop receipt input, Step 1's carry check; measured 318,010 / 187,706 = 1.6942. + the same lane's pass-3 cap rule in Step 9.4 decision 2 (a blocking finding still in fix scope on pass 3 stops for the owner; below the floor ships) and the adversarial step's RANGE_BASE scoping of queued fixes; measured 318,476 / 187,706 = 1.6967. + the skeleton's Review policy paragraph and the diff-plus-blocking scope value in Step 9.4; measured 319,071 / 187,706 = 1.6998. + the same lane's pass-1 review fixes: the lead-judgment stage in the finding merge (nit gravity, hypothetical vs actual, premature structure, a different design, missing context; absorbed from pstack's interrogate), the `cause` anchor in every specialist prompt, the blocker/should/nit glossary in Step 9.4 and the two stop rules in the repeat decisions (absorbed from tstack's sol-loop), pass 2's range in the core read; measured 321,630 / 187,706 = 1.7135 (2026-10-26). + absorb lane B (2026-10-26, Garry: "Do it, send" on plans/absorb-tstack.md item 4 and absorb-pstack.md items 2(a) and 11): the pr-body section's lead-with-the-problem Summary instruction with its BAD/GOOD pair, the `## Blast radius` item in pr-body and capy-pr, capy-pr's `## Why` pair, and one gstack-prose-lint command line each in the changelog and pr-body sections; skeleton unchanged at 82,645; measured 323,371 / 187,706 = 1.7228 after the pass-1 review fixes (the compose gate reads only the author-written sections through --sections). }, 'plan-ceo-review': { skill: 'plan-ceo-review', diff --git a/test/prose-lint.test.ts b/test/prose-lint.test.ts index 70daf50e4..f33d9b8c2 100644 --- a/test/prose-lint.test.ts +++ b/test/prose-lint.test.ts @@ -10,7 +10,7 @@ import { spawnSync } from 'node:child_process'; import * as fs from 'node:fs'; import * as os from 'node:os'; import * as path from 'node:path'; -import { addedOnly, lintProse, maskProse, renderProseLint, unreleasedBlock } from '../lib/prose-lint'; +import { addedOnly, lintProse, maskProse, renderProseLint, sectionsOnly, unreleasedBlock } from '../lib/prose-lint'; const ROOT = path.resolve(import.meta.dir, '..'); const FIXTURES = path.join(ROOT, 'test', 'fixtures', 'prose-lint'); @@ -125,6 +125,31 @@ describe('lintProse: each rule, positive and negative', () => { }); describe('selections', () => { + // Value: protects=the Step 19 compose gate reads only the sections an author writes, so a verbatim tool section (a review line such as "Problem → what you did") can never dead-end /ship; fails_when=a tool section's text is linted, an author section is skipped, heading case is significant, or a body with none of the headings reads as scanned; why_new=new selection; seam=none; no_claim=the compose block's heading list itself is pinned by the wiring test + test('sectionsOnly keeps only the named ## sections, line numbers intact, and is null when none is present', () => { + const body = [ + '## Summary', 'The parser showcases a vibrant result.', '', + '## Pre-Landing Review', '[AUTO-FIXED] [x.ts:1] Problem → what you did', '', + '## blast radius', 'Touches the compose block only.', 'Caller -> callee is the only arrow here.', + '## Test plan', '- [x] bun test → 20 pass', + ].join('\n'); + const picked = sectionsOnly(body, ['Summary', 'Blast radius'])!; + expect(picked.split('\n')).toHaveLength(body.split('\n').length); + expect(lintProse(picked, 'b.md').map(f => [f.rule, f.line])).toEqual([['vocabulary', 2], ['vocabulary', 2], ['arrow', 9]]); + expect(sectionsOnly(body, ['Why'])).toBeNull(); + const gated = run([path.join(FIXTURES, 'slopped-changelog.md'), '--sections', '[1.0.0] - 2026-10-01', '--gate']); + expect(gated.code).toBe(1); + expect(gated.out).toMatch(/^PROSE: \S+slopped-changelog\.md:16 /m); + expect(gated.out).not.toMatch(/^PROSE: \S+slopped-changelog\.md:(5|8|9|10) /m); + expect(run([path.join(FIXTURES, 'clean-changelog.md'), '--sections', 'Nope', '--gate']).out).toContain('PROSE_LINT: none'); + }); + + // Value: protects=an HTML comment that spans lines is never read, as the header promises; fails_when=the second line of a comment, or the text before its closing marker, produces a finding, or prose after the close is skipped; why_new=the mask test above covers one-line comments only; seam=none; no_claim=none beyond protects + test('a multi-line HTML comment is masked through its closing marker and prose after it is read', () => { + const text = ['Plain start. The parser showcases it.', 'Showcasing a vibrant end.'].join('\n'); + expect(lintProse(text, 'x.md').map(f => [f.rule, f.line])).toEqual([['vocabulary', 3], ['vocabulary', 4], ['vocabulary', 4]]); + }); + test('unreleasedBlock returns the block with its heading line, or null', () => { const text = fs.readFileSync(path.join(FIXTURES, 'slopped-changelog.md'), 'utf8'); const block = unreleasedBlock(text)!; @@ -225,6 +250,8 @@ describe('bin/gstack-prose-lint', () => { expect(run([path.join(FIXTURES, 'missing.md')]).code).toBe(2); expect(run([path.join(FIXTURES, 'clean-changelog.md'), '--nope']).code).toBe(2); expect(run([path.join(FIXTURES, 'clean-changelog.md'), '--unreleased', '--base', 'main']).code).toBe(2); + expect(run([path.join(FIXTURES, 'clean-changelog.md'), '--sections', 'Added', '--base', 'main']).code).toBe(2); + expect(run([path.join(FIXTURES, 'clean-changelog.md'), '--sections', '--gate']).code).toBe(2); const dir = run([FIXTURES]); expect(dir.code).toBe(2); expect(dir.err).toContain('cannot read'); @@ -243,7 +270,8 @@ describe('wiring', () => { expect(changelog).toContain('gstack-prose-lint CHANGELOG.md --base origin/ --gate'); const prBody = read('ship/sections/pr-body.md.tmpl'); const compose = prBody.slice(prBody.indexOf(': "${DOCS_SECTION_FILE:?'), prBody.indexOf('echo "PR_BODY_FILE: $PR_BODY_FILE"')); - expect(compose).toContain('gstack-prose-lint "$BODY_TOP_FILE" "$BODY_REST_FILE" --gate'); + expect(compose).toContain(`gstack-prose-lint "$BODY_TOP_FILE" --sections 'What changed,Why,Summary,Blast radius' --gate`); + expect(compose).not.toContain('"$BODY_REST_FILE" --gate'); expect(compose.indexOf('gstack-prose-lint')).toBeGreaterThan(compose.indexOf('is empty, so the body was never written')); expect(compose.indexOf('gstack-prose-lint')).toBeLessThan(compose.indexOf('{ awk 1 "$BODY_TOP_FILE"')); for (const host of ['claude', 'codex', 'factory', 'agy']) expect(fs.existsSync(path.join(ROOT, 'test', 'fixtures', 'golden', host + '-ship-SKILL.md'))).toBe(true); @@ -258,14 +286,14 @@ describe('wiring', () => { expect(summary).toContain('## Blast radius'); expect(summary).toContain('the one fact that makes that safe'); expect(summary).toContain('file:line read, walked failure path, script ran, repro'); - expect(summary).toContain('investigate/sections/blast-radius.md'); + expect(summary).toContain('`~/.claude/skills/gstack/investigate/sections/blast-radius.md`, when present'); expect(prBody).toContain('names what the user gets, not the mechanism'); const capy = read('ship/sections/capy-pr.md.tmpl'); expect(capy).toContain('`## Why`, `## Blast radius` and `## Approvals`'); expect(capy).toMatch(/\*\*`## Why`:\*\* two or three sentences\. The first states the problem in the person's words/); expect(capy).toMatch(/BAD "[\s\S]+" GOOD "/); expect(capy).toContain("**`## Blast radius`:** the body template's item, written once"); - expect(capy).toContain('investigate/sections/blast-radius.md'); + expect(capy).toContain('`~/.claude/skills/gstack/investigate/sections/blast-radius.md`, when present'); }); test('the quality gate runs the lint over the [Unreleased] block, and NOTICE.md credits unslop', () => { @@ -275,11 +303,11 @@ describe('wiring', () => { expect(read('docs/troubleshooting.md')).toContain(''); }); - test('the shipped templates pass their own lint as a report (no gate), and the current [Unreleased] block gates clean', () => { + // Value: protects=a template run is a report (exit 0 and a trailer) even when the templates carry findings, so a template's em dashes and BAD examples never fail a check; fails_when=report mode exits nonzero on a finding; why_new=the earlier row accepted ok or found and proved nothing; seam=none; no_claim=the templates' own findings count is not pinned (it moves with every prose edit) + test('the shipped templates get a report, never a gate: exit 0 with a trailer even with findings', () => { const r = run(['ship/sections/changelog.md.tmpl', 'ship/sections/capy-pr.md.tmpl', 'ship/sections/pr-body.md.tmpl']); expect(r.code).toBe(0); - expect(r.out).toMatch(/PROSE_LINT: (ok|found=\d+) files=3\n$/); - const cl = run(['CHANGELOG.md', '--unreleased', '--gate']); - expect(cl.code).toBe(0); + expect(r.out).toMatch(/PROSE_LINT: found=\d+ files=3\n$/); + expect(r.err).toBe(''); }); });