Skip to content

Commit 6bc42d9

Browse files
committed
fix(linting): single-export-per-file false positives on @fusionelement pattern
Fixes #5185 - Barrel exemption (index.ts/.tsx/.mts/.cts) now always applies, even when options.match overrides the matcher. - A companion const/let (e.g. a tag string) that only parameterizes an export default class no longer counts as a competing export.
1 parent fff6d84 commit 6bc42d9

3 files changed

Lines changed: 112 additions & 9 deletions

File tree

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
---
2+
"@equinor/fusion-framework-lint-rules": patch
3+
---
4+
5+
Fixed `single-export-per-file` false positives on the `@fusionElement` custom-element registration pattern:
6+
7+
- Barrel files (`index.ts`, `index.tsx`, `index.mts`, `index.cts`) now stay exempt from the rule even when a repo's config overrides `options.match` — previously only the default (unconfigured) matcher included the barrel exemption.
8+
- A companion top-level `const`/`let` that only parameterizes an `export default class ... {}` (e.g. a `tag` string used by `@fusionElement(tag)`) no longer counts as a competing export.

packages/linting/rules/src/__tests__/single-export-per-file.test.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,36 @@ export function bar() {}
116116
// '.generated.ts' isn't in the exclude list, but the custom fn exempts it
117117
expect(lint(source, '/src/fixture.generated.ts', rule)).toHaveLength(0);
118118
});
119+
120+
it('passes: index.ts stays exempt even when options.match overrides the matcher', () => {
121+
const rule = singleExportPerFile({ match: { exclude: ['barrel.ts'] } });
122+
const source = `
123+
export function foo() {}
124+
export function bar() {}
125+
`;
126+
expect(lint(source, '/src/index.ts', rule)).toHaveLength(0);
127+
});
128+
129+
it('passes: export class Foo + export default Foo (default re-export, not a 2nd export)', () => {
130+
const source = `
131+
export class Foo {}
132+
export default Foo;
133+
`;
134+
expect(lint(source)).toHaveLength(0);
135+
});
136+
137+
it('passes: companion const + export default class (fusionElement registration pattern)', () => {
138+
const source = `
139+
import { fusionElement } from '@equinor/fusion-wc-core';
140+
import ButtonElement from './ButtonElement';
141+
142+
export const tag = 'fwc-button';
143+
144+
@fusionElement(tag)
145+
export default class _ extends ButtonElement {}
146+
`;
147+
expect(lint(source)).toHaveLength(0);
148+
});
119149
});
120150

121151
// ── Failing cases ─────────────────────────────────────────────────────────────
@@ -151,4 +181,17 @@ export const DEFAULT_OPTIONS = {};
151181
expect(diags).toHaveLength(1);
152182
expect(diags[0]?.message).toContain('DEFAULT_OPTIONS');
153183
});
184+
185+
it('fails: companion const + export default class + a genuinely competing export', () => {
186+
const source = `
187+
export const tag = 'fwc-button';
188+
export function helper() {}
189+
190+
export default class _ {}
191+
`;
192+
// the const is exempted, but 'helper' still genuinely competes with the default class
193+
const diags = lint(source);
194+
expect(diags).toHaveLength(1);
195+
expect(diags[0]?.message).toContain('_');
196+
});
154197
});

packages/linting/rules/src/single-export-per-file/index.ts

Lines changed: 61 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import type {
44
Severity,
55
RuleDef,
66
LintContext,
7+
MatcherFn,
78
} from '@equinor/fusion-framework-lint-core';
89
import { createMatcher, resolveMatch } from '@equinor/fusion-framework-lint-core';
910
import { tsParser } from '../ts-parser.js';
@@ -43,10 +44,54 @@ function isValueExport(node: Node): boolean {
4344
}
4445

