Selective Tests by Component (Multi-Language)

Selective test execution runs only the test suites affected by a change. The unit of narrowing is a stacks[] entry — and a “stack” does not have to mean a separate service or a separate repository. It can be a component inside a single repo: a frontend, a backend, a shared library, a docs tree, a set of scripts. This tutorial shows how to split one project into component stacks — including a mixed-language example (React, Angular, Python, Go, C++) — so a change to one component runs only that component’s tests.

Why narrow by component

On a project with several components, a one-line change to the frontend should not run the backend and firmware test suites. Without narrowing, a developer waits for the whole suite on every pull request. With component narrowing, the pull request runs only the affected component’s tests — minutes instead of the full run — while the full suite still runs once at the final promotion (staging → main) as the last gate before release.

How narrowing works

Three deterministic steps drive every pull request:

  1. detect-affected — maps each changed file path to its owning stacks[] entry by longest-prefix match on that entry’s paths globs.
  2. cross-refs walk — expands the affected set along declared cross_refs dependency edges (a change to a shared component pulls in the components that depend on it).
  3. generate-pipeline — emits one CI matrix job per affected stack; each job runs that stack’s own test_cmd.

The narrowing is therefore only as fine-grained as your stacks[] partition. One stack covering the whole repo narrows to “everything.” Many component stacks narrow to “just the component(s) you touched.”

The single-stack trap

A common configuration declares a single stack whose paths cover the entire source tree:

# .gaia/config/project-config.yaml  (narrows to nothing useful)
stacks:
  - name: app
    language: typescript
    paths:
      - "src/**"
      - "lib/**"
      - "docs/**"
      - "tests/**"
    test_cmd: "npm test"        # the WHOLE suite, every time

Here detect-affected always returns ["app"] — one stack out of one — so the “narrowed” run is the full suite. The machinery works; there is simply nothing to narrow to. The fix is to split app into components.

Model each component as a stack

Give each component its own stacks[] entry with a non-overlapping paths list and its own test_cmd. Components can share a language or mix languages — the language field only sets the default tooling/persona; the narrowing is driven entirely by paths.

# .gaia/config/project-config.yaml  (single repo, four components)
stacks:
  - name: web
    language: typescript
    paths: [ "apps/web/**" ]
    test_cmd: "npm --prefix apps/web test"
  - name: api
    language: python
    paths: [ "services/api/**" ]
    test_cmd: "pytest services/api"
  - name: shared
    language: typescript
    paths: [ "packages/shared/**" ]
    test_cmd: "npm --prefix packages/shared test"
  - name: docs
    language: markdown
    paths: [ "docs/**", "*.md" ]
    test_cmd: "markdownlint docs"

Now a change under apps/web/ resolves to ["web"] and CI runs only npm --prefix apps/web test. A docs-only change resolves to ["docs"] and runs only the markdown lint — the application suites are skipped entirely.

Multi-language example (React, Angular, Python, Go, C++)

Components are language-agnostic, so a single repository can hold a React web app, an Angular admin console, a Python API, a Go worker, and a C++ firmware module — each narrowing independently:

# .gaia/config/project-config.yaml  (mixed-language monorepo)
stacks:
  # ---- frontend components ----
  - name: web-react
    language: typescript
    paths: [ "apps/web/**" ]
    test_cmd: "npm --prefix apps/web run test:ci"
  - name: admin-angular
    language: angular
    paths: [ "apps/admin/**" ]
    test_cmd: "npm --prefix apps/admin run test -- --watch=false"

  # ---- backend components ----
  - name: api-python
    language: python
    paths: [ "services/api/**" ]
    test_cmd: "pytest services/api -q"
  - name: worker-go
    language: go
    paths: [ "services/worker/**" ]
    test_cmd: "go test ./services/worker/..."

  # ---- firmware component ----
  - name: firmware-cpp
    language: cpp
    paths: [ "firmware/**" ]
    test_cmd: "ctest --test-dir firmware/build"

  # ---- shared design system used by BOTH frontends ----
  - name: design-system
    language: typescript
    paths: [ "packages/design-system/**" ]
    test_cmd: "npm --prefix packages/design-system test"
    cross_refs: [ "web-react", "admin-angular" ]
You change… Affected set CI runs
apps/web/src/App.tsx ["web-react"] React tests only
apps/admin/src/app.component.ts ["admin-angular"] Angular tests only
services/api/routes/users.py ["api-python"] pytest (API) only
services/worker/main.go ["worker-go"] go test (worker) only
firmware/src/driver.cpp ["firmware-cpp"] ctest (firmware) only
packages/design-system/Button.tsx ["design-system", "web-react", "admin-angular"] design-system + BOTH frontends (via cross_refs)

Dependencies between components: cross_refs

Components are rarely independent. When component B imports component A, a change to A must also test B — otherwise B’s narrow run could pass while A’s change silently breaks it. Declare the dependency with cross_refs on the depended-upon stack:

  - name: design-system
    paths: [ "packages/design-system/**" ]
    test_cmd: "npm --prefix packages/design-system test"
    cross_refs: [ "web-react", "admin-angular" ]   # both frontends depend on it

The cross-refs walk is transitive: if web-react in turn had dependents, they would be pulled in too. Model the real import graph; when in doubt, add the edge — a missed edge is a false-green, an extra edge merely runs one more suite.

Per-component test commands

Each stack’s test_cmd is the exact command CI runs for that component, executed from the repository root. It must select only that component’s tests — the narrowing is only as good as the command’s scoping:

If a test runner cannot select by directory, organise tests so each component’s tests live under their own path (or carry a tag the command can filter on). A flat test pile where any component’s tests are interleaved cannot be narrowed reliably.

Before / after

Change Single stack (whole repo) Component stacks
One React component Full suite (all languages) React tests only
One Python route Full suite pytest (API) only
Docs typo Full suite markdown lint only
Shared design token Full suite design-system + dependent frontends
Promotion staging → main Full suite Full suite (unchanged — last gate before release)

Setup checklist

  1. List your repo’s components and the directory each owns.
  2. Add one stacks[] entry per component with a non-overlapping paths list and a test_cmd that runs only that component’s tests.
  3. Organise tests so each component’s tests are selectable by path (or tag).
  4. Add cross_refs edges for every “B depends on A” relationship (declared on A, listing its dependents).
  5. Validate with /gaia-config-validate, then open a small single-component pull request and confirm CI ran only that component’s suite.
  6. Keep the full suite on the staging → main promotion — it is the final gate and is intentionally not narrowed.

What to read next: Selective Test Execution for the per-trigger test_policy and safety rails, and Shape: Multi-Repo / Per-Service when components live in separate repositories.