From 88d3aae4ec870f1eb74292a72e6310469ec0f0e3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sun, 11 Oct 2026 13:26:05 +0200 Subject: [PATCH 1/3] chore(docs): flag filler, marketing, and time-relative words in the user docs Add a dependency-free, line-based prose check (scripts/check-doc-prose.ts) over README.md and website/docs/**/*.md(x). It skips frontmatter, fenced code, inline code, HTML tags and comments, link destinations, and bare URLs. The banned words live in one exported PROSE_RULES table. Register it as the docs-prose gate, selected by the same user-docs predicate as docs-links, add check:doc-prose to check:tooling, and run it before the site build in the docs workflow, renamed docs.yml / Docs. Rewrite the three existing hits (two in commands.md, one in installation.md) without changing their meaning. Co-Authored-By: Claude Opus 5.5 --- .../workflows/{docs-links.yml => docs.yml} | 21 ++- package.json | 3 +- scripts/__tests__/check-doc-prose.test.ts | 89 ++++++++++ scripts/check-affected/checks.ts | 1 + scripts/check-affected/model.test.ts | 15 +- scripts/check-affected/model.ts | 28 ++-- scripts/check-doc-prose.ts | 155 ++++++++++++++++++ website/docs/docs/commands.md | 4 +- website/docs/docs/installation.md | 2 +- 9 files changed, 293 insertions(+), 25 deletions(-) rename .github/workflows/{docs-links.yml => docs.yml} (71%) create mode 100644 scripts/__tests__/check-doc-prose.test.ts create mode 100644 scripts/check-doc-prose.ts diff --git a/.github/workflows/docs-links.yml b/.github/workflows/docs.yml similarity index 71% rename from .github/workflows/docs-links.yml rename to .github/workflows/docs.yml index bff30d29ed..f8e7882058 100644 --- a/.github/workflows/docs-links.yml +++ b/.github/workflows/docs.yml @@ -1,7 +1,7 @@ -name: Docs Links +name: Docs -# ci.yml ignores docs paths, so docs-only PRs need their own lane. The gate -# builds the site and checks the rendered pages, so the rspress version +# ci.yml ignores docs paths, so docs-only PRs need their own lane. The links +# gate builds the site and checks the rendered pages, so the rspress version # (website/package.json, the lockfile) and config re-run it as well. on: pull_request: @@ -12,12 +12,14 @@ on: - 'README.md' - 'scripts/check-doc-links.ts' - 'scripts/__tests__/check-doc-links.test.ts' + - 'scripts/check-doc-prose.ts' + - 'scripts/__tests__/check-doc-prose.test.ts' - 'scripts/check-affected/**' - 'package.json' - 'pnpm-lock.yaml' - '.github/actions/run-gate/action.yml' - '.github/actions/setup-node-pnpm/action.yml' - - '.github/workflows/docs-links.yml' + - '.github/workflows/docs.yml' push: branches: - main @@ -28,19 +30,21 @@ on: - 'README.md' - 'scripts/check-doc-links.ts' - 'scripts/__tests__/check-doc-links.test.ts' + - 'scripts/check-doc-prose.ts' + - 'scripts/__tests__/check-doc-prose.test.ts' - 'scripts/check-affected/**' - 'package.json' - 'pnpm-lock.yaml' - '.github/actions/run-gate/action.yml' - '.github/actions/setup-node-pnpm/action.yml' - - '.github/workflows/docs-links.yml' + - '.github/workflows/docs.yml' permissions: contents: read jobs: check: - name: Internal links and heading anchors + name: Prose, internal links, and heading anchors runs-on: ubuntu-latest timeout-minutes: 5 steps: @@ -50,6 +54,11 @@ jobs: - name: Setup Node and pnpm uses: ./.github/actions/setup-node-pnpm + # Reads the Markdown sources only, so it fails before the site build. + - name: Check docs prose + uses: ./.github/actions/run-gate + with: { gate: docs-prose } + # Builds the site (root install covers the website workspace), then checks it. - name: Check docs links uses: ./.github/actions/run-gate diff --git a/package.json b/package.json index 464144b351..6f5c187910 100644 --- a/package.json +++ b/package.json @@ -162,6 +162,7 @@ "check:package": "node --experimental-strip-types scripts/check-package.ts", "check:command-docs": "vitest run --project unit-core src/__tests__/command-doc-coverage.test.ts", "check:doc-links": "node --experimental-strip-types scripts/node-test-tmpdir.ts --experimental-strip-types --test scripts/__tests__/check-doc-links.test.ts && pnpm --dir website build && node --experimental-strip-types scripts/check-doc-links.ts", + "check:doc-prose": "node --experimental-strip-types scripts/node-test-tmpdir.ts --experimental-strip-types --test scripts/__tests__/check-doc-prose.test.ts && node --experimental-strip-types scripts/check-doc-prose.ts", "check:agent-guidance": "node --experimental-strip-types scripts/node-test-tmpdir.ts --experimental-strip-types --test scripts/__tests__/agent-guidance-contract.test.ts", "check:replay-compat": "node --experimental-strip-types scripts/check-replay-compat-provenance.ts", "check:daemon-wire-compat": "node --experimental-strip-types scripts/wire-compat/run.ts", @@ -174,7 +175,7 @@ "check:quick": "pnpm lint && pnpm typecheck", "sync:mcp-metadata": "node scripts/sync-mcp-metadata.mjs", "check:mcp-metadata": "node scripts/sync-mcp-metadata.mjs --check", - "check:tooling": "pnpm format:check && pnpm lint && pnpm typecheck && pnpm check:layering && pnpm depgraph:test && pnpm check:gate-manifest:test && pnpm check:gate-manifest && pnpm check:production-exports && pnpm check:tmpdir-leaks:test && pnpm check:xctest-selection && pnpm check:packaged-runner-swift && pnpm check:mcp-metadata && pnpm check:doc-links && pnpm build && pnpm check:bundle-owner-files && pnpm check:package", + "check:tooling": "pnpm format:check && pnpm lint && pnpm typecheck && pnpm check:layering && pnpm depgraph:test && pnpm check:gate-manifest:test && pnpm check:gate-manifest && pnpm check:production-exports && pnpm check:tmpdir-leaks:test && pnpm check:xctest-selection && pnpm check:packaged-runner-swift && pnpm check:mcp-metadata && pnpm check:doc-prose && pnpm check:doc-links && pnpm build && pnpm check:bundle-owner-files && pnpm check:package", "check:unit": "pnpm test:unit && pnpm check:tmpdir-leaks && pnpm test:smoke", "check": "pnpm check:tooling && pnpm check:fallow && pnpm check:unit", "prepack": "npm_config_dry_run=false pnpm release:prepare", diff --git a/scripts/__tests__/check-doc-prose.test.ts b/scripts/__tests__/check-doc-prose.test.ts new file mode 100644 index 0000000000..dbc6e4c343 --- /dev/null +++ b/scripts/__tests__/check-doc-prose.test.ts @@ -0,0 +1,89 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { checkDocProse, PROSE_RULES } from '../check-doc-prose.ts'; + +function hits(text: string): string[] { + return checkDocProse('page.md', text).map( + ({ line, column, word }) => `${line}:${column} ${word}`, + ); +} + +test('banned words match case-insensitively as whole words, with line and column', () => { + assert.deepEqual(hits('Simply run it.\nIt is Just fine, and EASY.'), [ + '1:1 simply', + '2:7 just', + '2:22 easy', + ]); + assert.deepEqual(hits('Justify the adjustment; robustness and uneasy are fine.'), []); +}); + +test('phrases match across any run of whitespace', () => { + assert.deepEqual(hits('Support is coming soon. As of now it is newly added.'), [ + '1:12 coming soon', + '1:26 as of now', + '1:42 newly added', + ]); +}); + +test('bare new, now, and current are allowed', () => { + assert.deepEqual(hits('The new session is now the current one.'), []); +}); + +test('each violation names the file, position, matched text, and reason', () => { + const [violation] = checkDocProse('README.md', 'It is robust.'); + assert.equal( + violation?.message, + 'README.md:1:7: "robust" — marketing adjective: state what the feature does instead', + ); +}); + +test('every rule is a lowercase word or phrase with a reason', () => { + for (const { word, reason } of PROSE_RULES) { + assert.match(word, /^[a-z]+(?: [a-z]+)*$/); + assert.ok(reason.length > 0, word); + } +}); + +test('YAML frontmatter at the top is skipped, but a later thematic break is prose', () => { + assert.deepEqual(hits('---\ntitle: Just a page\n---\nText.\n\n---\n\nSimply.'), ['8:1 simply']); +}); + +test('fenced code is skipped until a matching closer of at least the opening length', () => { + const text = [ + '````md', + 'just', + '```', + 'still code: simply', + '````', + '~~~', + 'easy', + '~~~~', + 'Prose: obviously.', + ].join('\n'); + assert.deepEqual(hits(text), ['9:8 obviously']); +}); + +test('a backtick fence is not closed by tildes', () => { + assert.deepEqual(hits('```\n~~~\njust\n```\nJust.'), ['5:1 just']); +}); + +test('inline code spans are skipped, matching backtick run lengths', () => { + assert.deepEqual(hits('Run `just build` or ``a ` just`` then just go.'), ['1:39 just']); +}); + +test('HTML tags, comments, link destinations, and bare URLs are skipped', () => { + const text = [ + 'Easy', + 'See [the guide](./just-start.md) and ![logo](robust.svg).', + 'Visit https://example.com/simply/easy now.', + 'Before after.', + ' robust', + ].join('\n'); + assert.deepEqual(hits(text), ['1:19 easy', '7:11 robust']); +}); + +test('link text is prose', () => { + assert.deepEqual(hits('[Simply start](./start.md)'), ['1:2 simply']); +}); diff --git a/scripts/check-affected/checks.ts b/scripts/check-affected/checks.ts index 7ea0d1406c..f543a17ba6 100644 --- a/scripts/check-affected/checks.ts +++ b/scripts/check-affected/checks.ts @@ -117,6 +117,7 @@ export const CHECK_CATALOG: readonly CheckSpec[] = [ gate('fixture-fallback', 'Fixture-app cache-failure fallback', 'test:fixture-fallback'), gate('command-docs', 'Command reference doc coverage', 'check:command-docs'), gate('docs-links', 'Docs internal links and heading anchors', 'check:doc-links'), + gate('docs-prose', 'Docs filler, marketing, and time-relative words', 'check:doc-prose'), gate('agent-guidance', 'Agent guidance ownership and context budgets', 'check:agent-guidance'), // Parses ios.yml and the Swift sources; no Xcode, no simulator, so it runs anywhere. gate( diff --git a/scripts/check-affected/model.test.ts b/scripts/check-affected/model.test.ts index 6e84359283..5036aca8dc 100644 --- a/scripts/check-affected/model.test.ts +++ b/scripts/check-affected/model.test.ts @@ -205,20 +205,25 @@ test('docs-only change selects no checks and records the docs paths', () => { assert.equal(result.docsOnlyPaths.length, 3); }); -test('user docs pages and README select the docs-links check', () => { +test('user docs pages and README select the docs-links and docs-prose checks', () => { for (const file of [ 'README.md', 'website/docs/index.md', 'website/docs/docs/sessions.md', 'website/docs/404.mdx', - 'website/package.json', - 'website/rspress.config.ts', ]) { const result = plan([file]); - assert.deepEqual(result.checks, ['docs-links'], file); + assert.deepEqual(result.checks, ['docs-links', 'docs-prose'], file); assert.deepEqual(result.docsOnlyPaths, []); } - assert.deepEqual(plan(['website/docs/docs/commands.md']).checks, ['command-docs', 'docs-links']); + for (const file of ['website/package.json', 'website/rspress.config.ts']) { + assert.deepEqual(plan([file]).checks, ['docs-links'], file); + } + assert.deepEqual(plan(['website/docs/docs/commands.md']).checks, [ + 'command-docs', + 'docs-links', + 'docs-prose', + ]); assert.deepEqual(plan(['website/docs/public/logo.svg']).checks, []); }); diff --git a/scripts/check-affected/model.ts b/scripts/check-affected/model.ts index b381872385..6721806f5c 100644 --- a/scripts/check-affected/model.ts +++ b/scripts/check-affected/model.ts @@ -76,6 +76,7 @@ export const ALL_CHECKS = [ 'fixture-fallback', 'command-docs', 'docs-links', + 'docs-prose', 'agent-guidance', 'xctest-selection', 'packaged-runner-swift', @@ -543,16 +544,13 @@ function isAgentGuidance(file: string): boolean { return AGENT_GUIDANCE.has(file) || file.startsWith('docs/agents/'); } -// The pages and README whose internal links and heading anchors `check:doc-links` resolves, and -// the website manifest and config that decide what the built site renders. -const DOC_LINKS_INPUTS = new Set([ - 'README.md', - 'website/package.json', - 'website/rspress.config.ts', -]); +// The user docs: README and the docs site pages. `check:doc-prose` reads them; `check:doc-links` +// resolves their links and also reruns when the website manifest or config that decide what the +// built site renders change. +const SITE_BUILD_INPUTS = new Set(['website/package.json', 'website/rspress.config.ts']); -function isLinkedDoc(file: string): boolean { - return DOC_LINKS_INPUTS.has(file) || (file.startsWith('website/docs/') && /\.mdx?$/.test(file)); +function isUserDocPage(file: string): boolean { + return file === 'README.md' || (file.startsWith('website/docs/') && /\.mdx?$/.test(file)); } const docsOwnership: OwnershipRule = ({ file }) => { @@ -577,7 +575,7 @@ const docsOwnership: OwnershipRule = ({ file }) => { ), ); } - if (isLinkedDoc(file)) { + if (isUserDocPage(file) || SITE_BUILD_INPUTS.has(file)) { selections.push( reason( 'docs-links', @@ -587,6 +585,16 @@ const docsOwnership: OwnershipRule = ({ file }) => { ), ); } + if (isUserDocPage(file)) { + selections.push( + reason( + 'docs-prose', + file, + 'own:docs-prose', + 'user docs avoid filler, marketing, and time-relative words', + ), + ); + } return selections; }; diff --git a/scripts/check-doc-prose.ts b/scripts/check-doc-prose.ts new file mode 100644 index 0000000000..f83c9aa044 --- /dev/null +++ b/scripts/check-doc-prose.ts @@ -0,0 +1,155 @@ +// `pnpm check:doc-prose` — flags filler, marketing, and time-relative words in the user docs +// (website/docs/**/*.md(x) and README.md). Line-based on purpose: no Markdown parser. It skips +// YAML frontmatter, fenced code blocks, inline code spans, HTML tags and comments, link +// destinations, and bare URLs, so commands and identifiers never trip a rule. +// +// There is no allowlist: a legitimate hit ('just' meaning 'only', a quoted message) gets its sentence +// rewritten. + +import fs from 'node:fs'; +import path from 'node:path'; +import { pathToFileURL } from 'node:url'; + +const REPO_ROOT = path.resolve(import.meta.dirname, '..'); +const DOCS_DIR = 'website/docs'; + +const FILLER = 'condescending filler: if it were simple, the reader would not be reading the docs'; +const MARKETING = 'marketing adjective: state what the feature does instead'; +const TIME_RELATIVE = 'time-relative: rots as soon as it ships; state the behavior or a version'; + +/** Banned words and phrases, matched case-insensitively as whole words. */ +export const PROSE_RULES: readonly { readonly word: string; readonly reason: string }[] = [ + { word: 'just', reason: FILLER }, + { word: 'simply', reason: FILLER }, + { word: 'easy', reason: FILLER }, + { word: 'easily', reason: FILLER }, + { word: 'obvious', reason: FILLER }, + { word: 'obviously', reason: FILLER }, + { word: 'powerful', reason: MARKETING }, + { word: 'seamless', reason: MARKETING }, + { word: 'seamlessly', reason: MARKETING }, + { word: 'robust', reason: MARKETING }, + { word: 'intuitive', reason: MARKETING }, + { word: 'currently', reason: TIME_RELATIVE }, + { word: 'recently', reason: TIME_RELATIVE }, + { word: 'nowadays', reason: TIME_RELATIVE }, + { word: 'coming soon', reason: TIME_RELATIVE }, + { word: 'as of now', reason: TIME_RELATIVE }, + { word: 'newly added', reason: TIME_RELATIVE }, +]; + +export type ProseViolation = { + readonly line: number; + readonly column: number; + readonly word: string; + readonly message: string; +}; + +const RULE_PATTERNS = PROSE_RULES.map((rule) => ({ + ...rule, + pattern: new RegExp(String.raw`\b${rule.word.replaceAll(' ', String.raw`\s+`)}\b`, 'gi'), +})); + +const FENCE_OPEN = /^ {0,3}(`{3,}|~{3,})/; +// Spans that are not prose, blanked in place so columns stay true. +const NON_PROSE = [ + /(?/g, + /<\/?[A-Za-z][^>]*>/g, // HTML or JSX tag + /\]\([^)]*\)/g, // link or image destination + /\b(?:https?:\/\/|mailto:|www\.)\S+/g, +]; + +function blank(text: string): string { + return ' '.repeat(text.length); +} + +function maskNonProse(line: string): string { + return NON_PROSE.reduce((masked, pattern) => masked.replace(pattern, blank), line); +} + +function frontmatterEnd(lines: readonly string[]): number { + if (lines[0]?.trim() !== '---') return 0; + const close = lines.findIndex((line, index) => index > 0 && line.trim() === '---'); + return close === -1 ? 0 : close + 1; +} + +/** A closing fence repeats the opening character at least as many times, and nothing else. */ +function closesFence(line: string, fence: string): boolean { + const trimmed = line.trim(); + return trimmed.startsWith(fence) && trimmed.replaceAll(fence[0] ?? '', '') === ''; +} + +/** The open fence after `line`: opened, closed, or carried over. */ +function fenceAfter(fence: string | null, line: string): string | null { + if (fence === null) return FENCE_OPEN.exec(line)?.[1] ?? null; + return closesFence(line, fence) ? null : fence; +} + +/** The line with the end of a multi-line HTML comment blanked, or null when it does not end. */ +function afterCommentClose(line: string): string | null { + const end = line.indexOf('-->') + 3; + return end < 3 ? null : blank(line.slice(0, end)) + line.slice(end); +} + +/** Each line's prose with everything else blanked; lines inside code or comments yield nothing. */ +function* proseLines(lines: readonly string[]): Generator<{ index: number; prose: string }> { + let fence: string | null = null; + let inComment = false; + for (let index = frontmatterEnd(lines); index < lines.length; index += 1) { + const raw = lines[index] ?? ''; + const inCode = fence !== null || FENCE_OPEN.test(raw); + fence = fenceAfter(fence, raw); + if (inCode) continue; + const line = inComment ? afterCommentClose(raw) : raw; + if (line === null) continue; + const prose = maskNonProse(line); + const open = prose.indexOf('\nJust.'), ['4:1 just']); +}); + test('inline code spans are skipped, matching backtick run lengths', () => { assert.deepEqual(hits('Run `just build` or ``a ` just`` then just go.'), ['1:39 just']); }); +test('an inline code span that wraps across lines is skipped', () => { + assert.deepEqual(hits('Run `just\nsimply` here.'), []); +}); + +test('a stray backtick does not mask past the end of its paragraph', () => { + assert.deepEqual(hits('A ` tick.\n\nJust `x`.'), ['3:1 just']); +}); + +test('a phrase wrapped across lines matches at its first word', () => { + assert.deepEqual(hits('Support is coming\nsoon.'), ['1:12 coming soon']); +}); + test('HTML tags, comments, link destinations, and bare URLs are skipped', () => { const text = [ 'Easy', diff --git a/scripts/check-doc-prose.ts b/scripts/check-doc-prose.ts index f83c9aa044..8c8efb3b2d 100644 --- a/scripts/check-doc-prose.ts +++ b/scripts/check-doc-prose.ts @@ -1,7 +1,8 @@ // `pnpm check:doc-prose` — flags filler, marketing, and time-relative words in the user docs -// (website/docs/**/*.md(x) and README.md). Line-based on purpose: no Markdown parser. It skips -// YAML frontmatter, fenced code blocks, inline code spans, HTML tags and comments, link -// destinations, and bare URLs, so commands and identifiers never trip a rule. +// (website/docs/**/*.md(x) and README.md). Regex masks on purpose: no Markdown parser. It blanks +// YAML frontmatter, HTML comments, fenced code blocks, inline code spans, HTML tags, link +// destinations, and bare URLs, so commands and identifiers never trip a rule. Destinations with +// nested parentheses are not masked. // // There is no allowlist: a legitimate hit ('just' meaning 'only', a quoted message) gets its sentence // rewritten. @@ -50,82 +51,54 @@ const RULE_PATTERNS = PROSE_RULES.map((rule) => ({ pattern: new RegExp(String.raw`\b${rule.word.replaceAll(' ', String.raw`\s+`)}\b`, 'gi'), })); -const FENCE_OPEN = /^ {0,3}(`{3,}|~{3,})/; -// Spans that are not prose, blanked in place so columns stay true. +const FRONTMATTER = /^---\n[\s\S]*?\n---(?=\n|$)/; +const HTML_COMMENT = //g; +// A fence line, optionally inside blockquotes: the marker run, then the info string or nothing. +const FENCE_LINE = /^ {0,3}(?:> ?)*(`{3,}|~{3,})(.*)$/; +// Spans that are not prose. Code spans stop at a blank line, as paragraphs do. const NON_PROSE = [ - /(?/g, + /(?]*>/g, // HTML or JSX tag /\]\([^)]*\)/g, // link or image destination /\b(?:https?:\/\/|mailto:|www\.)\S+/g, ]; +/** Spaces in place of everything but newlines, so line and column math stays exact. */ function blank(text: string): string { - return ' '.repeat(text.length); + return text.replaceAll(/[^\n]/g, ' '); } -function maskNonProse(line: string): string { - return NON_PROSE.reduce((masked, pattern) => masked.replace(pattern, blank), line); -} - -function frontmatterEnd(lines: readonly string[]): number { - if (lines[0]?.trim() !== '---') return 0; - const close = lines.findIndex((line, index) => index > 0 && line.trim() === '---'); - return close === -1 ? 0 : close + 1; -} - -/** A closing fence repeats the opening character at least as many times, and nothing else. */ -function closesFence(line: string, fence: string): boolean { - const trimmed = line.trim(); - return trimmed.startsWith(fence) && trimmed.replaceAll(fence[0] ?? '', '') === ''; -} - -/** The open fence after `line`: opened, closed, or carried over. */ -function fenceAfter(fence: string | null, line: string): string | null { - if (fence === null) return FENCE_OPEN.exec(line)?.[1] ?? null; - return closesFence(line, fence) ? null : fence; -} - -/** The line with the end of a multi-line HTML comment blanked, or null when it does not end. */ -function afterCommentClose(line: string): string | null { - const end = line.indexOf('-->') + 3; - return end < 3 ? null : blank(line.slice(0, end)) + line.slice(end); +/** Blank every fenced code block, fence lines included; an unclosed fence runs to the end. */ +function blankFences(text: string): string { + let fence: string | null = null; + const lines = text.split('\n').map((line) => { + const [, marker = '', info = ''] = FENCE_LINE.exec(line) ?? []; + if (fence === null && marker === '') return line; + if (fence === null) fence = marker; + else if (marker[0] === fence[0] && marker.length >= fence.length && info.trim() === '') { + fence = null; + } + return blank(line); + }); + return lines.join('\n'); } -/** Each line's prose with everything else blanked; lines inside code or comments yield nothing. */ -function* proseLines(lines: readonly string[]): Generator<{ index: number; prose: string }> { - let fence: string | null = null; - let inComment = false; - for (let index = frontmatterEnd(lines); index < lines.length; index += 1) { - const raw = lines[index] ?? ''; - const inCode = fence !== null || FENCE_OPEN.test(raw); - fence = fenceAfter(fence, raw); - if (inCode) continue; - const line = inComment ? afterCommentClose(raw) : raw; - if (line === null) continue; - const prose = maskNonProse(line); - const open = prose.indexOf('