A catalogue of the current, observed behaviour of the wp co-authors-plus
subcommands, recorded while writing the Behat characterisation suite in
features/.
These describe what the code does today, not what it should do. Several
entries are bugs or clear infelicities. They are pinned as-is by the feature
files so that the planned refactor (splitting CoAuthorsPlus_Command into one
class per command) can be proven behaviour-preserving. Each entry is therefore a
candidate for its own fix PR, where the fix and the corresponding re-pinned
scenario land together.
Every entry below was verified against a live environment (WordPress trunk, PHP 8.4) rather than inferred from reading the source.
(Calibrated against the live env 2026-09-01; all scenarios green.)
-
Summary lines never pluralise:FIXED by the pluralisation sweep:- 1 posts now have the proper co-author,- 1 posts already had the co-author assignedeven for a single post._n()with%sandnumber_format_i18n(), both branches pinned. The "already had" plural needed a two-post extension of the re-run scenario; nothing else ever reached it, because the count is truthiness-guarded. -
When a run matches nothing, the output is just
All done! Here are your results:with no result lines at all — there is no "0 posts" line and no per-post output. Confirmed. -
The "already associated" path (meta value loosely matches an existing co-author, e.g. via the post_author fallback in
get_coauthors())continues BEFOREadd_coauthors()runs (php/class-wp-cli.php:388-392), so the command never backfills an author term for such a post. Term-based tooling (e.g.swap-coauthors' tax_query) will therefore not see it. Note: in practice a post created with a validpost_authorgets its author term immediately from the plugin's ownsave_posthook (coauthors_update_post()), so the pinned scenario removes that term first (wp post term remove) to exercise the fallback, then asserts--format=count= 0 after the command runs. Confirmed. -
The effective default
--meta_keyis_original_import_author; neither the docblock nor the synopsis documents this default. (The phase 0 brief's note that the default isauthordoes not match the code.) -
Posts lacking the meta key are invisible to the command (the WP_Query filters on meta-key existence), so they are not counted in any total. Confirmed.
-
The log echoes the RAW meta value while assigning the co-author found via the
sanitize_title()fallback: metaAuthor Oneassigns userauthor-onebut logs... has been assigned "Author One" as the author. Confirmed. -
Re-calibrated 2026-09-02 after adversarial review: 10 scenarios, all green.
-
The three summary blocks are emitted in a FIXED source order — already-associated, then missing, then associated (php/class-wp-cli.php:415-425) — and no single-outcome scenario can show that. Now pinned in "Report every outcome in one run and ignore posts the query cannot reach", where one run produces all three blocks and the per-post counter runs across the mixed outcomes (
1:assigned,2:already,3:missing). -
The WP_Query at :369 filters on meta-key EXISTENCE only and sets no
post_status, and WP-CLI runs unauthenticated, so two whole classes of post are invisible to this command: posts without the meta key, and DRAFT/pending/private posts that have it. Neither is counted in any total or summary line, and there is no--post_statusflag to opt in — an editorial team backfilling before publication is silently skipped. That same mixed scenario now carries a no-meta post and a draft-with-meta post as discriminators: neither is enumerated, and both end with zero author terms. -
The
sanitize_title()fallback path is NOT idempotent. The already-associated check compares the RAW meta value against$existing_coauthor->user_login(:381-386), while assignment uses the co-author's nicename (:401), so metaAuthor One-> userauthor-onere-assigns and re-counts on EVERY run —1: Post #N has been assigned "Author One" as the author/- 1 posts now have the proper co-author, for ever. On a large import that is a needless write storm (add_coauthors()pluswp_set_post_terms()per post per run). Pinned by the second run in "Fall back to a sanitised meta value, re-assigning on every run". The exact-match path IS idempotent (its second run reports "already has ... associated as a co-author"). -
An empty meta VALUE is processed, not skipped: the key exists,
get_post_meta()returns '', bothget_coauthor_by()lookups fail, and the post is reported asdoes not have "" associated as a co-author but there is not a co-author profile. The summary then implodes that empty string into the missing list, so the last line readsghostwriter, phantom,with a dangling comma and trailing space (:422). Pinned in "Report missing co-author profiles once each in the summary" (the wp-env harness trims the trailing space; a run whose ONLY missing value is empty loses the whole line, because the harness drops whitespace-only lines). NBwp post meta update <id> <key> ""does NOT store an empty string — WP-CLI repliesSuccess: Value passed for custom field '<key>' is unchanged.and writes nothing — so the fixture useswp eval 'update_post_meta( ..., "" );'. -
Assigning an unlinked guest author sets the author term but leavesFIXED in the caller, not inwp_posts.post_authoron the previous user, and the command ignoresadd_coauthors()'s return value, still logginghas been assignedand counting the post as successfully associated with no mention of the discrepancy.add_coauthors(). The per-post line is unchanged because it was true — the byline really was assigned — but the summary now counts the posts whosepost_authorcould not follow. That column is what the admin posts list,WP_Query'sauthorparameter and many themes read, so an operator who is not told about it will see the old author still attributed. The scenario already asserted the unchangedpost_authorin state; the command now says it aloud. Note the value handed toadd_coauthors()is$coauthor->user_nicename, which for a guest author issanitize_title( user_login )(jane-doe, NOT thecap--prefixed post_name); the prefix is re-added insideget_post_meta_key()during the nicename lookup, so a refactor that "normalised" this touser_login(as swap-coauthors does) would happen to keep working, whereas one that passed the post_name would double-prefix. -
Six defects resolved together (one PR), because they all live in one method.
TheFIXED by resolving the co-author first and comparing against the resolved login. Note the problem was broader thansanitize_title()fallback is not idempotent: the already-associated check compares the RAW meta value againstuser_loginwhile assignment passesuser_nicename, so metaAuthor Onenever matchesauthor-oneand the post is re-assigned and re-counted on EVERY run — a write storm, per post, for ever.sanitize_title():get_user_by()also retries after stripping acap-prefix, and guest-author lookups sanitise on every call, so metacap-author1was equally non-idempotent. Comparing against the resolved co-author is the only fix that closes all of them.The already-associated branchFIXED. Only a real author term now counts as already associated. Thecontinues beforeadd_coauthors()is ever called.get_coauthors()falls back to thepost_authoruser when a post has no author terms, so a post matched only via that fallback is treated as done and never gets a term — invisible to all term-driven tooling afterwards.post_authorfallback inget_coauthors()is untouched; what changed is that this command no longer reads it as evidence of a byline.NoFIXED withpost_statuson the query, and no flag to opt in, so drafts carrying the meta key are silently skipped.--post-statuses, defaulting topublish— an opt-in rather than a widened default, because this command rewrites a byline per post. Contrastlist-posts-without-terms, which was widened because it only reports. Naming the status also removes a quirk: the old default was publish PLUS whatever private statuses the current user could read, so scope depended on whether--userwas passed. That is a NARROWING for anyone running under--user, and belongs in the changelog.An empty meta VALUE is processed rather than skipped, so it joins the missing list and imodes into a dangling comma.FIXED with its own counter and message. A counter rather than anarray_filter()on the missing list, because it buys an invariant: every post reached now increments exactly one counter, soposts_total > 0implies at least one summary line — which is what makes the empty-run fix below airtight rather than reintroducible through a side door.The log echoes the RAW meta value while assigning the sanitised match, sayingFIXED on both the assign and the already-associated lines; the catalogue only named the first.assigned "Author One"while actually assigningauthor-one.An empty run prints onlyFIXED — it now says which meta key it found nothing for.All done! Here are your results:with no counts, because every summary line is truthiness-guarded.
-
TRAP for anyone touching this command: do NOT "normalise" what is passed to
add_coauthors()fromuser_nicenametouser_login. It resolves names with$query_type = 'user_nicename', so a login whose nicename differs (john.doevsjohn-doe) would fail that lookup,update_author_term( false )returns false, the slug substitution is skipped, and an unprefixedjohn.doeterm gets written.
(Calibrated against the live env 2026-09-01; all scenarios green.)
-
FIXED. The command now declares--dryuses the raw string value in a boolean context, so ANY non-empty value — including--dry=false— enables dry-run mode.[--dry-run]as a boolean flag per WP-CLI convention and reads it withUtils\get_flag_value().--dryremains accepted as a deprecated alias, emitting a notice. -
When a post has multiple co-authors, the swap rewrites
post_authorto the FIRST remaining co-author with a WP user (term-name order fromget_coauthors()), not necessarily the--toauthor, even though the log claims the--toauthor "has been assigned". Pinned in "Preserve other co-authors when swapping" (post_author ends up as author3, not author2). Confirmed. -
WP-CLI accepts an explicit empty value for the required parameter (
--to=), so the plugin's own--to param must not be emptyguard is reachable and fires. Confirmed. -
The
--to param must not be emptyguard (php/class-wp-cli.php:704-706) runs only AFTER the--fromlookup, so an invalid--fromcombined with an empty--toreportsNo co-author foundfirst. -
The tax_query only matches the prefixed
cap-<user_login>slug (php/class-wp-cli.php:695, 722-728), so posts carrying a legacy unprefixed author term for the--fromco-author are not swapped (not pinned in a scenario; noted from source). -
Re-calibrated 2026-09-02 after adversarial review: 11 scenarios, all green. The
--from-before---toguard order above is now pinned in "Validate --from before an empty --to", and all five error scenarios assertthe return code should be 1plusSTDOUT should be empty. Themissing --from parameterscenario was LOOSENED toSTDERR should contain:because the surroundingError: Parameter errors:rendering belongs to WP-CLI, not to CAP (and CI installs wp-env, hence WP-CLI, unpinned), so a one-class-per-command split that swaps@synopsisfor a## OPTIONSdocblock must not fail this file. -
Unbounded loop. Outside preview mode the drain loop advancesFIXED. The command now works from the resolved co-author logins rather than the raw input, which removes the case-mismatch cause entirely, rejectspagedonly when previewing, relying onadd_coauthors()removing thecap-<from>term to make progress;--fromequal to--to, or a--fromwhose case differed from the storeduser_login, left the term in place and the command ran forever.--fromequal to--toup front, and aborts with a clear error if a page of posts ever comes back unprocessed. Both former triggers are now pinned by scenarios, which are safe to run. -
FIXED. This was the worst defect the characterisation pass found: the two most natural ways to ask for a preview both mutated the site and exited 0.--drytruthiness:--dry=trueand--dry=falseare BOTH previews, but--dry=0and the valueless--dryperform a REAL swap — WP-CLI matches the bare flag against the[--dry=<dry>]synopsis, warns--dry parameter needs a value, DISCARDS it, and thefalsedefault applies.--dry-run(and the deprecated--dry) now preview correctly in their bare form. Following WP-CLI convention,--dry-run=0and--no-dry-runremain real swaps, since flag values use PHP truthiness and--no-<flag>is the documented negation; both are pinned in "Treat --dry-run=0 and --no-dry-run as a real swap". -
The command is TERM-driven, notDecision taken 2026-09-05: report, don't widen. The command now counts publish posts whosepost_author-driven, and silent about it. A post whose only link to thefromauthor iswp_posts.post_authorwas reported asFound 0 posts to update.and nothing more — a clean no-op on exactly the shape a plain-WordPress migration produces.post_authoris the from user's account but which carry nocap-term, and warns that the swap does not touch them. It still writes nothing new — actually swapping those posts would widen the command's write scope, which stays a separate decision (an opt-in flag is the likely shape if anyone asks). The count query passessuppress_filters, because CAP's own author-query rewrite would add term matches and defeat the point; both_n()branches and the unlinked-guest-author (no user ID) branch are covered by scenarios. The original wording of this entry continues below for the record: it was reported asFound 0 posts to update./Success: All done!and left untouched — unlikeassign-user-to-coauthorandget_coauthors(), which both fall back topost_author. Sites migrating from plain WordPress authorship therefore get a silent no-op. Pinned in "Ignore posts the swap query cannot reach". -
The WP_Query at :715-730 sets no
post_statusand WP-CLI runs unauthenticated, so only public statuses match: a DRAFT post carrying thecap-<from>term (CAP'ssave_posthook gives it one) is silently skipped and is not even counted inFound N posts to update.. There is no--post_statusflag to opt in. Pinned in the same scenario;assign-coauthorshad the identical restriction until it gained--post-statuses; swap-coauthors is now the only command whose status filter cannot be opted out of. -
Swapping TO an unlinked guest author leavesFIXED in the caller, exactly as forwp_posts.post_authorpointing at the previous WP user indefinitely, and the command ignoresadd_coauthors()'s return value — so the byline changes, the log still sayshas been assignedand the command still reportsSuccess: All done!.assign-coauthorsabove, and deliberately with the same wording so the two commands report the condition identically. -
NOT a bug — an adversarial-review claim tested live on 2026-09-02 and REJECTED: logins whose
user_nicenamediffers from the raw login, e.g.john.doe(nicenamejohn-doe, termcap-john-doe), ARE swapped correctly even though the tax_query slug is built naively as'cap-' . $from_userlogin(:694-695).WP_Tax_Querysanitisesslugterms withsanitize_term_field(), socap-john.doebecomescap-john-doebefore the query runs. Verified:swap-coauthors --from=john.doereportedFound 1 posts to update.and moved the term. No scenario added. -
Multi-post behaviour is now pinned (3 posts):
Found 3 posts to update., per-post counter1:/2:/3:in ascending ID order, and a re-run reportingFound 0 posts to update.— which is also the only proof that the non-dry drain loop terminates because thefromterm really is removed. Thecap-<from>term itself survives with count 0 (--slug=cap-author1 --format=countis 1). -
Test-isolation note: the Background now deletes every author term before each scenario (same convention as migrate-author-terms.feature and reassign-terms.feature), because author terms survive
reset_database_state(). Two assertions in this file are global by nature (--slug=cap-author1|cap-author2 --format=count), and without that cleanup an orphancap-author2term left by any other feature — guest-author terms are never cleaned up at all — would fail the dry scenario with an unrelated "dry run created a term" message. -
TheFIXED — the usage error is validated before any lookup. The scenario that pinned the old order is inverted and renamed.--to must not be emptyguard ran after the--fromlookup, so an invalid--fromwith an empty--toreported the missing co-author instead of the missing parameter.
- Its return value does not mean "the assignment succeeded". When
$appendis false and none of the given co-authors resolves to a WordPress user, it returnsfalseafter writing the author terms perfectly well (php/class-coauthors-plus.php, thefalse === $append && empty( $new_author )branch). A byline made up only of guest authors without linked accounts hits this every time, which is the normal case for a guest-author migration. - Treating it as a success signal makes a caller report that it changed nothing
while the terms are visibly there. Decide "did the byline change" from the
byline itself, not from this return value. Pinned by
Coauthor_Assignment_Serviceand its regression test. - Decision, and the reason the semantics were left alone. The obvious tidy-up is
to make the return mean "the assignment succeeded". It was rejected: this is
de-facto public PHP API, called by the classic metabox, both REST write paths,
bulk edit and user-deletion reassignment, and
Coauthor_Assignment_Service's regression test pins the current meaning. Instead the two CLI callers that misreported it —assign-coauthorsandswap-coauthors— now read it for what it actually says, which is whetherpost_authorwas synced, and report that separately from whether the byline changed. Any future caller should do the same; the return is apost_authorsignal, not a success signal, and the method is behaving correctly within its own contract. - The two summary lines this added are pluralised with
_n()from the outset, followingassign-user-to-coauthor, and both branches are covered by scenarios — one post and two. The general pluralisation sweep across the older strings is now done, but the principle stands: it was a reason to leave existing strings alone, never a licence to add new broken ones. The new strings used%srather than the precedent's%d:number_format_i18n()returns a thousands-separated string, and%dtruncates1,234to1.The precedent has that bug.FIXED in the sweep, pinned by a unit test that stubs the formatter to return1,234and asserts it survives into the output — Behat cannot stage a thousand posts, so the formatting contract is tested instead of the volume.
- Author taxonomy terms survive the Behat reset:
reset_database_state()deletes posts and non-admin users but never author terms. User deletion removes that user's own term (CAPdelete_userhook), but terms with no matching user persist forever — the live tests DB currently carries an orphancap-renamed-adminterm (residue of rename-coauthor.feature renaming admin's term to a login with no user). GLOBAL assertions such aswp term list author --format=countor unscoped--field=sluglistings are therefore unreliable; scope them with--object_ids=<ids>or--slug=<slug>instead. - CORRECTION (2026-09-02):
--slug=<slug>scoping is only safe for NEGATIVE or count assertions. For a POSITIVE "the command created this term" assertion it is a tautology — a leftovercap-<x>term from any earlier feature or earlier run satisfies it, andCoAuthors_Plus::get_author_term()matches on slug alone, socreate()silently REUSES such a term rather than inserting one. Use--object_ids=<the post the command just created>; term relationships are deleted with the post, so residue cannot satisfy them. Better still, add the Background term wipe (see migrate-author-terms.feature) so the term really is new. The guest-authors group's three features now do both. - CAP's
save_posthook (coauthors_update_post()) fires onwp post create, so any post created with a valid--post_authoralready has that author'scap-<nicename>term (creating the term if needed) before any CLI subcommand runs. Posts created without--post_authorget post_author 0 and no term.
(Calibrated against the live env 2026-09-01; re-verified green 2026-09-02 — 11 scenarios.)
-
The documentedFIXED. The defaults and the read now use the hyphenated key WP-CLI actually supplies, so the file is loaded, and a missing file reports an error and exits non-zero.--author-mapping=<file>flag IS dead code: WP-CLI passes the assoc arg under the hyphenated keyauthor-mapping, but the method reads$this->args['author_mapping'](underscore) afterwp_parse_args. The mapping file is neverrequired, theauthor_mapping doesn't existerror is unreachable, and even a nonexistent path is accepted silently. -
The flag naming in this command was mixed:
--author-mappinghyphenated alongside--old_term/--new_termunderscored, which is what invited the key-mismatch bug above.--old-termand--new-termare now the documented spellings; the underscored forms still work but report a deprecation notice. -
When neither a usable mapping norFIXED. The variable is initialised, a mapping file that does not define--old_term/--new_termis supplied,$authors_to_migrateis never defined, so theforeachraises PHP warnings on PHP 8+ — yet the command still prints a zero-count summary and exits 0.$cli_user_mapis reported, and the command now errors when it has nothing to reassign rather than pretending to succeed. -
The rename path (target term absent) sets the surviving term's slug AND name to the rawFIXED. The slug is now--new-termvalue with NOcap-prefix, inconsistent with the plugin'scap-<nicename>slug convention.Prefix::prefix_slug( $new_user )while the name stays raw, matching whatrename-coauthoralready does. Note this does NOT make a second run idempotent, and deliberately so: the command never touches the guest-author profile, sopost_namestill holds the old login and the entry below about the second run reporting a missing term still stands. Renaming the profile too isrename-coauthor's job. -
"Error: Term 'x' doesn't exist, skipping" is emitted via
WP_CLI::logto STDOUT and the exit code stays 0 (line 580), so scripted callers cannot detect the miss from the exit code. Confirmed. -
Summary lines never pluralise:FIXED by the pluralisation sweep:- 1 authors were successfully reassigned terms._n()with%sandnumber_format_i18n(), both branches pinned. The singular of the missing-terms line reads "was missing an old term", since each author misses exactly one. The merge message's plural (Reassigning 2 posts) needed a new two-post merge scenario: the merge path can never report zero, so no existing pin exercised it. -
The merge message's post count comes straight from
$old_term->count(it readReassigning 1 postsbefore the sweep; now1 post) — CAP's custom_update_users_posts_counthad run onwp post term add. Confirmed. -
Re-calibrated 2026-09-02 after adversarial review: 11 scenarios, all green.
-
The no-args PHP warnings are no longer pinned with their location (
... in /var/www/html/.../php/class-wp-cli.php on line 571); the exactSTDERR should be:block was replaced by two location-freeSTDERR should contain:blocks. The line number is incidental — any edit above it, and certainly the planned one-class-per-command split, would break a green test with no behaviour change. Same reasoning for the underscore-variant scenario: theError: Parameter errors:framing and theDid you mean '--author-mapping'?suggestion are owned by wp-cli/wp-cli (Dispatcher/Subcommand.php) and the wp-env image's WP-CLI version floats, so only rc 1 +unknown --author_mapping parameteris pinned now. -
Supplying only
--old_term(no--new_term) falls into exactly the same undefined-variable path as supplying nothing —Warning: Undefined variable $authors_to_migrate, rc 0, all-zero summary. Theif ( $old_term && $new_term )guard at :557 has no else branch and there is no validation of the pair. Pinned in the no-args scenario. -
NEW (silent false success): the rename branch ignoresFIXED. The return is checked and a failed rename reports a warning naming the term and the underlying reason, and is not counted. The scenario's fixture term had to move to slugwp_update_term()'s return value. When the target slug is already taken by an unrelated author term it returnsWP_Error( 'duplicate_term_slug' ), nothing is renamed, and the command still logsSuccess: Converted ...and counts it a success.cap-newuserto keep provoking the collision, since the prefix fix above changed what the rename targets — worth knowing, because had the prefix fix landed second this scenario would have started passing while proving nothing. Core owns the duplicate-slug wording, so only CAP's half of the message is pinned. -
NEW (unresolved numericFIXED. The lookup result is checked and the row is skipped with a warning naming the ID, so no null ever reaches core. rc stays 0, which is consistent with the other skip paths — see the open note about exit codes below.--new-term):--new-term=999999with no such user makesget_user_by( 'id', ... )return false, PHP 8.4 emitsWarning: Attempt to read property "user_login" on false,$new_userbecomes null, and the rename branch callswp_update_term()with a null name/slug whose WP_Error is discarded too. -
NEW (data loss):FIXED. The merge branch now compares--old-term=x --new-term=xtakes the MERGE branch, because both lookups return the same term object.wp_delete_term( $id, 'author', array( 'default' => $id, 'force_default' => true ) )reassigns the term's posts to the term that is about to be deleted, so those posts end up with NO author term at all while the summary reports a successful merge.term_idand skips with a warning. Comparing the two inputs would not have been enough: two different spellings can resolve to the same co-author, so the guard has to be on the resolved term. The scenario now asserts the post keepscap-olduserand the term survives. -
The docblock (:518-525) advertises cleaning up after an import that created 'author' terms under the OLD user_login, but the old-term lookup goes through
get_coauthor_by( 'login', ... )->get_author_term(), which returns null for a non-object. An orphan author term with no user or guest author behind it is therefore reported asError: Term 'orphan' doesn't exist, skippingand left in place, so the documented use case cannot be served. Pinned (with a term_id state assertion showing the term survives) in "A missing old term is reported and skipped without an error exit", alongside the never-existed slug, so the two causes of the same message are now distinguishable. -
Idempotency: a second identical run reports the old term as missing even though
get_coauthor_by( 'login', 'olduser' )still finds the guest-author profile — the rename leftpost_nameascap-olduserwhile the term becamenewuser, soget_author_term()(which triescap-<nicename>then<nicename>) finds nothing. This command never touches the guest-author profile (contrast rename-coauthor); both the rename and merge scenarios now assertwp post list --post_type=guest-author --field=post_name. NB guest-author post IDs followget_users()login order (admin, newuser, olduser), not user creation order.
(Calibrated against the live env 2026-09-01; re-verified green 2026-09-02 — 8 scenarios.)
-
NOT A DEFECT — do not "fix" this. The merge branch has no
continue, so after merging it also logs "isn't prefixed, adding one" and re-slugs the term. That fall-through is REQUIRED.wp_delete_term()is called on the PREFIXED sibling with the BARE term as itsdefault, so the survivor is the bare term and still holds the unprefixed slug; the re-slug is what completes the migration. Adding acontinuehere would merge two terms and leave the survivor unprefixed — a silently failed migration, and non-idempotent, since the nextupdate_author_term()would recreate the collision. Two log lines for two real operations is honest narration. The comment claiming the term "doesn't have a sibling" was wrong on this path and has been corrected in the source. -
"Now migrating up to N terms" counts ALL author terms, including already prefixed ones that will only be skipped.FIXED. Prefixed terms are filtered out before the count and the loop, so the number is the work actually to be done. One predicate fixes this and the stale-object entry below together: the only row the loop deletes is a prefixed sibling, which the filtered list no longer holds, so iterating a stale object became structurally impossible rather than merely unobserved. Aget_terms()WP_Erroris now caught too — without that,array_filter()on a non-array would have turned a PHP warning into a fatal. -
The success message reads "All done! Grab a cold one (Affogatto)".FIXED — the drink is an affogato."Now migrating up to 1 terms" still does not pluralise— FIXED by the sweep, which was deliberately left as one dedicated pass rather than scattered through fix PRs, since it touches nearly every command and feature file. -
Terms are processed in
get_termsdefault order (name ASC). Still true, but no longer observable from the log, since prefixed terms are never narrated. -
NOT A DEFECT — this entry was wrong. Only the SLUG is prefixed and the term NAME is left raw, which is not drift but the plugin-wide convention. Every author term the plugin creates is built that way by
update_author_term()(wp_insert_term( $coauthor->user_login, ..., array( 'slug' => Prefix::prefix_slug( ... ) ) )), and bothrename-coauthorandreassign-termswrite a prefixed slug with a raw name. The term name is also read exactly once in the whole plugin, in arename-coauthorlog line — every lookup goes by slug. Prefixing the name would diverge from all three creation sites and buy nothing. The scenario stays as a guard on the convention rather than a pin on a bug. -
Re-calibrated 2026-09-02 after adversarial review: 8 scenarios, all green.
-
Taxonomy scoping is now pinned. With a
guardiancategory and acap-guardianpost_tag in the database,Now migrating up to 1 termsprovesget_terms()is scoped to the author taxonomy, and the surviving post_tag proves the sibling lookupget_term_by( 'slug', 'cap-' . $slug, $coauthors_plus->coauthor_taxonomy )(:858) is too. That$taxonomyargument is optional in core, so a refactor that dropped it would startwp_delete_term()-ing same-slug terms from other taxonomies. NB category/post_tag terms are NOT cleared by the Behat reset either, so the scenario deletes its own two terms first viawp eval. -
ReverseFIXED by the same filter as the count above. The scenario is retained, retitled around what it still proves — that the merge works whatever the ordering — since its original subject no longer exists.get_terms()ordering: with names "Aaa" (slugsomeone) and "Zzz" (slugcap-someone) the BARE term is processed first, its prefixed sibling is deleted mid-loop, and the loop then still logsalready prefixed, skippingfor a term that no longer exists, because theforeachiterates term objects fetched before the loop. -
Return code 0 is now asserted on the primary happy paths:
I rundoes not check exit codes despite its docblock, and when the exit code is non-zero the harness movesError:lines off STDOUT, so a command that died after printing the expected lines would previously have satisfied everySTDOUT should be:block here. -
Watch the discriminators here. Because skipped terms are no longer narrated, the "already prefixed" and "run twice" scenarios now print exactly what a run against an empty database prints, so their state assertions are the only thing left distinguishing them — the run-twice scenario had none and has been given one. A new scenario with two prefixed terms and one bare one exercises the count properly; the file previously never held more than two terms.
-
A missing wordpress-importer plugin gives an uncaught PHP fatal from the unguardedFIXED. The path is checked first and reported withrequire_once, so an operator is handed a stack trace and WordPress's generic critical-error line instead of being told which dependency to install.WP_CLI::error(). The scenario that pinned the fatal now pins the clean error, and it discriminates: the fixture genuinely uninstalls the plugin, so the guard's failure branch is executed rather than assumed. -
The importer path also moved from
WP_CONTENT_DIR . '/plugins'toWP_PLUGIN_DIR, which is the constant WordPress provides for exactly this and respects a relocated plugin directory. Stated plainly: no test discriminates this. In the default environment the two constants resolve to the same path, and the scenario matches the path with a wildcard, so it passes either way. It ships on the reasoning that a site withWP_PLUGIN_DIRset elsewhere would otherwise be told the importer is missing when it is merely somewhere else. -
FIXED, and the earlier framing of the fix was wrong twice over. It is not "validate the file first" and not the importer's bug: the fallback parser (Failed to read WXR file.is unreachable: a file that is not a WXR fatals inside wordpress-importer 0.9.6's parser fallback chain rather than returning aWP_Error.WXR_Parser_XML_Processor) needs the importer's bundled php-toolkit, which only the importer's own bootstrap loads — CAP half-loaded the parser by requiringparsers.phpalone, breaking the parser's error contract. Loading the toolkit under the exact guard the bootstrap uses (class_exists( 'WordPress\XML\XMLProcessor' ), plusfile_existsso older importers without a toolkit keep their self-contained chain) makes garbage returnWP_Error( 'WXR_parse_error', ... )— verified live before patching — and the dead branch both reachable and pinned. Valid files are untouched: SimpleXML still returns first, confirmed against the good fixture. The error now appends the parser's reason after CAP's own prefix; only CAP's half is pinned.
(Calibrated against the live env 2026-09-01; re-verified green 2026-09-02 — 7 scenarios.)
-
The taxonomy is hardcoded asFIXED. The read side went through the configured taxonomy while the write named'author'in thewp_set_post_termscall instead of$coauthors_plus->coauthor_taxonomy.authordirectly, so on a site that had changed the property the command found the terms, logged a removal for each revision, counted them, and cleared nothing — reporting success for work it had not done. A unit guard now reads the command sources and fails if any of them names the taxonomy directly, since covering it live would mean registering a taxonomy under another name before init. Revisions are still read via direct SQL onpost_type='revision' AND post_status='inherit'. -
All output is
WP_CLI::log— there is noWP_CLI::successand the exit code is always 0 (deliberately left; see the correction below — siblings exit 0 too).Count lines never pluralise: "Found 1 revisions to look through", "1 revisions had author terms removed".FIXED by the pluralisation sweep:_n()with%sandnumber_format_i18n(), both branches pinned. -
Current plugin code no longer adds author terms to revisions (
coauthors_update_postbails for unsupported post types such asrevision), so the characterisation scenarios attach terms to revisions manually to exercise the removal path; a plainwp post updateproduces a term-less revision (pinned in "Leave a revision without author terms untouched") and exactly ONE revision per update on WP trunk. Confirmed. -
wp post term add <revision-id> author <slug>does NOT work for the manual attachment: entity-command rejects it withError: Invalid taxonomy author.because the author taxonomy is not registered for therevisionpost type. The scenarios usewp eval 'wp_set_post_terms( <id>, array( "cap-..." ), "author" );'instead, which bypasses the object-type check — the same ability the plugin itself historically used to pollute revisions. -
Removal logs slugs in term insertion order (
cap-admin,cap-aliceas attached), and the parent post's own author terms are untouched. Confirmed. -
wp_set_post_terms( $post_id, array(), 'author' )detaches the terms but does NOT delete them, so a merely-emptied term survives; and the revision post itself is left in place. Both pinned as state assertions. -
Test-isolation note (2026-09-02): the Background now deletes every author term before
create-guest-authors, becausereset_database_state()does not clear author terms between scenarios. Without it,wp term create author alice --slug=cap-alicecollides with residue from an earlier run and the unscopedwp term list authorassertion is nondeterministic. See the shared test-environment section above. -
Re-calibrated 2026-09-02 after adversarial review: 7 scenarios, all green.
-
The
$affectedcounter is now exercised above 1 ("Every revision with author terms is counted": two tagged revisions ->All done! 2 revisions had author terms removed). The driving query (:964) has no ORDER BY, so that scenario asserts withshould contain:blocks rather than one exact block. -
The comma-joined slug list for a multi-term revision is an incidental ordering:
cap_get_coauthor_terms_for_post()orders byterm_order, which is always 0 for this taxonomy, so every row ties and the database decides. Relaxed from an exactcap-admin,cap-alicetoSTDOUT should match /Removing (cap-admin,cap-alice|cap-alice,cap-admin)/(noteshould matchdoes not substitute {VAR}, so the revision ID stays out of the pattern). -
Revision-ID captures now use
--posts_per_page=1 --orderby=ID --order=DESC, and the first removal scenario asserts a global revision count of 1 before running the command. If WP trunk ever stores an extra revision, the setup now fails loudly instead of splicing two IDs into awp evalcall whose silent failureI runwould swallow. -
I runnever asserts exit status, so bothSTDOUT should be emptyassertions are paired withAnd the return code should be 0(otherwise they also pass when thewp term liststep itself errored), and rc 0 is asserted on the happy paths.
(Calibrated against the live env 2026-09-01; all scenarios green.)
-
Any omitted optional flag produces a PHP
Warning: Undefined array key "<key>"fromcreate_guest_author()(php/class-wp-cli.php:1156-1163), because the args array is built with unguarded key access. Pinned viashould contain:in features/create-author.feature. Confirmed. Each warning appears TWICE in the combined output: once as a timestamped debug-log echo ([01-Sep-2026 ...] PHP Warning: Undefined array key ...— the container routeserror_logoutput back to the terminal) and once as thedisplay_errorscopy (Warning: Undefined array key "<key>" in .../php/class-wp-cli.php on line NNNN). -
The
avatarkey is warned about on EVERY invocation: thecreate-authorsynopsis has no--avatarflag (WP-CLI rejects unknown parameters), so$author['avatar']at :1163 is always undefined even when every documented flag is supplied. -
Validation failures (missing
display_nameoruser_login) are reported viaWP_CLI::warning()and the command exits 0 — callers/scripts cannot detect failure from the exit code. The command also prints-- Not found; creating profile.BEFORE validation, even when nothing ends up being created. -
Running with no arguments at all is accepted by the synopsis (all flags optional) and goes through the same warn-and-exit-0 path.
-
Duplicate detection checks
user_emailpostmeta first, thenuser_login(with a post_name fallback), so an existing profile with the same email but a different login is treated as "already exists" and the requested new login is silently ignored. -
Re-calibrated 2026-09-02: all of the above confirmed again on the live env (PHP 8.4, WP trunk). Two additions:
- Supplying
--avatar=<n>is rejected by WP-CLI's synopsis check withError: Parameter errors:/unknown --avatar parameterand exit code 1, so theavatarvalue thatcreate_guest_author()reads at :1163 can never be set through this subcommand. Pinned as its own scenario. - The profile written on the happy path holds exactly
cap-display_name,cap-first_name,cap-last_name,cap-user_login,cap-user_email,cap-website,cap-descriptionand_original_author_login— there is nocap-avatarmeta, and_original_author_idis never written from this path either. Pinned with an exactwp post meta list --format=csvassertion.
- Supplying
-
Hardened 2026-09-02 after adversarial review: 11 scenarios, all green.
-
Silent author-term hijack. AFIXED (decision taken 2026-09-05).--user_loginthat matches an existing WP USER is accepted and the guest author is created, sharing that user's author term.create()'s guard now matches its own comment and rejects the collision with the existingduplicate-fielderror — with one deliberate allowance: the collision is permitted when the profile is being created as that user's linked account (linked_accountequals the found user's actual login). That allowance is NOT optional and is not new semantics: it mirrors the guardmanage_guest_author_filter_post_data()has always applied on the edit screen, and without itcreate_guest_author_from_user_id()— and thereforewp co-authors-plus create-guest-authors, the users-list "Create Profile" action, and every test factory user — would break for any user whose display_name equals their login, which is WordPress's default (includingadmin). Three integration tests pin the boundary: the rejection (fails against the old guard), the linked-account allowance (exists to fail against an over-tightened guard), andlinked_accountnot bypassing the guest-author duplicate check. The refusal scenario asserts the user's term survives with its description unrewritten. Historical detail preserved below. -
The original finding, for the record: the guest author was created sharing that user's author term.
create_guest_author()only dedupes against guest-author posts (:1136-1141) andGuest_Authors::create()only rejects a collision when the existing co-author'stypeisguest-author(php/class-coauthors-guest-authors.php:1402-1406), whileget_author_term()matches on thecap-<nicename>slug alone — soupdate_author_term()finds the user's term, REWRITES its description with the guest author's search values, andwp_set_post_terms()attaches the new profile to it. Verified live: userjane-doe(term_id 428, descriptionJane Doe jane-doe 166 jane-user@example.com) pluscreate-author --display_name="Jane Guest" --user_login=jane-doegives rc 0,Success: -- Created as guest author #760, the guest-author post carrying term_id 428, and that term's description now readingJane Guest jane-doe 760 jane-guest@example.com. The user's own published post therefore resolves to the guest author. Pinned in "A user_login that collides with an existing user reuses that user's author term" (term_id equality + the rewritten description). Note this makes theterm-creation-failed/ "The author slug may conflict with an existing user" error string unreachable from this path. -
No sanitisation at all.FIXED, and this is also the write-path unification PR #1406 deliberately deferred.create_author()hands$assoc_argsstraight to the creator, which stores every field verbatim — tags in the display name and login, script tags in the description — while onlypost_nameis normalised.Guest_Author_Servicenow exposes its sanitiser assanitize_profile()— each field's declaredsanitize_function, falling back tosanitize_text_field, exactly what the admin edit screen applies — and the sharedGuest_Author_Creatorruns every profile through it BEFORE its duplicate lookups and beforecreate()'s collision guard, so both vet the value that will actually be stored. Sanitise-first also closed a live variant of the term hijack: a raw--user_login="<b>jane</b>"used to miss the guard's user lookup while still producing the slugcap-jane. Deliberate limits, pinned as parity rather than perfection: the login keeps spaces and punctuation, because the admin fallback keeps them too (the website field followed on: it now declaressanitize_url—esc_url_rawis its alias, and theesc_name is a WP 2.8-era accident core corrected in 5.9 — which schemes unschemed values and preserves percent-encoding on every write path at once, admin screen included, deliberately);avataris an attachment ID, not a declared field, and bypasses the sanitiser so it still reachesset_post_thumbnail(); and the provenance meta records the login exactly as the source supplied it.One non-idempotency inherited from the admin screen:FIXED by the website field'ssanitize_text_fieldstrips%xxoctets from a percent-encoded URL on a second save.sanitize_urldeclaration; pinned by an integration test that fails under the fallback. CSV keeps its stricter per-cell layer on top; every second pass over its pinned output was verified to be the identity. This was the exact opposite ofcreate-guest-authors-from-csv, which sanitises every cell — a sharedbuild_guest_author_data()helper during the split would have silently changed one command or the other. Now pinned in "Field values are sanitised as the admin edit screen would sanitise them". -
The six
Undefined array keywarnings are now pinned as six separateSTDOUT should contain:steps instead of one ordered.*-chained regex: their order is just the literal order of the array literal at :1156-1163, so a behaviour-preserving reorder must not fail the suite. -
The
--avatarrejection now pins onlyunknown --avatar parameter(rc 1); the surroundingError: Parameter errors:framing is WP-CLI's, not CAP's, and the wp-env image's WP-CLI floats. Same treatment for the CSV/WXRmissing --file parameter. -
Background now wipes author terms (same convention as migrate-author-terms.feature) and the term assertion is scoped
--object_ids={GUEST_AUTHOR_ID}; see the correction in the shared test-environment section above. -
Guest author creator, resolved together (one PR). The shared
Guest_Author_Creator::create()now returns a bool, and the three importers act on it:FIXED — the guard tests the key the callers actually supply, so provenance is recorded again. Nothing inside CAP reads this meta; it exists for downstream migration tooling, so this restores a documented promise rather than changing plugin behaviour._original_author_idis never written: the guard testsisset( $author['author_id'] )while the WXR flow passes the ID underID, and even on a hit it stored$author['ID'].Unguarded array reads emitFIXED withUndefined array keywarnings for every key the caller omits — six percreate-authorrun, three per WXR author.?? ''defaults. This is behaviour-neutral for stored meta becauseCoAuthors_Guest_Authors::create()skips fields withempty(), which treats''and absent alike. Had it usedisset(), every empty field would have started writing an empty meta row.FIXED as a case of the above, not separately. Adding anavataris warned about on EVERYcreate-authorinvocation, because that command has no--avatarflag at all.--avatarflag would be a feature, and is not in scope.FIXED by deleting the line. Making it honest would mean duplicating-- Not found; creating profile.prints BEFOREcreate()validates, so it appears even when nothing is created.create()'s validation in the helper, and on the success pathSuccess: -- Created as guest author #Nalready says it.A validation failure is a warning plus an implicit exit 0, soFIXED for the single-author command, which now halts with 1. The bulk importers deliberately keep exit 0 — one bad row must not abort a large import — and instead tally failures and reportcreate-authorwith no arguments "succeeds" from a script's point of view.N of M authors could not be created.That split is the whole reason the helper returns a bool rather than erroring itself.Duplicate detection triesFIXED in the message, and the lookup order deliberately left alone. Reversing it would let a second profile share an email, anduser_emailbeforeuser_login, so an existing profile with the same email but a different login is reported as "already exists" and the requested login is silently dropped.get_guest_author_by( 'user_email', ... )is a bareget_varwhere the first row wins — that ambiguity would leak into linked accounts and the admin UI, well outside the CLI. Shared editorial addresses are common. The real defect was that the operator asked for one login and was told about a profile without being told which; the warning now names it.
(Calibrated against the live env, 2026-09-01; re-verified green 2026-09-02.)
-
When a post'sFIXED. The resolved term is checked withpost_authoruser does not exist,update_author_term()returnsfalseand the command dereferences it anyway, emitting PHP warnings forsluganduser_nicename. The post is still logged asAdded ... now has an author term for:with a trailing empty author, counted in$affected, and the run claimsOf 1 posts, 1 now have author terms.though NO term was set.! $author_term instanceof WP_Term, which covers both failure modes —falsefor a missing user, and aWP_Errorif the term cannot be created — and the post is skipped with a warning naming the post and the user ID. The summary counts it honestly, so the state assertion (0terms) now agrees with the reported figure instead of contradicting it. The memo also moved from! empty()to??, so a failed lookup is cached too and an orphaned author is resolved once per run rather than once per post. -
FIXED, and the original diagnosis here was wrong. Counts are recalculated — CAP registersUpdating author terms with new countsis misleading:update_author_term()only refreshes the term description; it never recalculates counts._update_users_posts_countas the taxonomy'supdate_count_callback, and core fires it fromwp_set_object_terms(), so setting the term on each post already maintains the count. The real defect was that the whole trailing pass was REDUNDANT: it looped over authors that had every one been throughupdate_author_term()moments earlier in the same run, with a description derived from user fields that cannot have changed meanwhile, sowp_update_term()never fired. A second pass doing nothing, announced by a message describing something else. Both are deleted. A new scenario forces a term count to 5, runs the command, and asserts the count comes back to 1 — verified live, which is what confirmed the write path maintains it and the deletion is safe. -
The command walks every supported post type (post AND page by default), unlike
create-author-terms-for-postswhich defaults topostonly. Pinned in the "Pages are inspected by default" scenario. -
Never-pluralised grammar:FIXED, and reworded rather than merely pluralised: two counts share the sentence, so it becameOf 1 posts, 1 now have author terms.Done! Author terms added to %1$s of %2$s post(s)., where only the trailing noun needs agreement and one_n()selection (on the total) covers it. -
The two per-post log lines use DIFFERENT identifiers for the same term: the "Skipping" line prints term NAMES while the "Added" line prints the user'sFIXED. Both lines now print the slug, so the log names the thing the command wrote and an operator can paste it straight intouser_nicename— neither shows thecap-prefixed slug that is actually stored.wp term list. -
The "Skipping" line usesFIXED. Both use{$posts->found_posts}as the denominator while the "Added" line uses$total_posts. They are equal today only becausefound_postsis re-read from an identical query each page.$total_posts. No output change today; it removes a latent divergence. -
Hardened 2026-09-02 after adversarial review: 9 scenarios, all green.
-
Drafts, pending and private posts are INVISIBLE to this command. The WP_Query sets noFIXED by the flag rather than by widening, for exactly the reason that last sentence gives. The default-scope scenario is retained and renamed to say "by default", with a companion scenario coveringpost_status, and a CLI request has no current user, so only public statuses are inspected — despite the docblock's claim that it walks all posts, and unlikecreate-author-terms-for-posts, which exposes--post-statuses. A "tidy-up" adding'post_status' => 'any'would be a silent scope change.--post-statuses=draft. -
The skip guard is "has ANY term in the author taxonomy", not "has the term for this post's author", so a post deliberately attributed to somebody other than
post_authoris skipped and never reconciled. Pinned in "A post whose existing author term is not its post author is left alone" (post authored by admin but carrying onlycap-writer:Skipping - ... already has these terms: writer, and the stored term is stillcap-writerafterwards). Changing the guard to "has the term for post_author" would overwrite such attributions. -
The per-post memoisation (
$authors[ $single_post->post_author ]/$author_terms[ ... ], :123-127) is now exercised with two distinct authors in "Each post gets an author term for its own author", so hoisting the lookup out of the loop can no longer pass. -
The orphan-author scenario was pinned with an end-anchored regex on the empty trailing nicename. That is gone with the bug: the scenario now pins the whole of STDOUT exactly, since a skipped post produces a short and fully predictable run.
-
Drafts, pending and private posts are invisible, and the docblock overclaims that the command walks every supported post.FIXED by adding--post-statuses, spelled and defaulted exactly as the siblingcreate-author-terms-for-postsdoes. The default stayspublishdeliberately: this command WRITES, so widening it would silently multiply the scope of a backfill and attach terms to drafts nobody asked about. Contrastlist-posts-without-terms, where the default WAS widened — that one is read-only, so a narrow default bought no safety and only withheld evidence. Naming the status explicitly also removes an oddity:WP_Query's default is publish PLUS whatever private statuses the current user can read, so the scope of a backfill previously depended on whether--userwas passed.
(Calibrated against the live env, 2026-09-01; re-verified green 2026-09-02. All draft predictions confirmed,
including Processing page 2. with --records-per-batch=1 re-selecting only
still-missing posts, and wp post create --post_author=1 auto-assigning the
cap-admin term so a fresh run reports Found 0 posts with missing author terms.)
-
An invalid ID range (
--above-post-id>=--below-post-id) surfaces as an UNCAUGHT PHP exception/fatal (Exceptionthrown at php/class-wp-cli.php:1241), not aWP_CLI::error(). Re-calibrated 2026-09-02: exit code is 1, and the user-facing output is a full PHP stack trace (printed twice — once as the timestamped debug-log copy, once as thedisplay_errorscopy) followed by WP-CLI's genericError: There has been a critical error on this website.Learn more about troubleshooting WordPress. There has been a critical error on this website.That generic line is the ONLY thing routed to STDERR, so an operator who reads just STDERR never learns which parameter was wrong. Pinned as: return code 1, STDOUT containsFatal error: Uncaught Exception: The $above_post_id param must be less than the $below_post_id param., STDERR contains the critical-error line. (The stack trace itself is incidental and is not pinned.) -
--above-post-idmay be given WITHOUT--below-post-id(and vice versa); the bound is then open-ended. Pinned in the "no upper bound" scenario. -
--post-types/--post-statusesaccept comma separated lists; the SQL builds one placeholder per value. Pinned with--post-statuses=publish,draft. -
The skip-postmeta warning interpolates
$wpdb->users, which iswp_usersin the wp-env tests container (default prefix). Pinned exactly. -
Duplicate of the entry struck under the resolution block below — fixed by #1419 (unconditional validation plus a stated-precedence warning). This copy predates that block and was missed when it landed; struck now so the catalogue stops disagreeing with itself.--specific-post-idstakes precedence over the range, so an invalid range combined with specific IDs is silently ignored. -
Posts withDuplicate — fixed by #1419 (they now take the orphan path); struck for the same reason as above.post_author = 0are excluded by the SQL and invisible to this command, whilelist-posts-without-termsDOES list them. -
Duplicate — the pass was deleted and the write path replaced by #1425; struck for the same reason as above.Updating author terms with new countsis misleading here too. -
Grammar:FIXED by the pluralisation sweep:Found 1 posts,1 records affected(never pluralised)._n()with%sandnumber_format_i18n(), both branches pinned. -
A post whose author is missing is counted in
Found N postsbut produces0 records affectedafter the skip postmeta warning; the run still ends withSuccess: Done!. -
Hardened 2026-09-02 after adversarial review: 14 scenarios for this command (20 in the file, the rest being delete-postmeta-that-skip-author-term-backfill).
-
The invalid-range scenario no longer pins WP core's
Error: There has been a critical error on this website.copy. That string is owned by core's fatal error handler and the tests env tracks WordPress trunk, so it is re-worded from release to release; the scenario now asserts rc 1, the CAP-ownedFatal error: Uncaught Exception: The $above_post_id param must be less than the $below_post_id param.on STDOUT, and STDERR not empty. The<=boundary is pinned too:--above-post-id=5 --below-post-id=5throws as well. -
--below-post-idon its own now has its own scenario (previously only--above-post-idalone and both together were covered, which the symmetricarray_unshift()argument ordering at :1245-1255 could hide). -
--specific-post-idsprecedence is now pinned, not just noted: a multi-ID CSV combined with an invalid range (--above-post-id=10 --below-post-id=5) exits 0 and processes the named IDs, because the specific-IDs branch is anelseifthat short-circuits the range validation. This also pins the CSVexplode()and theIN ( %d, %d )placeholder generation. -
Per-post author resolution and the author-loop running percentage are pinned with two authors:
Success: Updated author term for author N (alpha) (50.00%).then... (beta) (100.00%). -
Batched forward progress depends on the skip postmeta. Each batch re-runs the same
LIMIT nquery with NO offset, so a post that cannot be processed must drop out of the result set. Pinned in the--records-per-batch=1scenario, which now leads with an orphan-author post (lowest ID): the run printsProcessing page 2.andProcessing page 3.and terminates. Remove or deferskip_backfill_for_post()and this command becomes an infinite loop on any site with an orphanedpost_author. -
CORRECTION to a review suggestion: the author term
countis NOT derived from term relationships, so it cannot discriminate the raw$wpdb->insertintowp_term_relationshipsfromwp_set_object_terms(). CAP registers the author taxonomy withupdate_count_callback => _update_users_posts_count(php/class-coauthors-plus.php:279), and that callback counts published posts where the TERM matches orpost_authormatches the co-author (php/class-coauthors-plus.php:867-883). Measured live: two published posts by admin givecap-admincount 2;wp post term remove <id> author --allon both leaves the count at 2; after the backfill it is still 2. The feature pins 2 to document those semantics (an author term's count survives having every one of its relationships removed), not as a guard against the API swap. -
Parameter validation and skip reporting, resolved together.
An invalid ID range throws an uncaughtFIXED. Validated inExceptionfrom a private SQL builder. The operator gets a doubled PHP stack trace on STDOUT and only WP core's generic "There has been a critical error on this website" on STDERR, so nothing tells them which parameter was wrong — the message even names PHP variables rather than CLI flags.__invoke()withWP_CLI::error(), naming the flags as typed. Thethrowstays in the builder as an unreachable backstop.FIXED by the same guard, which is now unconditional. The precedence itself is unchanged but no longer silent: combining them warns that the range is ignored.--specific-post-idsis anelseifthat short-circuits the range validation, so an invalid range combined with specific IDs is silently ignored.Posts withFIXED by dropping the exclusion.post_author = 0are excluded byAND post_author <> 0, whilelist-posts-without-termsDOES list them, so the two diagnostics disagree about the same site.get_user_by( 'id', 0 )returns false, so such posts take the existing orphan path — warned, marked with the skip meta, and excluded from later batches — rather than needing a new branch.A post whose author is missing is counted inFIXED. Skips are counted and reported. The new string is pluralised withFound N postsbut yields0 records affected, and the run still endsSuccess: Done!with nothing explaining the gap._n()from the outset.
-
The rawFIXED (decision taken 2026-09-05: ship on reasoning, with a mechanism proxy). The write goes through$wpdb->insertintoterm_relationshipsbypasseswp_set_object_terms(), and with it theset_object_termsaction that CAP hooks to clear its owncoauthors_post_<id>cache — which caches an EMPTY array. On a host with a persistent object cache, the environment this command exists for, a backfilled post kept reporting no co-authors to the front end, template tags and REST until it was saved or the cache flushed.wp_set_object_terms()— non-append, deliberately, since the query selects only posts with no author terms so set and append coincide — wrapped inwp_defer_term_counting()so counting resumes with one recount per term. The cache staleness itself is invisible in wp-env, so the scenario pins the mechanism instead: a--requirefixture hooksset_object_termsand asserts it fires during the backfill, which the raw insert never did. That is the action CAP's invalidation listens to. Two useful discoveries along the way, recorded so they are not re-learnt:wp_set_object_terms()only writesterm_orderon NON-append calls, and even then only when its mid-function term lookup is not served from cache (wp_update_term_count()cleans term caches without bumping the query salt), soterm_orderis nondeterministic and must not be pinned. A forced-count scenario also guards the deleted "Updating author terms with new counts" pass: counts come from the deferred recount, which the raw insert bypassed entirely. Theupdate_author_term()WP_Error dereference that could write a relationship row with noterm_taxonomy_idis guarded too, and both failure paths now mark the skip meta — which also removes the batch-starvation source, since a post the run cannot complete drops out of the next batch. -
CORRECTION to the earlier note claiming
--specific-post-idscan loop forever: it cannot.if ( $count >= $count_of_posts_with_missing_author_terms ) break;bounds the run, and$countincrements per record processed whether or not it succeeded. The real symptom is STARVATION — a post that cannot be completed is re-selected by every batch and consumes the budget, so the others are never reached and the progress line repeats for the same post. On a large site that is indistinguishable from a hang, which is probably how it was recorded as a loop.
(Calibrated against the live env, 2026-09-01; re-verified green 2026-09-02.)
-
Success/failure per post is reported with bare emoji (FIXED. Each line now names the meta key and the post, which also matters for the harness:Success: 👍/Error: 👎), carrying no post ID and so no diagnostic value.FeatureContextsplits STDERR back out witharray_diff, which compares values, so identical emoji lines would all have been removed together. Distinct lines are a prerequisite for the split behaving per-line. The pre-emptiveDeleting postmeta key ... for Post ID Nline is dropped with them — it was pure duplication once the outcome line carried the ID, and it halved the output of a bare run over thousands of posts. -
FIXED. A post that never carried the marker is now a warning, and the loop carries on. The run exits 0, which is the deliberate part: the command's contract is that the named posts end up without the marker, and that holds.WP_CLI::error( '👎' )aborts the whole loop on the FIRST post whose meta cannot be deleted (e.g. an ID without the meta), leaving any later IDs in--specific-post-idsunprocessed, with exit code 1 — even if earlier deletions succeeded. A partial run is indistinguishable from a total failure, and re-running is the only recovery.delete_post_meta()returning false cannot distinguish "never had it" — the common--specific-post-idstypo — from a database error, so failing the whole run on it was over-reading a weak signal. The scenario that pinned the abort is inverted: it now asserts the second post's meta really is gone, which is what fails against the old code. -
With noSTALE, not fixed as written — there is no--specific-post-idsthe lookup WP_Query uses defaults, so skip metas on drafts, pages or other post types are never found.WP_Queryin this command any more; the bare lookup is a direct prepared read of the postmeta table, superseded by the struck entry below. (The struck entry's own wording is also inaccurate: it says the query "now passespost_type=any,post_status=anyandposts_per_page=-1", which describes an approach that was tried and replaced. Another reminder that "Noted; not pinned" entries are the ones to distrust.) -
When there is nothing to delete the command prints nothing at all (no summary, exit 0). Pinned.
-
Hardened 2026-09-02 after adversarial review: 6 scenarios for this command.
-
Bare (noFIXED. The lookup query set only--specific-post-ids) mode never sees skip metas on unpublished posts, nor on pages or other post types, and silently caps atposts_per_page(10 by default) with no warning and no summary. An operator who backfills 30 pages and then runs the bare delete gets no output, exit 0, and nothing deleted.meta_keyandfields, so it inheritedpost_type=post,post_status=publishand the site'sposts_per_page. It now passespost_type=any,post_status=anyandposts_per_page=-1, and both scenarios are re-pinned to the corrected behaviour. -
The "nothing to delete" scenario now asserts rc 0 and empty STDERR. On its own
STDOUT should be emptyis vacuous: the harness movesError:/Warning:lines into STDERR whenever the exit code is non-zero, so an unregistered subcommand after the class split would have kept that scenario green.
(Calibrated against the live env, 2026-09-01; re-verified green 2026-09-02.)
-
The tests site uses pretty DATE-BASED permalinks (e.g.
http://localhost:<port>/2026/09/01/alpha-post/), not plain?p=<ID>links as the draft assumed. The port comes from the git-ignored.wp-env.override.json(9893 locally), so the feature pins the permalink shape via regex withlocalhost:\d+rather than a literal port. -
The command only ever prints CSV-ish lines; there is no summary and no success message, and an empty result prints nothing (exit 0). Pinned.
-
Post titles go throughFIXED. Every field is wrapped inaddslashes(), so an apostrophe renders as\'in the output — PHP-style escaping inside CSV-style quoting."and joined with,, which is CSV, and CSV escapes an embedded quote by doubling it rather than with a backslash. A title containing a quote therefore produced a line no CSV parser could read back, and apostrophes and backslashes were mangled for no reason at all — neither needs escaping in CSV.addslashes()is replaced by doubling", and it appeared nowhere else in the plugin. Two scenarios now pin it: an apostrophe printed literally, and a quoted title round-tripping as"". -
OnlyFIXED. The query now passespublishposts are inspected (WP_Query default status with no logged-in user), so drafts without terms are silently excluded.post_status => 'any'. This is the command whose entire purpose is finding posts that lack author terms, and drafts are exactly where they go missing, so the omission defeated the diagnostic. There was also no way to work around it: the synopsis declares only[--post_type], and WP-CLI treats an undeclared argument as fatal, so--post_status=draftexited 1.'any'still excludes trashed and auto-draft posts, which a new scenario pins so the boundary cannot drift. No opt-in flag was added: this is a read-only diagnostic, so a narrow default buys no safety, and the two comparable fixes in this catalogue (update-author-termsanddelete-postmeta-that-skip-author-term-backfill) both widened rather than adding a flag. -
NEVER TRUE, rather than fixed. The pre-split code already declared$assoc_argsis merged straight into the WP_Query args, so ANY WP_Query var (e.g.--year=) is accepted, not just the documented--post_type.@synopsis [--post_type=<ptype>], and WP-CLI rejects an undeclared associative argument as a fatal parameter error, so--yearhas never reachedwp_parse_args. The entry was reasoned from reading the merge without accounting for WP-CLI's synopsis gate, and it was explicitly "Noted; not pinned" — the note's own methodology gave it away. The vestigial'year' => ''default it described has been deleted. Worth treating the other "not pinned" entries with the same suspicion, since they are the ones never executed. -
Hardened 2026-09-02 after adversarial review: 7 scenarios.
-
The permalink assertion has been RELAXED on purpose. The date-based structure
/%year%/%monthnum%/%day%/%postname%/is applied by @wordpress/env when it configures the environment (wp rewrite structure ... --hard), not by this repo and not by CAP, and CI installs @wordpress/env unpinned; the host and port come from wp-env too. The feature now pins the CAP-owned CSV shape only —#^"\d+","Alpha post","[^"]+","\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}"$#m— plus ashould contain:block proving a permalink is emitted. -
Every
STDOUT should be emptynow carriesthe return code should be 0andSTDERR should be empty. Without them those four scenarios pass against a completely broken command:Error: '...' is not a registered subcommandis moved out of STDOUT by the harness when the exit code is non-zero. -
Multi-post output is now pinned: one CSV line per qualifying post, in ascending post ID order (
'order' => 'ASC', 'orderby' => 'ID'at :797-798). Note thatSTDOUT should matchdoes NOT run{VAR}substitution (WpEnvFeatureContext::stdout_should_match()never callsreplace_variables()), so the ordering regex keys on the post titles rather than on saved IDs. -
The draft scenario now asserts the draft genuinely has no author term before the command runs, so its empty output can only be explained by the post_status filter. (Since the post_status fix it asserts the draft IS listed, and the no-author-term check still rules out the other explanation.)
(Calibrated against the live env, 2026-09-01; re-verified green 2026-09-02.)
-
TheFIXED.changed from A to Bmessage NEVER shows the corrected count, because the command re-read the term afterwp_cache_delete( $term_id, 'author' )— the wrong cache group, since core caches terms in thetermsgroup — soget_term_by( 'id', ... )returned the stale cached object and$new_countalways equalled$old_count.update_author_term_post_count()writes the corrected count via a direct$wpdb->update, which leaves core's term cache stale. The command now invalidates it withclean_term_cache( $term_id, $taxonomy ), matching whatreassign-termsalready does. The scenario that pinnedchanged from 5 to 5now pinschanged from 5 to 1, alongside the existing assertion that the DB count is 1. Note the fix belongs in the command rather than inupdate_author_term_post_count(): core'swp_update_term_count_now()callsclean_term_cache()after theupdate_count_callback, so the method is correctly invalidated on its normal path and only this direct caller was affected. -
Term X (N) changed from A to B and the description was refreshedis printed even when NO co-author matches the term slug:update_author_term( false )returns false andupdate_author_term_post_count()returns a silent WP_Error, so nothing is refreshed at all. Confirmed and pinned in the orphan-term scenario (changed from 0 to 0). -
The guest author pass queries theFIXED. The query now passesguest-authorCPT with WP_Query's default post_status (publish), but guest author posts created viaCoAuthors_Guest_Authors::create()(and hencecreate-guest-authors) are DRAFTS (wp_insert_postdefault), so the pass reportedNow inspecting or updating 0 Guest Authors.even when guest authors existed.post_status => 'any', so the pass sees drafts — which is the ordinary case, not the exception — while still excluding trashed and auto-draft profiles. The whole guest-author half of the command was previously dead on any site whose profiles were created by the CLI or programmatically. A new scenario covers a term being created for a draft guest author, mirroring the published one; the drafts-invisible scenario now pinsNow inspecting or updating 1 Guest Authors. -
wp term list author --field=slugreturns name-ascending order (cap-adminbeforecap-ghost/cap-guest-one) — confirmed, relied on by exact assertions. -
Author terms are created for EVERY user returned by
get_users()regardless of role or post count. -
Grammar:FIXED by the pluralisation sweep:Now updating 1 terms._n()with%sandnumber_format_i18n(), both branches pinned. The final message is stillSuccess: All done(no full stop), unlike other subcommands'All done!— cosmetic, deliberately untouched. -
Hardened 2026-09-02 after adversarial review: 8 scenarios.
-
The headline "and the description was refreshed" is now pinned as STATE, not just as log text. Seeding a term with
wp term create author admin --slug=cap-admin --description="stale description"and running the command rewrites the description toadmin admin 1 wordpress@example.com— that isimplode( ' ', ... )overCoAuthors_Plus::$ajax_search_fields(display_name, first_name, last_name, user_login, ID, user_email), hence the three consecutive spaces for the wp-env admin's empty first/last names. Pinned with#^admin {3}admin 1 \S+@\S+$#so the environment's admin e-mail address is not hard-coded, plus ashould not match /stale description/. Droppingupdate_author_term( $coauthor )from the term loop now fails a scenario; previously the whole suite stayed green because the count correction comes from the separateupdate_author_term_post_count()call. -
The
get_users()pass creating a term for EVERY user is now pinned, not just noted: a freshly created SUBSCRIBER who has never authored anything getscap-zsub. This is unbounded term growth on sites with large, low-privilege user bases, and narrowing the query (e.g. towho => authors) would be a silent behaviour change that the previous single-admin scenarios could not detect. -
The two multi-line
wp term list author --field=slugassertions now pass--orderby=slug --order=asc. They previously relied onwp term list's default name-ascending order coinciding with slug order, which only held because the admin term is named "admin".
(Calibrated against the live env 2026-09-01; all scenarios green.)
-
The predicted PHP 8.4
Deprecated: fgetcsv(): the $escape parameter...notice does NOT appear (PHP 8.4.25).fgetcsv( $file )is called with only the stream argument (php/class-wp-cli.php:1073), and the deprecation only fires when other optional parameters are passed without an explicit$escape. The assertion was dropped from the feature; the happy-path output is clean of warnings/deprecations because the CSV flow populates every array key thatcreate_guest_author()reads. -
There is no validation of required CSV columns (
// TODO: bail if required fields not foundat :1076). A CSV withoutuser_login/user_emailcolumns would hit undefined array keys at :1097. Not pinned — the fixture supplies all columns. -
Per-author failures are warnings only; once the file is readable the command always exits 0.
-
Re-calibrated 2026-09-02: confirmed, plus a new branch finding.
- The first/last-name logic at :1108-1119 has a GAP: the
ifneeds a space indisplay_nameAND both name columns empty; theelseifneeds BOTH columns populated. A row with a single-worddisplay_nameand emptyfirst_name/last_name(or only one of the two populated) matches neither branch, so the keys are never added to$guest_author_dataandcreate_guest_author()emitsUndefined array key "first_name"/"last_name"at :1159-1160 — the only way the CSV path can produce those warnings. The created profile then has nocap-first_name/cap-last_namemeta at all. Pinned with features/fixtures/guest-authors-single-name.csv (display_namePrince). Count line does not pluralise: a one-row CSV logsFIXED by the pluralisation sweep, both branches pinned.Found 1 authors in CSV.
- The first/last-name logic at :1108-1119 has a GAP: the
-
Hardened 2026-09-02 after adversarial review: 10 scenarios, all green.
-
The sanitisation layer is now pinned (features/fixtures/guest-authors-dirty.csv), because at the time this was the ONLY one of the three commands that sanitises and the difference would otherwise vanish in a refactor. (The creator now sanitises for all three with the admin fallback; CSV remains the only one layering STRICTER per-cell sanitisers —
sanitize_email,esc_url_raw, non-strictsanitize_user— on top, and these pins are what hold that layer in place.) Live results for the row<b>Dirty</b> Name,Dirty Login!,DIRTY@Example.com,example.com/x?a=1&b=2,<script>alert(1)</script><em>Bio</em>,abc,,:Processing author Dirty Login! (DIRTY@Example.com)— the log echoes the RAW cells; sanitisation happens after it.sanitize_text_field()strips the markup:cap-display_nameisDirty Name.- The first/last split reads the RAW
display_name, so first name is<b>Dirty</b>beforesanitize_text_field()reduces it toDirty; last nameName. sanitize_user()is called WITHOUT$strict, soDirty Login!is stored intact (spaces and!and all) incap-user_loginand_original_author_login. Onlypost_name/term slug are normalised, tocap-dirty-login.sanitize_email()does NOT lowercase:DIRTY@Example.comis stored as typed.esc_url_raw()adds the missing scheme:http://example.com/x?a=1&b=2(the&is left raw, since thedbcontext skips entity encoding).wp_filter_post_kses()removes the<script>TAGS but keeps their text content and the allowed<em>:cap-descriptionisalert(1)<em>Bio</em>. Stripping markup is not the same as neutralising a payload.absint( 'abc' )is 0, so no featured image is set and (as always) nocap-avatarmeta exists —avataris not one ofget_guest_author_fields().
-
A header that omits columns still imports, noisily. With header
display_name,user_loginthe command's own code emitsUndefined array keyforuser_emailtwice (:1097 in theProcessing author %s (%s)log, then :1102) and once each forwebsite(:1103),description(:1104) andavatar(:1105), logsProcessing author casey-missing ()with an empty email, and creates the profile anyway. No PHP 8.4 "Passing null to parameter" deprecations appear —sanitize_email( null ),esc_url_raw( null ),wp_filter_post_kses( null )andabsint( null )are all quiet on WP trunk. Pinned with features/fixtures/guest-authors-missing-columns.csv, whose header also ends in a NAMELESS column carrying a value, exercising theif ( empty( $field_keys[ $col_num ] ) ) { continue; }branch (:1099-1101) — the exact meta block shows the value is dropped. The// TODO: bail if required fields not foundat :1076 is still a TODO. -
A failing row does not stop the import. features/fixtures/guest-authors-invalid-row.csv (empty
display_nameon row 1, a good row 2) givesFound 2 authors in CSV,Warning: -- Failed to create guest author: display_name is a required field, thenProcessing author valid-person (valid@example.com)/Success:/All done!, rc 0, and exactly ONE guest author (cap-valid-person). So theFound Ncounter is unrelated to the number created, and a scripted caller cannot tell from the exit code that rows were dropped. Now pinned rather than merely asserted in this document. -
The name-splitting gap now includes the half-filled case. A row with exactly one of
first_name/last_namepopulated matches neither theif(:1110, needs both empty) nor theelseif(:1116, needs both non-empty), so the value supplied is silently DISCARDED and the profile gets no name meta at all — even when the display name does contain a space. Verified with features/fixtures/guest-authors-half-named.csv (Half Named,half-named,...,Halfy,):Undefined array key "first_name"/"last_name"from :1159-1160 and a profile holding only display_name, user_login, user_email and_original_author_login. Pinned alongside the single-word case. -
--file=<a directory>is NOT the way to reachWP_CLI::error( 'Failed to read file.' ):is_readable()passes,fopen( 'features/fixtures', 'rb' )SUCCEEDS on Linux/PHP 8.4, andfgetcsv()immediately returns false — so the command reportsFound 0 authors in CSV/All done!and exits 0 for an operator typo. Deliberately NOT pinned as a scenario: fopen-on-a-directory is platform/PHP behaviour, not CAP's. TheFailed to read file.branch remains unreached by any scenario. -
Term assertions are now
--object_ids=scoped and the Background wipes author terms; the previous--slug=cap-jane-doeassertion was satisfiable by residue from create-author.feature (which runs first and uses the same login). See the correction in the shared test-environment section. -
A CSV row supplying exactly ONE ofFIXED, and fixed in the same change as the creator'sfirst_name/last_namematches neither branch of the name-splitting logic — theifneeds both name columns empty AND a space indisplay_name, theelseifneeds BOTH populated — so the supplied name is silently discarded.Undefined array keywarnings, deliberately. Those warnings were the only operator-visible symptom of this gap, so silencing them alone would have made a data-losing import completely quiet. The condition is now "take whichever name columns the row supplies, and fall back to splittingdisplay_nameonly when it supplies neither", which is both shorter than what it replaced and covers the case that fell through.
(Calibrated against the live env 2026-09-01; all scenarios green.)
-
If the wordpress-importer plugin is not installed, the command dies with an uncaught PHP fatal (
require_onceofwordpress-importer/parsers.phpat php/class-wp-cli.php:1002) instead of a cleanWP_CLI::error(). Confirmed, with a twist: the exit code is 1 (not 255) because WP's fatal-error recovery handler catches the shutdown and WP-CLI then prints core'sError: There has been a critical error on this website...line and exits 1. STDOUT keeps both the timestampedPHP Fatal error:debug-log echo and theFatal error: Uncaught Error: Failed opening required '.../wordpress-importer/parsers.php'display copy (plus the full stack trace). Pinned viashould contain:onFatal error/wordpress-importer/parsers.php. -
The cleanFIXED — see the resolution above; the fatal-pinning scenario became "A file that is not a WXR is reported, not fatal". Historical calibration detail of the old fatal (pinned then: exit 1,Error: Failed to read WXR file.exit is UNREACHABLE with importer 0.9.6:WXR_Parserfalls through toWXR_Parser_XML_Processor, which fatals on a missing Data Liberation class because CAP requires only parsers.php without the toolkit.Fatal error, the xml-processor path, and 0 guest authors created). A valid WXR file parses fine (the SimpleXML parser succeeds before the fallback chain is reached). -
The WXR flow never sets
website,description, oravatarkeys, socreate_guest_author()emitsUndefined array keywarnings for each of them (:1161-1163) for every author processed. Confirmed. -
_original_author_idis NEVER saved: the guard checksisset( $author['author_id'] )(:1173) but the WXR flow passes the ID under the keyID(:1024), so the check never matches. (Had it matched, it would have stored$author['ID']anyway.) Pinned via awp post meta list --keys=_original_author_id --format=count= 0 assertion. Confirmed. -
Re-calibrated 2026-09-02: every point above reproduced exactly (wordpress-importer 0.9.6, PHP 8.4). Notes for whoever runs this file next:
- The shared wp-env tests container already had wordpress-importer installed
(inactive), so the "not installed" scenario has to
wp plugin uninstall wordpress-importer --deactivatefirst, and the later scenarios re-installit. The install is served from the WP-CLI download cache mounted from the host (~/.wp-cli/cache/plugin/wordpress-importer-0.9.6.zip), so it does not need the network once warm — but a cold CI cache does. Scenario ORDER matters: the uninstalling scenario is deliberately placed before the ones that install. - Output splitting artefact of the Behat context: on the fatal paths (
I try, exit 1) thedisplay_errorscopyWarning: require_once(...): Failed to open stream...is moved to STDERR because it starts withWarning:, while the timestamped[...] PHP Warning:copy and the wholeFatal error: Uncaught Error:block stay in STDOUT. Assertions are split accordingly. - The non-WXR-file scenario asserts only
Fatal error: Uncaught Error:plus a/wordpress-importer/match andSTDERR should not match /Failed to read WXR file/(proving the clean-error branch is dead), rather than the exact class-wxr-parser-xml-processor.php:357 path, so it does not break on the next wordpress-importer release.
- The shared wp-env tests container already had wordpress-importer installed
(inactive), so the "not installed" scenario has to
-
Hardened 2026-09-02 after adversarial review: 7 scenarios, all green.
-
wordpress-importer is now pinned to 0.9.6 in the Background (
wp plugin install wordpress-importer --version=0.9.6, immediately followed bywp plugin get wordpress-importer --field=version=0.9.6). Two reasons: the crash characterised below is that release's parser fallback chain, not CAP's, so an unpinned install would turn a green suite red on an upstream release with no change to this repo; andI rundoes not check exit codes, so an unpinnedI tryinstall could fail silently on a cold CI cache and surface as a confusingrequire_once parsers.phpfatal in the happy-path scenario instead of "the importer is missing". If wp-env ever ships a different importer version the Background now fails first, with the version diff as the message. When the pin is eventually moved, expect the "not a WXR file" scenario to need recalibrating. -
Scenario ORDER no longer matters. The install lives in the Background, and the "not installed" scenario uninstalls at the start and reinstalls (with the version re-asserted) at the end, so running a single scenario with
--nameno longer leaves the container without the importer. -
The
require_oncefatal is no longer pinned with the absolute container path or PHP's exact wording. It is nowSTDERR should match #require_once\(.*/wordpress-importer/parsers\.php\)#andSTDOUT should match #Failed opening required '.*/wordpress-importer/parsers\.php'#— the path is a wp-env layout detail (CAP builds it fromWP_CONTENT_DIR) and the "Failed to open stream: No such file or directory" phrasing is PHP's. -
The "not a WXR file" scenario now uses its own features/fixtures/not-a-wxr.xml instead of borrowing the CSV group's fixture, and asserts only rc 1, loose
/Fatal error/+/wordpress-importer/matches,STDERR should not match /Failed to read WXR file/and 0 guest authors. Same fatal as with a CSV file (Class "WordPress\DataLiberation\EntityReader\WXREntityReader" not found) — historical; resolved by the toolkit load above. Note a CSV handed to the WXR command now gets the clean parse error too, for the same reason. -
A valid WXR with NO
<wp:author>nodes (features/fixtures/no-authors.wxr) prints exactlyAll done!, exits 0 and creates nothing —$import_data['authors']is an empty array rather than an undefined key, so theforeachat :1017 is quiet. Now pinned as the cheapest guard on that loop. -
CORRECTION to the note above: the
_original_author_id= 0 assertion this document claimed was present was NOT in the feature file; the absence was only implied by the exact meta block.wp post meta list {JANE_ID} --keys=_original_author_id --format=count= 0 is now an explicit step (in this file, create-author.feature and create-guest-authors-from-csv.feature), with a Gherkin comment naming theisset( $author['author_id'] )vsIDguard bug, so the intent survives any reshuffle of the meta block. -
The second author's profile is now pinned too (exact meta block plus an
--object_idsterm assertion forwxr-bob), so the whole flow no longer rests on the first author alone. -
The three
Undefined array keywarnings are now separateSTDOUT should contain:steps rather than one ordered.*-chained regex, and themissing --file parametererror pins only the CAP-independent fragment (theError: Parameter errors:framing belongs to WP-CLI).