Skip to content

Convention Gates

Policy

Any PR that establishes or expands a project-wide convention (error hierarchies, persistence boundary, mock-spec, regional defaults, typed boundary, settings-to-startup wiring, secret-log redaction, API-DTO extra="forbid", no-magic-numbers, no-em-dashes, etc.) MUST include the AST/script gate that prevents regression. PRs proposing a convention without enforcement are rejected.

The gate's job is to catch the SECOND occurrence of the category; the audit's job is finding the FIRST.

Gate inventory

This table is the single source of truth for every custom scripts/check_*.py gate: the stages it runs at, the tree it scopes to, whether it re-scans its whole scope or only the changed files, whether it is baseline-driven, and the audit verdict. If an entry below disappears or a new check_*.py script lands, update this table in the same PR (the meta-gate check_convention_gate_inventory.py enforces this for the canonical doc set).

Column semantics:

  • Stages: commit+push (pre-commit and pre-push), push (pre-push only), PreToolUse / PostToolUse (Claude Code + OpenCode agent-time hooks, no repo-stage counterpart), CI (runs only in a dedicated CI job). Every commit+push / push gate ALSO runs in CI via the de-conditioned Gates job in ci.yml, which executes pre-commit run --all-files at both the pre-commit and pre-push stages; the exceptions are the SKIP-listed gates that have a dedicated CI job (see CI parity below). Agent-time hooks are excluded from CI parity by design.
  • Scan: full (re-scans its entire scope on every fire, pass_filenames: false; a violation anywhere in scope is caught regardless of which file the commit touched), staged (only the changed files pre-commit passes), affected (the affected-module set computed from the diff). full is the safe default for a correctness gate.
  • Changed-file?: whether the gate's findings are limited to changed files. no for every full-scan gate (the audit's target posture). This is the gate-level analogue of the CI cardinal rule.
  • Baseline: the offender-ledger file, or none for zero-tolerance gates.
  • Verdict: keep (correct as-is), harden (flipped fail-open to fail-closed in this audit), widen (scope widened to the whole tree), add (new gate shipped by this audit).
