How Nx decides what is affected, and why non-JS files slip through

Nx answers "what is affected?" in two steps. It diffs a base commit against the head to get a list of changed files. It then assigns each file to the project whose root directory contains it. From those directly touched projects, it walks the project graph and marks every dependent project as affected as well. Edges in that graph come from explicit configuration or from plugins that infer dependencies by reading source imports and package manifests.

Non-JavaScript assets fail at both steps. A top-level /proto or /db directory that no project claims has no owner. Depending on configuration, Nx either ignores a change there or treats it as a global change that invalidates everything. The first is a silent CI gap. The second makes affected useless.

Code generation causes the second failure. A Go service compiles stubs that protoc produced from orders.proto. Its source imports the generated package and never references the .proto file, so import-based inference finds no edge back to the schema.

repo/
├── libs/proto/          project: proto      (owns *.proto)
├── services/orders-go/  project: orders-go  (generated Go stubs)
├── services/billing-ts/ project: billing-ts (generated TS clients)
└── db/                  project: db         (migrations/*.sql)

  proto ──codegen──▶ orders-go ──reads schema──▶ db
    └────codegen──▶ billing-ts
  (none of these edges is inferred automatically)

This affects backend and platform teams running polyglot monorepos, where schemas, migrations and service configs change more often than shared TypeScript libraries.

 git diff base..head ──▶ changed files ──▶ owner = project whose root
                                           contains the file
                                                  │
                                                  ▼
                         walk project graph ──▶ mark dependents affected

                                  codegen
 ┌──────────────────────┐                          ┌──────────────────────┐
 │ libs/proto/          │─────────────────────────▶│ services/orders-go/  │
 │ project: proto       │                          │ project: orders-go   │
 │ owns *.proto         │                          │ generated Go stubs   │
 └──────────────────────┘                          └──────────────────────┘
            │ codegen                                          │ reads schema
            ▼                                                  ▼
 ┌──────────────────────┐                          ┌──────────────────────┐
 │ services/billing-ts/ │                          │ db/                  │
 │ project: billing-ts  │                          │ project: db          │
 │ generated TS clients │                          │ migrations/*.sql     │
 └──────────────────────┘                          └──────────────────────┘
 Edges above are real dependencies that import-based inference never sees.

 If /proto or /db sits at the repo root with no project claiming it:
 ┌──────────────┐             ┌───────────────────────────────────────────┐
 │ /proto, /db  │─── change ─▶│ ignored (CI gap) OR global (all affected) │
 │ (no owner)   │             │                                           │
 └──────────────┘             └───────────────────────────────────────────┘

Nx maps changed files to the project whose root contains them, then walks graph edges; the codegen and schema edges from proto and db are never inferred, and unowned root directories are either ignored or treated as global.

Affected detection versus cache inputs: two separate mechanisms

nx affected and the computation cache answer different questions, and each uses its own data. Affected asks which projects a change touches. It maps every changed file to the project whose root contains it, then follows graph edges to collect dependents. Caching asks whether a target's output can be reused. It hashes the files and values listed in that target's inputs, usually built from namedInputs. Affected selection does not read your declared inputs, and the cache does not read your git diff.

Because the two are independent, you can get one right and the other wrong, and each mistake fails differently.

Project selected, stale output. A proto lives in a shared project, and an implicit dependency correctly marks the service as affected. But the service's build inputs omit ^production, or a production named input excludes *.proto. The task runs, gets a cache hit, and restores artifacts generated from the old schema. CI is green and the build is wrong.

Correct hash, never selected. A target lists {workspaceRoot}/db/schema.sql as an input, so its hash changes when the schema changes. But no project owns that file and no edge points to it. Affected never selects the project, so the correct hash is never computed.

Symptom Mechanism at fault Fix
Task runs but restores output that predates the asset change Cache inputs Add the file pattern or ^namedInput to the target's inputs
Task never runs after the asset changes Affected (ownership or edges) Move the asset into a project, or add an implicit dependency
Task runs every time even when nothing relevant changed Cache inputs too broad Narrow the named input to the files that actually feed the target
Affected selects far too many projects Edges too broad Replace workspace-wide dependencies with edges to specific projects

Defining namedInputs for SQL, protos and configs in nx.json

Start in nx.json by defining one named input per asset type, so targets compose them instead of repeating globs. A sql input such as {projectRoot}/**/*.sql covers migrations and queries. A protos input covers {projectRoot}/**/*.proto plus the workspace-level {workspaceRoot}/buf.yaml and {workspaceRoot}/buf.gen.yaml, because lint rules and generator plugins change output even when no .proto file does. A configs input collects service configs ({projectRoot}/**/*.{yaml,yml,json,toml}), {projectRoot}/Dockerfile, and {workspaceRoot}/.sqlfluff for SQL linting.

Build production from default and add negations: !{projectRoot}/**/README.md, !{projectRoot}/**/fixtures/**, and !{projectRoot}/**/seeds/test/**. Without these exclusions, editing a test seed invalidates the build cache and the image build for that service. Order matters: put the negations after the positive patterns they narrow.

Some things that change output are not files. Add { "env": "DATABASE_DIALECT" } where a variable alters generated SQL. Add runtime inputs such as { "runtime": "buf --version" } or { "runtime": "protoc --version" }, and do the same for your migration tool's version command. A toolchain upgrade then misses the cache instead of replaying stale generated code.

Wire these through targetDefaults so every codegen, migrate or docker-build target picks them up. For example, codegen takes ["protos", "^protos"], which also tracks protos in dependency projects. Override inputs on an individual target only when that project actually differs from the default.

{
  "namedInputs": {
    "default": ["{projectRoot}/**/*", "sharedGlobals"],
    "sharedGlobals": [],
    "sql": ["{projectRoot}/**/*.sql"],
    "protos": [
      "{projectRoot}/**/*.proto",
      "{workspaceRoot}/buf.yaml",
      "{workspaceRoot}/buf.gen.yaml"
    ],
    "configs": [
      "{projectRoot}/**/*.{yaml,yml,json,toml}",
      "{projectRoot}/Dockerfile",
      "{workspaceRoot}/.sqlfluff"
    ],
    "production": [
      "default",
      "!{projectRoot}/**/README.md",
      "!{projectRoot}/**/fixtures/**",
      "!{projectRoot}/**/seeds/test/**"
    ]
  },
  "targetDefaults": {
    "codegen": {
      "inputs": ["protos", "^protos", { "runtime": "buf --version" }, { "runtime": "protoc --version" }]
    },
    "migrate": {
      "inputs": ["sql", "configs", { "env": "DATABASE_DIALECT" }, { "runtime": "./tools/migrate --version" }]
    },
    "docker-build": {
      "inputs": ["production", "^production", "configs"]
    }
  }
}

