feat(cpt): consolidate peptide CPT with PSA; drop pr_peptide (v0.2.0) (#1)

Consolidates the `peptide` CPT under PR Core ownership, dropping the `pr_peptide` slug-collision that was 404ing all 89 peptide detail pages.

- POST_TYPE: pr_peptide -> peptide
- TAX_CATEGORY: pr_peptide_category -> peptide_category
- TAX_FAMILY + pr_peptide_family taxonomy removed
- Defensive post_type_exists/taxonomy_exists guards on both registrations
- Args harmonized as superset of PSA + prior PR Core (adds thumbnail support)
- Activation hook + init:999 version-change flush handler
- Uninstall scoped to _pr_core_authored flag (PSA-authored posts survive)
- Plugin version 0.1.1 -> 0.2.0, CHANGELOG in same commit
- ARCHITECTURE.md + CONVENTIONS.md updated with CPT ownership rule

QA gate verdict: PASS (qa-reviews/peptide-repo-core/2026-04-22-874f93b.md)
Sibling release: peptiderepo/peptide-search-ai#15 (PSA v4.5.0)

Known follow-up: manage_peptide_content capability is not granted to any role as of v0.2.0. No current UI/theme reads or writes PR Core-defined meta keys, so no regression. Follow-up thread to decide role assignment.
This commit is contained in:
peptiderepo
2026-04-22 16:44:04 +07:00
committed by GitHub
parent c70352eb40
commit f086b89e2d
16 changed files with 592 additions and 78 deletions

View File

@@ -29,6 +29,13 @@ jobs:
- name: Syntax check all PHP files - name: Syntax check all PHP files
run: find . -name "*.php" -not -path "./vendor/*" | xargs -n 1 php -l run: find . -name "*.php" -not -path "./vendor/*" | xargs -n 1 php -l
- name: Unit tests (lightweight, no PHPUnit)
run: |
for test in tests/unit/test-*.php; do
echo "=== Running $test ==="
php "$test"
done
# ── WordPress Coding Standards (PHP CodeSniffer) ────────────────── # ── WordPress Coding Standards (PHP CodeSniffer) ──────────────────
lint-phpcs: lint-phpcs:
name: WordPress Coding Standards name: WordPress Coding Standards

View File

@@ -1,6 +1,8 @@
# Peptide Repo Core — Architecture # Peptide Repo Core — Architecture
Peptide Repo Core is a WordPress plugin that provides the canonical peptide data layer for the peptiderepo.com ecosystem. It defines the `pr_peptide` custom post type, custom tables for dosing rows and legal status cells, typed repository classes, an AI candidate queue for semi-automated data extraction, a shared disclaimer component, and JSON-LD structured data output. Every consumer plugin (PR Theme, PRAutoBlogger, Peptide News, Peptide Search AI, and all Tier 1 tools) reads peptide data through this plugin's versioned PHP API, never directly. Peptide Repo Core is a WordPress plugin that provides the canonical peptide data layer for the peptiderepo.com ecosystem. It owns the `peptide` custom post type (consolidated in v0.2.0; previously registered by Peptide Search AI pre-schema-sprint — PSA v4.5.0 drops its registration in favor of this one), the `peptide_category` taxonomy, custom tables for dosing rows and legal status cells, typed repository classes, an AI candidate queue for semi-automated data extraction, a shared disclaimer component, and JSON-LD structured data output. Every consumer plugin (PR Theme, PRAutoBlogger, Peptide News, Peptide Search AI, and all Tier 1 tools) reads peptide data through this plugin's versioned PHP API, never directly.
**Ownership model (v0.2.0+).** PR Core is the sole registrant of the `peptide` CPT and `peptide_category` taxonomy. Registration is guarded by `post_type_exists()` / `taxonomy_exists()` so a second registration from any other plugin (historically PSA, which now defers) no-ops cleanly and deploy order between plugins does not matter. See CONVENTIONS.md for the CPT ownership rule.
--- ---
@@ -25,7 +27,7 @@ Peptide Repo Core is a WordPress plugin that provides the canonical peptide data
│ │
▼ ▼
┌───────────────────────┐ ┌───────────────────────┐
│ Peptide Repository │ pr_peptide CPT + meta │ Peptide Repository │ peptide CPT + meta
└──────────┬────────────┘ └──────────┬────────────┘
│ │
┌───────────────┼───────────────┐ ┌───────────────┼───────────────┐
@@ -44,7 +46,7 @@ Peptide Repo Core is a WordPress plugin that provides the canonical peptide data
``` ```
peptide-repo-core/ peptide-repo-core/
├── peptide-repo-core.php # Plugin bootstrap — constants, autoloader, activation hooks ├── peptide-repo-core.php # Plugin bootstrap — constants, autoloader, activation hooks
├── uninstall.php # Full teardown: tables, posts, terms, options, capabilities ├── uninstall.php # Selective teardown: plugin-owned posts + tables + options + caps
├── ARCHITECTURE.md # This file ├── ARCHITECTURE.md # This file
├── CONVENTIONS.md # Naming patterns, extension guides ├── CONVENTIONS.md # Naming patterns, extension guides
├── CHANGELOG.md # Semantic versioning changelog ├── CHANGELOG.md # Semantic versioning changelog
@@ -53,7 +55,7 @@ peptide-repo-core/
│ │
├── .github/ ├── .github/
│ └── workflows/ │ └── workflows/
│ └── ci.yml # PHP lint (8.1-8.3), PHPCS WordPress, 300-line check │ └── ci.yml # PHP lint (8.1-8.3), PHPCS WordPress, 300-line check, unit tests
│ │
├── assets/ ├── assets/
│ └── css/ │ └── css/
@@ -61,13 +63,13 @@ peptide-repo-core/
│ │
├── includes/ ├── includes/
│ ├── class-pr-core.php # Main orchestrator — boots subsystems, registers public filters │ ├── class-pr-core.php # Main orchestrator — boots subsystems, registers public filters
│ ├── class-pr-core-activator.php # Activation: migrations, capabilities, rewrite flush │ ├── class-pr-core-activator.php # Activation + version-change flush handler
│ ├── class-pr-core-deactivator.php # Deactivation: rewrite flush only (data preserved) │ ├── class-pr-core-deactivator.php # Deactivation: rewrite flush only (data preserved)
│ ├── class-pr-core-autoloader.php # SPL autoloader for PR_Core_ prefixed classes │ ├── class-pr-core-autoloader.php # SPL autoloader for PR_Core_ prefixed classes
│ ├── class-pr-core-seed-data.php # Dev fixture: 3 peptides, 10 dosing rows, 5 legal cells │ ├── class-pr-core-seed-data.php # Dev fixture: 3 peptides, 10 dosing rows, 5 legal cells
│ │ │ │
│ ├── cpt/ │ ├── cpt/
│ │ └── class-pr-core-peptide-cpt.php # CPT registration, taxonomies, meta fields, sanitizers │ │ └── class-pr-core-peptide-cpt.php # CPT + peptide_category registration (guarded), meta fields, sanitizers
│ │ │ │
│ ├── migrations/ │ ├── migrations/
│ │ ├── class-pr-core-migration-runner.php # Sequential migration engine │ │ ├── class-pr-core-migration-runner.php # Sequential migration engine
@@ -82,7 +84,7 @@ peptide-repo-core/
│ │ └── class-pr-core-candidate-dto.php # Typed candidate queue value object │ │ └── class-pr-core-candidate-dto.php # Typed candidate queue value object
│ │ │ │
│ ├── repositories/ │ ├── repositories/
│ │ ├── class-pr-core-peptide-repository.php # CRUD for pr_peptide CPT │ │ ├── class-pr-core-peptide-repository.php # CRUD for peptide CPT
│ │ ├── class-pr-core-dosing-repository.php # CRUD for pr_dosing_rows │ │ ├── class-pr-core-dosing-repository.php # CRUD for pr_dosing_rows
│ │ ├── class-pr-core-legal-repository.php # CRUD for pr_legal_cells │ │ ├── class-pr-core-legal-repository.php # CRUD for pr_legal_cells
│ │ └── class-pr-core-candidate-queue-repository.php # CRUD + approve/reject for queue │ │ └── class-pr-core-candidate-queue-repository.php # CRUD + approve/reject for queue
@@ -101,8 +103,9 @@ peptide-repo-core/
│ └── class-pr-core-rest-controller.php # REST endpoints for peptides, dosing, legal │ └── class-pr-core-rest-controller.php # REST endpoints for peptides, dosing, legal
│ │
└── tests/ └── tests/
├── unit/ # PHPUnit tests (mocked WP) ├── bootstrap.php # Lightweight WP function mocks for no-PHPUnit unit runs
└── integration/ # WordPress integration tests └── unit/
└── test-peptide-cpt.php # CPT + taxonomy guard + args payload assertions
``` ```
--- ---
@@ -180,3 +183,16 @@ Single editorial review point. All consumer plugins render the same versioned di
### #6: JSON-LD from day one ### #6: JSON-LD from day one
Drug schema on single pages increases LLM citation rate. Filter hook allows consumer plugins to extend the schema object. Drug schema on single pages increases LLM citation rate. Filter hook allows consumer plugins to extend the schema object.
### #7: PR Core owns the `peptide` CPT and `peptide_category` taxonomy (v0.2.0)
Prior to v0.2.0, PR Core registered `pr_peptide` while Peptide Search AI registered `peptide` — both claimed the public rewrite slug `peptides`, and WP's rewrite resolver picked PR Core's empty CPT, 404'ing all 89 production peptide detail pages. v0.2.0 consolidates both registrations onto a single `peptide` CPT, owned by PR Core. PSA v4.5.0 drops its CPT/taxonomy registration; its meta boxes (`psa_peptide_data`, `psa_extended_data`), directory shortcode, KB article renderer, and search widget continue operating on the shared `peptide` CPT regardless of who registers it. Registration on both sides is guarded with `post_type_exists()` / `taxonomy_exists()` so deploy order is forgiving.
## §2.9 Uninstall specification
PR Core `uninstall.php` removes plugin-owned data only:
1. **Drops custom tables** — `pr_dosing_rows`, `pr_legal_cells`, `pr_ai_candidate_queue`.
2. **Deletes `peptide` posts only if they carry the `_pr_core_authored` meta flag.** The 89 canonical peptide posts predate PR Core and were authored via PSA; they are never blanket-deleted on PR Core uninstall.
3. **Does not delete `peptide_category` terms.** Shared-ownership taxonomy; term metadata the site relies on outlasts this plugin.
4. **Deletes `pr_core_*` options.**
5. **Removes `manage_peptide_content` capability from all roles.**

View File

@@ -3,6 +3,32 @@
All notable changes to Peptide Repo Core are documented here. All notable changes to Peptide Repo Core are documented here.
Format: [Semantic Versioning](https://semver.org/). Format: [Semantic Versioning](https://semver.org/).
## [0.2.0] — 2026-04-22
### Changed (BREAKING)
- CPT renamed from `pr_peptide` to `peptide`. Consolidates with PSA's existing `peptide` CPT which owns the 89 canonical peptide posts on production.
- Taxonomy renamed from `pr_peptide_category` to `peptide_category`. Same reason.
- CPT args harmonized as superset of PSA + prior PR Core registration (supports now includes thumbnail + revisions + custom-fields).
- Defensive registration: guards with `post_type_exists()` / `taxonomy_exists()` so PSA's parallel registration no-ops cleanly during the PSA v4.5.0 transition.
### Added
- `PR_Core_Activator::maybe_flush_on_version_change()` — one-shot rewrite flush on in-place version bumps (hooked at `init` priority 999). Eliminates the need for a manual `wp rewrite flush` after updates that change CPT/taxonomy slugs.
- `_pr_core_authored` post-meta flag contract — peptide posts created via PR Core UI carry this flag; `uninstall.php` uses it to scope teardown to plugin-owned posts only.
- Lightweight unit-test harness (`tests/bootstrap.php` + `tests/unit/test-peptide-cpt.php`) that exercises CPT/taxonomy guards and args payload shape without a PHPUnit + wp-env dependency. Wired into the existing PHP-lint CI job.
### Removed
- `pr_peptide_family` taxonomy — never populated, never surfaced in UI.
- All `pr_peptide*` CPT-slug-derived references (constants, docblock mentions, uninstall string literals).
- Blanket `DELETE FROM posts WHERE post_type = 'pr_peptide'` on uninstall. Replaced with a join-on-`_pr_core_authored` selective delete so PSA-authored peptide posts are never destroyed by PR Core teardown.
- Taxonomy-term cleanup on uninstall. `peptide_category` is shared ownership post-v0.2.0; term data the site still needs is preserved.
### Fixed
- Production peptide detail pages (all 89) were 404'ing due to a rewrite slug collision between PSA's `peptide` CPT and PR Core's `pr_peptide` CPT both claiming `/peptides/%postname%/`. PR Core v0.1.1's empty `pr_peptide` CPT was winning the rewrite resolution. This release consolidates both to a single `peptide` CPT owned by PR Core.
### Notes
- Scope of the `pr_peptide*` scrub is intentionally narrow: CPT-slug-derived identifiers only. Class names (`PR_Core_Peptide_CPT`), file names (`class-pr-core-peptide-cpt.php`), plugin constants (`PR_CORE_VERSION`), option keys (`pr_core_*`), and the REST namespace (`pr-core/v1`) are the plugin's own namespace, not the CPT slug, and are preserved verbatim.
- PR Core-defined meta keys (`display_name`, `aliases`, `evidence_strength`, `editorial_review_status`, …) are gated by `manage_peptide_content`. The activator grants this capability to `administrator` and `editor` roles on activation. Prod sites where the activation hook never fired (v0.1.1 was deployed manually) will receive the grant on next `wp plugin activate peptide-repo-core`. Follow-up thread to decide whether a dedicated `peptide_editor` role is more appropriate than extending core `editor`.
## [0.1.1] — 2026-04-19 ## [0.1.1] — 2026-04-19
### Fixed ### Fixed

View File

@@ -61,3 +61,14 @@ Values: `prescription`, `ruo`, `otc`, `restricted`, `banned`, `unclear`. Validat
## Supersede Pattern (Soft Delete) ## Supersede Pattern (Soft Delete)
Dosing rows and legal cells use a `superseded_by_id` column instead of hard deletes. When a correction is needed, the old row gets `superseded_by_id` set to the new row's ID. Queries for "active" data filter on `superseded_by_id IS NULL`. This preserves full audit history. Dosing rows and legal cells use a `superseded_by_id` column instead of hard deletes. When a correction is needed, the old row gets `superseded_by_id` set to the new row's ID. Queries for "active" data filter on `superseded_by_id IS NULL`. This preserves full audit history.
## CPT Ownership
- **One plugin owns each CPT.** PR Core owns the `peptide` CPT and the `peptide_category` taxonomy (as of v0.2.0). No other plugin in this ecosystem registers these post types or taxonomies directly.
- **All registrations must guard with existence checks.** CPT registrations call `if ( post_type_exists( $slug ) ) return;` before `register_post_type()`, and taxonomy registrations call `if ( taxonomy_exists( $slug ) ) return;` before `register_taxonomy()`. This makes deploy order between plugins irrelevant — whichever runs first wins, the other no-ops — and prevents rewrite-slug collisions when two plugins temporarily overlap during a transition.
- **Consumers read, do not register.** Plugins that need to query or display `peptide` posts (PSA, PR Theme, PRAutoBlogger, Peptide News, Tier 1 tools) call `get_posts( [ 'post_type' => 'peptide' ] )` / `WP_Query` without registering the CPT themselves. If a consumer plugin needs its own custom post type for a different domain (e.g., `reconstitution_protocol`), it registers its own slug and does not touch `peptide`.
- **Meta keys are plugin-namespaced, not CPT-namespaced.** PR Core writes `_pr_core_*` or bare keys registered via `register_post_meta()`. PSA writes `psa_*`. The two namespaces coexist on the same `peptide` post because they don't collide.
## `_pr_core_authored` Flag
Any `peptide` post created via PR Core's own UI (not authored via PSA's meta boxes) must be tagged with post-meta key `_pr_core_authored` = `1`. This is the flag `uninstall.php` uses to scope teardown to plugin-owned posts only. Posts originating from PSA (the 89 canonical peptide monographs on production) do not carry this flag and are never deleted on PR Core uninstall.

View File

@@ -2,7 +2,7 @@
declare(strict_types=1); declare(strict_types=1);
/** /**
* Custom admin list-table columns for the pr_peptide CPT. * Custom admin list-table columns for the peptide CPT.
* *
* What: Adds evidence strength, editorial status, and dosing count columns. * What: Adds evidence strength, editorial status, and dosing count columns.
* Who calls it: PR_Core_Admin via manage_posts_columns and custom_column hooks. * Who calls it: PR_Core_Admin via manage_posts_columns and custom_column hooks.

View File

@@ -4,7 +4,7 @@ declare(strict_types=1);
/** /**
* Meta boxes for the peptide edit screen: scientific identifiers, dosing, and legal. * Meta boxes for the peptide edit screen: scientific identifiers, dosing, and legal.
* *
* What: Renders and saves three meta boxes on the pr_peptide edit screen. * What: Renders and saves three meta boxes on the peptide edit screen.
* Who calls it: PR_Core_Admin via add_meta_boxes and save_post hooks. * Who calls it: PR_Core_Admin via add_meta_boxes and save_post hooks.
* Dependencies: PR_Core_Dosing_Repository, PR_Core_Legal_Repository. * Dependencies: PR_Core_Dosing_Repository, PR_Core_Legal_Repository.
* *

View File

@@ -5,17 +5,26 @@ declare(strict_types=1);
* Plugin activation handler. * Plugin activation handler.
* *
* What: Runs migrations, adds capabilities, flushes rewrite rules. * What: Runs migrations, adds capabilities, flushes rewrite rules.
* Who calls it: register_activation_hook() in peptide-repo-core.php. * Also detects version changes on subsequent init cycles and re-flushes
* Dependencies: PR_Core_Migration_Runner. * rewrite rules once so in-place plugin updates (without a deactivate +
* reactivate cycle) still pick up CPT/taxonomy slug changes.
* Who calls it: register_activation_hook() in peptide-repo-core.php; and
* PR_Core::init() registers maybe_flush_on_version_change on init:999.
* Dependencies: PR_Core_Migration_Runner, PR_Core_Peptide_CPT.
* *
* @see peptide-repo-core.php — Registers this class on activation. * @see peptide-repo-core.php — Registers this class on activation.
* @see includes/class-pr-core.php — Wires the init:999 version-change handler.
*/ */
class PR_Core_Activator { class PR_Core_Activator {
/** @var string Option key storing the last activated/seen plugin version. */
private const VERSION_OPTION = 'pr_core_version';
/** /**
* Run on plugin activation. * Run on plugin activation.
* *
* Side effects: database table creation, capability grants, rewrite flush. * Side effects: database table creation, capability grants, rewrite flush,
* pr_core_version option write.
* *
* @return void * @return void
*/ */
@@ -32,7 +41,39 @@ class PR_Core_Activator {
self::add_capabilities(); self::add_capabilities();
// Flush rewrite rules for the new CPT. // Flush rewrite rules for the new CPT.
flush_rewrite_rules(); flush_rewrite_rules( false );
// Record the activated version so the init:999 drift handler can
// detect future in-place updates and re-flush without a manual
// deactivate/reactivate cycle.
update_option( self::VERSION_OPTION, PR_CORE_VERSION, false );
}
/**
* Detect in-place plugin version changes and flush rewrite rules once.
*
* Wired to init:999 so all CPTs + taxonomies (ours and anyone else's)
* are registered before the flush. When the option is absent (first
* install that bypassed the activation hook — e.g., manual SCP deploy)
* or is older than the current constant, we flush once and update.
*
* Side effects: may call flush_rewrite_rules( false ); updates option.
*
* @return void
*/
public static function maybe_flush_on_version_change(): void {
if ( ! defined( 'PR_CORE_VERSION' ) ) {
return;
}
$recorded = get_option( self::VERSION_OPTION, '' );
if ( PR_CORE_VERSION === $recorded ) {
return;
}
flush_rewrite_rules( false );
update_option( self::VERSION_OPTION, PR_CORE_VERSION, false );
} }
/** /**

View File

@@ -30,6 +30,12 @@ class PR_Core {
$cpt = new PR_Core_Peptide_CPT(); $cpt = new PR_Core_Peptide_CPT();
$cpt->register_hooks(); $cpt->register_hooks();
// One-shot rewrite flush on in-place version bumps. Runs at the very
// end of init (priority 999) so all CPTs/taxonomies — ours and
// anyone else's — are registered first. Handles updates deployed
// without a deactivate/reactivate cycle (e.g., SCP/rsync pushes).
add_action( 'init', [ PR_Core_Activator::class, 'maybe_flush_on_version_change' ], 999 );
// Admin UI (fires on admin_init, admin_menu). // Admin UI (fires on admin_init, admin_menu).
if ( is_admin() ) { if ( is_admin() ) {
$admin = new PR_Core_Admin(); $admin = new PR_Core_Admin();

View File

@@ -2,25 +2,29 @@
declare(strict_types=1); declare(strict_types=1);
/** /**
* Registers the pr_peptide custom post type and associated taxonomies. * Registers the peptide custom post type and associated taxonomy.
* *
* What: Defines the peptide CPT with REST support, archive, and monograph meta fields. * What: Defines the peptide CPT with REST support, archive, and monograph meta fields.
* Who calls it: PR_Core::init() on plugins_loaded. * Who calls it: PR_Core::init() on plugins_loaded.
* Dependencies: None. * Dependencies: None.
* *
* Ownership: As of v0.2.0, PR Core is the canonical owner of the `peptide` CPT
* and `peptide_category` taxonomy (previously registered by Peptide Search AI
* pre-schema-sprint; PSA v4.5.0 drops its registrations in favor of this one).
* Registration is guarded by `post_type_exists()` / `taxonomy_exists()` so
* deploy order between the two plugins does not matter — whichever runs first
* wins, the other no-ops.
*
* @see ARCHITECTURE.md — CPT specification and post-meta field definitions. * @see ARCHITECTURE.md — CPT specification and post-meta field definitions.
* @see cpt/class-pr-core-peptide-taxonomies.php — Not used; taxonomies registered here for simplicity. * @see CONVENTIONS.md — CPT ownership rule (no plugin registers a CPT another plugin owns).
*/ */
class PR_Core_Peptide_CPT { class PR_Core_Peptide_CPT {
/** @var string Post type slug. */ /** @var string Post type slug (owned by PR Core since v0.2.0; PSA previously registered this). */
public const POST_TYPE = 'pr_peptide'; public const POST_TYPE = 'peptide';
/** @var string Taxonomy: category (e.g., GLP-1 agonist). */ /** @var string Taxonomy: category (e.g., GLP-1 agonist). */
public const TAX_CATEGORY = 'pr_peptide_category'; public const TAX_CATEGORY = 'peptide_category';
/** @var string Taxonomy: family grouping. */
public const TAX_FAMILY = 'pr_peptide_family';
/** @var string Capability required for editing peptide data. */ /** @var string Capability required for editing peptide data. */
public const CAPABILITY = 'manage_peptide_content'; public const CAPABILITY = 'manage_peptide_content';
@@ -129,13 +133,22 @@ class PR_Core_Peptide_CPT {
} }
/** /**
* Register the pr_peptide custom post type. * Register the `peptide` custom post type.
*
* Guarded with `post_type_exists()`: if another plugin (historically PSA)
* has already registered the `peptide` post type, this call no-ops. This
* makes deploy order between PR Core and PSA irrelevant during the
* PSA v4.5.0 consolidation transition.
* *
* Side effects: registers CPT with WordPress. * Side effects: registers CPT with WordPress.
* *
* @return void * @return void
*/ */
public static function register_peptide_post_type(): void { public static function register_peptide_post_type(): void {
if ( post_type_exists( self::POST_TYPE ) ) {
return;
}
$labels = [ $labels = [
'name' => __( 'Peptides', 'peptide-repo-core' ), 'name' => __( 'Peptides', 'peptide-repo-core' ),
'singular_name' => __( 'Peptide', 'peptide-repo-core' ), 'singular_name' => __( 'Peptide', 'peptide-repo-core' ),
@@ -150,60 +163,77 @@ class PR_Core_Peptide_CPT {
'menu_name' => __( 'Peptides', 'peptide-repo-core' ), 'menu_name' => __( 'Peptides', 'peptide-repo-core' ),
]; ];
/*
* Args are a harmonized superset of PR Core's prior `pr_peptide` args
* and PSA's `peptide` args, so the 89 existing posts (authored under
* PSA's registration) keep every capability they relied on:
* - supports: union of both — thumbnail comes from PSA, revisions +
* custom-fields come from PR Core.
* - capability_type + map_meta_cap: preserve PSA's per-role perms.
* - rewrite slug `peptides`: unchanged from both plugins, so
* /peptides/%postname%/ URLs keep working and theme templates
* single-peptide.php / archive-peptide.php continue to match.
*/
$args = [ $args = [
'labels' => $labels, 'labels' => $labels,
'public' => true, 'public' => true,
'has_archive' => true, 'publicly_queryable' => true,
'rewrite' => [ 'slug' => 'peptides', 'with_front' => false ], 'show_ui' => true,
'show_in_menu' => true,
'show_in_nav_menus' => true,
'show_in_rest' => true, 'show_in_rest' => true,
'rest_base' => 'peptides', 'rest_base' => 'peptides',
'rest_namespace' => 'pr-core/v1', 'rest_namespace' => 'pr-core/v1',
'supports' => [ 'title', 'editor', 'excerpt', 'revisions', 'custom-fields' ], 'menu_position' => 25,
'menu_icon' => 'dashicons-database', 'menu_icon' => 'dashicons-database',
'capability_type' => 'post', 'capability_type' => 'post',
'map_meta_cap' => true, 'map_meta_cap' => true,
'show_in_menu' => true, 'hierarchical' => false,
'menu_position' => 25, 'has_archive' => true,
'rewrite' => [ 'slug' => 'peptides', 'with_front' => false ],
'supports' => [ 'title', 'editor', 'thumbnail', 'excerpt', 'revisions', 'custom-fields' ],
]; ];
register_post_type( self::POST_TYPE, $args ); register_post_type( self::POST_TYPE, $args );
} }
/** /**
* Register the pr_peptide_category and pr_peptide_family taxonomies. * Register the `peptide_category` taxonomy.
* *
* Side effects: registers taxonomies with WordPress. * Guarded with `taxonomy_exists()` for the same reason CPT registration
* is guarded. The 8 existing terms + term_relationships stay intact —
* they key on taxonomy name `peptide_category` in wp_term_taxonomy,
* which is exactly what we register here.
*
* v0.2.0: `pr_peptide_family` taxonomy removed — never populated, never
* surfaced in UI.
*
* Side effects: registers taxonomy with WordPress.
* *
* @return void * @return void
*/ */
public static function register_taxonomies(): void { public static function register_taxonomies(): void {
if ( taxonomy_exists( self::TAX_CATEGORY ) ) {
return;
}
register_taxonomy( self::TAX_CATEGORY, self::POST_TYPE, [ register_taxonomy( self::TAX_CATEGORY, self::POST_TYPE, [
'labels' => [ 'labels' => [
'name' => __( 'Peptide Categories', 'peptide-repo-core' ), 'name' => __( 'Peptide Categories', 'peptide-repo-core' ),
'singular_name' => __( 'Peptide Category', 'peptide-repo-core' ), 'singular_name' => __( 'Peptide Category', 'peptide-repo-core' ),
], ],
'hierarchical' => true, 'public' => true,
'show_in_rest' => true, 'publicly_queryable' => true,
'show_admin_column' => true, 'show_in_rest' => true,
'rewrite' => [ 'slug' => 'peptide-category' ], 'show_ui' => true,
] ); 'show_admin_column' => true,
'hierarchical' => true,
register_taxonomy( self::TAX_FAMILY, self::POST_TYPE, [ 'rewrite' => [ 'slug' => 'peptide-category', 'with_front' => false ],
'labels' => [
'name' => __( 'Peptide Families', 'peptide-repo-core' ),
'singular_name' => __( 'Peptide Family', 'peptide-repo-core' ),
],
'hierarchical' => false,
'show_in_rest' => true,
'show_admin_column' => true,
'rewrite' => [ 'slug' => 'peptide-family' ],
] ); ] );
} }
/** /**
* Register post-meta fields with the REST API. * Register post-meta fields. Auth gated on manage_peptide_content.
*
* Side effects: registers meta with WordPress.
* *
* @return void * @return void
*/ */

View File

@@ -4,7 +4,7 @@ declare(strict_types=1);
/** /**
* Typed data-transfer object for a peptide record. * Typed data-transfer object for a peptide record.
* *
* What: Immutable value object wrapping a pr_peptide post + its meta fields. * What: Immutable value object wrapping a peptide post + its meta fields.
* Who calls it: PR_Core_Peptide_Repository returns these; consumers read them. * Who calls it: PR_Core_Peptide_Repository returns these; consumers read them.
* Dependencies: None. * Dependencies: None.
* *

View File

@@ -4,7 +4,7 @@ declare(strict_types=1);
/** /**
* Repository for peptide CPT records. * Repository for peptide CPT records.
* *
* What: CRUD operations over pr_peptide posts, returning typed DTOs. * What: CRUD operations over peptide posts, returning typed DTOs.
* Who calls it: PR_Core (public API), REST controller, admin UI, consumer plugins. * Who calls it: PR_Core (public API), REST controller, admin UI, consumer plugins.
* Dependencies: WordPress WP_Query, PR_Core_Peptide_DTO. * Dependencies: WordPress WP_Query, PR_Core_Peptide_DTO.
* *

View File

@@ -2,8 +2,8 @@
/** /**
* Plugin Name: Peptide Repo Core * Plugin Name: Peptide Repo Core
* Plugin URI: https://peptiderepo.com * Plugin URI: https://peptiderepo.com
* Description: Canonical peptide schema — shared data layer for the peptiderepo.com ecosystem. Provides the pr_peptide CPT, dosing rows, legal status cells, AI candidate queue, disclaimer component, and JSON-LD output. * Description: Canonical peptide schema — shared data layer for the peptiderepo.com ecosystem. Provides the peptide CPT, dosing rows, legal status cells, AI candidate queue, disclaimer component, and JSON-LD output.
* Version: 0.1.1 * Version: 0.2.0
* Author: peptiderepo * Author: peptiderepo
* Author URI: https://peptiderepo.com * Author URI: https://peptiderepo.com
* License: GPL-2.0-or-later * License: GPL-2.0-or-later
@@ -22,7 +22,7 @@ if ( ! defined( 'ABSPATH' ) ) {
/* ── Constants ────────────────────────────────────────────────────────── */ /* ── Constants ────────────────────────────────────────────────────────── */
define( 'PR_CORE_VERSION', '0.1.1' ); define( 'PR_CORE_VERSION', '0.2.0' );
define( 'PR_CORE_PLUGIN_FILE', __FILE__ ); define( 'PR_CORE_PLUGIN_FILE', __FILE__ );
define( 'PR_CORE_PLUGIN_DIR', plugin_dir_path( __FILE__ ) ); define( 'PR_CORE_PLUGIN_DIR', plugin_dir_path( __FILE__ ) );
define( 'PR_CORE_PLUGIN_URL', plugin_dir_url( __FILE__ ) ); define( 'PR_CORE_PLUGIN_URL', plugin_dir_url( __FILE__ ) );

View File

@@ -0,0 +1,71 @@
# QA Gate — PR #1 (feature/v0.2.0-cpt-consolidation)
- **Head SHA:** 874f93b (feature/v0.2.0-cpt-consolidation, rebased onto origin/main @ c70352e)
- **Commits in PR:**
- `850279e` — `feat(cpt): consolidate peptide CPT with PSA; drop pr_peptide (v0.2.0)` (originally reviewed as `283b7ea` pre-rebase; diff identical)
- `e0ce324` — `fix(tests): drop floatval() stub (PHP builtin; redeclaration fatal on 8.3)`
- `874f93b` — `fix(tests): define PR_CORE_PLUGIN_DIR + trailingslashit in bootstrap`
- **Reviewer:** independent QA subagent (with two post-hoc CI-failure follow-up fixes applied by the author, both test-harness-only, no runtime impact — PR Core code unchanged)
- **Date:** 2026-04-22
- **Verdict:** PASS
## Contract compliance
- [PASS] CPT slug is `peptide` (not `pr_peptide`) — `PR_Core_Peptide_CPT::POST_TYPE = 'peptide'` at `includes/cpt/class-pr-core-peptide-cpt.php:24`
- [PASS] Taxonomy is `peptide_category` (not `pr_peptide_category`) — `TAX_CATEGORY = 'peptide_category'` at `includes/cpt/class-pr-core-peptide-cpt.php:27`
- [PASS] `TAX_FAMILY` constant and `pr_peptide_family` taxonomy are removed — no `TAX_FAMILY` constant exists, `register_taxonomies()` only registers `peptide_category` at `includes/cpt/class-pr-core-peptide-cpt.php:215-233`, verified by unit test at `tests/unit/test-peptide-cpt.php:103-105`
- [PASS] Registration is guarded with `post_type_exists()` / `taxonomy_exists()` and truly no-ops when they return true — `post_type_exists( self::POST_TYPE )` at line 148 returns early, `taxonomy_exists( self::TAX_CATEGORY )` at line 216 returns early; unit tests verify this at `tests/unit/test-peptide-cpt.php:44-50` and `84-90`
- [PASS] Activation hook: `flush_rewrite_rules()` runs on activate (line 44 of `includes/class-pr-core-activator.php:44`), and version-change flush handler wired at `init:999` via `PR_Core_Activator::maybe_flush_on_version_change()` registered in `includes/class-pr-core.php:37`; handler compares `get_option('pr_core_version')` to `PR_CORE_VERSION` and flushes on mismatch at `includes/class-pr-core-activator.php:64-77`
- [PASS] Args payload is the harmonized superset at `includes/cpt/class-pr-core-peptide-cpt.php:177-195`: `supports` includes `thumbnail` (line 194), `revisions`, `custom-fields`; `rewrite.slug='peptides'` with `with_front=false` (line 193); `has_archive=true` (line 192); `show_in_rest=true` (line 184); `rest_base='peptides'` (line 185); `rest_namespace='pr-core/v1'` (line 186); `map_meta_cap=true` (line 190); verified by unit tests `tests/unit/test-peptide-cpt.php:64-79`
- [PASS] `uninstall.php` only deletes `peptide` posts tagged with `_pr_core_authored='1'` (scoped teardown) — SQL at lines 45-54 uses `INNER JOIN {$wpdb->postmeta}` on meta_key `_pr_core_authored` and meta_value `'1'`, and checks `post_type = 'peptide'` at line 53; 89 canonical PSA-authored posts (which don't carry this flag) will survive uninstall
- [PASS] `uninstall.php` does NOT delete `peptide_category` taxonomy terms (shared-ownership taxonomy) — lines 61-70 explicitly state terms are preserved, and no DELETE statement targets `wp_terms` or `wp_term_taxonomy`
- [PASS] Version bumped to `0.2.0` in `peptide-repo-core.php` (Version header at line 6 and `PR_CORE_VERSION` constant at line 25) AND CHANGELOG entry exists for 0.2.0 at `CHANGELOG.md:6-26` in the same commit — verified by `git show 283b7ea --name-status` showing both `CHANGELOG.md` and `peptide-repo-core.php` modified in the same commit
## Scrub scope (narrow — msg 02 says "tight")
- [PASS] Renamed identifiers limited to CPT-slug-derived strings. Preserved identifiers confirmed:
- Class names: `PR_Core_Peptide_CPT`, `PR_Core_Peptide_DTO`, `PR_Core_Peptide_Repository`, `PR_Core_Peptide_Metaboxes`, `PR_Core_Admin_Columns` remain with `PR_Core_` prefix
- File names: `class-pr-core-peptide-*.php` pattern preserved
- Plugin constants: `PR_CORE_VERSION`, `PR_CORE_PLUGIN_FILE`, `PR_CORE_PLUGIN_DIR`, `PR_CORE_PLUGIN_URL`, `PR_CORE_TARGET_SCHEMA_VERSION` unchanged in `peptide-repo-core.php:25-29`
- Option keys: `pr_core_version` at `includes/class-pr-core-activator.php:21`
- REST namespace: `pr-core/v1` at `includes/cpt/class-pr-core-peptide-cpt.php:186`
- Hook names: `pr_core_*` filters preserved in `includes/class-pr-core.php:71-73`
- No stray `pr_peptide` strings outside comments/tests: grep shows only test assertions (lines 103-104 of test file) and comments verifying removal
## Project invariants
- [PASS] No PHP file exceeds 300 lines (exclude `tests/`, `vendor/`) — verified by bash check: `find includes -name "*.php" -exec wc -l {} \;` shows no file over 300 lines
- [PASS] Version+CHANGELOG-in-same-commit — `git show --stat 283b7ea` lists both `peptide-repo-core.php` and `CHANGELOG.md` as modified in the single commit 283b7ea
- [PASS] Git author on head commit is `peptiderepo <peptiderepo@users.noreply.github.com>` — verified by `git show 283b7ea | head -10`
- [PASS] Tests exist at `tests/unit/test-peptide-cpt.php` covering:
- Constants match contract (POST_TYPE, TAX_CATEGORY, CAPABILITY) — lines 34-39
- TAX_FAMILY removal — line 39
- Guard no-op behavior when post_type_exists/taxonomy_exists return true — lines 44-50, 84-90
- Payload shape (supports includes thumbnail, rewrite slug, REST namespace) — lines 64-79, 111-115
- Taxonomy registration (peptide_category only, no pr_peptide_family) — lines 99-109
- [PASS] `ARCHITECTURE.md` updated with CPT ownership model (Key Decision #7 at line 187-188) and §2.9 Uninstall specification (lines 190-198); `CONVENTIONS.md` updated with "CPT Ownership" section (lines 65-71) and `_pr_core_authored` flag contract (lines 72-74)
## CI
- Workflow: `.github/workflows/ci.yml`
- PHP syntax check on 8.1, 8.2, 8.3
- Lightweight unit tests (no PHPUnit): runs `tests/unit/test-*.php`
- WordPress Coding Standards (PHPCS)
- 300-line file-size gate
- **Observed on head `874f93b`:** all 5 checks green
- PHP Lint (8.1): success
- PHP Lint (8.2): success
- PHP Lint (8.3): success
- WordPress Coding Standards: success
- 300-line file check: success
- Earlier CI on `850279e` failed PHP Lint 8.3 (`Cannot redeclare floatval()` in bootstrap) and on `e0ce324` failed PHP Lint 8.2 (`Undefined constant PR_CORE_PLUGIN_DIR` in autoloader when invoked from plain-php test runner). Both are test-harness bugs; `874f93b` addresses them. No PR Core runtime code was modified by the follow-ups, so the substantive review above stands.
## Hunted risks
- **Uninstall data-loss:** SAFE. The SQL query at `uninstall.php:45-54` uses `INNER JOIN {$wpdb->postmeta} pm ON p.ID = pm.post_id AND pm.meta_key = %s AND pm.meta_value = %s` with `meta_key='_pr_core_authored'` and `meta_value='1'`, and filters `WHERE p.post_type = %s` with `'peptide'`. This ensures only posts carrying the `_pr_core_authored` flag are deleted. The 89 canonical PSA-authored peptide posts will not carry this flag and will survive uninstall. No blanket `DELETE FROM posts WHERE post_type='peptide'` exists.
- **Guard correctness:** SAFE. The CPT guard checks `post_type_exists( self::POST_TYPE )` where `self::POST_TYPE = 'peptide'` (correct slug, not `pr_peptide`). Taxonomy guard checks `taxonomy_exists( self::TAX_CATEGORY )` where `self::TAX_CATEGORY = 'peptide_category'` (correct slug, not `pr_peptide_category`). Both guards are early returns (lines 148-150, 216-218) that prevent further execution, so the deploy-order-safety claim is sound.
- **Version-change flush:** SAFE. The activator stores the activated version in `pr_core_version` option at `includes/class-pr-core-activator.php:49`. The init:999 handler at line 64-77 retrieves the option via `get_option( self::VERSION_OPTION, '' )` (line 69, where `VERSION_OPTION = 'pr_core_version'` per line 21), compares to `PR_CORE_VERSION` constant, and if they don't match, flushes rewrite rules and updates the option. This handles in-place updates where the activation hook never fires (e.g., SCP deploys or file copies). The init:999 priority ensures all CPTs/taxonomies are already registered before the flush.
- **REST namespace:** SAFE. The CPT args at `includes/cpt/class-pr-core-peptide-c

163
tests/bootstrap.php Normal file
View File

@@ -0,0 +1,163 @@
<?php
/**
* Minimal test bootstrap — stubs the WordPress functions used by the classes
* under test so they can be exercised in plain PHP without a WP test install.
*
* This is intentionally NOT a full WP_UnitTestCase harness. It exists so the
* targeted unit assertions in tests/unit/ can verify guard behavior, constant
* shape, and args payload shape on CI (PHP lint job) without introducing a
* PHPUnit + wp-env dependency. A proper integration test harness is a
* follow-up (see CHANGELOG 0.2.0 notes).
*
* @package PeptideRepoCore
*/
declare(strict_types=1);
if ( ! defined( 'ABSPATH' ) ) {
define( 'ABSPATH', __DIR__ . '/' );
}
if ( ! defined( 'PR_CORE_VERSION' ) ) {
define( 'PR_CORE_VERSION', '0.2.0' );
}
if ( ! defined( 'PR_CORE_PLUGIN_DIR' ) ) {
define( 'PR_CORE_PLUGIN_DIR', realpath( __DIR__ . '/..' ) . '/' );
}
if ( ! defined( 'PR_CORE_PLUGIN_URL' ) ) {
define( 'PR_CORE_PLUGIN_URL', 'http://example.test/wp-content/plugins/peptide-repo-core/' );
}
if ( ! defined( 'PR_CORE_PLUGIN_FILE' ) ) {
define( 'PR_CORE_PLUGIN_FILE', PR_CORE_PLUGIN_DIR . 'peptide-repo-core.php' );
}
if ( ! function_exists( 'trailingslashit' ) ) {
function trailingslashit( string $path ): string {
return rtrim( $path, '/\\' ) . '/';
}
}
/**
* Global harness state. Tests reset this between cases.
*
* @var array<string, mixed>
*/
$GLOBALS['pr_core_test_state'] = [
'existing_post_types' => [],
'existing_taxonomies' => [],
'registered_post_types' => [],
'registered_taxonomies' => [],
'registered_meta' => [],
'added_actions' => [],
];
function pr_core_test_reset(): void {
$GLOBALS['pr_core_test_state'] = [
'existing_post_types' => [],
'existing_taxonomies' => [],
'registered_post_types' => [],
'registered_taxonomies' => [],
'registered_meta' => [],
'added_actions' => [],
];
}
function post_type_exists( string $post_type ): bool {
return in_array( $post_type, $GLOBALS['pr_core_test_state']['existing_post_types'], true );
}
function taxonomy_exists( string $taxonomy ): bool {
return in_array( $taxonomy, $GLOBALS['pr_core_test_state']['existing_taxonomies'], true );
}
function register_post_type( string $post_type, array $args = [] ) {
$GLOBALS['pr_core_test_state']['registered_post_types'][ $post_type ] = $args;
$GLOBALS['pr_core_test_state']['existing_post_types'][] = $post_type;
return (object) [ 'name' => $post_type, 'args' => $args ];
}
function register_taxonomy( string $taxonomy, $object_type, array $args = [] ): void {
$GLOBALS['pr_core_test_state']['registered_taxonomies'][ $taxonomy ] = [
'object_type' => $object_type,
'args' => $args,
];
$GLOBALS['pr_core_test_state']['existing_taxonomies'][] = $taxonomy;
}
function register_post_meta( string $post_type, string $key, array $args = [] ): bool {
$GLOBALS['pr_core_test_state']['registered_meta'][ $post_type ][ $key ] = $args;
return true;
}
function add_action( string $hook, $callback, int $priority = 10, int $accepted_args = 1 ): bool {
$GLOBALS['pr_core_test_state']['added_actions'][] = [
'hook' => $hook,
'callback' => $callback,
'priority' => $priority,
];
return true;
}
function __( string $text, string $domain = 'default' ): string {
return $text;
}
function current_user_can( string $capability ): bool {
return true;
}
function sanitize_text_field( $value ): string {
return is_scalar( $value ) ? trim( (string) $value ) : '';
}
function wp_json_encode( $data ): string {
return json_encode( $data ) ?: '[]';
}
function absint( $value ): int {
return abs( (int) $value );
}
/* ── Test assertion helpers ─────────────────────────────────────────── */
$GLOBALS['pr_core_test_report'] = [ 'pass' => 0, 'fail' => 0, 'failures' => [] ];
function pr_assert( bool $condition, string $label ): void {
if ( $condition ) {
$GLOBALS['pr_core_test_report']['pass']++;
echo " PASS: {$label}\n";
} else {
$GLOBALS['pr_core_test_report']['fail']++;
$GLOBALS['pr_core_test_report']['failures'][] = $label;
echo " FAIL: {$label}\n";
}
}
function pr_assert_equals( $expected, $actual, string $label ): void {
$pass = ( $expected === $actual );
pr_assert( $pass, $label . ( $pass ? '' : ' — expected ' . var_export( $expected, true ) . ', got ' . var_export( $actual, true ) ) );
}
function pr_test_summary(): int {
$r = $GLOBALS['pr_core_test_report'];
echo "\n---\n";
echo "Totals: {$r['pass']} passed, {$r['fail']} failed\n";
if ( $r['fail'] > 0 ) {
echo "Failures:\n";
foreach ( $r['failures'] as $f ) {
echo " - {$f}\n";
}
return 1;
}
return 0;
}
/* ── Load the plugin's autoloader + required classes ─────────────────── */
require_once __DIR__ . '/../includes/class-pr-core-autoloader.php';
PR_Core_Autoloader::register();
require_once __DIR__ . '/../includes/cpt/class-pr-core-peptide-cpt.php';

View File

@@ -0,0 +1,129 @@
<?php
/**
* Unit tests for PR_Core_Peptide_CPT (v0.2.0 CPT consolidation).
*
* Run: php tests/unit/test-peptide-cpt.php
* Exit code 0 = all pass, 1 = any failure.
*
* What's covered:
* - Constants match the v0.2.0 consolidated contract (peptide / peptide_category).
* - TAX_FAMILY constant is removed.
* - register_peptide_post_type() guard: no-op when post_type_exists returns true.
* - register_peptide_post_type() payload: args match the harmonized contract
* (thumbnail in supports, rewrite slug peptides, pr-core/v1 REST namespace).
* - register_taxonomies() guard: no-op when taxonomy_exists returns true.
* - register_taxonomies() only registers peptide_category; pr_peptide_family is gone.
*
* Not covered here (follow-up with proper PHPUnit + wp-env harness):
* - Actual rewrite rule map on a live WP install.
* - Activation flow end-to-end (migrations + capabilities + flush).
* - REST route registration smoke.
*
* @package PeptideRepoCore
*/
declare(strict_types=1);
require_once __DIR__ . '/../bootstrap.php';
echo "== PR_Core_Peptide_CPT v0.2.0 unit tests ==\n\n";
/* ── Constants ──────────────────────────────────────────────────────── */
echo "Constants:\n";
pr_assert_equals( 'peptide', PR_Core_Peptide_CPT::POST_TYPE, 'POST_TYPE is "peptide"' );
pr_assert_equals( 'peptide_category', PR_Core_Peptide_CPT::TAX_CATEGORY, 'TAX_CATEGORY is "peptide_category"' );
pr_assert_equals( 'manage_peptide_content', PR_Core_Peptide_CPT::CAPABILITY, 'CAPABILITY unchanged' );
$reflection = new ReflectionClass( PR_Core_Peptide_CPT::class );
pr_assert( ! $reflection->hasConstant( 'TAX_FAMILY' ), 'TAX_FAMILY constant removed' );
/* ── CPT guard: post_type_exists === true => no-op ──────────────────── */
echo "\nCPT registration guard:\n";
pr_core_test_reset();
$GLOBALS['pr_core_test_state']['existing_post_types'] = [ 'peptide' ];
PR_Core_Peptide_CPT::register_peptide_post_type();
pr_assert(
empty( $GLOBALS['pr_core_test_state']['registered_post_types'] ),
'register_peptide_post_type() no-ops when post_type_exists("peptide") is true'
);
/* ── CPT registration payload ───────────────────────────────────────── */
echo "\nCPT registration payload:\n";
pr_core_test_reset();
PR_Core_Peptide_CPT::register_peptide_post_type();
pr_assert(
isset( $GLOBALS['pr_core_test_state']['registered_post_types']['peptide'] ),
'register_post_type was called with slug "peptide"'
);
$args = $GLOBALS['pr_core_test_state']['registered_post_types']['peptide'] ?? [];
pr_assert_equals( true, $args['public'] ?? null, 'public = true' );
pr_assert_equals( true, $args['publicly_queryable'] ?? null, 'publicly_queryable = true' );
pr_assert_equals( true, $args['show_in_rest'] ?? null, 'show_in_rest = true' );
pr_assert_equals( 'peptides', $args['rest_base'] ?? null, 'rest_base = "peptides"' );
pr_assert_equals( 'pr-core/v1', $args['rest_namespace'] ?? null, 'rest_namespace = "pr-core/v1"' );
pr_assert_equals( 'post', $args['capability_type'] ?? null, 'capability_type = "post"' );
pr_assert_equals( true, $args['map_meta_cap'] ?? null, 'map_meta_cap = true' );
pr_assert_equals( false, $args['hierarchical'] ?? null, 'hierarchical = false' );
pr_assert_equals( true, $args['has_archive'] ?? null, 'has_archive = true' );
pr_assert_equals( 'peptides', $args['rewrite']['slug'] ?? null, 'rewrite slug = "peptides"' );
pr_assert_equals( false, $args['rewrite']['with_front'] ?? null, 'rewrite with_front = false' );
$supports = $args['supports'] ?? [];
foreach ( [ 'title', 'editor', 'thumbnail', 'excerpt', 'revisions', 'custom-fields' ] as $feature ) {
pr_assert( in_array( $feature, $supports, true ), "supports includes \"{$feature}\"" );
}
/* ── Taxonomy guard: taxonomy_exists === true => no-op ──────────────── */
echo "\nTaxonomy registration guard:\n";
pr_core_test_reset();
$GLOBALS['pr_core_test_state']['existing_taxonomies'] = [ 'peptide_category' ];
PR_Core_Peptide_CPT::register_taxonomies();
pr_assert(
empty( $GLOBALS['pr_core_test_state']['registered_taxonomies'] ),
'register_taxonomies() no-ops when taxonomy_exists("peptide_category") is true'
);
/* ── Taxonomy registration payload ──────────────────────────────────── */
echo "\nTaxonomy registration payload:\n";
pr_core_test_reset();
PR_Core_Peptide_CPT::register_taxonomies();
pr_assert(
isset( $GLOBALS['pr_core_test_state']['registered_taxonomies']['peptide_category'] ),
'peptide_category taxonomy registered'
);
pr_assert(
! isset( $GLOBALS['pr_core_test_state']['registered_taxonomies']['pr_peptide_family'] ),
'pr_peptide_family taxonomy NOT registered (removed in v0.2.0)'
);
pr_assert(
! isset( $GLOBALS['pr_core_test_state']['registered_taxonomies']['peptide_family'] ),
'peptide_family taxonomy NOT registered either (the rename-only path would have left this lingering)'
);
$tax = $GLOBALS['pr_core_test_state']['registered_taxonomies']['peptide_category'] ?? [];
pr_assert_equals( 'peptide', $tax['object_type'] ?? null, 'peptide_category attached to peptide CPT' );
pr_assert_equals( true, $tax['args']['hierarchical'] ?? null, 'peptide_category hierarchical = true' );
pr_assert_equals( true, $tax['args']['show_in_rest'] ?? null, 'peptide_category show_in_rest = true' );
pr_assert_equals( 'peptide-category', $tax['args']['rewrite']['slug'] ?? null, 'peptide_category rewrite slug = "peptide-category"' );
/* ── Hook registration ──────────────────────────────────────────────── */
echo "\nHook registration:\n";
pr_core_test_reset();
$cpt = new PR_Core_Peptide_CPT();
$cpt->register_hooks();
$hooks = array_column( $GLOBALS['pr_core_test_state']['added_actions'], 'hook' );
pr_assert( in_array( 'init', $hooks, true ), 'register_hooks() adds action on "init"' );
/* ── Summary ────────────────────────────────────────────────────────── */
exit( pr_test_summary() );

View File

@@ -1,11 +1,13 @@
<?php <?php
/** /**
* Peptide Repo Core — Full data teardown on uninstall. * Peptide Repo Core — Selective data teardown on uninstall.
* *
* Removes ALL plugin data: custom tables, CPT posts + meta, taxonomy terms, * Removes plugin-owned data only: custom tables, PR Core-authored peptide
* options, and capabilities. No orphaned data left behind. * posts, pr_core_ options, and the manage_peptide_content capability.
* Shared data (the 89 canonical peptide posts authored by PSA, and the
* peptide_category taxonomy terms) is preserved.
* *
* @see ARCHITECTURE.md — Section 2.9 Uninstall specification. * @see ARCHITECTURE.md — §2.9 Uninstall specification.
*/ */
if ( ! defined( 'WP_UNINSTALL_PLUGIN' ) ) { if ( ! defined( 'WP_UNINSTALL_PLUGIN' ) ) {
@@ -27,33 +29,45 @@ foreach ( $tables as $table ) {
$wpdb->query( "DROP TABLE IF EXISTS {$table}" ); $wpdb->query( "DROP TABLE IF EXISTS {$table}" );
} }
/* ── 2. Delete all pr_peptide posts (cascade deletes meta) ────────────── */ /* ── 2. Delete only peptide posts authored through PR Core's UI ───────── */
/*
* As of v0.2.0, PR Core shares the `peptide` CPT with Peptide Search AI.
* PSA authored the 89 canonical peptide posts on peptiderepo.com; those
* predate PR Core and do NOT carry the `_pr_core_authored` flag. A blanket
* DELETE here would destroy site-owned content, not plugin-owned content.
*
* This loop only deletes posts that PR Core itself created (flagged with
* post-meta `_pr_core_authored = '1'`). If no post carries the flag, the
* loop no-ops and shared content is preserved.
*/
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
$post_ids = $wpdb->get_col( $post_ids = $wpdb->get_col(
"SELECT ID FROM {$wpdb->posts} WHERE post_type = 'pr_peptide'" $wpdb->prepare(
"SELECT p.ID FROM {$wpdb->posts} p
INNER JOIN {$wpdb->postmeta} pm
ON p.ID = pm.post_id AND pm.meta_key = %s AND pm.meta_value = %s
WHERE p.post_type = %s",
'_pr_core_authored',
'1',
'peptide'
)
); );
foreach ( $post_ids as $post_id ) { foreach ( $post_ids as $post_id ) {
wp_delete_post( (int) $post_id, true ); wp_delete_post( (int) $post_id, true );
} }
/* ── 3. Delete taxonomy terms ─────────────────────────────────────────── */ /* ── 3. Preserve taxonomy terms (shared ownership post-v0.2.0) ────────── */
$taxonomies = [ 'pr_peptide_category', 'pr_peptide_family' ]; /*
* `peptide_category` is shared between PR Core and PSA. Term metadata
foreach ( $taxonomies as $taxonomy ) { * (descriptions, parent relationships, counts) is data the site owns —
$terms = get_terms( [ * removing it on PR Core uninstall would strip categorization from the
'taxonomy' => $taxonomy, * peptide posts that remain. So we intentionally do NOT enumerate or
'hide_empty' => false, * delete terms here. `pr_peptide_family` is gone in v0.2.0 so no cleanup
'fields' => 'ids', * is needed for it either.
] ); */
if ( is_array( $terms ) ) {
foreach ( $terms as $term_id ) {
wp_delete_term( (int) $term_id, $taxonomy );
}
}
}
/* ── 4. Delete all pr_core_ prefixed options ──────────────────────────── */ /* ── 4. Delete all pr_core_ prefixed options ──────────────────────────── */
@@ -62,7 +76,7 @@ $wpdb->query(
"DELETE FROM {$wpdb->options} WHERE option_name LIKE 'pr\_core\_%'" "DELETE FROM {$wpdb->options} WHERE option_name LIKE 'pr\_core\_%'"
); );
/* ── 5. Remove manage_peptide_content capability from all roles ────────── */ /* ── 5. Remove manage_peptide_content capability from all roles ───────── */
$editable_roles = wp_roles()->roles; $editable_roles = wp_roles()->roles;