Skip to content

ci: gate on doc comments that bind to nothing - #849

Merged
ryanbr merged 1 commit into
mainfrom
ci/doc-comment-lint
Jul 27, 2026
Merged

ci: gate on doc comments that bind to nothing#849
ryanbr merged 1 commit into
mainfrom
ci/doc-comment-lint

Conversation

@ryanbr

@ryanbr ryanbr commented Jul 27, 2026

Copy link
Copy Markdown
Owner

Follow-up to #846.

Why gate rather than just fix

Kotlin and Swift attach a doc block to the declaration that follows it, and keep only the last of several stacked ones. Insert a block above an existing one — or a declaration into the gap between a comment and the thing it documents — and the text is silently orphaned.

Nothing looks wrong on review. The prose is still there, still above the right function, still correct. It just no longer reaches Dokka or IDE hover. That is exactly why it survives.

#846 fixed three sites in one day, one of which #842 had introduced that same morning, and the block that came adrift was "Byte-parity twin of Swift classifyCoverage" — a parity-contract statement, with parity being the first rule in CLAUDE.md. Not a cosmetic loss.

Correcting #846

That PR claimed the tree was clean afterwards. It wasn't. My sweep only matched single-line blocks. A scanner handling multi-line blocks finds 25 more pre-existing sites across 13 files — 8 in WhoopBleClient.kt alone. AppViewModel.kt:68 is a class doc detached by an inserted data class: the same shape as my #842 slip. Corrected on that PR too.

The baseline, and why it's counts

Those 25 are grandfathered per-file so the gate starts green and blocks only new ones.

Keyed by count, not line number. Line numbers churn on every edit above them, so a line-keyed baseline goes stale constantly and trains people to regenerate it without looking. A count moves only when someone adds or removes a site — precisely the event worth gating on. It ratchets down: fixing a site prints an IMPROVED line asking for the allowance to be lowered.

Verification

Three ways, because a gate that never fires is worthless:

1. clean main            -> exit 0   "no NEW detached doc comments (1386 files, 25 baselined)"
2. all three pre-#846 sites reconstructed  -> caught 3/3
3a. new site, unbaselined file           -> exit 1
3b. EXTRA site in an already-baselined file -> exit 1   (count enforced, not mere presence)

3b is the one that matters — a presence-only baseline would let WhoopBleClient.kt grow from 8 to 80 unnoticed.

Cost

Pure Python + git on ubuntu, seconds. No Xcode, no Gradle — so unlike the app-target and Android checks it actually runs, given app-build.yml and android.yml are disabled. Same shape and triggers as i18n-coverage.yml, including auditing pushes to main so a release landing without a PR is still covered.

New workflow rather than a step in i18n-coverage.yml: different concern, and that job is currently the only thing gating PRs — I'd rather not have a docs regression report itself as an i18n failure.

Not included

Fixing the 25. Each needs a judgement call about how to merge the two blocks, and doing 25 of those at volume is how the mistake gets made in the first place. The baseline makes them visible and burnable-down without blocking anyone.

Kotlin and Swift attach a doc block to the declaration that FOLLOWS it, and keep only
the LAST of several stacked ones. So inserting a block above an existing one — or
inserting a declaration into the gap between a comment and the thing it documents —
silently orphans the text. Nothing looks wrong on review: the prose is still there,
still above the right function, still correct. It just no longer reaches Dokka or IDE
hover.

#846 fixed three sites, one of which #842 had introduced the same morning, and the
block that came adrift was "Byte-parity twin of Swift classifyCoverage" — a
parity-contract statement, and parity is the first rule in CLAUDE.md. That is the case
for gating rather than just fixing.

Tools/doc_comment_lint.py catches both detaching patterns (stacked blocks, and a blank
line between a doc block and its declaration) across Kotlin and Swift, skipping file
headers where a blank line before `package`/`import` is conventional.

