Skip to content

chore(lint): flag package-internal imports through a barrel - #9080

Merged
cixzhang merged 10 commits into
mainfrom
lint/no-internal-barrel
Oct 31, 2026
Merged

cixzhang merged 10 commits into
mainfrom
lint/no-internal-barrel

Conversation

@cixzhang

@cixzhang cixzhang commented Oct 30, 2026 •

Copy link
Copy Markdown
Contributor

Maintainer impact

A module inside an Astryx package that imports through a folder's index file (a barrel, e.g. import {List} from '../List') pulls every sibling export of that folder into its module graph. In lazily imported graphs this broke the single-file bundle (#9055 fixes the crash by importing defining modules). Nothing stops a new barrel import from bringing it back: there are about 600 such imports on main, and new code copies them.

Owner decision: Cindy Zhang approved this rule on 2026-10-30 ("yes import rule"). Inside Astryx's own packages, import the defining module, never a barrel; public consumers keep importing from @astryxdesign/core. No knowledge record owns internal import paths, so this PR adds none.

Intended invariant

  • Shipped source under packages/*/src imports defining modules ('../List/List'), not folder indexes ('../List').
  • Public package entries and component barrels keep owning the reachable public surface; consumers' imports don't change.
  • main stays lint-green: only new barrel imports are reported.

Change

  • New rule @astryx/no-internal-barrel-import (internal/eslint-plugin-astryx/). It reports a relative import, export … from or literal import() that resolves to a directory with an index.* file and no same-named module. Type-only imports and exports are allowed, since the compiler erases them. Package specifiers such as @astryxdesign/core are never read.
  • Repo config: on for packages/*/src, excluding tests, stories and test helpers (they never ship) and index files themselves. A barrel is the public surface (architecture:public-component-api: component barrels own reachable exports) and may re-export from other barrels, so adding export * from './Code' to a package entry, as CONTRIBUTING.md prescribes, still passes. It uses the repo's two-tier severity: an error under strict lint (CI), a warning locally. Naming the index itself ('../List/index', '../List/index.ts', or '../List/index.js', which TypeScript resolves to index.ts) is reported too.
  • Baseline: the 577 barrel imports already on main (188 files) are listed per file in no-internal-barrel-import.baseline.json and stay allowed. A new barrel import, a new file or a new specifier in an existing file is reported. Keys are relative to the repository root, passed from the config, so eslint src run inside a package finds the same entries. The list only shrinks; a later codemod clears it. node internal/eslint-plugin-astryx/no-internal-barrel-import.baseline.mjs rewrites it from the source with the same file scope as the config (--check verifies it).
    • Why a baseline: the repo's lint has no changed-lines mode. A warning-only rule (as no-nullish-jsx-guard uses) would not stop a pull request, and a diff-scoped CI step would change workflows.
  • Docs: one line in AGENTS.md's Quick Reference, and a rule section in the plugin README.
Before (passes today) After (strict lint)
import {List} from '../List'; in a new or changed module error: '../List' resolves to a folder's index (a barrel)…
import {List} from '../List/List'; passes
import type {ListProps} from '../List'; passes (erased)
import {List} from '@astryxdesign/core'; passes (public entry)
an existing barrel import listed in the baseline passes until the codemod
export * from './Code' in a package's index.ts passes (barrels may re-export)

Evidence

  • Failure before the change: a probe module with import {List} from '../List' lints clean on main. With this rule, strict lint reports it with the message above.
  • Success after the change: on current main merged in, ASTRYX_STRICT_LINT=1 eslint packages/{core,lab,charts,richtext,vega}/src reports 0 errors (1,879 files), cd packages/core && eslint src reports no barrel errors, full ASTRYX_STRICT_LINT=1 pnpm lint reports 0 errors, and pnpm check:repo passes.
  • Product/runtime behavior verified unchanged: no source, export or build file changes; only lint config, the local plugin and docs.

Scope

  • No intended public API, product behavior, visual direction, or policy change beyond the approved lint rule.
  • Product changes discovered during the work were removed or split. The codemod over existing imports is a separate later change.
  • Public text and artifacts contain no internal Meta context.

Testing

  • pnpm vitest run internal/eslint-plugin-astryx/no-internal-barrel-import.test.mjs: 23 cases. A temp package on disk covers '../List' (flagged); '../List/List', '../utils' (a file), a folder with no index, @astryxdesign/core and type-only imports (allowed); export … from, export *, literal import() and '..' (flagged); explicit '../List/index' and '../List/index.ts' (flagged); and the baseline allowing only the listed file and specifier, keyed from root.
  • pnpm vitest run internal/eslint-plugin-astryx: 861 tests pass.
  • Strict lint over every linted package source: 0 errors.

