Skip to content

feat(mi): stop charging documentation rows in MI - #1609

Merged
dekobon merged 9 commits into
mainfrom
fix/1597-loc-docstring
Oct 17, 2026
Merged

dekobon merged 9 commits into
mainfrom
fix/1597-loc-docstring

Conversation

@dekobon

@dekobon dekobon commented Oct 17, 2026 •

Copy link
Copy Markdown
Owner

Closes #1597.

The maintainability index charged documentation as code. A function with a doc comment scored lower than the same function without one. This PR measures documentation rows as a new loc.dloc metric and drops them from MI's size term and from both sides of its comment ratio. The previous formula is still available behind mi_charges_docs.

What changes

  • loc.dloc (Documentation Lines Of Code), with dloc_average, dloc_min and dloc_max. It is a subset of cloc, kept true by a single writer. Each language follows the tool that reads its documentation:
    • Marker languages: Rust (///, //!), Javadoc (incl. JEP 467 ///), KDoc, JSDoc, tsc (which takes /***), PHPDoc, C# XML docs, Doxygen for C/C++/ObjC, Groovydoc and Perl POD.
    • Python: PEP 257 docstrings, plus attribute docstrings and Sphinx #: comments, including on type aliases.
    • Positional languages: Go lead comments, YARD/RDoc runs in Ruby, and LDoc blocks in Lua.
    • In-body doc comments: they count where the tool publishes them (Doxygen, JSDoc, POD) and stay plain comments elsewhere.
    • Elixir: @doc stays code (fix(metrics/loc): multi-line strings and heredocs read as blank in Bash, Elixir, Tcl, iRules #1260).
  • MI: code_lines = sloc − dloc_without_code and comment_lines = cloc − dloc. A row that documentation shares with code (def f(): """doc""") stays in the size term.
  • mi_charges_docs: restores the previous formula. It is available as a MetricsOptions builder, a --mi-charges-docs flag, a bca.toml key, a web option and a Python keyword. --write-baseline stamps the mode into the baseline, and bca check warns when the run's mode and the baseline's disagree on mi.* entries.
  • Output: dloc appears in every output format, the CSV columns (appended at the end of the row), list-metrics and bca check --threshold loc.dloc. The new fields are SemVer-additive, with #[serde(default)].
  • CLI fix: bca metrics --metrics loc, the flag's own help example, used to be rejected. A family name now selects the whole family. bca diff --metric loc stays rejected, because a diff filters buckets.

Snapshots

  • The docstring → dloc rename moved all 1,611 corpus snapshots.
  • Doc-comment classification moved 172 of them, changing only dloc and mi.*.
  • The submodule is bumped to dekobon/big-code-analysis-output@22e5a762, which is on its main.
  • The later rounds moved no committed snapshot.

Validation

  • make pre-commit passes at the tip (BCA_GATE: pass).
  • make bench-scaling: all 58 probes stay within their complexity bound. Seven probes are new:
    • Watched failing with their defect reinstated:
      • loc/go-comment-run
      • loc/rust-nested-doc-mods
      • loc/go-nested-doc-fields
      • loc/ruby-nested-doc-constants
      • loc/lua-tagless-blocks
    • Also new: loc/rust-inner-doc-run and loc/python-additional-docstrings.
  • The new rules have perturbation-verified tests.
  • Over the 14,438 corpus files, --mi-charges-docs reproduces main's MI on every space.

Upgrading with an existing baseline

A baseline written by a released bca has no mi_charges_docs key. It reads as true, because every earlier release charged documentation. The first bca check after upgrading therefore warns, but only when an mi.* offender meets an mi.* entry. The warning says the baseline "records no mode, so it predates the stamp", and names both remedies:

  • refresh with --write-baseline;
  • pass --mi-charges-docs to keep the old formula.

I verified this end to end with a baseline written by the main binary.

Known gaps and follow-ups

Merging

The history is nine signed commits, one concern each, every one passing
make pre-commit on its own, ready for a fast-forward merge:

  1. refactor(loc): hand Loc::compute the source bytes.
  2. feat(loc): report Python docstrings as loc.dloc.
  3. feat(mi): leave documentation rows out of MI.
  4. feat(loc): count doc comments by their marker. This commit moves
    the corpus snapshots.
  5. feat(loc): read Go, Ruby and Lua docs by position.
  6. feat(mi): add mi_charges_docs to charge docs.
  7. fix(cli): accept a family name in --metrics.
  8. docs(lessons): add lesson 98 and extend lesson 81.
  9. docs: add per-commit baseline and rewrite rules.

@codecov

codecov Bot commented Oct 17, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 98.74327% with 42 lines in your changes missing coverage. Please review.
✅ Project coverage is 98.22%. Comparing base (f3aedfc) to head (e1e3256).
⚠️ Report is 9 commits behind head on main.

Files with missing lines Patch % Lines
...-code-analysis-ast/src/lang_helpers/doc_comment.rs 97.45% 3 Missing and 7 partials ⚠️
big-code-analysis-ast/src/lang_helpers/python.rs 95.59% 4 Missing and 6 partials ⚠️
src/metrics/loc/dloc.rs 99.41% 8 Missing and 1 partial ⚠️
src/metrics/mi.rs 96.00% 9 Missing ⚠️
big-code-analysis-ast/src/checker/ruby.rs 97.82% 0 Missing and 2 partials ⚠️
big-code-analysis-ast/src/checker/lua.rs 99.12% 1 Missing ⚠️
big-code-analysis-ast/src/node/ancestors.rs 99.25% 1 Missing ⚠️
Additional details and impacted files

Impacted file tree graph

@@            Coverage Diff             @@
##             main    #1609      +/-   ##
==========================================
+ Coverage   98.20%   98.22%   +0.02%     
==========================================
  Files         365      367       +2     
  Lines      101068   104922    +3854     
  Branches   100637   104487    +3850     
==========================================
+ Hits        99250   103064    +3814     
- Misses       1173     1197      +24     
- Partials      645      661      +16     
Flag Coverage Δ
python 100.00% <100.00%> (ø)
rust 98.22% <98.74%> (+0.02%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

Files with missing lines Coverage Δ
big-code-analysis-ast/src/checker.rs 96.93% <100.00%> (+0.10%) ⬆️
big-code-analysis-ast/src/checker/c.rs 100.00% <ø> (ø)
big-code-analysis-ast/src/checker/cpp.rs 100.00% <ø> (ø)
big-code-analysis-ast/src/checker/csharp.rs 100.00% <100.00%> (ø)
big-code-analysis-ast/src/checker/go.rs 87.50% <100.00%> (+13.58%) ⬆️
big-code-analysis-ast/src/checker/groovy.rs 100.00% <100.00%> (ø)
big-code-analysis-ast/src/checker/java.rs 92.85% <100.00%> (+3.96%) ⬆️
big-code-analysis-ast/src/checker/javascript.rs 80.00% <ø> (ø)
big-code-analysis-ast/src/checker/kotlin.rs 94.00% <100.00%> (+5.53%) ⬆️
big-code-analysis-ast/src/checker/mozcpp.rs 100.00% <ø> (ø)
... and 69 more

... and 5 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@dekobon
dekobon force-pushed the fix/1597-loc-docstring branch from c9e449e to 5126ec4 Compare October 17, 2026 19:38
dekobon added a commit that referenced this pull request Oct 17, 2026
Codecov listed 68 uncovered patch lines on PR #1609. The production
ones a valid input can reach now have tests, each confirmed by a
perturbation that fails it:

- fold_down agrees across the memoised cache, a known chain with no
  cache, and the chainless climb, on the state and on the parent each
  step's `outer` chain names. ancestors.rs goes from 23 missed lines
  to 2: an assertion-message argument and an older test's untaken
  match arm.
- A typed `self` in __init__, a `#:` on the last line, a Ruby `foo.()`
  call, a Go directive whose value starts with a digit, and the
  frozen-string Emacs modeline.
- mi_mode_warning with no run mode, against both baseline modes.

What stays uncovered is test-message formatting and `?` on lookups
the grammar guarantees, which codecov.yml documents as uncoverable.

Refs #1597
Every other per-node metric receives the source; Loc::compute did
not, so no LOC rule could read a comment's or a string's bytes. Pass
`code` through the trait, the walker's call site and the no-op macro
arm. The 23 impls ignore it for now, and no metric moves.

Refs #1597
A Python docstring has counted as comment rows since #415, so cloc
could not be read as "comments" the way a C or Rust reader expects,
and nothing said how many rows were documentation. Add loc.dloc with
dloc_average, dloc_min and dloc_max: the rows a language treats as
documentation, a subset of cloc kept true by one writer.

Python follows PEP 257 and Sphinx: the first statement of a module,
class or function, successive strings after a docstring, an attribute
docstring after an assignment to a name or a `type` alias at module or
class level or to `self.<name>` at the top of `__init__`, and `#:`
comments on or above such an assignment. Only a str literal counts;
any other bare string stays a comment row. LOC and Halstead share one
bare-string predicate, which fixes three shapes: an implicitly
concatenated docstring, a bare tuple of strings, and an escaped string
whose row was also billed as code.

The field reaches every output format, the text tree, the CSV row (as
trailing columns), list-metrics, the loc.dloc threshold, SARIF and the
Python LocDict. Every other language reports 0.

Refs #1597
MI charged documentation as code: a function with a docstring scored
lower than the same function without one. Size every mi.* variant on
sloc less the documentation rows that carry no code, and form mi.sei's
comment percentage as cloc − dloc over that size. A docstring sharing
its row with code (`def f(): """doc"""`) keeps the row. Working
comments and blank rows are still charged; #1607 tracks the rest.

The book's claim that MI is sized on PLOC was wrong; it has always
used SLOC, and now says so.

Refs #1597
Count a comment as dloc when the language's own documentation tool
would: the grammar's mark where there is one (Rust's doc_comment,
Groovydoc, Perl POD but not comment regions), and otherwise the
opening marker as Doxygen, javac (with JEP 467 `///`), Kotlin, JSDoc,
tsc, PHP's scanner and Roslyn read it, edges included (`/***`,
`////`, `/**/`).

Placement follows each tool too. A doc comment in a function body is
an ordinary comment where rustc, Roslyn, javadoc, Dokka and PHPDoc
ignore it; C, C++ and Objective-C keep it as Doxygen's in-body
description, the JS family as JSDoc inner symbols, and Perl POD
anywhere. Signature placements and Rust's inner doc opening a body
count. Checker gains is_doc_comment, DOC_COMMENTS_IN_FUNCTION_BODIES
and holds_doc_comment_in_body, and every comment arm routes through
add_comment_lines.

The in-body test folds down the ancestry once per walk through
Ancestors::fold_down and a FoldCache the walker lends, so it stays
linear in nesting depth (loc/rust-nested-doc-mods). Corpus snapshots
move dloc and mi.* only.

Refs #1597
Go, Ruby and Lua have no doc marker, so a comment is documentation by
where it sits, decided once per run of consecutive comment rows at its
last member:

- Go: go/doc's lead comment above a declaration, spec, field or
  interface method outside function bodies; directives are dropped.
- Ruby: YARD's block above a def, class, module, constant, alias,
  attr call or visibility-wrapped def, in a namespace body, plus RDoc
  `##` and YARD `@!` directive blocks; shebang and magic lines drop.
- Lua: an LDoc block that has text and is the file's first, carries a
  tag, or sits above a function, assignment, named field or a return
  of a function or table.

The Ruby then/do wrappers no longer credit their start row to PLOC,
which had turned a parked comment row into code. Scope tests fold once
per walk; probes pin each run walk as linear.

Refs #1597
Make the documentation-inclusive formula one setting away:
MetricsOptions::with_mi_charges_docs, the --mi-charges-docs flag, the
mi_charges_docs manifest key, a Python keyword and a web embedder
option. It moves nothing but mi.*.

--write-baseline stamps the mode into [provenance], and bca check
warns when an mi.* offender meets an mi.* entry written under the
other mode. A baseline with no stamp reads as charging documentation,
the only mode an earlier release had, so the upgrade warns.

Refs #1597
`bca metrics --metrics loc`, the flag's own help example, was rejected:
the selector reused `bca diff --metric`'s bucket vocabulary, where the
loc family appears only as its sub-metrics. A selection names what to
compute, so a family name now selects the whole family. diff keeps its
validator.
Lesson 98: an emulated tool's specification is its source, not its
manual or a summary of it (Doxygen in-body descriptions, tsc's
isJSDocLikeText, LDoc's parse.lua, YARD's processor). Lesson 81 gains
the predicate helpers that recursed once per nesting level.

Refs #1597
AGENTS.md: regenerate .bca-baseline.toml after each commit of a split
change, since the refresh measures the working tree. A new
history-rewrite rule: a scripted rebase that names a superseded hash
replays the old commit silently, so compare trees after any rewrite.

Refs #1597
@dekobon
dekobon force-pushed the fix/1597-loc-docstring branch from 3338c96 to e1e3256 Compare October 17, 2026 22:03
@dekobon
dekobon merged commit e1e3256 into main Oct 17, 2026
64 checks passed
@dekobon
dekobon deleted the fix/1597-loc-docstring branch October 17, 2026 22:24
dekobon added a commit that referenced this pull request Oct 19, 2026
C, C++, Mozilla C++ and Objective-C counted a Doxygen block as dloc
wherever it sat outside a function body, so a block above the end of
a file, a closing brace or a forward declaration inflated dloc and the
MI comment share.

Each now counts what Doxygen 1.13.2 documents, read from commentscan.l
and confirmed by running it with EXTRACT_ALL=NO over ~200 fixtures:

- Doxygen keeps every doc block before an entry and concatenates
  them: a declaration, definition, macro, typedef, alias, namespace,
  template, enumerator, macro call, or Objective-C container,
  property or method. It reads past plain comments, #include,
  #import, #pragma, an empty `;` and an empty conditional, and into a
  conditional that declares something.
- A block above a forward declaration, `}`, `@end`, `@synthesize`,
  an explicit instantiation or the end of the file documents nothing.
- A structural block (\file, \defgroup, \page, \name, or a naming
  command with a name: \class X, \def M) stands alone and counts
  wherever it sits; the doc blocks above a \file block count with it.
- Member-after /**< blocks and in-body blocks keep counting (#1609).

A new DocBinding::gathers models the \file case for a stretch that
ends at no target. The rule lives in lang_helpers/doxygen.rs and one
macro the four Checker impls share. Probes loc/c-dangling-docs and
loc/cpp-structural-docs hold it at O(k).

No committed corpus snapshot moves: every C++ doc block in DeepSpeech
native_client is attached. Submodule unchanged at 4d7a7b65.

Fixes #1613
dekobon added a commit that referenced this pull request Oct 20, 2026
C, C++, Mozilla C++ and Objective-C counted a Doxygen block as dloc
wherever it sat outside a function body, so a block above the end of
a file, a closing brace or a forward declaration inflated dloc and the
MI comment share.

Each now counts what Doxygen 1.13.2 documents, read from commentscan.l
and confirmed by running it with EXTRACT_ALL=NO over ~200 fixtures:

- Doxygen keeps every doc block before an entry and concatenates
  them: a declaration, definition, macro, typedef, alias, namespace,
  template, enumerator, macro call, or Objective-C container,
  property or method. It reads past plain comments, #include,
  #import, #pragma, an empty `;` and an empty conditional, and into a
  conditional that declares something.
- A block above a forward declaration, `}`, `@end`, `@synthesize`,
  an explicit instantiation or the end of the file documents nothing.
- A structural block (\file, \defgroup, \page, \name, or a naming
  command with a name: \class X, \def M) stands alone and counts
  wherever it sits; the doc blocks above a \file block count with it.
- Member-after /**< blocks and in-body blocks keep counting (#1609).

A new DocBinding::gathers models the \file case for a stretch that
ends at no target. The rule lives in lang_helpers/doxygen.rs and one
macro the four Checker impls share. Probes loc/c-dangling-docs and
loc/cpp-structural-docs hold it at O(k).

Two rules the other stretch bindings share change with it. A
stretch reads past a parse error that is no doc target and holds
none, as Doxygen's binding already does, so the error
tree-sitter-rust leaves for a Rust 2024 `safe fn` no longer drops
its doc comment; an error holding a declaration, such as a Kotlin
class-body context(...), stands in for it. And KeptDocs::Leading
asks whether a doc comment after code on its row still documents:
tsc's leading-comment scan starts at the line break after the token
before, so in TS and TSX one does only before a parameter, while
Roslyn, JSDoc and PHP-Parser read it.

No committed corpus snapshot moves: every C++ doc block in DeepSpeech
native_client is attached. Submodule unchanged at 9076c554.

Fixes #1613
dekobon added a commit that referenced this pull request Oct 20, 2026
C, C++, Mozilla C++ and Objective-C counted a Doxygen block as dloc
wherever it sat outside a function body, so a block above the end of
a file, a closing brace or a forward declaration inflated dloc and the
MI comment share.

Each now counts what Doxygen 1.13.2 documents, read from commentscan.l
and confirmed by running it with EXTRACT_ALL=NO over ~200 fixtures:

- Doxygen keeps every doc block before an entry and concatenates
  them: a declaration, definition, macro, typedef, alias, namespace,
  template, enumerator, macro call, or Objective-C container,
  property or method. It reads past plain comments, #include,
  #import, #pragma, an empty `;` and an empty conditional, and into a
  conditional that declares something.
- A block above a forward declaration, `}`, `@end`, `@synthesize`,
  an explicit instantiation or the end of the file documents nothing.
- A structural block (\file, \defgroup, \page, \name, or a naming
  command with a name: \class X, \def M) stands alone and counts
  wherever it sits; the doc blocks above a \file block count with it.
- Member-after /**< blocks and in-body blocks keep counting (#1609).

A new DocBinding::gathers models the \file case for a stretch that
ends at no target. The rule lives in lang_helpers/doxygen.rs and one
macro the four Checker impls share. Probes loc/c-dangling-docs and
loc/cpp-structural-docs hold it at O(k).

Two rules the other stretch bindings share change with it. A
stretch reads past a parse error that is no doc target and holds
none, as Doxygen's binding already does, so the error
tree-sitter-rust leaves for a Rust 2024 `safe fn` no longer drops
its doc comment; an error holding a declaration, such as a Kotlin
class-body context(...), stands in for it. And KeptDocs::Leading
asks whether a doc comment after code on its row still documents:
tsc's leading-comment scan starts at the line break after the token
before, so in TS and TSX one does only before a parameter, while
Roslyn, JSDoc and PHP-Parser read it.

No committed corpus snapshot moves: every C++ doc block in DeepSpeech
native_client is attached. Submodule unchanged at 9076c554.

Fixes #1613

This branch was successfully deployed

1 active deployment
github-pages — e1e32564 Deployed Oct 17, 2026 by dekobon via deploy to GitHub Pages #783
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.

feat(mi): stop charging documentation in the maintainability index

1 participant