;
+ `,
+ errors: [{messageId: 'sharedGridTemplate'}, {messageId: 'sharedGridTemplate'}],
+ },
],
});
diff --git a/static/oxlint/eslintPluginScraps/src/rules/noManualTable.ts b/static/oxlint/eslintPluginScraps/src/rules/noManualTable.ts
index 722ca4df4fa4..14de1ffbd900 100644
--- a/static/oxlint/eslintPluginScraps/src/rules/noManualTable.ts
+++ b/static/oxlint/eslintPluginScraps/src/rules/noManualTable.ts
@@ -61,9 +61,7 @@ function attribute(
): ESTree.JSXAttribute | undefined {
return node.attributes.find(
(item): item is ESTree.JSXAttribute =>
- item.type === 'JSXAttribute' &&
- item.name.type === 'JSXIdentifier' &&
- item.name.name === name
+ item.type === 'JSXAttribute' && item.name.name === name
);
}
@@ -140,10 +138,7 @@ export const noManualTable = defineRule({
}
function templateKey(node: ESTree.Node): unknown {
- if (
- belowMinimumTracks(node) ||
- (node.type === 'Identifier' && belowMinimumTracks(constantInit(node)))
- ) {
+ if (belowMinimumTracks(node) || belowMinimumTracks(constantInit(node))) {
return null;
}
const literal = staticString(node);
@@ -168,10 +163,7 @@ export const noManualTable = defineRule({
function styledRepetitions(node: ESTree.Node): boolean[] {
const declarator = node.parent;
- if (
- declarator?.type !== 'VariableDeclarator' ||
- declarator.id.type !== 'Identifier'
- ) {
+ if (declarator?.type !== 'VariableDeclarator') {
return [false];
}
const reads =
@@ -189,8 +181,12 @@ export const noManualTable = defineRule({
return;
}
- if (styled.kind === 'element' && TABLE_ELEMENTS.has(styled.name)) {
- context.report({node, messageId: 'tableElement', data: {element: styled.name}});
+ if (TABLE_ELEMENTS.has(styled.name)) {
+ context.report({
+ node: styled.tag,
+ messageId: 'tableElement',
+ data: {element: styled.name},
+ });
return;
}
@@ -221,19 +217,21 @@ export const noManualTable = defineRule({
);
if (
- styled.kind === 'component' &&
(columns || mixins.length > 0) &&
- importTracker
- .findLocalNames(PANEL_HEADER_SOURCE, 'PanelHeader')
- .includes(styled.name)
+ styled.tag.type === 'CallExpression' &&
+ styled.tag.arguments[0] &&
+ importTracker.is(styled.tag.arguments[0], PANEL_HEADER_SOURCE, 'PanelHeader')
) {
- context.report({node, messageId: 'panelHeaderColumns'});
+ context.report({node: styled.tag, messageId: 'panelHeaderColumns'});
return;
}
const repetitions = styledRepetitions(node);
for (const mixin of mixins) {
- addStyledUses(importTracker.resolveVariable(mixin), node, repetitions);
+ const variable = importTracker.resolveVariable(mixin);
+ if (variable) {
+ addStyledUses(variable, styled.tag, repetitions);
+ }
}
if (!columns) {
return;
@@ -244,18 +242,21 @@ export const noManualTable = defineRule({
if (columns.interpolations.length === 1 && interpolation) {
const key = templateKey(interpolation.expression);
if (key !== null) {
- addStyledUses(key, node, repetitions);
+ addStyledUses(key, styled.tag, repetitions);
}
} else if (
columns.interpolations.length === 0 &&
countTracks(columns.value) >= MINIMUM_SHARED_TRACKS
) {
- addStyledUses(`literal:${normalizeSpace(columns.value)}`, node, repetitions);
+ addStyledUses(
+ `literal:${normalizeSpace(columns.value)}`,
+ styled.tag,
+ repetitions
+ );
}
}
return {
- ...importTracker.visitors,
TaggedTemplateExpression: checkStyled,
CallExpression: checkStyled,
JSXOpeningElement(node) {
@@ -268,9 +269,14 @@ export const noManualTable = defineRule({
return;
}
- const as = staticString(attributeExpression(attribute(node, 'as')));
- if (as && TABLE_ELEMENTS.has(as)) {
- context.report({node, messageId: 'tableElement', data: {element: as}});
+ const asAttribute = attribute(node, 'as');
+ const as = staticString(attributeExpression(asAttribute));
+ if (asAttribute && as && TABLE_ELEMENTS.has(as)) {
+ context.report({
+ node: asAttribute,
+ messageId: 'tableElement',
+ data: {element: as},
+ });
return;
}
@@ -285,12 +291,15 @@ export const noManualTable = defineRule({
}
},
'Program:exit'() {
- for (const uses of templateUses.values()) {
- if (uses.some(use => use.repeated) && uses.some(use => !use.repeated)) {
- for (const node of new Set(uses.map(use => use.node))) {
- context.report({node, messageId: 'sharedGridTemplate'});
- }
- }
+ const shared = new Set(
+ [...templateUses.values()]
+ .filter(
+ uses => uses.some(use => use.repeated) && uses.some(use => !use.repeated)
+ )
+ .flatMap(uses => uses.map(use => use.node))
+ );
+ for (const node of shared) {
+ context.report({node, messageId: 'sharedGridTemplate'});
}
},
};
diff --git a/static/oxlint/eslintPluginScraps/src/rules/noRestrictedModuleMocks.spec.ts b/static/oxlint/eslintPluginScraps/src/rules/noRestrictedModuleMocks.spec.ts
index b62b439981ec..af9c54b6ca9c 100644
--- a/static/oxlint/eslintPluginScraps/src/rules/noRestrictedModuleMocks.spec.ts
+++ b/static/oxlint/eslintPluginScraps/src/rules/noRestrictedModuleMocks.spec.ts
@@ -13,6 +13,13 @@ ruleTester.run('no-restricted-module-mocks', noRestrictedModuleMocks, {
'jest.mock(moduleName);',
"const hooks = {useLocation() {}}; jest.spyOn(hooks, 'useLocation');",
"import * as location from 'sentry/utils/useLocation'; jest.spyOn(location, 'parseLocation');",
+ "jestLike.mock('sentry/utils/useLocation');",
+ 'jest.mock();',
+ "import * as location from 'sentry/utils/useLocation'; jest.spyOn(location);",
+ "jest.spyOn(hooks.location, 'useLocation');",
+ "jest.spyOn(load('sentry/utils/useLocation'), 'useLocation');",
+ "jest.spyOn(require(), 'useLocation');",
+ "import * as location from 'sentry/utils/useLocationExtra'; jest.spyOn(location, 'useLocationExtra');",
],
invalid: [
...[
diff --git a/static/oxlint/eslintPluginScraps/src/rules/noRestrictedModuleMocks.ts b/static/oxlint/eslintPluginScraps/src/rules/noRestrictedModuleMocks.ts
index 533960e24e18..7b1ccb1875c8 100644
--- a/static/oxlint/eslintPluginScraps/src/rules/noRestrictedModuleMocks.ts
+++ b/static/oxlint/eslintPluginScraps/src/rules/noRestrictedModuleMocks.ts
@@ -48,8 +48,6 @@ export const noRestrictedModuleMocks = defineRule({
const importTracker = createImportTracker(context);
return {
- ...importTracker.visitors,
-
CallExpression(node) {
const {callee} = node;
if (
@@ -82,25 +80,19 @@ export const noRestrictedModuleMocks = defineRule({
return;
}
- const moduleObject = node.arguments[0];
- const property = node.arguments[1];
- if (
- !moduleObject ||
- moduleObject.type === 'SpreadElement' ||
- property?.type !== 'Literal' ||
- typeof property.value !== 'string'
- ) {
+ const [moduleObject, property] = node.arguments;
+ if (property?.type !== 'Literal') {
return;
}
let moduleSource: string | undefined;
- if (moduleObject.type === 'Identifier') {
+ if (moduleObject?.type === 'Identifier') {
const importedModule = importTracker.resolve(moduleObject);
if (importedModule?.imported === '*') {
moduleSource = importedModule.source;
}
} else if (
- moduleObject.type === 'CallExpression' &&
+ moduleObject?.type === 'CallExpression' &&
moduleObject.callee.type === 'Identifier' &&
moduleObject.callee.name === 'require'
) {
diff --git a/static/oxlint/eslintPluginScraps/src/rules/noTokenImport.spec.ts b/static/oxlint/eslintPluginScraps/src/rules/noTokenImport.spec.ts
index f007cdb1163c..a9d2090549eb 100644
--- a/static/oxlint/eslintPluginScraps/src/rules/noTokenImport.spec.ts
+++ b/static/oxlint/eslintPluginScraps/src/rules/noTokenImport.spec.ts
@@ -14,14 +14,9 @@ ruleTester.run('no-token-import', noTokenImport, {
code: 'const x = require("other-package");',
filename: '/project/src/foo/file.js',
},
-
- {
- code: 'import {colors} from "sentry/utils/theme/scraps/colors";',
- filename: '/static/app/utils/theme/theme.tsx',
- },
{
- code: 'import {color} from "@sentry/scraps/tokens/color";',
- filename: '/static/app/utils/theme/theme.tsx',
+ code: 'import {Tokenized} from "@sentry/scraps/tokenized";',
+ filename: '/static/app/index.tsx',
},
{
code: 'import {size} from "@sentry/scraps/tokens/size";',
@@ -34,11 +29,6 @@ ruleTester.run('no-token-import', noTokenImport, {
],
invalid: [
- {
- code: 'import {colors} from "sentry/utils/theme/scraps/colors";',
- filename: '/static/app/index.tsx',
- errors: [{messageId: 'forbidden'}],
- },
{
code: 'import {size} from "@sentry/scraps/tokens";',
filename: '/static/app/index.tsx',
diff --git a/static/oxlint/eslintPluginScraps/src/rules/noTokenImport.ts b/static/oxlint/eslintPluginScraps/src/rules/noTokenImport.ts
index e07019012280..7279a5b2ef79 100644
--- a/static/oxlint/eslintPluginScraps/src/rules/noTokenImport.ts
+++ b/static/oxlint/eslintPluginScraps/src/rules/noTokenImport.ts
@@ -6,30 +6,11 @@ import {defineRule} from '@oxlint/plugins';
* Disallows raw token imports outside theme definitions and token exports.
*/
-const TOKEN_PATH = 'utils/theme/scraps';
const EXCEPT_DIRS = [
- 'static/app/utils/theme/',
'static/packages/scraps/src/theme/',
'static/packages/scraps/src/tokens/',
];
-/**
- *
- * @param {unknown} importPath
- * @returns {boolean}
- */
-function isForbiddenImportPath(importPath: string) {
- if (typeof importPath !== 'string') {
- return false;
- }
-
- return (
- importPath.includes(TOKEN_PATH) ||
- importPath === '@sentry/scraps/tokens' ||
- importPath.startsWith('@sentry/scraps/tokens/')
- );
-}
-
export const noTokenImport = defineRule({
meta: {
type: 'problem',
@@ -43,22 +24,18 @@ export const noTokenImport = defineRule({
},
},
create(context) {
- const importerIsInAllowedDir = EXCEPT_DIRS.some(directory =>
- context.filename.includes(directory)
- );
+ if (EXCEPT_DIRS.some(directory => context.filename.includes(directory))) {
+ return {};
+ }
return {
ImportDeclaration(node) {
- if (node?.source.type !== 'Literal') {
- return;
- }
- if (importerIsInAllowedDir) {
- return;
- }
-
const value = node.source.value;
- if (isForbiddenImportPath(value)) {
+ if (
+ value === '@sentry/scraps/tokens' ||
+ value.startsWith('@sentry/scraps/tokens/')
+ ) {
context.report({
node,
messageId: 'forbidden',
diff --git a/static/oxlint/eslintPluginScraps/src/rules/preferContainerResponsiveProps.spec.ts b/static/oxlint/eslintPluginScraps/src/rules/preferContainerResponsiveProps.spec.ts
index 07785c8d33b8..da505caad88d 100644
--- a/static/oxlint/eslintPluginScraps/src/rules/preferContainerResponsiveProps.spec.ts
+++ b/static/oxlint/eslintPluginScraps/src/rules/preferContainerResponsiveProps.spec.ts
@@ -75,6 +75,30 @@ ruleTester.run('prefer-container-responsive-props', preferContainerResponsivePro
filename: '/project/static/packages/scraps/src/slideOverPanel.tsx',
code: "import styled from '@emotion/styled'; const _SlideOverPanel = styled.div`@media (max-width: 800px) { width: 100%; }`;",
},
+ {
+ filename,
+ code: "import {css} from '@emotion/react'; function modalCss() { return css`@media (min-width: 800px) { width: 80%; }`; }",
+ },
+ {
+ filename,
+ code: "import {css} from '@emotion/react'; const modalCss = css`@media (min-width: 800px) {}`;",
+ },
+ {
+ filename: '/project/static/packages/scraps/src/slideOverPanel.tsx',
+ code: "import styled from '@emotion/styled'; const _SlideOverPanel = styled.div`@media (min-width: 800px) { ${p => p.styles} }`;",
+ },
+ {
+ filename,
+ code: "import {useMedia} from '@sentry/scraps/useMedia'; useMedia(query);",
+ },
+ {
+ filename,
+ code: "const sizes = {0: 'none', screen: 'md'};",
+ },
+ {
+ filename: '/project/static/oxlint/eslintPluginScraps/src/rules/example.spec.ts',
+ code: "const props = {direction: {'screen:md': 'row'}};",
+ },
],
invalid: [
{
@@ -187,5 +211,89 @@ ruleTester.run('prefer-container-responsive-props', preferContainerResponsivePro
code: "import styled from '@emotion/styled'; const Box = styled.div`@media (max-width: 800px) { width: 100%; }`;",
errors: [{messageId: 'forbidden'}],
},
+ {
+ filename,
+ code: `import styled from '@emotion/styled';
+const Box = styled.div\`
+ @media (max-width: \${p => p.theme.breakpoints.md}) /* sm */ {
+ width: 100%;
+ }
+ @container (width > 1px) { width: 50%; }
+\`;`,
+ errors: [
+ {messageId: 'forbidden', line: 3, column: 2, endLine: 3, endColumn: 53},
+ {messageId: 'forbidden', line: 6, column: 2, endLine: 6, endColumn: 26},
+ ],
+ },
+ {
+ filename,
+ code: "import styled from '@emotion/styled'; const Box = styled.div`@media (max-width: 1px) /* why */ and (min-width: 0) { color: red; }`;",
+ errors: [{messageId: 'forbidden', line: 1, column: 61, endLine: 1, endColumn: 113}],
+ },
+ {
+ filename,
+ code: "import styled from '@emotion/styled'; const Box = styled.div`@media screen { color: red; }`;",
+ errors: [{messageId: 'forbidden'}],
+ },
+ {
+ filename,
+ code: "import {css} from '@emotion/react'; const modalCss = css`@media (min-width: 800px) { & > div { width: 80%; } }`;",
+ errors: [{messageId: 'forbidden'}],
+ },
+ {
+ filename,
+ code: "import {css} from '@emotion/react'; const modalCss = css`@media (min-width: 800px) { width: 80%; } @media (min-width: 900px) { & > div { width: 90%; } }`;",
+ errors: [{messageId: 'forbidden'}, {messageId: 'forbidden'}],
+ },
+ {
+ filename,
+ code: "import {css} from '@emotion/react'; const modalCss = css`@media (min-width: 800px) { width: 80%; display: flex; }`;",
+ errors: [{messageId: 'forbidden'}],
+ },
+ {
+ filename,
+ code: "import {css} from '@emotion/react'; const modalCss = css`@media (min-width: 800px) { ${styles} }`;",
+ errors: [{messageId: 'forbidden'}],
+ },
+ {
+ filename: '/project/static/packages/scraps/src/slideOverPanel.tsx',
+ code: "import styled from '@emotion/styled'; const _SlideOverPanel = styled.div`@media (min-width: 800px) { display: none; }`;",
+ errors: [{messageId: 'forbidden'}],
+ },
+ {
+ filename,
+ code: "import styled from '@emotion/styled'; const _SlideOverPanel = styled.div`@media (min-width: 800px) { width: 100%; }`;",
+ errors: [{messageId: 'forbidden'}],
+ },
+ {
+ filename: '/project/static/packages/scraps/src/slideOverPanel.tsx',
+ code: "import styled from '@emotion/styled'; const SlideOverHeader = styled.div`@media (min-width: 800px) { width: 100%; }`;",
+ errors: [{messageId: 'forbidden'}],
+ },
+ {
+ filename,
+ code: "import {css} from '@emotion/react'; const onDemandBudgetEditModalCss = theme => css`@media (min-width: 800px) { width: 800px; }`;",
+ errors: [{messageId: 'forbidden'}],
+ },
+ {
+ filename: '/project/static/gsApp/actionCreators/modal.tsx',
+ code: "import {css} from '@emotion/react'; const otherModalCss = theme => css`@media (min-width: 800px) { width: 800px; }`;",
+ errors: [{messageId: 'forbidden'}],
+ },
+ {
+ filename,
+ code: "import styled from '@emotion/styled'; const DrawerSlidePanel = styled.div`@media (max-width: 800px) { width: 100%; }`;",
+ errors: [{messageId: 'forbidden'}],
+ },
+ {
+ filename: '/project/static/packages/scraps/src/drawer/components.tsx',
+ code: "import styled from '@emotion/styled'; const DrawerContent = styled.div`@media (max-width: 800px) { width: 100%; }`;",
+ errors: [{messageId: 'forbidden'}],
+ },
+ {
+ filename,
+ code: "import {useMedia} from '@sentry/scraps/useMedia'; useMedia('(800px <= width)');",
+ errors: [{messageId: 'forbidden'}],
+ },
],
});
diff --git a/static/oxlint/eslintPluginScraps/src/rules/preferContainerResponsiveProps.ts b/static/oxlint/eslintPluginScraps/src/rules/preferContainerResponsiveProps.ts
index 1689be774b5f..a5e13b30232f 100644
--- a/static/oxlint/eslintPluginScraps/src/rules/preferContainerResponsiveProps.ts
+++ b/static/oxlint/eslintPluginScraps/src/rules/preferContainerResponsiveProps.ts
@@ -4,8 +4,8 @@ import {parseCssTemplate, type ParsedCssAtRule} from '../ast/extractor/css.ts';
import {createImportTracker} from '../ast/tracker/imports.ts';
import {getStyledCallInfo} from '../ast/utils/styled.ts';
-const STYLE_QUERY = /@(?:media|container)\b/g;
-const WIDTH_QUERY = /(?:min-|max-)?width\s*[:<>=]/;
+const STYLE_QUERY = /@(?:media|container)\b/;
+const WIDTH_QUERY = /\bwidth\b/;
const LAYOUT_MEDIA_FEATURE = /\b(?:width|height|aspect-ratio|orientation)\b/;
const NON_LAYOUT_MEDIA_FEATURE =
/\b(?:prefers-[\w-]+|hover|pointer|forced-colors|color-gamut|dynamic-range|resolution|print)\b/;
@@ -52,12 +52,9 @@ function hasOnlyAllowedMediaDeclarations(
.split(';')
.map(declaration => declaration.trim())
.filter(Boolean);
- return (
- declarations.length > 0 &&
- declarations.every(
- declaration =>
- property.test(declaration) || (allowDynamicBody && declaration === 'VALUE')
- )
+ return declarations.every(
+ declaration =>
+ property.test(declaration) || (allowDynamicBody && declaration === 'VALUE')
);
});
}
@@ -113,7 +110,6 @@ export const preferContainerResponsiveProps = defineRule({
const importTracker = createImportTracker(context);
return {
- ...importTracker.visitors,
TaggedTemplateExpression(node) {
if (!getStyledCallInfo(node, importTracker)) {
return;
@@ -127,7 +123,17 @@ export const preferContainerResponsiveProps = defineRule({
!isNonLayoutMediaQuery(`@media ${atRule.params}`) &&
!allowsViewportSizing(node, context.filename))
) {
- context.report({node, messageId: 'forbidden'});
+ context.report({
+ loc: {
+ start: context.sourceCode.getLocFromIndex(
+ node.quasi.range[0] + atRule.sourceRange[0]
+ ),
+ end: context.sourceCode.getLocFromIndex(
+ node.quasi.range[0] + atRule.sourceRange[1]
+ ),
+ },
+ messageId: 'forbidden',
+ });
}
}
},
@@ -148,25 +154,16 @@ export const preferContainerResponsiveProps = defineRule({
},
Property(node) {
const key = node.key;
- const keyName =
- key.type === 'Literal' && typeof key.value === 'string'
- ? key.value
- : key.type === 'Identifier'
- ? key.name
- : undefined;
- if (
- keyName?.startsWith('screen:') &&
- !context.filename.includes('/static/packages/') &&
- !context.filename.includes('/static/oxlint/')
- ) {
- context.report({node: key, messageId: 'forbidden'});
+ if (key.type !== 'Literal' || typeof key.value !== 'string') {
return;
}
- const query = keyName?.match(STYLE_QUERY)?.[0];
+ const isViewportKey =
+ key.value.startsWith('screen:') &&
+ !context.filename.includes('/static/packages/') &&
+ !context.filename.includes('/static/oxlint/');
if (
- query &&
- keyName &&
- (query === '@container' || !isNonLayoutMediaQuery(keyName))
+ isViewportKey ||
+ (STYLE_QUERY.test(key.value) && !isNonLayoutMediaQuery(key.value))
) {
context.report({node: key, messageId: 'forbidden'});
}
diff --git a/static/oxlint/eslintPluginScraps/src/rules/preferInfoText.spec.ts b/static/oxlint/eslintPluginScraps/src/rules/preferInfoText.spec.ts
index 2f61cc8b732e..1f0117272495 100644
--- a/static/oxlint/eslintPluginScraps/src/rules/preferInfoText.spec.ts
+++ b/static/oxlint/eslintPluginScraps/src/rules/preferInfoText.spec.ts
@@ -182,6 +182,29 @@ ruleTester.run('prefer-info-text', preferInfoText, {
const x = {t('label')};
`,
},
+ {
+ name: 'Conditional expression with a non-text consequent',
+ code: `
+ import {Tooltip} from '@sentry/scraps/tooltip';
+ const x = {condition ? : 'label'};
+ `,
+ },
+ {
+ name: 'Logical OR with a non-text left side',
+ code: `
+ import {Tooltip} from '@sentry/scraps/tooltip';
+ import {t} from 'sentry/locale';
+ const x = {value || t('fallback')};
+ `,
+ },
+ {
+ name: 'Logical OR with a non-text right side',
+ code: `
+ import {Tooltip} from '@sentry/scraps/tooltip';
+ import {t} from 'sentry/locale';
+ const x = {t('label') || value};
+ `,
+ },
],
invalid: [
@@ -288,12 +311,18 @@ ruleTester.run('prefer-info-text', preferInfoText, {
const x = Some text here;
`,
errors: [
- errorWithSuggestion(`
+ {
+ ...errorWithSuggestion(`
import {Tooltip} from '@sentry/scraps/tooltip';
import {InfoText} from '@sentry/scraps/info';
const x = Some text here;
`),
+ line: 3,
+ column: 18,
+ endLine: 3,
+ endColumn: 47,
+ },
],
},
{
@@ -521,6 +550,97 @@ import {InfoText} from '@sentry/scraps/info';
`),
],
},
+ {
+ name: 'Stripped prop after a line comment keeps the line break',
+ code: `
+ import {Tooltip} from '@sentry/scraps/tooltip';
+ const x = Some text;
+ `,
+ errors: [
+ errorWithSuggestion(`
+ import {Tooltip} from '@sentry/scraps/tooltip';
+import {InfoText} from '@sentry/scraps/info';
+
+ const x = Some text;
+ `),
+ ],
+ },
+ {
+ name: 'Adjacent stripped props after a line comment keep the line break',
+ code: `
+ import {Tooltip} from '@sentry/scraps/tooltip';
+ const x = hi;
+ `,
+ errors: [
+ errorWithSuggestion(`
+ import {Tooltip} from '@sentry/scraps/tooltip';
+import {InfoText} from '@sentry/scraps/info';
+
+ const x = hi;
+ `),
+ ],
+ },
+ {
+ name: 'Adjacent stripped props after a line comment on the tag line keep the line break',
+ code: `
+ import {Tooltip} from '@sentry/scraps/tooltip';
+ const x = hi;
+ `,
+ errors: [
+ errorWithSuggestion(`
+ import {Tooltip} from '@sentry/scraps/tooltip';
+import {InfoText} from '@sentry/scraps/info';
+
+ const x = hi;
+ `),
+ ],
+ },
+ {
+ name: 'Block comment between stripped props is kept',
+ code: `
+ import {Tooltip} from '@sentry/scraps/tooltip';
+ const x = hi;
+ `,
+ errors: [
+ errorWithSuggestion(`
+ import {Tooltip} from '@sentry/scraps/tooltip';
+import {InfoText} from '@sentry/scraps/info';
+
+ const x = hi;
+ `),
+ ],
+ },
+ {
+ name: 'Line comments between stripped props are kept',
+ code: `
+ import {Tooltip} from '@sentry/scraps/tooltip';
+ const x = hi;
+ `,
+ errors: [
+ errorWithSuggestion(`
+ import {Tooltip} from '@sentry/scraps/tooltip';
+import {InfoText} from '@sentry/scraps/info';
+
+ const x = hi;
+ `),
+ ],
+ },
{
name: 'isHoverable is stripped in suggestion',
code: `
@@ -542,7 +662,7 @@ import {InfoText} from '@sentry/scraps/info';
import {Tooltip} from '@sentry/scraps/tooltip';
const x = Some text here;
`,
- errors: [{messageId: 'preferInfoText'}],
+ errors: [{messageId: 'preferInfoText', suggestions: []}],
},
{
name: 'showOnlyOnOverflow becomes overflowOnly mode in suggestion',
@@ -566,7 +686,7 @@ import {InfoText} from '@sentry/scraps/info';
import {t} from 'sentry/locale';
const x = {t('label')};
`,
- errors: [{messageId: 'preferInfoText'}],
+ errors: [{messageId: 'preferInfoText', suggestions: []}],
},
{
name: 'Text component with delay and disabled',
@@ -590,7 +710,7 @@ import {InfoText} from '@sentry/scraps/info';
);
`,
- errors: [{messageId: 'preferInfoText'}],
+ errors: [{messageId: 'preferInfoText', suggestions: []}],
},
{
name: 'Tooltip with supported delay prop',
@@ -715,5 +835,271 @@ import {InfoText} from '@sentry/scraps/info';
},
],
},
+ {
+ name: 'Tooltip with supported position prop',
+ code: `
+ import {Tooltip} from '@sentry/scraps/tooltip';
+ const x = Some text here;
+ `,
+ errors: [
+ errorWithSuggestion(`
+ import {Tooltip} from '@sentry/scraps/tooltip';
+import {InfoText} from '@sentry/scraps/info';
+
+ const x = Some text here;
+ `),
+ ],
+ },
+ {
+ name: 'JSX element in an expression container',
+ code: `
+ import {Tooltip} from '@sentry/scraps/tooltip';
+ const x = {label};
+ `,
+ errors: [
+ errorWithSuggestion(`
+ import {Tooltip} from '@sentry/scraps/tooltip';
+import {InfoText} from '@sentry/scraps/info';
+
+ const x = {label};
+ `),
+ ],
+ },
+ {
+ name: 'Stripped prop on its own line takes its line with it',
+ code: `
+ import {Tooltip} from '@sentry/scraps/tooltip';
+ const x = (
+
+ Some text here
+
+ );
+ `,
+ errors: [
+ errorWithSuggestion(`
+ import {Tooltip} from '@sentry/scraps/tooltip';
+import {InfoText} from '@sentry/scraps/info';
+
+ const x = (
+
+ Some text here
+
+ );
+ `),
+ ],
+ },
+ {
+ name: 'Comments in a Text child suggestion keep the Text element',
+ code: `
+ import {Tooltip} from '@sentry/scraps/tooltip';
+ import {Text} from '@sentry/scraps/text';
+ const x = label;
+ `,
+ errors: [
+ errorWithSuggestion(`
+ import {Tooltip} from '@sentry/scraps/tooltip';
+ import {Text} from '@sentry/scraps/text';
+import {InfoText} from '@sentry/scraps/info';
+
+ const x = label;
+ `),
+ ],
+ },
+ {
+ name: 'Comments in a Text child opening tag keep the Text element',
+ code: `
+ import {Tooltip} from '@sentry/scraps/tooltip';
+ import {Text} from '@sentry/scraps/text';
+ const x = label;
+ `,
+ errors: [
+ errorWithSuggestion(`
+ import {Tooltip} from '@sentry/scraps/tooltip';
+ import {Text} from '@sentry/scraps/text';
+import {InfoText} from '@sentry/scraps/info';
+
+ const x = label;
+ `),
+ ],
+ },
+ {
+ name: 'Comments in a Text child closing tag keep the Text element',
+ code: `
+ import {Tooltip} from '@sentry/scraps/tooltip';
+ import {Text} from '@sentry/scraps/text';
+ const x = label;
+ `,
+ errors: [
+ errorWithSuggestion(`
+ import {Tooltip} from '@sentry/scraps/tooltip';
+ import {Text} from '@sentry/scraps/text';
+import {InfoText} from '@sentry/scraps/info';
+
+ const x = label;
+ `),
+ ],
+ },
+ {
+ name: 'Comments in a Tooltip closing tag keep the Text element',
+ code: `
+ import {Tooltip} from '@sentry/scraps/tooltip';
+ import {Text} from '@sentry/scraps/text';
+ const x = label;
+ `,
+ errors: [
+ errorWithSuggestion(`
+ import {Tooltip} from '@sentry/scraps/tooltip';
+ import {Text} from '@sentry/scraps/text';
+import {InfoText} from '@sentry/scraps/info';
+
+ const x = label;
+ `),
+ ],
+ },
+ {
+ name: 'isHoverable true is stripped in suggestion',
+ code: `
+ import {Tooltip} from '@sentry/scraps/tooltip';
+ const x = Some text here;
+ `,
+ errors: [
+ errorWithSuggestion(`
+ import {Tooltip} from '@sentry/scraps/tooltip';
+import {InfoText} from '@sentry/scraps/info';
+
+ const x = Some text here;
+ `),
+ ],
+ },
+ {
+ name: 'skipWrapper is stripped in suggestion',
+ code: `
+ import {Tooltip} from '@sentry/scraps/tooltip';
+ const x = Some text here;
+ `,
+ errors: [
+ errorWithSuggestion(`
+ import {Tooltip} from '@sentry/scraps/tooltip';
+import {InfoText} from '@sentry/scraps/info';
+
+ const x = Some text here;
+ `),
+ ],
+ },
+ {
+ name: 'Tooltip with spread props has no suggestion',
+ code: `
+ import {Tooltip} from '@sentry/scraps/tooltip';
+ const x = Some text here;
+ `,
+ errors: [{messageId: 'preferInfoText', suggestions: []}],
+ },
+ {
+ name: 'Text child spread props are kept',
+ code: `
+ import {Tooltip} from '@sentry/scraps/tooltip';
+ import {Text} from '@sentry/scraps/text';
+ const x = label;
+ `,
+ errors: [
+ errorWithSuggestion(`
+ import {Tooltip} from '@sentry/scraps/tooltip';
+ import {Text} from '@sentry/scraps/text';
+import {InfoText} from '@sentry/scraps/info';
+
+ const x = label;
+ `),
+ ],
+ },
+ {
+ name: 'Text child without any props',
+ code: `
+ import {Tooltip} from '@sentry/scraps/tooltip';
+ import {Text} from '@sentry/scraps/text';
+ const x = label;
+ `,
+ errors: [
+ errorWithSuggestion(`
+ import {Tooltip} from '@sentry/scraps/tooltip';
+ import {Text} from '@sentry/scraps/text';
+import {InfoText} from '@sentry/scraps/info';
+
+ const x = label;
+ `),
+ ],
+ },
+ {
+ name: 'Text ellipsis is kept without overflow-only mode',
+ code: `
+ import {Tooltip} from '@sentry/scraps/tooltip';
+ import {Text} from '@sentry/scraps/text';
+ const x = label;
+ `,
+ errors: [
+ errorWithSuggestion(`
+ import {Tooltip} from '@sentry/scraps/tooltip';
+ import {Text} from '@sentry/scraps/text';
+import {InfoText} from '@sentry/scraps/info';
+
+ const x = label;
+ `),
+ ],
+ },
+ {
+ name: 'Text underline and ellipsis are stripped in overflow-only mode',
+ code: `
+ import {Tooltip} from '@sentry/scraps/tooltip';
+ import {Text} from '@sentry/scraps/text';
+ const x = label;
+ `,
+ errors: [
+ errorWithSuggestion(`
+ import {Tooltip} from '@sentry/scraps/tooltip';
+ import {Text} from '@sentry/scraps/text';
+import {InfoText} from '@sentry/scraps/info';
+
+ const x = label;
+ `),
+ ],
+ },
+ {
+ name: 'Self-closing Text child is wrapped',
+ code: `
+ import {Tooltip} from '@sentry/scraps/tooltip';
+ import {Text} from '@sentry/scraps/text';
+ const x = ;
+ `,
+ errors: [
+ errorWithSuggestion(`
+ import {Tooltip} from '@sentry/scraps/tooltip';
+ import {Text} from '@sentry/scraps/text';
+import {InfoText} from '@sentry/scraps/info';
+
+ const x = ;
+ `),
+ ],
+ },
+ {
+ name: 'Text child with a text sibling is wrapped',
+ code: `
+ import {Tooltip} from '@sentry/scraps/tooltip';
+ import {Text} from '@sentry/scraps/text';
+ const x = label more;
+ `,
+ errors: [
+ errorWithSuggestion(`
+ import {Tooltip} from '@sentry/scraps/tooltip';
+ import {Text} from '@sentry/scraps/text';
+import {InfoText} from '@sentry/scraps/info';
+
+ const x = label more;
+ `),
+ ],
+ },
],
});
diff --git a/static/oxlint/eslintPluginScraps/src/rules/preferInfoText.ts b/static/oxlint/eslintPluginScraps/src/rules/preferInfoText.ts
index 71960efa9bd3..a510fd86968b 100644
--- a/static/oxlint/eslintPluginScraps/src/rules/preferInfoText.ts
+++ b/static/oxlint/eslintPluginScraps/src/rules/preferInfoText.ts
@@ -1,6 +1,7 @@
-import {type Fix, type Fixer, defineRule, type ESTree, type Scope} from '@oxlint/plugins';
+import {type Fix, type Fixer, defineRule, type ESTree} from '@oxlint/plugins';
import {createImportTracker} from '../ast/tracker/imports.ts';
+import {removeJsxAttributes} from '../ast/utils/removeJsxAttributes.ts';
const TOOLTIP_SOURCE = '@sentry/scraps/tooltip';
const TEXT_SOURCE = '@sentry/scraps/text';
@@ -33,25 +34,12 @@ const TOOLTIP_PROPS_SUPPORTED_BY_INFO_TEXT = new Set([
'maxWidth',
'delay',
'showUnderline',
- // InfoText always renders its underlying Tooltip as hoverable.
- 'isHoverable',
]);
const TOOLTIP_PROPS_TO_STRIP = new Set(['showUnderline', 'isHoverable', 'skipWrapper']);
const TOOLTIP_PROPS_TO_RENAME = new Map([['showOnlyOnOverflow', 'mode="overflowOnly"']]);
const TEXT_PROPS_TO_STRIP = new Set(['underline']);
const TEXT_PROPS_TO_STRIP_IN_OVERFLOW_ONLY = new Set(['ellipsis']);
-function getElementName(nameNode: ESTree.JSXElementName): string {
- switch (nameNode.type) {
- case 'JSXIdentifier':
- return nameNode.name;
- case 'JSXMemberExpression':
- return `${getElementName(nameNode.object)}.${nameNode.property.name}`;
- case 'JSXNamespacedName':
- return `${nameNode.namespace.name}:${nameNode.name.name}`;
- }
-}
-
export const preferInfoText = defineRule({
meta: {
type: 'suggestion',
@@ -72,35 +60,16 @@ export const preferInfoText = defineRule({
const resolvingConstants = new Set();
function getConstantDeclarator(node: ESTree.IdentifierReference) {
- let scope: Scope | null = context.sourceCode.getScope(node);
- while (scope) {
- const variable = scope.set.get(node.name);
- if (variable) {
- const definition = variable.defs[0];
- if (
- variable.defs.length !== 1 ||
- definition?.type !== 'Variable' ||
- definition.parent?.type !== 'VariableDeclaration' ||
- definition.parent.kind !== 'const' ||
- definition.node.type !== 'VariableDeclarator' ||
- definition.node.id.type !== 'Identifier' ||
- variable.references.some(reference => reference.isWrite() && !reference.init)
- ) {
- return null;
- }
- return definition.node;
- }
- scope = scope.upper;
+ const definition = importTracker.resolveVariable(node)?.defs[0];
+ if (
+ definition?.parent?.type !== 'VariableDeclaration' ||
+ definition.parent.kind !== 'const' ||
+ definition.node.type !== 'VariableDeclarator' ||
+ definition.node.id.type !== 'Identifier'
+ ) {
+ return null;
}
- return null;
- }
-
- function isLocaleCall(node: ESTree.Expression): boolean {
- return (
- node.type === 'CallExpression' &&
- node.callee.type === 'Identifier' &&
- importTracker.resolve(node.callee)?.source === LOCALE_SOURCE
- );
+ return definition.node;
}
function isTextLikeExpression(
@@ -125,13 +94,13 @@ export const preferInfoText = defineRule({
}
case 'JSXElement':
case 'JSXFragment':
- return resolveConstants && isTextLikeChild(expr);
+ return isTextLikeChild(expr, resolveConstants);
case 'Literal':
return typeof expr.value === 'string';
case 'TemplateLiteral':
return true;
case 'CallExpression':
- return isLocaleCall(expr);
+ return importTracker.resolve(expr.callee)?.source === LOCALE_SOURCE;
case 'ConditionalExpression':
return (
isTextLikeExpression(expr.consequent, resolveConstants) &&
@@ -160,13 +129,13 @@ export const preferInfoText = defineRule({
}
return isTextLikeExpression(child.expression, resolveConstants);
case 'JSXElement': {
- const name = getElementName(child.openingElement.name);
+ const {name} = child.openingElement;
// Text is intended to render text content, so do not require the
// expression inside it to be statically recognizable as text.
if (importTracker.is(child.openingElement.name, TEXT_SOURCE, 'Text')) {
return true;
}
- if (TEXT_LIKE_INTRINSICS.has(name)) {
+ if (name.type === 'JSXIdentifier' && TEXT_LIKE_INTRINSICS.has(name.name)) {
return allChildrenAreTextLike(child.children, resolveConstants);
}
return false;
@@ -182,9 +151,7 @@ export const preferInfoText = defineRule({
children: ESTree.JSXChild[],
resolveConstants = true
): boolean {
- const meaningful = children.filter(
- c => !(c.type === 'JSXText' && c.value.trim() === '')
- );
+ const meaningful = getMeaningfulChildren(children);
return (
meaningful.length > 0 &&
meaningful.every(child => isTextLikeChild(child, resolveConstants))
@@ -200,15 +167,11 @@ export const preferInfoText = defineRule({
function getSingleTextElementChild(node: ESTree.JSXElement) {
const meaningfulChildren = getMeaningfulChildren(node.children);
const child = meaningfulChildren[0];
- if (meaningfulChildren.length !== 1 || !child) {
- return null;
- }
-
- if (child.type !== 'JSXElement' || child.closingElement === null) {
- return null;
- }
-
- if (!importTracker.is(child.openingElement.name, TEXT_SOURCE, 'Text')) {
+ if (
+ meaningfulChildren.length !== 1 ||
+ child?.type !== 'JSXElement' ||
+ !importTracker.is(child.openingElement.name, TEXT_SOURCE, 'Text')
+ ) {
return null;
}
@@ -216,10 +179,6 @@ export const preferInfoText = defineRule({
}
function canSuggestInfoText(node: ESTree.JSXElement): boolean {
- if (node.openingElement.selfClosing || node.closingElement === null) {
- return false;
- }
-
// Keep suggestions limited to content directly inside the Tooltip.
// A referenced element can also be used elsewhere and needs a manual change.
if (!allChildrenAreTextLike(node.children, false)) {
@@ -252,43 +211,41 @@ export const preferInfoText = defineRule({
});
}
- function getInfoTextName() {
- return importTracker.findLocalNames(INFO_SOURCE, 'InfoText')[0] ?? 'InfoText';
- }
-
- function getInfoTextImportFix(fixer: Fixer) {
- if (importTracker.findLocalNames(INFO_SOURCE, 'InfoText').length > 0) {
- return null;
+ function getInfoTextFixes(fixer: Fixer) {
+ const [existingName] = importTracker.findLocalNames(INFO_SOURCE, 'InfoText');
+ if (existingName) {
+ return {infoTextName: existingName, importFixes: []};
}
-
- const imports = context.sourceCode.ast.body.filter(
+ const lastImport = context.sourceCode.ast.body.findLast(
node => node.type === 'ImportDeclaration'
);
- const infoImport = `import {InfoText} from '${INFO_SOURCE}';\n`;
- const lastImport = imports.at(-1);
-
- if (lastImport) {
- return fixer.insertTextAfter(lastImport, `\n${infoImport}`);
- }
- return fixer.insertTextBeforeRange([0, 0], infoImport);
+ return {
+ infoTextName: 'InfoText',
+ importFixes: lastImport
+ ? [
+ fixer.insertTextAfter(
+ lastImport,
+ `\nimport {InfoText} from '${INFO_SOURCE}';\n`
+ ),
+ ]
+ : [],
+ };
}
function getAttributeText(
attributes: ESTree.JSXOpeningElement['attributes'],
- stripNames?: Set,
+ stripNames: Set,
renameNames?: Map
) {
return attributes
- .filter(attr => {
- if (!stripNames) {
- return true;
- }
- return !(
- attr.type === 'JSXAttribute' &&
- attr.name.type === 'JSXIdentifier' &&
- stripNames.has(attr.name.name)
- );
- })
+ .filter(
+ attr =>
+ !(
+ attr.type === 'JSXAttribute' &&
+ attr.name.type === 'JSXIdentifier' &&
+ stripNames.has(attr.name.name)
+ )
+ )
.map(attr => {
if (attr.type === 'JSXAttribute' && attr.name.type === 'JSXIdentifier') {
return renameNames?.get(attr.name.name) ?? context.sourceCode.getText(attr);
@@ -304,15 +261,13 @@ export const preferInfoText = defineRule({
}
return {
- ...importTracker.visitors,
-
JSXElement(node) {
if (!importTracker.is(node.openingElement.name, TOOLTIP_SOURCE, 'Tooltip')) {
return;
}
if (allChildrenAreTextLike(node.children)) {
context.report({
- node,
+ node: node.openingElement,
messageId: 'preferInfoText',
suggest: canSuggestInfoText(node)
? [
@@ -322,9 +277,26 @@ export const preferInfoText = defineRule({
if (!node.closingElement) {
return null;
}
- const infoTextName = getInfoTextName();
+ const {infoTextName, importFixes} = getInfoTextFixes(fixer);
const textChild = getSingleTextElementChild(node);
- if (textChild && textChild.closingElement !== null) {
+ const childrenRange: [number, number] | null =
+ textChild?.closingElement
+ ? [
+ textChild.openingElement.range[1],
+ textChild.closingElement.range[0],
+ ]
+ : null;
+ if (
+ textChild &&
+ childrenRange &&
+ context.sourceCode
+ .getCommentsInside(node)
+ .every(
+ comment =>
+ comment.range[0] >= childrenRange[0] &&
+ comment.range[1] <= childrenRange[1]
+ )
+ ) {
const isOverflowOnly = node.openingElement.attributes.some(
attr =>
attr.type === 'JSXAttribute' &&
@@ -349,22 +321,18 @@ export const preferInfoText = defineRule({
),
];
const childrenText = context.sourceCode.text.slice(
- textChild.openingElement.range[1],
- textChild.closingElement.range[0]
+ childrenRange[0],
+ childrenRange[1]
);
const replacement = `${buildOpeningTag(
infoTextName,
attributes
)}${childrenText}${infoTextName}>`;
- const fixes = [fixer.replaceText(node, replacement)];
- const importFix = getInfoTextImportFix(fixer);
- if (importFix !== null) {
- fixes.push(importFix);
- }
- return fixes;
+ return [fixer.replaceText(node, replacement), ...importFixes];
}
const fixes: Fix[] = [
+ ...importFixes,
fixer.replaceText(node.openingElement.name, infoTextName),
fixer.replaceText(node.closingElement.name, infoTextName),
fixer.insertTextAfter(
@@ -372,31 +340,52 @@ export const preferInfoText = defineRule({
' variant="inherit"'
),
];
+ let strippedRun: ESTree.JSXAttribute[] = [];
+ const flushStrippedRun = () => {
+ const [first] = strippedRun;
+ if (first) {
+ fixes.push(
+ removeJsxAttributes(
+ fixer,
+ context.sourceCode,
+ first,
+ strippedRun.at(-1)
+ )
+ );
+ }
+ strippedRun = [];
+ };
for (const attr of node.openingElement.attributes) {
- if (attr.type !== 'JSXAttribute') {
+ if (
+ attr.type === 'JSXAttribute' &&
+ attr.name.type === 'JSXIdentifier' &&
+ TOOLTIP_PROPS_TO_STRIP.has(attr.name.name)
+ ) {
+ const previous = strippedRun.at(-1);
+ if (
+ previous &&
+ context.sourceCode.getTokenBefore(attr, {
+ includeComments: true,
+ })?.range[1] !== previous.range[1]
+ ) {
+ flushStrippedRun();
+ }
+ strippedRun.push(attr);
continue;
}
-
- if (attr.name.type !== 'JSXIdentifier') {
+ flushStrippedRun();
+ if (
+ attr.type !== 'JSXAttribute' ||
+ attr.name.type !== 'JSXIdentifier'
+ ) {
continue;
}
-
const replacement = TOOLTIP_PROPS_TO_RENAME.get(attr.name.name);
if (replacement !== undefined) {
fixes.push(fixer.replaceText(attr, replacement));
- } else if (TOOLTIP_PROPS_TO_STRIP.has(attr.name.name)) {
- const src = context.sourceCode.getText();
- let start = attr.range[0];
- while (start > 0 && src[start - 1] === ' ') {
- start--;
- }
- fixes.push(fixer.removeRange([start, attr.range[1]]));
}
}
- const importFix = getInfoTextImportFix(fixer);
- if (importFix !== null) {
- fixes.push(importFix);
- }
+ flushStrippedRun();
return fixes;
},
},
diff --git a/static/oxlint/eslintPluginScraps/src/rules/preferPrimitives.spec.ts b/static/oxlint/eslintPluginScraps/src/rules/preferPrimitives.spec.ts
index b5e082481baf..39560edd5a66 100644
--- a/static/oxlint/eslintPluginScraps/src/rules/preferPrimitives.spec.ts
+++ b/static/oxlint/eslintPluginScraps/src/rules/preferPrimitives.spec.ts
@@ -39,6 +39,24 @@ function invalid(
ruleTester.run('prefer-primitives', preferPrimitives, {
valid: [
+ {
+ name: 'bindings passed as one of several styled arguments are not inlined (first)',
+ code:
+ emotion +
+ "const styles = {display:'flex'}; const A = styled.a(styles, other); const B =
;",
+ },
+ {
+ name: 'bindings passed as one of several styled arguments are not inlined (second)',
+ code:
+ emotion +
+ "const styles = {display:'flex'}; const A = styled.a(other, styles); const B =
;",
+ },
+ {
+ name: 'bindings passed as one of several css arguments are not inlined',
+ code:
+ emotion +
+ "const styles = {display:'flex'}; const A = ; const B =
;",
+ },
{
name: 'nested interpolations are not root declaration evidence',
code: emotion + 'const C = styled.div`display:flex;&:hover {color:${color};}`;',
@@ -201,8 +219,65 @@ ruleTester.run('prefer-primitives', preferPrimitives, {
name: 'CSS syntax errors do not crash unrelated source',
code: `${emotion} const C = styled.div\`padding 8px; broken: {;\`; const D = ;`,
},
+ {
+ name: 'conditional style expressions are not evaluated',
+ code: `const C = ;`,
+ },
+ {
+ name: 'self-referencing style constants do not recurse forever',
+ code: `const styles = styles; const C =
;`,
+ },
+ {
+ name: 'imported style objects are not resolved',
+ code: `import {styles} from './styles'; const C =
;`,
+ },
+ {
+ name: 'loop bindings without initializers are not resolved',
+ code: `for (const styles of list) { render(
); }`,
+ },
+ {
+ name: 'shared definitions must support every intrinsic tag in any order',
+ code: 'const styles = {display:"flex"}; const C = <>