1
1
import type { TSESTree } from '@typescript-eslint/types' ;
2
2
import { createRule } from '../utils/index.js' ;
3
+ import type { TrackedReferences } from '@eslint-community/eslint-utils' ;
3
4
import { ReferenceTracker } from '@eslint-community/eslint-utils' ;
4
5
import { FindVariableContext } from '../utils/ast-utils.js' ;
5
6
import { findVariable } from '../utils/ast-utils.js' ;
@@ -29,6 +30,9 @@ export default createRule('no-navigation-without-resolve', {
29
30
} ,
30
31
ignoreReplaceState : {
31
32
type : 'boolean'
33
+ } ,
34
+ allowSuffix : {
35
+ type : 'boolean'
32
36
}
33
37
} ,
34
38
additionalProperties : false
@@ -49,10 +53,14 @@ export default createRule('no-navigation-without-resolve', {
49
53
} ,
50
54
create ( context ) {
51
55
let resolveReferences : Set < TSESTree . Identifier > = new Set < TSESTree . Identifier > ( ) ;
56
+ let assetReferences : Set < TSESTree . Identifier > = new Set < TSESTree . Identifier > ( ) ;
52
57
return {
53
58
Program ( ) {
54
59
const referenceTracker = new ReferenceTracker ( context . sourceCode . scopeManager . globalScope ! ) ;
55
- resolveReferences = extractResolveReferences ( referenceTracker , context ) ;
60
+ ( { resolve : resolveReferences , asset : assetReferences } = extractResolveReferences (
61
+ referenceTracker ,
62
+ context
63
+ ) ) ;
56
64
const {
57
65
goto : gotoCalls ,
58
66
pushState : pushStateCalls ,
@@ -102,10 +110,16 @@ export default createRule('no-navigation-without-resolve', {
102
110
( node . value [ 0 ] . type === 'SvelteMustacheTag' &&
103
111
! expressionIsAbsolute ( new FindVariableContext ( context ) , node . value [ 0 ] . expression ) &&
104
112
! expressionIsFragment ( new FindVariableContext ( context ) , node . value [ 0 ] . expression ) &&
105
- ! isResolveCall (
113
+ ! isResolveWithOptionalSuffix (
114
+ new FindVariableContext ( context ) ,
115
+ node . value [ 0 ] . expression ,
116
+ resolveReferences ,
117
+ context . options [ 0 ] ?. allowSuffix !== false
118
+ ) &&
119
+ ! isAssetOnly (
106
120
new FindVariableContext ( context ) ,
107
121
node . value [ 0 ] . expression ,
108
- resolveReferences
122
+ assetReferences
109
123
) )
110
124
) {
111
125
context . report ( { loc : node . value [ 0 ] . loc , messageId : 'linkWithoutResolve' } ) ;
@@ -120,9 +134,10 @@ export default createRule('no-navigation-without-resolve', {
120
134
function extractResolveReferences (
121
135
referenceTracker : ReferenceTracker ,
122
136
context : RuleContext
123
- ) : Set < TSESTree . Identifier > {
124
- const set = new Set < TSESTree . Identifier > ( ) ;
125
- for ( const { node } of referenceTracker . iterateEsmReferences ( {
137
+ ) : { resolve : Set < TSESTree . Identifier > ; asset : Set < TSESTree . Identifier > } {
138
+ const resolveSet = new Set < TSESTree . Identifier > ( ) ;
139
+ const assetSet = new Set < TSESTree . Identifier > ( ) ;
140
+ for ( const { node, path } of referenceTracker . iterateEsmReferences ( {
126
141
'$app/paths' : {
127
142
[ ReferenceTracker . ESM ] : true ,
128
143
asset : {
@@ -139,17 +154,16 @@ function extractResolveReferences(
139
154
continue ;
140
155
}
141
156
for ( const reference of variable . references ) {
142
- if ( reference . identifier . type === 'Identifier' ) set . add ( reference . identifier ) ;
157
+ if ( reference . identifier . type !== 'Identifier' ) continue ;
158
+ if ( path [ path . length - 1 ] === 'resolve' ) resolveSet . add ( reference . identifier ) ;
159
+ if ( path [ path . length - 1 ] === 'asset' ) assetSet . add ( reference . identifier ) ;
143
160
}
144
- } else if (
145
- node . type === 'MemberExpression' &&
146
- node . property . type === 'Identifier' &&
147
- node . property . name === 'resolve'
148
- ) {
149
- set . add ( node . property ) ;
161
+ } else if ( node . type === 'MemberExpression' && node . property . type === 'Identifier' ) {
162
+ if ( node . property . name === 'resolve' ) resolveSet . add ( node . property ) ;
163
+ if ( node . property . name === 'asset' ) assetSet . add ( node . property ) ;
150
164
}
151
165
}
152
- return set ;
166
+ return { resolve : resolveSet , asset : assetSet } ;
153
167
}
154
168
155
169
// Extract all references to goto, pushState and replaceState
@@ -175,16 +189,21 @@ function extractFunctionCallReferences(referenceTracker: ReferenceTracker): {
175
189
}
176
190
} )
177
191
) ;
192
+
193
+ function onlyCallExpressions ( list : TrackedReferences < boolean > [ ] ) : TSESTree . CallExpression [ ] {
194
+ return list
195
+ . filter ( ( r ) => r . node . type === 'CallExpression' )
196
+ . map ( ( r ) => r . node as TSESTree . CallExpression ) ;
197
+ }
198
+
178
199
return {
179
- goto : rawReferences
180
- . filter ( ( { path } ) => path [ path . length - 1 ] === 'goto' )
181
- . map ( ( { node } ) => node as TSESTree . CallExpression ) ,
182
- pushState : rawReferences
183
- . filter ( ( { path } ) => path [ path . length - 1 ] === 'pushState' )
184
- . map ( ( { node } ) => node as TSESTree . CallExpression ) ,
185
- replaceState : rawReferences
186
- . filter ( ( { path } ) => path [ path . length - 1 ] === 'replaceState' )
187
- . map ( ( { node } ) => node as TSESTree . CallExpression )
200
+ goto : onlyCallExpressions ( rawReferences . filter ( ( { path } ) => path [ path . length - 1 ] === 'goto' ) ) ,
201
+ pushState : onlyCallExpressions (
202
+ rawReferences . filter ( ( { path } ) => path [ path . length - 1 ] === 'pushState' )
203
+ ) ,
204
+ replaceState : onlyCallExpressions (
205
+ rawReferences . filter ( ( { path } ) => path [ path . length - 1 ] === 'replaceState' )
206
+ )
188
207
} ;
189
208
}
190
209
@@ -199,7 +218,14 @@ function checkGotoCall(
199
218
return ;
200
219
}
201
220
const url = call . arguments [ 0 ] ;
202
- if ( ! isResolveCall ( new FindVariableContext ( context ) , url , resolveReferences ) ) {
221
+ if (
222
+ ! isResolveWithOptionalSuffix (
223
+ new FindVariableContext ( context ) ,
224
+ url ,
225
+ resolveReferences ,
226
+ context . options [ 0 ] ?. allowSuffix !== false
227
+ )
228
+ ) {
203
229
context . report ( { loc : url . loc , messageId : 'gotoWithoutResolve' } ) ;
204
230
}
205
231
}
@@ -216,7 +242,12 @@ function checkShallowNavigationCall(
216
242
const url = call . arguments [ 0 ] ;
217
243
if (
218
244
! expressionIsEmpty ( url ) &&
219
- ! isResolveCall ( new FindVariableContext ( context ) , url , resolveReferences )
245
+ ! isResolveWithOptionalSuffix (
246
+ new FindVariableContext ( context ) ,
247
+ url ,
248
+ resolveReferences ,
249
+ context . options [ 0 ] ?. allowSuffix !== false
250
+ )
220
251
) {
221
252
context . report ( { loc : url . loc , messageId } ) ;
222
253
}
@@ -253,6 +284,90 @@ function isResolveCall(
253
284
return isResolveCall ( ctx , variable . identifiers [ 0 ] . parent . init , resolveReferences ) ;
254
285
}
255
286
287
+ function isResolveWithOptionalSuffix (
288
+ ctx : FindVariableContext ,
289
+ node : TSESTree . Expression | TSESTree . CallExpressionArgument ,
290
+ resolveReferences : Set < TSESTree . Identifier > ,
291
+ allowSuffix : boolean
292
+ ) : boolean {
293
+ if (
294
+ ( node . type === 'CallExpression' || node . type === 'Identifier' ) &&
295
+ isResolveCall ( ctx , node , resolveReferences )
296
+ ) {
297
+ return true ;
298
+ }
299
+
300
+ if ( ! allowSuffix ) return false ;
301
+ return expressionStartsWithResolve ( ctx , node , resolveReferences ) ;
302
+ }
303
+
304
+ function expressionStartsWithResolve (
305
+ ctx : FindVariableContext ,
306
+ node : TSESTree . Expression | TSESTree . CallExpressionArgument ,
307
+ resolveReferences : Set < TSESTree . Identifier >
308
+ ) : boolean {
309
+ // Direct call
310
+ if ( node . type === 'CallExpression' ) {
311
+ return isResolveCall ( ctx , node , resolveReferences ) ;
312
+ }
313
+ // Binary chain: ensure the left-most operand is resolve(); any right-hand content is allowed
314
+ if ( node . type === 'BinaryExpression' ) {
315
+ if ( node . operator !== '+' || node . left . type === 'PrivateIdentifier' ) return false ;
316
+ return expressionStartsWithResolve ( ctx , node . left , resolveReferences ) ;
317
+ }
318
+ // Template literal: must start with expression and that expression starts with resolve(); content after is allowed
319
+ if ( node . type === 'TemplateLiteral' ) {
320
+ if (
321
+ node . expressions . length === 0 ||
322
+ ( node . quasis . length >= 1 && node . quasis [ 0 ] . value . raw !== '' )
323
+ )
324
+ return false ;
325
+ return expressionStartsWithResolve ( ctx , node . expressions [ 0 ] , resolveReferences ) ;
326
+ }
327
+ // Identifier indirection
328
+ if ( node . type === 'Identifier' ) {
329
+ const variable = ctx . findVariable ( node ) ;
330
+ if (
331
+ variable === null ||
332
+ variable . identifiers . length === 0 ||
333
+ variable . identifiers [ 0 ] . parent . type !== 'VariableDeclarator' ||
334
+ variable . identifiers [ 0 ] . parent . init === null
335
+ ) {
336
+ return false ;
337
+ }
338
+ return expressionStartsWithResolve ( ctx , variable . identifiers [ 0 ] . parent . init , resolveReferences ) ;
339
+ }
340
+ return false ;
341
+ }
342
+
343
+ function isAssetOnly (
344
+ ctx : FindVariableContext ,
345
+ node : TSESTree . Expression | TSESTree . CallExpressionArgument ,
346
+ assetReferences : Set < TSESTree . Identifier >
347
+ ) : boolean {
348
+ if ( node . type === 'CallExpression' ) {
349
+ return (
350
+ ( node . callee . type === 'Identifier' && assetReferences . has ( node . callee ) ) ||
351
+ ( node . callee . type === 'MemberExpression' &&
352
+ node . callee . property . type === 'Identifier' &&
353
+ assetReferences . has ( node . callee . property ) )
354
+ ) ;
355
+ }
356
+ if ( node . type === 'Identifier' ) {
357
+ const variable = ctx . findVariable ( node ) ;
358
+ if (
359
+ variable === null ||
360
+ variable . identifiers . length === 0 ||
361
+ variable . identifiers [ 0 ] . parent . type !== 'VariableDeclarator' ||
362
+ variable . identifiers [ 0 ] . parent . init === null
363
+ ) {
364
+ return false ;
365
+ }
366
+ return isAssetOnly ( ctx , variable . identifiers [ 0 ] . parent . init , assetReferences ) ;
367
+ }
368
+ return false ;
369
+ }
370
+
256
371
function expressionIsEmpty ( url : TSESTree . CallExpressionArgument ) : boolean {
257
372
return (
258
373
( url . type === 'Literal' && url . value === '' ) ||
0 commit comments