Gate (scripts/) Stages Scope Scan Changed-file? Baseline Verdict
check_architecture_drift.py push src/synthorg/ full no data/architecture_report.json keep
check_argument_count_suppression.py push whole tree (via ruff) full no argument_count_suppression_baseline.txt add
check_backend_enums_ts_in_sync.py commit+push ws_models.py + notifications/models.py + observability/enums.py + *.gen.ts full no none keep
check_backend_regional_defaults.py PostToolUse backend region/currency edits n/a n/a none harden
check_baseline_growth.py commit+push scripts/*_baseline.{txt,json} staged yes guards baselines keep
check_boundary_typed.py push src/synthorg/ full no none keep
check_ci_workflow_resilience.py push .github/workflows/ + .github/actions/ full no none add
check_comparison_md_in_sync.py push competitors.yaml + comparison.md + generator full no none keep
check_completion_config_temperature.py commit+push src/synthorg/ full no none keep
check_convention_gate_inventory.py push canonical docs + convention_gate_map.yaml full no none keep (meta-gate)
check_cost_scope_purpose.py push src/synthorg/ full no cost_scope_purpose_baseline.txt add
check_credentialed_mcp_governed.py push api/mcp_gateway/tools.py full no none add
check_governed_destructive_tools.py push tools/ full no none add
check_forge_repo_scoped.py push tools/forge/ full no none add
check_chat_inbound_fenced.py push integrations/chat_api/inbound/ full no none add
check_mcp_server_config_pinned.py push tools/mcp/config.py full no none add
check_catalog_credential_fields.py push mcp_catalog/bundled.json full no none add
check_mcp_self_consumer_scoped.py push engine/mcp_self_consumer.py full no none add
check_currency_aggregation_invariant.py push src/synthorg/ full no none keep
check_dead_api_endpoints.py push api/ + web/src/ full no dead_api_endpoints_baseline.txt keep
check_dependency_inversion.py push api/engine/communication/persistence full no none keep
check_doc_drift_counts.py commit+push design/research docs + events/ full no none keep
check_doc_numeric_macros.py push README + public docs + runtime_stats.yaml full no none keep
check_docs_nav_coverage.py push docs/**/*.md + mkdocs.yml nav full no allowlist in gate add
check_docstring_completeness.py push src/ + tests/ (ruff DOC201/202/501) full no none keep
check_domain_error_hierarchy.py push src/synthorg/ full no domain_error_hierarchy_baseline.txt keep
check_dto_types_ts_in_sync.py commit+push api/ + core/ + *.gen.ts full no none keep
check_dual_backend_test_parity.py push persistence protocols + conformance full no dual_backend_parity_baseline.txt keep
check_error_code_uniqueness.py push src/synthorg/**/*.py full no none add
check_error_codes_ts_in_sync.py commit+push error_taxonomy.py + error-codes.gen.ts full no none keep
check_feature_index_freshness.py push src/synthorg/ + data/*.json full no none keep
check_feature_manifest.py push src/synthorg/ full no none keep
check_forbidden_literals.py push src/synthorg/ full no none keep
check_gateway_explicit_binding.py push api/gateway/ full no none add
check_frozen_model_extra_forbid.py push src/synthorg/ + tests/ full no none keep
check_handler_arguments_get.py push meta/mcp/ full no none add
check_image_signatures.py CI (docker.yml) published image digests n/a n/a none keep
check_license_compat.py push pyproject.toml + uv.lock + cli/go.{mod,sum} + web/package-lock.json + NOTICE full no none add
check_list_pagination.py commit+push persistence/ full no list_pagination_baseline.txt keep
check_local_ci_parity.py commit+push .pre-commit-config.yaml + ci.yml full no none add (keystone)
check_logger_exception_str_exc.py commit+push src/synthorg/ staged yes none keep
check_long_running_loops_have_kill_switch.py push src/synthorg/ full no long_running_loops_kill_switch_baseline.txt keep
check_mcp_admin_tool_guardrails.py push meta/mcp/ full no none keep
check_mcp_capability_gap_documented.py push meta/mcp/handlers/ + *state*.py slices + src/synthorg/ construction sites + *_of accessors + manifest full no manifest add
check_mock_spec.py commit+push tests/ staged yes none keep (zero-tolerance)
check_module_depth.py push src/synthorg/ full no _module_depth_baseline.txt keep
check_module_size_budget.py push src/synthorg/ full no _module_size_baseline.json (drained) keep
check_no_api_dto_in_persistence_or_service.py commit+push persistence/ + *_service.py full no none keep
check_no_bare_time_in_business_logic.py commit+push src/synthorg/ full no none keep
check_no_boilerplate_docstrings.py commit+push src/synthorg/ full no none keep
check_no_bulk_edit.py PreToolUse Bash in-place rewrites n/a n/a none keep
check_no_central_junk_drawer.py commit+push core/enums.py full no none keep
check_no_circular_imports.py push src/synthorg/ full no _circular_imports_baseline.txt harden
check_no_client_state_persistence.py commit+push web/src/ outside the auth/CSRF allowlist full no none add
check_no_controller_response_for_domain_errors.py commit+push api/controllers/ full no no_controller_response_for_domain_errors_baseline.txt keep
check_no_em_dashes.py commit+push all text staged yes none harden
check_no_engine_worker_swallow.py push engine/ + workers/ full no none add
check_no_explicit_any_inline_disable.py commit+push src/ + tests/ staged yes none keep
check_no_ghost_wiring.py push src/synthorg/ + manifest full no manifest keep
check_no_growth_in_god_modules.py commit+push god-module allowlist full no allowlist (empty) keep
check_no_hardcoded_model_default.py push settings/definitions/ + src/synthorg/ full no hardcoded_model_default_baseline.txt keep
check_no_implicit_state_attribute.py push api/state.py full no none keep
check_no_loop_bound_init.py commit+push src/synthorg/ full no loop_bound_init_baseline.txt harden
check_no_magic_numbers.py push src/synthorg/ full no no_magic_numbers_baseline.txt keep
check_no_migration_framing.py push src/synthorg/ + tests/ full no none keep
check_no_module_level_io.py push src/synthorg/ full no _module_level_io_baseline.txt harden
check_no_os_environ_outside_bootstrap.py push src/synthorg/ full no none add
check_no_pre_commit_install_in_docs.py commit+push setup docs full no none keep
check_no_provider_auto_pick.py push src/synthorg/ full no none add
check_no_raw_playwright_imports.py push src/synthorg/ full no none keep
check_no_silent_embedder_fallback.py push src/synthorg/ full no none add
check_no_redundant_timeout.py commit+push tests/ staged yes none harden
check_no_release_please_token.py commit+push .github/**/*.yml staged yes none keep
check_no_review_origin_in_code.py push src/synthorg/ + tests/ full no none keep
check_no_ruff100_self_cloak.py commit+push every tracked .py full no none add
check_no_stdlib_logging.py push src/synthorg/ full no none keep
check_no_stubs.py push src/synthorg/ full no none add
check_no_synthorg_any_override.py commit+push pyproject.toml full no none keep
check_openapi_liveness.py CI (ci.yml) exported OpenAPI schema n/a n/a none keep
check_orphan_fixtures.py push tests/ full no none harden
check_output_boundaries_guarded.py push the output-style boundary files full no none add
check_otlp_span_redaction.py commit+push src/synthorg/ staged yes none keep
check_persistence_boundary.py push src/synthorg/ + tests/ full no none keep
check_persistence_protocol_return_types.py push persistence protocols + backends full no none keep
check_pin_golden_fresh.py CI (ci.yml :: pin-drift-regression) live pins vs pin_golden.json full no none add
check_prompt_class_metadata.py push src/synthorg/ full no none add
check_protocol_documented.py push src/synthorg/ full no _protocol_doc_baseline.txt harden
check_provider_complete_chokepoint.py push src/synthorg/ full no none keep
check_runtime_reachability.py push src/synthorg/ + manifest full no manifest keep
check_runtime_stats_freshness.py push (--skip-network); CI (full) runtime_stats.yaml + generator full no none keep
check_schema_drift.py push {sqlite,postgres}/schema.sql + revisions full no schema_drift_baseline.txt keep
check_schema_drift_revisions.py push (sqlite); CI (postgres) schema.sql vs revisions full no none keep
check_setting_restart_required_justified.py push settings/definitions/ full no setting_restart_required_baseline.txt keep
check_setting_to_startup_trace.py push settings/definitions/ + lifecycle full no setting_to_startup_trace_baseline.txt keep
check_settings_namespace_complete.py push settings/ full no _settings_namespace_baseline.txt harden
check_state_slice_immutability.py push src/synthorg/ full no _state_slice_immutability_baseline.txt harden
check_strategy_protocol_injection.py push src/synthorg/ full no _strategy_protocol_injection_baseline.txt harden
check_timeout_interval_default_drift.py commit+push boot-resolver + security defaults full no none harden
check_verified_completion_paths.py push plan/project transitions + src/synthorg/ full no none add
check_web_design_system.py PostToolUse web/src/ edits n/a n/a none harden
check_workflow_shell_git_commits.py commit+push .github/workflows/ staged yes none keep
check_workflow_tag_lifecycle.py commit+push .github/workflows/ full no none keep
check_ws_protocol_version_in_sync.py commit+push ws_models.py + constants.ts full no none keep

