How Nx decides what a changed file affects

Affected detection answers two separate questions. Most misconfigurations come from treating them as one.

Question one: which projects are touched?

Nx diffs your working tree against a base commit and gets a list of changed file paths. It assigns each path to the project whose root directory contains it. It then walks the project graph in reverse: if api depends on shared-protos, a change owned by shared-protos marks api as affected too. Those edges come from two places. Either a language plugin inferred them, typically by analyzing imports, or you declared them yourself. A file that sits under no project root has no owner, so nothing becomes affected through the normal path.

Question two: which task hashes change?

Separately, each target has inputs: the file patterns, environment values and dependency outputs that Nx hashes to decide whether a cached result is still valid. Inputs control cache correctness, not project ownership. A file can sit inside a project's root and still be excluded from a target's inputs, and the reverse can also happen.

Where non-JS files fall through

A .proto file in a directory that no project claims fails the first question. A Go service that reads that file at build time gets no graph edge if nothing taught Nx about the relationship. A SQL migration inside the project root but outside a narrowed input set fails the second question: the project shows as affected, but the cache replays a stale result.

Backend and platform engineers in polyglot repos are the most exposed. Their code generators, migration runners and template renderers consume files that import analysis cannot see.

     git diff vs base commit --> changed file paths
               |
      +--------+-----------------------+
      |                                |
      v                                v
+---------------------------+   +---------------------------+
| Q1: OWNERSHIP             |   | Q2: TASK INPUTS           |
| path under project root?  |   | path matches a target's   |
|   yes -> owning project   |   | inputs patterns?          |
|   no  -> no owner         |   |   yes -> hash changes     |
+-------------+-------------+   |   no  -> stale cache hit  |
              |                 +-------------+-------------+
              v                               |
+---------------------------+                 v
| reverse graph walk        |   +---------------------------+
| api depends on            |   | target re-runs, or Nx     |
| shared-protos: a change   |   | replays cached output     |
| there marks api affected  |   +---------------------------+
| edges: plugin-inferred    |
| (imports) or declared     |
+-------------+-------------+
              v
      affected projects

WHERE NON-JS FILES FALL THROUGH
 [A] .proto under no root      -> fails Q1: no owner, nothing affected
 [B] Go build reads .proto,    -> no graph edge: api never marked affected
     edge not inferred/declared
 [C] .sql inside root, outside -> passes Q1, fails Q2: project affected,
     narrowed inputs              but a stale cached result is replayed

Nx answers two separate questions for each changed file: which project owns it and spreads through graph edges, and which target input hashes it feeds; non-JS files can fail either one.

Why import analysis misses protobufs, migrations and templates

Nx builds its project graph largely by reading source code. For JavaScript and TypeScript it parses import and require statements, resolves them against tsconfig paths and package names, and draws an edge from the importing project to the project that owns the target file. That works because the dependency appears in the source text.

A Go service that runs protoc against libs/proto/orders.proto never writes that dependency in a form Nx parses. The path lives in a Makefile, a buf config or a generator flag. A migration runner finds .sql files by globbing a directory at startup. A Helm chart or GraphQL codegen step reads .yaml or .graphql files by path. None of these leave a trace in code that import analysis understands, so Nx sees two unrelated projects. When the shared file changes, the consuming service is not marked affected, and its cache hash does not change.

You can declare the relationship in two ways. The first is implicitDependencies in the consuming project's configuration, which lists projects it depends on even though no import connects them. The second is to give the shared files their own project, such as proto or db-migrations, and make consumers depend on it. With a dedicated project, ownership is clear, changes show up as affecting one named node, and you can attach targets like linting or breaking-change checks to it.

Plugins and project inference can also add edges and inputs automatically, depending on which plugins are installed and how they are configured. Don't assume non-JS files are covered. Inspect the graph and the resolved inputs for the consuming targets, and confirm that the shared file actually appears there.

# 1) Give the shared .proto files their own Nx project so they become a named graph node.
cat > libs/proto/project.json <<'EOF'
{
  "name": "proto",
  "projectType": "library",
  "targets": {}
}
EOF

# 2) Declare the edge the import parser cannot see. "^default" pulls the
#    dependency's files (including libs/proto/**) into this target's cache hash.
#    It assumes a "default" namedInput exists in nx.json, which is the usual setup.
cat > apps/orders-service/project.json <<'EOF'
{
  "name": "orders-service",
  "projectType": "application",
  "implicitDependencies": ["proto"],
  "targets": {
    "build": {
      "command": "make -C apps/orders-service build",
      "inputs": ["default", "^default"]
    }
  }
}
EOF

# 3) Verify the result instead of assuming it. Check the resolved inputs and
#    dependencies, including anything plugins inferred:
npx nx show project orders-service --json

# 4) Confirm that a change to the .proto file marks the consumer as affected.
#    This runs the build for the affected projects.
npx nx affected -t build --files=libs/proto/orders.proto

