The fitness loop

This is the spine. Every other doc in the set is a deeper read on one stage of this loop. If you understand only one doc in this set, make it this one.

What you'll understand after this:

- The eight stages a fitness run passes through, in order.

- Where each stage lives in source.

- What "the same check, run twice" actually means deterministically.

- Where the worked example (acme-api) lands at every stage.

We trace one specific scenario: a single check named no-console-log (one of the console-log family of detectors in packages/fitness/checks-universal/src/checks/quality/code-structure/no-console-log.ts) running inside acme-api. We follow it from the moment you type opensip fit to the moment your shell prompt reappears.


The eight stages

  argv          ┌──────────────────────────────────────────────────┐
   │            │  1. CLI dispatch       packages/cli/src/index.ts │
   ▼            └──────────────────────────────────────────────────┘
  commandSpecs ─────────► fitnessTool   (host-mounted; no raw Commander)
                            │
                            ▼
                ┌──────────────────────────────────────────────────┐
                │  2. Config + paths     core/lib/paths.ts         │
                │                        contracts/persistence    │
                └──────────────────────────────────────────────────┘
                            │
                            ▼
                ┌──────────────────────────────────────────────────┐
                │  3. Plugin load        core/plugins/discover.ts  │
                │                        fitness/plugins/          │
                └──────────────────────────────────────────────────┘
                            │
                            ▼
                ┌──────────────────────────────────────────────────┐
                │  4. Recipe selection   fitness/recipes/registry  │
                └──────────────────────────────────────────────────┘
                            │
                            ▼
                ┌──────────────────────────────────────────────────┐
                │  5. Target / scope     fitness/targets/          │
                │     resolution         fitness/framework/        │
                │                        path-matcher              │
                └──────────────────────────────────────────────────┘
                            │
                            ▼
                ┌──────────────────────────────────────────────────┐
                │  6. Check execution    fitness/framework/        │
                │                        define-check.ts           │
                │                        recipes/parallel-exec     │
                └──────────────────────────────────────────────────┘
                            │
                            ▼
                ┌──────────────────────────────────────────────────┐
                │  7. Signal aggregation core/types/signal.ts      │
                │                        contracts/types          │
                └──────────────────────────────────────────────────┘
                            │
                            ▼
                ┌──────────────────────────────────────────────────┐
                │  8. Render + exit      Ink (renderLive)          │
                │                        JSON / SARIF              │
                │                        exit code                 │
                └──────────────────────────────────────────────────┘
                            │
                            ▼
                       shell prompt

Eight stages, every one a read away.


Stage 1 — CLI dispatch

Source: packages/cli/src/index.ts

opensip fit invokes the binary. The CLI's job at this stage is small:

The CLI does not know what fit does. It knows a Tool exists, it admitted and

imported it, mounted the typed commandSpecs the Tool declared, and Commander

now owns the routing. Everything specific to fitness from this point on lives

inside @opensip-cli/fitness; see the tool-plugin model.

Where the example lands: the binary is opensip-cli, argv is ['fit'] (defaults applied), the resolved Tool is fitnessTool with metadata { id: 'fitness', version: <pkg.version>, description: 'Run fitness checks against a codebase' }. (Version is read at startup from @opensip-cli/fitness/package.json.)


Stage 2 — Config and paths

Source: packages/core/src/lib/paths.ts, the configure command for user-level settings, and loadProjectConfig() inside executeFit().

The handler resolves two things:

If the config is missing, the CLI exits 2 with a pointer to opensip init. If the config is malformed, the CLI exits 2 with the validation error.

Where the example lands: acme-api/opensip-cli.config.yml declares two languages (typescript, python), one custom check directory, and a quick-smoke recipe pointing at universal checks only. The default invocation runs every check, not the quick-smoke set.


Stage 3 — Plugin load

Source: packages/core/src/plugins/discover.ts, packages/fitness/engine/src/plugins/, packages/cli/src/bootstrap/register-language-adapters.ts (language-adapter registration).

Three sources of checks get loaded, in order:

plugins.checkPackages: is an exact-name supplement for non-marker packages; marker-based fit-pack discovery still runs.

After this stage, the in-memory check registry has every available check addressable by id and slug.

Where the example lands: acme-api ends up with the universal pack, the typescript pack, the python pack, and three custom checks (require-cdk-json-exists, no-print-outside-pipelines, infra-tag-required). The no-console-log check we're tracing comes from @opensip-cli/checks-universal (it lives there because the regex shape is identical across JS/TS files and its strip behavior comes from the language adapter, not the pack).


Stage 4 — Recipe selection

Source: packages/fitness/engine/src/recipes/registry.ts, packages/fitness/engine/src/recipes/built-in-recipes.ts.

The --recipe <name> flag selects which recipe to run. Without a flag, the default recipe runs (every enabled check, parallel execution, table output).