PreToolUse-only check_*.py that gate Claude Code / OpenCode tool calls before content lands (no repo-stage counterpart, excluded from CI parity): check_mock_spec_ratchet.py (blocks mock-spec regressions in tests/). See the PreToolUse hooks section below for the full agent-time hook set, including the Bash .sh guards.

(104 total check_*.py scripts: the enforcement gates in the table above, the meta-gate, and the PreToolUse / PostToolUse check_*.py agent-time hooks.)

CI parity

The de-conditioned Gates job in .github/workflows/ci.yml runs uv run pre-commit run --all-files at both the pre-commit and pre-push stages on every PR, so the whole commit+push / push gate set above has a machine-checked CI backstop: a --no-verify push can no longer land a violation CI never catches. check_local_ci_parity.py (the keystone gate of this audit) enforces that every parity-stage hook id either runs in that job or is explicitly accounted for in one of two maps:

  • _COVERED_ELSEWHERE: gates the Gates job SKIPs because a dedicated CI job already runs them with a richer toolchain. mypy to type-check, pytest-unit to test-unit, go-vet / go-test / golangci-lint to cli.yml, web-checks to dashboard-*, lychee to lychee.yml, hadolint-docker to dockerfile-lint, gitleaks to secret-scan, zizmor to zizmor, vale / caddy-validate to their own steps in the Gates job, and the two migration git-state gates (check-single-migration-per-pr, check-no-modify-migration) to schema-validate (the only job with fetch-depth: 0 + the base ref / origin/main those gates need).
  • _LOCAL_ONLY: the one git-state check meaningful only on the pushing developer's clone, never in an ephemeral CI runner: check-push-rebased (branch-freshness; CI checks out a fixed merge SHA where "behind main" is meaningless, and GitHub branch protection's "require branches up to date" is the server-side equivalent).

