mirror of
https://github.com/nlohmann/json.git
synced 2026-07-12 05:25:09 +00:00
f23b3c63a2
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>
289 lines
13 KiB
Python
Executable File
289 lines
13 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""Diff the public API surface between two refs to flag breaking vs. feature changes.
|
|
|
|
A "ref" for --old/--new is resolved in this order:
|
|
1. A stored, committed historical record at tools/api_checker/history/<ref>.json, if one exists
|
|
and --no-history wasn't passed (fast path -- no libclang/git-archive needed).
|
|
2. Live extraction: check the ref out via `git archive` into a temp dir and run extract_api.py
|
|
against it (needed for HEAD, branches, or any tag not yet backfilled into history/).
|
|
--old-file/--new-file bypass both and load an arbitrary surface JSON file directly.
|
|
|
|
Uses extract_api.py's --surface-output (identity-only: scope, kind, name, identity_name, tier,
|
|
signature, pretty_signature -- no location, no doc_url) for both sides, so the diff reflects only
|
|
genuine API changes, never unrelated code motion or documentation-site restructuring. Identity is
|
|
(scope, identity_name, kind, signature) -- see extract_api.py's identity_key()/get_signature_text()
|
|
docstrings for the full history of why this is what it is: a naive {scope,name,kind,params} key
|
|
silently collided on overloads differing only by constness/SFINAE; switching to libclang's USR
|
|
fixed that but encoded the *enclosing class template's own arity* into every member's identity, so
|
|
a single backward-compatible template-parameter addition (confirmed via real release tags
|
|
v3.11.2->v3.11.3) made ~228 of 330 entries look "changed" for a release with no real breaking
|
|
changes. The current raw-source-text-signature approach was arrived at, and each of several further
|
|
refinements verified, by testing against real historical releases -- not by inspecting code alone.
|
|
"""
|
|
|
|
import argparse
|
|
import json
|
|
import os
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
from collections import defaultdict
|
|
|
|
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
|
|
HISTORY_DIR = os.path.join(SCRIPT_DIR, 'history')
|
|
|
|
# The format_version this build of diff_api.py understands. Kept in sync with
|
|
# extract_api.py's SURFACE_FORMAT_VERSION by convention (both bump together whenever the
|
|
# identity-computing algorithm changes) -- imported directly so there's exactly one source of
|
|
# truth, not two constants that can drift apart.
|
|
sys.path.insert(0, SCRIPT_DIR)
|
|
from extract_api import SURFACE_FORMAT_VERSION # noqa: E402
|
|
|
|
|
|
def resolve_commit_sha(ref: str) -> str | None:
|
|
"""Resolve a ref to its full commit sha, or None if that fails."""
|
|
try:
|
|
result = subprocess.run(
|
|
['git', 'rev-parse', ref],
|
|
capture_output=True, text=True, check=True, timeout=10
|
|
)
|
|
return result.stdout.strip()
|
|
except (subprocess.CalledProcessError, FileNotFoundError, subprocess.TimeoutExpired):
|
|
return None
|
|
|
|
|
|
def extract_surface_for_ref(ref: str, header: str = 'include/nlohmann/json.hpp',
|
|
include: str = 'include') -> dict:
|
|
"""Check out the full include/ tree at `ref` into a temp dir and extract its API surface.
|
|
|
|
A single-file checkout of json.hpp is not enough: json.hpp #includes dozens of other
|
|
headers under nlohmann/detail/ that must exist at the same ref for parsing to succeed.
|
|
|
|
Returns the full loaded surface JSON (format_version, meta, public_api list), with `ref` and
|
|
the resolved commit sha recorded in meta -- callers that only care about the diff can ignore
|
|
these; snapshot_release.py uses them as a historical record's provenance.
|
|
"""
|
|
with tempfile.TemporaryDirectory(prefix='api_checker_') as tmpdir:
|
|
archive = subprocess.run(
|
|
['git', 'archive', ref, '--', 'include'],
|
|
capture_output=True, check=True
|
|
)
|
|
subprocess.run(['tar', '-x', '-C', tmpdir], input=archive.stdout, check=True)
|
|
|
|
ref_include = os.path.join(tmpdir, include)
|
|
ref_header = os.path.join(tmpdir, header)
|
|
if not os.path.exists(ref_header):
|
|
print(f"Error: {header} does not exist at ref {ref}")
|
|
sys.exit(1)
|
|
|
|
surface_output = os.path.join(tmpdir, 'surface.json')
|
|
result = subprocess.run(
|
|
[sys.executable, os.path.join(SCRIPT_DIR, 'extract_api.py'),
|
|
'--header', ref_header,
|
|
'--include', ref_include,
|
|
'--output', os.path.join(tmpdir, 'snapshot.json'),
|
|
'--surface-output', surface_output],
|
|
capture_output=True, text=True
|
|
)
|
|
if result.returncode != 0:
|
|
print(f"Error extracting API at ref {ref}:")
|
|
print(result.stdout)
|
|
print(result.stderr)
|
|
sys.exit(1)
|
|
|
|
with open(surface_output) as f:
|
|
surface = json.load(f)
|
|
|
|
# extract_api.py resolved 'extracted_from' relative to *its own* invocation cwd/repo-root
|
|
# detection, which inside this temp checkout is meaningless (a path like
|
|
# "../../../../var/folders/.../tmp.XXXX/include/nlohmann/json.hpp") -- overwrite with the
|
|
# stable, repo-relative header path actually passed in, so a stored history file's metadata
|
|
# doesn't embed a throwaway temp directory name.
|
|
surface.setdefault('meta', {})['extracted_from'] = header
|
|
surface['meta']['ref'] = ref
|
|
commit_sha = resolve_commit_sha(ref)
|
|
if commit_sha:
|
|
surface['meta']['commit'] = commit_sha
|
|
return surface
|
|
|
|
|
|
def load_surface_file(path: str) -> dict:
|
|
"""Load a surface JSON file from disk (a stored history/ record or an explicit --old-file/--new-file)."""
|
|
with open(path) as f:
|
|
return json.load(f)
|
|
|
|
|
|
def resolve_surface(ref: str | None, explicit_file: str | None, header: str, include: str,
|
|
no_history: bool) -> tuple:
|
|
"""Resolve one side of a diff. Returns (surface_dict, description_string_for_display)."""
|
|
if explicit_file:
|
|
return load_surface_file(explicit_file), f"file:{explicit_file}"
|
|
|
|
history_path = os.path.join(HISTORY_DIR, f"{ref}.json")
|
|
if not no_history and os.path.exists(history_path):
|
|
return load_surface_file(history_path), f"history:{ref}"
|
|
|
|
return extract_surface_for_ref(ref, header, include), f"live:{ref}"
|
|
|
|
|
|
def build_identity_dict(public_api: list) -> dict:
|
|
"""Group a surface's public_api list by identity for diffing.
|
|
|
|
Identity is (scope, identity_name, kind, signature) -- the same four components
|
|
extract_api.py's identity_key() joins into one opaque string internally, exposed here as
|
|
explicit fields on each record (see extract_api.py's write_surface()) so this reconstruction
|
|
is a plain, visible tuple lookup rather than decoding anything.
|
|
"""
|
|
return {
|
|
(entry['scope'], entry['identity_name'], entry['kind'], entry['signature']): entry
|
|
for entry in public_api
|
|
}
|
|
|
|
|
|
def diff_surfaces(old_api: dict, new_api: dict) -> dict:
|
|
"""Compare two API surfaces by identity key, grouping same-(scope,name) changes."""
|
|
old_keys = set(old_api.keys())
|
|
new_keys = set(new_api.keys())
|
|
|
|
added_keys = new_keys - old_keys
|
|
removed_keys = old_keys - new_keys
|
|
|
|
# Group removed/added entries by (scope, name) to detect "changed overload" pairs,
|
|
# rather than reporting a same-named removal+addition as two unrelated changes.
|
|
removed_by_name = defaultdict(list)
|
|
for key in removed_keys:
|
|
entry = old_api[key]
|
|
removed_by_name[(entry['scope'], entry['name'])].append(key)
|
|
|
|
added_by_name = defaultdict(list)
|
|
for key in added_keys:
|
|
entry = new_api[key]
|
|
added_by_name[(entry['scope'], entry['name'])].append(key)
|
|
|
|
changed = []
|
|
pure_added = []
|
|
pure_removed = []
|
|
|
|
for name, removed_list in removed_by_name.items():
|
|
if name in added_by_name:
|
|
added_list = added_by_name.pop(name)
|
|
for key in removed_list:
|
|
changed.append({'scope': name[0], 'name': name[1],
|
|
'old': old_api[key]['pretty_signature'],
|
|
'new': new_api[added_list[0]]['pretty_signature']})
|
|
else:
|
|
for key in removed_list:
|
|
pure_removed.append(old_api[key])
|
|
|
|
for name, added_list in added_by_name.items():
|
|
for key in added_list:
|
|
pure_added.append(new_api[key])
|
|
|
|
return {
|
|
'added': sorted(pure_added, key=lambda e: e['pretty_signature']),
|
|
'removed': sorted(pure_removed, key=lambda e: e['pretty_signature']),
|
|
'changed': sorted(changed, key=lambda e: (e['scope'], e['name'])),
|
|
}
|
|
|
|
|
|
def print_diff(diff: dict, old_desc: str, new_desc: str) -> bool:
|
|
"""Print a human-readable diff report. Returns True if breaking changes were found."""
|
|
print(120 * "-")
|
|
print(f"API Diff: {old_desc} -> {new_desc}")
|
|
print(120 * "-")
|
|
|
|
if diff['added']:
|
|
print(f"\nAdded ({len(diff['added'])} entries) -- feature:")
|
|
for entry in diff['added']:
|
|
print(f" + {entry['pretty_signature']}")
|
|
|
|
if diff['removed']:
|
|
print(f"\nRemoved ({len(diff['removed'])} entries) -- BREAKING:")
|
|
for entry in diff['removed']:
|
|
print(f" - {entry['pretty_signature']}")
|
|
|
|
if diff['changed']:
|
|
print(f"\nChanged overloads ({len(diff['changed'])} entries) -- BREAKING by default:")
|
|
for entry in diff['changed']:
|
|
print(f" ~ {entry['old']} -> {entry['new']}")
|
|
|
|
if not any([diff['added'], diff['removed'], diff['changed']]):
|
|
print("\nNo API changes detected")
|
|
|
|
print(120 * "-")
|
|
|
|
breaking = bool(diff['removed'] or diff['changed'])
|
|
if breaking:
|
|
print(f"\nWARNING: Potential BREAKING CHANGES detected:")
|
|
print(f" - {len(diff['removed'])} removed entries")
|
|
print(f" - {len(diff['changed'])} changed overloads")
|
|
if diff['added']:
|
|
print(f"\nNew features: {len(diff['added'])} added entries")
|
|
|
|
return breaking
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(description='Diff public API surface between two refs')
|
|
parser.add_argument('--old', help='Old git ref (tag/commit) -- checks tools/api_checker/history/ first, '
|
|
'then falls back to live extraction')
|
|
parser.add_argument('--new', help='New git ref (default: HEAD unless --new-file is given)')
|
|
parser.add_argument('--old-file', help='Path to a stored surface JSON file for the "old" side '
|
|
'(bypasses git and tools/api_checker/history/ entirely)')
|
|
parser.add_argument('--new-file', help='Path to a stored surface JSON file for the "new" side '
|
|
'(bypasses git and tools/api_checker/history/ entirely)')
|
|
parser.add_argument('--no-history', action='store_true',
|
|
help='Force live extraction even if a matching tools/api_checker/history/<ref>.json '
|
|
'exists -- useful to check a stored record is still faithful to a fresh run')
|
|
parser.add_argument('--allow-format-mismatch', action='store_true',
|
|
help='Proceed even if the two surfaces have different format_version (results may '
|
|
'be unsound -- the identity algorithm may differ between versions)')
|
|
parser.add_argument('--header', default='include/nlohmann/json.hpp',
|
|
help='Header file to analyze (relative to repo root, live-extraction only)')
|
|
parser.add_argument('--include', default='include',
|
|
help='Include directory (relative to repo root, live-extraction only)')
|
|
parser.add_argument('--fail-on-breaking', action='store_true',
|
|
help='Exit with status 1 if breaking changes are detected')
|
|
|
|
args = parser.parse_args()
|
|
|
|
if args.old and args.old_file:
|
|
parser.error('--old and --old-file are mutually exclusive')
|
|
if not args.old and not args.old_file:
|
|
parser.error('one of --old or --old-file is required')
|
|
if args.new and args.new_file:
|
|
parser.error('--new and --new-file are mutually exclusive')
|
|
|
|
new_ref = args.new or ('HEAD' if not args.new_file else None)
|
|
|
|
print(f"Resolving old surface ({args.old_file or args.old})...")
|
|
old_surface, old_desc = resolve_surface(args.old, args.old_file, args.header, args.include, args.no_history)
|
|
|
|
print(f"Resolving new surface ({args.new_file or new_ref})...")
|
|
new_surface, new_desc = resolve_surface(new_ref, args.new_file, args.header, args.include, args.no_history)
|
|
|
|
old_fmt = old_surface.get('format_version')
|
|
new_fmt = new_surface.get('format_version')
|
|
if old_fmt != new_fmt and not args.allow_format_mismatch:
|
|
print(f"Error: format_version mismatch ({old_desc}: {old_fmt!r} vs {new_desc}: {new_fmt!r}).")
|
|
print("The identity/signature algorithm may differ between these two surfaces, making a diff unsound.")
|
|
print("Re-run with --allow-format-mismatch to proceed anyway.")
|
|
sys.exit(1)
|
|
elif old_fmt != new_fmt:
|
|
print(f"WARNING: proceeding with mismatched format_version ({old_fmt!r} vs {new_fmt!r}) as requested.")
|
|
|
|
old_api = build_identity_dict(old_surface['public_api'])
|
|
new_api = build_identity_dict(new_surface['public_api'])
|
|
|
|
print(f"\nComparing {len(old_api)} old entries with {len(new_api)} new entries...")
|
|
diff = diff_surfaces(old_api, new_api)
|
|
|
|
breaking = print_diff(diff, old_desc, new_desc)
|
|
|
|
if args.fail_on_breaking and breaking:
|
|
sys.exit(1)
|
|
|
|
|
|
if __name__ == '__main__':
|
|
main()
|