This example creates a dedicated proto asset project and makes orders-service depend on it through implicitDependencies and ^default inputs, then inspects the resolved configuration and checks that a .proto change marks the service as affected.

Defining named inputs and target inputs in nx.json

Nx resolves a target's inputs from named inputs, which are reusable sets of file patterns declared under namedInputs in nx.json. Most workspaces start with two. default usually covers everything under {projectRoot}/**/* plus any shared globals. production takes default and subtracts files that cannot change the built artifact, using negated patterns such as !{projectRoot}/**/*_test.go or !{projectRoot}/docs/**. The exclusions are what keep a README edit from invalidating a build cache entry.

Custom sets cover the assets that import analysis cannot see. A sqlMigrations input might point at {projectRoot}/migrations/**/*.sql. A protoSchemas input might reference {workspaceRoot}/proto/**/*.proto. {projectRoot} resolves per project, so a single definition works for every service. {workspaceRoot} anchors a path to the repository root, which suits files that no single project owns.

You attach these sets through a target's inputs array, or once in targetDefaults so every target with that name inherits them. Put shared policy in targetDefaults. Override it on a target only when one project genuinely differs.

The ^ prefix controls scope:

Entry What it hashes
production This project's production files
^production Production files of every project this one depends on
sqlMigrations This project's migration files only

A build target normally needs both production and ^production. A lint target often needs only the project's own files.

Some factors that change the output are not files at all. For these, add { "env": "DATABASE_DIALECT" } to hash an environment variable, or { "runtime": "go version" } to hash the output of a command, such as a toolchain version.

{
  "namedInputs": {
    // Files outside any project that should invalidate every hash
    "sharedGlobals": ["{workspaceRoot}/go.work"],
    // Everything the project owns, plus shared globals
    "default": ["{projectRoot}/**/*", "sharedGlobals"],
    // Subtract files that cannot change the built artifact
    "production": [
      "default",
      "!{projectRoot}/**/*_test.go",
      "!{projectRoot}/docs/**",
      "!{projectRoot}/**/*.md"
    ],
    // Assets that import analysis cannot see
    "sqlMigrations": ["{projectRoot}/migrations/**/*.sql"],
    "protoSchemas": ["{workspaceRoot}/proto/**/*.proto"]
  },
  "targetDefaults": {
    "build": {
      "cache": true,
      "inputs": [
        "production",
        "^production",
        "protoSchemas",
        // Toolchain version is not a file, so hash the command output
        { "runtime": "go version" }
      ]
    },
    "migrate": {
      // Only this project's migrations and the target dialect matter
      "inputs": ["sqlMigrations", { "env": "DATABASE_DIALECT" }]
    },
    "lint": {
      "cache": true,
      // Lint checks the project's own files, not its dependencies
      "inputs": ["default"]
    }
  }
}

An nx.json that defines default, production and custom sqlMigrations and protoSchemas named inputs, then applies them through targetDefaults. It contrasts project-scoped inputs with ^production and adds env and runtime inputs for factors that globs cannot capture.

Handling workspace-root files no project owns

A file outside every project root, such as a root Dockerfile, a buf.yaml, a top-level go.work, or a shared schemas/ directory, has no owner in the project graph. Import analysis cannot link it to anything, and {projectRoot}/**/* never matches it. Unless an input names the file explicitly, editing it leaves task hashes unchanged. Nx then replays cached results that were built against the old file, and that failure produces no error. Do not depend on how a particular Nx version handles unowned files in affected. Declaring the file as an input makes the dependency explicit and makes it part of the hash.

You have two places to declare these files. Files that really do influence every task, such as the root toolchain pin or a shared lint config, belong in a global named input. Many workspaces call it sharedGlobals and include it in default. Every other root file should go in a targeted {workspaceRoot} fileset on the specific targets that read it. For example, {workspaceRoot}/proto/**/*.proto would go on code-generation targets only, and the root Dockerfile on container-build targets only.

The opposite mistake is adding {workspaceRoot}/**/* to default so that nothing gets missed. That pattern matches every file in the repository, so a README typo changes every hash, invalidates the whole cache, and marks every project as affected. You lose the incremental behavior you adopted Nx to get.

To choose the scope, ask one question for each root file: which tasks read it during execution? Add the file as an input to exactly those tasks and to no others. If the answer is "all of them," a global input is justified. If you cannot answer the question, find out before you add the file anywhere.

// nx.json (Nx parses this file with comments allowed)
{
  "namedInputs": {
    // Root files that genuinely affect every task. Keep this list short.
    "sharedGlobals": [
      "{workspaceRoot}/go.work",
      "{workspaceRoot}/.editorconfig"
    ],
    // Do NOT add "{workspaceRoot}/**/*" here. It would put every repo
    // file (README included) into every hash.
    "default": ["{projectRoot}/**/*", "sharedGlobals"]
  },
  "targetDefaults": {
    // Only code generation reads the shared proto tree and buf config.
    "codegen": {
      "inputs": [
        "default",
        "{workspaceRoot}/buf.yaml",
        "{workspaceRoot}/proto/**/*.proto"
      ],
      "cache": true
    },
    // Only container builds read the root Dockerfile.
    "docker-build": {
      "inputs": ["default", "{workspaceRoot}/Dockerfile"],
      "cache": true
    },
    // Migrations read the shared SQL schema directory, nothing else does.
    "db-migrate-check": {
      "inputs": ["default", "{workspaceRoot}/schemas/**/*.sql"],
      "cache": true
    }
  }
}

