Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/no-wildcard-exports.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@feature-sliced/steiger-plugin': minor
---

add the `no-wildcard-exports` rule, which forbids `export * from` in public APIs but allows `export * as ns from`. It is disabled by default.
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,7 @@ Currently, Steiger is not extendable with more rules, though that will change in
<tr> <td><a href="./packages/steiger-plugin-fsd/src/typo-in-layer-name/README.md"><code>fsd/typo-in-layer-name</code></a></td> <td>Ensure that all layers are named without any typos.</td> </tr>
<tr> <td><a href="./packages/steiger-plugin-fsd/src/no-processes/README.md"><code>fsd/no-processes</code></a></td> <td>Discourage the use of the deprecated Processes layer.</td> </tr>
<tr> <td><a href="./packages/steiger-plugin-fsd/src/import-locality/README.md"><code>fsd/import-locality</code></a></td> <td>[disabled] Require that imports from the same slice be relative and imports from one slice to another be absolute.</td> </tr>
<tr> <td><a href="./packages/steiger-plugin-fsd/src/no-wildcard-exports/README.md"><code>fsd/no-wildcard-exports</code></a></td> <td>[disabled] Forbid wildcard re-exports (<code>export * from</code>) in public APIs.</td> </tr>
</tbody>
</table>

Expand Down
85 changes: 78 additions & 7 deletions packages/steiger-plugin-fsd/src/_language-tools/index.ts

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.

