Compare commits

..

2 Commits

Author SHA1 Message Date
peptiderepo
66891267be test(dossier): split DossierMenuRegistrationTest (551→235+198+120 lines, P1 fix) + §22c→§22b (P3 fix)
P1: DossierMenuRegistrationTest.php was 551 lines (cap 300). Split into:
  - DossierMenuRegistrationTestCase.php (120 lines): shared Brain\Monkey harness,
    add_submenu_page spy stub, $submenu isolation — abstract base class.
  - DossierMenuRegistrationTest.php (235 lines): structural invariants (parent slug,
    $submenu non-mutation, page slug constant, asset enqueue gate prefix), deep-link
    URL shape, and hook priority ordering — 7 test methods.
  - DossierHooknameMechanismTest.php (198 lines): request-time hookname faithfulness
    (simulate_request_time_hookname discriminator) and regression proof — 2 test methods.

Method inventory parity: 9 before = 9 after.
  Before: test_request_time_hookname_matches_registration_hookname,
    test_hide_by_unset_would_fail_request_time_check,
    test_dossier_parent_slug_is_options_php, test_no_submenu_mutation_after_registration,
    test_dossier_uses_correct_slug, test_asset_enqueue_gate_uses_admin_page_prefix,
    test_url_for_post_contains_slug_and_post_id, test_url_for_post_exact_shape,
    test_dossier_hook_registered_at_priority_12.
  After: same 9, split 7/2 across two files. Zero assertions dropped.

P3: class-dossier-page.php docblock §22c → §22b (two occurrences).
  No §22c heading exists in ARCHITECTURE.md; convention content is in §22b.

Agent-Session: dossier-403-hotfix-20260612
2026-06-12 11:37:05 +00:00
peptiderepo
ae32876b6b fix: dossier page 403 — options.php-parent pattern (v0.19.3)
Dossier page dead on prod (403) since v0.19.2 deploy. Root cause:
on_register_menu() registered under 'prautoblogger-settings' parent then
unset the slug from $submenu to hide the nav entry. At request time
get_admin_page_parent() scanned $submenu, found nothing, fell back to
orphan hookname 'admin_page_prautoblogger-dossier'. Render callback was
registered under 'prautoblogger_page_prautoblogger-dossier'. Mismatch ->
wp_die(403). Same hookname-mismatch class as the board 404 (v0.19.1).

Fix (seq 21 §Required fix items 1-6):

1. options.php-parent: add_submenu_page('options.php', ...) -> hookname
   is 'admin_page_prautoblogger-dossier' at BOTH registration and request
   time. Hidden by construction; no $submenu mutation needed or permitted.
   Add PARENT_SLUG = 'options.php' constant.

2. Asset enqueue gate: updated from 'prautoblogger_page_*' to
   'admin_page_*' prefix to match options.php-parent hookname.

3. Deep-link URLs (admin.php?page=prautoblogger-dossier&post_id=N):
   parent-agnostic; unchanged. Asserted in
   test_url_for_post_contains_slug_and_post_id +
   test_url_for_post_exact_shape.

4. Regression test upgraded to request-time faithful:
   test_request_time_hookname_matches_registration_hookname simulates
   get_admin_page_parent() against post-registration $submenu state.
   FAILS on v0.19.2 hide-by-unset (4 failures, captured in evidence):
     - test_request_time_hookname_matches_registration_hookname FAIL
     - test_dossier_parent_slug_is_options_php FAIL
     - test_no_submenu_mutation_after_registration FAIL
     - test_asset_enqueue_gate_uses_admin_page_prefix FAIL
   PASSES on v0.19.3 options.php-parent (9/9 OK, captured in evidence).
   test_hide_by_unset_would_fail_request_time_check documents broken
   pattern inline.

5. ARCHITECTURE.md §22b: hidden-page convention cross-reference + v0.19.3
   incident record added. §23: page registration description updated to
   options.php-parent. File tree entry updated.
   CONVENTIONS.md §Hidden Admin Pages: full rule + both incident history
   (board 404 v0.19.1, dossier 403 v0.19.2) + test methodology.

6. Version 0.19.2 -> 0.19.3, CHANGELOG entry. tests/bootstrap.php:
   PRAUTOBLOGGER_PLUGIN_URL + PRAUTOBLOGGER_VERSION constants added
   (resolves 'Undefined constant' in enqueue tests).

Deletion audit: removed lines are the broken 'prautoblogger-settings'
parent string, the 12-line $submenu mutation block, the wrong hook prefix
'prautoblogger_page_*'. No other logic removed.

Agent-Session: dossier-403-hotfix-20260612
2026-06-12 11:18:15 +00:00
9 changed files with 222 additions and 424 deletions

View File

@@ -1,17 +1,20 @@
name: CTO Review name: CTO Review
# Automated PR review powered by Claude Haiku (via OpenRouter). # Automated PR review powered by Claude + DeepSeek + Kimi (via OpenRouter)
# plus Neurometric PR-Summarizer (a Qwen3-4B task-specialist SLM).
# #
# A/B panel decision 2026-06-12: panel dropped to Haiku-only. # During the A/B window (until further notice), every PR gets reviewed by FOUR models
# Evidence: convo/cto/threads/2026-06-qa-ab-review/02-analyst-findings.md # in parallel. The PR comment shows each model's verdict side-by-side so we can compare
# DeepSeek: high false-positive rate (absint()="code exec", PLUGIN_DIR="dir traversal"). # quality/cost on real PRs before picking a permanent default.
# Kimi: ~60% model failure rate across 13 PRs sampled.
# Neurometric: summarizer only, zero unique-real findings, unreliable heuristic verdict.
# Haiku: 0 failures, lowest noise, equivalent real-signal catch rate vs 4-voice panel.
# #
# Does NOT merge. Posts formal APPROVE/REQUEST_CHANGES/COMMENT reviews. # Neurometric is a "prose voice": it does not support OpenAI tool-calling, so its
# contribution is raw markdown + a heuristic verdict. It is never the primary voice.
# #
# Requires repo secret: OPENROUTER_API_KEY # Does NOT merge. Does NOT post formal APPROVE/REQUEST_CHANGES reviews — always COMMENT
# (GITHUB_TOKEN can't formally approve PRs without repo-setting toggle).
#
# Requires repo secrets: OPENROUTER_API_KEY, NEUROMETRIC_API_KEY (optional; the
# Neurometric voice self-skips with an inline error note if the key is missing).
# #
# Cost cap: tracked in Repository Variables (CTO_REVIEW_SPEND_CENTS, CTO_REVIEW_CAP_CENTS). # Cost cap: tracked in Repository Variables (CTO_REVIEW_SPEND_CENTS, CTO_REVIEW_CAP_CENTS).
# Default cap is 1000 cents ($10/month). Auto-resets on the 1st of each month. # Default cap is 1000 cents ($10/month). Auto-resets on the 1st of each month.
@@ -122,8 +125,8 @@ jobs:
HEAD_SHA="${{ github.event.pull_request.head.sha }}" HEAD_SHA="${{ github.event.pull_request.head.sha }}"
# Write the diff to a file, not a step output / env var. Passing a # Write the diff to a file, not a step output / env var. Passing a
# ~400KB diff through the process env (execve argv+envp) exceeds the # ~400KB diff through the process env (execve argv+envp) exceeds the
# runner ARG_MAX and fails the next step with "Argument list too long". # runner ARG_MAX and fails the next step with "Argument list too long"
# File transport also sidesteps the ~1MB step-output cap. # (issue #110). File transport also sidesteps the ~1MB step-output cap.
git diff --no-color "$BASE_SHA" "$HEAD_SHA" | head -c 400000 > /tmp/cto-review-diff.txt git diff --no-color "$BASE_SHA" "$HEAD_SHA" | head -c 400000 > /tmp/cto-review-diff.txt
FILES=$(git diff --name-only "$BASE_SHA" "$HEAD_SHA") FILES=$(git diff --name-only "$BASE_SHA" "$HEAD_SHA")
SIZE=$(wc -c < /tmp/cto-review-diff.txt) SIZE=$(wc -c < /tmp/cto-review-diff.txt)
@@ -148,11 +151,12 @@ jobs:
echo "EOF_CONV" echo "EOF_CONV"
} >> "$GITHUB_OUTPUT" } >> "$GITHUB_OUTPUT"
- name: Call Haiku via OpenRouter - name: Call 4 models in parallel (OpenRouter + Neurometric)
if: steps.budget.outputs.skip != 'true' if: steps.budget.outputs.skip != 'true'
id: review id: review
env: env:
OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }} OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }}
NEUROMETRIC_API_KEY: ${{ secrets.NEUROMETRIC_API_KEY }}
PR_TITLE: ${{ github.event.pull_request.title }} PR_TITLE: ${{ github.event.pull_request.title }}
PR_BODY: ${{ github.event.pull_request.body }} PR_BODY: ${{ github.event.pull_request.body }}
PR_AUTHOR: ${{ github.event.pull_request.user.login }} PR_AUTHOR: ${{ github.event.pull_request.user.login }}
@@ -180,17 +184,24 @@ jobs:
fi fi
python3 - <<'PY' python3 - <<'PY'
import json, os, sys, urllib.request import json, os, sys, urllib.request, concurrent.futures
api_key = os.environ["OPENROUTER_API_KEY"] api_key = os.environ["OPENROUTER_API_KEY"]
# Haiku-only reviewer. A/B panel dropped 2026-06-12 per QA data: # A/B harness: four models. Each gets the same prompt.
# DeepSeek high false-positive rate, Kimi ~60% failure rate, # Primary model's verdict drives the heading. Others are labeled.
# Neurometric zero unique-real findings. Haiku retained as sole voice. # Edit this list to change the roster (or drop to 1 after A/B window closes).
MODEL_SLUG = "anthropic/claude-haiku-4.5" #
MODEL_DISPLAY = "Claude Haiku 4.5" # provider_type semantics:
INPUT_PER_M_USD = 1.00 # - "openrouter_tools": OpenAI tool-calling via OpenRouter; structured verdict.
OUTPUT_PER_M_USD = 5.00 # - "neurometric_prose": Neurometric marketplace SLM; prose output + heuristic verdict.
MODELS = [
# (slug, display name, input_per_M_USD, output_per_M_USD, is_primary, provider_type)
("anthropic/claude-haiku-4.5", "Claude Haiku 4.5", 1.00, 5.00, True, "openrouter_tools"),
("deepseek/deepseek-chat-v3.1", "DeepSeek V3.1", 0.15, 0.75, False, "openrouter_tools"),
("moonshotai/kimi-k2-thinking", "Kimi K2 Thinking", 0.60, 2.50, False, "openrouter_tools"),
("neurometric/pr-summarizer", "Neurometric PR-Summarizer (Qwen3-4B)", 0.00, 0.00, False, "neurometric_prose"),
]
system = """You are the CTO and lead reviewer for the peptiderepo project. You are reviewing a pull request opened by an autonomous agent or human contributor. system = """You are the CTO and lead reviewer for the peptiderepo project. You are reviewing a pull request opened by an autonomous agent or human contributor.
@@ -253,100 +264,218 @@ jobs:
} }
}] }]
body = { PER_MODEL_TIMEOUT = 60 # seconds; one hung model should not block siblings
"model": MODEL_SLUG,
"max_tokens": 2048,
"messages": [
{"role": "system", "content": system},
{"role": "user", "content": user_message}
],
"tools": tools,
"tool_choice": {"type": "function", "function": {"name": "submit_review"}}
}
req = urllib.request.Request(
"https://openrouter.ai/api/v1/chat/completions",
data=json.dumps(body).encode(),
headers={
"Authorization": f"Bearer {api_key}",
"content-type": "application/json",
"HTTP-Referer": "https://github.com/peptiderepo",
"X-Title": f"peptiderepo CTO reviewer ({MODEL_DISPLAY})"
},
method="POST"
)
error = None # Heuristic verdict inference for prose-voice models that can't do tool-calling.
review = None # Keyword order matters: block markers beat approve markers.
cost_cents = 0.0 PROSE_BLOCK_MARKERS = [
ptok = 0 "do not merge", "do not approve", "should not be merged", "reject this",
ctok = 0 "critical security", "critical issue", "critical vulnerability",
"blocker", "blocking issue", "request_changes", "request changes",
"**critical**", "sql injection", "must be addressed before",
]
PROSE_APPROVE_MARKERS = [
"safe to merge", "ready to merge", "no blocking issues", "lgtm",
"approve this", "looks good to merge", "no concerns",
]
try: def infer_verdict_from_prose(text):
with urllib.request.urlopen(req, timeout=60) as resp: t = (text or "").lower()
data = json.loads(resp.read()) if any(m in t for m in PROSE_BLOCK_MARKERS):
except Exception as e: return "REQUEST_CHANGES"
error = str(e)[:300] if any(m in t for m in PROSE_APPROVE_MARKERS):
data = {} return "APPROVE"
return "COMMENT"
def call_openrouter_tools(slug, display, in_rate, out_rate, is_primary):
body = {
"model": slug,
"max_tokens": 2048,
"messages": [
{"role": "system", "content": system},
{"role": "user", "content": user_message}
],
"tools": tools,
"tool_choice": {"type": "function", "function": {"name": "submit_review"}}
}
req = urllib.request.Request(
"https://openrouter.ai/api/v1/chat/completions",
data=json.dumps(body).encode(),
headers={
"Authorization": f"Bearer {api_key}",
"content-type": "application/json",
"HTTP-Referer": "https://github.com/peptiderepo",
"X-Title": f"peptiderepo CTO reviewer ({display})"
},
method="POST"
)
try:
with urllib.request.urlopen(req, timeout=PER_MODEL_TIMEOUT) as resp:
data = json.loads(resp.read())
except Exception as e:
return {
"slug": slug, "display": display, "is_primary": is_primary,
"provider_type": "openrouter_tools",
"error": str(e)[:300],
"cost_cents": 0,
}
if not error:
choice = data.get("choices", [{}])[0].get("message", {}) choice = data.get("choices", [{}])[0].get("message", {})
tool_calls = choice.get("tool_calls", []) tool_calls = choice.get("tool_calls", [])
if not tool_calls: if not tool_calls:
error = f"No tool_call in response. Raw content: {str(choice)[:300]}" return {
else: "slug": slug, "display": display, "is_primary": is_primary,
try: "provider_type": "openrouter_tools",
review = json.loads(tool_calls[0]["function"]["arguments"]) "error": f"No tool_call in response. Raw content: {str(choice)[:300]}",
except Exception as e: "cost_cents": 0,
error = f"Malformed tool args: {e}" }
if not error: try:
usage = data.get("usage", {}) review = json.loads(tool_calls[0]["function"]["arguments"])
ptok = usage.get("prompt_tokens", 0) except Exception as e:
ctok = usage.get("completion_tokens", 0) return {
cost_usd = (ptok * INPUT_PER_M_USD / 1_000_000) + (ctok * OUTPUT_PER_M_USD / 1_000_000) "slug": slug, "display": display, "is_primary": is_primary,
cost_cents = cost_usd * 100 "provider_type": "openrouter_tools",
"error": f"Malformed tool args: {e}",
"cost_cents": 0,
}
# Build comment usage = data.get("usage", {})
if error: ptok = usage.get("prompt_tokens", 0)
heading_verdict = "ERROR" ctok = usage.get("completion_tokens", 0)
lines = [f"## CTO Review — {heading_verdict}", ""] cost_usd = (ptok * in_rate / 1_000_000) + (ctok * out_rate / 1_000_000)
lines.append(f"Model failed: `{error}`") cost_cents = cost_usd * 100
return {
"slug": slug, "display": display, "is_primary": is_primary,
"provider_type": "openrouter_tools",
"review": review, "prompt_tokens": ptok, "completion_tokens": ctok,
"cost_cents": cost_cents,
}
def call_neurometric_prose(slug, display, is_primary):
nm_key = os.environ.get("NEUROMETRIC_API_KEY", "")
if not nm_key:
return {
"slug": slug, "display": display, "is_primary": is_primary,
"provider_type": "neurometric_prose",
"error": "NEUROMETRIC_API_KEY secret not set on this repo",
"cost_cents": 0,
}
body = {
"model": slug,
"max_tokens": 2048,
"messages": [
{"role": "system", "content": system},
{"role": "user", "content": user_message},
],
}
req = urllib.request.Request(
"https://api.neurometric.ai/v1/chat/completions",
data=json.dumps(body).encode(),
headers={
"Authorization": f"Bearer {nm_key}",
"content-type": "application/json",
},
method="POST",
)
try:
with urllib.request.urlopen(req, timeout=PER_MODEL_TIMEOUT) as resp:
data = json.loads(resp.read())
except Exception as e:
return {
"slug": slug, "display": display, "is_primary": is_primary,
"provider_type": "neurometric_prose",
"error": str(e)[:300],
"cost_cents": 0,
}
prose = data.get("choices", [{}])[0].get("message", {}).get("content") or ""
verdict = infer_verdict_from_prose(prose)
return {
"slug": slug, "display": display, "is_primary": is_primary,
"provider_type": "neurometric_prose",
"review": {"verdict": verdict, "prose": prose},
"prompt_tokens": 0, "completion_tokens": 0,
"cost_cents": 0,
}
def call_model(slug, display, in_rate, out_rate, is_primary, provider_type="openrouter_tools"):
if provider_type == "neurometric_prose":
return call_neurometric_prose(slug, display, is_primary)
return call_openrouter_tools(slug, display, in_rate, out_rate, is_primary)
results = []
with concurrent.futures.ThreadPoolExecutor(max_workers=len(MODELS)) as pool:
futures = [pool.submit(call_model, *m) for m in MODELS]
for f in concurrent.futures.as_completed(futures):
results.append(f.result())
# Order: primary first, then alphabetical for stable comparison
results.sort(key=lambda r: (not r.get("is_primary"), r["display"]))
# Build combined comment
primary = next((r for r in results if r.get("is_primary") and "review" in r), None)
heading_verdict = primary["review"]["verdict"] if primary else "MIXED"
lines = [f"## CTO Review — {heading_verdict}", ""]
lines.append(f"_A/B harness active: {len(results)} models reviewing in parallel. Primary verdict drives heading; others are advisory._")
lines.append("")
total_cents = 0.0
for r in results:
lines.append(f"---")
primary_tag = " (primary)" if r.get("is_primary") else ""
lines.append(f"### {r['display']}{primary_tag}")
if "error" in r:
lines.append("")
lines.append(f"⚠️ Model failed: `{r['error']}`")
lines.append("")
continue
rev = r["review"]
lines.append("") lines.append("")
lines.append(f"_reviewer: {MODEL_DISPLAY}_") if r.get("provider_type") == "neurometric_prose":
else: lines.append(f"**Verdict:** {rev['verdict']} _(heuristic, inferred from prose)_")
heading_verdict = review.get("verdict", "COMMENT") lines.append("")
lines = [f"## CTO Review — {heading_verdict}", ""] # Cap at 6KB to keep PR comments readable; 4B model output is rarely over this
lines.append(f"**Verdict:** {heading_verdict}") prose = rev.get("prose", "")
if len(prose) > 6000:
prose = prose[:6000] + "\n\n_…output truncated for comment length…_"
lines.append(prose)
lines.append("")
lines.append("_free tier; per-call token usage not tracked_")
lines.append("")
continue
lines.append(f"**Verdict:** {rev.get('verdict', 'COMMENT')}")
lines.append("") lines.append("")
lines.append(review.get("summary") or "_(model returned no summary)_") lines.append(rev.get("summary") or "_(model returned no summary)_")
lines.append("") lines.append("")
if review.get("concerns"): if rev.get("concerns"):
lines.append("**Concerns:**") lines.append("**Concerns:**")
for c in review["concerns"]: for c in rev["concerns"]:
sev = c.get("severity", "UNKNOWN").upper() sev = c.get("severity", "UNKNOWN").upper()
f = f"`{c['file']}`" if c.get("file") else "" f = f"`{c['file']}`" if c.get("file") else ""
lines.append(f"- **{sev}** {f} — {c['issue']}") lines.append(f"- **{sev}** {f} — {c['issue']}")
lines.append("") lines.append("")
if review.get("positives"): if rev.get("positives"):
lines.append("**Positives:** " + "; ".join(review["positives"])) lines.append("**Positives:** " + "; ".join(rev["positives"]))
lines.append("") lines.append("")
lines.append(f"_tokens in/out: {ptok}/{ctok} · cost: ${cost_cents/100:.4f}_") lines.append(f"_tokens in/out: {r['prompt_tokens']}/{r['completion_tokens']} · cost: ${r['cost_cents']/100:.4f}_")
total_cents += r["cost_cents"]
lines.append("") lines.append("")
lines.append("---") lines.append("---")
lines.append(f"_Total review cost: **${cost_cents/100:.4f}**. Reviewer: {MODEL_DISPLAY}. Roster configured in `.github/workflows/cto-review.yml`._") lines.append(f"_Total review cost: **${total_cents/100:.4f}**. A/B roster configured in `.github/workflows/cto-review.yml`._")
with open("/tmp/review-body.md", "w") as f: with open("/tmp/review-body.md", "w") as f:
f.write("\n".join(lines)) f.write("\n".join(lines))
with open(os.environ["GITHUB_OUTPUT"], "a") as f: with open(os.environ["GITHUB_OUTPUT"], "a") as f:
f.write(f"added_cents={int(round(cost_cents))}\n") f.write(f"added_cents={int(round(total_cents))}\n")
f.write(f"skipped=0\n") f.write(f"skipped=0\n")
f.write(f"primary_verdict={heading_verdict}\n") f.write(f"primary_verdict={heading_verdict}\n")
PY PY
- name: Post PR review (formal APPROVE / REQUEST_CHANGES / COMMENT) - name: Post PR review (formal APPROVE / REQUEST_CHANGES / COMMENT)
# Uses Haiku's verdict as the formal event. Falls back to COMMENT # Uses primary model's verdict as the formal event. Falls back to COMMENT
# if the model failed or returned something unexpected. # if the primary model failed or returned something unexpected.
if: steps.budget.outputs.skip != 'true' && steps.review.outputs.skipped != '1' if: steps.budget.outputs.skip != 'true' && steps.review.outputs.skipped != '1'
uses: actions/github-script@v7 uses: actions/github-script@v7
env: env:

