Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
206 changes: 180 additions & 26 deletions src/rules/single-export.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string> {
const names = new Set<string>();
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<string>,
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<string>,
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: {
Expand All @@ -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 });
}
}
},
};
Expand Down
4 changes: 2 additions & 2 deletions tests/rules/async-methods/async-methods.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");

Expand Down
4 changes: 2 additions & 2 deletions tests/rules/ban-default-true/ban-default-true.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");

Expand Down
4 changes: 2 additions & 2 deletions tests/rules/ban-prefix/ban-prefix.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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, {
Expand Down
10 changes: 5 additions & 5 deletions tests/rules/ban-side-effects/ban-side-effects.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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, {
Expand Down
4 changes: 2 additions & 2 deletions tests/rules/class-pattern/class-pattern.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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, {
Expand Down
4 changes: 2 additions & 2 deletions tests/rules/decorators-context/decorators-context.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: [
Expand Down
8 changes: 4 additions & 4 deletions tests/rules/decorators-style/decorators-style.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
{
Expand Down Expand Up @@ -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"),
},
],

Expand All @@ -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)
},
],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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, {
Expand Down
11 changes: 7 additions & 4 deletions tests/rules/element-type/element-type.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");

Expand Down
10 changes: 5 additions & 5 deletions tests/rules/enforce-slot-jsdoc/enforce-slot-jsdoc.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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, {
Expand Down
8 changes: 0 additions & 8 deletions tests/rules/graceful-skip.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = `
Expand All @@ -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 }],
Expand Down
Loading