The same gate also asserts the cardinal rule: no CI correctness job (in ci.yml or cli.yml) may be conditioned on a changed-file filter. Path scoping survives only on pure build/perf jobs (codspeed, lighthouse, docker build, dashboard-build, cli-build / cli-bench / cli-fuzz), each carrying an explicit justification comment; a dorny/paths-filter race on a shallow checkout must never be able to silently drop a correctness gate.

Whole-tree lint / type

ruff check and ruff format scope to the whole tree (.), and mypy extends across src/, tests/, evals/, docker/, d2_fence.py, and scripts/ (the scripts/ flat-dir dual-name clash is resolved with a second invocation under MYPYPATH=. --explicit-package-bases). The [tool.ruff.lint.per-file-ignores] DOC / INP exemptions are tests/, scripts/, evals/, and docker/, mirrored consistently. These run as the shared ruff / mypy hooks (table above) and in CI.

Scope notes

Most gates scan src/synthorg/ only. Those that walk additional trees encode every such tree in their files: regex (a PR that adds a violation only in an unlisted tree would otherwise bypass the gate). The notable multi-tree gates:

  • check_frozen_model_extra_forbid.py: src/synthorg/ AND tests/. The project-wide extra="forbid" rule applies equally to test fixtures, so the gate walks both trees in a single pass. The same gate also enforces allow_inf_nan=False on every frozen model, but scoped to src/synthorg/ only (test fixtures are exempt from the inf/nan assertion). The extra check auto-exempts @computed_field-only models; the allow_inf_nan check does not. Per-line opt-outs: # lint-allow: frozen-extra-forbid -- <reason> and # lint-allow: frozen-allow-inf-nan -- <reason>.
  • check_persistence_boundary.py, check_no_review_origin_in_code.py, check_no_migration_framing.py, check_docstring_completeness.py: src/synthorg/ AND tests/.
  • check_dead_api_endpoints.py: src/synthorg/api/ AND web/src/ (frontend / backend route parity).
  • check_argument_count_suppression.py: the whole tree, enumerated with git ls-files and parsed directly. Deliberately NOT scoped to what ruff walks, since pruning that walk is one of the bypasses it exists to close.

PreToolUse hooks (Claude Code + OpenCode)

Some conventions are also enforced before the file lands on disk so the offending content never reaches the diff. Bash scripts under scripts/ registered in .claude/settings.json and .opencode/plugins/synthorg-hooks.ts.

