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

Resolves the rewrite slug collision that was 404'ing all 89 production
peptide detail pages. PR Core v0.1.1 registered an empty pr_peptide CPT
claiming /peptides/%postname%/; WP's rewrite resolver picked it over PSA's
populated peptide CPT. This release consolidates both registrations onto a
single peptide CPT owned by PR Core. PSA v4.5.0 drops its registration in
favor of this one.

## Contract (per CTO msg 02)
- POST_TYPE: pr_peptide -> peptide
- TAX_CATEGORY: pr_peptide_category -> peptide_category
- TAX_FAMILY const removed (pr_peptide_family was never populated)
- CPT + taxonomy registration guarded with post_type_exists() / taxonomy_exists()
  so deploy order between PR Core and PSA is forgiving
- Args harmonized as superset of PSA + prior PR Core (thumbnail added to
  supports; capability_type + map_meta_cap preserved)
- Activator: flush_rewrite_rules(false) on activation; new
  maybe_flush_on_version_change() handler hooked at init:999 for in-place
  updates that bypass the activation hook (e.g. SCP deploys)
- uninstall.php: blanket DELETE replaced with JOIN on _pr_core_authored so
  PSA-authored peptide posts (the 89 canonical monographs) are never destroyed;
  peptide_category terms preserved (shared ownership)
- Scrub scope: narrow — CPT-slug-derived identifiers only; class names,
  file names, PR_CORE_* constants, pr_core_* options, and pr-core/v1 REST
  namespace remain

## Docs
- ARCHITECTURE.md: new Key Decision #7 (v0.2.0 consolidation) and §2.9
  Uninstall specification
- CONVENTIONS.md: CPT Ownership rule + _pr_core_authored flag contract

## Tests
- Lightweight harness (tests/bootstrap.php) that stubs the handful of WP
  functions exercised by the CPT class
- tests/unit/test-peptide-cpt.php asserts: constants match contract,
  TAX_FAMILY is removed, guard no-ops when post_type_exists/taxonomy_exists
  returns true, args payload shape (thumbnail in supports, rewrite slug,
  REST namespace), hooks wired
- CI: new 'Unit tests (lightweight, no PHPUnit)' step in lint-php job,
  runs across PHP 8.1/8.2/8.3. Full PHPUnit + wp-env harness is a
  follow-up

Refs: convo/prcore/threads/2026-04-peptides-cpt-collision/02-cto-correction.md
This commit is contained in:
peptiderepo
2026-04-22 08:23:00 +00:00
parent c70352eb40
commit 850279e756
15 changed files with 506 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__ ) );

148
tests/bootstrap.php Normal file
View File

@@ -0,0 +1,148 @@
<?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' );
}
/**
* 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 );
}
function floatval( $value ): float {
return (float) $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;