fix(config-nx-scopes): read scopes from the nx project graph by dylanpulver · Pull Request #4957 · conventional-changelog/commitlint · GitHub
Skip to content

fix(config-nx-scopes): read scopes from the nx project graph - #4957

Merged
escapedcat merged 4 commits into
conventional-changelog:masterfrom
dylanpulver:fix/nx-scopes-inferred-projects
Aug 20, 2026
Merged

escapedcat merged 4 commits into
conventional-changelog:masterfrom
dylanpulver:fix/nx-scopes-inferred-projects

Conversation

@dylanpulver

@dylanpulver dylanpulver commented Aug 19, 2026 •

Copy link
Copy Markdown
Contributor

Description

@commitlint/config-nx-scopes built its scope list by calling getProjects() from
nx/src/generators/utils/project-configuration.js against a FsTree. That helper is a
generator utility and only reports projects that have a project.json or a package.json
on disk. A project that an nx plugin contributes through createNodes has neither, so it
never reached scope-enum and committing against it failed with "scope must be one of
[...]".

The config now reads the nx project graph instead. createProjectGraphAsync() runs the
workspace's plugins before returning, so plugin-inferred projects show up alongside
everything that was already being found. Project names come from graph.nodes, and each
node's data still carries the projectType and tags that the getProjects selector
is documented to receive.

One detail worth calling out. Nx works out its workspace root from the cwd of the process
that loads it, then caches that value in places that are not re-read later, including its
daemon client and its cache directory paths. Reassigning the root afterwards gives you a
graph for the right directory but a cache and a daemon keyed to the wrong one. So the
graph is read in a child process started in ctx.cwd, and spawnSync blocks until that
process exits. That keeps ctx.cwd behaving as it does today and keeps nx free to answer
from its daemon when the user has one. The new project-graph.js is the child entry point
and is added to the package's files.

Keeping nx's output out of the payload

The reader hands its JSON back on file descriptor 3 rather than stdout, because stdout is
not ours to keep clean. Nx prints progress there, and it runs workspace plugins in worker
processes that inherit the reader's stdout, so anything a plugin logs arrives interleaved
with the payload. Replacing process.stdout.write inside the reader does not help, since
those writes come from a different process. A sentinel line the parent slices on would
survive the ordinary case, but it still shares one channel with output we do not control,
from processes that can print after the payload has started.

A child's inherit stdio only covers the first three descriptors, so a fourth pipe is a
channel nothing below the reader can write to. The parent asks for
stdio: ["ignore", "pipe", "pipe", "pipe"] and reads result.output[3]; stdout and
stderr are captured for the error message and nothing else. maxBuffer is Infinity
because the default 1 MB cap counts the payload and nx's chatter together, and it aborts
the read with ENOBUFS rather than truncating, which a large workspace or a chatty plugin
can reach.

The descriptor is declared in exactly one place. The parent owns it and passes it to the
reader as its first argument, and the reader writes to whatever it is handed, rejecting a
missing or non-numeric one. Changing the number on the parent side is enough; there is no
second declaration to keep in step, so the two cannot drift into an empty payload.

fixtures/noisy pins the behavior. Its plugin writes to stdout twice while inferring a
project, once through console.log and once straight to fd 1, and one of those lines is a
JSON array itself, so a reader that hunts for JSON in stdout would pick the wrong one.
With the payload on fd 3 the test passes. Moving the payload to stdout and rerunning that
fixture fails with SyntaxError: Unexpected token 'o', "noisy-plugi"... is not valid JSON, which is how I checked the fixture reproduces the problem instead of only
describing it.

The exported utils.getProjects

utils.getProjects keeps its signature and its string[] return value. The graph is
built asynchronously inside the child, but spawnSync waits for the result, so nothing
about the exported signature moves and scope-enum goes back to its plain form.
Existing configs need no edit and this ships as a patch.

An earlier revision of this PR made getProjects async and I flagged the return type for
your call. @escapedcat asked for the synchronous version and that is what this branch now
does, so the question is settled and the breaking-change checkbox stays unticked. For a
hook that runs once per commit there is nothing else in flight while nx builds the graph,
so async bought us nothing anyway.

There is a second, smaller behavior change that comes with reading the graph. Nx's own
package-json plugin attaches an npm:public tag to projects that have a public
package.json, so selectors that inspect tags will now see tags that nx adds rather
than only the ones written in project.json. Existing selectors keep working because the
change is additive.

nx peer range

Unchanged at nx: >=14.0.0. createProjectGraphAsync is exported from
nx/src/project-graph/project-graph.js across that whole range. I checked the published
tarballs for 14.0.0, 14.1 through 14.5, 14.8.6, 15.0.0, 15.9.7, 16.0.0, 16.10.0, 17.0.0,
17.3.2, 18.0.0, 19.0.0, 20.0.0, 21.0.0 and 22.0.0, and 23.1.1 as installed in this repo,
where the file now resolves through nx's exports map to dist/. No fallback path and no
peer bump are needed.

Motivation and Context

Fixes #4861

The scope list is meant to be "the projects in this workspace". Any workspace using an nx
plugin that infers projects, which is the default shape of a modern nx workspace, was
getting a partial list and had no way to commit against the missing projects.

Usage examples

// commitlint.config.js
module.exports = { extends: ["@commitlint/config-nx-scopes"] };

Given a workspace where nx show projects lists api, web and docs, and docs only
exists because a plugin inferred it:

# before: rejected, because docs was not in the enum
# after: passes
echo "docs(docs): describe the new endpoint" | commitlint

How Has This Been Tested?

Two new fixtures.

fixtures/inferred holds a small nx plugin in tools/inferring-plugin.js that turns a
nx/*/inferred.marker file into a project. The fixture has fixture-inferred-i, which
exists only through that plugin and has no project.json or package.json, next to
fixture-inferred-j, which is an ordinary project.json project. Having both means the
tests show the change adding projects rather than swapping one discovery mechanism for
another.

fixtures/noisy holds a plugin that prints to stdout while it infers fixture-noisy-k,
covering the stdout question above.

Tests added to index.test.js:

  • scope-enum on the inferred fixture returns both the inferred and the explicit project
  • scope-enum on the noisy fixture returns the project and ignores what the plugin printed
  • utils.getProjects is still exported and still returns the project names on the existing
    basic fixture, without a promise, which pins the back compat decision above
  • the selector receives the inferred project's tags, filtering down to
    fixture-inferred-i
  • the selector receives the inferred project's projectType

Four of those five fail against master with assertion errors rather than import or build
errors. The fifth is the synchronous return value, which passes on master on purpose:
it exists to catch a regression in what this PR must not change. The tests set
NX_DAEMON=false, because each fixture runs in its own throwaway directory and a daemon
started for one of them would outlive the run with nothing left to watch.

What I ran locally on Node 22.12 and pnpm 11.22:

  • pnpm build
  • pnpm test (the full suite, 91 files and 1216 tests, plus typecheck, all passing)
  • pnpm lint and pnpm format, both clean

I also confirmed by hand that the daemon path works, by running the fixtures once without
NX_DAEMON=false and getting the same project lists, and that no daemon processes or .nx
directories are left behind after pnpm test. Payloads with multi-byte project names and
payloads several MB large come back over fd 3 intact.

My machine is macOS, so the fourth stdio pipe is the piece I would most want a second
platform on; the CI matrix exercises it on Windows and Ubuntu across Node 22 and 24.

What I did not run: pnpm run pkg fails in my checkout for @commitlint/config-nx-scopes
and equally for untouched packages such as @commitlint/config-lerna-scopes, because the
packed tarball cannot resolve workspace: dependencies outside the monorepo. That looks
like a local environment limitation rather than anything this change introduced, so I have
left it to CI. I have not tested against an nx version older than 23 at runtime, since the
repo installs a single nx; the peer range claim above rests on reading the published
packages.

Types of changes

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to change)

The return type question that was open here is resolved: utils.getProjects still returns
string[], so nothing on the public surface changes and this is a bug fix.

Checklist:

  • My change requires a change to the documentation.
  • I have updated the documentation accordingly.
  • I have verified that any documentation examples I added/changed actually work.
  • I have added tests to cover my changes.
  • All new and existing tests passed.
  • For a feature/bug fix, my commits follow the test-driven flow: a failing-test commit,
    then the implementation.

@greptile-apps

greptile-apps Bot commented Aug 19, 2026 •

Copy link
Copy Markdown

@escapedcat

Copy link
Copy Markdown
Member

Thanks!

Created this response in cooperation with Claude after going through different points. Let me know if this makes sense for you.

Thanks for the really thorough writeup, Dylan — the reasoning is easy to follow, and the fixtures/inferred setup (an
inferred project sitting next to an explicit one) makes the fix easy to trust. Reading scopes from the project graph via
createProjectGraphAsync is the right call, and the child-process-per-cwd reasoning for nx's baked-in workspace root is
sound.

You flagged the utils.getProjects return-type change for my call rather than just ticking the box — appreciated. I'd like
to keep this a non-breaking fix if we can, and I think we can. The only thing forcing a major here is that getProjects
became async, and for a hook that runs once per commit we gain nothing from async — there's nothing else happening while
nx builds the graph. If we keep the public signature synchronous, this ships as a patch and reaches affected users on the
normal cadence instead of waiting on our next major.

Concretely: could we read the graph synchronously instead of fork + IPC? Have project-graph.js print the projects as JSON
to stdout, and read it back with spawnSync so getProjects stays string[]:

  function readProjectGraph(cwd) {
    const { status, stdout, stderr } = spawnSync(process.execPath, [projectGraphReader], {
      cwd,
      encoding: "utf8",
    });
    if (status !== 0) {
      throw new Error(`Unable to read the nx project graph in "${cwd}".\n${stderr || stdout}`.trim());
    }
    return JSON.parse(stdout);
  }

Then getProjects drops the async/await, and scope-enum goes back to its plain form. Same fix, same fixtures, no signature
change — so it stays a fix: patch.

The one wrinkle I can see: since we'd be parsing stdout, nx's progress output can't leak onto it. Keeping the payload on
its own channel (printing only the JSON, or a sentinel line the parent slices on) should cover it, but I'll leave the
exact approach to you.

If there's a reason sync won't work that I'm missing, say so and we'll take the breaking route and roll it into the next
major instead — but my preference is to ship this as a patch. Really appreciate the work here.

…graph

Read the nx project graph with spawnSync instead of fork, so getProjects
keeps returning string[] and the fix stays a patch. The reader writes its
JSON payload to file descriptor 3, a channel nx, its daemon and the worker
processes it runs plugins in never write to, so their output on stdout
cannot corrupt it.
@dylanpulver

Copy link
Copy Markdown
Contributor Author

Done, and it stays a patch. getProjects is synchronous again and returns string[], scope-enum is back to its plain form, and the public signature is unchanged.

I did take a different channel than stdout, and the reason is worth a paragraph because it also answers the wrinkle you flagged.

Muzzling nx's output inside the reader does not work. Nx runs workspace plugins in worker processes that inherit the reader's stdout, which I confirmed by printing pid and ppid from inside a fixture plugin and getting a different pid back. Replacing process.stdout.write in the reader cannot stop a write coming from another process. A sentinel line survives the common case, but it still shares one channel with processes we do not control, and the daemon or a plugin worker can print after the payload has started.

So the payload goes to file descriptor 3. The parent spawns with stdio: ["ignore", "pipe", "pipe", "pipe"] and reads result.output[3], while stdout and stderr are captured only to build the error message. A child's inherit stdio wires the first three descriptors, so nothing below the reader can write to fd 3 at all. The fork version already used a four slot stdio array and passed Windows CI, and Node documents the extra pipe slots with no Windows caveat.

There is a regression test for it. fixtures/noisy runs a plugin that writes to stdout twice while inferring a project, once through console.log and once through a raw fs.writeSync(1, ...) whose line is itself a JSON array, so a parser hunting for JSON picks the wrong one. On fd 3 the test passes. Pointing the payload at stdout and rerunning the same fixture fails with a parse error.

I also set maxBuffer: Infinity, since the 1 MB default counts the payload plus nx's own output and aborts with ENOBUFS on a large workspace.

1216 tests pass with typecheck clean. Four of the five new assertions fail against master, and the fifth is the synchronous return, which is there to catch a future regression rather than to fail today.

One ask: the workflow runs are sitting at action_required, so CI has not run on this commit. The Windows behavior of the fd 3 pipe is the one piece I have reasoned about rather than observed on this branch, so an approval would be useful.

@escapedcat

Copy link
Copy Markdown
Member

Thanks! All good, just one last small thing to improve:

payloadFd = 3 is declared separately in both index.js and project-graph.js, and they have to agree: the parent reads
output[payloadFd] and the child does writeSync(payloadFd, …). Nothing ties the two together, so if one ever changes
without the other it won't be caught until it fails at runtime (empty payload → "Unable to read the nx project graph"). CI
shows they line up today — I'd just like to remove the chance of them drifting later.

Could you give it a single source of truth? Either a one-line shared module both import, or pass the fd to the child as an
argv so the parent owns it — whichever's least fuss for you.

…raph reader

The descriptor the reader writes its payload to was declared in index.js and
again in project-graph.js, and the two had to agree by hand. The parent now
owns the number and passes it to the reader on its command line, so it is
declared once and the two sides cannot drift apart.
@dylanpulver

Copy link
Copy Markdown
Contributor Author

Done in 5d1550d. I went with the argv option, so the parent owns the number and hands it to the reader.

The reason I did not use a shared module: it would mean a third file in the package, and files in package.json has to list it. Forgetting that entry produces exactly the failure class you want removed, a runtime-only "Unable to read the nx project graph" from a published tarball that CI never sees because CI runs from source. The argv version adds no file and keeps the reader runnable on its own.

The child now takes the descriptor from process.argv[2] and rejects anything that is not an integer of 3 or greater, so a bad value fails loudly at startup instead of arriving as an empty payload.

I checked the drift case rather than assuming it. Changing only the parent to fd 4, with the stdio array widened to match, still passes 18 of 18, because the child follows what it is given. Under the previous code that same one-sided edit produced an empty payload with no error.

Full suite is 1215 passing with typecheck clean. The single failure is the travis-cli git clone test, which is network dependent and self-labelled as locally flaky; it fails the same way on an unmodified tree here and passes when run on its own.

One thing outside my control: the workflow runs are still sitting at action_required for this fork, so CI has not executed on either of the last two commits.

@escapedcat
escapedcat added this pull request to the merge queue Aug 20, 2026
Merged via the queue into conventional-changelog:master with commit 013c358 Aug 20, 2026
12 checks passed
@escapedcat

Copy link
Copy Markdown
Member

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

config-nx-scopes does not find plugin-inferred projects

2 participants