An nx.json that defines sql, protos, configs and production named inputs, then wires them, plus env and runtime inputs, into codegen, migrate and docker-build via targetDefaults; './tools/migrate' stands in for your migration tool.

Turning shared assets into projects and modeling code generation

…does something different, such as reading protos from an unusual path.

Giving the assets a project of their own

Nx only infers dependencies for languages it understands, and it cannot follow a Go import or a buf module reference back to a .proto file. To make /proto participate in the graph, give it a project.json so it becomes a project like any other. The database works the same way: /db/project.json defines a migration-lint target using nx:run-commands, with inputs scoped to the sql named input. A typo in a migration then affects db and nothing else unless something depends on it.

Consumers declare that dependency explicitly. The Go orders-service and the TypeScript orders-client both list proto in implicitDependencies, and anything that reads the schema lists db. A change under /proto now marks both consumers as affected.

Each consumer owns a codegen target that runs buf generate or protoc. Its inputs are ^protos, and its outputs point at the generated directory, for example {projectRoot}/gen. In build, dependsOn: ["codegen", "^build"] does two things. The bare name runs this project's codegen first. The caret form builds dependencies first.

Whether generated code is checked in changes the behavior. Checked-in output appears in the git diff, so affected sees it directly. Stale generated files are a review problem, not a build failure. Build-time generation keeps the repository clean, but it depends entirely on correct outputs. A remote cache hit restores only declared directories. If you omit gen, the replayed task will compile against nothing.

