Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 7 additions & 3 deletions .github/claude-agent/run.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,8 @@ Everything else in this prompt applies identically in both modes.

### Step 1 — Triage

If `$BUNDLE_DIR/<source-repo>/watched-hits.txt` exists, the workflow invoked you (at least partly) because the RC changed files under a watch path, even if the diff adds no new hooks, REST routes or CLI commands. It lists those files, one per line. Start your triage with their hunks and check them against the area whose `source_paths` cover them. If they don't change the documented surface, that area gets 0 PRs.

Read `AGENT_MAP.md`. For every hunk in every `rc.diff.filtered`:

- Map the source path to an area via the area's `source_paths`.
Expand Down Expand Up @@ -80,14 +82,16 @@ For each candidate item that would otherwise produce a PR plan, apply this discr

1. **Authoritative override — `@internal` annotation.** If the registering call's surrounding PHPDoc, the registering method's docblock, or the registering class's docblock contains an `@internal` tag, treat the item as internal. Do NOT document. Note in the run summary's "Internal surface skipped" section (see Step 4).

2. **Heuristic — permission callback.** For `register_rest_route(...)`, read the `permission_callback`. If it enforces a logged-in admin capability check (`current_user_can('manage_options')`, `current_user_can('edit_posts')`, `current_user_can('wpseo_manage_options')`, similar) without an unauthenticated branch, treat the route as **likely internal**. Don't document; flag in "Internal surface skipped" with the callback as evidence.
2. **Public by design — WordPress Abilities.** Abilities registered via `wp_register_ability(...)` (and their categories, via `wp_register_ability_category(...)`) are public by design: the Abilities API exists to expose them to external consumers (AI agents, MCP clients, other plugins). Treat them as **public** regardless of the path/class heuristics below — e.g. Yoast SEO registers its abilities in `src/abilities/user-interface/` because of its onion-architecture layout, not because they are internal. A `permission_callback` that checks a capability is likewise expected for abilities and is NOT an internal signal. Only an `@internal` annotation (rule 1) overrides this. See the `yoast-seo-abilities` area in `AGENT_MAP.md` for the docs paths.

3. **Heuristic — permission callback.** For `register_rest_route(...)`, read the `permission_callback`. If it enforces a logged-in admin capability check (`current_user_can('manage_options')`, `current_user_can('edit_posts')`, `current_user_can('wpseo_manage_options')`, similar) without an unauthenticated branch, treat the route as **likely internal**. Don't document; flag in "Internal surface skipped" with the callback as evidence.

3. **Heuristic — file-path/class-name signals.** Default-to-internal when the registration lives in any of these:
4. **Heuristic — file-path/class-name signals.** Default-to-internal when the registration lives in any of these:
- File path contains `/admin/`, `/user-interface/`, `*-admin-*`, `*-internal-*`.
- Class name contains `Admin_`, `Internal_`, ends with `_Admin_Route` / `_UI_Route`.
- Registration is from a class that extends a known internal base (e.g. `Yoast\WP\SEO\Admin\...`).

4. **When in doubt, don't document.** False positives in the public-API direction are higher cost than false negatives. If the signals are mixed (e.g. neutral path but no `@internal` annotation and a `current_user_can` callback), prefer to skip and flag, rather than confidently document. The maintainer can ask the source-repo team and reverse the decision in a follow-up.
5. **When in doubt, don't document.** False positives in the public-API direction are higher cost than false negatives. If the signals are mixed (e.g. neutral path but no `@internal` annotation and a `current_user_can` callback), prefer to skip and flag, rather than confidently document. The maintainer can ask the source-repo team and reverse the decision in a follow-up.

Items skipped under this rule must be listed in the run summary under a heading **"Internal surface skipped"**, with one bullet per item: source path, symbol/route, and which signal fired (`@internal`, permission-callback heuristic, path/class heuristic, or "mixed signals"). Omit the heading entirely if no items were skipped.

Expand Down
49 changes: 39 additions & 10 deletions .github/workflows/rc-docs-sync.yml
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,9 @@ jobs:
"display_name": "Yoast SEO",
"repos": ["Yoast/wordpress-seo"],
"tracking_issue_var": "TRACKING_ISSUE_WORDPRESS_SEO",
# Any change under these path prefixes invokes the agent, even
# when the diff adds no new hooks, REST routes or CLI commands.
"watch_paths": ["src/abilities/"],
},
}

Expand Down Expand Up @@ -233,6 +236,7 @@ jobs:
"prev_kind": prev_kind,
"tracking_issue": tracking_issue,
"superseded_rcs": superseded_by_latest.get(rc_tag, []),
"watch_paths": product.get("watch_paths", []),
})

