diff --git a/.github/workflows/format-documents.yml b/.github/workflows/format-documents.yml new file mode 100644 index 00000000..527e2e28 --- /dev/null +++ b/.github/workflows/format-documents.yml @@ -0,0 +1,55 @@ +name: Format documents + +# Repairs formatting drift that reaches main through the advisory +# document-only lint path (ADR 0011, amendment of 2026-10-30). Formats the +# authored document paths with the repository Prettier configuration and +# opens or refreshes a single pull request when anything changed. +on: + push: + branches: [main] + paths: + - 'docs/**' + - 'blog/**' + - 'adr/**' + - 'CODE_OF_CONDUCT.md' + - 'CONTENT-SECURITY.md' + - 'CONTRIBUTING.md' + - 'GOVERNANCE.md' + - 'MAINTAINERS.md' + - 'README.md' + - 'SECURITY.md' + schedule: + - cron: '27 4 * * 1' + workflow_dispatch: + +permissions: + contents: write + pull-requests: write + +# Serialised on the branch this workflow force-pushes; see the note in +# refresh-radar-reports.yml. Not cancelled, for the same reason. +concurrency: + group: automation/format-documents + cancel-in-progress: false + +jobs: + format: + runs-on: ubuntu-24.04 + timeout-minutes: 10 + steps: + - name: Check out main + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Install dependencies + uses: ./.github/actions/setup-node-deps + - name: Format authored documents + run: npm run fix:format:documents + - name: Open or refresh the formatting pull request + uses: ./.github/actions/open-automation-pr + with: + branch: automation/format-documents + commit-message: 'chore: format documents' + title: 'chore: format documents' + body: | + Automated Prettier pass over the authored documents (docs/, blog/, adr/ and the root documents). A document-only pull request may merge with formatting findings left as advisory annotations; this pull request normalizes what landed. diff --git a/.markdownlint.yaml b/.markdownlint.yaml index 8cd166c2..fd999107 100644 --- a/.markdownlint.yaml +++ b/.markdownlint.yaml @@ -4,7 +4,8 @@ # small set of inline HTML elements (figure/figcaption/a) for captions. default: true -# Prose lines are not hard-wrapped in this repository. +# Prose may be wrapped or unwrapped (Prettier runs with proseWrap: preserve +# for Markdown), so line length is not enforced. MD013: false # Docusaurus doc/blog frontmatter provides the H1 via `title`, so pages may diff --git a/AGENTS.md b/AGENTS.md index 27405b32..3510501f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -80,8 +80,11 @@ takes the full path. in a file a PR does not touch is not a finding. See [`CONTRIBUTING.md`](CONTRIBUTING.md#mutation-testing). - **Lint**: the required "Lint repository" CI check runs on every PR. A - document-only PR lints just the changed files (formatting, spelling and - Markdown); every other PR runs `npm run check:format`, + document-only PR lints just the changed files: markdownlint blocks, while + Prettier and cspell findings are advisory annotations that do not fail the + check (ADR 0011 amendment); the "Format documents" workflow then opens a + `chore: format documents` PR to normalize any drift on `main`. Every other PR + runs `npm run check:format`, `npm run check:spelling`, `npm run check:markdown`, `npm run check:duplication`, `npm run check:lint` and `npm run check:unused`. `check:lint` runs ESLint (`eslint.config.mjs`) over `src/`, `scripts/`, diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9b30f029..2ce513c1 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -277,9 +277,17 @@ enough for a first contribution. Pull requests that change only recognized, human-maintained Markdown or MDX documents use focused CI: formatting, spelling, and Markdown checks run on the changed documents, document-contract tests run, and the production site is -built. Generated or data-backed pages, code, configuration, assets, mixed -changes, and unrecognized paths keep the full validation, lint, and browser -suites. If CI cannot confidently classify a change, it uses the full path. +built. On that path formatting and spelling are advisory: their findings appear +as annotations on the pull request and in the job summary, with the fix to +apply, but do not fail the check, so a page edited in the GitHub web editor can +merge without hard-wrapped lines or a dictionary entry for a new proper noun. +Markdown lint, the document-contract tests and the build still block. The +"Format documents" workflow opens a single `chore: format documents` pull +request whenever formatting drift reaches `main`. Generated or data-backed +pages, code, configuration, assets, mixed changes, and unrecognized paths keep +the full validation, lint, and browser suites, where formatting and spelling +fail as before. If CI cannot confidently classify a change, it uses the full +path. 1. Fork the repository and create a branch from `main`. 2. Make your change and run the relevant validation script. @@ -300,9 +308,12 @@ suites. If CI cannot confidently classify a change, it uses the full path. `npm run check:spelling`, `npm run check:markdown`, `npm run check:duplication`, `npm run check:lint` and `npm run check:unused`. The "Lint repository" check runs these six on that path. Content-only PRs use - focused formatting, spelling and Markdown checks on changed documents. If a - full-path check fails: - - `check:format` — run `npm run fix:format` to auto-format. + focused formatting, spelling and Markdown checks on changed documents, with + formatting and spelling reported as advisory annotations rather than + failures. If a full-path check fails: + - `check:format` — run `npm run fix:format` to auto-format. Markdown prose + may be wrapped or unwrapped (`proseWrap: preserve`); Prettier only + normalizes structure such as lists, emphasis and tables. - `check:spelling` — add the flagged term to `.cspell.yml` if it's a legitimate project word, or fix the typo. - `check:markdown` — fix the reported issue, or add an inline disable comment diff --git a/adr/0011-content-focused-validation-for-documentation.md b/adr/0011-content-focused-validation-for-documentation.md index 38ee18ee..ceabb2d4 100644 --- a/adr/0011-content-focused-validation-for-documentation.md +++ b/adr/0011-content-focused-validation-for-documentation.md @@ -1,6 +1,11 @@ # ADR 0011: Content-only pull requests use focused validation - **Status**: Accepted +- **Status note**: Amended 2026-10-30: on the document-only path, formatting and + spelling are reported as advisory annotations instead of failing the check, + Markdown no longer requires hard-wrapped prose, and formatting drift on `main` + is repaired by an automated pull request; see + [Amendment: advisory formatting and spelling](#amendment-advisory-formatting-and-spelling-2026-10-30). - **Date**: 2026-10-29 - **Related**: [ADR 0008](./0008-scope-agent-findings-to-user-observable-change.md), @@ -118,3 +123,53 @@ queue must apply the same classification and safety rules. silently skip validation. - This decision changes which checks run by change type; it does not change test coverage requirements or any other frozen threshold. + +## Amendment: advisory formatting and spelling (2026-10-30) + +The focused path still failed the contributions it was meant to ease. A page +edited in the GitHub web editor is never hard-wrapped at 80 columns, so the +Prettier check (`proseWrap: always`) failed every such change, most recently +[#2005](https://github.com/cncf/endusers/pull/2005), which a maintainer had to +force-merge and repair in [#2008](https://github.com/cncf/endusers/pull/2008). +Spelling failed on proper nouns that a new User Group page legitimately +introduces. Neither failure is a defect an end user can observe, and both are +mechanical to repair, so they should not stand between a contributor and a +merge. The DCO is not a hurdle here: the repository requires sign-off on web +commits, so the web editor adds the trailer itself. + +### What changes + +1. Markdown and MDX files use `proseWrap: preserve`. Prettier accepts prose + either wrapped or unwrapped and still normalizes everything else (lists, + emphasis, tables, blockquote continuation). Code files keep their settings. +2. On the document-only path, Prettier and cspell are advisory. Their findings + are surfaced as GitHub workflow annotations on the changed lines and in the + step summary, with the one-line fix for each (`npm run fix:format`, or a + `cspell:ignore` comment for a genuine proper noun), and they do not fail the + "Lint repository" check. markdownlint, the document-contract suites and the + production build still block, because what they catch does render wrong. +3. The full path is unchanged: every other change still fails on formatting and + spelling as before. +4. Formatting drift that reaches `main` through the advisory path is repaired by + a `format-documents` workflow. It runs on every push to `main` that touches + an authored document and on a weekly schedule, formats the authored document + paths with the repository Prettier configuration (so `.prettierignore` still + protects `blog/`), and opens or refreshes a single `chore: format documents` + pull request through the existing `open-automation-pr` action when anything + changed. That pull request is itself document-only and merges through the + focused path. + +### Effects + +- A contributor editing a page in the web editor can merge with unwrapped prose + and an unlisted proper noun; the formatting is normalized shortly after by the + automation pull request, and the spelling warning stays visible to the + reviewer on the pull request. +- `main` may carry unformatted prose for the minutes between a merge and the + repair pull request. `npm run check:format` on a fresh clone can therefore + fail until that pull request lands; `npm run fix:format` restores it. +- Advisory does not mean unchecked: the annotations appear on the pull request + and a reviewer can still ask for the fix. The classifier, the blocking + contract suites and the production build remain the trust boundary. +- No frozen threshold changes. This amendment narrows which focused-path + findings block; it does not alter coverage, mutation or clone gates. diff --git a/docs/community/index.md b/docs/community/index.md index 6d85f48d..cd175474 100644 --- a/docs/community/index.md +++ b/docs/community/index.md @@ -1,7 +1,6 @@ --- title: Community -description: - 'The CNCF End User Community, TAB, governance, and community groups.' +description: 'The CNCF End User Community, TAB, governance, and community groups.' sidebar_position: 1 --- diff --git a/docs/community/technical-advisory-board/governance.md b/docs/community/technical-advisory-board/governance.md index 35800079..08808fd5 100644 --- a/docs/community/technical-advisory-board/governance.md +++ b/docs/community/technical-advisory-board/governance.md @@ -60,10 +60,8 @@ standards bodies, such as the [Internet Engineering Task Force (IETF)] and the optional. [RFC2119 keywords]: https://www.ietf.org/rfc/rfc2119.txt -[Internet Engineering Task Force (IETF)]: - https://en.wikipedia.org/wiki/Internet_Engineering_Task_Force -[Kubernetes project]: - https://github.com/kubernetes/community/blob/master/committee-steering/governance/sig-governance-requirements.md +[Internet Engineering Task Force (IETF)]: https://en.wikipedia.org/wiki/Internet_Engineering_Task_Force +[Kubernetes project]: https://github.com/kubernetes/community/blob/master/committee-steering/governance/sig-governance-requirements.md ## User Groups (UGs) @@ -339,8 +337,7 @@ approval. TOC or TAB; the TCG organizers should reach out to the chairs of the TOC and TAB to discuss which group would be the most appropriate. -[Community Groups README]: - https://github.com/cncf/communitygroups?tab=readme-ov-file#how-to-apply +[Community Groups README]: https://github.com/cncf/communitygroups?tab=readme-ov-file#how-to-apply ### Roles, Terms, and Elections @@ -403,7 +400,5 @@ Community Groups. {/* Common Links */} -[Technical Leadership Principles]: - https://github.com/cncf/toc/blob/main/PRINCIPLES.md#technical-leadership-principles -[Community Group Organizer Requirements]: - https://github.com/cncf/communitygroups/blob/main/organizers.md +[Technical Leadership Principles]: https://github.com/cncf/toc/blob/main/PRINCIPLES.md#technical-leadership-principles +[Community Group Organizer Requirements]: https://github.com/cncf/communitygroups/blob/main/organizers.md diff --git a/package.json b/package.json index a405d321..a8d13cfd 100644 --- a/package.json +++ b/package.json @@ -56,6 +56,7 @@ "validate:members": "node scripts/validate-members.mjs", "docusaurus": "docusaurus", "fix:format": "npm run _check:format -- --write", + "fix:format:documents": "npx prettier --write docs blog adr CODE_OF_CONDUCT.md CONTENT-SECURITY.md CONTRIBUTING.md GOVERNANCE.md MAINTAINERS.md README.md SECURITY.md", "fix": "npm run seq -- $(npm -s run _list:fix:*)", "seq": "bash -c 'for cmd in \"$@\"; do npm run $cmd || exit 1; done' - ", "serve": "npm run docus:serve", @@ -147,7 +148,18 @@ "spelling": "cSpell:ignore ACMR docus HTMLTEST loglevel pkgs -", "prettier": { "proseWrap": "always", - "singleQuote": true + "singleQuote": true, + "overrides": [ + { + "files": [ + "*.md", + "*.mdx" + ], + "options": { + "proseWrap": "preserve" + } + } + ] }, "homepage": "https://endusers.cncf.io/", "overrides": { diff --git a/scripts/lib/content-only-ci.mjs b/scripts/lib/content-only-ci.mjs index 0ed96d03..db3f9664 100644 --- a/scripts/lib/content-only-ci.mjs +++ b/scripts/lib/content-only-ci.mjs @@ -213,33 +213,171 @@ export function decodeChangedDocuments(encoded) { return [...uniqueDocuments]; } -export function runDocumentLint(encoded, runCommand = spawnSync) { - const documents = decodeChangedDocuments(encoded); - if (documents.length === 0) return { documents, checked: [] }; +// Advisory checks on the document-only path (ADR 0011, amendment of +// 2026-10-30): their findings become workflow annotations and a step-summary +// entry, never a failed check. Each parser turns the tool's stdout into +// annotations; `fix` is the one-line repair shown next to them. +const ADVISORY_CHECKS = [ + { + name: 'prettier', + args: (documents) => ['prettier', '--check', ...documents], + parse: prettierAnnotations, + fix: 'run `npm run fix:format`', + }, + { + name: 'cspell', + args: (documents) => [ + 'cspell', + '--no-progress', + '-c', + '.cspell.yml', + ...documents, + ], + parse: cspellAnnotations, + fix: 'add the word to `.cspell.yml`, or to a `` comment in the page when it is a proper noun', + }, +]; - const markdown = documents.filter((path) => path.endsWith('.md')); - const commands = [ - ['npx', ['prettier', '--check', ...documents]], - ['npx', ['cspell', '--no-progress', '-c', '.cspell.yml', ...documents]], +export function prettierAnnotations(output) { + const annotations = []; + for (const match of String(output).matchAll(/^\[warn\] (\S+)$/gmu)) { + annotations.push({ + file: match[1], + message: 'Formatting differs from Prettier output', + }); + } + return annotations; +} + +export function cspellAnnotations(output) { + const annotations = []; + for (const match of String(output).matchAll( + /(.+?):(\d+):(\d+) - Unknown word \((.+)\)$/gmu, + )) { + annotations.push({ + file: match[1], + line: Number(match[2]), + col: Number(match[3]), + message: `Unknown word "${match[4]}"`, + }); + } + return annotations; +} + +function escapeAnnotationMessage(message) { + return message + .replaceAll('%', '%25') + .replaceAll('\r', '%0D') + .replaceAll('\n', '%0A'); +} + +export function formatAnnotation({ file, line, col, message }) { + const location = + line === undefined + ? `file=${file}` + : `file=${file},line=${line},col=${col}`; + return `::warning ${location}::${escapeAnnotationMessage(message)}`; +} + +export function advisorySummary(findings) { + if (findings.length === 0) return ''; + const lines = [ + '## Advisory document checks', + '', + 'These do not block a document-only pull request; a reviewer may still ask for the fix.', + '', ]; - if (markdown.length > 0) { - commands.push([ - 'npx', - ['--no-install', 'markdownlint', '-c', '.markdownlint.yaml', ...markdown], - ]); + for (const { check, annotations } of findings) { + lines.push( + `- \`${check.name}\`: ${annotations.length} finding(s); ${check.fix}.`, + ); + for (const annotation of annotations) { + const where = + annotation.line === undefined + ? annotation.file + : `${annotation.file}:${annotation.line}:${annotation.col}`; + lines.push(` - \`${where}\` ${annotation.message}`); + } } + lines.push(''); + return lines.join('\n'); +} - for (const [command, args] of commands) { - const result = runCommand(command, args, { +function runAdvisoryCheck(check, documents, runCommand, log) { + const args = check.args(documents); + const result = runCommand('npx', args, { + cwd: REPOSITORY_ROOT, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + }); + if (result.error) throw result.error; + const output = `${result.stdout ?? ''}${result.stderr ?? ''}`; + if (output.length > 0) log(output.trimEnd()); + if (result.status === 0) return []; + + const annotations = check.parse(output); + if (annotations.length === 0) { + // The tool failed without naming a document (a missing config, a crash); + // keep that visible rather than reporting a clean run. + annotations.push({ + file: documents[0], + message: `${check.name} exited with status ${result.status} without reporting a finding`, + }); + } + return annotations; +} + +export function runDocumentLint( + encoded, + runCommand = spawnSync, + { + log = console.log, + summaryPath = process.env.GITHUB_STEP_SUMMARY, + appendSummary = appendFileSync, + } = {}, +) { + const documents = decodeChangedDocuments(encoded); + if (documents.length === 0) return { documents, checked: [], advisories: [] }; + + const checked = []; + const findings = []; + for (const check of ADVISORY_CHECKS) { + const annotations = runAdvisoryCheck(check, documents, runCommand, log); + checked.push(check.name); + if (annotations.length > 0) findings.push({ check, annotations }); + } + for (const { annotations } of findings) { + for (const annotation of annotations) log(formatAnnotation(annotation)); + } + if (findings.length > 0 && summaryPath) { + appendSummary(summaryPath, advisorySummary(findings)); + } + + const markdown = documents.filter((path) => path.endsWith('.md')); + if (markdown.length > 0) { + const args = [ + '--no-install', + 'markdownlint', + '-c', + '.markdownlint.yaml', + ...markdown, + ]; + const result = runCommand('npx', args, { cwd: REPOSITORY_ROOT, stdio: 'inherit', }); if (result.error) throw result.error; if (result.status !== 0) { - throw new Error( - `${command} ${args[0]} failed with status ${result.status}`, - ); + throw new Error(`npx markdownlint failed with status ${result.status}`); } + checked.push('markdownlint'); } - return { documents, checked: commands.map(([, args]) => args[0]) }; + + return { + documents, + checked, + advisories: findings.flatMap(({ check, annotations }) => + annotations.map((annotation) => ({ check: check.name, ...annotation })), + ), + }; } diff --git a/tests/automation-pr-policy.test.mjs b/tests/automation-pr-policy.test.mjs index 80ac9f5a..15030b00 100644 --- a/tests/automation-pr-policy.test.mjs +++ b/tests/automation-pr-policy.test.mjs @@ -5,9 +5,10 @@ // supply its branch name and PR text", and the action supplies the rest by // pinning `delete-branch: true`, `labels: automated` and `signoff: true` on the // inner `peter-evans/create-pull-request` step, and forwarding its own declared -// inputs to it. Five workflows route through it -- architecture-submission, -// refresh-community-people, refresh-members, refresh-metrics, -// refresh-radar-reports -- and none uses the action directly, so that one +// inputs to it. Six workflows route through it -- architecture-submission, +// format-documents, refresh-community-people, refresh-members, +// refresh-metrics, refresh-radar-reports -- and none uses the action directly, +// so that one // `with:` block is the whole of the policy. // // Nothing read it. ./composite-action-supply-chain.test.mjs is the only test diff --git a/tests/automation-workflow-ordering.test.mjs b/tests/automation-workflow-ordering.test.mjs index 65a35450..d813ea3f 100644 --- a/tests/automation-workflow-ordering.test.mjs +++ b/tests/automation-workflow-ordering.test.mjs @@ -203,6 +203,7 @@ test('the workflows this contract applies to are actually detected', () => { [ 'architecture-submission.yml', 'audit-fix.yml', + 'format-documents.yml', 'refresh-community-people.yml', 'refresh-members.yml', 'refresh-metrics.yml', diff --git a/tests/ci-concurrency.test.mjs b/tests/ci-concurrency.test.mjs index 2df296a3..f19c6ecf 100644 --- a/tests/ci-concurrency.test.mjs +++ b/tests/ci-concurrency.test.mjs @@ -94,6 +94,12 @@ const SERIALISED_WORKFLOWS = new Map([ 'Force-pushes the constant branch automation/refresh-radar-reports; ' + 'same reasoning as refresh-metrics.yml.', ], + [ + 'format-documents.yml', + 'Force-pushes the constant branch automation/format-documents; same ' + + 'reasoning as refresh-metrics.yml. Triggered by push to main, a ' + + 'schedule and dispatch, not by a PR.', + ], [ 'audit-fix.yml', 'Force-pushes the constant branch automation/audit-fix; same reasoning ' + diff --git a/tests/content-only-ci.test.mjs b/tests/content-only-ci.test.mjs index fdc421a8..7d797306 100644 --- a/tests/content-only-ci.test.mjs +++ b/tests/content-only-ci.test.mjs @@ -19,6 +19,10 @@ import { decodeChangedDocuments, isAuthoredDocumentPath, parseNameStatus, + prettierAnnotations, + cspellAnnotations, + formatAnnotation, + advisorySummary, runDocumentLint, runClassifier, writeOutputs, @@ -364,6 +368,7 @@ test('decodes and validates the changed-document list', () => { assert.deepEqual(runDocumentLint(encoded([])), { documents: [], checked: [], + advisories: [], }); for (const invalid of [undefined, '', '!', '!W10=', 'W10=!']) { @@ -393,34 +398,52 @@ test('decodes and validates the changed-document list', () => { ); }); +const README_B64 = Buffer.from(JSON.stringify(['README.md'])).toString( + 'base64', +); +const REPO_ROOT = fileURLToPath(new URL('../', import.meta.url)); +const ADVISORY_OPTIONS = { + cwd: REPO_ROOT, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], +}; +const BLOCKING_OPTIONS = { cwd: REPO_ROOT, stdio: 'inherit' }; + +function quietLint(encoded, runCommand, extra = {}) { + const logged = []; + const summaries = []; + const result = runDocumentLint(encoded, runCommand, { + log: (line) => logged.push(line), + summaryPath: null, + appendSummary: (path, text) => summaries.push({ path, text }), + ...extra, + }); + return { result, logged, summaries }; +} + test('runs changed-document formatting, spelling, and Markdown checks', () => { const calls = []; - const result = runDocumentLint( - Buffer.from(JSON.stringify(['README.md'])).toString('base64'), - (command, args, options) => { - calls.push({ command, args, options }); - return { status: 0 }; - }, - ); + const { result, logged } = quietLint(README_B64, (command, args, options) => { + calls.push({ command, args, options }); + return { status: 0, stdout: '', stderr: '' }; + }); assert.deepEqual(result, { documents: ['README.md'], - checked: ['prettier', 'cspell', '--no-install'], + checked: ['prettier', 'cspell', 'markdownlint'], + advisories: [], }); - const options = { - cwd: fileURLToPath(new URL('../', import.meta.url)), - stdio: 'inherit', - }; + assert.deepEqual(logged, []); assert.deepEqual(calls, [ { command: 'npx', args: ['prettier', '--check', 'README.md'], - options, + options: ADVISORY_OPTIONS, }, { command: 'npx', args: ['cspell', '--no-progress', '-c', '.cspell.yml', 'README.md'], - options, + options: ADVISORY_OPTIONS, }, { command: 'npx', @@ -431,14 +454,14 @@ test('runs changed-document formatting, spelling, and Markdown checks', () => { '.markdownlint.yaml', 'README.md', ], - options, + options: BLOCKING_OPTIONS, }, ]); }); test('preserves repository Prettier ignores for blog documents', () => { const calls = []; - runDocumentLint( + quietLint( Buffer.from( JSON.stringify(['blog/2026-07-22-welcome-to-endusers-cncf-io.md']), ).toString('base64'), @@ -463,7 +486,7 @@ test('runs only formatting and spelling for MDX documents', () => { writeFileSync(path, '# Test\n'); try { const calls = []; - const result = runDocumentLint( + const { result } = quietLint( Buffer.from(JSON.stringify([path])).toString('base64'), (command, args) => { calls.push({ command, args }); @@ -480,15 +503,172 @@ test('runs only formatting and spelling for MDX documents', () => { } }); -test('fails changed-document lint when a command fails or cannot start', () => { - const encoded = Buffer.from(JSON.stringify(['README.md'])).toString('base64'); +test('parses Prettier and cspell output into annotations', () => { + assert.deepEqual( + prettierAnnotations( + 'Checking formatting...\n[warn] docs/a.md\n[warn] README.md\n[warn] Code style issues found in 2 files. Run Prettier with --write to fix.\n', + ), + [ + { file: 'docs/a.md', message: 'Formatting differs from Prettier output' }, + { file: 'README.md', message: 'Formatting differs from Prettier output' }, + ], + ); + assert.deepEqual( + prettierAnnotations( + 'All matched files use Prettier code style!\nnote [warn] docs/a.md\n[warn] docs/a.md trailing\n', + ), + [], + ); + assert.deepEqual( + cspellAnnotations( + 'docs/a.md:5:21 - Unknown word (Zorblaxian)\ndocs/b.md:12:3 - Unknown word (Qux)\nCSpell: Files checked: 2, Issues found: 2 in 2 files.\n', + ), + [ + { + file: 'docs/a.md', + line: 5, + col: 21, + message: 'Unknown word "Zorblaxian"', + }, + { file: 'docs/b.md', line: 12, col: 3, message: 'Unknown word "Qux"' }, + ], + ); + assert.deepEqual( + cspellAnnotations('docs/a.md:1:2 - Unknown word (Foo) trailing\n'), + [], + ); + assert.deepEqual(cspellAnnotations(undefined), []); +}); + +test('formats annotations as workflow commands with escaped messages', () => { + assert.equal( + formatAnnotation({ file: 'docs/a.md', message: 'Formatting differs' }), + '::warning file=docs/a.md::Formatting differs', + ); + assert.equal( + formatAnnotation({ + file: 'docs/a.md', + line: 5, + col: 2, + message: 'a%b\r\nc', + }), + '::warning file=docs/a.md,line=5,col=2::a%25b%0D%0Ac', + ); +}); + +test('reports formatting and spelling as advisories without failing', () => { + const outputs = { + prettier: { + status: 1, + stdout: '[warn] README.md\n', + stderr: '', + }, + cspell: { + status: 1, + stdout: 'README.md:3:1 - Unknown word (Zorblaxian)\n', + stderr: '', + }, + '--no-install': { status: 0 }, + }; + const { result, logged, summaries } = quietLint( + README_B64, + (command, args) => outputs[args[0]], + { summaryPath: '/tmp/summary.md' }, + ); + + assert.deepEqual(result.checked, ['prettier', 'cspell', 'markdownlint']); + assert.deepEqual(result.advisories, [ + { + check: 'prettier', + file: 'README.md', + message: 'Formatting differs from Prettier output', + }, + { + check: 'cspell', + file: 'README.md', + line: 3, + col: 1, + message: 'Unknown word "Zorblaxian"', + }, + ]); + assert.deepEqual(logged, [ + '[warn] README.md', + 'README.md:3:1 - Unknown word (Zorblaxian)', + '::warning file=README.md::Formatting differs from Prettier output', + '::warning file=README.md,line=3,col=1::Unknown word "Zorblaxian"', + ]); + assert.equal(summaries.length, 1); + assert.equal(summaries[0].path, '/tmp/summary.md'); + assert.equal( + summaries[0].text, + [ + '## Advisory document checks', + '', + 'These do not block a document-only pull request; a reviewer may still ask for the fix.', + '', + '- `prettier`: 1 finding(s); run `npm run fix:format`.', + ' - `README.md` Formatting differs from Prettier output', + '- `cspell`: 1 finding(s); add the word to `.cspell.yml`, or to a `` comment in the page when it is a proper noun.', + ' - `README.md:3:1` Unknown word "Zorblaxian"', + '', + ].join('\n'), + ); + assert.equal(advisorySummary([]), ''); +}); + +test('keeps an advisory tool failure visible when it names no document', () => { + const { result, logged, summaries } = quietLint( + README_B64, + (command, args) => + args[0] === 'cspell' + ? { status: 2, stdout: '', stderr: 'config not found\n' } + : { status: 0 }, + ); + assert.deepEqual(result.advisories, [ + { + check: 'cspell', + file: 'README.md', + message: 'cspell exited with status 2 without reporting a finding', + }, + ]); + assert.deepEqual(logged, [ + 'config not found', + '::warning file=README.md::cspell exited with status 2 without reporting a finding', + ]); + // No summary path was given, so nothing is appended. + assert.deepEqual(summaries, []); +}); + +test('writes nothing to the step summary when every advisory check is clean', () => { + const { summaries } = quietLint( + README_B64, + () => ({ status: 0, stdout: 'ok\n' }), + { summaryPath: '/tmp/summary.md' }, + ); + assert.deepEqual(summaries, []); +}); + +test('fails changed-document lint only for markdownlint or a tool that cannot start', () => { assert.throws( - () => runDocumentLint(encoded, () => ({ status: 1 })), - /npx prettier failed with status 1/u, + () => + quietLint(README_B64, (command, args) => ({ + status: args[0] === '--no-install' ? 1 : 0, + })), + /npx markdownlint failed with status 1/u, ); const failure = new Error('command unavailable'); assert.throws( - () => runDocumentLint(encoded, () => ({ error: failure })), + () => + quietLint(README_B64, (command, args) => + args[0] === 'prettier' ? { error: failure } : { status: 0 }, + ), + (error) => error === failure, + ); + assert.throws( + () => + quietLint(README_B64, (command, args) => + args[0] === '--no-install' ? { error: failure } : { status: 0 }, + ), (error) => error === failure, ); });