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.

- 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.


Errors

packages/core/src/lib/errors.ts defines the workspace's error hierarchy:

interface ToolErrorOptions extends ErrorOptions { code?: string; [key: string]: unknown }

class ToolError extends Error {
  readonly code: string;
  // `cause` is inherited from base `Error` via the options bag (ES2022).
  constructor(message: string, code: string, options?: ToolErrorOptions);
}

class ValidationError    extends ToolError { /* default code: 'VALIDATION_ERROR' */ }
class NotFoundError      extends ToolError { /* default code: 'NOT_FOUND' */ }
class SystemError        extends ToolError { /* default code: 'SYSTEM_ERROR' */ }
class TimeoutError       extends ToolError { /* default code: 'TIMEOUT'; second arg is `number | ToolErrorOptions` */ }
class NetworkError       extends ToolError { /* default code: 'NETWORK_ERROR'; supports { statusCode } */ }
class ConfigurationError extends ToolError { /* default code: 'CONFIGURATION_ERROR' */ }

Plus the Result<T, E> pattern with ok(value) / err(error) / tryCatch(fn) / tryCatchAsync(fn) exported from the same module.

When to throw vs. return Result

Error codes

Each error subclass ships with a sensible default: VALIDATION_ERROR, NOT_FOUND, SYSTEM_ERROR, TIMEOUT, NETWORK_ERROR, CONFIGURATION_ERROR. Call sites that want a more specific code pass { code: '...' } as the second argument, e.g. new ValidationError('bad', { code: 'SCHEMA_FAIL' }). Most production throws today use the defaults; the shape is in place for future scoped codes.

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