print(json.dumps({"queue": queue, "seeds": seed_actions}))
Expand Down Expand Up @@ -420,10 +424,11 @@ jobs:
# ---------------------------------------------------------------------
# Pre-agent fast-path: when the filtered diff is non-empty but contains
# no new public surface (no new register_rest_route / WP_CLI::add_command
# / apply_filters / do_action calls referencing undocumented symbols),
# post a "no doc changes" marker and skip the Claude agent invocation
# entirely. Catches the common case where a small RC contains only
# internal refactors / JS-only changes / version bumps.
# / apply_filters / do_action calls referencing undocumented symbols, and
# no changed files under the product's `watch_paths`), post a "no doc
# changes" marker and skip the Claude agent invocation entirely.
# Catches the common case where a small RC contains only internal
# refactors / JS-only changes / version bumps.
#
# Risk: misses behavior-only changes that don't introduce new symbols.
# The agent prompt flags these as uncertain anyway; if this becomes a
Expand All @@ -440,11 +445,12 @@ jobs:
BUNDLE_DIR: ${{ github.workspace }}/${{ steps.bundle.outputs.bundle_dir }}
PREV_RELEASE: ${{ matrix.item.prev_release }}
PREV_KIND: ${{ matrix.item.prev_kind }}
WATCH_PATHS: ${{ toJSON(matrix.item.watch_paths) }}
WORKFLOW_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
run: |
set -euo pipefail
python3 - <<'PY'
import glob, os, re, sys
import glob, json, os, re, sys

bundle_dir = os.environ['BUNDLE_DIR']
rc_tag = os.environ['RC_TAG']
Expand All @@ -453,6 +459,8 @@ jobs:
prev_kind = os.environ['PREV_KIND']
product = os.environ['PRODUCT']
run_url = os.environ['WORKFLOW_RUN_URL']
# `null` when the queue entry predates `watch_paths` (e.g. a re-run).
watch_paths = json.loads(os.environ.get('WATCH_PATHS') or '[]') or []

# symbol-index.txt entries are quoted (e.g. 'wpseo_foo'); strip quotes.
symbol_index = set()
Expand All @@ -467,12 +475,22 @@ jobs:
HOOK_RE = re.compile(r"(?:apply_filters|do_action)\s*\(\s*['\"]([A-Za-z_][A-Za-z0-9_]*)['\"]")
ROUTE_RE = re.compile(r"register_rest_route\s*\(")
CLI_RE = re.compile(r"WP_CLI::add_command\s*\(")
DIFF_HEADER_RE = re.compile(r"^diff --git a/(\S+) b/(\S+)")

new_routes, new_cli, new_hook_symbols = [], [], set()
new_routes, new_cli, new_hook_symbols, watched_hits = [], [], set(), set()
diff_files = sorted(glob.glob(os.path.join(bundle_dir, "*", "rc.diff.filtered")))
for path in diff_files:
repo_hits = set()
with open(path) as f:
for line in f:
# Both sides of the header are checked so that deletions and
# renames out of a watched path also count.
header = DIFF_HEADER_RE.match(line)
if header:
for changed in header.groups():
if any(changed.startswith(w) for w in watch_paths):
repo_hits.add(changed)
continue
if not line.startswith('+') or line.startswith('+++'):
continue
body = line[1:]
Expand All @@ -484,8 +502,14 @@ jobs:
sym = m.group(1)
if sym not in symbol_index:
new_hook_symbols.add(sym)

has_public = bool(new_routes or new_cli or new_hook_symbols)
# Tells the agent which watched files triggered it, so it can start
# its triage there instead of rediscovering them from the diff.
if repo_hits:
with open(os.path.join(os.path.dirname(path), "watched-hits.txt"), "w") as wf:
wf.write("\n".join(sorted(repo_hits)) + "\n")
watched_hits |= repo_hits

has_public = bool(new_routes or new_cli or new_hook_symbols or watched_hits)
with open(os.environ['GITHUB_OUTPUT'], 'a') as gho:
gho.write(f"has_public_surface={'true' if has_public else 'false'}\n")

Expand All @@ -499,6 +523,9 @@ jobs:
for l in new_cli[:5]: print(f" {l}")
if new_hook_symbols:
print(f" new (undocumented) hook symbols: {sorted(new_hook_symbols)}")
if watched_hits:
print(f" changed files under watch paths {watch_paths}: {len(watched_hits)}")
for p in sorted(watched_hits)[:10]: print(f" {p}")
sys.exit(0)

