diff --git a/scripts/python/check_prerequisites.py b/scripts/python/check_prerequisites.py index c7834fe456..7bfdf91018 100644 --- a/scripts/python/check_prerequisites.py +++ b/scripts/python/check_prerequisites.py @@ -28,7 +28,8 @@ def _json_line(payload: object) -> str: - return json.dumps(payload, ensure_ascii=False, separators=(",", ":")) + "\n" + # ASCII escapes keep the JSON intact on a legacy Windows stdout code page. + return json.dumps(payload, ensure_ascii=True, separators=(",", ":")) + "\n" HELP_TEXT = """Usage: check_prerequisites.py [OPTIONS] diff --git a/scripts/python/resolve_template.py b/scripts/python/resolve_template.py index d2a7da89b2..580e22b557 100644 --- a/scripts/python/resolve_template.py +++ b/scripts/python/resolve_template.py @@ -50,7 +50,8 @@ def main(argv: list[str] | None = None) -> int: "TEMPLATE_NAME": args.template_name, "TEMPLATE_CONTENT": content, }, - ensure_ascii=False, + # ASCII escapes keep the JSON intact on a legacy Windows stdout code page. + ensure_ascii=True, separators=(",", ":"), ) ) diff --git a/scripts/python/setup_plan.py b/scripts/python/setup_plan.py index 3eaa9f4a3f..113b27ca2c 100644 --- a/scripts/python/setup_plan.py +++ b/scripts/python/setup_plan.py @@ -23,7 +23,8 @@ def _json_line(payload: object) -> str: - return json.dumps(payload, ensure_ascii=False, separators=(",", ":")) + "\n" + # ASCII escapes keep the JSON intact on a legacy Windows stdout code page. + return json.dumps(payload, ensure_ascii=True, separators=(",", ":")) + "\n" def _help_text(argv0: str) -> str: diff --git a/scripts/python/setup_tasks.py b/scripts/python/setup_tasks.py index a69fc3c956..31c7b1ae41 100644 --- a/scripts/python/setup_tasks.py +++ b/scripts/python/setup_tasks.py @@ -29,7 +29,8 @@ def _json_line(payload: object) -> str: - return json.dumps(payload, ensure_ascii=False, separators=(",", ":")) + "\n" + # ASCII escapes keep the JSON intact on a legacy Windows stdout code page. + return json.dumps(payload, ensure_ascii=True, separators=(",", ":")) + "\n" def _help_text(argv0: str) -> str: diff --git a/tests/test_check_prerequisites_python_parity.py b/tests/test_check_prerequisites_python_parity.py index 3a53887ff8..10378babdc 100644 --- a/tests/test_check_prerequisites_python_parity.py +++ b/tests/test_check_prerequisites_python_parity.py @@ -12,7 +12,7 @@ import pytest from tests.conftest import requires_bash -from tests.parity_helpers import install_composition_stack +from tests.parity_helpers import install_composition_stack, run PROJECT_ROOT = Path(__file__).resolve().parent.parent COMMON_SH = PROJECT_ROOT / "scripts" / "bash" / "common.sh" @@ -122,6 +122,24 @@ def _json_stdout(result: subprocess.CompletedProcess[str]) -> object: return json.loads(result.stdout) +def test_python_json_preserves_non_ascii_feature_on_legacy_stdout( + prereq_repo: Path, +) -> None: + feature = prereq_repo / "specs/001-功能" + feature.mkdir(parents=True) + (feature / "plan.md").write_bytes(b"# Plan\r\n") + _write_feature_json(prereq_repo, "specs/001-功能") + env = _clean_env() + env["PYTHONIOENCODING"] = "cp1252" + + result = run(_py_cmd(prereq_repo, "--json"), prereq_repo, env) + + assert result.returncode == 0, result.stderr + payload = _json_stdout(result) + assert Path(payload["FEATURE_DIR"]) == feature + assert payload["AVAILABLE_DOCS"] == [] + + def _normalize_status_text(text: str) -> str: return ( text.replace(" ✓ ", " [OK] ") diff --git a/tests/test_resolve_template_python_parity.py b/tests/test_resolve_template_python_parity.py index 7e5f3a2088..9696358459 100644 --- a/tests/test_resolve_template_python_parity.py +++ b/tests/test_resolve_template_python_parity.py @@ -32,6 +32,25 @@ TEMPLATE = "constitution-template" +def test_python_json_preserves_constitution_on_legacy_stdout(tmp_path: Path) -> None: + repo = make_repo(tmp_path) + install_scripts(repo, SCRIPT) + template = repo / ".specify/templates/overrides/constitution-template.md" + template.parent.mkdir(parents=True) + expected = "# Конституція\n\nПравила: 功能 → звіт.\n" + template.write_bytes(expected.encode("utf-8")) + env = clean_env() + env["PYTHONIOENCODING"] = "cp1252" + + result = run(py_cmd(repo, SCRIPT, TEMPLATE, "--json"), repo, env) + + assert result.returncode == 0, result.stderr + assert json_stdout(result) == { + "TEMPLATE_NAME": TEMPLATE, "TEMPLATE_CONTENT": expected, + } + assert template.read_bytes() == expected.encode("utf-8") + + def _setup_repo(tmp_path: Path) -> tuple[Path, str]: repo = make_repo(tmp_path) install_scripts(repo, SCRIPT) diff --git a/tests/test_setup_plan_python_parity.py b/tests/test_setup_plan_python_parity.py index 0ac08cf626..5c5ea1c8e4 100644 --- a/tests/test_setup_plan_python_parity.py +++ b/tests/test_setup_plan_python_parity.py @@ -60,6 +60,25 @@ def repo(tmp_path: Path) -> Path: return _setup_repo(tmp_path) +def test_python_json_preserves_non_ascii_feature_on_legacy_stdout(repo: Path) -> None: + feature = repo / "specs/001-功能" + feature.mkdir() + write_feature_json(repo, "specs/001-功能") + env = clean_env() + env["PYTHONIOENCODING"] = "cp1252" + + result = run(py_cmd(repo, SCRIPT, "--json"), repo, env) + + assert result.returncode == 0, result.stderr + payload = json_stdout(result) + assert payload["BRANCH"] == "001-功能" + assert Path(payload["FEATURE_DIR"]) == feature + assert Path(payload["FEATURE_SPEC"]) == feature / "spec.md" + assert Path(payload["IMPL_PLAN"]) == feature / "plan.md" + template = repo / ".specify" / "templates" / "plan-template.md" + assert (feature / "plan.md").read_bytes() == template.read_bytes() + + @pytest.mark.parametrize("port", PORTS) @pytest.mark.parametrize("json_mode", [True, False], ids=["json", "text"]) @pytest.mark.parametrize("template_state", ["missing", "empty", "empty_override"]) diff --git a/tests/test_setup_tasks_python_parity.py b/tests/test_setup_tasks_python_parity.py index 5cd6e85ecb..5a341b57d5 100644 --- a/tests/test_setup_tasks_python_parity.py +++ b/tests/test_setup_tasks_python_parity.py @@ -26,6 +26,45 @@ SCRIPT = "setup-tasks" +def test_installed_python_json_preserves_stock_tasks_template( + tmp_path: Path, +) -> None: + """The tasks command's actual script must emit JSON for the stock template.""" + from typer.testing import CliRunner + + from specify_cli import app + + project = tmp_path / "project" + initialized = CliRunner().invoke( + app, + [ + "init", str(project), "--integration", "codex", "--script", "py", + "--ignore-agent-tools", "--non-interactive", + ], + ) + assert initialized.exit_code == 0, initialized.output + created = run( + py_cmd(project, "create-new-feature", "--json", "Add user authentication"), + project, + ) + assert created.returncode == 0, created.stderr + plan = run(py_cmd(project, "setup-plan", "--json"), project) + assert plan.returncode == 0, plan.stderr + template = project / ".specify/templates/tasks-template.md" + expected = template.read_bytes().decode("utf-8") + assert "🎯" in expected + env = clean_env() + env["PYTHONIOENCODING"] = "cp1252" + + result = run(py_cmd(project, SCRIPT, "--json"), project, env) + + assert result.returncode == 0, result.stderr + payload = json_stdout(result) + assert payload["TASKS_TEMPLATE_CONTENT"] == expected + assert Path(payload["TASKS_TEMPLATE"]) == template + assert payload["AVAILABLE_DOCS"] == [] + + def _setup_repo(tmp_path: Path) -> Path: repo = make_repo(tmp_path) install_scripts(repo, SCRIPT)