From a795aaea1c2ef7fcf2025fc172dcffeb482e9907 Mon Sep 17 00:00:00 2001 From: mindfn Date: Tue, 10 Mar 2026 23:24:38 +0800 Subject: [PATCH 01/14] =?UTF-8?q?feat:=20establish=20test=20=E2=86=92=20mo?= =?UTF-8?q?dule=20=E2=86=92=20owner=20mapping=20for=20failure=20attributio?= =?UTF-8?q?n=20(T0-5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add scripts/ci/test_ownership_map.py with canonical test-to-module mapping - Update tests/conftest.py to inject module/owner pytest markers from map - Add scripts/ci/check_test_failure_ownership.py for failure attribution reports - All 112 active test files mapped to responsible dare_framework modules Co-Authored-By: Claude Opus 4.6 (1M context) --- scripts/ci/check_test_failure_ownership.py | 139 +++++ scripts/ci/test_ownership_map.py | 614 +++++++++++++++++++++ tests/conftest.py | 26 + 3 files changed, 779 insertions(+) create mode 100644 scripts/ci/check_test_failure_ownership.py create mode 100644 scripts/ci/test_ownership_map.py diff --git a/scripts/ci/check_test_failure_ownership.py b/scripts/ci/check_test_failure_ownership.py new file mode 100644 index 00000000..7460be14 --- /dev/null +++ b/scripts/ci/check_test_failure_ownership.py @@ -0,0 +1,139 @@ +#!/usr/bin/env python3 +"""Test failure ownership attribution report. + +Reads pytest output (from stdin, a file, or a fresh run) and emits a +structured report that groups each failure by its responsible module. + +Usage:: + + # Run pytest internally and report + python scripts/ci/check_test_failure_ownership.py + + # Read from an existing report file + python scripts/ci/check_test_failure_ownership.py --report results.txt + + # Pipe pytest output via stdin + pytest -q --tb=line tests/ | python scripts/ci/check_test_failure_ownership.py --stdin +""" + +from __future__ import annotations + +import argparse +import re +import subprocess +import sys +from pathlib import Path + +# Ensure repo root is importable so ``from scripts.ci.test_ownership_map`` +# works regardless of how the script is invoked. +_REPO_ROOT = str(Path(__file__).resolve().parents[2]) +if _REPO_ROOT not in sys.path: + sys.path.insert(0, _REPO_ROOT) + +FAILED_TEST_RE = re.compile(r"^(?:FAILED|ERROR)\s+([^\s]+)") + + +def _parse_failed_lines(text: str) -> list[str]: + """Extract ``FAILED``/``ERROR`` test node IDs from raw pytest output.""" + results: list[str] = [] + for line in text.splitlines(): + m = FAILED_TEST_RE.match(line.strip()) + if m: + results.append(m.group(1)) + return results + + +def _test_path_from_nodeid(nodeid: str) -> str: + """Convert ``tests/unit/test_foo.py::test_bar`` to ``tests/unit/test_foo.py``.""" + return nodeid.split("::")[0] + + +def _build_report(failed_nodeids: list[str]) -> str: + from scripts.ci.test_ownership_map import OWNERSHIP_MAP + + if not failed_nodeids: + return "=== Test Failure Ownership Report ===\n\nNo test failures detected.\n" + + # Group by module + module_groups: dict[str, list[str]] = {} + unmapped: list[str] = [] + + for nodeid in failed_nodeids: + path = _test_path_from_nodeid(nodeid) + entry = OWNERSHIP_MAP.get(path) + if entry: + module_groups.setdefault(entry["module"], []).append(nodeid) + else: + unmapped.append(nodeid) + + lines: list[str] = ["=== Test Failure Ownership Report ===", ""] + + for module in sorted(module_groups): + # Find owner from first entry in this module group + first_path = _test_path_from_nodeid(module_groups[module][0]) + entry = OWNERSHIP_MAP.get(first_path, {}) + owner = entry.get("owner") or "unassigned" + lines.append(f"Module: {module} (owner: {owner})") + for nodeid in sorted(module_groups[module]): + lines.append(f" FAILED {nodeid}") + lines.append("") + + lines.append("UNMAPPED (no ownership entry):") + if unmapped: + for nodeid in sorted(unmapped): + lines.append(f" FAILED {nodeid}") + else: + lines.append(" (none)") + lines.append("") + + total_failed = len(failed_nodeids) + total_modules = len(module_groups) + (1 if unmapped else 0) + lines.append(f"Summary: {total_failed} failed tests across {total_modules} modules") + return "\n".join(lines) + + +def _run_pytest() -> str: + """Run ``pytest -q --tb=line tests/`` and return combined output.""" + completed = subprocess.run( + [sys.executable, "-m", "pytest", "-q", "--tb=line", "tests/"], + capture_output=True, + text=True, + check=False, + ) + return "\n".join( + part for part in [completed.stdout.strip(), completed.stderr.strip()] if part + ).strip() + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description="Test failure ownership attribution report") + source = parser.add_mutually_exclusive_group() + source.add_argument( + "--stdin", + action="store_true", + help="Read pytest output from stdin", + ) + source.add_argument( + "--report", + type=Path, + metavar="FILE", + help="Read pytest output from a file", + ) + args = parser.parse_args(argv) + + if args.stdin: + raw = sys.stdin.read() + elif args.report: + raw = args.report.read_text(encoding="utf-8") + else: + raw = _run_pytest() + + failed = _parse_failed_lines(raw) + report = _build_report(failed) + print(report) + + return 1 if failed else 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/ci/test_ownership_map.py b/scripts/ci/test_ownership_map.py new file mode 100644 index 00000000..f423de14 --- /dev/null +++ b/scripts/ci/test_ownership_map.py @@ -0,0 +1,614 @@ +"""Test file -> responsible module ownership mapping. + +Canonical source for test failure attribution. Used by: +- tests/conftest.py (pytest marker injection) +- scripts/ci/check_test_failure_ownership.py (failure attribution report) +""" + +from __future__ import annotations + +# Each entry: test_path (relative to repo root) -> { module, owner, tier } +# owner: empty string means unassigned; fill as ownership is established +OWNERSHIP_MAP: dict[str, dict[str, str]] = { + # --- integration tests --- + "tests/integration/test_client_cli_flow.py": { + "module": "client", + "owner": "", + "tier": "integration", + }, + "tests/integration/test_config_provider_reload.py": { + "module": "dare_framework/config", + "owner": "", + "tier": "integration", + }, + "tests/integration/test_example_agent_flow.py": { + "module": "examples", + "owner": "", + "tier": "integration", + }, + "tests/integration/test_hook_governance_flow.py": { + "module": "dare_framework/hook", + "owner": "", + "tier": "integration", + }, + "tests/integration/test_p0_conformance_gate.py": { + "module": "scripts/ci", + "owner": "", + "tier": "integration", + }, + "tests/integration/test_security_policy_gate_flow.py": { + "module": "dare_framework/security", + "owner": "", + "tier": "integration", + }, + # --- smoke tests --- + "tests/smoke/test_ci_smoke.py": { + "module": "dare_framework/agent", + "owner": "", + "tier": "smoke", + }, + "tests/smoke/test_client_cli_smoke.py": { + "module": "client", + "owner": "", + "tier": "smoke", + }, + "tests/smoke/test_client_entrypoint_smoke.py": { + "module": "client", + "owner": "", + "tier": "smoke", + }, + # --- unit tests: a2a --- + "tests/unit/test_a2a.py": { + "module": "dare_framework/a2a", + "owner": "", + "tier": "unit", + }, + # --- unit tests: agent domain --- + "tests/unit/test_agent_construction_contract.py": { + "module": "dare_framework/agent", + "owner": "", + "tier": "unit", + }, + "tests/unit/test_agent_event_transport_hook.py": { + "module": "dare_framework/agent", + "owner": "", + "tier": "unit", + }, + "tests/unit/test_agent_input_normalizer.py": { + "module": "dare_framework/agent", + "owner": "", + "tier": "unit", + }, + "tests/unit/test_agent_output_envelope.py": { + "module": "dare_framework/agent", + "owner": "", + "tier": "unit", + }, + "tests/unit/test_base_agent_transport_contract.py": { + "module": "dare_framework/agent", + "owner": "", + "tier": "unit", + }, + "tests/unit/test_builder_component_managers.py": { + "module": "dare_framework/agent", + "owner": "", + "tier": "unit", + }, + "tests/unit/test_builder_config_filtering.py": { + "module": "dare_framework/agent", + "owner": "", + "tier": "unit", + }, + "tests/unit/test_builder_hook_ordering.py": { + "module": "dare_framework/agent", + "owner": "", + "tier": "unit", + }, + "tests/unit/test_builder_manager_resolution.py": { + "module": "dare_framework/agent", + "owner": "", + "tier": "unit", + }, + "tests/unit/test_builder_security_boundary.py": { + "module": "dare_framework/agent", + "owner": "", + "tier": "unit", + }, + "tests/unit/test_builder_skill_tool_mode.py": { + "module": "dare_framework/agent", + "owner": "", + "tier": "unit", + }, + "tests/unit/test_builder_tool_gateway.py": { + "module": "dare_framework/agent", + "owner": "", + "tier": "unit", + }, + "tests/unit/test_component_managers.py": { + "module": "dare_framework/agent", + "owner": "", + "tier": "unit", + }, + "tests/unit/test_dare_agent_hook_governance.py": { + "module": "dare_framework/agent", + "owner": "", + "tier": "unit", + }, + "tests/unit/test_dare_agent_hook_transport_boundary.py": { + "module": "dare_framework/agent", + "owner": "", + "tier": "unit", + }, + "tests/unit/test_dare_agent_mcp_management.py": { + "module": "dare_framework/agent", + "owner": "", + "tier": "unit", + }, + "tests/unit/test_dare_agent_orchestration_split.py": { + "module": "dare_framework/agent", + "owner": "", + "tier": "unit", + }, + "tests/unit/test_dare_agent_security_boundary.py": { + "module": "dare_framework/agent", + "owner": "", + "tier": "unit", + }, + "tests/unit/test_dare_agent_security_policy_gate.py": { + "module": "dare_framework/agent", + "owner": "", + "tier": "unit", + }, + "tests/unit/test_dare_agent_step_driven_mode.py": { + "module": "dare_framework/agent", + "owner": "", + "tier": "unit", + }, + "tests/unit/test_five_layer_agent.py": { + "module": "dare_framework/agent", + "owner": "", + "tier": "unit", + }, + "tests/unit/test_kernel_flow.py": { + "module": "dare_framework/agent", + "owner": "", + "tier": "unit", + }, + "tests/unit/test_output_normalizer.py": { + "module": "dare_framework/agent", + "owner": "", + "tier": "unit", + }, + "tests/unit/test_react_agent_gateway_injection.py": { + "module": "dare_framework/agent", + "owner": "", + "tier": "unit", + }, + "tests/unit/test_runtime_state.py": { + "module": "dare_framework/agent", + "owner": "", + "tier": "unit", + }, + "tests/unit/test_session_orchestrator_message_input.py": { + "module": "dare_framework/agent", + "owner": "", + "tier": "unit", + }, + # --- unit tests: checkpoint --- + "tests/unit/test_checkpoint_message_schema.py": { + "module": "dare_framework/checkpoint", + "owner": "", + "tier": "unit", + }, + # --- unit tests: client --- + "tests/unit/test_client_cli.py": { + "module": "client", + "owner": "", + "tier": "unit", + }, + "tests/unit/test_client_runtime_bootstrap.py": { + "module": "client", + "owner": "", + "tier": "unit", + }, + # --- unit tests: config --- + "tests/unit/test_config_action_handler.py": { + "module": "dare_framework/config", + "owner": "", + "tier": "unit", + }, + "tests/unit/test_config_model.py": { + "module": "dare_framework/config", + "owner": "", + "tier": "unit", + }, + "tests/unit/test_config_nonnull_guards.py": { + "module": "dare_framework/config", + "owner": "", + "tier": "unit", + }, + "tests/unit/test_config_provider.py": { + "module": "dare_framework/config", + "owner": "", + "tier": "unit", + }, + "tests/unit/test_layered_config_provider.py": { + "module": "dare_framework/config", + "owner": "", + "tier": "unit", + }, + # --- unit tests: context --- + "tests/unit/test_context_implementation.py": { + "module": "dare_framework/context", + "owner": "", + "tier": "unit", + }, + "tests/unit/test_context_message_types.py": { + "module": "dare_framework/context", + "owner": "", + "tier": "unit", + }, + # --- unit tests: event --- + "tests/unit/test_event_log.py": { + "module": "dare_framework/event", + "owner": "", + "tier": "unit", + }, + "tests/unit/test_event_sqlite_event_log.py": { + "module": "dare_framework/event", + "owner": "", + "tier": "unit", + }, + # --- unit tests: examples --- + "tests/unit/test_example_10_agentscope_compat.py": { + "module": "examples", + "owner": "", + "tier": "unit", + }, + "tests/unit/test_example_hook_governance.py": { + "module": "examples", + "owner": "", + "tier": "unit", + }, + "tests/unit/test_examples_04_cli.py": { + "module": "examples", + "owner": "", + "tier": "unit", + }, + "tests/unit/test_examples_cli.py": { + "module": "examples", + "owner": "", + "tier": "unit", + }, + "tests/unit/test_examples_cli_mcp.py": { + "module": "examples", + "owner": "", + "tier": "unit", + }, + "tests/unit/test_examples_with_tools.py": { + "module": "examples", + "owner": "", + "tier": "unit", + }, + # --- unit tests: hook --- + "tests/unit/test_hook_config_model.py": { + "module": "dare_framework/hook", + "owner": "", + "tier": "unit", + }, + "tests/unit/test_hook_contract_types.py": { + "module": "dare_framework/hook", + "owner": "", + "tier": "unit", + }, + "tests/unit/test_hook_decision_arbiter.py": { + "module": "dare_framework/hook", + "owner": "", + "tier": "unit", + }, + "tests/unit/test_hook_extension_point_governance.py": { + "module": "dare_framework/hook", + "owner": "", + "tier": "unit", + }, + "tests/unit/test_hook_legacy_adapter.py": { + "module": "dare_framework/hook", + "owner": "", + "tier": "unit", + }, + "tests/unit/test_hook_metrics.py": { + "module": "dare_framework/hook", + "owner": "", + "tier": "unit", + }, + "tests/unit/test_hook_patch_validator.py": { + "module": "dare_framework/hook", + "owner": "", + "tier": "unit", + }, + "tests/unit/test_hook_phase_schema.py": { + "module": "dare_framework/hook", + "owner": "", + "tier": "unit", + }, + "tests/unit/test_hook_runner.py": { + "module": "dare_framework/hook", + "owner": "", + "tier": "unit", + }, + "tests/unit/test_hook_selector.py": { + "module": "dare_framework/hook", + "owner": "", + "tier": "unit", + }, + "tests/unit/test_stdout_hook.py": { + "module": "dare_framework/hook", + "owner": "", + "tier": "unit", + }, + # --- unit tests: governance / CI scripts --- + "tests/unit/test_governance_evidence_truth_gate.py": { + "module": "scripts/ci", + "owner": "", + "tier": "unit", + }, + "tests/unit/test_governance_intent_gate.py": { + "module": "scripts/ci", + "owner": "", + "tier": "unit", + }, + "tests/unit/test_governance_traceability_gate.py": { + "module": "scripts/ci", + "owner": "", + "tier": "unit", + }, + "tests/unit/test_main_guard.py": { + "module": "scripts/ci", + "owner": "", + "tier": "unit", + }, + "tests/unit/test_manual_merge_guard.py": { + "module": "scripts/ci", + "owner": "", + "tier": "unit", + }, + "tests/unit/test_p0_gate_ci.py": { + "module": "scripts/ci", + "owner": "", + "tier": "unit", + }, + # --- unit tests: interaction / transport --- + "tests/unit/test_interaction_controls.py": { + "module": "dare_framework/transport", + "owner": "", + "tier": "unit", + }, + "tests/unit/test_interaction_dispatcher.py": { + "module": "dare_framework/transport", + "owner": "", + "tier": "unit", + }, + "tests/unit/test_transport_adapters.py": { + "module": "dare_framework/transport", + "owner": "", + "tier": "unit", + }, + "tests/unit/test_transport_channel.py": { + "module": "dare_framework/transport", + "owner": "", + "tier": "unit", + }, + "tests/unit/test_transport_typed_payloads.py": { + "module": "dare_framework/transport", + "owner": "", + "tier": "unit", + }, + "tests/unit/test_transport_types.py": { + "module": "dare_framework/transport", + "owner": "", + "tier": "unit", + }, + # --- unit tests: mcp --- + "tests/unit/test_mcp_action_handler.py": { + "module": "dare_framework/mcp", + "owner": "", + "tier": "unit", + }, + "tests/unit/test_mcp_client.py": { + "module": "dare_framework/mcp", + "owner": "", + "tier": "unit", + }, + "tests/unit/test_mcp_manager.py": { + "module": "dare_framework/mcp", + "owner": "", + "tier": "unit", + }, + "tests/unit/test_mcp_tool_provider.py": { + "module": "dare_framework/mcp", + "owner": "", + "tier": "unit", + }, + # --- unit tests: memory --- + "tests/unit/test_memory_knowledge_direct.py": { + "module": "dare_framework/memory", + "owner": "", + "tier": "unit", + }, + "tests/unit/test_embedding_openai_adapter.py": { + "module": "dare_framework/memory", + "owner": "", + "tier": "unit", + }, + # --- unit tests: model --- + "tests/unit/test_anthropic_model_adapter.py": { + "module": "dare_framework/model", + "owner": "", + "tier": "unit", + }, + "tests/unit/test_builtin_prompt_loader.py": { + "module": "dare_framework/model", + "owner": "", + "tier": "unit", + }, + "tests/unit/test_default_model_adapter_manager.py": { + "module": "dare_framework/model", + "owner": "", + "tier": "unit", + }, + "tests/unit/test_openai_model_adapter.py": { + "module": "dare_framework/model", + "owner": "", + "tier": "unit", + }, + "tests/unit/test_openrouter_adapter.py": { + "module": "dare_framework/model", + "owner": "", + "tier": "unit", + }, + "tests/unit/test_prompt_store.py": { + "module": "dare_framework/model", + "owner": "", + "tier": "unit", + }, + # --- unit tests: observability --- + "tests/unit/test_llm_io_capture_hook.py": { + "module": "dare_framework/observability", + "owner": "", + "tier": "unit", + }, + "tests/unit/test_observability.py": { + "module": "dare_framework/observability", + "owner": "", + "tier": "unit", + }, + # --- unit tests: plan --- + "tests/unit/test_composite_validator.py": { + "module": "dare_framework/plan", + "owner": "", + "tier": "unit", + }, + "tests/unit/test_default_planner.py": { + "module": "dare_framework/plan", + "owner": "", + "tier": "unit", + }, + "tests/unit/test_default_remediator.py": { + "module": "dare_framework/plan", + "owner": "", + "tier": "unit", + }, + "tests/unit/test_plan_v2_tools.py": { + "module": "dare_framework/plan", + "owner": "", + "tier": "unit", + }, + "tests/unit/test_registry_plan_validator.py": { + "module": "dare_framework/plan", + "owner": "", + "tier": "unit", + }, + # --- unit tests: security --- + "tests/unit/test_security_boundary.py": { + "module": "dare_framework/security", + "owner": "", + "tier": "unit", + }, + # --- unit tests: skill --- + "tests/unit/test_search_skill_tool.py": { + "module": "dare_framework/skill", + "owner": "", + "tier": "unit", + }, + "tests/unit/test_skill_store_builder.py": { + "module": "dare_framework/skill", + "owner": "", + "tier": "unit", + }, + # --- unit tests: tool --- + "tests/unit/test_execution_control.py": { + "module": "dare_framework/tool", + "owner": "", + "tier": "unit", + }, + "tests/unit/test_governed_tool_gateway.py": { + "module": "dare_framework/tool", + "owner": "", + "tier": "unit", + }, + "tests/unit/test_native_tool_provider.py": { + "module": "dare_framework/tool", + "owner": "", + "tier": "unit", + }, + "tests/unit/test_tool_approval_action_handler.py": { + "module": "dare_framework/tool", + "owner": "", + "tier": "unit", + }, + "tests/unit/test_tool_approval_manager.py": { + "module": "dare_framework/tool", + "owner": "", + "tier": "unit", + }, + "tests/unit/test_tool_gateway.py": { + "module": "dare_framework/tool", + "owner": "", + "tier": "unit", + }, + "tests/unit/test_tool_manager.py": { + "module": "dare_framework/tool", + "owner": "", + "tier": "unit", + }, + "tests/unit/test_tool_signature_contract.py": { + "module": "dare_framework/tool", + "owner": "", + "tier": "unit", + }, + "tests/unit/test_tool_types.py": { + "module": "dare_framework/tool", + "owner": "", + "tier": "unit", + }, + "tests/unit/test_v4_file_tools.py": { + "module": "dare_framework/tool", + "owner": "", + "tier": "unit", + }, + # --- unit tests: cross-cutting --- + "tests/unit/test_llm_io_summary_script.py": { + "module": "scripts", + "owner": "", + "tier": "unit", + }, + "tests/unit/test_local_backend.py": { + "module": "_local_backend", + "owner": "", + "tier": "unit", + }, + "tests/unit/test_package_initializers_facade_pattern.py": { + "module": "dare_framework", + "owner": "", + "tier": "unit", + }, +} + + +def get_entry(test_path: str) -> dict[str, str] | None: + """Return the ownership entry for *test_path* (relative to repo root).""" + return OWNERSHIP_MAP.get(test_path) + + +def modules_for_failures(failed_paths: list[str]) -> dict[str, list[str]]: + """Group *failed_paths* by their responsible module. + + Returns ``{module: [test_path, ...]}``. Unmapped paths are grouped + under the key ``"UNMAPPED"``. + """ + grouped: dict[str, list[str]] = {} + for path in failed_paths: + entry = OWNERSHIP_MAP.get(path) + key = entry["module"] if entry else "UNMAPPED" + grouped.setdefault(key, []).append(path) + return grouped diff --git a/tests/conftest.py b/tests/conftest.py index 2a855d9f..804f7c77 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,6 +1,32 @@ import sys from pathlib import Path +import pytest + ROOT = Path(__file__).resolve().parents[1] if str(ROOT) not in sys.path: sys.path.insert(0, str(ROOT)) + + +# --------------------------------------------------------------------------- +# Marker registration & automatic injection from canonical ownership map +# --------------------------------------------------------------------------- + + +def pytest_configure(config: pytest.Config) -> None: + """Register ``module`` and ``owner`` markers so they pass ``--strict-markers``.""" + config.addinivalue_line("markers", "module(name): responsible dare_framework module") + config.addinivalue_line("markers", "owner(name): responsible owner/team handle") + + +def pytest_collection_modifyitems(items: list[pytest.Item]) -> None: + """Inject ``@pytest.mark.module`` / ``@pytest.mark.owner`` from the ownership map.""" + from scripts.ci.test_ownership_map import OWNERSHIP_MAP + + for item in items: + rel = str(Path(item.fspath).relative_to(ROOT)) + entry = OWNERSHIP_MAP.get(rel) + if entry: + item.add_marker(pytest.mark.module(entry["module"])) + if entry.get("owner"): + item.add_marker(pytest.mark.owner(entry["owner"])) From 60e5ad84d08810fc909c94f3d93478b0160694a7 Mon Sep 17 00:00:00 2001 From: mindfn Date: Tue, 10 Mar 2026 23:53:51 +0800 Subject: [PATCH 02/14] =?UTF-8?q?fix:=20address=20T0-5=20review=20findings?= =?UTF-8?q?=20=E2=80=94=20fill=20owner=20mapping=20and=20add=20coverage=20?= =?UTF-8?q?guard?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Set default owner 'lang' for all 112 test file entries - Add unmapped file warning in conftest.py pytest_collection_modifyitems - Add test_ownership_map_coverage.py with bidirectional sync guard tests - Guard ensures new test files must be added to OWNERSHIP_MAP Co-Authored-By: Claude Opus 4.6 (1M context) --- scripts/ci/test_ownership_map.py | 231 +++++++++++----------- tests/conftest.py | 13 ++ tests/unit/test_ownership_map_coverage.py | 33 ++++ 3 files changed, 164 insertions(+), 113 deletions(-) create mode 100644 tests/unit/test_ownership_map_coverage.py diff --git a/scripts/ci/test_ownership_map.py b/scripts/ci/test_ownership_map.py index f423de14..7197176f 100644 --- a/scripts/ci/test_ownership_map.py +++ b/scripts/ci/test_ownership_map.py @@ -8,588 +8,593 @@ from __future__ import annotations # Each entry: test_path (relative to repo root) -> { module, owner, tier } -# owner: empty string means unassigned; fill as ownership is established +# owner: responsible individual or team handle OWNERSHIP_MAP: dict[str, dict[str, str]] = { # --- integration tests --- "tests/integration/test_client_cli_flow.py": { "module": "client", - "owner": "", + "owner": "lang", "tier": "integration", }, "tests/integration/test_config_provider_reload.py": { "module": "dare_framework/config", - "owner": "", + "owner": "lang", "tier": "integration", }, "tests/integration/test_example_agent_flow.py": { "module": "examples", - "owner": "", + "owner": "lang", "tier": "integration", }, "tests/integration/test_hook_governance_flow.py": { "module": "dare_framework/hook", - "owner": "", + "owner": "lang", "tier": "integration", }, "tests/integration/test_p0_conformance_gate.py": { "module": "scripts/ci", - "owner": "", + "owner": "lang", "tier": "integration", }, "tests/integration/test_security_policy_gate_flow.py": { "module": "dare_framework/security", - "owner": "", + "owner": "lang", "tier": "integration", }, # --- smoke tests --- "tests/smoke/test_ci_smoke.py": { "module": "dare_framework/agent", - "owner": "", + "owner": "lang", "tier": "smoke", }, "tests/smoke/test_client_cli_smoke.py": { "module": "client", - "owner": "", + "owner": "lang", "tier": "smoke", }, "tests/smoke/test_client_entrypoint_smoke.py": { "module": "client", - "owner": "", + "owner": "lang", "tier": "smoke", }, # --- unit tests: a2a --- "tests/unit/test_a2a.py": { "module": "dare_framework/a2a", - "owner": "", + "owner": "lang", "tier": "unit", }, # --- unit tests: agent domain --- "tests/unit/test_agent_construction_contract.py": { "module": "dare_framework/agent", - "owner": "", + "owner": "lang", "tier": "unit", }, "tests/unit/test_agent_event_transport_hook.py": { "module": "dare_framework/agent", - "owner": "", + "owner": "lang", "tier": "unit", }, "tests/unit/test_agent_input_normalizer.py": { "module": "dare_framework/agent", - "owner": "", + "owner": "lang", "tier": "unit", }, "tests/unit/test_agent_output_envelope.py": { "module": "dare_framework/agent", - "owner": "", + "owner": "lang", "tier": "unit", }, "tests/unit/test_base_agent_transport_contract.py": { "module": "dare_framework/agent", - "owner": "", + "owner": "lang", "tier": "unit", }, "tests/unit/test_builder_component_managers.py": { "module": "dare_framework/agent", - "owner": "", + "owner": "lang", "tier": "unit", }, "tests/unit/test_builder_config_filtering.py": { "module": "dare_framework/agent", - "owner": "", + "owner": "lang", "tier": "unit", }, "tests/unit/test_builder_hook_ordering.py": { "module": "dare_framework/agent", - "owner": "", + "owner": "lang", "tier": "unit", }, "tests/unit/test_builder_manager_resolution.py": { "module": "dare_framework/agent", - "owner": "", + "owner": "lang", "tier": "unit", }, "tests/unit/test_builder_security_boundary.py": { "module": "dare_framework/agent", - "owner": "", + "owner": "lang", "tier": "unit", }, "tests/unit/test_builder_skill_tool_mode.py": { "module": "dare_framework/agent", - "owner": "", + "owner": "lang", "tier": "unit", }, "tests/unit/test_builder_tool_gateway.py": { "module": "dare_framework/agent", - "owner": "", + "owner": "lang", "tier": "unit", }, "tests/unit/test_component_managers.py": { "module": "dare_framework/agent", - "owner": "", + "owner": "lang", "tier": "unit", }, "tests/unit/test_dare_agent_hook_governance.py": { "module": "dare_framework/agent", - "owner": "", + "owner": "lang", "tier": "unit", }, "tests/unit/test_dare_agent_hook_transport_boundary.py": { "module": "dare_framework/agent", - "owner": "", + "owner": "lang", "tier": "unit", }, "tests/unit/test_dare_agent_mcp_management.py": { "module": "dare_framework/agent", - "owner": "", + "owner": "lang", "tier": "unit", }, "tests/unit/test_dare_agent_orchestration_split.py": { "module": "dare_framework/agent", - "owner": "", + "owner": "lang", "tier": "unit", }, "tests/unit/test_dare_agent_security_boundary.py": { "module": "dare_framework/agent", - "owner": "", + "owner": "lang", "tier": "unit", }, "tests/unit/test_dare_agent_security_policy_gate.py": { "module": "dare_framework/agent", - "owner": "", + "owner": "lang", "tier": "unit", }, "tests/unit/test_dare_agent_step_driven_mode.py": { "module": "dare_framework/agent", - "owner": "", + "owner": "lang", "tier": "unit", }, "tests/unit/test_five_layer_agent.py": { "module": "dare_framework/agent", - "owner": "", + "owner": "lang", "tier": "unit", }, "tests/unit/test_kernel_flow.py": { "module": "dare_framework/agent", - "owner": "", + "owner": "lang", "tier": "unit", }, "tests/unit/test_output_normalizer.py": { "module": "dare_framework/agent", - "owner": "", + "owner": "lang", "tier": "unit", }, "tests/unit/test_react_agent_gateway_injection.py": { "module": "dare_framework/agent", - "owner": "", + "owner": "lang", "tier": "unit", }, "tests/unit/test_runtime_state.py": { "module": "dare_framework/agent", - "owner": "", + "owner": "lang", "tier": "unit", }, "tests/unit/test_session_orchestrator_message_input.py": { "module": "dare_framework/agent", - "owner": "", + "owner": "lang", "tier": "unit", }, # --- unit tests: checkpoint --- "tests/unit/test_checkpoint_message_schema.py": { "module": "dare_framework/checkpoint", - "owner": "", + "owner": "lang", "tier": "unit", }, # --- unit tests: client --- "tests/unit/test_client_cli.py": { "module": "client", - "owner": "", + "owner": "lang", "tier": "unit", }, "tests/unit/test_client_runtime_bootstrap.py": { "module": "client", - "owner": "", + "owner": "lang", "tier": "unit", }, # --- unit tests: config --- "tests/unit/test_config_action_handler.py": { "module": "dare_framework/config", - "owner": "", + "owner": "lang", "tier": "unit", }, "tests/unit/test_config_model.py": { "module": "dare_framework/config", - "owner": "", + "owner": "lang", "tier": "unit", }, "tests/unit/test_config_nonnull_guards.py": { "module": "dare_framework/config", - "owner": "", + "owner": "lang", "tier": "unit", }, "tests/unit/test_config_provider.py": { "module": "dare_framework/config", - "owner": "", + "owner": "lang", "tier": "unit", }, "tests/unit/test_layered_config_provider.py": { "module": "dare_framework/config", - "owner": "", + "owner": "lang", "tier": "unit", }, # --- unit tests: context --- "tests/unit/test_context_implementation.py": { "module": "dare_framework/context", - "owner": "", + "owner": "lang", "tier": "unit", }, "tests/unit/test_context_message_types.py": { "module": "dare_framework/context", - "owner": "", + "owner": "lang", "tier": "unit", }, # --- unit tests: event --- "tests/unit/test_event_log.py": { "module": "dare_framework/event", - "owner": "", + "owner": "lang", "tier": "unit", }, "tests/unit/test_event_sqlite_event_log.py": { "module": "dare_framework/event", - "owner": "", + "owner": "lang", "tier": "unit", }, # --- unit tests: examples --- "tests/unit/test_example_10_agentscope_compat.py": { "module": "examples", - "owner": "", + "owner": "lang", "tier": "unit", }, "tests/unit/test_example_hook_governance.py": { "module": "examples", - "owner": "", + "owner": "lang", "tier": "unit", }, "tests/unit/test_examples_04_cli.py": { "module": "examples", - "owner": "", + "owner": "lang", "tier": "unit", }, "tests/unit/test_examples_cli.py": { "module": "examples", - "owner": "", + "owner": "lang", "tier": "unit", }, "tests/unit/test_examples_cli_mcp.py": { "module": "examples", - "owner": "", + "owner": "lang", "tier": "unit", }, "tests/unit/test_examples_with_tools.py": { "module": "examples", - "owner": "", + "owner": "lang", "tier": "unit", }, # --- unit tests: hook --- "tests/unit/test_hook_config_model.py": { "module": "dare_framework/hook", - "owner": "", + "owner": "lang", "tier": "unit", }, "tests/unit/test_hook_contract_types.py": { "module": "dare_framework/hook", - "owner": "", + "owner": "lang", "tier": "unit", }, "tests/unit/test_hook_decision_arbiter.py": { "module": "dare_framework/hook", - "owner": "", + "owner": "lang", "tier": "unit", }, "tests/unit/test_hook_extension_point_governance.py": { "module": "dare_framework/hook", - "owner": "", + "owner": "lang", "tier": "unit", }, "tests/unit/test_hook_legacy_adapter.py": { "module": "dare_framework/hook", - "owner": "", + "owner": "lang", "tier": "unit", }, "tests/unit/test_hook_metrics.py": { "module": "dare_framework/hook", - "owner": "", + "owner": "lang", "tier": "unit", }, "tests/unit/test_hook_patch_validator.py": { "module": "dare_framework/hook", - "owner": "", + "owner": "lang", "tier": "unit", }, "tests/unit/test_hook_phase_schema.py": { "module": "dare_framework/hook", - "owner": "", + "owner": "lang", "tier": "unit", }, "tests/unit/test_hook_runner.py": { "module": "dare_framework/hook", - "owner": "", + "owner": "lang", "tier": "unit", }, "tests/unit/test_hook_selector.py": { "module": "dare_framework/hook", - "owner": "", + "owner": "lang", "tier": "unit", }, "tests/unit/test_stdout_hook.py": { "module": "dare_framework/hook", - "owner": "", + "owner": "lang", "tier": "unit", }, # --- unit tests: governance / CI scripts --- "tests/unit/test_governance_evidence_truth_gate.py": { "module": "scripts/ci", - "owner": "", + "owner": "lang", "tier": "unit", }, "tests/unit/test_governance_intent_gate.py": { "module": "scripts/ci", - "owner": "", + "owner": "lang", "tier": "unit", }, "tests/unit/test_governance_traceability_gate.py": { "module": "scripts/ci", - "owner": "", + "owner": "lang", "tier": "unit", }, "tests/unit/test_main_guard.py": { "module": "scripts/ci", - "owner": "", + "owner": "lang", "tier": "unit", }, "tests/unit/test_manual_merge_guard.py": { "module": "scripts/ci", - "owner": "", + "owner": "lang", "tier": "unit", }, "tests/unit/test_p0_gate_ci.py": { "module": "scripts/ci", - "owner": "", + "owner": "lang", "tier": "unit", }, # --- unit tests: interaction / transport --- "tests/unit/test_interaction_controls.py": { "module": "dare_framework/transport", - "owner": "", + "owner": "lang", "tier": "unit", }, "tests/unit/test_interaction_dispatcher.py": { "module": "dare_framework/transport", - "owner": "", + "owner": "lang", "tier": "unit", }, "tests/unit/test_transport_adapters.py": { "module": "dare_framework/transport", - "owner": "", + "owner": "lang", "tier": "unit", }, "tests/unit/test_transport_channel.py": { "module": "dare_framework/transport", - "owner": "", + "owner": "lang", "tier": "unit", }, "tests/unit/test_transport_typed_payloads.py": { "module": "dare_framework/transport", - "owner": "", + "owner": "lang", "tier": "unit", }, "tests/unit/test_transport_types.py": { "module": "dare_framework/transport", - "owner": "", + "owner": "lang", "tier": "unit", }, # --- unit tests: mcp --- "tests/unit/test_mcp_action_handler.py": { "module": "dare_framework/mcp", - "owner": "", + "owner": "lang", "tier": "unit", }, "tests/unit/test_mcp_client.py": { "module": "dare_framework/mcp", - "owner": "", + "owner": "lang", "tier": "unit", }, "tests/unit/test_mcp_manager.py": { "module": "dare_framework/mcp", - "owner": "", + "owner": "lang", "tier": "unit", }, "tests/unit/test_mcp_tool_provider.py": { "module": "dare_framework/mcp", - "owner": "", + "owner": "lang", "tier": "unit", }, # --- unit tests: memory --- "tests/unit/test_memory_knowledge_direct.py": { "module": "dare_framework/memory", - "owner": "", + "owner": "lang", "tier": "unit", }, "tests/unit/test_embedding_openai_adapter.py": { "module": "dare_framework/memory", - "owner": "", + "owner": "lang", "tier": "unit", }, # --- unit tests: model --- "tests/unit/test_anthropic_model_adapter.py": { "module": "dare_framework/model", - "owner": "", + "owner": "lang", "tier": "unit", }, "tests/unit/test_builtin_prompt_loader.py": { "module": "dare_framework/model", - "owner": "", + "owner": "lang", "tier": "unit", }, "tests/unit/test_default_model_adapter_manager.py": { "module": "dare_framework/model", - "owner": "", + "owner": "lang", "tier": "unit", }, "tests/unit/test_openai_model_adapter.py": { "module": "dare_framework/model", - "owner": "", + "owner": "lang", "tier": "unit", }, "tests/unit/test_openrouter_adapter.py": { "module": "dare_framework/model", - "owner": "", + "owner": "lang", "tier": "unit", }, "tests/unit/test_prompt_store.py": { "module": "dare_framework/model", - "owner": "", + "owner": "lang", "tier": "unit", }, # --- unit tests: observability --- "tests/unit/test_llm_io_capture_hook.py": { "module": "dare_framework/observability", - "owner": "", + "owner": "lang", "tier": "unit", }, "tests/unit/test_observability.py": { "module": "dare_framework/observability", - "owner": "", + "owner": "lang", "tier": "unit", }, # --- unit tests: plan --- "tests/unit/test_composite_validator.py": { "module": "dare_framework/plan", - "owner": "", + "owner": "lang", "tier": "unit", }, "tests/unit/test_default_planner.py": { "module": "dare_framework/plan", - "owner": "", + "owner": "lang", "tier": "unit", }, "tests/unit/test_default_remediator.py": { "module": "dare_framework/plan", - "owner": "", + "owner": "lang", "tier": "unit", }, "tests/unit/test_plan_v2_tools.py": { "module": "dare_framework/plan", - "owner": "", + "owner": "lang", "tier": "unit", }, "tests/unit/test_registry_plan_validator.py": { "module": "dare_framework/plan", - "owner": "", + "owner": "lang", "tier": "unit", }, # --- unit tests: security --- "tests/unit/test_security_boundary.py": { "module": "dare_framework/security", - "owner": "", + "owner": "lang", "tier": "unit", }, # --- unit tests: skill --- "tests/unit/test_search_skill_tool.py": { "module": "dare_framework/skill", - "owner": "", + "owner": "lang", "tier": "unit", }, "tests/unit/test_skill_store_builder.py": { "module": "dare_framework/skill", - "owner": "", + "owner": "lang", "tier": "unit", }, # --- unit tests: tool --- "tests/unit/test_execution_control.py": { "module": "dare_framework/tool", - "owner": "", + "owner": "lang", "tier": "unit", }, "tests/unit/test_governed_tool_gateway.py": { "module": "dare_framework/tool", - "owner": "", + "owner": "lang", "tier": "unit", }, "tests/unit/test_native_tool_provider.py": { "module": "dare_framework/tool", - "owner": "", + "owner": "lang", "tier": "unit", }, "tests/unit/test_tool_approval_action_handler.py": { "module": "dare_framework/tool", - "owner": "", + "owner": "lang", "tier": "unit", }, "tests/unit/test_tool_approval_manager.py": { "module": "dare_framework/tool", - "owner": "", + "owner": "lang", "tier": "unit", }, "tests/unit/test_tool_gateway.py": { "module": "dare_framework/tool", - "owner": "", + "owner": "lang", "tier": "unit", }, "tests/unit/test_tool_manager.py": { "module": "dare_framework/tool", - "owner": "", + "owner": "lang", "tier": "unit", }, "tests/unit/test_tool_signature_contract.py": { "module": "dare_framework/tool", - "owner": "", + "owner": "lang", "tier": "unit", }, "tests/unit/test_tool_types.py": { "module": "dare_framework/tool", - "owner": "", + "owner": "lang", "tier": "unit", }, "tests/unit/test_v4_file_tools.py": { "module": "dare_framework/tool", - "owner": "", + "owner": "lang", "tier": "unit", }, # --- unit tests: cross-cutting --- "tests/unit/test_llm_io_summary_script.py": { "module": "scripts", - "owner": "", + "owner": "lang", "tier": "unit", }, "tests/unit/test_local_backend.py": { "module": "_local_backend", - "owner": "", + "owner": "lang", "tier": "unit", }, "tests/unit/test_package_initializers_facade_pattern.py": { "module": "dare_framework", - "owner": "", + "owner": "lang", + "tier": "unit", + }, + "tests/unit/test_ownership_map_coverage.py": { + "module": "scripts/ci", + "owner": "lang", "tier": "unit", }, } diff --git a/tests/conftest.py b/tests/conftest.py index 804f7c77..eb7f19cb 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -21,6 +21,8 @@ def pytest_configure(config: pytest.Config) -> None: def pytest_collection_modifyitems(items: list[pytest.Item]) -> None: """Inject ``@pytest.mark.module`` / ``@pytest.mark.owner`` from the ownership map.""" + import warnings + from scripts.ci.test_ownership_map import OWNERSHIP_MAP for item in items: @@ -30,3 +32,14 @@ def pytest_collection_modifyitems(items: list[pytest.Item]) -> None: item.add_marker(pytest.mark.module(entry["module"])) if entry.get("owner"): item.add_marker(pytest.mark.owner(entry["owner"])) + + # Warn about test files missing from OWNERSHIP_MAP (soft guard) + collected_files = {str(Path(item.fspath).relative_to(ROOT)) for item in items} + test_files = {f for f in collected_files if Path(f).name.startswith("test_")} + unmapped = test_files - set(OWNERSHIP_MAP.keys()) + if unmapped: + warnings.warn( + f"Test files missing from OWNERSHIP_MAP ({len(unmapped)}): " + + ", ".join(sorted(unmapped)[:5]), + stacklevel=1, + ) diff --git a/tests/unit/test_ownership_map_coverage.py b/tests/unit/test_ownership_map_coverage.py new file mode 100644 index 00000000..46352fae --- /dev/null +++ b/tests/unit/test_ownership_map_coverage.py @@ -0,0 +1,33 @@ +"""Verify OWNERSHIP_MAP stays in sync with actual test files.""" +from pathlib import Path + +from scripts.ci.test_ownership_map import OWNERSHIP_MAP + +REPO_ROOT = Path(__file__).resolve().parent.parent.parent + + +def test_all_test_files_have_ownership_entry(): + """Every test_*.py must be present in OWNERSHIP_MAP.""" + actual = sorted( + str(p.relative_to(REPO_ROOT)) + for p in REPO_ROOT.joinpath("tests").rglob("test_*.py") + ) + mapped = set(OWNERSHIP_MAP.keys()) + missing = [f for f in actual if f not in mapped] + assert not missing, f"Test files missing from OWNERSHIP_MAP: {missing}" + + +def test_no_stale_ownership_entries(): + """Every OWNERSHIP_MAP entry must correspond to an existing test file.""" + actual = set( + str(p.relative_to(REPO_ROOT)) + for p in REPO_ROOT.joinpath("tests").rglob("test_*.py") + ) + stale = sorted(k for k in OWNERSHIP_MAP if k not in actual) + assert not stale, f"Stale OWNERSHIP_MAP entries (files no longer exist): {stale}" + + +def test_all_owners_are_nonempty(): + """Every entry must have a non-empty owner assigned.""" + unassigned = sorted(k for k, v in OWNERSHIP_MAP.items() if not v.get("owner")) + assert not unassigned, f"Entries with no owner assigned: {unassigned[:10]}..." From 9c75c931f63b106dbcc3c141d9f84eeb2f64d781 Mon Sep 17 00:00:00 2001 From: mindfn Date: Wed, 11 Mar 2026 15:23:34 +0800 Subject: [PATCH 03/14] fix(ci): propagate pytest process errors in ownership report Handle pytest invocation/configuration failures in check_test_failure_ownership without misreporting them as a clean run. The script now preserves the subprocess return code for default-mode pytest execution and only emits the ownership summary when node-level failures were actually parsed. A dedicated regression test covers the no-nodeid process-error path, and the new test file is registered in OWNERSHIP_MAP so the coverage guard stays consistent. --- scripts/ci/check_test_failure_ownership.py | 27 ++++++++++++++----- scripts/ci/test_ownership_map.py | 5 ++++ .../unit/test_check_test_failure_ownership.py | 24 +++++++++++++++++ 3 files changed, 49 insertions(+), 7 deletions(-) create mode 100644 tests/unit/test_check_test_failure_ownership.py diff --git a/scripts/ci/check_test_failure_ownership.py b/scripts/ci/check_test_failure_ownership.py index 7460be14..cfe50027 100644 --- a/scripts/ci/check_test_failure_ownership.py +++ b/scripts/ci/check_test_failure_ownership.py @@ -92,17 +92,21 @@ def _build_report(failed_nodeids: list[str]) -> str: return "\n".join(lines) -def _run_pytest() -> str: - """Run ``pytest -q --tb=line tests/`` and return combined output.""" - completed = subprocess.run( +def _format_completed_output(completed: subprocess.CompletedProcess[str]) -> str: + """Join stdout/stderr into the single text blob consumed by the parser.""" + return "\n".join( + part for part in [completed.stdout.strip(), completed.stderr.strip()] if part + ).strip() + + +def _run_pytest() -> subprocess.CompletedProcess[str]: + """Run ``pytest -q --tb=line tests/`` and return the completed process.""" + return subprocess.run( [sys.executable, "-m", "pytest", "-q", "--tb=line", "tests/"], capture_output=True, text=True, check=False, ) - return "\n".join( - part for part in [completed.stdout.strip(), completed.stderr.strip()] if part - ).strip() def main(argv: list[str] | None = None) -> int: @@ -123,12 +127,21 @@ def main(argv: list[str] | None = None) -> int: if args.stdin: raw = sys.stdin.read() + pytest_returncode: int | None = None elif args.report: raw = args.report.read_text(encoding="utf-8") + pytest_returncode = None else: - raw = _run_pytest() + completed = _run_pytest() + raw = _format_completed_output(completed) + pytest_returncode = completed.returncode failed = _parse_failed_lines(raw) + if pytest_returncode and not failed: + if raw: + print(raw, file=sys.stderr) + return pytest_returncode + report = _build_report(failed) print(report) diff --git a/scripts/ci/test_ownership_map.py b/scripts/ci/test_ownership_map.py index 7197176f..c3e255f1 100644 --- a/scripts/ci/test_ownership_map.py +++ b/scripts/ci/test_ownership_map.py @@ -362,6 +362,11 @@ "owner": "lang", "tier": "unit", }, + "tests/unit/test_check_test_failure_ownership.py": { + "module": "scripts/ci", + "owner": "lang", + "tier": "unit", + }, "tests/unit/test_main_guard.py": { "module": "scripts/ci", "owner": "lang", diff --git a/tests/unit/test_check_test_failure_ownership.py b/tests/unit/test_check_test_failure_ownership.py new file mode 100644 index 00000000..3b980f3b --- /dev/null +++ b/tests/unit/test_check_test_failure_ownership.py @@ -0,0 +1,24 @@ +import subprocess + +from scripts.ci import check_test_failure_ownership as module + + +def test_main_propagates_pytest_process_errors_without_failed_nodeids( + monkeypatch, capsys +) -> None: + def _fake_run(*_args, **_kwargs) -> subprocess.CompletedProcess[str]: + return subprocess.CompletedProcess( + args=["pytest"], + returncode=4, + stdout="", + stderr="ERROR: usage: pytest [options] [file_or_dir] ...", + ) + + monkeypatch.setattr(module.subprocess, "run", _fake_run) + + exit_code = module.main([]) + + captured = capsys.readouterr() + assert exit_code == 4 + assert "ERROR: usage: pytest" in captured.err + assert "No test failures detected." not in captured.out From b2e4b283dbca9f00aa49d5510010fc3a3358d323 Mon Sep 17 00:00:00 2001 From: mindfn Date: Wed, 11 Mar 2026 16:48:00 +0800 Subject: [PATCH 04/14] fix(ci): ignore collection pseudo-nodeids in ownership parser Prevent ERROR collecting output from being misparsed as a failed test nodeid in the ownership attribution check.\n\nAdd a regression test for collection failures, tighten failed-line parsing so only real pytest nodeids are attributed, and preserve the subprocess-returncode fallback for non-nodeid process errors. --- scripts/ci/check_test_failure_ownership.py | 16 ++++++++++++-- .../unit/test_check_test_failure_ownership.py | 21 +++++++++++++++++++ 2 files changed, 35 insertions(+), 2 deletions(-) diff --git a/scripts/ci/check_test_failure_ownership.py b/scripts/ci/check_test_failure_ownership.py index cfe50027..bc4a4367 100644 --- a/scripts/ci/check_test_failure_ownership.py +++ b/scripts/ci/check_test_failure_ownership.py @@ -30,16 +30,28 @@ if _REPO_ROOT not in sys.path: sys.path.insert(0, _REPO_ROOT) -FAILED_TEST_RE = re.compile(r"^(?:FAILED|ERROR)\s+([^\s]+)") +FAILED_TEST_RE = re.compile(r"^FAILED\s+([^\s]+)") + + +def _looks_like_test_nodeid(token: str) -> bool: + """Return true when a pytest summary token looks like a real test node id.""" + return ".py" in token def _parse_failed_lines(text: str) -> list[str]: """Extract ``FAILED``/``ERROR`` test node IDs from raw pytest output.""" results: list[str] = [] for line in text.splitlines(): - m = FAILED_TEST_RE.match(line.strip()) + stripped = line.strip() + m = FAILED_TEST_RE.match(stripped) if m: results.append(m.group(1)) + continue + if not stripped.startswith("ERROR "): + continue + parts = stripped.split(maxsplit=2) + if len(parts) >= 2 and _looks_like_test_nodeid(parts[1]): + results.append(parts[1]) return results diff --git a/tests/unit/test_check_test_failure_ownership.py b/tests/unit/test_check_test_failure_ownership.py index 3b980f3b..37721e78 100644 --- a/tests/unit/test_check_test_failure_ownership.py +++ b/tests/unit/test_check_test_failure_ownership.py @@ -22,3 +22,24 @@ def _fake_run(*_args, **_kwargs) -> subprocess.CompletedProcess[str]: assert exit_code == 4 assert "ERROR: usage: pytest" in captured.err assert "No test failures detected." not in captured.out + + +def test_main_propagates_pytest_collection_errors_without_fake_failed_nodeid( + monkeypatch, capsys +) -> None: + def _fake_run(*_args, **_kwargs) -> subprocess.CompletedProcess[str]: + return subprocess.CompletedProcess( + args=["pytest"], + returncode=2, + stdout="ERROR collecting tests/unit/test_demo.py", + stderr="", + ) + + monkeypatch.setattr(module.subprocess, "run", _fake_run) + + exit_code = module.main([]) + + captured = capsys.readouterr() + assert exit_code == 2 + assert "ERROR collecting tests/unit/test_demo.py" in captured.err + assert "FAILED collecting" not in captured.out From 3d0f75ad88b52cf39e20cb503ab14bcf75ce3a84 Mon Sep 17 00:00:00 2001 From: mindfn Date: Wed, 11 Mar 2026 17:29:44 +0800 Subject: [PATCH 05/14] fix(ci): reject file-level ERROR lines as pytest nodeids Tighten ownership parsing so only ERROR summary tokens with real pytest nodeid shape are attributed as failed tests.\n\nThis keeps file-level collection or import failures on the subprocess-returncode path, preserves the original pytest diagnostics, and adds regression coverage for that pseudo-nodeid case. --- scripts/ci/check_test_failure_ownership.py | 4 +++- .../unit/test_check_test_failure_ownership.py | 21 +++++++++++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/scripts/ci/check_test_failure_ownership.py b/scripts/ci/check_test_failure_ownership.py index bc4a4367..ac754a84 100644 --- a/scripts/ci/check_test_failure_ownership.py +++ b/scripts/ci/check_test_failure_ownership.py @@ -35,7 +35,9 @@ def _looks_like_test_nodeid(token: str) -> bool: """Return true when a pytest summary token looks like a real test node id.""" - return ".py" in token + # ``ERROR path/to/test_file.py`` is still a file-level collection/import failure. + # Only ``.py::...`` tokens identify a concrete pytest test node. + return ".py::" in token def _parse_failed_lines(text: str) -> list[str]: diff --git a/tests/unit/test_check_test_failure_ownership.py b/tests/unit/test_check_test_failure_ownership.py index 37721e78..a3d9a5ab 100644 --- a/tests/unit/test_check_test_failure_ownership.py +++ b/tests/unit/test_check_test_failure_ownership.py @@ -43,3 +43,24 @@ def _fake_run(*_args, **_kwargs) -> subprocess.CompletedProcess[str]: assert exit_code == 2 assert "ERROR collecting tests/unit/test_demo.py" in captured.err assert "FAILED collecting" not in captured.out + + +def test_main_propagates_pytest_file_level_errors_without_pseudo_nodeid( + monkeypatch, capsys +) -> None: + def _fake_run(*_args, **_kwargs) -> subprocess.CompletedProcess[str]: + return subprocess.CompletedProcess( + args=["pytest"], + returncode=2, + stdout="ERROR tests/unit/test_tmp_collection_error.py", + stderr="", + ) + + monkeypatch.setattr(module.subprocess, "run", _fake_run) + + exit_code = module.main([]) + + captured = capsys.readouterr() + assert exit_code == 2 + assert "ERROR tests/unit/test_tmp_collection_error.py" in captured.err + assert "FAILED tests/unit/test_tmp_collection_error.py" not in captured.out From d34bce2ab1756eee6e8f56d0c0a0a8e16fcafa4b Mon Sep 17 00:00:00 2001 From: mindfn Date: Thu, 12 Mar 2026 09:55:09 +0800 Subject: [PATCH 06/14] fix(ci): preserve full pytest failure identities in report modes Handle stdin and report inputs as failing runs when pytest emits collection or process errors without attributable node IDs.\n\nPreserve full FAILED and ERROR summary node IDs, including parametrized IDs with spaces, and add regression coverage for stdin/report non-zero behavior plus whitespace-containing node IDs in the ownership parser tests. --- scripts/ci/check_test_failure_ownership.py | 46 ++++++++++++----- .../unit/test_check_test_failure_ownership.py | 49 +++++++++++++++++++ 2 files changed, 84 insertions(+), 11 deletions(-) diff --git a/scripts/ci/check_test_failure_ownership.py b/scripts/ci/check_test_failure_ownership.py index ac754a84..abe05da9 100644 --- a/scripts/ci/check_test_failure_ownership.py +++ b/scripts/ci/check_test_failure_ownership.py @@ -30,7 +30,8 @@ if _REPO_ROOT not in sys.path: sys.path.insert(0, _REPO_ROOT) -FAILED_TEST_RE = re.compile(r"^FAILED\s+([^\s]+)") +FAILED_TEST_RE = re.compile(r"^FAILED\s+(.+?)(?=\s+-\s+|$)") +ERROR_TEST_RE = re.compile(r"^ERROR\s+(.+?)(?=\s+-\s+|$)") def _looks_like_test_nodeid(token: str) -> bool: @@ -40,23 +41,43 @@ def _looks_like_test_nodeid(token: str) -> bool: return ".py::" in token +def _summary_nodeid(line: str, pattern: re.Pattern[str]) -> str | None: + """Extract the pytest summary nodeid portion from a FAILED/ERROR line.""" + match = pattern.match(line) + if match is None: + return None + return match.group(1) + + def _parse_failed_lines(text: str) -> list[str]: """Extract ``FAILED``/``ERROR`` test node IDs from raw pytest output.""" results: list[str] = [] for line in text.splitlines(): stripped = line.strip() - m = FAILED_TEST_RE.match(stripped) - if m: - results.append(m.group(1)) + failed_nodeid = _summary_nodeid(stripped, FAILED_TEST_RE) + if failed_nodeid is not None: + results.append(failed_nodeid) continue - if not stripped.startswith("ERROR "): - continue - parts = stripped.split(maxsplit=2) - if len(parts) >= 2 and _looks_like_test_nodeid(parts[1]): - results.append(parts[1]) + error_nodeid = _summary_nodeid(stripped, ERROR_TEST_RE) + if error_nodeid is not None and _looks_like_test_nodeid(error_nodeid): + results.append(error_nodeid) return results +def _has_unattributed_pytest_error(text: str) -> bool: + """Detect pytest process/collection failures that did not yield test node IDs.""" + for line in text.splitlines(): + stripped = line.strip() + if not stripped: + continue + if stripped.startswith(("ERROR:", "ERROR collecting ", "INTERNALERROR>")): + return True + error_nodeid = _summary_nodeid(stripped, ERROR_TEST_RE) + if error_nodeid is not None and not _looks_like_test_nodeid(error_nodeid): + return True + return False + + def _test_path_from_nodeid(nodeid: str) -> str: """Convert ``tests/unit/test_foo.py::test_bar`` to ``tests/unit/test_foo.py``.""" return nodeid.split("::")[0] @@ -151,10 +172,13 @@ def main(argv: list[str] | None = None) -> int: pytest_returncode = completed.returncode failed = _parse_failed_lines(raw) - if pytest_returncode and not failed: + fallback_exit_code = pytest_returncode + if fallback_exit_code is None and not failed and _has_unattributed_pytest_error(raw): + fallback_exit_code = 1 + if fallback_exit_code and not failed: if raw: print(raw, file=sys.stderr) - return pytest_returncode + return fallback_exit_code report = _build_report(failed) print(report) diff --git a/tests/unit/test_check_test_failure_ownership.py b/tests/unit/test_check_test_failure_ownership.py index a3d9a5ab..55f9d1ef 100644 --- a/tests/unit/test_check_test_failure_ownership.py +++ b/tests/unit/test_check_test_failure_ownership.py @@ -1,3 +1,4 @@ +import io import subprocess from scripts.ci import check_test_failure_ownership as module @@ -64,3 +65,51 @@ def _fake_run(*_args, **_kwargs) -> subprocess.CompletedProcess[str]: assert exit_code == 2 assert "ERROR tests/unit/test_tmp_collection_error.py" in captured.err assert "FAILED tests/unit/test_tmp_collection_error.py" not in captured.out + + +def test_main_stdin_mode_returns_nonzero_for_collection_errors_without_failed_nodeids( + monkeypatch, capsys +) -> None: + monkeypatch.setattr( + module.sys, + "stdin", + io.StringIO("ERROR collecting tests/unit/test_demo.py"), + ) + + exit_code = module.main(["--stdin"]) + + captured = capsys.readouterr() + assert exit_code == 1 + assert "ERROR collecting tests/unit/test_demo.py" in captured.err + assert "No test failures detected." not in captured.out + + +def test_main_report_mode_returns_nonzero_for_usage_errors_without_failed_nodeids( + tmp_path, capsys +) -> None: + report_path = tmp_path / "pytest-output.txt" + report_path.write_text( + "ERROR: usage: pytest [options] [file_or_dir] ...", + encoding="utf-8", + ) + + exit_code = module.main(["--report", str(report_path)]) + + captured = capsys.readouterr() + assert exit_code == 1 + assert "ERROR: usage: pytest" in captured.err + assert "No test failures detected." not in captured.out + + +def test_parse_failed_lines_preserves_parametrized_nodeids_with_spaces() -> None: + raw = "\n".join( + [ + "FAILED tests/unit/test_demo.py::test_case[hello world] - AssertionError: boom", + "ERROR tests/unit/test_demo.py::test_setup[hello world] - RuntimeError: boom", + ] + ) + + assert module._parse_failed_lines(raw) == [ + "tests/unit/test_demo.py::test_case[hello world]", + "tests/unit/test_demo.py::test_setup[hello world]", + ] From f6e34825f11fa0a9921d2cde23c840ef4f7c4768 Mon Sep 17 00:00:00 2001 From: mindfn Date: Thu, 12 Mar 2026 11:22:10 +0800 Subject: [PATCH 07/14] fix(ci): detect interrupted report-mode pytest runs Treat stdin and report inputs as failing runs when pytest output indicates an interrupted execution or that no tests were collected, even when no FAILED or ERROR node IDs are present.\n\nAdd regression coverage for KeyboardInterrupt and no-tests-collected report flows while preserving the earlier ownership parsing fixes and whitespace-safe nodeid handling. --- scripts/ci/check_test_failure_ownership.py | 7 +++- .../unit/test_check_test_failure_ownership.py | 34 +++++++++++++++++++ 2 files changed, 40 insertions(+), 1 deletion(-) diff --git a/scripts/ci/check_test_failure_ownership.py b/scripts/ci/check_test_failure_ownership.py index abe05da9..41df3599 100644 --- a/scripts/ci/check_test_failure_ownership.py +++ b/scripts/ci/check_test_failure_ownership.py @@ -65,13 +65,18 @@ def _parse_failed_lines(text: str) -> list[str]: def _has_unattributed_pytest_error(text: str) -> bool: - """Detect pytest process/collection failures that did not yield test node IDs.""" + """Detect pytest process failures that did not yield attributable node IDs.""" for line in text.splitlines(): stripped = line.strip() if not stripped: continue + normalized = stripped.lower() if stripped.startswith(("ERROR:", "ERROR collecting ", "INTERNALERROR>")): return True + if "KeyboardInterrupt" in stripped or "NO_TESTS_COLLECTED" in stripped: + return True + if normalized.startswith("no tests ran") or "no tests collected" in normalized: + return True error_nodeid = _summary_nodeid(stripped, ERROR_TEST_RE) if error_nodeid is not None and not _looks_like_test_nodeid(error_nodeid): return True diff --git a/tests/unit/test_check_test_failure_ownership.py b/tests/unit/test_check_test_failure_ownership.py index 55f9d1ef..4975636e 100644 --- a/tests/unit/test_check_test_failure_ownership.py +++ b/tests/unit/test_check_test_failure_ownership.py @@ -101,6 +101,40 @@ def test_main_report_mode_returns_nonzero_for_usage_errors_without_failed_nodeid assert "No test failures detected." not in captured.out +def test_main_stdin_mode_returns_nonzero_for_keyboard_interrupt( + monkeypatch, capsys +) -> None: + monkeypatch.setattr( + module.sys, + "stdin", + io.StringIO("KeyboardInterrupt"), + ) + + exit_code = module.main(["--stdin"]) + + captured = capsys.readouterr() + assert exit_code == 1 + assert "KeyboardInterrupt" in captured.err + assert "No test failures detected." not in captured.out + + +def test_main_report_mode_returns_nonzero_when_no_tests_are_collected( + tmp_path, capsys +) -> None: + report_path = tmp_path / "pytest-output.txt" + report_path.write_text( + "no tests ran in 0.01s", + encoding="utf-8", + ) + + exit_code = module.main(["--report", str(report_path)]) + + captured = capsys.readouterr() + assert exit_code == 1 + assert "no tests ran in 0.01s" in captured.err + assert "No test failures detected." not in captured.out + + def test_parse_failed_lines_preserves_parametrized_nodeids_with_spaces() -> None: raw = "\n".join( [ From cdaf675a4f136590c2d0be920f004e51a9369bb7 Mon Sep 17 00:00:00 2001 From: mindfn Date: Thu, 12 Mar 2026 11:47:59 +0800 Subject: [PATCH 08/14] fix(ci): harden stdin and nodeid summary parsing Treat empty stdin in report mode as an upstream pytest failure and preserve full node IDs when parametrized summaries contain the literal " - " delimiter.\n\nAdd regression coverage for empty stdin pipelines and dash-containing parametrized IDs while keeping the prior stdin/report non-zero handling and ownership parsing fixes intact. --- scripts/ci/check_test_failure_ownership.py | 37 ++++++++++++++----- .../unit/test_check_test_failure_ownership.py | 25 +++++++++++++ 2 files changed, 52 insertions(+), 10 deletions(-) diff --git a/scripts/ci/check_test_failure_ownership.py b/scripts/ci/check_test_failure_ownership.py index 41df3599..ba3c9944 100644 --- a/scripts/ci/check_test_failure_ownership.py +++ b/scripts/ci/check_test_failure_ownership.py @@ -19,7 +19,6 @@ from __future__ import annotations import argparse -import re import subprocess import sys from pathlib import Path @@ -30,8 +29,8 @@ if _REPO_ROOT not in sys.path: sys.path.insert(0, _REPO_ROOT) -FAILED_TEST_RE = re.compile(r"^FAILED\s+(.+?)(?=\s+-\s+|$)") -ERROR_TEST_RE = re.compile(r"^ERROR\s+(.+?)(?=\s+-\s+|$)") +FAILED_PREFIX = "FAILED " +ERROR_PREFIX = "ERROR " def _looks_like_test_nodeid(token: str) -> bool: @@ -41,12 +40,25 @@ def _looks_like_test_nodeid(token: str) -> bool: return ".py::" in token -def _summary_nodeid(line: str, pattern: re.Pattern[str]) -> str | None: +def _summary_nodeid(line: str, prefix: str) -> str | None: """Extract the pytest summary nodeid portion from a FAILED/ERROR line.""" - match = pattern.match(line) - if match is None: + if not line.startswith(prefix): return None - return match.group(1) + remainder = line[len(prefix) :] + if not remainder: + return None + + bracket_depth = 0 + for idx, char in enumerate(remainder): + if char == "[": + bracket_depth += 1 + continue + if char == "]" and bracket_depth > 0: + bracket_depth -= 1 + continue + if bracket_depth == 0 and remainder.startswith(" - ", idx): + return remainder[:idx] + return remainder def _parse_failed_lines(text: str) -> list[str]: @@ -54,11 +66,11 @@ def _parse_failed_lines(text: str) -> list[str]: results: list[str] = [] for line in text.splitlines(): stripped = line.strip() - failed_nodeid = _summary_nodeid(stripped, FAILED_TEST_RE) + failed_nodeid = _summary_nodeid(stripped, FAILED_PREFIX) if failed_nodeid is not None: results.append(failed_nodeid) continue - error_nodeid = _summary_nodeid(stripped, ERROR_TEST_RE) + error_nodeid = _summary_nodeid(stripped, ERROR_PREFIX) if error_nodeid is not None and _looks_like_test_nodeid(error_nodeid): results.append(error_nodeid) return results @@ -77,7 +89,7 @@ def _has_unattributed_pytest_error(text: str) -> bool: return True if normalized.startswith("no tests ran") or "no tests collected" in normalized: return True - error_nodeid = _summary_nodeid(stripped, ERROR_TEST_RE) + error_nodeid = _summary_nodeid(stripped, ERROR_PREFIX) if error_nodeid is not None and not _looks_like_test_nodeid(error_nodeid): return True return False @@ -168,6 +180,11 @@ def main(argv: list[str] | None = None) -> int: if args.stdin: raw = sys.stdin.read() pytest_returncode: int | None = None + if not raw.strip(): + # The documented pipe flow should yield pytest stdout. Empty stdin + # means the upstream command failed before producing reportable output. + print("No pytest output received on stdin.", file=sys.stderr) + return 1 elif args.report: raw = args.report.read_text(encoding="utf-8") pytest_returncode = None diff --git a/tests/unit/test_check_test_failure_ownership.py b/tests/unit/test_check_test_failure_ownership.py index 4975636e..2d4d3857 100644 --- a/tests/unit/test_check_test_failure_ownership.py +++ b/tests/unit/test_check_test_failure_ownership.py @@ -118,6 +118,17 @@ def test_main_stdin_mode_returns_nonzero_for_keyboard_interrupt( assert "No test failures detected." not in captured.out +def test_main_stdin_mode_returns_nonzero_for_empty_input(monkeypatch, capsys) -> None: + monkeypatch.setattr(module.sys, "stdin", io.StringIO("")) + + exit_code = module.main(["--stdin"]) + + captured = capsys.readouterr() + assert exit_code == 1 + assert "No pytest output received on stdin." in captured.err + assert "No test failures detected." not in captured.out + + def test_main_report_mode_returns_nonzero_when_no_tests_are_collected( tmp_path, capsys ) -> None: @@ -147,3 +158,17 @@ def test_parse_failed_lines_preserves_parametrized_nodeids_with_spaces() -> None "tests/unit/test_demo.py::test_case[hello world]", "tests/unit/test_demo.py::test_setup[hello world]", ] + + +def test_parse_failed_lines_preserves_nodeids_containing_dash_delimiters() -> None: + raw = "\n".join( + [ + "FAILED tests/unit/test_demo.py::test_case[hello - world] - AssertionError: boom", + "ERROR tests/unit/test_demo.py::test_setup[hello - world] - RuntimeError: boom", + ] + ) + + assert module._parse_failed_lines(raw) == [ + "tests/unit/test_demo.py::test_case[hello - world]", + "tests/unit/test_demo.py::test_setup[hello - world]", + ] From 87d378b6c71b5d42ead87b0ac89de57ec5134be9 Mon Sep 17 00:00:00 2001 From: mindfn Date: Thu, 12 Mar 2026 11:58:32 +0800 Subject: [PATCH 09/14] fix(ci): normalize ownership paths and fail empty reports Treat empty --report inputs as upstream pytest failures so report-mode wrappers do not silently pass on stdout-only capture mistakes.\n\nNormalize ownership-map lookup keys to forward-slash repo-relative paths in tests/conftest.py and the coverage checks so Windows collection uses the same key shape as OWNERSHIP_MAP. Add targeted regression coverage for empty report files and path normalization, and register the new conftest ownership test in the canonical map. --- scripts/ci/check_test_failure_ownership.py | 3 +++ scripts/ci/test_ownership_map.py | 5 ++++ tests/conftest.py | 9 +++++-- .../unit/test_check_test_failure_ownership.py | 14 +++++++++++ tests/unit/test_conftest_ownership_markers.py | 24 +++++++++++++++++++ tests/unit/test_ownership_map_coverage.py | 4 ++-- 6 files changed, 55 insertions(+), 4 deletions(-) create mode 100644 tests/unit/test_conftest_ownership_markers.py diff --git a/scripts/ci/check_test_failure_ownership.py b/scripts/ci/check_test_failure_ownership.py index ba3c9944..cc94e0a8 100644 --- a/scripts/ci/check_test_failure_ownership.py +++ b/scripts/ci/check_test_failure_ownership.py @@ -188,6 +188,9 @@ def main(argv: list[str] | None = None) -> int: elif args.report: raw = args.report.read_text(encoding="utf-8") pytest_returncode = None + if not raw.strip(): + print("No pytest output found in report file.", file=sys.stderr) + return 1 else: completed = _run_pytest() raw = _format_completed_output(completed) diff --git a/scripts/ci/test_ownership_map.py b/scripts/ci/test_ownership_map.py index c3e255f1..fdfdae03 100644 --- a/scripts/ci/test_ownership_map.py +++ b/scripts/ci/test_ownership_map.py @@ -367,6 +367,11 @@ "owner": "lang", "tier": "unit", }, + "tests/unit/test_conftest_ownership_markers.py": { + "module": "tests", + "owner": "lang", + "tier": "unit", + }, "tests/unit/test_main_guard.py": { "module": "scripts/ci", "owner": "lang", diff --git a/tests/conftest.py b/tests/conftest.py index eb7f19cb..a19543b6 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -19,6 +19,11 @@ def pytest_configure(config: pytest.Config) -> None: config.addinivalue_line("markers", "owner(name): responsible owner/team handle") +def _normalize_ownership_relpath(path: Path) -> str: + """Normalize repo-relative test paths into OWNERSHIP_MAP key format.""" + return path.as_posix() + + def pytest_collection_modifyitems(items: list[pytest.Item]) -> None: """Inject ``@pytest.mark.module`` / ``@pytest.mark.owner`` from the ownership map.""" import warnings @@ -26,7 +31,7 @@ def pytest_collection_modifyitems(items: list[pytest.Item]) -> None: from scripts.ci.test_ownership_map import OWNERSHIP_MAP for item in items: - rel = str(Path(item.fspath).relative_to(ROOT)) + rel = _normalize_ownership_relpath(Path(item.fspath).relative_to(ROOT)) entry = OWNERSHIP_MAP.get(rel) if entry: item.add_marker(pytest.mark.module(entry["module"])) @@ -34,7 +39,7 @@ def pytest_collection_modifyitems(items: list[pytest.Item]) -> None: item.add_marker(pytest.mark.owner(entry["owner"])) # Warn about test files missing from OWNERSHIP_MAP (soft guard) - collected_files = {str(Path(item.fspath).relative_to(ROOT)) for item in items} + collected_files = {_normalize_ownership_relpath(Path(item.fspath).relative_to(ROOT)) for item in items} test_files = {f for f in collected_files if Path(f).name.startswith("test_")} unmapped = test_files - set(OWNERSHIP_MAP.keys()) if unmapped: diff --git a/tests/unit/test_check_test_failure_ownership.py b/tests/unit/test_check_test_failure_ownership.py index 2d4d3857..33f505a1 100644 --- a/tests/unit/test_check_test_failure_ownership.py +++ b/tests/unit/test_check_test_failure_ownership.py @@ -101,6 +101,20 @@ def test_main_report_mode_returns_nonzero_for_usage_errors_without_failed_nodeid assert "No test failures detected." not in captured.out +def test_main_report_mode_returns_nonzero_for_empty_report_file( + tmp_path, capsys +) -> None: + report_path = tmp_path / "pytest-output.txt" + report_path.write_text("", encoding="utf-8") + + exit_code = module.main(["--report", str(report_path)]) + + captured = capsys.readouterr() + assert exit_code == 1 + assert "No pytest output found in report file." in captured.err + assert "No test failures detected." not in captured.out + + def test_main_stdin_mode_returns_nonzero_for_keyboard_interrupt( monkeypatch, capsys ) -> None: diff --git a/tests/unit/test_conftest_ownership_markers.py b/tests/unit/test_conftest_ownership_markers.py new file mode 100644 index 00000000..14c738b4 --- /dev/null +++ b/tests/unit/test_conftest_ownership_markers.py @@ -0,0 +1,24 @@ +from __future__ import annotations + +import importlib.util +from pathlib import Path, PureWindowsPath + + +def _load_repo_conftest(): + repo_root = Path(__file__).resolve().parents[2] + spec = importlib.util.spec_from_file_location( + "repo_test_conftest", + repo_root / "tests" / "conftest.py", + ) + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def test_normalize_ownership_relpath_uses_forward_slashes() -> None: + repo_conftest = _load_repo_conftest() + + assert repo_conftest._normalize_ownership_relpath( # type: ignore[attr-defined] + PureWindowsPath("tests\\unit\\test_demo.py") + ) == "tests/unit/test_demo.py" diff --git a/tests/unit/test_ownership_map_coverage.py b/tests/unit/test_ownership_map_coverage.py index 46352fae..a084fc3d 100644 --- a/tests/unit/test_ownership_map_coverage.py +++ b/tests/unit/test_ownership_map_coverage.py @@ -9,7 +9,7 @@ def test_all_test_files_have_ownership_entry(): """Every test_*.py must be present in OWNERSHIP_MAP.""" actual = sorted( - str(p.relative_to(REPO_ROOT)) + p.relative_to(REPO_ROOT).as_posix() for p in REPO_ROOT.joinpath("tests").rglob("test_*.py") ) mapped = set(OWNERSHIP_MAP.keys()) @@ -20,7 +20,7 @@ def test_all_test_files_have_ownership_entry(): def test_no_stale_ownership_entries(): """Every OWNERSHIP_MAP entry must correspond to an existing test file.""" actual = set( - str(p.relative_to(REPO_ROOT)) + p.relative_to(REPO_ROOT).as_posix() for p in REPO_ROOT.joinpath("tests").rglob("test_*.py") ) stale = sorted(k for k in OWNERSHIP_MAP if k not in actual) From 6aeacaa65481314d98a1271988c0d84555eeef68 Mon Sep 17 00:00:00 2001 From: mindfn Date: Thu, 12 Mar 2026 14:14:22 +0800 Subject: [PATCH 10/14] fix(ci): keep pytest errors visible beside failed nodeids Preserve unattributed pytest process and collection errors even when a run also contains ordinary failed test node IDs.\n\nWhen mixed failure modes occur, stderr now retains the raw pytest diagnostics and the script returns the synthesized or upstream error exit code instead of downgrading the run to a generic failed-test report. Add regression coverage for the mixed failed-nodeid plus collection-error scenario. --- scripts/ci/check_test_failure_ownership.py | 13 +++++---- .../unit/test_check_test_failure_ownership.py | 29 +++++++++++++++++++ 2 files changed, 37 insertions(+), 5 deletions(-) diff --git a/scripts/ci/check_test_failure_ownership.py b/scripts/ci/check_test_failure_ownership.py index cc94e0a8..291c6247 100644 --- a/scripts/ci/check_test_failure_ownership.py +++ b/scripts/ci/check_test_failure_ownership.py @@ -197,18 +197,21 @@ def main(argv: list[str] | None = None) -> int: pytest_returncode = completed.returncode failed = _parse_failed_lines(raw) + has_unattributed_error = _has_unattributed_pytest_error(raw) fallback_exit_code = pytest_returncode - if fallback_exit_code is None and not failed and _has_unattributed_pytest_error(raw): + if fallback_exit_code is None and has_unattributed_error: fallback_exit_code = 1 - if fallback_exit_code and not failed: - if raw: - print(raw, file=sys.stderr) + if has_unattributed_error and raw: + print(raw, file=sys.stderr) + if not failed and fallback_exit_code: return fallback_exit_code report = _build_report(failed) print(report) - return 1 if failed else 0 + if failed: + return fallback_exit_code or 1 + return 0 if __name__ == "__main__": diff --git a/tests/unit/test_check_test_failure_ownership.py b/tests/unit/test_check_test_failure_ownership.py index 33f505a1..d3af794c 100644 --- a/tests/unit/test_check_test_failure_ownership.py +++ b/tests/unit/test_check_test_failure_ownership.py @@ -67,6 +67,35 @@ def _fake_run(*_args, **_kwargs) -> subprocess.CompletedProcess[str]: assert "FAILED tests/unit/test_tmp_collection_error.py" not in captured.out +def test_main_preserves_unattributed_pytest_errors_alongside_failed_nodeids( + monkeypatch, capsys +) -> None: + def _fake_run(*_args, **_kwargs) -> subprocess.CompletedProcess[str]: + return subprocess.CompletedProcess( + args=["pytest"], + returncode=2, + stdout="\n".join( + [ + "FAILED tests/unit/test_check_test_failure_ownership.py::test_parse_failed_lines_preserves_parametrized_nodeids_with_spaces - AssertionError: boom", + "ERROR collecting tests/unit/test_tmp_collection_error.py", + ] + ), + stderr="", + ) + + monkeypatch.setattr(module.subprocess, "run", _fake_run) + + exit_code = module.main([]) + + captured = capsys.readouterr() + assert exit_code == 2 + assert "ERROR collecting tests/unit/test_tmp_collection_error.py" in captured.err + assert ( + "FAILED tests/unit/test_check_test_failure_ownership.py::" + "test_parse_failed_lines_preserves_parametrized_nodeids_with_spaces" + ) in captured.out + + def test_main_stdin_mode_returns_nonzero_for_collection_errors_without_failed_nodeids( monkeypatch, capsys ) -> None: From 35af5f34ca195b18243338f5f29328f8887d6efa Mon Sep 17 00:00:00 2001 From: mindfn Date: Thu, 12 Mar 2026 14:38:55 +0800 Subject: [PATCH 11/14] fix(ci): ignore bogus FAILED output lines Filter FAILED summary lines through the same nodeid-shape validation used for ERROR entries so captured test stdout or stderr cannot inflate ownership parsing with fake failures.\n\nAdd a regression case covering a logged line alongside a real pytest failure and verify the ownership-report parser still preserves all previously fixed stdin/report and mixed-failure behaviors. --- scripts/ci/check_test_failure_ownership.py | 2 +- tests/unit/test_check_test_failure_ownership.py | 13 +++++++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/scripts/ci/check_test_failure_ownership.py b/scripts/ci/check_test_failure_ownership.py index 291c6247..b4827c7a 100644 --- a/scripts/ci/check_test_failure_ownership.py +++ b/scripts/ci/check_test_failure_ownership.py @@ -67,7 +67,7 @@ def _parse_failed_lines(text: str) -> list[str]: for line in text.splitlines(): stripped = line.strip() failed_nodeid = _summary_nodeid(stripped, FAILED_PREFIX) - if failed_nodeid is not None: + if failed_nodeid is not None and _looks_like_test_nodeid(failed_nodeid): results.append(failed_nodeid) continue error_nodeid = _summary_nodeid(stripped, ERROR_PREFIX) diff --git a/tests/unit/test_check_test_failure_ownership.py b/tests/unit/test_check_test_failure_ownership.py index d3af794c..dd7bb88d 100644 --- a/tests/unit/test_check_test_failure_ownership.py +++ b/tests/unit/test_check_test_failure_ownership.py @@ -215,3 +215,16 @@ def test_parse_failed_lines_preserves_nodeids_containing_dash_delimiters() -> No "tests/unit/test_demo.py::test_case[hello - world]", "tests/unit/test_demo.py::test_setup[hello - world]", ] + + +def test_parse_failed_lines_ignores_non_node_failed_output() -> None: + raw = "\n".join( + [ + "FAILED not-a-nodeid", + "FAILED tests/unit/test_demo.py::test_case - AssertionError: boom", + ] + ) + + assert module._parse_failed_lines(raw) == [ + "tests/unit/test_demo.py::test_case", + ] From ed2c784eb1bd7a5fc9a301cf1407c55eeb3f70a0 Mon Sep 17 00:00:00 2001 From: mindfn Date: Thu, 12 Mar 2026 15:14:46 +0800 Subject: [PATCH 12/14] fix(ci): align ownership coverage with pytest discovery Match ownership-map coverage and collection warnings to pytest's default filename patterns by recognizing both test_*.py and *_test.py.\n\nAdd a shared discovery helper in scripts/ci/test_ownership_map.py and reuse it from tests/conftest.py plus the ownership coverage tests so the enforcement paths stay consistent across platforms and future test additions. --- scripts/ci/test_ownership_map.py | 8 ++++++++ tests/conftest.py | 4 ++-- tests/unit/test_conftest_ownership_markers.py | 8 ++++++++ tests/unit/test_ownership_map_coverage.py | 10 ++++++---- 4 files changed, 24 insertions(+), 6 deletions(-) diff --git a/scripts/ci/test_ownership_map.py b/scripts/ci/test_ownership_map.py index fdfdae03..de37625a 100644 --- a/scripts/ci/test_ownership_map.py +++ b/scripts/ci/test_ownership_map.py @@ -7,6 +7,14 @@ from __future__ import annotations +from pathlib import Path, PurePath + + +def is_pytest_discovery_file(path: str | Path | PurePath) -> bool: + """Match pytest's default module filename patterns used by this repo.""" + name = PurePath(path).name + return name.startswith("test_") or name.endswith("_test.py") + # Each entry: test_path (relative to repo root) -> { module, owner, tier } # owner: responsible individual or team handle OWNERSHIP_MAP: dict[str, dict[str, str]] = { diff --git a/tests/conftest.py b/tests/conftest.py index a19543b6..88defd07 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -28,7 +28,7 @@ def pytest_collection_modifyitems(items: list[pytest.Item]) -> None: """Inject ``@pytest.mark.module`` / ``@pytest.mark.owner`` from the ownership map.""" import warnings - from scripts.ci.test_ownership_map import OWNERSHIP_MAP + from scripts.ci.test_ownership_map import OWNERSHIP_MAP, is_pytest_discovery_file for item in items: rel = _normalize_ownership_relpath(Path(item.fspath).relative_to(ROOT)) @@ -40,7 +40,7 @@ def pytest_collection_modifyitems(items: list[pytest.Item]) -> None: # Warn about test files missing from OWNERSHIP_MAP (soft guard) collected_files = {_normalize_ownership_relpath(Path(item.fspath).relative_to(ROOT)) for item in items} - test_files = {f for f in collected_files if Path(f).name.startswith("test_")} + test_files = {f for f in collected_files if is_pytest_discovery_file(f)} unmapped = test_files - set(OWNERSHIP_MAP.keys()) if unmapped: warnings.warn( diff --git a/tests/unit/test_conftest_ownership_markers.py b/tests/unit/test_conftest_ownership_markers.py index 14c738b4..ee582312 100644 --- a/tests/unit/test_conftest_ownership_markers.py +++ b/tests/unit/test_conftest_ownership_markers.py @@ -3,6 +3,8 @@ import importlib.util from pathlib import Path, PureWindowsPath +from scripts.ci.test_ownership_map import is_pytest_discovery_file + def _load_repo_conftest(): repo_root = Path(__file__).resolve().parents[2] @@ -22,3 +24,9 @@ def test_normalize_ownership_relpath_uses_forward_slashes() -> None: assert repo_conftest._normalize_ownership_relpath( # type: ignore[attr-defined] PureWindowsPath("tests\\unit\\test_demo.py") ) == "tests/unit/test_demo.py" + + +def test_is_pytest_discovery_file_matches_default_filename_patterns() -> None: + assert is_pytest_discovery_file("tests/unit/test_demo.py") + assert is_pytest_discovery_file("tests/unit/demo_test.py") + assert not is_pytest_discovery_file("tests/unit/demo.py") diff --git a/tests/unit/test_ownership_map_coverage.py b/tests/unit/test_ownership_map_coverage.py index a084fc3d..9a06eb4d 100644 --- a/tests/unit/test_ownership_map_coverage.py +++ b/tests/unit/test_ownership_map_coverage.py @@ -1,16 +1,17 @@ """Verify OWNERSHIP_MAP stays in sync with actual test files.""" from pathlib import Path -from scripts.ci.test_ownership_map import OWNERSHIP_MAP +from scripts.ci.test_ownership_map import OWNERSHIP_MAP, is_pytest_discovery_file REPO_ROOT = Path(__file__).resolve().parent.parent.parent def test_all_test_files_have_ownership_entry(): - """Every test_*.py must be present in OWNERSHIP_MAP.""" + """Every pytest-discoverable test module must be present in OWNERSHIP_MAP.""" actual = sorted( p.relative_to(REPO_ROOT).as_posix() - for p in REPO_ROOT.joinpath("tests").rglob("test_*.py") + for p in REPO_ROOT.joinpath("tests").rglob("*.py") + if is_pytest_discovery_file(p) ) mapped = set(OWNERSHIP_MAP.keys()) missing = [f for f in actual if f not in mapped] @@ -21,7 +22,8 @@ def test_no_stale_ownership_entries(): """Every OWNERSHIP_MAP entry must correspond to an existing test file.""" actual = set( p.relative_to(REPO_ROOT).as_posix() - for p in REPO_ROOT.joinpath("tests").rglob("test_*.py") + for p in REPO_ROOT.joinpath("tests").rglob("*.py") + if is_pytest_discovery_file(p) ) stale = sorted(k for k in OWNERSHIP_MAP if k not in actual) assert not stale, f"Stale OWNERSHIP_MAP entries (files no longer exist): {stale}" From 3672055e442c5f62665e6e7642051e3ea68150f8 Mon Sep 17 00:00:00 2001 From: mindfn Date: Thu, 12 Mar 2026 15:56:19 +0800 Subject: [PATCH 13/14] fix(ci): ignore failed-like log lines before pytest summary Limit failure-node parsing to pytest's short test summary section when a full report is available so captured stdout or stderr lines that happen to start with FAILED do not get counted as extra test failures.\n\nKeep the existing fallback for summary-only inputs, and add a regression test covering a fake FAILED tests/...::... log line that appears before the real summary output. --- scripts/ci/check_test_failure_ownership.py | 12 +++++++++++- tests/unit/test_check_test_failure_ownership.py | 14 ++++++++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/scripts/ci/check_test_failure_ownership.py b/scripts/ci/check_test_failure_ownership.py index b4827c7a..e1f5a501 100644 --- a/scripts/ci/check_test_failure_ownership.py +++ b/scripts/ci/check_test_failure_ownership.py @@ -31,6 +31,7 @@ FAILED_PREFIX = "FAILED " ERROR_PREFIX = "ERROR " +SUMMARY_HEADER = "short test summary info" def _looks_like_test_nodeid(token: str) -> bool: @@ -61,10 +62,19 @@ def _summary_nodeid(line: str, prefix: str) -> str | None: return remainder +def _candidate_summary_lines(text: str) -> list[str]: + """Prefer the pytest short-summary section when the full report is available.""" + lines = text.splitlines() + for index, line in enumerate(lines): + if SUMMARY_HEADER in line.lower(): + return lines[index + 1 :] + return lines + + def _parse_failed_lines(text: str) -> list[str]: """Extract ``FAILED``/``ERROR`` test node IDs from raw pytest output.""" results: list[str] = [] - for line in text.splitlines(): + for line in _candidate_summary_lines(text): stripped = line.strip() failed_nodeid = _summary_nodeid(stripped, FAILED_PREFIX) if failed_nodeid is not None and _looks_like_test_nodeid(failed_nodeid): diff --git a/tests/unit/test_check_test_failure_ownership.py b/tests/unit/test_check_test_failure_ownership.py index dd7bb88d..36432a8d 100644 --- a/tests/unit/test_check_test_failure_ownership.py +++ b/tests/unit/test_check_test_failure_ownership.py @@ -228,3 +228,17 @@ def test_parse_failed_lines_ignores_non_node_failed_output() -> None: assert module._parse_failed_lines(raw) == [ "tests/unit/test_demo.py::test_case", ] + + +def test_parse_failed_lines_ignores_failed_like_log_lines_before_summary() -> None: + raw = "\n".join( + [ + "FAILED tests/unit/test_other.py::test_noise - user log line", + "=========================== short test summary info ============================", + "FAILED tests/unit/test_demo.py::test_case - AssertionError: boom", + ] + ) + + assert module._parse_failed_lines(raw) == [ + "tests/unit/test_demo.py::test_case", + ] From 287cdb7e5748484955abf35abea2fc28661e9649 Mon Sep 17 00:00:00 2001 From: mindfn Date: Thu, 12 Mar 2026 16:13:38 +0800 Subject: [PATCH 14/14] fix(ci): treat strict xpass summary rows as failures Extend the pytest summary parser to recognize XPASS node IDs alongside FAILED and ERROR entries so stdin/report flows do not silently succeed when strict xfail expectations unexpectedly pass.\n\nAdd a regression test covering a short-summary-only report with XPASS output and verify that the ownership report stays non-zero instead of falling through to the clean-run message. --- scripts/ci/check_test_failure_ownership.py | 15 ++++++----- .../unit/test_check_test_failure_ownership.py | 25 +++++++++++++++++++ 2 files changed, 32 insertions(+), 8 deletions(-) diff --git a/scripts/ci/check_test_failure_ownership.py b/scripts/ci/check_test_failure_ownership.py index e1f5a501..e764f160 100644 --- a/scripts/ci/check_test_failure_ownership.py +++ b/scripts/ci/check_test_failure_ownership.py @@ -31,6 +31,7 @@ FAILED_PREFIX = "FAILED " ERROR_PREFIX = "ERROR " +XPASS_PREFIX = "XPASS " SUMMARY_HEADER = "short test summary info" @@ -72,17 +73,15 @@ def _candidate_summary_lines(text: str) -> list[str]: def _parse_failed_lines(text: str) -> list[str]: - """Extract ``FAILED``/``ERROR`` test node IDs from raw pytest output.""" + """Extract pytest failure-like summary node IDs from raw pytest output.""" results: list[str] = [] for line in _candidate_summary_lines(text): stripped = line.strip() - failed_nodeid = _summary_nodeid(stripped, FAILED_PREFIX) - if failed_nodeid is not None and _looks_like_test_nodeid(failed_nodeid): - results.append(failed_nodeid) - continue - error_nodeid = _summary_nodeid(stripped, ERROR_PREFIX) - if error_nodeid is not None and _looks_like_test_nodeid(error_nodeid): - results.append(error_nodeid) + for prefix in (FAILED_PREFIX, ERROR_PREFIX, XPASS_PREFIX): + nodeid = _summary_nodeid(stripped, prefix) + if nodeid is not None and _looks_like_test_nodeid(nodeid): + results.append(nodeid) + break return results diff --git a/tests/unit/test_check_test_failure_ownership.py b/tests/unit/test_check_test_failure_ownership.py index 36432a8d..d86fef18 100644 --- a/tests/unit/test_check_test_failure_ownership.py +++ b/tests/unit/test_check_test_failure_ownership.py @@ -189,6 +189,31 @@ def test_main_report_mode_returns_nonzero_when_no_tests_are_collected( assert "No test failures detected." not in captured.out +def test_main_report_mode_returns_nonzero_for_strict_xpass_summary( + tmp_path, capsys +) -> None: + report_path = tmp_path / "pytest-output.txt" + report_path.write_text( + "\n".join( + [ + "=========================== short test summary info ============================", + "XPASS tests/unit/test_check_test_failure_ownership.py::test_parse_failed_lines_ignores_non_node_failed_output - unexpectedly passed", + ] + ), + encoding="utf-8", + ) + + exit_code = module.main(["--report", str(report_path)]) + + captured = capsys.readouterr() + assert exit_code == 1 + assert ( + "FAILED tests/unit/test_check_test_failure_ownership.py::" + "test_parse_failed_lines_ignores_non_node_failed_output" + ) in captured.out + assert "No test failures detected." not in captured.out + + def test_parse_failed_lines_preserves_parametrized_nodeids_with_spaces() -> None: raw = "\n".join( [