No-op

The noop generator lets Mr.Docs extract the corpus and does nothing with it, collecting warning diagnostics from the extraction phase. This can be used to quickly catch undocumented symbols, parameters, incorrect documentation arguments, broken references, and malformed comments in general. Pick it through the generator option:

generator: noop

In other words, you can combine it with warn-as-error to get a documentation linter.

A documentation check

Pair noop with warn-as-error and turn on the warnings you care about. Any documentation warning exits non-zero, and you can use it as a test. Consider a header that documents a function but forgets to document its parameters:

  • Config

  • Input

mrdocs.yml
# A validation-only MrDocs run: extract everything, write nothing, and fail on
# any documentation problem. Point CI at this config to gate the docs.
generator: noop
warnings: true
warn-as-error: true
warn-if-undocumented: true
warn-no-paramdoc: true
source-root: include
input:
  - include
include/geo/area.hpp
#ifndef GEO_AREA_HPP
#define GEO_AREA_HPP

/// Area calculations for simple shapes.
namespace geo {

/** The area of a rectangle.

    @return The area, `width * height`.
*/
double rectangle_area(double width, double height);

}

#endif

Run the check and it names each problem, per symbol.

geo::rectangle_area: Missing documentation for parameter 'width'
geo::rectangle_area: Missing documentation for parameter 'height'

Wiring it into CTest makes a documentation error fail the build like any other test:

CMakeLists.txt
find_package(mrdocs REQUIRED)
add_test(
    NAME documentation-check
    COMMAND ${MRDOCS_EXECUTABLE}
            --config=${CMAKE_CURRENT_SOURCE_DIR}/mrdocs.yml
            --generator=noop --warn-as-error)

A complete, runnable example lives at examples/generators/noop/documentation-check/. Because the run produces no output, this is the cheapest way to ensure documentation is always provided in a codebase.

Agentic Loop

The report above not only deterministically ensures that the documentation is complete and valid, but also provides a checklist of what is wrong. This makes it a useful tool for AI agents that work on a codebase but would often not ensure they maintain the documentation themselves.

For a project that’s already mostly documented, you can use a target like the one above as part of the tests to ensure documentation is included for new functions. For a project that is not well documented, you can create an agentic loop that forces the agent to fix the documentation until it passes the check:

fix-docs.py
#!/usr/bin/env python3
"""Run MrDocs' no-op generator as a documentation linter and let an AI agent
fix what it reports, looping until the check is clean.

The first argument is the config file (default mrdocs.yml); anything after it
is forwarded to mrdocs (lint flags, --max-errors, include directories). The
environment supplies AGENT (the agent's non-interactive command, default
"agent -p -f"; use "claude -p" for Claude Code), MRDOCS (the binary), BATCH
(source locations per round, default 200), and MAX_ROUNDS (default 40). The
agent prompt lives beside this script in prompt.md.
"""

import os
import re
import shlex
import subprocess
import sys
import time
from pathlib import Path

MRDOCS = os.environ.get("MRDOCS", "mrdocs")
AGENT = shlex.split(os.environ.get("AGENT", "agent -p -f"))
BATCH = int(os.environ.get("BATCH", "200"))
MAX_ROUNDS = int(os.environ.get("MAX_ROUNDS", "40"))
PROMPT = (Path(__file__).resolve().parent / "prompt.md").read_text(encoding="utf-8")

CONFIG = sys.argv[1] if len(sys.argv) > 1 else "mrdocs.yml"
CMD = [MRDOCS, f"--config={CONFIG}", "--generator=noop", *sys.argv[2:]]

ANSI = re.compile(r"\x1b\[[0-9;]*m")
LOCATION = re.compile(r"^\S.*:\d+:\d+:\s*$")
FOOTER = re.compile(
    r"^(An issue occurred|If you believe|\s*MrDocs Version|\s*Error Location|"
    r"\s*Reported From|with the following details|"
    r"max-errors=|error limit reached)")


