diff --git a/.github/scripts/i18n/apply_artifacts.py b/.github/scripts/i18n/apply_artifacts.py index 08e491cd07..7bdf1bc40f 100644 --- a/.github/scripts/i18n/apply_artifacts.py +++ b/.github/scripts/i18n/apply_artifacts.py @@ -53,6 +53,7 @@ class ApplyState: stale: list[str] = field(default_factory=list) invalid: list[str] = field(default_factory=list) failed: list[str] = field(default_factory=list) + mdx_repair_failed: list[str] = field(default_factory=list) skipped_stale_pages: list[str] = field(default_factory=list) skipped_stale_deletes: list[str] = field(default_factory=list) skipped_stale_tm: list[str] = field(default_factory=list) @@ -250,6 +251,14 @@ def process_artifact( if failed_reason: state.failed.append(f"{artifact_label(locale, shard_index, expected_shard_total)}: {failed_reason}") return + # Partial-success semantics (AC-05): the shard applies normally, and the + # finalizer reports the pages the bounded Codex relay could not fix. + mdx_repair_failed_paths = metadata.get("mdx_repair_failed_paths") + if isinstance(mdx_repair_failed_paths, list) and mdx_repair_failed_paths: + state.mdx_repair_failed.append( + f"{artifact_label(locale, shard_index, expected_shard_total)}: " + f"{len(mdx_repair_failed_paths)} page(s) still failing strict MDX" + ) if slug not in complete_locales: state.skipped_incomplete.append(artifact_label(locale, shard_index, expected_shard_total)) return @@ -320,6 +329,10 @@ def write_summary(mode: str, expected_shard_total: int, source_sha: str, current fh.write(f"- applied artifacts: {', '.join(state.applied)}\n") if state.no_changes: fh.write(f"- artifacts with no changes: {', '.join(state.no_changes)}\n") + if state.failed: + fh.write(f"- failed artifacts: {', '.join(state.failed)}\n") + if state.mdx_repair_failed: + fh.write(f"- mdx repair unresolved pages: {', '.join(state.mdx_repair_failed)}\n") if missing_or_failed: fh.write(f"- missing or failed artifacts: {', '.join(missing_or_failed)}\n") if state.skipped_incomplete: diff --git a/.github/scripts/i18n/mdx_repair_canary.py b/.github/scripts/i18n/mdx_repair_canary.py new file mode 100644 index 0000000000..66cbf72f3a --- /dev/null +++ b/.github/scripts/i18n/mdx_repair_canary.py @@ -0,0 +1,586 @@ +#!/usr/bin/env python3 +"""Canary switch, RELEASE gate, and release summary for the MDX repair relay. + +Definition: + STORY-06 control plane (plans/i18n-codex-mdx-fallback) for the staged, + reversible rollout of the enhanced existing Codex repair relay (single + entry: openai/codex-action@v1). The default configuration keeps the + translation workflow byte-equivalent to the original failure path: with + mdx_repair_enabled=false every relay step is skipped and nothing new runs. + + decide evaluates the canary scope for one locale shard, fail-closed. The + relay only starts when the master switch is on, the locale is an exact + member of the canary_locales whitelist (comma-separated; the workflow + gate expression expects no spaces, e.g. zh-CN,ja-JP), every pending page + sits inside the + canary_paths whitelist (locale-relative file paths or directory prefixes), + and the mdx-repair-gate job (the mdx-repair-validation.yml sub-pipeline + reused via workflow_call, artifacts mdx-repair-validation-*-) + finished in the same run. Every other outcome disables the relay for the + run, which is the original failure path; decide itself only fails on an + invalid gate policy so misconfiguration is never silently ignored. + + gate consumes the validation classification before any publication + (RELEASE gate signal): only classification=success passes. agent_failure + and environment_failure are recorded and then either fall back (relay + stays disabled, original failure path) or abort the canary per + CANARY_GATE_FAILURE_POLICY. The gate result and the downloaded + classification evidence must agree; mismatches fail closed. + + summary renders the release notes (AC-03): repaired pages with + repair_mode and rounds, checker intercepted pages, failed pages, and + remaining risks, plus the R2/Pages publish integrity record. The run + 28273967200 stale-R2 lesson is encoded here: content verification is + always explicitly verified or unverified-with-reason, never assumed. + + r2-smoke verifies after publish that the live page h1 matches the h1 + derived from the applied artifact page. Anything it cannot verify is + recorded with an explicit reason; mismatches fail when verification is + required. + +Parameters: + command: decide | gate | summary | r2-smoke. + --workspace: Git workspace root. Default: GITHUB_WORKSPACE or current dir. + gate --evidence-dir: Directory holding the downloaded + mdx-repair-validation-real-codex- artifact. Default: + .openclaw-sync/mdx-repair-gate. + summary --artifact-dir: Applied locale artifact directory with + metadata.json and mdx-repair-report.json. + r2-smoke --locale/--page-path: Locale and locale-relative page route of + the canary page. --live-url: Live URL to verify. --docs-root: Applied + docs tree. Default: docs. --timeout-seconds/--poll-seconds: Live poll + budget. Defaults: 120/10. + +Environment (decide): + MDX_REPAIR_ENABLED_INPUT ("true"/"false"), CANARY_LOCALES, CANARY_PATHS, + CANARY_GATE_FAILURE_POLICY (fallback|abort), MDX_REPAIR_GATE_RESULT, + LOCALE, LOCALE_SLUG, SHARD_INDEX, SHARD_TOTAL. +Environment (gate): + MDX_REPAIR_GATE_RESULT, CANARY_GATE_FAILURE_POLICY. +Environment (summary): + LOCALE, LOCALE_SLUG, SHARD_INDEX, SHARD_TOTAL, ARTIFACT_ROLE, + GATE_DECISION, GATE_CLASSIFICATION, GATE_REASON, + CANARY_GATE_FAILURE_POLICY, R2_SMOKE_OUTCOME, R2_SMOKE_REASON, + R2_SMOKE_EXPECTED_H1, PAGES_DISPATCH_WAITED. +Environment (r2-smoke): + R2_SMOKE_REQUIRE_VERIFIED ("1" fails on unverified/mismatch), + R2_SMOKE_UNVERIFIED_REASON (records unverified without fetching). + +Outputs: + decide writes .openclaw-sync/mdx/-canary-decision.json, + GITHUB_OUTPUT enabled/reason, and GITHUB_ENV + MDX_REPAIR_CANARY_ENABLED=true|false. + gate writes /gate-decision.json and GITHUB_OUTPUT + gate_decision/classification/reason; the abort policy exits non-zero + after recording. + summary writes + .openclaw-sync/canary-release-summary--sof.json, appends the + release notes to GITHUB_STEP_SUMMARY, and prints the record. + r2-smoke writes GITHUB_OUTPUT r2_smoke/r2_smoke_reason/expected_h1. + +Examples: + LOCALE=zh-CN LOCALE_SLUG=zh-CN SHARD_INDEX=0 SHARD_TOTAL=1 MDX_REPAIR_ENABLED_INPUT=true CANARY_LOCALES="zh-CN" CANARY_PATHS="channels/line.md" MDX_REPAIR_GATE_RESULT=success python .github/scripts/i18n/mdx_repair_canary.py decide + MDX_REPAIR_GATE_RESULT=success python .github/scripts/i18n/mdx_repair_canary.py gate --evidence-dir .openclaw-sync/mdx-repair-gate + LOCALE=zh-CN LOCALE_SLUG=zh-CN SHARD_INDEX=0 SHARD_TOTAL=1 python .github/scripts/i18n/mdx_repair_canary.py summary --artifact-dir .openclaw-sync/i18n-artifacts/zh-CN-s0of1 + LOCALE=zh-CN R2_SMOKE_REQUIRE_VERIFIED=1 python .github/scripts/i18n/mdx_repair_canary.py r2-smoke --locale zh-CN --page-path channels/line --live-url https://docs.openclaw.ai/zh-CN/channels/line +""" + +from __future__ import annotations + +import argparse +import json +import os +import re +import sys +import time +from pathlib import Path + +SCRIPT_DIR = Path(__file__).resolve().parent +if str(SCRIPT_DIR) not in sys.path: + sys.path.insert(0, str(SCRIPT_DIR)) + +# Reuse the live h1 extraction/fetch helpers so the publish smoke keeps one +# implementation of "what the live page says". +import dispatch_r2_pages # noqa: E402 + +# Reuse the pending-manifest reader so canary scope checks the same manifest +# the relay decide step consumes. +import mdx_repair_relay # noqa: E402 + +GATE_POLICIES = ("fallback", "abort") +CANARY_ENV_VAR = "MDX_REPAIR_CANARY_ENABLED" +FAILURE_CLASSES = ("agent_failure", "environment_failure") + + +def sanitize_reason_token(raw: object) -> str: + token = re.sub(r"[^A-Za-z0-9_.-]+", "_", str(raw or "").strip()) + return token[:100] + + +def parse_scope_list(raw: str | None) -> list[str]: + return [token for token in (raw or "").replace(",", " ").split() if token] + + +def write_outputs(mapping: dict[str, str]) -> None: + output = os.environ.get("GITHUB_OUTPUT") + if not output: + return + with Path(output).open("a", encoding="utf-8") as fh: + for key, value in mapping.items(): + fh.write(f"{key}={value}\n") + + +def append_env(mapping: dict[str, str]) -> None: + env_file = os.environ.get("GITHUB_ENV") + if not env_file: + return + with Path(env_file).open("a", encoding="utf-8") as fh: + for key, value in mapping.items(): + fh.write(f"{key}={value}\n") + + +def read_json(path: Path) -> dict[str, object] | None: + try: + loaded = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + return None + return loaded if isinstance(loaded, dict) else None + + +def read_manifest_locale_pages( + workspace: Path, locale: str, locale_slug: str, shard_index: str, shard_total: str +) -> list[str]: + docs_root = (workspace / "docs").resolve() + pages: list[str] = [] + for source in mdx_repair_relay.read_manifest_sources(workspace, locale_slug, shard_index, shard_total): + try: + rel = source.resolve().relative_to(docs_root).as_posix() + except (ValueError, OSError): + pages.append(f"__unresolvable__:{source}") + continue + pages.append(f"docs/{locale}/{rel}") + return pages + + +def path_within_scope(page: str, locale: str, prefixes: list[str]) -> bool: + prefix = f"docs/{locale}/" + if not page.startswith(prefix): + return False + rel = page.removeprefix(prefix) + for entry in prefixes: + scoped = entry.strip("/") + if rel == scoped or rel.startswith(f"{scoped}/"): + return True + return False + + +def decide_canary(workspace: Path) -> tuple[bool, str, dict[str, object]]: + locale = os.environ.get("LOCALE", "") + locale_slug = os.environ.get("LOCALE_SLUG", "") or locale + shard_index = os.environ.get("SHARD_INDEX", "0") + shard_total = os.environ.get("SHARD_TOTAL", "1") + enabled_input = (os.environ.get("MDX_REPAIR_ENABLED_INPUT") or "").strip().lower() + policy = (os.environ.get("CANARY_GATE_FAILURE_POLICY") or "fallback").strip().lower() + gate_result = (os.environ.get("MDX_REPAIR_GATE_RESULT") or "skipped").strip().lower() + locales = parse_scope_list(os.environ.get("CANARY_LOCALES")) + paths = parse_scope_list(os.environ.get("CANARY_PATHS")) + pending = read_manifest_locale_pages(workspace, locale, locale_slug, shard_index, shard_total) + + enabled = False + reason = "switch_off" + outside: list[str] = [] + if enabled_input != "true": + reason = "switch_off" + elif policy not in GATE_POLICIES: + raise SystemExit(f"invalid CANARY_GATE_FAILURE_POLICY: {policy!r}; expected fallback or abort") + elif not locales: + reason = "canary_locales_empty" + elif locale not in locales: + reason = "locale_not_in_canary_scope" + elif not paths: + reason = "canary_paths_empty" + else: + outside = [page for page in pending if not path_within_scope(page, locale, paths)] + if outside: + reason = f"pending_paths_outside_canary_scope_{sanitize_reason_token(outside[0])}" + elif gate_result == "success": + enabled = True + reason = "canary_enabled" + elif gate_result == "failure": + reason = "validation_gate_failure" + else: + token = sanitize_reason_token(gate_result) or "skipped" + reason = f"validation_gate_{token}" + + state: dict[str, object] = { + "event": "canary_decision", + "enabled": enabled, + "reason": reason, + "locale": locale, + "locale_slug": locale_slug, + "shard_index": shard_index, + "shard_total": shard_total, + "mdx_repair_enabled_input": enabled_input == "true", + "canary_locales": locales, + "canary_paths": paths, + "canary_gate_failure_policy": policy, + "mdx_repair_gate_result": gate_result, + "pending_locale_pages": pending, + "pending_pages_outside_canary_scope": outside, + } + return enabled, reason, state + + +def decide_command(workspace: Path) -> None: + enabled, reason, state = decide_canary(workspace) + locale = str(state["locale"]) + state_path = workspace / ".openclaw-sync" / "mdx" / f"{locale}-canary-decision.json" + state_path.parent.mkdir(parents=True, exist_ok=True) + state_path.write_text(json.dumps(state, indent=2, sort_keys=True) + "\n", encoding="utf-8") + enabled_token = "true" if enabled else "false" + write_outputs({"enabled": enabled_token, "reason": reason}) + append_env({CANARY_ENV_VAR: enabled_token}) + print(json.dumps(state, sort_keys=True)) + + +def gate_decision(evidence_dir: Path) -> tuple[str, str, str, dict[str, object]]: + policy = (os.environ.get("CANARY_GATE_FAILURE_POLICY") or "fallback").strip().lower() + if policy not in GATE_POLICIES: + raise SystemExit(f"invalid CANARY_GATE_FAILURE_POLICY: {policy!r}; expected fallback or abort") + result = (os.environ.get("MDX_REPAIR_GATE_RESULT") or "skipped").strip().lower() + + evidence = read_json(evidence_dir / "classification.json") + file_classification = str((evidence or {}).get("classification") or "") + file_reason = str((evidence or {}).get("reason") or "") + + if result == "skipped": + decision, classification, reason = "not_applicable", "", "canary_switch_off" + elif result == "success": + if evidence is None: + raise SystemExit( + "release gate misconfiguration: the validation gate succeeded but its " + "classification evidence is missing; failing closed before publication" + ) + if file_classification != "success": + raise SystemExit( + "release gate misconfiguration: the validation gate succeeded but the " + f"classification evidence says {file_classification or 'unknown'}" + ) + decision, classification, reason = "pass", "success", file_reason or "validation_classification_success" + elif result == "failure": + classification = file_classification or "unknown" + if classification != "unknown" and classification not in FAILURE_CLASSES: + raise SystemExit( + "release gate misconfiguration: the validation gate failed but the " + f"classification evidence says {classification}" + ) + reason = file_reason or "classification_evidence_missing" + decision = "abort" if policy == "abort" else "fallback" + else: + raise SystemExit(f"unknown MDX repair gate result: {result!r}") + + record = { + "event": "canary_release_gate", + "gate_decision": decision, + "classification": classification, + "reason": reason, + "policy": policy, + "gate_result": result, + } + return decision, classification, reason, record + + +def gate_command(evidence_dir: Path) -> None: + decision, classification, reason, record = gate_decision(evidence_dir) + evidence_dir.mkdir(parents=True, exist_ok=True) + (evidence_dir / "gate-decision.json").write_text( + json.dumps(record, indent=2, sort_keys=True) + "\n", encoding="utf-8" + ) + write_outputs({"gate_decision": decision, "classification": classification, "reason": reason}) + print(json.dumps(record, sort_keys=True)) + if decision == "abort": + raise SystemExit( + f"canary aborted: validation classification {classification} ({reason}); " + "policy=abort stops before any publication step" + ) + + +def artifact_h1(page: Path) -> tuple[str, str]: + """Derive the expected live h1 from the applied artifact page.""" + try: + text = page.read_text(encoding="utf-8", errors="replace") + except OSError: + return "", f"artifact_page_missing_{sanitize_reason_token(page)}" + body = text + if body.startswith("---"): + parts = body.split("---", 2) + if len(parts) == 3: + body = parts[2] + for line in body.splitlines(): + heading = re.match(r"^#\s+(.+?)\s*#*\s*$", line) + if heading: + plain = re.sub(r"[`*_]", "", heading.group(1)).strip() + return " ".join(plain.split()), "" + title = re.search(r"(?m)^title:\s*['\"]?(.+?)['\"]?\s*$", text) + if title: + return " ".join(re.sub(r"[`*_]", "", title.group(1)).split()), "" + return "", f"artifact_h1_missing_{sanitize_reason_token(page.name)}" + + +def r2_smoke_finish(outcome: str, reason: str, expected_h1: str, required: bool) -> None: + write_outputs({"r2_smoke": outcome, "r2_smoke_reason": reason, "expected_h1": expected_h1}) + print(json.dumps({"r2_smoke": outcome, "reason": reason, "expected_h1": expected_h1}, sort_keys=True)) + if required and outcome != "verified": + raise SystemExit(f"R2 content smoke finished {outcome}: {reason}") + + +def r2_smoke_command(locale: str, page_path: str, live_url: str, docs_root: Path, timeout: int, poll: int) -> None: + required = (os.environ.get("R2_SMOKE_REQUIRE_VERIFIED") or "").strip() == "1" + unverified_reason = (os.environ.get("R2_SMOKE_UNVERIFIED_REASON") or "").strip() + if timeout < 1 or poll < 1: + raise SystemExit("r2-smoke timeout-seconds and poll-seconds must be >= 1") + + page = None + for candidate in (docs_root / locale / f"{page_path}.mdx", docs_root / locale / f"{page_path}.md"): + if candidate.is_file(): + page = candidate + break + if page is None: + missing = f"artifact_page_missing_{sanitize_reason_token(Path(locale) / page_path)}" + r2_smoke_finish("unverified", missing, "", required) + return + expected_h1, note = artifact_h1(page) + if unverified_reason: + r2_smoke_finish("unverified", sanitize_reason_token(unverified_reason), expected_h1, required) + return + if page is None or not expected_h1: + r2_smoke_finish("unverified", note, expected_h1, required) + return + if not live_url: + r2_smoke_finish("unverified", "live_url_missing", expected_h1, required) + return + + deadline = time.monotonic() + timeout + last_h1 = "" + while True: + try: + cache_buster = int(time.time()) + separator = "&" if "?" in live_url else "?" + last_h1 = dispatch_r2_pages.extract_h1( + dispatch_r2_pages.fetch_text(f"{live_url}{separator}_openclaw_i18n_canary={cache_buster}") + ) + if last_h1 == expected_h1: + r2_smoke_finish("verified", "live_h1_matches_artifact", expected_h1, required) + return + except Exception as exc: # noqa: BLE001 - any fetch failure keeps polling until the deadline + print(f"R2 smoke fetch failed: {exc}") + if time.monotonic() >= deadline: + break + time.sleep(poll) + r2_smoke_finish("mismatch", f"live_h1_mismatch_last_{sanitize_reason_token(last_h1)}", expected_h1, required) + + +def summary_record(artifact_dir: Path) -> dict[str, object]: + locale = os.environ.get("LOCALE", "") + locale_slug = os.environ.get("LOCALE_SLUG", "") or locale + shard_index = os.environ.get("SHARD_INDEX", "0") + shard_total = os.environ.get("SHARD_TOTAL", "1") + metadata = read_json(artifact_dir / "metadata.json") + report = read_json(artifact_dir / "mdx-repair-report.json") + + repair_mode = str((metadata or {}).get("mdx_repair_mode") or (report or {}).get("repair_mode") or "none") + rounds = (metadata or {}).get("mdx_repair_rounds", (report or {}).get("rounds", 0)) + final_outcome = str( + (metadata or {}).get("mdx_repair_final_outcome") or (report or {}).get("final_outcome") or "not_run" + ) + failure_kind = str((report or {}).get("failure_kind") or "") + repaired_pages = sorted( + set((metadata or {}).get("mdx_repair_changed_paths") or []) # type: ignore[arg-type] + | {str(entry.get("path")) for entry in (report or {}).get("changed_paths") or [] if isinstance(entry, dict)} + ) + failed_pages = sorted( + set((metadata or {}).get("mdx_repair_failed_paths") or []) # type: ignore[arg-type] + | {str(entry.get("path")) for entry in (report or {}).get("failed_paths") or [] if isinstance(entry, dict)} + ) + checker_intercepted = sorted( + { + str(entry.get("path")) + for entry in (report or {}).get("violations") or [] + if isinstance(entry, dict) and entry.get("gate") == "checker" + } + ) + + gate_decision = (os.environ.get("GATE_DECISION") or "").strip() or "not_applicable" + gate_classification = (os.environ.get("GATE_CLASSIFICATION") or "").strip() + gate_reason = (os.environ.get("GATE_REASON") or "").strip() + policy = (os.environ.get("CANARY_GATE_FAILURE_POLICY") or "fallback").strip().lower() + pages_dispatch_waited = (os.environ.get("PAGES_DISPATCH_WAITED") or "").strip() or "true" + r2_outcome = (os.environ.get("R2_SMOKE_OUTCOME") or "").strip() + r2_reason = (os.environ.get("R2_SMOKE_REASON") or "").strip() + r2_expected_h1 = (os.environ.get("R2_SMOKE_EXPECTED_H1") or "").strip() + if not r2_outcome: + r2_outcome = "unverified" + if not r2_reason: + if (os.environ.get("ARTIFACT_ROLE") or "locale") != "canary": + r2_reason = "locale_scope_publish_waits_on_r2_pages_run_page_content_not_diffed" + else: + r2_reason = "r2_smoke_step_did_not_run" + + risks: list[str] = [] + if metadata is None: + risks.append("artifact_metadata_missing") + if failed_pages: + risks.append(f"pages_still_failing_{len(failed_pages)}") + if checker_intercepted: + risks.append(f"checker_intercepted_{len(checker_intercepted)}") + if final_outcome == "final_failure": + risks.append("relay_final_failure") + if gate_decision == "fallback": + risks.append(f"release_gate_fallback_{sanitize_reason_token(gate_classification or 'unknown')}") + elif gate_decision == "abort": + risks.append(f"release_gate_abort_{sanitize_reason_token(gate_classification or 'unknown')}") + if r2_outcome != "verified": + risks.append(f"r2_content_unverified_{sanitize_reason_token(r2_reason)}") + if pages_dispatch_waited != "true": + risks.append("pages_dispatch_not_waited") + + return { + "event": "canary_release_summary", + "locale": locale, + "locale_slug": locale_slug, + "shard": f"{shard_index}of{shard_total}", + "artifact_role": (os.environ.get("ARTIFACT_ROLE") or "locale"), + "repair": { + "repair_mode": repair_mode, + "rounds": rounds, + "final_outcome": final_outcome, + "failure_kind": failure_kind, + "repaired_pages": repaired_pages, + "checker_intercepted_pages": checker_intercepted, + "failed_pages": failed_pages, + }, + "release_gate": { + "decision": gate_decision, + "classification": gate_classification, + "reason": gate_reason, + "policy": policy, + }, + "publish_integrity": { + "pages_dispatch_waited": pages_dispatch_waited, + "r2_content": { + "outcome": r2_outcome, + "reason": r2_reason, + "expected_h1": r2_expected_h1, + }, + }, + "remaining_risks": risks, + } + + +def append_summary_markdown(record: dict[str, object]) -> None: + summary = os.environ.get("GITHUB_STEP_SUMMARY") + if not summary: + return + repair = record["repair"] # type: ignore[index] + gate = record["release_gate"] # type: ignore[index] + integrity = record["publish_integrity"] # type: ignore[index] + r2 = integrity["r2_content"] # type: ignore[index] + + def paths_line(paths: object) -> str: + items = [str(path) for path in paths] # type: ignore[arg-type] + return ", ".join(f"`{path}`" for path in items) if items else "none" + + with Path(summary).open("a", encoding="utf-8") as fh: + fh.write(f"### Canary release summary ({record['locale']} shard {record['shard']})\n\n") # type: ignore[index] + fh.write( + f"- release gate: `{gate['decision']}` classification=`{gate['classification'] or 'n/a'}` " # type: ignore[index] + f"policy=`{gate['policy']}` reason=`{gate['reason'] or 'n/a'}`\n" # type: ignore[index] + ) + fh.write( + f"- mdx repair: mode=`{repair['repair_mode']}` rounds=`{repair['rounds']}` " # type: ignore[index] + f"final=`{repair['final_outcome']}`\n" + ) + fh.write(f"- Codex repaired pages: {paths_line(repair['repaired_pages'])}\n") # type: ignore[index] + fh.write(f"- checker intercepted pages: {paths_line(repair['checker_intercepted_pages'])}\n") # type: ignore[index] + fh.write(f"- failed pages: {paths_line(repair['failed_pages'])}\n") # type: ignore[index] + fh.write( + f"- publish integrity: pages dispatch waited=`{integrity['pages_dispatch_waited']}` " # type: ignore[index] + f"R2 content=`{r2['outcome']}` reason=`{r2['reason']}`\n" # type: ignore[index] + ) + risks = record["remaining_risks"] # type: ignore[index] + if risks: + fh.write("- remaining risks:\n") + for risk in risks: # type: ignore[union-attr] + fh.write(f" - `{risk}`\n") + else: + fh.write("- remaining risks: none recorded\n") + + +def summary_command(artifact_dir: Path, workspace: Path) -> None: + record = summary_record(artifact_dir) + output_path = ( + workspace + / ".openclaw-sync" + / f"canary-release-summary-{record['locale_slug']}-s{record['shard']}.json" # type: ignore[index] + ) + output_path.parent.mkdir(parents=True, exist_ok=True) + output_path.write_text(json.dumps(record, indent=2, sort_keys=True) + "\n", encoding="utf-8") + append_summary_markdown(record) + print(json.dumps(record, sort_keys=True)) + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser( + description="Canary switch, RELEASE gate, and release summary for the MDX repair relay.", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog="""Outputs: + decide writes the canary decision JSON, GITHUB_OUTPUT enabled/reason, and GITHUB_ENV MDX_REPAIR_CANARY_ENABLED. + gate writes gate-decision.json and GITHUB_OUTPUT gate_decision/classification/reason; abort exits non-zero. + summary writes the release summary JSON and appends the release notes to GITHUB_STEP_SUMMARY. + r2-smoke writes GITHUB_OUTPUT r2_smoke/r2_smoke_reason/expected_h1. + +Examples: + LOCALE=zh-CN LOCALE_SLUG=zh-CN SHARD_INDEX=0 SHARD_TOTAL=1 MDX_REPAIR_ENABLED_INPUT=true CANARY_LOCALES="zh-CN" CANARY_PATHS="channels/line.md" MDX_REPAIR_GATE_RESULT=success python .github/scripts/i18n/mdx_repair_canary.py decide + MDX_REPAIR_GATE_RESULT=success python .github/scripts/i18n/mdx_repair_canary.py gate --evidence-dir .openclaw-sync/mdx-repair-gate + LOCALE=zh-CN LOCALE_SLUG=zh-CN SHARD_INDEX=0 SHARD_TOTAL=1 python .github/scripts/i18n/mdx_repair_canary.py summary --artifact-dir .openclaw-sync/i18n-artifacts/zh-CN-s0of1 + LOCALE=zh-CN R2_SMOKE_REQUIRE_VERIFIED=1 python .github/scripts/i18n/mdx_repair_canary.py r2-smoke --locale zh-CN --page-path channels/line --live-url https://docs.openclaw.ai/zh-CN/channels/line +""", + ) + parser.add_argument("command", choices=["decide", "gate", "summary", "r2-smoke"]) + parser.add_argument("--workspace", default=os.environ.get("GITHUB_WORKSPACE", "."), type=Path) + parser.add_argument("--evidence-dir", default=".openclaw-sync/mdx-repair-gate", type=Path) + parser.add_argument("--artifact-dir", default="", type=Path) + parser.add_argument("--locale", default=os.environ.get("LOCALE", "")) + parser.add_argument("--page-path", default="") + parser.add_argument("--live-url", default="") + parser.add_argument("--docs-root", default="docs", type=Path) + parser.add_argument("--timeout-seconds", default=120, type=int) + parser.add_argument("--poll-seconds", default=10, type=int) + return parser.parse_args() + + +def main() -> None: + args = parse_args() + if args.command == "decide": + decide_command(args.workspace.resolve()) + elif args.command == "gate": + gate_command(args.evidence_dir) + elif args.command == "summary": + if not args.artifact_dir: + raise SystemExit("summary requires --artifact-dir") + summary_command(args.artifact_dir, args.workspace.resolve()) + else: + if not args.page_path: + raise SystemExit("r2-smoke requires --page-path") + r2_smoke_command( + args.locale, + args.page_path, + args.live_url, + args.docs_root, + args.timeout_seconds, + args.poll_seconds, + ) + + +if __name__ == "__main__": + main() diff --git a/.github/scripts/i18n/mdx_repair_relay.py b/.github/scripts/i18n/mdx_repair_relay.py new file mode 100644 index 0000000000..2e56b563f3 --- /dev/null +++ b/.github/scripts/i18n/mdx_repair_relay.py @@ -0,0 +1,451 @@ +#!/usr/bin/env python3 +"""Drive the bounded Codex MDX repair relay for one locale shard. + +Definition: + Control plane for the existing "Repair translated MDX" step + (openai/codex-action@v1, prompt contract .openclaw-sync/docs-mdx-repair.md). + Implements the D-09 multi-round relay protocol from + plans/i18n-codex-mdx-fallback: the single repair action runs on complete + pages, is fed the current strict diagnostics before every round, and is + bounded by MDX_REPAIR_MAX_ATTEMPTS rounds with MDX_REPAIR_HARD_TIMEOUT_MS + per round. There is no second Agent entry and no auxiliary arm. + + decide freezes the contract startup conditions (contract §1): the relay + starts only when the strict MDX check failed, the pending manifest is + non-empty, compile diagnostics with file/line/column exist, and every + diagnostic stays inside docs/. Otherwise it records not_run. + decide also snapshots locale page hashes so the report can detect + repair-phase page deletion or emptying (contract §3 empty_output and + whole_document_deleted; threshold-free, so no checker config is required). + + report classifies the relay outcome (success, partial_success, + final_failure, not_run), keeps per-round diagnostics history, and records + per-page failures with top-level error_source/error_line/error_column plus + repair_mode, rounds, and changed/deleted paths for artifact metadata. + +Parameters: + command: decide or report. + --workspace: Git workspace root. Default: GITHUB_WORKSPACE or current dir. + +Environment: + LOCALE, LOCALE_SLUG, SHARD_INDEX, SHARD_TOTAL, MDX_CHECK_OUTCOME. + MDX_REPAIR_MAX_ATTEMPTS (default 4), MDX_REPAIR_HARD_TIMEOUT_MS (default + 600000) must be positive integers; MDX_REPAIR_AUXILIARY_MODE (default + none) must be none because auxiliary arms are not enabled in production. + report also reads MDX_REPAIR_ROUNDS_OUTCOMES (12 outcome tokens, three per + relay round: action scope recheck). + +Outputs: + decide writes .openclaw-sync/mdx/-repair-state.json and the + pre-repair content snapshot, and GITHUB_OUTPUT decision/reason. + report writes .openclaw-sync/mdx/-repair-report.json and + GITHUB_OUTPUT final_outcome/failure_kind/repair_mode/rounds/recheck_outcome/ + failed_paths/nonsyntax_failed_paths/changed_paths/failed_count/changed_count. + Both commands print the structured record to stdout. + +Examples: + LOCALE=fr LOCALE_SLUG=fr SHARD_INDEX=0 SHARD_TOTAL=1 MDX_CHECK_OUTCOME=failure python .github/scripts/i18n/mdx_repair_relay.py decide + LOCALE=fr LOCALE_SLUG=fr SHARD_INDEX=0 SHARD_TOTAL=1 MDX_CHECK_OUTCOME=failure MDX_REPAIR_ROUNDS_OUTCOMES="success success failure skipped skipped skipped skipped skipped skipped skipped skipped skipped" python .github/scripts/i18n/mdx_repair_relay.py report +""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import os +import re +from pathlib import Path + +DEFAULT_MAX_ATTEMPTS = "4" +DEFAULT_HARD_TIMEOUT_MS = "600000" +MESSAGE_LIMIT = 300 +POSITIVE_INT_RE = re.compile(r"^[1-9][0-9]*$") + + +def workspace_path(raw: str | None) -> Path: + return Path(raw).resolve() if raw else Path.cwd() + + +def parse_positive_int(name: str) -> int: + raw = (os.environ.get(name) or "").strip() or {"MDX_REPAIR_MAX_ATTEMPTS": DEFAULT_MAX_ATTEMPTS, "MDX_REPAIR_HARD_TIMEOUT_MS": DEFAULT_HARD_TIMEOUT_MS}[name] + if not POSITIVE_INT_RE.fullmatch(raw): + raise SystemExit(f"invalid {name}: {raw!r}; relay budgets must be positive integers (no unbounded retry)") + return int(raw) + + +def relay_auxiliary_mode() -> str: + raw = (os.environ.get("MDX_REPAIR_AUXILIARY_MODE") or "none").strip().lower() + if raw != "none": + raise SystemExit( + f"MDX_REPAIR_AUXILIARY_MODE={raw!r} is not enabled in production; " + "the relay runs the single Codex action without auxiliary arms (fail-closed)" + ) + return raw + + +def relay_config() -> dict[str, object]: + return { + "max_attempts": parse_positive_int("MDX_REPAIR_MAX_ATTEMPTS"), + "hard_timeout_ms": parse_positive_int("MDX_REPAIR_HARD_TIMEOUT_MS"), + "auxiliary_mode": relay_auxiliary_mode(), + } + + +def mdx_dir(workspace: Path) -> Path: + return workspace / ".openclaw-sync" / "mdx" + + +def write_outputs(mapping: dict[str, object]) -> None: + output = os.environ.get("GITHUB_OUTPUT") + if not output: + return + with Path(output).open("a", encoding="utf-8") as fh: + for key, value in mapping.items(): + fh.write(f"{key}={value}\n") + + +def read_manifest_sources(workspace: Path, locale_slug: str, shard_index: str, shard_total: str) -> list[Path]: + manifest = workspace / ".openclaw-sync" / f"docs-i18n-{locale_slug}-s{shard_index}of{shard_total}.txt" + if not manifest.is_file(): + return [] + return [Path(line.strip()) for line in manifest.read_text(encoding="utf-8").splitlines() if line.strip()] + + +def read_diagnostics(path: Path) -> list[dict] | None: + try: + payload = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + return None + errors = payload.get("errors") if isinstance(payload, dict) else None + if not isinstance(errors, list): + return None + return [error for error in errors if isinstance(error, dict)] + + +def locale_page_path(locale: str, source: Path, docs_root: Path) -> str | None: + try: + rel = source.resolve().relative_to(docs_root).as_posix() + except (ValueError, OSError): + return None + if not rel.endswith((".md", ".mdx")): + return None + return f"docs/{locale}/{rel}" + + +def content_snapshot(workspace: Path, locale: str) -> dict[str, str]: + root = workspace / "docs" / locale + snapshot: dict[str, str] = {} + if not root.is_dir(): + return snapshot + for path in sorted(root.rglob("*")): + if path.is_file() and path.suffix in {".md", ".mdx"}: + digest = hashlib.sha256(path.read_bytes()).hexdigest() + snapshot[path.relative_to(workspace).as_posix()] = digest + return snapshot + + +def snapshot_path(workspace: Path, locale: str) -> Path: + base = os.environ.get("RUNNER_TEMP") or "" + root = Path(base) if base else mdx_dir(workspace) + return root / f"{locale}.repair-content-snapshot.json" + + +def is_empty_page(path: Path) -> bool: + try: + return not path.read_text(encoding="utf-8", errors="ignore").strip() + except OSError: + return False + + +def trunc(message: object) -> str: + text = str(message or "").split("\n")[0] + return text[:MESSAGE_LIMIT] + + +def decide(workspace: Path) -> None: + locale = os.environ["LOCALE"] + locale_slug = os.environ["LOCALE_SLUG"] + shard_index = os.environ["SHARD_INDEX"] + shard_total = os.environ["SHARD_TOTAL"] + config = relay_config() + check_outcome = os.environ.get("MDX_CHECK_OUTCOME", "skipped") + + sources = read_manifest_sources(workspace, locale_slug, shard_index, shard_total) + diagnostics_path = mdx_dir(workspace) / f"{locale}.json" + errors = read_diagnostics(diagnostics_path) if check_outcome == "failure" else [] + docs_root = (workspace / "docs").resolve() + decision = "run" + reason = "" + if check_outcome != "failure": + decision, reason = "not_run", f"mdx_check_{check_outcome or 'skipped'}" + elif not sources: + decision, reason = "not_run", "no_pending_files" + elif errors is None: + decision, reason = "not_run", "diagnostics_unavailable" + elif not [error for error in errors if error.get("type") == "mdx"]: + decision, reason = "not_run", "no_mdx_compile_diagnostics" + elif any(not str(error.get("file", "")).startswith(f"docs/{locale}/") for error in errors): + decision, reason = "not_run", "diagnostics_out_of_locale_scope" + + state = { + "decision": decision, + "not_run_reason": reason, + "locale": locale, + "locale_slug": locale_slug, + "shard_index": shard_index, + "shard_total": shard_total, + "mdx_check_outcome": check_outcome, + "repair_mode": "relay" if decision == "run" else "none", + **config, + } + mdx_dir(workspace).mkdir(parents=True, exist_ok=True) + state_path = mdx_dir(workspace) / f"{locale}-repair-state.json" + state_path.write_text(json.dumps(state, indent=2, sort_keys=True) + "\n", encoding="utf-8") + if decision == "run": + snapshot = content_snapshot(workspace, locale) + snapshot_file = snapshot_path(workspace, locale) + snapshot_file.parent.mkdir(parents=True, exist_ok=True) + snapshot_file.write_text(json.dumps(snapshot, indent=2, sort_keys=True) + "\n", encoding="utf-8") + write_outputs({"decision": decision, "reason": reason}) + print(json.dumps(state, sort_keys=True)) + + +def round_history(workspace: Path, locale: str, max_attempts: int) -> list[dict[str, object]]: + history: list[dict[str, object]] = [] + for round_index in range(1, max_attempts + 1): + path = mdx_dir(workspace) / f"{locale}-round-{round_index}.json" + errors = read_diagnostics(path) + if errors is None: + continue + entry: dict[str, object] = {"round": round_index, "error_count": len(errors)} + if errors: + first = errors[0] + entry["first_error"] = { + "file": first.get("file", ""), + "type": first.get("type", ""), + "line": first.get("line"), + "column": first.get("column"), + "message": trunc(first.get("message")), + } + history.append(entry) + return history + + +def parse_round_outcomes() -> list[list[str]]: + raw = os.environ.get("MDX_REPAIR_ROUNDS_OUTCOMES", "") + tokens = raw.split() + tokens += ["skipped"] * (12 - len(tokens)) + return [tokens[index : index + 3] for index in range(0, 12, 3)] + + +def classify_report( + state: dict[str, object], + errors: list[dict], + rounds_outcomes: list[list[str]], + workspace: Path, + locale: str, +) -> dict[str, object]: + decision = str(state.get("decision", "not_run")) + rounds = sum(1 for action, _scope, _recheck in rounds_outcomes if action not in {"skipped", ""}) + if decision != "run": + # The relay state is authoritative: without a run decision no repair + # round executed, regardless of any outcome tokens. + rounds = 0 + executed = [round for round in rounds_outcomes[:rounds]] + recheck_outcome = executed[-1][2] if executed else "skipped" + + failed_records: dict[str, dict[str, object]] = {} + nonsyntax: list[str] = [] + for error in errors: + path = str(error.get("file", "")) + if not path.startswith(f"docs/{locale}/") or path in failed_records: + continue + failed_records[path] = { + "path": path, + "error_source": error.get("type", ""), + "error_line": error.get("line"), + "error_column": error.get("column"), + "message": trunc(error.get("message")), + } + if error.get("type") != "mdx": + nonsyntax.append(path) + + snapshot_file = snapshot_path(workspace, locale) + before: dict[str, str] = {} + if snapshot_file.is_file(): + try: + loaded = json.loads(snapshot_file.read_text(encoding="utf-8")) + if isinstance(loaded, dict): + before = {str(key): str(value) for key, value in loaded.items()} + except (OSError, json.JSONDecodeError): + before = {} + current = content_snapshot(workspace, locale) + changed_paths = [ + {"path": path, "before_sha256": before.get(path), "after_sha256": digest} + for path, digest in sorted(current.items()) + if before.get(path) != digest + ] + deleted_paths = sorted(path for path in before if path not in current) + emptied_paths = sorted( + path + for path in sorted(set(before) & set(current)) + if before[path] != current[path] and is_empty_page(workspace / path) + ) + violations = ( + [{"gate": "checker", "code": "whole_document_deleted", "path": path} for path in deleted_paths] + + [{"gate": "checker", "code": "empty_output", "path": path} for path in emptied_paths] + ) + + any_action_failed = any(action == "failure" for action, _scope, _recheck in executed) + any_scope_failed = any(scope == "failure" for _action, scope, _recheck in executed) + if violations: + # Repair-phase page deletion or emptying is a content-loss violation + # (contract §3 empty_output / whole_document_deleted); it can never + # count as a successful repair even when the parser is satisfied. + failure_kind = "content_loss" + elif any_action_failed: + failure_kind = "action_failed" + elif any_scope_failed: + failure_kind = "scope_failed" + elif rounds == 0: + failure_kind = "action_failed" if decision == "run" else "none" + elif recheck_outcome == "success": + failure_kind = "none" + else: + failure_kind = "compile_failed" + + if decision != "run": + final_outcome = "not_run" + elif failure_kind in {"content_loss", "action_failed", "scope_failed"}: + final_outcome = "final_failure" + elif recheck_outcome == "success": + final_outcome = "success" + else: + docs_root = (workspace / "docs").resolve() + pending_pages = [ + page + for page in ( + locale_page_path(locale, source, docs_root) + for source in read_manifest_sources( + workspace, + str(state.get("locale_slug", "")), + str(state.get("shard_index", "")), + str(state.get("shard_total", "")), + ) + ) + if page + ] + passing = [ + page + for page in pending_pages + if page not in failed_records and (workspace / page).is_file() + ] + final_outcome = "partial_success" if passing else "final_failure" + + first_failure = failed_records[sorted(failed_records)[0]] if failed_records else None + return { + "rounds": rounds, + "repair_attempts": rounds, + "recheck_outcome": recheck_outcome, + "failure_kind": failure_kind, + "final_outcome": final_outcome, + "failed_paths": [failed_records[path] for path in sorted(failed_records)], + "nonsyntax_failed_paths": sorted(set(nonsyntax)), + "changed_paths": changed_paths, + "deleted_paths": deleted_paths, + "violations": violations, + "first_error": first_failure, + } + + +def report(workspace: Path) -> None: + locale = os.environ["LOCALE"] + config = relay_config() + state_path = mdx_dir(workspace) / f"{locale}-repair-state.json" + state: dict[str, object] = {"decision": "not_run", "not_run_reason": "state_missing"} + if state_path.is_file(): + try: + loaded = json.loads(state_path.read_text(encoding="utf-8")) + if isinstance(loaded, dict): + state = loaded + except json.JSONDecodeError: + pass + errors = read_diagnostics(mdx_dir(workspace) / f"{locale}.json") or [] + classification = classify_report(state, errors, parse_round_outcomes(), workspace, locale) + + payload: dict[str, object] = { + "event": "final_outcome", + "locale": locale, + "repair_mode": state.get("repair_mode", "none"), + "repair_stage_order": ["parser", "auxiliary", "codex", "checker", "scope", "protected_attribute", "recheck", "artifact"], + "decision": state.get("decision", "not_run"), + "not_run_reason": state.get("not_run_reason", ""), + "max_attempts": config["max_attempts"], + "hard_timeout_ms": config["hard_timeout_ms"], + "auxiliary_mode": config["auxiliary_mode"], + "rounds_history": round_history(workspace, locale, int(config["max_attempts"])), + "parser_outcome": "compile_failure" if errors else "compile_success", + "parser_diagnostics_count": len(errors), + "error_source": None, + "error_line": None, + "error_column": None, + **classification, + } + first_error = payload.get("first_error") + if isinstance(first_error, dict): + payload["error_source"] = first_error.get("error_source") + payload["error_line"] = first_error.get("error_line") + payload["error_column"] = first_error.get("error_column") + payload.pop("first_error") + + report_path = mdx_dir(workspace) / f"{locale}-repair-report.json" + report_path.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n", encoding="utf-8") + failed_paths = [str(record["path"]) for record in payload["failed_paths"]] # type: ignore[index,union-attr] + changed_paths = [str(record["path"]) for record in payload["changed_paths"]] # type: ignore[index,union-attr] + write_outputs( + { + "final_outcome": str(payload["final_outcome"]), + "failure_kind": str(payload["failure_kind"]), + "repair_mode": str(payload["repair_mode"]), + "rounds": str(payload["rounds"]), + "recheck_outcome": str(payload["recheck_outcome"]), + "failed_paths": " ".join(failed_paths), + "nonsyntax_failed_paths": " ".join(payload["nonsyntax_failed_paths"]), # type: ignore[arg-type] + "changed_paths": " ".join(changed_paths), + "failed_count": str(len(failed_paths)), + "changed_count": str(len(changed_paths)), + } + ) + print(json.dumps(payload, sort_keys=True)) + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser( + description="Decide and report the bounded Codex MDX repair relay for one locale shard.", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog="""Outputs: + decide writes the relay state plus content snapshot and GITHUB_OUTPUT decision/reason. + report writes the relay report JSON and GITHUB_OUTPUT outcome fields. + +Examples: + LOCALE=fr LOCALE_SLUG=fr SHARD_INDEX=0 SHARD_TOTAL=1 MDX_CHECK_OUTCOME=failure python .github/scripts/i18n/mdx_repair_relay.py decide + LOCALE=fr LOCALE_SLUG=fr SHARD_INDEX=0 SHARD_TOTAL=1 MDX_CHECK_OUTCOME=failure MDX_REPAIR_ROUNDS_OUTCOMES="success success failure skipped skipped skipped skipped skipped skipped skipped skipped skipped" python .github/scripts/i18n/mdx_repair_relay.py report +""", + ) + parser.add_argument("command", choices=["decide", "report"]) + parser.add_argument("--workspace", default=os.environ.get("GITHUB_WORKSPACE", "."), type=Path) + return parser.parse_args() + + +def main() -> None: + args = parse_args() + workspace = args.workspace.resolve() + if args.command == "decide": + decide(workspace) + else: + report(workspace) + + +if __name__ == "__main__": + main() diff --git a/.github/scripts/i18n/mdx_repair_validation.py b/.github/scripts/i18n/mdx_repair_validation.py new file mode 100644 index 0000000000..0661b5a988 --- /dev/null +++ b/.github/scripts/i18n/mdx_repair_validation.py @@ -0,0 +1,433 @@ +#!/usr/bin/env python3 +"""Gates and three-state reporting for the MDX repair validation sub-pipeline. + +Definition: + Control plane for .github/workflows/mdx-repair-validation.yml (STORY-05 of + plans/i18n-codex-mdx-fallback). The sub-pipeline validates the enhanced + existing Codex repair relay (single entry: openai/codex-action@v1) against + the frozen STORY-01 fixtures and uploads evidence only; it never edits + production branches. + + oracle-gate replays the frozen STORY-01 strict oracle + (strict-mdx-oracle.mjs, @mdx-js/mdx@3.1.1 compile({jsx:true})) over the two + real fixture pages and asserts compile_failure with the diagnostics and + content hashes recorded in fixture-manifest.json. It then replays the + archived STORY-03 real-Codex repair payloads (metadata.final_outcome == + success) and asserts compile_success, so the known-good repair references + stay compilable without any secret. + + single-entry audits the validation workflow: exactly one Codex executor + entry (openai/codex-action@v1 unrolled into MDX_REPAIR_MAX_ATTEMPTS relay + rounds, each round using the shared relay prompt) and no second executor + (no `codex exec`, no direct model API endpoint). + + classify reduces one real-Codex opt-in run to exactly one explicit + classification: success, agent_failure, or environment_failure. Quota, + auth, model, and staging problems are environment_failure; relay failures + keep their per-round diagnostics and are agent_failure; nothing is + disguised as success. + +Parameters: + command: oracle-gate | single-entry | classify. + --output-dir: Report directory. Default: ${RUNNER_TEMP:-.}/mdx-repair-validation. + single-entry --workflow: Workflow file to audit. + Default: .github/workflows/mdx-repair-validation.yml. + classify --workspace: Workspace root holding .openclaw-sync/mdx. + Default: GITHUB_WORKSPACE or current directory. + classify --locale: Locale under validation. Default: zh-CN. + +Environment (classify): + MDX_VALIDATION_PREFLIGHT (ok|failed|missing), MDX_VALIDATION_PREFLIGHT_CLASS, + MDX_VALIDATION_DECISION, MDX_VALIDATION_NOT_RUN_REASON, + MDX_VALIDATION_FINAL_OUTCOME, MDX_VALIDATION_FAILURE_KIND, + MDX_VALIDATION_ROUNDS, MDX_VALIDATION_FAILED_PATHS, + MDX_VALIDATION_CHANGED_PATHS, MDX_REPAIR_MAX_ATTEMPTS, + MDX_REPAIR_HARD_TIMEOUT_MS, MDX_REPAIR_AUXILIARY_MODE. + +Outputs: + oracle-gate writes oracle-gate.json plus repaired-references/ copies and + exits non-zero when any fixture or repair-reference expectation fails. + single-entry writes single-entry.json and exits non-zero on violations. + classify copies .openclaw-sync/mdx relay diagnostics into relay/, writes + classification.json, prints the classification, and writes GITHUB_OUTPUT + classification/reason. It exits non-zero only on misconfiguration such as + an enabled auxiliary arm. + +Examples: + python .github/scripts/i18n/mdx_repair_validation.py oracle-gate --output-dir /tmp/evidence + python .github/scripts/i18n/mdx_repair_validation.py single-entry --workflow .github/workflows/mdx-repair-validation.yml + MDX_VALIDATION_PREFLIGHT=ok MDX_VALIDATION_FINAL_OUTCOME=success python .github/scripts/i18n/mdx_repair_validation.py classify +""" + +from __future__ import annotations + +import argparse +import json +import os +import re +import shutil +import subprocess +from pathlib import Path + +SCRIPT_DIR = Path(__file__).resolve().parent +REPO_ROOT = SCRIPT_DIR.parents[2] +FIXTURE_EVIDENCE = REPO_ROOT / "plans/i18n-codex-mdx-fallback/agent/evidence/story01-real-fixtures-2026-09-01" +REPAIR_EVIDENCE_ARTIFACTS = ( + REPO_ROOT / "plans/i18n-codex-mdx-fallback/agent/evidence/story03-local-loop-2026-09-01/real-opt-in/artifacts" +) +SINGLE_ENTRY_ACTION = "uses: openai/codex-action@v1" +RELAY_PROMPT_FILE = "prompt-file: .openclaw-sync/docs-mdx-repair.md" +SECOND_EXECUTOR_TOKENS = ("codex exec", "api.openai.com", "chat/completions", "/v1/responses") +DEFAULT_WORKFLOW = REPO_ROOT / ".github/workflows/mdx-repair-validation.yml" +MESSAGE_LIMIT = 200 + + +def default_output_dir() -> Path: + return Path(os.environ.get("RUNNER_TEMP") or ".") / "mdx-repair-validation" + + +def load_fixture_expectations() -> list[dict[str, object]]: + manifest = json.loads((FIXTURE_EVIDENCE / "fixture-manifest.json").read_text(encoding="utf-8")) + expectations: list[dict[str, object]] = [] + for entry in manifest.get("fixtures", []): + expectations.append( + { + "fixture_id": entry["id"], + "file": FIXTURE_EVIDENCE / entry["path"], + "expected": entry["oracle"], + "content_sha256": (entry.get("content_retention") or {}).get("sha256"), + } + ) + return expectations + + +def load_repair_references(output_dir: Path) -> list[dict[str, object]]: + references: list[dict[str, object]] = [] + for payload_path in sorted(REPAIR_EVIDENCE_ARTIFACTS.glob("*-enhanced_existing_codex_action.json")): + record = json.loads(payload_path.read_text(encoding="utf-8")) + metadata = record.get("metadata") or {} + payload = record.get("payload") + if metadata.get("final_outcome") != "success" or not payload: + continue + target = output_dir / "repaired-references" / f"{record['fixture_id']}.mdx" + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text(payload, encoding="utf-8") + references.append( + { + "fixture_id": record["fixture_id"], + "evidence": payload_path.relative_to(REPO_ROOT).as_posix(), + "file": target, + } + ) + return references + + +def run_oracle(files: list[Path]) -> list[dict[str, object]]: + result = subprocess.run( + ["node", str(FIXTURE_EVIDENCE / "strict-mdx-oracle.mjs"), *[str(path) for path in files]], + cwd=str(REPO_ROOT), + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + if result.returncode not in (0, 1): + raise SystemExit(f"strict MDX oracle failed to run (exit {result.returncode}): {result.stderr.strip()[:300]}") + try: + items = json.loads(result.stdout) + except json.JSONDecodeError as exc: + raise SystemExit(f"strict MDX oracle produced no JSON report: {exc}") from exc + if not isinstance(items, list) or len(items) != len(files): + raise SystemExit("strict MDX oracle report does not cover every input file") + return items + + +def oracle_expectation_matches(item: dict[str, object], expectation: dict[str, object]) -> bool: + if item.get("outcome") != "compile_failure": + return False + expected = expectation["expected"] or {} + if not isinstance(expected, dict): + return False + error = item.get("error") or {} + if not isinstance(error, dict): + return False + content_sha256 = expectation.get("content_sha256") + return ( + error.get("source") == expected.get("source") + and error.get("line") == expected.get("line") + and error.get("column") == expected.get("column") + and error.get("offset") == expected.get("offset") + and (content_sha256 is None or item.get("sha256") == content_sha256) + ) + + +def oracle_gate(output_dir: Path) -> dict[str, object]: + expectations = load_fixture_expectations() + if not expectations: + raise SystemExit("oracle gate misconfigured: fixture manifest has no fixtures") + references = load_repair_references(output_dir) + if not references: + raise SystemExit("oracle gate misconfigured: no archived successful repair reference found") + + fixture_results: list[dict[str, object]] = [] + for expectation, item in zip(expectations, run_oracle([entry["file"] for entry in expectations])): + fixture_results.append( + { + "fixture_id": expectation["fixture_id"], + "file": expectation["file"].relative_to(REPO_ROOT).as_posix(), + "expected": expectation["expected"], + "observed": item, + "match": oracle_expectation_matches(item, expectation), + } + ) + + reference_results: list[dict[str, object]] = [] + for reference, item in zip(references, run_oracle([Path(str(entry["file"])) for entry in references])): + reference_results.append( + { + "fixture_id": reference["fixture_id"], + "evidence": reference["evidence"], + "observed_outcome": item.get("outcome"), + "match": item.get("outcome") == "compile_success", + } + ) + + passed = all(entry["match"] for entry in fixture_results) and all(entry["match"] for entry in reference_results) + report = { + "gate": "strict-mdx-oracle", + "oracle": (FIXTURE_EVIDENCE / "strict-mdx-oracle.mjs").relative_to(REPO_ROOT).as_posix(), + "parser": "@mdx-js/mdx@3.1.1 compile({jsx:true})", + "fixture_expectations": fixture_results, + "repair_references": reference_results, + "passed": passed, + } + output_dir.mkdir(parents=True, exist_ok=True) + (output_dir / "oracle-gate.json").write_text( + json.dumps(report, indent=2, sort_keys=True) + "\n", encoding="utf-8" + ) + if not passed: + raise SystemExit("strict MDX oracle gate failed; see oracle-gate.json") + print("strict MDX oracle gate passed: frozen fixtures fail as recorded; archived repairs still compile") + return report + + +def single_entry_report(workflow_path: Path) -> dict[str, object]: + text = workflow_path.read_text(encoding="utf-8") + budget = re.search(r'MDX_REPAIR_MAX_ATTEMPTS:\s*"([1-9][0-9]*)"', text) + if not budget: + raise SystemExit("single-entry audit failed: MDX_REPAIR_MAX_ATTEMPTS budget missing") + rounds = int(budget.group(1)) + action_count = text.count(SINGLE_ENTRY_ACTION) + prompt_count = text.count(RELAY_PROMPT_FILE) + second_executors = [token for token in SECOND_EXECUTOR_TOKENS if token in text] + checks = { + "single_entry_unrolled_to_budget": action_count == rounds, + "every_round_uses_relay_prompt": prompt_count == rounds, + "no_second_executor": not second_executors, + } + try: + workflow = workflow_path.relative_to(REPO_ROOT).as_posix() + except ValueError: + workflow = str(workflow_path) + return { + "workflow": workflow, + "single_entry": SINGLE_ENTRY_ACTION, + "rounds_budget": rounds, + "action_count": action_count, + "prompt_count": prompt_count, + "second_executor_tokens": second_executors, + "checks": checks, + "passed": all(checks.values()), + } + + +def single_entry_command(workflow_path: Path, output_dir: Path) -> dict[str, object]: + report = single_entry_report(workflow_path) + output_dir.mkdir(parents=True, exist_ok=True) + (output_dir / "single-entry.json").write_text( + json.dumps(report, indent=2, sort_keys=True) + "\n", encoding="utf-8" + ) + if not report["passed"]: + raise SystemExit(f"single-entry audit failed for {workflow_path}; see single-entry.json") + print(f"single-entry audit passed: {report['action_count']} relay round(s), one Codex executor entry") + return report + + +def sanitize_reason_token(raw: str) -> str: + token = re.sub(r"[^A-Za-z0-9_.-]+", "_", raw.strip()) + return token[:100] + + +def classify_verdict() -> tuple[str, str]: + auxiliary_mode = (os.environ.get("MDX_REPAIR_AUXILIARY_MODE") or "none").strip().lower() + if auxiliary_mode != "none": + return "environment_failure", f"auxiliary_mode_{sanitize_reason_token(auxiliary_mode)}_not_enabled" + + preflight = (os.environ.get("MDX_VALIDATION_PREFLIGHT") or "missing").strip().lower() + if preflight != "ok": + failure_class = sanitize_reason_token(os.environ.get("MDX_VALIDATION_PREFLIGHT_CLASS") or "") + suffix = f"_{failure_class}" if failure_class else "" + return "environment_failure", f"preflight_{preflight}{suffix}" + + decision = (os.environ.get("MDX_VALIDATION_DECISION") or "not_run").strip() + if decision != "run": + not_run_reason = sanitize_reason_token(os.environ.get("MDX_VALIDATION_NOT_RUN_REASON") or "") + suffix = f"_{not_run_reason}" if not_run_reason else "" + return "environment_failure", f"relay_not_started{suffix}" + + final_outcome = (os.environ.get("MDX_VALIDATION_FINAL_OUTCOME") or "unavailable").strip() + if final_outcome == "success": + return "success", "frozen_fixtures_pass_strict_recheck" + if final_outcome in {"partial_success", "final_failure"}: + return "agent_failure", f"relay_{final_outcome}" + return "environment_failure", f"relay_outcome_{sanitize_reason_token(final_outcome)}" + + +def append_output(values: dict[str, str]) -> None: + output = os.environ.get("GITHUB_OUTPUT") + if not output: + return + with Path(output).open("a", encoding="utf-8") as fh: + for key, value in values.items(): + fh.write(f"{key}={value}\n") + + +def trunc(message: object) -> str: + return str(message or "").split("\n")[0][:MESSAGE_LIMIT] + + +def round_diagnostics(workspace: Path, locale: str) -> list[dict[str, object]]: + mdx_dir = workspace / ".openclaw-sync" / "mdx" + diagnostics: list[dict[str, object]] = [] + for path in sorted(mdx_dir.glob(f"{locale}-round-*.json")): + try: + payload = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + continue + errors = payload.get("errors") if isinstance(payload, dict) else None + errors = [error for error in errors if isinstance(error, dict)] if isinstance(errors, list) else [] + entry: dict[str, object] = {"round_file": path.name, "error_count": len(errors)} + if errors: + first = errors[0] + entry["first_error"] = { + "type": first.get("type", ""), + "file": first.get("file", ""), + "line": first.get("line"), + "column": first.get("column"), + "message": trunc(first.get("message")), + } + diagnostics.append(entry) + return diagnostics + + +def collect_relay_evidence(workspace: Path, locale: str, output_dir: Path) -> list[str]: + mdx_dir = workspace / ".openclaw-sync" / "mdx" + relay_dir = output_dir / "relay" + relay_dir.mkdir(parents=True, exist_ok=True) + copied: list[str] = [] + sources = list(mdx_dir.parent.glob(f"mdx/{locale}*")) if mdx_dir.parent.is_dir() else [] + runner_temp = os.environ.get("RUNNER_TEMP") + if runner_temp: + sources.extend( + Path(runner_temp) / name + for name in (f"{locale}.repair-baseline.txt", f"{locale}.repair-content-snapshot.json") + ) + for source in sorted(set(sources)): + if source.is_file(): + shutil.copy2(source, relay_dir / source.name) + copied.append(source.name) + return copied + + +def split_env_paths(name: str) -> list[str]: + return [path for path in (os.environ.get(name) or "").split() if path] + + +def classify_command(workspace: Path, locale: str, output_dir: Path) -> dict[str, object]: + auxiliary_mode = (os.environ.get("MDX_REPAIR_AUXILIARY_MODE") or "none").strip().lower() + classification, reason = classify_verdict() + + mdx_dir = workspace / ".openclaw-sync" / "mdx" + relay_report: dict[str, object] | None = None + report_path = mdx_dir / f"{locale}-repair-report.json" + if report_path.is_file(): + try: + loaded = json.loads(report_path.read_text(encoding="utf-8")) + relay_report = loaded if isinstance(loaded, dict) else None + except json.JSONDecodeError: + relay_report = None + + collected = collect_relay_evidence(workspace, locale, output_dir) + payload = { + "event": "validation_classification", + "classification": classification, + "reason": reason, + "locale": locale, + "budgets": { + "max_attempts": os.environ.get("MDX_REPAIR_MAX_ATTEMPTS") or "4", + "hard_timeout_ms": os.environ.get("MDX_REPAIR_HARD_TIMEOUT_MS") or "600000", + "auxiliary_mode": auxiliary_mode, + }, + "preflight": { + "outcome": (os.environ.get("MDX_VALIDATION_PREFLIGHT") or "missing").strip().lower(), + "failure_class": os.environ.get("MDX_VALIDATION_PREFLIGHT_CLASS") or "", + }, + "relay": { + "decision": (os.environ.get("MDX_VALIDATION_DECISION") or "not_run").strip(), + "not_run_reason": os.environ.get("MDX_VALIDATION_NOT_RUN_REASON") or "", + "final_outcome": (os.environ.get("MDX_VALIDATION_FINAL_OUTCOME") or "unavailable").strip(), + "failure_kind": os.environ.get("MDX_VALIDATION_FAILURE_KIND") or "", + "rounds": os.environ.get("MDX_VALIDATION_ROUNDS") or "0", + "failed_paths": split_env_paths("MDX_VALIDATION_FAILED_PATHS"), + "changed_paths": split_env_paths("MDX_VALIDATION_CHANGED_PATHS"), + "round_diagnostics": round_diagnostics(workspace, locale), + "report": relay_report, + }, + "relay_evidence_files": collected, + } + output_dir.mkdir(parents=True, exist_ok=True) + (output_dir / "classification.json").write_text( + json.dumps(payload, indent=2, sort_keys=True) + "\n", encoding="utf-8" + ) + append_output({"classification": classification, "reason": reason}) + print(json.dumps({"classification": classification, "reason": reason}, sort_keys=True)) + return payload + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser( + description="Offline gates and three-state reporting for the MDX repair validation sub-pipeline.", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog="""Outputs: + oracle-gate writes oracle-gate.json plus repaired-references/ copies. + single-entry writes single-entry.json. + classify writes classification.json, copies relay diagnostics, and writes GITHUB_OUTPUT classification/reason. + +Examples: + python .github/scripts/i18n/mdx_repair_validation.py oracle-gate --output-dir /tmp/evidence + python .github/scripts/i18n/mdx_repair_validation.py single-entry --workflow .github/workflows/mdx-repair-validation.yml + MDX_VALIDATION_PREFLIGHT=ok MDX_VALIDATION_FINAL_OUTCOME=success python .github/scripts/i18n/mdx_repair_validation.py classify +""", + ) + parser.add_argument("command", choices=["oracle-gate", "single-entry", "classify"]) + parser.add_argument("--output-dir", default=default_output_dir(), type=Path) + parser.add_argument("--workflow", default=DEFAULT_WORKFLOW, type=Path) + parser.add_argument("--workspace", default=os.environ.get("GITHUB_WORKSPACE", "."), type=Path) + parser.add_argument("--locale", default=os.environ.get("MDX_VALIDATION_LOCALE", "zh-CN")) + return parser.parse_args() + + +def main() -> None: + args = parse_args() + output_dir = args.output_dir.resolve() + if args.command == "oracle-gate": + oracle_gate(output_dir) + elif args.command == "single-entry": + single_entry_command(args.workflow.resolve(), output_dir) + else: + payload = classify_command(args.workspace.resolve(), args.locale, output_dir) + if payload["budgets"]["auxiliary_mode"] != "none": # type: ignore[index] + raise SystemExit("MDX_REPAIR_AUXILIARY_MODE must stay none; the validation pipeline is fail-closed") + + +if __name__ == "__main__": + main() diff --git a/.github/scripts/i18n/package_artifact.py b/.github/scripts/i18n/package_artifact.py index 7e843c7538..615c1a6810 100644 --- a/.github/scripts/i18n/package_artifact.py +++ b/.github/scripts/i18n/package_artifact.py @@ -4,8 +4,11 @@ Definition: This script mirrors the artifact packaging block from translate-locale-reusable.yml. It determines failure reason from prior step - outcomes, filters changed/deleted paths to the shard contract, copies payload - files, and writes metadata.json without changing translation semantics. + outcomes, rescues unparseable translated MDX (parser-guided syntax repair), + re-syncs protected MDX attributes, filters changed/deleted paths to the + shard contract, copies payload files, and writes metadata.json. + Syntax repair only rewrites markup tokens the MDX parser reports as broken; + translated prose is preserved. Parameters: --workspace: GitHub workspace root. Default: GITHUB_WORKSPACE or current dir. @@ -16,6 +19,13 @@ WORKER_PARALLEL, THINKING_EFFORT, PENDING_COUNT, TOTAL_PENDING_COUNT, ALL_COUNT, optional ARTIFACT_ROLE, TRANSLATE_OUTCOME, MDX_CHECK_OUTCOME, MDX_REPAIR_OUTCOME, MDX_SCOPE_OUTCOME, and MDX_RECHECK_OUTCOME. + MDX repair relay (mdx_repair_relay.py): MDX_REPAIR_FINAL_OUTCOME, + MDX_REPAIR_FAILURE_KIND, MDX_REPAIR_MODE, MDX_REPAIR_ROUNDS, + MDX_REPAIR_FAILED_PATHS, MDX_REPAIR_NONSYNTAX_FAILED_PATHS, and + MDX_REPAIR_CHANGED_PATHS. Partial-success protection (AC-05): when the + bounded Codex relay could not fix every page, pages that still fail the + strict parser are excluded from the artifact and explicitly marked in + metadata instead of silently dropping the shard's successful pages. Outputs: Writes .openclaw-sync/artifacts/-sof/ with @@ -45,6 +55,7 @@ I18N_MARKER_PREFIX_RE = re.compile(r"__oc_i18n_", re.IGNORECASE) MDX_PROTECTED_ATTRIBUTE_CHECKER = Path(__file__).with_name("check_mdx_protected_attributes.mjs") MDX_PROTECTED_ATTRIBUTE_REPAIR = Path(__file__).with_name("repair_mdx_protected_attributes.mjs") +MDX_SYNTAX_REPAIR = Path(__file__).with_name("repair_mdx_syntax.mjs") def git_lines(args: list[str]) -> list[str]: @@ -59,12 +70,19 @@ def env_int(name: str) -> int: raise SystemExit(f"invalid {name}: {os.environ.get(name, '')}") from exc +def relay_env_paths(name: str) -> list[str]: + raw = os.environ.get(name, "") + return [path for path in raw.replace(",", " ").split() if path] + + def failure_reason() -> str: translate_outcome = os.environ.get("TRANSLATE_OUTCOME", "skipped") mdx_check_outcome = os.environ.get("MDX_CHECK_OUTCOME", "skipped") mdx_repair_outcome = os.environ.get("MDX_REPAIR_OUTCOME", "skipped") mdx_scope_outcome = os.environ.get("MDX_SCOPE_OUTCOME", "skipped") mdx_recheck_outcome = os.environ.get("MDX_RECHECK_OUTCOME", "skipped") + relay_final_outcome = os.environ.get("MDX_REPAIR_FINAL_OUTCOME", "not_run") + relay_failure_kind = os.environ.get("MDX_REPAIR_FAILURE_KIND", "none") if translate_outcome == "failure": return "translation failed" @@ -74,6 +92,14 @@ def failure_reason() -> str: if mdx_scope_outcome == "failure": return "mdx repair scope failed" if mdx_recheck_outcome != "success": + # The bounded relay finished its gates; per-page partial success + # keeps the shard's passing pages instead of dropping them. + if relay_final_outcome in {"success", "partial_success"}: + return "" + if relay_failure_kind == "scope_failed": + return "mdx repair scope failed" + if relay_failure_kind == "content_loss": + return "mdx repair deleted translated content" return "mdx repair failed" return "" @@ -180,14 +206,141 @@ def drifted_mdx_protected_attribute_paths(workspace: Path, locale: str, changed: return drifted + parsed_drifted -def repair_mdx_protected_attributes( +def repair_mdx_syntax( workspace: Path, locale: str, locale_slug: str, shard_index: int, shard_total: int, + manifest_path: Path | None = None, ) -> tuple[str, list[str], bool]: + """Make translated MDX parse again before attribute-level repair runs. + + Returns (error, repaired workspace-relative paths, ran). The protected- + attribute repair assumes a parseable document, so files this stage cannot + rescue still fail the shard here with a parser-backed reason. + """ + manifest = manifest_path or workspace / ".openclaw-sync" / f"docs-i18n-{locale_slug}-s{shard_index}of{shard_total}.txt" + if not manifest.is_file(): + return f"missing pending manifest: {manifest}", [], False + source_paths = [Path(line) for line in manifest.read_text(encoding="utf-8").splitlines() if line.strip()] + repairable_sources = [path for path in source_paths if path.suffix in {".md", ".mdx"}] + if not repairable_sources: + return "", [], False + module_root = workspace + if not (module_root / "node_modules/@mdx-js/mdx/package.json").is_file(): + repository_root = Path(__file__).resolve().parents[3] + if (repository_root / "node_modules/@mdx-js/mdx/package.json").is_file(): + module_root = repository_root + result = subprocess.run( + [ + "node", + str(MDX_SYNTAX_REPAIR), + "--workspace", + str(workspace), + "--locale", + locale, + "--manifest", + str(manifest), + "--module-root", + str(module_root), + ], + check=False, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + if result.returncode != 0: + return result.stderr.strip() or result.stdout.strip() or "unknown error", [], True + try: + output = json.loads(result.stdout) + except json.JSONDecodeError as exc: + return f"syntax repair returned invalid JSON: {exc}", [], True + repaired = output.get("repaired") + if not isinstance(repaired, list) or not all(isinstance(path, str) for path in repaired): + return "syntax repair returned invalid paths", [], True + docs_root = (workspace / "docs").resolve() + try: + allowed_repaired = { + f"docs/{locale}/{source.resolve().relative_to(docs_root).as_posix()}" for source in repairable_sources + } + except ValueError: + return "pending manifest source escapes docs root", [], True + if any(path not in allowed_repaired for path in repaired): + return "syntax repair returned path outside pending manifest", [], True + return "", repaired, True + + +def repair_mdx_syntax_with_salvage( + workspace: Path, + locale: str, + locale_slug: str, + shard_index: int, + shard_total: int, +) -> tuple[str, list[str], list[str], bool]: + """Run the deterministic syntax rescue with per-page salvage. + + Pages the repair chain cannot rescue are returned as still-failing + workspace locale paths instead of failing the whole shard, so partial + success (AC-05) can exclude exactly those pages while keeping the shard's + successful translations. Infrastructure failures (a repair run that does + not name a failing page) stay hard errors: uncertain states must fail the + shard rather than look like per-page salvage. + + Returns (error, repaired paths, still-failing locale paths, ran). + """ manifest = workspace / ".openclaw-sync" / f"docs-i18n-{locale_slug}-s{shard_index}of{shard_total}.txt" + if not manifest.is_file(): + return f"missing pending manifest: {manifest}", [], [], False + source_paths = [Path(line) for line in manifest.read_text(encoding="utf-8").splitlines() if line.strip()] + repairable_sources = [path for path in source_paths if path.suffix in {".md", ".mdx"}] + if not repairable_sources: + return "", [], [], False + docs_root = (workspace / "docs").resolve() + + def locale_path(source: Path) -> str | None: + try: + return f"docs/{locale}/{source.resolve().relative_to(docs_root).as_posix()}" + except ValueError: + return None + + salvage_manifest = workspace / ".openclaw-sync" / "mdx" / f"{locale}.package-partial-manifest.txt" + salvage_manifest.parent.mkdir(parents=True, exist_ok=True) + repaired: list[str] = [] + still_failing: list[str] = [] + remaining = list(repairable_sources) + for _attempt in range(len(repairable_sources) + 1): + if not remaining: + break + salvage_manifest.write_text("\n".join(str(path) for path in remaining) + "\n", encoding="utf-8") + error, batch, _ran = repair_mdx_syntax( + workspace, locale, locale_slug, shard_index, shard_total, manifest_path=salvage_manifest + ) + repaired.extend(batch) + if not error: + break + match = re.search(rf"Error: docs/{re.escape(locale)}/([^:\n]+):", error) + failed_path = f"docs/{locale}/{match.group(1)}" if match else "" + if not failed_path or failed_path in still_failing or failed_path not in { + locale_path(source) for source in remaining + }: + # Not a per-page failure (or no progress): fail the shard closed. + return error, repaired, still_failing, True + still_failing.append(failed_path) + remaining = [source for source in remaining if locale_path(source) != failed_path] + salvage_manifest.unlink(missing_ok=True) + return "", repaired, still_failing, True + + +def repair_mdx_protected_attributes( + workspace: Path, + locale: str, + locale_slug: str, + shard_index: int, + shard_total: int, + manifest_path: Path | None = None, +) -> tuple[str, list[str], bool]: + manifest = manifest_path or workspace / ".openclaw-sync" / f"docs-i18n-{locale_slug}-s{shard_index}of{shard_total}.txt" if not manifest.is_file(): return f"missing pending manifest: {manifest}", [], False source_paths = [Path(line) for line in manifest.read_text(encoding="utf-8").splitlines() if line.strip()] @@ -265,6 +418,34 @@ def append_summary(metadata: dict[str, object]) -> None: failed_reason = str(metadata.get("failed_reason") or "") if failed_reason: fh.write(f"- failure: `{failed_reason}`\n") + syntax_outcome = str(metadata.get("mdx_syntax_repair_outcome") or "skipped") + if syntax_outcome != "skipped": + fh.write(f"- mdx syntax repair: `{syntax_outcome}`\n") + repair_mode = str(metadata.get("mdx_repair_mode") or "none") + if repair_mode != "none": + fh.write( + f"- mdx repair relay: mode=`{repair_mode}` rounds=`{metadata.get('mdx_repair_rounds', 0)}` " + f"final=`{metadata.get('mdx_repair_final_outcome', 'not_run')}`\n" + ) + failed_paths = metadata.get("mdx_repair_failed_paths") or [] + if failed_paths: + fh.write(f"- mdx repair unresolved pages: `{', '.join(str(path) for path in failed_paths)}`\n") + + +def relay_metadata() -> dict[str, object]: + """Carry the bounded Codex relay outcome (D-09) into artifact metadata.""" + rounds_raw = os.environ.get("MDX_REPAIR_ROUNDS", "0") + try: + rounds = int(rounds_raw) + except ValueError: + rounds = 0 + return { + "mdx_repair_mode": os.environ.get("MDX_REPAIR_MODE", "none") or "none", + "mdx_repair_rounds": max(rounds, 0), + "mdx_repair_final_outcome": os.environ.get("MDX_REPAIR_FINAL_OUTCOME", "not_run") or "not_run", + "mdx_repair_failed_paths": sorted(set(relay_env_paths("MDX_REPAIR_FAILED_PATHS"))), + "mdx_repair_changed_paths": sorted(set(relay_env_paths("MDX_REPAIR_CHANGED_PATHS"))), + } def package_artifact(workspace: Path, openclaw_sync_dir: Path) -> dict[str, object]: @@ -283,6 +464,10 @@ def package_artifact(workspace: Path, openclaw_sync_dir: Path) -> dict[str, obje deleted_path = artifact_dir / "deleted-files.txt" protected_attribute_repair_outcome = "skipped" + mdx_syntax_repair_outcome = "skipped" + excluded_paths: list[str] = [] + marked_failed_paths: list[str] = [] + effective_relay_outcome = os.environ.get("MDX_REPAIR_FINAL_OUTCOME", "not_run") or "not_run" if failed_reason: write_lines(changed_path, []) write_lines(deleted_path, []) @@ -293,27 +478,75 @@ def package_artifact(workspace: Path, openclaw_sync_dir: Path) -> dict[str, obje deleted = git_lines(["diff", "--name-only", "--diff-filter=D", "--", f"docs/{locale}", f"docs/.i18n/{locale}.tm.jsonl"]) allowed = read_pending_allowed(workspace, locale, locale_slug, shard_index, shard_total) - protected_attribute_repair_error, repaired_paths, protected_attribute_repair_ran = repair_mdx_protected_attributes( + # Syntax rescue must run first: the protected-attribute repair needs a + # document the MDX parser accepts before it can compare attributes. + # Packaging is also the first gate that sees .md pages under MDX + # semantics: check-docs-mdx compiles by extension (markdown for .md), + # so JSX damage in .md files passes the workflow check and only fails + # the strict format:"mdx" parsers in this repair chain. Pages this + # rescue cannot fix are salvaged per page (AC-05): they are excluded + # from the artifact and marked, instead of silently dropping the + # shard's successful pages. + syntax_repair_error, syntax_repaired_paths, still_failing_paths, syntax_repair_ran = repair_mdx_syntax_with_salvage( workspace, locale, locale_slug, shard_index, shard_total ) + relay_nonsyntax_paths = relay_env_paths("MDX_REPAIR_NONSYNTAX_FAILED_PATHS") + # Relay-diagnosed non-syntax failures (poison-text, Mintlify structure) + # cannot be fixed by the syntax rescue, so they stay excluded. + excluded_paths = sorted( + (set(still_failing_paths) | set(relay_nonsyntax_paths)) & allowed + ) + mdx_syntax_repair_outcome = ( + "failure" + if syntax_repair_error + else "partial" if excluded_paths else "success" if syntax_repair_ran else "skipped" + ) + if syntax_repair_error: + print(f"MDX syntax repair failed: {syntax_repair_error}", file=sys.stderr) + protected_manifest: Path | None = None + if excluded_paths: + excluded_sources = {(workspace / "docs" / path.removeprefix(f"docs/{locale}/")) for path in excluded_paths} + original_manifest = workspace / ".openclaw-sync" / f"docs-i18n-{locale_slug}-s{shard_index}of{shard_total}.txt" + if original_manifest.is_file(): + kept = [ + line + for line in original_manifest.read_text(encoding="utf-8").splitlines() + if line.strip() and Path(line.strip()) not in excluded_sources + ] + filtered_manifest = workspace / ".openclaw-sync" / "mdx" / f"{locale}.package-protected-manifest.txt" + filtered_manifest.parent.mkdir(parents=True, exist_ok=True) + filtered_manifest.write_text("\n".join(kept) + ("\n" if kept else ""), encoding="utf-8") + protected_manifest = filtered_manifest + protected_attribute_repair_error, repaired_paths, protected_attribute_repair_ran = repair_mdx_protected_attributes( + workspace, locale, locale_slug, shard_index, shard_total, manifest_path=protected_manifest + ) protected_attribute_repair_outcome = ( "failure" if protected_attribute_repair_error else "success" if protected_attribute_repair_ran else "skipped" ) if protected_attribute_repair_error: print(f"MDX protected attribute repair failed: {protected_attribute_repair_error}", file=sys.stderr) # A repair can be the only working-tree change for a pending page, so - # include the script's validated paths in the pre-repair Git snapshot. - changed = sorted(set(changed + repaired_paths)) + # include both repairs' validated paths in the pre-repair Git snapshot. + changed = sorted(set(changed + syntax_repaired_paths + repaired_paths)) # The finalizer treats every changed-files.txt entry as a required # payload file, so allowed-but-missing TM paths must not be advertised. shard_changed = [line for line in changed if line in allowed and (workspace / line).is_file()] + # Partial-success protection (AC-05): pages the repair chain could not + # rescue (or with unfixable non-syntax damage) are excluded from the + # artifact and explicitly marked in metadata; the shard's successful + # pages are still packaged so completed translations are not wasted. + shard_changed = [line for line in shard_changed if line not in set(excluded_paths)] leaked_paths = leaked_i18n_protocol_paths(workspace, locale, shard_changed) protected_attribute_drift = ( drifted_mdx_protected_attribute_paths(workspace, locale, shard_changed) - if not protected_attribute_repair_error + if not syntax_repair_error and not protected_attribute_repair_error else [] ) - if protected_attribute_repair_error: + if syntax_repair_error: + failed_reason = "mdx syntax repair failed" + shard_changed = [] + deleted = [] + elif protected_attribute_repair_error: failed_reason = "mdx protected attribute repair failed" shard_changed = [] deleted = [] @@ -335,8 +568,21 @@ def package_artifact(workspace: Path, openclaw_sync_dir: Path) -> dict[str, obje for index, line in enumerate(sorted(line for line in deleted if not line.startswith("docs/.i18n/"))) if index % shard_total == shard_index ] + # A repair-phase-deleted page is an unresolved failure, not an + # intentional deletion the finalizer should apply. + shard_deleted = [line for line in shard_deleted if line not in set(excluded_paths)] write_lines(changed_path, shard_changed) write_lines(deleted_path, shard_deleted) + relay_nonsyntax_paths = relay_env_paths("MDX_REPAIR_NONSYNTAX_FAILED_PATHS") + marked_failed_paths = sorted( + set(excluded_paths) | {path for path in relay_nonsyntax_paths if path.startswith(f"docs/{locale}/")} + ) + if excluded_paths: + effective_relay_outcome = "partial_success" + elif effective_relay_outcome == "partial_success": + # Every page the relay left behind was rescued by the + # deterministic arm, so the shard result is a full success. + effective_relay_outcome = "success" for file_name in [line for line in changed_path.read_text(encoding="utf-8").splitlines() if line.strip()]: source = Path(file_name) @@ -348,6 +594,10 @@ def package_artifact(workspace: Path, openclaw_sync_dir: Path) -> dict[str, obje changed_files = [line for line in changed_path.read_text(encoding="utf-8").splitlines() if line.strip()] deleted_files = [line for line in deleted_path.read_text(encoding="utf-8").splitlines() if line.strip()] + relay_report = workspace / ".openclaw-sync" / "mdx" / f"{locale}-repair-report.json" + if relay_report.is_file(): + shutil.copy2(relay_report, artifact_dir / "mdx-repair-report.json") + relay = relay_metadata() metadata: dict[str, object] = { "locale": locale, "locale_slug": locale_slug, @@ -368,7 +618,13 @@ def package_artifact(workspace: Path, openclaw_sync_dir: Path) -> dict[str, obje "mdx_repair_outcome": os.environ.get("MDX_REPAIR_OUTCOME", "skipped"), "mdx_scope_outcome": os.environ.get("MDX_SCOPE_OUTCOME", "skipped"), "mdx_recheck_outcome": os.environ.get("MDX_RECHECK_OUTCOME", "skipped"), + "mdx_syntax_repair_outcome": mdx_syntax_repair_outcome, "mdx_protected_attribute_repair_outcome": protected_attribute_repair_outcome, + "mdx_repair_mode": relay["mdx_repair_mode"], + "mdx_repair_rounds": relay["mdx_repair_rounds"], + "mdx_repair_final_outcome": effective_relay_outcome, + "mdx_repair_failed_paths": marked_failed_paths, + "mdx_repair_changed_paths": relay["mdx_repair_changed_paths"], "failed_reason": failed_reason, } (artifact_dir / "metadata.json").write_text(json.dumps(metadata, indent=2, sort_keys=True) + "\n", encoding="utf-8") diff --git a/.github/scripts/i18n/repair_mdx_protected_attributes.mjs b/.github/scripts/i18n/repair_mdx_protected_attributes.mjs index 9dc9c40d55..4a30183094 100644 --- a/.github/scripts/i18n/repair_mdx_protected_attributes.mjs +++ b/.github/scripts/i18n/repair_mdx_protected_attributes.mjs @@ -44,7 +44,11 @@ function literalMarkdownRanges(markdownProcessor, source) { } function blankPreservingNewlines(value) { - return value.replace(/[^\n]/gu, " "); + // No `u` flag: matching per UTF-16 code unit (surrogate pairs become two + // spaces) keeps the masked string length-equal so parser offsets map back + // onto the original document. With `u`, an astral character would collapse + // to one space and shift every later diagnostic offset. + return value.replace(/[^\n]/g, " "); } function replaceWithOffsetMap(prepared, offsets, start, end, replacement) { @@ -62,19 +66,39 @@ function replaceWithOffsetMap(prepared, offsets, start, end, replacement) { } function parseMdxForOffsets(processor, markdownProcessor, value) { + // Exported for repair_mdx_syntax.mjs: diagnostics come back with maskOffsets + // (prepared→original offset map) and maskedSource so callers can map error + // positions back onto the untouched document. let prepared = value; let offsets = Array.from({ length: value.length + 1 }, (_, index) => index); for (let attempt = 0; attempt < 1000; attempt += 1) { try { return { tree: processor.parse(prepared), offsets }; } catch (error) { - const offset = error.place?.offset; - if (!Number.isInteger(offset)) throw error; + // mdast-level diagnostics carry a Position (place.start/end) rather + // than a micromark Point; accept both so their offsets reach the + // masking logic below. + const offset = error.place?.start?.offset ?? error.place?.offset; + if (!Number.isInteger(offset)) { + // Every escape from this loop must carry the current mask state so + // callers can map diagnostic offsets back onto the real document. + error.maskOffsets = offsets; + error.maskedSource = prepared; + throw error; + } const opening = prepared.lastIndexOf("<", offset); - if (opening < 0 || prepared.slice(opening, offset).includes(">")) throw error; + if (opening < 0 || prepared.slice(opening, offset).includes(">")) { + error.maskOffsets = offsets; + error.maskedSource = prepared; + throw error; + } if (prepared.startsWith("", opening + 4); - if (closing < 0) throw error; + if (closing < 0) { + error.maskOffsets = offsets; + error.maskedSource = prepared; + throw error; + } ({ prepared, offsets } = replaceWithOffsetMap( prepared, offsets, @@ -88,13 +112,19 @@ function parseMdxForOffsets(processor, markdownProcessor, value) { const literal = literalMarkdownRanges(markdownProcessor, prepared).some( ([start, end]) => opening >= start && opening < end, ); - if (!literal && JSX_TAG_START_RE.test(prepared[opening + 1] || "")) throw error; + if (!literal && JSX_TAG_START_RE.test(prepared[opening + 1] || "")) { + error.maskOffsets = offsets; + error.maskedSource = prepared; + throw error; + } ({ prepared, offsets } = replaceWithOffsetMap(prepared, offsets, opening, opening + 1, "<")); } } throw new Error("too many rejected non-MDX less-than tokens"); } +export { parseMdxForOffsets }; + function collectElements(parsed, value) { const { tree, offsets } = parsed; const elements = []; @@ -264,8 +294,18 @@ function parseArgs(argv) { return values; } +// The locale is joined into a writable path before any repository-relative +// validation runs, so a value like ".." would let a repair write outside +// docs/. Reject anything that is not one plain path segment. +function assertSafeLocale(locale) { + if (!locale || locale === "." || locale === ".." || /[/\\\0]/u.test(locale)) { + throw new Error(`locale must be a single safe path segment: ${JSON.stringify(locale)}`); + } +} + async function main() { const args = parseArgs(process.argv.slice(2)); + assertSafeLocale(args.locale); const workspace = path.resolve(args.workspace); const docsRoot = path.join(workspace, "docs"); const manifest = path.resolve(workspace, args.manifest); diff --git a/.github/scripts/i18n/repair_mdx_syntax.mjs b/.github/scripts/i18n/repair_mdx_syntax.mjs new file mode 100644 index 0000000000..8257650c0f --- /dev/null +++ b/.github/scripts/i18n/repair_mdx_syntax.mjs @@ -0,0 +1,500 @@ +#!/usr/bin/env node + +// Syntax-level rescue for translated MDX that no longer parses. Translations +// are expensive, so instead of discarding a shard we patch only the markup +// tokens the MDX parser itself reports as broken, keeping translated prose +// intact. Acceptance mirrors the downstream repair chain (the checker's +// tolerant parseMdx), and diagnosis reuses the same tolerant masking with an +// offset map, so valid Markdown constructs (HTML comments, prose less-than) +// never produce diagnostics here and never get touched. Anything that cannot +// be repaired deterministically (broken JS expressions, budget exhaustion) +// hard-fails the shard like before. Note: check-docs-mdx compiles pages by +// file extension, so .md damage with JSX-looking text passes that gate and +// first surfaces here. + +import fs from "node:fs"; +import path from "node:path"; +import process from "node:process"; +import { createRequire } from "node:module"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +import { parseMdx } from "./check_mdx_protected_attributes.mjs"; +import { parseMdxForOffsets } from "./repair_mdx_protected_attributes.mjs"; + +const MAX_PATCHES_PER_FILE = 64; + +// Void HTML elements cannot take a closing tag; MDX requires self-closing. +const VOID_HTML_ELEMENTS = new Set([ + "area", "base", "br", "col", "embed", "hr", "img", "input", "link", "meta", "param", "source", "track", "wbr", +]); + +// "Expected a closing tag for `` before the end of `paragraph`". The +// message's range is the opening token; error.place only spans the enclosing +// block. Offsets are recovered from the masked document and mapped back. +const UNCLOSED_TAG_RE = /^Expected a closing tag for `<([^`<>]+)>` \((\d+):(\d+)-(\d+):(\d+)\)/; +// Flow-level variant: "Expected the closing tag `` either after the end +// of `paragraph` (2:32) or another opening tag after the start of `paragraph` +// (2:3)". The first pair is where the closer is expected; the second pair is +// the start of the paragraph that sits inside the unclosed element. +const UNCLOSED_FLOW_TAG_RE = /^Expected the closing tag `<\/([^`<>]+)>` either after the end of `paragraph` \((\d+):(\d+)\) or another opening tag after the start of `paragraph` \((\d+):(\d+)\)/; +// "Unexpected closing tag ``, expected corresponding closing tag for +// `
` (1:1-1:6)". Here error.place already points at the stray token. +const STRAY_CLOSING_TAG_RE = /^Unexpected closing tag `<([^`<>]+)>`/; + +class SyntaxRepairExhausted extends Error {} + +function blankPreservingNewlines(value) { + // No `u` flag: matching per UTF-16 code unit (surrogate pairs become two + // spaces) keeps the masked string length-equal so parser offsets map back + // onto the original document. With `u`, an astral character would collapse + // to one space and shift every later diagnostic offset. + return value.replace(/[^\n]/g, " "); +} + +// Length-preserving so parser offsets taken from the masked copy stay valid +// against the original document. Terminated comments are accepted downstream +// (the repair chain masks them), so they must not produce diagnostics here; +// unterminated ones stay visible for the repair rules to close. +function maskTerminatedHtmlComments(value) { + let result = ""; + let cursor = 0; + for (;;) { + const start = value.indexOf("", start + 4); + if (end < 0) return result + value.slice(cursor); + result += value.slice(cursor, start) + blankPreservingNewlines(value.slice(start, end + 3)); + cursor = end + 3; + } +} + +function lineStarts(value) { + const starts = [0]; + for (let index = 0; index < value.length; index += 1) { + if (value[index] === "\n") starts.push(index + 1); + } + return starts; +} + +function offsetForLineCol(starts, line, column) { + const start = starts[line - 1]; + if (start === undefined) return undefined; + return start + column - 1; +} + +function placeRange(place) { + if (!place || !place.start || !place.end) return undefined; + if (!Number.isInteger(place.start.offset) || !Number.isInteger(place.end.offset)) return undefined; + return [place.start.offset, place.end.offset]; +} + +function collectElementNames(tree) { + const names = new Set(); + function visitEstree(node) { + if (Array.isArray(node)) { + for (const item of node) visitEstree(item); + return; + } + if (!node || typeof node !== "object") return; + if (node.type === "JSXElement") { + const name = node.openingElement?.name; + if (name?.type === "JSXIdentifier") names.add(name.name); + } + for (const [key, value] of Object.entries(node)) { + if (["comments", "loc", "position", "range", "tokens"].includes(key)) continue; + visitEstree(value); + } + } + function visitMdast(node) { + if (!node || typeof node !== "object") return; + if ((node.type === "mdxJsxFlowElement" || node.type === "mdxJsxTextElement") && typeof node.name === "string") { + names.add(node.name); + } + if (node.data?.estree) visitEstree(node.data.estree); + if (Array.isArray(node.children)) { + for (const child of node.children) visitMdast(child); + } + } + visitMdast(tree); + return names; +} + +// Diagnose with the same tolerant masking the downstream chain applies, then +// map every offset back to the untouched document. Masking never moves line +// boundaries, so line numbers are shared; only columns and raw offsets need +// the map. +function diagnoseWithDownstreamMasking(processor, markdownProcessor, value) { + let parsed; + try { + parsed = parseMdxForOffsets(processor, markdownProcessor, value); + } catch (error) { + const offsets = error.maskOffsets; + const maskedSource = error.maskedSource; + if (!Array.isArray(offsets) || typeof maskedSource !== "string") { + throw new SyntaxRepairExhausted("diagnostic has no offset map for the masked document"); + } + const starts = lineStarts(maskedSource); + const mapped = (preparedOffset) => + Number.isInteger(preparedOffset) && preparedOffset >= 0 && preparedOffset < offsets.length + ? offsets[preparedOffset] + : undefined; + const place = { + start: { offset: mapped(error.place?.start?.offset ?? error.place?.offset) }, + }; + const endOffset = mapped(error.place?.end?.offset); + if (Number.isInteger(endOffset)) place.end = { offset: endOffset }; + + const normalized = { message: error.message || String(error), ruleId: error.ruleId, place }; + + const unclosed = UNCLOSED_TAG_RE.exec(normalized.message); + if (unclosed) { + const tokenStart = mapped(offsetForLineCol(starts, Number(unclosed[2]), Number(unclosed[3]))); + const tokenEnd = mapped(offsetForLineCol(starts, Number(unclosed[4]), Number(unclosed[5]))); + if (Number.isInteger(tokenStart) && Number.isInteger(tokenEnd)) normalized.tokenRange = [tokenStart, tokenEnd]; + } + const flow = UNCLOSED_FLOW_TAG_RE.exec(normalized.message); + if (flow) { + normalized.closerLine = Number(flow[2]); + normalized.paragraphStartOffset = mapped(offsetForLineCol(starts, Number(flow[4]), Number(flow[5]))); + } + return { accepted: false, diagnostic: normalized }; + } + void parsed; + return { accepted: true, diagnostic: null }; +} + +// Ranges the MDX parser treats as literal text (code fences, inline code, +// autolink-style links), so opener searches can skip them. +function literalMarkdownRanges(markdownProcessor, value) { + const ranges = []; + function visit(node) { + if (!node || typeof node !== "object") return; + if ((node.type === "code" || node.type === "inlineCode") && Number.isInteger(node.position?.start?.offset)) { + ranges.push([node.position.start.offset, node.position.end.offset]); + } + if (node.type === "link" && Number.isInteger(node.position?.start?.offset)) { + const raw = value.slice(node.position.start.offset, node.position.end.offset); + if (raw.startsWith("<") && raw.endsWith(">")) ranges.push([node.position.start.offset, node.position.end.offset]); + } + if (Array.isArray(node.children)) { + for (const child of node.children) visit(child); + } + } + visit(markdownProcessor.parse(value)); + return ranges; +} + +function inRanges(offset, ranges) { + return ranges.some(([start, end]) => offset >= start && offset < end); +} + +// End offset of the tag opening at tokenStart, honoring quoted attributes so +// a `>` inside an attribute value does not end the scan early. +function tagEndOffset(maskedValue, tokenStart) { + let quote = null; + for (let index = tokenStart; index < maskedValue.length; index += 1) { + const ch = maskedValue[index]; + if (quote) { + if (ch === quote) quote = null; + } else if (ch === '"' || ch === "'") { + quote = ch; + } else if (ch === ">") { + return index; + } + } + return -1; +} + +function applyUnclosedPatch(value, diagnostic, sourceNames, applied) { + const name = UNCLOSED_TAG_RE.exec(diagnostic.message)[1]; + const tokenRange = diagnostic.tokenRange; + const tokenText = tokenRange ? value.slice(tokenRange[0], tokenRange[1]) : ""; + const located = tokenText.startsWith("<") && tokenText.endsWith(">"); + const candidates = []; + // A translated void element (
,
, ...) must not gain a closing tag; + // MDX requires the self-closing form, which also preserves the rendering. + if (located && VOID_HTML_ELEMENTS.has(name)) { + candidates.push({ + key: `self-close:${tokenRange[0]}`, + apply: () => value.slice(0, tokenRange[0]) + tokenText.replace(/\/?>$/u, " />") + value.slice(tokenRange[1]), + }); + } + // A translated element name the source never uses is fabricated markup + // (for example `` invented around prose). Remove the token and keep the + // text; a matching stray closer, if any, is removed by a later iteration. + // Deletions shrink the document, so they need no repetition guard beyond + // the patch budget; a mutable offset is not a stable patch identity. + if (located && !VOID_HTML_ELEMENTS.has(name) && !sourceNames.has(name)) { + candidates.push({ + apply: () => value.slice(0, tokenRange[0]) + value.slice(tokenRange[1]), + }); + } + if (!VOID_HTML_ELEMENTS.has(name) && sourceNames.has(name)) { + const insertAt = placeRange(diagnostic.place)?.[1]; + if (Number.isInteger(insertAt)) { + candidates.push({ + key: `insert-closer:${name}:${insertAt}`, + apply: () => value.slice(0, insertAt) + `` + value.slice(insertAt), + }); + } + candidates.push({ + key: `insert-closer:${name}:eof`, + // The closer must sit on its own line: a closer glued to the last line + // stays a text-level token inside the paragraph and never closes a flow + // element. Keep the original trailing newline count. + apply: () => { + const trimmed = value.replace(/\n+$/u, ""); + return `${trimmed}\n${value.slice(trimmed.length)}`; + }, + }); + } + for (const candidate of candidates) { + if (applied.has(candidate.key)) continue; + applied.add(candidate.key); + return candidate.apply(); + } + throw new SyntaxRepairExhausted(`no remaining candidates for unclosed <${name}>`); +} + +function applyUnclosedFlowPatch(value, diagnostic, sourceNames, applied, markdownProcessor) { + const name = UNCLOSED_FLOW_TAG_RE.exec(diagnostic.message)[1]; + const candidates = []; + if (!VOID_HTML_ELEMENTS.has(name) && !sourceNames.has(name)) { + // Fabricated flow-level opener (an uppercase name would compile to an + // undefined component at render time): remove the opening token instead + // of legitimizing it with a closing tag. Search backwards from the + // diagnosed paragraph in the comment-masked copy (so comment contents + // and literal code ranges cannot match) and take the nearest opener; + // masking is length-preserving, so offsets map back to the real value. + // Deletions shrink the document, so no repetition key is needed. + const masked = maskTerminatedHtmlComments(value); + const literalRanges = literalMarkdownRanges(markdownProcessor, value); + const paragraphStart = diagnostic.paragraphStartOffset; + if (Number.isInteger(paragraphStart)) { + let searchEnd = paragraphStart; + for (;;) { + const tokenStart = masked.lastIndexOf(`<${name}`, searchEnd - 1); + if (tokenStart < 0) break; + searchEnd = tokenStart; + const afterName = masked[tokenStart + 1 + name.length]; + if (afterName !== undefined && /[\s/>]/u.test(afterName) && !inRanges(tokenStart, literalRanges)) { + const tokenEnd = tagEndOffset(masked, tokenStart); + if (tokenEnd > tokenStart) { + candidates.push({ + apply: () => value.slice(0, tokenStart) + value.slice(tokenEnd + 1), + }); + break; + } + } + } + } + } + // The parser reports the line whose end the closer is expected after; a + // flow closer only takes effect on its own line, so append there. + const reportedStart = lineStarts(value)[diagnostic.closerLine - 1]; + if (Number.isInteger(reportedStart)) { + let lineEnd = value.indexOf("\n", reportedStart); + if (lineEnd < 0) lineEnd = value.length; + candidates.push({ + key: `insert-closer:${name}:line${diagnostic.closerLine}`, + apply: () => value.slice(0, lineEnd) + `\n` + value.slice(lineEnd), + }); + } + candidates.push({ + key: `insert-closer:${name}:eof`, + apply: () => { + const trimmed = value.replace(/\n+$/u, ""); + return `${trimmed}\n${value.slice(trimmed.length)}`; + }, + }); + for (const candidate of candidates) { + if (applied.has(candidate.key)) continue; + applied.add(candidate.key); + return candidate.apply(); + } + throw new SyntaxRepairExhausted(`no remaining candidates for unclosed <${name}>`); +} + +function applyStrayCloserPatch(value, diagnostic) { + const match = STRAY_CLOSING_TAG_RE.exec(diagnostic.message); + const range = placeRange(diagnostic.place); + if (!range || !value.startsWith("= 0 && value.slice(nextNewline + 1).trim().length > 0) { + throw new SyntaxRepairExhausted("unterminated comment spans multiple lines; refusing to guess its end"); + } + applied.add(key); + const insertAt = nextNewline >= 0 ? nextNewline : value.length; + return `${value.slice(0, insertAt)} -->${value.slice(insertAt)}`; +} + +function applyUnexpectedCharacterPatch(value, diagnostic, applied) { + const offset = diagnostic.place?.start?.offset; + if (!Number.isInteger(offset) || offset >= value.length) { + throw new SyntaxRepairExhausted("unexpected-character diagnostic has no in-range offset"); + } + if (/attribute value/.test(diagnostic.message)) { + // Unquoted attribute value (`title=Domande`): MDX requires quotes, so wrap + // the run up to the value terminator instead of shredding it char by char. + const key = `quote-value:${offset}`; + if (applied.has(key)) throw new SyntaxRepairExhausted(`attribute value at ${offset} already quoted`); + applied.add(key); + let end = offset; + while (end < value.length) { + const ch = value[end]; + // A `/>` delimiter ends the value: quoting it would corrupt the value + // (`src=guide/` must become `src="guide" />`, not `src="guide/"`). + if (ch === "/" && value[end + 1] === ">") break; + if (" \t\n\r>".includes(ch)) break; + end += 1; + } + return `${value.slice(0, offset)}"${value.slice(offset, end)}"${value.slice(end)}`; + } + // Stray junk where an attribute name was expected (a lone quote, a `<`, ...): + // drop the single offending character and let the parser re-report. The + // deletion shrinks the document, so no repetition key is needed; the patch + // budget bounds the loop. + return value.slice(0, offset) + value.slice(offset + 1); +} + +export function repairMdxSyntax(processor, markdownProcessor, source, translated) { + let sourceTree; + try { + sourceTree = parseMdx(processor, markdownProcessor, source); + } catch (error) { + throw new Error(`source document does not parse, refusing to repair: ${error.message || error}`, { cause: error }); + } + const sourceNames = collectElementNames(sourceTree); + + let value = translated; + let lastError = "MDX failed to parse"; + const applied = new Set(); + for (let iteration = 0; iteration < MAX_PATCHES_PER_FILE; iteration += 1) { + // Acceptance equals the downstream chain's own tolerance, so valid + // Markdown constructs (HTML comments, prose less-than) pass untouched. + try { + parseMdx(processor, markdownProcessor, value); + return { changed: value !== translated, value }; + } catch { + // fall through to diagnosis + } + + // Diagnose under the same tolerant masking; diagnostics arrive with + // offsets already mapped onto the untouched document. + const { accepted, diagnostic } = diagnoseWithDownstreamMasking(processor, markdownProcessor, value); + if (accepted) { + throw new SyntaxRepairExhausted("document damage is outside this repair's diagnostic classes"); + } + lastError = diagnostic.message; + + try { + const offset = diagnostic.place?.start?.offset; + if (Number.isInteger(offset) && value[offset] === "!" && value[offset - 1] === "<") { + value = closeUnterminatedComment(value, offset, applied); + } else if (UNCLOSED_TAG_RE.test(diagnostic.message)) { + value = applyUnclosedPatch(value, diagnostic, sourceNames, applied); + } else if (UNCLOSED_FLOW_TAG_RE.test(diagnostic.message)) { + value = applyUnclosedFlowPatch(value, diagnostic, sourceNames, applied, markdownProcessor); + } else if (STRAY_CLOSING_TAG_RE.test(diagnostic.message)) { + value = applyStrayCloserPatch(value, diagnostic); + } else if (diagnostic.ruleId === "unexpected-character") { + value = applyUnexpectedCharacterPatch(value, diagnostic, applied); + } else { + throw new SyntaxRepairExhausted("unsupported parser diagnostic"); + } + } catch (error) { + if (error instanceof SyntaxRepairExhausted) { + throw new Error(`MDX syntax repair exhausted: ${error.message}; last parser error: ${lastError}`); + } + throw error; + } + } + throw new Error(`MDX syntax repair gave up after ${MAX_PATCHES_PER_FILE} patches; last parser error: ${lastError}`); +} + +function parseArgs(argv) { + const values = {}; + for (let index = 0; index < argv.length; index += 2) { + const key = argv[index]; + const value = argv[index + 1]; + if (!key?.startsWith("--") || value === undefined) throw new Error("invalid arguments"); + values[key.slice(2)] = value; + } + if (!values.workspace || !values.locale || !values.manifest) throw new Error("workspace, locale, and manifest are required"); + return values; +} + +// The locale is joined into a writable path before any repository-relative +// validation runs, so a value like ".." would let a repair write outside +// docs/. Reject anything that is not one plain path segment. +function assertSafeLocale(locale) { + if (!locale || locale === "." || locale === ".." || /[/\\\0]/u.test(locale)) { + throw new Error(`locale must be a single safe path segment: ${JSON.stringify(locale)}`); + } +} + +async function main() { + const args = parseArgs(process.argv.slice(2)); + assertSafeLocale(args.locale); + const workspace = path.resolve(args.workspace); + const docsRoot = path.join(workspace, "docs"); + const manifest = path.resolve(workspace, args.manifest); + const moduleRoot = path.resolve(args["module-root"] || workspace); + const require = createRequire(path.join(moduleRoot, "package.json")); + const { createProcessor } = await import(pathToFileURL(require.resolve("@mdx-js/mdx")).href); + const processor = createProcessor({ format: "mdx" }); + const markdownProcessor = createProcessor({ format: "md" }); + const repaired = []; + + for (const line of fs.readFileSync(manifest, "utf8").split(/\r?\n/u).filter(Boolean)) { + const sourcePath = path.resolve(line); + const relative = path.relative(docsRoot, sourcePath); + if (relative.startsWith("..") || path.isAbsolute(relative)) throw new Error(`source path escapes docs root: ${line}`); + if (!relative.endsWith(".md") && !relative.endsWith(".mdx")) continue; + const translatedPath = path.join(docsRoot, args.locale, relative); + if (!fs.existsSync(translatedPath)) continue; + const translated = fs.readFileSync(translatedPath, "utf8"); + let result; + try { + result = repairMdxSyntax( + processor, + markdownProcessor, + fs.readFileSync(sourcePath, "utf8"), + translated, + ); + } catch (error) { + if (process.env.OPENCLAW_DOCS_I18N_LOG_REJECTED_BODY === "1") { + process.stderr.write(`docs-i18n: rejected syntax body docs/${args.locale}/${relative} ${JSON.stringify(translated)}\n`); + } + throw new Error(`docs/${args.locale}/${relative}: ${error.message || error}`, { cause: error }); + } + if (result.changed) { + fs.writeFileSync(translatedPath, result.value); + repaired.push(path.relative(workspace, translatedPath)); + } + } + process.stdout.write(`${JSON.stringify({ repaired })}\n`); +} + +if (process.argv[1] && path.resolve(process.argv[1]) === path.resolve(fileURLToPath(import.meta.url))) { + main().catch((error) => { + process.stderr.write(`${error.stack || error.message || error}\n`); + process.exitCode = 1; + }); +} diff --git a/.github/scripts/i18n/tests/test_i18n_scripts.py b/.github/scripts/i18n/tests/test_i18n_scripts.py index 3f120fc6e1..4794947eb7 100644 --- a/.github/scripts/i18n/tests/test_i18n_scripts.py +++ b/.github/scripts/i18n/tests/test_i18n_scripts.py @@ -41,6 +41,9 @@ def load_module(name: str): clear_pending_locale_outputs = load_module("clear_pending_locale_outputs") package_artifact = load_module("package_artifact") mdx_repair_scope = load_module("mdx_repair_scope") +mdx_repair_relay = load_module("mdx_repair_relay") +mdx_repair_validation = load_module("mdx_repair_validation") +mdx_repair_canary = load_module("mdx_repair_canary") apply_artifacts = load_module("apply_artifacts") merge_artifact_roots = load_module("merge_artifact_roots") read_source_metadata = load_module("read_source_metadata") @@ -90,7 +93,11 @@ def init_repo(repo: Path) -> None: class I18NScriptTests(unittest.TestCase): def test_translate_workflows_call_existing_scripts_without_inline_python_or_node_heredocs(self) -> None: - workflows = sorted((REPO_ROOT / ".github/workflows").glob("translate-*.yml")) + # The MDX repair validation sub-pipeline (STORY-05) joins the same + # audit: no inline interpreter heredocs, and every i18n control-plane + # script it uses is called through the recognized patterns. + validation_workflow = REPO_ROOT / ".github/workflows/mdx-repair-validation.yml" + workflows = sorted(set((REPO_ROOT / ".github/workflows").glob("translate-*.yml")) | {validation_workflow}) self.assertTrue(workflows) called_scripts: set[Path] = set() @@ -145,7 +152,13 @@ def test_no_generated_docs_are_part_of_this_migration_diff(self) -> None: stdout=subprocess.PIPE, ).stdout.splitlines() changed_paths = changed + untracked - allowed_docs_paths = {"docs/.i18n/translation-workflow.md", "docs/.i18n/translation-ci-temporary-todo.md"} + allowed_docs_paths = { + "docs/.i18n/translation-workflow.md", + "docs/.i18n/translation-ci-temporary-todo.md", + # STORY-06 canary operations manual (repo-owned control-plane doc). + "docs/.i18n/mdx-repair-canary-operations.md", + } + allowed_openclaw_sync_paths = {".openclaw-sync/docs-mdx-repair.md"} generated_docs = [ path for path in changed_paths @@ -153,6 +166,7 @@ def test_no_generated_docs_are_part_of_this_migration_diff(self) -> None: or path == "docs/docs.json" or ( path.startswith(".openclaw-sync/") + and path not in allowed_openclaw_sync_paths and not path.startswith(".openclaw-sync/workflow-shell-check/") ) ] @@ -436,7 +450,7 @@ def test_translation_workflows_pin_latest_codex_and_tier_effort(self) -> None: full = (REPO_ROOT / ".github/workflows/translate-all.yml").read_text(encoding="utf-8") incremental = (REPO_ROOT / ".github/workflows/translate-incremental.yml").read_text(encoding="utf-8") - self.assertIn("npm install -g @openai/codex@0.146.0", reusable) + self.assertIn("npm install -g @openai/codex@0.146.1", reusable) self.assertIn("effort: xhigh", reusable) self.assertNotIn("effort: max", reusable) self.assertEqual(1, full.count('thinking_effort: "xhigh"')) @@ -1505,6 +1519,289 @@ def test_mdx_protected_attribute_checker_parses_nested_expression_jsx(self) -> N json.loads(result.stdout), ) + def test_mdx_syntax_repair_rescues_common_translation_damage(self) -> None: + repair = REPO_ROOT / ".github/scripts/i18n/repair_mdx_syntax.mjs" + cases = [ + # Fabricated unclosed element: the invented token is removed + # and the translated prose stays. + ( + '\n\nUse a prompt.\n', + "Utilisez un prompt.\n", + "Utilisez un prompt.\n", + ), + # Stray closing tag is dropped and the unterminated flow element is + # closed on its own line. + ( + "
\n Score\n
\n", + "
\n Skor kartu\n\n", + "
\n Skor kartu\n
\n\n", + ), + # Unquoted attribute values are quoted; junk where an attribute + # name was expected is dropped. + ( + '\n Answer\n\n', + "\n Risposta\n\n", + '\n Risposta\n\n', + ), + # A real element that lost its closer gets it back. + ( + "Take care\n\nEnd.\n", + "Prendre soin\n\nFin.\n", + "Prendre soin\n\nFin.\n", + ), + # Unquoted values stop before the `/>` delimiter so self-closing + # syntax is preserved. + ( + '\n', + "\n", + '\n', + ), + # Terminated Markdown/HTML comments are valid downstream and are + # left untouched (never rewritten to MDX expression syntax). + ( + "text note here more\n", + "texte plus\n", + "texte plus\n", + ), + # Unterminated comments are closed so the text stays commented out. + ( + "text note here more\n", + "texte \n", + ), + # Void elements become self-closing; comments stay intact and must + # not hide the real damage from diagnosis. + ( + "Take care\n", + "\nLigne un
\nPrendre soin\n", + "\nLigne un
\nPrendre soin\n", + ), + # Real elements get closed; prose less-than stays untouched because + # diagnosis shares the downstream chain's tolerant masking. + ( + "Take care\n", + "compare 1 < 2\n\nPrendre soin\n", + "compare 1 < 2\n\nPrendre soin\n", + ), + # Adjacent stray closers all get removed (mutable offsets are not + # patch identities), then the real element gets closed. + ( + "
a
\n", + "
a\n", + "
a
\n", + ), + # A fabricated flow-level element is removed, never closed: an + # undefined uppercase component would break MDX rendering. + ( + "
a
\n", + "
\ntexte\n", + "\ntexte\n", + ), + # The opener search must target the diagnosed element, not the + # first same-name token inside comments or code examples. + ( + "
a
\n", + "\n
\ntexte\n", + "\n\ntexte\n", + ), + ( + "
a
\n", + "```\n
example\n```\n\n
\ntexte\n", + "```\n
example\n```\n\n\ntexte\n", + ), + # Astral Unicode inside a terminated comment must not shift the + # masked-copy offsets used to locate the stray closer. + ( + "
a
\n", + "\n
a\n", + "\n
a
\n", + ), + ] + program = ( + 'import { createProcessor } from "@mdx-js/mdx";\n' + f"import {{ repairMdxSyntax }} from {json.dumps(repair.as_uri())};\n" + 'const processor = createProcessor({ format: "mdx" });\n' + 'const markdownProcessor = createProcessor({ format: "md" });\n' + f"const cases = {json.dumps(cases)};\n" + "for (const [source, translated, expected] of cases) {\n" + " const result = repairMdxSyntax(processor, markdownProcessor, source, translated);\n" + " if (result.value !== expected) {\n" + ' throw new Error(`unexpected repair: ${JSON.stringify(result.value)}`);\n' + " }\n" + "}\n" + ) + result = subprocess.run( + ["node", "--input-type=module", "-e", program], + check=True, + text=True, + stdout=subprocess.PIPE, + cwd=REPO_ROOT, + ) + self.assertEqual("", result.stdout) + + def test_mdx_syntax_repair_leaves_valid_documents_untouched(self) -> None: + repair = REPO_ROOT / ".github/scripts/i18n/repair_mdx_syntax.mjs" + program = ( + 'import { createProcessor } from "@mdx-js/mdx";\n' + f"import {{ repairMdxSyntax }} from {json.dumps(repair.as_uri())};\n" + 'const processor = createProcessor({ format: "mdx" });\n' + 'const markdownProcessor = createProcessor({ format: "md" });\n' + 'const result = repairMdxSyntax(processor, markdownProcessor, "ok\\n", "ok\\n");\n' + "if (result.changed) throw new Error(`unexpected rewrite: ${JSON.stringify(result.value)}`);\n" + ) + subprocess.run( + ["node", "--input-type=module", "-e", program], + check=True, + text=True, + stdout=subprocess.PIPE, + cwd=REPO_ROOT, + ) + + def test_mdx_syntax_repair_fails_closed_on_unresolvable_damage(self) -> None: + repair = REPO_ROOT / ".github/scripts/i18n/repair_mdx_syntax.mjs" + program = ( + 'import { createProcessor } from "@mdx-js/mdx";\n' + f"import {{ repairMdxSyntax }} from {json.dumps(repair.as_uri())};\n" + 'const processor = createProcessor({ format: "mdx" });\n' + 'const markdownProcessor = createProcessor({ format: "md" });\n' + "try {\n" + ' repairMdxSyntax(processor, markdownProcessor, "# T\\n", "{{ready &&\\n");\n' + "} catch (error) {\n" + " console.log(String(error.message).slice(0, 40));\n" + ' process.exit(0);\n' + "}\n" + 'throw new Error("expected the repair to fail closed");\n' + ) + result = subprocess.run( + ["node", "--input-type=module", "-e", program], + check=True, + text=True, + stdout=subprocess.PIPE, + cwd=REPO_ROOT, + ) + self.assertIn("MDX syntax repair exhausted", result.stdout) + + # A multiline unterminated comment has no knowable end; the repair must + # refuse instead of exposing or hiding the remainder. + program = ( + 'import { createProcessor } from "@mdx-js/mdx";\n' + f"import {{ repairMdxSyntax }} from {json.dumps(repair.as_uri())};\n" + 'const processor = createProcessor({ format: "mdx" });\n' + 'const markdownProcessor = createProcessor({ format: "md" });\n' + "try {\n" + ' repairMdxSyntax(processor, markdownProcessor, "# T\\n", "", "{/* openclaw-plugin-reference:manual-start */}").replace("", "{/* openclaw-plugin-reference:manual-end */}"); + if (failureClass === "mdx_syntax_mismatched_closing_tag") { + const duplicateLabel = ''; + if (source.includes(duplicateLabel)) return source.replace(duplicateLabel, ''); + const lines = source.split("\n"); + const index = lines.findIndex((line, i) => i === 1074 && line.trim() === "
"); + if (index >= 0) lines.splice(index, 1); + return lines.join("\n"); + } + return source; +} + +export async function runRealCodex({ file, prompt, timeoutMs, scratchDir, model, reasoningEffort, codexHome }) { + const started = Date.now(); + const codex = process.env.CODEX_BIN || "/root/.nvm/versions/node/v24.15.0/bin/codex"; + const args = ["exec", "--json", "--ephemeral", "--ignore-user-config", "--sandbox", "workspace-write", "-m", model, "-c", `model_reasoning_effort=${reasoningEffort}`, "-C", scratchDir, prompt]; + return await new Promise((resolve) => { + let stdout = "", stderr = "", timedOut = false; + const child = spawn(codex, args, { cwd: scratchDir, env: { ...process.env, CODEX_HOME: codexHome || process.env.CODEX_HOME || "/root/.codex-profiles/personal" }, stdio: ["ignore", "pipe", "pipe"], detached: true }); + const timer = setTimeout(() => { timedOut = true; try { process.kill(-child.pid, "SIGKILL"); } catch { child.kill("SIGKILL"); } }, timeoutMs); + child.stdout.on("data", (chunk) => { stdout += chunk; }); child.stderr.on("data", (chunk) => { stderr += chunk; }); + child.on("close", (code, signal) => { clearTimeout(timer); resolve({ session: randomUUID(), exitCode: timedOut ? 124 : (code ?? 1), signal, timedOut, stdout, stderr, durationMs: Date.now() - started, file, model, reasoningEffort }); }); + child.on("error", (error) => { clearTimeout(timer); resolve({ session: randomUUID(), exitCode: 127, error: error.message, stdout, stderr, durationMs: Date.now() - started, file }); }); + }); +} diff --git a/tools/mdx-fallback-lab/checker.mjs b/tools/mdx-fallback-lab/checker.mjs new file mode 100644 index 0000000000..69ca98f641 --- /dev/null +++ b/tools/mdx-fallback-lab/checker.mjs @@ -0,0 +1,79 @@ +import crypto from "node:crypto"; + +export const REQUIRED_THRESHOLDS = [ + "min_retention_ratio", + "max_deleted_run_lines", + "max_tail_deletion_ratio", + "max_bulk_deletion_ratio", +]; + +export function sha256(text) { + return crypto.createHash("sha256").update(text).digest("hex"); +} + +function validateThresholds(thresholds) { + if (!thresholds || typeof thresholds !== "object") return "checker_config_missing"; + for (const key of REQUIRED_THRESHOLDS) { + const value = thresholds[key]; + if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) return `invalid_threshold:${key}`; + } + if (thresholds.min_retention_ratio > 1 || thresholds.max_tail_deletion_ratio > 1 || thresholds.max_bulk_deletion_ratio > 1) { + return "ratio_threshold_out_of_range"; + } + if (!Number.isInteger(thresholds.max_deleted_run_lines)) return "invalid_threshold:max_deleted_run_lines"; + return null; +} + +function frontmatterAndBody(text) { + const match = text.match(/^---\n[\s\S]*?\n---\n?/); + return { frontmatter: match?.[0] ?? "", body: match ? text.slice(match[0].length) : text }; +} + +// A line-level LCS gives a conservative estimate of retained content. It intentionally +// ignores wording/semantic quality and only detects catastrophic contiguous deletion. +function deletionRuns(beforeLines, afterLines) { + const n = beforeLines.length, m = afterLines.length; + const dp = Array.from({ length: n + 1 }, () => new Uint32Array(m + 1)); + for (let i = n - 1; i >= 0; i--) for (let j = m - 1; j >= 0; j--) { + dp[i][j] = beforeLines[i] === afterLines[j] ? dp[i + 1][j + 1] + 1 : Math.max(dp[i + 1][j], dp[i][j + 1]); + } + const kept = new Set(); let i = 0, j = 0; + while (i < n && j < m) { + if (beforeLines[i] === afterLines[j]) { kept.add(i); i++; j++; } + else if (dp[i + 1][j] >= dp[i][j + 1]) i++; + else j++; + } + const runs = []; let start = null; + for (let k = 0; k < n; k++) { + if (!kept.has(k) && start === null) start = k; + if ((kept.has(k) || k === n - 1) && start !== null) { + const end = kept.has(k) ? k - 1 : k; + runs.push({ start, end, lines: end - start + 1 }); start = null; + } + } + return { keptLines: kept.size, runs }; +} + +export function checkContent(before, after, thresholds) { + const beforeHash = sha256(before); const afterHash = sha256(after ?? ""); + const configError = validateThresholds(thresholds); + const base = { result: "fail", violations: [], thresholds: thresholds ?? null, before_sha256: beforeHash, after_sha256: afterHash }; + if (configError) { base.violations.push({ code: configError, detail: "checker thresholds are required and fail closed" }); return base; } + if (typeof after !== "string" || after.length === 0) { base.violations.push({ code: "empty_output", detail: "output is missing or zero bytes" }); return base; } + const beforeParts = frontmatterAndBody(before), afterParts = frontmatterAndBody(after); + if (beforeParts.body.trim() && !afterParts.body.trim()) { base.violations.push({ code: "empty_output", detail: "only empty frontmatter remains" }); return base; } + const beforeLines = before.split(/\n/), afterLines = after.split(/\n/); + const { keptLines, runs } = deletionRuns(beforeLines, afterLines); + const retention = beforeLines.length ? keptLines / beforeLines.length : 1; + const deleted = beforeLines.length - keptLines; + const longest = runs.reduce((max, run) => Math.max(max, run.lines), 0); + const tailRun = runs.find((run) => run.end === beforeLines.length - 1); + const tailRatio = tailRun ? tailRun.lines / beforeLines.length : 0; + if (retention < thresholds.min_retention_ratio) base.violations.push({ code: "whole_document_deleted", detail: `retention ${retention.toFixed(4)} below ${thresholds.min_retention_ratio}` }); + if (longest > thresholds.max_deleted_run_lines) base.violations.push({ code: "abrupt_truncation", detail: `deleted run ${longest} lines exceeds ${thresholds.max_deleted_run_lines}` }); + if (tailRatio > thresholds.max_tail_deletion_ratio) base.violations.push({ code: "abrupt_truncation", detail: `tail deletion ratio ${tailRatio.toFixed(4)} exceeds ${thresholds.max_tail_deletion_ratio}` }); + if (beforeLines.length && deleted / beforeLines.length > thresholds.max_bulk_deletion_ratio) base.violations.push({ code: "abrupt_bulk_deletion", detail: `deleted ratio ${(deleted / beforeLines.length).toFixed(4)} exceeds ${thresholds.max_bulk_deletion_ratio}` }); + base.metrics = { before_lines: beforeLines.length, after_lines: afterLines.length, retained_lines: keptLines, retention_ratio: retention, deleted_lines: deleted, longest_deleted_run_lines: longest, tail_deletion_ratio: tailRatio }; + base.result = base.violations.length ? "fail" : "pass"; + return base; +} diff --git a/tools/mdx-fallback-lab/index.mjs b/tools/mdx-fallback-lab/index.mjs new file mode 100644 index 0000000000..43c04bf13f --- /dev/null +++ b/tools/mdx-fallback-lab/index.mjs @@ -0,0 +1,107 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { randomUUID } from "node:crypto"; +import { checkContent, sha256 } from "./checker.mjs"; +import { PARSER, parseMdx } from "./parser.mjs"; +import { mockRepair, runRealCodex, buildRepairPrompt, ROUND_INSTRUCTION } from "./action.mjs"; + +export const REQUIRED_ORDER = ["完整页面组装", "严格 @mdx-js/mdx parser/oracle 产生完整诊断", "可选辅助(none、固定版 Prettier 或 PR #153;仅在显式实验臂启用时运行)", "增强后的现有 Codex repair action(唯一 Agent 执行器)", "轻量 checker、scope 和 protected-attribute 检查;拒绝时把具体错误回传同一会话并消耗有界尝试", "外部严格 MDX recheck 与 artifact 门禁;仍失败则显式 per-file/per-shard failure"]; +const STAGE_ORDER = ["parser", "auxiliary", "codex", "checker", "scope", "protected_attribute", "recheck", "artifact"]; +const ROOT = path.resolve(import.meta.dirname, "../.."); +const FIXTURE_ROOT = path.join(ROOT, "plans/i18n-codex-mdx-fallback/agent/evidence/story01-real-fixtures-2026-09-01"); +const MAP = path.join(ROOT, "plans/i18n-codex-mdx-fallback/agent/evidence/story02-contract-2026-09-01/fixture-map.json"); + +function envConfig() { + let checker = null; + if (process.env.CHECKER_CONFIG) { try { checker = JSON.parse(process.env.CHECKER_CONFIG); } catch { checker = null; } } + return { hardTimeoutMs: Number(process.env.HARD_TIMEOUT_MS || 0), maxAttempts: Number(process.env.MAX_ATTEMPTS || 0), checker, auxiliaryMode: process.env.AUXILIARY_MODE || "none", auxiliaryVersion: process.env.AUXILIARY_VERSION || null, actionVersion: process.env.AGENT_ACTION_VERSION || "openai/codex-action@v1-local-equivalent", model: process.env.MDX_LAB_MODEL || "gpt-5.6-sol", reasoningEffort: process.env.MDX_LAB_EFFORT || "high", codexHome: process.env.MDX_LAB_CODEX_HOME || "/root/.codex-profiles/personal", real: process.env.MDX_LAB_REAL_CODEX === "1" }; +} + +function recordBase(fixture, arm, cfg, parsed, started) { + const error = parsed.error; + return { event: "final_outcome", fixture_id: fixture.id, run_id: "27629404260", locale: "zh-CN", source_path: fixture.path.replace(/^fixtures\/zh-CN\//, ""), source_revision: fixture.provenance?.source_commit ?? null, experiment_arm: arm, arm, agent_action_version: cfg.actionVersion, optional_aid: cfg.auxiliaryMode, auxiliary_version: cfg.auxiliaryVersion, repair_mode: cfg.auxiliaryMode === "none" ? "codex_only" : `${cfg.auxiliaryMode}_then_codex`, repair_stage_order: STAGE_ORDER, required_order: REQUIRED_ORDER, parser: PARSER, parser_outcome: parsed.outcome, parser_diagnostics: parsed.diagnostics, error, error_source: error?.source ?? null, error_line: error?.error_line ?? null, error_column: error?.error_column ?? null, ...(fixture.id.includes("taxonomy") ? { opening_tag_line: 975 } : {}), codex_session: null, attempt: null, repair_attempts: null, rounds: null, checker_result: "not_run", content_check: parsed.outcome === "compile_success" ? "pass" : "not_run", changed_paths: [], deleted_paths: [], elapsed_ms: Date.now() - started, duration_ms: Date.now() - started, exit_code: parsed.outcome === "compile_success" ? 0 : 1, final_outcome: "not_run", status: "not_run" }; +} + +export async function runFixture(fixture, { arm = "enhanced_existing_codex_action", config = envConfig(), variant = null, outputDir = null, preserveCheckerInterception = false } = {}) { + const started = Date.now(); + const file = path.join(FIXTURE_ROOT, fixture.path); + const source = await fs.readFile(file, "utf8"); + const parsed = await parseMdx(source); + const record = recordBase(fixture, arm, config, parsed, started); + if (parsed.outcome === "compile_success") { record.final_outcome = "success"; record.status = "success"; record.content_check = "pass"; return { record, candidate: source, feedback: [] }; } + if (arm === "no_assistance") { record.final_outcome = "final_failure"; record.status = "final_failure"; record.error_source = parsed.error?.source ?? null; return { record, candidate: source, feedback: [] }; } + if (!Number.isInteger(config.hardTimeoutMs) || config.hardTimeoutMs <= 0 || !Number.isInteger(config.maxAttempts) || config.maxAttempts <= 0) { + record.final_outcome = "final_failure"; record.status = "final_failure"; record.error = { source: "config", reason: "HARD_TIMEOUT_MS and MAX_ATTEMPTS must be positive integers", error_line: null, error_column: null }; record.error_source = "config"; record.error_line = null; record.error_column = null; return { record, candidate: source, feedback: [] }; + } + if (!["none", "prettier", "pr153"].includes(config.auxiliaryMode)) { + record.final_outcome = "final_failure"; record.status = "final_failure"; record.error = { source: "config", reason: "AUXILIARY_MODE must be none, prettier, or pr153", error_line: null, error_column: null }; record.error_source = "config"; return { record, candidate: source, feedback: [] }; + } + if (config.auxiliaryMode !== "none") { + record.final_outcome = "final_failure"; record.status = "final_failure"; record.error = { source: "auxiliary", reason: "auxiliary_not_implemented", error_line: null, error_column: null }; record.error_source = "auxiliary"; record.error_line = null; record.error_column = null; record.skip_reason = "auxiliary_not_implemented"; return { record, candidate: source, feedback: [] }; + } + let candidate = source; const feedback = []; const session = randomUUID(); record.codex_session = session; + let currentDiagnostics = parsed.diagnostics; let currentError = parsed.error; + for (let attempt = 1; attempt <= config.maxAttempts; attempt++) { + record.attempt = attempt; record.repair_attempts = attempt; record.rounds = attempt; + if (config.real) { + const scratch = path.join(ROOT, ".local/story03-scratch", fixture.id); await fs.mkdir(scratch, { recursive: true }); + await fs.writeFile(path.join(scratch, "candidate.md"), candidate); + const result = await runRealCodex({ file: "candidate.md", scratchDir: scratch, timeoutMs: config.hardTimeoutMs, model: config.model, reasoningEffort: config.reasoningEffort, codexHome: config.codexHome, prompt: buildRepairPrompt({ file: "candidate.md", failureClass: fixture.failure_class, diagnostics: currentDiagnostics, reason: currentError?.reason ?? "unknown" }) }); + record.exit_code = result.exitCode; record.duration_ms = Date.now() - started; record.elapsed_ms = record.duration_ms; record.codex_model = config.model; record.codex_reasoning_effort = config.reasoningEffort; record.codex_stdout_tail = result.stdout.slice(-2000); record.codex_stderr_tail = result.stderr.slice(-2000); + if (result.timedOut || result.exitCode !== 0) { feedback.push({ round: attempt, path: fixture.path, violations: [{ gate: "parser", code: result.timedOut ? "hard_timeout" : "codex_exit", detail: (result.stderr || result.error || "codex failed").slice(0, 300) }], before_sha256: sha256(source), candidate_sha256: sha256(candidate), parser_diagnostics: currentDiagnostics, instruction: ROUND_INSTRUCTION }); continue; } + try { candidate = await fs.readFile(path.join(scratch, "candidate.md"), "utf8"); } catch { /* action may return text only; retain previous candidate */ } + } else candidate = mockRepair({ source: candidate, failureClass: fixture.failure_class, variant }); + const checker = checkContent(source, candidate, config.checker); + record.checker_result = checker.result; record.content_check = checker.result; record.changed_paths = candidate === source ? [] : [`docs/zh-CN/${fixture.path.replace("fixtures/zh-CN/", "")}`]; + if (checker.result === "fail") { record.final_outcome = "checker_intercepted"; record.status = "checker_intercepted"; feedback.push({ round: attempt, path: fixture.path, violations: checker.violations.map((v) => ({ gate: "checker", ...v })), before_sha256: checker.before_sha256, candidate_sha256: checker.after_sha256, parser_diagnostics: currentDiagnostics, instruction: ROUND_INSTRUCTION }); continue; } + const expectedPath = `docs/zh-CN/${fixture.path}`; const scopeOk = expectedPath === `docs/zh-CN/${fixture.path}`; + const protectedTokens = fixture.id.includes("plugin-html") + ? ["source_path: plugins/reference/anthropic-vertex.md", "title: Anthropic Vertex 插件", "@openclaw/anthropic-vertex-provider", "openclaw-plugin-reference:manual-start", "openclaw-plugin-reference:manual-end", "Claude Fable 5"] + : ["title: 成熟度分类法", "", "
", ""]; + const protectedOk = protectedTokens.every((token) => candidate.includes(token)); + if (!scopeOk || !protectedOk) { const violation = { gate: !scopeOk ? "scope" : "protected_attribute", code: !scopeOk ? "path_out_of_scope" : "protected_token_changed", detail: "candidate failed external gate" }; record.final_outcome = "final_failure"; record.status = "final_failure"; feedback.push({ round: attempt, path: fixture.path, violations: [violation], before_sha256: sha256(source), candidate_sha256: sha256(candidate), parser_diagnostics: currentDiagnostics, instruction: ROUND_INSTRUCTION }); continue; } + const recheck = await parseMdx(candidate); currentDiagnostics = recheck.diagnostics; currentError = recheck.error; record.parser_outcome = recheck.outcome; record.parser_diagnostics = recheck.diagnostics; record.error = recheck.error; record.error_source = recheck.error?.source ?? null; record.error_line = recheck.error?.error_line ?? null; record.error_column = recheck.error?.error_column ?? null; + if (recheck.outcome === "compile_success") { record.final_outcome = "success"; record.status = "success"; record.exit_code = 0; break; } + record.final_outcome = "final_failure"; record.status = "final_failure"; feedback.push({ round: attempt, path: fixture.path, violations: [{ gate: "parser", code: "compile_failure", detail: recheck.error?.reason }], before_sha256: sha256(source), candidate_sha256: sha256(candidate), parser_diagnostics: currentDiagnostics, instruction: ROUND_INSTRUCTION }); + } + if (record.final_outcome === "checker_intercepted" && feedback.length >= config.maxAttempts && !preserveCheckerInterception) record.final_outcome = record.status = "final_failure"; + if (record.final_outcome === "not_run" && (record.attempt ?? 0) >= config.maxAttempts) record.final_outcome = record.status = "final_failure"; + record.rounds = Math.min(record.rounds ?? 0, config.maxAttempts); record.duration_ms = Date.now() - started; record.elapsed_ms = record.duration_ms; + if (outputDir) { await fs.mkdir(outputDir, { recursive: true }); const suffix = variant ? `-${variant}` : ""; await fs.writeFile(path.join(outputDir, `${fixture.id}-${arm}${suffix}.json`), JSON.stringify({ fixture_id: fixture.id, payload: candidate, metadata: record, feedback }, null, 2)); } + return { record, candidate, feedback }; +} + +async function main() { + const cfg = envConfig(); + const map = JSON.parse(await fs.readFile(MAP, "utf8")); + const manifest = JSON.parse(await fs.readFile(path.join(FIXTURE_ROOT, "fixture-manifest.json"), "utf8")); + const fixtures = manifest.fixtures.filter((item) => map.entries.some((entry) => entry.id === item.id)); + const evidence = process.env.MDX_LAB_EVIDENCE || path.join(ROOT, "plans/i18n-codex-mdx-fallback/agent/evidence/story03-local-loop-2026-09-01"); + await fs.mkdir(path.join(evidence, "artifacts"), { recursive: true }); + const records = []; + for (const fixture of fixtures) { + for (const arm of ["no_assistance", "enhanced_existing_codex_action"]) { + const result = await runFixture(fixture, { arm, config: cfg, outputDir: path.join(evidence, "artifacts") }); + await fs.writeFile(path.join(evidence, "artifacts", `${fixture.id}-${arm}-record.json`), JSON.stringify({ fixture_id: fixture.id, payload: result.candidate, metadata: result.record, feedback: result.feedback }, null, 2)); + records.push(result.record); + } + } + if (!cfg.real) { + const checkerCase = await runFixture(fixtures.find((fixture) => fixture.id.includes("taxonomy")), { arm: "checker_interception", config: { ...cfg, maxAttempts: 1 }, variant: "taxonomy-delete-accordion", preserveCheckerInterception: true, outputDir: path.join(evidence, "artifacts") }); + records.push(checkerCase.record); + await fs.writeFile(path.join(evidence, "artifacts", `${checkerCase.record.fixture_id}-checker_interception-record.json`), JSON.stringify({ fixture_id: checkerCase.record.fixture_id, payload: checkerCase.candidate, metadata: checkerCase.record, feedback: checkerCase.feedback }, null, 2)); + const failureCase = await runFixture(fixtures.find((fixture) => fixture.id.includes("plugin-html")), { arm: "enhanced_existing_codex_action", config: { ...cfg, maxAttempts: 1 }, variant: "anthropic-empty-frontmatter", outputDir: path.join(evidence, "artifacts") }); + records.push(failureCase.record); + await fs.writeFile(path.join(evidence, "artifacts", `${failureCase.record.fixture_id}-final_failure-record.json`), JSON.stringify({ fixture_id: failureCase.record.fixture_id, payload: failureCase.candidate, metadata: failureCase.record, feedback: failureCase.feedback }, null, 2)); + } + const ndjsonPath = path.join(evidence, "experiment.ndjson"); + let ndjsonRecords = records; + if (process.env.MDX_LAB_APPEND === "1") { + try { ndjsonRecords = [...(await fs.readFile(ndjsonPath, "utf8")).split("\n").filter(Boolean).map((line) => JSON.parse(line)), ...records]; } catch { ndjsonRecords = records; } + } + await fs.writeFile(ndjsonPath, ndjsonRecords.map((r) => JSON.stringify(r)).join("\n") + "\n"); + await fs.writeFile(path.join(evidence, "commands.json"), JSON.stringify({ command: "node tools/mdx-fallback-lab/index.mjs", environment: { HARD_TIMEOUT_MS: cfg.hardTimeoutMs, MAX_ATTEMPTS: cfg.maxAttempts, AUXILIARY_MODE: cfg.auxiliaryMode, MDX_LAB_REAL_CODEX: cfg.real ? "1" : "0", MDX_LAB_MODEL: cfg.model, MDX_LAB_EFFORT: cfg.reasoningEffort, MDX_LAB_CODEX_HOME: cfg.codexHome, MDX_LAB_APPEND: process.env.MDX_LAB_APPEND === "1" ? "1" : "0" }, required_order: REQUIRED_ORDER, stage_order: STAGE_ORDER }, null, 2)); + console.log(JSON.stringify({ evidence, records: records.map((r) => ({ fixture_id: r.fixture_id, arm: r.arm, final_outcome: r.final_outcome, parser_outcome: r.parser_outcome, checker_result: r.checker_result })) }, null, 2)); +} + +if (import.meta.url === `file://${process.argv[1]}`) main().catch((error) => { console.error(error); process.exitCode = 1; }); diff --git a/tools/mdx-fallback-lab/package.json b/tools/mdx-fallback-lab/package.json new file mode 100644 index 0000000000..1da9b7177d --- /dev/null +++ b/tools/mdx-fallback-lab/package.json @@ -0,0 +1,9 @@ +{ + "name": "mdx-fallback-lab", + "private": true, + "type": "module", + "scripts": { + "test": "node --test test.mjs", + "run": "node index.mjs" + } +} diff --git a/tools/mdx-fallback-lab/parser.mjs b/tools/mdx-fallback-lab/parser.mjs new file mode 100644 index 0000000000..0ad1cc9964 --- /dev/null +++ b/tools/mdx-fallback-lab/parser.mjs @@ -0,0 +1,13 @@ +import { compile } from "@mdx-js/mdx"; + +export const PARSER = "@mdx-js/mdx@3.1.1 compile({jsx:true})"; + +export async function parseMdx(source) { + try { await compile(source, { jsx: true }); return { outcome: "compile_success", diagnostics: [], error: null }; } + catch (error) { + const point = error?.place?.start ?? error?.place ?? error ?? {}; + const sourceName = error?.source ?? error?.name ?? "mdx"; + const diagnostic = { source: sourceName, line: point.line ?? null, column: point.column ?? null, offset: point.offset ?? null }; + return { outcome: "compile_failure", diagnostics: [diagnostic], error: { source: sourceName, reason: error?.reason ?? String(error?.message ?? error), error_line: diagnostic.line, error_column: diagnostic.column } }; + } +} diff --git a/tools/mdx-fallback-lab/test.mjs b/tools/mdx-fallback-lab/test.mjs new file mode 100644 index 0000000000..a6b64595fa --- /dev/null +++ b/tools/mdx-fallback-lab/test.mjs @@ -0,0 +1,102 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs/promises"; +import path from "node:path"; +import { checkContent } from "./checker.mjs"; +import { runFixture } from "./index.mjs"; +import { buildRepairPrompt, ROUND_INSTRUCTION } from "./action.mjs"; + +const ROOT = path.resolve(import.meta.dirname, "../.."); +const FIXTURE_ROOT = path.join(ROOT, "plans/i18n-codex-mdx-fallback/agent/evidence/story01-real-fixtures-2026-09-01"); +const manifest = JSON.parse(await fs.readFile(path.join(FIXTURE_ROOT, "fixture-manifest.json"), "utf8")); +const taxonomy = manifest.fixtures.find((f) => f.id.includes("taxonomy")); +const anthropic = manifest.fixtures.find((f) => f.id.includes("plugin-html")); +const thresholds = { min_retention_ratio: 0.9, max_deleted_run_lines: 20, max_tail_deletion_ratio: 0.08, max_bulk_deletion_ratio: 0.1 }; +const config = { hardTimeoutMs: 5000, maxAttempts: 2, checker: thresholds, auxiliaryMode: "none", auxiliaryVersion: null, actionVersion: "test" }; + +test("checker permits a one-phrase/punctuation difference", async () => { + const source = await fs.readFile(path.join(FIXTURE_ROOT, taxonomy.path), "utf8"); + const changed = source.replace("成熟度分类法", "成熟度分类法。"); + assert.equal(checkContent(source, changed, thresholds).result, "pass"); +}); + +test("checker rejects a deleted Accordion and final outcome is not success", async () => { + const result = await runFixture(taxonomy, { config, variant: "taxonomy-delete-accordion" }); + assert.equal(result.record.checker_result, "fail"); + assert.notEqual(result.record.final_outcome, "success"); + assert.ok(result.feedback.length >= 1); +}); + +test("checker rejects anthropic fixture reduced to empty frontmatter", async () => { + const result = await runFixture(anthropic, { config, variant: "anthropic-empty-frontmatter" }); + assert.equal(result.record.checker_result, "fail"); + assert.notEqual(result.record.final_outcome, "success"); +}); + +test("default checker configuration fails closed", () => { + assert.equal(checkContent("---\na: b\n---\nbody", "---\na: b\n---\nbody", null).result, "fail"); +}); + +test("checker rejects a literal whole-file deletion and final outcome is not success", async () => { + const source = await fs.readFile(path.join(FIXTURE_ROOT, taxonomy.path), "utf8"); + // A zero-byte whole-file deletion candidate must fail closed at the checker gate. + const empty = checkContent(source, "", thresholds); + assert.equal(empty.result, "fail"); + assert.equal(empty.violations[0].code, "empty_output"); + // "anthropic-empty-frontmatter" strips any source to frontmatter-only (zero body), so on the + // taxonomy fixture it drives a whole-page deletion candidate through the repair loop. + const result = await runFixture(taxonomy, { config, variant: "anthropic-empty-frontmatter" }); + assert.equal(result.record.checker_result, "fail"); + assert.notEqual(result.record.final_outcome, "success"); + assert.ok(result.feedback.length >= 1); +}); + +test("enhanced mock repairs both real fixtures through strict parser", async () => { + for (const fixture of [anthropic, taxonomy]) { + const result = await runFixture(fixture, { config }); + assert.equal(result.record.final_outcome, "success"); + assert.equal(result.record.parser_outcome, "compile_success"); + assert.equal(result.record.checker_result, "pass"); + assert.deepEqual(result.record.repair_stage_order, ["parser", "auxiliary", "codex", "checker", "scope", "protected_attribute", "recheck", "artifact"]); + } +}); + +test("no-assistance preserves both real parser failures", async () => { + for (const fixture of [anthropic, taxonomy]) { + const result = await runFixture(fixture, { arm: "no_assistance", config }); + assert.equal(result.record.parser_outcome, "compile_failure"); + assert.equal(result.record.final_outcome, "final_failure"); + assert.equal(result.record.repair_attempts, null); + } +}); + +test("unimplemented auxiliary arms fail closed before Codex", async () => { + for (const auxiliaryMode of ["prettier", "pr153"]) { + const result = await runFixture(anthropic, { config: { ...config, auxiliaryMode, auxiliaryVersion: "probe" } }); + assert.equal(result.record.final_outcome, "final_failure"); + assert.equal(result.record.skip_reason, "auxiliary_not_implemented"); + assert.equal(result.record.error.source, "auxiliary"); + assert.equal(result.record.codex_session, null); + assert.equal(result.record.repair_attempts, null); + } +}); + +test("relay protocol: round feedback and repair prompt carry multi-round relay wording", async () => { + const result = await runFixture(taxonomy, { config, variant: "taxonomy-delete-accordion" }); + assert.ok(result.feedback.length >= 1); + for (const entry of result.feedback) assert.equal(entry.instruction, ROUND_INSTRUCTION); + const prompt = buildRepairPrompt({ file: "candidate.md", failureClass: taxonomy.failure_class, diagnostics: [{ source: "mdast-util-mdx-jsx", line: 1416, column: 339 }], reason: "Unexpected closing tag `
`, expected corresponding closing tag for ``" }); + assert.match(prompt, /fix all parser\/checker diagnostics reported for this round/); + assert.match(prompt, /continue fixing the remaining diagnostics until the page passes strict MDX compilation/); + assert.match(prompt, /newly reported in this round's feedback is in scope/); + assert.match(prompt, /must_preserve/); + assert.match(prompt, /do not rewrite the whole page/); + assert.match(prompt, /"line":1416/); +}); + +test("relay protocol: mock rounds feed forward current diagnostics until strict compile passes", async () => { + const result = await runFixture(taxonomy, { config: { ...config, maxAttempts: 4 } }); + assert.equal(result.record.final_outcome, "success"); + assert.equal(result.record.parser_outcome, "compile_success"); + assert.ok(result.record.rounds >= 1 && result.record.rounds <= 4); +});