Files
2026-07-13 13:05:33 +08:00

313 lines
8.9 KiB
JavaScript

// TypeScript 7 is a native CLI; AST consumers still need the legacy JavaScript API.
import ts from 'typescript-api'
const STYLED_SCROLLBAR_CLASSES = new Set(
'scrollbar-sleek scrollbar-editor worktree-sidebar-scrollbar'.split(' ')
)
// Why: vertical scroll is where native scrollbar drift keeps recurring. The
// guard intentionally ignores horizontal-only overflow.
const VERTICAL_SCROLL_CLASSES = new Set(
'overflow-auto overflow-scroll overflow-y-auto overflow-y-scroll'.split(' ')
)
const VERTICAL_SCROLL_STYLE_VALUES = new Set(['auto', 'scroll'])
export function plainClassName(token) {
const normalizedToken = token.startsWith('!') ? token.slice(1) : token
const parts = []
let bracketDepth = 0
let currentPart = ''
for (const char of normalizedToken) {
if (char === '[') {
bracketDepth += 1
} else if (char === ']') {
bracketDepth = Math.max(0, bracketDepth - 1)
}
if (char === ':' && bracketDepth === 0) {
parts.push(currentPart)
currentPart = ''
continue
}
currentPart += char
}
parts.push(currentPart)
const className = parts.at(-1) ?? ''
return className.startsWith('!') ? className.slice(1) : className
}
function classTokenParts(token) {
const variants = []
let bracketDepth = 0
let currentPart = ''
for (const char of token.startsWith('!') ? token.slice(1) : token) {
if (char === '[') {
bracketDepth += 1
} else if (char === ']') {
bracketDepth = Math.max(0, bracketDepth - 1)
}
if (char === ':' && bracketDepth === 0) {
variants.push(currentPart)
currentPart = ''
continue
}
currentPart += char
}
return { className: plainClassName(token), variants: variants.filter(Boolean) }
}
function classTokens(text) {
return text.split(/\s+/).filter(Boolean).map(classTokenParts)
}
function sameVariants(left, right) {
return left.length === right.length && left.every((variant, index) => variant === right[index])
}
function literalHasScrollbarForVertical(text, verticalToken) {
return classTokens(text).some((candidate) => {
if (!STYLED_SCROLLBAR_CLASSES.has(candidate.className)) {
return false
}
return (
candidate.variants.length === 0 || sameVariants(candidate.variants, verticalToken.variants)
)
})
}
function uncoveredVerticalClass(text) {
return classTokens(text).find((token) => {
return (
VERTICAL_SCROLL_CLASSES.has(token.className) && !literalHasScrollbarForVertical(text, token)
)
})
}
function reportAt(node, filePath, sourceFile, text) {
const position = sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile))
return {
filePath,
line: position.line + 1,
column: position.character + 1,
text
}
}
function stringLiteralTexts(node) {
if (ts.isStringLiteralLike(node) || ts.isNoSubstitutionTemplateLiteral(node)) {
return [node.text]
}
if (!ts.isTemplateExpression(node)) {
return []
}
return [node.head.text, ...node.templateSpans.map((span) => span.literal.text)]
}
function collectClassLiteralReports(node, filePath, sourceFile) {
const reports = []
function visit(current) {
for (const text of stringLiteralTexts(current)) {
const uncovered = uncoveredVerticalClass(text)
if (uncovered) {
reports.push(reportAt(current, filePath, sourceFile, uncovered.className))
}
}
ts.forEachChild(current, visit)
}
visit(node)
return reports
}
function expressionHasStyledScrollbarLiteral(node) {
let hasStyledScrollbar = false
function visit(current) {
if (hasStyledScrollbar) {
return
}
if (
stringLiteralTexts(current).some((text) =>
classTokens(text).some((token) => STYLED_SCROLLBAR_CLASSES.has(token.className))
)
) {
hasStyledScrollbar = true
return
}
// Why: a scrollbar literal that only renders on some branches must not be
// treated as covering an unconditional inline overflow. Skip conditional
// and short-circuit expressions when proving unconditional coverage.
if (ts.isConditionalExpression(current)) {
return
}
if (
ts.isBinaryExpression(current) &&
(current.operatorToken.kind === ts.SyntaxKind.AmpersandAmpersandToken ||
current.operatorToken.kind === ts.SyntaxKind.BarBarToken ||
current.operatorToken.kind === ts.SyntaxKind.QuestionQuestionToken)
) {
return
}
ts.forEachChild(current, visit)
}
visit(node)
return hasStyledScrollbar
}
function propertyNameText(name) {
if (ts.isIdentifier(name) || ts.isStringLiteralLike(name)) {
return name.text
}
if (ts.isComputedPropertyName(name) && ts.isStringLiteralLike(name.expression)) {
return name.expression.text
}
return undefined
}
function styleValueIsVerticalScroll(propertyName, value) {
const parts = value.trim().toLowerCase().split(/\s+/).filter(Boolean)
if (parts.length === 0) {
return false
}
if (propertyName === 'overflowY' || propertyName === 'overflow-y') {
return VERTICAL_SCROLL_STYLE_VALUES.has(parts[0])
}
if (propertyName !== 'overflow') {
return false
}
const verticalValue = parts.length > 1 ? parts[1] : parts[0]
return VERTICAL_SCROLL_STYLE_VALUES.has(verticalValue)
}
function collectStyleReports(node, filePath, sourceFile) {
const reports = []
function visit(current) {
if (ts.isPropertyAssignment(current)) {
const propertyName = propertyNameText(current.name)
for (const value of propertyName ? stringLiteralTexts(current.initializer) : []) {
if (styleValueIsVerticalScroll(propertyName, value)) {
reports.push(reportAt(current, filePath, sourceFile, 'inline vertical scroll'))
}
}
ts.forEachChild(current.initializer, visit)
return
}
ts.forEachChild(current, visit)
}
visit(node)
return reports
}
function jsxAttributeName(attribute) {
return ts.isIdentifier(attribute.name) ? attribute.name.text : undefined
}
function jsxAttributeExpression(attribute) {
if (ts.isStringLiteral(attribute.initializer)) {
return attribute.initializer
}
if (attribute.initializer && ts.isJsxExpression(attribute.initializer)) {
return attribute.initializer.expression
}
return undefined
}
function spreadPropExpressions(node, propName) {
if (
ts.isParenthesizedExpression(node) ||
ts.isAsExpression(node) ||
ts.isSatisfiesExpression(node)
) {
return spreadPropExpressions(node.expression, propName)
}
if (ts.isConditionalExpression(node)) {
return [
...spreadPropExpressions(node.whenTrue, propName),
...spreadPropExpressions(node.whenFalse, propName)
]
}
if (ts.isBinaryExpression(node)) {
return [
...spreadPropExpressions(node.left, propName),
...spreadPropExpressions(node.right, propName)
]
}
if (!ts.isObjectLiteralExpression(node)) {
return []
}
return node.properties.flatMap((property) => {
if (ts.isSpreadAssignment(property)) {
return spreadPropExpressions(property.expression, propName)
}
if (ts.isPropertyAssignment(property) && propertyNameText(property.name) === propName) {
return [property.initializer]
}
return []
})
}
function jsxElementReports(node, filePath, sourceFile) {
const reports = []
let classExpression
const styleExpressions = []
for (const attribute of node.attributes.properties) {
if (ts.isJsxSpreadAttribute(attribute)) {
// Why: at runtime React applies attributes in source order, so a later
// spread that supplies className overrides an earlier explicit className.
const spreadClassExpression = spreadPropExpressions(attribute.expression, 'className').at(-1)
if (spreadClassExpression) {
classExpression = spreadClassExpression
}
styleExpressions.push(...spreadPropExpressions(attribute.expression, 'style'))
} else if (jsxAttributeName(attribute) === 'className') {
classExpression = jsxAttributeExpression(attribute)
} else if (jsxAttributeName(attribute) === 'style') {
const expression = jsxAttributeExpression(attribute)
if (expression) {
styleExpressions.push(expression)
}
}
}
if (classExpression) {
reports.push(...collectClassLiteralReports(classExpression, filePath, sourceFile))
}
if (classExpression && expressionHasStyledScrollbarLiteral(classExpression)) {
return reports
}
for (const expression of styleExpressions) {
reports.push(...collectStyleReports(expression, filePath, sourceFile))
}
return reports
}
export function reportUnstyledScrollbars(filePath, sourceText) {
const sourceFile = ts.createSourceFile(
filePath,
sourceText,
ts.ScriptTarget.Latest,
true,
ts.ScriptKind.TSX
)
const reports = []
function visit(node) {
if (ts.isJsxOpeningElement(node) || ts.isJsxSelfClosingElement(node)) {
reports.push(...jsxElementReports(node, filePath, sourceFile))
}
ts.forEachChild(node, visit)
}
visit(sourceFile)
return reports
}