Skip to content

v1.0.5

Choose a tag to compare

@github-actions github-actions released this 26 Jun 09:11
0a2533d

Fixed

  • CI asset-drift guard now verifies the WASM pair in its manifest-absent fallback, not just scolta.js. The "Verify duplicated assets" CI step is manifest-driven: when scolta-php ships assets/ASSETS.sha256 it checksums all four canonical runtime assets (assets/js/scolta.js, assets/css/scolta.css, assets/wasm/scolta_core.js, assets/wasm/scolta_core_bg.wasm) and is correct. But its backward-compat fallback — taken when the vendored scolta-php predates the manifest — checksummed only assets/js/scolta.js and then exit 0'd, so a sync that refreshed the JS but left scolta_core.js / scolta_core_bg.wasm (or scolta.css) stale passed CI. That JS-only verification path is the class of gap behind demos shipping a stale browser WASM scorer. The fallback now verifies every canonical asset directly against the vendored scolta-php source (vendor/tag1/scolta-php/assets/<rel>), covering the WASM glue + binary pair and the CSS, so a stale WASM can no longer slip through regardless of which branch runs. Guarded by StructuralIntegrityTest::test_ci_asset_verify_fallback_covers_wasm_pair, which asserts the fallback branch references scolta_core.js, scolta_core_bg.wasm, and scolta.css (it fails against the old scolta.js-only fallback). The Composer copy-assets hook, the manifest-present CI branch, and the build-dist.sh / validate-dist.sh dist paths already covered both WASM files and are unchanged.
  • Amazee credentials provisioned without resolved model names now self-heal instead of breaking AI permanently. The Amazee.ai trial provisioner persists credentials (LiteLLM token + URL) and resolves the model names in two steps; scolta_auto_provision_amazee() stored the credentials but discarded the resolved models (it passed no onModelsResolved), so Scolta_Ai_Service::from_options() drove the LiteLLM gateway with the shipped dated default claude-sonnet-4-5-20250929, which the gateway rejects with HTTP 400 — summarize silently returned nothing and expand ran unexpanded. (A distinct failure class from the expired-key recovery above: a 400 invalid-model from a half-provisioned credentials store, not an auth failure.) Two changes fix it: (1) scolta_auto_provision_amazee() now passes a guarded onModelsResolved (scolta_amazee_persist_resolved_models() — persists the resolved names into scolta_settings, only filling the dated default / empty expansion so an admin's explicit model choice is never clobbered, mirroring the admin Amazee connect flow) and scolta-php's new hasResolvedModels predicate (scolta_amazee_models_resolved()), so when credentials are stored but only the dated default is persisted the library re-resolves against the already-stored key (never a fresh trial) and self-heals; the predicate deliberately treats the dated default as unresolved, because settings seed ai_model to it and a naive "non-empty" check would never fire the heal. (2) from_options() now degrades on the Amazee path when no resolved model is present: it does not inject the Amazee key, so a key-less client throws ApiKeyMissingException (degraded to an unexpanded/no-summary HTTP 200 by the REST controllers) rather than sending the gateway the dated default (HTTP 400). The explicit-key path is unchanged (a dated model is valid on the real Anthropic API); only the Amazee branch degrades. Requires scolta-php ≥ 1.0.4 (unreleased) (AutoProvisioner::ensureAiAvailable()'s $hasResolvedModels parameter); the committed composer.lock stays pinned at the stable release and a composer update tag1/scolta-php lock bump lands once scolta-php 1.0.4 is tagged. Covered by ModelResolutionSelfHealTest (predicate reports unresolved for the dated default; the real AutoProvisioner re-resolves against the stored key, driven by the actual callbacks; the persist guard preserves a user-configured model; a naive non-empty predicate provably would not heal; the degraded service throws ApiKeyMissingException without calling the gateway) and the new AmazeeAiServiceTest/AutoProvisioningTest cases.
  • scripts/validate-dist.sh no longer writes fixed-name scratch files into the working directory or leaks them on failure. The dist validator wrote nine fixed-name scratch files (zip-contents.txt, zip-files.txt, zip-nonsource.txt, network-callsites-raw.txt, network-callsites.txt, network-hosts-found.txt, network-hosts-allowed.txt, readme-external-services.txt, readme-compiled-assets.txt) into the current working directory and only rm -f'd them on the success path — under set -euo pipefail with ~12 exit 1 paths, any failure left all nine plus the mktemp -d extract dir behind. Predictable names in a shared/reused CWD are a TOCTOU surface (a pre-existing or concurrently-written file of the same name can be read instead of the script's own output, and the script silently clobbers anything by those names), and cleanup-only-on-success pollutes the tree. All scratch state — including the extract dir — now lives under one private mktemp -d work dir removed by a single trap 'rm -rf "$WORK"' EXIT that fires on every exit. Validation behavior, checks, fixtures, and messages are unchanged; this script is a CI/build tool and does not ship inside the plugin ZIP.
  • Expired Amazee trial keys now recover instead of silently killing AI, and /health tells the truth. An Amazee.ai trial key is revoked server-side when the trial ends; the next AI call then returned 400 expired_key while the adapter no-opped on the stored (dead) credentials — so expand echoed the query, summarize returned nothing, and GET /wp-json/scolta/v1/health still reported ai_configured: true (the django demo outage, 2026-06-09). Scolta_Ai_Service::from_options() now wires scolta-php's KeyExpiryRecovery into the AI call path only on the auto-provisioned Amazee path (stored credentials, no explicit key): an auth-class failure triggers a one-shot re-provision of a fresh trial key (guarded to one attempt per window, markers in the transient store) and a single retry. The explicit-key path (SCOLTA_API_KEY env/constant or a database key) returns before the wiring, so a user's own key is never re-provisioned behind their back; budget-exhaustion is excluded by KeyExpiryRecovery so it can't reset the spend ceiling. The health endpoint hands the same transient-backed Scolta_Cache_Driver to HealthChecker, so a recorded auth failure surfaces as ai_usable: false / ai_auth_failing: true (status degraded) while the key is known-bad. Requires scolta-php ≥ 1.0.4 (unreleased) (AiServiceAdapter::setKeyExpiryRecovery(), KeyExpiryRecovery, HealthChecker's $cache parameter); the committed composer.lock stays pinned at the stable release — same accepted interim state as the merged isBudgetError() / AuthenticatedCipher adoptions — and a composer update tag1/scolta-php lock bump lands once scolta-php 1.0.4 is tagged. Covered by KeyExpiryRecoveryWiringTest (recovery wired only on the Amazee path, once-per-window re-provision through the transient bridge, health truthfulness).
  • Re-sync assets/js/scolta.js, assets/css/scolta.css, and assets/js/scolta.js.sha256 from canonical scolta-php (AI-Overview stall fix + drift since scolta-php#205). Headline (scolta-php#217): the sub-word frequency guard computed its corpus denominator with a match-all pagefind.search(null), which downloads the entire word index — 5,678 chunks / 111 MB on a 6,909-page long-form corpus — and the AI summary is sequenced behind it, so the AI Overview stalled for minutes on large sites while the summarize endpoint answers in seconds. The denominator now comes from init-time cached totals (pagefind-entry.json page counts, pagefind.filters() value counts) and the guard never touches the word index. The byte-identical re-sync also carries the previously-unsynced scolta-php main changes: the LLM filter-hint recall guard with offered chips (scolta-php#213 — the CSS gains the offered-chip styles the synced JS references), the unmatched-subject sort fallback (scolta-php#210), and the PER-CS/renderer-parity cleanups (scolta-php#205/#206). No WordPress-side logic changed.

Changed

  • The Generic.CodeAnalysis.UnusedFunctionParameter phpcs exclude is scoped to the files that hold fixed-signature callbacks instead of excluded repo-wide, and a dead NonEnqueuedScript inline ignore was removed (tooling only, no behavior change). The exclude existed because WordPress hook, REST/shortcode, WP-CLI command, and interface signatures are fixed by their contracts, so those callbacks legitimately ignore trailing parameters — but <exclude-pattern>*</exclude-pattern> silenced the sniff everywhere, including code where a genuinely unused parameter is a real finding. Running the sniff without the exclude showed it fires in exactly six files (scolta.php, cli/class-scolta-cli.php, and includes/class-scolta-{rest-api,shortcode,build-status-progress-reporter,amazee-budget-handler}.php), each a contract-fixed callback or interface implementation; the exclude is now pinned to those paths so unused parameters anywhere else surface. Separately, an inline // phpcs:ignore WordPress.WP.EnqueuedResources.NonEnqueuedScript on a Scolta_Admin::sanitize_settings() regression test in tests/AdminSettingsSanitizeExtrasTest.php suppressed a finding that fires nowhere in the codebase (and tests/ is not even in the phpcs <file> set), so it was removed rather than relocated — there is no location that genuinely needs it. Full phpcs and the 747-test suite stay green.
  • The WordPress.org distribution is now opt-in for ALL remote AI functionality: activation contacts no remote service and AI features default off (WP.org review round 3, Guidelines 7 & 9). Root cause: Amazee.ai auto-provisioning shipped (rc2) as a zero-config onboarding feature with configuration gates (scolta_has_explicit_api_key(), stored credentials) but no consent gate — activation POSTed the site admin email to api.amazee.ai and enabled ai_expand_query/ai_summarize by default, and no test asserted what activation must NOT do. One code path now serves both products via a build-time constant: SCOLTA_AUTO_PROVISION_DEFAULT (source default true for self-distributed/partner builds; scripts/build-dist.sh flips it to false in the staged wp.org copy, failing the build unless the define line matches exactly once, and scripts/validate-dist.sh fails any zip whose default is not false). With the constant false, activation schedules only the local index build, defaults both AI feature settings off, and records a scolta_ai_optin_pending option that drives a dismissible admin notice (server-side dismissal via scolta_ai_optin_notice_dismissed) plus an "Enable AI features" control on the settings page that states — before confirmation — that enabling provisions a free Amazee.ai trial, sends the site admin email address to amazee.ai (api.amazee.ai), and links Amazee.ai's terms and privacy policy. The admin_post_scolta_enable_ai handler reuses the existing provisioning path, enables the AI settings only on success, and surfaces provisioning failure as an admin error notice with the features left off. Configuring an explicit API key remains its own consent act and bypasses the opt-in flow entirely. scolta_auto_provision_amazee() now returns the provisioning result and exposes a scolta_pre_auto_provision short-circuit filter (the pre_http_request pattern) so tests can observe provisioning attempts with zero network contact. readme.txt's FAQ and External Services sections now state the opt-in default and exactly what is sent on enable.
  • Plugin Check warnings are now fatal in CI. The plugin-check job dropped --ignore-warnings: the current result is 0 errors / 0 warnings, so this costs nothing today and stops a warning-grade issue from riding into a review round. If a future warning is a confirmed false positive, the route is plugin-check's --exclude-checks flag in an explicit, justified commit — never re-adding --ignore-warnings (noted in the job comment).
  • The distribution archive is now fail-closed end to end, because vendor/ was the one fail-open surface (WP.org review round 3, "not permitted files"). Root cause: build-dist.sh allowlists root files/sources/assets but copies vendor/ wholesale and prunes a denylist, while validate-dist.sh checked known-bad instances — so each review round could flag a new file the checker had never heard of. The validator now sweeps EVERY file in the zip (vendor included) against an extension allowlist (php, js, css, wasm, pagefind, json, lock, txt, md) plus enumerated, justified exceptions (LICENSE*/COPYING*, the four Pagefind runtime files by path, composer.json/composer.lock), failing with the path of anything else; it also asserts the four Pagefind runtime files are PRESENT (the indexer copies them into every generated index and silently skips them when missing — upstream defect filed as tag1consulting/scolta-php#212). The reviewer-flaggable surface is under change control: tests/fixtures/dist-manifest-nonsource.txt is the committed sorted list of every non-php/css/js file in the zip, and validation diffs the built archive against it, so any new binary/data file fails CI until the manifest is updated in an explicit commit. build-dist.sh now also prunes vendor dotfiles, .github/, *.yml/*.yaml, *.html, *.xml, *.dist, *.log, and package-lock.json. The flagged files themselves were all legitimately required and are retained — the new readme.txt "Source code and compiled assets" section documents where each compiled asset comes from (scolta_core_bg.wasm from the public scolta-core repo via wasm-pack, the unmodified Pagefind runtime, dependency LICENSE files kept per their license terms) so the justification travels with the zip.

Added

  • Class-level WP.org review guards (tests/WpOrgComplianceTest.php): every shipped first-party PHP file is scanned, not just the file a reviewer cited. The review team flags issue classes across rounds ("we may not share all cases of the same issue"), so the round-2 instance tests now have class-wide companions: one test bans ini_set(), error_reporting(), and display_errors across includes/, admin/, cli/, scolta.php, and uninstall.php (round 2 cited only the CLI; the CLI-only test keeps the citation), and the other fails any ABSPATH use outside an allowlist of site-root-only patterns (defined() guards, require_once ABSPATH . 'wp-admin/...' core includes, and enumerated site-root resolutions) — ABSPATH must never locate plugin files; that is what SCOLTA_PLUGIN_DIR is for (round 2, REST health handler). Allowed constructs are stripped by pattern before the check, so a disallowed second use hiding on an allowed line still fails, and refactors don't churn line numbers.

  • The distribution archive's outbound-network surface is under change control, like the non-source file manifest (WP.org Guidelines 7 & 9 — round 3's root cause was a new feature adding a remote call with nobody noticing). scripts/validate-dist.sh now extracts the built zip and (1) scans every shipped .php file — vendor included, that's where the AI clients live — for outbound-HTTP markers (wp_remote_*, wp_safe_remote_*, wp_remote_fopen, download_url, curl_init/curl_exec, fsockopen, stream_socket_client, and file_get_contents/fopen with an http literal on the same line) and diffs the normalized path<TAB>marker list against tests/fixtures/dist-network-callsites.txt; (2) extracts every https?:// host literal from first-party shipped PHP (plugin code plus vendor/tag1/scolta-php/src — first-party remote calls go through Guzzle, so a new endpoint changes a host literal, not a transport call site) and diffs against tests/fixtures/dist-network-hosts.txt, which categorizes each host as service (contactable) or reference (docs/UI links only); and (3) asserts every service host appears in the shipped readme.txt == External Services == section. A new call site, a new host, or an undisclosed service fails the build until the change is consciously gated, disclosed, and committed to the fixture. Seeded from a clean-clone dist build and reviewed entry by entry: five call sites (the CLI's GitHub-API Pagefind version check and download_url() release download, plus Guzzle's curl transport, used exclusively by scolta-php's AI clients) and five service hosts (api.github.com, github.com, api.anthropic.com, api.openai.com, api.amazee.ai). readme.txt now names the literal endpoint hosts (api.anthropic.com, api.openai.com, github.com for the Pagefind release download) so the disclosure text matches the hosts the code can contact.

  • Shipped-binary justifications are structurally tied to the zip (round 3 re-flagged binaries whose justification lived only in PR threads). scripts/validate-dist.sh asserts each enumerated binary exception (scolta_core_bg.wasm and the four Pagefind runtime files) appears by name in the shipped readme.txt == Source code and compiled assets == section, so a binary can never again ship without its in-artifact justification.

  • WP.org Plugin Check runs in CI against the built zip (wordpress/plugin-check-action, slug scolta-ai-search): errors fail the job, warnings are reported. Verified locally against the dist-built plugin on a clean WordPress install: zero errors, zero warnings.

  • Activation-path network regression tests at the layer the defect lived in (tests/ActivationNetworkTest.php + tests/integration/activation-network-probe.php): a subprocess probe with a real minimal hook registry runs the actual activation hook (register_activation_hookdo_action, not a hand-called function) under recorders on pre_http_request and the scolta_pre_auto_provision seam. Asserts: with the constant false, activation performs zero outbound HTTP, schedules no provisioning, defaults AI off, and sets the pending flag (while still scheduling the local index build); with the constant true, provisioning is scheduled and attempted (intercepted, never real); and the opt-in action provisions exactly once, flips the AI settings, and clears the pending flag. The probe is a subprocess because the build-time constant must be defined before scolta.php loads.

  • The /health endpoint returns only the overall status to anonymous requesters — this is a user-visible API change. GET /wp-json/scolta/v1/health is publicly reachable (by design, for uptime monitoring) but returned the full diagnostic payload to everyone: AI provider and configuration state, resolved Pagefind binary path and version, index/state directory paths, indexer flags, stale-artifact details, and the index integrity breakdown — reconnaissance detail an anonymous caller has no use for. Anonymous responses now contain exactly {"status": "..."} (HTTP 200 unchanged, and the status still reflects index integrity, e.g. degraded); the full payload requires the manage_options capability via an authenticated REST request. Monitoring that only checks status is unaffected; monitoring that scraped detail fields anonymously must now authenticate. The admin settings page and dashboard widget read index state through Scolta_Admin::get_health_status() (direct filesystem reads), not this endpoint, so the admin UI is unaffected.

  • Ordinary page views no longer pay two non-autoloaded option reads per request. On hosts without a persistent object cache, every request cost one DB query for scolta_prompt_cache_version (the prompt-cache staleness check on plugins_loaded) and one for scolta_settings (Scolta_Auto_Rebuild::init() reading the auto_rebuild flag on init). Now: (1) the version scalar is written with autoload=true — it is exactly the option the per-request check reads; existing installs converge without a migration because the value changes on every plugin update, which is when update_option() applies a new autoload flag. (2) Auto-rebuild hooks are registered unconditionally and the auto_rebuild flag is checked inside the callbacks, so scolta_settings is only read when content actually changes. trigger_rebuild() re-checks the flag at fire time, preserving the old behavior that a debounce event queued before the setting was turned off does not rebuild after it. scolta_settings (growing serialized array, can hold a legacy API key) and scolta_resolved_prompts (read only by AI endpoint requests) intentionally stay non-autoloaded.

  • Scheduler and admin builds now run the same streamed, budget-aware orchestrator pipeline as wp scolta build. Both paths previously violated the gatherer's own generator contract by materializing the entire corpus in RAM (exportToItems()), hardcoded 100-post chunks while the UI documented chunk_size/memory_budget_profile as applying to PHP builds, and the Action Scheduler path serialized the full corpus into ONE scolta_build_chunks transient that could exceed max_allowed_packet and fail silently. Both now stream the gatherer generator through IndexBuildOrchestrator exactly like the CLI: unchanged posts come back as CachedContentReferences via the timestamp manifest, the configured memory budget drives the chunk math, and resumable chunk state lives in the orchestrator's on-disk state directory — no corpus transient at all. Background rebuilds run as time-boxed slices (~20s per Action Scheduler invocation, checked at chunk boundaries alongside a memory-pressure probe) that resume from committed state, so a large site neither times out a single action nor loses progress; progress is mirrored into scolta_build_status by a new Scolta_Build_Status_Progress_Reporter, preserving the admin polling contract. Behavior notes: the scheduler's previous whole-corpus shouldBuild() fingerprint no-op is gone (the manifest makes unchanged content near-free instead, and force still bypasses it), and a failed background build now reports Failed: … in the build status instead of silently leaving stale chunk transients.

  • The two content pipelines now produce identical ContentItems via one shared mapper (Scolta_Content_Gatherer::to_content_item()). They had diverged on every field: ID scheme (post-{ID} vs {post_type}-{ID}), date (publish vs modified — recency scoring differed per pipeline), title (raw post_title vs entity-decoded get_the_title()), and WooCommerce metadata (PHP pipeline only). Canonical semantics, documented on the mapper: post-{ID} (keeps existing PHP-pipeline indexes and caches valid), the publish date (a typo fix must not bump a post's recency; the export pipeline previously used post_modified), entity-decoded filtered titles, and WooCommerce metadata in both pipelines. The scolta_content_item filter now runs inside the mapper, so it also fires for the tracker-driven get_changed_content() path, which previously skipped it. A parity regression test maps the same post through both pipelines and asserts identical output.

  • The Amazee.ai admin step UI is no longer a second source of truth for its strings. assets/js/amazee-admin.js re-rendered the PHP-side i18n-wrapped step markup as hardcoded English (with a stray PHP-style translators comment). The JS now uses wp.i18n.__()/sprintf() with the same source strings as the PHP step renderers (one set of translations), and the script is registered with the wp-i18n dependency plus wp_set_script_translations( 'scolta-amazee-admin', 'scolta-ai-search' ).

  • Amazee.ai credentials are now encrypted with authenticated encryption (scolta-php's AuthenticatedCipher, encrypt-then-MAC with HKDF key separation) instead of bare AES-256-CBC. [needs unreleased scolta-php] The old format had no MAC, and ANY decrypt failure — including a routine AUTH_KEY salt rotation — returned null = "not connected", silently dropping a working Amazee connection. Now: new credentials are stored as scolta-enc:v1: envelopes; an envelope that fails authentication logs once (throttled) and queues a persistent "reconnect to Amazee.ai" admin notice instead of silently disconnecting; legacy CBC blobs still decrypt via the (decrypt-only) legacy path and are immediately re-encrypted through the helper, so the migration is transparent. No crypto is hand-rolled locally; key derivation lives in scolta-php.

  • The duplicated 'Budget has been exceeded!' magic string is gone. [needs unreleased scolta-php] Scolta_Ai_Service::handlePossibleBudgetException() now delegates budget-error detection to scolta-php's public BudgetAwareProviderDecorator::isBudgetError(), which owns the Amazee budget signal (and checks the exception cause chain, not just the top-level message).

  • All four WordPress-minimum declarations agree on 6.1 (the code requires it — wp_cache_flush_group()): the plugin header and readme.txt header already said 6.1; the readme.txt body Requirements line and phpcs.xml.dist minimum_supported_wp_version said 6.0 and now match. PluginHeaderTest asserts all four stay in lockstep.

  • readme.txt changelog backfilled with the released 1.0.2/1.0.3/1.0.4 sections (condensed from CHANGELOG.md — wordpress.org users only see readme.txt, which stopped at 1.0.1), and the release workflow gained a check-readme-changelog job that fails a tag whose version has no = X.Y.Z = readme.txt section. A test keeps every stable CHANGELOG.md release ≥ 1.0.0 covered.

  • Untranslated user-facing strings wrapped for i18n (scolta-ai-search text domain): the activation admin notice, the shortcode "index not built yet" fallback, and the ajax_remove_db_key JSON responses.

Added

  • PHPStan static analysis (level 5) with the szepeviktor/phpstan-wordpress extension, a composer analyse script, and a CI static-analysis job (running against scolta-php dev-main like the test matrix). The rollout immediately caught real bugs: the WP REST controllers' isset( $result['exception'] ) debug-log blocks were dead code — the vendor handler never returns that key; it logs through its injected PSR-3 logger, which the WP controller never supplied, so AI endpoint failures were being silently swallowed. make_handler() now passes Scolta_Logger and the dead blocks are removed. One entry was initially baselined — scolta-php's handleFollowUp() @return array shape omitted the limit key it actually returns — and removed again once the upstream fix (tag1consulting/scolta-php#208) merged; the baseline is now empty.
  • phpcs now enforces WordPress-Docs on includes/, admin/, and cli/, checks uninstall.php, and counts warnings (the errors-only -n flag is gone). The one-time fallout triage added ~120 real docblocks, replaced stale sniff names in existing ignores, gave every remaining discouraged-function warning a targeted written justification, removed an orphaned duplicate docblock in Scolta_Admin, and renamed two reserved-keyword parameters. Two sniffs are excluded with written justification: the soft line-length warning (the hard limit stays enforced) and unused-parameter (WP hook signatures are fixed by core).

Fixed

  • Starting an Amazee.ai trial no longer overwrites a customized AI model. An operator-precedence bug in the trial-start handler ($settings['ai_model'] ?? $default === $default parses as ?? ($default === $default) — always truthy) made the "only adopt the provisioned model when the site is still on the default" guard a no-op, so provisioning clobbered ANY configured ai_model. The null-coalesce is now parenthesized, and a source-parse test bans the ?? $x === pattern across admin/.
  • The Amazee.ai budget-exceeded admin notice can now actually display. The handler registered its admin_notices callback during the REST search request in which the budget error fires — a request where that hook never runs — while still setting the 24h throttle transient, so the trial budget could exhaust, visitors silently lose AI search, and the administrator was never told. handle() now persists a pending-notice transient and Scolta_Admin::init() unconditionally hooks a renderer that shows and clears it on the administrator's next admin page load (non-admin page views don't consume it).
  • The Scolta dashboard widget is no longer registered for non-administrators. It rendered index status, AI configuration state, and a rebuild button (whose POST handler is gated on manage_options) to every role that can reach the dashboard — info disclosure plus a guaranteed-broken button. add_dashboard_widget() now requires manage_options.
  • The CI "unbounded WP_Query" guard actually fires now. Its grep pattern started at the bare key, so WP's quoted array syntax ('posts_per_page' => -1) could never match — the guard has been inert since it was added, while the pattern it polices existed in gather_count(). The regex now matches the quoted form (and also scans admin/ and cli/), gather_count() switched from an ids-only unbounded WP_Query to wp_count_posts() (cached COUNT(*), loads nothing) like Scolta_Content_Source::get_total_count(), and an in-suite mirror test re-checks the source tree and proves the CI regex matches the quoted form.
  • The "Reset to default" button on the custom-prompt fields works now. Its inline onclick dereferenced two nulls: closest('div').querySelector('textarea') (the textarea is a sibling after the div) and .scolta-badge (the class existed nowhere). The handler moved into enqueue_admin_scripts() via wp_add_inline_script(), the badge span now carries scolta-badge, the textarea has an id the button references via data-textarea-id, and AdminInlineScriptTest now asserts no onclick= attribute (and no .innerHTML assignment) anywhere in admin/ — the gap that let this survive the rc4 inline-script cleanup.
  • The admin "Rebuild Now" handler honors the shared build lock and logs failures. It ran a full synchronous build with no check of Scolta_Rebuild_Scheduler::LOCK_KEY (the REST sibling 409s on it) and its catch (\Throwable $e) discarded the exception. It now refuses with an "already in progress" notice while the lock is held, holds the lock itself for the duration (released in finally), and logs the throwable via Scolta_Logger.
  • wp scolta clear-cache deletes the transient timeout rows too. The single LIKE '%_transient_scolta_%' could never match _transient_timeout_scolta_* (SQL _ matches exactly one character), stranding live expiry rows — the 2026-06-09 fleet regression found 157 of them on one demo after a cache flush. The command now deletes the same value/timeout pattern pair as deactivation and uninstall; a regression test runs the command against a LIKE-emulating wpdb stub and asserts zero _transient%scolta% rows remain.
  • Health checks resolve the index directory the builder actually wrote to when output_dir ends in /pagefind (2026-06-09 fleet regression, P2). The REST /health enrichment and the dashboard get_health_status() appended /pagefind to the raw setting, so an already-suffixed output_dir permanently reported degraded/fragments: 0 against a healthy index. A new shared scolta_normalize_output_dir() applies the builder's exact normalization (strip one trailing /pagefind); health, the dashboard widget, and the shortcode (which previously duplicated the logic inline) all use it. Regression tests assert suffixed and unsuffixed configs report identical fragment counts and status.
  • Scolta_Tracker::mark_all_for_reindex() early-returns 0 on an empty post_types setting instead of interpolating an invalid IN () clause into the INSERT…SELECT.
  • ai_base_url is validated as an http(s) URL (esc_url_raw with an http/https scheme allowlist) instead of sanitize_text_field, and recency_curve validates its decoded shape ([[days, boost], ...] numeric pairs; anything else is rejected wholesale) instead of accepting any JSON array.
  • The resolved-prompt cache write on settings save passes autoload=false, matching its sibling in the version-staleness refresh path — the blob is only read at AI request time.
  • The test-connection result is rendered via textContent/createTextNode instead of concatenating server-returned values (provider, model, response_time, error) into innerHTML.
  • Removed the dead --bundle forwarding in the CLI auto-resume path. No command docblock ever declared --bundle, so WP-CLI rejected it before the handler could see it — the branch was unreachable.
  • Saving the settings form no longer stores a silently-truncated copy of an over-5000-character default prompt as a stale "custom" prompt. sanitize_prompt() truncated the submitted textarea value to the 5000-character storage cap before comparing it against the built-in scolta-php default, so any default longer than the cap could never match itself and round-trip back to '' ("use the default"): the truncated text was stored as a custom prompt, silently mangling the prompt and pinning the site to a stale copy that no longer follows upstream default updates. This was latent on every release since the cap was added — the vendored scolta-php 1.0.3 summarize default is already 5415 characters — and became visible when scolta-php#201 pushed the expand_query default past the cap too, turning scolta-php's cross-repo "Adapter install — WordPress" job red on every scolta-php PR since (the adapter's own CI stayed green because the old test only exercised the under-cap expand_query). The default-match comparison now happens on the untruncated sanitized value (with the default passed through the same sanitize_textarea_field() so the comparison survives WP form sanitization), and the cap applies only to genuinely custom prompts. AdminSanitizeTest::test_prompt_clears_when_matches_default now derives the expected text from the installed scolta-php for all three prompts — making it drift-proof by construction in both CI contexts, whatever prompt text upstream currently ships — and a new test pins the 5000-character cap for custom values. (tag1consulting/scolta-php#201)
  • Synced assets/js/scolta.js (and its .sha256) from scolta-php — four shared browser-render bug fixes (tag1consulting/scolta-php#199). Asset-only re-sync of the canonical browser script; no WordPress-side logic changed. (1) Zero-result blank panel: a search that returned no matches blanked the result panel for the entire duration of the asynchronous AI query-expansion round-trip — renderResults() cleared the container and header and returned early while expansion was in flight, so "No results found." only appeared after the expand promise settled (on a slow expand endpoint, a multi-second blank). It now shows a neutral "Searching…" in-progress state while expansion runs, then the terminal results-or-"No results found." once it settles. (2) "1 results": the header now pluralizes the noun on the count (1 result vs N results). (3) Doubled quotes: a quoted-phrase query rendered as ""merge conflict"" because the header wraps the query in its own quotes and escapeHtml() does not escape "; a matched surrounding quote pair is now stripped before the template re-adds one. (4) Citation duplication: AI-summary context (buildLLMContext) now collapses results that resolve to the same URL, so the summarizer is no longer handed — and no longer cites — duplicate sources. This resync also carries scolta-php main's previously-unsynced facet-count fix (computeUnionFacetCounts): facet counts no longer read all-zero on a long conversational query whose strict-AND search is empty — counts follow the same per-term OR union the result list uses. The committed copy (and its .sha256) is byte-identical to scolta-php's canonical assets/js/scolta.js; CI's asset-drift check compares against scolta-php dev-main, so it passes once scolta-php#199 merges to main.