CI / WordPress latest + WooCommerce latest on PHP 8.3 (pull_request) Successful in 1m18s
Compatibility / WP latest / WC latest / PHP 7.4 (pull_request) Skipped
Compatibility / WP latest / WC beta / PHP 8.3 (pull_request) Skipped
Compatibility / WP latest / WC latest / PHP 8.3 (pull_request) Skipped
Compatibility / WP nightly / WC latest / PHP 8.3 (pull_request) Skipped
Compatibility / WP latest / WC latest / PHP 8.4 (pull_request) Skipped
Compatibility / Keep the compatibility headers honest (pull_request) Skipped
CI / Static checks (pull_request) Successful in 4m38s
Takes woo-security-manager's current checker (sibling-hook boundary via FAMILY_PREFIXES, table-row scoping), keeping the floor in ci/plugin.conf. dist-files.sh now lists the same files in a git-less copy, which is what ci_php's php container is handed on the Gitea runner; before, check-docs failed there with exit 127. Co-Authored-By: Claude Code <[email protected]>
607 lines
20 KiB
PHP
607 lines
20 KiB
PHP
<?php
|
|
/**
|
|
* Assert that docs/ exists, is not a stub, and still describes THIS tree.
|
|
*
|
|
* 🔴 The third check is the only one that earns its place. "The file exists"
|
|
* and "the version matches" are cheap and would both pass over documentation
|
|
* that describes a plugin we stopped shipping a year ago. The set difference
|
|
* between docs/CONTRACT.md and the source is the one that cannot.
|
|
*
|
|
* Two directions, because they fail differently:
|
|
*
|
|
* in the code, not in CONTRACT.md — a hook a sibling plugin cannot discover
|
|
* without reading our source. That is the hole Nolimit Forms fell into
|
|
* asking for a reputation filter that did not exist yet.
|
|
*
|
|
* in CONTRACT.md, not in the code — a comment describing a tree that is not
|
|
* this one. Renaming a hook and leaving the row is silent, and the row
|
|
* reads as deliberate.
|
|
*
|
|
* 🔴 It strips comments before looking at the source, and that is not a
|
|
* nicety: docs/CONTRACT.md documents `nlsec_request_reputation` by showing
|
|
* the `apply_filters()` call a consumer makes, and the same example is in
|
|
* class-reputation.php's docblock. A checker that reads comments finds this
|
|
* plugin applying a filter it answers — the self-consumption fault — in a file
|
|
* that is documenting why it must not. The most careful comment in the tree,
|
|
* reported as the bug it is warning about.
|
|
*
|
|
* Roles matter and are easy to get backwards: `add_filter` is how you ANSWER
|
|
* a filter, not how you consume one.
|
|
*
|
|
* Usage: php ci/check-docs.php <plugin-root>
|
|
*/
|
|
|
|
/**
|
|
* Is this hook part of a contract, or is it internal wiring?
|
|
*
|
|
* 🔴 A contract is a name SOMEBODY ELSE has to know. That is the whole rule,
|
|
* and getting it wrong in either direction ruins the check.
|
|
*
|
|
* The first version required every hook in the tree, which meant 50 core and
|
|
* WooCommerce names — `admin_menu`, `init`, `wp_login`, `woocommerce_login_form`
|
|
* — that we merely listen to. Listening to `init` is not a promise to anyone;
|
|
* WordPress documents `init`. Requiring them would have made CONTRACT.md a
|
|
* transcript of our own `add_action` calls, and the first person to read it
|
|
* would stop, which is worse than not having the check.
|
|
*
|
|
* So the test is the PREFIX, because the prefix is exactly "is this a name the
|
|
* family owns":
|
|
*
|
|
* do_action / apply_filters on nlsec_* or wpmgr_*
|
|
* we are the source, or we are asking the family. Document it.
|
|
*
|
|
* add_filter / add_action on wpmgr_*
|
|
* we are answering a name another plugin defined. It crosses a
|
|
* repository boundary, which is the class of value nobody's checks cover.
|
|
* Document it.
|
|
*
|
|
* add_filter / add_action on nlsec_*
|
|
* our own wiring. If it is also offered, the first rule already caught
|
|
* it; if it is not, nobody outside needs to know.
|
|
*
|
|
* anything else
|
|
* core, WooCommerce, a third party. Not ours to document.
|
|
*
|
|
* Narrowing it this way is not loosening it. It found three real gaps on the
|
|
* first run that the noisy version had buried under fifty core hooks:
|
|
* `wpmgr_ai_usage` (a family-wide hook this plugin EMITS and had never told
|
|
* anybody about), `nlsec_public_rest_routes` (a genuine extension point a form
|
|
* plugin needs when a shop locks REST down) and `nlsec_pending_wp_config`.
|
|
*
|
|
* @param string $call One of do_action, apply_filters, add_filter, add_action.
|
|
* @param string $hook The hook name.
|
|
* @return bool
|
|
*/
|
|
function is_contract( $call, $hook ) {
|
|
// 🔴 From the conf, never a literal. Hardcoding 'nlsec_' here is exactly
|
|
// the copied-kit trap this family has paid for twice: the check keeps
|
|
// working in the tree it was written in and silently classifies every hook
|
|
// in the next one as "not ours", so CONTRACT.md may be empty and the run
|
|
// is green. I wrote it as a literal first and caught it re-reading.
|
|
$prefix = hook_prefix();
|
|
|
|
$ours = 0 === strpos( $hook, $prefix );
|
|
$family = false;
|
|
|
|
foreach ( sibling_prefixes() as $other ) {
|
|
if ( 0 === strpos( $hook, $other ) ) {
|
|
$family = true;
|
|
break;
|
|
}
|
|
}
|
|
|
|
if ( ! $ours && ! $family ) {
|
|
return false;
|
|
}
|
|
|
|
if ( 'do_action' === $call || 'apply_filters' === $call ) {
|
|
return true;
|
|
}
|
|
|
|
// add_filter / add_action: only when the name is the family's, i.e. we are
|
|
// answering somebody else.
|
|
return $family;
|
|
}
|
|
|
|
/**
|
|
* The shipped-file floor, from MIN_SHIPPED_PHP_FILES in ci/plugin.conf.
|
|
*
|
|
* Refuses rather than defaulting: an unset floor parses to 0 and every
|
|
* comparison against it passes, which is a scan over zero files one level up.
|
|
*
|
|
* @return int
|
|
*/
|
|
function shipped_php_floor() {
|
|
global $conf_root;
|
|
|
|
$conf = $conf_root . '/ci/plugin.conf';
|
|
|
|
if ( is_file( $conf ) && preg_match( '/^MIN_SHIPPED_PHP_FILES=([0-9]+)/m', (string) file_get_contents( $conf ), $m ) && (int) $m[1] > 0 ) {
|
|
return (int) $m[1];
|
|
}
|
|
|
|
fwrite( STDERR, "::error::No usable MIN_SHIPPED_PHP_FILES in ci/plugin.conf — refusing to run with no floor.\n" );
|
|
exit( 1 );
|
|
}
|
|
|
|
/**
|
|
* This plugin's own hook prefix, from ci/plugin.conf.
|
|
*
|
|
* Refuses rather than guessing. TEXT_DOMAIN is not it — here that is
|
|
* nolimit-security while the hooks are nlsec_ — and a checker that guessed
|
|
* would guess wrong in this tree and silently right in another, which is the
|
|
* worse of the two.
|
|
*
|
|
* @return string
|
|
*/
|
|
function hook_prefix() {
|
|
static $prefix = null;
|
|
|
|
if ( null !== $prefix ) {
|
|
return $prefix;
|
|
}
|
|
|
|
global $conf_root;
|
|
|
|
$conf = $conf_root . '/ci/plugin.conf';
|
|
|
|
if ( is_file( $conf ) && preg_match( '/^HOOK_PREFIX=([A-Za-z0-9_]+)/m', (string) file_get_contents( $conf ), $m ) ) {
|
|
$prefix = strtolower( $m[1] );
|
|
|
|
return $prefix;
|
|
}
|
|
|
|
fwrite( STDERR, "::error::No HOOK_PREFIX in ci/plugin.conf — refusing to guess which hooks are this plugin's.\n" );
|
|
exit( 1 );
|
|
}
|
|
|
|
/**
|
|
* Prefixes owned by the family, ours excluded.
|
|
*
|
|
* 🔴 Without this the check has a hole on the CONSUMER side, found by Nolimit
|
|
* Forms by deleting two rows and watching it stay green. The first version
|
|
* tested `ours || wpmgr_`, which is right in the tree that wrote it — `nlsec_`
|
|
* IS its prefix — and wrong everywhere else: in Forms' tree `nlsec_` is
|
|
* neither theirs nor `wpmgr_`, so the whole Forms/Security boundary fell into
|
|
* "core, not ours to document" and a green was printed over its absence.
|
|
*
|
|
* That is the class `wpmgr_` was widened to catch in the first place: a name a
|
|
* SIBLING owns that we answer. `wpmgr_` was one instance of it, not the rule.
|
|
*
|
|
* ⚠️ This list goes stale the day a ninth plugin appears — the same fault as
|
|
* the hardcoded prefix, one level out, and it is worth saying rather than
|
|
* leaving to be found. It belongs in FAMILY.md once that exists, read from one
|
|
* place by all eight. Until then it is one line per repo, visible, in the file
|
|
* that already holds everything plugin-specific.
|
|
*
|
|
* Named "sibling" rather than "family" because PHPCS's compatibility sniff
|
|
* reads a function starting `fam` as the removed PHP 5.1 `fam_*` extension and
|
|
* fails the build on it — a false positive, but one that costs the next person
|
|
* the same ten minutes.
|
|
*
|
|
* @return array
|
|
*/
|
|
function sibling_prefixes() {
|
|
static $list = null;
|
|
|
|
if ( null !== $list ) {
|
|
return $list;
|
|
}
|
|
|
|
global $conf_root;
|
|
|
|
$conf = $conf_root . '/ci/plugin.conf';
|
|
$raw = is_file( $conf ) ? (string) file_get_contents( $conf ) : '';
|
|
|
|
/*
|
|
* 🔴 Refuse when the line is absent, rather than defaulting to wpmgr_.
|
|
*
|
|
* The default used to BE array( 'wpmgr_' ) — which is exactly the narrow
|
|
* behaviour this key exists to remove. So a tree that copied the kit and
|
|
* forgot the line got the consumer-side hole straight back, silently, with
|
|
* a plausible number and a green, while HOOK_PREFIX three lines away
|
|
* refused. Nolimit Forms named it: **a default that is the pre-fix
|
|
* behaviour is a fix that switches itself off.**
|
|
*
|
|
* `FAMILY_PREFIXES=""` is how a plugin says it answers no sibling hooks.
|
|
* Same argument as HOOK_PREFIX_MIN=0: "none" and "nobody filled this in"
|
|
* are otherwise identical silences, and only one of the two is a decision.
|
|
*
|
|
* Quotes are stripped because ci/plugin.conf is SOURCED by every ci/*.sh,
|
|
* so a bare space-separated value makes the shell try to run the second
|
|
* word. The site test found that, reporting
|
|
* `ci/plugin.conf: line 89: nlfrm_: command not found` — a message naming
|
|
* neither this file nor the setting.
|
|
*/
|
|
if ( ! preg_match( '/^FAMILY_PREFIXES=(.*)$/m', $raw, $m ) ) {
|
|
fwrite( STDERR, "::error::No FAMILY_PREFIXES in ci/plugin.conf. Without it a hook a SIBLING owns falls into \"core, not ours to document\" and this check goes green over the absence of the whole boundary. List the other plugins' prefixes, or set FAMILY_PREFIXES=\"\" to say this plugin answers none.\n" );
|
|
exit( 1 );
|
|
}
|
|
|
|
$list = preg_split( '/[\s,]+/', trim( $m[1], " \t\"'" ), -1, PREG_SPLIT_NO_EMPTY );
|
|
|
|
// Ours is handled separately; leaving it here too is harmless but makes the
|
|
// two arms of is_contract() overlap, which reads as a bug to the next person.
|
|
$list = array_values( array_diff( $list, array( hook_prefix() ) ) );
|
|
|
|
return $list;
|
|
}
|
|
|
|
$root = isset( $argv[1] ) ? rtrim( $argv[1], '/' ) : '.';
|
|
$docs = $root . '/docs';
|
|
|
|
global $conf_root;
|
|
$conf_root = $root;
|
|
|
|
$fails = 0;
|
|
|
|
/**
|
|
* Record a problem and print it where CI will pick it up.
|
|
*
|
|
* @param string $message What is wrong, naming the file or hook.
|
|
* @return void
|
|
*/
|
|
function fail( $message ) {
|
|
global $fails;
|
|
++$fails;
|
|
printf( "::error::%s\n", $message );
|
|
}
|
|
|
|
/*
|
|
* 1. The set exists and is not a stub.
|
|
*
|
|
* A floor, not a mere existence test: an empty file passes `is_file()`, and a
|
|
* heading with nothing under it passes any check written from the concept of
|
|
* "is it documented". The numbers are per-file because the documents have
|
|
* genuinely different jobs — README is one screen on purpose.
|
|
*/
|
|
$required = array(
|
|
'README.md' => 25,
|
|
'SETUP.md' => 40,
|
|
'ARCHITECTURE.md' => 60,
|
|
'CONTRACT.md' => 50,
|
|
'RELEASE.md' => 40,
|
|
);
|
|
|
|
$lines_total = 0;
|
|
|
|
foreach ( $required as $file => $floor ) {
|
|
$path = $docs . '/' . $file;
|
|
|
|
if ( ! is_file( $path ) ) {
|
|
fail( sprintf( 'docs/%s is missing.', $file ) );
|
|
continue;
|
|
}
|
|
|
|
$lines = count( file( $path, FILE_IGNORE_NEW_LINES ) );
|
|
$lines_total += $lines;
|
|
|
|
if ( $lines < $floor ) {
|
|
fail( sprintf( 'docs/%s has %d lines, under the floor of %d — a stub is not documentation.', $file, $lines, $floor ) );
|
|
}
|
|
}
|
|
|
|
if ( $fails > 0 ) {
|
|
printf( "\n%d problem(s) with the document set.\n", $fails );
|
|
exit( 1 );
|
|
}
|
|
|
|
printf( " %d documents, %d lines.\n", count( $required ), $lines_total );
|
|
|
|
/*
|
|
* 2. The version in the docs is this plugin's version.
|
|
*
|
|
* Named value, not "a version is mentioned": a check that merely finds a
|
|
* version-shaped string passes on last year's.
|
|
*/
|
|
$mains = glob( $root . '/*.php' );
|
|
$version = '';
|
|
|
|
foreach ( (array) $mains as $main ) {
|
|
if ( preg_match( '/^\s*\*\s*Version:\s*([0-9][^\s]*)/m', (string) file_get_contents( $main ), $m ) ) {
|
|
$version = $m[1];
|
|
break;
|
|
}
|
|
}
|
|
|
|
if ( '' === $version ) {
|
|
fail( 'No Version: header found in the plugin main file — nothing to compare the docs against.' );
|
|
} else {
|
|
$readme = (string) file_get_contents( $docs . '/README.md' );
|
|
|
|
if ( false === strpos( $readme, $version ) ) {
|
|
fail( sprintf( 'docs/README.md does not mention version %s.', $version ) );
|
|
} else {
|
|
printf( " docs/README.md names version %s, which is the header's.\n", $version );
|
|
}
|
|
}
|
|
|
|
/*
|
|
* 3. CONTRACT.md against the source, both directions.
|
|
*/
|
|
$listing = $root . '/ci/dist-files.sh';
|
|
|
|
if ( ! is_file( $listing ) ) {
|
|
fail( 'ci/dist-files.sh is missing — cannot tell which files ship.' );
|
|
exit( 1 );
|
|
}
|
|
|
|
exec( escapeshellcmd( $listing ) . ' 2>/dev/null', $shipped, $rc );
|
|
|
|
if ( 0 !== $rc ) {
|
|
fail( 'ci/dist-files.sh failed — refusing to guess which files ship.' );
|
|
exit( 1 );
|
|
}
|
|
|
|
$php = array_values(
|
|
array_filter(
|
|
$shipped,
|
|
static function ( $f ) {
|
|
return '.php' === substr( $f, -4 );
|
|
}
|
|
)
|
|
);
|
|
|
|
$min_php = shipped_php_floor();
|
|
|
|
if ( count( $php ) < $min_php ) {
|
|
// The floor is not the fault: a copied kit that reads zero files prints a
|
|
// plausible number and passes. Refuse instead of reporting a green over
|
|
// nothing.
|
|
fail( sprintf( 'Only %d shipped PHP files found, under the floor of %d. That is too few to be this tree — check the prefix and the paths before lowering this.', count( $php ), $min_php ) );
|
|
exit( 1 );
|
|
}
|
|
|
|
$in_code = array();
|
|
$anywhere = array();
|
|
|
|
foreach ( $php as $file ) {
|
|
$path = $root . '/' . $file;
|
|
|
|
if ( ! is_file( $path ) ) {
|
|
continue;
|
|
}
|
|
|
|
$source = (string) file_get_contents( $path );
|
|
|
|
// 🔴 Strip comments. See the header: our own docblocks contain the exact
|
|
// calls this check looks for, and reading them turns the file that
|
|
// explains a fault into the file that reports it.
|
|
$code = '';
|
|
|
|
foreach ( token_get_all( $source ) as $token ) {
|
|
if ( is_array( $token ) ) {
|
|
if ( T_COMMENT === $token[0] || T_DOC_COMMENT === $token[0] ) {
|
|
continue;
|
|
}
|
|
|
|
$code .= $token[1];
|
|
continue;
|
|
}
|
|
|
|
$code .= $token;
|
|
}
|
|
|
|
if ( preg_match_all( '/(do_action|apply_filters|add_filter|add_action)\(\s*\x27([a-z0-9_]+)\x27/', $code, $m, PREG_SET_ORDER ) ) {
|
|
foreach ( $m as $hit ) {
|
|
if ( is_contract( $hit[1], $hit[2] ) ) {
|
|
$in_code[ $hit[2] ] = true;
|
|
}
|
|
}
|
|
}
|
|
|
|
/*
|
|
* 🔴 A second, wider set, and the two directions need different ones.
|
|
*
|
|
* "Must be documented" is the narrow question — contract hooks only.
|
|
* "Has this row gone stale" is a different one, and asking it against the
|
|
* narrow set is wrong: it reported `nlsec_request_reputation` as no longer
|
|
* existing, which is this plugin's most important contract. The only
|
|
* occurrence in shipped code is `add_filter()` — we ANSWER it, and the
|
|
* consumer applies it — so the contract classifier correctly declines to
|
|
* demand it and the staleness check then read that as absence.
|
|
*
|
|
* Same for the five cron hooks, which the document names under a heading
|
|
* that says they are explicitly NOT a contract.
|
|
*
|
|
* So: a documented name is stale only when it appears NOWHERE in the
|
|
* shipped source. Every string literal, not just the ones in hook calls.
|
|
*/
|
|
if ( preg_match_all( '/\x27([a-z0-9_]+)\x27/', $code, $m ) ) {
|
|
foreach ( $m[1] as $literal ) {
|
|
$anywhere[ $literal ] = true;
|
|
}
|
|
}
|
|
}
|
|
|
|
// WordPress core hooks, and our own scheduled jobs, are named in the document
|
|
// under headings of their own rather than in the tables. Listing them here is
|
|
// what stops the check reporting them as undocumented forever — which is how a
|
|
// floor gets lowered until the check reads nothing.
|
|
$contract = (string) file_get_contents( $docs . '/CONTRACT.md' );
|
|
|
|
/*
|
|
* 🔴 Table ROWS, not every backticked token in the file.
|
|
*
|
|
* Found by Nolimit Assistant from the other side and confirmed here by plant:
|
|
* delete a hook's table row, leave the name in one sentence of prose, and the
|
|
* first version printed "all 16 in docs/CONTRACT.md" and exited 0. A mention
|
|
* is not a row — the row carries the signature, what a caller gets when we are
|
|
* absent, and what a consumer must not do, and none of that is what a reader
|
|
* needs replaced by its own name in a sentence.
|
|
*
|
|
* It matters in the other direction too, which is how Assistant hit it: their
|
|
* first version read every backticked token and reported nine names as
|
|
* vanished hooks — two functions and seven WP_Error codes, every one of them
|
|
* correctly written in prose. Scoping to rows fixes both at once, and it makes
|
|
* the prose free: the tables are the contract, everything else is commentary,
|
|
* and commentary should not be able to satisfy or break a check.
|
|
*
|
|
* Verified against this document before narrowing: all 16 contract hooks are
|
|
* in rows, and the 30 backticked names that are not are field names, core
|
|
* hooks and the internal cron hooks listed under a heading that says they are
|
|
* explicitly not a contract.
|
|
*/
|
|
$documented = array();
|
|
|
|
if ( preg_match_all( '/^\|[^|]*`([a-z0-9_]+)`/m', $contract, $m ) ) {
|
|
foreach ( $m[1] as $name ) {
|
|
$documented[ $name ] = true;
|
|
}
|
|
}
|
|
|
|
$core = array( 'plugins_api', 'rest_api_init' );
|
|
|
|
$undocumented = array();
|
|
|
|
foreach ( array_keys( $in_code ) as $hook ) {
|
|
if ( ! isset( $documented[ $hook ] ) ) {
|
|
$undocumented[] = $hook;
|
|
}
|
|
}
|
|
|
|
sort( $undocumented );
|
|
|
|
// The other direction is narrower on purpose: CONTRACT.md legitimately names
|
|
// things that are not hooks (fields, codes, functions), so only names carrying
|
|
// this plugin's own prefix are held to existing in the source.
|
|
// The prefix comes from ci/plugin.conf, which every plugin in the family has,
|
|
// rather than from a pattern over the hook names — a copied kit that guesses
|
|
// the prefix from its own contents is how a check ends up reading zero files
|
|
// in the next tree and printing a plausible number.
|
|
$prefix = hook_prefix();
|
|
|
|
/*
|
|
* 🔴 A wrong prefix is GREEN, and that is the whole reason this block exists.
|
|
*
|
|
* Measured on this tree by setting HOOK_PREFIX=zzzz_ : the run printed
|
|
* "6 hooks in 46 shipped PHP files, all 6 in docs/CONTRACT.md" and exited 0.
|
|
* Sixteen became six — only the wpmgr_ names survived — and nothing looked
|
|
* wrong, because six is a plausible number and every one of the six really was
|
|
* documented. Two wrongs cancelling into a pass, in a checker I had just told
|
|
* seven sibling sessions to copy into trees with seven different prefixes.
|
|
*
|
|
* Same shape as the copied kit that read zero always-loaded files: the port is
|
|
* not done when plugin.conf is, and the error you can see is never the one
|
|
* that matters. So: refuse. A plugin may genuinely define no hooks of its own,
|
|
* but that is a thing to state rather than to arrive at by a typo.
|
|
*/
|
|
$own = 0;
|
|
|
|
foreach ( array_keys( $in_code ) as $hook ) {
|
|
if ( 0 === strpos( $hook, $prefix ) ) {
|
|
++$own;
|
|
}
|
|
}
|
|
|
|
$min_own = 1;
|
|
|
|
if ( is_file( $root . '/ci/plugin.conf' ) && preg_match( '/^HOOK_PREFIX_MIN=([0-9]+)/m', (string) file_get_contents( $root . '/ci/plugin.conf' ), $m ) ) {
|
|
$min_own = (int) $m[1];
|
|
}
|
|
|
|
if ( $own < $min_own ) {
|
|
// Nolimit Cache's improvement on the first version of this message, and it
|
|
// is the better one: say what IS there. "No hook carries nlsec_" leaves the
|
|
// reader to work out whether the scan found nothing at all or found the
|
|
// wrong things, and those need different fixes.
|
|
$present = array();
|
|
|
|
foreach ( array_keys( $in_code ) as $hook ) {
|
|
if ( preg_match( '/^([a-z]{2,8}_)/', $hook, $pm ) ) {
|
|
$present[ $pm[1] ] = isset( $present[ $pm[1] ] ) ? $present[ $pm[1] ] + 1 : 1;
|
|
}
|
|
}
|
|
|
|
$summary = array();
|
|
|
|
foreach ( $present as $seen => $count ) {
|
|
$summary[] = sprintf( '%s* (%d)', $seen, $count );
|
|
}
|
|
|
|
$message = sprintf(
|
|
'HOOK_PREFIX is %s* and no hook in the shipped source starts with it — the prefixes actually present are: %s. Either HOOK_PREFIX is wrong for this plugin, or this plugin defines none of its own; if it really defines none, say so in docs/CONTRACT.md and set HOOK_PREFIX_MIN=0 in ci/plugin.conf.',
|
|
$prefix,
|
|
$summary ? implode( ', ', $summary ) : 'none'
|
|
);
|
|
|
|
fail( $message );
|
|
exit( 1 );
|
|
}
|
|
|
|
$stale = array();
|
|
|
|
foreach ( array_keys( $documented ) as $name ) {
|
|
// Long enough to be a hook rather than the bare prefix itself, which is
|
|
// also a table-name literal in the source.
|
|
if ( 0 !== strpos( $name, $prefix ) || strlen( $name ) <= strlen( $prefix ) + 3 ) {
|
|
continue;
|
|
}
|
|
|
|
if ( isset( $anywhere[ $name ] ) ) {
|
|
continue;
|
|
}
|
|
|
|
/*
|
|
* 🔴 A dynamic hook is built by concatenation, so the source carries the
|
|
* literal PREFIX and the document has to carry the full name a reader
|
|
* needs — `update_option_{$option}`, `site_transient_{$transient}`,
|
|
* `plugin_action_links_{$file}`. Compared literally, that row is correct
|
|
* in both files and fails forever, and the fix somebody reaches for is
|
|
* deleting the row rather than the rule. Found by Nolimit Cache, whose
|
|
* run was 56/56 with exactly one such false positive.
|
|
*/
|
|
$dynamic = false;
|
|
|
|
foreach ( array_keys( $anywhere ) as $literal ) {
|
|
if ( strlen( $literal ) > strlen( $prefix ) && 0 === strpos( $name, $literal ) ) {
|
|
$dynamic = true;
|
|
break;
|
|
}
|
|
}
|
|
|
|
if ( ! $dynamic ) {
|
|
$stale[] = $name;
|
|
}
|
|
}
|
|
|
|
sort( $stale );
|
|
|
|
if ( $undocumented ) {
|
|
fail(
|
|
sprintf(
|
|
'%d hook(s) in the shipped source and not in docs/CONTRACT.md: %s',
|
|
count( $undocumented ),
|
|
implode( ', ', $undocumented )
|
|
)
|
|
);
|
|
}
|
|
|
|
if ( $stale ) {
|
|
fail(
|
|
sprintf(
|
|
'%d name(s) in docs/CONTRACT.md that no longer exist in the source: %s',
|
|
count( $stale ),
|
|
implode( ', ', $stale )
|
|
)
|
|
);
|
|
}
|
|
|
|
if ( ! $undocumented && ! $stale ) {
|
|
printf(
|
|
" %d hooks in %d shipped PHP files, all %d in docs/CONTRACT.md (prefix %s, %d core).\n",
|
|
count( $in_code ),
|
|
count( $php ),
|
|
count( $in_code ),
|
|
'' !== $prefix ? $prefix . '*' : '?',
|
|
count( $core )
|
|
);
|
|
}
|
|
|
|
if ( $fails > 0 ) {
|
|
printf( "\n%d documentation problem(s).\n", $fails );
|
|
exit( 1 );
|
|
}
|
|
|
|
exit( 0 );
|