Skip to content

Commit 91d8b88

Browse files
feat: consume .hypatia-baseline.json in governance gate (#166)
## Summary Adds a single, formal exemption convention to the estate governance gate: `.hypatia-baseline.json`. Same shape as Hypatia findings (severity, rule_module, type, file/file_pattern), so an acknowledged finding can be listed once and silently suppressed by the gate. Companion to `hyperpolymath/gitbot-fleet#148`. ### Why The `language-policy` job runs `banned_language_file` across every caller repo. In-flight migrations (e.g. the ReScript → AffineScript port at `gitbot-fleet`) keep getting blocked on `.res` files that are already acknowledged in the repo's baseline. PR authors started inventing alternate ignore conventions (`.hypatia-ignore` flat-files) to work around the gap. This PR formalises one convention so the estate stops sprouting more. ### What 1. **`validate-baseline` job** (new) — detects the per-repo baseline file, schema-validates it (`ajv` when available, `jq` fallback otherwise), surfaces stale entries (referenced file no longer exists) as warnings. Soft-failure throughout. 2. **`language-policy` job** (updated) — `is_exempt()` now consults `.hypatia-baseline.json` before falling back to legacy `.hypatia-ignore` and the inline `# hypatia:ignore` pragma. The legacy path stays for back-compat; a follow-up PR retires it once the estate has converged. 3. **`.machine_readable/hypatia-baseline.schema.json`** — formal JSON Schema (draft 2020-12). Required: `severity`, `rule_module`, `type`, plus exactly one of `file` / `file_pattern`. Optional: `severity_override`, `expires_at`, `note`, `tracking_issue`. 4. **`scripts/apply-baseline.sh`** — pure bash+jq filter. Used by the richer findings-list flow this PR seeds; the current workflow integration uses the inline `jq` lookup instead so the advisory-mode landing has the smallest possible footprint. 5. **`docs/HYPATIA-BASELINE-FORMAT.adoc`** — authoritative format doc. 6. **`docs/EXEMPTION-MECHANISMS.adoc`** — convention doc: when to use `.hypatia-baseline.json` vs the estate-wide `bot_exclusion_registry.a2ml` vs per-PR labels. ### Rollout **Advisory mode (default).** Exempting a finding via the baseline removes it from the gate output. Blocking-mode promotion (via `vars.HYPATIA_BASELINE_MODE = "blocking"`) is the follow-up after a one-week soak. Documented inline at the top of `governance-reusable.yml`. ## Test plan - [ ] CI green on this branch. - [ ] Manual: caller repo with a baseline file gets a `⏭️ exempt (baseline)` line in the language-policy job logs and the gate passes despite the banned file. - [ ] Manual: caller repo without a baseline behaves identically to before (legacy `.hypatia-ignore` + inline pragma still honoured). - [ ] Manual: caller repo with a malformed baseline gets a `validate-baseline` warning (not a hard failure during advisory soak). - [ ] Follow-up after one-week soak: flip `HYPATIA_BASELINE_MODE` to `blocking` and confirm the gate behaves identically across the estate. https://claude.ai/code/session_01W3PAoaqgJj3mnM8kjhEqx4 --- _Generated by [Claude Code](https://claude.ai/code/session_01W3PAoaqgJj3mnM8kjhEqx4)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 3ec2e85 commit 91d8b88

6 files changed

Lines changed: 674 additions & 9 deletions

File tree

.github/workflows/governance-reusable.yml

Lines changed: 124 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,24 @@
2121
# jobs:
2222
# governance:
2323
# uses: hyperpolymath/standards/.github/workflows/governance-reusable.yml@main
24+
#
25+
# Hypatia baseline integration (added 2026-05-25):
26+
# - A caller repo may carry `.hypatia-baseline.json` (array of
27+
# acknowledged Hypatia findings; schema lives at
28+
# `.machine_readable/hypatia-baseline.schema.json` in this repo and
29+
# is documented in `docs/HYPATIA-BASELINE-FORMAT.adoc`).
30+
# - `validate-baseline` (below) checks the file shape and reports
31+
# stale entries — it never fails the gate when the baseline is
32+
# absent or matches.
33+
# - `language-policy` consults the baseline for
34+
# `cicd_rules/banned_language_file` exemptions; the legacy
35+
# `.hypatia-ignore` flat-file support is retained for
36+
# backward-compat and will be retired in a follow-up PR once the
37+
# estate has converged on `.hypatia-baseline.json`.
38+
# - Rollout mode is controlled by `vars.HYPATIA_BASELINE_MODE`
39+
# (`advisory` | `blocking`); the default is `advisory` for the
40+
# one-week soak. Flip to `blocking` once the conversion is
41+
# observed-clean.
2442

2543
name: Estate Governance (reusable)
2644

@@ -37,6 +55,71 @@ permissions:
3755
contents: read
3856

3957
jobs:
58+
validate-baseline:
59+
name: Validate Hypatia baseline
60+
runs-on: ${{ inputs.runs-on }}
61+
permissions:
62+
contents: read
63+
steps:
64+
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
65+
with:
66+
repository: ${{ github.repository }}
67+
ref: ${{ github.ref }}
68+
69+
- name: Detect baseline file
70+
id: detect
71+
run: |
72+
if [ -f .hypatia-baseline.json ]; then
73+
echo "present=true" >> "$GITHUB_OUTPUT"
74+
echo "::notice::Found .hypatia-baseline.json — entries will be honoured by the language-policy gate."
75+
else
76+
echo "present=false" >> "$GITHUB_OUTPUT"
77+
echo "::notice::No .hypatia-baseline.json — language-policy gate will treat every banned-language file as new."
78+
fi
79+
80+
- name: Validate baseline against schema
81+
if: steps.detect.outputs.present == 'true'
82+
run: |
83+
# Schema lives in this reusable workflow's repo so consumers
84+
# don't need to bundle their own copy.
85+
SCHEMA_URL="https://raw.githubusercontent.com/hyperpolymath/standards/main/.machine_readable/hypatia-baseline.schema.json"
86+
curl -sSfL "$SCHEMA_URL" -o /tmp/hypatia-baseline.schema.json || {
87+
echo "::warning::Could not fetch baseline schema from $SCHEMA_URL — skipping schema validation."
88+
exit 0
89+
}
90+
# `ajv` is preferred but a pure-jq sanity check is enough for
91+
# the advisory-mode rollout: array of objects, required keys
92+
# present.
93+
if command -v ajv >/dev/null 2>&1; then
94+
ajv validate --spec=draft2020 \
95+
-s /tmp/hypatia-baseline.schema.json \
96+
-d .hypatia-baseline.json
97+
else
98+
jq -e 'type == "array"
99+
and all(.[]; type == "object"
100+
and has("severity")
101+
and has("rule_module")
102+
and has("type")
103+
and (has("file") or has("file_pattern"))
104+
)' .hypatia-baseline.json >/dev/null
105+
echo "✅ baseline shape OK (jq fallback — install ajv for full schema validation)"
106+
fi
107+
108+
- name: Detect stale baseline entries
109+
if: steps.detect.outputs.present == 'true'
110+
run: |
111+
# Soft-fail: a baseline entry pointing at a file that no
112+
# longer exists is a warning, not a hard failure. Flips to
113+
# blocking once estate-wide convergence is observed.
114+
STALE_COUNT=0
115+
while IFS= read -r file; do
116+
if [ -n "$file" ] && [ ! -e "$file" ]; then
117+
echo "::warning file=.hypatia-baseline.json::Stale baseline entry: $file does not exist in the working tree."
118+
STALE_COUNT=$((STALE_COUNT + 1))
119+
fi
120+
done < <(jq -r '.[].file // empty' .hypatia-baseline.json)
121+
echo "Stale entries: $STALE_COUNT"
122+
40123
language-policy:
41124
name: Language / package anti-pattern policy
42125
runs-on: ${{ inputs.runs-on }}
@@ -138,20 +221,52 @@ jobs:
138221
PYEOF
139222
140223
# Shared escape hatch for the banned-language-file checks below.
141-
# Honours the estate's declared machine-readable exemption (standards#72,
142-
# Explicit-Escape Principle): a file is exempt from the
143-
# `cicd_rules/banned_language_file` rule if EITHER
144-
# * `.hypatia-ignore` contains the exact line
145-
# `cicd_rules/banned_language_file:<relpath>`, OR
146-
# * the file carries an inline `# hypatia:ignore ...
147-
# cicd_rules/banned_language_file` pragma in its first 8 lines
148-
# — the same escape the Hypatia scanner itself honours.
224+
# Honours three exemption mechanisms (see
225+
# standards/docs/EXEMPTION-MECHANISMS.adoc):
226+
# 1. `.hypatia-baseline.json` — array of acknowledged findings,
227+
# shape mirrors the Hypatia findings themselves. Schema is
228+
# `.machine_readable/hypatia-baseline.schema.json` in this
229+
# repo. The validate-baseline job above schema-checks this.
230+
# Added 2026-05-25 as part of the convergence on a single
231+
# exemption mechanism.
232+
# 2. `.hypatia-ignore` flat-file — legacy single-rule-per-line
233+
# format (`cicd_rules/banned_language_file:<relpath>`).
234+
# Will be retired in a follow-up PR once .hypatia-baseline.json
235+
# is in active use across the estate.
236+
# 3. Inline `# hypatia:ignore ...` pragma in the file's first
237+
# 8 lines — the same escape the Hypatia scanner itself
238+
# honours.
149239
- name: Check for ReScript / Go / Python (banned language files)
150240
run: |
151-
rule="cicd_rules/banned_language_file"
241+
rule_module="cicd_rules"
242+
rule_type="banned_language_file"
243+
rule="${rule_module}/${rule_type}"
244+
245+
# Baseline lookup: returns 0 (exempt) if the file appears in
246+
# .hypatia-baseline.json with a matching rule_module + type.
247+
# `file_pattern` glob match is intentionally NOT implemented
248+
# here; the advisory-rollout window only honours exact `file`
249+
# matches. Pattern support arrives with the blocking-mode
250+
# flip and the apply-baseline.sh upgrade (see
251+
# standards/scripts/apply-baseline.sh).
252+
in_baseline() {
253+
local target="$1"
254+
[ -f .hypatia-baseline.json ] || return 1
255+
command -v jq >/dev/null 2>&1 || return 1
256+
jq -e \
257+
--arg rm "$rule_module" \
258+
--arg rt "$rule_type" \
259+
--arg f "$target" \
260+
'any(.[]; .rule_module == $rm and .type == $rt and .file == $f)' \
261+
.hypatia-baseline.json >/dev/null 2>&1
262+
}
152263
153264
is_exempt() {
154265
f="${1#./}"
266+
if in_baseline "$f"; then
267+
echo "⏭️ exempt (baseline): $f"
268+
return 0
269+
fi
155270
if [ -f .hypatia-ignore ] && grep -qxF "${rule}:${f}" .hypatia-ignore; then
156271
return 0
157272
fi

.hypatia-baseline.json

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
[
2+
{
3+
"severity": "critical",
4+
"rule_module": "cicd_rules",
5+
"type": "banned_language_file",
6+
"file": "a2ml-templates/state-scm-to-v2.py",
7+
"note": "One-shot v1 STATE.scm -> v2 STATE.a2ml directive-format migration script. Python is banned estate-wide (CLAUDE.md); this should be rewritten in Rust/AffineScript or retired once every estate repo has migrated to v2. Acknowledged here so the governance gate stops noise-flagging it on every PR."
8+
}
9+
]
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
{
2+
"$schema": "https://json-schema.org/draft/2020-12/schema",
3+
"$id": "https://github.com/hyperpolymath/standards/blob/main/.machine_readable/hypatia-baseline.schema.json",
4+
"title": "Hypatia per-repo baseline",
5+
"description": "Authoritative schema for `.hypatia-baseline.json` files in hyperpolymath estate repos. A baseline is an array of acknowledged Hypatia findings that should be suppressed from blocking governance gates.",
6+
"type": "array",
7+
"items": {
8+
"$ref": "#/$defs/BaselineEntry"
9+
},
10+
"$defs": {
11+
"BaselineEntry": {
12+
"type": "object",
13+
"additionalProperties": false,
14+
"required": ["severity", "rule_module", "type"],
15+
"oneOf": [
16+
{ "required": ["file"] },
17+
{ "required": ["file_pattern"] }
18+
],
19+
"properties": {
20+
"severity": {
21+
"description": "Severity of the finding as reported by Hypatia. Must match the finding's severity exactly for the entry to apply.",
22+
"type": "string",
23+
"enum": ["critical", "high", "medium", "low", "info"]
24+
},
25+
"rule_module": {
26+
"description": "Hypatia rule module that emitted the finding (e.g. `cicd_rules`, `code_safety`, `migration_rules`).",
27+
"type": "string",
28+
"pattern": "^[a-z][a-z0-9_]*$"
29+
},
30+
"type": {
31+
"description": "Hypatia finding type within the rule module (e.g. `banned_language_file`, `obj_magic`, `deprecated_api`).",
32+
"type": "string",
33+
"pattern": "^[a-z][a-z0-9_]*$"
34+
},
35+
"file": {
36+
"description": "Repo-relative path to a single file the entry exempts. Mutually exclusive with `file_pattern`.",
37+
"type": "string",
38+
"minLength": 1
39+
},
40+
"file_pattern": {
41+
"description": "Glob pattern (gitignore-style) matching multiple files. Use to exempt a whole subtree without per-file enumeration. Mutually exclusive with `file`.",
42+
"type": "string",
43+
"minLength": 1
44+
},
45+
"severity_override": {
46+
"description": "Optional. If set, the matched finding is downgraded to this severity in the gate (e.g. `low` for migration-window acknowledgement) instead of being silently suppressed. Useful for advisory-mode triage.",
47+
"type": "string",
48+
"enum": ["critical", "high", "medium", "low", "info", "advisory"]
49+
},
50+
"expires_at": {
51+
"description": "Optional ISO-8601 date. After this date the entry is treated as expired and the gate fails again. Used to bound migration windows and prevent baseline rot.",
52+
"type": "string",
53+
"format": "date"
54+
},
55+
"note": {
56+
"description": "Optional free-text human-readable rationale. Recommended for any non-obvious entry. Linked from the gate's summary output.",
57+
"type": "string"
58+
},
59+
"tracking_issue": {
60+
"description": "Optional GitHub issue reference (e.g. `hyperpolymath/gitbot-fleet#148`) that tracks the underlying work to remove the need for this exemption.",
61+
"type": "string",
62+
"pattern": "^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+#[0-9]+$"
63+
}
64+
}
65+
}
66+
}
67+
}

docs/EXEMPTION-MECHANISMS.adoc

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
// SPDX-License-Identifier: PMPL-1.0-or-later
2+
= Exemption mechanisms in the hyperpolymath estate
3+
:toc:
4+
5+
Three concentric exemption layers exist in the estate. Each addresses a
6+
different question and lives in a different file. Mixing them up is the
7+
single most common cause of "I added an ignore file but CI still fails"
8+
confusion.
9+
10+
== Quick decision tree
11+
12+
[source]
13+
----
14+
Are you trying to stop bots from WRITING to a path?
15+
-> use the estate-wide bot_exclusion_registry.a2ml
16+
17+
Are you trying to stop the governance gate from FAILING on a
18+
pre-existing finding?
19+
-> use the per-repo .hypatia-baseline.json
20+
21+
Are you trying to make a finding GO AWAY for one specific PR only?
22+
-> currently unsupported. See "per-PR exemptions" below.
23+
----
24+
25+
== Layer 1: Estate-wide bot write denylist
26+
27+
**File:** `hyperpolymath/standards/.machine_readable/bot_exclusion_registry.a2ml`
28+
29+
**Scope:** All bots, all repos. Authoritative across the entire estate.
30+
31+
**Purpose:** Tells bots what they may not *write* to. Read-only scanning
32+
is always allowed; only write actions (Write, Commit, CreatePr,
33+
CreateBranch, etc.) are gated.
34+
35+
**Format:** A2ML (TOML-shaped). Three axes:
36+
1. `external-repos` — exact `owner/repo` matches that bots must skip.
37+
2. `vendored-directory-patterns` — globs that should never be edited.
38+
3. `remote-origin-patterns` — bail-if-origin-matches patterns.
39+
40+
**Consumer:** Every bot via `shared-context::ExclusionRegistry::check()`.
41+
The registry is fail-closed: if the file fails to load, all bot writes
42+
are denied.
43+
44+
**Edit when:** A new repo should be off-limits to bots, or a new
45+
vendored subtree should never be modified.
46+
47+
**Don't use for:** Suppressing CI findings. This layer doesn't touch
48+
CI at all.
49+
50+
== Layer 2: Per-repo Hypatia finding baseline
51+
52+
**File:** `.hypatia-baseline.json` at the calling repo's root.
53+
54+
**Scope:** One repo. Acknowledged findings only — not a "ignore these
55+
forever" file.
56+
57+
**Purpose:** Tells the governance gate which Hypatia findings have been
58+
seen, triaged, and accepted as known debt. Findings matched by a baseline
59+
entry don't block CI; they're surfaced in the run summary as
60+
"acknowledged."
61+
62+
**Format:** JSON array of `{severity, rule_module, type, file | file_pattern, ...}`
63+
objects. See `docs/HYPATIA-BASELINE-FORMAT.adoc` for the full schema.
64+
65+
**Consumer:** `apply-baseline.sh` invoked from
66+
`hyperpolymath/standards/.github/workflows/governance-reusable.yml`.
67+
Suppresses matching findings from the gate, downgrades severity if
68+
`severity_override` is set, fails on expired entries.
69+
70+
**Edit when:**
71+
* Pre-existing debt is tracked for migration (link a `tracking_issue`).
72+
* Vendored / third-party code triggers a rule the team can't fix.
73+
* Test fixtures intentionally violate a rule (with `note` explaining).
74+
75+
**Don't use for:**
76+
* Newly-introduced findings on freshly-authored code. The rule exists
77+
for a reason; baselining the new file defeats it. Fix the code.
78+
* One-off PR-scoped suppression. Baseline edits are merged to main; they
79+
affect all subsequent PRs. See Layer 3.
80+
81+
== Layer 3: Per-PR exemptions (NOT YET IMPLEMENTED)
82+
83+
There is currently no supported per-PR exemption mechanism. PR authors
84+
attempting one (e.g. by inventing a `.hypatia-ignore` file) will find
85+
nothing reads it, and the gate will still fail. **Do not invent new
86+
file conventions.** If you need per-PR exemption, file an issue against
87+
`hyperpolymath/standards` proposing a designed mechanism.
88+
89+
If a mechanism is added, it should be one of:
90+
91+
1. **PR-body marker**, e.g. a fenced block:
92+
+
93+
[source,markdown]
94+
----
95+
```hypatia-exempt
96+
- rule: code_safety/obj_magic
97+
file: bots/sustainabot/bot-integration/src/Analysis.res
98+
reason: Pre-migration debt; tracked in #148
99+
```
100+
----
101+
+
102+
Pros: lives with the PR, vanishes when the PR closes, reviewable in
103+
the same UI as the change. Cons: requires a PR-body parser.
104+
105+
2. **Label-driven**, e.g. apply `gate:exempt:code_safety` and require an
106+
exemption-justification comment from a CODEOWNER.
107+
108+
3. **Sidecar file under `.github/pr-exemptions/`** — committed for one
109+
PR's lifetime, deleted at merge by a workflow.
110+
111+
A formal proposal should pick one and document it explicitly.
112+
113+
== Anti-patterns to reject
114+
115+
These have been attempted and should be refused at review:
116+
117+
* `.hypatia-ignore` (any format) — never read by anything. Reject; point
118+
the author at `.hypatia-baseline.json`.
119+
* Adding `pragma: ignore-rule` comments in source. Hypatia scans by AST
120+
and ignores comments; suppression has to be data-driven, not
121+
source-comment driven.
122+
* Setting `continue-on-error: true` on the governance gate. This makes
123+
CI lie. Use a baseline entry with `severity_override: advisory`
124+
instead — keeps the finding visible while unblocking the gate.
125+
* Adding the offending path to `.gitignore`. The file still exists in
126+
history; Hypatia and other estate scanners still see it.
127+
* Forking `hyperpolymath/standards` to disable the rule for one repo.
128+
If a rule is wrong for one repo, the rule needs scoping in the
129+
upstream, not a fork.
130+
131+
== Cross-references
132+
133+
* `docs/HYPATIA-BASELINE-FORMAT.adoc` — the baseline file format.
134+
* `.machine_readable/hypatia-baseline.schema.json` — machine schema.
135+
* `hyperpolymath/standards#????` — proposal that landed this consumer.
136+
* `hyperpolymath/hypatia` — the scanner that emits findings.

0 commit comments

Comments
 (0)