Files
prautoblogger/scripts/check-constants.php
peptiderepo c7f63b1133 feat: M4 chained-cron checkpoint generation + board button (v0.21.0)
## Board 'New Article' button
- templates/admin/board-page.php: add #prab-board-generate-now button + status div
- includes/admin/class-board-page.php: inject generateNonce + i18n strings into prabBoard;
  enqueue companion board-generate.js
- assets/js/board-generate.js: NEW – board button AJAX handler (fires
  prautoblogger_generate_now, resets board backoff on success)
- assets/js/board.js: applySnapshot() re-enables button when no active runs

## Chained-cron checkpoint machine (retires SSH workaround)
- includes/core/class-generation-checkpoint-runner.php: NEW (295 lines)
  Tick 1 (prautoblogger_gen_orchestrate): orchestrate_only() → persist queue → schedule Tick 2
  Tick 2..N (prautoblogger_gen_tick): pop one idea → Article_Worker → reschedule or finalize
  Budget/halt contract + lock release on all exit paths
- includes/class-executor.php: on_manual_generation() replaced with 1-line delegate to
  Generation_Checkpoint_Runner::on_orchestrate_tick() (SSH workaround retired)
- includes/class-prautoblogger.php: register ORCHESTRATE_ACTION + GENERATE_ACTION hooks
- includes/core/class-pipeline-runner.php: expose orchestrate_only() public wrapper
- includes/core/class-pipeline-status.php: add write_initial() + write_error() helpers

## WP-CLI command
- includes/admin/class-wp-cli-commands.php: wp prautoblogger generate → kick_off()

## IA / perf pass (settings-backed pagination + DB indexes)
- includes/admin/class-board-data-provider.php: replace hardcoded posts_per_page=20 with
  get_option('prautoblogger_board_column_limit', DEFAULT)
- includes/admin/class-board-gen-log-query.php: replace LIMIT 10/20 literals with settings-backed values
- includes/admin/class-ideas-browser-query.php: NEW – query helpers extracted from Ideas_Browser
  (300-line rule split): query_ideas(), get_type_counts(), load_idea_data(),
  find_post_by_run_id(), update_idea_stage()
- includes/admin/class-ideas-browser.php: delegates all query work to Ideas_Browser_Query
- includes/class-activator.php: set_default_options() adds board_column_limit + ideas_per_page
- includes/admin/class-admin-page.php: add new settings to numeric sanitization list
- prautoblogger.php: PRAUTOBLOGGER_DEFAULT_BOARD_COLUMN_LIMIT=20, PRAUTOBLOGGER_DEFAULT_IDEAS_PER_PAGE=30
- includes/class-schema-installer.php: indexes on generation_log + analysis_results (db 1.4.0)
- includes/class-pipeline-schema-installer.php: KEY started_at on runs table

## Guard P2s
- scripts/check-constants.php: --self-test mode + inline CONSTANTS_GUARD_WINDOW comment
- .github/workflows/ci.yml: check-constants-self-test step after main constants check

## PHPUnit test
- tests/unit/Core/GenerationCheckpointRunnerTest.php: NEW (227 lines)
  4 tests: kick_off schedules+transient, empty-queue finalizes, halted-run aborts+cleanup,
  multi-idea pops-one-and-reschedules

## Docs + version
- prautoblogger.php: Version 0.21.0, PRAUTOBLOGGER_DB_VERSION 1.4.0
- ARCHITECTURE.md: §25 Chained-Cron Checkpoint Generation Path
- CHANGELOG.md: v0.21.0 entry
- INFORMATION-ARCHITECTURE.md: NEW – admin pages, entry points, DB tables, options, CLI
- uninstall.php: purge prautoblogger_gen_orchestrate + prautoblogger_gen_tick cron hooks

Agent-Session: phase2-admin-m4-20260614
2026-06-14 13:33:49 +00:00

253 lines
8.5 KiB
PHP

