/** * 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, sectionsOnly, 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([]); 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', () => { 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('Gate refuses stale receipt and exits.')).toEqual(['dropped-article']); expect(rules('- Gate refuses the head when the receipt is stale.')).toEqual(['dropped-article']); 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', () => { const text = [ '# Title', '', '```bash', 'echo "not just X, but Y" → substrate', '```', 'See https://example.com/vibrant-tapestry and `a pivotal → change`. ', 'Gate refuses stale receipt.', ].join('\n'); const f = lintProse(text, 'x.md'); expect(f.map(x => [x.rule, x.line])).toEqual([['dropped-article', 7]]); 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. ', '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 test('a multi-line HTML comment is masked through its closing marker and prose after it is read', () => { const text = ['Plain start. The parser showcases it.', 'Showcasing a vibrant end.'].join('\n'); expect(lintProse(text, 'x.md').map(f => [f.rule, f.line])).toEqual([['vocabulary', 3], ['vocabulary', 4], ['vocabulary', 4]]); const quoted = ['Use `