Before merging, the baseline must match the merge base. If main gains or drops barrel imports in the meantime, I merge main in again and rerun the baseline script.

A new @astryx/no-internal-barrel-import rule reports a relative import in
a package's shipped source that resolves to a folder's index instead of
the defining module ('../List' rather than '../List/List'). A lazily
imported module that goes through a barrel pulls every sibling export into
its graph, which broke the single-file bundle (#9055). Type-only imports
are allowed; public consumers keep importing @astryxdesign/core.

The 796 barrel imports already on main are listed by file in a baseline
and stay allowed until a later codemod removes them, so main stays green
while a pull request that adds a new one fails strict lint. AGENTS.md
gets a one-line rule; the plugin README documents it.
@vercel

vercel Bot commented Oct 30, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
astryx Ready Ready Preview Oct 31, 2026 5:24am UTC

Request Review

@meta-cla meta-cla Bot added the CLA Signed This label is managed by the Meta Open Source bot. label Oct 30, 2026
@github-actions

github-actions Bot commented Oct 30, 2026 •

Copy link
Copy Markdown
Contributor

PR Analysis Report

📚 Storybook Preview

View Storybook for this PR

🧪 Sandbox Preview

View Sandbox for this PR

No new or modified components detected.

Bundle Size Summary

No component packages changed.

Accessibility Audit

Status: The accessibility audit did not run on this pull request. It runs after merges to main and before each release; pull requests that touch components run it with the ci:heavy-lanes label.


Generated by PR Enrichment workflow | Storybook | Sandbox | View full report

@astracat-bot

astracat-bot Bot commented Oct 30, 2026 •

Copy link
Copy Markdown

AI review status for this pull request.

Review status Updated
🟢 Reviewed (for maintainers only) Oct 31, 2026, 5:33 AM UTC

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Thanks, the rule does what it describes and keeps main lint-green, but two scope problems need fixing before it lands.

  • It also covers package entries, so the export * from './MyComponent' step in CONTRIBUTING.md now fails strict lint for every new component; leaving index.* files out of the rule (and dropping their baseline entries) keeps the lazy-graph protection.
  • Baseline keys are resolved against the working directory, so each package's own eslint src script reports every baselined import (782 in core); resolve them against the repository root instead.

Explicit '../X/index' specifiers also slip past the rule and are worth covering in the same pass.

[Automated review]

Comment thread eslint.config.js Outdated
// lint, while main stays green). Type-only imports are erased and allowed.
{
files: ['packages/*/src/**/*.{ts,tsx,mts,cts,js,jsx,mjs}'],
ignores: INTERNAL_BARREL_TEST_FILES,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This block also covers package entries and component barrels. Adding export * from './Code'; to packages/core/src/index.ts, the step CONTRIBUTING.md prescribes for a new component, fails strict lint ('./Code' resolves to a folder's index). Following the message there (export * from './Dialog/Dialog') would publish exports the barrel leaves out. Adding '**/index.*' to these ignores keeps the lazy-graph protection, because an ordinary module's barrel import is still reported, and lets the 13 index-file keys leave the baseline.

const filename = context.filename;
const baseline = context.options[0]?.baseline ?? {};
const relative = path
.relative(context.cwd, filename)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

context.cwd is the directory ESLint was started from. Core, lab, charts and richtext run eslint src from their package directory, where these keys become src/... and miss the baseline: cd packages/core && ASTRYX_STRICT_LINT=1 eslint src/AlertDialog/AlertDialog.tsx reports 5 errors, and the whole package reports 782. Passing the repository root from eslint.config.js (for example {baseline, root: import.meta.dirname}) and computing the key relative to it would fix this.

if (isFile(target)) {
return false;
}
if (MODULE_EXTENSIONS.some(extension => isFile(target + extension))) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

An explicit index specifier passes here: '../Button/index' resolves to index.ts through this check, and '../Button/index.ts' and '../Button/index.js' are accepted too. A lazy import('../List/index') would reach the barrel unreported. Treating a last segment of index or index.<ext> as a barrel closes the gap.

@cixzhang
cixzhang marked this pull request as ready for review October 31, 2026 02:47
…ch '/index' specifiers

- A barrel itself (a folder's or a package's index) is the public surface
  and may re-export from other barrels, so the rule skips index files and
  the documented component-export step keeps passing.
- Baseline keys are relative to the repository root, passed from the
  config, so 'eslint src' run from a package directory finds them.
- '../List/index' and '../List/index.ts' resolve to the barrel and are
  reported too.

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

The rule and the fixes from the last round work as described, but the Lint check fails on this head and needs two fixes before it can land.

  • eslint.config.js:39 uses URL, which the config file does not declare as a global, so pnpm lint reports 'URL' is not defined; reading the baseline with path.join(import.meta.dirname, …), as the .gitignore imports do, avoids it.
  • #9057 added import {useTranslator} from '../../../i18n' in useTableColumnResize.tsx on main, so the baseline needs regenerating after merging main (or that import can point at ../../../i18n/useTranslator).

'…/index.js' specifiers still pass the explicit-index check, and the new config block still sits inside richtext's backlog comment; both are worth fixing in the same pass.

[Automated review]

Comment thread eslint.config.js Outdated
// by repo-relative file. The list only shrinks.
const internalBarrelBaseline = JSON.parse(
readFileSync(
new URL(

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

URL is not declared for this file (only /* global process */ at line 13), so pnpm lint reports 'URL' is not defined (no-undef) here in strict and local mode; the Lint check for this head fails on it. Reading the file the way the .gitignore imports do avoids the global: readFileSync(path.join(import.meta.dirname, 'internal/eslint-plugin-astryx/no-internal-barrel-import.baseline.json'), 'utf8').

const base = path.basename(target);
if (
(base === 'index' && MODULE_EXTENSIONS.some(e => isFile(target + e))) ||
(INDEX_FILES.includes(base) && isFile(target))

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This only matches an index basename that exists as a file, so '../i18n/index.js' (resolved to index.ts by TypeScript) still passes, and in native, which writes .js specifiers, that is the form a barrel import takes ('../components/index.js' from a non-index module reports nothing). Reporting a basename of index with any module extension when the directory holds any index.* would close it.

…' specifiers

- eslint.config.js reads the baseline through path.join(import.meta.dirname)
  like its .gitignore imports, so no-undef stays quiet.
- A specifier naming index with any module extension ('../i18n/index.js',
  which TypeScript resolves to index.ts) is reported when the folder holds
  an index file.

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

The fixes from the last round work: the config lints clean and '…/index.js' specifiers are now reported, but the Lint check still fails on the merge with main. #9023 landed import {useTranslator} from '../i18n' at packages/core/src/Slider/Slider.tsx:62 after this head was pushed, so the baseline needs regenerating after merging main again, ideally right before landing so it does not drift a third time. The new config block also still sits inside richtext's backlog comment (eslint.config.js:878-892); moving it above that comment in the same pass keeps the comment with its block.

[Automated review]

"../utils"
],
"packages/core/src/Skeleton/Skeleton.tsx": ["../utils"],
"packages/core/src/Slider/Slider.tsx": [

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

#9023 added import {useTranslator} from '../i18n'; at packages/core/src/Slider/Slider.tsx:62 on main, and this entry does not list '../i18n', so the Lint check on the merge with main fails with that one error. Regenerating after merging main adds it; nothing else differs.

Comment thread eslint.config.js
//
// Remove an entry here as its backlog closes; the file is clean when the
// block is empty.
// Inside a package's own shipped source, import the module that defines a

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This block lands between richtext's backlog comment (lines 878-892) and the richtext block at line 914, so that comment now reads as if it describes the barrel rule. Moving this block and its comment above line 878 keeps richtext's block last and its comment attached.

…lock last

- no-internal-barrel-import.baseline.mjs rewrites (or --check verifies) the
  baseline from the source with the repo config, so it can follow main
  and shrink after a codemod.
- The rule's config block moves above richtext's backlog comment, which
  stays attached to the last block.
The rule module exports the files and ignores the repo config uses, so the
baseline script lints exactly the same files (skipping packages whose
source the config ignores). The baseline picks up the one barrel import
main gained since (Slider's '../i18n').

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

The barrel-import rule, its shared file scope and the baseline script work as described, and the points from the last round are resolved: the baseline now lists the Slider.tsx import and the config block sits above richtext's backlog comment. The Lint check passes on the merge with main, and the rule's tests pass. If main gains another barrel import before this lands, merge it and rerun the baseline script, as the description plans.

[Automated review]

@cixzhang
cixzhang merged commit bcb16db into main Oct 31, 2026
61 checks passed
@github-actions
github-actions Bot deleted the lint/no-internal-barrel branch October 31, 2026 06:09

This branch was successfully deployed

1 active deployment
Preview — df7c562b Deployed Oct 31, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

CLA Signed This label is managed by the Meta Open Source bot.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant