diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b1ea67b..ad5989a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -27,6 +27,7 @@ jobs: llvmdev cxx-compiler zlib + python - name: Configure run: cmake -S . -B build -GNinja -DCMAKE_BUILD_TYPE=Release -DCMAKE_PREFIX_PATH="${CONDA_PREFIX}" diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml index 763c22b..b1f5530 100644 --- a/.github/workflows/pages.yml +++ b/.github/workflows/pages.yml @@ -56,6 +56,11 @@ jobs: python docs/gen_self_docs.py --tool ./build/apiary python docs/extract_cmake_docs.py docs/cmake.rst + - name: Doc-quality lint (apiary lints its own docs) + # gen_self_docs.py emits docs/api/*.json — cross-check doc vs signature. + # --strict so any drift (incl. missing-param warnings) fails the build. + run: python scripts/doc_lint.py --strict docs/api/*.json + - name: Build HTML run: sphinx-build -b html -W docs docs/_build/html diff --git a/CMakeLists.txt b/CMakeLists.txt index 4f12673..bd9d404 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -179,4 +179,12 @@ if(EINSUMS_WITH_TESTS OR CMAKE_SOURCE_DIR STREQUAL CMAKE_CURRENT_SOURCE_DIR) "$" "${CMAKE_CURRENT_SOURCE_DIR}/include" ) + # doc_lint.py: doc-quality validator over the docs-JSON IR. Pure Python, + # so it needs no apiary binary — it diffs diagnostics for a seeded-drift + # fixture against a golden. Prefer a CMake-found interpreter, else python3. + find_package(Python COMPONENTS Interpreter QUIET) + add_test(NAME apiary_doc_lint + COMMAND bash "${CMAKE_CURRENT_SOURCE_DIR}/tests/run_doc_lint.sh" + "$,${Python_EXECUTABLE},python3>" + ) endif() diff --git a/scripts/doc_lint.py b/scripts/doc_lint.py new file mode 100755 index 0000000..51e92a5 --- /dev/null +++ b/scripts/doc_lint.py @@ -0,0 +1,214 @@ +#!/usr/bin/env python3 +# Copyright (c) The Einsums Developers. All rights reserved. +# Licensed under the MIT License. See LICENSE.txt in the project root. +"""Doc-quality validator over apiary's public-API JSON IR. + +apiary already parses both the *signature* (real parameter names, template +parameters, return type) and the *doc comment* (``@param`` / ``@tparam`` / +``@return`` / ``@throws``) and emits both in the ``--emit-cpp-docs-json`` +output. This tool cross-checks the two so documentation drift becomes a CI +gate rather than something discovered at render time: + + - a ``@param``/``@tparam`` that names something the signature doesn't have + (a typo or a parameter that was renamed/removed) -> error; + - a parameter/template-parameter left undocumented when its siblings are + documented (partial docs -> likely an oversight) -> warning; + - ``@return`` text on a ``void`` function -> warning; + - a malformed ``@throws`` with no exception type -> warning. + +Input is one or more docs-JSON files (the output of ``apiary +--emit-cpp-docs-json``). Diagnostics are printed as +``file:line:col: severity: message [check]`` (editor/CI parseable). Exit +status is non-zero when any error is found (or any warning under --strict). + + apiary --emit-cpp-docs-json --module m header.hpp -- > m.json + doc_lint.py m.json +""" +from __future__ import annotations + +import argparse +import json +import re +import sys +from dataclasses import dataclass + +# check id -> default severity +SEVERITY = { + "unknown-param": "error", + "unknown-tparam": "error", + "missing-param": "warning", + "missing-tparam": "warning", + "returns-on-void": "warning", + "malformed-throws": "warning", +} + + +@dataclass +class Finding: + file: str + line: int + col: int + severity: str + check: str + message: str + + def sort_key(self) -> tuple: + return (self.file, self.line, self.col, self.check) + + def text(self) -> str: + return f"{self.file}:{self.line}:{self.col}: {self.severity}: {self.message} [{self.check}]" + + +# ``@param[in,out] name`` -> ``name``; ``@param a, b`` -> ``a``, ``b``. +_DIR_PREFIX = re.compile(r"^\[[^\]]*\]\s*") + + +def doc_names(entries: list[dict]) -> list[str]: + """Parameter/tparam names a doc comment claims, normalized. + + Strips a leading Doxygen direction prefix (``[in]``/``[out]``/``[in,out]``) + and splits the rare comma-grouped ``@param a, b`` form into separate names. + """ + out: list[str] = [] + for e in entries: + raw = _DIR_PREFIX.sub("", (e.get("name") or "").strip()) + for piece in raw.split(","): + nm = piece.strip() + if nm: + out.append(nm) + return out + + +def real_param_names(callable_obj: dict) -> list[str]: + """Named parameters in the signature (unnamed params are skipped).""" + names = [] + for p in callable_obj.get("params", []): + # functions/methods: params are objects {name,type,...}; function-like + # macros: params are bare strings. + nm = p.get("name") if isinstance(p, dict) else p + if nm: + names.append(nm) + return names + + +_VOID = re.compile(r"^\s*(const\s+|volatile\s+)*void\s*$") + + +def returns_void(callable_obj: dict) -> bool: + if callable_obj.get("is_constructor") or callable_obj.get("is_destructor"): + return False + rt = callable_obj.get("return_type_canonical") or callable_obj.get("return_type") or "" + return bool(_VOID.match(rt)) + + +def loc(entity: dict) -> tuple[str, int, int]: + location = entity.get("location") or {} + return location.get("file", ""), int(location.get("line", 0)), int(location.get("column", 0)) + + +def check_callable(entity: dict, *, kind: str, findings: list[Finding]) -> None: + """Cross-check one function/method/constructor/function-like macro.""" + ds = entity.get("doc_structured") or {} + name = entity.get("qualified_name") or entity.get("name") or "" + f, ln, col = loc(entity) + + def add(check: str, msg: str) -> None: + findings.append(Finding(f, ln, col, SEVERITY[check], check, msg)) + + # --- parameters --- + real = real_param_names(entity) + documented = doc_names(ds.get("params", [])) + real_set, doc_set = set(real), set(documented) + for d in documented: + if d not in real_set: + add("unknown-param", f"{name}: @param '{d}' does not name a parameter") + # only flag undocumented params when SOME params are already documented + # (a fully-undocumented parameter list is the ratchet's concern, not ours) + if documented: + for r in real: + if r not in doc_set: + add("missing-param", f"{name}: parameter '{r}' is undocumented") + + # --- template parameters --- + real_tp = [t for t in entity.get("template_params", []) if t] + doc_tp = doc_names(ds.get("tparams", [])) + real_tp_set, doc_tp_set = set(real_tp), set(doc_tp) + for d in doc_tp: + if d not in real_tp_set: + add("unknown-tparam", f"{name}: @tparam '{d}' does not name a template parameter") + if doc_tp: + for r in real_tp: + if r not in doc_tp_set: + add("missing-tparam", f"{name}: template parameter '{r}' is undocumented") + + # --- return --- + if (ds.get("returns") or "").strip() and returns_void(entity): + add("returns-on-void", f"{name}: @return documented on a void {kind}") + + # --- throws --- + for t in ds.get("throws", []): + if not (t.get("name") or "").strip(): + add("malformed-throws", f"{name}: @throws with no exception type") + + +def walk_class(cls: dict, findings: list[Finding]) -> None: + for m in cls.get("constructors", []): + check_callable(m, kind="constructor", findings=findings) + for m in cls.get("methods", []): + check_callable(m, kind="method", findings=findings) + for nested in cls.get("nested_classes", []): + walk_class(nested, findings) + + +def lint_module(doc: dict, findings: list[Finding]) -> None: + for fn in doc.get("functions", []): + check_callable(fn, kind="function", findings=findings) + for cls in doc.get("classes", []): + walk_class(cls, findings) + for mac in doc.get("macros", []): + if mac.get("is_function_like"): + check_callable(mac, kind="macro", findings=findings) + + +def main(argv: list[str] | None = None) -> int: + ap = argparse.ArgumentParser(description="Cross-check apiary docs JSON for doc/signature drift.") + ap.add_argument("files", nargs="+", help="docs-JSON files (apiary --emit-cpp-docs-json output)") + ap.add_argument("--format", choices=("text", "json"), default="text") + ap.add_argument("--strict", action="store_true", help="treat warnings as errors for the exit status") + ap.add_argument("--select", default="", help="comma-separated check ids to run (default: all)") + args = ap.parse_args(argv) + + selected = set(filter(None, args.select.split(","))) or set(SEVERITY) + unknown = selected - set(SEVERITY) + if unknown: + ap.error(f"unknown check id(s): {', '.join(sorted(unknown))}; valid: {', '.join(sorted(SEVERITY))}") + + findings: list[Finding] = [] + for path in args.files: + try: + with open(path) as fh: + doc = json.load(fh) + except (OSError, json.JSONDecodeError) as e: + print(f"doc_lint: cannot read {path}: {e}", file=sys.stderr) + return 2 + lint_module(doc, findings) + + findings = [f for f in findings if f.check in selected] + findings.sort(key=Finding.sort_key) + + n_err = sum(f.severity == "error" for f in findings) + n_warn = sum(f.severity == "warning" for f in findings) + + if args.format == "json": + json.dump([f.__dict__ for f in findings], sys.stdout, indent=2) + sys.stdout.write("\n") + else: + for f in findings: + print(f.text()) + print(f"doc_lint: {n_err} error(s), {n_warn} warning(s)", file=sys.stderr) + + return 1 if (n_err or (args.strict and n_warn)) else 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/fixtures/doc_lint_drift.json b/tests/fixtures/doc_lint_drift.json new file mode 100644 index 0000000..2c4d603 --- /dev/null +++ b/tests/fixtures/doc_lint_drift.json @@ -0,0 +1,90 @@ +{ + "schema_version": 1, + "module": "fixture_doc_drift", + "classes": [ + { + "name": "Widget", + "qualified_name": "Widget", + "location": { "file": "doc_drift.hpp", "line": 30, "column": 7 }, + "doc_structured": { "brief": "A widget.", "detail": "", "params": [], "tparams": [], "returns": "", "throws": [] }, + "constructors": [ + { + "name": "Widget", + "qualified_name": "Widget::Widget", + "location": { "file": "doc_drift.hpp", "line": 31, "column": 5 }, + "return_type": "", + "is_constructor": true, + "params": [ { "name": "n", "type": "int" } ], + "template_params": [], + "doc_structured": { "brief": "Build a widget.", "detail": "", "params": [], "tparams": [], "returns": "", "throws": [] } + } + ], + "methods": [ + { + "name": "resize", + "qualified_name": "Widget::resize", + "location": { "file": "doc_drift.hpp", "line": 33, "column": 10 }, + "return_type": "void", + "return_type_canonical": "void", + "is_constructor": false, + "params": [ { "name": "w", "type": "int" }, { "name": "h", "type": "int" } ], + "template_params": [], + "doc_structured": { + "brief": "Resize.", "detail": "", + "params": [ { "name": "w", "description": "width" } ], + "tparams": [], "returns": "", "throws": [] + } + } + ], + "nested_classes": [] + } + ], + "functions": [ + { + "name": "compute", + "qualified_name": "compute", + "location": { "file": "doc_drift.hpp", "line": 10, "column": 6 }, + "return_type": "void", + "return_type_canonical": "void", + "params": [ { "name": "a", "type": "int" }, { "name": "b", "type": "int" } ], + "template_params": [], + "doc_structured": { + "brief": "Compute something.", "detail": "", + "params": [ { "name": "a", "description": "first" }, { "name": "c", "description": "typo" } ], + "tparams": [], "returns": "the result", "throws": [] + } + }, + { + "name": "transform", + "qualified_name": "transform", + "location": { "file": "doc_drift.hpp", "line": 20, "column": 3 }, + "return_type": "int", + "return_type_canonical": "int", + "params": [ { "name": "x", "type": "int" } ], + "template_params": [ "T" ], + "doc_structured": { + "brief": "Transform x.", "detail": "", + "params": [ { "name": "x", "description": "input" } ], + "tparams": [ { "name": "U", "description": "wrong" } ], + "returns": "", "throws": [ { "name": "", "description": "on failure" } ] + } + } + ], + "enums": [], + "typedefs": [], + "concepts": [], + "macros": [ + { + "name": "MAX", + "qualified_name": "MAX", + "location": { "file": "doc_drift.hpp", "line": 40, "column": 9 }, + "is_function_like": true, + "params": [ "a", "b" ], + "doc_structured": { + "brief": "Max of two.", "detail": "", + "params": [ { "name": "a", "description": "first" }, { "name": "z", "description": "typo" } ], + "tparams": [], "returns": "", "throws": [] + } + } + ] +} diff --git a/tests/golden/doc_lint_drift.txt.golden b/tests/golden/doc_lint_drift.txt.golden new file mode 100644 index 0000000..b9b5e2a --- /dev/null +++ b/tests/golden/doc_lint_drift.txt.golden @@ -0,0 +1,9 @@ +doc_drift.hpp:10:6: warning: compute: parameter 'b' is undocumented [missing-param] +doc_drift.hpp:10:6: warning: compute: @return documented on a void function [returns-on-void] +doc_drift.hpp:10:6: error: compute: @param 'c' does not name a parameter [unknown-param] +doc_drift.hpp:20:3: warning: transform: @throws with no exception type [malformed-throws] +doc_drift.hpp:20:3: warning: transform: template parameter 'T' is undocumented [missing-tparam] +doc_drift.hpp:20:3: error: transform: @tparam 'U' does not name a template parameter [unknown-tparam] +doc_drift.hpp:33:10: warning: Widget::resize: parameter 'h' is undocumented [missing-param] +doc_drift.hpp:40:9: warning: MAX: parameter 'b' is undocumented [missing-param] +doc_drift.hpp:40:9: error: MAX: @param 'z' does not name a parameter [unknown-param] diff --git a/tests/run_doc_lint.sh b/tests/run_doc_lint.sh new file mode 100755 index 0000000..a10d69a --- /dev/null +++ b/tests/run_doc_lint.sh @@ -0,0 +1,62 @@ +#!/usr/bin/env bash +# ---------------------------------------------------------------------------------------------- +# Copyright (c) The Einsums Developers. All rights reserved. +# Licensed under the MIT License. See LICENSE.txt in the project root for license information. +# ---------------------------------------------------------------------------------------------- +# +# doc_lint.py test. Runs the doc-quality validator over a docs-JSON fixture +# seeded with every drift class and diffs the diagnostics against a committed +# golden, then checks a clean input exits 0. +# +# Updating the golden: run with REGEN=1. +# +# Invocation: +# run_doc_lint.sh [python-executable] + +set -euo pipefail + +readonly PY="${1:-python3}" +readonly SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +readonly ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" +readonly LINT="${ROOT}/scripts/doc_lint.py" +readonly FIXTURE="${SCRIPT_DIR}/fixtures/doc_lint_drift.json" +readonly GOLDEN="${SCRIPT_DIR}/golden/doc_lint_drift.txt.golden" +readonly REGEN="${REGEN:-0}" + +fail=0 + +# --- drift fixture: diagnostics must match the golden, exit status must be 1 --- +out="$("${PY}" "${LINT}" "${FIXTURE}" 2>/dev/null)" && rc=0 || rc=$? + +if [[ "${REGEN}" == "1" ]]; then + printf '%s\n' "${out}" > "${GOLDEN}" + echo "regenerated ${GOLDEN}" +else + if ! diff -u "${GOLDEN}" <(printf '%s\n' "${out}"); then + echo "FAIL: doc_lint output does not match golden" >&2 + fail=1 + fi + if [[ "${rc}" -ne 1 ]]; then + echo "FAIL: expected exit 1 on drift, got ${rc}" >&2 + fail=1 + fi +fi + +# --- clean input: no findings, exit 0 --- +clean="$(mktemp)" +trap 'rm -f "${clean}"' EXIT +cat > "${clean}" <<'JSON' +{ "schema_version": 1, "module": "clean", + "classes": [], "functions": [], "enums": [], "typedefs": [], "concepts": [], "macros": [] } +JSON +if "${PY}" "${LINT}" "${clean}" >/dev/null 2>&1; then + : # exit 0 as expected +else + echo "FAIL: clean input should exit 0" >&2 + fail=1 +fi + +if [[ "${fail}" -eq 0 ]]; then + echo "doc_lint: OK" +fi +exit "${fail}"