{
  "name": "orders-client",
  "$schema": "../../node_modules/nx/schemas/project-schema.json",
  "implicitDependencies": ["proto"],
  "targets": {
    "codegen": {
      "executor": "nx:run-commands",
      "inputs": ["^protos", "{projectRoot}/buf.gen.yaml"],
      "outputs": ["{projectRoot}/gen"],
      "options": {
        "command": "buf generate ../../proto --template buf.gen.yaml",
        "cwd": "{projectRoot}"
      }
    },
    "build": {
      "executor": "nx:run-commands",
      "dependsOn": ["codegen", "^build"],
      "inputs": ["default", "^production"],
      "outputs": ["{projectRoot}/dist"],
      "options": {
        "command": "tsc -p tsconfig.json",
        "cwd": "{projectRoot}"
      }
    }
  }
}

A consumer project.json for apps/orders-client: proto is an implicit dependency, codegen hashes proto files and declares gen as an output so cache hits restore it, and build runs codegen first, then upstream builds.

Verifying the configuration before trusting CI

Each of these failure modes is silent: the pipeline stays green and reports a cache hit while the artifact is stale. Verify the configuration on a throwaway branch before CI depends on it.

Inspect what Nx resolved

Run nx show project <name> for a consumer project and read the inputs listed for each target. Do not rely on what you meant to write in nx.json. Check that your named inputs expanded to the paths you expected. A glob rooted at {projectRoot} when the protos live in a shared directory is the usual mistake, and it shows up here immediately.

Next, open nx graph and confirm there is an edge from each consumer to the asset project that owns the .proto or .sql files. If the edge is missing, affected detection will not reach the consumer, however correct its inputs are.

Change one file, then compare

Create a branch that modifies only a single .proto file. Run nx affected with an explicit --base and --head. Explicit refs remove any doubt about which comparison Nx made. The affected list should contain the generator project and every downstream service, and nothing else. Repeat the test with a branch that touches only a migration .sql file.

Then run the target itself and read the end-of-run summary, adding --verbose if you need more detail. Every task you expected to rebuild should show as executed, not restored from cache. If a task reports a cache hit after its input changed, you have found a false cache hit while it costs only a few minutes. Found later, it would have shipped a service built against an old schema.

#!/usr/bin/env bash
# Verify that Nx treats .proto and .sql changes as real inputs before CI relies on it.
set -euo pipefail

BASE=main

# 1. Inspect the inputs Nx actually resolved for a consumer's build target.
#    Confirm the named inputs expanded to the shared proto/sql paths you intended.
npx nx show project orders-service --json | jq '.targets.build.inputs'

# 2. Confirm the graph has an edge from the consumer to the asset project.
#    An empty result means affected detection will never reach orders-service.
npx nx graph --file=graph.json
jq '.graph.dependencies["orders-service"][] | select(.target == "shared-protos")' graph.json

# 3. Throwaway branch that changes only one .proto file.
git switch -c verify/proto-only "$BASE"
echo '// verification change' >> libs/shared-protos/src/orders.proto
git commit -am 'test: touch orders.proto only'

# Explicit refs remove doubt about which comparison Nx made.
# Expect: the generator project plus every downstream service, nothing else.
npx nx show projects --affected --base="$BASE" --head=HEAD

# 4. Run the target and read the summary. Every expected task must be executed,
#    not restored from cache. A cache hit here is a false cache hit.
npx nx affected -t build --base="$BASE" --head=HEAD --verbose

# 5. Repeat with a branch that touches only a migration .sql file.
git switch -c verify/sql-only "$BASE"
echo '-- verification change' >> libs/db-migrations/migrations/0042_add_index.sql
git commit -am 'test: touch migration only'
npx nx show projects --affected --base="$BASE" --head=HEAD
npx nx affected -t build --base="$BASE" --head=HEAD --verbose

# Clean up the throwaway branches.
git switch "$BASE" && git branch -D verify/proto-only verify/sql-only

A throwaway-branch script that inspects resolved inputs and graph edges, then changes a single .proto or .sql file. It runs affected with explicit --base/--head so you can confirm the right projects rebuild instead of being restored from cache.

When to model precisely, when to avoid it, and the alternatives

