Skip to content

fix(linter): flag banned external imports reached through internal projects - #36654

Open
leosvelperez wants to merge 8 commits into
masterfrom
gh-36519
Open

fix(linter): flag banned external imports reached through internal projects#36654
leosvelperez wants to merge 8 commits into
masterfrom
gh-36519

Conversation

@leosvelperez

@leosvelperez leosvelperez commented Aug 13, 2026

Copy link
Copy Markdown
Member

Current Behavior

With checkNestedExternalImports: true, importing an internal project never reports a violation for that project's transitive external dependencies:

  • With a bannedExternalImports constraint, a child project depending on the banned package produces no violation. Only direct imports of the package are flagged, so the nested check does nothing.
  • With an allowedExternalImports constraint and no ban list, the nested check is skipped entirely. A child project depending on a package outside the allowed list is never reported.

Expected Behavior

Importing an internal project whose transitive external dependencies violate a bannedExternalImports or allowedExternalImports constraint reports a violation. The error names the violating package and the child project where it was found. Each package is reported once per child project. The allowed list is exclusive on the nested path, as it already is for direct imports.

Related Issue(s)

Fixes #36519

Implementation Notes

  • The subpath-matching guard feat(linter): allow banning of deep/secondary paths #17755 added to isConstraintBanningProject compares the original import specifier against the external package name. On the nested path the specifier names the imported internal project while the compared package is the transitive external, so the guard never matched. Nested matching now uses each transitive package's own name. The direct import path keeps the specifier-based subpath matching.
  • Nested matching stays package-granular, same as before feat(linter): allow banning of deep/secondary paths #17755, since the project graph records external dependencies at the package level. A lodash/fp ban does not flag a child depending on lodash. The rule options docs state this.
  • The nested check ran only when a constraint had a ban list, a gate written before allowedExternalImports existed. The gate accepts an allowed list too, including an empty one, which bans every external package.
  • findTransitiveExternalDependencies keeps one dependency edge per project and package. Several edges to the same package (static and dynamic, or several resolved versions) produce a single violation instead of identical repeats.
  • Rule-level tests cover both directions for banned, allowed-only, mixed and empty-allowed constraints, plus multi-hop chains and the duplicate-edge cases.

View Polygraph session ↗

@netlify

netlify Bot commented Aug 13, 2026

Copy link
Copy Markdown

Deploy Preview for nx-docs ready!

Name Link
🔨 Latest commit f923abf
🔍 Latest deploy log https://app.netlify.com/projects/nx-docs/deploys/6a9561af2964f20008a88c40
😎 Deploy Preview https://deploy-preview-36654--nx-docs.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.

To edit notification comments on pull requests, go to your Netlify project configuration.

@netlify

netlify Bot commented Aug 13, 2026

Copy link
Copy Markdown

Deploy Preview for nx-dev ready!

Name Link
🔨 Latest commit f923abf
🔍 Latest deploy log https://app.netlify.com/projects/nx-dev/deploys/6a9561af5fac53000855447d
😎 Deploy Preview https://deploy-preview-36654--nx-dev.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.

To edit notification comments on pull requests, go to your Netlify project configuration.

@nx-cloud

nx-cloud Bot commented Aug 13, 2026

Copy link
Copy Markdown
Contributor

View your CI Pipeline Execution ↗ for commit 601de4a

Command Status Duration Result
nx affected --targets=lint,oxlint,test,build,e2... ⏳ In Progress ... View ↗
nx run-many -t check-imports check-lock-files c... ✅ Succeeded 4s View ↗
nx-cloud record -- pnpm nx-cloud conformance:check ✅ Succeeded 55s View ↗
nx build workspace-plugin ✅ Succeeded <1s View ↗
nx-cloud record -- nx sync:check ✅ Succeeded 19s View ↗
nx-cloud record -- nx format:check ✅ Succeeded 1s View ↗

☁️ Nx Cloud last updated this comment at 2026-08-31 11:17:01 UTC

@leosvelperez leosvelperez self-assigned this Aug 13, 2026
@leosvelperez
leosvelperez marked this pull request as ready for review August 13, 2026 13:57
@leosvelperez
leosvelperez requested a review from a team as a code owner August 13, 2026 13:57
@leosvelperez
leosvelperez requested a review from MaxKless August 13, 2026 13:57
nx-cloud[bot]

This comment was marked as outdated.

nx-cloud[bot]

This comment was marked as outdated.

@AgentEnder AgentEnder left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed the fix does what it says. I ran your new specs against master's version of the two source files, and exactly the four tests that assert a nested violation fail there. All 118 pass at HEAD. The feature was fully inert before this.

One thing I'd want changed before it lands.

The violation message never names the package that violated

nestedBannedExternalImportsViolation interpolates imp, which on this path is the internal import. The transitive package that actually matched the constraint never appears.

That was harmless while the branch was unreachable. Now that allowedExternalImports reaches it, the error blames an import that is itself permitted:

A project tagged with "api" is not allowed to import "@mycompany/impl". Nested import found at implName

Same finding, second half: hasBannedDependencies returns one tuple per dependency edge and the rule reports one error per tuple. With your own fixture, implName depending on npm-package (static), npm-awesome-package (static) and npm-package (dynamic) produces 3 byte-identical reports for a single import. findTransitiveExternalDependencies doesn't dedupe, despite its jsdoc promising unique deps.

Adding target.data.packageName to the report data and the message template covers the first half. Keying the filtered tuples by ${dep.source}|${dep.target} covers the second.

Maintainer calls

Three decisions worth naming rather than inheriting. All three read as deliberate to me and I'd land them as they are:

  • The gate widening to allowedExternalImports goes past #36519. It's the coherent choice, since the allow branch already ran nested for mixed constraints and fixing only the ban list would leave {allowed: [...]} silently unenforced. But anyone already running checkNestedExternalImports next to an allow list goes from zero nested errors to one per transitive package outside that list.
  • allowedExternalImports: [] now bans every transitive external, because the gate tests truthiness. That matches the direct path.
  • Nested matching is package-granular, so ['lodash/fp'] won't catch a nested lodash. The options table doesn't mention the asymmetry.

Smaller things

  • Every new test has the banned package exactly one hop away. Two hops works, and names the right child, but nothing pins it.
  • runtime-lint-utils.spec.ts:349 is now identical in inputs and expectation to the test at :314.
  • imp means "import specifier" on the direct path and "package name" on the nested one. A rename would carry that without a comment.

leosvelperez and others added 6 commits August 31, 2026 11:09
…ojects

With checkNestedExternalImports enabled, importing an internal project
whose transitive dependencies include a package listed in
bannedExternalImports never produced a violation. The subpath-matching
guard added to isConstraintBanningProject for deep import bans compares
the original import specifier against the external package name, and on
the nested path that specifier names the imported internal project, so
the guard never matched and the nested check was dead.

Match transitive external dependencies against the external package's
own name instead of the original import specifier, restoring the
behavior the nested check had before the guard was introduced. The
direct import path keeps the specifier-based subpath matching.
The nested external-import check only ran for constraints with a
bannedExternalImports list. That gate predates allowedExternalImports,
so an allowed-only constraint never checked the transitive external
dependencies of imported projects, while a direct import of a package
outside the list was reported. The gate now accepts an allowed list too,
including an empty one, which bans every external package.
The nested external-import violation only showed the internal import and
the project owning the nested dependency. Under an allowed-only
constraint the blamed import is itself permitted, and the package that
matched the constraint never appeared in the error. The report now
includes the matched package name in the message.
hasBannedDependencies returned one tuple per dependency edge, so an
internal project reaching the same package through several edges (static
and dynamic, or several resolved versions of the same package) produced
byte-identical errors for a single import statement.
findTransitiveExternalDependencies now keeps one edge per project and
package, matching the uniqueness its jsdoc already promised.
Dropping the imp parameter from hasBannedDependencies left two tests
with identical inputs and expectations, so one is removed. The
isConstraintBanningProject parameter is renamed to importSpecifier since
it receives the bare package name on the nested path. The
checkNestedExternalImports docs now state that nested matching is
package-granular.
@leosvelperez

Copy link
Copy Markdown
Member Author

Thanks for the thorough pass @AgentEnder, and for running the specs against master to confirm the fix.

Good catch on the message. It now names the package: ... is not allowed to import "@mycompany/impl". Nested import of "npm-package" found at implName.

For the duplicates I went with deduping in findTransitiveExternalDependencies, but keyed by source project + package name rather than node name, so two versioned nodes of the same package (npm:pkg, npm:pkg@2.0.0) also collapse into one report. Both cases have tests now.

Also added a two-hop test, removed the lint-utils test that had become identical to its sibling, renamed the isConstraintBanningProject param to importSpecifier, and documented the package-granular matching in the options table.

The three maintainer calls stay as you read them: all deliberate.

nx-cloud[bot]

This comment was marked as outdated.

@nx-cloud nx-cloud Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nx Cloud has identified a flaky task in your failed CI:

🔂 Since the failure was identified as flaky, we triggered a CI rerun by adding an empty commit to this branch.

Nx Cloud View detailed reasoning in Nx Cloud ↗

🔔 Heads up, your workspace has pending recommendations ↗ to auto-apply fixes for similar failures.


🎓 Learn more about Self-Healing CI on nx.dev

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

@nx/eslint-plugin: checkNestedExternalImports never flags transitive bannedExternalImports reached via an internal project

2 participants