Skip to content
Closed
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
6 changes: 4 additions & 2 deletions .agents/skills/lint-new/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,12 +24,14 @@ Before writing AST traversal logic, check `static/eslint/eslintPluginScraps/src/

| Utility | Location | Use for |
| ----------------------- | ---------------------------------------- | ------------------------------------------------------------------- |
| `getStyledCallInfo` | `src/ast/utils/styled.ts` | Classifying styled/css calls as element, component, or css |
| `createQuasiScanner` | `src/ast/scanner/index.ts` | Scanning static CSS text in template literals (Archetype 4) |
| `createImportTracker` | `src/ast/tracker/imports.ts` | Resolving where a local name was imported from |
| `createStyleCollector` | `src/ast/extractor/index.ts` | Collecting CSS-in-JS _dynamic value_ declarations (NOT static text) |
| `shouldAnalyze` | `src/ast/extractor/index.ts` | Fast pre-scan to skip files without Emotion usage |
| `normalizePropertyName` | `src/ast/utils/normalizePropertyName.ts` | Normalizing CSS property names |
| `decomposeValue` | `src/ast/extractor/value-decomposer.ts` | Breaking complex expressions into all possible values |
| Theme tracker | `src/ast/extractor/theme.ts` | Tracking `useTheme()` and callback theme bindings |
| `getStyledInfo` | `src/ast/utils/styled.ts` (if exists) | Detecting styled calls and extracting component/element name |
| Theme tracker | `src/ast/tracker/theme.ts` | Tracking `useTheme()` and callback theme bindings |

If another rule already solves a similar problem, extract shared logic into `src/ast/utils/` and reuse it.

Expand Down
52 changes: 32 additions & 20 deletions .agents/skills/lint-new/references/rule-archetypes.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,14 +80,28 @@ create(context) {

**When**: Rule restricts which JSX elements can appear in specific props or slots.

**Pattern**: Two visitors:
**Pattern**: Use `createImportTracker` from `src/ast/tracker/imports.ts` for import resolution, plus a `JSXAttribute` visitor for tree walking:

1. `ImportDeclaration` — build a `Set<string>` of allowed local names by resolving imports against a config
1. `createImportTracker()` — merge its `visitors`, then use `resolve(localName)` or `findLocalNames(source, name)` to check imports
2. `JSXAttribute` — when a configured prop is found, recursively walk the JSX tree checking each element against the allowed set

```typescript
create(context) {
const importTracker = createImportTracker();

return {
...importTracker.visitors,
JSXAttribute(node) {
// Use importTracker.resolve(displayName) to check where an element comes from
// Use importTracker.findLocalNames(source, name) to find local aliases
},
};
}
```

**Key patterns**:

- Handle import aliasing: `import {Foo as Bar}` means `Bar` is the local name
- Handle import aliasing: `import {Foo as Bar}` means `Bar` is the local name — `importTracker.resolve('Bar')` returns `{source, imported: 'Foo'}`
- Handle member expressions: `MenuComponents.Alert` must match `${localName}.${member}`
- Recurse through: direct JSX children, ternaries, logical expressions (`&&`, `||`, `??`), `JSXExpressionContainer`, `JSXFragment`, arrow function expression bodies
- Skip `React.Fragment` / `<Fragment>` (transparent wrappers)
Expand All @@ -101,27 +115,25 @@ create(context) {

**When**: Rule detects patterns in the static CSS text of template literals (not in interpolated expressions).

**Pattern**: Visit `TaggedTemplateExpression`, check if it's a styled/css call, then iterate over `quasi.quasis` to inspect static text.
**Pattern**: Use `createQuasiScanner` from `src/ast/scanner/index.ts` — it handles `shouldAnalyze` bailout, tag detection via `getStyledCallInfo`, and quasi iteration for you:

```typescript
create(context) {
if (!shouldAnalyze(context)) return {};
import {createQuasiScanner} from '../ast/scanner/index';

return {
TaggedTemplateExpression(node) {
// Use getStyledInfo(node.tag) or manual tag detection
if (!isRelevantTag(node.tag)) return;

for (const quasiElement of node.quasi.quasis) {
const cssText = quasiElement.value.cooked ?? quasiElement.value.raw;
// Analyze cssText with regex or string parsing
// Report on quasiElement node for error location
}
},
};
create(context) {
return createQuasiScanner(context, (cssText, quasi, info) => {
// cssText: the static CSS text of this quasi segment
// quasi: the TemplateElement node (use for error reporting)
// info: { kind: 'element' | 'component' | 'css', name?: string }
for (const match of cssText.matchAll(MY_PATTERN)) {
context.report({ node: quasi, messageId: '...' });
}
});
}
```

**When to use this vs Archetype 2**: If you're looking for patterns in the CSS _text itself_ (raw colors, nested selectors, property names), use this. If you're validating _what values are passed_ to CSS properties via interpolation (`${theme.tokens.X}`), use the style collector.
The scanner calls your `analyze` callback for every quasi element in every styled/css tagged template in the file. It automatically skips files without Emotion usage.

**When to use this vs Archetype 2**: If you're looking for patterns in the CSS _text itself_ (raw colors, nested selectors, property names), use `createQuasiScanner`. If you're validating _what values are passed_ to CSS properties via interpolation (`${theme.tokens.X}`), use `createStyleCollector`.

**Shared utilities for tag detection**: Check `src/ast/utils/styled.ts` for `getStyledInfo(tag)` which returns `{kind: 'element' | 'component', name: string}` or `null`. This handles `styled.div`, `styled(Component)`, and `styled(Component).attrs(...)` patterns. If this utility doesn't exist yet, check if it was added in a recent PR.
**Tag detection utility**: `getStyledCallInfo(node)` from `src/ast/utils/styled.ts` classifies any `TaggedTemplateExpression` or `CallExpression` as `{kind: 'element', name}`, `{kind: 'component', name}`, `{kind: 'css'}`, or `null`. Handles `styled.div`, `styled('div')`, `styled(Component)`, `styled(Component).attrs(...)`, and `css` patterns. The scanner uses this internally, but you can also use it directly in custom visitors.
6 changes: 3 additions & 3 deletions .agents/skills/lint-new/references/style-collector-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,9 @@ Use `createStyleCollector` when your rule needs to:

Do NOT use it when you need to:

- Detect patterns in static CSS text (use template quasi analysis instead)
- Detect patterns in static CSS text use `createQuasiScanner` from `src/ast/scanner/index.ts` instead
- Check import paths (use `ImportDeclaration` visitor)
- Restrict JSX element usage (use JSX tree walking)
- Restrict JSX element usage (use JSX tree walking + `createImportTracker`)

## Architecture

Expand Down Expand Up @@ -101,4 +101,4 @@ const Box = styled.div`
`;
```

If your rule detects raw hex colors, nested selectors, or other patterns in the _text itself_, walk `quasi.quasis` directly instead. See the "Template Text Analysis" archetype in `rule-archetypes.md`.
If your rule detects raw hex colors, nested selectors, or other patterns in the _text itself_, use `createQuasiScanner` from `src/ast/scanner/index.ts` instead. See the "Template Text Analysis" archetype in `rule-archetypes.md`.
1 change: 1 addition & 0 deletions eslint.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -462,6 +462,7 @@ export default typescript.config([
plugins: {'@sentry/scraps': sentryScrapsPlugin},
rules: {
'@sentry/scraps/no-core-import': 'error',
'@sentry/scraps/no-styled-core': 'error',
'@sentry/scraps/no-token-import': 'error',
'@sentry/scraps/use-semantic-token': [
'error',
Expand Down
9 changes: 6 additions & 3 deletions static/eslint/eslintPluginScraps/src/ast/extractor/index.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import {TSESLint, TSESTree} from '@typescript-eslint/utils';

import {createThemeTracker} from '../tracker/theme';

import {createCssPropExtractor} from './css-prop';
import {createStylePropExtractor} from './style-prop';
import {createStyledExtractor} from './styled';
import {createThemeTracker} from './theme';
import type {ExtractorContext, StyleCollector, StyleDeclaration} from './types';

/**
Expand All @@ -15,7 +16,9 @@ import type {ExtractorContext, StyleCollector, StyleDeclaration} from './types';
* Returns false if file has no emotion/styled patterns.
* False positives are acceptable; we just want to skip clearly unrelated files.
*/
export function shouldAnalyze(context: TSESLint.RuleContext<string, unknown[]>) {
export function shouldAnalyze(
context: Readonly<TSESLint.RuleContext<string, readonly unknown[]>>
) {
const text = context.sourceCode.getText();

// Check for emotion imports OR usage patterns
Expand Down Expand Up @@ -103,5 +106,5 @@ export function createStyleCollector(context: TSESLint.RuleContext<string, unkno
export {createStyledExtractor} from './styled';
export {createCssPropExtractor} from './css-prop';
export {createStylePropExtractor} from './style-prop';
export {createThemeTracker} from './theme';
export {createThemeTracker} from '../tracker/theme';
export {decomposeValue} from './value-decomposer';
43 changes: 3 additions & 40 deletions static/eslint/eslintPluginScraps/src/ast/extractor/styled.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
import type {TSESLint, TSESTree} from '@typescript-eslint/utils';

import {normalizePropertyName} from '../utils/normalizePropertyName';
import {getStyledCallInfo} from '../utils/styled';

import type {ExtractorContext, StyleDeclaration} from './types';
import {decomposeValue} from './value-decomposer';
Expand All @@ -37,22 +38,6 @@ export function createStyledExtractor({
return match?.[1] ?? null;
}

/**
* Check if a tagged template is a styled/css pattern.
*/
function isStyledOrCssTag(node: TSESTree.TaggedTemplateExpression) {
const tag = node.tag;
return (
(tag.type === 'Identifier' && tag.name === 'css') ||
(tag.type === 'MemberExpression' &&
((tag.property.type === 'Identifier' && tag.property.name === 'css') ||
(tag.object.type === 'Identifier' && tag.object.name === 'styled'))) ||
(tag.type === 'CallExpression' &&
tag.callee.type === 'Identifier' &&
tag.callee.name === 'styled')
);
}

/**
* Check if we're in a lookup table pattern that should be excluded.
* e.g., ({ none: theme.tokens.content.primary })[status]
Expand Down Expand Up @@ -174,37 +159,15 @@ export function createStyledExtractor({

return {
TaggedTemplateExpression(node: TSESTree.TaggedTemplateExpression) {
if (!isStyledOrCssTag(node)) {
if (!getStyledCallInfo(node)) {
return;
}
processTemplateLiteral(node.quasi, node);
},

// Handle styled.div({ ... }) object syntax
CallExpression(node: TSESTree.CallExpression) {
const callee = node.callee;

let isStyledCall = false;

// styled.div({ ... })
if (
callee.type === 'MemberExpression' &&
callee.object.type === 'Identifier' &&
callee.object.name === 'styled'
) {
isStyledCall = true;
}

// styled('div')({ ... }) - callee is a CallExpression
if (
callee.type === 'CallExpression' &&
callee.callee.type === 'Identifier' &&
callee.callee.name === 'styled'
) {
isStyledCall = true;
}

if (!isStyledCall) {
if (!getStyledCallInfo(node)) {
return;
}

Expand Down
62 changes: 62 additions & 0 deletions static/eslint/eslintPluginScraps/src/ast/scanner/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
/**
* @file Reusable scanner for static CSS text in tagged template literals.
*
* Wraps the common pattern: shouldAnalyze → TaggedTemplateExpression → check tag
* → iterate quasi.quasis → call analyze callback.
*
* Usage:
* create(context) {
* return createQuasiScanner(context, (cssText, quasi, info) => {
* // pattern-match cssText, report on quasi
* });
* }
*/

import type {TSESLint, TSESTree} from '@typescript-eslint/utils';

import {shouldAnalyze} from '../extractor/index';
import {getStyledCallInfo, type StyledCallInfo} from '../utils/styled';

/**
* Callback invoked for each quasi (static text segment) in a styled/css template literal.
*
* @param cssText - The static CSS text (cooked or raw)
* @param quasi - The TemplateElement AST node (use for error reporting location)
* @param info - Classification of the styled/css call (element, component, or css)
*/
export type QuasiAnalyzer = (
cssText: string,
quasi: TSESTree.TemplateElement,
info: NonNullable<StyledCallInfo>
) => void;

/**
* Creates an ESLint visitor that scans static CSS text in tagged template literals.
*
* Calls `analyze` for every quasi element in every styled/css tagged template
* in the file. Bails out early via `shouldAnalyze` for files without Emotion usage.
*/
export function createQuasiScanner(
context: Readonly<TSESLint.RuleContext<'quasi', readonly unknown[]>>,
analyze: QuasiAnalyzer
): TSESLint.RuleListener {
if (!shouldAnalyze(context)) {
return {};
}

return {
TaggedTemplateExpression(node: TSESTree.TaggedTemplateExpression) {
const info = getStyledCallInfo(node);
if (!info) {
return;
}

for (const quasi of node.quasi.quasis) {
const cssText = quasi.value.cooked ?? quasi.value.raw;
if (cssText) {
analyze(cssText, quasi, info);
}
}
},
};
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import {RuleTester} from '@typescript-eslint/rule-tester';
import {ESLintUtils} from '@typescript-eslint/utils';

import {createQuasiScanner} from './index';

/**
* Minimal rule that reports each quasi's CSS text via createQuasiScanner.
*/
const testRule = ESLintUtils.RuleCreator.withoutDocs({
meta: {
type: 'problem',
schema: [],
messages: {
quasi: '{{kind}}:{{text}}',
},
},
create(context) {
return createQuasiScanner(context, (cssText, quasi, info) => {
context.report({
node: quasi,
messageId: 'quasi',
data: {
kind: info.kind,
text: cssText.trim().slice(0, 40),
},
});
});
},
});

const ruleTester = new RuleTester();

ruleTester.run('createQuasiScanner', testRule, {
valid: [
{
// No emotion usage — shouldAnalyze bails out
code: 'const x = "hello";',
filename: '/project/src/file.tsx',
},
{
// Non-styled tagged template — getStyledCallInfo returns null
code: `
import styled from '@emotion/styled';
const x = html\`<div>hi</div>\`;
`,
filename: '/project/src/file.tsx',
},
],
invalid: [
{
code: `
import styled from '@emotion/styled';
const Box = styled.div\`
color: red;
\`;
`,
filename: '/project/src/file.tsx',
errors: [{messageId: 'quasi'}],
},
{
code: `
import styled from '@emotion/styled';
const Box = css\`
background: blue;
\`;
`,
filename: '/project/src/file.tsx',
errors: [{messageId: 'quasi'}],
},
{
// Multiple quasis from interpolation
code: `
import styled from '@emotion/styled';
const Box = styled.div\`
color: \${red};
background: blue;
\`;
`,
filename: '/project/src/file.tsx',
errors: [{messageId: 'quasi'}, {messageId: 'quasi'}],
},
],
});
Loading
Loading