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-disciplineowns 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.verificationowns observed evidence that the system runs;testingowns test strategy and the coverage floor. Neither bounds code shape.python-uv,typescript-bun, andreact-viteown the toolchain (ruff, pyright, Biome, ESLint). This concern owns the rule set and the strict default values for those tools (seepractices.md).architecture-stylefillers (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.mdowns 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
PostToolUsehook that lints the edited file and checks file length, and aStophook 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.mdfor 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.mdwith the starting values.
Global, no exceptions
- Ceilings apply to the whole tree. No per-file ignore,
noqa,eslint-disable,biome-ignore, or# pyright: ignorefor 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 lintor 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 withoutcomplexityandmax-lines-per-function) → add them at the strict defaults - A ceiling raised, or a
noqa/eslint-disable/biome-ignorefor a complexity or size rule → split the code; revert the change - A per-file ignore list, or a type-checker
ignorelist 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, orindexfiles 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 lintor 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 lintbefore 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.mdwhether 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
| Rule | Python (ruff) | TypeScript (ESLint) | TypeScript (Biome, typescript-bun) |
|---|---|---|---|
| Complexity per function | mccabe.max-complexity = 10 | complexity: 10 | noExcessiveCognitiveComplexity at 15 |
| Function length | pylint.max-statements = 40 | max-lines-per-function: 60 (skip blanks, comments) | none; file cap only |
| Parameters | review cohesion | review cohesion | review cohesion |
| Returns / branches | max-returns = 6, max-branches = 12 | max-depth: 4 | none; review |
| Public methods per class | pylint.max-public-methods = 15 (needs PLR0904, a preview rule) | max-classes-per-file: 1 | none; review |
| File length | SOURCE_MAX = 500, TEST_MAX = 800 | same script | same 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 lintInstall 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 lintfails, then remove it. - Prove
check_ceilings.pyworks: 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 lintruns the linter, formatter check, type check,check_file_size.py, andcheck_ceilings.py; CI runs the same recipe.- The Claude
PostToolUseandStophooks 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_MAXand no test file overTEST_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"
}
]
}