diff --git a/.github/workflows/javascript-coverage-quality-ci.yml b/.github/workflows/javascript-coverage-quality-ci.yml new file mode 100644 index 000000000..d9c8c7429 --- /dev/null +++ b/.github/workflows/javascript-coverage-quality-ci.yml @@ -0,0 +1,63 @@ +name: JavaScript Coverage Gate Quality CI + +on: + pull_request: + branches: [main] + paths: + - '.github/workflows/javascript-coverage-quality-ci.yml' + - 'scripts/ci/javascript_coverage_gate.py' + - 'tests/test_javascript_coverage_gate.py' + - 'tests/test_javascript_coverage_storybook_boundary.py' +permissions: + contents: read + +concurrency: + group: javascript-coverage-quality-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + exact-head-coverage-contract: + runs-on: ubuntu-24.04 + timeout-minutes: 15 + steps: + - name: Checkout exact source revision + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + ref: ${{ github.event.pull_request.head.sha || github.sha }} + persist-credentials: false + - name: Set up Python + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: '3.14' + - name: Install exact hash-verified quality dependencies + env: + PIP_DISABLE_PIP_VERSION_CHECK: '1' + PIP_NO_INPUT: '1' + shell: bash --noprofile --norc -e -o pipefail {0} + run: | + cat >"${RUNNER_TEMP}/javascript-coverage-quality-requirements.txt" <<'EOF' + coverage==7.15.2 --hash=sha256:b9a6367e4aff723e8ee8190836836124284e8fcd4265e307c844010cfa074f3f + iniconfig==2.1.0 --hash=sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760 + packaging==26.2 --hash=sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e + pluggy==1.6.0 --hash=sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746 + pygments==2.20.0 --hash=sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176 + pytest==9.1.1 --hash=sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c + EOF + python -m pip install \ + --only-binary=:all: \ + --require-hashes \ + -r "${RUNNER_TEMP}/javascript-coverage-quality-requirements.txt" + - name: Verify full central suite and classifier coverage + shell: bash --noprofile --norc -e -o pipefail {0} + run: | + test "$(git rev-parse HEAD)" = "${{ github.event.pull_request.head.sha || github.sha }}" + python -m coverage run --branch -m pytest --import-mode=importlib tests -q + python -m coverage report \ + --include='scripts/ci/javascript_coverage_gate.py' \ + --show-missing \ + --fail-under=100 + python -m compileall -q \ + scripts/ci/javascript_coverage_gate.py \ + tests/test_javascript_coverage_gate.py \ + tests/test_javascript_coverage_storybook_boundary.py + git diff --exit-code diff --git a/scripts/ci/javascript_coverage_gate.py b/scripts/ci/javascript_coverage_gate.py index b8c39e920..8a12720f3 100644 --- a/scripts/ci/javascript_coverage_gate.py +++ b/scripts/ci/javascript_coverage_gate.py @@ -14,6 +14,7 @@ METRICS = ("statements", "branches", "functions", "lines") SOURCE_SUFFIXES = {".js", ".jsx", ".ts", ".tsx", ".mjs", ".cjs"} EXCLUDED_PARTS = { + ".storybook", "__tests__", "coverage", "dist", @@ -23,6 +24,7 @@ "tests", } TEST_NAME_RE = re.compile(r"\.(?:spec|test)\.[cm]?[jt]sx?$") +STORY_NAME_RE = re.compile(r"\.stories\.[cm]?[jt]sx?$") HUNK_RE = re.compile(r"^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@") @@ -66,7 +68,7 @@ def is_runtime_source(path: str) -> bool: name = normalized.name.casefold() if normalized.suffix.casefold() not in SOURCE_SUFFIXES: return False - if name.endswith(".d.ts") or TEST_NAME_RE.search(name): + if name.endswith(".d.ts") or TEST_NAME_RE.search(name) or STORY_NAME_RE.search(name): return False if lowered_parts & EXCLUDED_PARTS: return False diff --git a/tests/test_javascript_coverage_gate.py b/tests/test_javascript_coverage_gate.py index c83af5503..d93fab544 100644 --- a/tests/test_javascript_coverage_gate.py +++ b/tests/test_javascript_coverage_gate.py @@ -481,3 +481,69 @@ def test_comment_only_change_without_mapped_units_passes( report = capsys.readouterr().out assert "comments, delimiters, or type-only declarations" in report assert "Result: PASS" in report + + +def test_changed_runtime_path_without_added_hunks_is_ignored( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + completed = subprocess.CompletedProcess( + args=["git"], returncode=0, stdout=b"src/runtime.ts\0", stderr=b"" + ) + monkeypatch.setattr(gate.subprocess, "run", lambda *args, **kwargs: completed) + monkeypatch.setattr(gate, "git", lambda *args: "diff without an added hunk") + assert gate.changed_runtime_lines(tmp_path, "base", "head") == {} + + +def test_global_summary_ignores_invalid_statement_locations() -> None: + metrics = gate.summarize_final( + { + "src/runtime.ts": { + "s": {"invalid": 1, "valid": 1}, + "f": {}, + "b": {}, + "statementMap": { + "invalid": {"start": {"line": "two"}}, + "valid": {"start": {"line": 2}}, + }, + } + } + ) + assert metrics["statements"] == 100.0 + assert metrics["lines"] == 100.0 + + +def test_path_normalization_falls_through_nonmatching_in_repo_paths( + tmp_path: Path, +) -> None: + changed = {"frontend/src/runtime.ts"} + assert gate.normalize_coverage_path( + str(tmp_path / "other.ts"), tmp_path, changed + ) is None + assert gate.normalize_coverage_path("other.ts", tmp_path, changed) is None + + +def test_coverage_loader_accepts_absolute_paths_and_ignores_unknown_json( + tmp_path: Path, +) -> None: + final = tmp_path / "coverage-final.json" + other = tmp_path / "metadata.json" + final.write_text("{}", encoding="utf-8") + other.write_text("{}", encoding="utf-8") + listing = tmp_path / "coverage-files.txt" + listing.write_text(f"{final}\n{other}\n", encoding="utf-8") + summaries, finals = gate.load_coverage_files(tmp_path, listing) + assert summaries == [] + assert finals == [(final, {})] + + +def test_unrelated_coverage_records_do_not_satisfy_changed_source( + tmp_path: Path, capsys +) -> None: + repo, base_sha, head_sha = fixture_repo(tmp_path) + summary_list = write_coverage(repo, statement_count=1) + final_path = repo / "coverage" / "coverage-final.json" + final_path.write_text( + json.dumps({str(repo / "src" / "other.ts"): {}}), encoding="utf-8" + ) + assert run_gate(repo, base_sha, head_sha, summary_list) == 1 + assert "src/calculate_total.ts is absent" in capsys.readouterr().out diff --git a/tests/test_javascript_coverage_storybook_boundary.py b/tests/test_javascript_coverage_storybook_boundary.py new file mode 100644 index 000000000..3d488a49b --- /dev/null +++ b/tests/test_javascript_coverage_storybook_boundary.py @@ -0,0 +1,37 @@ +"""Regression tests for Storybook development evidence classification.""" + +from __future__ import annotations + +import pytest + +from scripts.ci import javascript_coverage_gate as gate + + +@pytest.mark.parametrize( + "path", + [ + ".storybook/main.ts", + ".storybook/preview.ts", + "packages/editor/.storybook/main.ts", + "stories/Foo.stories.tsx", + "src/components/Foo.stories.ts", + ], +) +def test_storybook_development_evidence_is_not_product_runtime(path: str) -> None: + """Storybook configuration and stories do not count as shipped runtime.""" + assert not gate.is_runtime_source(path) + + +@pytest.mark.parametrize( + "path", + [ + "src/foo.ts", + "stories/runtime.ts", + "src/Foo.story.tsx", + "src/Foo.stories-helper.ts", + "src/storybook/main.ts", + ], +) +def test_nearby_and_near_miss_product_modules_remain_governed(path: str) -> None: + """Story-adjacent product modules remain subject to runtime coverage.""" + assert gate.is_runtime_source(path)