Commit 9a31116fe6 for openssl.org

commit 9a31116fe657f1d9a4d720b31159a7d1d7bfc393
Author: Bob Beck <beck@openssl.org>
Date:   Thu Jul 9 12:02:53 2026 -0600

    Make a doc comments checker to enforce DOCUMENTATION.md

    For the documentation requirements laid out in  https://github.com/openssl/openssl/pull/29295

    Run in pre-commit and CI,

    Reviewed-by: Norbert Pocs <norbertp@openssl.org>
    Reviewed-by: Andrew Dinh <andrewd@openssl.org>
    Merge-date: Mon Nov  2 08:45:01 2026
    Merged-from: https://github.com/openssl/openssl/pull/31914

diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index aab58f03a3..14cb3db6d1 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -17,3 +17,11 @@ repos:
                   crypto/objects/obj_xref.h|
                   include/openssl/obj_mac.h
                  )$
+  - repo: local
+    hooks:
+      - id: "doc-comments"
+        name: "documentation comments on new function declarations"
+        entry: "perl util/check-doc-comments.pl"
+        language: "system"
+        files: '\.h(\.in)?$'
+        require_serial: true
diff --git a/util/check-doc-comments.pl b/util/check-doc-comments.pl
new file mode 100755
index 0000000000..e27e22e209
--- /dev/null
+++ b/util/check-doc-comments.pl
@@ -0,0 +1,347 @@
+#! /usr/bin/env perl
+# Copyright 2026 The OpenSSL Project Authors. All Rights Reserved.
+#
+# Licensed under the Apache License 2.0 (the "License").  You may not use
+# this file except in compliance with the License.  You can obtain a copy
+# in the file LICENSE in the source distribution or at
+# https://www.openssl.org/source/license.html
+
+# check-doc-comments.pl - check that new or changed function declarations in
+# header files carry the documentation comments required by DOCUMENTATION.md
+# and STYLE.md.  It only verifies that the required documentation is present;
+# it does not judge whether the content is correct.
+#
+# Those two documents are authoritative.  The rules below come from STYLE.md
+# "Applicability" and "Doxygen comments" (including "Public functions: link the
+# manual page") and DOCUMENTATION.md "Public symbols in the libraries" and
+# "Internal functions, structures, globals and macros".
+#
+# Runs as a pre-commit hook (.pre-commit-config.yaml) and in CI via
+# .github/workflows/style-checks.yml on a pull request's changed files.  Only
+# function prototypes on added or modified lines are checked; unchanged code is
+# left alone.
+#
+# Rules:
+#   * public headers (include/openssl/...): the prototype must be immediately
+#     preceded by a /** ... */ doxygen block containing "@see NAME(3)" for the
+#     function's own name, and that manual page must exist in the doc/ tree.
+#   * other (internal) headers: the prototype must be immediately preceded by a
+#     /** ... */ block with a @param for each named parameter and a @returns
+#     when the return type is not void.  When the parameter list cannot be
+#     parsed with confidence the @param check is skipped; the block is still
+#     required.
+#
+# Not checked: static functions, non-function declarations (macros, typedefs,
+# function-pointer typedefs), and declarations carrying a "doc-exempt" marker.
+# The marker invokes the trivial-item exception from DOCUMENTATION.md; place
+# /* doc-exempt: <reason> */ immediately before the declaration.
+#
+# The diff range is the first of these that applies:
+#   * pre-commit's --from-ref and --to-ref
+#   * $DOC_COMMENTS_RANGE
+#   * the staged changes
+#   * the merge base with the upstream default branch
+#   * HEAD~1
+
+use strict;
+use warnings;
+
+my @files = grep { /\.h(?:\.in)?$/ && -f $_ } @ARGV;
+exit 0 unless @files;
+
+my $range     = diff_range();
+print STDERR "check-doc-comments: diffing changed lines against '$range'\n";
+my %doc_name  = harvest_pod_names();          # NAME => { section => 1 }
+my $nerrors   = 0;
+
+for my $file (@files) {
+    # Skip a generated header when its .h.in source exists; the source is
+    # what contributors edit and what carries the declarations.
+    next if $file =~ /\.h$/ && -f "$file.in";
+    my %changed = changed_lines($range, $file);
+    next unless %changed;
+    check_file($file, \%changed);
+}
+
+print STDERR "note: a trivial function may be exempted with a "
+    . "\"/* doc-exempt: <reason> */\" comment immediately before its "
+    . "declaration\n"
+    if $nerrors;
+
+exit($nerrors ? 1 : 0);
+
+# ---------------------------------------------------------------------------
+
+sub nit {
+    my ($file, $line, $msg) = @_;
+    print STDERR "$file:$line: $msg\n";
+    $nerrors++;
+}
+
+sub is_public { return $_[0] =~ m{(?:^|/)include/openssl/}; }
+
+sub diff_range {
+    my ($from, $to) = ($ENV{PRE_COMMIT_FROM_REF}, $ENV{PRE_COMMIT_TO_REF});
+    if ($from && $to) {
+        # A shallow clone, as in CI, may lack the merge base that FROM...TO
+        # needs; FROM..TO compares the two trees alone.
+        `git merge-base $from $to 2>/dev/null`;
+        return $? == 0 ? "$from...$to" : "$from..$to";
+    }
+    return $ENV{DOC_COMMENTS_RANGE} if $ENV{DOC_COMMENTS_RANGE};
+    my $staged = `git diff --cached --name-only 2>/dev/null`;
+    return '--cached' if length $staged;
+    for my $base (qw(upstream/master origin/master master)) {
+        chomp(my $mb = `git merge-base $base HEAD 2>/dev/null`);
+        return $mb if $mb ne '';
+    }
+    return 'HEAD~1';
+}
+
+# Set of new-side line numbers added/changed for $file in $range.
+sub changed_lines {
+    my ($range, $file) = @_;
+    my @cmd = $range eq '--cached'
+        ? ('git', 'diff', '--cached', '-U0', '--', $file)
+        : ('git', 'diff', '-U0', $range, '--', $file);
+    my %ch;
+    open my $fh, '-|', @cmd or return %ch;
+    while (<$fh>) {
+        next unless /^\@\@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? \@\@/;
+        my ($start, $count) = ($1, defined $2 ? $2 : 1);
+        next unless $count > 0;
+        $ch{$_} = 1 for $start .. $start + $count - 1;
+    }
+    close $fh;
+    return %ch;
+}
+
+# Build NAME => { section => 1 } from doc/man*/*.pod on disk (the working
+# tree), so a page added in the same change resolves.
+sub harvest_pod_names {
+    my %names;
+    for my $pod (glob 'doc/man*/*.pod') {
+        my ($sec) = $pod =~ m{doc/man(\d)/} or next;
+        open my $fh, '<', $pod or next;
+        my ($in_name, $buf) = (0, '');
+        while (<$fh>) {
+            if (/^=head1\s+NAME/)      { $in_name = 1; next; }
+            if ($in_name && /^=head1/) { last; }
+            $buf .= $_ if $in_name;
+        }
+        close $fh;
+        $buf =~ s/\s+-\s+.*\z//s;          # drop the " - description" tail
+        $names{$_}{$sec} = 1 for ($buf =~ /([A-Za-z_]\w*)/g);
+    }
+    return %names;
+}
+
+sub check_file {
+    my ($file, $changed) = @_;
+    open my $fh, '<', $file or return;
+    my @lines = <$fh>;
+    close $fh;
+    chomp @lines;
+    my $is_hin = $file =~ /\.h\.in$/ ? 1 : 0;
+
+    for my $p (@{ parse_prototypes(\@lines, $is_hin) }) {
+        # Only new/changed declarations.
+        next unless grep { $changed->{$_} } $p->{start} .. $p->{end};
+
+        my ($block, $doxy) = preceding_block(\@lines, $p->{start});
+        next if defined $block && $block =~ /doc-exempt/;   # opt-out
+
+        if (!$doxy) {
+            nit($file, $p->{start},
+                "$p->{name}(): missing doxygen /** */ documentation block");
+            next;
+        }
+
+        if (is_public($file)) {
+            if ($block !~ /\@see\b[^*]*?\b\Q$p->{name}\E\s*\(3\)/s) {
+                nit($file, $p->{start},
+                    "$p->{name}(): doxygen block must link its manual page "
+                    . "with \@see $p->{name}(3)");
+            } elsif (!$doc_name{$p->{name}}{3}) {
+                nit($file, $p->{start},
+                    "$p->{name}(): \@see $p->{name}(3) has no matching "
+                    . "doc/man3 page");
+            }
+        } else {
+            # Report a function's missing tags on one line; they are all fixed
+            # in the single doxygen block attached to the declaration.
+            my @clauses;
+            push @clauses, "\@returns for the return value"
+                if !$p->{ret_void} && $block !~ /\@returns?\b/;
+            my @params;
+            unless ($p->{params_uncertain}) {        # fail open on parse doubt
+                # Harvest the names doxygen actually documents.  Accept an
+                # optional [in]/[out]/[in,out] direction attribute and a
+                # comma-separated list of names on a single @param line.
+                my %documented;
+                while ($block =~ /\@param\b\s*(?:\[[^\]]*\])?\s*
+                                  ([A-Za-z_]\w*(?:\s*,\s*[A-Za-z_]\w*)*)/gx) {
+                    $documented{$_} = 1 for split /\s*,\s*/, $1;
+                }
+                for my $param (@{ $p->{params} }) {
+                    push @params, "'$param'" unless $documented{$param};
+                }
+            }
+            if (@params) {
+                my $word = @params == 1 ? 'parameter' : 'parameters';
+                push @clauses, "\@param for $word " . join(', ', @params);
+            }
+            nit($file, $p->{start},
+                "$p->{name}(): doxygen block missing " . join(' and ', @clauses))
+                if @clauses;
+        }
+    }
+}
+
+# Find the doxygen/comment block immediately preceding (adjacent to) $start
+# (1-indexed).  Returns ($text, $is_doxygen).
+sub preceding_block {
+    my ($lines, $start) = @_;
+    my $i = $start - 2;                    # 0-indexed line just above
+    return (undef, 0) if $i < 0 || $lines->[$i] !~ m{\*/\s*$};
+    my @blk;
+    while ($i >= 0) {
+        unshift @blk, $lines->[$i];
+        last if $lines->[$i] =~ m{/\*};
+        $i--;
+    }
+    my $doxy = ($blk[0] // '') =~ m{/\*\*} ? 1 : 0;
+    # Strip the leading " * " from each comment line so a tag that spans lines
+    # (e.g. a multi-line @see list) can be matched without the '*' at the start
+    # of a continuation line interfering.
+    (my $text = join("\n", @blk)) =~ s/^\s*\*\s?//gm;
+    return ($text, $doxy);
+}
+
+# Return arrayref of prototypes: { name, start, end, ret_void, params,
+# params_uncertain }.  Comments are blanked first so they do not interfere.
+sub parse_prototypes {
+    my ($lines, $is_hin) = @_;
+    my @code = blank_noncode($lines, $is_hin);
+
+    # Prototypes appear at file scope or directly inside an extern "C" {}
+    # linkage block; never inside a struct/union/enum body or a function
+    # body.  So track a "skip depth": every '{' opens a skip scope EXCEPT an
+    # extern "..." linkage block, which is transparent.  Prototypes are only
+    # recorded when the skip depth is zero.
+    my @protos;
+    my @stack;                          # 1 = skip brace, 0 = transparent
+    my ($stmt, $sline, $sskip, $skip) = ('', 0, 0, 0);
+    for my $idx (0 .. $#code) {
+        next if $lines->[$idx] =~ /^\s*#/;         # preprocessor directive
+        for my $ch (split //, $code[$idx]) {
+            if ($ch eq '{') {
+                (my $pre = $stmt) =~ s/\s+$//;
+                my $transparent = $pre =~ /\bextern\s*"[^"]*"$/ ? 1 : 0;
+                push @stack, $transparent ? 0 : 1;
+                $skip++ unless $transparent;
+                $stmt = '';
+            } elsif ($ch eq '}') {
+                my $was = pop @stack;
+                $skip-- if $was;
+                $skip = 0 if $skip < 0;
+                $stmt = '';
+            } elsif ($ch eq ';') {
+                if ($sskip == 0 && $stmt !~ /[{}]/) {
+                    my $p = classify_stmt($stmt . ';', $sline, $idx + 1);
+                    push @protos, $p if $p;
+                }
+                $stmt = '';
+            } else {
+                if ($stmt eq '' && $ch =~ /\S/) { $sline = $idx + 1; $sskip = $skip; }
+                $stmt .= $ch if $stmt ne '' || $ch =~ /\S/;
+            }
+        }
+        $stmt .= ' ' if $stmt ne '';
+    }
+    return \@protos;
+}
+
+sub classify_stmt {
+    my ($raw, $start, $end) = @_;
+    (my $s = $raw) =~ s/\s+/ /g;
+    $s =~ s/^\s+//;
+    return undef if $s =~ /^typedef\b/;                      # typedef
+    return undef if $s =~ /^\s*static\b/;                    # static fn
+    # Collapse type macros to a plain type token so a return type like
+    # "const STACK_OF(X509) *" does not get mistaken for a function named
+    # STACK_OF.
+    $s =~ s/\b(?:STACK_OF|LHASH_OF|SPARSE_ARRAY_OF|PRIORITY_QUEUE_OF|OSSL_LIST
+              |OSSL_RBT_HEAD|OSSL_RBT_ENTRY)\s*\([^()]*\)/OSSL_TYPE/gx;
+    # strip leading attribute / deprecation qualifier macros
+    1 while $s =~ s/^(?:OSSL_DEPRECATEDIN_\S+|__owur|ossl_unused|ossl_inline
+                     |ossl_noreturn|OSSL_CRYPTO_ALLOC)\s+//x;
+    # strip a trailing attribute, e.g. "__attribute__((destructor))", after
+    # the parameter list
+    $s =~ s/\)\s*\w+\s*\(\(.*\)\)\s*;$/);/;
+    # ret-type (must contain a space or '*' before the name) then NAME(args);
+    return undef unless
+        $s =~ /^((?:\w[\w ]*?)[ \*]+)([A-Za-z_]\w*)\s*\((.*)\)\s*;$/;
+    my ($ret, $name, $args) = ($1, $2, $3);
+    (my $rt = $ret) =~ s/\s+$//;
+    my $ret_void = ($rt eq 'void') ? 1 : 0;
+
+    my ($params, $uncertain) = parse_params($args);
+    return {
+        name             => $name,
+        start            => $start,
+        end              => $end,
+        ret_void         => $ret_void,
+        params           => $params,
+        params_uncertain => $uncertain,
+    };
+}
+
+# Extract parameter names.  Returns (\@names, $uncertain).  We fail open
+# (uncertain=1) on anything we cannot confidently name.
+sub parse_params {
+    my ($args) = @_;
+    $args =~ s/^\s+//; $args =~ s/\s+$//;
+    return ([], 0) if $args eq '' || $args eq 'void';
+    return ([], 1) if $args =~ /\Q...\E/ || $args =~ /\(\s*\*/;  # varargs / fn-ptr
+    my @names;
+    for my $a (split /,/, $args) {
+        $a =~ s/\[.*?\]//g;                       # drop array dims
+        if ($a =~ /([A-Za-z_]\w*)\s*$/) {
+            my $n = $1;
+            # a trailing bare type keyword => unnamed parameter => uncertain
+            return ([], 1) if $n =~ /^(void|int|char|long|short|unsigned|
+                                       signed|size_t|double|float)$/x;
+            push @names, $n;
+        } else {
+            return ([], 1);
+        }
+    }
+    return (\@names, 0);
+}
+
+# Blank out /* */ and // comments, and (for .h.in files) {- ... -} template
+# fragments, preserving line count and columns so line numbers stay accurate.
+sub blank_noncode {
+    my ($lines, $is_hin) = @_;
+    my @out;
+    my $mode = 0;                       # 0 code, 1 /* */ comment, 2 {- -} template
+    for my $l (@$lines) {
+        my ($o, $j) = ('', 0);
+        while ($j < length $l) {
+            my $two = substr($l, $j, 2);
+            if ($mode == 1) {
+                if ($two eq '*/') { $mode = 0; $o .= '  '; $j += 2; }
+                else              { $o .= ' ';             $j++;    }
+            } elsif ($mode == 2) {
+                if ($two eq '-}') { $mode = 0; $o .= '  '; $j += 2; }
+                else              { $o .= ' ';             $j++;    }
+            } elsif ($two eq '/*')            { $mode = 1; $o .= '  '; $j += 2; }
+            elsif ($is_hin && $two eq '{-')   { $mode = 2; $o .= '  '; $j += 2; }
+            elsif ($two eq '//') { $o .= ' ' x (length($l) - $j);   last; }
+            else                 { $o .= substr($l, $j, 1);         $j++;   }
+        }
+        push @out, $o;
+    }
+    return @out;
+}