@@ -4,6 +4,7 @@ import type {
44 Severity ,
55 RuleDef ,
66 LintContext ,
7+ MatcherFn ,
78} from '@equinor/fusion-framework-lint-core' ;
89import { createMatcher , resolveMatch } from '@equinor/fusion-framework-lint-core' ;
910import { 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 */
5196const 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 */
59104export 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