mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-05 16:34:00 +01:00
feat(ship): PR bodies lead with the problem and carry a Blast radius line, and gstack-prose-lint checks the CHANGELOG entry and PR body drafts
Absorb lane B (Garry, 2026-10-26: "Do it, send" on plans/absorb-tstack.md item 4 and plans/absorb-pstack.md items 2(a) and 11). Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
This commit is contained in:
1 parent
576839cb8c
commit
4ded8efad2
18 files changed
+674
-19
No files matched your search
@@ -57,6 +57,12 @@ jobs:
|
||||
- name: Slop scan (changed files, advisory)
|
||||
run: bun run slop:diff || true
|
||||
|
||||
# The CHANGELOG's [Unreleased] block is the entry this PR adds (the train
|
||||
# stamps it at merge). The lint gates only that block: an older entry, a
|
||||
# template or a doc gets a report from the same bin and never a red check.
|
||||
- name: Prose lint (CHANGELOG [Unreleased] block, gating)
|
||||
run: bun --no-env-file --config=/dev/null bin/gstack-prose-lint CHANGELOG.md --unreleased --gate
|
||||
|
||||
- name: Scan changed text for credentials (added lines, own redact engine)
|
||||
env:
|
||||
BASE_SHA: ${{ github.event.pull_request.base.sha || github.event.before }}
|
||||
|
||||
@@ -1,5 +1,25 @@
|
||||
# Changelog
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
**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 <ref>` 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`.
|
||||
- **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<version>` 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/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/<base> --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`.
|
||||
|
||||
### 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.
|
||||
|
||||
## [1.91.256.0] - 2026-10-26
|
||||
|
||||
**The /ship review loop converges, and a receipt survives a base merge.** Five Captains' /ship runs hit the review cap on the same PR shape: every pass re-reviewed the whole diff, reviewers answered an empty range with an invented advisory, and each advisory earned another full pass; four more re-ran /ship on product code they had not changed because a conflict-only main merge or a test fix moved the head the receipt named. The loop now fixes blocking findings in the diff on pass 1, reads only the range since the previous pass afterwards, caps at three passes, and counts `passes=<n> blocking_found=<k> deferred=<m>` in the ship receipt; the receipt carries to the new head when the product patch-id is unchanged. The blocking floor is the owner's call (Garry, 2026-10-26: "Ok set it at that"), and the policy lives in gstack rather than in any one team's rules (Garry, 2026-10-26: "Gstack should be the actual holder of how to do things not one off Capy files"): `docs/review-policy.md` is the canonical statement, the skills carry it as their written policy, and a crew rule can be one line pointing at it.
|
||||
|
||||
@@ -95,3 +95,13 @@ design is not a defect, missing context; independent agreement as the strongest
|
||||
`NO FINDINGS` as a correct result) in `scripts/resolvers/review-army.ts` stage 4 and
|
||||
`docs/review-policy.md` are written in gstack's words. No sentence is copied, so no license
|
||||
text is carried; this entry is a courtesy.
|
||||
|
||||
## pstack (unslop) — Copyright Lauren Tan — MIT License
|
||||
|
||||
https://github.com/cursor/plugins (`pstack/skills/unslop/SKILL.md`, at 99b609a7). The
|
||||
word lists in `lib/prose-lint.ts` (the autopilot vocabulary and the metaphor nouns with
|
||||
their plain replacements) are facts taken from unslop's rules 7 and 26; the rules a
|
||||
regular expression decides (the "not just X, but Y" shape, the mid-sentence colon, the
|
||||
bold label that restates its line, arrows and dropped articles) are written in gstack's
|
||||
words, scoped to the CHANGELOG entry and PR body /ship writes, and report-only elsewhere.
|
||||
No sentence is copied, so no license text is carried; this entry is a courtesy.
|
||||
@@ -294,6 +294,7 @@ gstack_result_anchor() {
|
||||
REVIEW_SCOPE_INPUT) printf '%s' 'review-scope-input' ;;
|
||||
REVIEW_SCOPE_RANGE) printf '%s' 'review-scope-range' ;;
|
||||
RECEIPT_CARRY_REFUSED) printf '%s' 'receipt-carry-refused' ;;
|
||||
PROSE_LINT_FOUND) printf '%s' 'prose-lint-found' ;;
|
||||
RECEIPT_CARRY_PRODUCT_CHANGED) printf '%s' 'receipt-carry-product-changed' ;;
|
||||
APPROVAL_RECORD_INVALID) printf '%s' 'approval-record-invalid' ;;
|
||||
APPROVAL_RECORD_SECRET) printf '%s' 'approval-record-secret' ;;
|
||||
|
||||
Executable
+62
@@ -0,0 +1,62 @@
|
||||
#!/usr/bin/env -S bun --no-env-file --config=/dev/null
|
||||
/**
|
||||
* 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 <file>... [--unreleased | --base <ref>] [--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=<n> | none`.
|
||||
* --unreleased reads only each file's `## [Unreleased]` CHANGELOG block and
|
||||
* --base <ref> only the lines the working tree adds since the merge base with
|
||||
* <ref> (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
|
||||
* quality-gate workflow.
|
||||
*/
|
||||
import * as fs from 'node:fs';
|
||||
import { EXIT, exitTableHelp } from '../lib/headless-artifacts';
|
||||
import { lintFiles, renderProseLint } from '../lib/prose-lint';
|
||||
import { resultLine } from '../lib/result-codes';
|
||||
|
||||
const HELP = `gstack-prose-lint — AI tells a regex can decide, over CHANGELOG entries and PR bodies (docs/troubleshooting.md#prose-lint-found)
|
||||
|
||||
<file>... 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 <ref> scan only the lines the working tree adds to each file since the merge base with <ref>
|
||||
--gate exit 1 when anything was found (default: report and exit 0)
|
||||
--json findings as JSON instead of PROSE: lines
|
||||
|
||||
Rules: vocabulary, not-just-but, metaphor-noun, mid-sentence-colon, inline-header-list, arrow, dropped-article.
|
||||
|
||||
${exitTableHelp()}`;
|
||||
|
||||
function main(argv: string[]): number {
|
||||
if (argv.length === 0 || argv.includes('--help') || argv.includes('-h')) { process.stdout.write(HELP + '\n'); return argv.length ? EXIT.ok : EXIT.usage; }
|
||||
const flags = new Set<string>();
|
||||
const files: string[] = [];
|
||||
let base: 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 (['--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; }
|
||||
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 }); }
|
||||
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));
|
||||
if (result.findings.length && flags.has('--gate')) {
|
||||
process.stderr.write(resultLine('gstack-prose-lint', 'PROSE_LINT_FOUND', `${result.findings.length} finding${result.findings.length === 1 ? '' : 's'}`) + '\n');
|
||||
return EXIT.fail;
|
||||
}
|
||||
return EXIT.ok;
|
||||
}
|
||||
|
||||
process.exit(main(process.argv.slice(2)));
|
||||
@@ -4466,6 +4466,34 @@ every flag and code.
|
||||
|
||||
**Fix.** Rerun once; if it repeats, report the printed frame to the gstack maintainers.
|
||||
|
||||
## 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
|
||||
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,
|
||||
URLs and HTML comments are never read. Everywhere else (a template, a doc, this
|
||||
file) it is a report: run it, read it, keep your em dashes.
|
||||
|
||||
<a id="prose-lint-found"></a>
|
||||
### `PROSE_LINT_FOUND`
|
||||
|
||||
**Meaning.** A gated run (`--gate`) found at least one tell in the text it was
|
||||
given. Each `PROSE: <file>:<line> <rule> "<match>" (<hint>)` line above the
|
||||
trailer names the rule and what to write instead; `PROSE_LINT: found=<n>` counts
|
||||
them. With `--unreleased` only the `## [Unreleased]` block was read; with
|
||||
`--base <ref>` only the lines the working tree added since the merge base.
|
||||
|
||||
**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.
|
||||
|
||||
## Quick answers
|
||||
|
||||
The short troubleshooting entries that used to live in the README. Each names the symptom and the fix; the sections above cover every gate message in depth.
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
/**
|
||||
* prose-lint — the AI tells a regular expression can decide, as a free check
|
||||
* over the prose /ship writes: a CHANGELOG entry and a PR body. Ideas from
|
||||
* pstack's unslop skill (Lauren Tan, MIT; NOTICE.md); the rule text is
|
||||
* gstack's and the word lists are facts. Everything else about voice stays
|
||||
* with the reader: this file never judges tone, length or metaphor it cannot
|
||||
* match. Fenced code, inline code, URLs and HTML comments are never scanned.
|
||||
* `bin/gstack-prose-lint` prints one line per finding and a
|
||||
* `PROSE_LINT: ok | found=<n> | none` trailer; it gates only when asked
|
||||
* (`--gate`), so templates and docs get a report and nothing more.
|
||||
*/
|
||||
import * as fs from 'node:fs';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { addedLines } from './banned-terms';
|
||||
import { parseChangelog } from './changelog-check';
|
||||
|
||||
export type ProseRuleId =
|
||||
| 'vocabulary'
|
||||
| 'not-just-but'
|
||||
| 'metaphor-noun'
|
||||
| 'mid-sentence-colon'
|
||||
| 'inline-header-list'
|
||||
| 'arrow'
|
||||
| 'dropped-article';
|
||||
|
||||
export interface ProseFinding {
|
||||
rule: ProseRuleId;
|
||||
file: string;
|
||||
line: number;
|
||||
/** The matched text, trimmed to 80 characters. */
|
||||
match: string;
|
||||
/** What to do instead, in one clause. */
|
||||
hint: string;
|
||||
}
|
||||
|
||||
export interface ProseLintResult {
|
||||
findings: ProseFinding[];
|
||||
/** Files that were read. */
|
||||
scanned: string[];
|
||||
/** Files that had no text to scan (no `[Unreleased]` block, or no added lines). */
|
||||
empty: string[];
|
||||
}
|
||||
|
||||
/** Words that mark text as written on autopilot. Replace each with the plain word. */
|
||||
export const VOCABULARY = [
|
||||
'additionally', 'crucial', 'delve', 'delves', 'delving', 'enduring', 'enhance', 'enhances', 'enhanced', 'enhancing',
|
||||
'fostering', 'fosters', 'garner', 'garners', 'garnered', 'interplay', 'intricate', 'pivotal', 'showcase', 'showcases',
|
||||
'showcasing', 'testament', 'underscores', 'underscored', 'underscoring', 'vibrant',
|
||||
] as const;
|
||||
|
||||
/** Nouns borrowed from engineering or geography to make an ordinary thing sound structural. */
|
||||
export const METAPHOR_NOUNS: ReadonlyArray<{ word: string; plain: string }> = [
|
||||
{ word: 'substrate', plain: 'base' },
|
||||
{ word: 'wedge', plain: 'the thing itself, or "add"' },
|
||||
{ word: 'locus', plain: 'place' },
|
||||
{ word: 'vantage', plain: 'view' },
|
||||
{ word: 'nexus', plain: 'link or center' },
|
||||
{ word: 'bedrock', plain: 'base' },
|
||||
{ word: 'modality', plain: 'mode or kind' },
|
||||
{ word: 'paradigm', plain: 'model or approach' },
|
||||
{ word: 'gold-plating', plain: 'more than the job needs' },
|
||||
{ word: 'north star', plain: 'goal' },
|
||||
{ word: 'flywheel', plain: 'the loop you mean' },
|
||||
{ word: 'endgame', plain: 'last phase' },
|
||||
{ word: 'API surface', plain: 'the API, or the exported names' },
|
||||
{ word: 'evacuate', plain: 'move out' },
|
||||
];
|
||||
|
||||
const VOCABULARY_RE = new RegExp(String.raw`\b(?:${VOCABULARY.join('|')})\b`, 'gi');
|
||||
const LANDSCAPE_RE = /\b(?:landscape|tapestry)\b(?!\s+(?:mode|orientation|image|photo|print))/gi;
|
||||
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 INLINE_HEADER_RE = /^\s*(?:[-*+]|\d+\.)\s+\*\*([^*\n]{1,60}?):?\*\*:?\s+(\S+)/;
|
||||
const ARROW_RE = /(\S+)\s*(→|->|=>|⇒)\s*(\S+)/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',
|
||||
'receipt', 'train', 'lane', 'thread', 'model', 'caller', 'server', 'client', 'daemon', 'process', 'worker',
|
||||
'function', 'helper', 'hook', 'skill', 'section', 'template', 'body', 'title', 'entry', 'step', 'loop', 'check',
|
||||
'linter', 'workflow', 'pipeline', 'job', 'tool', 'flag', 'option', 'config', 'policy', 'rule', 'finding', 'issue',
|
||||
'request', 'response', 'result', 'output', 'error', 'warning', 'page', 'button', 'form', 'field', 'input', 'token',
|
||||
'key', 'value', 'record', 'row', 'table', 'column', 'query', 'index', 'cache', 'queue', 'message', 'event',
|
||||
'handler', 'listener', 'plugin', 'driver', 'module', 'package', 'library', 'dependency', 'version', 'release',
|
||||
'commit', 'merge', 'rebase', 'diff', 'patch', 'compiler', 'loader', 'scheduler', 'watcher', 'migration', 'schema',
|
||||
'endpoint', 'route', 'controller', 'service', 'container', 'image', 'cluster', 'node', 'deploy', 'install',
|
||||
'installer', 'upgrade', 'session', 'browser', 'tab', 'window', 'dialog', 'prompt', 'reply', 'answer', 'question',
|
||||
]);
|
||||
const VERBS = new Set([
|
||||
'rejects', 'returns', 'fails', 'writes', 'reads', 'exits', 'runs', 'calls', 'prints', 'sends', 'takes', 'needs',
|
||||
'uses', 'gets', 'sets', 'moves', 'keeps', 'stops', 'starts', 'checks', 'skips', 'drops', 'adds', 'removes', 'opens',
|
||||
'closes', 'loads', 'saves', 'parses', 'builds', 'creates', 'deletes', 'updates', 'handles', 'passes', 'blocks',
|
||||
'refuses', 'accepts', 'picks', 'finds', 'holds', 'emits', 'throws', 'catches', 'logs', 'records', 'marks', 'shows',
|
||||
'hides', 'renders', 'resolves', 'binds', 'tracks', 'waits', 'retries', 'restarts', 'reports', 'asks', 'tells',
|
||||
'validates', 'verifies', 'scans', 'lints', 'formats', 'compiles', 'installs', 'upgrades', 'merges', 'pushes',
|
||||
'pulls', 'fetches', 'commits', 'stamps', 'reheads', 'owns', 'carries', 'gains', 'loses', 'wins', 'counts',
|
||||
]);
|
||||
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. */
|
||||
export function maskProse(text: string): string {
|
||||
const lines = text.split('\n');
|
||||
let fence: string | null = null;
|
||||
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 (open) { fence = open[1]!; return ''; }
|
||||
return line
|
||||
.replace(/<!--[\s\S]*?-->/g, (m) => ' '.repeat(m.length))
|
||||
.replace(/`[^`\n]*`/g, (m) => 'CODE' + ' '.repeat(Math.max(0, m.length - 4)))
|
||||
.replace(/\]\([^)\s]+\)/g, (m) => ']' + ' '.repeat(m.length - 1))
|
||||
.replace(/https?:\/\/\S+/g, (m) => 'URL' + ' '.repeat(Math.max(0, m.length - 3)));
|
||||
});
|
||||
return out.join('\n');
|
||||
}
|
||||
|
||||
function excerpt(s: string): string {
|
||||
const t = s.replace(/\s+/g, ' ').trim();
|
||||
return t.length > 80 ? t.slice(0, 77) + '...' : t;
|
||||
}
|
||||
|
||||
/** Lint one text; `file` is only carried into the findings. Lines are 1-based from the text's own start plus `offset`. */
|
||||
export function lintProse(text: string, file: string, offset = 0): ProseFinding[] {
|
||||
const masked = maskProse(text);
|
||||
const lines = masked.split('\n');
|
||||
const findings: ProseFinding[] = [];
|
||||
const push = (rule: ProseRuleId, line: number, match: string, hint: string) => findings.push({ rule, file, line: line + offset, match: excerpt(match), hint });
|
||||
const lineAt = (index: number) => masked.slice(0, index).split('\n').length;
|
||||
|
||||
lines.forEach((line, i) => {
|
||||
const n = i + 1;
|
||||
for (const m of line.matchAll(VOCABULARY_RE)) push('vocabulary', n, m[0], 'use the plain word');
|
||||
for (const m of line.matchAll(LANDSCAPE_RE)) push('vocabulary', n, m[0], 'name the actual set of things');
|
||||
for (const m of line.matchAll(METAPHOR_RE)) {
|
||||
const row = METAPHOR_NOUNS.find(r => new RegExp(String.raw`^${r.word.replace('-', '[- ]')}s?$`, 'i').test(m[0]));
|
||||
push('metaphor-noun', n, m[0], `say "${row?.plain ?? 'the concrete thing'}"`);
|
||||
}
|
||||
for (const m of line.matchAll(NOT_JUST_BUT_RE)) push('not-just-but', n, m[0], 'state the point once, without the contrast');
|
||||
const header = INLINE_HEADER_RE.exec(line);
|
||||
if (header) {
|
||||
const label = header[1]!.replace(/[*`]/g, '').trim().split(/\s+/)[0]!.toLowerCase().replace(/[^a-z0-9]/g, '');
|
||||
const first = header[2]!.toLowerCase().replace(/[^a-z0-9]/g, '');
|
||||
if (label && label.length > 2 && label !== 'code' && first === label) push('inline-header-list', n, header[0], 'drop the label or make the line say something the label does not');
|
||||
}
|
||||
for (const m of line.matchAll(ARROW_RE)) {
|
||||
if (NUMERIC_SIDE.test(trimSide(m[1]!)) && NUMERIC_SIDE.test(trimSide(m[3]!))) continue;
|
||||
push('arrow', n, m[0], 'write the sentence the arrow stands for');
|
||||
}
|
||||
});
|
||||
for (const m of masked.matchAll(MID_COLON_RE)) if (!LIST_INTRODUCER.test(m[1]!)) push('mid-sentence-colon', lineAt(m.index! + m[0].indexOf(':')), `${m[1]!.split(/\s+/).slice(-3).join(' ')}: ${m[2]!.split(/\s+/).slice(0, 3).join(' ')}`, 'end the sentence there, or join the halves with a plain conjunction');
|
||||
for (const m of masked.matchAll(SENTENCE_START_RE)) {
|
||||
if (BARE_NOUNS.has(m[1]!.toLowerCase()) && VERBS.has(m[2]!)) push('dropped-article', lineAt(m.index! + m[0].indexOf(m[1]!)), `${m[1]} ${m[2]}`, 'start with "the" or "a"');
|
||||
}
|
||||
return findings.sort((a, b) => a.line - b.line || a.rule.localeCompare(b.rule));
|
||||
}
|
||||
|
||||
/** The `## [Unreleased]` block of a CHANGELOG, with the 1-based line its heading sits on; null when there is none. */
|
||||
export function unreleasedBlock(text: string): { text: string; line: number } | null {
|
||||
const section = parseChangelog(text).sections.find(s => s.unreleased);
|
||||
if (!section) return null;
|
||||
return { text: section.lines.join('\n'), line: section.start + 1 };
|
||||
}
|
||||
|
||||
export interface LintFilesOptions {
|
||||
/** Scan only each file's `[Unreleased]` CHANGELOG block. */
|
||||
unreleased?: boolean;
|
||||
/** 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;
|
||||
}
|
||||
|
||||
/** The file's text with every line the branch did not add blanked, so line numbers hold; null when nothing was added. */
|
||||
export function addedOnly(file: string, base: string): string | null {
|
||||
const run = (args: string[]) => spawnSync('git', args, { encoding: 'utf8', timeout: 30_000 });
|
||||
if (run(['ls-files', '--error-unmatch', '--', file]).status !== 0) return fs.readFileSync(file, 'utf8');
|
||||
const mb = run(['merge-base', base, 'HEAD']);
|
||||
const since = mb.status === 0 ? mb.stdout.trim() : base;
|
||||
const diff = run(['diff', '--unified=0', '--no-color', '--no-ext-diff', since, '--', file]);
|
||||
if (diff.status !== 0) throw new Error(`git diff ${since} -- ${file} failed: ${diff.stderr.trim()}`);
|
||||
const added = [...addedLines(diff.stdout).values()].flat();
|
||||
if (added.length === 0) return null;
|
||||
const keep = new Set(added.map(l => l.line));
|
||||
return fs.readFileSync(file, 'utf8').split('\n').map((l, i) => keep.has(i + 1) ? l : '').join('\n');
|
||||
}
|
||||
|
||||
export function lintFiles(files: string[], opts: LintFilesOptions = {}): ProseLintResult {
|
||||
const result: ProseLintResult = { findings: [], scanned: [], empty: [] };
|
||||
for (const file of files) {
|
||||
const text = fs.readFileSync(file, 'utf8');
|
||||
if (opts.base) {
|
||||
const added = addedOnly(file, opts.base);
|
||||
if (added === null) { result.empty.push(file); continue; }
|
||||
result.scanned.push(file);
|
||||
result.findings.push(...lintProse(added, file));
|
||||
continue;
|
||||
}
|
||||
if (opts.unreleased) {
|
||||
const block = unreleasedBlock(text);
|
||||
if (!block) { result.empty.push(file); continue; }
|
||||
result.scanned.push(file);
|
||||
result.findings.push(...lintProse(block.text, file, block.line - 1));
|
||||
continue;
|
||||
}
|
||||
result.scanned.push(file);
|
||||
result.findings.push(...lintProse(text, file));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function renderProseLint(r: ProseLintResult): string {
|
||||
const lines = r.findings.map(f => `PROSE: ${f.file}:${f.line} ${f.rule} "${f.match}" (${f.hint})`);
|
||||
const state = r.findings.length ? `found=${r.findings.length}` : r.scanned.length ? 'ok' : 'none';
|
||||
lines.push(`PROSE_LINT: ${state} files=${r.scanned.length}${r.empty.length ? ` nothing-to-scan=${r.empty.length}` : ''}`);
|
||||
return lines.join('\n') + '\n';
|
||||
}
|
||||
@@ -224,6 +224,11 @@ export const RELEASE_RESULT_CODES = {
|
||||
summary: 'gstack-ship-receipt carry could not carry the /ship receipt to HEAD: the PR body has no receipt, the receipt head is not an ancestor of HEAD (rebase or force-push), or the product diff could not be compared',
|
||||
fix: 'merge the base in instead of rebasing, keep the reviewed head in HEAD\'s history, then rerun carry; otherwise run /ship on the new head',
|
||||
},
|
||||
PROSE_LINT_FOUND: {
|
||||
anchor: 'prose-lint-found',
|
||||
summary: 'the prose /ship is about to publish (a CHANGELOG entry or a PR body) carries an AI tell a regular expression can decide',
|
||||
fix: 'rewrite each PROSE: line in the draft the way its hint says, then rerun gstack-prose-lint on the same file',
|
||||
},
|
||||
RECEIPT_CARRY_PRODUCT_CHANGED: {
|
||||
anchor: 'receipt-carry-product-changed',
|
||||
summary: 'the product diff against the base changed between the receipt\'s head and HEAD (a product file differs beyond a base merge, a test-only fix or release metadata), so the reviewed verdict does not cover HEAD',
|
||||
|
||||
@@ -7,8 +7,8 @@ the READY report, through a Captain, so the body shows what changed in pictures,
|
||||
approved what. Guide: `captain.md` beside the Capy `gstack` skill's SKILL.md.
|
||||
|
||||
**Order.** The `gstack-ship-receipt` block stays first when Step 19 writes one. Then `## What changed`,
|
||||
`## Why` and `## Approvals`, then the standard sections below. Keep `## Summary` to commit themes;
|
||||
do not repeat the captions.
|
||||
`## Why`, `## Blast radius` and `## Approvals`, then the standard sections below. Keep `## Summary` to
|
||||
commit themes; do not repeat the captions.
|
||||
|
||||
1. **Pick the evidence:** one to three before/after pairs, highest-signal first. The first is the
|
||||
key image.
|
||||
@@ -33,10 +33,13 @@ do not repeat the captions.
|
||||
`image upload failed: <reason>` under the caption. Never block publication on an image.
|
||||
4. **Under every image:** a one-line caption saying what changed and why, then the contents of the
|
||||
printed `text.md` (a collapsed text before/after, for readers who cannot see the image and for search).
|
||||
5. **`## Why`:** two or three sentences covering the cause (issue, failed run, or the person's request,
|
||||
quoted) and the effect the user will see.
|
||||
6. **`## Approvals`:** the output of `~/.claude/skills/gstack/bin/gstack-approvals render`, verbatim. It
|
||||
5. **`## Why`:** two or three sentences. The first states the problem in the person's words (the issue,
|
||||
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`).
|
||||
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.
|
||||
7. **READY report:** attach the key image's PNG to the report you send (Capy's message
|
||||
8. **READY report:** attach the key image's PNG to the report you send (Capy's message
|
||||
attachments) and give its uploaded URL, so the Captain can show it inline.
|
||||
@@ -5,8 +5,8 @@ the READY report, through a Captain, so the body shows what changed in pictures,
|
||||
approved what. Guide: `captain.md` beside the Capy `gstack` skill's SKILL.md.
|
||||
|
||||
**Order.** The `gstack-ship-receipt` block stays first when Step 19 writes one. Then `## What changed`,
|
||||
`## Why` and `## Approvals`, then the standard sections below. Keep `## Summary` to commit themes;
|
||||
do not repeat the captions.
|
||||
`## Why`, `## Blast radius` and `## Approvals`, then the standard sections below. Keep `## Summary` to
|
||||
commit themes; do not repeat the captions.
|
||||
|
||||
1. **Pick the evidence:** one to three before/after pairs, highest-signal first. The first is the
|
||||
key image.
|
||||
@@ -31,10 +31,13 @@ do not repeat the captions.
|
||||
`image upload failed: <reason>` under the caption. Never block publication on an image.
|
||||
4. **Under every image:** a one-line caption saying what changed and why, then the contents of the
|
||||
printed `text.md` (a collapsed text before/after, for readers who cannot see the image and for search).
|
||||
5. **`## Why`:** two or three sentences covering the cause (issue, failed run, or the person's request,
|
||||
quoted) and the effect the user will see.
|
||||
6. **`## Approvals`:** the output of `~/.claude/skills/gstack/bin/gstack-approvals render`, verbatim. It
|
||||
5. **`## Why`:** two or three sentences. The first states the problem in the person's words (the issue,
|
||||
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`).
|
||||
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.
|
||||
7. **READY report:** attach the key image's PNG to the report you send (Capy's message
|
||||
8. **READY report:** attach the key image's PNG to the report you send (Capy's message
|
||||
attachments) and give its uploaded URL, so the Captain can show it inline.
|
||||
@@ -42,6 +42,8 @@
|
||||
|
||||
**Do NOT ask the user to describe changes.** Infer from the diff and commit history.
|
||||
|
||||
7. **Lint the entry:** `~/.claude/skills/gstack/bin/gstack-prose-lint CHANGELOG.md --base origin/<base> --gate` reads only the lines this branch added; rewrite each `PROSE:` line it names and rerun until the trailer is `PROSE_LINT: ok`.
|
||||
|
||||
**NO_VERSION (Step 12):** skip this step; write no CHANGELOG entry or version header.
|
||||
|
||||
---
|
||||
@@ -1,5 +1,7 @@
|
||||
{{CHANGELOG_WORKFLOW}}
|
||||
|
||||
7. **Lint the entry:** `~/.claude/skills/gstack/bin/gstack-prose-lint CHANGELOG.md --base origin/<base> --gate` reads only the lines this branch added; rewrite each `PROSE:` line it names and rerun until the trailer is `PROSE_LINT: ok`.
|
||||
|
||||
**NO_VERSION (Step 12):** skip this step; write no CHANGELOG entry or version header.
|
||||
|
||||
---
|
||||
@@ -20,7 +20,8 @@ Write the text into each printed file with your file-write tool (Claude Code's W
|
||||
gh pr view <pr-number> --json title -q .title | ~/.claude/skills/gstack/bin/gstack-pr-title-rewrite.sh <new-version> --stdin > "$TITLE_FILE" || exit 1
|
||||
```
|
||||
GitLab reads `glab mr view <pr-number> --output json | jq -r .title` into the same pipe.
|
||||
2. For a new PR/MR, write `v<NEW_VERSION> <type>: <summary>` into the file with your file-write tool.
|
||||
2. For a new PR/MR, write `v<NEW_VERSION> <type>: <summary>` into the file with your file-write tool;
|
||||
`<summary>` names what the user gets, not the mechanism.
|
||||
3. Every created or updated title MUST start with `v$NEW_VERSION `; check the file with
|
||||
`grep -Eq '^v<new-version>( |$)' "$TITLE_FILE"` and never publish an unprefixed title.
|
||||
4. **NO_VERSION:** replaces items 1-3: keep an existing title (the platform title
|
||||
@@ -74,8 +75,18 @@ The PR/MR body should contain these sections (never reuse a prior run's body):
|
||||
|
||||
```
|
||||
## Summary
|
||||
<Read `git log origin/<base>..HEAD --oneline`. Group every substantive commit by
|
||||
theme, excluding VERSION/CHANGELOG bookkeeping. Do not paste the commit list.>
|
||||
<Open with the problem in the user's words (what they hit, saw or asked), then the fix
|
||||
and what they get now; themes from `git log origin/<base>..HEAD --oneline` after that,
|
||||
minus VERSION/CHANGELOG bookkeeping. Never an inventory or a commit list. BAD: "Adds
|
||||
gstack-prose-lint, wires Steps 13 and 19, refreshes the goldens." GOOD: "Nobody reads a
|
||||
CHANGELOG entry twice, so autopilot words shipped every release. /ship now lints the entry
|
||||
before the commit and names each line to rewrite.">
|
||||
|
||||
## Blast radius
|
||||
<One or two sentences: what else the change touches (callers, consumers, generated
|
||||
files, hosts) and the one fact that makes that safe, with the rung of the evidence
|
||||
ladder the fact reached (file:line read, walked failure path, script ran, repro). The
|
||||
ladder is `investigate/sections/blast-radius.md`.>
|
||||
|
||||
## Test Coverage
|
||||
<coverage diagram from Step 7, or "All new code paths have test coverage.">
|
||||
@@ -194,6 +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-top-file-name>"
|
||||
BODY_REST_FILE="$(git rev-parse --show-toplevel 2>/dev/null || pwd)/.gstack/tmp/<body-rest-file-name>"
|
||||
[ -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; }
|
||||
{ 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"
|
||||
|
||||
@@ -11,7 +11,8 @@ Prepare the title in a private file; Step 19 posts that file, never a shell stri
|
||||
gh pr view <pr-number> --json title -q .title | ~/.claude/skills/gstack/bin/gstack-pr-title-rewrite.sh <new-version> --stdin > "$TITLE_FILE" || exit 1
|
||||
```
|
||||
GitLab reads `glab mr view <pr-number> --output json | jq -r .title` into the same pipe.
|
||||
2. For a new PR/MR, write `v<NEW_VERSION> <type>: <summary>` into the file with your file-write tool.
|
||||
2. For a new PR/MR, write `v<NEW_VERSION> <type>: <summary>` into the file with your file-write tool;
|
||||
`<summary>` names what the user gets, not the mechanism.
|
||||
3. Every created or updated title MUST start with `v$NEW_VERSION `; check the file with
|
||||
`grep -Eq '^v<new-version>( |$)' "$TITLE_FILE"` and never publish an unprefixed title.
|
||||
4. **NO_VERSION:** replaces items 1-3: keep an existing title (the platform title
|
||||
@@ -65,8 +66,18 @@ The PR/MR body should contain these sections (never reuse a prior run's body):
|
||||
|
||||
```
|
||||
## Summary
|
||||
<Read `git log origin/<base>..HEAD --oneline`. Group every substantive commit by
|
||||
theme, excluding VERSION/CHANGELOG bookkeeping. Do not paste the commit list.>
|
||||
<Open with the problem in the user's words (what they hit, saw or asked), then the fix
|
||||
and what they get now; themes from `git log origin/<base>..HEAD --oneline` after that,
|
||||
minus VERSION/CHANGELOG bookkeeping. Never an inventory or a commit list. BAD: "Adds
|
||||
gstack-prose-lint, wires Steps 13 and 19, refreshes the goldens." GOOD: "Nobody reads a
|
||||
CHANGELOG entry twice, so autopilot words shipped every release. /ship now lints the entry
|
||||
before the commit and names each line to rewrite.">
|
||||
|
||||
## Blast radius
|
||||
<One or two sentences: what else the change touches (callers, consumers, generated
|
||||
files, hosts) and the one fact that makes that safe, with the rung of the evidence
|
||||
ladder the fact reached (file:line read, walked failure path, script ran, repro). The
|
||||
ladder is `investigate/sections/blast-radius.md`.>
|
||||
|
||||
## Test Coverage
|
||||
<coverage diagram from Step 7, or "All new code paths have test coverage.">
|
||||
@@ -177,6 +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-top-file-name>"
|
||||
BODY_REST_FILE="$(git rev-parse --show-toplevel 2>/dev/null || pwd)/.gstack/tmp/<body-rest-file-name>"
|
||||
[ -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; }
|
||||
{ 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"
|
||||
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
# Release notes
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
**The CHANGELOG lint lands.** A /ship run wrote entries nobody read twice, so the autopilot words slipped through. The lint reads the entry before the commit and names each line to rewrite.
|
||||
|
||||
### Added
|
||||
- **`bin/gstack-prose-lint`**: scans the files it is given and prints one line per finding. The parser rejects a bad date, exits with code 2, and writes nothing.
|
||||
- A measurement line keeps its arrow. The ratio moved 1.6740 → 1.7010 (measured 321,630 / 187,706), and the headline reads before → after.
|
||||
- Fix: run `gstack-prose-lint CHANGELOG.md --unreleased` and read the trailer.
|
||||
- The reviewer reads the diff. A caller that passes no file gets the usage text.
|
||||
|
||||
## [1.0.0] - 2026-10-01
|
||||
|
||||
Older text is never scanned under --unreleased, even when it showcases a vibrant tapestry.
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
# Release notes
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
**Prose lint lands.** This release doesn't just add a linter, but a whole new paradigm for CHANGELOG hygiene, showcasing a vibrant tapestry of checks.
|
||||
|
||||
### Added
|
||||
- **Performance:** Performance improved across the substrate because the parser now short-circuits.
|
||||
- Parser rejects bad date → exit 2, no write.
|
||||
- The lint is pivotal for teams coming from other tools: instead of reading every entry by hand, they run one command and additionally get a report.
|
||||
- Measured 1.6740 → 1.7010 (this arrow is a measurement and stays).
|
||||
- `gstack-prose-lint`: a code span that says `not just X, but Y` is never scanned.
|
||||
|
||||
## [1.0.0] - 2026-10-01
|
||||
|
||||
An older entry that showcases the vibrant tapestry of the past and is never scanned under --unreleased.
|
||||
@@ -168,7 +168,7 @@ export const CARVE_GUARDS: Record<string, CarveGuard> = {
|
||||
// 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.7150, // 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 integra
Line truncated
|
||||
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 integra
Line truncated
|
||||
},
|
||||
'plan-ceo-review': {
|
||||
skill: 'plan-ceo-review',
|
||||
|
||||
@@ -0,0 +1,241 @@
|
||||
/**
|
||||
* gstack-prose-lint: the AI tells a regular expression can decide, over the
|
||||
* CHANGELOG entry and PR body /ship writes (absorbed from pstack's unslop,
|
||||
* rewritten; NOTICE.md). Positive and negative fixtures per rule, the two
|
||||
* selections (--unreleased, --base), the exit codes, and the template and
|
||||
* workflow wiring that calls it.
|
||||
*/
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
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';
|
||||
|
||||
const ROOT = path.resolve(import.meta.dir, '..');
|
||||
const FIXTURES = path.join(ROOT, 'test', 'fixtures', 'prose-lint');
|
||||
const BIN = path.join(ROOT, 'bin', 'gstack-prose-lint');
|
||||
const run = (args: string[], cwd = ROOT) => {
|
||||
const r = spawnSync('bun', [BIN, ...args], { cwd, encoding: 'utf8', timeout: 60_000 });
|
||||
return { code: r.status ?? -1, out: r.stdout ?? '', err: r.stderr ?? '' };
|
||||
};
|
||||
const rules = (text: string) => lintProse(text, 'x.md').map(f => f.rule);
|
||||
|
||||
describe('lintProse: each rule, positive and negative', () => {
|
||||
test('vocabulary flags the autopilot words and leaves plain prose alone', () => {
|
||||
expect(rules('This release showcases a vibrant, pivotal change that underscores our intricate work.')).toEqual(['vocabulary', 'vocabulary', 'vocabulary', 'vocabulary', 'vocabulary']);
|
||||
expect(rules('The lint reads the entry and names each line to rewrite.')).toEqual([]);
|
||||
expect(rules('Rotate the photo to landscape orientation; a leading underscore marks a private name.')).toEqual([]);
|
||||
expect(rules('They mapped the vendor landscape before choosing.')).toEqual(['vocabulary']);
|
||||
});
|
||||
|
||||
test('not-just-but catches the contrast in both spellings', () => {
|
||||
expect(rules("It isn't just a linter, but a whole new way of writing.")).toEqual(['not-just-but']);
|
||||
expect(rules('This is not only faster but also smaller.')).toEqual(['not-just-but']);
|
||||
expect(rules('The lint is fast. It is also small.')).toEqual([]);
|
||||
});
|
||||
|
||||
test('metaphor-noun names the plain word', () => {
|
||||
const f = lintProse('The queue is the substrate every lane builds on, and its north star is a green train.', 'x.md');
|
||||
expect(f.map(x => [x.rule, x.match, x.hint])).toEqual([
|
||||
['metaphor-noun', 'substrate', 'say "base"'],
|
||||
['metaphor-noun', 'north star', 'say "goal"'],
|
||||
]);
|
||||
expect(rules('A smaller API with two exported names is easier to keep stable.')).toEqual([]);
|
||||
expect(rules('Shrink the API surface before the release.')).toEqual(['metaphor-noun']);
|
||||
});
|
||||
|
||||
test("mid-sentence-colon flags a colon doing a conjunction's job and spares labels, lists and times", () => {
|
||||
expect(rules('If you come from other tools: instead of reading every entry by hand, run one command.')).toEqual(['mid-sentence-colon']);
|
||||
expect(rules('- The classifier is conservative: a local lane that cannot prove its base is refused.')).toEqual(['mid-sentence-colon']);
|
||||
expect(rules('Fix: run the command again.')).toEqual([]);
|
||||
expect(rules('The gate reads three inputs: the head, the base and the receipt.')).toEqual([]);
|
||||
expect(rules('The run started at 12:30 and the following rules apply: no retries, no merges.')).toEqual([]);
|
||||
expect(rules('- **`gstack-post`**: the receipt is written first.')).toEqual([]);
|
||||
});
|
||||
|
||||
test('inline-header-list flags a bold label that restates its line and spares a label that adds detail', () => {
|
||||
expect(rules('- **Performance:** Performance improved by 40% on the hot path.')).toEqual(['inline-header-list']);
|
||||
expect(rules('- **Performance.** The hot path is 40% faster because the parser short-circuits.')).toEqual([]);
|
||||
expect(rules('- **`bin/gstack-prose-lint`**: `--gate` exits 1 on a finding.')).toEqual([]);
|
||||
});
|
||||
|
||||
test('arrow flags symbol-speak and keeps measurements and the before → after heading', () => {
|
||||
expect(rules('Bad input → exit 2, no write.')).toEqual(['arrow']);
|
||||
expect(rules('policy -> warn')).toEqual(['arrow']);
|
||||
expect(rules('The ratio moved 1.6740 → 1.7010 (measured 323,295 / 187,706) and the pins 250 -> 500.')).toEqual([]);
|
||||
expect(rules('### Before → after')).toEqual([]);
|
||||
expect(rules('Rename `old` → `new` in one pass.')).toEqual([]);
|
||||
});
|
||||
|
||||
test('dropped-article flags a bare noun opening a sentence and spares one with its article', () => {
|
||||
expect(rules('Parser rejects bad date and exits.')).toEqual(['dropped-article']);
|
||||
expect(rules('- Gate refuses the head when the receipt is stale.')).toEqual(['dropped-article']);
|
||||
expect(rules('The parser rejects a bad date and exits.')).toEqual([]);
|
||||
expect(rules('Garry approves the plan; Bun runs the tests.')).toEqual([]);
|
||||
expect(rules('Parser errors are rare.')).toEqual([]);
|
||||
});
|
||||
|
||||
test('fenced code, inline code, URLs and HTML comments are never scanned, and line numbers hold', () => {
|
||||
const text = [
|
||||
'# Title',
|
||||
'',
|
||||
'```bash',
|
||||
'echo "not just X, but Y" → substrate',
|
||||
'```',
|
||||
'See https://example.com/vibrant-tapestry and `a pivotal → change`. <!-- showcases -->',
|
||||
'Parser rejects bad date.',
|
||||
].join('\n');
|
||||
const f = lintProse(text, 'x.md');
|
||||
expect(f.map(x => [x.rule, x.line])).toEqual([['dropped-article', 7]]);
|
||||
const masked = maskProse(text);
|
||||
expect(masked.split('\n').length).toBe(text.split('\n').length);
|
||||
expect(masked).not.toContain('substrate');
|
||||
expect(masked).not.toContain('vibrant');
|
||||
});
|
||||
|
||||
test('findings are sorted by line and the render ends in the trailer', () => {
|
||||
const text = renderProseLint({ findings: lintProse('Showcasing a vibrant release.\n\nParser rejects it.', 'f.md'), scanned: ['f.md'], empty: [] });
|
||||
expect(text.split('\n').filter(Boolean)).toEqual([
|
||||
'PROSE: f.md:1 vocabulary "Showcasing" (use the plain word)',
|
||||
'PROSE: f.md:1 vocabulary "vibrant" (use the plain word)',
|
||||
'PROSE: f.md:3 dropped-article "Parser rejects" (start with "the" or "a")',
|
||||
'PROSE_LINT: found=3 files=1',
|
||||
]);
|
||||
expect(renderProseLint({ findings: [], scanned: ['a'], empty: [] })).toBe('PROSE_LINT: ok files=1\n');
|
||||
expect(renderProseLint({ findings: [], scanned: [], empty: ['a'] })).toBe('PROSE_LINT: none files=0 nothing-to-scan=1\n');
|
||||
});
|
||||
});
|
||||
|
||||
describe('selections', () => {
|
||||
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)!;
|
||||
expect(block.line).toBe(3);
|
||||
expect(block.text).toStartWith('## [Unreleased]');
|
||||
expect(block.text).not.toContain('[1.0.0]');
|
||||
expect(unreleasedBlock('# Changelog\n\n## [1.0.0] - 2026-01-01\n- x\n')).toBeNull();
|
||||
});
|
||||
|
||||
test('addedOnly keeps only the lines the working tree added, with their line numbers, and reads an untracked file whole', () => {
|
||||
const repo = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-prose-lint-'));
|
||||
const git = (...a: string[]) => {
|
||||
const r = spawnSync('git', a, { cwd: repo, encoding: 'utf8', timeout: 30_000, env: { ...process.env, GIT_CONFIG_GLOBAL: path.join(repo, '.gitconfig'), GIT_CONFIG_NOSYSTEM: '1', GIT_AUTHOR_NAME: 't', GIT_AUTHOR_EMAIL: 't@x', GIT_COMMITTER_NAME: 't', GIT_COMMITTER_EMAIL: 't@x' } });
|
||||
if (r.status !== 0) throw new Error(r.stderr);
|
||||
return r.stdout.trim();
|
||||
};
|
||||
try {
|
||||
git('init', '-q', '-b', 'main');
|
||||
fs.writeFileSync(path.join(repo, 'CHANGELOG.md'), '# Changelog\n\n## [1.0.0] - 2026-01-01\n\nOld entry showcases a vibrant tapestry.\n');
|
||||
git('add', '.'); git('commit', '-q', '-m', 'base');
|
||||
git('checkout', '-q', '-b', 'feature');
|
||||
fs.writeFileSync(path.join(repo, 'CHANGELOG.md'), '# Changelog\n\n## [1.0.1] - 2026-01-02\n\nParser rejects bad date → exit 2.\n\n## [1.0.0] - 2026-01-01\n\nOld entry showcases a vibrant tapestry.\n');
|
||||
const cwd = process.cwd();
|
||||
process.chdir(repo);
|
||||
try {
|
||||
const added = addedOnly('CHANGELOG.md', 'main')!;
|
||||
expect(added.split('\n')[4]).toBe('Parser rejects bad date → exit 2.');
|
||||
expect(added.split('\n')[8]).toBe('');
|
||||
expect(lintProse(added, 'CHANGELOG.md').map(f => [f.rule, f.line])).toEqual([['arrow', 5], ['dropped-article', 5]]);
|
||||
fs.writeFileSync(path.join(repo, 'NEW.md'), 'A vibrant note.\n');
|
||||
expect(addedOnly('NEW.md', 'main')).toBe('A vibrant note.\n');
|
||||
git('checkout', '-q', '--', 'CHANGELOG.md');
|
||||
expect(addedOnly('CHANGELOG.md', 'main')).toBeNull();
|
||||
} finally { process.chdir(cwd); }
|
||||
const r = run(['CHANGELOG.md', '--base', 'main'], repo);
|
||||
expect(r.out.trim()).toBe('PROSE_LINT: none files=0 nothing-to-scan=1');
|
||||
} finally { fs.rmSync(repo, { recursive: true, force: true }); }
|
||||
});
|
||||
});
|
||||
|
||||
describe('bin/gstack-prose-lint', () => {
|
||||
test('a slopped [Unreleased] block gates with exit 1, every rule named, and the result code on stderr', () => {
|
||||
const r = run([path.join(FIXTURES, 'slopped-changelog.md'), '--unreleased', '--gate']);
|
||||
expect(r.code).toBe(1);
|
||||
const found = [...r.out.matchAll(/^PROSE: \S+:(\d+) (\S+) /gm)].map(m => [Number(m[1]), m[2]] as const);
|
||||
for (const rule of ['vocabulary', 'not-just-but', 'metaphor-noun', 'mid-sentence-colon', 'inline-header-list', 'arrow', 'dropped-article']) expect(found.map(f => f[1])).toContain(rule);
|
||||
expect(found.every(f => f[0] >= 3 && f[0] <= 12)).toBe(true);
|
||||
expect(r.out).toMatch(/\nPROSE_LINT: found=\d+ files=1\n$/);
|
||||
expect(r.err).toMatch(/^gstack-prose-lint: .+; fix: .+ \(PROSE_LINT_FOUND\)$/m);
|
||||
});
|
||||
|
||||
test('the clean block is ok, and the same file read whole shows the older entry the selection skipped', () => {
|
||||
const clean = path.join(FIXTURES, 'clean-changelog.md');
|
||||
const gated = run([clean, '--unreleased', '--gate']);
|
||||
expect(gated.code).toBe(0);
|
||||
expect(gated.out.trim()).toBe('PROSE_LINT: ok files=1');
|
||||
const whole = run([clean]);
|
||||
expect(whole.code).toBe(0);
|
||||
expect(whole.out).toMatch(/^PROSE: \S+clean-changelog\.md:15 vocabulary/m);
|
||||
expect(whole.out).toMatch(/PROSE_LINT: found=\d+ files=1\n$/);
|
||||
});
|
||||
|
||||
test('no [Unreleased] block is none, exit 0, even with --gate', () => {
|
||||
const r = run([path.join(ROOT, 'NOTICE.md'), '--unreleased', '--gate']);
|
||||
expect(r.code).toBe(0);
|
||||
expect(r.out.trim()).toBe('PROSE_LINT: none files=0 nothing-to-scan=1');
|
||||
});
|
||||
|
||||
test('--json carries the findings; usage errors exit 2', () => {
|
||||
const j = run([path.join(FIXTURES, 'slopped-changelog.md'), '--unreleased', '--json']);
|
||||
expect(j.code).toBe(0);
|
||||
const parsed = JSON.parse(j.out);
|
||||
expect(parsed.findings.length).toBeGreaterThan(5);
|
||||
expect(parsed.findings[0]).toMatchObject({ rule: expect.any(String), line: expect.any(Number), match: expect.any(String), hint: expect.any(String) });
|
||||
expect(run([]).code).toBe(2);
|
||||
expect(run(['--help']).code).toBe(0);
|
||||
expect(run(['--help']).out).toContain('--unreleased');
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
describe('wiring', () => {
|
||||
const read = (p: string) => fs.readFileSync(path.join(ROOT, p), 'utf8');
|
||||
|
||||
test('/ship Step 13 lints the added CHANGELOG lines and Step 19 gates the body drafts before composing', () => {
|
||||
const changelog = read('ship/sections/changelog.md.tmpl');
|
||||
expect(changelog).toContain('gstack-prose-lint CHANGELOG.md --base origin/<base> --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.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);
|
||||
});
|
||||
|
||||
test('the PR body leads with the problem and carries a Blast radius item, on both the standard and the Capy path', () => {
|
||||
const prBody = read('ship/sections/pr-body.md.tmpl');
|
||||
const summary = prBody.slice(prBody.indexOf('## Summary'), prBody.indexOf('## Test Coverage'));
|
||||
expect(summary).toContain("Open with the problem in the user's words");
|
||||
expect(summary).toContain('Never an inventory or a commit list.');
|
||||
expect(summary).toMatch(/BAD: ".+" GOOD: "/s);
|
||||
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(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');
|
||||
});
|
||||
|
||||
test('the quality gate runs the lint over the [Unreleased] block, and NOTICE.md credits unslop', () => {
|
||||
const wf = read('.github/workflows/quality-gate.yml');
|
||||
expect(wf).toContain('bun --no-env-file --config=/dev/null bin/gstack-prose-lint CHANGELOG.md --unreleased --gate');
|
||||
expect(read('NOTICE.md')).toContain('## pstack (unslop) — Copyright Lauren Tan — MIT License');
|
||||
expect(read('docs/troubleshooting.md')).toContain('<a id="prose-lint-found"></a>');
|
||||
});
|
||||
|
||||
test('the shipped templates pass their own lint as a report (no gate), and the current [Unreleased] block gates clean', () => {
|
||||
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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user