11import type { TSESTree } from '@typescript-eslint/types' ;
22import { createRule } from '../utils/index.js' ;
3+ import type { TrackedReferences } from '@eslint-community/eslint-utils' ;
34import { ReferenceTracker } from '@eslint-community/eslint-utils' ;
45import { FindVariableContext } from '../utils/ast-utils.js' ;
56import { findVariable } from '../utils/ast-utils.js' ;
@@ -29,6 +30,9 @@ export default createRule('no-navigation-without-resolve', {
2930 } ,
3031 ignoreReplaceState : {
3132 type : 'boolean'
33+ } ,
34+ allowSuffix : {
35+ type : 'boolean'
3236 }
3337 } ,
3438 additionalProperties : false
@@ -49,10 +53,14 @@ export default createRule('no-navigation-without-resolve', {
4953 } ,
5054 create ( context ) {
5155 let resolveReferences : Set < TSESTree . Identifier > = new Set < TSESTree . Identifier > ( ) ;
56+ let assetReferences : Set < TSESTree . Identifier > = new Set < TSESTree . Identifier > ( ) ;
5257 return {
5358 Program ( ) {
5459 const referenceTracker = new ReferenceTracker ( context . sourceCode . scopeManager . globalScope ! ) ;
55- resolveReferences = extractResolveReferences ( referenceTracker , context ) ;
60+ ( { resolve : resolveReferences , asset : assetReferences } = extractResolveReferences (
61+ referenceTracker ,
62+ context
63+ ) ) ;
5664 const {
5765 goto : gotoCalls ,
5866 pushState : pushStateCalls ,
@@ -102,10 +110,16 @@ export default createRule('no-navigation-without-resolve', {
102110 ( node . value [ 0 ] . type === 'SvelteMustacheTag' &&
103111 ! expressionIsAbsolute ( new FindVariableContext ( context ) , node . value [ 0 ] . expression ) &&
104112 ! 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 (
106120 new FindVariableContext ( context ) ,
107121 node . value [ 0 ] . expression ,
108- resolveReferences
122+ assetReferences
109123 ) )
110124 ) {
111125 context . report ( { loc : node . value [ 0 ] . loc , messageId : 'linkWithoutResolve' } ) ;
@@ -120,9 +134,10 @@ export default createRule('no-navigation-without-resolve', {
120134function extractResolveReferences (
121135 referenceTracker : ReferenceTracker ,
122136 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 ( {
126141 '$app/paths' : {
127142 [ ReferenceTracker . ESM ] : true ,
128143 asset : {
@@ -139,17 +154,16 @@ function extractResolveReferences(
139154 continue ;
140155 }
141156 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 ) ;
143160 }
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 ) ;
150164 }
151165 }
152- return set ;
166+ return { resolve : resolveSet , asset : assetSet } ;
153167}
154168
155169// Extract all references to goto, pushState and replaceState
@@ -175,16 +189,21 @@ function extractFunctionCallReferences(referenceTracker: ReferenceTracker): {
175189 }
176190 } )
177191 ) ;
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+
178199 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+ )
188207 } ;
189208}
190209
@@ -199,7 +218,14 @@ function checkGotoCall(
199218 return ;
200219 }
201220 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+ ) {
203229 context . report ( { loc : url . loc , messageId : 'gotoWithoutResolve' } ) ;
204230 }
205231}
@@ -216,7 +242,12 @@ function checkShallowNavigationCall(
216242 const url = call . arguments [ 0 ] ;
217243 if (
218244 ! 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+ )
220251 ) {
221252 context . report ( { loc : url . loc , messageId } ) ;
222253 }
@@ -253,6 +284,90 @@ function isResolveCall(
253284 return isResolveCall ( ctx , variable . identifiers [ 0 ] . parent . init , resolveReferences ) ;
254285}
255286
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+
256371function expressionIsEmpty ( url : TSESTree . CallExpressionArgument ) : boolean {
257372 return (
258373 ( url . type === 'Literal' && url . value === '' ) ||
0 commit comments