---
name: cspell-triage
description: "Use when cspell reports unknown words or forbidden words in a repository, when adding entries to a cspell dictionary, when a cspell config needs setting up, consolidating, or auditing, or before trusting a cspell result run inside a git worktree or one that reports `Files checked: 0`."
metadata:
  category: code-quality
---

# cspell Triage

## Overview

An unknown word is a question about where it came from — not a word to add.

Most hits disappear before any word is written down, because the dictionaries the project's own ecosystem ships were never enabled.
Measured on a real repository: 371 unknown words fell to 233 with one `ignorePaths` entry for committed build output, then to 159 by naming six bundled dictionaries.
57% were never dictionary candidates.

## The gate — most requests are step 3 alone

Run the repo's own gate command first (`npx -y cspell .`, or whatever `grep -rn cspell .trunk/trunk.yaml package.json` says actually runs). What it reports decides how much of this skill applies:

| The run shows                                                                                  | Scope                                                        |
| ---------------------------------------------------------------------------------------------- | ------------------------------------------------------------ |
| A handful of unknown words, and a root config exists                                           | **Step 3 only.** Dispose of the words, re-run, canary, stop. |
| Unknown words dominated by ecosystem vocabulary (`pytest` flagging in Markdown, `xcassets`, …) | Step 2, then step 3.                                         |
| No root config, several configs, or a gate command carrying scope flags the config should own  | Step 1, then onward.                                         |
| An inherited dictionary suspected of dead weight, or an explicit audit request                 | Step 4.                                                      |

A setup step you did not enter is not skipped work — it is work that was never needed.
In the basic case the whole task is four commands: run, dispose, re-run, canary.
Do not modify a working config's scan surface on the way past it.

When setup IS needed, work in this order.
Skipping to step 3 there is what produces 300-entry dictionaries full of stemmer fragments.

## Step 1 — one config, at the repo root

Only root-level config names are auto-discovered.
`.github/cspell.json` is **never** picked up on its own; trunk's linter definition lists root names only.
A config in a boilerplate or template directory is usually dead weight — find every config, then find what actually invokes cspell:

```bash
grep -rn cspell .trunk/trunk.yaml .github/workflows/ package.json Makefile 2>/dev/null
```

If a runner deliberately points at a non-root path, keep the root config authoritative and have the other one `import:` it.
Otherwise consolidate to the root and delete the orphan.

Set `useGitignore: true` while you are there — without it, ignored trees are still scanned.
Pair it with `gitignoreRoot: "."`: in a linked worktree inside another repository, `useGitignore` alone can hide every file; see "Reading `Files checked: 0` as a clean run" under Common mistakes.
The quotation marks are JSON syntax.
In a YAML config, write `gitignoreRoot: .`, because yamllint with `quoted-strings: {required: only-when-needed}` reports the quoted form (auto-chatter `cspell.config.yaml`, 2026-11-01).

**Put the scan surface in the config, never in the gate command — and only widen it when the repo has something to widen it for.**
By default cspell skips every path beginning with `.`. Whether that hides anything is one command:

```bash
git ls-files | grep '^\.'    # tracked dot-paths — what enableGlobDot would add
```

**Empty output → leave `enableGlobDot` alone and add nothing.** There is nothing to check behind the dot, so enabling it buys zero coverage and forces a `.git` ignore to exist purely to patch the setting's side effect.
Non-empty (`.github/workflows/`, `.trunk/`, dotfiles carrying prose) → those files go unchecked and a green result is scoped rather than real. Then, and only then, set `enableGlobDot` in the config (the CLI `--dot` is only its per-invocation equivalent) — and turning it on pulls in the git database, so it is always paired with a `.git` ignore:

```yaml
useGitignore: true
enableGlobDot: true # only because tracked dot-paths exist — see the ls-files check
ignorePaths:
  - .git # required exactly when enableGlobDot is on; the git database is not prose
```

The gate is then a bare `cspell .` with no arguments, and the config alone describes what gets checked.
A config that only behaves correctly under a particular set of CLI flags is an incomplete config.

## Step 2 — enable the dictionaries the stack already ships

59 dictionaries ship with cspell.
Detect from the manifest, do not hardcode:

| Present in repo                       | Add to `dictionaries:`                |
| ------------------------------------- | ------------------------------------- |
| `pubspec.yaml`                        | `dart`, `flutter`                     |
| `package.json`                        | `node`, `npm`, `typescript`           |
| `pyproject.toml` / `requirements.txt` | `python`                              |
| `Cargo.toml`                          | `rust`                                |
| `*.xcodeproj` / `Podfile`             | `swift`                               |
| `build.gradle*`                       | `java`, `kotlin`                      |
| `go.mod`                              | `golang`                              |
| `Dockerfile` / `*.tf` / k8s manifests | `docker`, `terraform`, `k8s`          |
| any repo                              | `softwareTerms`, `filetypes`, `fonts` |

