Skip to main content

notignored

A terminal session: notignored src/ is typed and the suppressions appear one per line — each with its file and line in cyan, the tool in magenta, the silenced rules in yellow, the scope in blue and the stated reason dimmed — then notignored --diff --diff-base main reports only the two the change added

Find every lint and type-check suppression comment in a codebase — natively, and fast.

The default report: seven suppressions across seven tools — ruff, mypy, eslint, typescript, rust, shellcheck and llmlint — one per line as colorized path:line:column tool rules (scope) -- reason, closing with a summary counting them and the files they live in

A reviewer cares about the suppressions a change introduces, not the inventory it inherited, so --diff reports only those:

The same report under --diff: only the two suppressions the change added, a ruff E501 and a biome noExplicitAny, above a summary counting two ignores in two files

Narrowing to particular tools, the JSON envelope, and the pull-request comment

--tool, repeated, reports only the checkers you name:

The same scan narrowed with --tool ruff --tool mypy --tool shellcheck: three of the seven suppressions, the other four filtered out

--format json emits the full report envelope — every field of every record, documented below:

The JSON report envelope for one file: a version, an ignores array whose two records carry tool, scope, rules, reason, path, line, end_line, column, the raw directive text and the suppressed range, and an empty errors array

--format markdown renders the body the GitHub Action posts, with each suppression linked to its line and its silenced code one click away:

The pull-request comment body as markdown: a heading counting two suppressions, then one bullet per suppression naming its tool and rule, its reason in italics, a permalink to the line, and a collapsed details block holding the suppressed code

These are real captures of the CLI, rendered from its actual colorized output by just screenshots and gated by screencomp — so they change only when the output does.

Why

Suppression comments are where lint and type-check debt hides. A # noqa costs one line to add and scrolls past review unremarked — especially in agent-written code, where silencing a rule is often easier than fixing it.

notignored turns every suppression into a first-class, queryable record: which tool, which rules, the stated reason, and exactly where it lives. That makes a high-level review of a large change possible — you can see the bypasses and their justifications without reading every line.

It parses the directives itself and never invokes the tool whose rule is being silenced, so scanning a tree costs a read and a scan instead of ten linter startups. That is what makes it cheap enough to run on every pull request.

Install

# From PyPI or npm  both ship the prebuilt binary, so no Rust toolchain is
# needed and nothing is compiled at install time:
pip install notignored-cli
npm install -g notignored-cli

# Or without installing at all:
npx notignored-cli src/

# Cross-platform, from source (Linux, macOS, Windows):
cargo install --git https://github.com/nickderobertis/notignored --locked

# Or a prebuilt binary (Linux, macOS, and Windows under a POSIX shell):
curl -fsSL https://raw.githubusercontent.com/nickderobertis/notignored/main/scripts/install.sh | sh

All four install the same notignored command. The notignored-cli distributions carry the release binary for your platform — a wheel per platform on PyPI, a package per platform on npm, picked automatically — so they are the fastest path on a CI image with no Rust toolchain, and the only one that works where github.com is blocked but the package registries are not. Prebuilt targets are Linux (x64, arm64), macOS (x64, arm64), and Windows (x64); anywhere else, cargo install builds from source.

The installer honours NOTIGNORED_VERSION / NOTIGNORED_INSTALL_DIR (or the --version / --to flags), verifies the archive against the SHA-256 checksum published beside it, and refuses to install a binary it cannot verify. Every tagged release attaches per-platform archives built on native runners.

In CI there is nothing to install: the GitHub Action fetches the release binary itself and posts what the pull request added.

- uses: nickderobertis/notignored@v0

Try it

examples/ holds a handful of tiny files — Python, TypeScript, shell, Rust — each carrying the kind of suppression, and the kind of reason, real code collects. Point the binary at them:

$ notignored examples/
examples/api_client.py:3:20 mypy import-untyped (line) -- the vendored SDK ships no type stubs
examples/api_client.py:4:28 ruff F401 (line) -- re-exported so callers can configure retries
examples/deploy.sh:8:1 shellcheck SC2086 (next-line) -- the flags file is ours, and has to split into separate arguments
examples/deploy.sh:13:3 llmlint tool_output_is_signal (file) -- example input the README quickstart scans, not a script this project runs
examples/retry.rs:6:1 rust dead_code (next-line) -- the scheduler starts calling this once backoff lands
examples/widget.ts:6:3 eslint no-console (next-line) -- the mount path is traced in production
examples/widget.ts:9:3 typescript * (next-line) -- the vendored analytics global is declared without its options bag
notignored: 7 ignores in 4 files

Seven suppressions, seven tools, one pass over four files — no linter was run. Each line is path:line:column tool rules (scope) -- reason; --format json gives the same records as the envelope below, and --format markdown gives the comment the action posts. That block is checked against the real binary by tests/e2e/examples.rs, so it is output, not an illustration.

Usage

notignored [PATHS...] [--format human|json|markdown] [--color auto|always|never]
           [--tool NAME]... [--fail-if-found] [--diff [--diff-base REF]]
           [--github-repo OWNER/REPO] [--github-sha SHA] [--max-entries N]
  • PATHS — files and/or directories. Directories are walked recursively, honouring .gitignore. Defaults to ..
  • --formathuman (default), json, or markdown (a pull-request comment body; see the action below).
  • --color — when to colorize the human report. auto (the default) colors only an interactive terminal, always forces it, never disables it.
  • --tool — only report this tool; repeat to allow several. Omit for all.
  • --fail-if-found — exit 1 when any suppression is reported.
  • --diff — report only the suppressions the change added (see below).
  • --diff-base — the git revision or range --diff compares against.
  • --github-repo / --github-sha — the owner/repo and commit the markdown format builds its permalinks from.
  • --max-entries — how many suppressions the markdown format lists before it closes with a line counting the rest. Defaults to 20; must be at least 1.

The human report is colorized — the location, the tool, the rules, the scope, and the reason each get their own role, and a blanket * is red because it silences every rule the tool has. Coloring follows the NO_COLOR convention (and TERM=dumb) and the --color flag: auto colors only an interactive terminal, always forces it (through a pager, or to capture a screenshot), never disables it. The json and markdown formats are never colorized — they are contracts, not presentation, and are byte for byte the same whatever --color says.

Reviewing a pull request

A reviewer cares about the suppressions a change introduces, not the inventory it inherited. --diff reports only those: a directive is new when the diff added at least one of the lines it occupies.

$ notignored --diff --diff-base main --fail-if-found
src/app.py:42:20 ruff E501 (line) -- long wrapped URL
notignored: 1 ignore in 1 file
  • Bare --diff compares the work tree — staged and unstaged — against HEAD.
  • --diff-base REF takes any git revision or range. A plain ref is compared from the merge base, the way a pull request's "Files changed" is, so commits that landed on the base branch after this one forked are never reported as this branch's own. An explicit A..B range is passed to git as-is, two-dot semantics and all. These are llmlint's --diff / --diff-base semantics exactly.
  • PATHS still narrow the result: notignored --diff --diff-base main src/ reports the new suppressions under src/ only. An empty intersection is a clean exit 0.
  • Only the files the change touched are read, so a diff run stays fast on a large repository. Files the change deleted are skipped; a renamed file reports what the change added to it, not the lines that merely moved.
  • Git names a file in bytes and a report names it with a string, so a path that is not valid UTF-8 has no faithful spelling here. It becomes an errors entry (and exit 2) rather than a file quietly dropped from the review — the lossy spelling would name a file that does not exist.

--diff shells out to git — infrastructure, not one of the linters whose directives are parsed natively — so it needs git on PATH and a work tree.

On a pull request: the GitHub Action

