Why was this comment kept?
A run that reports fewer comments than you expected has not lost them: it
decided to keep them, and it can say which rule decided. --explain lists every
comment a human check or scan met, the kept ones included, and puts the rule
and the setting behind that rule on the line under each one.
--explain annotates a report of comments, so it belongs to the two commands
that write one. Asking for it with --format json, or any other machine format,
or with a command that writes no report of comments, is a usage error rather
than a flag that quietly does nothing.
Ask the binary
This project has a generated directory, a JavaScript file with a lint
directive, and a house rule that a NOTE: comment is deliberate:
version = 1
[policy]
mode = "conservative"
keep_regex = ['^//\s*NOTE\b']
[[overrides]]
paths = ["gen/**"]
policy = "all"
keep_regex = ["(?i)generated"]
.ocomment.toml
gen/api.rs
src/app.js
src/lib.rs
--explain names the rule and the setting behind every comment it meets —
0 comments in this project:
$ ocomment check --explain
NO 1 comment in 1 file · 2 of 3 files scanned · policy conservative
DECIDE do what it promises, or delete it 1 comment
src/app.js:2
- console.log(1); // TODO: drop
removed: policy `conservative` removes ordinary comments ([policy] in .ocomment.toml)
or keep them [policy.allow]
tags = ["TODO"]
ALLOWED 2 comments this run did not report
src/app.js:1 // eslint-disable-next-line no-console
kept: tool or language directive `eslint`; use --remove-kind directive or --policy all to remove it
src/lib.rs:1 // NOTE: the retry budget is what the server documents.
kept: matched keep_regex #0 `^//\s*NOTE\b` ([policy] in .ocomment.toml)
──────────────────────────────────────────────────────────────────────
ocomment fix removes all 1, including anything above you meant to keep
Found 1 removable comment in 1 file (2 files scanned). Run `ocomment fix` to remove it. 1 file skipped (generated file: 1; use -v to list).
Read the second line of each pair as the answer. A keep_regex match kept the
first comment even though [[overrides]] puts the whole gen/** tree under the
all policy; a marker the scanner recognises kept the lint directive with no
configuration at all; and the setting in brackets is named where it was written,
so [policy] in .ocomment.toml and [[overrides]] #0 point at the two places
that decided this run. A comment no setting decided is left with the flag that
would overrule the built-in rule instead.
What each policy removes
spec/directives.toml is the shared table both implementations are
checked against, and this is that table. --policy and [policy] mode
choose a column, and the settings in the next sections move a single
comment out of the column its kind lands in.
| Comment kind | none | conservative | standard | all |
|---|---|---|---|---|
line | kept | removed | removed | removed |
block | kept | removed | removed | removed |
doc-line | kept | removed | removed | removed |
doc-block | kept | removed | removed | removed |
license | kept | kept | removed | removed |
directive | kept | kept | kept | removed |
load-bearing | kept | kept | kept | kept unless --force-protected |
html-comment | kept | kept | kept | removed |
shebang | kept | kept | kept | kept unless --force-protected |
encoding | kept | kept | kept | kept unless --force-protected |
optimizer-hint | kept | kept | kept | kept unless --force-protected |
version-comment | kept | kept | kept | kept unless --force-protected |
Markers that survive a removal
Some comments are not commentary at all: a build tag, a lint control, or an optimiser hint changes what a compiler, a linter, or a database does with the file. Each row below is scanned by the binary that built this page, so the table cannot claim a protection that is not there.
The Kept because column says which of two protections a marker has,
and the difference is what --policy all does to it. A marker kept as
a tool or language directive is addressed to something that reports
on the code – a linter, a formatter, a coverage tool – so losing it
makes that tool noisier and leaves the program alone, and all is
free to take it. A marker kept as required by the language or its
build is read by the language itself, by its compiler or by its
package manager, and losing it changes what compiles or what the code
does: //go:build linux decides whether the file is compiled at all,
and // swift-tools-version: decides whether a Package.swift is a
manifest. No policy is offered that choice, and --force-protected
is the only way to give one up.
| Marker | Language | Written as | Kind | Kept because |
|---|---|---|---|---|
go: | go | //go:build linux | load-bearing | required by the language or its build |
+build | go | // +build linux | load-bearing | required by the language or its build |
triple-slash-reference | typescript | /// <reference path="types.d.ts" /> | load-bearing | required by the language or its build |
syntax= | shell | # syntax=docker/dockerfile:1 | load-bearing | required by the language or its build |
frozen_string_literal: | ruby | # frozen_string_literal: true | load-bearing | required by the language or its build |
warn_indent: | ruby | # warn_indent: true | load-bearing | required by the language or its build |
shareable_constant_value: | ruby | # shareable_constant_value: literal | load-bearing | required by the language or its build |
@dart | dart | // @dart = 2.12 | load-bearing | required by the language or its build |
swift-tools-version: | swift | // swift-tools-version:5.9 | load-bearing | required by the language or its build |
//> using | scala | //> using scala "3.3.0" | load-bearing | required by the language or its build |
optimizer-hint | oracle | /*+ index(t) */ | optimizer-hint | required by the language or its build |
version-comment | mysql | /*!40101 SET NAMES utf8 */ | version-comment | required by the language or its build |
webpack | javascript | /* webpackChunkName: "x" */ | load-bearing | required by the language or its build |
vite-ignore | javascript | /* @vite-ignore */ | load-bearing | required by the language or its build |
#__PURE__ | javascript | /*#__PURE__*/ | load-bearing | required by the language or its build |
@__PURE__ | javascript | /*@__PURE__*/ | load-bearing | required by the language or its build |
#__NO_SIDE_EFFECTS__ | javascript | /*#__NO_SIDE_EFFECTS__*/ | load-bearing | required by the language or its build |
shebang | shell | #!/bin/sh | shebang | required source preamble |
encoding | python | # -*- coding: utf-8 -*- | encoding | required source preamble |
sourceMappingURL | javascript | //# sourceMappingURL=bundle.js.map | directive | tool or language directive |
sourceURL | javascript | //# sourceURL=bundle.js | directive | tool or language directive |
lint-and-formatter | javascript | // eslint-disable-next-line no-eval | directive | tool or language directive |
type-checker | python | # type: ignore | directive | tool or language directive |
hadolint | shell | # hadolint ignore=DL3018 | directive | tool or language directive |
:schema | toml | #:schema https://example.test/pyproject.json | directive | tool or language directive |
taplo: | toml | # taplo: array_auto_expand = false | directive | tool or language directive |
---@diagnostic | lua | ---@diagnostic disable-next-line: undefined-global | directive | tool or language directive |
luacheck: | lua | -- luacheck: ignore 212 | directive | tool or language directive |
selene: | lua | -- selene: allow(unused_variable) | directive | tool or language directive |
stylua: | lua | -- stylua: ignore | directive | tool or language directive |
luacov: | lua | -- luacov: disable | directive | tool or language directive |
yaml-language-server: | yaml | # yaml-language-server: $schema=https://example.test/schema.json | directive | tool or language directive |
yamllint | yaml | # yamllint disable-line rule:line-length | directive | tool or language directive |
renovate: | yaml | # renovate: datasource=docker depName=alpine | directive | tool or language directive |
checkov:skip | yaml | # checkov:skip=CKV_AWS_20:public by design | directive | tool or language directive |
trivy:ignore | yaml | # trivy:ignore:AVD-AWS-0089 | directive | tool or language directive |
nosec | yaml | # nosec | directive | tool or language directive |
kics-scan | yaml | # kics-scan ignore-line | directive | tool or language directive |
@schema | yaml | # @schema type: string | directive | tool or language directive |
phpcs: | php | // phpcs:ignore Squiz.Commenting.FunctionComment | directive | tool or language directive |
@phpstan-ignore | php | // @phpstan-ignore-next-line | directive | tool or language directive |
@psalm-suppress | php | /** @psalm-suppress InvalidReturnType */ | directive | tool or language directive |
@codeCoverageIgnore | php | // @codeCoverageIgnoreStart | directive | tool or language directive |
rubocop: | ruby | # rubocop:disable Style/Documentation | directive | tool or language directive |
standard: | ruby | # standard:disable Style/StringLiterals | directive | tool or language directive |
typed: | ruby | # typed: strict | directive | tool or language directive |
zig fmt: | zig | // zig fmt: off | directive | tool or language directive |
styler: | r | # styler: off | directive | tool or language directive |
nocov | r | # nocov start | directive | tool or language directive |
dart format | dart | // dart format off | directive | tool or language directive |
ignore: | dart | // ignore: unused_local_variable | directive | tool or language directive |
ignore_for_file: | dart | // ignore_for_file: unused_import | directive | tool or language directive |
swiftlint: | swift | // swiftlint:disable force_cast | directive | tool or language directive |
swiftformat: | swift | // swiftformat:disable redundantSelf | directive | tool or language directive |
swift-format-ignore | swift | // swift-format-ignore | directive | tool or language directive |
<auto-generated | csharp | // <auto-generated/> | directive | tool or language directive |
ReSharper | csharp | // ReSharper disable once UnusedMember.Local | directive | tool or language directive |
csharpier-ignore | csharp | // csharpier-ignore | directive | tool or language directive |
formatter: | java | // @formatter:off | directive | tool or language directive |
nosonar | java | // NOSONAR | directive | tool or language directive |
pylint: | python | # pylint: disable=invalid-name | directive | tool or language directive |
pragma: | python | # pragma: no cover | directive | tool or language directive |
$non-nls | java | //$NON-NLS-1$ | directive | tool or language directive |
checkstyle: | java | // CHECKSTYLE:OFF | directive | tool or language directive |
no critic | perl | ## no critic | directive | tool or language directive |
use critic | perl | ## use critic | directive | tool or language directive |
cppcheck-suppress | c | // cppcheck-suppress nullPointer | directive | tool or language directive |
lint:ignore | go | //lint:ignore SA1000 the pattern is checked | directive | tool or language directive |
format: | scala | // format: off | directive | tool or language directive |
--remove-kind directive or --policy all removes a directive anyway.
A shebang and an encoding preamble need --force-protected on top of
--policy all, because removing one changes how the file is executed
or decoded rather than how it reads.
The one keep no setting reaches
Every rule above is about what a comment says. One is about where it sits, and it is the only keep no flag overrules:
k: |
a
# ends the block
# yamllint disable
z: 1
A YAML block scalar decides where its body ends from the lines below
it, so # ends the block is not commentary: it is what terminates the
body, and the directive under it is indented deep enough to be content
of that body. Remove the terminating line – and a removal there takes
the whole line, which is the least it can take – and the directive
rejoins the scalar, so k changes from a to two lines. No removal
preserves the value, so the comment stays, and --explain writes the
reason under it:
k.yaml:3:1: kept line comment: # ends the block
kept: it separates a `yaml` block scalar from the kept comment below it; the comment under it has to go first
--policy all is not a way out: it removes the directive as well, and
with nothing left standing under the body both comments go. What holds
the first one in place is whatever comment survives under it, so that
is the line to take first. A surviving comment shallower than the
body’s own content ends the scalar on its own and keeps nothing above
it – see Languages for the depth this is measured at.
Keeping more
version = 1
[policy]
keep_kind = ["doc-line", "doc-block"]
keep_regex = ['^(//|\(\*|/\*|#)\s*(NOTE|SAFETY|INVARIANT|PERF)\b']
[[overrides]]
paths = ["vendor/**"]
policy = "conservative"
keep_kind names whole kinds from the first column of the table above,
so keep_kind = ["doc-line", "doc-block"] takes documentation out of
reach of every policy. keep_regex matches the comment token itself,
delimiters included, which is what lets ^// anchor a rule to the start
of the comment; this repository uses exactly that to require a tag on
every explanatory comment it keeps. Both are lists, and --explain
reports the index it matched, so keep_regex #0 is the first entry.
That the pattern is matched against the whole token is the detail
worth reading twice, because a pattern written against the text
inside the comment silently protects nothing: ^\s*rustfmt::
anchors in front of a // that is always there and can never
match. A setting that protects nothing is the one failure that
looks like success, so a run that walks a directory names every
keep_kind, remove_kind, keep_regex and remove_regex that
met no comment:
$ ocomment check
keep_regex #0 `^\s*rustfmt::` matched none of the 2 comments this run scanned; it is set in [policy] in .ocomment.toml
A pattern is matched against the whole comment token, so `^` is the comment's own first byte — the `//`, `#` or `/*` — and not the text after it.
The report goes to standard error beside the summary, so a
--format json consumer keeps a clean pipe, and -q drops it with
every other note. A run over named files stays quiet: a walk is the
caller saying everything under here, so a pattern that met
nothing in it is a pattern doing no work, while a run over one file
is a question about that file and a pattern with nothing to say
about it has not thereby failed.
The same walk names an [[overrides]] block whose globs matched no
file. A pattern that protects nothing is one failure that looks
like success; a path glob that matches nothing is the other, and it
is the one that fails in the direction of removing more. An
override is how a project exempts files from a rule it keeps
everywhere else, so a glob a character off the name of a file
sitting right there leaves that rule in force over exactly the
files somebody decided it should not cover:
$ ocomment check
[[overrides]] #0 (`.gitignor`) matched none of the 3 files this run reached, so everything it sets was left unapplied
Keeping less
--remove-kind is the mirror of keep_kind and removes a kind the
policy would have kept. --policy all removes every kind at once.
--force-protected is needed on top of it for a shebang or an encoding
preamble. --force-invalid edits the part of a file that scanned
when the rest of it did not, and is the only one of these that is
about a broken file rather than about a policy. It stops at the
failure: past that point the scanner is guessing where tokens end,
and the bytes it calls a comment may be code. The verdicts on those
comments still stand in the report – they are removable, and they
are still in the file, which is what a file that does not lex earns.
When the answer is still surprising
ocomment config explain prints the resolved configuration and where
each value came from, naming every kind and pattern it resolved with
the index the reports above count from, ocomment doctor reports the
environment around it, and ocomment scan --format json gives the
span, the line, the column and the text of every comment for a tool
to read.
--explain and --trace answer different questions
--explain is about one comment: the rule that decided it and the
setting behind that rule, printed under the finding it belongs to. It
is part of the report, so it goes to standard output and only the two
commands that write a report of comments accept it.
--trace is about the run: which layer of configuration applied, what
evidence chose each file’s language, which files were never scanned and
why, what was decided for every comment, and which edits were planned
from those decisions. It is a diagnostic rather than a product, so it
goes to standard error and every command accepts it.
That separation is what lets the two be combined with anything else:
--trace json beside --format json leaves the document on standard
output byte-for-byte identical to the one the same run writes without
it. Standard error also carries the run summary, so a reader that needs
every line to parse should add --quiet:
$ ocomment check --quiet --trace json 2>trace.jsonl >/dev/null
$ head -2 trace.jsonl
{"event":"config-resolved","root":"/repo","sources":["built-in defaults"]}
{"event":"file-detected","path":"src/main.rs","language":"rust","dialect":"standard","how":"extension","bytes":18}
The stream is described by spec/trace.schema.json. What it does not
record is the scanner’s recursion into an embedded language — a
<script> body read as JavaScript, a Markdown fence read as the
language its info string names. Those comments are reported at their
byte span in the outer file, as they are everywhere else.