2026-09-28 21:45:29 +02:00
<? 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`.
*
2026-09-29 08:46:20 +02:00
* @param string $call One of do_action, apply_filters, add_filter, add_action.
2026-09-28 21:45:29 +02:00
* @param string $hook The hook name.
* @return bool
*/
function is_contract ( $call , $hook ) {
2026-09-29 08:46:20 +02:00
// 🔴 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 ;
}
}
2026-09-28 21:45:29 +02:00
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 ;
}
2026-09-29 08:46:20 +02:00
/**
* 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 ;
}
2026-09-28 21:45:29 +02:00
$root = isset ( $argv [ 1 ] ) ? rtrim ( $argv [ 1 ], '/' ) : '.' ;
$docs = $root . '/docs' ;
2026-09-29 08:46:20 +02:00
global $conf_root ;
$conf_root = $root ;
2026-09-28 21:45:29 +02:00
$fails = 0 ;
2026-09-29 08:46:20 +02:00
/**
* 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
*/
2026-09-28 21:45:29 +02:00
function fail ( $message ) {
global $fails ;
2026-09-29 08:46:20 +02:00
++ $fails ;
2026-09-28 21:45:29 +02:00
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 ;
}
2026-09-29 08:46:20 +02:00
$lines = count ( file ( $path , FILE_IGNORE_NEW_LINES ) );
2026-09-28 21:45:29 +02:00
$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.
*/
2026-09-29 08:46:20 +02:00
$mains = glob ( $root . '/*.php' );
2026-09-28 21:45:29 +02:00
$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 );
}
2026-09-29 08:46:20 +02:00
$php = array_values (
array_filter (
$shipped ,
static function ( $f ) {
return '.php' === substr ( $f , - 4 );
}
)
);
2026-09-28 21:45:29 +02:00
$min_php = shipped_php_floor ();
if ( count ( $php ) < $min_php ) {
2026-09-29 08:46:20 +02:00
// 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.
2026-09-28 21:45:29 +02:00
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' );
2026-09-29 08:46:20 +02:00
/*
* 🔴 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.
*/
2026-09-28 21:45:29 +02:00
$documented = array ();
2026-09-29 08:46:20 +02:00
if ( preg_match_all ( '/^\|[^|]*`([a-z0-9_]+)`/m' , $contract , $m ) ) {
2026-09-28 21:45:29 +02:00
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.
2026-09-29 08:46:20 +02:00
$prefix = hook_prefix ();
2026-09-28 21:45:29 +02:00
2026-09-29 08:46:20 +02:00
/*
* 🔴 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 ;
}
2026-09-28 21:45:29 +02:00
}
2026-09-29 08:46:20 +02:00
$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 );
2026-09-28 21:45:29 +02:00
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 ) {
2026-09-29 08:46:20 +02:00
fail (
sprintf (
'%d hook(s) in the shipped source and not in docs/CONTRACT.md: %s' ,
count ( $undocumented ),
implode ( ', ' , $undocumented )
)
);
2026-09-28 21:45:29 +02:00
}
if ( $stale ) {
2026-09-29 08:46:20 +02:00
fail (
sprintf (
'%d name(s) in docs/CONTRACT.md that no longer exist in the source: %s' ,
count ( $stale ),
implode ( ', ' , $stale )
)
);
2026-09-28 21:45:29 +02:00
}
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 );