Skip to content

ci(lint): require a godoc on pkg's exported symbols - #6989

Draft
otavio wants to merge 6 commits into
masterfrom
comment-policy/allowlist
Draft

ci(lint): require a godoc on pkg's exported symbols#6989
otavio wants to merge 6 commits into
masterfrom
comment-policy/allowlist

Conversation

@otavio

@otavio otavio commented Aug 31, 2026

Copy link
Copy Markdown
Member

Policy PR: shellhub-io/claude#54. Sibling: #6990, the wider
version of the same rule. Pick one.

What is true today

revive has been enabled in .golangci.yaml since the config was written, but with no rules:
block it reports nothing. Measured, not assumed:

golangci-lint run ./api/...                          # pkg/api → 0 issues
golangci-lint run -c <same + rules: [exported]> ...  # pkg/api → 50 issues

staticcheck's equivalents (ST1000, ST1020-22) are disabled a few lines below. So nothing in this
repo has ever asked for a doc comment.

What this does

Turns revive's exported rule on. pkg/ is where a doc comment pays first — these packages are the
surface server/, agent/ and cloud/ consume, by a caller who never opens the body.

The per-module .golangci.yaml files are symlinks to the root one, so the rule cannot be scoped
in config. qa.yml scopes it instead, with -D revive on the modules whose exported symbols are
still undocumented; a module rejoins by dropping its flag. disableStutteringCheck keeps the rule
on the comment — a package-stuttering name is a rename, a different change with different reviewers.

Counts, measured with --max-issues-per-linter=0 (the default caps at 50, so an unflagged run reads
as exactly "50" for every module):

module sites enforced here
pkg 229 yes
gateway 0 yes
server 605 held back
agent 71 held back

What this does not do

The 229. They are worth writing by hand, one package at a time: a doc comment restating its symbol
name clears the rule while telling a caller nothing, which is exactly what the policy exists to
prevent. CI is red on pkg until they are written — that cost is the point of the PR, not an
oversight.

What it looks like on real code

The last commit applies the rule to three packages — pkg/envs, pkg/wsconnadapter,
pkg/api/jwttoken — so it can be judged on a diff. 22 godocs written, and the whys that were
buried in function bodies move up into the contract they belong to: why Read hides io.EOF, why
a failed ping write tears the adapter down, why Read holds a mutex. The floating pair above
ErrUnexpectedMessageType becomes the package doc, where gorilla/websocket#441 keeps its place
as case 3. jwttoken's TODO list leaves the source. go test passes and golangci-lint is clean on
all three with the rule on.

Scaled to everything (non-test sources, mocks and the generated SDK excluded), the rule would
remove ~3,238 comment lines — shellhub Go 1,537, cloud Go 420, ui/ 1,281 — and add 503
godocs
(shellhub/pkg 229, cloud/pkg 274). Compare the sibling: B removes ~5,241 and adds
1,557.

Backfill done — pkg/ is green

golangci-lint run ./... in pkg/ now reports 0 issues with the rule on. All 229 exported
symbols carry a godoc, written to be worth reading rather than to clear the linter: what a caller
cannot see from the signature (HasMaxDevicesReached compares against -1 when there is no ceiling,
so ask HasMaxDevices first; a Filter with an unrecognized Type narrows nothing instead of
failing; KindInvalid is what makes a forgotten scope a rejection rather than an unbounded query).

The other direction is the same work read backwards. 31 comments sitting inside function bodies
moved up into the contract above them — why AddressFromHost tolerates a trailing dot, why the API
client sends Content-Length: 0, why ValidateFilters treats virtual bool fields differently — and
two became names instead (limitrejectAtOrAbove, lockoutSTRlockoutDeadline). Four stale
NOTE/TODO pairs about structs that moved to the cloud repo are gone; the fact is now in the type's
doc.

Tests pass apart from the two asynq cases that need a Docker daemon inside the test container.

revive has been enabled here since the config was written, but with no rules
block it reports nothing: `golangci-lint run` over pkg/api is clean, and the
same package with `rules: [exported]` reports 50. staticcheck's equivalents
(ST1000, ST1020-22) are disabled a few lines below. So nothing in this repo
has ever asked for a doc comment.

Turn revive's `exported` rule on. pkg/ is where a doc comment pays first —
these packages are the surface server/, agent/ and cloud/ consume, by a
caller who never opens the body.

The per-module .golangci.yaml files are symlinks to this one, so the rule
cannot be scoped in the config; qa.yml scopes it instead, with -D revive on
the modules whose exported symbols are still undocumented. A module rejoins
by dropping its flag. Counts, measured with --max-issues-per-linter=0:
server 605, agent 71, pkg 229, gateway 0.

pkg's 229 are the backfill this PR does not do: they are worth writing by
hand, one package at a time, because a doc comment restating its symbol name
clears the rule while telling a caller nothing.
@otavio
otavio force-pushed the comment-policy/allowlist branch from 2097e17 to 68247d9 Compare August 31, 2026 21:51
otavio added 5 commits August 31, 2026 20:29
229 doc comments, so that revive's exported rule passes on the module a caller
consumes without opening the body. golangci-lint reports 0 issues over ./...
with the rule on.

