diff --git a/.github/workflows/spellcheck.yml b/.github/workflows/spellcheck.yml new file mode 100644 index 000000000..5e67db872 --- /dev/null +++ b/.github/workflows/spellcheck.yml @@ -0,0 +1,54 @@ +name: Spell check + +# Reports spelling errors on lines added by a pull request, as a summary comment +# plus inline review comments on the flagged lines. Existing spelling errors and +# errors on unchanged lines are not included. + +on: + pull_request: + +permissions: + contents: read + pull-requests: write + +jobs: + spellcheck: + name: CSpell (advisory) + runs-on: ubuntu-latest + steps: + - name: Check out pull request head + uses: actions/checkout@v4 + with: + ref: ${{ github.event.pull_request.head.sha }} + fetch-depth: 0 + + # Node 24 is pre-cached on ubuntu-latest, avoiding the download + # incurred by node-version: latest. + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: 24 + + # Install only CSpell instead of the site's full dependency tree. + - name: Install CSpell + run: npm install --global cspell@10 + + - name: Find spelling errors introduced by this PR + env: + BASE_SHA: ${{ github.event.pull_request.base.sha }} + run: | + base=$(git merge-base "$BASE_SHA" HEAD) + # Exit 1 means findings; anything else is an operational error + node dev/check-spelling.mjs --base "$base" --format json \ + > "$RUNNER_TEMP/spelling.json" || [ "$?" -eq 1 ] + cat "$RUNNER_TEMP/spelling.json" + + - name: Report on the pull request + # Fork PRs get a read-only token; the findings are still in the job log + if: github.event.pull_request.head.repo.full_name == github.repository + env: + GH_TOKEN: ${{ github.token }} + PR_NUMBER: ${{ github.event.pull_request.number }} + HEAD_SHA: ${{ github.event.pull_request.head.sha }} + HEAD_REF: ${{ github.event.pull_request.head.ref }} + run: node dev/post-spelling-review.mjs --findings "$RUNNER_TEMP/spelling.json" diff --git a/.gitignore b/.gitignore index ff9bc8266..7ad682477 100644 --- a/.gitignore +++ b/.gitignore @@ -56,3 +56,5 @@ public/changelog.rss .amp/portals/ public/technical-changelog.rss + +logs/ diff --git a/README.md b/README.md index e2b1eea27..3323b01e0 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,8 @@ Now that the base requirements of the project have been satisfied, we can instal pnpm install ``` +Spell checking is not part of the project dependencies. To run it locally: `npx cspell@10 --no-progress --dot '**/*'` + Next, run the development server: ```sh diff --git a/cspell-allow-list.txt b/cspell-allow-list.txt new file mode 100644 index 000000000..e5d99ffcd --- /dev/null +++ b/cspell-allow-list.txt @@ -0,0 +1,598 @@ +# Words CSpell should accept +# Matching is case- and accent-insensitive + +# Sourcegraph Chrome extension ID +dgjhfomjieaadpoljlnidmbgkdffpack + +# Base64-encoded GraphQL ID "User:1" +VXNlcjox + +# Algolia application ID +0EBA2NRQU3 + +# Base64-encoded GraphQL ID "SearchJob:1" +U2VhcmNoSm9iOjY5 + +# base64 for `RegistryExtension:` in synced / generated file `docs/cli/references/extensions/delete.mdx` +# The twist is that the command it documents doesn't exist anymore +# TODO: Delete docs for commands which no longer exist +# TODO: Remove after merging +UmVnaXN0cnlFeHRlbnNpb246 + +# Fragments of truncated shell output in examples +actr +autol +eror # TODO: Remove after merging 1884 +functio # TODO: Remove after merging 1885 +ified +mtok +pousr +sourcegr +stabili + +# Words +acmeco +ADFS +advisements +airgapped +Aiven +algoliasearch +allkeys +amcheck +amname +ampcode +anotherproject +anotherrepo +apidocs +appendonly +appengine +appgw +ARGF +atoburl +atoi +attnum +attrelid +autocompletions +autoedit +autoedits +autoindexing +automations +autoscalers +autoupgrade +autovacuum +Awais +awscli +awsrepos +azuredevops +backfiller +baseai +batchchange +batchchanges +batcheshelper +batchignore +batchutils +behaviour +behaviours +beyang +bgwriter +binutils +bitbucketcloud +bitbucketserver +bitmapscan +Bitwarden +blkid +blobstore +blurple +browsable +buildconfig +Burkina +burndown +BYOK +BYOLLM +Cabo +cacerts +cadvisor +callsign +callsigns +callsites +camdentest +CAROOT +Certbot +certonly +changesettemplate +changesettemplatebody +changesettemplatebranch +changesettemplatecommit +changesettemplatecommitauthor +changesettemplatecommitmessage +changesettemplatefork +changesettemplatepublished +changesettemplatetitle +chatbots +cheatsheet +checkhealth +citext +Citus +clickjacking +clientauthconfig +cloneproxy +cloudasset +cloudkms +cloudnotifications +cloudtrace +clusterrole +clusterrolebinding +cmdline +codecompletions +codegraph +codehost +codehosts +codeinsights +codeintel +codellama +codemod +codemonitors +codesearch +collatable +colordiff +combinatorially +comby +commitgraph +committerdate +configmap +conntrack +Consolas +contentlayer +Côte +crashloop +crashloopbackoff +createdat +cstring +ctid +ctstate +CUDA +customcert +customising +d'Ivoire +daemonset +darkhold +datalake +datcollversion +datname +dbname +dbstore +dbug +decrypter +deepsearch +deepseek +demopasswordchangeme +DEVMINOR +DEVPATCH +devrel +dgrijalva +diffstat +dind +distros +dmsetup +dockerfiles +docsite +docstrings +doombot +downto +dport +drilldown +drilldowns +drwxr +dshm +dumpall +eastus +eksctl +ELEC +emailaddress +encrypter +Enry +entrycommand +envsubst +errgroup +errorf +esbenp +Eswatini +etcdctl +euxo +EVICTEDPOD +exfiltration +explorable +externalapi +extsvc +Falco +Faso +favorited +favoriting +favourite +federationmetadata +Filippo +finetuning +finishedat +fmtlib +forrester +frontmatter +FSTYPE +fullchain +fullpath +gifs +gitdir +gitea +githost +Gitolite +gitserver +gnachman +gobwas +gofmt +goimports +GOMAXPROCS +googlecloud +gopkg +goreleaser +goroutines +graphbook +graphd +graphqlbackend +gsuite +healthcheck +healthz +HELO +horsegraph +horsten +hostmatcher +hostpath +HSTS +httptest +huggingface +Iconify +IDPSSO +imagepullsecrets +importchangesets +importchangesetsexternalids +importchangesetsrepository +incidentio +inconshreveable +indclass +indcollation +indexability +indexrelid +indexroot +indexscan +indisready +indisvalid +indkey +indnkeyatts +indrelid +intarray +intelli +isopen +istari +itable +iterm +itoa +JCEF +jiapantw +journalctl +Jsonnet +jsonschemadoc +Kaspersky +kbar +keegancsmith +keymap +keymappings +keypair +killall +Kitts +kubeconfig +kubelet +kustomization +Langbase +languagemodels +Laravel +latencytest +latveria +lefthand +Leste +letsencrypt +libsecret +logfmt +logpush +logtost +logurl +lookarounds +losetup +lsif +Luhansk +maedahbatool +Marino +maxage +maxmemory +maxpages +megarepo +Memorystore +Menlo +migops +mixtral +mktemp +mmap +modelconfig +mountpoint +mpim +multiplicatively +multiqueue +multiversion +mycompany +myconfig +mydockerhub +myext +myextension +mygroup +mykey +myorg +myotherrepo +mypassword +myproject +myregistry +myrepo +mysqli +mystring +mytag +myteam +myvalue +nameid +nameopt +NETRC +nodeport +noeviction +nofail +noheadings +nonexistenturl +nonprivileged +noout +NOSYSTEM +nslookup +nspname +numpy +nvim +oauthconfig +objectname +oidvectorin +oldtbl +Ollama +omnibox +oneclick +onelogin +onrepositoriesmatchingquery +onrepository +OOBMIGRATION +opclass +opcmethod +openaicompatible +opencode +openctx +opengrok +openidconnect +optim +organisation +organisations +ORGID +orgpolicy +overcounted +oyaml +pagure +Parsely +parsewithclaims +patterntype +PCIW +PCRE +pekko +persistentvolumeclaims +pgbouncer +pgcrypto +PGDATABASE +PGDATASOURCE +PGHOST +PGPASSWORD +PGPORT +pgsql +PGSSLMODE +PGUSER +phpdoc +pipefail +PKCE +PKEY +plpgsql +PODNAME +polysans +pooler +PREEMPTIBLE +preg +premade +Príncipe +privkey +projectname +protoc +pubout +pullrequest +pvcs +pykafka +quicklinks +Qwen +rbacs +readwrite +reclone +recloning +reexecuting +refreshable +registryurl +reindexdb +reindexing +relid +relkind +relname +relnamespace +relpages +relpersistence +repogroup +repohasfile +reponame +requestclient +requirepass +rescope +resourcemanager +Rica +Rockskip +rootfs +rstrip +runllm +ruplacer +samltool +sams +sandboxing +sboms +SCIM +scip +SCIW +seccomp +secretmanager +secretname +Sectigo +Sendgrid +serde +serilog +setext +settingsjson +setx +sgdev +sggray +sgquery +sgtest +sgviolet +Shadcn +shellsession +shiki +showcerts +SIEM +sigalg +SLES +Snek +snekpm +somerandom +Sourcegraph +sourcegraphcloud +sourcegraphcom +spacebar +specialised +spectaql +SQLSTATE +srcgraph +ssbc +sshuttle +sslmode +stackexchange +standardly +starcoder +Starlark +startedat +statefulsets +stepscontainer +stepsenv +stepsfiles +stepsif +stepsmount +stepsoutputs +stepsoutputsnameformat +stepsoutputsnamevalue +stepsrun +storageaccounttype +STORAGECLASS +Strato +strconv +subchart +subcharts +subexpression +subnetworks +subpackages +subsubgroup +subteam +subteams +subwords +sunsetting +symf +syncer +syncers +synchronised +Syntect +Talkhouse +TARGETGROUP +testroute +testsourcegraph +testusers +Textualize +theirrepo +thorsten +thorstens +threadcreate +timedout +timemachine +tini +tjdevries +tolerations +Tomé +toolcall +topk +topsecretorg +topsecretproject +transactionally +transformchanges +transformchangesgroup +transformchangesgroupbranch +transformchangesgroupdirectory +transformchangesgrouprepository +trgm +triaging +trialling +Türkiye +typesafe +typescriptjavascript +unarchived +unarchiving +uncordon +underallocated +undercount +undercounted +underprovisioned +unibeautify +unindexed +unioned +unmigrated +untar +updatecheck +upperand +urlencode +urlsafe +userprincipalname +USERTRUST +Valsorda +vegeta +vercel +Verilog +VHDL +vscodesourcegraph +waitlist +Weaveworks +winsize +workspacesin +workspacesonlyfetchworkspace +workspacesrootatlocationof +XGET +xlarge +yourorgname +YOURUSERNAME +Zaporizhzhia +Zoekt +zoomable diff --git a/cspell-block-list.txt b/cspell-block-list.txt new file mode 100644 index 000000000..a21c45e61 --- /dev/null +++ b/cspell-block-list.txt @@ -0,0 +1,112 @@ +# Words CSpell should flag +# Matching is case- and accent-insensitive +accomodate +acepted +activites +alignemnt +authentitcation +authroized +autoiondex +compatability +comphrensive +composit +conection +configuredimageimage +consule +contiainer +contraint +crtl +custmoize +databse +dedupliacte +defininition +depdendencies +determinstic +documenta +eclispe +embeeddings +emtpy +endable +exector +executon +exernal +exisiting +explictly +fetchinig +fulfiill +fulfuill +gatway +gemin +gitab +gitgnore +gomft +guarauntees +hopefull +horizonal +identitfier +ingore +insufficent +intterupt +invididual +jeager +limitting +managmeent +matadata +migtrator +mulitenant +necesarily +occured +occurence +occuring +opean +optimisitic +optimizated +ordererd +owernship +paramter +peforce +percaution +permanant +permanentaly +permisisons +postgress +prerequisit +privileized +privisioning +promotheus +pugins +pumbling +puslishing +reapplyed +repliacas +rermoved +reveiver +santizes +screeen +scret +seach +searcg +searchs +searhces +seperate +serch +simiply +simpify +sitiched +sorucegraph +soucegraph +soureces +specifiy +stablility +stiched +sufficiantly +symbos +synchronizzation +tailling +targetting +teamates +telemetery +tetsted +unmarsha +unsued +workpsaces diff --git a/cspell.json b/cspell.json new file mode 100644 index 000000000..749ab8131 --- /dev/null +++ b/cspell.json @@ -0,0 +1,35 @@ +{ + "$schema": "https://raw.githubusercontent.com/streetsidesoftware/cspell/main/cspell.schema.json", + "version": "0.2", + "useGitignore": true, + "dictionaryDefinitions": [ + { + "name": "sourcegraph-docs", + "path": "./cspell-allow-list.txt", + "addWords": true + }, + { + "name": "sourcegraph-docs-block-list", + "path": "./cspell-block-list.txt", + "kind": "flag-words" + } + ], + "dictionaries": ["sourcegraph-docs", "sourcegraph-docs-block-list"], + "ignoreRegExpList": [ + "/\\\\[nrt]/g", + "/(? [--format text|json] + * + * The json format feeds dev/post-spelling-review.mjs. + * Exits 1 when spelling issues are found and 2 for operational errors. + */ + +import {execFileSync, spawnSync} from 'child_process'; +import path from 'path'; +import {fileURLToPath} from 'url'; + +const args = process.argv.slice(2); +const BASE = flagValue('--base'); +const FORMAT = flagValue('--format') ?? 'text'; + +function flagValue(name) { + const index = args.indexOf(name); + return index === -1 ? undefined : args[index + 1]; +} + +function addedLineRanges(base) { + const diff = execFileSync( + 'git', + ['diff', '--unified=0', '--no-color', '--find-renames', base, '--'], + {encoding: 'utf8', maxBuffer: 50 * 1024 * 1024} + ); + const ranges = new Map(); + let file; + + for (const line of diff.split('\n')) { + const fileMatch = line.match(/^\+\+\+ b\/(.+)$/); + if (fileMatch) { + file = fileMatch[1]; + if (!ranges.has(file)) { + ranges.set(file, []); + } + continue; + } + + const hunkMatch = line.match(/^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@/); + if (file && hunkMatch) { + const start = Number(hunkMatch[1]); + const count = hunkMatch[2] === undefined ? 1 : Number(hunkMatch[2]); + if (count > 0) { + ranges.get(file).push([start, start + count - 1]); + } + } + } + + return ranges; +} + +function runCSpell(files) { + if (files.length === 0) { + return []; + } + + const result = spawnSync( + 'cspell', + [ + '--no-progress', + '--show-suggestions', + '--reporter', + '@cspell/cspell-json-reporter', + '--file', + ...files + ], + {encoding: 'utf8', maxBuffer: 50 * 1024 * 1024} + ); + + if (result.error) { + throw result.error; + } + + const report = JSON.parse(result.stdout); + if (![0, 1].includes(result.status) || report.error.length > 0) { + throw new Error(result.stderr || JSON.stringify(report.error)); + } + + return report.issues.map(issue => ({ + file: path.relative(process.cwd(), fileURLToPath(issue.uri)), + line: issue.row, + 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() + })); +} + +function findingsOnAddedLines(ranges, issues) { + return issues.filter(issue => + (ranges.get(issue.file) ?? []).some( + ([start, end]) => issue.line >= start && issue.line <= end + ) + ); +} + +function formatText(findings) { + if (findings.length === 0) { + return 'No spelling errors found in added lines.\n'; + } + + const lines = [ + `Found ${findings.length} spelling error(s) in added lines:` + ]; + for (const finding of findings) { + lines.push( + `${finding.file}:${finding.line}:${finding.column} - Unknown word (${finding.word})` + ); + } + return lines.join('\n') + '\n'; +} + +async function main() { + if (!BASE) { + throw new Error('Missing required --base '); + } + if (!['text', 'json'].includes(FORMAT)) { + throw new Error(`Unknown --format "${FORMAT}"; use text or json`); + } + + const ranges = addedLineRanges(BASE); + const findings = findingsOnAddedLines( + ranges, + runCSpell([...ranges.keys()]) + ); + process.stdout.write( + FORMAT === 'json' + ? JSON.stringify(findings, null, '\t') + '\n' + : formatText(findings) + ); + process.exit(findings.length === 0 ? 0 : 1); +} + +main().catch(error => { + console.error(error); + process.exit(2); +}); diff --git a/dev/post-spelling-review.mjs b/dev/post-spelling-review.mjs new file mode 100644 index 000000000..467fbb12b --- /dev/null +++ b/dev/post-spelling-review.mjs @@ -0,0 +1,267 @@ +#!/usr/bin/env node + +/** + * Reports CSpell findings on a pull request: one summary comment in the + * discussion, plus an inline review comment on each flagged line. + * + * Usage: node dev/post-spelling-review.mjs --findings [--dry-run] + * + * Reads the JSON written by `dev/check-spelling.mjs --format json`. + * Requires GH_TOKEN, GITHUB_REPOSITORY, PR_NUMBER, HEAD_SHA and HEAD_REF. + */ + +import {readFileSync} from 'fs'; + +const args = process.argv.slice(2); +const FINDINGS_FILE = args[args.indexOf('--findings') + 1]; +const DRY_RUN = args.includes('--dry-run'); +const MAX_INLINE_COMMENTS = 25; + +const API_URL = process.env.GITHUB_API_URL ?? 'https://api.github.com'; +const REPOSITORY = process.env.GITHUB_REPOSITORY; +const PR_NUMBER = process.env.PR_NUMBER; +const HEAD_SHA = process.env.HEAD_SHA; +const HEAD_REF = process.env.HEAD_REF; + +// Link to the PR branch, not the commit, so GitHub's edit button works from it +const ALLOW_LIST_LINK = `[\`cspell-allow-list.txt\`](https://github.com/${REPOSITORY}/blob/${HEAD_REF}/cspell-allow-list.txt)`; + +const SUMMARY_MARKER = ''; +const INLINE_MARKER = '/)?.[1]; + return word && findingKey({file: comment.path, line: comment.line, word}); +} + +// CSpell suggests case-insensitively, so prefer a suggestion whose first +// letter matches the case of the flagged word. +function bestSuggestion({word, suggestions}) { + const isUpper = letter => letter === letter.toUpperCase(); + return ( + suggestions.find( + suggestion => isUpper(suggestion[0]) === isUpper(word[0]) + ) ?? suggestions[0] + ); +} + +// One GitHub suggestion block with an apply button. A four-backtick fence so +// lines containing ``` cannot break out of the block. +function suggestionBlock(finding) { + const suggestion = bestSuggestion(finding); + if (!suggestion) { + return []; + } + const {text, column, word} = finding; + const start = column - 1; + return [ + `Did you mean \`${suggestion}\`?`, + '', + '````suggestion', + text.slice(0, start) + suggestion + text.slice(start + word.length), + '````', + '' + ]; +} + +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'); +} + +function reviewBody(shown, total) { + const summary = `CSpell found ${total} spelling error(s) on lines added by this PR. Please correct them, or add them to ${ALLOW_LIST_LINK} if they are correct.`; + return shown < total + ? `${summary} The first ${shown} are commented inline; the summary comment lists them all.` + : summary; +} + +async function syncInlineComments(findings) { + const wanted = new Map( + findings.map(finding => [findingKey(finding), finding]) + ); + const comments = await githubList( + `/repos/${REPOSITORY}/pulls/${PR_NUMBER}/comments` + ); + + for (const comment of comments) { + const key = existingCommentKey(comment); + if (!key) { + continue; + } + if (wanted.has(key)) { + wanted.delete(key); + } else { + await githubWrite( + 'DELETE', + `/repos/${REPOSITORY}/pulls/comments/${comment.id}` + ); + } + } + + const fresh = [...wanted.values()]; + if (fresh.length === 0) { + return; + } + const shown = fresh.slice(0, MAX_INLINE_COMMENTS); + await githubWrite( + 'POST', + `/repos/${REPOSITORY}/pulls/${PR_NUMBER}/reviews`, + { + commit_id: HEAD_SHA, + event: 'COMMENT', + body: reviewBody(shown.length, fresh.length), + comments: shown.map(finding => ({ + path: finding.file, + line: finding.line, + side: 'RIGHT', + body: inlineBody(finding) + })) + } + ); +} + +async function main() { + for (const name of [ + 'GH_TOKEN', + 'GITHUB_REPOSITORY', + 'PR_NUMBER', + 'HEAD_SHA', + 'HEAD_REF' + ]) { + if (!process.env[name]) { + throw new Error(`Missing required environment variable ${name}`); + } + } + if (!FINDINGS_FILE) { + throw new Error('Missing required --findings '); + } + + const findings = JSON.parse(readFileSync(FINDINGS_FILE, 'utf8')); + console.log(`${findings.length} finding(s) to report`); + await upsertSummaryComment(findings); + await syncInlineComments(findings); +} + +main().catch(error => { + console.error(error); + process.exit(2); +}); diff --git a/src/data/redirects.ts b/src/data/redirects.ts index e480a4195..c1a548241 100644 --- a/src/data/redirects.ts +++ b/src/data/redirects.ts @@ -1670,6 +1670,7 @@ const redirectsData = [ '/cody/embeddings/usage-and-limits#configure-global-policy-match-limit' }, { + // cspell:disable-next-line source: '/cody/explanations/code_graph_context#limitting-the-number-of-embeddings-that-can-be-generated', destination: '/cody/embeddings/usage-and-limits#limit-the-number-of-embeddings-that-can-be-generated' @@ -3771,6 +3772,7 @@ const redirectsData = [ '/cody/embeddings/usage-and-limits#configure-global-policy-match-limit' }, { + // cspell:disable-next-line source: '/cody/explanations/code_graph_context#limitting-the-number-of-embeddings-that-can-be-generated', destination: '/cody/embeddings/usage-and-limits#limit-the-number-of-embeddings-that-can-be-generated'