Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
135 changes: 135 additions & 0 deletions .github/workflows/canon-quality.yml
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,141 @@ env:
USER_AGENT: 'klappy.dev-canon-quality/1.0 (+github-actions; ${{ github.repository }}#${{ github.run_id }})'

jobs:
frontmatter:
name: Frontmatter schema validation
runs-on: ubuntu-latest
timeout-minutes: 3
# Hard-block from day one. The schema is unambiguous; canon
# (klappy://canon/constraints/frontmatter-validation-before-merge) mandates
# this gate "No Exceptions". No soft-observation cycle — the renderer's
# silent-drop failure mode (the May 10 incident) is exactly what this
# prevents.
steps:
- name: Checkout
uses: actions/checkout@v4

- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.x'

- name: Install dependencies
run: pip install --quiet pyyaml

- name: Run validator
id: validate
run: |
python3 scripts/validate-frontmatter.py --json writings/ > /tmp/fm-result.json || true
python3 - <<'PY'
import json
d = json.load(open('/tmp/fm-result.json'))
print(f"scanned={d['scanned']} status={d['status']} findings={len(d['findings'])}")
PY

- name: Upload findings artifact
if: always()
uses: actions/upload-artifact@v4
with:
name: frontmatter-findings
path: /tmp/fm-result.json
retention-days: 14
if-no-files-found: warn

- name: Render PR comment
id: render
if: github.event_name == 'pull_request'
run: |
python3 - <<'PY'
import json, collections
d = json.load(open('/tmp/fm-result.json'))
findings = d['findings']
scanned = d['scanned']
lines = []
if not findings:
lines.append('### Canon Quality — Frontmatter Schema ✅')
lines.append('')
lines.append(f'All {scanned} file(s) in `writings/` conform to '
f'`klappy://canon/meta/frontmatter-schema`.')
else:
lines.append('### Canon Quality — Frontmatter Schema ❌')
lines.append('')
lines.append(f"**{len(findings)} violation(s)** across "
f"`writings/` ({scanned} files scanned). "
f"Mode: **hard-block** (canon mandate, no exceptions).")
lines.append('')
by_file = collections.defaultdict(list)
for f in findings:
by_file[f['location']['path']].append(f)
for path, items in sorted(by_file.items()):
lines.append(f'<details><summary><code>{path}</code> — '
f'{len(items)} finding(s)</summary>')
lines.append('')
lines.append('| Rule | Occurrence | Message |')
lines.append('|---|---|---|')
for it in items:
occ = (it['occurrence'] or '').replace('|', '\\|')
msg = (it['message'] or '').replace('|', '\\|')
lines.append(f"| `{it['rule_id']}` | `{occ}` | {msg} |")
lines.append('')
lines.append('</details>')
lines.append('')
lines.append('> **What to do**')
lines.append('> - Compare the offending file against `canon/meta/frontmatter-schema.md` (the source of truth) and a known-good essay like `writings/reverse-engineer-the-future.md`.')
lines.append('> - For enum violations: pick a value from the allowed set named in the message.')
lines.append('> - For type mismatches: remove the surrounding quotes on booleans and integers.')
lines.append('> - For missing essay-discovery fields: backfill `type`, `slug`, `hook`, `description` from your own opening — do not invent content.')
lines.append('> - There is no allowlist for these violations. They cause the renderer to silently drop the page from the homepage.')
lines.append('')
lines.append('<sub>Validator: `scripts/validate-frontmatter.py` · Canon: `klappy://canon/constraints/frontmatter-validation-before-merge` · Run: [#${{ github.run_number }}](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})</sub>')
open('/tmp/fm-comment.md', 'w').write('\n'.join(lines))
PY

- name: Sticky comment
if: github.event_name == 'pull_request'
uses: marocchino/sticky-pull-request-comment@v2
with:
header: canon-quality-frontmatter
path: /tmp/fm-comment.md