#846 also claimed the tree was clean afterwards. It was not: that sweep only matched
SINGLE-LINE blocks. A scanner that handles multi-line blocks finds 25 more pre-existing
sites across 13 files — 8 in WhoopBleClient.kt alone, and AppViewModel.kt:68 is a class
doc detached by an inserted data class, the same shape as the #842 slip. Corrected on
that PR.

Those 25 are grandfathered per-file in a baseline so the gate starts green and blocks
only NEW ones. Baselined by COUNT, not line number: line numbers churn on every edit
above them, which would keep the file stale and train people to regenerate it without
looking. A count moves only when someone adds or removes a site — the event worth
gating on. It ratchets DOWN; fixing a site prints an IMPROVED line asking for the
allowance to be lowered.

Verified three ways: passes on clean main; catches all three pre-#846 sites when they
are reconstructed; and fails both on a new site in an unbaselined file AND on an EXTRA
site in a file that is already baselined, so the count is genuinely enforced rather
than mere presence.

Pure Python, ubuntu, seconds — no Xcode or Gradle, so it works despite app-build.yml
and android.yml being disabled.
@ryanbr
ryanbr merged commit 16c69d3 into main Jul 27, 2026
2 checks passed
@ryanbr
ryanbr deleted the ci/doc-comment-lint branch July 27, 2026 00:46
ryanbr added a commit that referenced this pull request Jul 27, 2026
Tools/i18n_audit.py:34 was LANGS = ["de","es","fr","pt-PT"]. The Apple catalogs ship
EIGHT non-English localizations, so the gate covered four of them — and the four it did
not cover drifted exactly as you would expect:

  catalog                     de/es/fr/pt-PT   it     ru    zh-Hans  zh-Hant
  Strand (3255 keys)                100%      3086   3110    3136     3086
  StrandDesign (95)                 100%        14     41      20       14
  NOOPWatch (52)                    100%        49     50      49       49
  NOOPWatchComplications (30)       100%         5     30       5        5

The gated four are at 100% everywhere. The ungated four are missing up to 85% —
StrandDesign ships 14 of 95 Italian, the watch complications 5 of 30. That is the gate
working where it is pointed and nothing happening where it is not. Android is the same:
ANDROID_LOCALE_DIRS hardcoded the same four, values-zh existed on disk, was never
compared, and sat 43 keys behind. That is how #834 passed CI while adding a key to four
of five locale files (#844).

Both lists are now DISCOVERED — Apple languages from the union of each catalog own
localizations, Android dirs from values-* on disk — so a locale is covered the day it
appears rather than the day someone remembers a constant.

LANGS keeps ZERO tolerance and is untouched: those four are perfect precisely because
they are hard-gated. The newly covered locales carry 1019 real pre-existing gaps and are
gated against a ratcheting allowance instead; switching them on hard would red-check
every open PR on lines nobody touched, the exact #514 failure this workflow own comments
warn about. Counts rather than key lists, same reasoning as doc_comment_lint_baseline
(#849) — a key list goes stale on every edit and trains people to regenerate it unread.
Ratchets DOWN, printing IMPROVED when a count drops and STALE for an allowance whose
target no longer exists.

catalog_summary() had the same blind spot and is fixed too: it reported four of eight
languages, so the human-readable coverage view read 0 missing across the board. That is
very likely WHY this went unnoticed — anyone checking coverage was shown a clean sheet.
It now doubles as the burn-down view for the 1019.

Verified: green on main; fires when the values-zh gap grows 43->44; fires when one
StrandDesign Italian string is removed; de still hard-fails on a single missing key, so
zero tolerance is intact; a fabricated allowance target is reported STALE; and the
locale section states its own verdict even when an earlier check failed (it keyed on the
global failed flag, so a broken German string silenced it entirely).

Translates nothing. It stops the hole growing and makes the debt countable. Whether
it/ru/zh-* should ship at 14/95 or be withheld until filled is a product call, not a
linter one.
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