Skip to content

Exclude files behind wildcard null package exports - #2085

Closed
jonathanong wants to merge 2 commits into
webpro-nl:mainfrom
jonathanong:null-subpath-export-targets
Closed

jonathanong wants to merge 2 commits into
webpro-nl:mainfrom
jonathanong:null-subpath-export-targets

Conversation

@jonathanong

@jonathanong jonathanong commented Oct 1, 2026 •

Copy link
Copy Markdown
Contributor

Closes #2084
Refs #2082

A wildcard null subpath such as "./internal/*": null hides <pkg>/internal/* in Node.js. Knip negates the key as a path (!./internal/**), not the files that a less specific pattern like "./*": "./src/*.ts" resolves those subpaths to, so those files stay entries.

Changes

  • getEntrySpecifiersFromManifest: for each wildcard null key, find the pattern that would match it without the null (getPackageMapTarget), and negate that target with the key's pattern match substituted. Under "./*": "./src/*.ts", "./internal/*": null adds !./src/internal/**/*.ts. The existing key-as-path negation stays, so "./hidden.js": null in package-entry-points-gitignored is unchanged.
  • More specific keys: a non-null key below the null prefix that wins under Node.js precedence (e.g. "./internal/public") still exports its file. When such a key exists, that null key keeps the current behaviour instead of adding a negation.
  • getToSourcePathsHandler: negated patterns are now source-mapped like positive ones. Without this, !./dist/internal/**/*.js never matched the src files that ./dist/**/*.js is mapped to. Only manifest entry specifiers go through this handler, so the only other negations affected are the existing key-as-path ones, and only when they fall under an outDir.

Scope

  • Only wildcard null keys. An exact key like "./features/private": null under "./features/*" is also unexported in Node.js. Handling it the same way would make declaration-dependencies report dist/features/private.d.ts as an unused file, so I left that out.
  • Conditions: null inside condition objects is unchanged.

Tests

The first commit adds the fixtures and tests:

  • package-entry-points-null-subpath: src/internal/orphan.ts and formatLoud are reported.
  • package-entry-points-null-subpath-dist: the same, with "./*": "./dist/*.js" and a tsconfig outDir.
  • package-entry-points-null-subpath-exported: "./internal/public" below the null pattern stays an entry.

The first two fail on main, and the third passes. Each part of the change has a test that fails without it:

  • reverting the source-path change fails the dist test;
  • removing the more-specific-key check fails the exported test.

Local results:

  • The new tests pass with node --test and bun test, along with the other package-entry-points*, null-root-export and declaration-dependencies tests.
  • node: the full suite passes, except the tests that spawn dist/cli.js, which isn't built locally.
  • bun: test/entry, test/dependencies and test/cli pass.

πŸ€– Generated with Claude Code

https://claude.ai/code/session_01RocMt6KQsTi6y25xAphdsr

@pkg-pr-new

pkg-pr-new Bot commented Oct 1, 2026

Copy link
Copy Markdown

Open in StackBlitz

npm i https://pkg.pr.new/knip@2085
npm i https://pkg.pr.new/@knip/language-server@2085
npm i https://pkg.pr.new/@knip/mcp@2085

commit: 7a69e05

@webpro

webpro commented Oct 3, 2026

Copy link
Copy Markdown
Member

Thanks for the two reports and both pull requests, Jonathan. The fixtures and tests made the problems easy to confirm, and #2083 is merged.

For #2084 I landed a different implementation on main (5bd4626) instead of merging this branch. Two things did not hold up:

  • Resolving the null key as a specifier string works for ./internal/* under ./*, but not for the example in the Node docs. With "./features/*.js": "./src/features/*.js" and "./features/private-internal/*": null, the key does not end in .js, so no target matches and nothing is hidden.
  • The negated target also wins over explicit entries that point into the hidden directory. A bin under ./internal/* is reported as an unused file.

Keeping the key-as-path negation next to the new one also keeps treating keys as file paths. Node resolves them as subpaths: "./hidden.js": null hides the hidden.js subpath, and "./aliased": "./hidden.js" still exports the file, so package-entry-points-gitignored now expects hidden.js to stay an entry.

What landed instead:

  • A null key is matched against every less specific wildcard key by intersecting the two subpath patterns, and the overlap is substituted into that key's wildcard targets. This covers ./internal/* and ./*.test under ./*, the Node docs example, dist targets (with the same getToSourcePathsHandler change as yours), and exact keys. declaration-dependencies now reports dist/features/private.d.ts, which is right for an unexported file nothing references.
  • A hidden pattern that also matches an explicit entry (main, bin, an exact export target, or a more specific wildcard target) is skipped as a whole, so those files stay entries. This replaces the more-specific-key check and covers bin and main too.
  • The key-as-path negation is gone.

Your three fixtures and their tests are in that commit (the tests now share one file) and you are credited as co-author. There is also a follow-up to #2083 (7c229f4): flattening a plugin's entry patterns into negations lost the group's own exclusions, so with entry: ["src/**/*.ts", "!src/generated/**"] for vitest an exported src/generated/client.ts was dropped from production entries. Each group is now a matcher applied to the discovered paths.

Thanks again, these were good reports to work from.

@webpro webpro closed this in 5bd4626 Oct 3, 2026
@webpro

webpro commented Oct 6, 2026

Copy link
Copy Markdown
Member

πŸš€ This issue has been resolved in v6.40.0. See Release 6.40.0 for release notes.

Using Knip in a commercial project? Please consider becoming a sponsor.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Wildcard null export subpaths do not exclude the files they hide

2 participants