- name: Workflow step summary
if: always()
run: |
{
echo "## Canon Quality — Frontmatter Schema"
echo ""
python3 - <<'PY'
import json
try:
d = json.load(open('/tmp/fm-result.json'))
n = len(d['findings'])
if n == 0:
print(f"- **Status**: ✅ OK")
print(f"- **Files scanned**: {d['scanned']}")
else:
print(f"- **Status**: ❌ FINDINGS")
print(f"- **Files scanned**: {d['scanned']}")
print(f"- **Total findings**: {n}")
from collections import Counter
by_rule = Counter(f['rule_id'] for f in d['findings'])
print(f"- **By rule**: `{dict(by_rule)}`")
except Exception as e:
print(f"- **Result**: validator did not produce output ({e})")
PY
} >> "$GITHUB_STEP_SUMMARY"

- name: Enforcement gate (hard-block)
run: |
python3 - <<'PY'
import json, sys
d = json.load(open('/tmp/fm-result.json'))
if d['findings']:
print(f"::error::Frontmatter validation found {len(d['findings'])} violation(s). "
f"Fix the offending fields per canon/meta/frontmatter-schema.md "
f"or see the PR comment for per-finding guidance.")
sys.exit(1)
print(f"Frontmatter OK ({d['scanned']} file(s) scanned).")
PY

audit:
name: Reference integrity audit
runs-on: ubuntu-latest
Expand Down
40 changes: 32 additions & 8 deletions canon/constraints/frontmatter-validation-before-merge.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,14 +58,38 @@ These specific combinations have caused renderer crashes in production:

## Automation

A Managed Agent can be used for validation. The agent should:
1. Clone the repo and check out the branch
2. Fetch the frontmatter schema via oddkit
3. Read 3-4 working essays for structural comparison
4. Diff the new essay's frontmatter field-by-field
5. Fix issues and push, or report findings

The agent configuration, environment ID, and API credentials are stored in the project instructions.
This constraint is implemented as a CI gate. The implementation is:

- **Validator**: `scripts/validate-frontmatter.py` (lives in this repo).
Mirrors the schema's enums and required-field rules. Single-file Python,
PyYAML, no external dependencies beyond the standard library + pyyaml.
- **Workflow**: `.github/workflows/canon-quality.yml` runs the validator as
the **`frontmatter`** job on every PR and push that touches `writings/**`.
Runs in parallel with the reference-integrity audit (`oddkit_audit`).
- **Enforcement mode**: **hard-block from day one**. The schema is
unambiguous; the renderer's failure mode is silent-drop with no operator
signal; canon mandates this gate "No Exceptions". There is no soft-block
observation cycle. There is no allowlist directive — any finding fails the
job.

The validator emits findings under five rule_ids, each mapped directly to a
"Known Crash Patterns" row above:

| rule_id | Catches |
|---------|---------|
| `frontmatter-missing-block` | File has no `---`-delimited frontmatter at all |
| `frontmatter-parse-error` | Frontmatter block exists but YAML is malformed |
| `frontmatter-missing-required` | One of the eight universal fields, or one of `type` / `slug` / `hook` / `description` on a public essay in writings/, is missing or empty |
| `frontmatter-invalid-enum` | `exposure`, `voice`, `tier`, or `audience` has a value not in the canonical allowed set |
| `frontmatter-type-mismatch` | Quoted boolean (`public: "true"`) or quoted integer (`tier: "3"`) |
| `frontmatter-contradictory` | `public: false` combined with `exposure: public` |

Authoring agents may run the validator locally before pushing
(`python3 scripts/validate-frontmatter.py`); the CI gate is the
authoritative check.

When the validator and `canon/meta/frontmatter-schema.md` disagree, the
schema doc wins and the validator's enum mirror must be updated to match.

---

