*/ /** * 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 );