@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.
@@ -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
+ }