diff --git a/.github/workflows/python-tests.yml b/.github/workflows/python-tests.yml index 4b413eb057..2d7f182664 100644 --- a/.github/workflows/python-tests.yml +++ b/.github/workflows/python-tests.yml @@ -838,6 +838,11 @@ jobs: - name: Verify native Windows test Python discovery run: node --no-warnings --experimental-strip-types --test tests/control_plane_ts/test_python_runtime.test.ts + - name: Qualify the canary plan's repository path rendering + run: >- + python -m pytest -q + tests/canary/test_repository_root.py::test_plan_renders_repository_paths_in_canonical_posix_form + - name: Run native Windows lifecycle tests run: >- python -m pytest -q diff --git a/loopx/canary/planner.py b/loopx/canary/planner.py index 68d7fd7bbc..4a180c1022 100644 --- a/loopx/canary/planner.py +++ b/loopx/canary/planner.py @@ -1387,7 +1387,7 @@ def _read_catalog(catalog_path: Path | None = None) -> tuple[Path, str]: def _display_path(path: Path) -> str: try: - return str(path.relative_to(REPO_ROOT)) + return path.relative_to(REPO_ROOT).as_posix() except ValueError: return str(path) diff --git a/loopx/canary/premerge.py b/loopx/canary/premerge.py index 24d6be33d6..ee7b4b66c3 100644 --- a/loopx/canary/premerge.py +++ b/loopx/canary/premerge.py @@ -168,7 +168,7 @@ def _public_boundary_changed_files_run( display_paths: list[str] = [] for path in existing_paths: try: - display_paths.append(str(path.relative_to(repo_root.resolve()))) + display_paths.append(path.relative_to(repo_root.resolve()).as_posix()) except ValueError: display_paths.append(str(path)) display_argv = ["loopx", "check"] @@ -345,7 +345,7 @@ def _display_argv(argv: list[str], *, repo_root: Path = REPO_ROOT) -> list[str]: for index, value in enumerate(displayed[1:], start=1): path = Path(value) try: - displayed[index] = str(path.resolve().relative_to(repo_root.resolve())) + displayed[index] = path.resolve().relative_to(repo_root.resolve()).as_posix() except (OSError, ValueError): displayed[index] = value return displayed diff --git a/loopx/canary/runner.py b/loopx/canary/runner.py index b6a4fd6f11..f376a64b9a 100644 --- a/loopx/canary/runner.py +++ b/loopx/canary/runner.py @@ -152,7 +152,7 @@ def normalize_canary_command(command: str, repo_root: Path | None = None) -> dic "argv": argv, "display_argv": _display_argv(argv, repo_root=repo_root), "injected_args": injected_args, - "script": str(script.relative_to(repo_root)), + "script": script.relative_to(repo_root).as_posix(), } @@ -164,7 +164,7 @@ def _display_argv(argv: list[str], repo_root: Path | None = None) -> list[str]: for index, value in enumerate(displayed[1:], start=1): path = Path(value) if path.is_absolute() and _is_relative_to(path.resolve(), repo_root.resolve()): - displayed[index] = str(path.resolve().relative_to(repo_root.resolve())) + displayed[index] = path.resolve().relative_to(repo_root.resolve()).as_posix() return displayed diff --git a/tests/canary/test_repository_root.py b/tests/canary/test_repository_root.py index 10d822b619..22b0dced34 100644 --- a/tests/canary/test_repository_root.py +++ b/tests/canary/test_repository_root.py @@ -53,6 +53,58 @@ def test_premerge_uses_selected_checkout_for_nested_plans(tmp_path): assert Path(check["normalized"]["argv"][1]).is_relative_to(repo / "examples") +def _selected_checks(payload: dict) -> list[dict]: + checks = [check for check in payload.get("selected_checks") or [] if isinstance(check, dict)] + for section in ("catalog_run", "risk_profile_run"): + run = payload.get(section) + if isinstance(run, dict): + checks.extend(check for check in run.get("selected_checks") or [] if isinstance(check, dict)) + return checks + + +def _assert_canonical_path(rendered: str) -> None: + """A repository-relative path keeps the canonical POSIX spelling. + + ``PurePosixPath`` would leave a host separator untouched, so the stray + backslash is rejected explicitly rather than through a normalization that + already agrees with the value. + """ + + assert "\\" not in rendered, rendered + assert rendered == Path(rendered).as_posix(), rendered + + +def test_plan_renders_repository_paths_in_canonical_posix_form(tmp_path): + """A plan's repository-relative paths keep the canonical spelling everywhere. + + The catalog, the suite checks, the shipped contract smokes and the public + docs all declare ``python3 examples/<...>.py``. Rendering the same path with + the host separator makes one check read differently per host, which fails + those smokes on Windows and records host-dependent premerge evidence. + """ + + repo = _checkout(tmp_path / "repo") + suite = build_canary_smoke_suite_run(repo_root=repo) + gate = build_premerge_validation_gate( + repo_root=repo, changed_files=["loopx/canary/runner.py"], + base_ref="HEAD", execute=False, + ) + checks = _selected_checks(suite) + _selected_checks(gate) + rendered = [] + for check in checks: + normalized = check.get("normalized") or {} + if normalized.get("ok") is not True: + continue + rendered.append(str(normalized.get("script") or "")) + rendered.extend( + str(token) for token in normalized.get("display_argv") or [] if "examples" in str(token) + ) + commands = [str(item) for item in gate["validation_summary"]["selected_commands"]] + assert rendered and commands + for item in [*rendered, *commands]: + _assert_canonical_path(item) + + @pytest.mark.parametrize("jobs", [1, 2]) def test_target_checkout_write_guard_detects_and_restores(tmp_path, jobs): repo = _checkout(tmp_path / "repo", mutate=True)