The action posts one sticky comment naming every suppression the pull request added, with its stated reason and a link to the line. It edits that same comment on each push instead of adding another, and on a pull request that adds none it posts nothing at all.

# .github/workflows/notignored.yml
name: notignored

on:
  pull_request:

permissions:
  contents: read
  pull-requests: write

jobs:
  suppressions:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0 # the base branch has to be fetched to diff against it
      - uses: nickderobertis/notignored@v0

@v0 is a floating major tag: it follows every release in the 0.x line and moves only once that release's artifacts have published, so it never resolves to unreleased work. It will keep meaning 0.x after v1 exists. Pin exactly — nickderobertis/notignored@v0.1.10 — where a reproducible build matters more than picking up fixes; the tag for a specific version never moves.

Input Default Meaning
github-token ${{ github.token }} Token used to upsert the comment. Needs pull-requests: write.
diff-base the pull request's base branch Any git revision or range, as --diff-base takes.
paths the whole repository Whitespace-separated files and directories to scan.
max-entries 20 How many suppressions the comment lists before it closes with a line counting the rest. At least 1; anything else fails the run.
version latest A release tag such as v0.1.0, or local to build the action's own source with cargo.

It exposes count (how many suppressions the change added) and report-path (the JSON report), so a later step can fail the build, upload the report, or gate on a threshold:

      - uses: nickderobertis/notignored@v0
        id: notignored
      - if: steps.notignored.outputs.count != '0'
        run: echo "this change adds ${{ steps.notignored.outputs.count }} suppression(s)"

The steps are bash, so the action runs on the Linux and macOS runners; it needs jq and the gh CLI (both preinstalled there), plus cargo when version: local.

--format markdown renders exactly the body the action posts, so it can be previewed locally:

$ notignored --diff --diff-base main --format markdown \
    --github-repo nickderobertis/notignored --github-sha "$(git rev-parse HEAD)"

Both permalink flags are optional; without them each location renders as plain path:line text.

The body lists at most max-entries suppressions — 20 by default — and when a change adds more it closes with one line naming how many it left out and the total, so the count is never hidden. Every listed entry carries a collapsed <details> block holding the code that suppression silences: its line, next-line, or block span, or the top of the file for a whole-file directive, line-numbered and capped at ten lines with a note when there is more. Collapsed by default, so a long list stays one screen and any single entry is one click from its context. A file that has since become unreadable renders its entry without a snippet rather than failing — the block only ever shows real source.

Exit codes

Code Meaning
0 The scan completed.
1 --fail-if-found was given and at least one suppression was reported.
2 The scan could not complete — an unreadable path or file, or a bad argument.

Findings go to stdout; the summary and any errors go to stderr, so notignored --format json > report.json captures only the report. A downstream consumer that stops reading (| head, | grep -q) is not an error: the scan's own verdict still decides the exit code.

Output

The json format emits the full report envelope:

{
  "version": 1,
  "ignores": [
    {
      "tool": "ruff",
      "scope": "line",
      "rules": ["E501"],
      "reason": "long wrapped URL",
      "path": "src/app.py",
      "line": 12,
      "end_line": 12,
      "column": 20,
      "raw": "# noqa: E501  # long wrapped URL",
      "suppressed": { "start_line": 12, "end_line": 12 }
    }
  ],
  "errors": []
}
  • scopeline, next-line, file, or block.
  • rules — rule names/codes exactly as written. [] means a blanket suppression of every rule the tool would apply (rendered as * in the human format).
  • reason — the stated justification, or null. Taken from the tool's native reason syntax where one exists, otherwise from the trailing comment on the directive line; whitespace is collapsed to single spaces.
  • line / end_line / column — 1-based.
  • suppressed — the best-effort range the directive silences. end_line is null when it runs to end-of-file or is unterminated.
  • errors — files that could not be read. Never a panic.

version is the envelope version; it changes only when the shape does.

From TypeScript