def main():
    """Check, repair, repeat until the docs are clean or the rounds run out."""
    print(f"Checking with: {shlex.join(CMD)}")
    timings = []
    for round in range(1, MAX_ROUNDS + 1):
        clean, report, check_time = check()
        if clean:
            print(f"Documentation is clean after {round - 1} round(s).")
            return summary(timings, 0)
        # No parseable diagnostics (a compile error or a broken config) is
        # not "clean" and not "stalled": hand the agent the raw output so it
        # can unblock extraction, and keep going.
        batch = diagnostics(report)[:BATCH] or [report]
        if stalled(batch):
            print("The same locations keep coming back; stopping so a human "
                  "can look.", file=sys.stderr)
            return summary(timings, 1)
        print(f"Round {round}: check took {check_time:.0f}s; "
              f"handing {len(batch)} location(s) to the agent.")
        agent_time = repair(batch)
        print(f"Round {round}: agent took {agent_time:.0f}s.")
        timings.append((check_time, agent_time))
    print(f"Still failing after {MAX_ROUNDS} rounds; stopping so a human "
          f"can look.", file=sys.stderr)
    return summary(timings, 1)


def check():
    """Run the strict no-op check; return (clean?, report, seconds).

    Clean requires both a success exit code and a non-empty corpus: a run
    that extracts zero declarations is a broken configuration, not a clean
    one, even when mrdocs reports success.
    """
    start = time.monotonic()
    run = subprocess.run(CMD, capture_output=True, text=True)
    report = ANSI.sub("", run.stdout + run.stderr)
    clean = run.returncode == 0 and "Extracted 0 declarations" not in report
    return clean, report, time.monotonic() - start


def diagnostics(report):
    """Split the report into its location-grouped diagnostic blocks.

    A block starts with a "path:line:col:" header, then its numbered
    messages and a source snippet. The warn-as-error footer is skipped.
    """
    blocks, current = [], []
    for line in report.splitlines():
        if FOOTER.match(line):
            continue
        if LOCATION.match(line):
            if current:
                blocks.append("\n".join(current))
            current = [line]
        elif current:
            current.append(line)
    if current:
        blocks.append("\n".join(current))
    return [block for block in blocks if block.strip()]


def stalled(batch):
    """True when three rounds in a row report the same locations.

    The batch is identified by its location headers only: with --max-errors
    the total count stays pinned at the cap while the frontier advances, so
    only the same locations coming back means no progress.
    """
    headers = tuple(block.splitlines()[0] for block in batch)
    stalled.streak = stalled.streak + 1 if headers == stalled.previous else 0
    stalled.previous = headers
    return stalled.streak >= 2


stalled.previous = None
stalled.streak = 0


def repair(batch):
    """Hand one rendered prompt to the agent; return how long it took."""
    max_errors = next(
        (arg.split("=", 1)[1] for arg in CMD
         if arg.startswith("--max-errors=")), str(BATCH))
    prompt = PROMPT
    for key, value in (
        ("command", shlex.join(CMD)),
        ("report", "\n\n".join(batch)),
        ("max_errors", max_errors),
        ("batch", str(len(batch))),
    ):
        prompt = prompt.replace("{" + key + "}", value)
    start = time.monotonic()
    subprocess.run(AGENT + [prompt])
    return time.monotonic() - start


def summary(timings, exit_code):
    """Print a per-round timing table with totals and averages."""
    if timings:
        print(f"\n{'Round':>7} {'mrdocs':>8} {'agent':>8}")
        for i, (check_time, agent_time) in enumerate(timings, 1):
            print(f"{i:>7} {check_time:>7.0f}s {agent_time:>7.0f}s")
        checks, agents = zip(*timings)
        print(f"{'Total':>7} {sum(checks):>7.0f}s {sum(agents):>7.0f}s")
        print(f"{'Average':>7} {sum(checks) / len(checks):>7.0f}s "
              f"{sum(agents) / len(agents):>7.0f}s")
    return exit_code


if __name__ == "__main__":
    sys.exit(main())

This pattern can be used to give a project a baseline of documentation.

The example above is intentionally small. A larger "pro" version ships in tools/fix-docs with a number of optimizations.