@satinhq/lint 0.0.0-stage → 0.1.0-beta.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +104 -2
- package/oxlint.json +100 -0
- package/package.json +38 -3
- package/scripts/check-fidelity.mjs +28 -0
- package/scripts/check-modal-lifecycle.mjs +49 -0
- package/scripts/check-sidebar-artwork.mjs +20 -0
- package/scripts/check-webgpu-worker.mjs +144 -0
- package/src/checker.mjs +71 -0
- package/src/css-analysis.mjs +595 -0
- package/src/html-analysis.mjs +49 -0
- package/src/native-typescript.mjs +59 -0
- package/src/rules.mjs +276 -0
- package/src/typescript-analysis.mjs +1461 -0
|
@@ -0,0 +1,1461 @@
|
|
|
1
|
+
import { syntax as ts, parseSourceFile } from './native-typescript.mjs'
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
analyzeCssTextFragment,
|
|
5
|
+
analyzeInlineCssText,
|
|
6
|
+
analyzeInlineStyleValue,
|
|
7
|
+
isScrollingInlineValue,
|
|
8
|
+
hasCustomLinearEasing,
|
|
9
|
+
} from './css-analysis.mjs'
|
|
10
|
+
import { rules } from './rules.mjs'
|
|
11
|
+
|
|
12
|
+
const REACT_MODULE = 'react'
|
|
13
|
+
const JSX_RUNTIME_MODULES = new Set(['react/jsx-runtime', 'react/jsx-dev-runtime'])
|
|
14
|
+
const CSS_TAG_MODULES = new Set([
|
|
15
|
+
'styled-components',
|
|
16
|
+
'@emotion/react',
|
|
17
|
+
'@emotion/styled',
|
|
18
|
+
'@linaria/core',
|
|
19
|
+
])
|
|
20
|
+
const INLINE_OVERFLOW_PROPERTIES = new Set(['overflow', 'overflowx', 'overflowy'])
|
|
21
|
+
const FUNCTION_LIKE_KINDS = new Set([
|
|
22
|
+
ts.SyntaxKind.ArrowFunction,
|
|
23
|
+
ts.SyntaxKind.FunctionDeclaration,
|
|
24
|
+
ts.SyntaxKind.FunctionExpression,
|
|
25
|
+
])
|
|
26
|
+
|
|
27
|
+
function isGlassPackage(moduleName) {
|
|
28
|
+
if (moduleName.startsWith('.') || moduleName.startsWith('/') || moduleName.includes('://')) {
|
|
29
|
+
return false
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const segments = moduleName.toLowerCase().split('/')
|
|
33
|
+
const packageSegments = moduleName.startsWith('@') ? segments.slice(0, 2) : segments.slice(0, 1)
|
|
34
|
+
return packageSegments.some(
|
|
35
|
+
segment =>
|
|
36
|
+
segment.includes('liquid-glass') ||
|
|
37
|
+
segment.includes('liquidglass') ||
|
|
38
|
+
segment.includes('liquid-gl') ||
|
|
39
|
+
segment.includes('openglass'),
|
|
40
|
+
)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function unwrapExpression(node) {
|
|
44
|
+
let current = node
|
|
45
|
+
while (
|
|
46
|
+
current &&
|
|
47
|
+
(ts.isParenthesizedExpression(current) ||
|
|
48
|
+
ts.isAsExpression(current) ||
|
|
49
|
+
ts.isTypeAssertionExpression(current) ||
|
|
50
|
+
ts.isNonNullExpression(current) ||
|
|
51
|
+
ts.isSatisfiesExpression(current))
|
|
52
|
+
) {
|
|
53
|
+
current = current.expression
|
|
54
|
+
}
|
|
55
|
+
return current
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function isFunctionLike(node) {
|
|
59
|
+
return node !== undefined && FUNCTION_LIKE_KINDS.has(node.kind)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function containingScope(node) {
|
|
63
|
+
let current = node.parent
|
|
64
|
+
while (current) {
|
|
65
|
+
if (ts.isBlock(current) || ts.isSourceFile(current) || isFunctionLike(current)) return current
|
|
66
|
+
current = current.parent
|
|
67
|
+
}
|
|
68
|
+
return node.getSourceFile()
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function functionScope(node) {
|
|
72
|
+
let current = node.parent
|
|
73
|
+
while (current) {
|
|
74
|
+
if (isFunctionLike(current)) return current
|
|
75
|
+
current = current.parent
|
|
76
|
+
}
|
|
77
|
+
return node.getSourceFile()
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function staticModuleName(node) {
|
|
81
|
+
if (ts.isStringLiteralLike(node)) return node.text
|
|
82
|
+
return undefined
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function propertyNameText(name, context, environment = new Map()) {
|
|
86
|
+
if (ts.isIdentifier(name) || ts.isPrivateIdentifier(name)) return name.text
|
|
87
|
+
if (ts.isStringLiteralLike(name) || ts.isNumericLiteral(name)) return name.text
|
|
88
|
+
if (ts.isComputedPropertyName(name)) {
|
|
89
|
+
return context.staticStrings(name.expression, environment)[0]?.value
|
|
90
|
+
}
|
|
91
|
+
return undefined
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function literalContentOffset(node, sourceFile) {
|
|
95
|
+
if (ts.isStringLiteralLike(node) || ts.isNoSubstitutionTemplateLiteral(node)) {
|
|
96
|
+
return node.getStart(sourceFile) + 1
|
|
97
|
+
}
|
|
98
|
+
return node.getStart(sourceFile)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
class AstContext {
|
|
102
|
+
constructor(fileName, source) {
|
|
103
|
+
this.sourceFile = parseSourceFile(fileName, source).file
|
|
104
|
+
this.evaluationWork = 0
|
|
105
|
+
this.bindings = new Map()
|
|
106
|
+
this.violations = []
|
|
107
|
+
this.violationKeys = new Set()
|
|
108
|
+
this.collectBindings(this.sourceFile)
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
consumeEvaluationBudget(node, amount = 1) {
|
|
112
|
+
this.evaluationWork += amount
|
|
113
|
+
if (this.evaluationWork <= 65536) return
|
|
114
|
+
const location = this.sourceFile.getLineAndCharacterOfPosition(node.getStart(this.sourceFile))
|
|
115
|
+
const error = new Error(
|
|
116
|
+
`${this.sourceFile.fileName}:${location.line + 1}:${location.character + 1}: ` +
|
|
117
|
+
'static-evaluation-budget-exceeded: analysis incomplete; simplify combinatorial expressions.',
|
|
118
|
+
)
|
|
119
|
+
error.code = 'static-evaluation-budget-exceeded'
|
|
120
|
+
throw error
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
addBinding(name, binding) {
|
|
124
|
+
const existing = this.bindings.get(name) ?? []
|
|
125
|
+
existing.push(binding)
|
|
126
|
+
this.bindings.set(name, existing)
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
collectBindingName(name, binding) {
|
|
130
|
+
if (ts.isIdentifier(name)) {
|
|
131
|
+
this.addBinding(name.text, binding)
|
|
132
|
+
return
|
|
133
|
+
}
|
|
134
|
+
if (!ts.isObjectBindingPattern(name)) {
|
|
135
|
+
for (const element of name.elements) {
|
|
136
|
+
// Native TS 7 represents array elisions as nameless BindingElements.
|
|
137
|
+
if (ts.isOmittedExpression(element) || !element.name) continue
|
|
138
|
+
this.collectBindingName(element.name, { ...binding, value: undefined })
|
|
139
|
+
}
|
|
140
|
+
return
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
for (const element of name.elements) {
|
|
144
|
+
if (!ts.isIdentifier(element.name)) continue
|
|
145
|
+
this.addBinding(element.name.text, {
|
|
146
|
+
...binding,
|
|
147
|
+
value: undefined,
|
|
148
|
+
destructureSource: binding.value,
|
|
149
|
+
destructureProperty:
|
|
150
|
+
element.propertyName === undefined
|
|
151
|
+
? element.name.text
|
|
152
|
+
: propertyNameText(element.propertyName, this),
|
|
153
|
+
})
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
collectBindings(node) {
|
|
158
|
+
if (ts.isImportDeclaration(node) && node.importClause) {
|
|
159
|
+
const moduleName = staticModuleName(node.moduleSpecifier)
|
|
160
|
+
if (moduleName !== undefined && !node.importClause.isTypeOnly) {
|
|
161
|
+
if (node.importClause.name) {
|
|
162
|
+
this.addBinding(node.importClause.name.text, {
|
|
163
|
+
node: node.importClause.name,
|
|
164
|
+
scope: this.sourceFile,
|
|
165
|
+
importInfo: { moduleName, imported: 'default' },
|
|
166
|
+
})
|
|
167
|
+
}
|
|
168
|
+
const bindings = node.importClause.namedBindings
|
|
169
|
+
if (bindings && ts.isNamespaceImport(bindings)) {
|
|
170
|
+
this.addBinding(bindings.name.text, {
|
|
171
|
+
node: bindings.name,
|
|
172
|
+
scope: this.sourceFile,
|
|
173
|
+
importInfo: { moduleName, imported: '*' },
|
|
174
|
+
})
|
|
175
|
+
}
|
|
176
|
+
if (bindings && ts.isNamedImports(bindings)) {
|
|
177
|
+
for (const element of bindings.elements) {
|
|
178
|
+
if (element.isTypeOnly) continue
|
|
179
|
+
this.addBinding(element.name.text, {
|
|
180
|
+
node: element.name,
|
|
181
|
+
scope: this.sourceFile,
|
|
182
|
+
importInfo: {
|
|
183
|
+
moduleName,
|
|
184
|
+
imported: element.propertyName?.text ?? element.name.text,
|
|
185
|
+
},
|
|
186
|
+
})
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
if (ts.isVariableDeclaration(node)) {
|
|
193
|
+
this.collectBindingName(node.name, {
|
|
194
|
+
node,
|
|
195
|
+
scope: containingScope(node),
|
|
196
|
+
value: node.initializer,
|
|
197
|
+
})
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
if (ts.isParameter(node)) {
|
|
201
|
+
this.collectBindingName(node.name, {
|
|
202
|
+
node,
|
|
203
|
+
scope: functionScope(node),
|
|
204
|
+
value: node.initializer,
|
|
205
|
+
})
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
if (ts.isFunctionDeclaration(node) && node.name) {
|
|
209
|
+
this.addBinding(node.name.text, {
|
|
210
|
+
node,
|
|
211
|
+
scope: containingScope(node),
|
|
212
|
+
value: node,
|
|
213
|
+
hoisted: true,
|
|
214
|
+
})
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
if (ts.isClassDeclaration(node) && node.name) {
|
|
218
|
+
this.addBinding(node.name.text, {
|
|
219
|
+
node,
|
|
220
|
+
scope: containingScope(node),
|
|
221
|
+
})
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
ts.forEachChild(node, child => this.collectBindings(child))
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
lookupBinding(name, usage) {
|
|
228
|
+
const candidates = (this.bindings.get(name) ?? []).filter(binding => {
|
|
229
|
+
const insideScope = usage.pos >= binding.scope.pos && usage.end <= binding.scope.end
|
|
230
|
+
const declared = binding.hoisted || binding.importInfo || binding.node.pos <= usage.pos
|
|
231
|
+
return insideScope && declared
|
|
232
|
+
})
|
|
233
|
+
candidates.sort((left, right) => {
|
|
234
|
+
const leftSpan = left.scope.end - left.scope.pos
|
|
235
|
+
const rightSpan = right.scope.end - right.scope.pos
|
|
236
|
+
return leftSpan - rightSpan || right.node.pos - left.node.pos
|
|
237
|
+
})
|
|
238
|
+
return candidates[0]
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
addViolation(rule, nodeOrOffset) {
|
|
242
|
+
const offset =
|
|
243
|
+
typeof nodeOrOffset === 'number' ? nodeOrOffset : nodeOrOffset.getStart(this.sourceFile)
|
|
244
|
+
const key = `${rule.id}:${offset}`
|
|
245
|
+
if (this.violationKeys.has(key)) return
|
|
246
|
+
this.violationKeys.add(key)
|
|
247
|
+
this.violations.push({ ruleId: rule.id, message: rule.message, offset })
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
importInfo(expression) {
|
|
251
|
+
const current = unwrapExpression(expression)
|
|
252
|
+
if (!current) return undefined
|
|
253
|
+
|
|
254
|
+
if (ts.isIdentifier(current)) {
|
|
255
|
+
const binding = this.lookupBinding(current.text, current)
|
|
256
|
+
if (binding?.importInfo) return binding.importInfo
|
|
257
|
+
if (binding?.value && binding.value !== current) return this.importInfo(binding.value)
|
|
258
|
+
}
|
|
259
|
+
return undefined
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
resolveExpressions(expression, environment = new Map(), seen = new Set()) {
|
|
263
|
+
const current = unwrapExpression(expression)
|
|
264
|
+
if (!current || seen.has(current)) return []
|
|
265
|
+
this.consumeEvaluationBudget(current)
|
|
266
|
+
const nextSeen = new Set(seen).add(current)
|
|
267
|
+
|
|
268
|
+
if (ts.isIdentifier(current)) {
|
|
269
|
+
const environmentValue = environment.get(current.text)
|
|
270
|
+
if (environmentValue && !Array.isArray(environmentValue)) {
|
|
271
|
+
return this.resolveExpressions(environmentValue, environment, nextSeen)
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
const binding = this.lookupBinding(current.text, current)
|
|
275
|
+
if (binding?.destructureSource && binding.destructureProperty) {
|
|
276
|
+
const properties = this.objectProperties(binding.destructureSource, environment, nextSeen)
|
|
277
|
+
return properties
|
|
278
|
+
.filter(property => property.name === binding.destructureProperty)
|
|
279
|
+
.flatMap(property =>
|
|
280
|
+
property.value ? this.resolveExpressions(property.value, environment, nextSeen) : [],
|
|
281
|
+
)
|
|
282
|
+
}
|
|
283
|
+
if (binding?.value && binding.value !== current && !isFunctionLike(binding.value)) {
|
|
284
|
+
return this.resolveExpressions(binding.value, environment, nextSeen)
|
|
285
|
+
}
|
|
286
|
+
return [current]
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
if (ts.isPropertyAccessExpression(current) || ts.isElementAccessExpression(current)) {
|
|
290
|
+
const memberName = ts.isPropertyAccessExpression(current)
|
|
291
|
+
? current.name.text
|
|
292
|
+
: current.argumentExpression
|
|
293
|
+
? this.staticStrings(current.argumentExpression, environment)[0]?.value
|
|
294
|
+
: undefined
|
|
295
|
+
if (memberName !== undefined) {
|
|
296
|
+
const resolvedObjects = this.resolveExpressions(current.expression, environment, nextSeen)
|
|
297
|
+
const resolvedMembers = resolvedObjects.flatMap(resolvedObject => {
|
|
298
|
+
if (!ts.isObjectLiteralExpression(resolvedObject)) return []
|
|
299
|
+
return this.objectProperties(resolvedObject, environment, nextSeen)
|
|
300
|
+
.filter(property => property.name === memberName && property.value)
|
|
301
|
+
.flatMap(property => this.resolveExpressions(property.value, environment, nextSeen))
|
|
302
|
+
})
|
|
303
|
+
if (resolvedMembers.length > 0) return resolvedMembers
|
|
304
|
+
}
|
|
305
|
+
return [current]
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
if (ts.isConditionalExpression(current)) {
|
|
309
|
+
return [
|
|
310
|
+
...this.resolveExpressions(current.whenTrue, environment, nextSeen),
|
|
311
|
+
...this.resolveExpressions(current.whenFalse, environment, nextSeen),
|
|
312
|
+
]
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
if (
|
|
316
|
+
ts.isBinaryExpression(current) &&
|
|
317
|
+
(current.operatorToken.kind === ts.SyntaxKind.BarBarToken ||
|
|
318
|
+
current.operatorToken.kind === ts.SyntaxKind.QuestionQuestionToken)
|
|
319
|
+
) {
|
|
320
|
+
return [
|
|
321
|
+
...this.resolveExpressions(current.left, environment, nextSeen),
|
|
322
|
+
...this.resolveExpressions(current.right, environment, nextSeen),
|
|
323
|
+
]
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
return [current]
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
staticStrings(expression, environment = new Map(), seen = new Set()) {
|
|
330
|
+
const current = unwrapExpression(expression)
|
|
331
|
+
if (!current || seen.has(current)) return []
|
|
332
|
+
this.consumeEvaluationBudget(current)
|
|
333
|
+
const nextSeen = new Set(seen).add(current)
|
|
334
|
+
|
|
335
|
+
if (ts.isStringLiteralLike(current) || ts.isNoSubstitutionTemplateLiteral(current)) {
|
|
336
|
+
return [{ value: current.text, node: current }]
|
|
337
|
+
}
|
|
338
|
+
if (ts.isNumericLiteral(current)) return [{ value: current.text, node: current }]
|
|
339
|
+
|
|
340
|
+
if (ts.isIdentifier(current)) {
|
|
341
|
+
const environmentValue = environment.get(current.text)
|
|
342
|
+
if (environmentValue && !Array.isArray(environmentValue)) {
|
|
343
|
+
return this.staticStrings(environmentValue, environment, nextSeen)
|
|
344
|
+
}
|
|
345
|
+
return this.resolveExpressions(current, environment, seen).flatMap(resolved =>
|
|
346
|
+
resolved === current ? [] : this.staticStrings(resolved, environment, nextSeen),
|
|
347
|
+
)
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
if (ts.isPropertyAccessExpression(current) || ts.isElementAccessExpression(current)) {
|
|
351
|
+
return this.resolveExpressions(current, environment, seen).flatMap(resolved =>
|
|
352
|
+
resolved === current ? [] : this.staticStrings(resolved, environment, nextSeen),
|
|
353
|
+
)
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
if (ts.isPrefixUnaryExpression(current) && ts.isNumericLiteral(current.operand)) {
|
|
357
|
+
const prefix = current.operator === ts.SyntaxKind.MinusToken ? '-' : '+'
|
|
358
|
+
return [{ value: `${prefix}${current.operand.text}`, node: current }]
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
if (ts.isBinaryExpression(current) && current.operatorToken.kind === ts.SyntaxKind.PlusToken) {
|
|
362
|
+
const left = this.staticStrings(current.left, environment, nextSeen)
|
|
363
|
+
const right = this.staticStrings(current.right, environment, nextSeen)
|
|
364
|
+
this.consumeEvaluationBudget(current, left.length * right.length)
|
|
365
|
+
return left.flatMap(leftValue =>
|
|
366
|
+
right.map(rightValue => ({
|
|
367
|
+
value: leftValue.value + rightValue.value,
|
|
368
|
+
node: current,
|
|
369
|
+
})),
|
|
370
|
+
)
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
if (ts.isConditionalExpression(current)) {
|
|
374
|
+
return [
|
|
375
|
+
...this.staticStrings(current.whenTrue, environment, nextSeen),
|
|
376
|
+
...this.staticStrings(current.whenFalse, environment, nextSeen),
|
|
377
|
+
]
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
if (ts.isTemplateExpression(current)) {
|
|
381
|
+
let values = [{ value: current.head.text, node: current }]
|
|
382
|
+
for (const span of current.templateSpans) {
|
|
383
|
+
const substitutions = this.staticStrings(span.expression, environment, nextSeen)
|
|
384
|
+
if (substitutions.length === 0) return []
|
|
385
|
+
this.consumeEvaluationBudget(current, values.length * substitutions.length)
|
|
386
|
+
values = values.flatMap(value =>
|
|
387
|
+
substitutions.map(substitution => ({
|
|
388
|
+
value: value.value + substitution.value + span.literal.text,
|
|
389
|
+
node: current,
|
|
390
|
+
})),
|
|
391
|
+
)
|
|
392
|
+
}
|
|
393
|
+
return values
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
return []
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
objectProperties(expression, environment = new Map(), seen = new Set()) {
|
|
400
|
+
return this.objectPropertyBranches(expression, environment, seen).flat()
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
mergePropertyBranches(leftBranches, rightBranches, node) {
|
|
404
|
+
this.consumeEvaluationBudget(node, leftBranches.length * rightBranches.length)
|
|
405
|
+
return leftBranches.flatMap(left =>
|
|
406
|
+
rightBranches.map(right => {
|
|
407
|
+
const properties = new Map(left.map(property => [property.name, property]))
|
|
408
|
+
for (const property of right) properties.set(property.name, property)
|
|
409
|
+
return [...properties.values()]
|
|
410
|
+
}),
|
|
411
|
+
)
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
objectPropertyBranches(expression, environment = new Map(), seen = new Set()) {
|
|
415
|
+
const current = unwrapExpression(expression)
|
|
416
|
+
if (!current || seen.has(current)) return [[]]
|
|
417
|
+
this.consumeEvaluationBudget(current)
|
|
418
|
+
const nextSeen = new Set(seen).add(current)
|
|
419
|
+
const resolved = this.resolveExpressions(current, environment, seen)
|
|
420
|
+
|
|
421
|
+
// Unknown spreads contribute no known properties, but must retain the branch.
|
|
422
|
+
if (resolved.length === 0) return [[]]
|
|
423
|
+
|
|
424
|
+
if (resolved.length !== 1 || resolved[0] !== current) {
|
|
425
|
+
return resolved.flatMap(value => this.objectPropertyBranches(value, environment, nextSeen))
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
if (ts.isConditionalExpression(current)) {
|
|
429
|
+
return [
|
|
430
|
+
...this.objectPropertyBranches(current.whenTrue, environment, nextSeen),
|
|
431
|
+
...this.objectPropertyBranches(current.whenFalse, environment, nextSeen),
|
|
432
|
+
]
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
if (
|
|
436
|
+
ts.isCallExpression(current) &&
|
|
437
|
+
ts.isPropertyAccessExpression(current.expression) &&
|
|
438
|
+
ts.isIdentifier(current.expression.expression) &&
|
|
439
|
+
current.expression.expression.text === 'Object' &&
|
|
440
|
+
current.expression.name.text === 'assign'
|
|
441
|
+
) {
|
|
442
|
+
return current.arguments.reduce(
|
|
443
|
+
(branches, argument) =>
|
|
444
|
+
this.mergePropertyBranches(
|
|
445
|
+
branches,
|
|
446
|
+
this.objectPropertyBranches(argument, environment, nextSeen),
|
|
447
|
+
current,
|
|
448
|
+
),
|
|
449
|
+
[[]],
|
|
450
|
+
)
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
if (!ts.isObjectLiteralExpression(current)) return [[]]
|
|
454
|
+
|
|
455
|
+
let branches = [[]]
|
|
456
|
+
for (const property of current.properties) {
|
|
457
|
+
if (ts.isSpreadAssignment(property)) {
|
|
458
|
+
branches = this.mergePropertyBranches(
|
|
459
|
+
branches,
|
|
460
|
+
this.objectPropertyBranches(property.expression, environment, nextSeen),
|
|
461
|
+
current,
|
|
462
|
+
)
|
|
463
|
+
continue
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
if (
|
|
467
|
+
ts.isPropertyAssignment(property) ||
|
|
468
|
+
ts.isShorthandPropertyAssignment(property) ||
|
|
469
|
+
ts.isMethodDeclaration(property) ||
|
|
470
|
+
ts.isGetAccessorDeclaration(property) ||
|
|
471
|
+
ts.isSetAccessorDeclaration(property)
|
|
472
|
+
) {
|
|
473
|
+
const name = propertyNameText(property.name, this, environment)
|
|
474
|
+
if (name === undefined) continue
|
|
475
|
+
const entry = {
|
|
476
|
+
name,
|
|
477
|
+
nameNode: property.name,
|
|
478
|
+
value: ts.isPropertyAssignment(property)
|
|
479
|
+
? property.initializer
|
|
480
|
+
: ts.isShorthandPropertyAssignment(property)
|
|
481
|
+
? property.name
|
|
482
|
+
: property,
|
|
483
|
+
}
|
|
484
|
+
branches = this.mergePropertyBranches(branches, [[entry]], current)
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
return branches
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
callableOrigin(expression, seen = new Set()) {
|
|
491
|
+
const current = unwrapExpression(expression)
|
|
492
|
+
if (!current || seen.has(current)) return undefined
|
|
493
|
+
const nextSeen = new Set(seen).add(current)
|
|
494
|
+
|
|
495
|
+
if (ts.isIdentifier(current)) {
|
|
496
|
+
const binding = this.lookupBinding(current.text, current)
|
|
497
|
+
if (
|
|
498
|
+
binding?.importInfo?.moduleName === REACT_MODULE &&
|
|
499
|
+
binding.importInfo.imported === 'createElement'
|
|
500
|
+
) {
|
|
501
|
+
return 'createElement'
|
|
502
|
+
}
|
|
503
|
+
if (
|
|
504
|
+
JSX_RUNTIME_MODULES.has(binding?.importInfo?.moduleName) &&
|
|
505
|
+
['jsx', 'jsxs', 'jsxDEV'].includes(binding.importInfo.imported)
|
|
506
|
+
) {
|
|
507
|
+
return binding.importInfo.imported
|
|
508
|
+
}
|
|
509
|
+
if (binding?.value && !isFunctionLike(binding.value)) {
|
|
510
|
+
return this.callableOrigin(binding.value, nextSeen)
|
|
511
|
+
}
|
|
512
|
+
return undefined
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
if (ts.isPropertyAccessExpression(current)) {
|
|
516
|
+
const namespace = this.importInfo(current.expression)
|
|
517
|
+
const unboundReactNamespace =
|
|
518
|
+
ts.isIdentifier(current.expression) &&
|
|
519
|
+
current.expression.text === 'React' &&
|
|
520
|
+
this.lookupBinding(current.expression.text, current.expression) === undefined
|
|
521
|
+
if (
|
|
522
|
+
current.name.text === 'createElement' &&
|
|
523
|
+
((namespace?.moduleName === REACT_MODULE &&
|
|
524
|
+
(namespace.imported === '*' || namespace.imported === 'default')) ||
|
|
525
|
+
unboundReactNamespace)
|
|
526
|
+
) {
|
|
527
|
+
return 'createElement'
|
|
528
|
+
}
|
|
529
|
+
if (
|
|
530
|
+
['jsx', 'jsxs', 'jsxDEV'].includes(current.name.text) &&
|
|
531
|
+
JSX_RUNTIME_MODULES.has(namespace?.moduleName) &&
|
|
532
|
+
namespace.imported === '*'
|
|
533
|
+
) {
|
|
534
|
+
return current.name.text
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
if (ts.isBinaryExpression(current) && current.operatorToken.kind === ts.SyntaxKind.CommaToken) {
|
|
539
|
+
return this.callableOrigin(current.right, nextSeen)
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
return undefined
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
resolveFunction(expression) {
|
|
546
|
+
const current = unwrapExpression(expression)
|
|
547
|
+
if (!current) return undefined
|
|
548
|
+
if (isFunctionLike(current)) return current
|
|
549
|
+
if (!ts.isIdentifier(current)) return undefined
|
|
550
|
+
const binding = this.lookupBinding(current.text, current)
|
|
551
|
+
return isFunctionLike(binding?.value) ? binding.value : undefined
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
returnedCall(functionNode) {
|
|
555
|
+
if (!functionNode.body) return undefined
|
|
556
|
+
const expressionBody = unwrapExpression(functionNode.body)
|
|
557
|
+
if (expressionBody && ts.isCallExpression(expressionBody)) return expressionBody
|
|
558
|
+
if (!ts.isBlock(functionNode.body)) return undefined
|
|
559
|
+
const returnStatement = functionNode.body.statements.find(ts.isReturnStatement)
|
|
560
|
+
const returnedExpression = returnStatement?.expression
|
|
561
|
+
? unwrapExpression(returnStatement.expression)
|
|
562
|
+
: undefined
|
|
563
|
+
return returnedExpression && ts.isCallExpression(returnedExpression)
|
|
564
|
+
? returnedExpression
|
|
565
|
+
: undefined
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
expandedArguments(argumentsList, environment) {
|
|
569
|
+
const output = []
|
|
570
|
+
for (const argument of argumentsList) {
|
|
571
|
+
if (ts.isSpreadElement(argument) && ts.isIdentifier(argument.expression)) {
|
|
572
|
+
const replacement = environment.get(argument.expression.text)
|
|
573
|
+
if (Array.isArray(replacement)) {
|
|
574
|
+
output.push(...replacement)
|
|
575
|
+
continue
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
output.push(argument)
|
|
579
|
+
}
|
|
580
|
+
return output
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
factoryInvocation(call, environment = new Map(), seen = new Set()) {
|
|
584
|
+
if (seen.has(call)) return undefined
|
|
585
|
+
const nextSeen = new Set(seen).add(call)
|
|
586
|
+
const directOrigin = this.callableOrigin(call.expression)
|
|
587
|
+
if (directOrigin) {
|
|
588
|
+
return {
|
|
589
|
+
kind: directOrigin,
|
|
590
|
+
arguments: this.expandedArguments(call.arguments, environment),
|
|
591
|
+
environment,
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
if (
|
|
596
|
+
ts.isPropertyAccessExpression(call.expression) &&
|
|
597
|
+
(call.expression.name.text === 'call' || call.expression.name.text === 'apply')
|
|
598
|
+
) {
|
|
599
|
+
const calledOrigin = this.callableOrigin(call.expression.expression)
|
|
600
|
+
if (calledOrigin) {
|
|
601
|
+
const forwardedArguments =
|
|
602
|
+
call.expression.name.text === 'apply' && ts.isArrayLiteralExpression(call.arguments[1])
|
|
603
|
+
? call.arguments[1].elements
|
|
604
|
+
: call.arguments.slice(1)
|
|
605
|
+
return {
|
|
606
|
+
kind: calledOrigin,
|
|
607
|
+
arguments: this.expandedArguments(forwardedArguments, environment),
|
|
608
|
+
environment,
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
const wrapper = this.resolveFunction(call.expression)
|
|
614
|
+
const returnedCall = wrapper ? this.returnedCall(wrapper) : undefined
|
|
615
|
+
if (!wrapper || !returnedCall) return undefined
|
|
616
|
+
|
|
617
|
+
const wrapperEnvironment = new Map(environment)
|
|
618
|
+
for (let index = 0; index < wrapper.parameters.length; index += 1) {
|
|
619
|
+
const parameter = wrapper.parameters[index]
|
|
620
|
+
if (!ts.isIdentifier(parameter.name)) continue
|
|
621
|
+
if (parameter.dotDotDotToken) {
|
|
622
|
+
wrapperEnvironment.set(parameter.name.text, call.arguments.slice(index))
|
|
623
|
+
break
|
|
624
|
+
}
|
|
625
|
+
const argument = call.arguments[index] ?? parameter.initializer
|
|
626
|
+
if (argument) wrapperEnvironment.set(parameter.name.text, argument)
|
|
627
|
+
}
|
|
628
|
+
return this.factoryInvocation(returnedCall, wrapperEnvironment, nextSeen)
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
inspectStyleProperty(name, value, nameNode, environment = new Map()) {
|
|
632
|
+
if (
|
|
633
|
+
/^--menu-(highlight-y|highlight-opacity|contact|finger-x|finger-y|trigger-contact)$/.test(
|
|
634
|
+
name,
|
|
635
|
+
)
|
|
636
|
+
) {
|
|
637
|
+
this.addViolation(rules['no-menu-motion-properties'], nameNode)
|
|
638
|
+
}
|
|
639
|
+
const normalizedName = name.replaceAll('-', '').toLowerCase()
|
|
640
|
+
const values = this.staticStrings(value, environment)
|
|
641
|
+
if (
|
|
642
|
+
['backdropfilter', 'webkitbackdropfilter'].includes(normalizedName) &&
|
|
643
|
+
(!values.length || values.some(item => item.value !== 'none'))
|
|
644
|
+
)
|
|
645
|
+
this.addViolation(rules['no-handrolled-glass'], nameNode)
|
|
646
|
+
if (
|
|
647
|
+
['animation', 'animationtimingfunction', 'transition', 'transitiontimingfunction'].includes(
|
|
648
|
+
normalizedName,
|
|
649
|
+
) &&
|
|
650
|
+
values.some(item => hasCustomLinearEasing(item.value))
|
|
651
|
+
)
|
|
652
|
+
this.addViolation(rules['no-custom-linear-easing'], nameNode)
|
|
653
|
+
for (const staticValue of values) {
|
|
654
|
+
const baseOffset = literalContentOffset(staticValue.node, this.sourceFile)
|
|
655
|
+
for (const found of analyzeInlineStyleValue(staticValue.value, baseOffset)) {
|
|
656
|
+
if (found.ruleId !== 'no-private-css-variable')
|
|
657
|
+
this.addViolation(rules[found.ruleId], found.offset)
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
if (
|
|
662
|
+
INLINE_OVERFLOW_PROPERTIES.has(normalizedName) &&
|
|
663
|
+
values.some(staticValue => isScrollingInlineValue(staticValue.value))
|
|
664
|
+
) {
|
|
665
|
+
this.addViolation(rules['no-raw-inline-overflow-scroll'], nameNode)
|
|
666
|
+
}
|
|
667
|
+
if (
|
|
668
|
+
normalizedName === 'zoom' ||
|
|
669
|
+
(normalizedName === 'touchaction' && values.some(value => /\bpinch-zoom\b/.test(value.value)))
|
|
670
|
+
) {
|
|
671
|
+
this.addViolation(rules['no-page-zoom'], nameNode)
|
|
672
|
+
}
|
|
673
|
+
if (normalizedName === 'webkitoverflowscrolling') {
|
|
674
|
+
this.addViolation(rules['no-webkit-overflow-scrolling-inline'], nameNode)
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
inspectStyleExpression(expression, environment = new Map()) {
|
|
679
|
+
for (const property of this.objectProperties(expression, environment)) {
|
|
680
|
+
if (!property.value) continue
|
|
681
|
+
this.inspectStyleProperty(property.name, property.value, property.nameNode, environment)
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
inspectStyleTextExpression(expression, environment = new Map()) {
|
|
686
|
+
for (const staticValue of this.staticStrings(expression, environment)) {
|
|
687
|
+
const baseOffset = literalContentOffset(staticValue.node, this.sourceFile)
|
|
688
|
+
for (const found of analyzeInlineCssText(staticValue.value, baseOffset)) {
|
|
689
|
+
if (found.ruleId !== 'no-private-css-variable')
|
|
690
|
+
this.addViolation(rules[found.ruleId], found.offset)
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
inspectPropsExpression(expression, environment = new Map()) {
|
|
696
|
+
for (const property of this.objectProperties(expression, environment)) {
|
|
697
|
+
if (property.name === 'onClick') {
|
|
698
|
+
this.addViolation(rules['no-onclick-activation'], property.nameNode)
|
|
699
|
+
}
|
|
700
|
+
if (property.name === 'style' && property.value) {
|
|
701
|
+
this.inspectStyleExpression(property.value, environment)
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
inspectJsxAttributes(attributes) {
|
|
707
|
+
for (const property of attributes.properties) {
|
|
708
|
+
if (ts.isJsxSpreadAttribute(property)) {
|
|
709
|
+
this.inspectPropsExpression(property.expression)
|
|
710
|
+
continue
|
|
711
|
+
}
|
|
712
|
+
if (
|
|
713
|
+
['href', 'src', 'url', 'destination'].includes(property.name.text) &&
|
|
714
|
+
property.initializer
|
|
715
|
+
) {
|
|
716
|
+
const value = ts.isJsxExpression(property.initializer)
|
|
717
|
+
? property.initializer.expression
|
|
718
|
+
: property.initializer
|
|
719
|
+
if (
|
|
720
|
+
value &&
|
|
721
|
+
this.staticStrings(value).some(item =>
|
|
722
|
+
/^(?:javascript|vbscript|data|blob|x-safari[^:]*):/i.test(
|
|
723
|
+
item.value.replace(/[\u0000-\u0020]/g, ''),
|
|
724
|
+
),
|
|
725
|
+
)
|
|
726
|
+
)
|
|
727
|
+
this.addViolation(rules['no-executable-url'], property.name)
|
|
728
|
+
}
|
|
729
|
+
if (property.name.text === 'onClick') {
|
|
730
|
+
this.addViolation(rules['no-onclick-activation'], property.name)
|
|
731
|
+
}
|
|
732
|
+
if (property.name.text !== 'style' || !property.initializer) continue
|
|
733
|
+
|
|
734
|
+
if (ts.isStringLiteral(property.initializer)) {
|
|
735
|
+
this.inspectStyleTextExpression(property.initializer)
|
|
736
|
+
} else if (ts.isJsxExpression(property.initializer) && property.initializer.expression) {
|
|
737
|
+
const styleExpression = property.initializer.expression
|
|
738
|
+
if (this.staticStrings(styleExpression).length > 0) {
|
|
739
|
+
this.inspectStyleTextExpression(styleExpression)
|
|
740
|
+
} else {
|
|
741
|
+
this.inspectStyleExpression(styleExpression)
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
inspectFactoryCall(call) {
|
|
748
|
+
const invocation = this.factoryInvocation(call)
|
|
749
|
+
if (!invocation) return
|
|
750
|
+
const tag = invocation.arguments[0]
|
|
751
|
+
if (tag) {
|
|
752
|
+
const image = this.staticStrings(tag, invocation.environment).find(
|
|
753
|
+
value => value.value.toLowerCase() === 'img',
|
|
754
|
+
)
|
|
755
|
+
if (image) this.addViolation(rules['no-raw-image'], image.node)
|
|
756
|
+
const rawButton = this.staticStrings(tag, invocation.environment).find(
|
|
757
|
+
value => value.value.toLowerCase() === 'button',
|
|
758
|
+
)
|
|
759
|
+
if (rawButton) this.addViolation(rules['no-raw-button'], rawButton.node)
|
|
760
|
+
const input = this.staticStrings(tag, invocation.environment).find(value =>
|
|
761
|
+
['input', 'textarea'].includes(value.value),
|
|
762
|
+
)
|
|
763
|
+
if (input) {
|
|
764
|
+
const props = invocation.arguments[1]
|
|
765
|
+
const escape =
|
|
766
|
+
props &&
|
|
767
|
+
this.objectProperties(props, invocation.environment).find(
|
|
768
|
+
p => p.name === 'data-satin-native-input',
|
|
769
|
+
)
|
|
770
|
+
if (
|
|
771
|
+
!escape?.value ||
|
|
772
|
+
!this.staticStrings(escape.value, invocation.environment).some(v => v.value.trim())
|
|
773
|
+
)
|
|
774
|
+
this.addViolation(rules['no-raw-text-input'], input.node)
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
const props = invocation.arguments[1]
|
|
778
|
+
if (props && props.kind !== ts.SyntaxKind.NullKeyword) {
|
|
779
|
+
this.inspectPropsExpression(props, invocation.environment)
|
|
780
|
+
}
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
isCssTag(tag) {
|
|
784
|
+
const current = unwrapExpression(tag)
|
|
785
|
+
if (!current) return false
|
|
786
|
+
if (ts.isIdentifier(current)) {
|
|
787
|
+
const imported = this.importInfo(current)
|
|
788
|
+
return imported !== undefined && CSS_TAG_MODULES.has(imported.moduleName)
|
|
789
|
+
}
|
|
790
|
+
if (ts.isPropertyAccessExpression(current)) {
|
|
791
|
+
const root = current.expression
|
|
792
|
+
const imported = this.importInfo(root)
|
|
793
|
+
return imported !== undefined && CSS_TAG_MODULES.has(imported.moduleName)
|
|
794
|
+
}
|
|
795
|
+
if (ts.isCallExpression(current)) return this.isCssTag(current.expression)
|
|
796
|
+
return false
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
inspectCssTemplate(node) {
|
|
800
|
+
if (!this.isCssTag(node.tag)) return
|
|
801
|
+
if (ts.isNoSubstitutionTemplateLiteral(node.template)) {
|
|
802
|
+
const baseOffset = literalContentOffset(node.template, this.sourceFile)
|
|
803
|
+
for (const found of analyzeCssTextFragment(node.template.text, baseOffset)) {
|
|
804
|
+
if (found.ruleId !== 'no-private-css-variable')
|
|
805
|
+
this.addViolation(rules[found.ruleId], found.offset)
|
|
806
|
+
}
|
|
807
|
+
return
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
const baseOffset = node.template.getStart(this.sourceFile) + 1
|
|
811
|
+
const endOffset = node.template.end - 1
|
|
812
|
+
let fragment = this.sourceFile.text.slice(baseOffset, endOffset)
|
|
813
|
+
let precedingLiteral = node.template.head
|
|
814
|
+
for (const span of node.template.templateSpans) {
|
|
815
|
+
const start = precedingLiteral.end - 2 - baseOffset
|
|
816
|
+
const end = span.literal.getStart(this.sourceFile) + 1 - baseOffset
|
|
817
|
+
// Keep declarations and their source offsets intact around unresolved values.
|
|
818
|
+
const placeholder = 'x' + fragment.slice(start + 1, end).replace(/[^\r\n]/g, ' ')
|
|
819
|
+
fragment = fragment.slice(0, start) + placeholder + fragment.slice(end)
|
|
820
|
+
precedingLiteral = span.literal
|
|
821
|
+
}
|
|
822
|
+
for (const found of analyzeCssTextFragment(fragment, baseOffset)) {
|
|
823
|
+
this.addViolation(rules[found.ruleId], found.offset)
|
|
824
|
+
}
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
inspectStyleMutation(node) {
|
|
828
|
+
if (
|
|
829
|
+
ts.isBinaryExpression(node) &&
|
|
830
|
+
node.operatorToken.kind === ts.SyntaxKind.EqualsToken &&
|
|
831
|
+
(ts.isPropertyAccessExpression(node.left) || ts.isElementAccessExpression(node.left))
|
|
832
|
+
) {
|
|
833
|
+
const left = node.left
|
|
834
|
+
const propertyName = ts.isPropertyAccessExpression(left)
|
|
835
|
+
? left.name.text
|
|
836
|
+
: left.argumentExpression
|
|
837
|
+
? this.staticStrings(left.argumentExpression)[0]?.value
|
|
838
|
+
: undefined
|
|
839
|
+
const receiver = left.expression
|
|
840
|
+
if (propertyName === 'cssText' && this.isElementStyleExpression(receiver)) {
|
|
841
|
+
this.inspectStyleTextExpression(node.right)
|
|
842
|
+
} else if (propertyName && this.isElementStyleExpression(receiver)) {
|
|
843
|
+
this.inspectStyleProperty(propertyName, node.right, left)
|
|
844
|
+
}
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
if (!ts.isCallExpression(node) || !ts.isPropertyAccessExpression(node.expression)) return
|
|
848
|
+
const receiver = node.expression.expression
|
|
849
|
+
const method = node.expression.name.text
|
|
850
|
+
|
|
851
|
+
if (method === 'setAttribute' && node.arguments.length >= 2) {
|
|
852
|
+
const attribute = this.staticStrings(node.arguments[0])[0]?.value
|
|
853
|
+
if (attribute?.toLowerCase() === 'style') this.inspectStyleTextExpression(node.arguments[1])
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
if (method === 'setProperty' && this.isElementStyleExpression(receiver)) {
|
|
857
|
+
const propertyName = node.arguments[0]
|
|
858
|
+
? this.staticStrings(node.arguments[0])[0]?.value
|
|
859
|
+
: undefined
|
|
860
|
+
if (propertyName && node.arguments[1]) {
|
|
861
|
+
this.inspectStyleProperty(propertyName, node.arguments[1], node.arguments[0])
|
|
862
|
+
}
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
if (
|
|
866
|
+
ts.isIdentifier(receiver) &&
|
|
867
|
+
receiver.text === 'Object' &&
|
|
868
|
+
method === 'assign' &&
|
|
869
|
+
node.arguments[0] &&
|
|
870
|
+
this.isElementStyleExpression(node.arguments[0])
|
|
871
|
+
) {
|
|
872
|
+
for (const source of node.arguments.slice(1)) this.inspectStyleExpression(source)
|
|
873
|
+
}
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
isElementStyleExpression(expression) {
|
|
877
|
+
const current = unwrapExpression(expression)
|
|
878
|
+
if (!current) return false
|
|
879
|
+
if (ts.isPropertyAccessExpression(current)) return current.name.text === 'style'
|
|
880
|
+
if (ts.isElementAccessExpression(current) && current.argumentExpression) {
|
|
881
|
+
return this.staticStrings(current.argumentExpression)[0]?.value === 'style'
|
|
882
|
+
}
|
|
883
|
+
return false
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
isRequireReference(expression) {
|
|
887
|
+
return this.resolveExpressions(expression).some(
|
|
888
|
+
value =>
|
|
889
|
+
ts.isIdentifier(value) && value.text === 'require' && !this.lookupBinding('require', value),
|
|
890
|
+
)
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
inspectModuleReference(node) {
|
|
894
|
+
const reference =
|
|
895
|
+
node.moduleSpecifier ??
|
|
896
|
+
(ts.isImportTypeNode(node) ? node.argument?.literal : undefined) ??
|
|
897
|
+
(ts.isImportEqualsDeclaration(node) ? node.moduleReference.expression : undefined) ??
|
|
898
|
+
(ts.isCallExpression(node) &&
|
|
899
|
+
(node.expression.kind === ts.SyntaxKind.ImportKeyword ||
|
|
900
|
+
this.isRequireReference(node.expression))
|
|
901
|
+
? node.arguments[0]
|
|
902
|
+
: undefined)
|
|
903
|
+
if (
|
|
904
|
+
reference &&
|
|
905
|
+
this.staticStrings(reference).some(
|
|
906
|
+
item =>
|
|
907
|
+
item.value === '@satinhq/kit/internal' || item.value.startsWith('@satinhq/kit/internal/'),
|
|
908
|
+
)
|
|
909
|
+
)
|
|
910
|
+
this.addViolation(rules['no-internal-import'], reference)
|
|
911
|
+
let specifier
|
|
912
|
+
let moduleName
|
|
913
|
+
let alternatives
|
|
914
|
+
if (ts.isImportDeclaration(node)) {
|
|
915
|
+
const clause = node.importClause
|
|
916
|
+
const named = clause?.namedBindings
|
|
917
|
+
if (
|
|
918
|
+
clause?.isTypeOnly ||
|
|
919
|
+
(!clause?.name &&
|
|
920
|
+
named &&
|
|
921
|
+
ts.isNamedImports(named) &&
|
|
922
|
+
named.elements.length > 0 &&
|
|
923
|
+
named.elements.every(element => element.isTypeOnly))
|
|
924
|
+
)
|
|
925
|
+
return
|
|
926
|
+
specifier = node.moduleSpecifier
|
|
927
|
+
moduleName = staticModuleName(specifier)
|
|
928
|
+
} else if (ts.isExportDeclaration(node) && node.moduleSpecifier) {
|
|
929
|
+
const named = node.exportClause
|
|
930
|
+
if (
|
|
931
|
+
node.isTypeOnly ||
|
|
932
|
+
(named &&
|
|
933
|
+
ts.isNamedExports(named) &&
|
|
934
|
+
named.elements.length > 0 &&
|
|
935
|
+
named.elements.every(element => element.isTypeOnly))
|
|
936
|
+
)
|
|
937
|
+
return
|
|
938
|
+
specifier = node.moduleSpecifier
|
|
939
|
+
moduleName = staticModuleName(specifier)
|
|
940
|
+
} else if (ts.isImportEqualsDeclaration(node) && !node.isTypeOnly) {
|
|
941
|
+
const reference = node.moduleReference
|
|
942
|
+
if (!ts.isExternalModuleReference(reference) || !reference.expression) return
|
|
943
|
+
specifier = reference.expression
|
|
944
|
+
moduleName = staticModuleName(specifier)
|
|
945
|
+
} else if (ts.isCallExpression(node) && node.arguments.length > 0) {
|
|
946
|
+
const dynamic = node.expression.kind === ts.SyntaxKind.ImportKeyword
|
|
947
|
+
const require = this.isRequireReference(node.expression)
|
|
948
|
+
if (!dynamic && !require) return
|
|
949
|
+
specifier = node.arguments[0]
|
|
950
|
+
alternatives = this.staticStrings(specifier).map(item => item.value)
|
|
951
|
+
}
|
|
952
|
+
for (const candidate of alternatives ?? [moduleName]) {
|
|
953
|
+
if (candidate === 'react-dom/client') {
|
|
954
|
+
this.addViolation(rules['no-direct-react-root'], specifier)
|
|
955
|
+
} else if (candidate && isGlassPackage(candidate)) {
|
|
956
|
+
this.addViolation(rules['no-direct-glass-import'], specifier)
|
|
957
|
+
}
|
|
958
|
+
}
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
// Resolve simple aliases and destructuring without matching an app object's
|
|
962
|
+
// unrelated history/reload methods. Dynamic inter-module aliases need review.
|
|
963
|
+
globalPath(expression, seen = new Set()) {
|
|
964
|
+
const current = unwrapExpression(expression)
|
|
965
|
+
if (!current || seen.has(current)) return undefined
|
|
966
|
+
const next = new Set(seen).add(current)
|
|
967
|
+
if (ts.isIdentifier(current)) {
|
|
968
|
+
const binding = this.lookupBinding(current.text, current)
|
|
969
|
+
if (binding?.destructureSource) {
|
|
970
|
+
const parent = this.globalPath(binding.destructureSource, next)
|
|
971
|
+
return parent && binding.destructureProperty
|
|
972
|
+
? [...parent, binding.destructureProperty]
|
|
973
|
+
: undefined
|
|
974
|
+
}
|
|
975
|
+
if (binding?.value) return this.globalPath(binding.value, next)
|
|
976
|
+
if (binding) return undefined
|
|
977
|
+
return [
|
|
978
|
+
'window',
|
|
979
|
+
'self',
|
|
980
|
+
'globalThis',
|
|
981
|
+
'document',
|
|
982
|
+
'navigator',
|
|
983
|
+
'Notification',
|
|
984
|
+
'history',
|
|
985
|
+
'location',
|
|
986
|
+
'addEventListener',
|
|
987
|
+
'alert',
|
|
988
|
+
'confirm',
|
|
989
|
+
'prompt',
|
|
990
|
+
].includes(current.text)
|
|
991
|
+
? [current.text]
|
|
992
|
+
: undefined
|
|
993
|
+
}
|
|
994
|
+
if (ts.isPropertyAccessExpression(current) || ts.isElementAccessExpression(current)) {
|
|
995
|
+
const parent = this.globalPath(current.expression, next)
|
|
996
|
+
const member = ts.isPropertyAccessExpression(current)
|
|
997
|
+
? current.name.text
|
|
998
|
+
: this.staticStrings(current.argumentExpression)[0]?.value
|
|
999
|
+
return parent && member ? [...parent, member] : undefined
|
|
1000
|
+
}
|
|
1001
|
+
return undefined
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
inspectAppOwnership(node) {
|
|
1005
|
+
if (!ts.isCallExpression(node)) return
|
|
1006
|
+
const callee = unwrapExpression(node.expression)
|
|
1007
|
+
const value = ts.isIdentifier(callee) ? this.lookupBinding(callee.text, callee)?.value : callee
|
|
1008
|
+
if (value && ts.isCallExpression(value)) {
|
|
1009
|
+
const hook = this.importInfo(value.expression)
|
|
1010
|
+
if (
|
|
1011
|
+
hook?.moduleName === '@satinhq/kit' &&
|
|
1012
|
+
hook.imported === 'useOpenURL' &&
|
|
1013
|
+
node.arguments[0] &&
|
|
1014
|
+
this.staticStrings(node.arguments[0]).some(item =>
|
|
1015
|
+
/^(?:javascript|vbscript|data|blob|x-safari[^:]*):/i.test(
|
|
1016
|
+
item.value.replace(/[\u0000-\u0020]/g, ''),
|
|
1017
|
+
),
|
|
1018
|
+
)
|
|
1019
|
+
)
|
|
1020
|
+
this.addViolation(rules['no-executable-url'], node.arguments[0])
|
|
1021
|
+
}
|
|
1022
|
+
const path = this.globalPath(node.expression)
|
|
1023
|
+
if (!path) return
|
|
1024
|
+
if (['window', 'self', 'globalThis'].includes(path[0]) && path.length > 1) path.shift()
|
|
1025
|
+
if (
|
|
1026
|
+
path.length === 2 &&
|
|
1027
|
+
path[0] === 'history' &&
|
|
1028
|
+
['pushState', 'replaceState', 'go', 'back', 'forward'].includes(path[1])
|
|
1029
|
+
) {
|
|
1030
|
+
this.addViolation(rules['no-browser-history'], node.expression)
|
|
1031
|
+
}
|
|
1032
|
+
if (path.length === 1 && ['alert', 'confirm', 'prompt'].includes(path[0]))
|
|
1033
|
+
this.addViolation(rules['no-browser-dialog'], node.expression)
|
|
1034
|
+
if (path.join('.') === 'location.reload')
|
|
1035
|
+
this.addViolation(rules['no-app-reload'], node.expression)
|
|
1036
|
+
const globalListener =
|
|
1037
|
+
path.join('.') === 'document.addEventListener' || path.join('.') === 'addEventListener'
|
|
1038
|
+
if (
|
|
1039
|
+
globalListener &&
|
|
1040
|
+
node.arguments[0] &&
|
|
1041
|
+
this.staticStrings(node.arguments[0]).some(item =>
|
|
1042
|
+
/^touch(?:start|move|end|cancel)$/.test(item.value),
|
|
1043
|
+
)
|
|
1044
|
+
) {
|
|
1045
|
+
this.addViolation(rules['no-global-touch-listener'], node.expression)
|
|
1046
|
+
}
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
inspectServiceWorkerRegistration(node) {
|
|
1050
|
+
if (!ts.isCallExpression(node)) return
|
|
1051
|
+
const path = this.globalPath(node.expression)
|
|
1052
|
+
if (!path) return
|
|
1053
|
+
if (['window', 'self', 'globalThis'].includes(path[0])) path.shift()
|
|
1054
|
+
const api = path.join('.')
|
|
1055
|
+
if (api === 'navigator.serviceWorker.register') {
|
|
1056
|
+
this.addViolation(rules['no-direct-sw-registration'], node.expression)
|
|
1057
|
+
} else if (
|
|
1058
|
+
[
|
|
1059
|
+
'Notification.requestPermission',
|
|
1060
|
+
'navigator.setAppBadge',
|
|
1061
|
+
'navigator.clearAppBadge',
|
|
1062
|
+
].includes(api)
|
|
1063
|
+
) {
|
|
1064
|
+
this.addViolation(rules['no-direct-push-api'], node.expression)
|
|
1065
|
+
}
|
|
1066
|
+
}
|
|
1067
|
+
|
|
1068
|
+
inspectAnimationEasing(node) {
|
|
1069
|
+
if (!ts.isCallExpression(node) || !ts.isPropertyAccessExpression(node.expression)) return
|
|
1070
|
+
const method = node.expression.name.text
|
|
1071
|
+
const options =
|
|
1072
|
+
method === 'animate'
|
|
1073
|
+
? node.arguments[1]
|
|
1074
|
+
: method === 'updateTiming'
|
|
1075
|
+
? node.arguments[0]
|
|
1076
|
+
: undefined
|
|
1077
|
+
if (!options) return
|
|
1078
|
+
for (const property of this.objectProperties(options)) {
|
|
1079
|
+
if (
|
|
1080
|
+
property.name === 'easing' &&
|
|
1081
|
+
this.staticStrings(property.value).some(item => hasCustomLinearEasing(item.value))
|
|
1082
|
+
) {
|
|
1083
|
+
this.addViolation(rules['no-custom-linear-easing'], property.nameNode)
|
|
1084
|
+
}
|
|
1085
|
+
}
|
|
1086
|
+
}
|
|
1087
|
+
|
|
1088
|
+
inspectGlassMorph(node) {
|
|
1089
|
+
if (!ts.isJsxOpeningElement(node) && !ts.isJsxSelfClosingElement(node)) return
|
|
1090
|
+
const imports = new Map()
|
|
1091
|
+
for (const statement of this.sourceFile.statements) {
|
|
1092
|
+
if (!ts.isImportDeclaration(statement) || statement.moduleSpecifier.text !== '@satinhq/kit')
|
|
1093
|
+
continue
|
|
1094
|
+
const bindings = statement.importClause?.namedBindings
|
|
1095
|
+
if (bindings && ts.isNamedImports(bindings))
|
|
1096
|
+
for (const item of bindings.elements)
|
|
1097
|
+
imports.set(item.name.text, item.propertyName?.text ?? item.name.text)
|
|
1098
|
+
}
|
|
1099
|
+
if (imports.get(node.tagName.getText(this.sourceFile)) !== 'GlassEffect') return
|
|
1100
|
+
const applySpread = (expression, states, seen = new Set()) => {
|
|
1101
|
+
const resolved = this.resolveExpressions(expression)
|
|
1102
|
+
return resolved.flatMap(current => {
|
|
1103
|
+
this.consumeEvaluationBudget(current, states.length)
|
|
1104
|
+
if (seen.has(current)) return states.map(() => ({ properties: new Map(), unknown: true }))
|
|
1105
|
+
const nextSeen = new Set(seen).add(current)
|
|
1106
|
+
if (
|
|
1107
|
+
ts.isCallExpression(current) &&
|
|
1108
|
+
ts.isPropertyAccessExpression(current.expression) &&
|
|
1109
|
+
current.expression.expression.getText(this.sourceFile) === 'Object' &&
|
|
1110
|
+
current.expression.name.text === 'assign'
|
|
1111
|
+
) {
|
|
1112
|
+
return current.arguments.reduce(
|
|
1113
|
+
(branches, argument) => applySpread(argument, branches, nextSeen),
|
|
1114
|
+
states,
|
|
1115
|
+
)
|
|
1116
|
+
}
|
|
1117
|
+
if (!ts.isObjectLiteralExpression(current))
|
|
1118
|
+
return states.map(() => ({ properties: new Map(), unknown: true }))
|
|
1119
|
+
let branches = states.map(state => ({ ...state, properties: new Map(state.properties) }))
|
|
1120
|
+
for (const property of current.properties) {
|
|
1121
|
+
if (ts.isSpreadAssignment(property)) {
|
|
1122
|
+
branches = applySpread(property.expression, branches, nextSeen)
|
|
1123
|
+
continue
|
|
1124
|
+
}
|
|
1125
|
+
const name = propertyNameText(property.name, this, new Map())
|
|
1126
|
+
if (name === undefined) {
|
|
1127
|
+
branches = branches.map(() => ({ properties: new Map(), unknown: true }))
|
|
1128
|
+
continue
|
|
1129
|
+
}
|
|
1130
|
+
const value = ts.isPropertyAssignment(property)
|
|
1131
|
+
? property.initializer
|
|
1132
|
+
: ts.isShorthandPropertyAssignment(property)
|
|
1133
|
+
? property.name
|
|
1134
|
+
: undefined
|
|
1135
|
+
for (const branch of branches) branch.properties.set(name, { value, node: property })
|
|
1136
|
+
}
|
|
1137
|
+
return branches
|
|
1138
|
+
})
|
|
1139
|
+
}
|
|
1140
|
+
let states = [{ properties: new Map(), unknown: false }]
|
|
1141
|
+
for (const property of node.attributes.properties) {
|
|
1142
|
+
if (ts.isJsxSpreadAttribute(property)) {
|
|
1143
|
+
states = applySpread(property.expression, states)
|
|
1144
|
+
continue
|
|
1145
|
+
}
|
|
1146
|
+
const value =
|
|
1147
|
+
property.initializer && ts.isJsxExpression(property.initializer)
|
|
1148
|
+
? property.initializer.expression
|
|
1149
|
+
: property.initializer
|
|
1150
|
+
for (const state of states)
|
|
1151
|
+
state.properties.set(property.name.text, { value, node: property })
|
|
1152
|
+
}
|
|
1153
|
+
for (const { properties, unknown } of states) {
|
|
1154
|
+
const relation = properties.get('id') ?? properties.get('union')
|
|
1155
|
+
const namespace = properties.get('namespace')
|
|
1156
|
+
if (
|
|
1157
|
+
relation &&
|
|
1158
|
+
((!namespace && !unknown) ||
|
|
1159
|
+
(namespace && this.staticStrings(namespace.value).some(item => item.value === '')))
|
|
1160
|
+
)
|
|
1161
|
+
this.addViolation(rules['valid-glass-morph'], relation.node)
|
|
1162
|
+
const transition = properties.get('transition')
|
|
1163
|
+
if (
|
|
1164
|
+
transition &&
|
|
1165
|
+
this.staticStrings(transition.value).some(
|
|
1166
|
+
item => !['matchedGeometry', 'materialize', 'identity'].includes(item.value),
|
|
1167
|
+
)
|
|
1168
|
+
)
|
|
1169
|
+
this.addViolation(rules['valid-glass-morph'], transition.node)
|
|
1170
|
+
}
|
|
1171
|
+
}
|
|
1172
|
+
|
|
1173
|
+
inspectTabIdentities(node) {
|
|
1174
|
+
if (!ts.isJsxElement(node)) return
|
|
1175
|
+
const imports = new Map()
|
|
1176
|
+
for (const statement of this.sourceFile.statements) {
|
|
1177
|
+
if (!ts.isImportDeclaration(statement) || statement.moduleSpecifier.text !== '@satinhq/kit')
|
|
1178
|
+
continue
|
|
1179
|
+
const bindings = statement.importClause?.namedBindings
|
|
1180
|
+
if (bindings && ts.isNamedImports(bindings))
|
|
1181
|
+
for (const item of bindings.elements)
|
|
1182
|
+
imports.set(item.name.text, item.propertyName?.text ?? item.name.text)
|
|
1183
|
+
}
|
|
1184
|
+
if (imports.get(node.openingElement.tagName.getText(this.sourceFile)) !== 'TabView') return
|
|
1185
|
+
const ids = new Set()
|
|
1186
|
+
for (const child of node.children) {
|
|
1187
|
+
const opening = ts.isJsxElement(child)
|
|
1188
|
+
? child.openingElement
|
|
1189
|
+
: ts.isJsxSelfClosingElement(child)
|
|
1190
|
+
? child
|
|
1191
|
+
: undefined
|
|
1192
|
+
if (!opening || imports.get(opening.tagName.getText(this.sourceFile)) !== 'Tab') continue
|
|
1193
|
+
const id = opening.attributes.properties.find(
|
|
1194
|
+
property => ts.isJsxAttribute(property) && property.name.text === 'id',
|
|
1195
|
+
)
|
|
1196
|
+
const value = id?.initializer
|
|
1197
|
+
const literal =
|
|
1198
|
+
value && ts.isStringLiteral(value)
|
|
1199
|
+
? value.text
|
|
1200
|
+
: value &&
|
|
1201
|
+
ts.isJsxExpression(value) &&
|
|
1202
|
+
value.expression &&
|
|
1203
|
+
ts.isStringLiteral(value.expression)
|
|
1204
|
+
? value.expression.text
|
|
1205
|
+
: undefined
|
|
1206
|
+
if (literal === undefined) continue
|
|
1207
|
+
if (ids.has(literal)) this.addViolation(rules['no-duplicate-tab-id'], id)
|
|
1208
|
+
ids.add(literal)
|
|
1209
|
+
}
|
|
1210
|
+
}
|
|
1211
|
+
|
|
1212
|
+
// Preserve JSX override order. Unknown spreads/names are a review case,
|
|
1213
|
+
// not proof that a control lacks an accessible name.
|
|
1214
|
+
accessibleJsxProps(opening) {
|
|
1215
|
+
const values = new Map()
|
|
1216
|
+
let unknown = false
|
|
1217
|
+
const object = (expression, seen = new Set()) => {
|
|
1218
|
+
const resolved = this.resolveExpressions(expression)
|
|
1219
|
+
if (
|
|
1220
|
+
resolved.length !== 1 ||
|
|
1221
|
+
!ts.isObjectLiteralExpression(resolved[0]) ||
|
|
1222
|
+
seen.has(resolved[0])
|
|
1223
|
+
) {
|
|
1224
|
+
unknown = true
|
|
1225
|
+
return
|
|
1226
|
+
}
|
|
1227
|
+
const next = new Set(seen).add(resolved[0])
|
|
1228
|
+
for (const property of resolved[0].properties) {
|
|
1229
|
+
if (ts.isSpreadAssignment(property)) {
|
|
1230
|
+
object(property.expression, next)
|
|
1231
|
+
continue
|
|
1232
|
+
}
|
|
1233
|
+
const name = propertyNameText(property.name, this, new Map())
|
|
1234
|
+
if (name === undefined) {
|
|
1235
|
+
unknown = true
|
|
1236
|
+
continue
|
|
1237
|
+
}
|
|
1238
|
+
values.set(
|
|
1239
|
+
name,
|
|
1240
|
+
ts.isPropertyAssignment(property)
|
|
1241
|
+
? property.initializer
|
|
1242
|
+
: ts.isShorthandPropertyAssignment(property)
|
|
1243
|
+
? property.name
|
|
1244
|
+
: undefined,
|
|
1245
|
+
)
|
|
1246
|
+
}
|
|
1247
|
+
}
|
|
1248
|
+
for (const property of opening.attributes.properties) {
|
|
1249
|
+
if (ts.isJsxSpreadAttribute(property)) object(property.expression)
|
|
1250
|
+
else
|
|
1251
|
+
values.set(
|
|
1252
|
+
property.name.text,
|
|
1253
|
+
property.initializer && ts.isJsxExpression(property.initializer)
|
|
1254
|
+
? property.initializer.expression
|
|
1255
|
+
: property.initializer,
|
|
1256
|
+
)
|
|
1257
|
+
}
|
|
1258
|
+
const state = names => {
|
|
1259
|
+
let uncertain = unknown
|
|
1260
|
+
for (const name of names) {
|
|
1261
|
+
if (!values.has(name)) continue
|
|
1262
|
+
const expression = values.get(name)
|
|
1263
|
+
if (!expression) continue
|
|
1264
|
+
const strings = this.staticStrings(expression)
|
|
1265
|
+
if (strings.some(item => item.value.trim())) return true
|
|
1266
|
+
if (
|
|
1267
|
+
!strings.length &&
|
|
1268
|
+
expression.kind !== ts.SyntaxKind.NullKeyword &&
|
|
1269
|
+
expression.kind !== ts.SyntaxKind.FalseKeyword
|
|
1270
|
+
)
|
|
1271
|
+
uncertain = true
|
|
1272
|
+
}
|
|
1273
|
+
return uncertain ? undefined : false
|
|
1274
|
+
}
|
|
1275
|
+
const hidden = values.get('aria-hidden')
|
|
1276
|
+
const isHidden =
|
|
1277
|
+
hidden?.kind === ts.SyntaxKind.TrueKeyword ||
|
|
1278
|
+
(hidden && this.staticStrings(hidden).some(item => item.value === 'true'))
|
|
1279
|
+
return { state, hidden: !!isHidden }
|
|
1280
|
+
}
|
|
1281
|
+
|
|
1282
|
+
visit(node) {
|
|
1283
|
+
if (ts.isStringLiteralLike(node) || ts.isTemplateExpression(node)) {
|
|
1284
|
+
const source = this.sourceFile.text.slice(node.pos, node.end)
|
|
1285
|
+
if (/--satin-[\w-]+/.test(source)) {
|
|
1286
|
+
this.addViolation(rules['no-private-css-variable'], node)
|
|
1287
|
+
}
|
|
1288
|
+
}
|
|
1289
|
+
if (!this.sourceFile.text.includes('// satin-data-allow-raw-storage')) {
|
|
1290
|
+
const isDirectName =
|
|
1291
|
+
ts.isIdentifier(node) &&
|
|
1292
|
+
['localStorage', 'indexedDB'].includes(node.text) &&
|
|
1293
|
+
(!ts.isPropertyAccessExpression(node.parent) || node.parent.expression === node) &&
|
|
1294
|
+
!ts.isPropertyAssignment(node.parent) &&
|
|
1295
|
+
!ts.isTypeReferenceNode(node.parent)
|
|
1296
|
+
const isGlobalProperty =
|
|
1297
|
+
ts.isPropertyAccessExpression(node) &&
|
|
1298
|
+
['localStorage', 'indexedDB'].includes(node.name.text) &&
|
|
1299
|
+
ts.isIdentifier(node.expression) &&
|
|
1300
|
+
['window', 'globalThis', 'self'].includes(node.expression.text)
|
|
1301
|
+
const isGlobalElement =
|
|
1302
|
+
ts.isElementAccessExpression(node) &&
|
|
1303
|
+
ts.isIdentifier(node.expression) &&
|
|
1304
|
+
['window', 'globalThis', 'self'].includes(node.expression.text) &&
|
|
1305
|
+
node.argumentExpression &&
|
|
1306
|
+
this.staticStrings(node.argumentExpression).some(item =>
|
|
1307
|
+
['localStorage', 'indexedDB'].includes(item.value),
|
|
1308
|
+
)
|
|
1309
|
+
if (isDirectName || isGlobalProperty || isGlobalElement)
|
|
1310
|
+
this.addViolation(rules['no-raw-persistent-storage'], node)
|
|
1311
|
+
}
|
|
1312
|
+
this.inspectAppOwnership(node)
|
|
1313
|
+
this.inspectTabIdentities(node)
|
|
1314
|
+
this.inspectGlassMorph(node)
|
|
1315
|
+
|
|
1316
|
+
this.inspectAnimationEasing(node)
|
|
1317
|
+
this.inspectModuleReference(node)
|
|
1318
|
+
this.inspectStyleMutation(node)
|
|
1319
|
+
this.inspectServiceWorkerRegistration(node)
|
|
1320
|
+
|
|
1321
|
+
if (ts.isJsxOpeningElement(node) || ts.isJsxSelfClosingElement(node)) {
|
|
1322
|
+
const image = this.staticStrings(node.tagName).find(value => value.value === 'img')
|
|
1323
|
+
if ((ts.isIdentifier(node.tagName) && node.tagName.text === 'img') || image)
|
|
1324
|
+
this.addViolation(rules['no-raw-image'], image?.node ?? node)
|
|
1325
|
+
const rawButton =
|
|
1326
|
+
ts.isIdentifier(node.tagName) && node.tagName.text === 'button'
|
|
1327
|
+
? { node }
|
|
1328
|
+
: this.staticStrings(node.tagName).find(value => value.value === 'button')
|
|
1329
|
+
if (rawButton) this.addViolation(rules['no-raw-button'], rawButton.node)
|
|
1330
|
+
const tag = ts.isIdentifier(node.tagName) ? node.tagName.text : ''
|
|
1331
|
+
const attributes = node.attributes.properties.filter(ts.isJsxAttribute)
|
|
1332
|
+
if (['button', 'Row', 'NavigationRow', 'ButtonRow', 'ValueRow', 'Pressable'].includes(tag)) {
|
|
1333
|
+
const handler = attributes.find(attribute => attribute.name.text === 'onContextMenu')
|
|
1334
|
+
if (handler?.initializer)
|
|
1335
|
+
this.addViolation(rules['no-handrolled-context-menu'], handler.name)
|
|
1336
|
+
}
|
|
1337
|
+
const attributeText = name =>
|
|
1338
|
+
attributes
|
|
1339
|
+
.find(p => p.name.text === name)
|
|
1340
|
+
?.initializer?.getText(this.sourceFile)
|
|
1341
|
+
.replace(/['"{}]/g, '')
|
|
1342
|
+
.trim()
|
|
1343
|
+
const heading = /^h[1-6]$/.test(tag) || attributeText('role') === 'heading'
|
|
1344
|
+
const hidden =
|
|
1345
|
+
attributes.some(
|
|
1346
|
+
p => p.name.text === 'hidden' && (!p.initializer || attributeText('hidden') === 'true'),
|
|
1347
|
+
) ||
|
|
1348
|
+
attributeText('aria-hidden') === 'true' ||
|
|
1349
|
+
/(?:^|\s)(?:sr-only|visually-hidden)(?:\s|$)/.test(attributeText('className') ?? '')
|
|
1350
|
+
if (heading && hidden && attributes.some(p => p.name.text === 'tabIndex'))
|
|
1351
|
+
this.addViolation(rules['no-hidden-focus-heading'], node)
|
|
1352
|
+
const importedControl = [...this.sourceFile.statements].some(
|
|
1353
|
+
statement =>
|
|
1354
|
+
ts.isImportDeclaration(statement) &&
|
|
1355
|
+
statement.moduleSpecifier.text === '@satinhq/kit' &&
|
|
1356
|
+
statement.importClause?.namedBindings &&
|
|
1357
|
+
ts.isNamedImports(statement.importClause.namedBindings) &&
|
|
1358
|
+
statement.importClause.namedBindings.elements.some(
|
|
1359
|
+
specifier =>
|
|
1360
|
+
specifier.name.text === tag &&
|
|
1361
|
+
['Pressable', 'GlassButton', 'Button'].includes(
|
|
1362
|
+
specifier.propertyName?.text ?? specifier.name.text,
|
|
1363
|
+
),
|
|
1364
|
+
),
|
|
1365
|
+
)
|
|
1366
|
+
if (importedControl || ['Pressable', 'GlassButton', 'Button'].includes(tag)) {
|
|
1367
|
+
const named = this.accessibleJsxProps(node).state(['aria-label', 'aria-labelledby'])
|
|
1368
|
+
const children = ts.isJsxOpeningElement(node) ? node.parent.children : []
|
|
1369
|
+
const meaningful = children.filter(child => !ts.isJsxText(child) || child.text.trim())
|
|
1370
|
+
const symbolOnly =
|
|
1371
|
+
meaningful.length === 0 && attributes.some(attribute => attribute.name.text === 'symbol')
|
|
1372
|
+
const unnamedIcons =
|
|
1373
|
+
meaningful.length > 0 &&
|
|
1374
|
+
meaningful.every(child => {
|
|
1375
|
+
if (!ts.isJsxElement(child) && !ts.isJsxSelfClosingElement(child)) return false
|
|
1376
|
+
const opening = ts.isJsxElement(child) ? child.openingElement : child
|
|
1377
|
+
const tag = opening.tagName.getText(this.sourceFile)
|
|
1378
|
+
const props = this.accessibleJsxProps(opening)
|
|
1379
|
+
if (props.hidden) return true
|
|
1380
|
+
const isSymbol =
|
|
1381
|
+
tag === 'Symbol' ||
|
|
1382
|
+
[...this.sourceFile.statements].some(
|
|
1383
|
+
statement =>
|
|
1384
|
+
ts.isImportDeclaration(statement) &&
|
|
1385
|
+
statement.moduleSpecifier.text === '@satinhq/kit' &&
|
|
1386
|
+
statement.importClause?.namedBindings &&
|
|
1387
|
+
ts.isNamedImports(statement.importClause.namedBindings) &&
|
|
1388
|
+
statement.importClause.namedBindings.elements.some(
|
|
1389
|
+
specifier =>
|
|
1390
|
+
specifier.name.text === tag &&
|
|
1391
|
+
(specifier.propertyName?.text ?? specifier.name.text) === 'Symbol',
|
|
1392
|
+
),
|
|
1393
|
+
)
|
|
1394
|
+
if (isSymbol) return props.state(['label', 'aria-label', 'aria-labelledby']) === false
|
|
1395
|
+
if (!['svg', 'img'].includes(tag)) return false
|
|
1396
|
+
if (
|
|
1397
|
+
props.state(['aria-label', 'aria-labelledby', ...(tag === 'img' ? ['alt'] : [])]) !==
|
|
1398
|
+
false
|
|
1399
|
+
)
|
|
1400
|
+
return false
|
|
1401
|
+
if (
|
|
1402
|
+
tag === 'svg' &&
|
|
1403
|
+
ts.isJsxElement(child) &&
|
|
1404
|
+
child.children.some(
|
|
1405
|
+
item =>
|
|
1406
|
+
ts.isJsxElement(item) &&
|
|
1407
|
+
item.openingElement.tagName.getText(this.sourceFile) === 'title' &&
|
|
1408
|
+
item.children.some(text => !ts.isJsxText(text) || text.text.trim()),
|
|
1409
|
+
)
|
|
1410
|
+
)
|
|
1411
|
+
return false
|
|
1412
|
+
return true
|
|
1413
|
+
})
|
|
1414
|
+
if ((symbolOnly || unnamedIcons) && named === false)
|
|
1415
|
+
this.addViolation(rules['require-icon-label'], node)
|
|
1416
|
+
}
|
|
1417
|
+
|
|
1418
|
+
if (['input', 'textarea'].includes(tag)) {
|
|
1419
|
+
const escape = node.attributes.properties.find(
|
|
1420
|
+
p => ts.isJsxAttribute(p) && p.name.text === 'data-satin-native-input',
|
|
1421
|
+
)
|
|
1422
|
+
if (
|
|
1423
|
+
!escape?.initializer ||
|
|
1424
|
+
!ts.isStringLiteral(escape.initializer) ||
|
|
1425
|
+
!escape.initializer.text.trim()
|
|
1426
|
+
)
|
|
1427
|
+
this.addViolation(rules['no-raw-text-input'], node)
|
|
1428
|
+
}
|
|
1429
|
+
if (['input', 'textarea', 'TextField', 'SecureField', 'TextEditor'].includes(tag)) {
|
|
1430
|
+
for (const prop of node.attributes.properties) {
|
|
1431
|
+
if (
|
|
1432
|
+
!ts.isJsxAttribute(prop) ||
|
|
1433
|
+
prop.name.text !== 'style' ||
|
|
1434
|
+
!prop.initializer ||
|
|
1435
|
+
!ts.isJsxExpression(prop.initializer) ||
|
|
1436
|
+
!prop.initializer.expression
|
|
1437
|
+
)
|
|
1438
|
+
continue
|
|
1439
|
+
for (const style of this.objectProperties(prop.initializer.expression)) {
|
|
1440
|
+
if (style.name !== 'fontSize' || !style.value) continue
|
|
1441
|
+
const raw = style.value.getText(this.sourceFile).replace(/^['"]|['"]$/g, '')
|
|
1442
|
+
if (/^\d+(?:\.\d+)?(?:px)?$/.test(raw) && parseFloat(raw) < 16)
|
|
1443
|
+
this.addViolation(rules['no-small-input-font'], style.nameNode)
|
|
1444
|
+
}
|
|
1445
|
+
}
|
|
1446
|
+
}
|
|
1447
|
+
this.inspectJsxAttributes(node.attributes)
|
|
1448
|
+
}
|
|
1449
|
+
|
|
1450
|
+
if (ts.isCallExpression(node)) this.inspectFactoryCall(node)
|
|
1451
|
+
if (ts.isTaggedTemplateExpression(node)) this.inspectCssTemplate(node)
|
|
1452
|
+
|
|
1453
|
+
ts.forEachChild(node, child => this.visit(child))
|
|
1454
|
+
}
|
|
1455
|
+
}
|
|
1456
|
+
|
|
1457
|
+
export function analyzeTypeScript(fileName, source) {
|
|
1458
|
+
const context = new AstContext(fileName, source)
|
|
1459
|
+
context.visit(context.sourceFile)
|
|
1460
|
+
return context.violations
|
|
1461
|
+
}
|