An nx.json that keeps truly global root files in a small sharedGlobals input and attaches each other unowned root file (proto tree, Dockerfile, SQL schemas) as a targeted {workspaceRoot} input only on the targets that read it.

Worked example: a Go service with shared protobufs and SQL migrations

Take a repo with two services and two shared asset directories. apps/orders-api is a Go service whose build target is a run-commands wrapper around go build. apps/billing-worker is a Python service. Both compile stubs from libs/proto/*.proto. Only orders-api applies the SQL files in db/migrations, through a migrate target.

Before

Neither libs/proto nor db/migrations contains a project.json, so no project owns those files. The services import nothing that Nx's analysis can follow into them. Editing 0042_add_refund_index.sql therefore marks nothing as affected, and CI skips the one service that needs to re-run its migration tests.

After

Add a project.json with no targets to each directory. This creates two asset projects, proto and db-migrations, and gives every file in them an owner. orders-api lists both projects in implicitDependencies. billing-worker lists only proto.

Next, define named inputs: protoFiles for {projectRoot}/**/*.proto and migrationFiles for *.sql. Wire them in with the ^ dependency form. The Go build target hashes ^protoFiles but not ^migrationFiles, and migrate does the reverse.

This split matters because the two settings control different things. The graph edges decide which projects are affected. The inputs decide which targets on those projects get a new cache hash and actually execute.

Changed file Affected before Affected after
db/migrations/0042_add_refund_index.sql none db-migrations, orders-api
libs/proto/payment.proto none proto, orders-api, billing-worker
apps/orders-api/main.go orders-api orders-api

After a migration-only change, orders-api:build should still be a cache hit, and only migrate should run.

Verifying the configuration instead of trusting it

A table of expectations is only a hypothesis until Nx confirms it. Start with nx graph and check the edges. If orders-api has no edge to proto, no input pattern will compensate, because the dependency was never declared. Then run nx show project orders-api and read the resolved inputs for each target. Named inputs are expanded there, so a glob that points at the wrong directory or a {projectRoot} that should have been {workspaceRoot} shows up immediately.

Next, check behaviour against a real change. Create a throwaway commit that touches only one asset, such as a single .sql migration. Run nx affected with an explicit --base set to its parent and --head set to the commit. Compare the affected list with your table. If too few projects appear, a pattern is missing. If every project appears, a broad glob such as a bare {workspaceRoot}/**/* is pulling in more than it should.

CI adds two failure modes that local runs hide:

  • Shallow clones. If the base commit is not in the fetched history, Nx cannot compute a diff. Depending on how the pipeline handles that error, you end up with either a failed step or a fallback that treats everything as affected. Fetch enough history to include the base.
  • Wrong base. Comparing against the previous commit instead of the merge base or the last successful run can either hide changes from earlier commits or include unrelated ones.

Patterns go stale when a team adds a directory such as schemas/ that nothing references. A cheap guard is a scheduled job or a CI check that lists asset files by extension and fails when one matches no project's inputs.

Trade-offs, alternatives and impact on engineering time

Precise inputs pay off when shared assets fan out to many consumers and CI time is the bottleneck. In a repo with a handful of projects and one proto/ directory, a single broad input on every target costs a few redundant runs. That is usually cheaper than keeping fine-grained patterns accurate.

Approach Precision Structural cost Main risk
implicitDependencies Coarse: any change in the upstream project triggers dependents Low Re-runs on unrelated edits
Dedicated asset project Good, and visible in the project graph Can conflict with Go module layout, Python packaging, or the paths a migration runner expects Bending toolchain conventions to fit Nx
Broad global inputs Lowest Lowest Any matching change invalidates every cache
Per-target named inputs Highest Ongoing maintenance Patterns go stale

The two failure modes do not cost the same. Over-broad inputs are visible: CI slows down and cache hit rates drop, and both show up in run times. Under-specified inputs fail silently. Nx replays a cached green result for tests that never ran against the changed schema, and the break surfaces later in staging. If you are unsure whether a file matters, include it.

Illustrative estimate (assumed numbers, not a benchmark)

Weekly CI minutes saved = PRs per week × (targets run with broad inputs − targets run with precise inputs) × minutes per target.

Assume 150 PRs, 12 targets versus 4, and 3 minutes per target: 150 × 8 × 3 = 3,600 runner-minutes per week.

Debugging time saved = false-green incidents avoided × hours per incident. Assume one incident per quarter at 6 engineer-hours each. Weigh both totals against the setup and upkeep cost of the precise configuration.

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 affected 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.