mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-05 16:34:00 +01:00
fix: pre-landing review fixes
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
This commit is contained in:
1 parent
0a741933be
commit
cc5ff0fea2
8 files changed
+138
-66
No files matched your search
+3
-3
@@ -5,12 +5,12 @@
|
||||
**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, `--base <ref>` only the lines the working tree added since the merge base, and `--sections <a,b>` 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.
|
||||
- **`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. Eight 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), `dropped-article` (a bare noun opening a sentence, with compound nouns such as test runs and merge commits left alone) and `unclosed-block` (a fence or HTML comment left open, since nothing after it was read). Fenced code, inline code, URLs, HTML comments and quoted words are never read, so the problem a `## Why` quotes stays in the person's words. `--unreleased` reads only a CHANGELOG's `## [Unreleased]` block, `--base <ref>` only the lines the working tree added since the merge base, and `--sections <a,b>` 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<version>` 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/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 --sections 'Why,Summary,Blast radius' --gate` over the first body draft before the drafts are joined, so a review line such as `Problem → what you did`, an approvals row or an image's text is never read, a finding in a section the author wrote stops composition with the lines to rewrite, and a draft that lost one of those headings is a usage error rather than a pass.
|
||||
- **`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`.
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
- `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,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.
|
||||
- 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,454 / 187,706 = 1.7232) 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. The raise is this lane's own call, made under Garry's 2026-10-26 "Do it, send" on the two absorb plans (which budget these ship sections; the quote approves the plans, not the number); the skeleton is unchanged, so no host pin moves.
|
||||
|
||||
## [1.91.256.0] - 2026-10-26
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
*
|
||||
* gstack-prose-lint <file>... [--unreleased | --base <ref> | --sections <a,b>] [--gate] [--json]
|
||||
*
|
||||
* Scans each file (fenced code, inline code, URLs and comments excluded) and
|
||||
* Scans each file (fenced code, inline code, URLs, comments and quoted words excluded) and
|
||||
* prints one `PROSE:` line per finding, then `PROSE_LINT: ok | found=<n> | none`.
|
||||
* --unreleased reads only each file's `## [Unreleased]` CHANGELOG block,
|
||||
* --base <ref> only the lines the working tree adds since the merge base with
|
||||
@@ -30,7 +30,9 @@ const HELP = `gstack-prose-lint — AI tells a regex can decide, over CHANGELOG
|
||||
--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.
|
||||
Rules: vocabulary, not-just-but, metaphor-noun, mid-sentence-colon, inline-header-list, arrow, dropped-article,
|
||||
and unclosed-block (a fence or HTML comment left open hid the text after it). Quoted words are never read.
|
||||
--sections with --gate exits 2 when a file has none of the headings, so a renamed heading cannot pass unread.
|
||||
|
||||
${exitTableHelp()}`;
|
||||
|
||||
@@ -54,6 +56,10 @@ function main(argv: string[]): number {
|
||||
let result;
|
||||
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 (sections && flags.has('--gate') && result.empty.length) {
|
||||
process.stderr.write(`gstack-prose-lint: no "## <section>" heading from --sections in ${result.empty.join(', ')}; nothing would be gated\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')) {
|
||||
|
||||
+12
-9
@@ -4470,14 +4470,16 @@ every flag and code.
|
||||
|
||||
`bin/gstack-prose-lint` reads the prose /ship is about to publish, a CHANGELOG
|
||||
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,
|
||||
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.
|
||||
`## Why`, `## Summary` and `## Blast radius`, through `--sections`; the
|
||||
`## What changed` captions and image text stay a report), 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, and a fence or
|
||||
HTML comment left open (`unclosed-block`), because nothing after it was read.
|
||||
Fenced code, inline code, URLs, HTML comments and quoted words are never read,
|
||||
so the problem quoted in `## Why` is the person's to phrase. 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`
|
||||
@@ -4487,7 +4489,8 @@ 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; with
|
||||
`--sections <a,b>` only the text under those `## ` headings.
|
||||
`--sections <a,b>` only the text under those `## ` headings (under `--gate`, a
|
||||
file with none of them is exit 2, not a pass: the draft lost a heading).
|
||||
|
||||
**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
|
||||
|
||||
+69
-40
@@ -4,7 +4,9 @@
|
||||
* 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.
|
||||
* match. Fenced code, inline code, URLs, HTML comments and quoted words (someone
|
||||
* else's) are never scanned; a fence or comment left open is itself a finding,
|
||||
* because the text after it went unread.
|
||||
* `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.
|
||||
@@ -21,7 +23,8 @@ export type ProseRuleId =
|
||||
| 'mid-sentence-colon'
|
||||
| 'inline-header-list'
|
||||
| 'arrow'
|
||||
| 'dropped-article';
|
||||
| 'dropped-article'
|
||||
| 'unclosed-block';
|
||||
|
||||
export interface ProseFinding {
|
||||
rule: ProseRuleId;
|
||||
@@ -71,7 +74,7 @@ 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 = /(?:^[ \t]*(?:(?:[-*+]|\d+\.)[ \t]+)?|[.!?]\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]+)?|[.!?][ \t]+)((?:[A-Za-z][\w'’-]*[ \t,]+){3,}[A-Za-z][\w'’-]*): ([a-z][\w'’-]*(?:[ \t]+[\w'’-]+){2,})/gm;
|
||||
const INLINE_HEADER_RE = /^\s*(?:[-*+]|\d+\.)\s+\*\*([^*\n]{1,60}?):?\*\*:?\s+(\S+)/;
|
||||
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;
|
||||
@@ -87,38 +90,64 @@ const BARE_NOUNS = new Set([
|
||||
'endpoint', 'route', 'controller', 'service', 'container', 'image', 'cluster', 'node', 'deploy', 'install',
|
||||
'installer', 'upgrade', 'session', 'browser', 'tab', 'window', 'dialog', 'prompt', 'reply', 'answer', 'question',
|
||||
]);
|
||||
/** Verbs that are rarely plural nouns: "Test runs", "Error logs" and "Merge commits" are compound nouns, so runs, logs, calls, checks, passes, builds, counts, records, reports and their kind are left out. */
|
||||
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',
|
||||
'rejects', 'refuses', 'accepts', 'emits', 'throws', 'renders', 'resolves', 'retries', 'restarts', 'tells',
|
||||
'validates', 'verifies', 'scans', 'lints', 'compiles', 'installs', 'upgrades', 'fetches', 'stamps', 'reheads',
|
||||
'owns', 'carries', 'loses', 'parses', 'creates', 'deletes', 'updates', 'handles', 'hides', 'keeps', 'skips',
|
||||
'adds', 'removes', 'writes', 'reads', 'prints', 'sends', 'exits', 'fails', 'needs', 'takes', 'uses',
|
||||
]);
|
||||
const NUMERIC_SIDE = /^[\d$£€.,%+-]|^v?\d|^CODE$|^URL$|^(?:before|after)$/i;
|
||||
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 (multi-line ones too) with same-length placeholders so line numbers hold. */
|
||||
export function maskProse(text: string): string {
|
||||
const lines = text.split('\n');
|
||||
const FENCE_OPEN_RE = /^\s*(`{3,}|~{3,})/;
|
||||
const FENCE_CLOSE_RE = /^\s*(`{3,}|~{3,})\s*$/;
|
||||
const pad = (m: string) => ' '.repeat(m.length);
|
||||
|
||||
/**
|
||||
* Walk the lines once, tracking the fenced-block and HTML-comment state a Markdown
|
||||
* renderer would. `inside` is true for a line that is wholly inside a fence or a
|
||||
* comment; a line that opens or closes one is reported with the text outside it.
|
||||
* A fence closes only on a bare fence line of the same kind and at least its length.
|
||||
*/
|
||||
function walkBlocks(text: string, visit: (line: string, inside: boolean, index: number) => void): { kind: 'fence' | 'comment'; line: number } | null {
|
||||
let fence: string | null = null;
|
||||
let fenceLine = 0, commentLine = 0;
|
||||
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 ''; }
|
||||
text.split('\n').forEach((raw, i) => {
|
||||
if (fence) {
|
||||
const close = FENCE_CLOSE_RE.exec(raw);
|
||||
if (close && close[1]![0] === fence[0] && close[1]!.length >= fence.length) fence = null;
|
||||
return visit(raw, true, i);
|
||||
}
|
||||
let line = raw;
|
||||
if (comment) {
|
||||
const end = line.indexOf('-->');
|
||||
if (end < 0) return visit(raw, true, i);
|
||||
comment = false;
|
||||
line = ' '.repeat(end + 3) + line.slice(end + 3);
|
||||
}
|
||||
const open = FENCE_OPEN_RE.exec(line);
|
||||
if (open) { fence = open[1]!; fenceLine = i + 1; return visit(raw, true, i); }
|
||||
line = line
|
||||
.replace(/`[^`\n]*`/g, (m) => 'CODE' + ' '.repeat(Math.max(0, m.length - 4)))
|
||||
.replace(/(`+)([^`\n](?:[^\n]*?[^`\n])?)\1(?!`)/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)));
|
||||
.replace(/https?:\/\/(?:(?!-->)\S)+/g, (m) => 'URL' + ' '.repeat(Math.max(0, m.length - 3)));
|
||||
const tail = line.lastIndexOf('<!--');
|
||||
if (tail >= 0 && line.indexOf('-->', tail) < 0) { comment = true; line = line.slice(0, tail); }
|
||||
return line.replace(/<!--[\s\S]*?-->/g, (m) => ' '.repeat(m.length));
|
||||
if (tail >= 0 && line.indexOf('-->', tail) < 0) { comment = true; commentLine = i + 1; line = line.slice(0, tail); }
|
||||
visit(line.replace(/<!--[\s\S]*?-->/g, pad), false, i);
|
||||
});
|
||||
return out.join('\n');
|
||||
return fence ? { kind: 'fence', line: fenceLine } : comment ? { kind: 'comment', line: commentLine } : null;
|
||||
}
|
||||
|
||||
/** Replace fenced blocks, inline code, links' targets, URLs, HTML comments and quoted words with same-length placeholders so line numbers hold. A fence or comment left open at the end is reported, since everything after it went unread. */
|
||||
export function maskProse(text: string): { masked: string; unclosed: { kind: 'fence' | 'comment'; line: number } | null } {
|
||||
const out: string[] = [];
|
||||
const unclosed = walkBlocks(text, (line, inside) => {
|
||||
out.push(inside ? '' : line.replace(/(["“])[^"“”\n]{1,400}(["”])/g, pad));
|
||||
});
|
||||
return { masked: out.join('\n'), unclosed };
|
||||
}
|
||||
|
||||
function excerpt(s: string): string {
|
||||
@@ -128,11 +157,12 @@ function excerpt(s: string): string {
|
||||
|
||||
/** 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 { masked, unclosed } = 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;
|
||||
if (unclosed) push('unclosed-block', unclosed.line, unclosed.kind === 'fence' ? 'fence never closed' : 'HTML comment never closed', 'close it; nothing after it was read');
|
||||
|
||||
lines.forEach((line, i) => {
|
||||
const n = i + 1;
|
||||
@@ -150,7 +180,8 @@ export function lintProse(text: string, file: string, offset = 0): ProseFinding[
|
||||
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;
|
||||
const left = trimSide(m[1]!), right = trimSide(m[3]!);
|
||||
if (!left || !right || (NUMERIC_SIDE.test(left) && NUMERIC_SIDE.test(right))) continue;
|
||||
push('arrow', n, m[0], 'write the sentence the arrow stands for');
|
||||
}
|
||||
});
|
||||
@@ -171,15 +202,13 @@ export function unreleasedBlock(text: string): { text: string; line: number } |
|
||||
/** 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));
|
||||
const raw = text.split('\n');
|
||||
let keep = false, found = false;
|
||||
let fence: string | null = null;
|
||||
const out = text.split('\n').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 keep ? line : ''; }
|
||||
if (open) { fence = open[1]!; return keep ? line : ''; }
|
||||
const h = /^##\s+(.+?)\s*$/.exec(line);
|
||||
if (h) { keep = wanted.has(h[1]!.toLowerCase()); found ||= keep; return ''; }
|
||||
return keep ? line : '';
|
||||
const out: string[] = [];
|
||||
walkBlocks(text, (line, inside, i) => {
|
||||
const h = inside ? null : /^##\s+(.+?)\s*$/.exec(line);
|
||||
if (h) { keep = wanted.has(h[1]!.toLowerCase()); found ||= keep; out.push(''); return; }
|
||||
out.push(keep ? raw[i]! : '');
|
||||
});
|
||||
return found ? out.join('\n') : null;
|
||||
}
|
||||
@@ -193,18 +222,18 @@ export interface LintFilesOptions {
|
||||
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 {
|
||||
/** The file's whole text plus the 1-based lines the branch added, so fences and comments around an added line still mask it; `added` is null for an untracked file (read whole) and the result is null when nothing was added. */
|
||||
export function addedOnly(file: string, base: string): { text: string; added: Set<number> | null } | 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 text = fs.readFileSync(file, 'utf8');
|
||||
if (run(['ls-files', '--error-unmatch', '--', file]).status !== 0) return { text, added: null };
|
||||
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');
|
||||
return { text, added: new Set(added.map(l => l.line)) };
|
||||
}
|
||||
|
||||
export function lintFiles(files: string[], opts: LintFilesOptions = {}): ProseLintResult {
|
||||
@@ -215,7 +244,7 @@ export function lintFiles(files: string[], opts: LintFilesOptions = {}): ProseLi
|
||||
const added = addedOnly(file, opts.base);
|
||||
if (added === null) { result.empty.push(file); continue; }
|
||||
result.scanned.push(file);
|
||||
result.findings.push(...lintProse(added, file));
|
||||
result.findings.push(...lintProse(added.text, file).filter(f => !added.added || added.added.has(f.line)));
|
||||
continue;
|
||||
}
|
||||
if (opts.sections) {
|
||||
|
||||
@@ -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-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" --sections 'What changed,Why,Summary,Blast radius' --gate || { echo "Not composed: rewrite each PROSE: line above in the draft, then rerun." >&2; exit 1; }
|
||||
~/.claude/skills/gstack/bin/gstack-prose-lint "$BODY_TOP_FILE" --sections 'Why,Summary,Blast radius' --gate || { echo "Not composed: fix what gstack-prose-lint printed above (a PROSE: line is a sentence to rewrite in the draft; a usage line means the draft lost a heading), 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"
|
||||
|
||||
@@ -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-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" --sections 'What changed,Why,Summary,Blast radius' --gate || { echo "Not composed: rewrite each PROSE: line above in the draft, then rerun." >&2; exit 1; }
|
||||
~/.claude/skills/gstack/bin/gstack-prose-lint "$BODY_TOP_FILE" --sections 'Why,Summary,Blast radius' --gate || { echo "Not composed: fix what gstack-prose-lint printed above (a PROSE: line is a sentence to rewrite in the draft; a usage line means the draft lost a heading), 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"
|
||||
|
||||
@@ -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.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
|
||||
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',
|
||||
|
||||
+43
-9
@@ -52,6 +52,7 @@ describe('lintProse: each rule, positive and negative', () => {
|
||||
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([]);
|
||||
expect(lintProse(['The parser stops at the end of a line here', 'see note: something else happens now'].join('\n'), 'x.md')).toEqual([]);
|
||||
});
|
||||
|
||||
test('inline-header-list flags a bold label that restates its line and spares a label that adds detail', () => {
|
||||
@@ -74,6 +75,7 @@ describe('lintProse: each rule, positive and negative', () => {
|
||||
expect(rules('The gate refuses a stale receipt and exits.')).toEqual([]);
|
||||
expect(rules('Garry approves the plan; Bun runs the tests.')).toEqual([]);
|
||||
expect(rules('Parser errors are rare.')).toEqual([]);
|
||||
for (const s of ['Test runs now finish faster.', 'Error logs are rotated daily.', 'Tool calls take a while.', 'Merge commits stay.']) expect(rules(s), s).toEqual([]);
|
||||
});
|
||||
|
||||
test('fenced code, inline code, URLs and HTML comments are never scanned, and line numbers hold', () => {
|
||||
@@ -88,12 +90,29 @@ describe('lintProse: each rule, positive and negative', () => {
|
||||
].join('\n');
|
||||
const f = lintProse(text, 'x.md');
|
||||
expect(f.map(x => [x.rule, x.line])).toEqual([['dropped-article', 7]]);
|
||||
const masked = maskProse(text);
|
||||
const { masked, unclosed } = maskProse(text);
|
||||
expect(unclosed).toBeNull();
|
||||
expect(masked.split('\n').length).toBe(text.split('\n').length);
|
||||
expect(masked).not.toContain('substrate');
|
||||
expect(masked).not.toContain('vibrant');
|
||||
});
|
||||
|
||||
// Value: protects=someone else's words are never the author's finding (the Capy `## Why` quotes the person's problem verbatim), and a double-backtick code span holding a single backtick is one code span; fails_when=a quoted tell gates the body, or the inner backtick splits the span and leaks its text; why_new=new masking; seam=none; no_claim=a quote spanning lines is read
|
||||
test('quoted words and multi-backtick code spans are masked', () => {
|
||||
expect(rules('"Please enhance the landscape view." The fix names the view.')).toEqual([]);
|
||||
expect(rules('“Please enhance the landscape view.” The fix names the view.')).toEqual([]);
|
||||
expect(rules('He asked to enhance the view.')).toEqual(['vocabulary']);
|
||||
expect(rules('Use `` `name` showcases the result `` as the span.')).toEqual([]);
|
||||
expect(rules('Rename --foo -> --bar in the flags.')).toEqual(['arrow']);
|
||||
});
|
||||
|
||||
// Value: protects=a fence or comment left open is a finding instead of a silent mask of everything after it, and a fence closes only on a bare fence line; fails_when=an unclosed block reads as clean, or a fence line with an info string closes the block early; why_new=the fence test above pinned the fail-open behaviour; seam=none; no_claim=an indented code block is not a fence
|
||||
test('an unclosed fence or comment is a finding at its opening line, and an info string never closes a fence', () => {
|
||||
expect(lintProse(['Plain.', '```', 'showcasing a vibrant release'].join('\n'), 'x.md').map(f => [f.rule, f.line])).toEqual([['unclosed-block', 2]]);
|
||||
expect(lintProse(['Plain. <!-- oops', 'It is crucial and pivotal.'].join('\n'), 'x.md').map(f => [f.rule, f.line])).toEqual([['unclosed-block', 1]]);
|
||||
expect(lintProse(['```', 'foo', '```js', 'crucial', '```', 'done, and vibrant.'].join('\n'), 'x.md').map(f => [f.rule, f.line])).toEqual([['vocabulary', 6]]);
|
||||
});
|
||||
|
||||
// Value: protects=a longer fence is closed only by a fence at least as long, so a quoted template in a PR body draft is never linted; fails_when=a shorter inner fence or a tilde fence closes the block early, or an unclosed fence stops masking; why_new=the case above uses one three-backtick fence; seam=none; no_claim=other fence syntaxes (indented code blocks) are not masked
|
||||
test('a longer fence holds a shorter inner fence, a tilde fence masks too, and an unclosed fence masks to the end', () => {
|
||||
const text = [
|
||||
@@ -108,7 +127,7 @@ describe('lintProse: each rule, positive and negative', () => {
|
||||
'```',
|
||||
'Showcasing a vibrant release.',
|
||||
].join('\n');
|
||||
expect(lintProse(text, 'x.md').map(f => [f.rule, f.line])).toEqual([['dropped-article', 5]]);
|
||||
expect(lintProse(text, 'x.md').map(f => [f.rule, f.line])).toEqual([['dropped-article', 5], ['unclosed-block', 9]]);
|
||||
});
|
||||
|
||||
test('findings are sorted by line and the render ends in the trailer', () => {
|
||||
@@ -143,7 +162,12 @@ describe('selections', () => {
|
||||
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');
|
||||
expect(run([path.join(FIXTURES, 'clean-changelog.md'), '--sections', 'Nope']).out).toContain('PROSE_LINT: none');
|
||||
const lost = run([path.join(FIXTURES, 'clean-changelog.md'), '--sections', 'Nope', '--gate']);
|
||||
expect(lost.code).toBe(2);
|
||||
expect(lost.err).toContain('nothing would be gated');
|
||||
const commented = ['## Summary', 'Fine.', '<!--', '## Test plan', '-->', 'Still the summary, showcasing a vibrant tell.'].join('\n');
|
||||
expect(lintProse(sectionsOnly(commented, ['Summary'])!, 'b.md').map(f => [f.rule, f.line])).toEqual([['vocabulary', 6], ['vocabulary', 6]]);
|
||||
});
|
||||
|
||||
// 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
|
||||
@@ -181,22 +205,31 @@ describe('selections', () => {
|
||||
process.chdir(repo);
|
||||
try {
|
||||
const added = addedOnly('CHANGELOG.md', 'main')!;
|
||||
expect(added.split('\n')[4]).toBe('Gate refuses stale receipt → exit 3.');
|
||||
expect(added.split('\n')[8]).toBe('');
|
||||
expect(lintProse(added, 'CHANGELOG.md').map(f => [f.rule, f.line])).toEqual([['arrow', 5], ['dropped-article', 5]]);
|
||||
expect([...added.added!]).toEqual([3, 4, 5, 6]);
|
||||
expect(added.text.split('\n')[8]).toBe('Old entry showcases a vibrant tapestry.');
|
||||
const gated = run(['CHANGELOG.md', '--base', 'main', '--gate'], repo);
|
||||
expect(gated.code).toBe(1);
|
||||
expect(gated.out).toMatch(/^PROSE: CHANGELOG\.md:5 arrow /m);
|
||||
expect(gated.out).toMatch(/^PROSE: CHANGELOG\.md:5 dropped-article /m);
|
||||
expect(gated.out).not.toMatch(/^PROSE: CHANGELOG\.md:9 /m);
|
||||
expect(gated.err).toContain('PROSE_LINT_FOUND');
|
||||
fs.writeFileSync(path.join(repo, 'CHANGELOG.md'), '# Changelog\n\n## [1.0.0] - 2026-01-01\n\n```\nold line\nit delves into a vibrant example\n```\nOld entry showcases a vibrant tapestry.\n');
|
||||
git('add', '.'); git('commit', '-q', '-m', 'base with a fence');
|
||||
git('checkout', '-q', 'main'); git('merge', '-q', '--ff-only', 'feature'); git('checkout', '-q', 'feature');
|
||||
fs.writeFileSync(path.join(repo, 'CHANGELOG.md'), '# Changelog\n\n## [1.0.0] - 2026-01-01\n\n```\nold line\nit delves into a vibrant example\nadded line that showcases code\n```\nOld entry showcases a vibrant tapestry.\n');
|
||||
const fenced = run(['CHANGELOG.md', '--base', 'main', '--gate'], repo);
|
||||
expect(fenced.code).toBe(0);
|
||||
expect(fenced.out).toBe('PROSE_LINT: ok files=1\n');
|
||||
git('checkout', '-q', '--', 'CHANGELOG.md');
|
||||
fs.writeFileSync(path.join(repo, 'CHANGELOG.md'), '# Changelog\n\n## [1.0.1] - 2026-01-02\n\nGate refuses stale receipt → exit 3.\n\n## [1.0.0] - 2026-01-01\n\n```\nold line\nit delves into a vibrant example\n```\nOld entry showcases a vibrant tapestry.\n');
|
||||
const badRef = run(['CHANGELOG.md', '--base', 'no-such-ref', '--gate'], repo);
|
||||
expect(badRef.code).toBe(2);
|
||||
expect(badRef.out).not.toContain('PROSE_LINT:');
|
||||
git('branch', 'orphan', git('commit-tree', git('write-tree'), '-m', 'unrelated history'));
|
||||
expect(() => git('merge-base', 'orphan', 'HEAD')).toThrow();
|
||||
expect(addedOnly('CHANGELOG.md', 'orphan')).toBe(added);
|
||||
expect([...addedOnly('CHANGELOG.md', 'orphan')!.added!]).toEqual([3, 4, 5, 6]);
|
||||
fs.writeFileSync(path.join(repo, 'NEW.md'), 'A vibrant note.\n');
|
||||
expect(addedOnly('NEW.md', 'main')).toBe('A vibrant note.\n');
|
||||
expect(addedOnly('NEW.md', 'main')).toEqual({ text: 'A vibrant note.\n', added: null });
|
||||
git('checkout', '-q', '--', 'CHANGELOG.md');
|
||||
expect(addedOnly('CHANGELOG.md', 'main')).toBeNull();
|
||||
} finally { process.chdir(cwd); }
|
||||
@@ -274,7 +307,8 @@ describe('wiring', () => {
|
||||
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" --sections 'What changed,Why,Summary,Blast radius' --gate`);
|
||||
expect(compose).toContain(`gstack-prose-lint "$BODY_TOP_FILE" --sections 'Why,Summary,Blast radius' --gate`);
|
||||
expect(compose).not.toContain('What changed,');
|
||||
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"'));
|
||||
|
||||
Reference in new issue
Block a user