They must go under the **top-level `dictionaries:` key**.
A language dictionary left to its own `languageId` activation only covers its own file types, so `pytest` still flags inside Markdown.

**A local dictionary must not take a bundled dictionary's name.**
A `.cspell/flutter.txt` registered as `flutter` masks cspell's own `flutter` dictionary: the bundled one appears enabled and the local file appears to do the work, and neither is true.
Name local lists something that cannot collide (`project_terms`, `shared_software_terms`) and check a candidate name against the bundled set with `cspell dictionaries` before registering it (wellness_visualizer_demo, 2026-08-19).

Verify a specific word rather than guessing:

```bash
cspell trace <word>     # shows every dictionary that has it, and where that dictionary lives
```

For a whole lint run rather than one word, `cspell-dict-report` (this plugin's `bin/`, on PATH) traces the list in one process and ranks the unenabled dictionaries by what each one _newly_ covers:

```bash
cspell lint . --words-only --no-progress --no-summary | cspell-dict-report
```

Run it from the repo root so cspell discovers the real config.
The ranking is evidence, not a decision — a dictionary that clears words is still yours to reject if it does not match the stack.

## Step 3 — dispose of what remains

Each surviving word gets exactly one disposition.
Prefer the earliest one that fits.

| #   | Disposition                    | Use when                                                                                                                                                                                                                                 |
| --- | ------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 1   | **Fix the source**             | A genuine typo. Grep for siblings — copy-pasted comments repeat the same misspelling.                                                                                                                                                    |
| 2   | **Register the word**          | Real vocabulary, including identifiers whose meaning you can state. Repo-specific → repo dictionary; recurring across projects → shared personal dictionary.                                                                             |
| 3   | **Inline directive**           | The term appears in one file only. `<!-- cspell:words foo bar -->` keeps shared config untouched.                                                                                                                                        |
| 4   | **Scoped override or pattern** | The term is legitimate only in one kind of file, or the noise has a regular shape. An `overrides:` entry keyed to a `filename` glob carries its own `ignoreWords` / `dictionaries`; `ignoreRegExpList` / `patterns` handle shaped noise. |
| 5   | **Ignore the path**            | **Last resort.** Generated or vendored trees only: build output, lock files, minified bundles.                                                                                                                                           |

A new dictionary file must be staged in the same commit as the config that references it — a config pointing at an untracked path gives a fresh checkout a broken dictionary reference, and cspell reports that as a `Dictionary Error` rather than as a missing file.

**Do not relocate a working repo-wide `ignoreWords` into `overrides`.**
Scoping it removes the entry everywhere else, which makes the word flag in places it did not before — including inside the config file that names it — and each new flag invites another override.
Add an `overrides` entry only for a term that must stay flagged outside its one legitimate file type; leave everything else at the top level.
An entry that is simply unused gets deleted, not scoped.

Do not add `.git` to `ignorePaths` while `enableGlobDot` is off — cspell already skips dot-paths then, and the entry is dead weight; the `.git` ignore exists only as `enableGlobDot`'s mandatory pair (step 1). Never ignore the dictionary's own directory: there is no reason to stop checking your own word lists.

**Never blanket-ignore a file that carries direct human input.**
A path ignore is permanent and silent — it also stops catching real typos in that file forever.

**An identifier you can name is vocabulary, not noise.**
An Apple Team ID, a project ref, a product name belong in the dictionary as semantic units.
Prefer the full identifier over the fragment cspell reports — it is self-documenting for the next reader.

**Write the entry exactly as it appears in the source.**
Matching is case-insensitive by default — `WCONDITIONAL`, `Wconditional`, and `wconditional` as dictionary entries all cover all three spellings — so normalising the case buys nothing and costs the reader the meaning.
`Wconditional` reads as the compiler flag it is; `wconditional` does not. `393JTTV68D` is recognisable as a Team ID; `393jttv68d` is not.

Lowercase only when the term genuinely occurs in several casings in the repo (`Unmute`, `UNMUTE`, `unmute`).
There, picking one form would misrepresent the source, and the lowercase entry says "any casing" honestly.

**Keep the file a flat list, sorted case-insensitively, with no section headings.**
The editor's "add to dictionary" action re-sorts the whole file, so category groupings and their comments are destroyed the first time anyone uses it — and a layout only an agent maintains will not survive collaboration.
If you want the file to stay hand-curated, set `addWords: false` so the editor writes elsewhere instead.

### Worked example — why `*.xcodeproj` should stay checked

A real `project.pbxproj` looks unusable at first: 39 unknown words.
It is not.

| Step                                                                    | Unknown words |
| ----------------------------------------------------------------------- | ------------- |
| No dictionaries enabled                                                 | 39            |
| After step 2 (`swift`, `flutter`, `dart`, `softwareTerms`, `filetypes`) | 22            |

The 22 split into 18 Xcode build-setting terms identical in every iOS project (`xcassets`, `appex`, `SRCROOT`, `INFOPLIST`, `iphoneos`, `wholemodule`, …) → shared dictionary; and 4 project-specific (product name, team ID, plugin names) → repo dictionary.
The 24-character hex object IDs that fill the file are never flagged at all.

Blanket-ignoring the bundle would have hidden all of it, including any future typo in a product name or bundle identifier.

## Step 4 — prune a dictionary you inherited

Dictionaries get copied between projects and then never shrink.
Measured across five repositories, **37–74% of entries were dead** — matching nothing in the repo — while dozens of words those repos actually needed were missing from the same file.
The worst case had 86 entries of which 73 were byte-identical to an unrelated project's dictionary, 64 were dead, and exactly 3 needed words were absent.

A dictionary that overlaps heavily with an unrelated project's was copied, not earned; `comm -12` between the two files shows it in one command.

To find the live set, feed the dictionary to `cspell-dict-report --exclude <its name>`.
That reports as if the file were not configured, without editing anything: the covered section is what something else already has — delete those entries — and the in-no-dictionary section is what the file is actually carrying its weight for.

```bash
cspell-dict-report --exclude custom-dictionary < .cspell/custom-dictionary.txt
```

That reads the dictionary against itself, which finds dead-by-redundancy entries but not dead-by-nothing-uses-them ones.
For those, **edit the config in place on a scratch copy** — see the `-c` trap below:

1. Enable the ecosystem dictionaries from step 2.
2. Remove the local dictionary from `dictionaries:`.
3. Run cspell. The unknown words that remain are the true requirement.
4. Keep the intersection of that set with the current dictionary; delete every other entry; add what was missing.

Removing a dictionary should make the issue count **rise**.
If it does not move at all, the config you edited is not the one being used.

## Forbidden words

Imported style dictionaries (e.g. Very Good Ventures') mark words forbidden.
Some are misclassified for a given stack — `meta-data` is flagged prose but is valid `AndroidManifest.xml` syntax.

**`ignoreWords` clears a forbidden word.
`words` does not.** Adding it to `words:` is the reflex and it leaves the run failing.

```yaml
ignoreWords:
  - meta-data # valid AndroidManifest.xml element, flagged by the imported style dictionary
```

Scope it to where the syntax is legitimate rather than repo-wide:

```yaml
overrides:
  - filename: "**/AndroidManifest.xml"
    ignoreWords:
      - meta-data
```

The same split applies to inline directives, which is easy to get wrong:

| Directive                              | Clears unknown | Clears forbidden |
| -------------------------------------- | -------------- | ---------------- |
| `cspell:words`                         | yes            | **no**           |
| `cspell:ignore` / `cspell:ignoreWords` | yes            | yes              |

## Common mistakes

- **Widening a working config's scan surface as a side quest.**
  A word-triage request on a repo with no tracked dot-paths does not need `enableGlobDot`, and enabling it there forces a `.git` ignore into existence purely to patch the setting's own side effect (observed twice by Dongmin Yu, 2026-08-12). The gate at the top routes this case to step 3 alone.
- **`-c` does not replace the repo's own config — it merges with it.**
  Running `cspell -c probe.json .` inside a repo that has `cspell.json` still loads that `cspell.json`, dictionaries and all.
  A/B tests built on `-c` therefore show no difference and read as "the dictionary contributes nothing."
  To test a config change, edit the real config in place on a scratch copy of the repo.
- **`ignorePaths` globs resolve relative to the config file's directory**, not the working directory — the schema says they are relative to that config's `globRoot`, which defaults to where the config lives.
  A config passed with `--config` from elsewhere silently matches nothing and the hit count does not move — which reads exactly like "the ignore did not help."
- **Listing an override's words inline makes them unknown words in the config file itself.**
  The override scopes them to its glob, so everywhere else — including the config that names them — they now flag, and each new flag invites another override.
  Once an override needs more than a couple of words, put them in their own dictionary file and reference it with `dictionaries:`, adding that file to the override's `filename` globs so it covers itself.
- **Declaring the gate green from a scoped run.**
  Run the same command the gate runs, then break it once before trusting the pass.
  Inject the canary typo into a file you are **already** editing.
  Preserve its pre-existing working-tree diff before the change, then remove only the injected typo with the same editor used for the change.

  ```bash
  canary_before=$(mktemp)
  canary_after=$(mktemp)
  git ls-files --error-unmatch -- <file> >/dev/null
  git diff -- <file> > "$canary_before"
  # Run the repository cspell gate and require it to pass before injection.
  # Add one unique temporary typo, then run the same gate.
  # Require the gate to fail and report the exact canary token.
  # Remove only that typo.
  git diff -- <file> > "$canary_after"
  cmp -s "$canary_before" "$canary_after"
  ```

  The canary file must already be tracked.
  Stop if `git ls-files --error-unmatch` fails.
  The unmodified cspell gate must pass before canary injection.
  After injection, the same gate must fail and its output must name the exact canary token.
  If `cmp` fails, restore the pre-existing diff manually before continuing.
  Never create or delete a file in a source directory to test a spell checker — in a Flutter repo, `lib/main.dart` is the conventional entry point, so both creating and removing it carry meaning far beyond this task, and `rm` in a tracked tree is not a scratch operation.

- **Declaring the task done from the cspell gate alone.**
  The cspell config and the dictionary files are files of the repository too, so other linters check them.
  After you change them, run the repository's full gate without a linter filter, for example `trunk check --all --no-fix --show-existing` in a Trunk repository.
  In auto-chatter on 2026-11-01, a delegated run of this skill finished with `trunk check --all --filter=cspell` green.
  The unfiltered run then failed on `cspell.config.yaml` with `yamllint/quoted-strings`, because the config had `gitignoreRoot: "."` (step 1).

- **Reading `Files checked: 0` as a clean run.**
  With `useGitignore: true`, a linked worktree (`git worktree add`) that sits inside another repository's working tree can check nothing: `CSpell: Files checked: 0, Issues found: 0`, which reads as a pass.
  The enclosing repository is what matters, not the worktree alone.
  Measured with cspell 9 on one throwaway repository: a linked worktree outside any other repository checked 1 file, the same kind of worktree inside a home directory that is itself a git root ignoring `*` checked 0, a plain clone in that same home directory checked 1, and the home-directory worktree with `--no-gitignore` checked 1 again.
  The same zero appeared in local worktree runs in three different repositories; in one of them CI, on a plain checkout, then failed on a word the worktree run had passed.
  The fix belongs in the config rather than the gate command, because a wrapper (a package script, a Make target, Trunk) may not forward a cspell flag: add `gitignoreRoot: "."` beside `useGitignore: true`, which stops cspell from reading `.gitignore` files above the repository root while it still honours the repository's own.
  On the probe's home-directory worktree, that one config line took the unchanged `cspell .` gate from 0 checked files to 2, with a directory the worktree's own `.gitignore` excludes still left out; run from a subdirectory instead of the root, the same gate checked 0 again, so run it from the root, as the gate normally is.
  These runs called cspell directly; a run through Trunk's sandbox was not measured.
  For a one-off direct cspell call, the CLI equivalent is `--gitignore-root .`: on the same kind of worktree, holding an untracked file, an unstaged deletion, a non-ASCII name and a name starting with `-`, `npx -y cspell --gitignore-root . .` checked 3 files and raised no error.
  Rebuilding the file list instead (`git ls-files` piped into `--no-gitignore`) broke on those same cases one at a time: a dash-led name read as an option, a quoted non-ASCII name, a deleted path reported as an error.
  Read the `Files checked` count before quoting the issue count.
  Where the gate suppresses that summary — Trunk runs cspell with `--no-summary` (see `references/mechanisms.md`, Trunk integration) — prove the gate with the canary procedure in "Declaring the gate green from a scoped run" above instead: a gate that checked nothing cannot fail on the canary.
- **Adding tokenization fragments.**
  `abli`, `alism`, `aliti`, `singl`, `failur` are stemmer output from a committed search index.
  They mean a generated tree is being scanned — that is disposition 5, not 200 dictionary entries.
- **Trusting a truncated word list.**
  `cspell --words-only --unique` piped to `head` under-reports; quote `wc -l` from the full run.

## Reference

`references/mechanisms.md` — verified behaviour of remote dictionaries, shareable dictionary packaging (`cspell-ext.json`), trunk's cspell sandbox, and editor-vs-CLI config divergence.