A recipe's CheckSelector decides which checks make it into the run:

The recipe service (packages/fitness/engine/src/recipes/service.ts) projects the recipe's config: map (per-check parameter overrides) into module-level state so each check can read its slice via getCheckConfig<T>(slug).

Where the example lands: the default recipe runs. Selector is { type: 'all' }. no-console-log makes the cut because it's not in the exclude list.


Stage 5 — Target / scope resolution

Source: packages/targeting/src/resolve.ts, packages/fitness/engine/src/framework/path-matcher.ts, packages/fitness/engine/src/framework/scope-resolver.ts.

For each check that survived selector filtering, the framework computes the file set it'll run against:

The end result is one resolved file list per check. If no files match, the check is reported as skipped with reason no matching files.

Where the example lands: no-console-log declares scope: { languages: ['typescript'], concerns: ['backend'] }. The acme-api project has a backend target with services/api//.ts. After exclusions (/.test.ts, node_modules/, dist/), the file list resolves to 47 TypeScript files under services/api/src/.


Stage 6 — Check execution

Source: packages/fitness/engine/src/framework/define-check.ts, packages/fitness/engine/src/recipes/parallel-execution.ts, packages/fitness/engine/src/recipes/sequential-execution.ts.

The recipe's execution mode (parallel or sequential) decides the dispatcher. Each check runs inside an ExecutionContext carrying:

The check's analysis mode determines what the framework does inside that context:

The framework also applies inline ignore directives at this stage — // @fitness-ignore-next-line <slug>, / @fitness-ignore-file <slug> / — by filtering the produced Signal[] before returning. The ignore-processing step records which directives were applied, so the renderer can show "ignored 3 violations" alongside "found 1 violation".

A timeout per check kicks in if execution.timeout is set. A timed-out check returns an error result; the recipe-level stopOnFirstFailure decides whether subsequent checks still run.

Where the example lands: no-console-log is mode analyze. The framework reads each of the 47 TypeScript files, runs the typescript adapter's strip-strings-and-comments content filter (so // console.log("debug") and "console.log" don't match), then runs the regex /console\.log\b/. Two files match: services/api/src/routes/health.ts:42 and services/api/src/routes/orders.ts:118. Two violations.


Stage 7 — Signal aggregation

Source: packages/core/src/types/signal.ts, packages/contracts/src/signal-envelope.ts, packages/fitness/engine/src/framework/result-builder.ts.

Every check returns a CheckResult carrying Signal[]. The recipe service aggregates results into the run-level summary (totals, pass/fail counts, ignored counts), then assembles the SignalEnvelope.

The SignalEnvelope (packages/contracts/src/signal-envelope.ts) is the canonical artifact: schemaVersion, tool, recipe?, runId, createdAt, a verdict (score, passed, summary), a units[] sidecar (per-check ran/errored/timing facts), and the flat signals[] list. Anything that consumes the JSON output (CI, dashboard, the gate) reads the envelope (ADR-0011).

The aggregation pass is also where the score is computed — currently Math.round((passedChecks / totalChecks) * 100) (a simple pass-rate percent). The score is informational; the exit code is the gate.

Where the example lands: the run produces a SignalEnvelope carrying ~80 units and ~30 signals, two of which are our no-console-log violations.


Stage 8 — Render and exit

Source: packages/cli/src/ui/, packages/fitness/engine/src/cli/fit.ts, packages/cli/src/open-report.ts.

The fitness Tool returns its SignalEnvelope via CommandResult; the CLI composition root dispatches by output mode (ADR-0011 — tools no longer render their own output):

After rendering, the report auto-open runs if conditions allow: --open was passed (or the user opted into auto-open in their config), output isn't --json, and stdout is a TTY. The HTML report at <project>/opensip-cli/.runtime/reports/latest.html opens in the user's default browser (a single rolling file overwritten on each generation, not a per-run archive).

The exit code is set by the fitness Tool via cli.setExitCode(code):

The CLI process exits when Node's event loop drains, which happens after Ink unmounts and the dashboard launcher returns.

Where the example lands: stdout shows a table with two failed checks (no-console-log: 2 violations, plus one other failure from a different check). The exit code is 1. The dashboard does not auto-open because the example invocation was non-interactive (CI). The session record is persisted as a row in acme-api/opensip-cli/.runtime/datastore.sqlite (tool fit, recipe default) via SessionRepo.


Why this loop, and not a different one

A few alternative shapes were considered and rejected during the design. Worth knowing why they're not what you see:

These are policy choices, not technical limits. They keep the loop comprehensible. A change to any of them is a kernel-level decision, not a tool-level one.


What's next

The fitness loop is the spine. The next three docs in this section sharpen it:

When you want stage-by-stage detail, jump to ../20-fit/ — each doc there expands one of these stages with full code paths.