code-metrics · git:20260828.a89ede0 · 2026-08-28 · sha256 2c130afdcac9d403

code-metrics git:20260828.a89ede0A

Immutable. This exact content never changes and is served at /api/v1/blob/2c130afdcac9d403.

---
name: code-metrics
description: Numeric code-quality budgets (function/file size, params, complexity) enforced mechanically at the Lint Gate, plus structural boundary rules via dependency-cruiser and the exception-audit policy. Loaded by the engineer agents when writing code and referenced by the Code Reviewer when auditing suppressions.
---

# Code Metrics & Boundaries

Numeric budgets against the failure modes agent-written code drifts toward: god classes, long complex functions, files with no separation of concerns. The budgets live here; the **enforcement lives in the project's linter**, wired into `commands.lint` and run by the Lint Gate — rules in prompts drift, rules in gates hold.

## The Budgets

| Metric | Budget | ESLint rule |
|---|---|---|
| Function parameters | ≤ 3 | `max-params` |
| Function length | ≤ 50 lines (excl. blanks/comments) | `max-lines-per-function` |
| File length | ≤ 300 lines (excl. blanks/comments) | `max-lines` |
| Cyclomatic complexity | ≤ 10 | `complexity` |
| Cognitive complexity | ≤ 15 | `sonarjs/cognitive-complexity` |
| Nesting depth | ≤ 4 | `max-depth` |

All budgets are **errors, not warnings** — the Lint Gate treats a violation like any other lint failure. These are starting values, deliberately achievable; tighten per project once a baseline holds (record the decision in an ADR).

Design toward the budgets from the start — more than 3 parameters wants an options object with a named type; a function crossing 50 lines wants extraction *at a meaningful seam*, not shredding into fragments to satisfy the linter; a file crossing 300 lines is mixing concerns and wants a module split.

## ESLint Configuration (reference)

The config belongs to the target project. Flat-config layer to merge into `eslint.config.mjs`:

```js
import sonarjs from 'eslint-plugin-sonarjs';

export default [
  {
    plugins: { sonarjs },
    rules: {
      'max-params': ['error', 3],
      'max-lines-per-function': ['error', { max: 50, skipBlankLines: true, skipComments: true }],
      'max-lines': ['error', { max: 300, skipBlankLines: true, skipComments: true }],
      complexity: ['error', 10],
      'max-depth': ['error', 4],
      'sonarjs/cognitive-complexity': ['error', 15],
    },
  },
  // React/JSX components may relax function length to 80 — markup inflates line
  // counts without inflating complexity. Complexity budgets stay unchanged.
  {
    files: ['**/*.tsx'],
    rules: {
      'max-lines-per-function': ['error', { max: 80, skipBlankLines: true, skipComments: true }],
    },
  },
  // Test files: length budgets off — a thorough describe block is not a god function.
  // MUST be the last override: for a *.test.tsx file the last matching entry wins per
  // rule, so placing this before the *.tsx entry would re-enable the budget on tests.
  {
    files: ['**/*.test.*', '**/*.spec.*', '**/*.e2e-spec.*'],
    rules: {
      'max-lines-per-function': 'off',
      'max-lines': 'off',
    },
  },
];
```

## Boundary Rules (dependency-cruiser)

Size limits catch bloated units; **dependency-cruiser** catches the structural half — cross-boundary dependencies on concretions. Reference `.dependency-cruiser.cjs`, with path patterns adapted to the project layout in `.constellation/project-map.md`:

```js
module.exports = {
  forbidden: [
    {
      name: 'no-circular',
      comment: 'Circular dependencies are undeclared god modules',
      severity: 'error',
      from: {},
      to: { circular: true },
    },
    {
      name: 'domain-not-to-infrastructure',
      comment: 'Business logic depends on contracts, never on infrastructure concretions',
      severity: 'error',
      from: { path: '^src/[^/]+/(domain|services)/' },
      to: { path: '^src/(infra|database|prisma|clients)/' },
    },
    {
      name: 'no-cross-module-internals',
      comment: "Modules use each other's public surface (service/module), not internals",
      severity: 'error',
      from: { path: '^src/([^/]+)/' },
      to: {
        path: '^src/([^/]+)/.+\\.(repository|dto|entity)\\.',
        pathNot: '^src/$1/',
      },
    },
  ],
};
```

Wire it into the lint command so the Lint Gate runs it, e.g. `"lint": "eslint . && depcruise src"`.

## Contracts at Boundaries — Not Everywhere

The goal behind "depend on abstractions" is **no cross-boundary dependency on concretions** — not an interface for every class:

- **Require a named contract** (a `type`, or an abstract class where the DI container needs a runtime token) at: repository boundaries, external SDK/API adapters, and anything one module exposes for another to consume.
- **Do not** wrap single-implementation, module-internal collaborators in contracts — that is indirection without decoupling, and the DX Analyst will flag it as complexity.

## Exceptions — Visible and Justified

There are legitimate reasons to exceed a budget. The escape hatch is an inline suppression that is **narrow, named, and justified**:

```typescript
// eslint-disable-next-line max-lines-per-function -- exhaustive mapper over the 14 webhook event types; splitting would scatter one decision across files
export function mapWebhookEvent(event: WebhookEvent): DomainEvent {
```

- Narrowest scope only: `eslint-disable-next-line` with the specific rule — never file-level disables, never blanket `eslint-disable`
- The `--` justification is **required** and must state the reason, not restate the rule
- dependency-cruiser exceptions are rule-scoped entries in the config file with a comment, same standard
- **The Code Reviewer audits every suppression in the diff**: a metric suppression with no justification, or a justification the change context does not support, is a 🔴 blocker

## Adoption

Enforcement requires the rules in the target project's lint setup. When a project's `commands.lint` does not yet carry these rules, the engineer still designs within the budgets (they are hard limits on new code regardless), and the gap is filed as an improvement (`.constellation/improvements/`) or a tweak to adopt the config — **do not** bolt the lint config onto an unrelated feature branch. On adoption in a legacy codebase, baseline pragmatically: apply the rules to changed files first rather than mass-suppressing existing violations.