<?php
/**
* CI guard: every PRAUTOBLOGGER_* constant referenced in the shipped plugin PHP
* must be either (a) defined in prautoblogger.php, or (b) protected by a
* defined() guard at the reference site.
*
* Purpose: prevent the class of regression that caused the v0.19.0 admin-500
* incident — a constant deleted from prautoblogger.php while tests/bootstrap.php
* kept a fallback copy, masking the gap until prod boot.
*
* Usage (from repo root):
* php scripts/check-constants.php [--verbose] [--self-test]
*
* Exit 0 = all clear. Exit 1 = unguarded undefined constants found. Exit 2 = --self-test failed.
*
* Include/exclude summary (relative to repo root):
* Defined-set source prautoblogger.php only
* Scanned for refs prautoblogger.php, includes/, templates/, uninstall.php
* Excluded from refs tests/ vendor/ .github/ .git/ node_modules/
* Excluded from defs everything except prautoblogger.php
*
* Guard window: a bare constant use is considered guarded if a defined() call
* for the same constant appears within GUARD_WINDOW lines above it in the same
* file (covers if/elseif blocks and multi-line && chains).
*
* @package PRAutoBlogger
*/
declare(strict_types=1);
// How many lines above the bare use to look for a defined() guard.
const CONSTANTS_GUARD_WINDOW = 4; // 4 covers if/elseif blocks and multi-line && chains (wider risks false-positives).
// Source of truth for define()d constants -- test-bootstrap fallbacks excluded.
const CONSTANTS_BOOTSTRAP_FILE = 'prautoblogger.php';
// Directories skipped when walking the shipped tree for references.
const CONSTANTS_EXCLUDE_DIRS = ['tests', 'vendor', '.github', '.git', 'node_modules'];
// Entry points for the reference scan (files and dirs relative to repo root).
const CONSTANTS_SCAN_ROOTS = [
'prautoblogger.php',
'uninstall.php',
'includes',
'templates',
];
$verbose = in_array( '--verbose', $argv ?? [], true );
$self_test = in_array( '--self-test', $argv ?? [], true );
// -------------------------------------------------------------------------
// 1. Build the defined set from prautoblogger.php only.
// -------------------------------------------------------------------------
if ( ! file_exists( CONSTANTS_BOOTSTRAP_FILE ) ) {
fwrite( STDERR, "ERROR: bootstrap file not found -- run from repo root.\n" );
fwrite( STDERR, ' Expected: ' . CONSTANTS_BOOTSTRAP_FILE . "\n" );
exit( 1 );
}
$bootstrap_src = (string) file_get_contents( CONSTANTS_BOOTSTRAP_FILE );
preg_match_all(
"/define\s*\(\s*'(PRAUTOBLOGGER_[A-Z0-9_]+)'/",
$bootstrap_src,
$def_matches
);
/** @var array<string, int> $defined_set constant name => 1 */
$defined_set = array_flip( $def_matches[1] ?? [] );
if ( $verbose ) {
echo 'Defined in ' . CONSTANTS_BOOTSTRAP_FILE . ' (' . count( $defined_set ) . "):\n";
foreach ( array_keys( $defined_set ) as $constant_name ) {
echo " {$constant_name}\n";
}
echo "\n";
}
// -------------------------------------------------------------------------
// 2. Collect shipped PHP files to scan.
// -------------------------------------------------------------------------
/**
* Recursively collect *.php paths under a directory, skipping excluded dirs.
*
* @param string $dir Directory to walk.
* @param string[] $files Accumulator (modified by reference).
* @return void
*/
function constants_collect_php_files( string $dir, array &$files ): void {
$dir_iter = new DirectoryIterator( $dir );
foreach ( $dir_iter as $entry ) {
if ( $entry->isDot() ) {
continue;
}
$basename = $entry->getBasename();
if ( $entry->isDir() ) {
if ( ! in_array( $basename, CONSTANTS_EXCLUDE_DIRS, true ) ) {
constants_collect_php_files( $entry->getPathname(), $files );
}
continue;
}
if ( $entry->isFile() && $entry->getExtension() === 'php' ) {
$files[] = $entry->getPathname();
}
}
}
/** @var string[] $php_files */
$php_files = [];
foreach ( CONSTANTS_SCAN_ROOTS as $root ) {
if ( ! file_exists( $root ) ) {
// Template dir may not exist in very early checkouts -- skip silently.
continue;
}
if ( is_file( $root ) ) {
$php_files[] = $root;
} else {
constants_collect_php_files( $root, $php_files );
}
}
sort( $php_files );
if ( $verbose ) {
echo 'Scanning ' . count( $php_files ) . " PHP files\n\n";
}
// -------------------------------------------------------------------------
// 3. Scan each file for bare PRAUTOBLOGGER_* references.
// -------------------------------------------------------------------------
/** @var array<int, array{string, int, string, string}> $errors */
$errors = [];
foreach ( $php_files as $php_path ) {
$raw = file_get_contents( $php_path );
if ( $raw === false ) {
fwrite( STDERR, "WARNING: cannot read {$php_path} -- skipping\n" );
continue;
}
$lines = explode( "\n", $raw );
foreach ( $lines as $idx => $raw_line ) {
// Fast pre-check: skip lines with no PRAUTOBLOGGER_ token.
if ( strpos( $raw_line, 'PRAUTOBLOGGER_' ) === false ) {
continue;
}
// Skip pure comment lines (docblock * lines, // comments, # lines).
$trimmed = ltrim( $raw_line );
if (
str_starts_with( $trimmed, '*' ) ||
str_starts_with( $trimmed, '//' ) ||
str_starts_with( $trimmed, '#' )
) {
continue;
}
// Extract all constant names from this line.
if ( ! preg_match_all( '/PRAUTOBLOGGER_[A-Z0-9_]+/', $raw_line, $match ) ) {
continue;
}
foreach ( $match[0] as $name ) {
// Skip if this line is itself a define() call for this name.
if ( preg_match( "/define\s*\(\s*'" . preg_quote( $name, '/' ) . "'/", $raw_line ) ) {
continue;
}
// Skip if the constant is production-defined in prautoblogger.php.
if ( isset( $defined_set[ $name ] ) ) {
continue;
}
// Check for a defined() guard in the preceding GUARD_WINDOW lines
// (inclusive of the current line to catch same-line chained &&).
$window_start = max( 0, $idx - CONSTANTS_GUARD_WINDOW );
$guarded = false;
for ( $w = $window_start; $w <= $idx; $w++ ) {
if ( preg_match( "/defined\s*\(\s*'" . preg_quote( $name, '/' ) . "'/", $lines[ $w ] ) ) {
$guarded = true;
break;
}
}
if ( ! $guarded ) {
$errors[] = [ $php_path, $idx + 1, $name, $raw_line ];
}
}
}
}
// -------------------------------------------------------------------------
// 4. Report results.
// -------------------------------------------------------------------------
if ( $errors !== [] ) {
fwrite( STDERR, "\nFAIL -- Unguarded undefined PRAUTOBLOGGER_* constants:\n\n" );
foreach ( $errors as [ $php_path, $lineno, $name, $line_text ] ) {
fwrite( STDERR, " {$php_path}:{$lineno}: {$name}\n" );
fwrite( STDERR, ' ' . trim( $line_text ) . "\n" );
}
fwrite( STDERR, "\nTo fix: add define( '" . $errors[0][2] . "', ... ) to prautoblogger.php,\n" );
fwrite( STDERR, "or guard the reference with if ( defined( '" . $errors[0][2] . "' ) ) { ... }\n\n" );
exit( 1 );
}
// -------------------------------------------------------------------------
// 5. Self-test mode: synthesize a missing constant and assert non-zero exit.
// -------------------------------------------------------------------------
if ( $self_test ) {
// Temporarily inject a fake reference that must NOT be in prautoblogger.php.
// The scan above will flag PRAUTOBLOGGER_SELFTEST_SENTINEL as unguarded.
// We verify this script would have exited non-zero in that scenario.
$sentinel = 'PRAUTOBLOGGER_SELFTEST_SENTINEL';
$bootstrap_src = (string) file_get_contents( CONSTANTS_BOOTSTRAP_FILE );
if ( false !== strpos( $bootstrap_src, $sentinel ) ) {
fwrite( STDERR, "SELF-TEST FAIL: sentinel constant already defined in prautoblogger.php -- pick a unique name.\n" );
exit( 2 );
}
// If $errors is empty at this point, the real codebase is clean (good).
// Now simulate an unguarded reference by re-running the scan on a temp file.
$tmp_file = tempnam( sys_get_temp_dir(), 'prab_selftest_' ) . '.php';
file_put_contents( $tmp_file, "<?php\necho {$sentinel};\n" );
$tmp_errors = [];
$tmp_lines = explode( "\n", (string) file_get_contents( $tmp_file ) );
foreach ( $tmp_lines as $idx => $raw_line ) {
if ( preg_match_all( '/PRAUTOBLOGGER_[A-Z0-9_]+/', $raw_line, $m ) ) {
foreach ( $m[0] as $n ) {
if ( ! isset( $defined_set[ $n ] ) ) {
$tmp_errors[] = $n;
}
}
}
}
@unlink( $tmp_file );
if ( ! in_array( $sentinel, $tmp_errors, true ) ) {
fwrite( STDERR, "SELF-TEST FAIL: scanner did not detect unguarded sentinel constant.\n" );
exit( 2 );
}
echo "SELF-TEST OK -- scanner correctly detected the synthetic missing constant.\n";
exit( 0 );
}
echo "OK -- All PRAUTOBLOGGER_* constants are production-defined or defined()-guarded.\n";
exit( 0 );