notignored-sdk hands the same envelope to Node as typed records, by running the same binary — so a vitest suite, a GitHub Actions step, and your terminal all report the same thing:

npm install notignored-sdk notignored-cli
import { scan } from "notignored-sdk";

const added = await scan(["."], { diff: true, diffBase: "origin/main" });
if (added.ignores.some((directive) => directive.reason === null)) {
  throw new Error("this change adds a suppression with no stated reason");
}

Supported tools

Tool Directives
eslint // eslint-disable-line rule, // eslint-disable-next-line rule -- reason, /* eslint-disable rule -- reason *//* eslint-enable rule */
biome // biome-ignore lint/group/rule: reason, // biome-ignore-all lint/group/rule: reason, // biome-ignore-start lint/group/rule: reason// biome-ignore-end lint/group/rule: reason
ruff # noqa, # noqa: E501, F401, # ruff: noqa, # ruff: noqa: E501
typescript // @ts-ignore, // @ts-expect-error reason, /* @ts-ignore */, // @ts-nocheck
mypy # type: ignore, # type: ignore[arg-type, index], # mypy: ignore-errors, # mypy: disable-error-code="arg-type"
pyright # pyright: ignore, # pyright: ignore[reportArgumentType], # pyright: reportMissingImports=false
ty # ty: ignore, # ty: ignore[invalid-argument-type]
rust #[allow(dead_code)], #[allow(clippy::needless_collect, dead_code)], #[expect(dead_code, reason = "…")], #![allow(…)], #![expect(…, reason = "…")]
shellcheck # shellcheck disable=SC2086, # shellcheck disable=SC2086,SC2046, # shellcheck disable=SC2000-SC2100, # shellcheck disable=all, # shellcheck disable=SC2086 # reason
llmlint ignore[rule, …] reason, ignore-file[rule, …] reason, ignore-block[rule, …] reasonignore-end[rule, …] — each written after the llmlint keyword and a colon, in the host language's comment syntax