# No new public surface — assemble the fast-path comment body.
Expand Down Expand Up @@ -532,7 +559,9 @@ jobs:
"",
"### Outcome: 0 PRs opened (fast-path)",
"",
"The filtered diff contains no new `apply_filters` / `do_action` calls referencing undocumented symbols, no `register_rest_route(...)` registrations, and no `WP_CLI::add_command(...)` calls. Per the deterministic pre-agent fast-path, this RC introduces no public API surface and the Claude agent was not invoked.",
"The filtered diff contains no new `apply_filters` / `do_action` calls referencing undocumented symbols, no `register_rest_route(...)` registrations, no `WP_CLI::add_command(...)` calls"
+ (f", and no changes under the watched paths ({', '.join(f'`{w}`' for w in watch_paths)})" if watch_paths else "")
+ ". Per the deterministic pre-agent fast-path, this RC introduces no public API surface and the Claude agent was not invoked.",
"",
]
if top:
Expand Down Expand Up @@ -602,7 +631,7 @@ jobs:
- `PRODUCT` (e.g. `wordpress-seo`)
- `RC_TAG` (e.g. `27.5-RC1`)
- `DISPLAY_NAME` (e.g. `Yoast SEO`)
- `BUNDLE_DIR` — absolute path to this run's bundle directory; contains `rc.diff.filtered`, `rc.diff.full`, `rc.diff.stat`, `changelog.source`, `symbol-index.txt`, organized as `$BUNDLE_DIR/<source-repo>/...`.
- `BUNDLE_DIR` — absolute path to this run's bundle directory; contains `rc.diff.filtered`, `rc.diff.full`, `rc.diff.stat`, `changelog.source`, `symbol-index.txt`, and — when the RC touched one of the product's watch paths — `watched-hits.txt`, organized as `$BUNDLE_DIR/<source-repo>/...`.
- `TRACKING_ISSUE` — numeric issue id where the run-summary comment must be posted.
- `PREV_RELEASE` — the source-repo tag the diff was computed against. May be a stable release (e.g. `27.5`) or a prior RC of the same base version (e.g. `27.6-RC1`); `PREV_KIND` is `stable` or `rc` accordingly. When it's `rc`, expect the diff to be small (incremental delta vs. the previous RC of this cycle); when it's `stable`, the diff is the full release cycle.
- `WORKFLOW_RUN_URL` — link to this workflow run; include in the PR body for reviewer context.
Expand Down
11 changes: 10 additions & 1 deletion AGENT_MAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,15 @@ No currently-listed product has more than one source repo. If one is ever added
- **Symbol namespaces**: `WP_CLI::add_command` registrations.
- **Typical triggers**: new CLI command; new option on an existing command.

### `yoast-seo-abilities`
- **Products**: wordpress-seo
- **Docs paths**: `docs/features/yoast-seo-abilities/**`
- **Source paths** (wordpress-seo): `src/abilities/**`
- **Ability names**: `yoast-seo/*` (registered in the `yoast-seo` ability category)
- **Typical triggers**: new/renamed/removed ability; changes to an ability's input or output schema, annotations (`readonly`, `destructive`, `idempotent`) or permission checks.
- **Public surface**: abilities are public by design, even though they're registered under `src/abilities/user-interface/` with a capability-checking `permission_callback`. Don't apply the internal path/permission heuristics from "Public vs. internal surface" to them; only `@internal` opts an ability out.
- **Workflow watch path**: any change under `src/abilities/` invokes the agent even without new hooks/routes/CLI commands (see `watch_paths` in `rc-docs-sync.yml`). Many of these changes will be internal refactors — open 0 PRs when the documented ability surface is unchanged.

---

### `apis` (shared, low-frequency)
Expand Down Expand Up @@ -302,7 +311,7 @@ Not every new REST route, hook, or class in a plugin RC is intended to be public

The agent's prompt (`.github/claude-agent/run.md`, Step 1.6) implements this discrimination. Source-repo authors can mark a route or class as internal in either of two ways, in increasing order of authority:

1. **Path/naming heuristics** are picked up automatically. Files under `*-admin-*`, `**/admin/**`, or `**/user-interface/**`, classes named `*_Admin_*` or `*_Internal_*`, and `register_rest_route` calls whose `permission_callback` enforces an admin capability check (`current_user_can('manage_options')` etc.) are treated as internal by default.
1. **Path/naming heuristics** are picked up automatically. Files under `*-admin-*`, `**/admin/**`, or `**/user-interface/**`, classes named `*_Admin_*` or `*_Internal_*`, and `register_rest_route` calls whose `permission_callback` enforces an admin capability check (`current_user_can('manage_options')` etc.) are treated as internal by default. **Exception:** abilities registered via `wp_register_ability` / `wp_register_ability_category` are public by design and are exempt from these heuristics (including the `/user-interface/` path and capability-checking `permission_callback`s) — see the `yoast-seo-abilities` area.

2. **`@internal` PHPDoc annotation** is the unambiguous override. Add it to the registering method's or class's docblock:
```php
Expand Down
Loading