diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7ea4334b..de102fbb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -114,7 +114,55 @@ jobs: BRAND_PCT="$(pct packages/brand/coverage/coverage-summary.json || true)" WEBSITE_PCT="$(pct apps/website/coverage/coverage-summary.json || true)" echo "coverage: brand(shared)=${BRAND_PCT}% website(frontend)=${WEBSITE_PCT}%" + # Hand the measured numbers to the commit-back step (#372) so the + # extraction is not duplicated. Values only — no decision is made here. + # VALIDATED before writing: $GITHUB_ENV is the classic Actions + # environment-injection sink, and a value carrying a newline would + # define arbitrary further variables for every later step. These come + # from a coverage report, so a stray value is a tooling bug rather than + # an attack — but the guard costs one case statement and removes the + # sink entirely. A non-numeric value is dropped with a warning: the + # ratchet then sees no measurement and proposes nothing, which is the + # conservative outcome (it never blocks the gate — AC6). + for kv in "PAIR_COV_SHARED=${BRAND_PCT}" "PAIR_COV_FRONTEND=${WEBSITE_PCT}"; do + case "${kv#*=}" in + '' | *[!0-9.]*) + echo "::warning::${kv%%=*}: non-numeric coverage value, not exported to the ratchet" + ;; + *) echo "$kv" >>"$GITHUB_ENV" ;; + esac + done coverage_gate "$TIER" shared "$BRAND_PCT" "$CFG" coverage_gate "$TIER" frontend "$WEBSITE_PCT" "$CFG" + # Coverage baseline commit-back (story #372) — OPT-IN and nested under the + # guardrail above: `Coverage baseline commit-back` in way-of-working.md is + # `disabled` here (and is the framework default), so today this step is a + # no-op that prints WHY it skipped and writes nothing (AC1). + # Thin entrypoint (ADL 2026-07-13): every decision — the skip predicate + # (flag / base-branch push only / ratchet-marker loop guard), the monotonic + # ratchet, the git+gh command plan and the refusal classification — lives in + # the unit-tested module packages/knowledge-hub/src/tools/coverage-baseline-ratchet.ts; + # the CLI wiring is demonstrated by the coverage-gate smoke scenario. + # Placed AFTER the guardrail and always exiting 0: a refused write degrades + # to a ::warning:: and can never change the gate's verdict (AC6). + - name: Coverage baseline commit-back (ratchet — opt-in #372) + env: + # Attacker-controlled text: read via env, never interpolated into the script. + PAIR_RATCHET_HEAD_COMMIT_MESSAGE: ${{ github.event.head_commit.message }} + PAIR_RATCHET_BASE_BRANCH: main + # Dedicated, repo-scoped credential (contents:write + pull-requests:write, + # no protection bypass). Absent => the step warns, the gate is unaffected. + # Least privilege in the EVENT dimension too: bound to the only event + # allowed to write, so a pull-request run — where the diff under test can + # influence what this job executes — never has the token in its + # environment. Belt to the module's own `not-base-push` skip (braces). + COVERAGE_RATCHET_TOKEN: ${{ (github.event_name == 'push' && github.ref_name == 'main') && secrets.COVERAGE_RATCHET_TOKEN || '' }} + run: | + set -euo pipefail + pnpm --filter @pair/knowledge-hub coverage:ratchet \ + --config .pair/adoption/tech/coverage-baseline.md \ + --way-of-working .pair/adoption/tech/way-of-working.md \ + --measured "shared=${PAIR_COV_SHARED:-}" \ + --measured "frontend=${PAIR_COV_FRONTEND:-}" - name: Run E2E tests run: pnpm --filter @pair/website e2e diff --git a/.pair/adoption/decision-log/2026-07-30-coverage-ratchet-pr-not-push.md b/.pair/adoption/decision-log/2026-07-30-coverage-ratchet-pr-not-push.md new file mode 100644 index 00000000..72da8bda --- /dev/null +++ b/.pair/adoption/decision-log/2026-07-30-coverage-ratchet-pr-not-push.md @@ -0,0 +1,79 @@ +# Decision: Coverage baseline commit-back writes a bot PR from the post-merge run — never a push to the protected base, never the PR branch + +## Date + +2026-07-30 + +## Status + +Active + +## Category + +Process Decision + +## Context + +Story #282 shipped the coverage guardrail with an explicitly **advisory** persistence model: `coverage-gate.sh` prints a suggested `baseline.=NN` to stderr and a human commits it. Story #372 adds the opt-in automation, and its AC5/AC6 force two decisions that cannot be left implicit: + +1. **Does a pull-request run write back, or only the post-merge run on the base branch?** +2. **With what credential?** + +Both must be decided against the **post-#234 regime**, not against today's unprotected `main`. Story #234 (PR #390, parked open) makes `pair-review` and `pair-explicit-approval` **required status checks** on `main` and sets **`enforce_admins: true`** (ADR-018, which lands with that story — not linked here because this decision precedes its merge). Under that regime: + +- **A direct push to `main` is structurally impossible for CI.** Required status checks are evaluated on pushes to a protected branch, not only on merges; `enforce_admins: true` removes the admin escape hatch. A commit pushed by a workflow will never carry a `pair-explicit-approval` context — that context is produced by a *human* approving a PR (adr-018 pins it to `pull_request.head.sha`). So the "simplest" option in the story body — defer to the post-merge run and push — is not merely awkward after #390, it is **rejected by the host**. +- **Writing to the PR branch mutates `head.sha`**, which is exactly the commit both required contexts are pinned to. A raise would therefore invalidate a human approval that had already been given, forcing a re-approval on every coverage improvement — the automation would consume the very control #234 introduces. +- **A fork PR has no write access at all** (`GITHUB_TOKEN` is read-only on `pull_request` from a fork), so a PR-branch write is not uniformly available anyway. +- **`GITHUB_TOKEN` cannot open a PR that CI will run.** Events triggered by `GITHUB_TOKEN` do not start new workflow runs, so a PR opened with it would sit forever with `CI` and `pair-review` **pending** — un-mergeable under the same required-check regime. + +## Decision + +**AC5 — PR vs. push: neither. The write-back happens only on the post-merge `push` run on the base branch, and it lands as a bot-authored pull request, not as a push to the base branch.** + +- A `pull_request` run **never** writes. The skip predicate refuses any event that is not a `push` whose ref is the configured base branch (skip code `not-base-push`). This is uniform for fork and same-repo PRs — no fork special case, no head-SHA mutation, no invalidated approval. +- The post-merge run computes the raise, commits it **on the checked-out base commit without creating or switching to any local branch**, pushes that commit by explicit refspec to a dedicated remote branch `chore/coverage-baseline-ratchet` (`HEAD:refs/heads/chore/coverage-baseline-ratchet`, `--force-with-lease`), creates-or-updates **one** PR from it, and then resets the checkout back to the SHA it was given — the later steps of the same job must find the workspace they were handed, not a bot branch. That PR goes through the *same* protection the regime requires: CI runs, `pair-review` reports, a human approves. Nothing bypasses branch protection and no exemption/bypass allowance is requested — which is the point: #234 exists to make `main` unwritable except through review, and an automated coverage bump is not a reason to punch a hole in it. +- **The lease is made to work, not assumed.** `--force-with-lease` needs a remote-tracking ref for the destination, and a CI checkout has none for the ratchet branch (it fetches the base ref only, and the clone's fetch refspec does not map anything else). Verified empirically: without one, git rejects every non-fast-forward lease push as `stale info` — the ratchet would land its first raise and then warn forever. So the plan adds a fetch refspec for the ratchet branch and fetches it (tolerating the failure on the first run, when the branch does not exist yet) *before* pushing. A bare `--force` was rejected: the branch is bot-owned, but a force push should still be the kind git can refuse. Note what does **not** rest on the lease — never clobbering a higher baseline is a data-level guarantee (`applyRaises` re-reads the config and re-checks each proposal), not a push-level one. +- **Automated, not manual, in the sense the story asks for**: nobody has to *remember* to bump the file or *measure* anything — the raise arrives as a reviewable PR. What stays human is the one irreducible act the protection regime mandates: approving a change to `main`. + +**Credential model — a dedicated, repo-scoped, non-bypassing token, and its absence is a warning.** + +- The step reads `COVERAGE_RATCHET_TOKEN`: a **fine-grained PAT (or GitHub App installation token) scoped to this repository only**, with exactly two permissions — `contents: write` (to push the `chore/coverage-baseline-ratchet` branch) and `pull requests: write` (to open/update the ratchet PR). **No** `administration` scope, and the token holder is **not** added to any branch-protection bypass list. +- **Least privilege in the event dimension as well**: the workflow binds the secret to `push` on the base branch, so a `pull_request` run — the one whose diff can influence what the job executes — never has the token in its environment at all. That is belt; the module's `not-base-push` skip is braces. +- `GITHUB_TOKEN` is deliberately *not* used, for a functional reason rather than a scope one: a PR it opens gets no workflow run, so it can never satisfy the required contexts. +- The workflow's default token permissions are unchanged (the repo does not grant job-wide write); the ratchet's credential enters through a single `env:` on a single step. +- **Missing/insufficient credential is a refusal, not a failure** (AC6): the step emits `::warning::` naming the reason and exits 0. It runs *after* the guardrail step and never touches the gate's exit code, so a persistence failure can neither redden a green gate nor green a red one. + +**Loop termination (AC4) is a predicate plus a fixpoint, not a `[skip ci]`.** + +- The skip predicate refuses a run whose head commit message carries the marker `[coverage-baseline-ratchet]` (skip code `automated-commit`). The marker is placed in the **PR title** as well as the commit subject, so it survives a squash merge (whose subject defaults to the PR title); the ratchet **branch name** is accepted as a second marker so a plain merge commit (`Merge pull request #N from chore/coverage-baseline-ratchet`) is also caught. +- Independently, the ratchet is a **fixpoint**: the value it writes is `floor(measured) - 1pp`, so re-running on the same coverage proposes the value already committed and produces no raise. Termination therefore does not depend on the marker surviving — that is belt, this is braces. `[skip ci]` was rejected as the mechanism: it suppresses the whole pipeline, including the very gates the ratchet PR must satisfy under #234. + +**Ratchet semantics.** Monotonic — the writer only ever raises a `baseline.` value in place, never lowers or reorders, and never rewrites the surrounding markdown. The margin (`floor(measured) - 1`) is not a new convention: it is the one `tech/coverage-baseline.md` already documents and reproduces exactly (85.04 → 84, 20.16 → 19). Concurrency: the config is re-read from disk immediately before the write and each proposal re-checked for a strict raise, so a higher value written by another run is never clobbered. + +**Framework default stays `disabled`**, and **pair itself stays `disabled`** for now: enabling it before #390 lands and before `COVERAGE_RATCHET_TOKEN` is provisioned would only produce a warning on every push. The flag is a one-word edit in `way-of-working.md` when both are true. + +## Alternatives Considered + +- **Post-merge direct push to the base branch (the story body's "simplest, protection-compatible" option)**: Rejected. It is *not* protection-compatible after #234 — required status checks apply to direct pushes and `enforce_admins: true` leaves no bypass; the pushed commit can never carry `pair-explicit-approval`. Making it work requires a bypass allowance for the ratchet identity, i.e. a permanent write path into `main` that skips review — precisely the hole #234 closes. +- **Write to the PR branch during the PR run**: Rejected on three counts: no write access on fork PRs; mutating `head.sha` invalidates the `pair-review`/`pair-explicit-approval` contexts pinned to it, so every coverage improvement would silently revoke a human approval; and two concurrent PRs both raising the same key conflict in the config file. +- **Ratchet PR opened with the default `GITHUB_TOKEN`**: Rejected. Events from `GITHUB_TOKEN` do not trigger workflows, so the PR would never obtain the required `CI`/`pair-review` contexts and would be un-mergeable — an automation that reliably produces a stuck PR. +- **`[skip ci]` in the automated commit as the loop guard**: Rejected. It skips the whole pipeline, so the ratchet PR would never get the checks #234 requires; and it guards the wrong thing (the coverage job is what must be skipped, not the build). +- **Keep #282's advisory model and do nothing** (`disabled` forever): Rejected as the *shipped capability* — it is, however, exactly what the default and pair's own current flag state give, so nothing regresses for adopters who never opt in. +- **Persist the baseline from inside `coverage-gate.sh`**: Rejected, unchanged from #282. The gate decides pass/fail and must stay a pure reader; persistence is a separable side effect that is allowed to fail alone (and per the gate-tooling ADL its logic belongs in a tested module, not in the shell asset). + +## Consequences + +- `coverage-gate.sh` is untouched in behaviour: it still never persists. Only its PERSISTENCE comment is updated to point at the new, decided model instead of "tracked separately". +- The ratchet logic lives in `packages/knowledge-hub/src/tools/coverage-baseline-ratchet.ts` (unit-tested, white-box) per [2026-07-13-gate-tooling-code-in-tested-modules.md](./2026-07-13-gate-tooling-code-in-tested-modules.md); the CI step and the package's `coverage:ratchet` script are thin entrypoints, and the CLI is verified end-to-end by the `coverage-gate.sh` smoke scenario (dry-run), never by a unit test. The git/gh command sequence is data (a plan the module returns), so its non-negotiable properties — one push, to the ratchet ref only, leaseable, no `git add -A`, no branch switch, always restored — are asserted by tests rather than reviewed by eye in YAML. +- Adopters who enable the flag must provision `COVERAGE_RATCHET_TOKEN`; without it the step warns on every base-branch push. Documented on the adoption line and in the KB guideline, not only in the workflow YAML. +- A merged ratchet PR produces a base-branch push whose subject carries the marker, so the next run skips — and even without the marker the fixpoint yields no raise. Both are demonstrated in the smoke scenario. +- One extra open PR may exist at a time (`chore/coverage-baseline-ratchet` is create-or-update, never a second PR per raise). + +## Adoption Impact + +- [way-of-working.md](../tech/way-of-working.md): the existing **Coverage guardrail** bullet under Quality Gates gains the `Coverage baseline commit-back: disabled` sub-flag, its default, the token requirement and a pointer here. Edited surgically (that bullet only) because the file is contended by parked PRs #389/#390. +- [tech/coverage-baseline.md](../tech/coverage-baseline.md): the "Human-committed" note is amended to describe the opt-in ratchet and the margin rule the writer reproduces. +- [tier-aware-pipeline.md](../../knowledge/guidelines/infrastructure/cicd-strategy/tier-aware-pipeline.md) (+ dataset twin): the coverage-guardrail subsection's commit-back sentence replaces "tracked separately — see story #372" with the decided model, the flag, the default and the credential requirement. Surgical for the same reason (contended by #390). +- [coverage-config-example.md](../../knowledge/assets/coverage-config-example.md) (+ dataset twin): the "tracked separately — see story #372" sentence is replaced by the decided model, so the shipped config documentation no longer points at an open question. +- **Deliberately deferred**: the sub-flag is NOT added to the shipped `dataset/.pair/adoption/tech/way-of-working.md` template, because that file is contended by two parked PRs (#389, #390) and a template line is not worth a merge conflict. Nothing depends on it: an **absent** flag reads as off (`readCommitBackFlag` → `absent` → `flag-disabled`, unit-tested), which is exactly the framework default, and the KB guideline already documents the flag, its default and its credential where an adopter reads them. Adding the template line is a one-line follow-up once those PRs land. +- No dataset mirror for this ADL: `adoption/decision-log/` is an adoption-only record (same rationale as the sibling ADLs). diff --git a/.pair/adoption/tech/coverage-baseline.md b/.pair/adoption/tech/coverage-baseline.md index f912ace9..7abce953 100644 --- a/.pair/adoption/tech/coverage-baseline.md +++ b/.pair/adoption/tech/coverage-baseline.md @@ -45,5 +45,6 @@ baseline.frontend=19 ## Notes - **Baseline-relative, not an absolute wall.** A drop below `baseline.` blocks; below the gradual `target` but at/above baseline only warns (R7.3: incremental improvement). -- **Human-committed.** The gate never persists a baseline itself; raise a baseline here by committing a new value once coverage improves. +- **Human-committed by default.** The gate never persists a baseline itself; raise a baseline here by committing a new value once coverage improves. +- **Opt-in ratchet (#372), currently `disabled`.** `Coverage baseline commit-back` in [way-of-working.md](./way-of-working.md) is the nested opt-in that automates the raise. While it is `disabled` (pair's current state and the framework default) nothing writes to this file but a human. When enabled, a **push to the base branch** — never a pull-request run — proposes the raise as a **bot pull request** from `chore/coverage-baseline-ratchet` (never a push to `main`), editing only the `baseline.=` values below, in place. It is **monotonic**: a baseline is only ever raised; lowering one stays a deliberate human commit. The value it writes is `floor(measured) - 1pp` — the same ~1pp margin documented above, which is also why re-running on unchanged coverage proposes nothing. See ADL [2026-07-30-coverage-ratchet-pr-not-push.md](../decision-log/2026-07-30-coverage-ratchet-pr-not-push.md). - **Fail-safe.** If a coverage report is missing, the CI step runs the gate at a conservative fixed `red` tier, so a missing report blocks rather than silently passes. diff --git a/.pair/adoption/tech/way-of-working.md b/.pair/adoption/tech/way-of-working.md index 6641a96f..6ddfa6cd 100644 --- a/.pair/adoption/tech/way-of-working.md +++ b/.pair/adoption/tech/way-of-working.md @@ -38,7 +38,7 @@ Maps this project's GitHub Projects board columns to the 5 canonical macrostates - `pnpm quality-gate` is the adopted project-level quality gate command. - Quality gate includes: type checking (`ts:check`), testing (`test`), linting (`lint`), formatting (`prettier:fix`), markdown lint (`mdlint:fix`). - **Pre-merge tiering**: `disabled` (default) — every PR runs the full pre-merge check suite. Set to `enabled` to opt into risk-tier-scoped pre-merge checks (lighter checks on lower-risk PRs) per [tier-aware-pipeline.md](../../knowledge/guidelines/infrastructure/cicd-strategy/tier-aware-pipeline.md); `/pair-capability-setup-gates` reads this flag before generating the pipeline. -- **Coverage guardrail**: `enabled` — pair dogfoods its own capability: the [`Coverage guardrail` step](../../../.github/workflows/ci.yml) in CI sources [`coverage-gate.sh`](../../knowledge/assets/coverage-gate.sh), extracts the line-coverage % from each package's istanbul `coverage-summary.json`, and blocks a PR whose coverage drops below the human-committed baseline in [`tech/coverage-baseline.md`](./coverage-baseline.md) (maintaining/improving passes — not an absolute wall). The framework **default remains `disabled`** (the dataset template ships off); this line is pair's project-level opt-in only. See [coverage guardrail](../../knowledge/guidelines/infrastructure/cicd-strategy/tier-aware-pipeline.md#coverage-guardrail-opt-in-regression-gate-consumed-by-this-pipeline) + [config format](../../knowledge/assets/coverage-config-example.md); `/pair-capability-setup-gates` reads this flag before generating the pipeline. +- **Coverage guardrail**: `enabled` — pair dogfoods its own capability: the [`Coverage guardrail` step](../../../.github/workflows/ci.yml) in CI sources [`coverage-gate.sh`](../../knowledge/assets/coverage-gate.sh), extracts the line-coverage % from each package's istanbul `coverage-summary.json`, and blocks a PR whose coverage drops below the human-committed baseline in [`tech/coverage-baseline.md`](./coverage-baseline.md) (maintaining/improving passes — not an absolute wall). The framework **default remains `disabled`** (the dataset template ships off); this line is pair's project-level opt-in only. See [coverage guardrail](../../knowledge/guidelines/infrastructure/cicd-strategy/tier-aware-pipeline.md#coverage-guardrail-opt-in-regression-gate-consumed-by-this-pipeline) + [config format](../../knowledge/assets/coverage-config-example.md); `/pair-capability-setup-gates` reads this flag before generating the pipeline. **Coverage baseline commit-back**: `disabled` — the separate, nested opt-in ratchet (#372, framework default also `disabled`): when `enabled`, a **push to the base branch** (never a PR run, never a fork) proposes a raised `baseline.` as a **bot pull request** from `chore/coverage-baseline-ratchet`, never a push to `main`, and requires a repo-scoped `COVERAGE_RATCHET_TOKEN` (`contents: write` + `pull requests: write`, no protection bypass) — without it the step warns and the gate's verdict is unchanged. It stays `disabled` here until story #234's branch protection is applied and that secret is provisioned (ADR-018 lands with that story, so it is not linked from here yet); see ADL [2026-07-30-coverage-ratchet-pr-not-push.md](../decision-log/2026-07-30-coverage-ratchet-pr-not-push.md). - **Gate & tooling code:** a gate's logic lives in a tested module in its owning package (white-box unit tests); scripts/CLIs are thin entrypoints and a root gate delegates (`pnpm --filter `). Scripts are never unit-tested — CLI-level checks go to smoke tests. See ADL [2026-07-13-gate-tooling-code-in-tested-modules.md](../decision-log/2026-07-13-gate-tooling-code-in-tested-modules.md). Gate/tooling packages are organized by bounded context, not one package per tool family — a new tool family sharing an existing package's bounded context is a new folder there, not a new package. See [ADR-014](adr/adr-014-tool-package-boundary-by-bounded-context.md). - **Conformance tests** (`packages/knowledge-hub/src/conformance/`): one test file per target KB artifact (a `SKILL.md`, guideline, or template), not per introducing story — a new story extends the matching file's `describe` block instead of adding a new story-named file. See ADL [2026-07-18-conformance-test-per-file-not-per-story.md](../decision-log/2026-07-18-conformance-test-per-file-not-per-story.md). - **Monorepo tooling gotchas** (e.g. `pnpm --filter` bypassing turbo's `dependsOn` graph on a fresh checkout): documented once, centrally, in `DEVELOPMENT.md`'s `Turbo Caching` section — affected packages' READMEs carry only a short pointer, not a full copy. See ADL [2026-07-18-workspace-gotcha-doc-placement.md](../decision-log/2026-07-18-workspace-gotcha-doc-placement.md). diff --git a/.pair/knowledge/assets/coverage-config-example.md b/.pair/knowledge/assets/coverage-config-example.md index b7d8c390..ff50d4a0 100644 --- a/.pair/knowledge/assets/coverage-config-example.md +++ b/.pair/knowledge/assets/coverage-config-example.md @@ -17,7 +17,7 @@ The `` is the touched code's type — `backend`, `frontend`, `shared`, or The guardrail is **live only once a human commits a `baseline.=NN` line** to this file. The gate never persists a baseline itself: a CI checkout is ephemeral, so anything it wrote would be discarded when the runner is torn down — and coverage could then drift down run after run, each run re-suggesting a new lower baseline it never keeps, with the regression guard never firing. -So bootstrapping is **advisory**: on an unset/missing/corrupt baseline the gate prints the suggested `baseline.=NN` to stderr and **passes** (bootstrap-only mode). Read the CI log, copy the suggested line into this committed config, and the guardrail becomes live on the next run. Automated commit-back of a bootstrapped baseline is provider-specific and tracked separately — see story **#372**. +So bootstrapping is **advisory**: on an unset/missing/corrupt baseline the gate prints the suggested `baseline.=NN` to stderr and **passes** (bootstrap-only mode). Read the CI log, copy the suggested line into this committed config, and the guardrail becomes live on the next run. Automated commit-back is a **separate, nested opt-in** (`Coverage baseline commit-back` in `way-of-working.md`; default `disabled`, story **#372**): when enabled, a push to the base branch proposes a *raised* baseline as a bot pull request — never a push to the base branch, never from a pull-request run — and it only ever raises a value, in place. It needs a repo-scoped write credential; a refused write is a warning that leaves this gate's verdict untouched. Bootstrapping the *first* baseline stays human either way. **Adopter scope, stated plainly**: today the ratchet is **pair-internal**. Its logic lives in `packages/knowledge-hub/src/tools/`, not among the shipped provider-agnostic assets (`coverage-gate.sh`, `tier-resolve.sh`), and `/pair-capability-setup-gates` neither asks about this nested flag nor emits the step — so an adopter who sets it to `enabled` gets a **silent no-op**. Shipping it as an asset and generating the step is tracked separately (see #409). ## KB-sensible defaults diff --git a/.pair/knowledge/assets/coverage-gate.sh b/.pair/knowledge/assets/coverage-gate.sh index e2bb5bb6..c7ff6daa 100644 --- a/.pair/knowledge/assets/coverage-gate.sh +++ b/.pair/knowledge/assets/coverage-gate.sh @@ -31,8 +31,12 @@ # then throws away, and the regression guard would never fire). So bootstrapping is # ADVISORY: the gate echoes the suggested `baseline.=NN` to stderr for a human # to copy into the committed config, and passes without blocking until that commit -# lands. Automated commit-back of a bootstrapped baseline is provider-specific and -# tracked separately — see story #372. +# lands. THIS FILE NEVER WRITES, in any configuration. Automated commit-back +# (story #372) is a SEPARATE, nested opt-in that lives outside this gate: when +# `Coverage baseline commit-back: enabled` is set, a push to the base branch +# proposes a raised baseline as a bot pull request (never a push to the base +# branch, never from a pull-request run). It is a side effect that may fail alone +# — a refused write is a warning and leaves this gate's verdict untouched. # # See: # .pair/knowledge/guidelines/infrastructure/cicd-strategy/tier-aware-pipeline.md diff --git a/.pair/knowledge/guidelines/infrastructure/cicd-strategy/tier-aware-pipeline.md b/.pair/knowledge/guidelines/infrastructure/cicd-strategy/tier-aware-pipeline.md index fa78fe76..130bdaab 100644 --- a/.pair/knowledge/guidelines/infrastructure/cicd-strategy/tier-aware-pipeline.md +++ b/.pair/knowledge/guidelines/infrastructure/cicd-strategy/tier-aware-pipeline.md @@ -188,7 +188,7 @@ The `coverage` job (above) sources the shipped, provider-agnostic [`coverage-gat - **Blocks a regression, not an absolute wall.** A PR whose coverage drops **below the committed baseline** fails the gate, at every tier (R7.3, epic AC3). A PR that **maintains or improves** coverage passes — the guardrail never demands a fixed X% be hit on every PR. Below the gradual *target* but still at/above the baseline only **warns**. - **Baseline + per-type targets live in adoption**, in `tech/coverage-baseline.md` (created when the guardrail is enabled; configurable, with KB-sensible defaults) — see [coverage-config-example.md](../../../assets/coverage-config-example.md). The gate reads whatever coverage number the adopted test tooling produced; **no specific coverage tool is mandated** (istanbul `coverage-summary.json`, LCOV, Cobertura, … — the pipeline extracts the % and passes it in). Per-type targets (`backend`/`frontend`/`shared`/…) let the gate apply the threshold matching the touched code's type (AC5). -- **Baseline is human-committed; bootstrapping is advisory** (AC4). The guardrail is live only once a human commits a `baseline.=NN` line to the config. With no committed baseline for a type — or a missing/corrupt one — the gate runs in **bootstrap-only mode**: it prints the current coverage as a suggested `baseline.=NN` to **stderr** and **passes** without blocking. It does **not** persist the baseline itself: a CI checkout is ephemeral, so a written baseline would be discarded and coverage could drift down run after run with the guard never firing. A human copies the suggested line into the committed config to make the guardrail live. Automated commit-back of a bootstrapped baseline is provider-specific and tracked separately — see story #372. +- **Baseline is human-committed; bootstrapping is advisory** (AC4). The guardrail is live only once a human commits a `baseline.=NN` line to the config. With no committed baseline for a type — or a missing/corrupt one — the gate runs in **bootstrap-only mode**: it prints the current coverage as a suggested `baseline.=NN` to **stderr** and **passes** without blocking. It does **not** persist the baseline itself: a CI checkout is ephemeral, so a written baseline would be discarded and coverage could drift down run after run with the guard never firing. A human copies the suggested line into the committed config to make the guardrail live. **Automated commit-back is a separate, nested opt-in** (`Coverage baseline commit-back: enabled` in the same way-of-working file; default `disabled`, so the gate stays advisory-only unless a project asks for it). When it is enabled, the raise is proposed **only by a push to the base branch** — never by a pull-request run, which is uniform for fork and same-repo PRs and never mutates a PR's head commit — and it lands as a **bot pull request** from a dedicated `chore/coverage-baseline-ratchet` branch rather than a push to the base branch, so it satisfies the branch protection a review-gated base branch requires instead of needing an exemption from it. It requires a **repo-scoped write credential** (`COVERAGE_RATCHET_TOKEN`: `contents: write` + `pull requests: write`, no protection bypass; the default CI token is unusable because a pull request it opens triggers no workflow run and so can never satisfy required checks). The ratchet is **monotonic** (a `baseline.` value is only ever raised, edited in place) and **terminating** (a run whose head commit carries the `[coverage-baseline-ratchet]` marker is skipped, and the value written is `floor(measured) - 1pp`, so an unchanged coverage proposes what is already committed). A refused write — missing credential, protected branch, insufficient scope — degrades to a **warning naming the reason** and leaves the gate's verdict untouched: persistence and verdict are independent. **Adopter scope, stated plainly**: today the ratchet is **pair-internal**. Its logic lives in `packages/knowledge-hub/src/tools/`, not among the shipped provider-agnostic assets (`coverage-gate.sh`, `tier-resolve.sh`), and `/pair-capability-setup-gates` neither asks about this nested flag nor emits the step — so an adopter who sets it to `enabled` gets a **silent no-op**. Shipping it as an asset and generating the step is tracked separately (see #409). See story #372. - **Fail-safe on no report**: if no coverage was measured (tooling emitted nothing, or the suite did not run), the gate **blocks at 🔴 red** and **warns at lower tiers** — never a silent pass, matching this pipeline's fail-safe stance. - **Genuinely untestable surface** (generated files, config) is recorded via the `exclude` key in the config, but that key is **applied by the adopter to their own coverage tool's config** — the gate does **not** read it and this pipeline snippet does **not** pass it. It is documentation of the exclusion intent, not a gate-enforced override. diff --git a/packages/knowledge-hub/dataset/.pair/knowledge/assets/coverage-config-example.md b/packages/knowledge-hub/dataset/.pair/knowledge/assets/coverage-config-example.md index b7d8c390..ff50d4a0 100644 --- a/packages/knowledge-hub/dataset/.pair/knowledge/assets/coverage-config-example.md +++ b/packages/knowledge-hub/dataset/.pair/knowledge/assets/coverage-config-example.md @@ -17,7 +17,7 @@ The `` is the touched code's type — `backend`, `frontend`, `shared`, or The guardrail is **live only once a human commits a `baseline.=NN` line** to this file. The gate never persists a baseline itself: a CI checkout is ephemeral, so anything it wrote would be discarded when the runner is torn down — and coverage could then drift down run after run, each run re-suggesting a new lower baseline it never keeps, with the regression guard never firing. -So bootstrapping is **advisory**: on an unset/missing/corrupt baseline the gate prints the suggested `baseline.=NN` to stderr and **passes** (bootstrap-only mode). Read the CI log, copy the suggested line into this committed config, and the guardrail becomes live on the next run. Automated commit-back of a bootstrapped baseline is provider-specific and tracked separately — see story **#372**. +So bootstrapping is **advisory**: on an unset/missing/corrupt baseline the gate prints the suggested `baseline.=NN` to stderr and **passes** (bootstrap-only mode). Read the CI log, copy the suggested line into this committed config, and the guardrail becomes live on the next run. Automated commit-back is a **separate, nested opt-in** (`Coverage baseline commit-back` in `way-of-working.md`; default `disabled`, story **#372**): when enabled, a push to the base branch proposes a *raised* baseline as a bot pull request — never a push to the base branch, never from a pull-request run — and it only ever raises a value, in place. It needs a repo-scoped write credential; a refused write is a warning that leaves this gate's verdict untouched. Bootstrapping the *first* baseline stays human either way. **Adopter scope, stated plainly**: today the ratchet is **pair-internal**. Its logic lives in `packages/knowledge-hub/src/tools/`, not among the shipped provider-agnostic assets (`coverage-gate.sh`, `tier-resolve.sh`), and `/pair-capability-setup-gates` neither asks about this nested flag nor emits the step — so an adopter who sets it to `enabled` gets a **silent no-op**. Shipping it as an asset and generating the step is tracked separately (see #409). ## KB-sensible defaults diff --git a/packages/knowledge-hub/dataset/.pair/knowledge/assets/coverage-gate.sh b/packages/knowledge-hub/dataset/.pair/knowledge/assets/coverage-gate.sh index e2bb5bb6..c7ff6daa 100644 --- a/packages/knowledge-hub/dataset/.pair/knowledge/assets/coverage-gate.sh +++ b/packages/knowledge-hub/dataset/.pair/knowledge/assets/coverage-gate.sh @@ -31,8 +31,12 @@ # then throws away, and the regression guard would never fire). So bootstrapping is # ADVISORY: the gate echoes the suggested `baseline.=NN` to stderr for a human # to copy into the committed config, and passes without blocking until that commit -# lands. Automated commit-back of a bootstrapped baseline is provider-specific and -# tracked separately — see story #372. +# lands. THIS FILE NEVER WRITES, in any configuration. Automated commit-back +# (story #372) is a SEPARATE, nested opt-in that lives outside this gate: when +# `Coverage baseline commit-back: enabled` is set, a push to the base branch +# proposes a raised baseline as a bot pull request (never a push to the base +# branch, never from a pull-request run). It is a side effect that may fail alone +# — a refused write is a warning and leaves this gate's verdict untouched. # # See: # .pair/knowledge/guidelines/infrastructure/cicd-strategy/tier-aware-pipeline.md diff --git a/packages/knowledge-hub/dataset/.pair/knowledge/guidelines/infrastructure/cicd-strategy/tier-aware-pipeline.md b/packages/knowledge-hub/dataset/.pair/knowledge/guidelines/infrastructure/cicd-strategy/tier-aware-pipeline.md index d709a546..7f88b77b 100644 --- a/packages/knowledge-hub/dataset/.pair/knowledge/guidelines/infrastructure/cicd-strategy/tier-aware-pipeline.md +++ b/packages/knowledge-hub/dataset/.pair/knowledge/guidelines/infrastructure/cicd-strategy/tier-aware-pipeline.md @@ -188,7 +188,7 @@ The `coverage` job (above) sources the shipped, provider-agnostic [`coverage-gat - **Blocks a regression, not an absolute wall.** A PR whose coverage drops **below the committed baseline** fails the gate, at every tier (R7.3, epic AC3). A PR that **maintains or improves** coverage passes — the guardrail never demands a fixed X% be hit on every PR. Below the gradual *target* but still at/above the baseline only **warns**. - **Baseline + per-type targets live in adoption**, in `tech/coverage-baseline.md` (created when the guardrail is enabled; configurable, with KB-sensible defaults) — see [coverage-config-example.md](../../../assets/coverage-config-example.md). The gate reads whatever coverage number the adopted test tooling produced; **no specific coverage tool is mandated** (istanbul `coverage-summary.json`, LCOV, Cobertura, … — the pipeline extracts the % and passes it in). Per-type targets (`backend`/`frontend`/`shared`/…) let the gate apply the threshold matching the touched code's type (AC5). -- **Baseline is human-committed; bootstrapping is advisory** (AC4). The guardrail is live only once a human commits a `baseline.=NN` line to the config. With no committed baseline for a type — or a missing/corrupt one — the gate runs in **bootstrap-only mode**: it prints the current coverage as a suggested `baseline.=NN` to **stderr** and **passes** without blocking. It does **not** persist the baseline itself: a CI checkout is ephemeral, so a written baseline would be discarded and coverage could drift down run after run with the guard never firing. A human copies the suggested line into the committed config to make the guardrail live. Automated commit-back of a bootstrapped baseline is provider-specific and tracked separately — see story #372. +- **Baseline is human-committed; bootstrapping is advisory** (AC4). The guardrail is live only once a human commits a `baseline.=NN` line to the config. With no committed baseline for a type — or a missing/corrupt one — the gate runs in **bootstrap-only mode**: it prints the current coverage as a suggested `baseline.=NN` to **stderr** and **passes** without blocking. It does **not** persist the baseline itself: a CI checkout is ephemeral, so a written baseline would be discarded and coverage could drift down run after run with the guard never firing. A human copies the suggested line into the committed config to make the guardrail live. **Automated commit-back is a separate, nested opt-in** (`Coverage baseline commit-back: enabled` in the same way-of-working file; default `disabled`, so the gate stays advisory-only unless a project asks for it). When it is enabled, the raise is proposed **only by a push to the base branch** — never by a pull-request run, which is uniform for fork and same-repo PRs and never mutates a PR's head commit — and it lands as a **bot pull request** from a dedicated `chore/coverage-baseline-ratchet` branch rather than a push to the base branch, so it satisfies the branch protection a review-gated base branch requires instead of needing an exemption from it. It requires a **repo-scoped write credential** (`COVERAGE_RATCHET_TOKEN`: `contents: write` + `pull requests: write`, no protection bypass; the default CI token is unusable because a pull request it opens triggers no workflow run and so can never satisfy required checks). The ratchet is **monotonic** (a `baseline.` value is only ever raised, edited in place) and **terminating** (a run whose head commit carries the `[coverage-baseline-ratchet]` marker is skipped, and the value written is `floor(measured) - 1pp`, so an unchanged coverage proposes what is already committed). A refused write — missing credential, protected branch, insufficient scope — degrades to a **warning naming the reason** and leaves the gate's verdict untouched: persistence and verdict are independent. **Adopter scope, stated plainly**: today the ratchet is **pair-internal**. Its logic lives in `packages/knowledge-hub/src/tools/`, not among the shipped provider-agnostic assets (`coverage-gate.sh`, `tier-resolve.sh`), and `/pair-capability-setup-gates` neither asks about this nested flag nor emits the step — so an adopter who sets it to `enabled` gets a **silent no-op**. Shipping it as an asset and generating the step is tracked separately (see #409). See story #372. - **Fail-safe on no report**: if no coverage was measured (tooling emitted nothing, or the suite did not run), the gate **blocks at 🔴 red** and **warns at lower tiers** — never a silent pass, matching this pipeline's fail-safe stance. - **Genuinely untestable surface** (generated files, config) is recorded via the `exclude` key in the config, but that key is **applied by the adopter to their own coverage tool's config** — the gate does **not** read it and this pipeline snippet does **not** pass it. It is documentation of the exclusion intent, not a gate-enforced override. diff --git a/packages/knowledge-hub/package.json b/packages/knowledge-hub/package.json index 11747682..ec9cd4dd 100644 --- a/packages/knowledge-hub/package.json +++ b/packages/knowledge-hub/package.json @@ -19,7 +19,8 @@ "mdlint:fix": "markdownlint-fix", "check:links": "ts-node src/tools/check-broken-links.ts", "transfer:dataset": "ts-node src/tools/transfer-dataset.ts", - "skills:conformance": "ts-node src/tools/skills-conformance-check.ts" + "skills:conformance": "ts-node src/tools/skills-conformance-check.ts", + "coverage:ratchet": "ts-node src/tools/coverage-baseline-ratchet.ts" }, "devDependencies": { "@pair/eslint-config": "workspace:*", diff --git a/packages/knowledge-hub/src/tools/coverage-baseline-ratchet.test.ts b/packages/knowledge-hub/src/tools/coverage-baseline-ratchet.test.ts new file mode 100644 index 00000000..af690261 --- /dev/null +++ b/packages/knowledge-hub/src/tools/coverage-baseline-ratchet.test.ts @@ -0,0 +1,712 @@ +import { describe, it, expect } from 'vitest' +import { + RATCHET_MARKER, + RATCHET_BRANCH, + DEFAULT_MARGIN_PP, + TOKEN_ENV, + EXTRAHEADER_CONFIG_KEY, + proposeBaseline, + readBaselineValue, + readCommitBackFlag, + readGuardrailFlag, + shouldSkipCommitBack, + planRatchet, + applyRaises, + classifyWriteRefusal, + ratchetGitPlan, + ratchetTrackingRefspec, + ratchetBranchConfigCommand, + gitAuthConfig, + renderRatchetPlan, +} from './coverage-baseline-ratchet' + +// A config fixture shaped like the real adoption file: the parseable `key=value` +// block lives inside a fenced code block, wrapped in markdown that MUST survive +// a write untouched (business rule: "edits values in place, never rewrites the +// surrounding markdown"). +const CONFIG = `# \`tech/coverage-baseline.md\` — config + +Some prose that must not move. + +## Config + +\`\`\`ini +# comment line +target.default=70 +target.shared=90 +target.frontend=25 + +baseline.shared=84 +baseline.frontend=19 +\`\`\` + +## Notes + +- trailing prose +` + +const measuredPush = { + guardrailFlag: 'enabled' as const, + eventName: 'push', + refName: 'main', + baseBranch: 'main', + headCommitMessage: 'feat: something unrelated', +} + +describe('proposeBaseline — the margin convention the adoption file already documents', () => { + it('reproduces the committed baselines from the measured values (85.04 -> 84, 20.16 -> 19)', () => { + expect(proposeBaseline(85.04)).toBe(84) + expect(proposeBaseline(20.16)).toBe(19) + }) + + it('is floor(measured) - margin', () => { + expect(proposeBaseline(90)).toBe(89) + expect(proposeBaseline(90.99)).toBe(89) + expect(proposeBaseline(100)).toBe(99) + expect(proposeBaseline(90, 0)).toBe(90) + expect(proposeBaseline(90, 5)).toBe(85) + }) + + it('never goes negative', () => { + expect(proposeBaseline(0)).toBe(0) + expect(proposeBaseline(0.5)).toBe(0) + }) + + it('is a fixpoint — proposing from a value already at the proposal yields no increase', () => { + // measured 85.04 => 84; a later run measuring the same never proposes 85. + const first = proposeBaseline(85.04) + expect(proposeBaseline(85.04)).toBe(first) + }) +}) + +describe('readBaselineValue — `^baseline.=` only, format unchanged', () => { + it('reads a committed integer baseline', () => { + expect(readBaselineValue(CONFIG, 'shared')).toBe(84) + expect(readBaselineValue(CONFIG, 'frontend')).toBe(19) + }) + + it('returns null for a type absent from the config', () => { + expect(readBaselineValue(CONFIG, 'backend')).toBeNull() + }) + + it('returns null for a malformed value', () => { + expect(readBaselineValue('baseline.shared=not-a-number\n', 'shared')).toBeNull() + }) + + it('tolerates a CRLF-authored config', () => { + expect(readBaselineValue('baseline.shared=84\r\n', 'shared')).toBe(84) + }) + + it('does not match a key that merely contains the type name', () => { + expect(readBaselineValue('baseline.sharedish=99\nbaseline.shared=84\n', 'shared')).toBe(84) + }) + + it('ignores a commented-out baseline line (only `^baseline.` matches)', () => { + expect(readBaselineValue('# baseline.shared=99\nbaseline.shared=84\n', 'shared')).toBe(84) + }) +}) + +describe('readCommitBackFlag — the opt-in, default disabled', () => { + it('reads `enabled` from the way-of-working bullet', () => { + expect(readCommitBackFlag('- **Coverage baseline commit-back**: `enabled` — foo')).toBe( + 'enabled', + ) + }) + + it('reads `disabled`', () => { + expect(readCommitBackFlag('- **Coverage baseline commit-back**: `disabled` — foo')).toBe( + 'disabled', + ) + }) + + it('reports `absent` when the flag is not declared at all (framework default = off)', () => { + expect(readCommitBackFlag('- **Coverage guardrail**: `enabled`')).toBe('absent') + expect(readCommitBackFlag('')).toBe('absent') + }) + + it('is case-insensitive and tolerates missing backticks/bold', () => { + expect(readCommitBackFlag('- Coverage Baseline Commit-Back: Enabled')).toBe('enabled') + expect(readCommitBackFlag('* **Coverage baseline commit-back**: enabled')).toBe('enabled') + }) + + it('reads a NESTED bullet (the flag is documented as nested under the guardrail)', () => { + const wow = `## Quality +- **Coverage guardrail**: \`enabled\` + - **Coverage baseline commit-back**: \`enabled\` +` + expect(readCommitBackFlag(wow)).toBe('enabled') + }) + + // The KB documents this flag IN PROSE, and adopters copy guideline sentences into + // their own way-of-working. An unanchored, first-match-wins search would let a + // QUOTATION switch the ratchet on. This is the first machine-parsed + // way-of-working flag, so the parse robustness is a precedent, not a detail. + it('ignores the phrase quoted in prose — only a declaring BULLET counts', () => { + const quoting = `## Quality + +A project that sets \`Coverage baseline commit-back: enabled\` gets a bot PR +raising the baseline. We have not turned that on. +` + expect(readCommitBackFlag(quoting)).toBe('absent') + }) + + it('still finds the real declaration in a document that ALSO quotes the phrase', () => { + const both = `Docs say \`Coverage baseline commit-back: enabled\` does X. + +- **Coverage baseline commit-back**: \`disabled\` +` + expect(readCommitBackFlag(both)).toBe('disabled') + }) +}) + +describe('readGuardrailFlag — the PARENT flag the opt-in is nested under', () => { + it('reads the parent guardrail bullet', () => { + expect(readGuardrailFlag('- **Coverage guardrail**: `enabled`')).toBe('enabled') + expect(readGuardrailFlag('- **Coverage guardrail**: `disabled` (default)')).toBe('disabled') + }) + + it('reports `absent` when the guardrail is not declared', () => { + expect(readGuardrailFlag('- **Something else**: `enabled`')).toBe('absent') + }) + + it('does not confuse the child flag for the parent', () => { + expect(readGuardrailFlag('- **Coverage baseline commit-back**: `enabled`')).toBe('absent') + }) +}) + +describe('shouldSkipCommitBack — AC1 (default off), AC4 (loop termination), AC5 (PR vs push)', () => { + it('AC1: skips when the flag is absent — the framework default writes nothing', () => { + const d = shouldSkipCommitBack({ ...measuredPush, commitBackFlag: 'absent' }) + expect(d.skip).toBe(true) + expect(d.code).toBe('flag-disabled') + }) + + it('AC1: skips when the flag is explicitly disabled', () => { + expect(shouldSkipCommitBack({ ...measuredPush, commitBackFlag: 'disabled' }).code).toBe( + 'flag-disabled', + ) + }) + + // The nesting under the guardrail is ENFORCED, not just documented: ratcheting a + // baseline for a gate that never runs would raise a floor nothing checks. + it('skips when the PARENT guardrail flag is not enabled, even with the opt-in on', () => { + const d = shouldSkipCommitBack({ + ...measuredPush, + commitBackFlag: 'enabled', + guardrailFlag: 'disabled', + }) + expect(d.skip).toBe(true) + expect(d.code).toBe('guardrail-disabled') + expect(d.reason).toContain('nested') + }) + + it('skips when the parent guardrail flag is absent (framework default = off)', () => { + expect( + shouldSkipCommitBack({ ...measuredPush, commitBackFlag: 'enabled', guardrailFlag: 'absent' }) + .code, + ).toBe('guardrail-disabled') + }) + + it('AC5: skips a pull_request run — a PR never writes back (fork or not)', () => { + const d = shouldSkipCommitBack({ + ...measuredPush, + commitBackFlag: 'enabled', + eventName: 'pull_request', + refName: 'feature/US-1-foo', + }) + expect(d.skip).toBe(true) + expect(d.code).toBe('not-base-push') + }) + + it('AC5: skips a push to a non-base branch', () => { + const d = shouldSkipCommitBack({ + ...measuredPush, + commitBackFlag: 'enabled', + refName: 'feature/US-1-foo', + }) + expect(d.code).toBe('not-base-push') + }) + + it('AC4: skips a run whose head commit carries the ratchet marker', () => { + const d = shouldSkipCommitBack({ + ...measuredPush, + commitBackFlag: 'enabled', + headCommitMessage: `chore: ratchet coverage baseline (shared 84->89) ${RATCHET_MARKER}`, + }) + expect(d.skip).toBe(true) + expect(d.code).toBe('automated-commit') + }) + + it('AC4: skips a squash-merge subject taken from the ratchet PR title', () => { + const d = shouldSkipCommitBack({ + ...measuredPush, + commitBackFlag: 'enabled', + headCommitMessage: `chore: ratchet coverage baseline ${RATCHET_MARKER} (#123)`, + }) + expect(d.code).toBe('automated-commit') + }) + + it('AC4: skips a plain merge commit naming the ratchet branch (marker not in the subject)', () => { + const d = shouldSkipCommitBack({ + ...measuredPush, + commitBackFlag: 'enabled', + headCommitMessage: `Merge pull request #123 from foomakers/${RATCHET_BRANCH}`, + }) + expect(d.code).toBe('automated-commit') + }) + + // A substring match on the branch name would treat ANY commit that merely + // mentions it as the ratchet's own and silently swallow a legitimate raise — + // including the commits of the story that introduced the ratchet, and any revert + // or follow-up naming the branch. + it('does NOT treat a commit that merely mentions the ratchet branch as automated', () => { + const mentions = [ + `[US-372] feat: land the ratchet on ${RATCHET_BRANCH}`, + `Merge pull request #405 from foomakers/feature/US-372-coverage-baseline-commit-back\n\nadds ${RATCHET_BRANCH}`, + `Revert "Merge pull request #9 from foomakers/${RATCHET_BRANCH}"`, + ] + for (const headCommitMessage of mentions) { + expect( + shouldSkipCommitBack({ ...measuredPush, commitBackFlag: 'enabled', headCommitMessage }) + .skip, + ).toBe(false) + } + }) + + it('proceeds on a base-branch push from a human commit with the flag enabled', () => { + expect(shouldSkipCommitBack({ ...measuredPush, commitBackFlag: 'enabled' })).toEqual({ + skip: false, + }) + }) + + it('evaluates the flag before anything else (an enabled-only concern never leaks)', () => { + const d = shouldSkipCommitBack({ + ...measuredPush, + commitBackFlag: 'disabled', + eventName: 'pull_request', + headCommitMessage: RATCHET_MARKER, + }) + expect(d.code).toBe('flag-disabled') + }) +}) + +describe('planRatchet — monotonic, per-type', () => { + it('AC2: raises when the proposal is strictly above the committed baseline', () => { + const plan = planRatchet(CONFIG, { shared: 90.5 }) + expect(plan).toEqual([ + { + type: 'shared', + measured: 90.5, + current: 84, + proposed: 89, + action: 'raise', + reason: 'measured 90.5% => baseline 89 (floor - 1pp margin), above committed 84', + }, + ]) + }) + + it('AC3: holds when coverage is exactly at the baseline', () => { + const [p] = planRatchet(CONFIG, { shared: 84 }) + expect(p.action).toBe('hold') + expect(p.reason).toContain('not above') + }) + + it('AC3: holds when the proposal equals the committed baseline (fixpoint, no churn)', () => { + const [p] = planRatchet(CONFIG, { shared: 85.04 }) + expect(p.proposed).toBe(84) + expect(p.action).toBe('hold') + }) + + it('AC3 + business rule: NEVER lowers — a drop is a hold, not a write', () => { + const [p] = planRatchet(CONFIG, { shared: 40 }) + expect(p.action).toBe('hold') + expect(p.proposed).toBeLessThan(p.current as number) + }) + + it('edge case: a type measured but absent from the config is reported, not written', () => { + const [p] = planRatchet(CONFIG, { backend: 99 }) + expect(p.action).toBe('no-baseline-configured') + expect(p.current).toBeNull() + }) + + it('edge case: a malformed committed baseline is reported, not overwritten', () => { + const [p] = planRatchet('baseline.shared=oops\n', { shared: 99 }) + expect(p.action).toBe('no-baseline-configured') + }) + + it('edge case: a missing/unparseable measurement is reported, never assumed', () => { + expect(planRatchet(CONFIG, { shared: '' }).map(p => p.action)).toEqual(['not-measured']) + expect(planRatchet(CONFIG, { shared: 'null' }).map(p => p.action)).toEqual(['not-measured']) + expect(planRatchet(CONFIG, { shared: undefined }).map(p => p.action)).toEqual(['not-measured']) + }) + + it('plans every type independently and preserves input order', () => { + const plan = planRatchet(CONFIG, { shared: 90.5, frontend: 19.2, backend: 50 }) + expect(plan.map(p => [p.type, p.action])).toEqual([ + ['shared', 'raise'], + ['frontend', 'hold'], + ['backend', 'no-baseline-configured'], + ]) + }) + + it('accepts a custom margin', () => { + const [p] = planRatchet(CONFIG, { shared: 90.5 }, 0) + expect(p.proposed).toBe(90) + }) +}) + +describe('applyRaises — in-place value edit, surrounding markdown untouched', () => { + it('changes only the matched baseline line', () => { + const raises = planRatchet(CONFIG, { shared: 90.5 }).filter(p => p.action === 'raise') + const { text, changedLines } = applyRaises(CONFIG, raises) + expect(changedLines).toBe(1) + + const before = CONFIG.split('\n') + const after = text.split('\n') + expect(after.length).toBe(before.length) + const differing = before + .map((line, i) => (line === after[i] ? null : i)) + .filter((i): i is number => i !== null) + expect(differing.length).toBe(1) + expect(before[differing[0]]).toBe('baseline.shared=84') + expect(after[differing[0]]).toBe('baseline.shared=89') + }) + + it('leaves the file byte-identical when there is nothing to raise', () => { + expect(applyRaises(CONFIG, []).text).toBe(CONFIG) + expect(applyRaises(CONFIG, []).changedLines).toBe(0) + }) + + it('raises several types in one pass', () => { + const raises = planRatchet(CONFIG, { shared: 95, frontend: 40 }).filter( + p => p.action === 'raise', + ) + const { text, changedLines } = applyRaises(CONFIG, raises) + expect(changedLines).toBe(2) + expect(text).toContain('baseline.shared=94') + expect(text).toContain('baseline.frontend=39') + }) + + it('is idempotent — re-planning against the written text yields no further raise', () => { + const raises = planRatchet(CONFIG, { shared: 90.5 }).filter(p => p.action === 'raise') + const { text } = applyRaises(CONFIG, raises) + expect(planRatchet(text, { shared: 90.5 }).map(p => p.action)).toEqual(['hold']) + }) + + it('refuses to write a value that is not strictly above what is on disk now (concurrency)', () => { + // Simulates: this run planned 89 from a stale read; a concurrent run already + // committed 92. Re-checking against the CURRENT text must drop the proposal + // so the higher value is never clobbered. + const concurrent = CONFIG.replace('baseline.shared=84', 'baseline.shared=92') + const stale = planRatchet(CONFIG, { shared: 90.5 }).filter(p => p.action === 'raise') + const { text, changedLines, dropped } = applyRaises(concurrent, stale) + expect(changedLines).toBe(0) + expect(text).toBe(concurrent) + expect(dropped.map(d => d.type)).toEqual(['shared']) + }) + + // The exceptional scenario in the story: "two concurrent runs both raising the + // same baseline — the second must not clobber a higher value written by the + // first". Re-reading the BASE-BRANCH checkout only covers half of it: run A's + // raise is not on the base branch, it is a PENDING proposal on the ratchet + // branch. Without reading that ref, run B's lease is valid (it fetched A's tip), + // its force-push succeeds, and the open ratchet PR ends up proposing the LOWER + // value. + it('refuses to lower a HIGHER value pending on the ratchet branch (concurrency, other half)', () => { + const pendingText = CONFIG.replace('baseline.shared=84', 'baseline.shared=89') + // This run measured less: 87.x => 86, above the base branch's 84 but below the + // 89 the open ratchet PR already proposes. + const raises = planRatchet(CONFIG, { shared: 87.4 }).filter(p => p.action === 'raise') + expect(raises.map(r => r.proposed)).toEqual([86]) + const { text, changedLines, dropped } = applyRaises(CONFIG, raises, { pendingText }) + expect(changedLines).toBe(0) + expect(text).toBe(CONFIG) + expect(dropped.map(d => d.type)).toEqual(['shared']) + }) + + it('still raises when the proposal is above BOTH the base branch and the pending value', () => { + const pendingText = CONFIG.replace('baseline.shared=84', 'baseline.shared=86') + const raises = planRatchet(CONFIG, { shared: 90.5 }).filter(p => p.action === 'raise') + const { text, changedLines } = applyRaises(CONFIG, raises, { pendingText }) + expect(changedLines).toBe(1) + expect(text).toContain('baseline.shared=89') + }) + + it('holds at EQUAL to the pending value — the ratchet only ever moves up', () => { + const pendingText = CONFIG.replace('baseline.shared=84', 'baseline.shared=89') + const raises = planRatchet(CONFIG, { shared: 90.5 }).filter(p => p.action === 'raise') + expect(applyRaises(CONFIG, raises, { pendingText }).changedLines).toBe(0) + }) + + it('falls back to the on-disk value when there is no ratchet branch yet', () => { + const raises = planRatchet(CONFIG, { shared: 90.5 }).filter(p => p.action === 'raise') + for (const pendingText of [null, undefined, '']) { + expect(applyRaises(CONFIG, raises, { pendingText }).changedLines).toBe(1) + } + }) + + it('ignores a ratchet branch whose config lacks the type (no pending proposal for it)', () => { + const raises = planRatchet(CONFIG, { shared: 90.5 }).filter(p => p.action === 'raise') + expect(applyRaises(CONFIG, raises, { pendingText: '# nothing parseable' }).changedLines).toBe(1) + }) + + it('preserves CRLF line endings on the edited line', () => { + const crlf = 'x\r\nbaseline.shared=84\r\ny\r\n' + const raises = planRatchet(crlf, { shared: 90.5 }).filter(p => p.action === 'raise') + expect(applyRaises(crlf, raises).text).toBe('x\r\nbaseline.shared=89\r\ny\r\n') + }) +}) + +describe('classifyWriteRefusal — AC6 names the reason', () => { + it('names a missing credential', () => { + expect(classifyWriteRefusal('', { hasToken: false }).code).toBe('missing-credential') + expect(classifyWriteRefusal('', { hasToken: false }).message).toContain(TOKEN_ENV) + }) + + it('names branch protection', () => { + const r = classifyWriteRefusal( + 'remote: error: GH006: Protected branch update failed for refs/heads/main.', + { hasToken: true }, + ) + expect(r.code).toBe('protected-branch') + }) + + it('names an insufficient scope / permission', () => { + expect( + classifyWriteRefusal('remote: Permission to foomakers/pair.git denied to github-actions.', { + hasToken: true, + }).code, + ).toBe('insufficient-scope') + expect( + classifyWriteRefusal('gh: Resource not accessible by integration (HTTP 403)', { + hasToken: true, + }).code, + ).toBe('insufficient-scope') + }) + + it('names a stale-lease race', () => { + expect( + classifyWriteRefusal('! [rejected] chore/x -> chore/x (stale info)', { hasToken: true }).code, + ).toBe('stale-lease') + }) + + it('falls back to an explicit unknown rather than swallowing the output', () => { + const r = classifyWriteRefusal('something nobody predicted', { hasToken: true }) + expect(r.code).toBe('unknown') + expect(r.message).toContain('something nobody predicted') + }) +}) + +describe('ratchetGitPlan — the exact command sequence (a bot PR, never a push to base)', () => { + const raises = planRatchet(CONFIG, { shared: 90.5 }).filter(p => p.action === 'raise') + const HEAD_SHA = '0123456789abcdef0123456789abcdef01234567' + const plan = ratchetGitPlan({ + raises, + configPath: '.pair/adoption/tech/coverage-baseline.md', + baseBranch: 'main', + remote: 'origin', + headCommit: HEAD_SHA, + }) + const argvs = plan.commands.map(c => c.argv) + const flat = argvs.flat() + + it('never pushes to the base branch — only to the dedicated ratchet branch', () => { + const pushes = argvs.filter(c => c[0] === 'git' && c.includes('push')) + expect(pushes.length).toBe(1) + expect(pushes[0]).toContain(`HEAD:refs/heads/${RATCHET_BRANCH}`) + expect(pushes.flat().join(' ')).not.toContain('refs/heads/main') + }) + + it('pushes with --force-with-lease (never a bare force)', () => { + const push = argvs.find(c => c.includes('push')) as string[] + expect(push).toContain('--force-with-lease') + expect(push).not.toContain('--force') + }) + + it('fetches the ratchet branch in `prepare`, BEFORE anything is written or pushed', () => { + // Two things need that ref: `--force-with-lease` (a CI checkout fetches only + // the base ref, and without a remote-tracking ref for the destination git + // rejects every non-fast-forward lease push as `stale info` — the ratchet would + // work once and then warn forever), and reading the baseline the open ratchet PR + // already proposes. + const refspec = ratchetTrackingRefspec('origin') + expect(plan.prepare.map(c => c.argv)).toEqual([ + ['git', '-c', `remote.origin.fetch=${refspec}`, 'fetch', '--no-tags', 'origin', refspec], + ]) + expect(plan.prepare.every(c => c.optional === true)).toBe(true) + }) + + it('supplies the refspec TRANSIENTLY (`git -c`) — the checkout config is never mutated', () => { + // `git config --add remote..fetch` would survive `restore` (which only + // touches refs and files), breaking the plan's promise that the checkout is + // left as the later job steps expect, and repeat invocations in one checkout + // would accumulate duplicate refspecs. + const refspec = ratchetTrackingRefspec('origin') + const all = [...plan.prepare, ...plan.commands, ...plan.restore].map(c => c.argv) + expect(all.some(c => c[1] === 'config' && c.includes('--add'))).toBe(false) + expect(all.some(c => c.join(' ').includes('config --add remote.origin.fetch'))).toBe(false) + const push = all.find(c => c.includes('push')) as string[] + expect(push.slice(0, 3)).toEqual(['git', '-c', `remote.origin.fetch=${refspec}`]) + }) + + it('tolerates no failure at all on the write path — every command there is required', () => { + expect(plan.commands.filter(c => c.optional === true)).toEqual([]) + }) + + it('creates no local branch and never switches branches', () => { + const switches = [...plan.prepare, ...plan.commands].map(c => c.argv) + expect(switches.some(c => c[1] === 'checkout' || c[1] === 'switch' || c[1] === 'branch')).toBe( + false, + ) + }) + + it('restores NARROWLY: the ratchet commit and the config edit, nothing else', () => { + // `reset --hard` would also revert any OTHER tracked file an earlier step of + // the same job legitimately modified (this step runs before the E2E tests). + // An explicit SHA, never `HEAD~1`: a relative reset would destroy the base + // branch's own tip if the sequence failed before the commit existed. + expect(plan.restore.map(c => c.argv)).toEqual([ + ['git', 'reset', '--mixed', HEAD_SHA], + ['git', 'checkout', '--', '.pair/adoption/tech/coverage-baseline.md'], + ]) + const flatRestore = plan.restore.flatMap(c => c.argv) + expect(flatRestore).not.toContain('--hard') + expect(flatRestore).not.toContain('HEAD~1') + }) + + it('stages ONLY the config file — never `git add -A`', () => { + const add = argvs.find(c => c[1] === 'add') as string[] + expect(add).toEqual(['git', 'add', '--', '.pair/adoption/tech/coverage-baseline.md']) + expect(flat).not.toContain('-A') + }) + + it('marks the commit subject as automated (loop marker)', () => { + expect(plan.commitMessage).toContain(RATCHET_MARKER) + expect(plan.commitMessage).toContain('shared 84->89') + }) + + it('carries the marker in the PR title too, so a squash subject keeps it', () => { + expect(plan.prTitle).toContain(RATCHET_MARKER) + }) + + it('creates-or-updates ONE PR against the base branch from the ratchet branch', () => { + const gh = argvs.filter(c => c[0] === 'gh') + expect(gh.length).toBe(1) + const ghFlat = gh.map(c => c.join(' ')).join('\n') + expect(ghFlat).toContain(`--base main`) + expect(ghFlat).toContain(`--head ${RATCHET_BRANCH}`) + }) + + it('explains itself in the PR body: why a PR, the flag, and the ADL', () => { + expect(plan.prBody).toContain('#372') + expect(plan.prBody).toContain('2026-07-30-coverage-ratchet-pr-not-push') + expect(plan.prBody).toContain('shared') + }) + + it('refreshes an already-open ratchet PR in place instead of opening a second one', () => { + expect(plan.prUpdate.slice(0, 4)).toEqual(['gh', 'pr', 'edit', RATCHET_BRANCH]) + expect(plan.prUpdate).toContain(plan.prTitle) + expect(plan.prUpdate).toContain(plan.prBody) + }) + + it('has no commands at all when there is nothing to raise', () => { + const empty = ratchetGitPlan({ + raises: [], + configPath: 'x.md', + baseBranch: 'main', + remote: 'origin', + headCommit: HEAD_SHA, + }) + expect(empty.prepare).toEqual([]) + expect(empty.commands).toEqual([]) + expect(empty.prUpdate).toEqual([]) + expect(empty.restore).toEqual([]) + }) + + it('reads the pending baseline from the FETCHED ratchet ref, not from a branch name', () => { + // A local branch of that name does not exist in a CI checkout; the remote- + // tracking ref the `prepare` fetch created is what must be read. + expect(ratchetBranchConfigCommand('origin', 'cfg.md')).toEqual([ + 'git', + 'show', + `refs/remotes/origin/${RATCHET_BRANCH}:cfg.md`, + ]) + }) +}) + +// The credential is the one thing the entire write path rests on, and a dry run +// cannot observe it: these assertions are the pin. +describe('gitAuthConfig — exactly ONE Authorization header, ours', () => { + const cfg = gitAuthConfig('t0ken') + + it('resets the multi-valued extraheader (slot 0) BEFORE adding ours (slot 1)', () => { + // actions/checkout (persist-credentials defaults to true) has already persisted + // the read-only GITHUB_TOKEN under this key. Two values = two Authorization + // headers and a server-side coin toss — most likely lost by us, which AC6 would + // report as a warning on every base-branch push: a feature that never works and + // never says so. git: for http.extraHeader "an empty value will reset the extra + // headers to the empty list". + expect(cfg['GIT_CONFIG_COUNT']).toBe('2') + expect(cfg['GIT_CONFIG_KEY_0']).toBe(EXTRAHEADER_CONFIG_KEY) + expect(cfg['GIT_CONFIG_VALUE_0']).toBe('') + expect(cfg['GIT_CONFIG_KEY_1']).toBe(EXTRAHEADER_CONFIG_KEY) + expect(cfg['GIT_CONFIG_VALUE_1']).toContain('AUTHORIZATION: basic ') + }) + + it('sends the token as basic x-access-token, and never in argv', () => { + const basic = Buffer.from('x-access-token:t0ken').toString('base64') + expect(cfg['GIT_CONFIG_VALUE_1']).toBe(`AUTHORIZATION: basic ${basic}`) + // The plan's argv must not carry the credential (it would be visible in `ps`). + const plan = ratchetGitPlan({ + raises: planRatchet(CONFIG, { shared: 90.5 }).filter(p => p.action === 'raise'), + configPath: 'cfg.md', + baseBranch: 'main', + remote: 'origin', + headCommit: 'deadbeef', + }) + const flat = [...plan.prepare, ...plan.commands].flatMap(c => c.argv).join(' ') + expect(flat).not.toContain('t0ken') + expect(flat).not.toContain(basic) + expect(flat).not.toContain(EXTRAHEADER_CONFIG_KEY) + }) + + it('gives `gh` the same token, so the PR is opened by the same identity', () => { + expect(cfg['GH_TOKEN']).toBe('t0ken') + }) +}) + +describe('renderRatchetPlan — the human-readable step output', () => { + it('reports a skip with its code and reason', () => { + const out = renderRatchetPlan({ + skip: { skip: true, code: 'flag-disabled', reason: 'commit-back is disabled' }, + plan: [], + }) + expect(out).toContain('SKIPPED') + expect(out).toContain('flag-disabled') + }) + + it('reports each per-type action', () => { + const out = renderRatchetPlan({ plan: planRatchet(CONFIG, { shared: 90.5, frontend: 19.2 }) }) + expect(out).toContain('shared') + expect(out).toContain('raise') + expect(out).toContain('frontend') + expect(out).toContain('hold') + }) + + it('says so explicitly when nothing is to be raised', () => { + expect(renderRatchetPlan({ plan: planRatchet(CONFIG, { shared: 84 }) })).toContain('no raise') + }) +}) + +describe('module constants pin the contract the CI step and the docs rely on', () => { + it('exposes the marker, branch, margin and token env name', () => { + expect(RATCHET_MARKER).toBe('[coverage-baseline-ratchet]') + expect(RATCHET_BRANCH).toBe('chore/coverage-baseline-ratchet') + expect(DEFAULT_MARGIN_PP).toBe(1) + expect(TOKEN_ENV).toBe('COVERAGE_RATCHET_TOKEN') + }) + + it('names the git config key actions/checkout persists its credential into', () => { + expect(EXTRAHEADER_CONFIG_KEY).toBe('http.https://github.com/.extraheader') + }) +}) diff --git a/packages/knowledge-hub/src/tools/coverage-baseline-ratchet.ts b/packages/knowledge-hub/src/tools/coverage-baseline-ratchet.ts new file mode 100644 index 00000000..80356c98 --- /dev/null +++ b/packages/knowledge-hub/src/tools/coverage-baseline-ratchet.ts @@ -0,0 +1,1032 @@ +/** + * coverage-baseline-ratchet — the OPT-IN commit-back half of the coverage + * guardrail (story #372, on top of #282). + * + * The guardrail itself (`.pair/knowledge/assets/coverage-gate.sh`) decides + * pass/fail and NEVER persists anything. This module is the separable side + * effect: given the coverage a run measured and the committed config, it decides + * whether a `baseline.` may be RAISED, produces the edited config text, + * and produces the git/gh command plan that lands the raise as a bot pull + * request. The two are independent by construction — a refused write can never + * change the gate's verdict (AC6). + * + * The decisions this module encodes are recorded in + * `.pair/adoption/decision-log/2026-07-30-coverage-ratchet-pr-not-push.md`: + * + * - AC5 — a `pull_request` run NEVER writes back. Only the post-merge `push` + * on the base branch does, and it writes a bot PR from a dedicated branch, + * never a push to the (post-#234 protected) base branch. + * - AC4 — loop termination is a skip predicate (`shouldSkipCommitBack`) plus a + * fixpoint (`proposeBaseline` is idempotent on unchanged coverage), not + * `[skip ci]`. + * - AC6 — a refused write is classified and named (`classifyWriteRefusal`) and + * reported as a warning; the CLI exits 0. That holds for an UNEXPECTED failure + * too: `main` funnels everything except an argument-parsing bug (a + * workflow-authoring error) into the same warning + exit 0. + * - Monotonic ratchet — `applyRaises` re-checks every proposal against BOTH the + * text currently on disk (the base-branch checkout) and the config as + * committed on the open ratchet branch, so a higher value written by a + * concurrent run is never clobbered on either ref, and a value is never + * lowered. + * + * Per the gate-tooling ADL (2026-07-13) the logic lives HERE, white-box unit + * tested, and the CI step + `coverage:ratchet` scripts are thin entrypoints; the + * CLI wiring is verified end-to-end by the `coverage-gate.sh` smoke scenario + * (`--dry-run`), never by a unit test. + */ +import { execFileSync } from 'node:child_process' +import { readFileSync, writeFileSync } from 'node:fs' + +/** Marker that makes an automated commit (and the ratchet PR title) self-identifying. */ +export const RATCHET_MARKER = '[coverage-baseline-ratchet]' + +/** The dedicated branch the ratchet PR is opened from. Never the base branch. */ +export const RATCHET_BRANCH = 'chore/coverage-baseline-ratchet' + +/** + * Margin, in percentage points, between the measured coverage and the baseline + * written. NOT a new convention: `tech/coverage-baseline.md` already documents + * "~1pp below the measured value, to absorb per-run float jitter and legitimate + * line churn", and `floor(measured) - 1` reproduces both committed values + * exactly (85.04 -> 84, 20.16 -> 19). It is also what makes the ratchet a + * fixpoint, which is half of the loop-termination guarantee. + */ +export const DEFAULT_MARGIN_PP = 1 + +/** Env var carrying the dedicated, repo-scoped write credential (see the ADL). */ +export const TOKEN_ENV = 'COVERAGE_RATCHET_TOKEN' + +/** Slug of the ADL that decided the PR-vs-push behaviour and the credential model. */ +export const ADL_SLUG = '2026-07-30-coverage-ratchet-pr-not-push' + +/** + * The git config key `actions/checkout` persists the (read-only) `GITHUB_TOKEN` + * into (its `persist-credentials` defaults to true). We need the SAME key for our + * own credential, and `extraHeader` is MULTI-valued: naively adding ours would + * make git send TWO `Authorization` headers and leave it to the server which one + * wins. The likely loser is ours — a refusal that AC6 turns into a warning on + * every base-branch push, i.e. a feature that never actually works and never says + * so. See `gitAuthConfig` for how that is made unambiguous. + */ +export const EXTRAHEADER_CONFIG_KEY = 'http.https://github.com/.extraheader' + +const DEFAULT_CONFIG_PATH = '.pair/adoption/tech/coverage-baseline.md' +const DEFAULT_WOW_PATH = '.pair/adoption/tech/way-of-working.md' +const BOT_NAME = 'pair-coverage-ratchet[bot]' +const BOT_EMAIL = 'pair-coverage-ratchet[bot]@users.noreply.github.com' + +/** Escape regex metacharacters so a dotted config key matches literally. */ +const esc = (s: string): string => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + +/** True iff the value is a plain non-negative number (same shape the shell gate accepts). */ +const isNumeric = (v: unknown): boolean => typeof v === 'string' && /^\d+(\.\d+)?$/.test(v.trim()) + +// --------------------------------------------------------------------------- +// Ratchet arithmetic + config reading +// --------------------------------------------------------------------------- + +/** + * The baseline value a measured coverage justifies: `floor(measured) - margin`, + * clamped at 0. Idempotent by construction — re-running on the same coverage + * proposes the value already committed. + */ +export function proposeBaseline(measuredPct: number, marginPp: number = DEFAULT_MARGIN_PP): number { + return Math.max(0, Math.floor(measuredPct) - marginPp) +} + +/** + * The committed `baseline.` value, or null when absent or malformed. + * Config format is UNCHANGED from #282: a `^baseline.=` line, matched + * literally, ignoring the surrounding markdown. A trailing CR (CRLF-authored + * config) is tolerated. + */ +export function readBaselineValue(configText: string, type: string): number | null { + const m = new RegExp(`^baseline\\.${esc(type)}=([^\\r\\n]*)`, 'm').exec(configText) + if (!m) return null + const raw = (m[1] ?? '').trim() + return /^\d+$/.test(raw) ? Number(raw) : null +} + +/** Declared state of a way-of-working quality flag. */ +export type FlagState = 'enabled' | 'disabled' | 'absent' + +/** + * A way-of-working flag is a LIST BULLET (`- **