Publishable packs

A pack is a check directory (or sim-scenario directory) promoted to its own npm package. Use this when you want to ship the same checks across multiple projects — or to keep a project-local pack tidy as it grows past loose .mjs files.

Where the pack lives in your repo

The opensip-cli platform reserves three paths inside your repo's opensip-cli/ directory:

The platform doesn't load anything from these paths directly — discovery flows through node_modules/ walking. When opensip-cli/fit/ is a workspace package, your workspace's symlink puts it in node_modules/ where the marker walker finds it. The directory layout is a recommended convention, not a platform requirement.

The marker (recommended discovery path)

Tag your pack's package.json with opensipTools.kind:

{
  "name": "@your-scope/fit",
  "private": true,
  "type": "module",
  "main": "./dist/index.js",
  "opensipTools": {
    "kind": "fit-pack",
    "targetDomain": "fit-pack",
    "targetDomainApiVersion": 1
  }
}

For a fit pack the fit-pack marker is name-pattern-independent — your pack

can use any npm scope you own (@acme/fit, @my-internal-org/checks-platform,

anything); the marker is what makes the platform find it. The targetDomain

and targetDomainApiVersion fields declare the domain epoch your pack targets.

A sim pack is discovered by a name pattern instead — name it

<scope>/scenarios-* (see below).

Discovery paths

Listed in recommendation order:

<scope>/scenarios-* (e.g. @acme/scenarios-load). plugins.packageScopes

extends the scopes scanned beyond @opensip-cli.

When to graduate from loose .mjs

Concrete pain signals, not arbitrary thresholds:

If none of those apply, stay with loose .mjs. The graduation is worthwhile only when the loose-file shape starts to cost more than it saves.

Layout after graduation

@my-co/checks-internal/
├── package.json                # declares fit-pack marker + targetDomain epoch
├── tsconfig.json
├── src/
│   ├── index.ts                # exports: checks (display folded on), recipes
│   ├── checks/
│   │   ├── architecture/no-cycle.ts
│   │   ├── architecture/no-cycle.test.ts     # tests colocated
│   │   ├── observability/log-on-catch.ts
│   │   ├── architecture/index.ts             # category barrel
│   │   └── index.ts                          # top-level checks barrel
│   ├── shared/                               # internal helpers
│   ├── recipes/                              # canonical recipes shipped with the pack
│   │   └── default.ts
│   └── display/                              # icon/display-name map
├── dist/                                     # built artifact
└── README.md

Two structural details make this scale cleanly past a few dozen checks:

re-exports the checks in that category, and checks/index.ts re-exports the

category barrels. No runtime logic belongs there.

checks/index.ts, folds the per-pack display map onto them with

collectCheckObjects(...) + applyCheckDisplay(...), and exports the pack's

checks array. It stays small even as the pack grows.

the public surface.** With the split, adding a check normally touches the new

check file, its category barrel, the display map, and tests; the root

index.ts remains stable.

This pattern works at scale in the opensip codebase's 151 built-in fitness

checks across seven packs. Small packs can keep everything in one index.ts;

the split only pays off once re-skimming the public surface on every change

becomes a tax. Sim packs can use the same idea with scenarios/index.ts barrels

and defineLoadScenario(...) / defineChaosScenario(...) exports.

package.json

{
  "name": "@my-co/checks-internal",
  "version": "1.0.0",
  "main": "dist/index.js",
  "type": "module",
  "opensipTools": {
    "kind": "fit-pack",
    "targetDomain": "fit-pack",
    "targetDomainApiVersion": 1
  },
  "peerDependencies": {
    "@opensip-cli/fitness": "^0.1.14",
    "@opensip-cli/core": "^0.1.14"
  },
  "scripts": {
    "build": "tsc"
  },
  "files": ["dist"]
}

Peer-depend on @opensip-cli/fitness and @opensip-cli/core — the consumer brings their own version.

src/index.ts

import { applyCheckDisplay, type Check, type CheckDisplayEntry, type FitnessRecipe } from '@opensip-cli/fitness';

import { noFixme } from './checks/no-fixme.js';
import { infraMustHaveTags } from './checks/infra-must-have-tags.js';
import { quickSmoke } from './recipes/quick-smoke.js';

// Display (icon + name) travels ON each check (§5.3): keep an authoring map and
// fold it onto the checks here. There is no separate `checkDisplay` export.
const CHECK_DISPLAY: Readonly<Record<string, CheckDisplayEntry>> = {
  'no-fixme-comments': ['📝', 'No FIXME comments'],
  'infra-must-have-tags': ['🏷️', 'Infrastructure tags required'],
};