View File

@@ -5,41 +5,6 @@ All notable changes to PRAutoBlogger will be documented in this file.
Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project uses [Semantic Versioning](https://semver.org/). and this project uses [Semantic Versioning](https://semver.org/).
## [0.19.4] - 2026-06-12
### Added
- **GD-rung unit tests.** New `ImageComposerEditorTest.php` (7 tests) covering
`PRAutoBlogger_Image_Composer_Editor`: `wp_get_image_editor` WP_Error, resize WP_Error,
upscale-guard size mismatch, save WP_Error, zero-width target geometry guard, missing-role
guard, and successful OG resize happy path. Orchestrator ladder test
`test_gd_rung_emits_passthrough_featured_and_single_warning` added to `ImageComposerTest.php`,
asserting the Imagick renderer is never called on the `gd` rung and pass-through featured is
always first.
(Source: QA verdict `Peptide Repo CTO/qa-reviews/prautoblogger/2026-06-11-6f76df1.md` P2-1.)
### Fixed
- **Dead layout keys removed.** `caption_margin_right` (og) and `caption_side_padding` (square)
were defined in `Layout::defaults()` and exposed via the `prautoblogger_image_compose_layout`
filter but never consumed by the Imagick renderer. The og renderer positions text from
`logo_x + logo_w + caption_gap` (left-anchor); the square renderer uses centered `x` alignment.
Right-edge overflow is enforced via `caption_chars_per_line` on both variants. Keys removed;
filter users tuning these values had no effect and received no error.
(Source: QA verdict `Peptide Repo CTO/qa-reviews/prautoblogger/2026-06-11-6f76df1.md` P3-3.)
- **Amortized research row now carries audit fields.** `Post_Assembler::amortize_research_costs()`
previously inserted per-article `llm_research` copies without `prompt_version` or `agent_role`,
leaving those columns NULL/empty while the original (correctly stamped) row was deleted.
SELECT now includes both columns; INSERT carries them forward from the original row.
(Source: phase-1 thread seq 49, seq 50 carried-list: "amortized llm_research rows pv=null/role
empty" — `convo/prautoblogger/threads/2026-06-pipeline-v2-phase1/`.)
- **`fetch_page()` error body included in exception.** Non-200 responses from the Runware
modelSearch API now include up to 200 chars of the response body in the `RuntimeException`
message, making diagnosis from logs actionable without re-reproducing the request.
(Source: phase-1 thread seq 50 carried-list: "`fetch_page()` error-body logging".)
### Notes
- cto-review.yml isinstance guard (handoff item 5): the fix (`c.get("severity", "UNKNOWN")`)
is already applied at HEAD (line 454 of the workflow). No changes made.
## [0.19.3] - 2026-06-12 ## [0.19.3] - 2026-06-12
### Fixed ### Fixed

View File

@@ -38,10 +38,6 @@ class PRAutoBlogger_Image_Composer_Layout {
* plugin; sizes are px. Values were tuned against rendered samples (see * plugin; sizes are px. Values were tuned against rendered samples (see
* PR #thread 2026-06-image-composer assets). * PR #thread 2026-06-image-composer assets).
* *
* Note: 'caption_margin_right' (og) and 'caption_side_padding' (square) were removed
* in v0.19.4 — the Imagick renderer enforces right-edge geometry via caption_chars_per_line
* and centered x positioning respectively, so those keys had no consumer.
*
* @return array<string, array<string, mixed>> Keyed by role: featured, og, square. * @return array<string, array<string, mixed>> Keyed by role: featured, og, square.
*/ */
public static function defaults(): array { public static function defaults(): array {
@@ -64,6 +60,7 @@ class PRAutoBlogger_Image_Composer_Layout {
'caption_size' => 32, 'caption_size' => 32,
'caption_color' => '#FFFFFF', 'caption_color' => '#FFFFFF',
'caption_gap' => 24, 'caption_gap' => 24,
'caption_margin_right' => 40,
'caption_max_lines' => 2, 'caption_max_lines' => 2,
'caption_chars_per_line' => 52, 'caption_chars_per_line' => 52,
'caption_line_height' => 1.3, 'caption_line_height' => 1.3,
@@ -82,6 +79,7 @@ class PRAutoBlogger_Image_Composer_Layout {
'caption_color' => self::COLOR_INK, 'caption_color' => self::COLOR_INK,
'caption_max_lines' => 3, 'caption_max_lines' => 3,
'caption_chars_per_line' => 36, 'caption_chars_per_line' => 36,
'caption_side_padding' => 64,
'caption_line_height' => 1.3, 'caption_line_height' => 1.3,
), ),
); );

View File

@@ -91,9 +91,7 @@ class PRAutoBlogger_Post_Assembler {
* are generated. Its cost is logged with the run_id but no post_id. After all * are generated. Its cost is logged with the run_id but no post_id. After all
* articles finish, this method divides the research cost evenly and inserts * articles finish, this method divides the research cost evenly and inserts
* one 'llm_research' row per article so the cost breakdown popover includes * one 'llm_research' row per article so the cost breakdown popover includes
* the amortized research overhead. The audit fields prompt_version and * the amortized research overhead.
* agent_role are carried from the original research row (v0.19.4 fix;
* prior builds left them NULL/empty — see phase-1 thread seq 49).
* *
* Side effects: database SELECT, INSERT, DELETE on generation_log. * Side effects: database SELECT, INSERT, DELETE on generation_log.
* *
@@ -111,7 +109,7 @@ class PRAutoBlogger_Post_Assembler {
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
$research_row = $wpdb->get_row( $research_row = $wpdb->get_row(
$wpdb->prepare( // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared $wpdb->prepare( // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
"SELECT id, estimated_cost, provider, model, prompt_tokens, completion_tokens, prompt_version, agent_role "SELECT id, estimated_cost, provider, model, prompt_tokens, completion_tokens
FROM {$table} FROM {$table}
WHERE run_id = %s AND stage = 'llm_research' AND post_id IS NULL WHERE run_id = %s AND stage = 'llm_research' AND post_id IS NULL
LIMIT 1", LIMIT 1",
@@ -160,8 +158,6 @@ class PRAutoBlogger_Post_Assembler {
'estimated_cost' => $amortized_cost, 'estimated_cost' => $amortized_cost,
'response_status' => 'success', 'response_status' => 'success',
'error_message' => '', 'error_message' => '',
'prompt_version' => $research_row->prompt_version,
'agent_role' => $research_row->agent_role ?? '',
'created_at' => current_time( 'mysql' ), 'created_at' => current_time( 'mysql' ),
) )
); );

View File

@@ -138,8 +138,7 @@ class PRAutoBlogger_Runware_Catalog_Fetcher {
* @param int $offset Pagination offset (0-based). * @param int $offset Pagination offset (0-based).
* @param int $limit Page size. * @param int $limit Page size.
* @return array{results: array<int, array<string, mixed>>, totalResults: int} * @return array{results: array<int, array<string, mixed>>, totalResults: int}
* @throws \RuntimeException On HTTP error or malformed response (non-200 exceptions * @throws \RuntimeException On HTTP error or malformed response.
* include up to 200 chars of the response body for diagnosis).
*/ */
private function fetch_page( string $api_key, int $offset, int $limit ): array { private function fetch_page( string $api_key, int $offset, int $limit ): array {
$payload = array( $payload = array(
@@ -172,10 +171,8 @@ class PRAutoBlogger_Runware_Catalog_Fetcher {
$status = (int) wp_remote_retrieve_response_code( $response ); $status = (int) wp_remote_retrieve_response_code( $response );
if ( 200 !== $status ) { if ( 200 !== $status ) {
$raw_body = (string) wp_remote_retrieve_body( $response );
$body_excerpt = '' !== $raw_body ? ' — body: ' . substr( $raw_body, 0, 200 ) : '';
throw new \RuntimeException( throw new \RuntimeException(
sprintf( 'Runware modelSearch returned HTTP %d%s', $status, $body_excerpt ) sprintf( 'Runware modelSearch returned HTTP %d', $status )
); );
} }

View File

@@ -9,7 +9,7 @@
* Plugin Name: PRAutoBlogger * Plugin Name: PRAutoBlogger
* Plugin URI: https://peptiderepo.com/prautoblogger * Plugin URI: https://peptiderepo.com/prautoblogger
* Description: Monitors social media for trending topics, generates SEO-friendly blog posts using AI, and publishes them on a daily schedule with full cost tracking and self-improvement metrics. * Description: Monitors social media for trending topics, generates SEO-friendly blog posts using AI, and publishes them on a daily schedule with full cost tracking and self-improvement metrics.
* Version: 0.19.4 * Version: 0.19.3
* Requires at least: 6.0 * Requires at least: 6.0
* Requires PHP: 7.4 * Requires PHP: 7.4
* Author: PeptideRepo * Author: PeptideRepo
@@ -35,7 +35,7 @@ if ( ! defined( 'ABSPATH' ) ) {
| Defined here so every file in the plugin can reference paths, versions, | Defined here so every file in the plugin can reference paths, versions,
| and limits without magic strings. | and limits without magic strings.
*/ */
define( 'PRAUTOBLOGGER_VERSION', '0.19.4' ); define( 'PRAUTOBLOGGER_VERSION', '0.19.3' );
define( 'PRAUTOBLOGGER_DB_VERSION', '1.1.0' ); define( 'PRAUTOBLOGGER_DB_VERSION', '1.1.0' );
define( 'PRAUTOBLOGGER_PLUGIN_FILE', __FILE__ ); define( 'PRAUTOBLOGGER_PLUGIN_FILE', __FILE__ );
define( 'PRAUTOBLOGGER_PLUGIN_DIR', plugin_dir_path( __FILE__ ) ); define( 'PRAUTOBLOGGER_PLUGIN_DIR', plugin_dir_path( __FILE__ ) );

View File

@@ -1,246 +0,0 @@
<?php
/**
* Tests for PRAutoBlogger_Image_Composer_Editor (GD/WP-editor rung).
*
* Validates the three main branches of cover_crop():
* - wp_get_image_editor() returns WP_Error → null returned.
* - Resize returns WP_Error → null returned.
* - Exact size not reachable (would upscale) → null returned.
* - Happy path: successful resize/save returns the expected shape.
* And compose_variants() iteration:
* - Roles with bad geometry (0-width target) are skipped.
* - Null cover_crop result for a role skips that role with a debug log.
*
* Brain Monkey stubs wp_get_image_editor, get_temp_dir so no filesystem or
* WP runtime is needed.
*
* @package PRAutoBlogger\Tests\Core
*/
namespace PRAutoBlogger\Tests\Core;
use PRAutoBlogger\Tests\BaseTestCase;
use Brain\Monkey\Functions;
class ImageComposerEditorTest extends BaseTestCase {
/**
* Stub get_option so PRAutoBlogger_Logger can construct without Brain Monkey
* throwing on the prautoblogger_log_level option lookup. The logger is
* instantiated whenever compose_variants() skips a role and fires a debug log.
*/
protected function setUp(): void {
parent::setUp();
$this->stub_get_option( [] ); // Logger reads prautoblogger_log_level; fall back to default.
}
/** Minimal layout geometry mirroring Layout::defaults() relevant keys. */
private function layout( int $og_w = 1200, int $og_h = 630, int $sq_w = 1080, int $sq_h = 1080 ): array {
return array(
'og' => array( 'width' => $og_w, 'height' => $og_h ),
'square' => array( 'width' => $sq_w, 'height' => $sq_h ),
);
}
/** Helper: mock WP image editor whose resize returns a WP_Error. */
private function mock_editor_resize_error(): object {
$editor = $this->getMockBuilder( \stdClass::class )
->addMethods( array( 'resize', 'get_size', 'save' ) )
->getMock();
$editor->method( 'resize' )->willReturn( new \WP_Error( 'resize_fail', 'Mock resize failure' ) );
return $editor;
}
/** Helper: mock WP image editor whose save returns a WP_Error. */
private function mock_editor_save_error( int $w, int $h ): object {
$editor = $this->getMockBuilder( \stdClass::class )
->addMethods( array( 'resize', 'get_size', 'save' ) )
->getMock();
$editor->method( 'resize' )->willReturn( false ); // Not WP_Error = success.
$editor->method( 'get_size' )->willReturn( array( 'width' => $w, 'height' => $h ) );
$editor->method( 'save' )->willReturn( new \WP_Error( 'save_fail', 'Mock save failure' ) );
return $editor;
}
/** Helper: mock WP image editor that succeeds at the requested size. */
private function mock_editor_success( int $w, int $h ): object {
$editor = $this->getMockBuilder( \stdClass::class )
->addMethods( array( 'resize', 'get_size', 'save' ) )
->getMock();
$editor->method( 'resize' )->willReturn( false ); // Not WP_Error = success.
$editor->method( 'get_size' )->willReturn( array( 'width' => $w, 'height' => $h ) );
$editor->method( 'save' )->willReturn( array( 'path' => '/tmp/prab_ok.png' ) );
return $editor;
}
/** Helper: mock WP image editor that resizes but returns a different size (upscale guard). */
private function mock_editor_wrong_size( int $requested_w, int $requested_h, int $actual_w, int $actual_h ): object {
$editor = $this->getMockBuilder( \stdClass::class )
->addMethods( array( 'resize', 'get_size', 'save' ) )
->getMock();
$editor->method( 'resize' )->willReturn( false ); // Not WP_Error = success.
$editor->method( 'get_size' )->willReturn( array( 'width' => $actual_w, 'height' => $actual_h ) );
return $editor;
}
// ------------------------------------------------------------------
// cover_crop() failure paths (tested via compose_variants interface).
// ------------------------------------------------------------------
/**
* wp_get_image_editor() returns WP_Error → role skipped,
* no variants emitted.
*/
public function test_editor_wp_error_skips_role(): void {
Functions\when( 'get_temp_dir' )->justReturn( sys_get_temp_dir() . '/' );
Functions\when( 'wp_get_image_editor' )->justReturn(
new \WP_Error( 'no_editor', 'No image editor available' )
);
Functions\when( 'is_wp_error' )->alias( static function ( $val ) {
return $val instanceof \WP_Error;
} );
$editor = new \PRAutoBlogger_Image_Composer_Editor();
$variants = $editor->compose_variants( 'fake_bytes', array( 'og' ), $this->layout() );
$this->assertSame( array(), $variants, 'wp_get_image_editor WP_Error must yield no variants' );
}
/**
* Resize step returns WP_Error → role skipped.
*/
public function test_resize_wp_error_skips_role(): void {
Functions\when( 'get_temp_dir' )->justReturn( sys_get_temp_dir() . '/' );
Functions\when( 'wp_get_image_editor' )->justReturn( $this->mock_editor_resize_error() );
Functions\when( 'is_wp_error' )->alias( static function ( $val ) {
return $val instanceof \WP_Error;
} );
$editor = new \PRAutoBlogger_Image_Composer_Editor();
$variants = $editor->compose_variants( 'fake_bytes', array( 'og' ), $this->layout() );
$this->assertSame( array(), $variants, 'Resize WP_Error must yield no variants' );
}
/**
* Exact size not reachable (WP editor silently gives a different size
* instead of upscaling) → role skipped via the size-mismatch guard.
*/
public function test_size_mismatch_skips_role(): void {
Functions\when( 'get_temp_dir' )->justReturn( sys_get_temp_dir() . '/' );
// Editor resizes to 900×473 when 1080×1080 square was requested (would upscale).
Functions\when( 'wp_get_image_editor' )->justReturn(
$this->mock_editor_wrong_size( 1080, 1080, 900, 473 )
);
Functions\when( 'is_wp_error' )->alias( static function ( $val ) {
return $val instanceof \WP_Error;
} );
$editor = new \PRAutoBlogger_Image_Composer_Editor();
$variants = $editor->compose_variants( 'fake_bytes', array( 'square' ), $this->layout() );
$this->assertSame( array(), $variants, 'Size mismatch (upscale guard) must yield no variants' );
}
/**
* Save step returns WP_Error → role skipped.
*/
public function test_save_wp_error_skips_role(): void {
Functions\when( 'get_temp_dir' )->justReturn( sys_get_temp_dir() . '/' );
Functions\when( 'wp_get_image_editor' )->justReturn(
$this->mock_editor_save_error( 1200, 630 )
);
Functions\when( 'is_wp_error' )->alias( static function ( $val ) {
return $val instanceof \WP_Error;
} );
$editor = new \PRAutoBlogger_Image_Composer_Editor();
$variants = $editor->compose_variants( 'fake_bytes', array( 'og' ), $this->layout() );
$this->assertSame( array(), $variants, 'Save WP_Error must yield no variants' );
}
// ------------------------------------------------------------------
// Happy path — OG resizes successfully.
// ------------------------------------------------------------------
/**
* Successful cover_crop for 'og': variant shape matches expectations.
* file_put_contents and file_get_contents are exercised via temp file
* in the test runner's writable temp dir; the editor mock does not
* actually modify the file, but the class reads it back and returns its
* bytes.
*/
public function test_successful_og_resize_returns_correct_shape(): void {
$temp_dir = sys_get_temp_dir() . '/';
Functions\when( 'get_temp_dir' )->justReturn( $temp_dir );
$og_w = 1200;
$og_h = 630;
Functions\when( 'wp_get_image_editor' )->alias(
function () use ( $og_w, $og_h ) {
return $this->mock_editor_success( $og_w, $og_h );
}
);
Functions\when( 'is_wp_error' )->alias( static function ( $val ) {
return $val instanceof \WP_Error;
} );
$editor = new \PRAutoBlogger_Image_Composer_Editor();
$variants = $editor->compose_variants( 'fake_source_bytes', array( 'og' ), $this->layout() );
$this->assertCount( 1, $variants, 'Successful resize must emit exactly one variant' );
$variant = $variants[0];
$this->assertSame( 'og', $variant['role'] );
$this->assertSame( 'image/png', $variant['mime_type'] );
$this->assertSame( $og_w, $variant['width'] );
$this->assertSame( $og_h, $variant['height'] );
$this->assertIsString( $variant['bytes'] );
}
// ------------------------------------------------------------------
// compose_variants() geometry guard.
// ------------------------------------------------------------------
/**
* A role whose layout target has width=0 is skipped before any I/O —
* no editor call, no variant emitted.
*/
public function test_zero_width_target_skips_role_before_editor(): void {
// Make the 'og' slot have zero width so the guard fires before any I/O.
$layout = array(
'og' => array( 'width' => 0, 'height' => 630 ),
);
// wp_get_image_editor must never be called.
Functions\expect( 'wp_get_image_editor' )->never();
Functions\when( 'is_wp_error' )->alias( static function ( $val ) {
return $val instanceof \WP_Error;
} );
$editor = new \PRAutoBlogger_Image_Composer_Editor();
$variants = $editor->compose_variants( 'fake_bytes', array( 'og' ), $layout );
$this->assertSame( array(), $variants );
}
/**
* Role not present in layout skips cleanly.
*/
public function test_missing_role_in_layout_emits_no_variant(): void {
Functions\expect( 'wp_get_image_editor' )->never();
Functions\when( 'is_wp_error' )->alias( static function ( $val ) {
return $val instanceof \WP_Error;
} );
$editor = new \PRAutoBlogger_Image_Composer_Editor();
$variants = $editor->compose_variants( 'fake_bytes', array( 'nonexistent_role' ), array() );
$this->assertSame( array(), $variants );
}
}

View File

@@ -322,43 +322,4 @@ class ImageComposerTest extends BaseTestCase {
$this->assertStringStartsWith( 'prautoblogger_', $key ); $this->assertStringStartsWith( 'prautoblogger_', $key );
} }
} }
/**
* Ladder rung 2 forced via the override filter ('gd'): compose() emits the
* pass-through featured variant as first element, and fires exactly one WARNING.
* The Imagick renderer must never be called on this rung.
*
* Source: QA verdict 2026-06-11-6f76df1.md P2-1 and CTO handoff
* convo/prautoblogger/threads/2026-06-maintenance-v0194/01-cto-maintenance-handoff.md item 1.
*/
public function test_gd_rung_emits_passthrough_featured_and_single_warning(): void {
Filters\expectApplied( 'prautoblogger_image_compose_capability' )->andReturn( 'gd' );
// Imagick renderer must not be invoked on the gd rung.
$renderer = $this->createMock( \PRAutoBlogger_Image_Composer_Imagick::class );
$renderer->expects( $this->never() )->method( 'compose_featured' );
$renderer->expects( $this->never() )->method( 'compose_og' );
$renderer->expects( $this->never() )->method( 'compose_square' );
// Stub the WP functions that cover_crop() calls inside the GD rung.
// get_temp_dir() is called first; wp_get_image_editor() returning WP_Error
// makes cover_crop() return null for every role → compose_variants() returns []
// → the GD rung emits only the pass-through featured (array_merge($passthrough, [])).
// This is the real degradation-to-passthrough path the test is designed to assert.
Functions\when( 'get_temp_dir' )->justReturn( sys_get_temp_dir() . '/' );
Functions\when( 'wp_get_image_editor' )->justReturn(
new \WP_Error( 'no_editor', 'Mock: no GD editor in test environment' )
);
Functions\when( 'is_wp_error' )->alias( static function ( $val ) {
return $val instanceof \WP_Error;
} );
$composer = new \PRAutoBlogger_Image_Composer( $renderer );
$variants = $composer->compose( $this->image_data, $this->context() );
// Pass-through featured variant is always first.
$this->assertNotEmpty( $variants );
$this->assertSame( 'featured', $variants[0]['role'] );
$this->assertSame( 'raw_base_bytes', $variants[0]['bytes'] );
}
} }

View File

@@ -110,8 +110,6 @@ class ResearchReaperTest extends BaseTestCase {
'model' => $o->model ?? 'gemini-flash', 'model' => $o->model ?? 'gemini-flash',
'prompt_tokens' => $o->prompt_tokens ?? 1000, 'prompt_tokens' => $o->prompt_tokens ?? 1000,
'completion_tokens' => $o->completion_tokens ?? 500, 'completion_tokens' => $o->completion_tokens ?? 500,
'prompt_version' => $o->prompt_version ?? null,
'agent_role' => $o->agent_role ?? '',
]; ];
} }
} }