Coding standards

The workspace's quality gates are: TypeScript strict mode, ESLint with type-aware rules, dependency-cruiser for layer enforcement, knip for unused exports. The build fails on any of those. This doc describes the conventions those gates enforce.

What you'll understand after this:

- The ESLint rule set and the few documented exceptions.

- How errors are constructed and propagated (error and resiliency model).

- The exit-code convention.

- Logger event naming.

- Comment policy: when to write one, when not to.


TypeScript

The workspace root tsconfig.json sets target: ES2022, module: Node16, moduleResolution: Node16, and strict: true. Each package has its own tsconfig.json that extends those settings.

Notable settings:

exactOptionalPropertyTypes is not enabled. Optional fields can be undefined without | undefined in the type — the codebase treats field?: T as field?: T | undefined.


ESLint

Flat config at .config/eslint.config.mjs. The base layers:

Tunings

Per-file exceptions

A file can opt out of a specific rule by writing a directive comment at the top:

// @fitness-ignore-file module-coupling-metrics -- central orchestration; coupling is necessary
// eslint-disable-next-line sonarjs/cognitive-complexity -- multi-section diff renderer reads better inline

The convention is: always include a justification after --. A bare eslint-disable-next-line without a reason is a smell — future contributors won't know whether the suppression is still needed.

The @fitness-ignore-file directives are OpenSIP CLI's own (eaten by the fitness check framework, not ESLint). They're used to suppress fitness-check violations on the workspace's own source — yes, OpenSIP CLI dogfoods itself.

When a fitness-check change intentionally changes detection behavior, update the

labeled detection-quality corpus and refresh the baseline with

pnpm quality:measure:update. Fixture coverage proves a check fires on curated

examples; the detection-quality baseline tracks precision, recall, and

false-positive rate across releases.


Errors

Canonical model: Error and resiliency model (ADR-0181 / ADR-0183).

Registered codes: Error code index.

packages/core/src/lib/errors.ts still exports the subclass hierarchy, but machine semantics live on ErrorDefinition (catalogs via defineErrorCatalog). Prefer:

import { createToolError, defineErrorCatalog, normalizeFailure } from '@opensip-cli/core';

// createToolError(catalog.require('OWNER.DOMAIN.CONDITION'), message, { metadata, cause })
// Host: normalizeFailure(error) → public / machine / operator projections

Legacy form remains: new NotFoundError(msg, { code, definition, metadata }) — metadata and definition are retained (no longer discarded).

Plus the Result<T, E = ToolError> pattern with ok / err / tryCatch / tryCatchAsync and logging-forcing unwrapOrLog / matchLog.

When to throw vs. return Result

Handling a Result error observably

A Result failure must never be silently discarded. The error-handling-quality fitness check flags a !r.ok / r.ok === false (or r.isErr()) branch that returns a bare sentinel (null / undefined / false / [] / {}) with no logging, propagation, or marker. Handle it one of these ways:

Error codes

Subclass defaults (VALIDATION_ERROR, NOT_FOUND, …) remain for compatibility and map through the core system catalog. New public codes use OWNER.DOMAIN.CONDITION grammar, are append-only, and require a CHANGELOG note when first published or superseded. See the error code index.

Errors are mapped to user-facing suggestions by getErrorSuggestion:

export interface ErrorSuggestion {
  message: string;
  action?: string;
  exitCode: number;
}

export function getErrorSuggestion(err: unknown): ErrorSuggestion | null {
  // pattern-matches on the error message and returns a structured suggestion,
  // or null if no rule matched.
}

The CLI calls getErrorSuggestion(error) and threads the returned { message, action, exitCode } into the ErrorResult the renderer shows. Tools throw a typed error; the CLI does the message-matching and renders.


Exit codes

Defined exactly once in packages/contracts/src/exit-codes.ts:

export const EXIT_CODES = {
  SUCCESS: 0,
  RUNTIME_ERROR: 1,           // checks/scenarios failed, or unhandled runtime error
  CONFIGURATION_ERROR: 2,     // run could not start (config invalid, recipe unknown, plugin failed)
  CHECK_NOT_FOUND: 3,         // typed NotFoundError surfaced through the shared mapper
  REPORT_FAILED: 4,           // --report-to delivery failure
  PLUGIN_INCOMPATIBLE: 5,     // compatibility/trust gate rejected a Tool plugin before import
} as const;

Tools call cli.setExitCode(code) instead of mutating process.exitCode directly. The CLI mediates the final exit so it can run dashboard launching / cleanup after the Tool is done.

Exit-code classification is owned by typed errors: only ToolError subclasses (and Commander/bootstrap errors) choose a non-runtime exit code; an untyped error defaults to RUNTIME_ERROR even when a suggestion is attached. See ADR-0066.

Adding a new exit code is a major-version change — see 10-concepts/04-contract-surfaces.md.


Logging

The structured logger is in packages/core/src/lib/logger.ts. Every log entry carries:

Levels: error, warn, info, debug. Default info. --debug raises to debug. --quiet does not affect the log level.

Event naming

Event names are stable identifiers. They appear in CI logs, in the dashboard, and in any external log aggregator. Renaming an event is a breaking change for anyone grepping for it.

The convention:

<surface>.<action>[.<phase>]

Examples:

The phase is optional. A simple event like cli.gate.config_error doesn't need one.

What to include

A log entry should answer "what happened, in what context, with what outcome." Things to include:

Things to leave out:


Imports

The import ordering is enforced by eslint-plugin-import:

// 1. Node built-ins
import { readFileSync } from 'node:fs';
import { join } from 'node:path';

// 2. Third-party
import { z } from 'zod';

// 3. Internal workspace deps (alphabetical by package)
import { logger, ToolError } from '@opensip-cli/core';
import { EXIT_CODES } from '@opensip-cli/contracts';

// 4. Local relative imports
import { sarifBuilder } from './sarif.js';
import type { Check } from './types.js';

Type-only imports use import type so they're erased at compile time. The main dep-cruiser pass ignores type-only imports (tsPreCompilationDeps: false) because they carry no runtime edge — but this does not mean you may import type from a higher layer. A second, type-aware pass (.config/dependency-cruiser.types.cjs, tsPreCompilationDeps: true) re-runs the full layer ruleset over the type-inclusive graph, so a type-only layer inversion or cycle is still rejected. Both passes run under pnpm lint. See 05-layer-policy.md and ../10-concepts/03-modular-monolith.md.


Comments

Default to writing no comments. Only add one when:

Don't write comments that:

@fileoverview JSDoc is acceptable on the entry file of a module that has multiple consumers — it's the first thing a reader sees, and a one-paragraph summary saves them the work of inferring the module's role from the export list.


Test layout

Tests live alongside source under __tests__/ directories:

packages/fitness/engine/src/
├── gate.ts
└── __tests__/
    └── gate.test.ts

Vitest is the runner. Tests are compiled and type-checked with the same TS config as source. The dep-cruiser config excludes __tests__/ and *.test.ts files from the layer rules — tests can import anything.

Naming: .test.ts for unit tests, .integration.test.ts for cross-package integration tests. Snapshot files go in __snapshots__/.


What's next