Skip to content

Commit 52d9687

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

2 files changed

Lines changed: 90 additions & 13 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: 85 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,13 @@
11
import type { TSESTree } from '@typescript-eslint/types';
2+
import type { Type, TypeChecker } from 'typescript';
3+
24
import { createRule } from '../utils/index.js';
35
import { ReferenceTracker } from '@eslint-community/eslint-utils';
46
import { FindVariableContext } from '../utils/ast-utils.js';
57
import { findVariable } from '../utils/ast-utils.js';
68
import type { RuleContext } from '../types.js';
79
import type { AST } from 'svelte-eslint-parser';
10+
import { type TSTools, getTypeScriptTools } from '../utils/ts-utils/index.js';
811

912
export default createRule('no-navigation-without-resolve', {
1013
meta: {
@@ -48,6 +51,8 @@ export default createRule('no-navigation-without-resolve', {
4851
]
4952
},
5053
create(context) {
54+
const tsTools = getTypeScriptTools(context);
55+
5156
let resolveReferences: Set<TSESTree.Identifier> = new Set<TSESTree.Identifier>();
5257

5358
const ignoreGoto = context.options[0]?.ignoreGoto ?? false;
@@ -66,7 +71,7 @@ export default createRule('no-navigation-without-resolve', {
6671
} = extractFunctionCallReferences(referenceTracker);
6772
if (!ignoreGoto) {
6873
for (const gotoCall of gotoCalls) {
69-
checkGotoCall(context, gotoCall, resolveReferences);
74+
checkGotoCall(context, gotoCall, resolveReferences, tsTools);
7075
}
7176
}
7277
if (!ignorePushState) {
@@ -75,6 +80,7 @@ export default createRule('no-navigation-without-resolve', {
7580
context,
7681
pushStateCall,
7782
resolveReferences,
83+
tsTools,
7884
'pushStateWithoutResolve'
7985
);
8086
}
@@ -85,22 +91,24 @@ export default createRule('no-navigation-without-resolve', {
8591
context,
8692
replaceStateCall,
8793
resolveReferences,
94+
tsTools,
8895
'replaceStateWithoutResolve'
8996
);
9097
}
9198
}
9299
},
93100
...(!ignoreLinks && {
94101
SvelteShorthandAttribute(node) {
95-
checkLinkAttribute(context, node, node.value, resolveReferences);
102+
checkLinkAttribute(context, node, node.value, resolveReferences, tsTools);
96103
},
97104
SvelteAttribute(node) {
98105
if (node.value.length > 0) {
99106
checkLinkAttribute(
100107
context,
101108
node,
102109
node.value[0].type === 'SvelteMustacheTag' ? node.value[0].expression : node.value[0],
103-
resolveReferences
110+
resolveReferences,
111+
tsTools
104112
);
105113
}
106114
}
@@ -187,11 +195,18 @@ function extractFunctionCallReferences(referenceTracker: ReferenceTracker): {
187195
function checkGotoCall(
188196
context: RuleContext,
189197
call: TSESTree.CallExpression,
190-
resolveReferences: Set<TSESTree.Identifier>
198+
resolveReferences: Set<TSESTree.Identifier>,
199+
tsTools: TSTools | null
191200
): void {
192201
if (
193202
call.arguments.length > 0 &&
194-
!isValueAllowed(new FindVariableContext(context), call.arguments[0], resolveReferences, {})
203+
!isValueAllowed(
204+
new FindVariableContext(context),
205+
call.arguments[0],
206+
resolveReferences,
207+
tsTools,
208+
{}
209+
)
195210
) {
196211
context.report({ loc: call.arguments[0].loc, messageId: 'gotoWithoutResolve' });
197212
}
@@ -201,13 +216,20 @@ function checkShallowNavigationCall(
201216
context: RuleContext,
202217
call: TSESTree.CallExpression,
203218
resolveReferences: Set<TSESTree.Identifier>,
219+
tsTools: TSTools | null,
204220
messageId: string
205221
): void {
206222
if (
207223
call.arguments.length > 0 &&
208-
!isValueAllowed(new FindVariableContext(context), call.arguments[0], resolveReferences, {
209-
allowEmpty: true
210-
})
224+
!isValueAllowed(
225+
new FindVariableContext(context),
226+
call.arguments[0],
227+
resolveReferences,
228+
tsTools,
229+
{
230+
allowEmpty: true
231+
}
232+
)
211233
) {
212234
context.report({ loc: call.arguments[0].loc, messageId });
213235
}
@@ -217,7 +239,8 @@ function checkLinkAttribute(
217239
context: RuleContext,
218240
attribute: AST.SvelteAttribute | AST.SvelteShorthandAttribute,
219241
value: TSESTree.Expression | AST.SvelteLiteral,
220-
resolveReferences: Set<TSESTree.Identifier>
242+
resolveReferences: Set<TSESTree.Identifier>,
243+
tsTools: TSTools | null
221244
): void {
222245
if (
223246
attribute.parent.parent.type === 'SvelteElement' &&
@@ -226,7 +249,7 @@ function checkLinkAttribute(
226249
attribute.parent.parent.name.name === 'a' &&
227250
attribute.key.name === 'href' &&
228251
!hasRelExternal(new FindVariableContext(context), attribute.parent) &&
229-
!isValueAllowed(new FindVariableContext(context), value, resolveReferences, {
252+
!isValueAllowed(new FindVariableContext(context), value, resolveReferences, tsTools, {
230253
allowAbsolute: true,
231254
allowFragment: true,
232255
allowNullish: true
@@ -275,6 +298,7 @@ function isValueAllowed(
275298
ctx: FindVariableContext,
276299
value: TSESTree.CallExpressionArgument | AST.SvelteLiteral,
277300
resolveReferences: Set<TSESTree.Identifier>,
301+
tsTools: TSTools | null,
278302
config: {
279303
allowAbsolute?: boolean;
280304
allowEmpty?: boolean;
@@ -287,10 +311,20 @@ function isValueAllowed(
287311
if (
288312
variable !== null &&
289313
variable.identifiers.length > 0 &&
290-
variable.identifiers[0].parent.type === 'VariableDeclarator' &&
291-
variable.identifiers[0].parent.init !== null
314+
variable.identifiers[0].parent.type === 'VariableDeclarator'
292315
) {
293-
return isValueAllowed(ctx, variable.identifiers[0].parent.init, resolveReferences, config);
316+
if (expressionIsResolvedPathname(variable.identifiers[0], tsTools)) {
317+
return true;
318+
}
319+
if (variable.identifiers[0].parent.init !== null) {
320+
return isValueAllowed(
321+
ctx,
322+
variable.identifiers[0].parent.init,
323+
resolveReferences,
324+
tsTools,
325+
config
326+
);
327+
}
294328
}
295329
}
296330
if (value.type === 'ConditionalExpression') {
@@ -304,6 +338,7 @@ function isValueAllowed(
304338
(config.allowEmpty && expressionIsEmpty(value)) ||
305339
(config.allowFragment && expressionStartsWith(ctx, value, '#')) ||
306340
(config.allowNullish && expressionIsNullish(value)) ||
341+
expressionIsResolvedPathname(value, tsTools) ||
307342
expressionIsResolveCall(ctx, value, resolveReferences)
308343
) {
309344
return true;
@@ -313,6 +348,43 @@ function isValueAllowed(
313348

314349
// Helper functions
315350

351+
function expressionIsResolvedPathname(
352+
value: TSESTree.CallExpressionArgument | TSESTree.Expression | AST.SvelteLiteral,
353+
tsTools: TSTools | null
354+
): boolean {
355+
if (tsTools === null) {
356+
return false;
357+
}
358+
const checker = tsTools.service.program.getTypeChecker() as TypeChecker & {
359+
isTypeAssignableTo(source: Type, target: Type): boolean;
360+
};
361+
362+
const tsNode = tsTools.service.esTreeNodeToTSNodeMap.get(value);
363+
if (tsNode === undefined) {
364+
return false;
365+
}
366+
const nodeType = checker.getTypeAtLocation(tsNode);
367+
368+
const appTypesModule = checker.getAmbientModules().find((m) => m.name === '"$app/types"');
369+
if (!appTypesModule) {
370+
return false;
371+
}
372+
373+
const resolvedPathnameSymbol = checker
374+
.getExportsOfModule(appTypesModule)
375+
.find((e) => e.name === 'ResolvedPathname');
376+
if (!resolvedPathnameSymbol) {
377+
return false;
378+
}
379+
const resolvedPathnameType = checker.getDeclaredTypeOfSymbol(resolvedPathnameSymbol);
380+
381+
// 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).
382+
return (
383+
checker.isTypeAssignableTo(nodeType, resolvedPathnameType) &&
384+
checker.isTypeAssignableTo(resolvedPathnameType, nodeType)
385+
);
386+
}
387+
316388
function expressionIsResolveCall(
317389
ctx: FindVariableContext,
318390
node: TSESTree.CallExpressionArgument | AST.SvelteLiteral,

0 commit comments

Comments
 (0)