The rule also removes 31 in-body comments, and where one carried something a
reader needs it moved rather than went: why Read hides io.EOF, why a failed
ping tears the adapter down, why the lockout deadline is compared and not the
attempt count. Two became names instead — limit is now rejectAtOrAbove, and
lockoutSTR is lockoutDeadline — which is the outcome the rule is after.

pkg/envs, pkg/wsconnadapter and pkg/api/jwttoken were done first as a sample,
and are folded in here.
2,120 comment lines across 234 files, so the rule can be judged on the
codebase rather than on one module. Every module builds, golangci-lint is
clean, and the tests pass apart from the cases that need a Docker daemon
inside the test container.

Read this diff as the scale of the change, not as the finished state. The
rule says a why that has nowhere left to sit goes in the commit message,
and pkg/ (in the previous commits) is what that looks like done properly:
31 in-body comments moved up into the contract above them, two became
names. Here the same 2,089 elsewhere were deleted where they stood. That
is the work the rule creates, and it is the number worth arguing about —
one commit cannot carry 2,000 explanations, and neither can one reviewer.

What is left standing is the answer to "how much does this actually
remove": 2,120 of 8,161 comment lines, a quarter. The rest are doc
comments on declarations and their members, which both variants keep, plus
suppressions, licence headers and external references.
A draft skipped the whole validate job, so the branch that changes 200 UI
files was the one branch CI never looked at: lint, unit tests and build all
ran locally and nowhere else. A draft is where a check is worth the most —
it is the state a change sits in while it is still wrong.

The other workflows keep their gate. This one is dropped alone because the
comment-policy branches are drafts on purpose and their UI half is the part
with no other verification.
The four Go modules had revive's exported rule sitting dormant, so the policy
had something to ask a linter for. The UI had nothing: no jsdoc plugin was
installed and eslint carried no doc rule at all, which would have left the
removals enforced by the Stop hook alone — and the hook only ever sees the
lines a session touched.

eslint-plugin-jsdoc supplies the floor. jsdoc/require-jsdoc with publicOnly is
scoped to packages/design-system, the surface all three apps consume without
opening the body, and is the counterpart to enabling revive on shellhub/pkg
and cloud/pkg rather than on server/ or internal/. require-description is on
with it: require-jsdoc alone is satisfied by an empty block, and its own
--fix writes exactly that.

54 doc comments written by hand, none of them generated. The design-system is
where a doc comment earns its place — Button and IconButton rendered as
anything but a button cannot be disabled by the DOM, so loading falls back to
aria-disabled and the caller has to guard the click; Callout fixes role and
aria-live per variant, so an error interrupts a screen reader and a success
waits; Checkbox keeps the native input in the tree, so focus and form
participation are the browser's. None of that is legible from a signature.

1,173 comment lines removed across 188 files. The generated SDK under
apps/console/src/client is excluded: it is gitignored and regenerated from the
OpenAPI spec on every build, and carries 6,609 lines of its own.

Design-system lint now runs in CI. It was skipped by matrix.app !=
'design-system' in validate-ui.yml, so a rule declared there would have been
enforced nowhere.

As with the Go passes, this deletes in-body whys rather than relocating them,
except in the design-system where the contract above the declaration was
written properly. That is the scale of the change, not its finished state.

Three classifier bugs surfaced only by running the rule over real TypeScript,
each fixed in claude/ with a bats case before this pass: a bare asterisk read
as a JSDoc continuation (it is a JSX required marker), a triple-slash
reference read as a comment (it is how vite/client types are pulled in), and
// @vitest-environment read as a note (it picks the test environment, and
losing it broke the design-system token test with a URL-scheme error naming
nothing).
The 21 empty catch blocks each carried a sentence saying why an error was
swallowed. Under the allowlist that sentence is allowed to stay — the body of
an empty block is one of the four kinds of comment the rule keeps — but the
comment was never the best form for it. None of these needed a comment. They
needed a name.

utils/failure.ts holds four, each documented with when it is the right one:
ignoreFailure and nullOnFailure for .catch(), attempt for a synchronous call
that may not be there at all, and succeeded for a call whose error is already
recorded somewhere the screen reads.

Most sites did not even need those. The failure had an answer the code was not
stating: parseMessage returns null, because a frame that is not JSON is
ordinary terminal output; loadConfig falls back in the expression, so the
default is visible in the data flow; markWelcomeSeen returns whether it stuck,
mirroring hasSeenWelcome; listRecordings reads each sidecar through a helper
returning RecordingMeta | null, so a corrupt one is skipped by an if; and four
react-query call sites used mutateAsync inside a try that discarded the
rejection, where mutate() reports through the mutation's own state.

ForgotPassword keeps its reason in the code, as a name:

    const silenceToPreventAccountEnumeration = ignoreFailure;
    await recoverPassword({ ... }).catch(silenceToPreventAccountEnumeration);

Two behaviours are better than before rather than merely equivalent: Chatwoot
records the identity only when setUser actually succeeded, and the settings
toggles clear their busy flag through onSettled instead of a finally that ran
whether or not the mutation was still relevant.

This is the same change as on the total-ban branch, where it removes 21 lint
suppressions. Here it removes 21 comments the rule would have allowed, which
is the more interesting result: the allowance was carrying code that wanted
rewriting.
@otavio
otavio force-pushed the comment-policy/allowlist branch from 02f4b83 to f125767 Compare August 31, 2026 23:35
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant