Skip to content

Code Shape Ceilings

Category: Quality Attributes · Areas: all

Description

Category

quality-attribute

Areas

all

Boundary

This concern is the shape gate: it bounds how large and how branchy a function, class, and file may get, so god objects and god functions cannot form. It is mechanical (a linter or script fails), global (one ceiling, no per-file exceptions), and runs while the agent edits, not only in CI.

It composes with its neighbors and does not duplicate them:

  • scope-discipline owns is the change the right size (no gold-plating, no hollow stubs). This concern owns is the code the right shape. A change can build exactly what was asked and still pile it into one 3,000-line file.
  • verification owns observed evidence that the system runs; testing owns test strategy and the coverage floor. Neither bounds code shape.
  • python-uv, typescript-bun, and react-vite own the toolchain (ruff, pyright, Biome, ESLint). This concern owns the rule set and the strict default values for those tools (see practices.md).
  • architecture-style fillers (onion, hexagonal, clean, classic-layered) own where code lives between layers; this concern owns how big any one unit may be inside a layer.
  • workflows/ratchets.md owns the ratchet pattern; this concern is a ratchet whose floor is a ceiling.

Components

  • Complexity ceilings: cyclomatic complexity, branches, returns, statements per function, set in the linter config
  • Class ceiling: a cap on public methods per class
  • File-length ceiling: one script, with a separate cap for test files
  • Ceilings-only-go-down check: a script that compares the working tree with the merge base and fails a diff that raises a ceiling or adds a silencer for one of these rules
  • Editing-time hooks: a Claude Code PostToolUse hook that lints the edited file and checks file length, and a Stop hook that runs the full lint gate before a turn ends
  • Git pre-commit hook: runs the same lint gate as CI

Constraints

Greenfield starts strict

  • A new project sets the strict defaults in practices.md for its stack from the first commit. There is no baseline to ratchet from, so no value is looser than the default.
  • An existing project that adopts this concern sets each ceiling to the worst value in its tree today and lowers it as code is split. The adoption is recorded in concerns.md with the starting values.

Global, no exceptions

  • Ceilings apply to the whole tree. No per-file ignore, noqa, eslint-disable, biome-ignore, or # pyright: ignore for a complexity or size rule. When a unit trips a ceiling, split it along responsibility boundaries.
  • Review parameter counts for cohesion; they are not a default blocking ceiling. Evolve an existing operation and update callers rather than adding a wrapper, parameter object or similar method solely to satisfy an argument limit.
  • Generated code, vendored code, and lockfiles are excluded by path in the gate script, once, with a comment saying why. Hand-written code is never excluded.

Ceilings only go down

  • The ceiling-only-goes-down rule is owned by workflows/ratchets.md; here a raised ceiling or new silencer fails the check, and the fix is to split the code.
  • Lowering a ceiling is always allowed and is encouraged whenever a split brings the worst unit in the tree under the next lower value.

Gates run while the agent edits

  • The shape gate is wired into the agent loop (post-edit lint, stop-time full lint) and the git pre-commit hook, and CI runs the same recipe. A gate that only runs in CI is found after the god object exists.
  • One recipe (just lint or the stack’s equivalent) is the single entry point for hooks and CI, so they cannot drift apart.

Where new code goes

  • Where a new integration lives is owned by modularity-and-encapsulation. A class that accumulates unrelated responsibilities is split by responsibility, not by line count alone.

Drift Signals (anti-patterns to reject in review)

  • A linter config with no complexity or size rules selected (ruff without C901/PLR, ESLint without complexity and max-lines-per-function) → add them at the strict defaults
  • A ceiling raised, or a noqa / eslint-disable / biome-ignore for a complexity or size rule → split the code; revert the change
  • A per-file ignore list, or a type-checker ignore list of modules → the excuse list is the god-object list; remove entries by splitting the files
  • A source file past the file ceiling, or one class that owns persistence, business rules, and an external client together → split by responsibility
  • harness.py, utils.py, helpers.ts, manager, service, or index files growing every sprint → a god module; name modules for what they do
  • Lint runs only in CI → wire the editing-time and pre-commit hooks
  • A lint command that exists but is missing from just lint or from the hooks → the gate and the hook must run the same recipe

When to use

Every project with hand-written source code, from the first commit. High autonomy auto-selects this concern for buildable products, alongside scope-discipline and verification (see workflows/references/concern-resolution.md). Library, docs-only, and non-buildable work with no source code may omit it.

Artifact Impact

Apply these responsibilities in relevant existing artifacts or the acceptance record. Concern selection alone does not require a new process document:

  • TD: the ceiling values per stack, the file-size caps, the gate recipe, and the hook wiring
  • IMPLEMENTATION_PLAN: scaffold the lint config, the size and ceilings scripts, the hooks, and just lint before feature work
  • TEST_PLAN: the shape gate (just lint) runs in CI and fails the build; a raised ceiling or new silencer is a blocking finding
  • concerns.md: the adoption baseline for an existing project (starting values)

Practices by activity

Agents working in any of these activities inherit the practices below through runtime work context, such as a DDx bead context digest.

These practices bound function, class, and file size so god objects cannot form. They sit beside scope-discipline (right-sized change) and name the toolchain only through the selected stack concern’s linter.

Requirements (Frame activity)

  • Select this concern for every project with hand-written source code.
  • Record in concerns.md whether the project is greenfield (strict defaults) or adopting (starting values measured from the tree).

Design

  • Record the ceiling values, the file-size caps, and the excluded paths (with a reason each) in the TD.
  • Design modules by responsibility; integration ownership follows modularity-and-encapsulation.

Implementation

Scaffold the gate before feature work: the lint config, the two scripts, one lint recipe, the Claude hooks, and the git hook. Then write features.

Strict defaults for greenfield

RulePython (ruff)TypeScript (ESLint)TypeScript (Biome, typescript-bun)
Complexity per functionmccabe.max-complexity = 10complexity: 10noExcessiveCognitiveComplexity at 15
Function lengthpylint.max-statements = 40max-lines-per-function: 60 (skip blanks, comments)none; file cap only
Parametersreview cohesionreview cohesionreview cohesion
Returns / branchesmax-returns = 6, max-branches = 12max-depth: 4none; review
Public methods per classpylint.max-public-methods = 15 (needs PLR0904, a preview rule)max-classes-per-file: 1none; review
File lengthSOURCE_MAX = 500, TEST_MAX = 800same scriptsame script

Python also selects C901 and PLR (ignore PLR2004, PLR0913 and PLR0917), sets preview = true with explicit-preview-rules = true and extend-select = ["PLR0904"], and runs pyright with strict on application code. TypeScript also sets strict: true and noUncheckedIndexedAccess: true, and enables @typescript-eslint/strict, no-floating-promises, and no-misused-promises. Under typescript-bun, enable the Biome rule in biome.json; Biome has no function-length, parameter, or depth rule, so those fall to the file cap and review.

Adopting projects replace each number with the worst value in the tree today, then lower it.

Python config

[tool.ruff.lint]
select = ["E", "W", "F", "I", "B", "UP", "S", "SIM", "RUF", "PT", "C901", "PLR"]
preview = true
explicit-preview-rules = true
extend-select = ["PLR0904"]
ignore = ["PLR2004", "PLR0913", "PLR0917"]

# Global ceilings. No per-file exceptions; lower them, never raise them.
[tool.ruff.lint.mccabe]
max-complexity = 10

[tool.ruff.lint.pylint]
max-returns = 6
max-branches = 12
max-statements = 40
max-public-methods = 15

[tool.pyright]
typeCheckingMode = "standard"
strict = ["src"]

ESLint config (flat)

{
  // Global ceilings. No per-file exceptions; lower them, never raise them.
  files: ["**/*.{ts,tsx}"],
  rules: {
    complexity: ["error", 10],
    "max-lines-per-function": ["error", { max: 60, skipBlankLines: true, skipComments: true }],
    "max-depth": ["error", 4],
    "max-classes-per-file": ["error", 1],
  },
}

File-size script

scripts/check_file_size.py: fails when a hand-written file passes the cap.

import sys
from pathlib import Path

SOURCE_MAX = 500
TEST_MAX = 800
SUFFIXES = {".py", ".ts", ".tsx", ".js", ".mjs"}
# Generated or vendored paths only, each with a reason. Never hand-written code.
SKIP_DIRS = {".git", ".venv", "node_modules", "dist", "coverage"}


def is_test(path: str) -> bool:
    return path.startswith(("tests/", "e2e/")) or ".test." in path or ".spec." in path


def main() -> int:
    failures = []
    for file in sorted(Path().rglob("*")):
        if file.suffix not in SUFFIXES or SKIP_DIRS & set(file.parts) or not file.is_file():
            continue
        lines = len(file.read_text(encoding="utf-8").splitlines())
        limit = TEST_MAX if is_test(file.as_posix()) else SOURCE_MAX
        if lines > limit:
            failures.append(f"{file.as_posix()}: {lines} lines, ceiling {limit}")
    if failures:
        print("Files over the size ceiling; split them:\n" + "\n".join(failures))
        return 1
    return 0


if __name__ == "__main__":
    sys.exit(main())

Ceilings-only-go-down script

scripts/check_ceilings.py: compares the tree with the merge base. Fails when a ceiling value rose or a diff adds a silencer for a shape rule. List every file that holds a ceiling in CEILINGS, with a regex that captures name and value.

import re
import subprocess
import sys
from pathlib import Path

CEILINGS = {
    "pyproject.toml": re.compile(
        r"^(max-(?:complexity|returns|branches|statements|public-methods))\s*=\s*(\d+)", re.M
    ),
    "eslint.config.js": re.compile(r"(complexity|max-(?:lines-per-function|depth|classes-per-file))\W+(\d+)"),
    "biome.json": re.compile(r'"(maxAllowedComplexity)"\s*:\s*(\d+)'),
    "scripts/check_file_size.py": re.compile(r"^(SOURCE_MAX|TEST_MAX)\s*=\s*(\d+)", re.M),
}
# Built from parts so this file's own definition is not read as a silencer.
_RULES = r"(?:C901|PLR(?!0913|0917)\w+|complexity|max-(?!params)[\w-]+|noExcessive\w+)"
SILENCER = re.compile(
    rf"^\+.*(?:{'no' + 'qa'}:?[\w, ]*{_RULES}|(?:{'eslint' + '-disable'}|{'biome' + '-ignore'})[^\n]*{_RULES})",
    re.M,
)


def git(*args: str) -> str:
    done = subprocess.run(["git", *args], capture_output=True, text=True, check=False)
    return done.stdout if done.returncode == 0 else ""


def values(text: str, pattern: re.Pattern[str]) -> dict[str, int]:
    return {f"{k}#{i}": int(v) for i, (k, v) in enumerate(pattern.findall(text))}


def main() -> int:
    base = sys.argv[1] if len(sys.argv) > 1 else git("merge-base", "HEAD", "origin/main").strip()
    if not base:
        print("check_ceilings: no origin/main to compare with; skipped")
        return 0
    problems = []
    for path, pattern in CEILINGS.items():
        if not Path(path).is_file():
            continue
        before = values(git("show", f"{base}:{path}"), pattern)
        after = values(Path(path).read_text(encoding="utf-8"), pattern)
        problems += [
            f"{path}: {key.split('#')[0]} raised {before[key]} -> {after[key]}"
            for key in before.keys() & after.keys()
            if after[key] > before[key]
        ]
    problems += [f"new silencer: {m[1:].strip()}" for m in SILENCER.findall(git("diff", base, "--unified=0"))]
    if problems:
        print("Ceilings only go down; split the code instead:\n" + "\n".join(problems))
        return 1
    return 0


if __name__ == "__main__":
    sys.exit(main())

One recipe, three callers

lint:
	uv run ruff check . && uv run ruff format --check .
	python3 scripts/check_file_size.py
	python3 scripts/check_ceilings.py
	uv run pyright

Add the stack’s own lines (npm run lint, tsc --noEmit, biome check). CI, the git hook, and the Claude Stop hook all call this one recipe.

Claude Code hooks

.claude/settings.json:

{
  "hooks": {
    "PostToolUse": [
      { "matcher": "Edit|Write",
        "hooks": [{ "type": "command", "command": "python3 \"$CLAUDE_PROJECT_DIR/scripts/hooks/claude_hook.py\" post-edit", "timeout": 120 }] }
    ],
    "Stop": [
      { "hooks": [{ "type": "command", "command": "python3 \"$CLAUDE_PROJECT_DIR/scripts/hooks/claude_hook.py\" stop", "timeout": 600 }] }
    ]
  }
}

scripts/hooks/claude_hook.py takes post-edit or stop as its argument. post-edit reads the edited path from the hook JSON on stdin, lints that one file (ruff for .py, ESLint or Biome for .ts/.tsx), and runs check_file_size.py. stop returns 0 when stop_hook_active is set (avoids a loop), otherwise runs just lint. A failure prints the last 4,000 characters to stderr and exits 2, which hands the output back to the agent to fix.

Git pre-commit hook

.githooks/pre-commit:

#!/bin/sh
exec just lint

Install it with a just hooks recipe: git config core.hooksPath .githooks.

Testing

  • Prove the gate works once at scaffold time: add a function over the complexity ceiling and confirm just lint fails, then remove it.
  • Prove check_ceilings.py works: raise a ceiling in a scratch branch and confirm it fails.

Quality Gates

  • The lint config selects the complexity, statement, and public-method rules at or below the strict defaults (or the adoption baseline recorded in concerns.md).
  • just lint runs the linter, formatter check, type check, check_file_size.py, and check_ceilings.py; CI runs the same recipe.
  • The Claude PostToolUse and Stop hooks and the git pre-commit hook are committed and run that recipe.
  • No ceiling was raised, and no noqa, eslint-disable, biome-ignore, or per-file ignore was added for a complexity or size rule.
  • No source file is over SOURCE_MAX and no test file over TEST_MAX.
  • No type-checker ignore list of modules.
Innsigle seal: model-primary by HELIX

The signature covers the markdown source of this page, not these HTML bytes. This page quotes that seal; verify it against the source file.

Composition
model-primary
Issuer
HELIX helix
Signing key
ed25519:b0865d76d834a52c48506414d16f4e5a (build key)
Signed source
concerns/code-shape-ceilings.md
Signed
2026-10-22T18:09:54Z
Content digest
sha256:b472a558…f4d70fae

This build key is endorsed by the human key for build signing; the signature is not a detector and not a truth guarantee.

Raw attestation JSON
{
  "payload": {
    "innsigle": "1",
    "type": "https://innsigle.dev/claim/colophon/v1",
    "issued_at": "2026-10-22T18:09:54Z",
    "issuer": {
      "id": "helix",
      "name": "HELIX",
      "key_id": "ed25519:b0865d76d834a52c48506414d16f4e5a",
      "key_url": "https://documentdrivendx.github.io/helix/.well-known/innsigle/keys.json"
    },
    "subjects": [
      {
        "uri": "https://documentdrivendx.github.io/helix/concerns/code-shape-ceilings/",
        "digest": {
          "alg": "sha256",
          "value": "b472a5586b9a68a0bcd81545fef0d69ea0544086f5f042272ad6042cf4d70fae"
        }
      }
    ],
    "colophon": {
      "schema_version": "1",
      "composition": "model-primary",
      "ingredients": [
        {
          "kind": "model",
          "name": "Claude",
          "role": "draft"
        },
        {
          "kind": "tool",
          "name": "sloptimizer",
          "role": "rewrite"
        },
        {
          "kind": "human",
          "name": "operator",
          "role": "structure-edit"
        }
      ],
      "notes": null
    }
  },
  "payload_encoding": "json",
  "signatures": [
    {
      "key_id": "ed25519:b0865d76d834a52c48506414d16f4e5a",
      "alg": "ed25519",
      "sig": "UuaUGv6w_1HJvtUz75R-qHpUK7qfcpOg0QJxn01olrzw81_3zzVLLh2aabIR15Irsl49J0q31DM7N5a7IimhBA",
      "signed_at": "2026-10-22T18:09:54Z"
    }
  ]
}