diff --git a/.codex/config.toml b/.codex/config.toml new file mode 100644 index 000000000..32763c2d3 --- /dev/null +++ b/.codex/config.toml @@ -0,0 +1,48 @@ +#:schema https://developers.openai.com/codex/config-schema.json + +# Repository-local instructions for Codex. These complement AGENTS.md; they do +# not select a model or relax command permissions. +developer_instructions = """ +PHRUST CRANELIFT PRODUCTION-REPLACEMENT CONTRACT + +For performance and execution-engine work, externally observable PHP 8.5 +behavior is the compatibility boundary. Internal compatibility with a retired +VM, runtime helper ABI, generic binder, interpreter route, fallback route, or +wrapper API is not a product requirement. + +When the user asks to remove, replace, retire, cut over, eliminate, or make a +legacy execution route unreachable, treat the task as a production architecture +replacement rather than a compatibility migration or a local micro-optimization. +The final change must contain one auditable JSON contract under +`docs/performance/native-replacement-contracts/` and must pass: + +`python3 scripts/verify/native_replacement_guard.py --require-contract --diff-policy` + +For such a task, completion requires the named old route to be deleted or +production-unreachable in the same final change. Do not finish with old and new +production routes coexisting. Do not introduce or retain a production adapter, +wrapper, bridge, dual dispatch, shadow implementation, renamed legacy helper, +feature-gated old route, generic catch-all recovery, or preparation-only +refactor as a substitute for the requested replacement. + +Prefer the smallest COMPLETE vertical replacement, not the smallest diff. +Temporary internal breakage while editing is acceptable; only the final branch +state must build and pass its correctness and performance gates. + +Preserve PHP-visible output, diagnostics, exit status, side-effect order, +references, Copy-on-Write behavior, exceptions, warnings, visibility, magic +methods, hooks, destructors, generators, fibers, and request semantics. Genuine +PHP-semantic slow paths are allowed only when they are runtime-dynamic by PHP +semantics, are named in the replacement contract, and do not re-enter a retired +engine route. + +Before editing an architecture replacement, inventory the exact symbols, paths, +and production call edges to remove. After editing, search the final tree and +prove those targets absent, prove that no new engine fallback category was +introduced, run the required correctness/application gates, and show movement +in clean wall time plus structural counters. Renaming or wrapping the old route +is a failed result. +""" + +[features] +hooks = true diff --git a/.codex/hooks.json b/.codex/hooks.json new file mode 100644 index 000000000..ea296dafb --- /dev/null +++ b/.codex/hooks.json @@ -0,0 +1,29 @@ +{ + "description": "Enforce complete Cranelift production replacements instead of wrapper-based migrations.", + "hooks": { + "UserPromptSubmit": [ + { + "hooks": [ + { + "type": "command", + "command": "python3 \"$(git rev-parse --show-toplevel)/.codex/hooks/native_replacement_prompt.py\"", + "timeout": 10, + "statusMessage": "Applying the native replacement contract" + } + ] + } + ], + "Stop": [ + { + "hooks": [ + { + "type": "command", + "command": "python3 \"$(git rev-parse --show-toplevel)/.codex/hooks/native_replacement_stop.py\"", + "timeout": 180, + "statusMessage": "Checking the native replacement contract" + } + ] + } + ] + } +} diff --git a/.codex/hooks/native_replacement_prompt.py b/.codex/hooks/native_replacement_prompt.py new file mode 100755 index 000000000..e6a63c53e --- /dev/null +++ b/.codex/hooks/native_replacement_prompt.py @@ -0,0 +1,216 @@ +#!/usr/bin/env python3 +"""Activate strict replacement mode for legacy-route removal prompts. + +Codex sends one JSON event on stdin. For matching prompts this hook persists a +small per-session marker under the Git directory and injects additional +developer context. The Stop hook consumes that marker and refuses to finish +until the repository replacement guard passes. +""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import os +import re +import subprocess +import sys +import tempfile +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + + +OPT_OUT = re.compile(r"\[native-replacement:(?:off|disable)\]", re.IGNORECASE) +EXPLICIT = re.compile( + r"\[(?:native|architecture)-replacement(?::on)?\]|" + r"production[ _-]architecture[ _-]replacement|" + r"cranelift[ _-]only[ _-]cutover", + re.IGNORECASE, +) +REPLACEMENT = re.compile( + r"\b(?:remove|delete|eliminate|replace|retire|cut[ _-]?over|shut[ _-]?off|" + r"make\s+.+?unreachable|entfern(?:e|en|t)|ersetz(?:e|en|t)|" + r"eliminier(?:e|en|t)|abschalt(?:en|et)|abl(?:ö|oe)s(?:en|e|t)|" + r"vollst(?:ä|ae)ndig)\b", + re.IGNORECASE, +) +LEGACY_ROUTE = re.compile( + r"\b(?:fallbacks?|wrappers?|adapters?|bridges?|safe[ _-]?paths?|safepaths?|legacy|" + r"old[ _-]?(?:routes?|paths?|apis?)|" + r"alte[nr]?[ _-]?(?:routen?|pfade?|strecken?)|" + r"interpreters?|generic[ _-]?(?:binders?|runtimes?|helpers?|routes?)|" + r"dual[ _-]?(?:dispatch|routes?|paths?)|shadow[ _-]?(?:routes?|paths?))\b", + re.IGNORECASE, +) + + +def read_event() -> dict[str, Any]: + try: + document = json.load(sys.stdin) + except (json.JSONDecodeError, OSError) as error: + raise SystemExit(f"invalid Codex hook input: {error}") from error + if not isinstance(document, dict): + raise SystemExit("invalid Codex hook input: expected an object") + return document + + +def repository_root(cwd: str | None) -> Path | None: + start = Path(cwd or os.getcwd()) + result = subprocess.run( + ["git", "-C", str(start), "rev-parse", "--show-toplevel"], + text=True, + capture_output=True, + check=False, + ) + if result.returncode != 0 or not result.stdout.strip(): + return None + return Path(result.stdout.strip()).resolve() + + +def state_directory(root: Path) -> Path: + result = subprocess.run( + ["git", "-C", str(root), "rev-parse", "--git-path", "codex-native-replacement"], + text=True, + capture_output=True, + check=True, + ) + path = Path(result.stdout.strip()) + return path if path.is_absolute() else root / path + + +def state_path(root: Path, session_id: str) -> Path: + digest = hashlib.sha256(session_id.encode("utf-8")).hexdigest() + return state_directory(root) / f"{digest}.json" + + +def prompt_requests_replacement(prompt: str) -> bool: + if OPT_OUT.search(prompt): + return False + if EXPLICIT.search(prompt): + return True + return bool(REPLACEMENT.search(prompt) and LEGACY_ROUTE.search(prompt)) + + +def write_state(path: Path, document: dict[str, Any]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + fd, temporary = tempfile.mkstemp(prefix=path.name, dir=path.parent) + try: + with os.fdopen(fd, "w", encoding="utf-8") as handle: + json.dump(document, handle, indent=2, sort_keys=True) + handle.write("\n") + os.replace(temporary, path) + finally: + try: + os.unlink(temporary) + except FileNotFoundError: + pass + + +def additional_context() -> str: + return """PHRUST NATIVE REPLACEMENT MODE IS ACTIVE FOR THIS TURN. + +This is a production architecture replacement, not a compatibility migration. +Before changing code, create one concrete JSON contract under +`docs/performance/native-replacement-contracts/` naming the legacy symbols, +paths, and call edges that this turn will remove. The final production tree may +not contain an adapter, wrapper, bridge, dual route, shadow implementation, +renamed legacy helper, feature-gated old path, or generic engine fallback that +recreates the removed route. Internal compatibility with that route is not a +requirement; externally observable PHP 8.5 behavior is. + +Implement the smallest COMPLETE vertical replacement. The turn is incomplete +while old and new production routes coexist or while the change only prepares a +later cutover. Genuine PHP-semantic slow paths must be explicitly named and +reasoned in the contract and must not re-enter the retired engine route. + +Before finishing, run: +`python3 scripts/verify/native_replacement_guard.py --require-contract --diff-policy` +Then run every correctness, application, and performance command listed by the +contract. Do not claim completion until the guard passes and the named old route +is absent from the final production source.""" + + +def activate(event: dict[str, Any]) -> dict[str, Any] | None: + prompt = str(event.get("prompt") or "") + session_id = str(event.get("session_id") or "") + root = repository_root(str(event.get("cwd") or "")) + if not session_id or root is None: + return None + + path = state_path(root, session_id) + if not prompt_requests_replacement(prompt): + path.unlink(missing_ok=True) + return None + + write_state( + path, + { + "schema_version": 1, + "active": True, + "session_id": session_id, + "turn_id": event.get("turn_id"), + "repository_root": str(root), + "prompt_sha256": hashlib.sha256(prompt.encode("utf-8")).hexdigest(), + "activated_at": datetime.now(timezone.utc).isoformat(), + }, + ) + return { + "hookSpecificOutput": { + "hookEventName": "UserPromptSubmit", + "additionalContext": additional_context(), + } + } + + +def self_test() -> int: + positives = ( + "[native-replacement] remove the old runtime bridge", + "Replace the Cranelift fallback route completely", + "Die alte native Wrapper-Strecke vollständig entfernen", + "Cut over the execution architecture and eliminate the generic binder", + "Eliminiere endlich alle Fallbacks", + "Remove the wrappers around the old routes", + ) + negatives = ( + "Fix a parser diagnostic", + "Document the existing native fallback counter", + "[native-replacement:off] replace the Cranelift fallback route", + ) + failures = 0 + for prompt in positives: + if not prompt_requests_replacement(prompt): + print(f"[FAIL] replacement prompt not detected: {prompt}") + failures += 1 + for prompt in negatives: + if prompt_requests_replacement(prompt): + print(f"[FAIL] ordinary prompt misclassified: {prompt}") + failures += 1 + if "smallest COMPLETE vertical replacement" not in additional_context(): + print("[FAIL] replacement context lost the completion rule") + failures += 1 + if failures: + return 1 + print("[ok] native replacement prompt hook") + return 0 + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--self-test", action="store_true") + return parser.parse_args() + + +def main() -> int: + args = parse_args() + if args.self_test: + return self_test() + output = activate(read_event()) + if output is not None: + print(json.dumps(output)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/.codex/hooks/native_replacement_stop.py b/.codex/hooks/native_replacement_stop.py new file mode 100755 index 000000000..ac405d625 --- /dev/null +++ b/.codex/hooks/native_replacement_stop.py @@ -0,0 +1,136 @@ +#!/usr/bin/env python3 +"""Refuse to end an active architecture-replacement turn before its gate passes.""" + +from __future__ import annotations + +import argparse +import json +import subprocess +import sys +from pathlib import Path +from typing import Any + +import native_replacement_prompt as prompt_policy + + +MAX_FEEDBACK_CHARS = 7000 + + +def read_event() -> dict[str, Any]: + try: + document = json.load(sys.stdin) + except (json.JSONDecodeError, OSError) as error: + raise SystemExit(f"invalid Codex hook input: {error}") from error + if not isinstance(document, dict): + raise SystemExit("invalid Codex hook input: expected an object") + return document + + +def load_state(path: Path) -> dict[str, Any] | None: + try: + document = json.loads(path.read_text(encoding="utf-8")) + except FileNotFoundError: + return None + except (OSError, json.JSONDecodeError): + return {"active": True, "invalid": True} + return document if isinstance(document, dict) else {"active": True, "invalid": True} + + +def guard_command(root: Path) -> list[str]: + return [ + sys.executable, + str(root / "scripts/verify/native_replacement_guard.py"), + "--require-contract", + "--diff-policy", + ] + + +def feedback(output: str) -> str: + trimmed = output.strip() + if len(trimmed) > MAX_FEEDBACK_CHARS: + trimmed = trimmed[-MAX_FEEDBACK_CHARS:] + return ( + "The Phrust native replacement contract still fails. Continue the same " + "turn. Do not bypass the gate, weaken the contract, add an allowlist for " + "an engine fallback, or wrap/rename the legacy route. Remove the reported " + "production route and rerun the exact guard.\n\n" + + (trimmed or "The replacement guard exited without diagnostics.") + ) + + +def stop_output(already_continued: bool, reason: str) -> dict[str, Any]: + if already_continued: + return { + "continue": False, + "stopReason": reason, + "systemMessage": "Native replacement validation remains red.", + } + return {"decision": "block", "reason": reason} + + +def enforce(event: dict[str, Any]) -> dict[str, Any] | None: + root = prompt_policy.repository_root(str(event.get("cwd") or "")) + session_id = str(event.get("session_id") or "") + if root is None or not session_id: + return None + state_path = prompt_policy.state_path(root, session_id) + state = load_state(state_path) + if not state or not state.get("active"): + return None + + guard = subprocess.run( + guard_command(root), + cwd=root, + text=True, + capture_output=True, + check=False, + timeout=150, + ) + if guard.returncode == 0: + state_path.unlink(missing_ok=True) + return None + + reason = feedback(guard.stdout + "\n" + guard.stderr) + return stop_output(bool(event.get("stop_hook_active")), reason) + + +def self_test() -> int: + first = stop_output(False, "failure") + second = stop_output(True, "failure") + failures = 0 + if first != {"decision": "block", "reason": "failure"}: + print(f"[FAIL] first Stop response is invalid: {first}") + failures += 1 + if second.get("continue") is not False or "stopReason" not in second: + print(f"[FAIL] repeated Stop response is invalid: {second}") + failures += 1 + if "--require-contract" not in guard_command(Path("/repo")): + print("[FAIL] Stop hook does not require a replacement contract") + failures += 1 + if failures: + return 1 + print("[ok] native replacement Stop hook") + return 0 + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--self-test", action="store_true") + return parser.parse_args() + + +def main() -> int: + args = parse_args() + if args.self_test: + return self_test() + try: + output = enforce(read_event()) + except (OSError, subprocess.SubprocessError) as error: + output = stop_output(False, feedback(f"replacement hook setup failed: {error}")) + if output is not None: + print(json.dumps(output)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/.github/workflows/native-replacement-policy.yml b/.github/workflows/native-replacement-policy.yml new file mode 100644 index 000000000..6d7dda1b3 --- /dev/null +++ b/.github/workflows/native-replacement-policy.yml @@ -0,0 +1,90 @@ +name: native replacement policy + +on: + pull_request: + paths: + - ".codex/**" + - "AGENTS.md" + - "crates/php_jit/src/**" + - "crates/php_vm/src/vm/**" + - "crates/php_runtime/src/**" + - "crates/php_executor/src/**" + - "crates/php_server/src/**" + - "docs/performance/native-replacement-contracts/**" + - "scripts/performance/ratchet_next_prompt.py" + - "scripts/verify/native_replacement_*.py" + - ".github/workflows/native-replacement-policy.yml" + push: + branches: [main] + paths: + - ".codex/**" + - "AGENTS.md" + - "crates/php_jit/src/**" + - "crates/php_vm/src/vm/**" + - "crates/php_runtime/src/**" + - "crates/php_executor/src/**" + - "crates/php_server/src/**" + - "docs/performance/native-replacement-contracts/**" + - "scripts/performance/ratchet_next_prompt.py" + - "scripts/verify/native_replacement_*.py" + - ".github/workflows/native-replacement-policy.yml" + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + policy: + name: reject wrapper-based native changes + runs-on: mayflower-k8s-runners + steps: + - name: Checkout full history + uses: actions/checkout@v7 + with: + fetch-depth: 0 + + - name: Self-test policy and hooks + run: | + python3 -m py_compile \ + scripts/verify/native_replacement_guard.py \ + scripts/verify/native_replacement_git.py \ + scripts/verify/native_replacement_policy.py \ + scripts/performance/ratchet_next_prompt.py \ + .codex/hooks/native_replacement_prompt.py \ + .codex/hooks/native_replacement_stop.py + python3 -m json.tool .codex/hooks.json >/dev/null + python3 - <<'PY' + import tomllib + from pathlib import Path + tomllib.loads(Path(".codex/config.toml").read_text(encoding="utf-8")) + PY + python3 scripts/verify/native_replacement_guard.py --self-test + python3 .codex/hooks/native_replacement_prompt.py --self-test + python3 .codex/hooks/native_replacement_stop.py --self-test + python3 scripts/performance/ratchet_next_prompt.py --self-test + + - name: Check pull-request additions + if: github.event_name == 'pull_request' + env: + BASE_SHA: ${{ github.event.pull_request.base.sha }} + HEAD_SHA: ${{ github.event.pull_request.head.sha }} + run: | + python3 scripts/verify/native_replacement_guard.py \ + --base "$BASE_SHA" \ + --head "$HEAD_SHA" \ + --diff-policy + + - name: Check pushed additions + if: github.event_name == 'push' + env: + BASE_SHA: ${{ github.event.before }} + HEAD_SHA: ${{ github.sha }} + run: | + python3 scripts/verify/native_replacement_guard.py \ + --base "$BASE_SHA" \ + --head "$HEAD_SHA" \ + --diff-policy diff --git a/AGENTS.md b/AGENTS.md index 3f84a1890..939cfaf36 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -113,6 +113,37 @@ Do not automatically update the target PHP version without a new ADR. runnable PHPT fixtures belong under `tests/phpt/generated/`; run artifacts belong under `target/`. +### Cranelift Production Replacements + +- Externally observable PHP 8.5 behavior is the compatibility boundary. + Internal compatibility with a retired VM route, runtime helper ABI, generic + binder, interpreter path, fallback implementation, or wrapper API is not a + product requirement. +- When a task requests removal, replacement, retirement, cutover, or + elimination of an execution route, implement a complete production + replacement. Do not narrow it into a local micro-optimization or a staged + compatibility migration. +- The same final change must delete the named old route or make it + production-unreachable. Old and new production routes may not coexist at + completion. +- Do not substitute an adapter, wrapper, bridge, dual dispatch, shadow + implementation, renamed legacy helper, feature-gated old route, generic + catch-all recovery, or preparation-only refactor for the requested deletion. +- Prefer the smallest **complete vertical replacement**, not the smallest diff. + Temporary internal breakage while editing is acceptable; the final state must + build and pass its gates. +- Genuine PHP-semantic slow paths remain allowed only when runtime PHP behavior + makes them dynamic. Name and justify them explicitly; they must not re-enter + a retired engine route. +- Every architecture-replacement tranche needs a concrete JSON contract under + `docs/performance/native-replacement-contracts/`. The contract names removal + targets, allowed semantic slow paths, validation commands, and expected clean + wall-time plus structural counter movement. +- Before completion, run + `python3 scripts/verify/native_replacement_guard.py --require-contract --diff-policy`. + A renamed or wrapped legacy route is a failed result even when a local metric + improves. + ## Validation Commands - Use the narrowest relevant check while iterating. @@ -130,7 +161,9 @@ Do not automatically update the target PHP version without a new ADR. `just stdlib-coverage`, or the relevant `diff-*` gate before `just verify-stdlib`. - For performance changes, prefer the focused smoke target that owns the - optimization path before `just verify-performance`. + optimization path before `just verify-performance`. Architecture replacements + must also pass the native replacement guard and every command in their + checked-in contract. - For PHPT tooling or baseline changes, run `just verify-phpt`; use `just ci-phpt-smoke` for the CI runner-smoke contract. - Before finishing broad cross-layer changes, run the matching aggregate gate: @@ -183,3 +216,12 @@ edits without gates, and native/JIT reporting while no native code changed. A performance claim needs production Rust changes plus an executable gate (`just profiler-overhead-gate`, the WordPress root benchmark, or a focused fixture with before/after counters). + +For a Cranelift production replacement, also run the checked-in contract's +validation commands and: + +```bash +python3 scripts/verify/native_replacement_guard.py \ + --require-contract \ + --diff-policy +``` diff --git a/docs/performance/native-replacement-contracts/README.md b/docs/performance/native-replacement-contracts/README.md new file mode 100644 index 000000000..62111909b --- /dev/null +++ b/docs/performance/native-replacement-contracts/README.md @@ -0,0 +1,83 @@ +# Native production replacement contracts + +This directory is the auditable control plane for Cranelift architecture +replacements. A contract is required when a task retires an existing production +execution route. It prevents a request to delete a fallback from being completed +as a wrapper, adapter, dual route, or renamed copy of the same route. + +The compatibility boundary is externally observable PHP 8.5 behavior. Internal +compatibility with a retired VM path, helper ABI, generic binder, interpreter +route, or fallback implementation is not a requirement. + +## Required workflow + +1. Create one concrete `*.json` contract for the tranche before changing the + production path. +2. Name the exact legacy symbols, paths, and call edges that must disappear. +3. Name the genuinely runtime-dynamic PHP semantic slow paths that remain. Use + an empty list when none remain; never invent a slow path to satisfy the schema. +4. Implement the smallest **complete vertical replacement**. Do not leave old + and new production routes coexisting. +5. Run the replacement guard, then every correctness, application, and + performance command listed in the contract. +6. Keep the contract after merge. It is the permanent deletion and acceptance + record, not a temporary task note. + +```bash +python3 scripts/verify/native_replacement_guard.py \ + --require-contract \ + --diff-policy +``` + +Codex project hooks activate this mode automatically whenever a prompt combines +an explicit removal/replacement instruction with a fallback, wrapper, adapter, +bridge, legacy route, interpreter route, generic binder, or comparable old +execution path. Add `[native-replacement]` to activate it unambiguously. Use +`[native-replacement:off]` only for discussion or analysis that does not request +a production cutover. + +## Allowed PHP-semantic slow paths + +A slow path is allowed when the dynamic condition is observable PHP semantics +and cannot be decided when the native image is published. Typical examples are: + +- a callable or class target created at runtime; +- magic methods, property hooks, visibility failures, and user callbacks; +- references and Copy-on-Write separation; +- array-key coercion, hash growth, and genuinely mixed layouts; +- runtime `eval`, conditional declarations, and dynamic include targets; +- exception, warning, destructor, generator, and fiber state transitions. + +These paths must be typed and out of line. They must not re-enter a retired +interpreter, VM executor, generic stable-call binder, or old runtime ABI. The +contract list may be empty when the replacement needs no semantic slow path. + +## Forbidden engine fallbacks + +The following are not PHP semantic slow paths and cannot be used to satisfy a +replacement task: + +- a wrapper or adapter from the new API to the old implementation; +- dual dispatch, shadow execution, or a feature-gated old production route; +- a renamed legacy helper or generic catch-all recovery path; +- interpreter or old-VM re-entry after a native guard failure; +- generic argument binding for a statically known target; +- repeated ABI, helper-ID, callsite, or class-table validation after publication; +- a complete generic trampoline embedded under every optimized operation; +- preparation-only refactors that defer deletion to a later tranche. + +## Contract schema + +Copy `template.example.json` and replace every example value. Contracts with +placeholders, no removal target, no application/correctness gate, or no expected +structural metric movement are rejected. + +`diff_allowlist` is deliberately narrow. It permits a source line matching the +regular expression only under the listed path prefixes and only with a concrete +reason. It is for genuine PHP-semantic slow paths, not for retaining an engine +fallback. Adding a broad expression such as `fallback` defeats the contract and +must be rejected in review. + +The guard proves source-level absence for the named symbols and paths. The +contract's `required_validation` commands must supply the stronger runtime, +correctness, and performance evidence for that tranche. diff --git a/docs/performance/native-replacement-contracts/template.example.json b/docs/performance/native-replacement-contracts/template.example.json new file mode 100644 index 000000000..cbd21f0a5 --- /dev/null +++ b/docs/performance/native-replacement-contracts/template.example.json @@ -0,0 +1,51 @@ +{ + "schema_version": 1, + "mode": "production-architecture-replacement", + "title": "Replace the stable-call runtime bridge with direct native calls", + "target_architecture": "Published stable callsites invoke trusted native entries with a prepared binder plan and never enter the retired generic runtime bridge.", + "production_scope": [ + "crates/php_jit/src/", + "crates/php_vm/src/vm/" + ], + "remove": { + "symbols": [ + "legacy_runtime_call_bridge" + ], + "paths": [], + "call_edges": [ + { + "caller": "compiled_stable_callsite", + "callee": "legacy_runtime_call_bridge" + } + ] + }, + "allowed_php_semantic_slow_paths": [], + "diff_allowlist": [], + "required_validation": [ + "python3 scripts/verify/native_replacement_guard.py --require-contract --diff-policy", + "nix develop -c just runtime-semantics-diff", + "nix develop -c just wordpress-root-tranche-gate target/performance/wordpress-root/baseline.json" + ], + "expected_metric_movement": [ + { + "metric": "warm_c1_p50_ms", + "direction": "decrease", + "reason": "Stable calls stop crossing the generic runtime bridge." + }, + { + "metric": "native_call_runtime_mediated", + "direction": "decrease", + "reason": "Published stable callsites invoke their native target directly." + }, + { + "metric": "new_engine_fallback_categories", + "direction": "zero", + "reason": "The replacement must not introduce another production fallback classification." + } + ], + "acceptance": { + "old_route_production_reachable": false, + "new_engine_fallback_categories": 0, + "external_php_behavior_preserved": true + } +} diff --git a/scripts/performance/ratchet_next_prompt.py b/scripts/performance/ratchet_next_prompt.py index 7e6a8f0c6..b5ae1b7b4 100755 --- a/scripts/performance/ratchet_next_prompt.py +++ b/scripts/performance/ratchet_next_prompt.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Generate the next focused performance prompt from ratchet evidence.""" +"""Generate the next complete performance architecture tranche from evidence.""" from __future__ import annotations @@ -28,7 +28,11 @@ def parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser() parser.add_argument("--ratchet", action="append", type=Path, default=[]) parser.add_argument("--compare", type=Path) - parser.add_argument("--out", type=Path, default=ROOT / "target/performance/ratchet/next-performance-prompt.md") + parser.add_argument( + "--out", + type=Path, + default=ROOT / "target/performance/ratchet/next-performance-prompt.md", + ) parser.add_argument("--self-test", action="store_true") return parser.parse_args() @@ -51,11 +55,39 @@ def scenario_candidates(reports: list[dict[str, Any]]) -> list[dict[str, Any]]: return rows -def classify(reports: list[dict[str, Any]], compare: dict[str, Any] | None) -> tuple[str, dict[str, Any], str]: +def is_new_correctness_regression(item: dict[str, Any]) -> bool: + """Return true only when evidence identifies a new branch regression. + + A known or unclassified pre-existing failure must not redirect a native + architecture tranche into unrelated compatibility work. Producers can mark + a regression directly, provide a pass-like baseline, or report a pass-to- + fail status transition. + """ + + if item.get("correctness") != "fail": + return False + if item.get("correctness_regression") is True or item.get("regression") is True: + return True + baseline = item.get("baseline_correctness") + if baseline is True or ( + isinstance(baseline, str) and baseline.lower() in {"pass", "ok", "green"} + ): + return True + transition = str(item.get("correctness_transition", "")).lower().replace(" ", "") + return transition in {"pass->fail", "ok->fail", "green->fail"} + + +def classify( + reports: list[dict[str, Any]], compare: dict[str, Any] | None +) -> tuple[str, dict[str, Any], str]: scenarios = scenario_candidates(reports) - failing = [item for item in scenarios if item.get("correctness") == "fail"] - if failing: - return "correctness-blocker", failing[0], "correctness failure outranks speed work" + regressions = [item for item in scenarios if is_new_correctness_regression(item)] + if regressions: + return ( + "correctness-blocker", + regressions[0], + "a correctness regression introduced by the current change outranks speed work", + ) if not scenarios: return "measurement-gap", {}, "no ratchet reports were available" if compare is not None: @@ -64,17 +96,27 @@ def classify(reports: list[dict[str, Any]], compare: dict[str, Any] | None) -> t row = hard[0] metric = str(row.get("metric", "")) if metric.startswith("counter.") or "instruction" in metric: - return "counter-instruction-regression", row, "deterministic counter regression" + return ( + "counter-instruction-regression", + row, + "deterministic counter regression", + ) scored: list[tuple[float, str, dict[str, Any], str]] = [] for item in scenarios: metrics = item.get("metrics") if isinstance(item.get("metrics"), dict) else {} group = str(item.get("group", "")) - external = float(metrics.get("external_wall_ms.p50", metrics.get("request_total_ms.p50", 0.0))) + external = float( + metrics.get("external_wall_ms.p50", metrics.get("request_total_ms.p50", 0.0)) + ) startup = float(metrics.get("startup_external_ms.p50", 0.0)) compile_ms = float(metrics.get("compile_total_ms.p50", 0.0)) execute = float(metrics.get("execute_ms.p50", 0.0)) ttfb = float(metrics.get("ttfb_ms.p95", metrics.get("ttfb_ms.p50", 0.0))) - counters = item.get("counter_highlights") if isinstance(item.get("counter_highlights"), dict) else {} + counters = ( + item.get("counter_highlights") + if isinstance(item.get("counter_highlights"), dict) + else {} + ) instruction = float( metrics.get( "counter.instructions_executed", @@ -82,7 +124,9 @@ def classify(reports: list[dict[str, Any]], compare: dict[str, Any] | None) -> t ) ) if group == "server" and ttfb > 0: - scored.append((ttfb, "server-responsiveness", item, "server TTFB or tail latency dominates")) + scored.append( + (ttfb, "server-responsiveness", item, "server TTFB or tail latency dominates") + ) if external > 0 and startup / external >= 0.35: scored.append((startup, "startup", item, "startup is a large share of external wall time")) if compile_ms >= execute and compile_ms > 0: @@ -99,9 +143,19 @@ def classify(reports: list[dict[str, Any]], compare: dict[str, Any] | None) -> t return category, item, reason -def prompt(category: str, evidence: dict[str, Any], reason: str, inputs: list[Path], compare: Path | None) -> str: +def prompt( + category: str, + evidence: dict[str, Any], + reason: str, + inputs: list[Path], + compare: Path | None, +) -> str: metrics = evidence.get("metrics") if isinstance(evidence.get("metrics"), dict) else {} - counters = evidence.get("counter_highlights") if isinstance(evidence.get("counter_highlights"), dict) else {} + counters = ( + evidence.get("counter_highlights") + if isinstance(evidence.get("counter_highlights"), dict) + else {} + ) scenario = evidence.get("scenario_id") or evidence.get("id") or "unknown" metric_lines = [] for key in ( @@ -123,8 +177,47 @@ def prompt(category: str, evidence: dict[str, Any], reason: str, inputs: list[Pa metric_lines.append("- No decisive metric was present; improve measurement first.") if not counter_lines: counter_lines.append("- No counter highlights were present.") + + replacement = category not in {"measurement-gap", "correctness-blocker"} + mode = ( + "MODE: [native-replacement]\n\n" + "This is a complete production architecture tranche, not a compatibility migration." + if replacement + else "MODE: evidence or correctness repair before architecture replacement" + ) + implementation_steps = ( + """1. Reproduce the baseline and preserve the raw artifacts under `target/performance/`. +2. Map the shared cost block and write a deletion manifest: legacy symbols, paths, callers, and production call edges. +3. Create one concrete contract under `docs/performance/native-replacement-contracts/` with the target architecture, allowed PHP-semantic slow paths, validation commands, and expected wall-time plus structural movement. +4. Implement the smallest **complete vertical replacement**. Delete or make the named old production route unreachable in this same change. +5. Run the native replacement guard, then the contract's PHP correctness and application gates. +6. Run ratchet current and compare with instrumentation-free timing separated from diagnostics. +7. Keep the tranche only when clean wall time and the shared structural counters improve together.""" + if replacement + else """1. Reproduce and classify the missing evidence or new correctness regression. +2. Repair only the measurement or behavior needed to make the next architecture decision reliable. +3. Do not add a production wrapper, fallback, or compatibility route as part of this repair. +4. Regenerate the ratchet evidence and then select the architecture tranche.""" + ) + replacement_validation = ( + "python3 scripts/verify/native_replacement_guard.py --require-contract --diff-policy\n" + if replacement + else "" + ) + replacement_acceptance = ( + """- Every removal target in the changed contract is absent or production-unreachable. +- No adapter, wrapper, bridge, dual route, shadow implementation, renamed legacy helper, or feature-gated old route recreates it. +- No new engine-fallback category is introduced; only explicitly contracted PHP-semantic slow paths remain. +- Clean wall time improves together with at least one shared structural metric such as helper boundaries, value traffic, call traffic, allocations, or RSS. +""" + if replacement + else "" + ) + return f"""# Codex Performance Task: {category} +{mode} + ## Problem evidence - Scenario: `{scenario}` @@ -139,30 +232,31 @@ def prompt(category: str, evidence: dict[str, Any], reason: str, inputs: list[Pa {chr(10).join(artifact_lines) if artifact_lines else "- No artifact inputs were available."} -## Hypothesis +## Architecture decision -The next highest-value task is `{category}` because the ratchet evidence points there. Keep the hypothesis narrow and update it only from measured artifacts. +The ratchet selects the shared cost class `{category}`. It does not authorize a +one-helper micro-optimization or preserving the current internal route. For a +replacement tranche, externally observable PHP behavior is the compatibility +boundary; compatibility with the retired internal implementation is not a goal. ## Required implementation constraints -- Preserve stdout, stderr, exit status, diagnostics, PHP-visible behavior, and request semantics. -- Do not globally disable fast paths to make one scenario faster. -- Do not claim a speedup without before/after artifacts under `target/performance/`. +- Preserve stdout, stderr, exit status, diagnostics, PHP-visible behavior, side-effect order, and request semantics. +- Do not globally disable a PHP semantic path to make one scenario faster. +- Do not finish with old and new production routes coexisting. +- Do not satisfy the task with a wrapper, adapter, bridge, dual dispatch, shadow implementation, renamed old helper, or preparation-only refactor. +- Do not claim a speedup without clean before/after artifacts; diagnostic timing is ranking evidence only. - Keep raw measurements under `target/performance/` and do not commit them. +- A known unrelated pre-existing correctness failure does not redirect this tranche. A correctness regression introduced by the current change does block completion. ## Steps -1. Reproduce the baseline. -2. Add or improve one focused measurement if needed. -3. Implement the smallest fix. -4. Run correctness gates. -5. Run ratchet current and compare. -6. Keep the change only if metrics improve. +{implementation_steps} ## Validation commands ```bash -nix develop -c just perf-ratchet-baseline +{replacement_validation}nix develop -c just perf-ratchet-baseline nix develop -c just perf-ratchet-current nix develop -c just perf-ratchet-compare nix develop -c just perf-ratchet-next-prompt @@ -170,18 +264,67 @@ def prompt(category: str, evidence: dict[str, Any], reason: str, inputs: list[Pa ## Acceptance criteria -- The targeted metric improves without a correctness regression. +{replacement_acceptance}- No correctness regression is introduced by the current change. - The comparator reports no hard regressions. -- The regenerated next prompt no longer selects the same shallow bottleneck unless a deeper issue remains. +- The regenerated next prompt no longer selects the same shallow bottleneck unless a deeper part of the same shared cost block remains. """ def run_self_test() -> int: for category in CATEGORIES: - text = prompt(category, {"id": "self", "metrics": {"external_wall_ms.p50": 1.0}}, "self-test", [], None) + text = prompt( + category, + {"id": "self", "metrics": {"external_wall_ms.p50": 1.0}}, + "self-test", + [], + None, + ) assert f"Codex Performance Task: {category}" in text + assert "Implement the smallest fix." not in text + category, _, _ = classify([], None) assert category == "measurement-gap" + + known_failure = { + "scenarios": [ + { + "id": "known", + "correctness": "fail", + "correctness_regression": False, + "metrics": {"execute_ms.p50": 10.0}, + } + ] + } + category, _, _ = classify([known_failure], None) + assert category == "vm-execution" + + new_failure = { + "scenarios": [ + { + "id": "new", + "correctness": "fail", + "correctness_regression": True, + "metrics": {"execute_ms.p50": 10.0}, + } + ] + } + category, _, _ = classify([new_failure], None) + assert category == "correctness-blocker" + + architecture = prompt( + "vm-execution", + {"id": "self", "metrics": {"execute_ms.p50": 10.0}}, + "self-test", + [], + None, + ) + assert "[native-replacement]" in architecture + assert "smallest **complete vertical replacement**" in architecture + assert "--require-contract --diff-policy" in architecture + + measurement = prompt("measurement-gap", {"id": "self"}, "self-test", [], None) + assert "MODE: [native-replacement]" not in measurement + print("[pass] ratchet_next_prompt self-test") return 0 @@ -190,7 +333,11 @@ def main() -> int: args = parse_args() if args.self_test: return run_self_test() - reports = [load_json(path if path.is_absolute() else ROOT / path) for path in args.ratchet if (path if path.is_absolute() else ROOT / path).is_file()] + reports = [ + load_json(path if path.is_absolute() else ROOT / path) + for path in args.ratchet + if (path if path.is_absolute() else ROOT / path).is_file() + ] compare = load_optional(args.compare) category, evidence, reason = classify(reports, compare) if category not in CATEGORIES: diff --git a/scripts/verify/native_replacement_git.py b/scripts/verify/native_replacement_git.py new file mode 100755 index 000000000..502135311 --- /dev/null +++ b/scripts/verify/native_replacement_git.py @@ -0,0 +1,137 @@ +#!/usr/bin/env python3 +"""Git inspection helpers for native replacement policy checks.""" + +from __future__ import annotations + +import os +import subprocess +from pathlib import Path +from typing import Iterable, Sequence + +from native_replacement_policy import ( + AddedLine, + GuardError, + ROOT, + SENSITIVE_PREFIXES, + is_sensitive_source, +) + + +def git(*arguments: str, check: bool = True) -> subprocess.CompletedProcess[str]: + return subprocess.run( + ["git", *arguments], + cwd=ROOT, + env={**os.environ, "LC_ALL": "C", "TZ": "UTC"}, + text=True, + capture_output=True, + check=check, + ) + + +def resolve_base(explicit: str | None, head: str | None) -> str: + if explicit: + probe = git("rev-parse", "--verify", f"{explicit}^{{commit}}", check=False) + if probe.returncode != 0: + raise GuardError(f"cannot resolve base ref {explicit!r}: {probe.stderr.strip()}") + return explicit + configured = os.environ.get("PHRUST_NATIVE_REPLACEMENT_BASE") + if configured: + return resolve_base(configured, head) + current = head or "HEAD" + for candidate in ("origin/main", "main"): + probe = git("merge-base", current, candidate, check=False) + if probe.returncode == 0 and probe.stdout.strip(): + return probe.stdout.strip() + raise GuardError( + "cannot resolve a comparison base; pass --base or set " + "PHRUST_NATIVE_REPLACEMENT_BASE" + ) + + +def diff_arguments(base: str, head: str | None) -> list[str]: + return [base, head] if head else [base] + + +def changed_paths(base: str, head: str | None) -> set[str]: + result = git("diff", "--name-status", "--find-renames", *diff_arguments(base, head)) + paths: set[str] = set() + for raw in result.stdout.splitlines(): + fields = raw.split("\t") + if not fields: + continue + if fields[0].startswith(("R", "C")) and len(fields) >= 3: + paths.add(fields[2]) + elif len(fields) >= 2: + paths.add(fields[1]) + if head is None: + untracked = git("ls-files", "--others", "--exclude-standard") + paths.update(line for line in untracked.stdout.splitlines() if line) + return paths + + +def added_lines(base: str, head: str | None, paths: Iterable[str]) -> list[AddedLine]: + records: list[AddedLine] = [] + tracked = sorted(path for path in paths if (ROOT / path).exists()) + if tracked: + result = git( + "diff", + "--no-ext-diff", + "--unified=0", + "--no-color", + *diff_arguments(base, head), + "--", + *tracked, + ) + current_path = "" + for line in result.stdout.splitlines(): + if line.startswith("+++ b/"): + current_path = line[6:] + elif line.startswith("+") and not line.startswith("+++") and current_path: + records.append(AddedLine(current_path, line[1:])) + if head is None: + untracked = set( + line + for line in git("ls-files", "--others", "--exclude-standard").stdout.splitlines() + if line + ) + for path in sorted(untracked.intersection(paths)): + candidate = ROOT / path + if not candidate.is_file(): + continue + try: + text = candidate.read_text(encoding="utf-8") + except UnicodeDecodeError: + continue + records.extend(AddedLine(path, line) for line in text.splitlines()) + return records + + +def symbol_exists_at_ref(symbol: str, ref: str) -> bool: + result = git( + "grep", + "-F", + "-n", + "-e", + symbol, + ref, + "--", + *SENSITIVE_PREFIXES, + check=False, + ) + return result.returncode == 0 + + +def path_exists_at_ref(relative: str, ref: str) -> bool: + return git("cat-file", "-e", f"{ref}:{relative}", check=False).returncode == 0 + + +def repository_source_files(scope: Sequence[str]) -> list[Path]: + tracked = git("ls-files", "--cached", "--others", "--exclude-standard") + files: list[Path] = [] + for relative in tracked.stdout.splitlines(): + if not relative or not relative.startswith(tuple(scope)) or not is_sensitive_source(relative): + continue + path = ROOT / relative + if path.is_file(): + files.append(path) + return files diff --git a/scripts/verify/native_replacement_guard.py b/scripts/verify/native_replacement_guard.py new file mode 100755 index 000000000..0e3695698 --- /dev/null +++ b/scripts/verify/native_replacement_guard.py @@ -0,0 +1,300 @@ +#!/usr/bin/env python3 +"""Reject wrapper-based Cranelift changes and verify replacement contracts. + +``--diff-policy`` rejects newly added compatibility, fallback, adapter, and +interpreter-reentry machinery in production execution sources unless a changed +contract contains a narrow semantic allowlist. ``--require-contract`` also +proves that named legacy targets existed at the base and are absent from the +final production tree. +""" + +from __future__ import annotations + +import argparse +import json +import re +import subprocess +import sys +from pathlib import Path +from typing import Sequence + +from native_replacement_git import ( + added_lines, + changed_paths, + path_exists_at_ref, + repository_source_files, + resolve_base, + symbol_exists_at_ref, +) +from native_replacement_policy import ( + CONTRACT_PREFIX, + ROOT, + SENSITIVE_PREFIXES, + SUSPICIOUS_ADDITIONS, + AddedLine, + Contract, + DiffAllow, + GuardError, + is_comment_line, + is_contract_path, + is_sensitive_source, + load_contract, + valid_contract, + validate_contract_document, +) + + +def enforce_contract(contract: Contract, changed: set[str], base: str) -> list[str]: + failures: list[str] = [] + scope = tuple(contract.document["production_scope"]) + removal = contract.document["remove"] + if not any(path.startswith(scope) and is_sensitive_source(path) for path in changed): + failures.append( + f"{contract.path.relative_to(ROOT)} changes no production source in its declared scope" + ) + + for relative in removal.get("paths", []): + if not path_exists_at_ref(relative, base): + failures.append(f"legacy path did not exist at base {base}: {relative}") + if (ROOT / relative).exists(): + failures.append(f"legacy path still exists: {relative}") + + symbols = removal.get("symbols", []) + hits: dict[str, list[str]] = {symbol: [] for symbol in symbols} + for symbol in symbols: + if not symbol_exists_at_ref(symbol, base): + failures.append( + f"legacy symbol {symbol!r} did not exist in native production source at base {base}" + ) + for path in repository_source_files(SENSITIVE_PREFIXES): + try: + lines = path.read_text(encoding="utf-8").splitlines() + except UnicodeDecodeError: + continue + relative = path.relative_to(ROOT).as_posix() + for symbol in symbols: + for line_number, line in enumerate(lines, start=1): + if symbol in line and not is_comment_line(line): + hits[symbol].append(f"{relative}:{line_number}: {line.strip()}") + if len(hits[symbol]) >= 5: + break + for symbol, locations in hits.items(): + if locations: + failures.append( + f"legacy symbol {symbol!r} remains in production source:\n" + + "\n".join(f" {location}" for location in locations) + ) + return failures + + +def all_allowlist(contracts: Sequence[Contract]) -> tuple[DiffAllow, ...]: + return tuple(item for contract in contracts for item in contract.allowlist) + + +def scan_diff_policy( + records: Sequence[AddedLine], allowlist: Sequence[DiffAllow] +) -> list[str]: + failures: list[str] = [] + for record in records: + if ( + not is_sensitive_source(record.path) + or not record.line.strip() + or is_comment_line(record.line) + ): + continue + for label, pattern in SUSPICIOUS_ADDITIONS: + if not pattern.search(record.line): + continue + if any(rule.matches(record) for rule in allowlist): + break + failures.append( + f"{record.path}: added {label} without a contract allowlist: " + f"{record.line.strip()}" + ) + break + return failures + + +def contract_paths_from_diff(changed: set[str]) -> list[Path]: + return sorted( + ROOT / path + for path in changed + if is_contract_path(path) and (ROOT / path).is_file() + ) + + +def run_guard(args: argparse.Namespace) -> int: + base = resolve_base(args.base, args.head) + changed = changed_paths(base, args.head) + paths = [path if path.is_absolute() else ROOT / path for path in args.contract] + if not paths: + paths = contract_paths_from_diff(changed) + + contracts: list[Contract] = [] + failures: list[str] = [] + for path in paths: + try: + contracts.append(load_contract(path)) + except GuardError as error: + failures.append(str(error)) + + if args.require_contract and not contracts: + failures.append( + "architecture-replacement mode requires a changed contract under " + f"{CONTRACT_PREFIX}" + ) + for contract in contracts: + failures.extend(enforce_contract(contract, changed, base)) + + if args.require_contract and contracts: + uncovered = sorted( + path + for path in changed + if is_sensitive_source(path) + and not any( + path.startswith(tuple(contract.document["production_scope"])) + for contract in contracts + ) + ) + if uncovered: + failures.append( + "changed native production sources are outside every replacement " + "contract scope: " + ", ".join(uncovered) + ) + + if args.diff_policy or (not args.require_contract and not args.contract): + failures.extend( + scan_diff_policy(added_lines(base, args.head, changed), all_allowlist(contracts)) + ) + + if failures: + print("[fail] native replacement guard:", file=sys.stderr) + for failure in failures: + for line in failure.splitlines(): + print(f" - {line}", file=sys.stderr) + print( + " fix: remove the legacy production route rather than wrapping it; " + "document only a narrow, genuine PHP-semantic slow path.", + file=sys.stderr, + ) + return 1 + print( + "[ok] native replacement guard: " + f"base={base} changed={len(changed)} contracts={len(contracts)}" + ) + return 0 + + +def self_test() -> int: + failures = 0 + valid_failures, allowlist = validate_contract_document(valid_contract()) + if valid_failures or len(allowlist) != 1: + print(f"[FAIL] valid contract rejected: {valid_failures}") + failures += 1 + else: + print("[ok] valid replacement contract") + + invalid = valid_contract() + invalid["remove"] = {"symbols": [], "paths": [], "call_edges": []} + invalid["acceptance"] = {"old_route_production_reachable": True} + invalid_failures, _ = validate_contract_document(invalid) + if len(invalid_failures) < 3: + print(f"[FAIL] invalid contract was under-rejected: {invalid_failures}") + failures += 1 + else: + print("[ok] invalid replacement contract rejected") + + records = [ + AddedLine("crates/php_vm/src/vm/calls.rs", "fn compatibility_wrapper() {}"), + AddedLine("crates/php_vm/src/vm/calls.rs", "fn direct_call() {}"), + ] + policy_failures = scan_diff_policy(records, ()) + if not any("compatibility" in failure for failure in policy_failures): + print(f"[FAIL] wrapper addition escaped policy: {policy_failures}") + failures += 1 + else: + print("[ok] wrapper addition rejected") + + allowed = DiffAllow( + re.compile(r"dynamic_callable_slow", re.IGNORECASE), + ("crates/php_vm/src/vm/",), + "explicit PHP dynamic callable resolver", + ) + semantic = [ + AddedLine( + "crates/php_vm/src/vm/calls.rs", + "fn dynamic_callable_slow_fallback() {}", + ) + ] + if scan_diff_policy(semantic, (allowed,)): + print("[FAIL] allowlisted semantic slow path rejected") + failures += 1 + else: + print("[ok] narrow semantic slow-path allowlist") + + broad = valid_contract() + broad["diff_allowlist"][0]["pattern"] = "fallback" + broad_failures, _ = validate_contract_document(broad) + if not any("broadly allow" in failure for failure in broad_failures): + print(f"[FAIL] broad fallback allowlist escaped validation: {broad_failures}") + failures += 1 + else: + print("[ok] broad fallback allowlist rejected") + + comment = AddedLine( + "crates/php_vm/src/vm/calls.rs", + "// The optimized route has no generic fallback.", + ) + if scan_diff_policy([comment], ()): + print("[FAIL] explanatory source comment was treated as production code") + failures += 1 + else: + print("[ok] source comments do not trigger the diff policy") + + if is_sensitive_source("crates/php_jit/src/cranelift_lowering/tests.rs"): + print("[FAIL] Rust test module was treated as production source") + failures += 1 + else: + print("[ok] Rust test modules are excluded") + + template = ROOT / CONTRACT_PREFIX / "template.example.json" + if template.is_file(): + try: + document = json.loads(template.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as error: + print(f"[FAIL] contract template is unreadable: {error}") + failures += 1 + else: + template_failures, _ = validate_contract_document(document) + if template_failures: + print(f"[FAIL] contract template is invalid: {template_failures}") + failures += 1 + else: + print("[ok] checked-in replacement contract template") + return 1 if failures else 0 + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--base") + parser.add_argument("--head") + parser.add_argument("--contract", action="append", type=Path, default=[]) + parser.add_argument("--require-contract", action="store_true") + parser.add_argument("--diff-policy", action="store_true") + parser.add_argument("--self-test", action="store_true") + return parser.parse_args() + + +def main() -> int: + args = parse_args() + if args.self_test: + return self_test() + try: + return run_guard(args) + except (GuardError, OSError, subprocess.CalledProcessError) as error: + print(f"[fail] native replacement guard setup: {error}", file=sys.stderr) + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/verify/native_replacement_policy.py b/scripts/verify/native_replacement_policy.py new file mode 100755 index 000000000..16e8cce82 --- /dev/null +++ b/scripts/verify/native_replacement_policy.py @@ -0,0 +1,384 @@ +#!/usr/bin/env python3 +"""Shared policy and contract validation for native architecture replacements.""" + +from __future__ import annotations + +import json +import re +from dataclasses import dataclass +from pathlib import Path +from typing import Any + + +ROOT = Path(__file__).resolve().parents[2] +CONTRACT_PREFIX = "docs/performance/native-replacement-contracts/" +SENSITIVE_PREFIXES = ( + "crates/php_jit/src/", + "crates/php_vm/src/vm/", + "crates/php_runtime/src/", + "crates/php_executor/src/", + "crates/php_server/src/", +) +SOURCE_SUFFIXES = {".rs", ".c", ".cc", ".cpp", ".h", ".hpp"} +PLACEHOLDER = re.compile( + r"\b(?:TODO|TBD|FIXME|FILL[ _-]?ME|REPLACE[ _-]?ME|EXAMPLE[ _-]?ONLY)\b", + re.IGNORECASE, +) +SUSPICIOUS_ADDITIONS: tuple[tuple[str, re.Pattern[str]], ...] = ( + ( + "fallback", + re.compile( + r"(? bool: + return any(record.path.startswith(prefix) for prefix in self.paths) and bool( + self.pattern.search(record.line) + ) + + +@dataclass(frozen=True) +class Contract: + path: Path + document: dict[str, Any] + allowlist: tuple[DiffAllow, ...] + + +class GuardError(RuntimeError): + """Raised for a deterministic policy failure.""" + + +def is_sensitive_source(path: str) -> bool: + candidate = Path(path) + if candidate.suffix not in SOURCE_SUFFIXES or not path.startswith(SENSITIVE_PREFIXES): + return False + if candidate.name in {"test.rs", "tests.rs"}: + return False + return not any( + marker in path + for marker in ("/tests/", "/testdata/", "/fixtures/", "/benches/", "/examples/") + ) + + +def is_comment_line(line: str) -> bool: + return line.lstrip().startswith(("//", "/*", "*", "#")) + + +def is_contract_path(path: str) -> bool: + return ( + path.startswith(CONTRACT_PREFIX) + and path.endswith(".json") + and not path.endswith(".example.json") + ) + + +def require_text(document: dict[str, Any], key: str, failures: list[str]) -> str: + value = document.get(key) + if not isinstance(value, str) or len(value.strip()) < 12: + failures.append(f"{key} must be a descriptive string of at least 12 characters") + return "" + if PLACEHOLDER.search(value): + failures.append(f"{key} contains a placeholder") + return value.strip() + + +def string_list( + value: Any, + label: str, + failures: list[str], + *, + minimum: int = 0, +) -> list[str]: + if not isinstance(value, list) or not all( + isinstance(item, str) and item.strip() for item in value + ): + failures.append(f"{label} must be a list of non-empty strings") + return [] + items = [item.strip() for item in value] + if len(items) < minimum: + failures.append(f"{label} must contain at least {minimum} item(s)") + if any(PLACEHOLDER.search(item) for item in items): + failures.append(f"{label} contains a placeholder") + return items + + +def validate_relative_path(value: str, label: str, failures: list[str]) -> None: + path = Path(value) + if path.is_absolute() or ".." in path.parts: + failures.append(f"{label} must be a repository-relative path: {value!r}") + + +def parse_allowlist(value: Any, failures: list[str]) -> tuple[DiffAllow, ...]: + if value is None: + return () + if not isinstance(value, list): + failures.append("diff_allowlist must be a list") + return () + parsed: list[DiffAllow] = [] + for index, item in enumerate(value): + label = f"diff_allowlist[{index}]" + if not isinstance(item, dict): + failures.append(f"{label} must be an object") + continue + pattern = item.get("pattern") + reason = item.get("reason") + if not isinstance(pattern, str) or not pattern: + failures.append(f"{label}.pattern must be a non-empty regex") + continue + if not isinstance(reason, str) or len(reason.strip()) < 20: + failures.append(f"{label}.reason must explain the exception") + continue + paths = string_list(item.get("paths"), f"{label}.paths", failures, minimum=1) + for path in paths: + validate_relative_path(path, f"{label}.paths", failures) + if pattern.strip() in ALLOWLIST_WILDCARDS or ALLOWLIST_ENGINE_ESCAPE.search(pattern): + failures.append( + f"{label}.pattern may not broadly allow an engine fallback or compatibility term" + ) + continue + if any(not path.startswith(SENSITIVE_PREFIXES) for path in paths): + failures.append(f"{label}.paths must stay inside native execution source roots") + continue + try: + compiled = re.compile(pattern, re.IGNORECASE) + except re.error as error: + failures.append(f"{label}.pattern is invalid: {error}") + continue + parsed.append(DiffAllow(compiled, tuple(paths), reason.strip())) + return tuple(parsed) + + +def validate_contract_document(document: Any) -> tuple[list[str], tuple[DiffAllow, ...]]: + failures: list[str] = [] + if not isinstance(document, dict): + return ["contract root must be a JSON object"], () + if document.get("schema_version") != 1: + failures.append("schema_version must be 1") + if document.get("mode") != "production-architecture-replacement": + failures.append("mode must be 'production-architecture-replacement'") + + require_text(document, "title", failures) + require_text(document, "target_architecture", failures) + scope = string_list(document.get("production_scope"), "production_scope", failures, minimum=1) + for prefix in scope: + validate_relative_path(prefix, "production_scope", failures) + if not prefix.startswith(SENSITIVE_PREFIXES): + failures.append( + "production_scope must stay inside a native execution source root: " + f"{prefix!r}" + ) + + removal = document.get("remove") + if not isinstance(removal, dict): + failures.append("remove must be an object") + removal = {} + symbols = string_list(removal.get("symbols", []), "remove.symbols", failures) + paths = string_list(removal.get("paths", []), "remove.paths", failures) + edges = removal.get("call_edges", []) + if not isinstance(edges, list): + failures.append("remove.call_edges must be a list") + edges = [] + for index, edge in enumerate(edges): + if not isinstance(edge, dict): + failures.append(f"remove.call_edges[{index}] must be an object") + continue + caller = edge.get("caller") + callee = edge.get("callee") + if not isinstance(caller, str) or not caller.strip(): + failures.append(f"remove.call_edges[{index}].caller must be non-empty") + if not isinstance(callee, str) or not callee.strip(): + failures.append(f"remove.call_edges[{index}].callee must be non-empty") + elif callee.strip() not in symbols: + failures.append( + f"remove.call_edges[{index}].callee must also appear in remove.symbols" + ) + for path in paths: + validate_relative_path(path, "remove.paths", failures) + if not symbols and not paths: + failures.append("remove must name at least one legacy symbol or path") + + slow_paths = document.get("allowed_php_semantic_slow_paths") + if not isinstance(slow_paths, list): + failures.append("allowed_php_semantic_slow_paths must be a list") + else: + for index, item in enumerate(slow_paths): + label = f"allowed_php_semantic_slow_paths[{index}]" + if not isinstance(item, dict): + failures.append(f"{label} must be an object") + continue + require_text(item, "name", failures) + require_text(item, "reason", failures) + + validation = string_list( + document.get("required_validation"), "required_validation", failures, minimum=2 + ) + if validation and not any("native_replacement_guard.py" in command for command in validation): + failures.append("required_validation must include native_replacement_guard.py") + if validation and not any( + token in command + for command in validation + for token in ("wordpress-root", "verify-performance", "runtime-semantics", "phpt") + ): + failures.append("required_validation must include a correctness or application gate") + + metrics = document.get("expected_metric_movement") + if not isinstance(metrics, list) or len(metrics) < 2: + failures.append("expected_metric_movement must contain at least two metrics") + else: + for index, item in enumerate(metrics): + label = f"expected_metric_movement[{index}]" + if not isinstance(item, dict): + failures.append(f"{label} must be an object") + continue + if not isinstance(item.get("metric"), str) or not item["metric"].strip(): + failures.append(f"{label}.metric must be non-empty") + if item.get("direction") not in {"decrease", "increase", "zero"}: + failures.append(f"{label}.direction must be decrease, increase, or zero") + reason = item.get("reason") + if not isinstance(reason, str) or len(reason.strip()) < 12: + failures.append(f"{label}.reason must explain the expected movement") + + acceptance = document.get("acceptance") + expected = { + "old_route_production_reachable": False, + "new_engine_fallback_categories": 0, + "external_php_behavior_preserved": True, + } + if not isinstance(acceptance, dict): + failures.append("acceptance must be an object") + else: + for key, value in expected.items(): + if acceptance.get(key) != value: + failures.append(f"acceptance.{key} must be {value!r}") + + allowlist = parse_allowlist(document.get("diff_allowlist"), failures) + return failures, allowlist + + +def load_contract(path: Path) -> Contract: + try: + document = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as error: + raise GuardError(f"cannot read contract {path.relative_to(ROOT)}: {error}") from error + failures, allowlist = validate_contract_document(document) + if failures: + details = "\n".join(f" - {failure}" for failure in failures) + raise GuardError(f"invalid contract {path.relative_to(ROOT)}:\n{details}") + return Contract(path=path, document=document, allowlist=allowlist) + + +def valid_contract() -> dict[str, Any]: + return { + "schema_version": 1, + "mode": "production-architecture-replacement", + "title": "Remove the legacy runtime call bridge", + "target_architecture": ( + "Generated Cranelift code calls a typed production entry directly and " + "never re-enters the retired runtime bridge." + ), + "production_scope": ["crates/php_jit/src/", "crates/php_vm/src/vm/"], + "remove": { + "symbols": ["legacy_runtime_bridge"], + "paths": [], + "call_edges": [ + {"caller": "compiled_callsite", "callee": "legacy_runtime_bridge"} + ], + }, + "allowed_php_semantic_slow_paths": [ + { + "name": "runtime-created dynamic callable resolution", + "reason": ( + "PHP permits runtime-created callables whose target is not known " + "at publication." + ), + } + ], + "diff_allowlist": [ + { + "pattern": r"dynamic_callable_slow", + "paths": ["crates/php_vm/src/vm/"], + "reason": ( + "This is the explicit runtime-unknown callable resolver, not an " + "engine fallback." + ), + } + ], + "required_validation": [ + "python3 scripts/verify/native_replacement_guard.py --require-contract --diff-policy", + "nix develop -c just wordpress-root-tranche-gate target/performance/baseline.json", + ], + "expected_metric_movement": [ + { + "metric": "warm_c1_p50_ms", + "direction": "decrease", + "reason": "The shared call bridge leaves the warm path.", + }, + { + "metric": "runtime_helper_calls", + "direction": "decrease", + "reason": "Stable calls no longer cross the generic helper boundary.", + }, + ], + "acceptance": { + "old_route_production_reachable": False, + "new_engine_fallback_categories": 0, + "external_php_behavior_preserved": True, + }, + }