diff --git a/.github/workflows/public-repo-hygiene.yml b/.github/workflows/public-repo-hygiene.yml index 5f44ba188f..2ad8fd1403 100644 --- a/.github/workflows/public-repo-hygiene.yml +++ b/.github/workflows/public-repo-hygiene.yml @@ -9,10 +9,12 @@ # private address, an operator machine path, a private repository name, and # internal-KB prose in a public README. # -# mode: report. The gate records every finding and exits 0, so it lands without -# blocking unrelated PRs on a repository that carries pre-existing residue. It -# flips to enforce when that residue is FIXED -- never by moving it into -# .public-repo-hygiene.yaml. +# mode: report, with the five content classes enforced on ADDED lines +# (OMN-20782): .public-repo-hygiene.yaml sets enforce_scope and enforce_classes, +# the reusable resolves the pull request's diff base itself, and a PR that adds +# a line in one of those classes goes red while one that does not stays green +# over the pre-existing residue. The whole-tree mode flips to enforce when that +# residue is FIXED -- never by moving it into .public-repo-hygiene.yaml. # # Layer (a) reads .public-repo-hygiene.yaml at this repo's root; the gate # refuses to run without it rather than treating an absent config as an empty @@ -42,7 +44,7 @@ concurrency: jobs: public-repo-hygiene: - uses: OmniNode-ai/omniclaude/.github/workflows/public-repo-hygiene-reusable.yml@3b8addff365b1a53e04106284b9275b6d5fad3c6 + uses: OmniNode-ai/omniclaude/.github/workflows/public-repo-hygiene-reusable.yml@6e47aa311ac5d177d116d9597b07966a233d2463 with: mode: report secrets: inherit diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 9b54b0d39b..ca9a84aa31 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -2445,6 +2445,16 @@ repos: name: Skip count baseline can only shrink (OMN-19677) args: [--baseline, config/skip_count_baseline.yaml, --parser, 'yaml-counted-list-map:suites', --base-ref, origin/dev] files: ^config/skip_count_baseline\.yaml$ + # OMN-20782: the public-repo hygiene gate over the lines a commit adds, at the + # same omniclaude revision as .github/workflows/public-repo-hygiene.yml, so the + # local verdict and the CI verdict cannot diverge. The hook reads the private + # denylist from the file named by OMNI_HYGIENE_VOCAB_PATH, with the lab + # vocabulary beside it, and fails closed without them. + - repo: https://github.com/OmniNode-ai/omniclaude + rev: 6e47aa311ac5d177d116d9597b07966a233d2463 + hooks: + - id: public-repo-hygiene + require_serial: true # OMN-19666: VCS-backed additional_dependencies can inherit GIT_INDEX_FILE # during a cold-cache commit and overwrite the committing worktree index. - repo: local diff --git a/.public-repo-hygiene.yaml b/.public-repo-hygiene.yaml index c615bdd526..743bd7a9f2 100644 --- a/.public-repo-hygiene.yaml +++ b/.public-repo-hygiene.yaml @@ -13,12 +13,23 @@ # drift — a finding, not a root entry. Fix or delete it; do not # add it here. # -# mode: report — the gate records every finding with a per-class count and -# exits 0. It flips to enforce when the residue is FIXED, never by moving the +# mode: report — the gate records every finding with a per-class count. It +# flips to whole-tree enforce when the residue is FIXED, never by moving the # residue into this file: that is the recurrence mechanism the OMN-17992 # programme exists to remove. +# +# enforce_scope / enforce_classes (OMN-20782, the OMN-19835 mechanism): the five +# content classes below fail the run, in CI and in the pre-commit hook, on the +# lines a change ADDS. The whole-tree residue stays reported on every run. mode: report suppression_registry: ".public-repo-hygiene-suppressions.yaml" +enforce_scope: added-lines +enforce_classes: + - "private-repo-name" + - "internal-kb-prose" + - "lab-config" + - "person-name" + - "private-network" allowed_top_level: - ".claude" - ".env.example" diff --git a/contracts/OMN-20782.yaml b/contracts/OMN-20782.yaml new file mode 100644 index 0000000000..1bd83f97a5 --- /dev/null +++ b/contracts/OMN-20782.yaml @@ -0,0 +1,48 @@ +schema_version: "1.0.0" +ticket_id: OMN-20782 +title: omnibase_infra enforces the five hygiene classes on added lines +summary: .public-repo-hygiene.yaml declares enforce_scope added-lines and the five enforce_classes, the public-repo-hygiene caller moves to an omniclaude revision that supports added-lines enforcement, and a pre-commit hook runs the gate at the same revision over staged added lines. No allowlist entry is added. +is_seam_ticket: false +interface_change: false +interfaces_touched: [] +requirements: + - id: req-omn20782 + statement: A pull request or a commit that adds a line in one of the five enforced hygiene classes fails the gate, and a change that touches no enforced class passes. + acceptance: + - id: AC1 + statement: 'AC1: a pull request adding a lab-config literal fails the gate in CI, at the moved pin -- falsifier: the gate''s run on a scratch branch with one planted literal' + - id: AC2 + statement: 'AC2: the pre-commit hook refuses the same staged literal -- falsifier: `pre-commit run public-repo-hygiene --files the planted file` exits non-zero' + - id: AC3 + statement: 'AC3: a pull request touching no enforced class passes -- falsifier: this task''s own PR runs the gate green' +evidence_requirements: + - kind: tests + description: The config enforces the five classes on added lines, the caller is pinned to the added-lines revision, and a recorded run of the real gate at that pin fails a planted lab-config literal. + command: uv run pytest tests/ci/test_public_repo_hygiene_enforcement_omn20782.py -q + - kind: manual + description: The scratch pull request run, the local pre-commit refusal of the planted file and its positive control are quoted in the PR body; AC3 is this pull request's own public-repo-hygiene run. + command: pre-commit run public-repo-hygiene --files +emergency_bypass: + enabled: false + justification: '' + follow_up_ticket_id: '' +dod_evidence: + - id: dod-omn-20782-ac1 + description: The config enforces the five classes on added lines, the caller workflow is pinned to the added-lines revision, and the recorded run of the gate at that pin fails a planted lab-config literal. + source: manual + checks: + - check_type: test_passes + check_value: uv run pytest tests/ci/test_public_repo_hygiene_enforcement_omn20782.py -q -k "config_enforces_the_five_classes or caller_workflow_is_pinned or planted_lab_config_literal" + cwd: ${OMNI_HOME}/omnibase_infra + binds_ac: + - AC1 + - id: dod-omn-20782-precommit-and-sweep + description: The pre-commit hook runs the gate at the same revision, the recorded `pre-commit run public-repo-hygiene` over a staged planted literal exits 1 (AC2), and a red public-repo-hygiene row fails the CI Summary sweep while a green one passes (AC3). + source: manual + checks: + - check_type: test_passes + check_value: uv run pytest tests/ci/test_public_repo_hygiene_enforcement_omn20782.py -q -k "precommit_runs_the_gate_hook or planted_lab_config_literal or ci_summary_sweep" + cwd: ${OMNI_HOME}/omnibase_infra + binds_ac: + - AC2 + - AC3 diff --git a/tests/ci/fixtures/public_repo_hygiene_added_lines_omn20782.json b/tests/ci/fixtures/public_repo_hygiene_added_lines_omn20782.json new file mode 100644 index 0000000000..10e34b2d54 --- /dev/null +++ b/tests/ci/fixtures/public_repo_hygiene_added_lines_omn20782.json @@ -0,0 +1,15 @@ +{ + "_provenance": { + "source": "omniclaude scripts/public_repo_hygiene_gate.py at 6e47aa311ac5d177d116d9597b07966a233d2463, run by the exported public-repo-hygiene pre-commit hook over a staged planted lab-config literal in this repository", + "captured_utc": "2026-10-09T18:07:47Z" + }, + "response": { + "gate_rev": "6e47aa311ac5d177d116d9597b07966a233d2463", + "command": "pre-commit run public-repo-hygiene --files tests/planted_omn20782_probe.txt", + "exit_code": 1, + "enforce_scope_line": "ENFORCE SCOPE: added lines (index over 73f2a806510d). 1 enforced finding(s) on lines this change adds fail the run; 14956 other finding(s) are reported only.", + "added_findings": [ + "ADDED tests/planted_omn20782_probe.txt:1: lab-config" + ] + } +} diff --git a/tests/ci/test_anti_growth_baselines_omn19677.py b/tests/ci/test_anti_growth_baselines_omn19677.py index 1e44fc63c1..44bd4f0733 100644 --- a/tests/ci/test_anti_growth_baselines_omn19677.py +++ b/tests/ci/test_anti_growth_baselines_omn19677.py @@ -64,7 +64,12 @@ def _load_yaml(path: Path) -> dict[str, Any]: def _omniclaude_repo() -> dict[str, Any]: repos = _load_yaml(PRECOMMIT_CONFIG)["repos"] - matches = [repo for repo in repos if repo.get("repo") == OMNICLAUDE_REPO] + matches = [ + repo + for repo in repos + if repo.get("repo") == OMNICLAUDE_REPO + and any(hook.get("id") == "anti-growth-baseline" for hook in repo["hooks"]) + ] assert len(matches) == 1 return matches[0] diff --git a/tests/ci/test_public_repo_hygiene_enforcement_omn20782.py b/tests/ci/test_public_repo_hygiene_enforcement_omn20782.py new file mode 100644 index 0000000000..b0ea63a272 --- /dev/null +++ b/tests/ci/test_public_repo_hygiene_enforcement_omn20782.py @@ -0,0 +1,155 @@ +# SPDX-FileCopyrightText: 2025 OmniNode.ai Inc. +# SPDX-License-Identifier: MIT +"""OMN-20782: omnibase_infra enforces the five hygiene classes on added lines. + +The public-repo hygiene gate blocks a class only when the repository's own +``.public-repo-hygiene.yaml`` lists it in ``enforce_classes`` (and, for the +added-lines scope, declares ``enforce_scope: added-lines``). Until this change +the file declared neither, so the gate could not fail a pull request. The +caller workflow and the pre-commit hook must sit on the same omniclaude +revision, which must carry the added-lines scope (omniclaude 9a58ef705) and the +env-var vocabulary path (omniclaude 6e47aa311). + +Falsifier: restore the previous config, pin, or drop the pre-commit hook and the +named test fails. +""" + +from __future__ import annotations + +from datetime import UTC, datetime +from pathlib import Path +from typing import cast + +import pytest +import yaml + +from scripts.ci.ci_summary_gate import ( + EXTERNAL_SWEEP_EXCLUSIONS, + evaluate_external_sweep, +) + +pytestmark = pytest.mark.unit + +REPO_ROOT = Path(__file__).resolve().parents[2] +CONFIG = REPO_ROOT / ".public-repo-hygiene.yaml" +WORKFLOW = REPO_ROOT / ".github" / "workflows" / "public-repo-hygiene.yml" +PRECOMMIT = REPO_ROOT / ".pre-commit-config.yaml" + +# omniclaude dev 6e47aa311, a descendant of 9a58ef705 (added-lines scope). +GATE_REV = "6e47aa311ac5d177d116d9597b07966a233d2463" +# The pin before this change: it carries the added-lines scope but not the +# env-var vocabulary path the exported pre-commit hook reads. +PREVIOUS_REV = "3b8addff365b1a53e04106284b9275b6d5fad3c6" +FIVE_CLASSES = { + "private-repo-name", + "internal-kb-prose", + "lab-config", + "person-name", + "private-network", +} +REUSABLE = "OmniNode-ai/omniclaude/.github/workflows/public-repo-hygiene-reusable.yml" +HYGIENE_CONTEXT = "public-repo-hygiene / public-repo-hygiene" +NOW = datetime(2026, 10, 9, 18, 0, 0, tzinfo=UTC) + + +def _config() -> dict[str, object]: + loaded = yaml.safe_load(CONFIG.read_text()) + assert isinstance(loaded, dict) + return loaded + + +def test_config_enforces_the_five_classes_on_added_lines() -> None: + config = _config() + assert config.get("enforce_scope") == "added-lines" + classes = config.get("enforce_classes") + assert isinstance(classes, list) + assert set(classes) == FIVE_CLASSES + assert len(classes) == len(FIVE_CLASSES) + + +def test_caller_workflow_is_pinned_to_the_added_lines_revision() -> None: + workflow = yaml.safe_load(WORKFLOW.read_text()) + uses = workflow["jobs"]["public-repo-hygiene"]["uses"] + assert uses == f"{REUSABLE}@{GATE_REV}" + assert PREVIOUS_REV not in WORKFLOW.read_text() + assert workflow["jobs"]["public-repo-hygiene"]["secrets"] == "inherit" + + +def _hygiene_hook_blocks() -> list[dict[str, object]]: + precommit = yaml.safe_load(PRECOMMIT.read_text()) + blocks = [] + for repo in precommit["repos"]: + for hook in repo.get("hooks", []): + if hook.get("id") == "public-repo-hygiene": + blocks.append({"repo": repo, "hook": hook}) + return blocks + + +def test_precommit_runs_the_gate_hook_at_the_same_revision() -> None: + blocks = _hygiene_hook_blocks() + assert len(blocks) == 1, "exactly one public-repo-hygiene hook is declared" + repo = blocks[0]["repo"] + hook = blocks[0]["hook"] + assert isinstance(repo, dict) and isinstance(hook, dict) + assert repo["repo"] == "https://github.com/OmniNode-ai/omniclaude" + assert repo["rev"] == GATE_REV + # The exported hook already runs over the staged added lines (--diff-staged), + # always_run, in the pre-commit stage. An override here would narrow it. + narrowing = {"entry", "args", "files", "exclude", "stages", "always_run"} + assert not narrowing & set(hook), sorted(narrowing & set(hook)) + + +def test_hygiene_context_is_not_excluded_from_the_ci_summary_sweep() -> None: + """A red gate must fail the CI Summary umbrella: no exclusion admits it.""" + assert not [k for k in EXTERNAL_SWEEP_EXCLUSIONS if "hygiene" in k.lower()] + + +def _row(conclusion: str) -> dict[str, object]: + return { + "id": 20782, + "name": HYGIENE_CONTEXT, + "status": "completed", + "conclusion": conclusion, + "started_at": "2026-10-09T17:00:00Z", + "completed_at": "2026-10-09T17:05:00Z", + "head_sha": "a" * 40, + } + + +def _sweep(conclusion: str) -> list[str]: + failures, _in_flight, swept, _excluded, _provisional = evaluate_external_sweep( + [_row(conclusion)], + expected=(), + in_run_names=frozenset(), + self_name="CI Summary", + exclusions=EXTERNAL_SWEEP_EXCLUSIONS, + events={}, + now=NOW, + ) + assert swept == [HYGIENE_CONTEXT], "the sweep must have judged the row" + return failures + + +def test_a_red_hygiene_gate_fails_the_ci_summary_sweep_and_green_passes() -> None: + assert _sweep("failure") == [f"{HYGIENE_CONTEXT} (failure)"] + assert _sweep("success") == [] + + +@pytest.mark.live_contact( + "tests/ci/fixtures/public_repo_hygiene_added_lines_omn20782.json" +) +def test_gate_at_the_pin_fails_a_planted_lab_config_literal( + recorded_response: dict[str, object], +) -> None: + """The recorded run of the real gate, at the revision both callers use.""" + recording = cast("dict[str, object]", recorded_response["response"]) + assert recording["gate_rev"] == GATE_REV + assert recording["exit_code"] == 1 + assert "1 enforced finding(s) on lines this change adds" in cast( + "str", recording["enforce_scope_line"] + ) + assert [ + f.rsplit(": ", 1)[-1] for f in cast("list[str]", recording["added_findings"]) + ] == ["lab-config"] + assert recording["gate_rev"] in WORKFLOW.read_text() + assert recording["gate_rev"] in PRECOMMIT.read_text()