Scope follows each tool's own rules, not a house convention:

  • rust — an outer attribute is next-line and its suppressed range runs through the end of the item it annotates; an inner #![…] is file. A reason = "…" is the record's reason, even when the string wraps.
  • shellcheck — a directive above the first command is file; anywhere else it is next-line. A directive ShellCheck itself rejects (trailing prose with no #, or one placed after a command) is reported by neither tool.
  • typescript — the parity claim is pinned to one compiler: the typescript version in tests/js-toolchain/package.json7.0.2, the Go port — which is what tests/e2e/typescript_parity.rs drives. The 5.x compiler is a separate implementation of the same directives and is not guaranteed to read every form the same way, so the claim here is parity with the pinned compiler rather than with every tsc ever shipped.
  • llmlintignore trailing code is line; alone on its line it is next-line, because the code it silences is the one below (for a directive inside a multi-line comment, the first line after the comment ends). ignore-file is file, and ignore-blockignore-end is one block record spanning both directives. A block left unclosed keeps a null suppressed.end_line and adds an errors entry.

Each tool's own reason syntax is what gets captured: ESLint's -- description, Biome's mandatory : explanation, ruff's trailing # comment, and — for TypeScript, which defines no separator — whatever text trails the directive. A directive that lists no rules is a blanket suppression (rules: []).

Scope follows the tool rather than the syntax. // eslint-disable-line is line; // eslint-disable-next-line, // biome-ignore and // @ts-expect-error are next-line; # ruff: noqa, // biome-ignore-all and // @ts-nocheck are file; and the delimited pairs (/* eslint-disable *//* eslint-enable */, // biome-ignore-start// biome-ignore-end) are block, running to end-of-file with suppressed.end_line: null when they are never closed.

Adding one is four touch points: a module under src/tools/, one line in src/tools/mod.rs::registry(), one row above, and a directive in tests/fixtures/polyglot/. tests/tools_contract.rs fails the build if a row here and the registered parsers disagree, and tests/e2e/polyglot.rs fails it if a registered tool is missing from that fixture tree.

Where a directive reaches, and who honours it

Every # type: ignore and # pyright: ignore is line-scoped; the module-wide forms are mypy's two # mypy: config comments and pyright's rule override; for ty, where the comment sits is the scope; and a Rust attribute reaches to the end of the item it annotates:

Source Reported as
f(x) # type: ignore mypy, line
# mypy: ignore-errors on its own line mypy, file
# mypy: disable-error-code="arg-type" on its own line mypy, file
f(x) # pyright: ignore pyright, line
# pyright: reportMissingImports=false pyright, file
f(x) # ty: ignore ty, line
# ty: ignore above every statement ty, file
# ty: ignore on its own line in the body ty, next-line
#[allow(dead_code)] above an item rust, next-line, suppressed through the item's last line
#[expect(dead_code, reason = "…")] above an item rust, next-line, reason from the attribute
#![allow(dead_code)] at the top of the file rust, file

A Rust attribute's scope is next-line — that is where the item it annotates starts, and where a reviewer has to look — while its suppressed range covers the whole item, however many lines that item runs to. An inner #![…] attribute exempts the file it opens.

Pyright's <rule>=<value> override is reported only for the two values that switch a rule off, false and none; true, error, warning, and information turn a rule on or move its severity, so they are configuration rather than suppression. Pyright reads the rest of that line as its own item list, so the form can carry no reason — and a comment it refuses (a trailing # why, a value outside those six, or a directive that does not open the comment) silences nothing and is not reported.

Several tools honour a directive they did not invent: pyright and ty both act on mypy's # type: ignore, and ruff, pyright, and ty all act on one that does not open its comment (mypy does not). A directive is reported once, under the tool whose syntax it is# type: ignore is one mypy record, not three — so a count of records is a count of suppressions written, not of checkers affected.

One line can still carry directives for several tools, and each record covers its own directive only. Given

import legacy  # type: ignore[import-not-found]  # no stubs published  # noqa: F401  # imported for its side effects

the mypy record's raw stops at # no stubs published and its reason is "no stubs published"; the ruff record's raw starts at # noqa: F401 and its reason is "imported for its side effects". A record's raw and reason always end where the next tool's directive begins, so one tool's live suppression can never be filed as another's justification.

# pyright: basic and # pyright: strict switch pyright's type-checking mode rather than silencing a diagnostic, and are deliberately not reported.

How it works

Source is scanned once per file by a language-aware comment extractor (src/comments.rs) that understands # comments, // line comments, multi-line /* … */ blocks (nested, for Rust), Rust attributes, and the punctuation that delimits a Rust item — and that knows a string literal when it sees one, so MESSAGE = "# noqa: E501" is never reported. Tool parsers consume that extraction; they never re-scan raw lines.

Development

just bootstrap   # from a clean clone
just check       # the full gate: format, clippy, tests + coverage, docs
just --list      # everything else

This is an Nx monorepo of three projects — the notignored crate at the repo root, plus the python/notignored-sdk and npm/notignored-sdk SDKs. The repo-wide verbs above fan out across all of them; just nx run notignored-sdk-python:check runs one project's gate alone, and just nx show projects lists the graph. Pull-request CI narrows the gate to the projects the diff can reach.

just check runs the end-to-end suite, which drives the compiled binary as a subprocess and the real, pinned tools — ruff, mypy, pyright, ty, shellcheck, and llmlint (see the .<tool>-version files), eslint / biome / tsc (see tests/js-toolchain/package.json), plus the pinned toolchain's own rustc and clippy-driver — to prove that what notignored reports is what those tools actually suppress. just bootstrap installs them all under .dev/; it needs uv and Node.js 20+ on PATH.

License

MIT — see LICENSE.

Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distributions

No source distribution files available for this release.See tutorial on generating distribution archives.

Built Distributions

If you're not sure about the file name format, learn more about wheel file names.

notignored_cli-0.1.12-py3-none-win_amd64.whl (1.1 MB view details)

Uploaded Python 3Windows x86-64

notignored_cli-0.1.12-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl (1.1 MB view details)

Uploaded Python 3manylinux: glibc 2.17+ x86-64

notignored_cli-0.1.12-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl (997.4 kB view details)

Uploaded Python 3manylinux: glibc 2.17+ ARM64

notignored_cli-0.1.12-py3-none-macosx_11_0_arm64.whl (971.3 kB view details)

Uploaded Python 3macOS 11.0+ ARM64

notignored_cli-0.1.12-py3-none-macosx_10_12_x86_64.whl (1.0 MB view details)

Uploaded Python 3macOS 10.12+ x86-64

File details

Details for the file notignored_cli-0.1.12-py3-none-win_amd64.whl.

File metadata

File hashes

Hashes for notignored_cli-0.1.12-py3-none-win_amd64.whl
Algorithm Hash digest
SHA256 a8985501683db4218f6c0105013fe1ad58e62404fb5c89464318affd13aa395e
MD5 69e313d4c95fd46be8b8cfcc429e3f18
BLAKE2b-256 386f5aee53181046247ef2f5d78f3d19cded17c0b4ff9090d27e6e825cce8125

See more details on using hashes here.

File details

Details for the file notignored_cli-0.1.12-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl.

File metadata

File hashes

Hashes for notignored_cli-0.1.12-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl
Algorithm Hash digest
SHA256 808145f0f29966e3dace96fcfec4f71450ef43c6802e68b67f8237f90eb88deb
MD5 7f416253c0e38ca41c54449f778ac055
BLAKE2b-256 d07ee46c29bbee041b6571e0f208a5a72ba4db38824b370073fab96a57140377

See more details on using hashes here.

File details

Details for the file notignored_cli-0.1.12-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl.

File metadata

File hashes

Hashes for notignored_cli-0.1.12-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl
Algorithm Hash digest
SHA256 368a28e88b863a3361c82664e4a71849b02678de16ae7d97cdea014456a7be02
MD5 0c4bf4be2681df904d66749ca1b8d51c
BLAKE2b-256 eb3d39eb966e3fb7225bcafb7c33a01041976c3c2247173ea7d73be248092099

See more details on using hashes here.

File details

Details for the file notignored_cli-0.1.12-py3-none-macosx_11_0_arm64.whl.

File metadata

File hashes

Hashes for notignored_cli-0.1.12-py3-none-macosx_11_0_arm64.whl
Algorithm Hash digest
SHA256 287c282730006977914317325f7e979426223bc44941605ec610ee50da4a82ba
MD5 f96391926ef1f6ce08bb38e205dc390b
BLAKE2b-256 78719e080e88c954cefff6ba2de42f0676cb3b8442827050c293bb078a26050e

See more details on using hashes here.

File details

Details for the file notignored_cli-0.1.12-py3-none-macosx_10_12_x86_64.whl.

File metadata

File hashes

Hashes for notignored_cli-0.1.12-py3-none-macosx_10_12_x86_64.whl
Algorithm Hash digest
SHA256 329c3819c30292ebd50642fd562cd6aea233d8ffcba2caf33ddba0e99eaee1c0
MD5 3c843784e56e95e57859c100a11e958b
BLAKE2b-256 2d00d2e79bc1bf8ce2464da71109072fc146104f78c52ef13ef65bbdb662bb28

See more details on using hashes here.

Release history Release notifications | RSS feed

0.1.16

5 files

0.1.15

5 files

0.1.14

5 files

0.1.13

5 files

This release

0.1.12 This release

5 files

0.1.11

5 files

0.1.10

5 files

0.1.9

5 files

0.1.8

5 files

0.1.7

5 files

0.1.6

5 files

0.1.5

5 files

0.1.4

5 files

0.1.3

5 files

0.1.2

5 files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page