diff --git a/dev/check-spelling.mjs b/dev/check-spelling.mjs index 733949636..cf07ab1c9 100644 --- a/dev/check-spelling.mjs +++ b/dev/check-spelling.mjs @@ -1,7 +1,8 @@ #!/usr/bin/env node /** - * Reports CSpell findings on lines added by a Git diff. + * Reports CSpell findings on lines added by a Git diff, and dictionary entries + * added out of alphabetical order. * * Usage: node dev/check-spelling.mjs --base [--format text|json] * @@ -10,6 +11,7 @@ */ import {execFileSync, spawnSync} from 'child_process'; +import {readFileSync} from 'fs'; import path from 'path'; import {fileURLToPath} from 'url'; @@ -87,11 +89,51 @@ function runCSpell(files) { column: issue.col, word: issue.text, suggestions: issue.suggestions?.slice(0, 3) ?? [], - text: issue.line.text.replace(/\r?\n$/, ''), - context: issue.context?.text.trim() ?? issue.line.text.trim() + text: issue.line.text.replace(/\r?\n$/, '') })); } +// Entries in the dictionary files must be sorted, so duplicates stand out and +// merges are clean. Sorted the way CSpell matches: case- and accent-insensitive. +// Blank lines and comments start a new sorted run, so the lists can be sectioned. +const DICTIONARY_FILES = ['cspell-allow-list.txt', 'cspell-block-list.txt']; +const collator = new Intl.Collator('en', {sensitivity: 'base'}); + +function unsortedDictionaryEntries(files) { + const findings = []; + for (const file of files.filter(file => DICTIONARY_FILES.includes(file))) { + let run = []; // [{word, line}] of the current sorted run + readFileSync(file, 'utf8') + .split('\n') + .forEach((text, index) => { + if (/^\s*(#|$)/.test(text)) { + run = []; + return; + } + const word = text.replace(/\s*#.*/, '').trim(); + const line = index + 1; + const belongsBefore = run.find( + entry => collator.compare(word, entry.word) < 0 + ); + if (belongsBefore) { + findings.push({ + file, + line, + column: 1, + word, + suggestions: [], + text, + message: `\`${word}\` is out of alphabetical order: move it above \`${belongsBefore.word}\``, + relatedLine: belongsBefore.line // rendered as a link after the message + }); + } else { + run.push({word, line}); + } + }); + } + return findings; +} + function findingsOnAddedLines(ranges, issues) { return issues.filter(issue => (ranges.get(issue.file) ?? []).some( @@ -102,16 +144,17 @@ function findingsOnAddedLines(ranges, issues) { function formatText(findings) { if (findings.length === 0) { - return 'No spelling errors found in added lines.\n'; + return 'No issues found in added lines.\n'; } const lines = [ - `Found ${findings.length} spelling error(s) in added lines:` + `Found ${findings.length} issue(s) in added lines:` ]; - for (const finding of findings) { - lines.push( - `${finding.file}:${finding.line}:${finding.column} - Unknown word (${finding.word})` - ); + for (const {file, line, column, word, message, relatedLine} of findings) { + const detail = message + ? `${message}${relatedLine ? ` on line ${relatedLine}` : ''}` + : `Unknown word (${word})`; + lines.push(`${file}:${line}:${column} - ${detail}`); } return lines.join('\n') + '\n'; } @@ -125,10 +168,11 @@ async function main() { } const ranges = addedLineRanges(BASE); - const findings = findingsOnAddedLines( - ranges, - runCSpell([...ranges.keys()]) - ); + const files = [...ranges.keys()]; + const findings = findingsOnAddedLines(ranges, [ + ...runCSpell(files), + ...unsortedDictionaryEntries(files) + ]); process.stdout.write( FORMAT === 'json' ? JSON.stringify(findings, null, '\t') + '\n' diff --git a/dev/post-spelling-review.mjs b/dev/post-spelling-review.mjs index 38d18a88e..94eacb399 100644 --- a/dev/post-spelling-review.mjs +++ b/dev/post-spelling-review.mjs @@ -100,10 +100,35 @@ function groupByFile(findings) { return grouped; } +// Source view (?plain=1, so Markdown is not rendered) with the word highlighted +function sourceLink(finding) { + const {file, line, column, word} = finding; + const end = column + word.length; + return `https://github.com/${REPOSITORY}/blob/${HEAD_REF}/${file}?plain=1#L${line}C${column}-L${line}C${end}`; +} + +// A non-spelling finding's own message, e.g. an unsorted dictionary entry, +// pointing at its `relatedLine` when it has one +function messageText(finding) { + const {file, message, relatedLine} = finding; + if (!relatedLine) return `${message}.`; + const url = `https://github.com/${REPOSITORY}/blob/${HEAD_REF}/${file}?plain=1#L${relatedLine}`; + return `${message} on [line ${relatedLine}](${url}).`; +} + +// `word` → `suggestion`, or the finding's own message +function summaryItem(finding) { + if (finding.message) { + return messageText(finding); + } + const suggestion = bestSuggestion(finding); + return `\`${finding.word}\`${suggestion ? ` → \`${suggestion}\`` : ''}`; +} + function summaryBody(findings) { const lines = [ SUMMARY_MARKER, - `### ⚠️ CSpell found ${findings.length} spelling error(s) in this PR`, + `### ⚠️ Spell check found ${findings.length} issue(s) in this PR`, '', 'Only findings on lines added by this PR are shown.', ...(findings.length > MAX_INLINE_COMMENTS @@ -115,10 +140,11 @@ function summaryBody(findings) { ]; for (const [file, fileFindings] of groupByFile(findings)) { lines.push(`**\`${file}\`**`); - for (const {line, column, word, context} of fileFindings) { - const excerpt = context.replaceAll('`', "'").slice(0, 160); + for (const finding of fileFindings) { + const {line, column} = finding; lines.push( - `- line ${line}, column ${column}: \`${word}\` — \`${excerpt}\`` + `- [line ${line}, column ${column}](${sourceLink(finding)})`, + ` - ${summaryItem(finding)}` ); } lines.push(''); @@ -207,13 +233,15 @@ function suggestionBlock(finding) { } function inlineBody(finding) { - return [ - `${INLINE_MARKER} ${finding.word} -->`, - `\`${finding.word}\` is not in the dictionary.`, - '', - ...suggestionBlock(finding), - `Please correct the spelling, or add the word to ${ALLOW_LIST_LINK} if it is correct.` - ].join('\n'); + const explanation = finding.message + ? [messageText(finding)] + : [ + `\`${finding.word}\` is not in the dictionary.`, + '', + ...suggestionBlock(finding), + `Please correct the spelling, or add the word to ${ALLOW_LIST_LINK} if it is correct.` + ]; + return [`${INLINE_MARKER} ${finding.word} -->`, ...explanation].join('\n'); } async function syncInlineComments(findings) {