mirror of
https://github.com/garrytan/gstack.git
synced 2026-10-31 14:06:50 +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> * test: register the prose-lint fixtures and NOTICE.md as free-only PR inputs Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test: pin prose-lint gate lines and usage exits, use a gstack-native dropped-article example, keep NOTICE.md unmapped Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: pre-landing review fixes Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: pre-landing review fixes Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: pre-landing review fixes Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs: name the CHANGELOG prose-lint gate and the Blast radius body section in the contributor docs Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(ship): name the evidence ladder by skill instead of by a runtime-root path that does not exist yet test/runtime-root-assets.test.ts checks every $GSTACK_ROOT/<path> literal in a host render against the staged root; investigate/sections/blast-radius.md lands with its own lane, so the Blast radius item and the Capy body step now say "the /investigate skill's Blast radius section, when present". Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs: name the Blast radius section in the two Capy body-order lists The docs re-audit found capy/skill/gstack/SKILL.md (Merge asks) and docs/capy-install.md still listing the Capy PR body as What changed, Why, Approvals; both now match capy-pr.md.tmpl. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(ship): the compose block's prose-lint trailer goes to stderr, and the publication-gates fixture stages gstack-prose-lint beside gstack-post test/ship-publication-gates.test.ts runs the real compose block under a HOME whose installed bin dir held only gstack-post, so the block's new gstack-prose-lint call failed with "No such file" on every CI free lane; the fixture now stages both bins the blocks call, and the lint's ok line goes to stderr so the composed body is the only stdout. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --------- Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
276 lines
15 KiB
TypeScript
276 lines
15 KiB
TypeScript
/**
|
||
* 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, 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.
|
||
*/
|
||
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'
|
||
| 'unclosed-block';
|
||
|
||
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 = /(?:^[ \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;
|
||
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',
|
||
]);
|
||
/** 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', '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 LIST_INTRODUCER = /\b(?:examples?|following|these|this|below|measurements?|two|three|four|five|six)$/i;
|
||
const trimSide = (s: string) => s.replace(/^[(\[`"'*_]+|[)\]`"'*_,.;:]+$/g, '');
|
||
|
||
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;
|
||
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](?:[^\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)));
|
||
const tail = line.lastIndexOf('<!--');
|
||
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 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 {
|
||
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, 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;
|
||
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)) {
|
||
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');
|
||
}
|
||
});
|
||
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 };
|
||
}
|
||
|
||
/** 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;
|
||
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;
|
||
}
|
||
|
||
export interface LintFilesOptions {
|
||
/** Scan only each file's `[Unreleased]` CHANGELOG block. */
|
||
unreleased?: boolean;
|
||
/** Scan only the text under these `## ` headings (the sections an author writes in a PR body). */
|
||
sections?: string[];
|
||
/** Scan only the lines the working tree adds to each file since the merge base with this ref (the ref itself when no merge base resolves). */
|
||
base?: string;
|
||
}
|
||
|
||
/** 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 });
|
||
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;
|
||
return { text, added: new Set(added.map(l => l.line)) };
|
||
}
|
||
|
||
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.text, file).filter(f => !added.added || added.added.has(f.line)));
|
||
continue;
|
||
}
|
||
if (opts.sections) {
|
||
const picked = sectionsOnly(text, opts.sections);
|
||
if (picked === null) { result.empty.push(file); continue; }
|
||
result.scanned.push(file);
|
||
result.findings.push(...lintProse(picked, file));
|
||
continue;
|
||
}
|
||
if (opts.unreleased) {
|
||
const block = unreleasedBlock(text);
|
||
if (!block) { result.empty.push(file); continue; }
|
||
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';
|
||
}
|