Targets and scope

A check produces violations against files. The set of files is computed at run time from three things: the project's targets, the check's scope, and the global exclusions. This doc walks the resolution.

What you'll understand after this:

- The four ways a check can declare what files it cares about.

- How a target's languages/concerns match a check's scope.

- How globalExcludes and per-check overrides interact.

- Why targets exist instead of just glob patterns inline.


The two sides of the matching problem

A polyglot project has many file kinds. A check has one purpose. The matching problem is: given a project and a check, which files does this check inspect?

The naive answer is "the check declares its own globs": include: ['services/api//.ts'], exclude: ['/.test.ts']. That works in a single project; it doesn't work for a marketplace check pack. A pack like @opensip-cli/checks-typescript doesn't know your project's directory layout — it can't hardcode services/api/.

So opensip-cli splits the declaration:

This is the marketplace shape. A check author writes scope: { languages: ['typescript'], concerns: ['backend'] } once and the same check runs in your project (services/api/), my project (apps/server/), and a third project that hasn't been written yet.


How a check declares its files

Four mechanisms, in order of preference:

1. scope: — semantic (preferred)

defineCheck({
  // ...
  scope: {
    languages: ['typescript'],
    concerns: ['backend'],
  },
});

The framework finds every target whose languages overlaps ['typescript'] and whose concerns overlaps ['backend']. Empty arrays mean "match any" — scope: { languages: [], concerns: [] } is the universal scope (the shape used by every check in @opensip-cli/checks-universal).

This is the recommended shape for marketplace check packs.

2. fileTypes: — extension-based

defineCheck({
  // ...
  fileTypes: ['ts', 'tsx'],
});

The framework filters the matched file list to files with these extensions. Layered on top of scope: — if both are set, both apply. Useful when a check's scope is broader than what the file extensions imply (e.g. an architecture check that should still only run against TypeScript files).

3. Per-check target override (config-side)

# opensip-cli.config.yml
checkOverrides:
  no-console-log: backend
  no-todos: ['backend', 'frontend']

A user can pin a check to a specific target by slug, regardless of what the check declared. This is the escape hatch when a third-party check's scope doesn't match your project's reality. checkOverrides is a top-level key alongside targets: and globalExcludes:. Lives in TargetsConfig.checkOverrides.

4. No declaration at all

A check that declares neither scope: nor fileTypes: matches every file the targets registry resolves to. That's almost never what you want — most checks should declare scope. The framework permits it for genuinely cross-cutting checks (e.g. "every package has a README"), where the matched files are the targets' own and not language-specific.


Anatomy of a target

The shape lives in packages/fitness/engine/src/targets/types.ts:

interface TargetConfig {
  readonly name: string;                      // kebab-case, e.g. 'backend'
  readonly description: string;
  readonly include: readonly string[];        // globs (rooted at project)
  readonly exclude: readonly string[];        // globs subtracted from include
  readonly context?: readonly string[];       // doc paths shown to assessment runs
  readonly tags?: readonly string[];          // free-form
  readonly languages?: readonly string[];     // 'typescript' | 'rust' | ...
  readonly concerns?: readonly string[];      // 'backend' | 'frontend' | 'infra' | ...
}

A target answers two questions:

The first is for execution; the second is for matching.

Example: the acme-api targets

globalExcludes is a top-level key alongside targets:; targets are a map of kebab-case name → definition (no separate registry: wrapper):

globalExcludes:
  - '**/node_modules/**'
  - '**/dist/**'
  - '**/.next/**'

targets:
  backend:
    description: TypeScript REST API
    include: ['services/api/**/*.ts']
    exclude: ['**/*.test.ts']
    languages: ['typescript']
    concerns: ['backend', 'server']

  pipelines:
    description: Python ETL jobs
    include: ['pipelines/etl/**/*.py']
    exclude: ['**/*_test.py']
    languages: ['python']
    concerns: ['data-pipeline']

  infra:
    description: AWS CDK stack
    include: ['infra/**/*.ts']
    exclude: ['infra/**/*.test.ts']
    languages: ['typescript']
    concerns: ['infrastructure']

  tests:
    description: All test files
    include: ['**/*.test.ts', '**/*_test.py']
    languages: ['typescript', 'python']
    concerns: ['tests']

Now a check with scope: { languages: ['typescript'], concerns: ['backend'] } matches backend (overlap on typescript+backend). It does not match infra (different concern) or tests (different concern). It does not match pipelines (different language).

A universal check with scope: { languages: [], concerns: [] } matches all four targets.


How the resolution actually runs

packages/fitness/engine/src/framework/scope-resolver.ts is where it happens. The flow:

1. Load TargetsConfig from opensip-cli.config.yml.
2. Pre-glob every target's include patterns once, producing a
   pattern → matched-paths map. This avoids re-running the same
   glob multiple times when targets share patterns.
3. Per check:
     a. If checkOverrides[slug] is set → use those target names.
     b. Else, find every target whose languages overlap check.scope.languages
        AND concerns overlap check.scope.concerns (empty arrays = match-any).
     c. Combine the matched targets' file lists; deduplicate.
     d. Apply target-level excludes (already applied during pre-glob).
     e. Apply globalExcludes from TargetsConfig.
     f. If check.fileTypes is set, filter to those extensions.
4. Hand the resolved list to the check's ExecutionContext.

Pre-globbing is the optimization that makes resolution fast on large repos. With ~50 targets and ~100 checks, naive resolution would run the same glob hundreds of times. Pre-globbing runs each unique pattern once and reuses the results.

The COMMON_IGNORE set inside the resolver always includes node_modules, dist, and .git to keep glob traversal bounded — a misconfigured target that accidentally includes */ won't blow up.


Global excludes

globalExcludes is the top-level project-wide subtractor (it sits at the root of opensip-cli.config.yml, not under targets:). Every target's resolved file list passes through it. Common entries:

globalExcludes:
  - '**/node_modules/**'
  - '**/dist/**'
  - '**/build/**'
  - '**/.next/**'
  - '**/.turbo/**'
  - '**/coverage/**'
  - '**/__snapshots__/**'
  - '**/*.generated.ts'

Use this rather than repeating the same exclusions on every target. The historical .fitnessignore file from earlier versions has been retired — globalExcludes replaces it.


The PathMatcher

packages/fitness/engine/src/framework/path-matcher.ts is the per-check matcher object. It compiles include/exclude globs once and answers match(filePath) queries with a single pass through the compiled Minimatch instances.

You won't usually instantiate one. The framework constructs it for each check inside executeUnifiedCheck() and exposes ctx.matchFiles() to the check. If you're writing an analyzeAll-mode check that needs additional filtering on top of the resolved file list, the matcher is available via check.getMatcher(cwd).


Where the example lands

For acme-api running the default recipe:

Each resolved list is passed to its check's ExecutionContext.matchFiles(). The check iterates and produces signals.


Why this is targets-as-data and not targets-as-code

Targets could have been a programmable API: defineTarget({ name: 'backend', includes: () => ... }). They're declarative YAML instead, deliberately:

The trade-off: complex targets (e.g. "include any file in a directory that has a Dockerfile") aren't expressible. For those, write an analyzeAll-mode check that does its own filtering — the targets layer is for the common case.


What's next