Expand Down
8 changes: 8 additions & 0 deletions scripts/tests/fixtures/broken-missing-universal.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
title: "Missing universal fields + invalid enums + quoted booleans"
exposure: published
voice: klappy
tier: "3"
public: "true"
---
body
3 changes: 3 additions & 0 deletions scripts/tests/fixtures/broken-no-frontmatter.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
This file has no frontmatter block at all.

Just body content.
12 changes: 12 additions & 0 deletions scripts/tests/fixtures/writings/broken-contradictory.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
uri: klappy://writings/test-broken-2
title: "Contradictory flags + missing essay-critical fields"
audience: public
exposure: public
tier: 1
voice: first_person
stability: stable
tags: ["test"]
public: false
---
body
18 changes: 18 additions & 0 deletions scripts/tests/fixtures/writings/valid.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
---
uri: klappy://writings/test-valid
title: "A valid public essay"
audience: public
exposure: public
tier: 1
voice: first_person
stability: stable
tags: ["test"]
date: 2026-05-10

type: essay
slug: test-valid
hook: "A hook line."
description: "A description."
public: true
---
body
85 changes: 85 additions & 0 deletions scripts/tests/test_validator.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
#!/usr/bin/env python3
"""
Smoke tests for validate-frontmatter.py.

Run from the repo root:
python3 scripts/tests/test_validator.py
"""
import json
import os
import subprocess
import sys
from pathlib import Path

REPO = Path(__file__).resolve().parents[2]
SCRIPT = REPO / "scripts" / "validate-frontmatter.py"
FIXTURES = REPO / "scripts" / "tests" / "fixtures"


def run(*paths) -> dict:
proc = subprocess.run(
[sys.executable, str(SCRIPT), "--json", *paths],
capture_output=True, text=True, cwd=REPO,
)
return json.loads(proc.stdout), proc.returncode


def expect(rule_ids: set[str], findings: list, msg: str) -> None:
actual = {f["rule_id"] for f in findings}
missing = rule_ids - actual
if missing:
print(f"FAIL: {msg}: missing rules {missing}; got {actual}")
sys.exit(1)
print(f" OK: {msg} — {sorted(actual)}")


def main() -> None:
# 1. Valid essay → no findings, exit 0
d, rc = run(str(FIXTURES / "writings" / "valid.md"))
assert rc == 0 and not d["findings"], f"valid case failed: {d}"
print(" OK: valid public essay → 0 findings, exit 0")

# 2. Missing universal + invalid enums + quoted booleans
d, rc = run(str(FIXTURES / "broken-missing-universal.md"))
assert rc == 1, f"expected exit 1, got {rc}"
expect(
{"frontmatter-missing-required",
"frontmatter-invalid-enum",
"frontmatter-type-mismatch"},
d["findings"],
"broken-missing-universal: 3 rule classes fire",
)

# 3. Contradictory flags + essay discovery missing
d, rc = run(str(FIXTURES / "writings" / "broken-contradictory.md"))
assert rc == 1, f"expected exit 1, got {rc}"
expect(
{"frontmatter-contradictory",
"frontmatter-missing-required"},
d["findings"],
"broken-contradictory: contradictory + missing-essay-critical fire",
)

# 4. No frontmatter block at all
d, rc = run(str(FIXTURES / "broken-no-frontmatter.md"))
assert rc == 1, f"expected exit 1, got {rc}"
expect(
{"frontmatter-missing-block"},
d["findings"],
"broken-no-frontmatter: missing-block fires",
)

# 5. Real writings/ directory must be clean (this enforces that we never
# ship the validator with existing breakage)
d, rc = run("writings/")
assert rc == 0, (
f"writings/ failed validation with {len(d['findings'])} finding(s): "
f"{[(f['location']['path'], f['rule_id']) for f in d['findings']]}"
)
print(f" OK: writings/ clean ({d['scanned']} files)")

print("\nAll validator smoke tests passed.")


if __name__ == "__main__":
main()
Loading
Loading