The list below covers the convention-enforcing hooks. The Bash matcher additionally carries a set of workflow / push-state guards that enforce process rather than code conventions, and so are documented in claude-reference.md instead: check_no_repush_after_failure.sh, check_push_rebased.sh, check_push_throttle.sh, check_ci_before_push.sh, check_no_throttle_override_creation.sh, check_bash_no_write.sh, check_git_c_cwd.sh, check_no_git_no_verify.sh, and check_no_cmd_pager_pipe.sh. The OpenCode plugin's own header comment enumerates every registered hook across both groups, and is the shortest complete inventory.

  • check_no_edit_baseline.sh: blocks Edit / Write on tests/baselines/*.json, scripts/*_baseline.{txt,json}, and scripts/_*_baseline.py.
  • check_no_baseline_update.sh: blocks Bash invocations of scripts/check_*.py --update-baseline / --update / --refresh-baseline.
  • check_no_em_dashes_hook.sh: blocks Edit / Write whose candidate content contains a U+2014 em-dash or one of its HTML entities. Mirrors the diff-time check_no_em_dashes.py pre-commit gate.
  • check_no_client_state_persistence_hook.sh: blocks Edit / Write to web/src/ (outside the auth/CSRF allowlist) whose candidate content introduces localStorage / sessionStorage / indexedDB access or a zustand persist( import. Mirrors the diff-time check_no_client_state_persistence.py pre-push gate.
  • check_no_edit_migration.sh: blocks Edit / Write on src/synthorg/persistence/{sqlite,postgres}/revisions/*.sql (revisions are immutable once committed; author a new revision file with your delta instead).
  • check_pre_pr_review_triage_gate.sh: blocks Edit / Write outside _audit/ while a /pre-pr-review triage table is pending user approval.
  • check_mock_spec_ratchet.py: blocks Edit / Write to tests/*.py that would raise the mock-spec gate's CATCH count for the touched file, and blocks Edit / Write to scripts/check_mock_spec.py that would remove _Verdict.CATCH branches. Drives drive-by tightening: every edit reduces or holds the residual.
  • check_no_pr_create.sh: blocks Bash gh pr create (use /pre-pr-review).
  • check_no_cd_prefix.sh: blocks a Bash command that starts with cd followed by a space (poisons the tool cwd); bash -c "cd <dir> && ..." and native -C / --prefix / --project are allowed.
  • check_no_local_coverage.sh: blocks Bash pytest --cov / coverage run (coverage is a CI-only concern).
  • check_enforce_parallel_tests.sh: blocks Bash pytest with any explicit -n / --numprocesses (pyproject addopts pins -n=8 --dist=loadfile; omit it) and blocks xdist-disable (-n0 / --dist no / -p no:xdist) unless the run targets a single path::test node id; benchmarks / --codspeed exempt.
  • check_no_bulk_edit.py: blocks only shell in-place bulk rewrites (sed -i, perl -pi, redirect-overwrite of a tracked source file). The native Edit (incl. replace_all) and Write tools are intentionally not blocked: they surface a reviewable atomic diff.
  • check_no_audit_scratch_scripts.sh: blocks Edit / Write of a *.py / *.sh file at the project root or directly under scripts/ while the _audit/.audit-run-active marker exists (the /codebase-audit skill creates it in Phase 0 and removes it in Phase 7). Stops audit subagents leaking scratch helper scripts that pollute the diagnostic stream. Scoped, not blanket: inert whenever no audit run is active, so ordinary development is never affected, and a marker older than 12h (left by a crashed run) is auto-ignored and removed. Unlike the other gates here it fails open on a parse error, since it is narrow defence-in-depth (the skill's Phase 7 sweep is the backstop).

PostToolUse hooks (Claude Code + OpenCode)

Five PostToolUse hooks run after a tool call completes, in two groups.

Three validate the file an Edit / Write just produced: check_web_design_system.py (web design-token compliance on web/src/ edits), check_backend_regional_defaults.py (region / currency neutrality on backend edits), and run_edit_time_gates.py (see below).

Two react to a completed Bash command and validate nothing: record_push_throttle.sh (records a successful git push for the throttle window owned by check_push_throttle.sh) and rewarm_mypy_after_sync.sh (see below). Both are housekeeping rather than gates.

All five are agent-time only and excluded from CI parity, for a mechanical reason rather than an explicit exemption: none is registered in .pre-commit-config.yaml, so check_local_ci_parity.py never enumerates them. That gate's own docstring spells this out only for the PreToolUse case.

Edit-time gate dispatcher

scripts/run_edit_time_gates.py is a PostToolUse dispatcher, not a gate: it adds no rule of its own and registers nothing in convention_gate_map.yaml. It routes the file an agent just wrote to the gates whose verdict for that file is decidable from the file alone, so a violation that would otherwise surface minutes later at push time (on the push budget, leaving a <hook>-FAILED marker) surfaces while the change is still in hand.

The routed set is deliberately small. The disqualifying property is needing the whole tree to compute an answer at all: an import graph, endpoint parity, dual-backend test pairing, or a suppression population derived by diffing two whole-tree lint passes (check_argument_count_suppression.py). Merely reading a baseline does not disqualify a gate, which is why the module-size and magic-number gates are routed: their baselines are static, already-committed per-path lookups, so a single-file scan gives that file the same verdict the whole-tree run would.

Gate Roots Suffixes
check_no_stubs.py src/synthorg/ .py
check_frozen_model_extra_forbid.py src/synthorg/, tests/ .py
check_no_magic_numbers.py src/synthorg/ .py
check_module_size_budget.py src/synthorg/ .py
check_no_review_origin_in_code.py src/synthorg/, tests/ .py, .sql

Each gate re-filters the path itself, and says so on stderr when it is handed a set of paths none of which it wants, so a dispatcher routing table that has drifted out of step with a gate's own scan root surfaces as a diagnostic rather than as a clean scan. test_dispatcher_roots_match_gate_scan_roots pins the two tables together so the drift fails CI instead.

The four gates that had no file-scoped entry point gained a --files flag for this, all four delegating to the shared scripts/_gate_scope.py helper so the path-resolution, suffix-filter, containment-check and duplicate-removal mechanics cannot diverge between them; check_no_review_origin_in_code.py already accepted positional paths for its pre-commit mode. --files is refused alongside --update / --update-baseline, since a baseline written from a partial scan would drop every entry the scan did not visit. The pre-push invocations are unchanged and still scan in full: the flag narrows the agent-time loop only, and the whole-tree run remains the authority.

One deliberate asymmetry: check_no_magic_numbers.py's whole-tree walk enumerates via git ls-files and so sees only tracked files, while --files accepts any file that exists. A new module can therefore be flagged at edit time before it has been staged. That changes when a violation surfaces, never the eventual push verdict.

Post-sync mypy re-warm (housekeeping, not a gate)

scripts/rewarm_mypy_after_sync.sh is a PostToolUse hook on Bash. A uv sync rewrites site-packages, which invalidates the resident dmypy graph without stopping the daemon; the next check then pays a full cold rebuild (124s against 1.4s warm), and when that check is the pre-push hook a third of the 300s budget is gone before a gate has run.

The hook detaches run_affected_mypy.py --rewarm so the rebuild happens off the push path, logging to synthorg-hooks/mypy-rewarm-last.log alongside the git-hook logs. --rewarm refuses unless the main daemon is already resident: it restores a warm state that existed and never creates a new one. That guard is what makes the hook safe to run everywhere. The main daemon holds ~2.5GB (the separate scripts/ daemon, which --rewarm never touches, costs roughly half that again), which is why the worktree helper deliberately does not warm at creation, and why this is a post-sync hook rather than a SessionStart one: a session-start warm would fire in every worktree a session opens and exhaust the memory a machine running several of them has spare. Only uv sync / uv add / uv remove match; uv run (much the most common invocation) does not.

Because the rebuild is detached, nothing reads its exit code and nothing reads its log unless told to. A failed rebuild therefore drops a mypy-rewarm-FAILED marker that the next ordinary run_affected_mypy.py run reports once and clears. That is the same idea as the pre-push <hook>-FAILED marker, scaled down to a warning rather than a block: a stale graph costs time, never correctness, so it must not stop anyone working. A lock file (mypy-rewarm.pid) keeps two syncs in quick succession from detaching two rebuilds that would queue against the same single-threaded daemon and interleave into the same log.

Under Claude Code the payload carries tool_response, so a failed uv sync correctly skips the re-warm. The OpenCode plugin's runHookScript sends only tool_input, so there the success check sees no signal and the re-warm runs regardless: the harmless direction, since the cost is one wasted background rebuild and only when a daemon is already resident.

The hook layer is fail-closed: the OpenCode plugin treats hook execution errors as denials, so a misbehaving hook script blocks the action rather than letting it through.

SessionEnd hook (housekeeping, not a gate)

One SessionEnd hook in .claude/settings.json runs scripts/run_affected_mypy.py --stop, which enforces nothing and blocks nothing: it releases the worktree's mypy daemons when a session ends cleanly (for why a stray daemon matters, see _DAEMON_IDLE_TIMEOUT_SECONDS in scripts/run_affected_mypy.py). The hook cannot cover a session that is killed rather than exited, so it is the fast path only; the daemon's own two-hour idle timeout is what guarantees an orphan eventually goes away regardless.

--stop stops the daemons concurrently and escalates to dmypy kill when a graceful stop stalls. Both matter at session end: dmypy is single-threaded, so a stop queues behind an in-flight --rewarm rebuild and times out, and sequential stops would cost two full timeout windows back to back, landing on the hook's own ceiling. A daemon that survives session end keeps holding a handle on this worktree's interpreter, which is what makes a later git worktree remove fail with an error that reads nothing like its cause.

Third-party prose / formatting hooks

Three third-party linters run as pre-push hooks on Markdown to enforce style + link integrity without needing custom check_*.py scripts. They are listed here for completeness alongside the custom gates above:

  • markdownlint (igorshubovych/markdownlint-cli): Markdown formatting rules (list indent, heading levels, fenced-code language tags, blanks-around-lists). Config in .markdownlint.json; version pinned in .pre-commit-config.yaml. Runs on README + every CLAUDE.md tier + docs/**/*.md at every installed stage (no explicit stages:), so docs are linted at commit time AND on every push.
  • lychee (lycheeverse/lychee): Markdown link-checker. Config in lychee.toml. Runs on the same glob as markdownlint, at pre-push stage and as the .github/workflows/lychee.yml PR/push gate, both --offline: internal links only (relative + file://), so a third-party host's downtime or expired certificate can never block a push or a merge. External (remote) links are checked weekly by .github/workflows/lychee-external.yml, which files a tracking issue via the post-tracking-issue composite action instead of blocking. Binary installed via bash scripts/install_cli_tools.sh lychee.
  • vale (errata-ai/vale): prose linter for Google style + a British-English vocabulary. Config in .vale.ini; vocabularies under .vale/styles/config/vocabularies/{British,SynthOrg}/. Runs on the same glob as markdownlint + lychee, at pre-push stage, and as a dedicated step in the ci.yml Gates job. Binary installed once per machine via bash scripts/install_cli_tools.sh vale; the gitignored .vale/styles/Google/ style package is then materialised lazily by scripts/vale-prepush.sh (the pre-push wrapper) on the first push in each worktree, so additional worktrees need no extra setup step.

Ruff-enforced docstring completeness (DOC201 / DOC202 / DOC501)

The docstring-completeness convention (Google-style Returns: / Raises: sections must match the code) ships its enforcement gate as scripts/check_docstring_completeness.py, satisfying the Convention Rollout rule. The script is a thin wrapper: ruff's pydoclint extensions remain the engine, and the wrapper runs exactly the three DOC rules so it inherits the same per-file-ignores scope as the standard ruff check and cannot drift from it.

  • Gate: scripts/check_docstring_completeness.py. Runs ruff check --select DOC201,DOC202,DOC501 over src/ + tests/; wired at the pre-push stage in .pre-commit-config.yaml (id: docstring-completeness). The shared ruff check at pre-commit / pre-push / CI also enforces the same rules via extend-select, so the convention fails fast at every stage.
  • Rules: DOC201 (missing Returns:), DOC202 (extraneous Returns:), DOC501 (missing Raises:).
  • Properties: DOC201 exempts @property / @cached_property accessors (it never demands a Returns: on them), and DOC202 only fires on functions that return None, so neither rule flags a Returns: re-added to a value-returning property. Removing a pure type-restatement Returns: from a property is therefore lint-permitted, not lint-enforced; keep property docstrings to the one-line summary (return value is documented by the type annotation).
  • Activation: these are ruff preview rules. Under [tool.ruff.lint] preview = true + explicit-preview-rules = true, a preview rule activates only when selected by its exact code, so the codes live in extend-select (selecting the DOC prefix in select is inert under that flag). The standard ruff check . then enforces them at pre-commit, pre-push, and CI.
  • Scope: DOC201 / DOC202 / DOC501 are enforced across all of src/synthorg/. The only [tool.ruff.lint.per-file-ignores] DOC exemptions are tests/, scripts/, and evals/.
  • Per-line opt-out: a genuine false positive (e.g. an exception raised then caught within the same function, which ruff still reports) is suppressed with # noqa: DOC501 -- <reason> on the docstring's closing """ line; the reason is mandatory.
  • Presence vs completeness: interrogate (configured in [tool.interrogate], fail-under = 95) covers docstring presence; the DOC rules cover section completeness. The two are complementary.

Domain-error-hierarchy gate

scripts/check_domain_error_hierarchy.py enforces the rule at pre-push and in CI: every class definition under src/synthorg/ whose direct base is one of Exception / RuntimeError / LookupError / PermissionError / ValueError / TypeError / KeyError / IndexError / AttributeError / OSError / IOError is a violation unless the class itself reaches DomainError via another base.

Only the root of a stdlib-rooted chain is flagged; migrating the root to DomainError automatically corrects every descendant.

Per-line opt-out:

class TsaError(Exception):  # lint-allow: domain-error-hierarchy -- RFC 3161 internals; observability stays stdlib-rooted
    ...

The justification after -- is mandatory and must be non-empty. The gate also accepts a frozen baseline file (scripts/domain_error_hierarchy_baseline.txt) listing violations a rollout has not yet reached. The baseline shrinks monotonically: any entry that no longer maps to a real violation is reported as drift, so the file cannot harbour stale rows.

Argument-count-suppression gate

scripts/check_argument_count_suppression.py is the one gate in this inventory with no per-line opt-out, and that is the point of it. [tool.ruff.lint.pylint] max-args only means something when the set of functions allowed to exceed it is finite and shrinking; left to # noqa: PLR0913 alone the marker is freely addable, so a cap suppressed hundreds of times reports nothing and prevents nothing.

The population is derived from the AST of every tracked *.py (scripts/_argument_count_sites.py), not from what ruff reports. Treating the ruff diagnostic set as the whole population fails in two directions: ruff exempts a method decorated with @typing.override from PLR0913 syntactically, with no base class required and no type inference involved, and it never visits a file pruned by [tool.ruff] exclude / extend-exclude or by any .gitignore pattern. Either way an over-cap function produces no diagnostic, which an over-trusting gate reads as clean. That is not hypothetical: three such methods existed in this tree when the gate was written, one of them taking thirteen arguments, and none appeared in the first baseline drawn from ruff output alone.

So ruff classifies and the gate decides scope. Two ruff passes run, one with every suppression and pruning mechanism neutralised and one plain, and each AST candidate is placed against them: reported plainly is UNSUPPRESSED, a file-level blanket is BLANKET, a marker naming the rule on the reported line is PER_LINE, and a candidate neither pass mentions is RULE_EXEMPT. The parameter count mirrors PLR0913 exactly, validated whole-tree against ruff with zero divergence in either direction beyond the decorator exemptions.

Five invariants hold:

  1. max-args stays at or below 8; lowering it is a tightening and always allowed, and discovery then runs against the lower number so the gate looks for exactly what ruff enforces. max-positional-args stays pinned at exactly 5, neither raised nor lowered, because ruff defaults it to whatever max-args is: an implicit positional cap silently widens whenever the other one does.
  2. Neither PLR0913 nor PLR0917 is disabled tree-wide through lint.ignore or lint.extend-ignore. Prefixes count, so "PL" is rejected exactly like the full code. A per-file-ignores entry is NOT rejected: discovery sees the function whatever the config says, so a path-glob exemption changes only how a site is classified, and the function still needs its baseline row. That is what lets the framework-shaped Litestar route handlers and pytest fixture graphs keep their PLR0917 exemptions while staying on the ledger.
  3. The effective configuration stays where the gate reads it. A [tool.ruff] extend key, or any ruff.toml / .ruff.toml / pyproject.toml below the repository root, is rejected: both relocate settings the gate would otherwise never see.
  4. Every function over either cap appears in scripts/argument_count_suppression_baseline.txt, keyed path::qualname::arity. The qualified name rather than a line number keeps a long-lived list stable, since a path:lineno:col key would go stale on any unrelated edit above a marker. The arity is part of the identity because a name alone is not one: without it, deleting a baselined function and writing an unrelated one under the same name inherits the old approval, and an approved function can grow from six parameters to sixty with no baseline diff at all. Two candidates minting the same key is itself rejected, so one entry can never authorise two functions.
  5. A file-level # ruff: noqa covering either rule is never legal, and cannot be baselined.

Adding an entry therefore means regenerating the baseline, which check_baseline_growth.py blocks at commit time without an ALLOW_BASELINE_GROWTH=1 approval. A stale entry is reported as drift rather than tolerated: an entry outliving its function would silently pre-authorise a future suppression reusing the same identity. A scan that could not be trusted never writes: ruff emits at least [] whenever it actually ran, so blank output means it did not run, and --update refuses to overwrite a good baseline on the strength of a scan that failed.

Registration procedure

  1. Wire each new gate so it runs locally and in CI. A commit+push Python gate that must also run at pre-commit gets its own .pre-commit-config.yaml hook entry. A push-only Python gate is instead appended to the _GATES tuple in scripts/run_prepush_python_gates.py: the single consolidated-python-gates pre-push hook runs every entry, fanned across a bounded reused-worker pool (the per-gate failure reporting and exit codes are preserved), so adding a separate per-gate pre-push hook is redundant. check_local_ci_parity.py verifies the consolidated hook, not the individual push-only gate ids.
  2. Per-line opt-outs use a stable # lint-allow: <gate-name> -- <reason> comment; the reason is mandatory non-empty.
  3. Add a corresponding entry in the machine-readable inventory at scripts/convention_gate_map.yaml.
  4. Add a row to the gate-inventory table above and bump the <!--RS:convention_gates--> count macro.

Meta-gate

scripts/check_convention_gate_inventory.py enforces that every MANDATORY paragraph in the canonical doc set has either a registered gate or an explicit exempt: { reason } entry in scripts/convention_gate_map.yaml. Adding a new MANDATORY without updating the YAML fails pre-push.

See conventions.md ยง17 for the registration procedure detail.