export const checks: readonly Check[] = applyCheckDisplay([noFixme, infraMustHaveTags], CHECK_DISPLAY);

export const recipes: readonly FitnessRecipe[] = [quickSmoke];

Pack metadata (name, version, description) is read from package.json by the platform — don't duplicate those fields as a runtime export.

src/checks/no-fixme.ts

import { defineCheck } from '@opensip-cli/fitness';

export const noFixme = defineCheck({
  id: '0a0a0a0a-0a0a-4a0a-8a0a-0a0a0a0a0a0a',
  slug: 'no-fixme-comments',
  description: 'No FIXME comments left in source',
  tags: ['quality', 'documentation'],
  scope: { languages: [], concerns: [] },
  contentFilter: 'raw',
  analyze(content, filePath) {
    const violations: { line: number; message: string; severity: 'warning' }[] = [];
    content.split('\n').forEach((line, idx) => {
      if (/\bFIXME\b/.test(line)) {
        violations.push({ line: idx + 1, message: `FIXME at ${filePath}`, severity: 'warning' });
      }
    });
    return violations;
  },
});

Workspace integration

For a monorepo workspace pack, add opensip-cli/* to your workspace globs so pnpm/npm symlinks the package into node_modules/:

# pnpm-workspace.yaml
packages:
  - "apps/*"
  - "packages/*"
  - "opensip-cli/*"   # opensip-cli-related workspace packages

Add the pack as a root devDependency so the workspace symlink lands in node_modules/:

// root package.json
{
  "devDependencies": {
    "@your-scope/fit": "workspace:*"
  }
}

Then pnpm i. Marker-based discovery picks up the workspace symlink on the next opensip fit run.

For a TS-based pack you also need to build (pnpm -F @your-scope/fit build) so the main field resolves to real JS. The runtime doesn't currently load TypeScript directly; it loads the entry point your package.json#main points at.

If you don't have a monorepo, publish your pack to a private npm registry under your own scope and install it as a regular devDependency. The marker still drives discovery — no packageScopes config entry needed.

Migration recipe — loose .mjs → workspace pack

A step-by-step you can follow when you've decided to graduate:

Recipes during the move. A recipe that lived at opensip-cli/fit/recipes/<name>.mjs can either stay there (the platform's project-local recipe walker continues to load it from the reserved path) or move into the pack as src/recipes/<name>.ts and be re-exported through index.ts alongside checks. Moving it into the pack is the cleaner end-state — single source of truth, versioned with the checks it references — but doing so is optional and can happen after the check migration lands.

Reference example

The opensip codebase uses this pattern at production scale. The split is visible directly in the public layout:

Either is a working reference for the pattern when graduating your own pack.

Publish + consume

# In your pack:
npm publish --access public      # or wire it up to GitHub OIDC trusted publishing

# In a consuming project:
opensip fit plugin add @my-co/checks-internal

opensip fit plugin add installs to <project>/opensip-cli/.runtime/plugins/fit/node_modules/ and appends to plugins.fit: in opensip-cli.config.yml. Next opensip fit run, your checks load. (Sim packs use opensip sim plugin add — the domain is bound from the tool the plugin group hangs off of.)

Testing

Use vitest. The check is a plain function — call it with sample content and assert the violation list.

// src/checks/__tests__/no-fixme.test.ts
import { describe, it, expect } from 'vitest';
import { noFixme } from '../no-fixme.js';

describe('no-fixme', () => {
  it('flags a FIXME comment via the integration entry point', async () => {
    // Check.run(cwd, options?) walks the project's targets and runs the
    // analyzer over every matched file. It returns a CheckResult.
    const result = await noFixme.run(process.cwd());
    expect(result.passed).toBe(false);
  });
});

For a tighter unit test, call the analyzer directly — defineCheck keeps the original analyze/analyzeAll/command callable on the source module, so a unit test imports that function and feeds it a string of source code.

Where to go next

| You want to … | Go to … |

|---|---|

| Understand the platform side: pack contract, scope filters, discovery internals | Check pack architecture |

| Author a Tool with its own subcommand | Full Tool plugins |

| Walk the monorepo adoption flow end-to-end | Adopt in a monorepo |

| Browse all 151 built-in checks for inspiration | Checks reference |