4546
/**
46-
* Basename patterns exempted from this rule by default when `options.match`
47-
* is not provided. Barrel files legitimately re-export many symbols.
48-
* If `options.match` overrides this, the implementer must re-add any of
49-
* these patterns they still want exempted — the default list is not merged in.
47+
* Returns `true` when a value export is `export default class ... {}` — the
48+
* `@fusionElement` custom-element registration pattern's defining statement.
49+
*
50+
* @param node - A value-exporting `export_statement` AST node.
51+
* @returns `true` if the statement is a default class export.
52+
*/
53+
function isDefaultClassExport(node: Node): boolean {
54+
// A default export has a `default` keyword child
55+
const hasDefaultKeyword = node.children.some((c) => c.type === 'default');
56+
// ...and its declaration child is a class
57+
const hasClassChild = node.children.some((c) => c.type === 'class_declaration');
58+
return hasDefaultKeyword && hasClassChild;
59+
}
60+
61+
/**
62+
* Returns `true` when a value export is a top-level `const`/`let`/`var`
63+
* declaration (as opposed to a function/class declaration).
64+
*
65+
* @param node - A value-exporting `export_statement` AST node.
66+
* @returns `true` if the statement declares a const/let/var binding.
67+
*/
68+
function isConstOrLetExport(node: Node): boolean {
69+
// const/let use lexical_declaration, var uses variable_declaration
70+
return node.children.some((c) => c.type === 'lexical_declaration' || c.type === 'variable_declaration');
71+
}
72+
73+
/**
74+
* Filters `exports` down to the set that actually competes for the
75+
* one-symbol budget: when a default class export (the `@fusionElement`
76+
* registration pattern) is present, its companion const/let declarations
77+
* (e.g. a `tag` string) are dropped since they only parameterize it.
78+
*
79+
* @param exports - All top-level value exports collected from a file.
80+
* @returns The subset of `exports` that count toward the rule's limit.
81+
*/
82+
function competingExports(exports: readonly Node[]): Node[] {
83+
// Whether the file has the @fusionElement default class registration pattern
84+
const hasDefaultClassExport = exports.some(isDefaultClassExport);
85+
// No default class export means every export competes as-is
86+
if (!hasDefaultClassExport) return [...exports];
87+
// Drop const/let companions so only the default class export remains
88+
return exports.filter((node) => !isConstOrLetExport(node));
89+
}
90+
91+
/**
92+
* Basename patterns exempted from this rule, always applied in addition to
93+
* any `options.match` override. Barrel files legitimately re-export many
94+
* symbols, so they stay exempt regardless of how callers configure matching.
5095
*/
5196
const DEFAULT_EXCLUDE = ['index.ts', 'index.tsx', 'index.mts', 'index.cts'];
5297

@@ -57,15 +102,20 @@ const DEFAULT_EXCLUDE = ['index.ts', 'index.tsx', 'index.mts', 'index.cts'];
57102
* @returns A configured `Rule` instance.
58103
*/
59104
export const singleExportPerFile: RuleDef = (options = {}) => {
60-
const match = resolveMatch(options.match) ?? createMatcher([], DEFAULT_EXCLUDE);
105+
const barrelMatch = createMatcher([], DEFAULT_EXCLUDE);
106+
const overrideMatch = resolveMatch(options.match);
107+
// Barrel files stay exempt even when `options.match` overrides the default matcher
108+
const match: MatcherFn = overrideMatch
109+
? (filePath) => barrelMatch(filePath) && overrideMatch(filePath)
110+
: barrelMatch;
61111

62112
return {
63113
id: RULE_ID,
64114
defaultSeverity: DEFAULT_SEVERITY,
65115
/**
66-
* Barrel files (`index.ts`, etc.) are exempt by default. Delegates to
116+
* Barrel files (`index.ts`, etc.) are always exempt. Delegates to
67117
* `match` so the engine skips calling `check` for them entirely, and
68-
* callers can override the matching strategy via `options.match`.
118+
* callers can further narrow (not widen) matching via `options.match`.
69119
* @inheritdoc Rule.match
70120
*/
71121
match,
@@ -76,13 +126,15 @@ export const singleExportPerFile: RuleDef = (options = {}) => {
76126
// Guard: tsParser.parse returns null for empty or unparseable source
77127
if (!tree) return [];
78128

79-
const valueExports: Node[] = [];
129+
const allExports: Node[] = [];
80130
// Collect all top-level value export statements
81131
for (const child of tree.rootNode.children) {
82132
// Collect each top-level child that is a value export
83-
if (isValueExport(child)) valueExports.push(child);
133+
if (isValueExport(child)) allExports.push(child);
84134
}
85135

136+
const valueExports = competingExports(allExports);
137+
86138
// Only flag when more than one value export exists
87139
if (valueExports.length <= 1) return [];
88140

0 commit comments

Comments
 (0)