Skip to content

Commit 19d8884

Browse files
committed
feat(no-navigation-without-resolve): added support for ResolvedPathname types
1 parent 6cd59de commit 19d8884

2 files changed

Lines changed: 89 additions & 15 deletions

File tree

.changeset/icy-mammals-cover.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'eslint-plugin-svelte': patch
3+
---
4+
5+
feat(no-navigation-without-resolve): added support for ResolvedPathname types

packages/eslint-plugin-svelte/src/rules/no-navigation-without-resolve.ts

Lines changed: 84 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
import type { TSESTree } from '@typescript-eslint/types';
2+
23
import { createRule } from '../utils/index.js';
34
import { ReferenceTracker } from '@eslint-community/eslint-utils';
45
import { FindVariableContext } from '../utils/ast-utils.js';
56
import { findVariable } from '../utils/ast-utils.js';
67
import type { RuleContext } from '../types.js';
78
import type { AST } from 'svelte-eslint-parser';
9+
import { type TSTools, getTypeScriptTools } from '../utils/ts-utils/index.js';
810

911
export default createRule('no-navigation-without-resolve', {
1012
meta: {
@@ -48,6 +50,8 @@ export default createRule('no-navigation-without-resolve', {
4850
]
4951
},
5052
create(context) {
53+
const tsTools = getTypeScriptTools(context);
54+
5155
let resolveReferences: Set<TSESTree.Identifier> = new Set<TSESTree.Identifier>();
5256

5357
const ignoreGoto = context.options[0]?.ignoreGoto ?? false;
@@ -66,7 +70,7 @@ export default createRule('no-navigation-without-resolve', {
6670
} = extractFunctionCallReferences(referenceTracker);
6771
if (!ignoreGoto) {
6872
for (const gotoCall of gotoCalls) {
69-
checkGotoCall(context, gotoCall, resolveReferences);
73+
checkGotoCall(context, gotoCall, resolveReferences, tsTools);
7074
}
7175
}
7276
if (!ignorePushState) {
@@ -75,6 +79,7 @@ export default createRule('no-navigation-without-resolve', {
7579
context,
7680
pushStateCall,
7781
resolveReferences,
82+
tsTools,
7883
'pushStateWithoutResolve'
7984
);
8085
}
@@ -85,22 +90,24 @@ export default createRule('no-navigation-without-resolve', {
8590
context,
8691
replaceStateCall,
8792
resolveReferences,
93+
tsTools,
8894
'replaceStateWithoutResolve'
8995
);
9096
}
9197
}
9298
},
9399
...(!ignoreLinks && {
94100
SvelteShorthandAttribute(node) {
95-
checkLinkAttribute(context, node, node.value, resolveReferences);
101+
checkLinkAttribute(context, node, node.value, resolveReferences, tsTools);
96102
},
97103
SvelteAttribute(node) {
98104
if (node.value.length > 0) {
99105
checkLinkAttribute(
100106
context,
101107
node,
102108
node.value[0].type === 'SvelteMustacheTag' ? node.value[0].expression : node.value[0],
103-
resolveReferences
109+
resolveReferences,
110+
tsTools
104111
);
105112
}
106113
}
@@ -187,11 +194,18 @@ function extractFunctionCallReferences(referenceTracker: ReferenceTracker): {
187194
function checkGotoCall(
188195
context: RuleContext,
189196
call: TSESTree.CallExpression,
190-
resolveReferences: Set<TSESTree.Identifier>
197+
resolveReferences: Set<TSESTree.Identifier>,
198+
tsTools: TSTools | null
191199
): void {
192200
if (
193201
call.arguments.length > 0 &&
194-
!isValueAllowed(new FindVariableContext(context), call.arguments[0], resolveReferences, {})
202+
!isValueAllowed(
203+
new FindVariableContext(context),
204+
call.arguments[0],
205+
resolveReferences,
206+
tsTools,
207+
{}
208+
)
195209
) {
196210
context.report({ loc: call.arguments[0].loc, messageId: 'gotoWithoutResolve' });
197211
}
@@ -201,13 +215,20 @@ function checkShallowNavigationCall(
201215
context: RuleContext,
202216
call: TSESTree.CallExpression,
203217
resolveReferences: Set<TSESTree.Identifier>,
218+
tsTools: TSTools | null,
204219
messageId: string
205220
): void {
206221
if (
207222
call.arguments.length > 0 &&
208-
!isValueAllowed(new FindVariableContext(context), call.arguments[0], resolveReferences, {
209-
allowEmpty: true
210-
})
223+
!isValueAllowed(
224+
new FindVariableContext(context),
225+
call.arguments[0],
226+
resolveReferences,
227+
tsTools,
228+
{
229+
allowEmpty: true
230+
}
231+
)
211232
) {
212233
context.report({ loc: call.arguments[0].loc, messageId });
213234
}
@@ -217,7 +238,8 @@ function checkLinkAttribute(
217238
context: RuleContext,
218239
attribute: AST.SvelteAttribute | AST.SvelteShorthandAttribute,
219240
value: TSESTree.Expression | AST.SvelteLiteral,
220-
resolveReferences: Set<TSESTree.Identifier>
241+
resolveReferences: Set<TSESTree.Identifier>,
242+
tsTools: TSTools | null
221243
): void {
222244
if (
223245
attribute.parent.parent.type === 'SvelteElement' &&
@@ -226,7 +248,7 @@ function checkLinkAttribute(
226248
attribute.parent.parent.name.name === 'a' &&
227249
attribute.key.name === 'href' &&
228250
!hasRelExternal(new FindVariableContext(context), attribute.parent) &&
229-
!isValueAllowed(new FindVariableContext(context), value, resolveReferences, {
251+
!isValueAllowed(new FindVariableContext(context), value, resolveReferences, tsTools, {
230252
allowAbsolute: true,
231253
allowFragment: true,
232254
allowNullish: true
@@ -275,6 +297,7 @@ function isValueAllowed(
275297
ctx: FindVariableContext,
276298
value: TSESTree.CallExpressionArgument | AST.SvelteLiteral,
277299
resolveReferences: Set<TSESTree.Identifier>,
300+
tsTools: TSTools | null,
278301
config: {
279302
allowAbsolute?: boolean;
280303
allowEmpty?: boolean;
@@ -287,23 +310,34 @@ function isValueAllowed(
287310
if (
288311
variable !== null &&
289312
variable.identifiers.length > 0 &&
290-
variable.identifiers[0].parent.type === 'VariableDeclarator' &&
291-
variable.identifiers[0].parent.init !== null
313+
variable.identifiers[0].parent.type === 'VariableDeclarator'
292314
) {
293-
return isValueAllowed(ctx, variable.identifiers[0].parent.init, resolveReferences, config);
315+
if (expressionIsResolvedPathname(variable.identifiers[0], tsTools)) {
316+
return true;
317+
}
318+
if (variable.identifiers[0].parent.init !== null) {
319+
return isValueAllowed(
320+
ctx,
321+
variable.identifiers[0].parent.init,
322+
resolveReferences,
323+
tsTools,
324+
config
325+
);
326+
}
294327
}
295328
}
296329
if (value.type === 'ConditionalExpression') {
297330
return (
298-
isValueAllowed(ctx, value.consequent, resolveReferences, config) &&
299-
isValueAllowed(ctx, value.alternate, resolveReferences, config)
331+
isValueAllowed(ctx, value.consequent, resolveReferences, tsTools, config) &&
332+
isValueAllowed(ctx, value.alternate, resolveReferences, tsTools, config)
300333
);
301334
}
302335
if (
303336
(config.allowAbsolute && expressionIsAbsoluteUrl(ctx, value)) ||
304337
(config.allowEmpty && expressionIsEmpty(value)) ||
305338
(config.allowFragment && expressionStartsWith(ctx, value, '#')) ||
306339
(config.allowNullish && expressionIsNullish(value)) ||
340+
expressionIsResolvedPathname(value, tsTools) ||
307341
expressionIsResolveCall(ctx, value, resolveReferences)
308342
) {
309343
return true;
@@ -313,6 +347,41 @@ function isValueAllowed(
313347

314348
// Helper functions
315349

350+
function expressionIsResolvedPathname(
351+
value: TSESTree.CallExpressionArgument | TSESTree.Expression | AST.SvelteLiteral,
352+
tsTools: TSTools | null
353+
): boolean {
354+
if (tsTools === null) {
355+
return false;
356+
}
357+
const checker = tsTools.service.program.getTypeChecker();
358+
359+
const tsNode = tsTools.service.esTreeNodeToTSNodeMap.get(value);
360+
if (tsNode === undefined) {
361+
return false;
362+
}
363+
const nodeType = checker.getTypeAtLocation(tsNode);
364+
365+
const appTypesModule = checker.getAmbientModules().find((m) => m.name === '"$app/types"');
366+
if (!appTypesModule) {
367+
return false;
368+
}
369+
370+
const resolvedPathnameSymbol = checker
371+
.getExportsOfModule(appTypesModule)
372+
.find((e) => e.name === 'ResolvedPathname');
373+
if (!resolvedPathnameSymbol) {
374+
return false;
375+
}
376+
const resolvedPathnameType = checker.getDeclaredTypeOfSymbol(resolvedPathnameSymbol);
377+
378+
// getTypeAtLocation returns the resolved (structural) type without alias information, so we cannot compare aliasSymbols directly. Instead we check structural equivalence by testing assignability in both directions: this correctly rejects strict subtypes like Pathname (Pathname ⊂ ResolvedPathname, so only one direction holds).
379+
return (
380+
checker.isTypeAssignableTo(nodeType, resolvedPathnameType) &&
381+
checker.isTypeAssignableTo(resolvedPathnameType, nodeType)
382+
);
383+
}
384+
316385
function expressionIsResolveCall(
317386
ctx: FindVariableContext,
318387
node: TSESTree.CallExpressionArgument | AST.SvelteLiteral,

0 commit comments

Comments
 (0)