mirror of
https://github.com/nlohmann/json.git
synced 2026-07-12 05:25:09 +00:00
0663907b68
extract_api.py's own extraction wasn't deterministic across machines, which CI's drift check caught immediately: JSON_HAS_RANGES auto-detects via the standard library's __cpp_lib_ranges feature-test macro, which isn't reliably gated to C++20 mode by every stdlib -- undefined under -std=c++17 with macOS's libc++, but defined under the identical flag with the Ubuntu stdlib CI uses, so parse()/accept()/from_*() extracted different signatures purely depending on which machine ran the extraction. Pinned to -DJSON_HAS_RANGES=0: the deterministic and safe choice, since pinning to 1 was tried first and found to fail to parse on a stdlib without full <ranges> support even when the macro claims otherwise. Also found and fixed a second, independent source of the same class of drift: get_identity_name() used cursor.spelling verbatim for CONVERSION_FUNCTION cursors, which libclang renders as its own internally-canonicalized form of the return type rather than what's literally written. Confirmed for json_pointer::operator string_t() spelling differently on two machines pinned to the identical libclang==18.1.1 wheel, with the JSON_HAS_RANGES fix above ruled out as the cause. Now derived from the cursor's own raw source text instead, immune to libclang's dependent-type resolution differences and incidentally more readable than the libclang-internal forms it replaces. Bumped SURFACE_FORMAT_VERSION to 3 and regenerated all 27 history snapshots and the committed api_surface.json; both fixes are documented in tools/api_checker/history/README.md's format-history log. Also fixes diff_api.py's format_version guard, which only compared the two loaded surfaces against each other and never against SURFACE_FORMAT_VERSION (what this build actually understands) -- two surfaces on the same, newer-than-expected format_version would have silently passed the guard. Remaining fixes are the concretely actionable findings from Codacy's review of the new tools/api_checker/ files: unused imports/variables, a stray f-string with no placeholders. Left the docstring-formatting nitpicks (pydocstyle D2xx/D4xx) and generic subprocess-usage notices alone -- the former has no established convention elsewhere in this codebase's Python tooling to conform to, and the latter are inherent to a dev tool that shells out to git/clang with developer-controlled arguments, not user input. Signed-off-by: Niels Lohmann <mail@nlohmann.me> Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Signed-off-by: Niels Lohmann <mail@nlohmann.me>
183 lines
6.2 KiB
Python
Executable File
183 lines
6.2 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""Verify that public API entries have documentation links.
|
|
|
|
Consumes an API snapshot from extract_api.py and checks:
|
|
1. Every public callable/type-tier entry has an @sa comment (with exceptions)
|
|
2. Every @sa URL resolves to an existing documentation file
|
|
3. No @sa comments appear on non-public entities
|
|
"""
|
|
|
|
import argparse
|
|
import json
|
|
import os
|
|
import re
|
|
import subprocess
|
|
import sys
|
|
from urllib.parse import unquote
|
|
|
|
warnings = 0
|
|
|
|
# STL-container-named-requirement aliases that are exempt from @sa requirement
|
|
STL_EXEMPT = {'value_type', 'reference', 'const_reference', 'pointer', 'const_pointer',
|
|
'iterator', 'const_iterator', 'reverse_iterator', 'const_reverse_iterator',
|
|
'difference_type', 'size_type', 'allocator_type', 'key_type', 'mapped_type'}
|
|
|
|
|
|
def get_repo_root():
|
|
"""Find the repository root via git, so this script works regardless of invoking CWD."""
|
|
try:
|
|
result = subprocess.run(
|
|
['git', 'rev-parse', '--show-toplevel'],
|
|
capture_output=True, text=True, check=True, timeout=10
|
|
)
|
|
return result.stdout.strip()
|
|
except (subprocess.CalledProcessError, FileNotFoundError, subprocess.TimeoutExpired):
|
|
return os.getcwd()
|
|
|
|
|
|
REPO_ROOT = get_repo_root()
|
|
MKDOCS_YML = os.path.join(REPO_ROOT, 'docs', 'mkdocs', 'mkdocs.yml')
|
|
|
|
|
|
def load_redirect_map() -> dict:
|
|
"""Parse the redirect_maps block of docs/mkdocs/mkdocs.yml: {old_relative_path: new_relative_path}.
|
|
|
|
mkdocs' redirect plugin lets a doc page move without breaking existing @sa URLs -- e.g.
|
|
'api/basic_json/operator_ltlt.md' redirects to the real file at 'api/operator_ltlt.md'.
|
|
Without consulting this map, url_to_docfile() would flag every redirected URL as a broken link.
|
|
"""
|
|
if not os.path.exists(MKDOCS_YML):
|
|
return {}
|
|
with open(MKDOCS_YML) as f:
|
|
content = f.read()
|
|
redirect_map = {}
|
|
for m in re.finditer(r"^\s*'([^']+\.md)':\s*(\S+\.md)\s*$", content, re.MULTILINE):
|
|
redirect_map[m.group(1)] = m.group(2)
|
|
return redirect_map
|
|
|
|
|
|
REDIRECT_MAP = load_redirect_map()
|
|
|
|
|
|
def report(rule: str, location: str, description: str):
|
|
"""Report a documentation issue."""
|
|
global warnings
|
|
warnings += 1
|
|
print(f'{warnings:3}. {location}: {description} [{rule}]')
|
|
|
|
|
|
def url_to_docfile(url: str) -> str | None:
|
|
"""Convert @sa URL to the documentation file it resolves to, following mkdocs redirects."""
|
|
if not url:
|
|
return None
|
|
|
|
# Extract path after /api/
|
|
match = re.search(r'/api/(.+?)/?$', url)
|
|
if not match:
|
|
return None
|
|
|
|
# URL-decode each path component
|
|
path_parts = [unquote(part) for part in match.group(1).split('/')]
|
|
relative_path = 'api/' + '/'.join(path_parts) + '.md'
|
|
|
|
docs_root = os.path.join(REPO_ROOT, 'docs', 'mkdocs', 'docs')
|
|
direct_path = os.path.join(docs_root, relative_path)
|
|
|
|
# Class-overview URLs (a single path segment after /api/, e.g. ".../api/json_pointer/")
|
|
# use inconsistent on-disk conventions across classes: ordered_map.md is a flat file,
|
|
# byte_container_with_subtype/byte_container_with_subtype.md nests under a same-named
|
|
# file, json_pointer/index.md nests under index.md. Try all observed conventions.
|
|
candidates = [direct_path]
|
|
if len(path_parts) == 1:
|
|
stem = path_parts[0]
|
|
candidates.append(os.path.join(docs_root, 'api', stem, 'index.md'))
|
|
candidates.append(os.path.join(docs_root, 'api', stem, stem + '.md'))
|
|
|
|
redirected = REDIRECT_MAP.get(relative_path)
|
|
if redirected:
|
|
candidates.append(os.path.join(docs_root, redirected))
|
|
|
|
for candidate in candidates:
|
|
if os.path.exists(candidate):
|
|
return candidate
|
|
|
|
# Nothing resolved -- return the direct path so the caller reports a meaningful "missing" location.
|
|
return direct_path
|
|
|
|
|
|
def check_docs(snapshot_path: str):
|
|
"""Check documentation for all API entries."""
|
|
if not os.path.exists(snapshot_path):
|
|
print(f"Error: Snapshot file not found: {snapshot_path}")
|
|
sys.exit(1)
|
|
|
|
with open(snapshot_path, 'r') as f:
|
|
data = json.load(f)
|
|
|
|
api = data.get('public_api', {})
|
|
documented_non_public = data.get('documented_non_public', [])
|
|
|
|
print(f"Checking documentation for {len(api)} API entries...")
|
|
print(120 * "-")
|
|
|
|
# Check public API entries
|
|
for _identity_key, entry in sorted(api.items()):
|
|
location = entry.get('location', 'unknown')
|
|
name = entry.get('name', '?')
|
|
tier = entry.get('tier', '?')
|
|
doc_url = entry.get('doc_url')
|
|
has_sa = entry.get('has_sa', False)
|
|
|
|
# Skip type_exempt tier entries
|
|
if tier == 'type_exempt':
|
|
continue
|
|
|
|
# Check for missing @sa
|
|
if not has_sa:
|
|
report('docs/missing_sa_comment', location,
|
|
f'public API "{name}" (tier: {tier}) has no @sa documentation link')
|
|
continue
|
|
|
|
# Verify URL resolves to an existing file
|
|
doc_file = url_to_docfile(doc_url)
|
|
if not doc_file:
|
|
report('docs/invalid_sa_url', location,
|
|
f'public API "{name}" has invalid @sa URL: {doc_url}')
|
|
continue
|
|
|
|
if not os.path.exists(doc_file):
|
|
display_path = os.path.relpath(doc_file, REPO_ROOT)
|
|
report('docs/missing_doc_file', location,
|
|
f'public API "{name}" @sa URL points to non-existent file: {display_path}')
|
|
|
|
# Check documented_non_public entries
|
|
for entry in documented_non_public:
|
|
location = entry.get('location', 'unknown')
|
|
reason = entry.get('reason', '?')
|
|
report('docs/sa_on_non_public', location,
|
|
f'@sa comment found on non-public entity: {reason}')
|
|
|
|
print(120 * "-")
|
|
|
|
if warnings > 0:
|
|
print(f"\nFound {warnings} documentation issues")
|
|
return False
|
|
else:
|
|
print("\nAll public API entries are properly documented ✓")
|
|
return True
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(description='Check documentation for public API')
|
|
parser.add_argument('--snapshot', default='api_snapshot.json',
|
|
help='Path to API snapshot JSON file')
|
|
|
|
args = parser.parse_args()
|
|
|
|
if not check_docs(args.snapshot):
|
|
sys.exit(1)
|
|
|
|
|
|
if __name__ == '__main__':
|
|
main()
|