# Render-lint — Pages publish gate for markdown / YAML / JSON

GEODE publishes `docs/petri-bundle/` and links into `docs/audits/` via
`.github/workflows/pages.yml`. The petri-bundle README is served verbatim
by GitHub Pages; the audit docs are linked from the bundle and rendered
by the Next.js `site/` static export. Two surfaces, two markdown
renderers — and both are unforgiving about table column drift, blank-line
discipline around lists, and accidental list starts.

Render-lint is the CI gate that catches those before they ship.

## Scope

The gate covers only what gets published or linked from the publish.
The exact allowlist lives in [`scripts/lint_pages_markdown.sh`](../../scripts/lint_pages_markdown.sh)
so pre-commit and CI lint the same set; add new audit docs to that file
when they need gating.

Files currently gated:

- `docs/petri-bundle/README.md` — Pages landing page. Served verbatim;
  any malformed table renders as raw pipes.
- `docs/audits/2026-05-12-petri-geode-audit-v3.md`,
  `2026-05-12-petri-insights.md`,
  `2026-05-12-petri-multi-model-partial.md` — the three caveat docs
  PR #2 edited. The README cross-links to all three.
- `docs/architecture/render-lint.md` — this doc. Self-host the rules.
- `plugins/petri_audit/judge_dims/*.yaml` — loaded at runtime by
  `judge_dimensions(...)`. A typo here breaks the audit pipeline, not
  Pages, but the same publish path now triggers when these change.
- `docs/petri-bundle/**/*.json` — `listing.json` is the inspect_ai
  viewer's index file. Trailing commas or bad encoding break the
  static-site viewer load.

**Legacy carve-out:** audit reports authored before the gate landed
(everything under `docs/audits/` other than the four caveat files
above) have pre-existing MD032 / MD040 / MD031 violations. Linting
them would force a content rewrite of historical reports — not the
goal. New audit docs should be added to the allowlist when they land.

Out of scope:

- `docs/petri-bundle/index.html` and `docs/petri-bundle/assets/` — auto-
  generated by `inspect view bundle`. Linting upstream output would
  fight the generator.
- `.eval` archives — zstd-compressed binary.
- Other markdown under `docs/` — not on the Pages path. Existing
  `docs/audits/*.md` violations from older reports are deliberately
  left alone; the gate enforces no regression on the four caveat docs
  PR #2 touched and on any audit added going forward.

## Toolchain

All Python. No node dependency, no new toolchain in CI.

| Surface | Tool | Version | Local | CI |
|---|---|---|---|---|
| Markdown | `pymarkdownlnt` | `0.9.37` | pre-commit | `pages.yml` lint job |
| YAML | `yamllint` | `1.38.0` | pre-commit | `pages.yml` lint job |
| JSON | `python -m json.tool` | stdlib | `check-json` hook | `pages.yml` lint job |

Versions are pinned in three places — `pyproject.toml` dev-deps,
`.pre-commit-config.yaml`, and the install step in `pages.yml`. The
`tests/test_render_lint_config.py` ratchet asserts the config files
exist and parse so a silent regression on the gate itself shows up as
a test failure, not a green CI hiding a broken publish.

## Markdown rules

Config: [.pymarkdown.json](../../.pymarkdown.json).

**On:**

| Rule | Why it stays on |
|---|---|
| MD001 heading-increment | Predictable TOC rendering. |
| MD003 heading-style | Enforce ATX `#` style; setext breaks GFM tables. |
| MD009 no-trailing-spaces | Two trailing spaces silently become `<br>`. |
| MD010 no-hard-tabs | Tabs render unpredictably in tables. |
| MD022 blanks-around-headings | Missing blanks cause headings to merge into paragraphs in strict CommonMark. |
| MD028 no-blanks-blockquote | Adjacent blockquotes separated by a blank line render as one block in some viewers — we explicitly bridge with `>` blank lines. |
| MD031 blanks-around-fences | Same render-merge risk as MD022. |
| MD032 blanks-around-lists | Same render-merge risk; also stops accidental "label: list" merges from rendering as flat prose. |
| MD040 fenced-code-language | Bundle highlighter picks grammar from this. |
| MD055 table-pipe-style | Caveat tables use leading/trailing pipes; enforce so the `---:` right-align markers parse. |
| MD056 table-column-count | The most common Pages render breakage in this repo. |
| MD058 blanks-around-tables | Without blanks, GFM tables silently become flat text. |

**Off (with rationale):**

| Rule | Why it stays off |
|---|---|
| MD013 line-length | Korean engineer-essay tone runs long; an 80-col hard limit forces content rewrites. |
| MD024 no-duplicate-heading | Caveat retrospectives legitimately reuse section names like "### 본 root cause 의 actual" across A1/A2/A3. |
| MD033 no-inline-html | We use `<sub>` / `<kbd>` / `<br>` in tables for layout the renderer cannot express. |
| MD041 first-line-h1 | Some audit docs put footnotes (`[^credit]`) above the visible H1 by GFM convention. |
| MD007 ul-indent | 2-space and 4-space indents both render correctly; pyMarkdown's strict 2-space preference is cosmetic. |
| MD026 trailing-punctuation | Korean section headers end in colons / dots / "—" as a stylistic choice. |
| MD034 no-bare-urls | Inline bare URLs are common in audit cross-references; auto-linking is fine. |
| MD036 emphasis-as-heading | Audit summaries occasionally use bold-as-pseudo-heading mid-paragraph. |

## YAML rules

Config: [.yamllint.yaml](../../.yamllint.yaml).

**Off:** `line-length`, `document-start`, `comments-indentation`. The
judge-dim YAML files are intentionally bare lists; a `---` prefix would
be ceremony for no parse benefit.

**On:** `trailing-spaces`, `truthy` (warning, no key-coercion), 2-space
`indentation`, `comments` (require leading space).

## JSON rules

Pure parse check via `python -m json.tool`. We have no schema for
`listing.json` (inspect_ai owns the format), but a parse failure is
sufficient to fail-fast the Pages publish.

## Wiring

- **Local**: `uv run pre-commit run --all-files`. The hooks scoped via
  `files:` regex only trigger on files in scope, so unrelated commits
  do not pay the lint cost.
- **CI (pre-deploy)**: `pages.yml` runs the `lint` job first, with
  `build` declaring `needs: [lint]`. The deploy job already depends
  on `build`, so the chain is `lint → build → deploy`.
- **Ratchet**: `tests/test_render_lint_config.py` asserts the configs
  exist, parse, and wire into pre-commit + pages.yml. Runs in the
  existing test suite — no separate test job.

## When the gate trips

1. **Read the violation line** — pymarkdown / yamllint print exact
   `path:line:col: RULE: human reason`.
2. **Fix the structure, not the content** — the rules are scoped to
   render-breakage. If a rule conflicts with how a doc is written,
   open a PR to relax the rule (and document the rationale here),
   do not reword paragraphs.
3. **Re-run locally** — `uvx --from pymarkdownlnt pymarkdown --config
   .pymarkdown.json scan <file>` or
   `uvx --from yamllint yamllint -c .yamllint.yaml <file>`.

## Related

- PR #2 — added the caveats in `docs/audits/2026-05-12-petri-*.md` and
  `docs/petri-bundle/README.md` that motivated this gate.
- `.github/workflows/pages.yml` — the workflow this gate lives in.
- `tests/test_render_lint_config.py` — the integrity ratchet.