Precise modeling pays for itself when a wrong answer from affected is expensive. Three conditions point that way. First, many services consume the same proto or SQL schema, so the gap between "rebuild everything" and "rebuild three services" is large. Second, builds or integration suites take long enough that skipping them changes how fast CI finishes. Third, the shared schemas change often enough that the graph is exercised every week. In a small repo with a handful of projects, or where the affected checks finish in seconds, the configuration costs more to maintain than it saves. A missed edge there is also cheap to catch by eye.

The main risk is drift. Hand-written edges reflect the repo as it was when someone wrote them, and nothing warns you when a service starts reading a new proto. run-commands targets make this worse. Nx sees a shell command, not the files that command opens, so no graph is inferred from them. Every dependency a script relies on must be declared, or a plugin must declare it.

Approach Accuracy Maintenance Fits when
implicitDependencies by hand Good until it drifts Grows with every new consumer Few, stable edges
Per-package modeling (assets as projects) High Moderate, one-time restructuring Shared schemas with many consumers
Custom plugin that parses imports Highest, self-updating You own the parser Large repos, frequent schema changes
Checked-in generated code High for consumers Regeneration discipline, noisy diffs Generators are slow or unstable
Broad global inputs Safe but coarse Minimal Small repos, cheap builds
Always run cheap checks (e.g. SQL lint) Complete None The check takes seconds

Impact on development cost and engineering time

Over-broad inputs fail loudly. Declaring {workspaceRoot}/**/* as a dependency means any README edit changes every task hash. Every PR becomes a full rebuild, and the remote cache stops being useful. Too-narrow inputs fail silently, which makes them more dangerous. A proto change that misses the client-generation hash replays a cached build containing yesterday's stubs. A migration folder outside inputs lets CI report green while the schema never changed. You pay for these in incident time, not CI minutes.

Undeclared tool versions and environment variables cause a related failure. If protoc differs between a laptop and a CI runner, or a codegen target reads DATABASE_URL without declaring it as an env or runtime input, both machines compute the same hash for different outputs. Whichever writes first poisons the shared cache for everyone else.

Engineers often respond by moving global files into project folders to escape broad inputs. That spreads duplicated config across the repo. It is usually better to accept a narrow named input that points at the shared location. Either way, someone must update inputs whenever a new asset type appears. Make that ownership explicit.

Illustrative estimate (assumed numbers, not a benchmark)

Formula: daily CI minutes = PRs × affected tasks × minutes per task × (1 − cache hit rate)

Assumptions: 40 PRs/day, 60 projects, 3 minutes per task.

Strategy Affected tasks Cache hit rate Daily CI minutes
Broad 60 0 40 × 60 × 3 × 1 = 7,200
Narrow 4 0.2 40 × 4 × 3 × 0.8 = 384
Well-scoped 7 0.2 40 × 7 × 3 × 0.8 = 672

Narrow inputs look cheapest because the cost of stale outputs never appears in the CI bill.

How OneEnv can help

Disclosure: OneEnv is the product behind this blog.

OneEnv (oneenv.io) is a workspace-based governance platform for the things services share — environment variables, database structure, API routes and validation rules. A change to a shared value is staged on a branch, reviewed once, and then every affected service explicitly approves or rejects it before it goes live, so you can see who a change touches before it ships instead of finding out in production.

Applied to nx inputs non js files, this is where it helps:

Saves time

  • Every service that inherits a shared value is listed in one place, so nobody searches repos and chats to find out who is affected.
  • One review per branch replaces coordinating each team separately; each service still keeps its own approve/reject.
  • Changing a shared variable is one edit and one review instead of editing every service's .env by hand.

Saves cost

  • Fewer production incidents and rollbacks caused by a shared value, route or column changing under a consumer unannounced.
  • Less duplicated configuration to maintain: one shared definition inherited by many services.
  • A wrong or stale value is caught at review time by the services it affects, not by a failed deploy.
  • OneEnv has a free plan (1 workspace, unlimited services and members, full governance), so trying it costs nothing.

These are qualitative benefits, not measured results — how much you save depends on how many services share the value.

Want to try it? Start at oneenv.io.