diff --git a/src/rules/single-export.ts b/src/rules/single-export.ts index daaeb29..bb7f80b 100644 --- a/src/rules/single-export.ts +++ b/src/rules/single-export.ts @@ -2,6 +2,159 @@ import type { Rule } from "eslint"; import ts from "typescript"; import { getDecorator } from "../utils"; +const MESSAGE = `To allow efficient bundling, modules using @Component() can only have a single export which is the component class itself. Any other exports should be moved to a separate file. For further information check out: https://stenciljs.com/docs/module-bundling`; + +const TYPE_ONLY_DECLARATIONS = new Set(["TSInterfaceDeclaration", "TSTypeAliasDeclaration"]); + +interface ExportedItem { + name?: string; + node: any; +} + +/** Finds every top-level, @Component()-decorated class -- exported or not. */ +function findComponentClasses(body: any[]): any[] { + const found: any[] = []; + for (const node of body) { + const candidate = + node.type === "ClassDeclaration" + ? node + : node.type === "ExportNamedDeclaration" || node.type === "ExportDefaultDeclaration" + ? node.declaration + : undefined; + if (candidate?.type === "ClassDeclaration" && getDecorator(candidate, "Component")) { + found.push(candidate); + } + } + return found; +} + +/** + * Local names bound to type-only declarations/imports at the top level of the module. + * Used as a fallback for resolving bare `export { X }` specifiers when no type checker + * is available -- with a checker, `isSpecifierTypeOnly` resolves this precisely instead. + */ +function collectTypeOnlyNames(body: any[]): Set { + const names = new Set(); + for (const node of body) { + if (TYPE_ONLY_DECLARATIONS.has(node.type)) { + names.add(node.id.name); + } else if (node.type === "ImportDeclaration") { + for (const specifier of node.specifiers) { + if ( + node.importKind === "type" || + (specifier.type === "ImportSpecifier" && specifier.importKind === "type") + ) { + names.add(specifier.local.name); + } + } + } + } + return names; +} + +/** + * `export { X }` resolves to an alias symbol pointing at X's declaration, not a symbol + * with X's own flags -- has to be resolved through the alias to see whether X is a type. + */ +function isTypeOnlySymbol(symbol: ts.Symbol, typeChecker: ts.TypeChecker): boolean { + const resolved = + (symbol.flags & ts.SymbolFlags.Alias) !== 0 ? typeChecker.getAliasedSymbol(symbol) : symbol; + return (resolved.flags & (ts.SymbolFlags.Interface | ts.SymbolFlags.TypeAlias)) !== 0; +} + +/** + * Resolves whether a bare `export { X }` specifier is bound to a type. Prefers the type + * checker when available -- `getSymbolAtLocation` on the specifier's local identifier is + * always safe because that identifier lives in the file being linted, so this never runs + * into the "declaration lives in another file" problem that made a symbol-first approach + * (walking `getExportsOfModule` results back to their declarations) unreliable for + * re-exports. Falls back to the same-file syntactic heuristic when no checker is present. + */ +function isSpecifierTypeOnly( + specifier: any, + typeOnlyNames: Set, + typeChecker: ts.TypeChecker | undefined, + parserServices: any, +): boolean { + if (typeChecker && parserServices) { + const tsNode = parserServices.esTreeNodeToTSNodeMap.get(specifier.local); + const symbol = tsNode && typeChecker.getSymbolAtLocation(tsNode); + if (symbol) { + return isTypeOnlySymbol(symbol, typeChecker); + } + } + return typeOnlyNames.has(specifier.local.name); +} + +function collectExportedItems( + body: any[], + typeOnlyNames: Set, + typeChecker: ts.TypeChecker | undefined, + parserServices: any, +): ExportedItem[] { + const items: ExportedItem[] = []; + + for (const node of body) { + if (node.type === "ExportNamedDeclaration") { + if (node.exportKind === "type") { + continue; + } + if (node.source) { + // re-export from another module (`export { x } from './y'`) -- always a violation; + // resolving the individual re-exported bindings isn't worth the cross-module reach + if (node.specifiers.length > 0) { + items.push({ node }); + } + continue; + } + if (node.declaration) { + if (TYPE_ONLY_DECLARATIONS.has(node.declaration.type)) { + continue; + } + if (node.declaration.type === "VariableDeclaration") { + for (const declarator of node.declaration.declarations) { + const isIdentifier = declarator.id.type === "Identifier"; + items.push({ + name: isIdentifier ? declarator.id.name : undefined, + node: isIdentifier ? declarator.id : declarator, + }); + } + } else { + items.push({ name: node.declaration.id?.name, node: node.declaration }); + } + } else { + for (const specifier of node.specifiers) { + if ( + specifier.exportKind === "type" || + isSpecifierTypeOnly(specifier, typeOnlyNames, typeChecker, parserServices) + ) { + continue; + } + items.push({ name: specifier.local.name, node: specifier }); + } + } + } else if (node.type === "ExportDefaultDeclaration") { + if (TYPE_ONLY_DECLARATIONS.has(node.declaration.type)) { + continue; + } + items.push({ + name: + node.declaration.type === "Identifier" + ? node.declaration.name + : node.declaration.id?.name, + node: node.declaration, + }); + } else if (node.type === "ExportAllDeclaration") { + if (node.exportKind === "type") { + continue; + } + items.push({ node }); + } + } + + return items; +} + const rule: Rule.RuleModule = { meta: { docs: { @@ -16,33 +169,34 @@ const rule: Rule.RuleModule = { create(context): Rule.RuleListener { const parserServices = context.sourceCode.parserServices; - if (!parserServices?.esTreeNodeToTSNodeMap || !parserServices?.program) { - return {}; - } - const typeChecker = parserServices.program.getTypeChecker() as ts.TypeChecker; + const typeChecker = + parserServices?.esTreeNodeToTSNodeMap && parserServices?.program + ? (parserServices.program.getTypeChecker() as ts.TypeChecker) + : undefined; + return { - ClassDeclaration: (node: any) => { - const component = getDecorator(node, "Component"); - if (component) { - const originalNode = parserServices.esTreeNodeToTSNodeMap.get(node); - const nonTypeExports = typeChecker - .getExportsOfModule(typeChecker.getSymbolAtLocation(originalNode.getSourceFile())!) - .filter( - (symbol) => - (symbol.flags & (ts.SymbolFlags.Interface | ts.SymbolFlags.TypeAlias)) === 0, - ) - .filter((symbol) => symbol.name !== originalNode.name.text); - - nonTypeExports.forEach((symbol) => { - const errorNode = symbol.valueDeclaration - ? parserServices.tsNodeToESTreeNodeMap.get(symbol.valueDeclaration).id - : parserServices.tsNodeToESTreeNodeMap.get(symbol.declarations?.[0]); - - context.report({ - node: errorNode, - message: `To allow efficient bundling, modules using @Component() can only have a single export which is the component class itself. Any other exports should be moved to a separate file. For further information check out: https://stenciljs.com/docs/module-bundling`, - }); - }); + Program: (program: any) => { + const body = program.body; + const componentClasses = findComponentClasses(body); + if (componentClasses.length === 0) { + return; + } + + const typeOnlyNames = collectTypeOnlyNames(body); + const exportedItems = collectExportedItems( + body, + typeOnlyNames, + typeChecker, + parserServices, + ); + + for (const component of componentClasses) { + for (const item of exportedItems) { + if (item.node === component || (item.name && item.name === component.id?.name)) { + continue; + } + context.report({ node: item.node, message: MESSAGE }); + } } }, }; diff --git a/tests/rules/async-methods/async-methods.test.ts b/tests/rules/async-methods/async-methods.test.ts index 7b35a39..cfbc40c 100644 --- a/tests/rules/async-methods/async-methods.test.ts +++ b/tests/rules/async-methods/async-methods.test.ts @@ -8,8 +8,8 @@ import rule from "../../../src/rules/async-methods"; test("async-methods", () => { const files = { - good: path.resolve(__dirname, "async-methods.good.tsx"), - wrong: path.resolve(__dirname, "async-methods.wrong.tsx"), + good: path.resolve(import.meta.dirname, "async-methods.good.tsx"), + wrong: path.resolve(import.meta.dirname, "async-methods.wrong.tsx"), }; const validCode = fs.readFileSync(files.good, "utf8"); diff --git a/tests/rules/ban-default-true/ban-default-true.test.ts b/tests/rules/ban-default-true/ban-default-true.test.ts index 11c0145..49add0c 100644 --- a/tests/rules/ban-default-true/ban-default-true.test.ts +++ b/tests/rules/ban-default-true/ban-default-true.test.ts @@ -8,8 +8,8 @@ import rule from "../../../src/rules/ban-default-true"; test("ban-default-true", () => { const files = { - good: path.resolve(__dirname, "ban-default-true.good.tsx"), - wrong: path.resolve(__dirname, "ban-default-true.wrong.tsx"), + good: path.resolve(import.meta.dirname, "ban-default-true.good.tsx"), + wrong: path.resolve(import.meta.dirname, "ban-default-true.wrong.tsx"), }; const validCode = fs.readFileSync(files.good, "utf8"); diff --git a/tests/rules/ban-prefix/ban-prefix.test.ts b/tests/rules/ban-prefix/ban-prefix.test.ts index 6b4ab16..fe26579 100644 --- a/tests/rules/ban-prefix/ban-prefix.test.ts +++ b/tests/rules/ban-prefix/ban-prefix.test.ts @@ -8,8 +8,8 @@ import rule from "../../../src/rules/ban-prefix"; test("ban-prefix", () => { const files = { - good: path.resolve(__dirname, "ban-prefix.good.tsx"), - wrong: path.resolve(__dirname, "ban-prefix.wrong.tsx"), + good: path.resolve(import.meta.dirname, "ban-prefix.good.tsx"), + wrong: path.resolve(import.meta.dirname, "ban-prefix.wrong.tsx"), }; // const options = [['stencil', 'stnl']]; ruleTester.run("ban-prefix", rule, { diff --git a/tests/rules/ban-side-effects/ban-side-effects.test.ts b/tests/rules/ban-side-effects/ban-side-effects.test.ts index eb8feb5..b4ab7e0 100644 --- a/tests/rules/ban-side-effects/ban-side-effects.test.ts +++ b/tests/rules/ban-side-effects/ban-side-effects.test.ts @@ -8,11 +8,11 @@ import rule from "../../../src/rules/ban-side-effects"; test("ban-side-effects", () => { const fixtures = { - good: path.resolve(__dirname, "ban-side-effects.good.ts"), - bad: path.resolve(__dirname, "ban-side-effects.bad.ts"), - spec: path.resolve(__dirname, "ban-side-effects.spec.ts"), - e2e: path.resolve(__dirname, "ban-side-effects.e2e.ts"), - createStore: path.resolve(__dirname, "ban-side-effects.create-store.ts"), + good: path.resolve(import.meta.dirname, "ban-side-effects.good.ts"), + bad: path.resolve(import.meta.dirname, "ban-side-effects.bad.ts"), + spec: path.resolve(import.meta.dirname, "ban-side-effects.spec.ts"), + e2e: path.resolve(import.meta.dirname, "ban-side-effects.e2e.ts"), + createStore: path.resolve(import.meta.dirname, "ban-side-effects.create-store.ts"), }; ruleTester.run("ban-side-effects", rule, { diff --git a/tests/rules/class-pattern/class-pattern.test.ts b/tests/rules/class-pattern/class-pattern.test.ts index 891bf71..5971ecf 100644 --- a/tests/rules/class-pattern/class-pattern.test.ts +++ b/tests/rules/class-pattern/class-pattern.test.ts @@ -8,8 +8,8 @@ import rule from "../../../src/rules/class-pattern"; test("class-pattern", () => { const files = { - good: path.resolve(__dirname, "class-pattern.good.tsx"), - wrong: path.resolve(__dirname, "class-pattern.wrong.tsx"), + good: path.resolve(import.meta.dirname, "class-pattern.good.tsx"), + wrong: path.resolve(import.meta.dirname, "class-pattern.wrong.tsx"), }; const options = [{ pattern: "^(?!NoStart).*Component$", ignoreCase: true }]; ruleTester.run("class-pattern", rule, { diff --git a/tests/rules/decorators-context/decorators-context.test.ts b/tests/rules/decorators-context/decorators-context.test.ts index 57d2423..fbf0272 100644 --- a/tests/rules/decorators-context/decorators-context.test.ts +++ b/tests/rules/decorators-context/decorators-context.test.ts @@ -8,8 +8,8 @@ import rule from "../../../src/rules/decorators-context"; test("decorators-context", () => { const files = { - good: path.resolve(__dirname, "decorators-context.good.tsx"), - wrong: path.resolve(__dirname, "decorators-context.wrong.tsx"), + good: path.resolve(import.meta.dirname, "decorators-context.good.tsx"), + wrong: path.resolve(import.meta.dirname, "decorators-context.wrong.tsx"), }; ruleTester.run("decorators-context", rule, { valid: [ diff --git a/tests/rules/decorators-style/decorators-style.test.ts b/tests/rules/decorators-style/decorators-style.test.ts index 99b8250..c31b8d8 100644 --- a/tests/rules/decorators-style/decorators-style.test.ts +++ b/tests/rules/decorators-style/decorators-style.test.ts @@ -8,8 +8,8 @@ import rule from "../../../src/rules/decorators-style"; test("decorators-style", () => { const files = { - good: path.resolve(__dirname, "decorators-style.good.tsx"), - wrong: path.resolve(__dirname, "decorators-style.wrong.tsx"), + good: path.resolve(import.meta.dirname, "decorators-style.good.tsx"), + wrong: path.resolve(import.meta.dirname, "decorators-style.wrong.tsx"), }; const options = [ { @@ -67,7 +67,7 @@ export class SampleTag { } }`, options, - filename: path.resolve(__dirname, "decorators-style.good.tsx"), + filename: path.resolve(import.meta.dirname, "decorators-style.good.tsx"), }, ], @@ -87,7 +87,7 @@ export class SampleTag { } }`, options, - filename: path.resolve(__dirname, "decorators-style.wrong.tsx"), + filename: path.resolve(import.meta.dirname, "decorators-style.wrong.tsx"), errors: 1, // @Listen flagged; @Watch has newline after it (member on next line) }, ], diff --git a/tests/rules/dependency-suggestions/dependency-suggestions.test.ts b/tests/rules/dependency-suggestions/dependency-suggestions.test.ts index 8ee6646..fa3c62a 100644 --- a/tests/rules/dependency-suggestions/dependency-suggestions.test.ts +++ b/tests/rules/dependency-suggestions/dependency-suggestions.test.ts @@ -8,9 +8,9 @@ import rule from "../../../src/rules/dependency-suggestions"; test("dependency-suggestions", () => { const fixtures = { - good: path.resolve(__dirname, "dependency-suggestions.good.ts"), - bad: path.resolve(__dirname, "dependency-suggestions.bad.ts"), - custom: path.resolve(__dirname, "dependency-suggestions.custom.ts"), + good: path.resolve(import.meta.dirname, "dependency-suggestions.good.ts"), + bad: path.resolve(import.meta.dirname, "dependency-suggestions.bad.ts"), + custom: path.resolve(import.meta.dirname, "dependency-suggestions.custom.ts"), }; ruleTester.run("dependency-suggestions", rule, { diff --git a/tests/rules/element-type/element-type.test.ts b/tests/rules/element-type/element-type.test.ts index 32e2627..9b9d5d4 100644 --- a/tests/rules/element-type/element-type.test.ts +++ b/tests/rules/element-type/element-type.test.ts @@ -8,10 +8,13 @@ import rule from "../../../src/rules/element-type"; test("element-type", () => { const files = { - good: path.resolve(__dirname, "element-type.good.tsx"), - wrong: path.resolve(__dirname, "element-type.wrong.tsx"), - explicitAny: path.resolve(__dirname, "element-type.explicit-any.tsx"), - missingTypeAnnotation: path.resolve(__dirname, "element-type.missing-type-annotation.tsx"), + good: path.resolve(import.meta.dirname, "element-type.good.tsx"), + wrong: path.resolve(import.meta.dirname, "element-type.wrong.tsx"), + explicitAny: path.resolve(import.meta.dirname, "element-type.explicit-any.tsx"), + missingTypeAnnotation: path.resolve( + import.meta.dirname, + "element-type.missing-type-annotation.tsx", + ), }; const validCode = fs.readFileSync(files.good, "utf8"); diff --git a/tests/rules/enforce-slot-jsdoc/enforce-slot-jsdoc.test.ts b/tests/rules/enforce-slot-jsdoc/enforce-slot-jsdoc.test.ts index 0e41345..5116e99 100644 --- a/tests/rules/enforce-slot-jsdoc/enforce-slot-jsdoc.test.ts +++ b/tests/rules/enforce-slot-jsdoc/enforce-slot-jsdoc.test.ts @@ -8,11 +8,11 @@ import { ruleTester } from "../rule-tester"; test("stencil rules", () => { const files = { - good: path.resolve(__dirname, "enforce-slot-jsdoc.good.tsx"), - wrong: path.resolve(__dirname, "enforce-slot-jsdoc.wrong.tsx"), - noJsdoc: path.resolve(__dirname, "enforce-slot-jsdoc.no-jsdoc.tsx"), - hyphenated: path.resolve(__dirname, "enforce-slot-jsdoc.hyphenated.tsx"), - hyphenatedWrong: path.resolve(__dirname, "enforce-slot-jsdoc.hyphenated-wrong.tsx"), + good: path.resolve(import.meta.dirname, "enforce-slot-jsdoc.good.tsx"), + wrong: path.resolve(import.meta.dirname, "enforce-slot-jsdoc.wrong.tsx"), + noJsdoc: path.resolve(import.meta.dirname, "enforce-slot-jsdoc.no-jsdoc.tsx"), + hyphenated: path.resolve(import.meta.dirname, "enforce-slot-jsdoc.hyphenated.tsx"), + hyphenatedWrong: path.resolve(import.meta.dirname, "enforce-slot-jsdoc.hyphenated-wrong.tsx"), }; ruleTester.run("enforce-slot-jsdoc", rule, { diff --git a/tests/rules/graceful-skip.test.ts b/tests/rules/graceful-skip.test.ts index bbeef4c..ce20869 100644 --- a/tests/rules/graceful-skip.test.ts +++ b/tests/rules/graceful-skip.test.ts @@ -3,7 +3,6 @@ import { test } from "vitest"; import { ruleTesterNoTypeInfo } from "./rule-tester-no-typeinfo"; import asyncMethods from "../../src/rules/async-methods"; import renderReturnsHost from "../../src/rules/render-returns-host"; -import singleExport from "../../src/rules/single-export"; import strictBooleanConditions from "../../src/rules/strict-boolean-conditions"; const stencilComponent = ` @@ -28,13 +27,6 @@ test("render-returns-host skips gracefully without type info", () => { }); }); -test("single-export skips gracefully without type info", () => { - ruleTesterNoTypeInfo.run("single-export", singleExport, { - valid: [{ code: stencilComponent }], - invalid: [], - }); -}); - test("strict-boolean-conditions skips gracefully without type info", () => { ruleTesterNoTypeInfo.run("strict-boolean-conditions", strictBooleanConditions, { valid: [{ code: stencilComponent }], diff --git a/tests/rules/host-data-deprecated/host-data-deprecated.test.ts b/tests/rules/host-data-deprecated/host-data-deprecated.test.ts index 1e9157d..e73bcd0 100644 --- a/tests/rules/host-data-deprecated/host-data-deprecated.test.ts +++ b/tests/rules/host-data-deprecated/host-data-deprecated.test.ts @@ -8,8 +8,8 @@ import { ruleTester } from "../rule-tester"; test("host-data-deprecated", () => { const files = { - good: path.resolve(__dirname, "host-data-deprecated.good.tsx"), - wrong: path.resolve(__dirname, "host-data-deprecated.wrong.tsx"), + good: path.resolve(import.meta.dirname, "host-data-deprecated.good.tsx"), + wrong: path.resolve(import.meta.dirname, "host-data-deprecated.wrong.tsx"), }; ruleTester.run("host-data-deprecated", rule, { valid: [ diff --git a/tests/rules/methods-must-be-public/methods-must-be-public.test.ts b/tests/rules/methods-must-be-public/methods-must-be-public.test.ts index b7a78b0..c42caea 100644 --- a/tests/rules/methods-must-be-public/methods-must-be-public.test.ts +++ b/tests/rules/methods-must-be-public/methods-must-be-public.test.ts @@ -8,8 +8,8 @@ import { ruleTester } from "../rule-tester"; test("methods-must-be-public", () => { const files = { - good: path.resolve(__dirname, "methods-must-be-public.good.tsx"), - wrong: path.resolve(__dirname, "methods-must-be-public.wrong.tsx"), + good: path.resolve(import.meta.dirname, "methods-must-be-public.good.tsx"), + wrong: path.resolve(import.meta.dirname, "methods-must-be-public.wrong.tsx"), }; ruleTester.run("methods-must-be-public", rule, { valid: [ diff --git a/tests/rules/no-unused-watch/no-unused-watch.test.ts b/tests/rules/no-unused-watch/no-unused-watch.test.ts index b611198..67d1665 100644 --- a/tests/rules/no-unused-watch/no-unused-watch.test.ts +++ b/tests/rules/no-unused-watch/no-unused-watch.test.ts @@ -8,8 +8,8 @@ import rule from "../../../src/rules/no-unused-watch"; test("no-unused-watch", () => { const files = { - good: path.resolve(__dirname, "no-unused-watch.good.tsx"), - wrong: path.resolve(__dirname, "no-unused-watch.wrong.tsx"), + good: path.resolve(import.meta.dirname, "no-unused-watch.good.tsx"), + wrong: path.resolve(import.meta.dirname, "no-unused-watch.wrong.tsx"), }; ruleTester.run("no-unused-watch", rule, { valid: [ diff --git a/tests/rules/own-methods-must-be-private/own-methods-must-be-private.test.ts b/tests/rules/own-methods-must-be-private/own-methods-must-be-private.test.ts index 96ba96e..c48eac9 100644 --- a/tests/rules/own-methods-must-be-private/own-methods-must-be-private.test.ts +++ b/tests/rules/own-methods-must-be-private/own-methods-must-be-private.test.ts @@ -8,9 +8,9 @@ import rule from "../../../src/rules/own-methods-must-be-private"; test("own-methods-must-be-private", () => { const files = { - good: path.resolve(__dirname, "own-methods-must-be-private.good.tsx"), - wrong: path.resolve(__dirname, "own-methods-must-be-private.wrong.tsx"), - output: path.resolve(__dirname, "own-methods-must-be-private.output.tsx"), + good: path.resolve(import.meta.dirname, "own-methods-must-be-private.good.tsx"), + wrong: path.resolve(import.meta.dirname, "own-methods-must-be-private.wrong.tsx"), + output: path.resolve(import.meta.dirname, "own-methods-must-be-private.output.tsx"), }; ruleTester.run("own-methods-must-be-private", rule, { valid: [ diff --git a/tests/rules/own-props-must-be-private/own-props-must-be-private.test.ts b/tests/rules/own-props-must-be-private/own-props-must-be-private.test.ts index bc0dce0..d488dba 100644 --- a/tests/rules/own-props-must-be-private/own-props-must-be-private.test.ts +++ b/tests/rules/own-props-must-be-private/own-props-must-be-private.test.ts @@ -8,9 +8,9 @@ import rule from "../../../src/rules/own-props-must-be-private"; test("stencil rules", () => { const files = { - good: path.resolve(__dirname, "own-props-must-be-private.good.tsx"), - wrong: path.resolve(__dirname, "own-props-must-be-private.wrong.tsx"), - output: path.resolve(__dirname, "own-props-must-be-private.output.tsx"), + good: path.resolve(import.meta.dirname, "own-props-must-be-private.good.tsx"), + wrong: path.resolve(import.meta.dirname, "own-props-must-be-private.wrong.tsx"), + output: path.resolve(import.meta.dirname, "own-props-must-be-private.output.tsx"), }; ruleTester.run("own-props-must-be-private", rule, { valid: [ diff --git a/tests/rules/prefer-vdom-listener/prefer-vdom-listener.test.ts b/tests/rules/prefer-vdom-listener/prefer-vdom-listener.test.ts index 26b9b4c..89ab807 100644 --- a/tests/rules/prefer-vdom-listener/prefer-vdom-listener.test.ts +++ b/tests/rules/prefer-vdom-listener/prefer-vdom-listener.test.ts @@ -8,8 +8,8 @@ import rule from "../../../src/rules/prefer-vdom-listener"; test("prefer-vdom-listener", () => { const files = { - good: path.resolve(__dirname, "prefer-vdom-listener.good.tsx"), - wrong: path.resolve(__dirname, "prefer-vdom-listener.wrong.tsx"), + good: path.resolve(import.meta.dirname, "prefer-vdom-listener.good.tsx"), + wrong: path.resolve(import.meta.dirname, "prefer-vdom-listener.wrong.tsx"), }; ruleTester.run("prefer-vdom-listener", rule, { valid: [ diff --git a/tests/rules/props-must-be-public/props-must-be-public.test.ts b/tests/rules/props-must-be-public/props-must-be-public.test.ts index 65e460f..032e2af 100644 --- a/tests/rules/props-must-be-public/props-must-be-public.test.ts +++ b/tests/rules/props-must-be-public/props-must-be-public.test.ts @@ -8,8 +8,8 @@ import rule from "../../../src/rules/props-must-be-public"; test("props-must-be-public", () => { const files = { - good: path.resolve(__dirname, "props-must-be-public.good.tsx"), - wrong: path.resolve(__dirname, "props-must-be-public.wrong.tsx"), + good: path.resolve(import.meta.dirname, "props-must-be-public.good.tsx"), + wrong: path.resolve(import.meta.dirname, "props-must-be-public.wrong.tsx"), }; ruleTester.run("props-must-be-public", rule, { valid: [ diff --git a/tests/rules/props-must-be-readonly/props-must-be-readonly.test.ts b/tests/rules/props-must-be-readonly/props-must-be-readonly.test.ts index df30770..33cebe5 100644 --- a/tests/rules/props-must-be-readonly/props-must-be-readonly.test.ts +++ b/tests/rules/props-must-be-readonly/props-must-be-readonly.test.ts @@ -8,8 +8,8 @@ import rule from "../../../src/rules/props-must-be-readonly"; test("props-must-be-readonly", () => { const files = { - good: path.resolve(__dirname, "props-must-be-readonly.good.tsx"), - wrong: path.resolve(__dirname, "props-must-be-readonly.wrong.tsx"), + good: path.resolve(import.meta.dirname, "props-must-be-readonly.good.tsx"), + wrong: path.resolve(import.meta.dirname, "props-must-be-readonly.wrong.tsx"), }; const validCode = fs.readFileSync(files.good, "utf8"); diff --git a/tests/rules/render-returns-host/render-returns-host.test.ts b/tests/rules/render-returns-host/render-returns-host.test.ts index 0788c34..f7431a4 100644 --- a/tests/rules/render-returns-host/render-returns-host.test.ts +++ b/tests/rules/render-returns-host/render-returns-host.test.ts @@ -8,8 +8,8 @@ import rule from "../../../src/rules/render-returns-host"; test("render-returns-host", () => { const files = { - good: path.resolve(__dirname, "render-returns-host.good.tsx"), - wrong: path.resolve(__dirname, "render-returns-host.wrong.tsx"), + good: path.resolve(import.meta.dirname, "render-returns-host.good.tsx"), + wrong: path.resolve(import.meta.dirname, "render-returns-host.wrong.tsx"), }; ruleTester.run("render-returns-host", rule, { valid: [ diff --git a/tests/rules/required-jsdoc/required-jsdoc.test.ts b/tests/rules/required-jsdoc/required-jsdoc.test.ts index 7c68ab4..d4e54a8 100644 --- a/tests/rules/required-jsdoc/required-jsdoc.test.ts +++ b/tests/rules/required-jsdoc/required-jsdoc.test.ts @@ -8,8 +8,8 @@ import rule from "../../../src/rules/required-jsdoc"; test("required-jsdoc", () => { const files = { - good: path.resolve(__dirname, "required-jsdoc.good.tsx"), - wrong: path.resolve(__dirname, "required-jsdoc.wrong.tsx"), + good: path.resolve(import.meta.dirname, "required-jsdoc.good.tsx"), + wrong: path.resolve(import.meta.dirname, "required-jsdoc.wrong.tsx"), }; ruleTester.run("required-jsdoc", rule, { valid: [ diff --git a/tests/rules/required-prefix/required-prefix.test.ts b/tests/rules/required-prefix/required-prefix.test.ts index ed2cd7e..c80fe2c 100644 --- a/tests/rules/required-prefix/required-prefix.test.ts +++ b/tests/rules/required-prefix/required-prefix.test.ts @@ -8,8 +8,8 @@ import rule from "../../../src/rules/required-prefix"; test("required-prefix", () => { const files = { - good: path.resolve(__dirname, "required-prefix.good.tsx"), - wrong: path.resolve(__dirname, "required-prefix.wrong.tsx"), + good: path.resolve(import.meta.dirname, "required-prefix.good.tsx"), + wrong: path.resolve(import.meta.dirname, "required-prefix.wrong.tsx"), }; const options = [["app-", "me-"]]; ruleTester.run("required-prefix", rule, { diff --git a/tests/rules/reserved-member-names/reserved-member-names.test.ts b/tests/rules/reserved-member-names/reserved-member-names.test.ts index d6f5e0a..38b2043 100644 --- a/tests/rules/reserved-member-names/reserved-member-names.test.ts +++ b/tests/rules/reserved-member-names/reserved-member-names.test.ts @@ -8,8 +8,8 @@ import rule from "../../../src/rules/reserved-member-names"; test("reserved-member-names", () => { const files = { - good: path.resolve(__dirname, "reserved-member-names.good.tsx"), - wrong: path.resolve(__dirname, "reserved-member-names.wrong.tsx"), + good: path.resolve(import.meta.dirname, "reserved-member-names.good.tsx"), + wrong: path.resolve(import.meta.dirname, "reserved-member-names.wrong.tsx"), }; ruleTester.run("reserved-member-names", rule, { valid: [ diff --git a/tests/rules/rule-tester.ts b/tests/rules/rule-tester.ts index 6e3a998..fa5fe93 100644 --- a/tests/rules/rule-tester.ts +++ b/tests/rules/rule-tester.ts @@ -12,7 +12,7 @@ export const ruleTester = new RuleTester({ ecmaFeatures: { jsx: true, }, - project: path.resolve(__dirname, "..", "configs", "tsconfig.json"), + project: path.resolve(import.meta.dirname, "..", "configs", "tsconfig.json"), extraFileExtensions: [".ts", ".tsx"], env: { browser: true, diff --git a/tests/rules/single-export/single-export.export-all.tsx b/tests/rules/single-export/single-export.export-all.tsx new file mode 100644 index 0000000..8241c73 --- /dev/null +++ b/tests/rules/single-export/single-export.export-all.tsx @@ -0,0 +1,8 @@ +@Component({ tag: 'sample-tag' }) +export class SampleTag { + render() { + return (
test
); + } +} + +export * from './single-export.other-module'; diff --git a/tests/rules/single-export/single-export.extra-default-export.tsx b/tests/rules/single-export/single-export.extra-default-export.tsx new file mode 100644 index 0000000..1fcbd7d --- /dev/null +++ b/tests/rules/single-export/single-export.extra-default-export.tsx @@ -0,0 +1,8 @@ +@Component({ tag: 'sample-tag' }) +export class SampleTag { + render() { + return (
test
); + } +} + +export default 'not-the-component'; diff --git a/tests/rules/single-export/single-export.extra-value-export.tsx b/tests/rules/single-export/single-export.extra-value-export.tsx new file mode 100644 index 0000000..7a8e013 --- /dev/null +++ b/tests/rules/single-export/single-export.extra-value-export.tsx @@ -0,0 +1,8 @@ +export const DEFAULT_LABEL = 'hello'; + +@Component({ tag: 'sample-tag' }) +export class SampleTag { + render() { + return (
test
); + } +} diff --git a/tests/rules/single-export/single-export.local-interface-specifier.tsx b/tests/rules/single-export/single-export.local-interface-specifier.tsx new file mode 100644 index 0000000..94faf5b --- /dev/null +++ b/tests/rules/single-export/single-export.local-interface-specifier.tsx @@ -0,0 +1,12 @@ +interface SampleTagProps { + label: string; +} + +@Component({ tag: 'sample-tag' }) +export class SampleTag { + render() { + return (
test
); + } +} + +export { SampleTagProps }; diff --git a/tests/rules/single-export/single-export.no-component.tsx b/tests/rules/single-export/single-export.no-component.tsx new file mode 100644 index 0000000..25a1f34 --- /dev/null +++ b/tests/rules/single-export/single-export.no-component.tsx @@ -0,0 +1,4 @@ +export const helperA = 1; +export const helperB = 2; + +export class PlainClass {} diff --git a/tests/rules/single-export/single-export.other-module.ts b/tests/rules/single-export/single-export.other-module.ts new file mode 100644 index 0000000..e94ac33 --- /dev/null +++ b/tests/rules/single-export/single-export.other-module.ts @@ -0,0 +1 @@ +export const helper = 1; diff --git a/tests/rules/single-export/single-export.test.ts b/tests/rules/single-export/single-export.test.ts index 3b95c48..0285d3a 100644 --- a/tests/rules/single-export/single-export.test.ts +++ b/tests/rules/single-export/single-export.test.ts @@ -8,8 +8,17 @@ import rule from "../../../src/rules/single-export"; test("single-export", () => { const files = { - good: path.resolve(__dirname, "single-export.good.tsx"), - wrong: path.resolve(__dirname, "single-export.wrong.tsx"), + good: path.resolve(import.meta.dirname, "single-export.good.tsx"), + wrong: path.resolve(import.meta.dirname, "single-export.wrong.tsx"), + typeOnlyExports: path.resolve(import.meta.dirname, "single-export.type-only-exports.tsx"), + localInterfaceSpecifier: path.resolve( + import.meta.dirname, + "single-export.local-interface-specifier.tsx", + ), + extraValueExport: path.resolve(import.meta.dirname, "single-export.extra-value-export.tsx"), + extraDefaultExport: path.resolve(import.meta.dirname, "single-export.extra-default-export.tsx"), + exportAll: path.resolve(import.meta.dirname, "single-export.export-all.tsx"), + noComponent: path.resolve(import.meta.dirname, "single-export.no-component.tsx"), }; ruleTester.run("single-export", rule, { valid: [ @@ -17,6 +26,21 @@ test("single-export", () => { code: fs.readFileSync(files.good, "utf8"), filename: files.good, }, + { + // interfaces/type aliases are exempt -- they don't affect bundling + code: fs.readFileSync(files.typeOnlyExports, "utf8"), + filename: files.typeOnlyExports, + }, + { + // a local (non-exported) interface re-exported via a specifier is still type-only + code: fs.readFileSync(files.localInterfaceSpecifier, "utf8"), + filename: files.localInterfaceSpecifier, + }, + { + // files without any @Component() class are out of scope for this rule + code: fs.readFileSync(files.noComponent, "utf8"), + filename: files.noComponent, + }, ], invalid: [ @@ -25,6 +49,24 @@ test("single-export", () => { filename: files.wrong, errors: 2, }, + { + // a plain value export alongside the component is a violation + code: fs.readFileSync(files.extraValueExport, "utf8"), + filename: files.extraValueExport, + errors: 1, + }, + { + // a default export alongside the component is also a violation + code: fs.readFileSync(files.extraDefaultExport, "utf8"), + filename: files.extraDefaultExport, + errors: 1, + }, + { + // re-exporting everything from another module is a violation + code: fs.readFileSync(files.exportAll, "utf8"), + filename: files.exportAll, + errors: 1, + }, ], }); }); diff --git a/tests/rules/single-export/single-export.type-only-exports.tsx b/tests/rules/single-export/single-export.type-only-exports.tsx new file mode 100644 index 0000000..cfbafe0 --- /dev/null +++ b/tests/rules/single-export/single-export.type-only-exports.tsx @@ -0,0 +1,11 @@ +export interface SampleTagProps { + label: string; +} +export type SampleTagVariant = 'primary' | 'secondary'; + +@Component({ tag: 'sample-tag' }) +export class SampleTag { + render() { + return (
test
); + } +} diff --git a/tests/rules/strict-boolean-conditions/strict-boolean-conditions.test.ts b/tests/rules/strict-boolean-conditions/strict-boolean-conditions.test.ts index 5650380..d6f0f92 100644 --- a/tests/rules/strict-boolean-conditions/strict-boolean-conditions.test.ts +++ b/tests/rules/strict-boolean-conditions/strict-boolean-conditions.test.ts @@ -14,8 +14,8 @@ test("strict-boolean-conditions", () => { ruleTester.run("strict-boolean-conditions", rule, { valid: [ { - code: fs.readFileSync(path.resolve(__dirname, "valid-with-options.ts"), "utf8"), - filename: path.resolve(__dirname, "valid-with-options.ts"), + code: fs.readFileSync(path.resolve(import.meta.dirname, "valid-with-options.ts"), "utf8"), + filename: path.resolve(import.meta.dirname, "valid-with-options.ts"), options: [ [ "allow-string", @@ -29,25 +29,25 @@ test("strict-boolean-conditions", () => { ], }, { - code: fs.readFileSync(path.resolve(__dirname, "valid-default.ts"), "utf8"), - filename: path.resolve(__dirname, "valid-default.ts"), + code: fs.readFileSync(path.resolve(import.meta.dirname, "valid-default.ts"), "utf8"), + filename: path.resolve(import.meta.dirname, "valid-default.ts"), }, ], invalid: [ { - code: fs.readFileSync(path.resolve(__dirname, "invalid-basic.ts"), "utf8"), - filename: path.resolve(__dirname, "invalid-basic.ts"), + code: fs.readFileSync(path.resolve(import.meta.dirname, "invalid-basic.ts"), "utf8"), + filename: path.resolve(import.meta.dirname, "invalid-basic.ts"), errors: errors(4), }, { - code: fs.readFileSync(path.resolve(__dirname, "invalid-unions.ts"), "utf8"), - filename: path.resolve(__dirname, "invalid-unions.ts"), + code: fs.readFileSync(path.resolve(import.meta.dirname, "invalid-unions.ts"), "utf8"), + filename: path.resolve(import.meta.dirname, "invalid-unions.ts"), options: [[]], errors: errors(2), }, { - code: fs.readFileSync(path.resolve(__dirname, "invalid-mixed.ts"), "utf8"), - filename: path.resolve(__dirname, "invalid-mixed.ts"), + code: fs.readFileSync(path.resolve(import.meta.dirname, "invalid-mixed.ts"), "utf8"), + filename: path.resolve(import.meta.dirname, "invalid-mixed.ts"), errors: errors(4), }, ], diff --git a/tests/rules/strict-mutable/strict-mutable.test.ts b/tests/rules/strict-mutable/strict-mutable.test.ts index f85159a..4886a1c 100644 --- a/tests/rules/strict-mutable/strict-mutable.test.ts +++ b/tests/rules/strict-mutable/strict-mutable.test.ts @@ -8,11 +8,11 @@ import rule from "../../../src/rules/strict-mutable"; test("strict-mutable", () => { const files = { - good: path.resolve(__dirname, "strict-mutable.good.tsx"), - wrong: path.resolve(__dirname, "strict-mutable.wrong.tsx"), - nonComponent: path.resolve(__dirname, "strict-mutable.non-component.tsx"), - inlineValid: path.resolve(__dirname, "strict-mutable.inline-valid.tsx"), - missingAssignment: path.resolve(__dirname, "strict-mutable.missing-assignment.tsx"), + good: path.resolve(import.meta.dirname, "strict-mutable.good.tsx"), + wrong: path.resolve(import.meta.dirname, "strict-mutable.wrong.tsx"), + nonComponent: path.resolve(import.meta.dirname, "strict-mutable.non-component.tsx"), + inlineValid: path.resolve(import.meta.dirname, "strict-mutable.inline-valid.tsx"), + missingAssignment: path.resolve(import.meta.dirname, "strict-mutable.missing-assignment.tsx"), }; ruleTester.run("strict-mutable", rule, { valid: [ diff --git a/tests/tsconfig.json b/tests/tsconfig.json new file mode 100644 index 0000000..dc9c57f --- /dev/null +++ b/tests/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "./configs/tsconfig.json", + "include": ["**/*"] +}