I like how we can add many new rules now using tree-sitter, but I don't think adding random flags and queries into extractors is the way to go. As this API was created specifically for getting imports only, and it doesn't really scale up to exports (especially with wildcards and namespaces). I propose we refactor this part a bit first to make sure that it handles both imports and exports simultaneously, and then add export-based rules on top of it. (this is also the case for #251)

  1. make extractDependencies return something like
function extractDependencies(): Statement[]

// I'm not sure if the types are correct, need to veryfy how wildcards and namespaces work together
// this also doesn't take into account import renaming and import attributes
type Statement = Import | Export;
interface Import {
  specifier: string
  path: string;
}
interface Export {
  namespace?: string
  specifier: string
  path: string
}
  1. save all import/export information back to the FS cache so we don't have to re-parse the file
  2. add framework-specific logic for exports: for example, Vue files with the setup attribute by default have one implicit default export (I believe this is also the case for svelte)
  3. (optionally) change the querying options for the extractDependencies so we don't have to add too much filtering logic into the rules

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

I’ve updated the implementation based on your feedback, and namespace re-exports are now reported by this rule as well.

One thing I noticed while working on this is that issue #5 currently lists the following as an example that should pass:

export * as positions from "./tooltip-positions";

This seems to conflict with the direction of the current review. For now, I’ve followed the current review and included namespace re-exports in the rule, but could you confirm whether this is the intended behavior?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

I also looked into the framework specific implicit exports you mentioned. To implement this properly, I think I would need to spend some additional time looking into and verifying the behavior of Vue, Svelte.

Would you prefer to include this in the scope of this PR as well, or would it be better to keep this PR focused on the shared analysis structure and handle framework-specific implicit exports separately in a follow up?

Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,8 @@ interface Extractor {
language: Language
injections: Array<{ query: Query; lang: string }>
queries: Array<{ query: Query; type: 'static' | 'dynamic' }>
/** Matches `export * from` statements, capturing the whole statement as `@statement` and the module specifier as `@path`. */
wildcardExportQuery?: Query
}

const extractors: Array<Extractor> = [
Expand Down Expand Up @@ -90,6 +92,9 @@ const extractors: Array<Extractor> = [
type: 'dynamic',
},
],
// Deliberately does not match `export * as ns from`: a namespace re-export adds one name to the
// module's exports rather than an unknown number of them.
wildcardExportQuery: new Query(tsx, '(export_statement "*" source: (string (string_fragment) @path)) @statement'),
injections: [],
},
{
Expand Down Expand Up @@ -170,6 +175,46 @@ function processExtractor(extractor: Extractor, tree: Tree): Dependency[] {
return result
}

function processWildcardExports(extractor: Extractor, tree: Tree): WildcardExport[] {
if (extractor.wildcardExportQuery === undefined) return []

const result: WildcardExport[] = []

for (const match of extractor.wildcardExportQuery.matches(tree.rootNode)) {
const pathCapture = match.captures.find((capture) => capture.name === 'path')
const statementCapture = match.captures.find((capture) => capture.name === 'statement')
if (pathCapture === undefined || statementCapture === undefined) continue

result.push({
path: pathCapture.node.text,
start: {
line: statementCapture.node.startPosition.row + 1,
column: statementCapture.node.startPosition.column + 1,
},
end: {
line: statementCapture.node.endPosition.row + 1,
column: statementCapture.node.endPosition.column + 1,
},
})
}

return result
}

interface WildcardExport {
/** The module specifier that the names are re-exported from. */
path: string
// all indexes are 1-based, and they span the whole `export * from` statement
start: {
line: number
column: number
}
end: {
line: number
column: number
}
}

interface Dependency {
path: string
builtIn: boolean
Expand All @@ -185,22 +230,24 @@ interface Dependency {
}
}

const cache = createFSCache<Dependency[]>()

function extractAllDependencies(path: string): Dependency[] {
/**
* Parse a source file and run `process` on its syntax tree, as well as on the syntax trees of the
* languages injected into it (for example, the `<script>` block of a Vue component).
*/
function processSourceFile<T>(path: string, process: (extractor: Extractor, tree: Tree) => Array<T>): Array<T> {
const extension = extname(path)
const extractor = extractors.find((extractor) => extractor.extensions.includes(extension))
if (!extractor) throw new Error(`No extractor found for "${extension}"`)

const dependencies: Dependency[] = []
const results: Array<T> = []

const sourceCode = readFileSync(path, 'utf8')
const parser = new Parser()
parser.setLanguage(extractor.language)
const tree = parser.parse(sourceCode)
if (tree === null) return []

dependencies.push(...processExtractor(extractor, tree))
results.push(...process(extractor, tree))

for (const { query, lang } of extractor.injections) {
const injectedExtractor = extractors.find((extractor) => extractor.type === lang)
Expand All @@ -225,13 +272,19 @@ function extractAllDependencies(path: string): Dependency[] {
parser.setLanguage(injectedExtractor.language)
const injectedTree = parser.parse(sourceCode, null, { includedRanges })
if (injectedTree === null) continue
dependencies.push(...processExtractor(injectedExtractor, injectedTree))
results.push(...process(injectedExtractor, injectedTree))
injectedTree.delete()
}

tree.delete()

return dependencies
return results
}

const cache = createFSCache<Dependency[]>()

function extractAllDependencies(path: string): Dependency[] {
return processSourceFile(path, processExtractor)
}

export async function extractDependencies(
Expand All @@ -258,3 +311,21 @@ export async function extractDependencies(
return true
})
}

const wildcardExportCache = createFSCache<WildcardExport[]>()

/**
* Find the wildcard re-exports (`export * from`) in a file.
*
* Namespace re-exports (`export * as ns from`) are not reported. They add one name to the module's

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.

I think this could be a bit destructive; for example this file will be considered a valid public API file, while not providing any of contract/abstraction

// index.ts
export * as ui from "./ui";
export * as model from "./model";
export * as api from "./api";

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

export * as foo from "./foo";
export type * as foo from "./foo";

After hearing your feedback, I think it would be better to handle this part as well. Thank you for the review.

* exports, so they don't hide what the module exports.
*/
export async function extractWildcardExports(path: string): Promise<WildcardExport[]> {
let wildcardExports = wildcardExportCache.get(path)
if (!wildcardExports) {
wildcardExports = processSourceFile(path, processWildcardExports)
wildcardExportCache.set(path, wildcardExports)
}

return wildcardExports
}
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,24 @@ vi.mock('node:fs', () =>
const isEven = await import('is-even')
}
`,
'/src/wildcard-exports.ts': [
"export * from './model'",
'export * from "./ui";',
"export type * from './types'",
].join('\n'),
'/src/explicit-exports.ts': [
"export * as model from './model'",
"export type * as types from './types'",
"export { User, type UserData } from './model'",
"export { Button } from './ui'",
"import * as model from './model'",
'export const version = 1',
'export default version',
].join('\n'),
}),
)

import { extractDependencies } from './index.js'
import { extractDependencies, extractWildcardExports } from './index.js'

it('extracts esm dependencies from TypeScript source code', async () => {
const dependencies = await extractDependencies('/src/esm.tsx')
Expand All @@ -40,3 +54,15 @@ it('extracts dynamic dependencies from TypeScript source code', async () => {
{ path: 'is-even', builtIn: false, dynamic: true, start: { line: 3, column: 36 }, end: { line: 3, column: 43 } },
])
})

it('extracts wildcard re-exports from TypeScript source code', async () => {
expect(await extractWildcardExports('/src/wildcard-exports.ts')).toEqual([
{ path: './model', start: { line: 1, column: 1 }, end: { line: 1, column: 24 } },
{ path: './ui', start: { line: 2, column: 1 }, end: { line: 2, column: 22 } },
{ path: './types', start: { line: 3, column: 1 }, end: { line: 3, column: 29 } },
])
})

it('does not report namespace re-exports or other export forms', async () => {
expect(await extractWildcardExports('/src/explicit-exports.ts')).toEqual([])
})
3 changes: 2 additions & 1 deletion packages/steiger-plugin-fsd/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import packageJson from '../package.json' with { type: 'json' }
import noCrossImports from './no-cross-imports/index.js'
import noHigherLevelImports from './no-higher-level-imports/index.js'
import importLocality from './import-locality/index.js'
import noWildcardExports from './no-wildcard-exports/index.js'

const enabledRules = [
ambiguousSliceNames,
Expand All @@ -40,7 +41,7 @@ const enabledRules = [
typoInLayerName,
noProcesses,
]
const disabledRules = [noCrossImports, noHigherLevelImports, importLocality]
const disabledRules = [noCrossImports, noHigherLevelImports, importLocality, noWildcardExports]

const rules = [...enabledRules, ...disabledRules]

Expand Down
53 changes: 53 additions & 0 deletions packages/steiger-plugin-fsd/src/no-wildcard-exports/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
# `no-wildcard-exports`

Forbid wildcard re-exports (`export * from`) in public APIs.

According to the _public API rule on slices_:

> Every slice (and segment on layers that don't have slices) must contain a public API definition.
>
> Modules outside of this slice/segment can only reference the public API, not the internal file structure of the slice/segment.
> https://feature-sliced.design/docs/reference/slices-segments#public-api-rule-on-slices

The FSD documentation lists wildcard re-exports as bad practice:

> This hurts the discoverability of a slice because you can't easily tell what the interface of this slice is.
> https://feature-sliced.design/docs/reference/public-api#what-makes-a-good-public-api

This rule checks every public API file on every layer, including index variants like `index.client.ts` and `index.server.ts`. It skips every other file, because a wildcard export inside a module stays private to the slice or segment that contains it.

Namespace re-exports (`export * as ns from`) are allowed. They add one name to the public API, so a reader can still tell what the module exports.

Examples of public APIs that pass this rule:

```ts
// entities/user/index.ts
export { UserCard } from './ui/UserCard'
export { type User, useUser } from './model/user'
```

```ts
// shared/ui/index.ts
export { Form, Field } from './form'
export * as positions from './tooltip-positions'
```

Examples of public APIs that fail this rule:

```ts
// entities/user/index.ts
export * from './ui/UserCard' // ❌
export * from './model/user' // ❌
```

```ts
// shared/ui/index.ts
export { Form, Field } from './form'
export * from './tooltip-positions' // ❌
```

## Rationale

A wildcard re-export hides the public API of a group of modules, so you can't tell what a slice exports without opening every file inside it.

It also lets the public API change by accident. Adding an export to an internal module adds it to the public API too, and removing that export later breaks whoever started using it. Listing the names means the public API only changes when someone edits the index file.
Loading
Loading