Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 57 additions & 13 deletions dev/check-spelling.mjs
Original file line number Diff line number Diff line change
@@ -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 <revision> [--format text|json]
*
Expand All @@ -10,6 +11,7 @@
*/

import {execFileSync, spawnSync} from 'child_process';
import {readFileSync} from 'fs';
import path from 'path';
import {fileURLToPath} from 'url';

Expand Down Expand Up @@ -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(
Expand All @@ -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';
}
Expand All @@ -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'
Expand Down
50 changes: 39 additions & 11 deletions dev/post-spelling-review.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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('');
Expand Down Expand Up @@ -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) {
Expand Down
Loading