From e0104aee370da06755884c02e02436bdb0da399e Mon Sep 17 00:00:00 2001 From: JSONbored <49853598+JSONbored@users.noreply.github.com> Date: Tue, 30 Jun 2026 19:02:23 -0700 Subject: [PATCH] ci(codecov): raise patch coverage target --- .../contributing-to-gittensory/SKILL.md | 8 +-- .../contributing-to-gittensory/reference.md | 6 +- .github/pull_request_template.md | 2 +- .github/workflows/ci.yml | 14 +++- AGENTS.md | 2 +- codecov.yml | 14 ++-- test/unit/codecov-policy.test.ts | 69 +++++++++++++++++++ vitest.config.ts | 2 +- 8 files changed, 100 insertions(+), 17 deletions(-) create mode 100644 test/unit/codecov-policy.test.ts diff --git a/.claude/skills/contributing-to-gittensory/SKILL.md b/.claude/skills/contributing-to-gittensory/SKILL.md index 04d78df3b7..699ac3acc1 100644 --- a/.claude/skills/contributing-to-gittensory/SKILL.md +++ b/.claude/skills/contributing-to-gittensory/SKILL.md @@ -5,7 +5,7 @@ description: >- JSONbored/gittensory repo — picking/validating an issue, implementing a change, writing tests that pass Codecov, running the local CI gate, predicting the gittensory gate, and formatting the commit + PR. gittensory reviews PRs ONE-SHOT via the gittensory gate (a GitHub App / CI) plus a - strict CI suite with Codecov (97% patch coverage, hard); there is no review back-and-forth, so a + strict CI suite with Codecov (99% patch coverage, hard); there is no review back-and-forth, so a PR must be correct, fully tested, house-style-compliant, and green before it is pushed. Invoke for any "contribute to / open a PR against / fix a bug in / add a feature to gittensory" task. --- @@ -49,7 +49,7 @@ opening a **fresh** PR. This is the entire reason to get it right before you pus ## The non-negotiables (read once, hold throughout) -1. **97%+ patch coverage is a HARD wall.** Codecov `codecov/patch` has `target: 97%, threshold: 0%` +1. **99% patch coverage is a HARD wall.** Codecov `codecov/patch` has `target: 99%, threshold: 0%` — *zero slack* — and it counts **BRANCH** coverage, not just lines. **Aim for 100% on every line you change, including invariants and a regression test for any bug you fix.** Only `src/**` counts; `apps/**`, `test/**`, `scripts/**`, and `src/env.d.ts` are ignored by Codecov. (So a UI-only change @@ -183,8 +183,8 @@ This is where most PRs fail Codecov. The bar is **every changed line AND every c so a single shard under-reports). - **Find the uncovered branch.** In the v8 text report, read the **% Branch** column and the **Uncovered Line #s** for your changed file — a line at 100% lines but <100% branch has an un-taken - `??`/ternary/`&&` side; add that case. Aim for **≥98% branch on your diff locally** so normal CI - variance never drops you under the 97% wall. + `??`/ternary/`&&` side; add that case. Aim for **100% branch on your diff locally** so normal CI + variance never drops you under the 99% wall. --- diff --git a/.claude/skills/contributing-to-gittensory/reference.md b/.claude/skills/contributing-to-gittensory/reference.md index 324e250408..3faa86e77b 100644 --- a/.claude/skills/contributing-to-gittensory/reference.md +++ b/.claude/skills/contributing-to-gittensory/reference.md @@ -47,8 +47,8 @@ workflow in this repo. There is **no** root-level Prettier gate — Prettier is ## 2. Codecov — the real coverage gate (`codecov.yml`) -- **`codecov/patch`: `target: 97%`, `threshold: 0%`, `if_ci_failed: error`.** Every line your PR - changes must be ≥97% covered. With 0% threshold and small diffs, **one uncovered branch can fail it.** +- **`codecov/patch`: `target: 99%`, `threshold: 0%`, `if_ci_failed: error`, `only_pulls: true`.** + Every line your PR changes must be ≥99% covered. With 0% threshold and small diffs, **one uncovered branch can fail it.** - **It counts BRANCH coverage** (v8 → lcov `BRDA`). A changed line whose branches are only partially exercised counts against you. "100% lines" ≠ "100% branches." - **`codecov/project`: `informational: true`** — a trend, never blocks. @@ -168,7 +168,7 @@ Date("2026-05-28T00:00:00Z"))` then `vi.useRealTimers()`. or the `test:unit` / `test:integration` scripts. Verify before pushing with the full unsharded `npm run test:coverage`. **Find a partial branch:** read the v8 text report's **% Branch** column and the **Uncovered Line #s** for your changed file (or open `coverage/lcov-report/…` / `coverage/lcov.info`) -— a line at 100% lines but <100% branch has an un-taken side. Aim ≥98% branch on the diff locally. +— a line at 100% lines but <100% branch has an un-taken side. Aim for 100% branch on the diff locally. **Branch coverage — the rule that fails most PRs.** Each of `if/else`, `? :`, `&&`, `||`, and `??` is two branches; exercise **both**. diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 7756190393..721c553ee6 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -14,7 +14,7 @@ - [ ] `git diff --check` - [ ] `npm run actionlint` - [ ] `npm run typecheck` -- [ ] `npm run test:coverage` locally; **`codecov/patch` requires ≥97% coverage of the lines AND branches you changed** (aim for **98%+** on your diff so CI variance does not fail near the threshold). Global coverage is a non-blocking trend with a loose 90% backstop, not the gate. +- [ ] `npm run test:coverage` locally; **`codecov/patch` requires ≥99% coverage of the lines AND branches you changed** (aim for **100%** on your diff so CI variance does not fail near the threshold). Global coverage is a non-blocking trend with a loose 90% backstop, not the gate. - [ ] `npm run test:workers` - [ ] `npm run build:mcp` - [ ] `npm run test:mcp-pack` diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 24cd5a8f7a..6685ecd5f2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -129,6 +129,13 @@ jobs: echo "::error title=Tests::The backend test coverage suite failed." echo "Coverage itself is gated by Codecov on changed lines (codecov/patch), computed from the complete lcov generated by this job." echo "Reproduce locally with: 'npm run test:coverage'." + - name: Verify coverage report exists + if: ${{ success() && (github.event_name == 'push' || needs.changes.outputs.backend == 'true') }} + run: | + if [ ! -s coverage/lcov.info ]; then + echo "::error title=Coverage::coverage/lcov.info is missing or empty" + exit 1 + fi - name: Upload coverage to Codecov if: ${{ success() && (github.event_name == 'push' || needs.changes.outputs.backend == 'true') }} uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0 @@ -139,7 +146,12 @@ jobs: override_branch: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.ref || github.ref_name }} override_commit: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }} override_pr: ${{ github.event_name == 'pull_request' && github.event.pull_request.number || '' }} - fail_ci_if_error: false + # Coverage upload is part of the hard gate: upload or service errors + # should fail CI instead of allowing a PR to merge without patch data. + fail_ci_if_error: true + # Test results are useful Codecov annotations, not the coverage gate. Keep + # their upload non-blocking so a JUnit ingestion hiccup does not fail CI + # after the tests and hard coverage upload have already passed. - name: Upload Vitest results to Codecov if: ${{ !cancelled() && (github.event_name == 'push' || needs.changes.outputs.backend == 'true') }} uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0 diff --git a/AGENTS.md b/AGENTS.md index b713f8aacd..a5de88b131 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -18,7 +18,7 @@ process evolves — edits to those files improve both Claude Code and Codex. (including `codecov/patch`), a gate failure, a base conflict, or an ineligible linked issue is **auto-CLOSED, one-shot** (recovery = open a fresh PR). Green CI + passing gate + mergeable-clean + a valid linked issue is **auto-MERGED**. So make it perfect before you push. -2. **97% patch coverage, branch-counted.** Aim for **100% of every changed line *and branch*** — test +2. **99% patch coverage, branch-counted.** Aim for **100% of every changed line *and branch*** — test both sides of every `??` / ternary / `&&` (a `SUM()` can return `NULL`, so the nullish arm is real), plus invariant tests and a regression test for every fix. Only `src/**` is measured by Codecov. 3. **The whole local gate must be green:** `npm run test:ci` (+ `npm audit --audit-level=moderate`). diff --git a/codecov.yml b/codecov.yml index 09014bf891..76dd25a4bc 100644 --- a/codecov.yml +++ b/codecov.yml @@ -1,9 +1,9 @@ # Codecov configuration. # -# The real coverage gate is `patch`: every line a PR changes must be >=97% -# covered. Patch coverage depends only on the PR's own diff, so merging one PR -# can never push another below the bar -- this is what kills the cross-PR churn -# the old global vitest threshold caused. +# The real coverage gate is `patch`: changed lines and branches in a PR must be +# >=99% covered. Patch coverage depends only on the PR's own diff, so merging +# one PR can never push another below the bar -- this is what kills the cross-PR +# churn the old global vitest threshold caused. # # `project` (whole-repo total) is informational only: it is reported as a trend # but never blocks a merge. vitest keeps a loose 90% local backstop separately. @@ -15,10 +15,12 @@ coverage: status: patch: default: - target: 97% + target: 99% threshold: 0% - # Only fail if the PR actually changes coverable lines. + # Keep the patch status in error if the upstream CI run failed. if_ci_failed: error + # Suppress codecov/patch on main pushes; uploads still maintain history. + only_pulls: true project: default: informational: true diff --git a/test/unit/codecov-policy.test.ts b/test/unit/codecov-policy.test.ts new file mode 100644 index 0000000000..9d89fb8b7b --- /dev/null +++ b/test/unit/codecov-policy.test.ts @@ -0,0 +1,69 @@ +import { readFileSync } from "node:fs"; +import { parse } from "yaml"; + +function readYaml(path: string): Record { + return record(parse(readFileSync(path, "utf8")), path); +} + +function record(value: unknown, label: string): Record { + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw new Error(`${label} must be an object`); + } + return value as Record; +} + +function recordArray(value: unknown, label: string): Array> { + if (!Array.isArray(value)) throw new Error(`${label} must be an array`); + return value.map((entry, index) => record(entry, `${label}[${index}]`)); +} + +function nestedRecord(source: Record, path: string[]): Record { + return path.reduce((current, key) => record(current[key], path.join(".")), source); +} + +describe("Codecov policy", () => { + it("keeps patch coverage strict and PR-scoped", () => { + const config = readYaml("codecov.yml"); + const patch = nestedRecord(config, ["coverage", "status", "patch", "default"]); + const project = nestedRecord(config, ["coverage", "status", "project", "default"]); + + expect(patch.target).toBe("99%"); + expect(patch.threshold).toBe("0%"); + expect(patch.if_ci_failed).toBe("error"); + expect(patch.only_pulls).toBe(true); + expect(project.informational).toBe(true); + }); + + it("fails closed when the backend coverage report is missing or cannot upload", () => { + const workflow = readYaml(".github/workflows/ci.yml"); + const validateCode = nestedRecord(workflow, ["jobs", "validate-code"]); + const steps = recordArray(validateCode.steps, "jobs.validate-code.steps"); + + const stepNames = steps.map((step) => step.name); + const verifyIndex = stepNames.indexOf("Verify coverage report exists"); + const coverageUploadIndex = stepNames.indexOf("Upload coverage to Codecov"); + const testResultsUploadIndex = stepNames.indexOf("Upload Vitest results to Codecov"); + + expect(verifyIndex).toBeGreaterThan(-1); + expect(coverageUploadIndex).toBeGreaterThan(verifyIndex); + expect(testResultsUploadIndex).toBeGreaterThan(coverageUploadIndex); + + const verifyStep = steps[verifyIndex]!; + const coverageUpload = steps[coverageUploadIndex]!; + const testResultsUpload = steps[testResultsUploadIndex]!; + + expect(verifyStep.if).toBe(coverageUpload.if); + expect(String(verifyStep.run)).toContain("coverage/lcov.info is missing or empty"); + expect(String(verifyStep.run)).toContain("exit 1"); + + const coverageUploadWith = record(coverageUpload.with, "coverage upload with"); + expect(coverageUploadWith.files).toBe("./coverage/lcov.info"); + expect(coverageUploadWith.disable_search).toBe(true); + expect(coverageUploadWith.fail_ci_if_error).toBe(true); + + const testResultsUploadWith = record(testResultsUpload.with, "test results upload with"); + expect(testResultsUploadWith.report_type).toBe("test_results"); + expect(testResultsUploadWith.disable_search).toBe(true); + expect(testResultsUploadWith.fail_ci_if_error).toBe(false); + }); +}); diff --git a/vitest.config.ts b/vitest.config.ts index 4bfc377064..61b6a6fba5 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -30,7 +30,7 @@ export default defineConfig({ exclude: ["src/env.d.ts", "apps/**"], // Emit lcov for Codecov to compute patch (changed-lines) coverage. reporter: ["text", "lcov"], - // The 97% requirement now lives in codecov.yml as a *patch* gate (changed + // The 99% requirement now lives in codecov.yml as a *patch* gate (changed // lines only), which is compositional: merging one PR can't drop another // below the bar. These global thresholds are only a loose catastrophe net // (e.g. a deleted test file), set well below actual coverage so routine