Adds tools/api_checker/: extract_api.py derives the public API surface directly from the libclang AST (independent of documentation status), check_docs.py flags public entries missing @sa links (and @sa on non-public ones), diff_api.py does an overload-aware breaking/feature diff between two refs, check_macros.py cross- checks documented macros against #define sites, and snapshot_release.py backfills immutable per-release surface snapshots into tools/api_checker/history/ (v3.1.0 through v3.12.0) so diff_api.py can compare releases without live extraction. POLICY.md documents what counts as public API and what stability is guaranteed. Running this tooling against the current tree found and fixed a real documentation backlog: ~25 new API doc pages (ordered_map's methods, json_sax's ctor/dtor/ operator=, byte_container_with_subtype's comparison operators, several orphaned type aliases), each with a compiled and output-verified example, plus missing @sa comments and stale/incorrect Version History entries on several existing pages (found by diffing consecutive release pairs and checking whether the resulting change was actually reflected in the target page's history section). Also adds docs/home/api_changes.md, a per-release, per-function reference of public API changes (v3.1.0 through v3.12.0) generated from the history/ snapshots, complementing (not replacing) the existing release notes. Along the way, found and fixed several extractor bugs by testing against real release tags rather than trusting the algorithm in isolation -- most notably an identity-key scheme based on libclang's USR that encoded the enclosing class template's own arity, and a since-renamed ABI inline-namespace pattern (json_v3_11_0 vs. today's json_abi_v3_11_2) that neither of two earlier regex attempts stripped correctly. Both are documented in extract_api.py's docstrings and tools/api_checker/history/README.md so the failure mode doesn't recur silently. .github/workflows/check_api_docs.yml runs extract_api.py + check_docs.py in CI, advisory-only for now (documented backlog may not be at zero for entities this PR didn't touch), plus a blocking drift check on the committed tools/api_checker/api_surface.json. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Signed-off-by: Niels Lohmann <mail@nlohmann.me>
20 KiB
API Checker Tools
Tooling to extract, validate, and track changes to the public API surface of nlohmann/json.
Overview
These tools use libclang AST parsing to programmatically derive "the public API" from C++ semantics
(class templates, access specifiers, namespace scoping) — independently of documentation status. The
extracted surface is the source of truth for what is considered "public API." On top of this, the tools
verify that every public entity carries a documentation link and detect API changes between releases.
A per-release historical record lives in history/, one file per tagged v3.*
release, so past API changes can be derived without re-running libclang against old git refs.
See POLICY.md for the full definition of what counts as public API, what's excluded, how breaking vs. feature changes are classified, and known limitations.
Installation
Install dependencies:
pip install -r tools/api_checker/requirements.txt
Requires:
- Python 3.7+
- libclang 18.1.1 (installed via pip)
- clang++ or clang (system package, for include-path discovery only — does not need to version-match the pinned libclang wheel)
Tools
extract_api.py
Extract the public API surface by parsing C++ AST using libclang. Produces two different outputs for two different consumers — see "Two outputs, one extraction pass" below for why.
Usage:
python3 tools/api_checker/extract_api.py \
--header include/nlohmann/json.hpp \
--include include \
--output api_snapshot.json \
--surface-output api_surface.json
Options:
--header PATH— Header file to analyze (default:include/nlohmann/json.hpp)--include PATH— Include directory for parsing (default:include)--output PATH— Full snapshot output (location + doc status; default:api_snapshot.json)--surface-output PATH— Minimal surface output (identity only; omit to skip)--extra-isystem PATH— Extra-isysteminclude path (repeatable), in case system-include discovery viaclang++ -E -vever fails on a given runner image--self-test— Run self-tests and exit (validates ABI-tag stripping)
How it works:
Parses the header file using libclang with PARSE_DETAILED_PROCESSING_RECORD, discovers system
includes via clang++ -E -x c++ -v /dev/null, and walks the AST:
- For each of the 6 public class templates (
basic_json,adl_serializer,byte_container_with_subtype,json_pointer,json_sax,ordered_map): locate theCLASS_TEMPLATEcursor withis_definition() == True— never aCLASS_DECLimplicit instantiation, which silently drops SFINAE-guarded overloads - Extract public members:
CXX_METHOD,CONSTRUCTOR,DESTRUCTOR,CONVERSION_FUNCTION,FUNCTION_TEMPLATE(callable tier — strict@sarequirement), andTYPE_ALIAS_DECL(type tier — with STL-container exemptions) - Extract free functions in
nlohmann::namespace (excludingdetail::andstd::) - Normalize away ABI inline-namespace (regex
::json_abi[a-z_]*_v\d+_\d+_\d+→::) - Use overload-disambiguating identity keys built from raw source-text signature capture, ABI-tag-stripped — see "Identity keys" below
Two outputs, one extraction pass:
--output(full snapshot): each entry carrieslocation(file:line) and documentation status (doc_url,has_sa). Consumed bycheck_docs.py. Not meant to be committed —locationshifts on any unrelated code edit anddoc_urlchanges when doc pages move, so a diff of this file mixes real API changes with pure noise.--surface-output(minimal surface): each entry has onlyscope,kind,name,identity_name,tier,signature, andpretty_signature— nothing that can change without the API itself changing. This is the file that gets committed (tools/api_checker/api_surface.jsonfor the current working tree,tools/api_checker/history/<tag>.jsonper released tag) and diffed bydiff_api.py.
Identity keys — three approaches were tried and rejected before arriving at the current one:
{scope, name, kind, params}fromcursor.get_arguments(): silently collided for any overload set differentiated only by constness, ref-qualifiers, or SFINAE constraints rather than parameter types — confirmed empirically:basic_json's two zero-argumentget()overloads (oneconst, one not) both producedparams=[]and silently overwrote each other. A full scan found 59 such silent overwrites across 27 colliding names.- libclang's USR: correctly disambiguates every overload (it encodes the full mangled signature) — but
also encodes the enclosing class template's own arity into every member's USR. Confirmed
empirically against real release tags:
basic_jsongaining one new defaulted template parameter between v3.11.2 and v3.11.3 (a backward-compatible change) changed literally every member's USR, which madediff_api.pyreport 228 of 330 entries as "changed" for a release with zero real breaking changes among them. - Current approach: raw source-text signature capture —
identity = (scope, identity_name, kind, signature), wheresignatureis the declaration's own text (return type, name, parameter list, trailing cv/ref/noexcept qualifiers; comments and constructors' member-initializer-lists stripped; stops before the function body) read directly via the cursor's byte-offset extent. This is what's actually written in source — declared names likeValueType, never a resolvedbasic_json<T0,...,T10>— so it's immune to the class-arity problem above. Verified by manually cross-referencing every "added"/"removed"/"changed" entry in three real release-to-release diffs (v3.11.2→v3.11.3, v3.11.3→v3.12.0, v3.12.0→HEAD) against the actualgit diffof the source — every one confirmed genuine. Full details, including several further edge-case fixes found the same way (a libclang tokenizer gap, comment-stripping, constructor-name arity-poisoning), are inextract_api.py'sidentity_key()/get_signature_text()docstrings.
Output (full snapshot, from --output):
{
"meta": {
"extracted_from": "include/nlohmann/json.hpp"
},
"public_api": {
"<opaque internal key, not meant to be read>": {
"scope": "nlohmann::basic_json",
"name": "parse",
"identity_name": "parse",
"kind": "CXX_METHOD",
"tier": "callable",
"signature": "static basic_json parse ( InputType && i , ... )",
"location": "include/nlohmann/json.hpp:4104",
"doc_url": "https://json.nlohmann.me/api/basic_json/parse/",
"has_sa": true,
"pretty_signature": "nlohmann::basic_json::parse"
}
},
"documented_non_public": []
}
documented_non_public lists entities not part of the public surface (private/protected members of
the six tracked classes) that surprisingly carry a real @sa URL — a genuine documentation leak. It does
not list public entries that merely lack @sa; that's check_docs.py's job.
Output (surface, from --surface-output):
{
"format_version": 1,
"meta": {
"extracted_from": "include/nlohmann/json.hpp"
},
"public_api": [
{
"scope": "nlohmann::basic_json",
"kind": "CXX_METHOD",
"name": "parse",
"identity_name": "parse",
"tier": "callable",
"signature": "static basic_json parse ( InputType && i , parser_callback_t cb = nullptr , const bool allow_exceptions = true , const bool ignore_comments = false )",
"pretty_signature": "nlohmann::basic_json::parse"
}
]
}
A flat, sorted list of self-describing records — signature/identity_name are the same values
identity_key() joins into one opaque internal string, exposed here as explicit fields so the file is
readable and diffable by inspection, not just by tooling. identity_name differs from name only for
constructors/destructors (a canonical "(constructor)"/"(destructor)" placeholder — see
get_identity_name()'s docstring for why cursor.spelling isn't used directly there).
format_version guards against a future change to this schema or to the identity-computing algorithm
silently corrupting a comparison against an older stored surface — bump it whenever such a change could
alter signature/identity_name text for otherwise-unchanged source (see diff_api.py).
check_docs.py
Verify that all public API entries have valid documentation links and that no non-public entities
carry @sa comments.
Usage:
python3 tools/api_checker/check_docs.py --snapshot api_snapshot.json
Options:
--snapshot PATH— Full API snapshot JSON file fromextract_api.py --output(default:api_snapshot.json)
How it works: Two-pass validation over the extracted snapshot:
- For every
public_apientry (callable tier, and type tier minus STL exemptions): flag if missing@sa, flag if@saURL doesn't resolve to an existing documentation file (followingdocs/mkdocs/mkdocs.yml'sredirect_mapswhen a page has moved, and trying the class-overview conventions used by different classes — flat<class>.md, nested<class>/<class>.md, nested<class>/index.md) - For every
documented_non_publicentry: flag as unexpected@saon a non-public entity
Output: Prints warnings for each issue, categorized by rule:
docs/missing_sa_comment— public API without@sadocumentation linkdocs/missing_doc_file—@saURL points to non-existent.mdfiledocs/invalid_sa_url— malformed@saURLdocs/sa_on_non_public— unexpected@saon a non-public entity
Exits with status 0 if all checks pass, 1 if issues found.
diff_api.py
Compare the public API surface between two refs and classify changes as feature (added) or breaking (removed / changed overload).
Usage:
python3 tools/api_checker/diff_api.py --old v3.12.0 --new HEAD
python3 tools/api_checker/diff_api.py --old v3.11.2 --new v3.11.3 # both resolved from history/
python3 tools/api_checker/diff_api.py --old v3.12.0 --new HEAD --fail-on-breaking
python3 tools/api_checker/diff_api.py --old-file a.json --new-file b.json # compare two files directly
Options:
--old REF/--new REF— Refs to compare (tags, branches, commits).--newdefaults toHEAD. Each is resolved by checkingtools/api_checker/history/<ref>.jsonfirst (fast — no libclang or git-archive needed), falling back to live extraction if no matching file exists there.--old-file PATH/--new-file PATH— Load an arbitrary surface JSON file directly, bypassing both git andtools/api_checker/history/. Mutually exclusive with--old/--newrespectively.--no-history— Force live extraction even when a matchingtools/api_checker/history/<ref>.jsonexists. Useful to check that a stored record is still faithful to a fresh run of the current tool.--allow-format-mismatch— Proceed even if the two surfaces have differentformat_version(otherwisediff_api.pyrefuses — see below).--header PATH/--include PATH— For live extraction only (default:include/nlohmann/json.hpp/include)--fail-on-breaking— Exit with status 1 if breaking changes are detected
How it works:
For a ref not found in tools/api_checker/history/, checks out the full include/ tree at
that ref into a temp directory via git archive (a single-file checkout of json.hpp is not
enough — it #includes dozens of other headers that must exist at the same ref), then runs the
current extract_api.py --surface-output against it. Diffs the two surfaces by identity
(scope, identity_name, kind, signature):
- Identity only in the new surface → feature.
- Identity only in the old surface → breaking (removed).
- Same
(scope, name)with a removed identity and an added identity → grouped as a changed overload, breaking by default. No automatic overload-compatibility reasoning is attempted — a human judges whether the change is actually source-compatible.
Before diffing, the two surfaces' format_version fields are compared; a mismatch aborts with an
error (override with --allow-format-mismatch) rather than silently producing an unsound diff — see
extract_api.py's SURFACE_FORMAT_VERSION docstring for the incident that motivated this guard.
ABI-tag stripping is inherited automatically since both extractions go through the same
extract_api.py.
Use case: Run before cutting a release to verify the changelog correctly categorizes changes as breaking vs. features.
snapshot_release.py
Capture an immutable, per-release API surface record into tools/api_checker/history/<tag>.json.
This is what makes diff_api.py fast for released tags — see "How it works" above.
Usage:
python3 tools/api_checker/snapshot_release.py --ref v3.12.0
python3 tools/api_checker/snapshot_release.py --ref v3.11.0 --ref v3.12.0 # repeatable
python3 tools/api_checker/snapshot_release.py --all-tags # every v3.* tag
python3 tools/api_checker/snapshot_release.py --ref v3.12.0 --force # overwrite an existing record
Options:
--ref REF— Git tag to snapshot; repeatable--all-tags— Snapshot everyv3.*tag (existing files are skipped unless--force)--output-dir PATH— Where to write (default:tools/api_checker/history/)--force— Overwrite an existing history file. History files are immutable by convention — only pass this for a deliberate, reviewed regeneration; review the resulting diff before committing--header PATH/--include PATH— Same asdiff_api.py's live-extraction options
How it works: Reuses diff_api.py's extract_surface_for_ref() for the git-archive-and-extract
work, then adds generated_at/generator provenance and writes the result to
tools/api_checker/history/<ref>.json. Given multiple refs (or --all-tags), does not abort
the batch on one failing ref — collects failures and prints a summary at the end, so one
unparseable old tag doesn't block backfilling the releases that do work. See
tools/api_checker/history/README.md for the currently-known gaps (pre-restructuring tags that
predate the include/nlohmann/ layout entirely).
Not CI-automated — this is a manual step in the release checklist (see below), by design.
check_macros.py
Advisory-only cross-check between documented macros and their #define/reference sites. Never blocks
CI, regardless of findings.
Usage:
python3 tools/api_checker/check_macros.py
Options:
--macros-dir PATH— Directory of macro doc pages (default:docs/mkdocs/docs/api/macros)--include-dir PATH— Directory to search for#define/reference sites (default:include/nlohmann)
How it works:
For each .md page under docs/mkdocs/docs/api/macros/ (excluding index.md), extracts the macro
name(s) from the H1 heading — handling both plain single-macro headings and multi-line HTML headings
that list a family of related macros (e.g. the NLOHMANN_DEFINE_DERIVED_TYPE_* family) — then checks
whether each name is defined or referenced (#define, #ifdef, #ifndef, defined(...)) anywhere
under include/nlohmann/. Referenced-but-not-defined is deliberately accepted: macros like
JSON_NOEXCEPTION or JSON_THROW_USER are user-supplied overrides that the library only checks for,
never defines itself.
Only checks the documented-macro-still-exists direction (catches stale/renamed doc pages). Does not check the converse (undocumented macros) — see POLICY.md's "Known limitations" for why.
Continuous Integration
A GitHub Actions workflow (.github/workflows/check_api_docs.yml) runs on every pull request:
- Installs
clang(system package, for include discovery) and Python dependencies - Runs
extract_api.py, regenerating both the ephemeral full snapshot and the trackedtools/api_checker/api_surface.json - Runs
check_docs.py— Phase 1: advisory (continue-on-error: true), surfacing the doc backlog without failing the job while it's burned down. Will flip to blocking once the backlog is cleared. - Runs
check_macros.py— always advisory - Diffs
tools/api_checker/api_surface.jsonagainst the regenerated copy — blocking from the start (unlike the doc-backlog check, this is purely mechanical regeneration with no backlog to phase in, matching the precedent set bycheck_amalgamation.yml). Uploads a patch artifact if it differs, so contributors cangit applyit instead of installing libclang locally.
Workflow: Adding New Public API
- Add the new public method/function to the header
- Add a
/// @sa https://json.nlohmann.me/api/<class>/<member>/comment above it - Create the corresponding documentation page at
docs/mkdocs/docs/api/<class>/<member>.mdand amkdocs.ymlnav entry - Regenerate and commit the tracked surface file:
python3 tools/api_checker/extract_api.py --surface-output tools/api_checker/api_surface.json git add tools/api_checker/api_surface.json - Push your PR — CI verifies both the doc link and that the surface file is up to date
Workflow: Release Checklist
Before cutting a release:
# Diff current API against the previous release
python3 tools/api_checker/diff_api.py --old v3.12.0 --new HEAD
# Review the output to verify the changelog correctly categorizes breaking vs. feature changes
After tagging the release:
# Capture and commit the new tag's API surface, so future diffs against it hit the fast,
# stored-file path instead of live-extracting every time. A manual step, not CI-automated.
python3 tools/api_checker/snapshot_release.py --ref v3.13.0
git add tools/api_checker/history/v3.13.0.json
Troubleshooting
libclang not found:
Error: Could not locate libclang library
→ Ensure libclang is installed: python3 -m pip install libclang==18.1.1
Parse errors in header:
Parse errors encountered:
... list of diagnostics ...
→ Check that system includes can be discovered. Run clang++ -E -x c++ -v /dev/null and verify
the output includes a section titled #include <...> search starts here: with system paths.
If include discovery fails, use --extra-isystem PATH to provide additional paths.
No API entries extracted (or very few):
- Check that the header file exists and is valid C++:
ls -la include/nlohmann/json.hpp - Verify that no parse errors occur above
- Confirm that you're targeting a
CLASS_TEMPLATEdefinition, not an implicit instantiation (the tool logsFound N public API entries— a zero or very small count suggests the wrong cursor kind)
Doc link returns 404:
- Verify the file exists at the expected path:
docs/mkdocs/docs/api/<class>/<member>.md - Check for URL encoding issues (e.g.,
operator[]→operator%5B%5D) - Verify the URL structure in the
@sacomment: should behttps://json.nlohmann.me/api/<path>/ - Check
docs/mkdocs/mkdocs.yml'sredirect_mapsif the page has moved
tools/api_checker/api_surface.json is out of date in CI:
→ Regenerate and commit it: python3 tools/api_checker/extract_api.py --surface-output tools/api_checker/api_surface.json
diff_api.py refuses with "format_version mismatch":
→ One side is a stored surface (live api_surface.json or a tools/api_checker/history/*.json
file) captured with an older/newer version of extract_api.py's identity-computing algorithm than
the other side. Comparing them directly could produce an unsound diff (this is exactly the failure
mode that motivated adding the check — see extract_api.py's SURFACE_FORMAT_VERSION docstring).
Either regenerate the older side with the current tool, or pass --allow-format-mismatch if you
understand the risk and want to proceed anyway.
Contributing
Report bugs or suggest improvements to Discussion #3691. Read POLICY.md first for the definition of public API this tooling enforces.