/** * @file no-hardcoded-search-params.js * Enforces use of SearchParam system instead of manual URLSearchParams manipulation */ module.exports = { meta: { type: 'problem', docs: { description: 'Enforce use of SearchParamBuilder/SearchParamParser instead of manual URLSearchParams manipulation', category: 'Best Practices', recommended: true, }, fixable: 'code', schema: [], messages: { manualSearchParams: 'Manual URLSearchParams construction. Use SearchParamBuilder instead: import { SearchParamBuilder } from "@/lib/routing/search-params"', manualGetParam: 'Manual search param access with get(). Use SearchParamParser instead: import { SearchParamParser } from "@/lib/routing/search-params"', manualSetParam: 'Manual search param setting with set(). Use SearchParamBuilder instead', manualQueryString: 'Manual query-string construction detected (e.g. "?returnTo=..."). Use SearchParamBuilder instead: import { SearchParamBuilder } from "@/lib/routing/search-params/SearchParamBuilder"', }, }, create(context) { const SEARCH_PARAM_KEYS = new Set([ // Auth 'returnTo', 'token', 'email', 'error', 'message', // Sponsor 'type', 'campaignId', // Pagination 'page', 'limit', 'offset', // Sorting 'sortBy', 'order', // Filters 'status', 'role', 'tier', ]); /** * Detect patterns like: * - "?returnTo=" * - "&returnTo=" * - "?page=" * - "returnTo=" (within a URL string) */ function containsManualQueryParamFragment(raw) { if (typeof raw !== 'string' || raw.length === 0) return false; // Fast pre-check if (!raw.includes('?') && !raw.includes('&') && !raw.includes('=')) return false; for (const key of SEARCH_PARAM_KEYS) { if ( raw.includes(`?${key}=`) || raw.includes(`&${key}=`) || // catches "...returnTo=..." in some string-building scenarios raw.includes(`${key}=`) ) { return true; } } return false; } return { // Detect: new URLSearchParams() NewExpression(node) { if (node.callee.type === 'Identifier' && node.callee.name === 'URLSearchParams') { // Check if it's being used for construction (not just parsing) const parent = node.parent; // If it's in a call expression like new URLSearchParams().toString() // or new URLSearchParams().get() if (parent.type === 'MemberExpression') { const property = parent.property.name; if (property === 'toString' || property === 'set' || property === 'append') { context.report({ node, messageId: 'manualSearchParams', }); } } // If it's just new URLSearchParams() without further use else if (parent.type === 'VariableDeclarator' || parent.type === 'AssignmentExpression') { context.report({ node, messageId: 'manualSearchParams', }); } } }, // Detect manual query strings, e.g. // `${routes.auth.login}?returnTo=${routes.protected.onboarding}` // routes.auth.login + '?returnTo=' + routes.protected.onboarding TemplateLiteral(node) { // If any static chunk contains a query-param fragment, treat it as manual. for (const quasi of node.quasis) { const raw = quasi.value && (quasi.value.raw ?? quasi.value.cooked); if (containsManualQueryParamFragment(raw)) { context.report({ node, messageId: 'manualQueryString', }); return; } } }, BinaryExpression(node) { // String concatenation patterns, e.g. a + '?returnTo=' + b if (node.operator !== '+') return; // If either side is a literal string containing query params, report. const left = node.left; const right = node.right; if (left && left.type === 'Literal' && typeof left.value === 'string' && containsManualQueryParamFragment(left.value)) { context.report({ node, messageId: 'manualQueryString' }); return; } if (right && right.type === 'Literal' && typeof right.value === 'string' && containsManualQueryParamFragment(right.value)) { context.report({ node, messageId: 'manualQueryString' }); return; } }, // Detect: params.get() or params.set() CallExpression(node) { if (node.callee.type === 'MemberExpression') { const object = node.callee.object; const property = node.callee.property.name; // Check if it's a URLSearchParams instance if (object.type === 'Identifier' && (property === 'get' || property === 'set' || property === 'append' || property === 'delete')) { // Try to trace back to see if it's URLSearchParams let current = object; let isUrlSearchParams = false; // Check if it's from URLSearchParams constructor const scope = context.getScope(); const variable = scope.variables.find(v => v.name === current.name); if (variable && variable.defs.length > 0) { const def = variable.defs[0]; if (def.node.init && def.node.init.type === 'NewExpression' && def.node.init.callee.name === 'URLSearchParams') { isUrlSearchParams = true; } } if (isUrlSearchParams) { if (property === 'get') { context.report({ node, messageId: 'manualGetParam', }); } else if (property === 'set' || property === 'append') { context.report({ node, messageId: 'manualSetParam', }); } } } } }, // Detect: searchParams.get() directly (from function parameter) MemberExpression(node) { if (node.property.type === 'Identifier' && (node.property.name === 'get' || node.property.name === 'set')) { // Check if object is named "searchParams" or "params" if (node.object.type === 'Identifier' && (node.object.name === 'searchParams' || node.object.name === 'params')) { // Check parent is a call expression if (node.parent.type === 'CallExpression' && node.parent.callee === node) { if (node.property.name === 'get') { context.report({ node, messageId: 'manualGetParam', }); } else if (node.property.name === 'set') { context.report({ node, messageId: 'manualSetParam', }); } } } } }, }; }, };