Skip to content

Conversation

Gaic4o
Copy link
Collaborator

@Gaic4o Gaic4o commented Aug 12, 2025

Background

  • Implement no-wildcard-exports rule in @feature-sliced/steiger-plugin
  • Disallow export * in public APIs of business layers
  • Allow export * as namespace and wildcard exports in shared/app
  • Public API is detected via index.(js|jsx|ts|tsx); non-index and test files are ignored
  • Add tests and README.md for the rule
  • Move @tsconfig/node18 to dependencies in @steiger/tsconfig for proper tsconfig extends resolution

Copy link

changeset-bot bot commented Aug 12, 2025

⚠️ No Changeset found

Latest commit: 743c15a

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

This PR includes no changesets

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

Copy link
Member

@illright illright left a comment

Choose a reason for hiding this comment

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

Thanks for the great work! You covered nearly all aspects of creating a rule all by yourself :) The last things to do are to packages/steiger-plugin-fsd/src/index.ts and add it to disabledRules (we don't want to enable it by default for now not to break people's CI, we will enable it by default in 0.6, with the next round of breaking changes) and also add it to the table of rules in the README (note that the README is copy-pasted between the project root and packages/steiger, so both files need to be updated).

if (!/\.(js|jsx|ts|tsx)$/.test(file.path)) continue

// Allow wildcard exports in unsliced layers (shared, app)
if (sourceFile.layerName === 'shared' || sourceFile.layerName === 'app') continue
Copy link
Member

Choose a reason for hiding this comment

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

suggestion: let's use isSliced from @feature-sliced/filesystem to keep this logic centralized

Comment on lines +26 to +27
const fileName = basename(file.path)
const isPublicApiFile = /^index\.(js|jsx|ts|tsx)$/.test(fileName)
Copy link
Member

Choose a reason for hiding this comment

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

suggestion: likewise, let's use isIndex here

Copy link
Member

Choose a reason for hiding this comment

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

This is also important because we allow index.client.ts and index.server.ts as valid Public APIs, and this function takes that into account

if (!isPublicApiFile) continue

// Parse file content using oxc-parser
const content = (file as FileWithContent).content
Copy link
Member

Choose a reason for hiding this comment

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

question: why is the type-cast here necessary?

Copy link
Member

Choose a reason for hiding this comment

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

I just realized — we don't include content as a field on the VFS node, we might need to explicitly read the file contents with readFile

const content = (file as FileWithContent).content
if (!content) continue // Skip if file has no contents

const parseResult = parseSync(file.path, content)
Copy link
Member

Choose a reason for hiding this comment

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

suggestion: rules can be asynchronous, so we can use the async version of the parser

Comment on lines +49 to +56
fixes: [
{
type: 'modify-file',
path: file.path,
content:
'// Replace with named exports\n// Example: export { ComponentA, ComponentB } from "./components"',
},
],
Copy link
Member

Choose a reason for hiding this comment

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

issue: I don't think that's a great autofix — ideally, autofixes should be safe code changes that you can apply and not worry about your code breaking. This will probably break code

suggestion: let's not offer an autofix for this rule until we're able to determine what names are being wildcard-exported (which is a worthwhile improvement as a second iteration of this rule)


Forbid wildcard exports (`export *`) in public APIs of business logic layers. Named exports and namespace exports (`export * as namespace`) are allowed.

**Exception:** Wildcard exports are allowed in unsliced layers (`shared` and `app`), as they serve as foundational layers with different architectural purposes.
Copy link
Member

Choose a reason for hiding this comment

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

issue: I would actually argue against allowing wildcard exports even in Shared and App. To me, wildcard exports conceal the public API of a group of modules, which really hurts the ability to discover a codebase shallowly, i. e. without digging into every file.

question: why do you think they should be allowed there? And do you agree with the content of the Public API reference page in the FSD docs?

Comment on lines +53 to +55
- Unintentionally exposing internal implementation details
- Difficulty in tracking dependencies between modules
- Potential naming conflicts when multiple modules use wildcard exports
Copy link
Member

Choose a reason for hiding this comment

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

suggestion: I would also add the point about hurting shallow discovery (if you agree with it, that is :D)

- Difficulty in tracking dependencies between modules
- Potential naming conflicts when multiple modules use wildcard exports

Using named exports or namespace exports makes the public API more explicit and easier to maintain in business logic layers.
Copy link
Member

Choose a reason for hiding this comment

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

suggestion: let's also leave a link to the docs about Public API

"@feature-sliced/filesystem": "^3.0.1",
"fastest-levenshtein": "^1.0.16",
"lodash-es": "^4.17.21",
"oxc-parser": "^0.47.1",
Copy link
Member

Choose a reason for hiding this comment

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

praise: kudos for using the oxc-parser :) I was hoping that we would switch to it entirely at some point and drop precinct because it isn't flexible enough and doesn't extend well

Copy link
Member

Choose a reason for hiding this comment

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

By the way, it supposedly provides a list of exports in a separate list that's easily accessible, can we use that? See "Returns ESM information" in https://www.npmjs.com/package/oxc-parser

Comment on lines +115 to +130
function addContentToFiles(folder: Folder): void {
for (const child of folder.children) {
if (child.type === 'file') {
const fileWithContent = child as FileWithContent
if (child.path === joinFromRoot('shared', 'ui', 'index.ts')) {
fileWithContent.content = "export * as positions from './positions'"
} else if (child.path === joinFromRoot('entities', 'user', 'index.ts')) {
fileWithContent.content = "export * as userModel from './model'"
} else {
fileWithContent.content = ''
}
} else if (child.type === 'folder') {
addContentToFiles(child)
}
}
}
Copy link
Member

Choose a reason for hiding this comment

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

issue: I think this conceals the fact that this test will actually not pass during real usage, since the linter engine doesn't include the content field

suggestion: let's use FS mocking for tests, like it's done in the test file for forbidden-imports

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.

2 participants